Files
App_Scraper/ARCHITECTURE.html
dhruv.godvani 03bf0fc923 Initial commit: App Store + Google Play scraper
- discover.py: find app IDs (Play search, iTunes Search API, RSS charts) -> apps.json
- scraper.py: fetch metadata + ratings per app/country, threaded, shardable, resumable
- netutil.py: shared retry/backoff + adaptive circuit breaker (rate-limit safe)
- merge_csv.py: combine sharded outputs from multiple machines
- ARCHITECTURE.html: full architecture & flow documentation
- english_words.txt: deep search-term wordlist

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:54:18 +05:30

542 lines
30 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App Store Scraper — Architecture &amp; Flow</title>
<style>
:root{
--bg:#0d1117; --panel:#161b22; --panel2:#1c2330; --border:#30363d;
--text:#e6edf3; --muted:#9aa7b4; --accent:#58a6ff; --green:#3fb950;
--amber:#d29922; --red:#f85149; --purple:#bc8cff; --cyan:#39c5cf;
--code:#0b1020;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0; background:var(--bg); color:var(--text);
font-family:"Segoe UI",system-ui,Roboto,Helvetica,Arial,sans-serif;
line-height:1.6; font-size:16px;
}
.wrap{max-width:1080px; margin:0 auto; padding:0 24px 120px}
header.hero{
background:linear-gradient(135deg,#11203b 0%,#0d1117 60%);
border-bottom:1px solid var(--border); padding:54px 24px 40px; text-align:center;
}
header.hero h1{margin:0 0 8px; font-size:2.3rem; letter-spacing:.3px}
header.hero p{margin:4px 0; color:var(--muted)}
.pill{display:inline-block; background:var(--panel2); border:1px solid var(--border);
color:var(--cyan); border-radius:999px; padding:4px 14px; font-size:.8rem; margin-top:14px}
h2{font-size:1.55rem; margin:54px 0 14px; padding-bottom:8px; border-bottom:2px solid var(--border)}
h2 .num{color:var(--accent); font-variant-numeric:tabular-nums; margin-right:10px}
h3{font-size:1.15rem; margin:30px 0 10px; color:var(--cyan)}
p{margin:10px 0}
a{color:var(--accent); text-decoration:none}
a:hover{text-decoration:underline}
code{background:var(--code); border:1px solid var(--border); padding:1px 6px;
border-radius:5px; font-family:"Cascadia Code",Consolas,monospace; font-size:.86em; color:#ffd9a0}
pre{background:var(--code); border:1px solid var(--border); border-radius:10px;
padding:16px 18px; overflow:auto; font-family:"Cascadia Code",Consolas,monospace;
font-size:.85rem; line-height:1.5}
pre code{background:none; border:none; padding:0; color:#c9d6e3}
.muted{color:var(--muted)}
/* TOC */
nav.toc{background:var(--panel); border:1px solid var(--border); border-radius:12px;
padding:18px 22px; margin:34px 0}
nav.toc h4{margin:0 0 10px; color:var(--muted); font-weight:600; text-transform:uppercase; font-size:.78rem; letter-spacing:1px}
nav.toc ol{margin:0; padding-left:20px; columns:2; column-gap:40px}
nav.toc li{margin:5px 0}
/* cards / panels */
.panel{background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:18px 22px; margin:18px 0}
.grid{display:grid; gap:14px}
.grid.c2{grid-template-columns:1fr 1fr}
.grid.c3{grid-template-columns:repeat(3,1fr)}
@media(max-width:760px){.grid.c2,.grid.c3{grid-template-columns:1fr} nav.toc ol{columns:1}}
/* tables */
table{width:100%; border-collapse:collapse; margin:16px 0; font-size:.92rem}
th,td{border:1px solid var(--border); padding:9px 12px; text-align:left; vertical-align:top}
th{background:var(--panel2); color:var(--text); font-weight:600}
tr:nth-child(even) td{background:rgba(255,255,255,.02)}
/* flow diagram boxes */
.flow{display:flex; align-items:center; flex-wrap:wrap; gap:10px; justify-content:center; margin:24px 0}
.node{background:var(--panel2); border:1px solid var(--border); border-radius:10px;
padding:12px 16px; text-align:center; min-width:130px}
.node .t{font-weight:700} .node .s{font-size:.78rem; color:var(--muted)}
.node.file{border-color:var(--green)} .node.file .t{color:var(--green)}
.node.proc{border-color:var(--accent)} .node.proc .t{color:var(--accent)}
.node.out{border-color:var(--purple)} .node.out .t{color:var(--purple)}
.arrow{color:var(--muted); font-size:1.5rem; font-weight:bold}
.vthread{display:flex; flex-direction:column; align-items:center; gap:6px}
/* badges */
.badge{display:inline-block; border-radius:6px; padding:2px 8px; font-size:.75rem; font-weight:600; border:1px solid}
.b-green{color:var(--green); border-color:var(--green); background:rgba(63,185,80,.08)}
.b-amber{color:var(--amber); border-color:var(--amber); background:rgba(210,153,34,.08)}
.b-red{color:var(--red); border-color:var(--red); background:rgba(248,81,73,.08)}
.b-blue{color:var(--accent); border-color:var(--accent); background:rgba(88,166,255,.08)}
/* callouts */
.call{border-left:4px solid var(--accent); background:var(--panel); border-radius:0 10px 10px 0; padding:12px 18px; margin:16px 0}
.call.warn{border-left-color:var(--amber)}
.call.danger{border-left-color:var(--red)}
.call.ok{border-left-color:var(--green)}
.call .h{font-weight:700; margin-bottom:4px}
/* step list */
.steps{counter-reset:step; list-style:none; padding:0}
.steps li{position:relative; padding:14px 16px 14px 58px; margin:12px 0; background:var(--panel); border:1px solid var(--border); border-radius:10px}
.steps li::before{counter-increment:step; content:counter(step);
position:absolute; left:16px; top:14px; width:28px; height:28px; border-radius:50%;
background:var(--accent); color:#06121f; font-weight:800; display:flex; align-items:center; justify-content:center}
/* kpi */
.kpi{display:flex; gap:14px; flex-wrap:wrap; margin:18px 0}
.kpi .k{flex:1; min-width:150px; background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px; text-align:center}
.kpi .k .v{font-size:1.7rem; font-weight:800; color:var(--cyan)}
.kpi .k .l{font-size:.8rem; color:var(--muted)}
footer{color:var(--muted); text-align:center; padding:30px; border-top:1px solid var(--border); margin-top:60px; font-size:.85rem}
.legend{display:flex; gap:18px; flex-wrap:wrap; font-size:.82rem; color:var(--muted); justify-content:center; margin-top:10px}
.legend span{display:flex; align-items:center; gap:6px}
.dot{width:12px; height:12px; border-radius:3px; display:inline-block}
</style>
</head>
<body>
<header class="hero">
<h1>📱 App Store Scraper</h1>
<p>Architecture &amp; Data-Flow Reference</p>
<p class="muted">Google Play + Apple App Store · discovery → scraping → combined CSV · scaled across 2 PCs</p>
<span class="pill">Self-contained document — open in any browser</span>
</header>
<div class="wrap">
<nav class="toc">
<h4>Contents</h4>
<ol>
<li><a href="#overview">Big picture</a></li>
<li><a href="#pipeline">The pipeline</a></li>
<li><a href="#files">Files in the project</a></li>
<li><a href="#discover">Phase 1 — Find apps (discover.py)</a></li>
<li><a href="#appsjson">apps.json (the app list)</a></li>
<li><a href="#scrape">Phase 2 — Get data (scraper.py)</a></li>
<li><a href="#safety">Safety layer (netutil.py)</a></li>
<li><a href="#concurrency">Concurrency (threads)</a></li>
<li><a href="#twopc">Two-PC architecture</a></li>
<li><a href="#resume">Resume &amp; crash safety</a></li>
<li><a href="#config">Configuration &amp; flags</a></li>
<li><a href="#cheatsheet">Command cheat sheet</a></li>
<li><a href="#limits">Limits &amp; ground truth</a></li>
</ol>
</nav>
<!-- ============================================================= -->
<h2 id="overview"><span class="num">0</span>Big picture</h2>
<p>
There is <strong>no "all apps" endpoint</strong> in either store. So the system works in
<strong>two phases</strong>: first it <em>discovers</em> a big list of app IDs, then it
<em>scrapes</em> the details for every app on that list into one CSV. Everything is free —
no API keys, no paid services.
</p>
<div class="kpi">
<div class="k"><div class="v">2</div><div class="l">stores covered<br>(Play + App Store)</div></div>
<div class="k"><div class="v">~30</div><div class="l">apps / Google Play query<br>(hard server cap)</div></div>
<div class="k"><div class="v">~180</div><div class="l">apps / iTunes search term<br>(the volume engine)</div></div>
<div class="k"><div class="v">100k+</div><div class="l">target catalog size<br>(App Store driven)</div></div>
</div>
<div class="call ok">
<div class="h">Two verbs, two phases</div>
<strong>FIND apps</strong><code>discover.py</code> writes <code>apps.json</code>.
<strong>GET data</strong><code>scraper.py</code> reads <code>apps.json</code> and writes <code>output/app_data.csv</code>.
</div>
<!-- ============================================================= -->
<h2 id="pipeline"><span class="num">1</span>The pipeline</h2>
<p>End-to-end, data flows left to right. The same flow runs on each PC; only the final merge is shared.</p>
<div class="flow">
<div class="node proc"><div class="t">discover.py</div><div class="s">FIND apps</div></div>
<span class="arrow"></span>
<div class="node file"><div class="t">apps.json</div><div class="s">list of app IDs</div></div>
<span class="arrow"></span>
<div class="node proc"><div class="t">scraper.py</div><div class="s">GET data</div></div>
<span class="arrow"></span>
<div class="node out"><div class="t">app_data.csv</div><div class="s">one row / app / country</div></div>
<span class="arrow"></span>
<div class="node proc"><div class="t">merge_csv.py</div><div class="s">combine 2 PCs</div></div>
</div>
<div class="legend">
<span><span class="dot" style="background:var(--accent)"></span>process (script)</span>
<span><span class="dot" style="background:var(--green)"></span>data file</span>
<span><span class="dot" style="background:var(--purple)"></span>output</span>
</div>
<table>
<tr><th>Step</th><th>Script</th><th>Reads</th><th>Writes</th><th>Purpose</th></tr>
<tr><td>Find</td><td><code>discover.py</code></td><td>search APIs + charts</td><td><code>apps.json</code></td><td>build a big list of app IDs</td></tr>
<tr><td>Get</td><td><code>scraper.py</code></td><td><code>apps.json</code></td><td><code>output/app_data.csv</code></td><td>fetch metadata + ratings for each app</td></tr>
<tr><td>Merge</td><td><code>merge_csv.py</code></td><td>shard CSVs</td><td><code>output/app_data.csv</code></td><td>combine the two PCs' results, de-duped</td></tr>
</table>
<!-- ============================================================= -->
<h2 id="files"><span class="num">2</span>Files in the project</h2>
<table>
<tr><th>File</th><th>Role</th></tr>
<tr><td><code>discover.py</code></td><td><span class="badge b-blue">Phase 1</span> Finds app IDs and fills <code>apps.json</code></td></tr>
<tr><td><code>scraper.py</code></td><td><span class="badge b-blue">Phase 2</span> Fetches per-app data into the CSV (threaded, shardable, resumable)</td></tr>
<tr><td><code>netutil.py</code></td><td><span class="badge b-green">Shared safety</span> Error classification + adaptive throttle / circuit breaker</td></tr>
<tr><td><code>merge_csv.py</code></td><td><span class="badge b-green">Helper</span> Merges the two PCs' shard CSVs into one</td></tr>
<tr><td><code>apps.json</code></td><td><span class="badge b-amber">Data</span> Settings + the list of app IDs (the contract between the two phases)</td></tr>
<tr><td><code>english_words.txt</code></td><td><span class="badge b-amber">Data</span> ~9,868 common words used as deep search terms (path to 100k)</td></tr>
<tr><td><code>requirements.txt</code></td><td>Dependencies: <code>google-play-scraper</code>, <code>requests</code></td></tr>
<tr><td><code>output/app_data.csv</code></td><td>The final dataset</td></tr>
</table>
<!-- ============================================================= -->
<h2 id="discover"><span class="num">3</span>Phase 1 — Find apps <span class="muted" style="font-size:1rem">(discover.py)</span></h2>
<p>
Because no full catalog exists, discovery <strong>queries many sources with many terms</strong>
and keeps only the unique app IDs. Three sources feed one de-duplicated set per store.
</p>
<h3>The three discovery sources</h3>
<table>
<tr><th>Source</th><th>Store</th><th>How</th><th>Yield / call</th><th>Role</th></tr>
<tr><td>Keyword search</td><td>Google Play</td><td><code>google_play_scraper.search(term)</code></td><td><span class="badge b-amber">~30</span> (hard cap)</td><td>breadth via many terms</td></tr>
<tr><td>iTunes Search API</td><td>App Store</td><td><code>itunes.apple.com/search?term=</code></td><td><span class="badge b-green">~180</span></td><td><strong>main volume engine</strong></td></tr>
<tr><td>RSS top charts</td><td>App Store</td><td><code>.../rss/{feed}/genre={id}</code> (free/grossing/paid × 23 genres)</td><td>up to 200</td><td>popular apps, fast</td></tr>
</table>
<h3>How the search terms are built</h3>
<p>The function <code>build_terms()</code> assembles the query list from up to four tiers:</p>
<div class="flow">
<div class="node"><div class="t">~155 curated</div><div class="s">topic keywords</div></div>
<span class="arrow">+</span>
<div class="node"><div class="t">az</div><div class="s">26 letters</div></div>
<span class="arrow">+</span>
<div class="node"><div class="t">aazz</div><div class="s"><code>--deep</code> (676)</div></div>
<span class="arrow">+</span>
<div class="node"><div class="t">wordlist</div><div class="s"><code>--terms-file</code> (~9,868)</div></div>
<span class="arrow">=</span>
<div class="node out"><div class="t">~9,943 terms</div><div class="s">de-duped, ordered</div></div>
</div>
<p class="muted">
More terms = more apps. The wordlist (<code>--terms-file english_words.txt</code>) is the single
biggest lever toward 100k. <code>--deeper</code> (aaazzz, 17,576 terms) exists but is very slow.
</p>
<h3>Discovery flow (per store)</h3>
<ol class="steps">
<li>Build the term list and resolve the country list (from <code>--countries</code> or settings).</li>
<li>For each country, loop every term; call the source through the safety layer (retry + backoff).</li>
<li>Collect each result's <strong>app ID</strong> into a <code>seen</code> dictionary — duplicates are ignored automatically.</li>
<li>Print live progress: <code>total unique = N / target</code>.</li>
<li>Stop as soon as <code>--target</code> is reached (or terms are exhausted).</li>
<li><strong>Merge</strong> into the existing <code>apps.json</code> (keeps your settings + anything already listed), then write the file.</li>
</ol>
<div class="call">
<div class="h">Why Google Play can't reach 100k</div>
Play search returns only <strong>~30 apps per query no matter what</strong> — that's Google's cap, not the
code's. So Play contributes ~1530k; the App Store's ~180/term search is what carries you to 100k.
</div>
<div class="call ok">
<div class="h">De-duplication is automatic and idempotent</div>
Discovery keys on app ID, and <code>merge()</code> drops duplicates against what's already in
<code>apps.json</code>. Re-running only <em>adds</em> new apps — it never loses or doubles existing ones.
If <code>apps.json</code> is missing, a fresh one is created automatically.
</div>
<!-- ============================================================= -->
<h2 id="appsjson"><span class="num">4</span>apps.json — the app list</h2>
<p>This file is the <strong>contract</strong> between the two phases: discovery writes it, scraping reads it.</p>
<pre><code>{
"settings": {
"country": "us", // default single country
"countries": ["us","gb","in","ca","au"], // scrape/discover across these
"lang": "en", // language (Google Play)
"review_count": 100,
"delay_seconds": 1.0 // polite pause; rate ≈ workers / delay
},
"google_play": [
{ "app_id": "com.whatsapp" } // the id= value from the Play URL (string)
],
"app_store": [
{ "app_id": 310633997, "name": "WhatsApp" } // numeric id from the App Store URL
]
}</code></pre>
<table>
<tr><th>Store</th><th>ID type</th><th>Where it comes from</th></tr>
<tr><td>Google Play</td><td>string</td><td><code>play.google.com/store/apps/details?id=<strong>com.whatsapp</strong></code></td></tr>
<tr><td>App Store</td><td>number</td><td><code>apps.apple.com/us/app/.../id<strong>310633997</strong></code></td></tr>
</table>
<!-- ============================================================= -->
<h2 id="scrape"><span class="num">5</span>Phase 2 — Get data <span class="muted" style="font-size:1rem">(scraper.py)</span></h2>
<p>
Scraping turns the ID list into real data. For <strong>every app × every country</strong> it makes one
"detail" call and writes one CSV row. Two stores, two API calls, one unified row schema.
</p>
<h3>The two detail calls</h3>
<div class="grid c2">
<div class="panel">
<h3 style="margin-top:0">Google Play</h3>
<p><code>google_play_scraper.app(app_id, lang, country)</code></p>
<p class="muted">Scrapes the public listing page → title, developer, genre, price, score, ratings count, review count, last-updated, version, URL.</p>
</div>
<div class="panel">
<h3 style="margin-top:0">Apple App Store</h3>
<p><code>itunes.apple.com/lookup?id=&amp;country=</code></p>
<p class="muted">Official lookup API → trackName, seller, genre, price, average rating, rating count, release date, version, URL.</p>
</div>
</div>
<h3>Output columns — <code>output/app_data.csv</code></h3>
<table>
<tr><th>Column</th><th>Meaning</th></tr>
<tr><td><code>store</code></td><td><code>google_play</code> or <code>app_store</code></td></tr>
<tr><td><code>country</code></td><td>which region this row was fetched for</td></tr>
<tr><td><code>app_id</code>, <code>title</code>, <code>developer</code>, <code>category</code></td><td>identity</td></tr>
<tr><td><code>price</code>, <code>currency</code>, <code>free</code></td><td>pricing</td></tr>
<tr><td><code>avg_rating</code></td><td>final average star rating</td></tr>
<tr><td><code>total_ratings</code></td><td>total number of ratings</td></tr>
<tr><td><code>text_review_count</code></td><td>number of written reviews (Play only)</td></tr>
<tr><td><code>last_updated</code></td><td>date the app was last updated</td></tr>
<tr><td><code>version</code></td><td>latest version</td></tr>
<tr><td><code>url</code></td><td>store listing link</td></tr>
</table>
<p class="muted">CSV is UTF-8 with BOM → opens cleanly in Excel. The same columns apply to both stores.</p>
<h3>How one job is processed</h3>
<div class="flow">
<div class="node">job<br><span class="s">(store, id, country)</span></div>
<span class="arrow"></span>
<div class="node proc">throttle.wait()<br><span class="s">honor cooldown</span></div>
<span class="arrow"></span>
<div class="node proc">fetch<br><span class="s">Play / App Store</span></div>
<span class="arrow"></span>
<div class="node out">row<br><span class="s">flushed to CSV</span></div>
</div>
<!-- ============================================================= -->
<h2 id="safety"><span class="num">6</span>Safety layer <span class="muted" style="font-size:1rem">(netutil.py)</span></h2>
<div class="call danger">
<div class="h">The #1 rule: slow is fine — getting the IP blocked is not.</div>
Every request (in both scripts) passes through a shared throttle. The moment a store pushes back,
<strong>all workers slow down together</strong> instead of hammering until blocked.
</div>
<h3>Error classification</h3>
<table>
<tr><th>Signal</th><th>Meaning</th><th>Reaction</th></tr>
<tr><td><span class="badge b-red">429 / 403</span> "too many requests", "quota"</td><td>rate-limited</td><td><strong>Trip circuit breaker</strong>, wait it out, retry generously</td></tr>
<tr><td><span class="badge b-amber">408 / 5xx</span>, timeout, connection</td><td>transient hiccup</td><td>short exponential backoff, a few retries</td></tr>
<tr><td><span class="badge b-green">404</span> / not-found</td><td>healthy answer</td><td>skip immediately — <em>no</em> wasted retries</td></tr>
</table>
<h3>The circuit breaker (a shared state machine)</h3>
<div class="flow">
<div class="node" style="border-color:var(--green)"><div class="t" style="color:var(--green)">RUNNING</div><div class="s">requests flow freely</div></div>
<span class="arrow"></span>
<div class="node" style="border-color:var(--red)"><div class="t" style="color:var(--red)">TRIPPED</div><div class="s">all workers pause</div></div>
<span class="arrow"></span>
<div class="node" style="border-color:var(--amber)"><div class="t" style="color:var(--amber)">COOLDOWN</div><div class="s">30→60→120…→300s</div></div>
<span class="arrow"></span>
<div class="node" style="border-color:var(--green)"><div class="t" style="color:var(--green)">RECOVER</div><div class="s">speed back up on success</div></div>
</div>
<ul>
<li><strong>Trips on</strong> a rate-limit signal <em>or</em> 5 consecutive unexplained failures (catches "stealth" blocks that don't return a clean 429).</li>
<li><strong>Cooldown doubles</strong> each time it re-trips (30s → 60 → 120 … capped at 5 min) and lingers as extra spacing.</li>
<li><strong>Recovers automatically</strong>: each success shrinks the cooldown and extra delay back toward normal.</li>
</ul>
<div class="call warn">
<div class="h">Reading the logs</div>
A line like <code>THROTTLING: pausing all workers ~60s</code> is the protection <em>working</em>, not an error.
If it fires constantly, that IP is at its limit → lower <code>--workers</code> on that machine.
</div>
<!-- ============================================================= -->
<h2 id="concurrency"><span class="num">7</span>Concurrency — why threads</h2>
<p>
These are <strong>network-wait</strong> tasks (most time is spent waiting for the server, not computing),
which is the ideal case for a <strong>thread pool</strong>. With <code>--workers N</code>, up to N requests are
in flight at once.
</p>
<div class="flow">
<div class="node file"><div class="t">job queue</div><div class="s">all apps × countries</div></div>
<span class="arrow"></span>
<div class="vthread">
<div class="node proc" style="min-width:200px">worker 1</div>
<div class="node proc" style="min-width:200px">worker 2</div>
<div class="node proc" style="min-width:200px">… worker N</div>
</div>
<span class="arrow"></span>
<div class="node out"><div class="t">CSV sink</div><div class="s">thread-safe, flush per row</div></div>
</div>
<p>
Effective request rate ≈ <code>workers ÷ delay_seconds</code>. The throttle sits above all workers,
so one rate-limit signal pauses the whole pool.
</p>
<!-- ============================================================= -->
<h2 id="twopc"><span class="num">8</span>Two-PC architecture <span class="muted" style="font-size:1rem">(2 CPUs, 2 IPs)</span></h2>
<p>
Two machines with <strong>separate IPs</strong> double throughput <em>and</em> the safe request budget
(each store rate-limits per IP). You don't split the file by hand — both PCs read the <em>same</em>
<code>apps.json</code> and each takes a different <strong>shard</strong>.
</p>
<h3>How sharding splits the work</h3>
<p>
All jobs are flattened to a list, then each PC takes <code>jobs[shard::shards]</code> — i.e. every Nth job.
This interleaves Play/App-Store/country evenly, so neither PC gets a lopsided slice.
</p>
<div class="flow">
<div class="node file" style="min-width:200px"><div class="t">apps.json</div><div class="s">jobs: 0,1,2,3,4,5,6,7…</div></div>
</div>
<div class="grid c2">
<div class="panel">
<h3 style="margin-top:0">PC #1 — IP A</h3>
<p><code>--shard 0 --shards 2</code></p>
<p>does jobs <strong>0, 2, 4, 6 …</strong></p>
<p class="muted">writes <code>app_data_shard0of2.csv</code></p>
</div>
<div class="panel">
<h3 style="margin-top:0">PC #2 — IP B</h3>
<p><code>--shard 1 --shards 2</code></p>
<p>does jobs <strong>1, 3, 5, 7 …</strong></p>
<p class="muted">writes <code>app_data_shard1of2.csv</code></p>
</div>
</div>
<div class="flow">
<div class="node out">shard0 CSV</div>
<span class="arrow"></span>
<div class="node proc"><div class="t">merge_csv.py</div><div class="s">de-duped by store+country+id</div></div>
<span class="arrow"></span>
<div class="node out">shard1 CSV</div>
</div>
<div class="flow">
<span class="arrow"></span>
</div>
<div class="flow">
<div class="node out"><div class="t">app_data.csv</div><div class="s">complete combined dataset</div></div>
</div>
<h3>The 3 steps end-to-end</h3>
<ol class="steps">
<li><strong>Discover once</strong> (either PC): run <code>discover.py</code>, then copy the resulting <code>apps.json</code> to both machines.</li>
<li><strong>Scrape in parallel</strong>: PC #1 runs shard 0, PC #2 runs shard 1 (each with its own IP and its own thread pool).</li>
<li><strong>Merge</strong>: copy both shard CSVs into one <code>output/</code> folder and run <code>merge_csv.py</code>.</li>
</ol>
<div class="kpi">
<div class="k"><div class="v">÷2</div><div class="l">work per PC<br>(sharding)</div></div>
<div class="k"><div class="v">×~10</div><div class="l">per PC<br>(thread pool)</div></div>
<div class="k"><div class="v">×2 IPs</div><div class="l">safe rate budget<br>(per-IP limits)</div></div>
<div class="k"><div class="v">28h → ~1h</div><div class="l">rough wall-clock<br>for 100k records</div></div>
</div>
<!-- ============================================================= -->
<h2 id="resume"><span class="num">9</span>Resume &amp; crash safety</h2>
<p>A 100k run can't depend on a single perfect execution. Two mechanisms make interruptions harmless:</p>
<div class="grid c2">
<div class="panel">
<h3 style="margin-top:0">💾 Flush-as-you-go</h3>
<p>Each row is written and flushed to the CSV <strong>the instant it's fetched</strong>. A crash, block,
or <kbd>Ctrl-C</kbd> never loses completed work.</p>
</div>
<div class="panel">
<h3 style="margin-top:0">⏯️ Resume on restart</h3>
<p>On startup the scraper reads the existing CSV and <strong>skips apps already done</strong>
(keyed by store + country + id). Just re-run the same command to continue.</p>
</div>
</div>
<div class="call ok">
<div class="h">Practical meaning</div>
If a PC is interrupted at app 40,000 of 50,000, re-running the exact same command picks up at 40,001.
Use <code>--no-resume</code> only if you deliberately want to re-scrape everything.
</div>
<!-- ============================================================= -->
<h2 id="config"><span class="num">10</span>Configuration &amp; flags</h2>
<h3>discover.py</h3>
<table>
<tr><th>Flag</th><th>Default</th><th>Purpose</th></tr>
<tr><td><code>--target N</code></td><td>0 (all terms)</td><td>stop each store after N unique apps</td></tr>
<tr><td><code>--terms-file PATH</code></td><td></td><td>extra search terms (best lever to 100k)</td></tr>
<tr><td><code>--deep</code></td><td>off</td><td>add 2-letter terms aazz (676)</td></tr>
<tr><td><code>--deeper</code></td><td>off</td><td>add 3-letter terms aaazzz (17,576; slow)</td></tr>
<tr><td><code>--countries a,b,c</code></td><td>settings</td><td>regions to discover across</td></tr>
<tr><td><code>--skip-play</code> / <code>--skip-appstore</code></td><td>off</td><td>run only one store</td></tr>
<tr><td><code>--search-limit</code> / <code>--chart-limit</code></td><td>200 / 200</td><td>results per iTunes term / per chart</td></tr>
<tr><td><code>--hits</code></td><td>30</td><td>Play results per term (server caps ~30)</td></tr>
<tr><td><code>--delay</code></td><td>0.5</td><td>polite pause between discovery requests</td></tr>
</table>
<h3>scraper.py</h3>
<table>
<tr><th>Flag</th><th>Default</th><th>Purpose</th></tr>
<tr><td><code>--workers N</code></td><td>10</td><td>concurrent requests (thread pool)</td></tr>
<tr><td><code>--shard i</code></td><td>0</td><td>this machine's slice index</td></tr>
<tr><td><code>--shards N</code></td><td>1</td><td>total machines splitting the work</td></tr>
<tr><td><code>--countries a,b,c</code></td><td>settings</td><td>regions to scrape (one row each)</td></tr>
<tr><td><code>--no-resume</code></td><td>off</td><td>ignore existing CSV, scrape all again</td></tr>
<tr><td><code>--out DIR</code> / <code>--config PATH</code></td><td>output / apps.json</td><td>output folder / input file</td></tr>
</table>
<!-- ============================================================= -->
<h2 id="cheatsheet"><span class="num">11</span>Command cheat sheet</h2>
<h3>① Discover ~100k apps (App Store driven)</h3>
<pre><code># fastest route to 1 lakh — App Store only, using the wordlist
python discover.py --skip-play --target 100000 --terms-file english_words.txt --countries us
# include Google Play too (adds ~1530k; can't reach 100k on its own)
python discover.py --target 100000 --terms-file english_words.txt --countries us,gb</code></pre>
<h3>② Scrape across 2 PCs</h3>
<pre><code># --- PC #1 (IP A) ---
python scraper.py --shard 0 --shards 2 --workers 10
# --- PC #2 (IP B) ---
python scraper.py --shard 1 --shards 2 --workers 10
# ...interrupted? just run the same command again — it resumes.</code></pre>
<h3>③ Merge the two PCs' output</h3>
<pre><code># copy both app_data_shard*of2.csv into one output/ folder, then:
python merge_csv.py</code></pre>
<h3>Single-PC run (no sharding)</h3>
<pre><code>python scraper.py --workers 10</code></pre>
<!-- ============================================================= -->
<h2 id="limits"><span class="num">12</span>Limits &amp; ground truth</h2>
<table>
<tr><th>Reality</th><th>Detail</th></tr>
<tr><td>No full catalog</td><td>Neither store lists "all apps"; you only get what discovery finds.</td></tr>
<tr><td>Google Play search cap</td><td><strong>~30 results per query</strong> — Play tops out ~1530k regardless of terms.</td></tr>
<tr><td>App Store is the volume</td><td>iTunes search ~180/term → this is what reaches 100k.</td></tr>
<tr><td>Per-IP rate limits</td><td>Both stores throttle per IP; 2 PCs/IPs ≈ 2× safe budget. Proxies extend further.</td></tr>
<tr><td>Per-country data</td><td>Ratings &amp; pricing differ by country → one row per app per country.</td></tr>
<tr><td>Terms of Service</td><td>Both stores restrict automated scraping. Fine for internal research at modest scale; get sign-off before commercial redistribution.</td></tr>
</table>
<div class="call warn">
<div class="h">Tuning rule of thumb</div>
Start at <code>--workers 10</code>. If you see constant <code>THROTTLING</code> lines, <strong>lower</strong> workers
(e.g. 6) — fewer cooldowns beats pushing too hard. Back up <code>apps.json</code> after a big discovery run;
it's the expensive artifact.
</div>
</div>
<footer>
App Store Scraper — Architecture &amp; Flow · Google Play + Apple App Store · self-contained reference (no internet needed to view)
</footer>
</body>
</html>