docs(market-research): switch Play-chart source AppBrain->play.google.com

AppBrain is Cloudflare-blocked (403) for free scraping. play.google.com's
own /store/apps/top + /store/apps/category HTML works (validated live 46/46).
Rework Task 1, fetch-play-charts.py CLI/output, and all downstream prose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fatih.bulut 2026-06-23 22:39:00 +03:00
parent 4326bde33f
commit f1eef0560b
2 changed files with 116 additions and 106 deletions

View File

@ -4,7 +4,7 @@
**Goal:** Upgrade the `market-research` skill to produce numerically-grounded, source-cited clone candidates that each carry 12 verified Google Play Store links — all free, no API keys. **Goal:** Upgrade the `market-research` skill to produce numerically-grounded, source-cited clone candidates that each carry 12 verified Google Play Store links — all free, no API keys.
**Architecture:** Add three deterministic scraper scripts (AppBrain Play charts, a Play resolver/saturation tool, a best-effort Google Trends fetcher) plus a curated numeric-sources rubric. Rewrite the scoring guide to require numeric evidence, the report template to carry Play links + citations + saturation, and the SKILL orchestrator to a survivor-only-enrichment phase flow. Each scraper is fixture-tested offline; reality is confirmed by a one-time live-fetch validation step. **Architecture:** Add three deterministic scraper scripts (play.google.com top/category charts, a Play resolver/saturation tool, a best-effort Google Trends fetcher) plus a curated numeric-sources rubric. Rewrite the scoring guide to require numeric evidence, the report template to carry Play links + citations + saturation, and the SKILL orchestrator to a survivor-only-enrichment phase flow. Each scraper is fixture-tested offline; reality is confirmed by a one-time live-fetch validation step.
**Tech Stack:** Python 3 stdlib only (`urllib`, `json`, `re`, `subprocess`/`curl` SSL fallback), bash 4+, Markdown skill/reference docs. **Tech Stack:** Python 3 stdlib only (`urllib`, `json`, `re`, `subprocess`/`curl` SSL fallback), bash 4+, Markdown skill/reference docs.
@ -26,14 +26,14 @@
## File Structure ## File Structure
**Create:** **Create:**
- `plugins/market-research/skills/market-research/scripts/fetch-play-charts.py`AppBrain HTML top-charts → normalized JSON (real Android packages). - `plugins/market-research/skills/market-research/scripts/fetch-play-charts.py`play.google.com top/category HTML → normalized JSON (real Android packages).
- `plugins/market-research/skills/market-research/scripts/play.py``resolve` (name → Play URL + stats) and `count` (saturation) subcommands. - `plugins/market-research/skills/market-research/scripts/play.py``resolve` (name → Play URL + stats) and `count` (saturation) subcommands.
- `plugins/market-research/skills/market-research/scripts/trends.py` — best-effort Google Trends, never hard-fails. - `plugins/market-research/skills/market-research/scripts/trends.py` — best-effort Google Trends, never hard-fails.
- `plugins/market-research/skills/market-research/references/numeric-sources.md` — curated free numeric-data sources + query/extraction rubric. - `plugins/market-research/skills/market-research/references/numeric-sources.md` — curated free numeric-data sources + query/extraction rubric.
- `plugins/market-research/tests/test-fetch-play-charts.py` - `plugins/market-research/tests/test-fetch-play-charts.py`
- `plugins/market-research/tests/test-play.py` - `plugins/market-research/tests/test-play.py`
- `plugins/market-research/tests/test-trends.py` - `plugins/market-research/tests/test-trends.py`
- `plugins/market-research/tests/fixtures/appbrain-popular.html` - `plugins/market-research/tests/fixtures/play-chart.html`
- `plugins/market-research/tests/fixtures/play-search.html` - `plugins/market-research/tests/fixtures/play-search.html`
- `plugins/market-research/tests/fixtures/play-details.html` - `plugins/market-research/tests/fixtures/play-details.html`
- `plugins/market-research/tests/fixtures/trends-sample.json` - `plugins/market-research/tests/fixtures/trends-sample.json`
@ -51,38 +51,31 @@
--- ---
## Task 1: AppBrain Play charts scraper ## Task 1: play.google.com charts scraper
**Context:** AppBrain (the brainstorm's first choice) is Cloudflare-blocked (HTTP 403) for free scraping. `play.google.com`'s own server-rendered chart HTML works (validated live: 46/46 real Android packages from a category page). This scrapes `/store/apps/top` and `/store/apps/category/<CAT>`.
**Files:** **Files:**
- Create: `plugins/market-research/skills/market-research/scripts/fetch-play-charts.py` - Create: `plugins/market-research/skills/market-research/scripts/fetch-play-charts.py`
- Create: `plugins/market-research/tests/fixtures/appbrain-popular.html` - Create: `plugins/market-research/tests/fixtures/play-chart.html`
- Test: `plugins/market-research/tests/test-fetch-play-charts.py` - Test: `plugins/market-research/tests/test-fetch-play-charts.py`
**Interfaces:** **Interfaces:**
- Consumes: nothing. - Consumes: nothing.
- Produces: CLI `fetch-play-charts.py <chart> [--region R] [--limit N] [--html-file F]` where `<chart> ∈ {popular, top-grossing, top-new}`. Stdout JSON: `{"source":"appbrain","chart":str,"region":str,"count":int,"entries":[{"rank":int,"name":str,"developer":str|None,"category":str|None,"package":str,"rating":float|None,"installs":str|None}]}`. - Produces: CLI `fetch-play-charts.py <chart> [--category CAT] [--region R] [--limit N] [--html-file F]` where `<chart> ∈ {top, category}` (`category` requires `--category`, e.g. `PRODUCTIVITY`). Stdout JSON: `{"source":"google-play","chart":str,"region":str,"count":int,"entries":[{"rank":int,"name":str,"package":str,"rating":float|None}]}`. `chart` label is `"top"` or `"category:<CAT>"`. `rating` is best-effort (may be `None`).
- [ ] **Step 1: Create the synthetic fixture** - [ ] **Step 1: Create the synthetic fixture**
Create `plugins/market-research/tests/fixtures/appbrain-popular.html` with markup matching AppBrain's app-list rows (a link to `/app/<slug>/<package>` carrying name, developer, rating, installs): Create `plugins/market-research/tests/fixtures/play-chart.html` mirroring real Play app-card markup — a `details?id=` anchor wrapping the icon, then a `class="Epkrse …"` title div, then a star-rating aria-label. (Note the trailing space inside `class="Epkrse "` — that is the real markup; the parser must tolerate it. Card 3 repeats app 1's package to exercise dedup.)
```html ```html
<!DOCTYPE html><html><body> <!DOCTYPE html><html><body>
<div class="app-list"> <div role="main">
<div class="app"> <a href="/store/apps/details?id=com.example.habit" jslog="38003; track:click"><div class="TjRVLb"><img src="https://play-lh.googleusercontent.com/x=s256"></div><div class="Epkrse ">Habit Tracker</div></a>
<a class="app-icon-name" href="/app/habit-tracker/com.example.habit">Habit Tracker</a> <div aria-label="Rated 4.6 stars out of five stars"></div>
<span class="developer">Focus Labs</span> <a href="/store/apps/details?id=com.example.budget" jslog="38003; track:click"><div class="TjRVLb"><img src="https://play-lh.googleusercontent.com/y=s256"></div><div class="Epkrse ">Budget Buddy</div></a>
<span class="category">Productivity</span> <div aria-label="Rated 4.2 stars out of five stars"></div>
<span class="rating">4.6</span> <a href="/store/apps/details?id=com.example.habit" jslog="38003; track:click"><div class="Epkrse ">Habit Tracker</div></a>
<span class="installs">5,000,000+</span>
</div>
<div class="app">
<a class="app-icon-name" href="/app/budget-buddy/com.example.budget">Budget Buddy</a>
<span class="developer">Money Inc</span>
<span class="category">Finance</span>
<span class="rating">4.2</span>
<span class="installs">1,000,000+</span>
</div>
</div> </div>
</body></html> </body></html>
``` ```
@ -97,11 +90,11 @@ import json, subprocess, sys, os
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "..", "skills", "market-research", "scripts", "fetch-play-charts.py") SCRIPT = os.path.join(HERE, "..", "skills", "market-research", "scripts", "fetch-play-charts.py")
FIXTURE = os.path.join(HERE, "fixtures", "appbrain-popular.html") FIXTURE = os.path.join(HERE, "fixtures", "play-chart.html")
def run(): def run():
out = subprocess.check_output( out = subprocess.check_output(
[sys.executable, SCRIPT, "popular", "--region", "us", "--html-file", FIXTURE]) [sys.executable, SCRIPT, "top", "--region", "US", "--html-file", FIXTURE])
return json.loads(out) return json.loads(out)
def main(): def main():
@ -111,20 +104,19 @@ def main():
print(f"{'PASS' if cond else 'FAIL'}: {name}") print(f"{'PASS' if cond else 'FAIL'}: {name}")
if not cond: fails.append(name) if not cond: fails.append(name)
check("source", d["source"] == "appbrain") check("source", d["source"] == "google-play")
check("chart", d["chart"] == "popular") check("chart", d["chart"] == "top")
check("count", d["count"] == 2) check("region", d["region"] == "US")
check("count (dedup app1 repeat)", d["count"] == 2)
e0 = d["entries"][0] e0 = d["entries"][0]
check("rank 1", e0["rank"] == 1) check("rank 1", e0["rank"] == 1)
check("name", e0["name"] == "Habit Tracker") check("name", e0["name"] == "Habit Tracker")
check("package", e0["package"] == "com.example.habit") check("package", e0["package"] == "com.example.habit")
check("developer", e0["developer"] == "Focus Labs")
check("category", e0["category"] == "Productivity")
check("rating", e0["rating"] == 4.6) check("rating", e0["rating"] == 4.6)
check("installs", e0["installs"] == "5,000,000+") for k in ["rank", "name", "package", "rating"]:
for k in ["rank", "name", "package", "developer", "category", "rating", "installs"]:
check(f"key present: {k}", k in e0) check(f"key present: {k}", k in e0)
check("second package", d["entries"][1]["package"] == "com.example.budget") check("second package", d["entries"][1]["package"] == "com.example.budget")
check("second name", d["entries"][1]["name"] == "Budget Buddy")
sys.exit(1 if fails else 0) sys.exit(1 if fails else 0)
main() main()
@ -141,22 +133,20 @@ Create `plugins/market-research/skills/market-research/scripts/fetch-play-charts
```python ```python
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Fetch an AppBrain Play-store top-chart into a normalized JSON list. """Fetch a play.google.com chart page into a normalized JSON list.
Google Play has no public chart feed and renders via obfuscated batchexecute Google Play has no public chart feed, but its server-rendered HTML for
JS. AppBrain (appbrain.com) publishes server-rendered HTML top-chart pages with /store/apps/top and /store/apps/category/<CAT> carries ranked app cards with
REAL Android package names. This scrapes those rows. Stdlib-only, no pip. REAL Android package names (unlike Apple's RSS iOS bundle ids). Each card is an
<a href="/store/apps/details?id=PKG"> wrapping the icon, then a
<div class="Epkrse ">NAME</div> title, then a star aria-label. Play obfuscates
the title class, so the package LINK (stable) is the load-bearing field; name
and rating are best-effort. Stdlib-only, no pip.
Unlike Apple's RSS (iOS bundle ids), entries here carry Android packages usable (AppBrain, the original plan's source, is Cloudflare-blocked 403 — dropped.)
directly for a clone-app handoff.
""" """
import sys, json, re, argparse, urllib.request, ssl, subprocess, shutil import sys, json, re, html as _html, argparse, urllib.request, ssl, subprocess, shutil
CHARTS = {
"popular": "https://www.appbrain.com/apps/popular/",
"top-grossing": "https://www.appbrain.com/apps/highest-grossing/",
"top-new": "https://www.appbrain.com/apps/new/",
}
UA = "Mozilla/5.0" UA = "Mozilla/5.0"
def _http_get(url): def _http_get(url):
@ -175,58 +165,66 @@ def _http_get(url):
raise raise
return out.stdout.decode("utf-8", "replace") return out.stdout.decode("utf-8", "replace")
# One app row: a link to /app/<slug>/<package> followed (within the same row LINK = re.compile(r'href="/store/apps/details\?id=([a-zA-Z0-9._]+)"')
# block) by developer / category / rating / installs spans. NAME = re.compile(r'class="Epkrse[^"]*"[^>]*>([^<]+)<')
ROW = re.compile( # rating is best-effort: search-style aria-label OR a star-icon followed by a number
r'href="/app/[^"/]+/(?P<package>[\w.]+)"[^>]*>(?P<name>[^<]+)</a>' RATE_ARIA = re.compile(r'aria-label="Rated\s+([\d.]+)\s+star')
r'(?P<rest>.*?)(?=href="/app/|</div>\s*</div>|$)', re.DOTALL) RATE_STAR = re.compile(r'>star</[^>]+>\s*([\d.]+)')
DEV = re.compile(r'class="developer"[^>]*>([^<]+)<')
CAT = re.compile(r'class="category"[^>]*>([^<]+)<')
RAT = re.compile(r'class="rating"[^>]*>\s*([\d.]+)\s*<')
INST = re.compile(r'class="installs"[^>]*>\s*([\d,]+\+)\s*<')
def _first(rx, text): def _chart_url(chart, category, region):
m = rx.search(text) if chart == "category":
return m.group(1).strip() if m else None return f"https://play.google.com/store/apps/category/{category}?hl=en&gl={region}"
return f"https://play.google.com/store/apps/top?hl=en&gl={region}"
def parse(html, chart, region, limit): def parse(html, chart_label, region, limit):
entries = [] entries = []
for i, m in enumerate(ROW.finditer(html), start=1): seen = set()
if i > limit: for m in LINK.finditer(html):
break pkg = m.group(1)
rest = m.group("rest") if pkg in seen:
rating = _first(RAT, rest) continue
win = html[m.start():m.start() + 1600] # one card's worth of markup
nm = NAME.search(win)
if not nm:
continue # icon-only anchor with no title nearby; skip
seen.add(pkg)
rt = RATE_ARIA.search(win) or RATE_STAR.search(win)
entries.append({ entries.append({
"rank": i, "rank": len(entries) + 1,
"name": m.group("name").strip(), "name": _html.unescape(nm.group(1).strip()),
"developer": _first(DEV, rest), "package": pkg,
"category": _first(CAT, rest), "rating": float(rt.group(1)) if rt else None,
"package": m.group("package"),
"rating": float(rating) if rating else None,
"installs": _first(INST, rest),
}) })
return {"source": "appbrain", "chart": chart, "region": region, if len(entries) >= limit:
break
return {"source": "google-play", "chart": chart_label, "region": region,
"count": len(entries), "entries": entries} "count": len(entries), "entries": entries}
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("chart", choices=sorted(CHARTS)) ap.add_argument("chart", choices=("top", "category"))
ap.add_argument("--region", default="us") ap.add_argument("--category")
ap.add_argument("--region", default="US")
ap.add_argument("--limit", type=int, default=25) ap.add_argument("--limit", type=int, default=25)
ap.add_argument("--html-file") ap.add_argument("--html-file")
args = ap.parse_args() args = ap.parse_args()
if args.chart == "category" and not args.category:
print("ERROR: --category is required for the 'category' chart", file=sys.stderr)
sys.exit(2)
chart_label = f"category:{args.category}" if args.chart == "category" else "top"
if args.html_file: if args.html_file:
with open(args.html_file, encoding="utf-8") as f: with open(args.html_file, encoding="utf-8") as f:
html = f.read() html = f.read()
else: else:
try: try:
html = _http_get(CHARTS[args.chart]) html = _http_get(_chart_url(args.chart, args.category, args.region))
except Exception as e: except Exception as e:
print(f"ERROR: failed to fetch AppBrain chart: {e}", file=sys.stderr) print(f"ERROR: failed to fetch Play chart: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
print(json.dumps(parse(html, args.chart, args.region, args.limit), indent=2)) print(json.dumps(parse(html, chart_label, args.region, args.limit), indent=2))
if __name__ == "__main__": if __name__ == "__main__":
main() main()
@ -241,18 +239,18 @@ Run: `chmod +x plugins/market-research/skills/market-research/scripts/fetch-play
Run: `python3 plugins/market-research/tests/test-fetch-play-charts.py` Run: `python3 plugins/market-research/tests/test-fetch-play-charts.py`
Expected: every line `PASS`, exit 0. Expected: every line `PASS`, exit 0.
- [ ] **Step 7: Validate the selectors against live AppBrain once** - [ ] **Step 7: Validate the selectors against live Play once**
Run: `python3 plugins/market-research/skills/market-research/scripts/fetch-play-charts.py popular --limit 5` Run: `python3 plugins/market-research/skills/market-research/scripts/fetch-play-charts.py category --category PRODUCTIVITY --limit 5`
Expected: JSON with ~5 entries carrying real `com.*` packages. **If the live markup differs from the fixture** (empty `entries`), update both the regexes in the script AND `appbrain-popular.html` to match the real structure, then re-run Step 6 until the offline test passes against the corrected fixture. If AppBrain blocks/changes hosting, record the failure in the commit message and keep the script (the skill degrades to Apple RSS + web). Expected: JSON with ~5 entries carrying real `com.*` packages and names (e.g. ChatGPT, Google Keep). Also smoke `top`: `... top --limit 5`. **If live `entries` come back empty** (Play rotated the obfuscated `Epkrse` title class), find the new title class in the live HTML, update `NAME` in the script AND the `class="Epkrse "` strings in `play-chart.html` to match, then re-run Step 6 until the offline test passes against the corrected fixture. The package link is stable, so an empty result means the title-class selector — not the link — drifted. If Play is unreachable from this environment, keep the script as-is, note it in the report, and still commit (the offline test must pass regardless).
- [ ] **Step 8: Commit** - [ ] **Step 8: Commit**
```bash ```bash
git add plugins/market-research/skills/market-research/scripts/fetch-play-charts.py \ git add plugins/market-research/skills/market-research/scripts/fetch-play-charts.py \
plugins/market-research/tests/test-fetch-play-charts.py \ plugins/market-research/tests/test-fetch-play-charts.py \
plugins/market-research/tests/fixtures/appbrain-popular.html plugins/market-research/tests/fixtures/play-chart.html
git commit -m "feat(market-research): add AppBrain Play-charts scraper" git commit -m "feat(market-research): add play.google.com charts scraper"
``` ```
--- ---
@ -624,7 +622,7 @@ def main():
check("trend_pct", d["trend_pct"] == 100.0) # (100-50)/50*100 check("trend_pct", d["trend_pct"] == 100.0) # (100-50)/50*100
# bad fixture -> graceful fallback, still exit 0 # bad fixture -> graceful fallback, still exit 0
rc2, d2 = run("x", "--json-file", os.path.join(HERE, "fixtures", "appbrain-popular.html")) rc2, d2 = run("x", "--json-file", os.path.join(HERE, "fixtures", "play-chart.html"))
check("fallback exit 0", rc2 == 0) check("fallback exit 0", rc2 == 0)
check("fallback ok false", d2["ok"] is False) check("fallback ok false", d2["ok"] is False)
check("fallback flag", d2["fallback"] == "websearch") check("fallback flag", d2["fallback"] == "websearch")
@ -775,7 +773,7 @@ Every number that lands in the report carries the URL it came from.
| Source | Pull | How (free) | | Source | Pull | How (free) |
|---|---|---| |---|---|---|
| **Google Trends** | interest-over-time, % momentum, breakout/rising queries | `trends.py "<term>"`; on `{"ok":false}` fall back to WebSearch `google trends <term>`. | | **Google Trends** | interest-over-time, % momentum, breakout/rising queries | `trends.py "<term>"`; on `{"ok":false}` fall back to WebSearch `google trends <term>`. |
| **AppBrain** | install brackets, category rank/share, Android growth | `fetch-play-charts.py` for charts; WebSearch `appbrain <app/category> statistics` for detail pages. | | **play.google.com** | ranked Android packages, per-app installs/rating | `fetch-play-charts.py` for top/category charts; `play.py resolve` for per-app installs/rating. |
| **Sensor Tower (blog/reports)** | downloads, revenue, DAU, YoY growth | WebSearch `sensortower <category> revenue downloads 2026`; WebFetch the article; quote the figure + date. | | **Sensor Tower (blog/reports)** | downloads, revenue, DAU, YoY growth | WebSearch `sensortower <category> revenue downloads 2026`; WebFetch the article; quote the figure + date. |
| **data.ai / Apptopia posts** | top-charts movement, market revenue | WebSearch `data.ai <category> market 2026`; WebFetch + cite. | | **data.ai / Apptopia posts** | top-charts movement, market revenue | WebSearch `data.ai <category> market 2026`; WebFetch + cite. |
| **Statista (free public charts)** | market size $, user counts, CAGR | WebSearch `statista <category> market size`; use the visible free figure only; cite. | | **Statista (free public charts)** | market size $, user counts, CAGR | WebSearch `statista <category> market size`; use the visible free figure only; cite. |
@ -849,7 +847,7 @@ or `null`. A field that is `null` caps that subscore at 60.
| Subscore | Evidence that lifts the cap above 60 | | Subscore | Evidence that lifts the cap above 60 |
|---|---| |---|---|
| Cloneability | a stack/complexity signal (e.g. "few endpoints — RE later"); no external number required, but state the basis. | | Cloneability | a stack/complexity signal (e.g. "few endpoints — RE later"); no external number required, but state the basis. |
| Market opportunity | an installs figure (AppBrain/Play) AND either a Trends `trend_pct` OR a saturation `app_count`. | | Market opportunity | an installs figure (Play via `play.py resolve`) AND either a Trends `trend_pct` OR a saturation `app_count`. |
| Monetization fit | a category ARPU/revenue figure (Sensor Tower/data.ai/Statista) OR top-grossing chart presence. | | Monetization fit | a category ARPU/revenue figure (Sensor Tower/data.ai/Statista) OR top-grossing chart presence. |
| Niche gap | a region/language gap signal (saturation `app_count` low in region, or no localized incumbent). | | Niche gap | a region/language gap signal (saturation `app_count` low in region, or no localized incumbent). |
@ -914,7 +912,7 @@ Every number carries a source link. Every candidate carries 12 Google Play li
## Run parameters ## Run parameters
- Angles this run: <categories / regions / niche lens chosen in Phase 0> - Angles this run: <categories / regions / niche lens chosen in Phase 0>
- Focus argument: <the user's focus, or "none"> - Focus argument: <the user's focus, or "none">
- Sources: Apple RSS (<feeds/regions>) + AppBrain Play charts (<charts>) + web numeric (<sources>) + Trends (<ok/fallback>) - Sources: Apple RSS (<feeds/regions>) + play.google.com charts (<charts>) + web numeric (<sources>) + Trends (<ok/fallback>)
- Candidates after history exclusion: <N> (history had <M> prior suggestions) - Candidates after history exclusion: <N> (history had <M> prior suggestions)
## Top candidates (ranked) ## Top candidates (ranked)
@ -984,14 +982,17 @@ python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-charts.py \
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-charts.py \ python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-charts.py \
topgrossingapplications --region <region> --limit 25 > "$WORK/charts-<region>-grossing.json" topgrossingapplications --region <region> --limit 25 > "$WORK/charts-<region>-grossing.json"
``` ```
AppBrain Play charts (Android signal — REAL packages): play.google.com charts (Android signal — REAL packages). Pull the overall top
plus one category page per chosen category (Play category code, e.g.
`PRODUCTIVITY`, `GAME_PUZZLE`, `FINANCE`):
```bash ```bash
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-play-charts.py \ python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-play-charts.py \
popular --limit 25 > "$WORK/play-popular.json" top --region <region> --limit 25 > "$WORK/play-top-<region>.json"
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-play-charts.py \ python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-play-charts.py \
top-grossing --limit 25 > "$WORK/play-grossing.json" category --category <CAT> --region <region> --limit 25 > "$WORK/play-<CAT>.json"
``` ```
If any fetch fails, note it and continue with the feeds you got. If any fetch fails (Play may rotate its obfuscated title class), note it and
continue with the feeds you got — Apple RSS + web signal still stand.
## Phase 2: Trend + numeric signal ## Phase 2: Trend + numeric signal
Read `${CLAUDE_PLUGIN_ROOT}/skills/market-research/references/numeric-sources.md`. Read `${CLAUDE_PLUGIN_ROOT}/skills/market-research/references/numeric-sources.md`.
@ -1005,10 +1006,11 @@ If a `trends.py` result is `{"ok": false, "fallback": "websearch"}`, WebSearch t
same momentum signal instead. Vary queries by the run's angles. same momentum signal instead. Vary queries by the run's angles.
## Phase 3: Synthesize ≥12 candidates ## Phase 3: Synthesize ≥12 candidates
Cluster chart entries (Apple + AppBrain) + web findings into ≥12 distinct ideas Cluster chart entries (Apple RSS + play.google.com) + web findings into ≥12
(synthesize > 10 so dedup still leaves ≥10). For each: name, category, distinct ideas (synthesize > 10 so dedup still leaves ≥10). For each: name,
what-it-does, why-now (with a cited number where available), incumbent(s), category, what-it-does, why-now (with a cited number where available),
monetization model. AppBrain entries already give an Android `package`; carry it. incumbent(s), monetization model. play.google.com entries already give an
Android `package`; carry it.
Write the working list as a JSON array (objects with at least `name`, optional Write the working list as a JSON array (objects with at least `name`, optional
`package`, `category`) to `$WORK/candidates.json`. `package`, `category`) to `$WORK/candidates.json`.
@ -1066,7 +1068,7 @@ stands on its own.
| Scenario | Action | | Scenario | Action |
|---|---| |---|---|
| `fetch-charts.py` / `fetch-play-charts.py` fails | note it, continue with other feeds/web search | | `fetch-charts.py` / `fetch-play-charts.py` fails | note it, continue with other feeds/web search |
| AppBrain blocks or markup changed | continue on Apple RSS + web signal | | play.google.com chart fetch fails / title class rotated | continue on Apple RSS + web signal; package link stays stable |
| `play.py resolve` finds no package | flag candidate "Play link unresolved", skip handoff | | `play.py resolve` finds no package | flag candidate "Play link unresolved", skip handoff |
| Play link fails WebFetch verify | drop that link; if none verify, flag unresolved | | Play link fails WebFetch verify | drop that link; if none verify, flag unresolved |
| `trends.py` returns `{"ok": false}` | WebSearch the same momentum signal | | `trends.py` returns `{"ok": false}` | WebSearch the same momentum signal |
@ -1154,7 +1156,7 @@ git commit -m "test(market-research): cover v2 scripts in smoke; update README"
## Self-Review Notes (for the executor) ## Self-Review Notes (for the executor)
- **Spec coverage:** AppBrain charts → Task 1; Play resolver + links → Task 2; saturation → Task 3; Trends best-effort → Task 4; numeric sources → Task 5; numeric scoring → Task 6; report links/citations/saturation → Task 7; survivor-only phase flow + WebFetch verify → Task 8; smoke/README/full-suite + RE-untouched gate → Task 9. - **Spec coverage:** play.google.com charts → Task 1; Play resolver + links → Task 2; saturation → Task 3; Trends best-effort → Task 4; numeric sources → Task 5; numeric scoring → Task 6; report links/citations/saturation → Task 7; survivor-only phase flow + WebFetch verify → Task 8; smoke/README/full-suite + RE-untouched gate → Task 9.
- **Live-markup risk** is handled by the per-scraper "validate against live once" step (Tasks 1 Step 7, 2 Step 8); fix script + fixture together if reality differs, keep the offline test green. - **Live-markup risk** is handled by the per-scraper "validate against live once" step (Tasks 1 Step 7, 2 Step 8); fix script + fixture together if reality differs, keep the offline test green.
- **No new pip deps, no API keys, all stdlib** — every script mirrors `fetch-charts.py`'s `_http_get` fallback pattern. - **No new pip deps, no API keys, all stdlib** — every script mirrors `fetch-charts.py`'s `_http_get` fallback pattern.
- **`trends.py` never hard-fails** — verified by the fallback assertions in Task 4 test. - **`trends.py` never hard-fails** — verified by the fallback assertions in Task 4 test.

