Merge pull request #4 from masa2146/feat/market-research-v2

Feat/market research v2
This commit is contained in:
masa2146 2026-06-24 22:26:07 +03:00 committed by GitHub
commit e0f1865843
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1952 additions and 88 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,164 @@
# Market Research v2 — Design
**Date:** 2026-06-23
**Plugin:** `plugins/market-research/`
**Status:** Approved, pending implementation plan
## Goal
Make the `market-research` skill produce higher-quality, numerically-grounded,
source-cited clone candidates — entirely free, no API keys. Every candidate must
carry 12 real Google Play Store links. Scoring must cite real numbers (installs,
trend %, competitor count) instead of pure AI judgment.
## Constraints (unchanged from repo)
- **Free, no API key, no pip.** Python stdlib only (`urllib`, `json`, `re`,
`subprocess` for `curl` SSL fallback). bash 4+ at runtime.
- **`plugins/android-reverse-engineering/` is untouched.** This work is confined
to `plugins/market-research/`.
- **Scrape logic needs a fixture, not a live call.** Each new scraper is tested
offline against `tests/fixtures/` via a `--html-file` / `--json-file` flag.
- Working dir stays `./work/market-research/` in the user's cwd.
- Scripts-for-deterministic / rubrics-for-judgment split is preserved.
## Honesty on free sources
Not every requested source is cleanly scrapeable. The design routes each to the
method that actually works free:
| Source | Method | Why |
|---|---|---|
| Apple App Store RSS | existing `fetch-charts.py` | Public no-auth JSON feed. Keep. |
| 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). |
| 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. |
| Statista / Sensor Tower / data.ai / SimilarWeb | curated WebSearch/WebFetch targets in `numeric-sources.md` | Paywalled/JS sites. The free signal is their public blog posts and report summaries, surfaced by WebSearch — not a scraper. |
## Architecture
Same shape as today: `SKILL.md` orchestrates prose phases; deterministic steps are
helper scripts; judgment steps follow reference rubrics.
### New scripts (`skills/market-research/scripts/`)
**`fetch-play-charts.py`**
- Input: a chart kind (`top` = `play.google.com/store/apps/top`, or `category`
with `--category <CAT>` = `/store/apps/category/<CAT>`), `--region` (Play `gl`
code), `--limit`, `--html-file` (offline test).
- 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.
- 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:
- `resolve "<app name>"` → scrape Play search, take the top apps hit, fetch its
details page, print `{name, package, play_url, rating, installs, last_updated,
developer}`. Reuses the `ld+json` extraction approach from clone-app's
`scrape-play-store.py` (re-implemented locally — market-research stays
self-contained per the repo's per-plugin-layout convention; no cross-plugin
script dependency).
- `count "<query>"` → scrape Play search results, print `{query, app_count,
avg_rating, top_packages: [...]}` for saturation. `app_count` is "apps on the
first results page", stated as approximate in the report.
**`trends.py`** (best-effort, never hard-fails)
- `"<term>"` → attempt Google Trends interest-over-time + rising queries via the
unofficial endpoint (token dance in stdlib). On ANY failure, exit 0 with
`{"ok": false, "fallback": "websearch"}` so the skill knows to fall back to
WebSearch for the same momentum signal. `--json-file` for offline test of the
parser.
### New reference (`skills/market-research/references/`)
**`numeric-sources.md`** — for each free numeric source: what it's good for, the
exact WebSearch query shape to use, which number to extract, and how to cite it.
Covers Google Trends, play.google.com (installs/rating), Statista, Sensor Tower
blog, data.ai, SimilarWeb free tier. This is the rubric that turns "go search the web" into "pull these
specific numbers from these specific places."
### Rewritten references
**`scoring-guide.md` → numeric scoring.** Each subscore must cite at least one
number or it is capped low (e.g. a market score >70 requires an installs figure
AND either a trend % or a competitor count). Adds a per-subscore "evidence" field
to each candidate object. Keeps the existing weights
(cloneability 35 / market 35 / monetization 20 / niche 10).
**`report-template.md`.** Adds: a **Play links** column (12 verified links per
candidate); **source citations** inline on every "why now" claim and every number;
a **saturation** figure per candidate; numeric evidence in the per-candidate
detail block.
### Rewritten orchestrator (`SKILL.md`)
New phase flow, with an explicit **efficiency rule**: enrich only the survivors,
not all raw candidates.
```
Phase 0 Seed rotation (unchanged)
Phase 1 Gather charts Apple RSS (fetch-charts.py) + play.google.com
charts (fetch-play-charts.py) per region
Phase 2 Trend + numeric WebSearch per numeric-sources.md; trends.py per
top themes (fallback to WebSearch on failure)
Phase 3 Synthesize >=12 cluster charts + signal into ideas; Android
packages now available from the Play charts
Phase 4 Cheap score + dedup score on chart/trend signal only; history.py
filter; loop for more if <10 survive
Phase 5 Enrich survivors for the surviving top ~10 ONLY: play.py resolve
(1-2 links) + WebFetch verify each link (200 +
package match) + play.py count (saturation) +
trends.py momentum
Phase 6 Re-score numeric apply scoring-guide with real numbers + evidence
Phase 7 Present + handoff fill report-template (Play links, citations,
saturation); history.py add; resolve picks to
Play URL; invoke clone-app skill
```
Efficiency: the expensive per-candidate work (Play resolve, WebFetch verify,
saturation, trends) happens in Phase 5 against ~10 survivors, never against the
full raw synthesis set.
## Link verification
In Phase 5, each resolved Play URL is WebFetched and confirmed (HTTP 200 and the
`id=` package present on the page) before it is written into the report. Dead or
mismatched links are dropped; a candidate with no verifiable Play link is kept in
the report flagged "Play link unresolved" and skipped on clone-app handoff (same
rule as today's iOS-only case).
## Out of scope
- Cross-platform gap detection (iOS-popular / Android-missing) — explicitly cut.
- 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.
- Changes outside `plugins/market-research/`.
## Testing
- `fetch-play-charts.py`, `play.py` (both subcommands), `trends.py` each get an
offline fixture test driven by `--html-file` / `--json-file`. New fixtures:
a Play chart-page HTML, a Play search results HTML, a Play details HTML, a
Trends JSON response.
- Each scraper's first implementation task: fetch live once, save the fixture,
confirm the parser. Then all subsequent tests run offline.
- `run-all.sh` and `smoke-structure.sh` extended to cover the new scripts/refs.
- Existing bash test convention kept: `set -uo pipefail`, aggregate failures.
## Risks
- **Play HTML class-name drift** (Play obfuscates classes like `Epkrse`, which can
rotate). Mitigation: fixture-driven parser isolates the brittle selector; the
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;
WebSearch fallback covers the same signal.
- **Play search HTML variance by region/locale.** Mitigation: force an `hl=en&gl=US`
query in `play.py` for stable markup; fixture covers that shape.

View File

@ -6,14 +6,16 @@ scored, non-repeating clone candidates, then hands user-chosen candidates to the
## What it does
`/market-research` runs a 6-phase workflow:
`/market-research` runs an 8-phase workflow (07):
0. **Seed rotation** — pick varied search angles (category × region × niche) so runs differ.
1. **Gather** — App Store RSS chart data (`fetch-charts.py`) + free web search for trends.
1. **Gather** — App Store RSS (`fetch-charts.py`) + play.google.com charts (`fetch-play-charts.py`) + Google Trends best-effort (`trends.py`) + free web search.
2. **Synthesize** — cluster findings into ≥10 distinct app/game ideas.
3. **Score** — composite score: cloneability + market opportunity + monetization fit.
3. **Score** — composite numeric score: cloneability 35 + market 35 + monetization 20 + niche 10 (see `scoring-guide.md` + `numeric-sources.md`).
4. **Dedup** — exclude anything already in `./work/market-research/history.json`.
5. **Present + handoff** — show the ranked table; chosen candidates flow into `clone-app`.
5. **Present** — show the scored, ranked table.
6. **Survivor-only enrichment** — for user-chosen candidates only: resolve the Play Store package ID (`play.py`) and verify the link is live via WebFetch before handing off.
7. **Handoff** — chosen candidates (with verified Play links) flow into `clone-app`.
## State
@ -23,7 +25,10 @@ Written under `./work/market-research/` in your current directory:
## Scripts
- `scripts/fetch-charts.py` — fetch App Store RSS chart feeds → normalized JSON.
- `scripts/fetch-charts.py` — fetch Apple App Store RSS chart feeds → normalized JSON.
- `scripts/fetch-play-charts.py` — fetch play.google.com top/category charts → normalized JSON (no AppBrain; uses the public Play web endpoint directly).
- `scripts/play.py` — resolve an app name or bundle ID to a Play Store package ID and count search results (used in survivor-only enrichment, Phase 6).
- `scripts/trends.py` — best-effort Google Trends interest data for a keyword; never hard-fails (falls back to `null` if the endpoint is unavailable).
- `scripts/history.py` — read / append / dedup the suggestion history.
Python is stdlib-only. Tests run offline against `tests/fixtures/`:
@ -32,6 +37,15 @@ Python is stdlib-only. Tests run offline against `tests/fixtures/`:
bash plugins/market-research/tests/run-all.sh
```
## References
AI-judgment rubrics under `skills/market-research/references/`:
- `research-angles.md` — rotating category × region × niche seeds for run-to-run variety.
- `scoring-guide.md` — the 0100 weighted composite formula and per-dimension guidance.
- `numeric-sources.md` — how to map raw data (chart rank, install count, rating, search results) to numeric sub-scores used by `scoring-guide.md`.
- `report-template.md` — output format for the scored candidate table and per-candidate write-ups.
## Effort convention
Effort is measured in **AI Sprints** (one focused Claude session), never calendar time.

View File

@ -9,7 +9,7 @@ Scan the app/game market with free sources, score candidates, and hand the ones
you pick to the `clone-app` skill. Every run rotates its search angles and
excludes everything suggested before, so results stay fresh.
This skill orchestrates 6 phases (05). Deterministic steps are factored into
This skill orchestrates 8 phases (07). Deterministic steps are factored into
helper scripts under `${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/`;
AI-judgment steps follow rubrics under `.../references/`.
@ -36,51 +36,86 @@ used lately. Choose 23 categories, 12 regions, and 1 niche lens you did NO
last run. If the command passed a focus argument, force one category to match it.
State the chosen angles to the user in one line before continuing.
## Phase 1: Gather (free web)
Hard chart data — for each chosen region, pull the relevant feeds:
## Phase 1: Gather charts (free)
For each chosen region pull BOTH stores. Apple RSS (iOS signal — bundle ids, not
Android packages):
```bash
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-charts.py \
topfreeapplications --region <region> --limit 25 > "$WORK/charts-<region>-free.json"
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-charts.py \
topgrossingapplications --region <region> --limit 25 > "$WORK/charts-<region>-grossing.json"
```
(Top-grossing = monetization signal; top-free = demand signal. Add
`toppaidapplications` if willingness-to-pay matters for the angle.) If a fetch
fails, note it and continue with the feeds you got.
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
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-play-charts.py \
top --region <region> --limit 25 > "$WORK/play-top-<region>.json"
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/fetch-play-charts.py \
category --category <CAT> --region <region> --limit 25 > "$WORK/play-<CAT>.json"
```
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.
Trend signal — use WebSearch for the chosen categories/niches: new releases,
ProductHunt launches, Reddit/news chatter in the last ~90 days, "fastest growing
<category> apps 2026", dated-incumbent complaints. Vary the queries by the
run's angles so two runs don't search the same terms. These results are the
qualitative half the charts can't give.
## Phase 2: Trend + numeric signal
Read `${CLAUDE_PLUGIN_ROOT}/skills/market-research/references/numeric-sources.md`.
For the chosen categories/niches, WebSearch the named numeric sources and pull
real figures (market size, downloads, revenue, YoY) WITH their source URLs. For
the top themes, attempt Trends momentum:
```bash
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/trends.py "<theme>"
```
If a `trends.py` result is `{"ok": false, "fallback": "websearch"}`, WebSearch the
same momentum signal instead. Vary queries by the run's angles.
## Phase 2: Synthesize candidates
Cluster the chart entries + web findings into **at least 12** distinct app/game
ideas (synthesize more than 10 so dedup in Phase 4 still leaves ≥10). For each:
name, category, what-it-does, why-now (trend signal), incumbent(s), monetization
model. Note that App Store `bundle_id`s from the charts are iOS — treat them as
signal, not Android packages. Write the working list as a JSON array (objects
with at least `name`, optional `package`, `category`) to `$WORK/candidates.json`.
## Phase 3: Synthesize ≥12 candidates
Cluster chart entries (Apple RSS + play.google.com) + web findings into ≥12
distinct ideas (synthesize > 10 so dedup still leaves ≥10). For each: name,
category, what-it-does, why-now (with a cited number where available),
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
`package`, `category`) to `$WORK/candidates.json`.
## Phase 3: Score
Read `${CLAUDE_PLUGIN_ROOT}/skills/market-research/references/scoring-guide.md`.
Score every candidate's four components and weighted total. Add the subscores and
`total` to each object in `$WORK/candidates.json`. Rank by `total` descending.
## Phase 4: History dedup
Drop anything already suggested:
## Phase 4: Cheap score + history dedup
Score each candidate on the chart/trend signal you ALREADY have (don't enrich
yet — that's Phase 5). Then drop anything suggested before:
```bash
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/history.py \
filter --history "$WORK/history.json" < "$WORK/candidates.json" > "$WORK/fresh.json"
```
If fewer than 10 candidates survive, go back to Phase 1/2 with a different angle
and synthesize more, then re-filter — never present a padded or repeated list.
If fewer than 10 survive, loop back to Phase 1/2 with a DIFFERENT angle and
synthesize more, then re-filter. Never present a padded or repeated list.
## Phase 5: Present + handoff
## Phase 5: Enrich survivors only (efficiency)
For the surviving top ~10 ONLY (never the full raw set), enrich each:
1. Resolve 12 Play links + stats:
```bash
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/play.py \
resolve "<candidate name>"
```
2. **Verify** each resolved `play_url` with WebFetch — confirm HTTP 200 and the
`id=<package>` is present on the page. Drop dead/mismatched links. A candidate
with no verifiable link is flagged "Play link unresolved" (kept in report,
skipped on handoff).
3. Saturation:
```bash
python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/play.py \
count "<category or core feature>"
```
4. Momentum (`trends.py` as in Phase 2) if not already pulled.
## Phase 6: Re-score with numbers
Read `${CLAUDE_PLUGIN_ROOT}/skills/market-research/references/scoring-guide.md`.
Re-score every surviving candidate using the enriched numbers; attach the
`evidence` object (cited installs / trend % / saturation / ARPU). Rank by `total`
descending. Subscores with no supporting number are capped per the guide.
## Phase 7: Present + handoff
Read `${CLAUDE_PLUGIN_ROOT}/skills/market-research/references/report-template.md`.
Fill it from `$WORK/fresh.json` and write `$WORK/research-<YYYY-MM-DD>.md` (use
the actual run date). Show the user the ranked table (≥10 rows) and your top-3
recommended picks.
Fill it from the enriched, re-scored survivors and write
`$WORK/research-<YYYY-MM-DD>.md`. Show the user the ranked table (≥10 rows, with
Play links + saturation) and your top-3 picks.
Record this run's suggestions so they won't repeat:
```bash
@ -88,20 +123,20 @@ python3 ${CLAUDE_PLUGIN_ROOT}/skills/market-research/scripts/history.py \
add --history "$WORK/history.json" --date <YYYY-MM-DD> --run-id "<RUN_ID>" \
< "$WORK/fresh.json"
```
Then ask which candidate(s) to pursue. For each pick:
1. Resolve it to a Google Play package/URL. If you don't already have the package,
WebSearch `"<name>" site:play.google.com` (or the developer + app name) and
confirm the `play.google.com/store/apps/details?id=...` URL.
2. Invoke the `clone-app` skill on that URL/package to run full feasibility.
If the user picks nothing, stop — the report stands on its own.
Then ask which candidate(s) to pursue. Each pick already has a verified Play URL —
invoke the `clone-app` skill on it. If the user picks nothing, stop — the report
stands on its own.
## Error Handling Summary
| Scenario | Action |
|---|---|
| `fetch-charts.py` fails for a region | note it, continue with other feeds/web search |
| `fetch-charts.py` / `fetch-play-charts.py` fails | note it, continue with other feeds/web search |
| 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 link fails WebFetch verify | drop that link; if none verify, flag unresolved |
| `trends.py` returns `{"ok": false}` | WebSearch the same momentum signal |
| numeric source paywalled/JS-only | use only free-visible figures; never invent a number |
| Web search returns thin results | broaden queries within the chosen angle, try another region |
| < 10 candidates survive dedup | loop back to Phase 1/2 with a new angle, re-filter |
| history.json missing/first run | treat as empty; all candidates are fresh |
| Candidate has no resolvable Play package | skip the handoff for it, keep it in the report as iOS-only/unresolved |
| User picks nothing | stop after writing the report |

View File

@ -0,0 +1,34 @@
# Numeric Market-Data Sources (free, no API key)
Phase 2 uses this to turn "search the web" into "pull THESE numbers from THESE
places, and cite each." Prefer a real number with a source link over a vibe.
Every number that lands in the report carries the URL it came from.
## Sources & what to pull
| 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>`. |
| **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. |
| **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. |
| **SimilarWeb (free tier)** | incumbent web traffic, engagement | WebSearch `similarweb <incumbent domain>`; cite visits/engagement. |
## Query shaping (vary by the run's angles)
- Always bind a year: `... 2026` so figures are current.
- Bind a region when the angle is regional: `... <category> Brazil downloads`.
- For growth: `fastest growing <category> apps 2026`, `<category> market size 2026`.
- For incumbent weakness: `<incumbent> complaints reddit`, `<incumbent> alternative 2026`.
## Citation rule
Each extracted number is written as `<number> [<source>](<url>)` in the report.
A claim with no source link is downgraded to a qualitative note, not a number —
it must NOT be used to justify a numeric subscore (see scoring-guide.md).
## When a source is paywalled / JS-only
Do not scrape it. Use the figure only if it's visible free (article body, public
chart). Otherwise drop to the next source. Never invent a number to fill a gap.

View File

@ -1,6 +1,7 @@
# Market Research Report Template
Fill every section. Write to `./work/market-research/research-<YYYY-MM-DD>.md`.
Every number carries a source link. Every candidate carries 12 Google Play links.
---
@ -9,36 +10,39 @@ Fill every section. Write to `./work/market-research/research-<YYYY-MM-DD>.md`.
## Run parameters
- Angles this run: <categories / regions / niche lens chosen in Phase 0>
- Focus argument: <the user's focus, or "none">
- Sources: App Store RSS (<feeds/regions pulled>) + web search (<themes>)
- 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)
## Top candidates (ranked)
| # | Name | Category | Clone | Market | Monet. | Niche | **Total** | Why now |
|---|------|----------|------:|-------:|-------:|------:|----------:|---------|
| 1 | … | … | 85 | 80 | 75 | 60 | **79** | one-line trend rationale |
| … | | | | | | | | |
| # | Name | Category | Play link(s) | Installs | Saturation | Clone | Market | Monet. | Niche | **Total** | Why now (cited) |
|---|------|----------|--------------|---------:|-----------:|------:|-------:|-------:|------:|----------:|-----------------|
| 1 | … | … | [Play](url) | 5M+ | 12 apps/4.2★ | 85 | 80 | 75 | 60 | **79** | trend +120% [src] |
(At least 10 rows.)
(At least 10 rows. Each Play link is verified live — HTTP 200 + package match.)
## Candidate detail
For each of the top candidates:
For each top candidate:
### <name>
- **Package (if known):** <com.x.y or "to resolve">
- **Play link(s):** [<package1>](url1) [, [<package2>](url2)] ← 12, verified
- **What it does:** <12 sentences>
- **Why now:** <trend signal: chart movement, news, dated incumbent>
- **Incumbents:** <who already does this and how weak/strong>
- **Monetization:** <ads / IAP / subscription; ARPU note>
- **Why now:** <trend signal with a number + source link>
- **Incumbents:** <who, and saturation: app_count + avg rating from play.py count>
- **Monetization:** <ads / IAP / subscription; ARPU figure + source>
- **Evidence:** installs <n> [src], trend <pct>% [src], saturation <n> apps, ARPU <…> [src]
- **Scores:** clone <>, market <>, monetization <>, niche <> → **total <>**
- **Clone risk flags:** <heavy ML / native / content moat / none>
## Recommended picks
Top 3 to send to clone-app first, with one sentence each on why they lead.
Top 3 to send to clone-app first, one sentence each on why they lead (cite the
deciding number).
## Next step
The user picks one or more candidates; each chosen candidate is resolved to a
Google Play package/URL and handed to the `clone-app` skill for full feasibility.
The user picks one or more candidates; each chosen candidate already has a
verified Google Play URL and is handed to the `clone-app` skill for full
feasibility. Candidates whose Play link could not be verified are flagged
"Play link unresolved" and skipped on handoff.

View File

@ -1,43 +1,54 @@
# Scoring Guide
# Scoring Guide (numeric)
Score every candidate 0100 as a weighted composite. Three PRIMARY components
(cloneability, market opportunity, monetization fit) plus a secondary tiebreaker
(niche gap). Show the component scores, not just the total, so picks are auditable.
Score every candidate 0100 as a weighted composite of three PRIMARY components
plus a tiebreaker. **Every subscore must be justified by at least one real number
with a source** (see numeric-sources.md). A subscore with no supporting number is
CAPPED at 60 — you may not assign a high score on vibes.
## Components & weights
## Components & weights (unchanged)
| Component | Weight | What it measures |
| Component | Weight | Measures |
|---|---|---|
| Cloneability | 35% | How cheaply this rebuilds with clone-app + AI. |
| Market opportunity | 35% | Demand, growth, and incumbent weakness. |
| Market opportunity | 35% | Demand, growth, incumbent weakness. |
| Monetization fit | 20% | Ads/IAP friendliness and category ARPU. |
| Niche gap (tiebreaker) | 10% | Underserved region/language/segment. |
Total = 0.35·clone + 0.35·market + 0.20·monetization + 0.10·niche, each subscore 0100.
`total = 0.35·clone + 0.35·market + 0.20·monetization + 0.10·niche`, each 0100.
## Scoring each component (0100)
## Required evidence per subscore
**Cloneability** — higher = easier:
- 80100: simple CRUD/utility, few backend endpoints, no heavy ML, standard UI.
- 5079: moderate backend, some real-time or media, mainstream third-party SDKs.
- 049: heavy ML/on-device models, complex real-time/multiplayer, deep native, large content moat.
Attach an `evidence` object to each candidate. Each field is `"<number> + source"`
or `null`. A field that is `null` caps that subscore at 60.
**Market opportunity** — higher = better:
- 80100: strong/growing demand, dated or weak incumbents, clear unmet need.
- 5079: healthy demand, beatable incumbents.
- 049: saturated, dominated by entrenched well-funded players.
| 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. |
| 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. |
| Niche gap | a region/language gap signal (saturation `app_count` low in region, or no localized incumbent). |
**Monetization fit** — higher = better:
- 80100: category with proven ads+IAP and high ARPU (casual games, utilities).
- 5079: monetizable but moderate ARPU.
- 049: hard to monetize / users expect free.
## Scoring bands (0100)
**Niche gap** — higher = more underserved:
- 80100: clear language/region/segment with no quality option.
- 049: well served everywhere.
**Cloneability** (higher = easier): 80100 simple CRUD/utility, few endpoints, no
heavy ML. 5079 moderate backend/media, mainstream SDKs. 049 heavy ML/native/
real-time/large content moat.
**Market opportunity** (higher = better): 80100 strong/growing demand (high
installs + positive `trend_pct`), weak/dated incumbents, low saturation. 5079
healthy demand, beatable incumbents, moderate saturation. 049 saturated
(`app_count` high, strong avg rating) or entrenched well-funded players.
**Monetization fit** (higher = better): 80100 proven ads+IAP, high ARPU
(casual games, utilities) with a cited revenue figure. 5079 monetizable, moderate
ARPU. 049 users expect free / hard to monetize.
**Niche gap** (higher = more underserved): 80100 clear region/language/segment
with no quality option (low regional `app_count`). 049 well served everywhere.
## Output per candidate
For each candidate keep: `name`, `package` (if resolved), `category`, the four
subscores, the weighted `total`, and a one-line rationale. Rank by `total`
descending. Produce at least 10 candidates AFTER history exclusion.
Keep: `name`, `package` (if resolved), `play_url`(s), `category`, the four
subscores, `evidence` (the cited numbers + source URLs), the weighted `total`,
and a one-line rationale. Rank by `total` descending. Produce ≥10 candidates
AFTER history exclusion.

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Fetch a play.google.com chart page into a normalized JSON list.
Google Play has no public chart feed, but its server-rendered HTML for
/store/apps/top and /store/apps/category/<CAT> carries ranked app cards with
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.
(AppBrain, the original plan's source, is Cloudflare-blocked 403 — dropped.)
"""
import sys, json, re, html as _html, argparse, urllib.request, ssl, subprocess, shutil
UA = "Mozilla/5.0"
def _http_get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
except urllib.error.URLError as e:
if not isinstance(e.reason, ssl.SSLError):
raise
if not shutil.which("curl"):
raise
out = subprocess.run(["curl", "-sL", "--fail", "-A", UA, url],
capture_output=True, timeout=60)
if out.returncode != 0:
raise
return out.stdout.decode("utf-8", "replace")
LINK = re.compile(r'href="/store/apps/details\?id=([a-zA-Z0-9._]+)"')
NAME = re.compile(r'class="Epkrse[^"]*"[^>]*>([^<]+)<')
# rating is best-effort: search-style aria-label OR a star-icon followed by a number
RATE_ARIA = re.compile(r'aria-label="Rated\s+([\d.]+)\s+star')
RATE_STAR = re.compile(r'>star</[^>]+>\s*([\d.]+)')
def _chart_url(chart, category, region):
if chart == "category":
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_label, region, limit):
entries = []
seen = set()
for m in LINK.finditer(html):
pkg = m.group(1)
if pkg in seen:
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({
"rank": len(entries) + 1,
"name": _html.unescape(nm.group(1).strip()),
"package": pkg,
"rating": float(rt.group(1)) if rt else None,
})
if len(entries) >= limit:
break
return {"source": "google-play", "chart": chart_label, "region": region,
"count": len(entries), "entries": entries}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("chart", choices=("top", "category"))
ap.add_argument("--category")
ap.add_argument("--region", default="US")
ap.add_argument("--limit", type=int, default=25)
ap.add_argument("--html-file")
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:
with open(args.html_file, encoding="utf-8") as f:
html = f.read()
else:
try:
html = _http_get(_chart_url(args.chart, args.category, args.region))
except Exception as e:
print(f"ERROR: failed to fetch Play chart: {e}", file=sys.stderr)
sys.exit(1)
print(json.dumps(parse(html, chart_label, args.region, args.limit), indent=2))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Resolve an app name to a Google Play link + stats, and measure saturation.
Subcommands:
resolve "<name>" -> top Play apps hit: package, play_url, rating, installs,
last_updated, developer (1-2 links per candidate upstream).
count "<query>" -> saturation: how many distinct apps + avg rating on the
first Play search results page.
Play search (play.google.com/store/search?q=...&c=apps&hl=en&gl=US) is
server-rendered enough to grep details?id= links + star aria-labels. The details
page carries rating/installs/updated in embedded ld+json (+ light regex), the
same structure clone-app's scrape-play-store.py parses. Stdlib-only, no pip.
"""
import sys, json, re, argparse, urllib.request, urllib.parse, ssl, subprocess, shutil
UA = "Mozilla/5.0"
ID_RE = re.compile(r'/store/apps/details\?id=([\w.]+)')
STAR_RE = re.compile(r'Rated\s+([\d.]+)\s+stars')
def _http_get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
except urllib.error.URLError as e:
if not isinstance(e.reason, ssl.SSLError):
raise
if not shutil.which("curl"):
raise
out = subprocess.run(["curl", "-sL", "--fail", "-A", UA, url],
capture_output=True, timeout=60)
if out.returncode != 0:
raise
return out.stdout.decode("utf-8", "replace")
def _search_html(query, search_file):
if search_file:
with open(search_file, encoding="utf-8") as f:
return f.read()
q = urllib.parse.quote(query)
return _http_get(f"https://play.google.com/store/search?q={q}&c=apps&hl=en&gl=US")
def _details_html(package, details_file):
if details_file:
with open(details_file, encoding="utf-8") as f:
return f.read()
return _http_get(
f"https://play.google.com/store/apps/details?id={package}&hl=en&gl=US")
def parse_details(html, package):
out = {"name": None, "developer": None, "rating": None,
"installs": None, "last_updated": None}
for m in re.finditer(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',
html, re.DOTALL):
try:
data = json.loads(m.group(1).strip())
except Exception:
continue
if isinstance(data, dict) and data.get("@type") == "SoftwareApplication":
out["name"] = data.get("name")
auth = data.get("author")
if isinstance(auth, dict):
out["developer"] = auth.get("name")
ar = data.get("aggregateRating") or {}
if ar.get("ratingValue") is not None:
try: out["rating"] = float(ar["ratingValue"])
except Exception: pass
break
m = re.search(r'([\d.,]+[KMB]?\+)\s*</[^>]+>\s*<[^>]*>\s*Downloads', html)
if not m:
m = re.search(r'([\d.,]+[KMB]?\+)\s*<span>\s*Downloads', html)
if m:
out["installs"] = m.group(1)
m = re.search(r'Updated on\s*</[^>]+>\s*<[^>]*>([^<]+)</[^>]+>', html)
if not m:
m = re.search(r'Updated on\s*<span>([^<]+)</span>', html)
if m:
out["last_updated"] = m.group(1).strip()
return out
def cmd_resolve(args):
html = _search_html(args.query, args.search_file)
m = ID_RE.search(html)
res = {"query": args.query, "name": None, "package": None, "play_url": None,
"rating": None, "installs": None, "last_updated": None, "developer": None}
if not m:
print(json.dumps(res, indent=2))
return
package = m.group(1)
res["package"] = package
res["play_url"] = f"https://play.google.com/store/apps/details?id={package}"
details = _details_html(package, args.details_file)
res.update(parse_details(details, package))
print(json.dumps(res, indent=2))
def cmd_count(args):
html = _search_html(args.query, args.search_file)
packages = []
for pkg in ID_RE.findall(html):
if pkg not in packages:
packages.append(pkg)
ratings = [float(x) for x in STAR_RE.findall(html)]
avg = round(sum(ratings) / len(ratings), 2) if ratings else None
print(json.dumps({"query": args.query, "app_count": len(packages),
"avg_rating": avg, "top_packages": packages}, indent=2))
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
pr = sub.add_parser("resolve")
pr.add_argument("query")
pr.add_argument("--search-file")
pr.add_argument("--details-file")
pc = sub.add_parser("count")
pc.add_argument("query")
pc.add_argument("--search-file")
args = ap.parse_args()
try:
{"resolve": cmd_resolve, "count": cmd_count}[args.cmd](args)
except Exception as e:
print(f"ERROR: play {args.cmd} failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Best-effort Google Trends interest signal. NEVER hard-fails.
Google Trends has no API key but the unofficial endpoint needs a token dance
and is fragile under stdlib-only. So: try the live token+timeline flow; on ANY
failure print {"ok": false, "fallback": "websearch"} and exit 0, signalling the
skill to fall back to WebSearch for the same momentum signal. The JSON parser is
unit-tested offline via --json-file. Stdlib-only, no pip.
"""
import sys, json, argparse, urllib.request, urllib.parse, ssl, subprocess, shutil
UA = "Mozilla/5.0"
def _http_get(url, headers=None):
req = urllib.request.Request(url, headers={"User-Agent": UA, **(headers or {})})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
except urllib.error.URLError as e:
if not isinstance(e.reason, ssl.SSLError):
raise
if not shutil.which("curl"):
raise
out = subprocess.run(["curl", "-sL", "--fail", "-A", UA, url],
capture_output=True, timeout=60)
if out.returncode != 0:
raise
return out.stdout.decode("utf-8", "replace")
def parse_timeline(raw):
"""raw = the timeline JSON (Google prefixes live responses with )]}', —
callers strip it first). Returns the computed interest summary dict."""
data = json.loads(raw)
points = data["default"]["timelineData"]
values = [int(p["value"][0]) for p in points if p.get("value")]
if not values:
raise ValueError("no timeline values")
avg = round(sum(values) / len(values), 2)
first, latest = values[0], values[-1]
pct = round((latest - first) / first * 100, 2) if first else None
return {"ok": True, "avg_interest": avg, "latest_interest": latest,
"trend_pct": pct, "points": len(values)}
def fetch_live(term):
"""Token dance: explore -> widget token -> multiline timeline."""
base = "https://trends.google.com/trends/api"
comp = urllib.parse.quote(json.dumps(
{"comparisonItem": [{"keyword": term, "geo": "", "time": "today 3-m"}],
"category": 0, "property": ""}))
explore = _http_get(f"{base}/explore?hl=en-US&tz=0&req={comp}")
widgets = json.loads(explore.lstrip(")]}',\n"))
w = next(x for x in widgets["widgets"] if x.get("id") == "TIMESERIES")
token = w["token"]
reqobj = urllib.parse.quote(json.dumps(w["request"]))
tl = _http_get(f"{base}/widgetdata/multiline?hl=en-US&tz=0&req={reqobj}&token={token}")
return tl.lstrip(")]}',\n")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("term")
ap.add_argument("--json-file")
args = ap.parse_args()
try:
if args.json_file:
with open(args.json_file, encoding="utf-8") as f:
raw = f.read()
else:
raw = fetch_live(args.term)
res = parse_timeline(raw)
res["term"] = args.term
print(json.dumps(res, indent=2))
except Exception:
print(json.dumps({"ok": False, "term": args.term,
"fallback": "websearch"}, indent=2))
sys.exit(0)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,9 @@
<!DOCTYPE html><html><body>
<div role="main">
<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>
<div aria-label="Rated 4.6 stars out of five stars"></div>
<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>
<div aria-label="Rated 4.2 stars out of five stars"></div>
<a href="/store/apps/details?id=com.example.habit" jslog="38003; track:click"><div class="Epkrse ">Habit Tracker</div></a>
</div>
</body></html>

View File

@ -0,0 +1,8 @@
<!DOCTYPE html><html><body>
<script type="application/ld+json" nonce="abc">
{"@type":"SoftwareApplication","name":"Habit Tracker","author":{"name":"Focus Labs"},
"applicationCategory":"Productivity","aggregateRating":{"ratingValue":"4.6","ratingCount":"120000"}}
</script>
<div>1B+</div><div>Downloads</div>
<div>Updated on</div><div>Jun 1, 2026</div>
</body></html>

View File

@ -0,0 +1,10 @@
<!DOCTYPE html><html><body>
<div role="main">
<a href="/store/apps/details?id=com.example.habit"><span>Habit Tracker</span></a>
<div aria-label="Rated 4.6 stars out of five stars"></div>
<a href="/store/apps/details?id=com.example.habit2"><span>Habit Plus</span></a>
<div aria-label="Rated 4.1 stars out of five stars"></div>
<a href="/store/apps/details?id=com.example.habit3"><span>Daily Habits</span></a>
<div aria-label="Rated 3.9 stars out of five stars"></div>
</div>
</body></html>

View File

@ -0,0 +1,5 @@
{"default":{"timelineData":[
{"time":"1","value":[50]},
{"time":"2","value":[60]},
{"time":"3","value":[100]}
]}}

View File

@ -10,11 +10,11 @@ must_exist "$P/.claude-plugin/plugin.json"
must_exist "$P/commands/market-research.md"
must_exist "$P/README.md"
must_exist "$P/skills/market-research/SKILL.md"
for s in fetch-charts.py history.py; do
for s in fetch-charts.py history.py fetch-play-charts.py play.py trends.py; do
must_exist "$P/skills/market-research/scripts/$s"
must_exec "$P/skills/market-research/scripts/$s"
done
for r in research-angles scoring-guide report-template; do
for r in research-angles scoring-guide report-template numeric-sources; do
must_exist "$P/skills/market-research/references/$r.md"
done

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import json, subprocess, sys, os
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "..", "skills", "market-research", "scripts", "fetch-play-charts.py")
FIXTURE = os.path.join(HERE, "fixtures", "play-chart.html")
def run():
out = subprocess.check_output(
[sys.executable, SCRIPT, "top", "--region", "US", "--html-file", FIXTURE])
return json.loads(out)
def main():
d = run()
fails = []
def check(name, cond):
print(f"{'PASS' if cond else 'FAIL'}: {name}")
if not cond: fails.append(name)
check("source", d["source"] == "google-play")
check("chart", d["chart"] == "top")
check("region", d["region"] == "US")
check("count (dedup app1 repeat)", d["count"] == 2)
e0 = d["entries"][0]
check("rank 1", e0["rank"] == 1)
check("name", e0["name"] == "Habit Tracker")
check("package", e0["package"] == "com.example.habit")
check("rating", e0["rating"] == 4.6)
for k in ["rank", "name", "package", "rating"]:
check(f"key present: {k}", k in e0)
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)
main()

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import json, subprocess, sys, os
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "..", "skills", "market-research", "scripts", "play.py")
SEARCH = os.path.join(HERE, "fixtures", "play-search.html")
DETAILS = os.path.join(HERE, "fixtures", "play-details.html")
def run(*args):
return json.loads(subprocess.check_output([sys.executable, SCRIPT, *args]))
def main():
fails = []
def check(name, cond):
print(f"{'PASS' if cond else 'FAIL'}: {name}")
if not cond: fails.append(name)
r = run("resolve", "Habit Tracker", "--search-file", SEARCH, "--details-file", DETAILS)
check("package", r["package"] == "com.example.habit")
check("play_url", r["play_url"] == "https://play.google.com/store/apps/details?id=com.example.habit")
check("name", r["name"] == "Habit Tracker")
check("rating", r["rating"] == 4.6)
check("installs", r["installs"] == "1B+")
check("last_updated", r["last_updated"] == "Jun 1, 2026")
check("developer", r["developer"] == "Focus Labs")
c = run("count", "habit tracker", "--search-file", SEARCH)
check("app_count", c["app_count"] == 3)
check("avg_rating", c["avg_rating"] == 4.2) # mean(4.6,4.1,3.9)=4.2
check("top_packages has habit", "com.example.habit" in c["top_packages"])
check("top_packages distinct", len(c["top_packages"]) == len(set(c["top_packages"])))
sys.exit(1 if fails else 0)
main()

View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
import json, subprocess, sys, os
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "..", "skills", "market-research", "scripts", "trends.py")
FIXTURE = os.path.join(HERE, "fixtures", "trends-sample.json")
def run(*args):
# exit 0 always (never hard-fails); capture stdout
out = subprocess.run([sys.executable, SCRIPT, *args],
capture_output=True, text=True)
return out.returncode, json.loads(out.stdout)
def main():
fails = []
def check(name, cond):
print(f"{'PASS' if cond else 'FAIL'}: {name}")
if not cond: fails.append(name)
rc, d = run("habit tracker", "--json-file", FIXTURE)
check("exit 0", rc == 0)
check("ok", d["ok"] is True)
check("points", d["points"] == 3)
check("latest_interest", d["latest_interest"] == 100)
check("avg_interest", d["avg_interest"] == 70.0) # mean(50,60,100)=70
check("trend_pct", d["trend_pct"] == 100.0) # (100-50)/50*100
# bad fixture -> graceful fallback, still exit 0
rc2, d2 = run("x", "--json-file", os.path.join(HERE, "fixtures", "play-chart.html"))
check("fallback exit 0", rc2 == 0)
check("fallback ok false", d2["ok"] is False)
check("fallback flag", d2["fallback"] == "websearch")
sys.exit(1 if fails else 0)
main()