""" App store scraper — Google Play + Apple App Store. For a list of apps (apps.json) it collects, into ONE combined CSV: - core metadata (title, developer, category, price, version, LAST UPDATED date) - the final rating (average) + total number of ratings Input : apps.json (Play app IDs + App Store numeric IDs) Output: output/app_data.csv (one row per app x country, both stores together) SAFETY (never get IP-blocked): - A shared circuit breaker (netutil.Throttle) pauses EVERY worker the moment a rate-limit (HTTP 429/403) or a run of errors appears, then relaxes on success. - 404/not-found is skipped (no retry); rate limits are waited out; timeouts/5xx get a short backoff. - Every row is flushed to disk immediately, so a crash/block loses nothing. RESUME: - On restart it reads the existing CSV and SKIPS apps already scraped, so you just re-run after an interruption. SPEED / SCALE: --workers N concurrent requests (network-wait tasks -> ~Nx throughput) --shard i with --shards N, this machine only does jobs i, i+N, i+2N, ... --shards N run shard 0 on PC #1 and shard 1 on PC #2 (each its own IP), then combine with merge_csv.py. Run: python scraper.py python scraper.py --workers 12 python scraper.py --shard 0 --shards 2 --workers 10 # PC #1 python scraper.py --shard 1 --shards 2 --workers 10 # PC #2 python merge_csv.py """ import argparse import csv import json import logging import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from pathlib import Path import requests from google_play_scraper import app as gp_app from netutil import Throttle, is_not_found, is_rate_limited logging.basicConfig( level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S" ) log = logging.getLogger("scraper") ITUNES_LOOKUP = "https://itunes.apple.com/lookup" # Per-app retry budgets. Rate limits are waited out generously; transient # network errors get a few quick retries before we give up on that one app. MAX_RATE_LIMIT_WAITS = 12 MAX_TRANSIENT_RETRIES = 4 CSV_FIELDS = [ "store", "country", "app_id", "title", "developer", "category", "price", "currency", "free", "avg_rating", "total_ratings", "text_review_count", "last_updated", "version", "url", ] def epoch_to_date(ts): try: return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%d") except (TypeError, ValueError, OSError): return None # --- Google Play ------------------------------------------------------------ def play_row(app_id: str, lang: str, country: str) -> dict: d = gp_app(app_id, lang=lang, country=country) return { "store": "google_play", "country": country, "app_id": app_id, "title": d.get("title"), "developer": d.get("developer"), "category": d.get("genre"), "price": d.get("price"), "currency": d.get("currency"), "free": d.get("free"), "avg_rating": d.get("score"), "total_ratings": d.get("ratings"), "text_review_count": d.get("reviews"), "last_updated": d.get("lastUpdatedOn") or epoch_to_date(d.get("updated")), "version": d.get("version"), "url": d.get("url"), } # --- Apple App Store -------------------------------------------------------- def appstore_metadata(app_id: int, country: str) -> dict | None: resp = requests.get( ITUNES_LOOKUP, params={"id": app_id, "country": country}, timeout=20 ) resp.raise_for_status() # turns 429/403/5xx into exceptions we classify results = resp.json().get("results", []) return results[0] if results else None def appstore_row(app_id: int, country: str) -> dict | None: meta = appstore_metadata(app_id, country) if not meta: return None price = meta.get("price") return { "store": "app_store", "country": country, "app_id": meta.get("trackId"), "title": meta.get("trackName"), "developer": meta.get("sellerName"), "category": meta.get("primaryGenreName"), "price": price, "currency": meta.get("currency"), "free": (price == 0.0) if price is not None else None, "avg_rating": meta.get("averageUserRating"), "total_ratings": meta.get("userRatingCount"), "text_review_count": None, "last_updated": (meta.get("currentVersionReleaseDate") or "")[:10] or None, "version": meta.get("version"), "url": meta.get("trackViewUrl"), } # --- thread-safe, crash-safe CSV sink -------------------------------------- class CsvSink: """Append rows one at a time, flushing each so progress survives a crash.""" def __init__(self, path: Path, fields: list): self.fields = fields self.lock = threading.Lock() write_header = not path.exists() or path.stat().st_size == 0 self.fh = path.open("a", newline="", encoding="utf-8-sig") self.writer = csv.DictWriter(self.fh, fieldnames=fields, extrasaction="ignore") if write_header: self.writer.writeheader() self.fh.flush() def write(self, row: dict) -> None: with self.lock: self.writer.writerow({k: row.get(k) for k in self.fields}) self.fh.flush() def close(self) -> None: self.fh.close() # --- orchestration ---------------------------------------------------------- def job_key(store: str, app_id, country: str) -> tuple: return (store, country, str(app_id)) def build_jobs(cfg: dict, countries: list) -> list: """Flat (store, app_id, country) list so sharding balances stores+countries.""" jobs = [] for country in countries: for entry in cfg.get("google_play", []): jobs.append(("google_play", entry["app_id"], country)) for entry in cfg.get("app_store", []): jobs.append(("app_store", entry["app_id"], country)) return jobs def load_done(path: Path) -> set: """Keys already present in the output CSV (for resume).""" done = set() if path.exists() and path.stat().st_size > 0: with path.open(encoding="utf-8-sig", newline="") as fh: for row in csv.DictReader(fh): done.add((row.get("store"), row.get("country"), str(row.get("app_id")))) return done def fetch_one(job, lang: str, delay: float, throttle: Throttle): """ Scrape one app. Returns a row dict, or None if the app has no data (404). Raises only after exhausting retries. Rate limits trip the shared breaker so ALL workers slow down together. """ store, app_id, country = job rate_waits = 0 transient = 0 while True: throttle.wait() # honor any global cooldown before requesting if delay: time.sleep(delay) # steady per-thread pacing try: if store == "google_play": row = play_row(app_id, lang, country) else: row = appstore_row(app_id, country) throttle.record_success() return row except Exception as exc: if is_not_found(exc): throttle.record_success() # a 404 is a healthy answer return None rate = is_rate_limited(exc) cd = throttle.record_fail(rate) if cd: log.warning("THROTTLING: pausing all workers ~%.0fs (%s)", cd, "rate-limit" if rate else "error burst") if rate: rate_waits += 1 if rate_waits > MAX_RATE_LIMIT_WAITS: raise RuntimeError(f"persistent rate-limit on {app_id}") from exc continue # loop; throttle.wait() enforces the cooldown transient += 1 if transient > MAX_TRANSIENT_RETRIES: raise time.sleep(min(2 ** transient, 20)) def run(config_path: Path, out_dir: Path, countries_override: list | None, workers: int, shard: int, shards: int, resume: bool) -> None: if not config_path.exists(): log.error("Config %s not found — run discover.py first to create it.", config_path) return cfg = json.loads(config_path.read_text(encoding="utf-8")) s = cfg.get("settings", {}) lang = s.get("lang", "en") delay = float(s.get("delay_seconds", 1.0)) countries = countries_override or s.get("countries") or [s.get("country", "us")] jobs = build_jobs(cfg, countries) if shards > 1: jobs = jobs[shard::shards] out_dir.mkdir(parents=True, exist_ok=True) name = "app_data.csv" if shards == 1 else f"app_data_shard{shard}of{shards}.csv" out_path = out_dir / name skipped = 0 if resume: done = load_done(out_path) before = len(jobs) jobs = [j for j in jobs if job_key(*j) not in done] skipped = before - len(jobs) total = len(jobs) throttle = Throttle() sink = CsvSink(out_path, CSV_FIELDS) log.info( "shard %d/%d | %d jobs (%d already done, skipped) | %d workers | %s", shard, shards, total, skipped, workers, ", ".join(countries), ) if total == 0: log.info("Nothing to do. -> %s", out_path) sink.close() return stats = {"ok": 0, "empty": 0, "failed": 0} done_n = 0 executor = ThreadPoolExecutor(max_workers=workers) try: futures = { executor.submit(fetch_one, job, lang, delay, throttle): job for job in jobs } for fut in as_completed(futures): job = futures[fut] try: row = fut.result() if row: sink.write(row) stats["ok"] += 1 else: stats["empty"] += 1 except Exception as exc: stats["failed"] += 1 log.error("FAILED %s -> %s", job, exc) done_n += 1 if done_n % 50 == 0 or done_n == total: log.info("progress %d/%d (ok=%d empty=%d failed=%d)", done_n, total, stats["ok"], stats["empty"], stats["failed"]) except KeyboardInterrupt: log.warning("Interrupted — saving progress and stopping. " "Re-run to resume from where you left off.") executor.shutdown(wait=False, cancel_futures=True) finally: sink.close() log.info("DONE ok=%d empty=%d failed=%d -> %s", stats["ok"], stats["empty"], stats["failed"], out_path) def main() -> None: parser = argparse.ArgumentParser(description="Play Store + App Store scraper") parser.add_argument("--config", default="apps.json", help="path to apps.json") parser.add_argument("--out", default="output", help="output directory") parser.add_argument("--countries", default=None, help="comma-separated, e.g. us,gb,in (overrides settings)") parser.add_argument("--workers", type=int, default=10, help="concurrent requests (network-wait, so go high)") parser.add_argument("--shard", type=int, default=0, help="this machine's slice index (0-based)") parser.add_argument("--shards", type=int, default=1, help="total number of machines splitting the work") parser.add_argument("--no-resume", action="store_true", help="ignore existing CSV and scrape everything again") args = parser.parse_args() if not 0 <= args.shard < args.shards: parser.error("--shard must be between 0 and --shards-1") countries = ( [c.strip() for c in args.countries.split(",") if c.strip()] if args.countries else None ) run(Path(args.config), Path(args.out), countries, args.workers, args.shard, args.shards, resume=not args.no_resume) if __name__ == "__main__": main()