View File

@ -30,7 +30,7 @@ method that actually works free:
| Source | Method | Why | | Source | Method | Why |
|---|---|---| |---|---|---|
| Apple App Store RSS | existing `fetch-charts.py` | Public no-auth JSON feed. Keep. | | Apple App Store RSS | existing `fetch-charts.py` | Public no-auth JSON feed. Keep. |
| Google Play charts | **AppBrain HTML** via new `fetch-play-charts.py` | Play itself has no public feed and renders via obfuscated `batchexecute` JS. AppBrain publishes scrapeable server-rendered HTML top-charts with real Android packages. | | Google Play charts | **`play.google.com` HTML** via new `fetch-play-charts.py` | Play has no public JSON feed, but its server-rendered HTML for `/store/apps/top` and `/store/apps/category/<CAT>` returns ~4570 ranked app cards carrying real Android packages + names (validated live: 46/46). AppBrain — the original plan's source — is Cloudflare-blocked (HTTP 403) for free scraping, so it was dropped. |
| Play link + stats per candidate | new `play.py resolve` | `play.google.com/store/search?q=…&c=apps` is server-rendered enough to grep the first `details?id=` link; the details page carries rating/installs/last-updated in embedded `ld+json` (same parse clone-app's `scrape-play-store.py` already uses). | | Play link + stats per candidate | new `play.py resolve` | `play.google.com/store/search?q=…&c=apps` is server-rendered enough to grep the first `details?id=` link; the details page carries rating/installs/last-updated in embedded `ld+json` (same parse clone-app's `scrape-play-store.py` already uses). |
| Saturation / competition density | new `play.py count` | Count distinct `details?id=` links + their ratings on the first Play search results page. Approximate but real. | | Saturation / competition density | new `play.py count` | Count distinct `details?id=` links + their ratings on the first Play search results page. Approximate but real. |
| Google Trends | best-effort `trends.py` + WebSearch fallback | No key, but the unofficial endpoint needs a token dance and is fragile under stdlib-only. Script is best-effort and **never hard-fails**; on any failure the skill falls back to WebSearch for the same signal. | | Google Trends | best-effort `trends.py` + WebSearch fallback | No key, but the unofficial endpoint needs a token dance and is fragile under stdlib-only. Script is best-effort and **never hard-fails**; on any failure the skill falls back to WebSearch for the same signal. |
@ -44,12 +44,16 @@ helper scripts; judgment steps follow reference rubrics.
### New scripts (`skills/market-research/scripts/`) ### New scripts (`skills/market-research/scripts/`)
**`fetch-play-charts.py`** **`fetch-play-charts.py`**
- Input: a chart kind (`popular` / `top-grossing` / `top-new`, mapped to AppBrain - Input: a chart kind (`top` = `play.google.com/store/apps/top`, or `category`
paths), `--region` (where AppBrain supports it), `--limit`, `--html-file` (offline test). with `--category <CAT>` = `/store/apps/category/<CAT>`), `--region` (Play `gl`
- Output: normalized JSON `{ "source": "appbrain", "chart": ..., "count": N, code), `--limit`, `--html-file` (offline test).
"entries": [ {rank, name, developer, category, package, rating, installs} ] }`. - Output: normalized JSON `{ "source": "google-play", "chart": ..., "region": R,
"count": N, "entries": [ {rank, name, package, rating} ] }`. (`rating` is
best-effort and may be `None`; rank+name+package is the load-bearing signal.)
- Mirrors `fetch-charts.py`'s shape and its `curl` SSL fallback. - Mirrors `fetch-charts.py`'s shape and its `curl` SSL fallback.
- Entries carry **real Android `package`s** (unlike Apple's iOS bundle ids). - Entries carry **real Android `package`s** (unlike Apple's iOS bundle ids),
parsed from Play app cards: `href="/store/apps/details?id=PKG"` +
`class="Epkrse …">NAME</div>` (validated live: 46/46 on a category page).
**`play.py`** — two subcommands, both with `--html-file` for offline tests: **`play.py`** — two subcommands, both with `--html-file` for offline tests:
- `resolve "<app name>"` → scrape Play search, take the top apps hit, fetch its - `resolve "<app name>"` → scrape Play search, take the top apps hit, fetch its
@ -97,7 +101,7 @@ not all raw candidates.
``` ```
Phase 0 Seed rotation (unchanged) Phase 0 Seed rotation (unchanged)
Phase 1 Gather charts Apple RSS (fetch-charts.py) + AppBrain Play Phase 1 Gather charts Apple RSS (fetch-charts.py) + play.google.com
charts (fetch-play-charts.py) per region charts (fetch-play-charts.py) per region
Phase 2 Trend + numeric WebSearch per numeric-sources.md; trends.py per Phase 2 Trend + numeric WebSearch per numeric-sources.md; trends.py per
top themes (fallback to WebSearch on failure) top themes (fallback to WebSearch on failure)
@ -130,7 +134,9 @@ rule as today's iOS-only case).
## Out of scope ## Out of scope
- Cross-platform gap detection (iOS-popular / Android-missing) — explicitly cut. - Cross-platform gap detection (iOS-popular / Android-missing) — explicitly cut.
- Scraping play.google.com charts directly — AppBrain used instead. - AppBrain as a chart source — Cloudflare-blocked (403), dropped for play.google.com.
- Top-grossing Play chart — Play exposes no clean grossing URL without JS; Apple
RSS `topgrossingapplications` carries the monetization-rank signal instead.
- Any pip dependency, API key, or paid tier. - Any pip dependency, API key, or paid tier.
- Changes outside `plugins/market-research/`. - Changes outside `plugins/market-research/`.
@ -138,8 +144,8 @@ rule as today's iOS-only case).
- `fetch-play-charts.py`, `play.py` (both subcommands), `trends.py` each get an - `fetch-play-charts.py`, `play.py` (both subcommands), `trends.py` each get an
offline fixture test driven by `--html-file` / `--json-file`. New fixtures: offline fixture test driven by `--html-file` / `--json-file`. New fixtures:
AppBrain chart HTML, a Play search results HTML, a Play details HTML, a Trends a Play chart-page HTML, a Play search results HTML, a Play details HTML, a
JSON response. Trends JSON response.
- Each scraper's first implementation task: fetch live once, save the fixture, - Each scraper's first implementation task: fetch live once, save the fixture,
confirm the parser. Then all subsequent tests run offline. confirm the parser. Then all subsequent tests run offline.
- `run-all.sh` and `smoke-structure.sh` extended to cover the new scripts/refs. - `run-all.sh` and `smoke-structure.sh` extended to cover the new scripts/refs.
@ -147,9 +153,11 @@ rule as today's iOS-only case).
## Risks ## Risks
- **AppBrain markup drift / blocking.** Mitigation: fixture-driven parser isolates - **Play HTML class-name drift** (Play obfuscates classes like `Epkrse`, which can
the brittle selector; the skill notes a failed Play-chart fetch and continues on rotate). Mitigation: fixture-driven parser isolates the brittle selector; the
Apple RSS + web signal (same degrade-gracefully pattern as today). package link (`/store/apps/details?id=`) is stable even when title classes
rotate, so rank+package survives a class change; the skill notes a failed
Play-chart fetch and continues on Apple RSS + web signal.
- **Google Trends fragility.** Mitigation by design: `trends.py` never hard-fails; - **Google Trends fragility.** Mitigation by design: `trends.py` never hard-fails;
WebSearch fallback covers the same signal. WebSearch fallback covers the same signal.
- **Play search HTML variance by region/locale.** Mitigation: force an `hl=en&gl=US` - **Play search HTML variance by region/locale.** Mitigation: force an `hl=en&gl=US`