Created
June 6, 2026 13:44
-
-
Save tfriedel/f383879396f41fa09aeba1ca00dc842f to your computer and use it in GitHub Desktop.
Bulk-downloader for Slay the Spire 2 runs from spire-codex.com (concurrent listing + incremental top-up). Standalone, single-file. Deps: httpx, typer, tenacity.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Bulk-download every run from spire-codex.com. | |
| Lists all runs from ``/api/runs/list`` then fetches ``/api/runs/shared/{hash}`` | |
| for every run not already on disk, writing the JSON to | |
| ``<data>/files-spire-codex/<character>/<YYYY-MM>/<hash>.run``. | |
| The listing endpoint is the bottleneck (~10s/page, capped at 50 rows/page), so | |
| pages are fetched concurrently rather than one at a time — sequential paging of | |
| the full corpus took *hours*. Detail fetches are fast (~0.3s) and also run | |
| concurrently. Runs already on disk, and run_hashes recorded as permanently | |
| unretrievable in ``_errors.log``, are skipped without any network call so | |
| re-runs (top-ups) are cheap. | |
| For a cheap incremental top-up (e.g. from a cron), pass ``--since-newest``: the | |
| listing is newest-first, so it walks from page 1 and stops once it reaches runs | |
| already on disk instead of enumerating all ~2,300 pages. | |
| Output directory and User-Agent are read from the environment: | |
| STS2_DATA_DIR output root (default: ./data) -> writes under <root>/files-spire-codex | |
| STS2_USER_AGENT HTTP User-Agent header (default: a Chrome UA string) | |
| Dependencies (PyPI): httpx, typer, tenacity. With uv you can run it inline: | |
| uv run --with httpx --with typer --with tenacity python download_spire_codex.py | |
| Usage: | |
| python download_spire_codex.py [--concurrency 32] [--list-concurrency 32] | |
| python download_spire_codex.py --since-newest # incremental top-up | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import os | |
| import re | |
| import sys | |
| from pathlib import Path | |
| import httpx | |
| import typer | |
| from tenacity import ( | |
| retry, | |
| retry_if_exception, | |
| stop_after_attempt, | |
| wait_exponential, | |
| ) | |
| DEFAULT_UA = ( | |
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" | |
| ) | |
| def data_root() -> Path: | |
| """Output root for downloaded runs: ``<STS2_DATA_DIR>/files-spire-codex``.""" | |
| data_dir = Path(os.environ.get("STS2_DATA_DIR", "data")).resolve() | |
| return data_dir / "files-spire-codex" | |
| def user_agent() -> str: | |
| return os.environ.get("STS2_USER_AGENT", DEFAULT_UA) | |
| BASE = "https://spire-codex.com/api" | |
| # The listing endpoint reads `limit` (NOT `per_page`, which it silently ignores) | |
| # and caps it server-side at 50 rows per page. | |
| SERVER_MAX_PAGE_SIZE = 50 | |
| _SLUG = re.compile(r"[^a-z0-9]+") | |
| _HASH_RE = re.compile(r"/shared/([0-9a-f]{16})") | |
| def slug(s: str) -> str: | |
| return _SLUG.sub("-", s.lower()).strip("-") or "unknown" | |
| def target_path(root: Path, row: dict) -> Path: | |
| char = slug(row.get("character") or "unknown") | |
| submitted = row.get("submitted_at") or "" | |
| bucket = submitted[:7] if len(submitted) >= 7 else "unknown" | |
| return root / char / bucket / f"{row['run_hash']}.run" | |
| def _is_transient(exc: BaseException) -> bool: | |
| """Retry only on server/network hiccups — never on 4xx (e.g. 404s).""" | |
| if isinstance(exc, httpx.HTTPStatusError): | |
| return exc.response.status_code >= 500 or exc.response.status_code == 429 | |
| return isinstance(exc, httpx.HTTPError) | |
| @retry( | |
| reraise=True, | |
| stop=stop_after_attempt(5), | |
| wait=wait_exponential(multiplier=1, min=1, max=30), | |
| retry=retry_if_exception(_is_transient), | |
| ) | |
| async def get_json(client: httpx.AsyncClient, url: str) -> dict: | |
| r = await client.get(url) | |
| r.raise_for_status() | |
| return r.json() | |
| async def list_page(client: httpx.AsyncClient, page: int) -> dict: | |
| return await get_json(client, f"{BASE}/runs/list?page={page}&limit={SERVER_MAX_PAGE_SIZE}") | |
| def load_known_bad(root: Path) -> set[str]: | |
| """run_hashes previously logged as unretrievable (404) — skip them outright.""" | |
| log = root / "_errors.log" | |
| if not log.exists(): | |
| return set() | |
| bad: set[str] = set() | |
| for line in log.read_text().splitlines(): | |
| try: | |
| obj = json.loads(line) | |
| except ValueError: | |
| continue | |
| h = obj.get("run_hash") | |
| if not h or h == "?": | |
| m = _HASH_RE.search(obj.get("error", "")) | |
| h = m.group(1) if m else None | |
| if h: | |
| bad.add(h) | |
| return bad | |
| async def probe_first_page(client: httpx.AsyncClient, min_total: int) -> dict | None: | |
| """Fetch page 1, retrying through the API's stale-total cache flapping.""" | |
| for attempt in range(20): | |
| probe = await list_page(client, 1) | |
| if probe.get("total", 0) >= min_total: | |
| return probe | |
| typer.echo( | |
| f" stale total {probe.get('total')}/{probe.get('total_pages')} " | |
| f"on probe {attempt}; waiting for cache to refresh..." | |
| ) | |
| await asyncio.sleep(min(30, 5 + attempt * 2)) | |
| return None | |
| async def fetch_page_rows(client: httpx.AsyncClient, page: int) -> list[dict]: | |
| """One list page, retrying transient empties / errors before giving up.""" | |
| for attempt in range(4): | |
| try: | |
| payload = await list_page(client, page) | |
| except httpx.HTTPError as exc: | |
| typer.echo(f" page {page} list failed (attempt {attempt}): {exc!r}", err=True) | |
| await asyncio.sleep(1 + attempt) | |
| continue | |
| rows = payload.get("runs") or [] | |
| if rows: | |
| return rows | |
| await asyncio.sleep(1 + attempt) # empty: API may be flaking | |
| return [] | |
| async def list_all_rows( | |
| client: httpx.AsyncClient, first: dict, total_pages: int, concurrency: int | |
| ) -> list[dict]: | |
| """Fetch every list page concurrently; page 1 is reused from the probe.""" | |
| sem = asyncio.Semaphore(concurrency) | |
| done = 0 | |
| async def one(page: int) -> list[dict]: | |
| nonlocal done | |
| async with sem: | |
| if page == 1: | |
| rows = list(first.get("runs") or []) | |
| else: | |
| rows = await fetch_page_rows(client, page) | |
| done += 1 | |
| if done % 200 == 0: | |
| typer.echo(f" listed {done}/{total_pages} pages") | |
| return rows | |
| pages = await asyncio.gather(*[one(p) for p in range(1, total_pages + 1)]) | |
| return [row for page_rows in pages for row in page_rows] | |
| def _take_new(rows: list[dict], skip: set[str], seen: set[str], todo: list[dict]) -> int: | |
| """Append rows not already seen/skipped to ``todo``; return how many were new.""" | |
| new = 0 | |
| for row in rows: | |
| h = row.get("run_hash") | |
| if not h or h in seen: | |
| continue | |
| seen.add(h) | |
| if h not in skip: | |
| todo.append(row) | |
| new += 1 | |
| return new | |
| def select_new_rows(rows: list[dict], skip: set[str]) -> tuple[list[dict], int]: | |
| """Dedupe by hash and drop runs already on disk or known-unretrievable.""" | |
| seen: set[str] = set() | |
| todo: list[dict] = [] | |
| _take_new(rows, skip, seen, todo) | |
| return todo, len(seen) | |
| async def list_until_known( | |
| client: httpx.AsyncClient, first: dict, skip: set[str], total_pages: int, concurrency: int | |
| ) -> tuple[list[dict], int]: | |
| """Incremental top-up: the list is newest-first, so new runs cluster at the top. | |
| Walk pages in concurrent batches and stop after the first batch that yields no | |
| new runs — everything older is already on disk. Avoids enumerating all ~2,300 | |
| pages when only a handful of runs are new. | |
| """ | |
| sem = asyncio.Semaphore(concurrency) | |
| async def page_rows(page: int) -> list[dict]: | |
| async with sem: | |
| if page == 1: | |
| return list(first.get("runs") or []) | |
| return await fetch_page_rows(client, page) | |
| seen: set[str] = set() | |
| todo: list[dict] = [] | |
| page = 1 | |
| while page <= total_pages: | |
| batch = list(range(page, min(page + concurrency, total_pages + 1))) | |
| results = await asyncio.gather(*[page_rows(p) for p in batch]) | |
| new_in_batch = sum(_take_new(rows, skip, seen, todo) for rows in results) | |
| typer.echo( | |
| f" scanned pages {batch[0]}-{batch[-1]}: {new_in_batch} new (total {len(todo)})" | |
| ) | |
| if new_in_batch == 0: | |
| break | |
| page = batch[-1] + 1 | |
| return todo, len(seen) | |
| async def download_one( | |
| client: httpx.AsyncClient, row: dict, root: Path, sem: asyncio.Semaphore | |
| ) -> None: | |
| async with sem: | |
| data = await get_json(client, f"{BASE}/runs/shared/{row['run_hash']}") | |
| path = target_path(root, row) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| tmp = path.with_suffix(".run.tmp") | |
| tmp.write_text(json.dumps(data, indent=2)) | |
| tmp.replace(path) | |
| async def download_all( | |
| client: httpx.AsyncClient, todo: list[dict], root: Path, concurrency: int | |
| ) -> tuple[int, int, list[dict]]: | |
| sem = asyncio.Semaphore(concurrency) | |
| downloaded = errors = 0 | |
| failures: list[dict] = [] | |
| async def one(row: dict) -> None: | |
| nonlocal downloaded, errors | |
| try: | |
| await download_one(client, row, root, sem) | |
| downloaded += 1 | |
| except httpx.HTTPError as exc: | |
| errors += 1 | |
| failures.append({"run_hash": row.get("run_hash", "?"), "error": repr(exc)}) | |
| tasks = [asyncio.create_task(one(r)) for r in todo] | |
| for done, coro in enumerate(asyncio.as_completed(tasks), 1): | |
| await coro | |
| if done % 200 == 0: | |
| typer.echo(f" fetched {done}/{len(todo)} (new={downloaded} err={errors})") | |
| return downloaded, errors, failures | |
| def write_failures(root: Path, failures: list[dict]) -> None: | |
| log = root / "_errors.log" | |
| with log.open("a") as fh: | |
| for f in failures: | |
| fh.write(json.dumps(f) + "\n") | |
| typer.echo(f" wrote {len(failures)} error entries to {log}") | |
| async def main_async( | |
| concurrency: int, list_concurrency: int, max_runs: int | None, since_newest: bool | |
| ) -> None: | |
| root = data_root() | |
| root.mkdir(parents=True, exist_ok=True) | |
| max_conn = max(concurrency, list_concurrency) * 2 | |
| timeout = httpx.Timeout(90.0, connect=10.0) | |
| limits = httpx.Limits(max_connections=max_conn, max_keepalive_connections=max_conn) | |
| headers = {"User-Agent": user_agent(), "Accept": "application/json"} | |
| async with httpx.AsyncClient(timeout=timeout, limits=limits, headers=headers) as client: | |
| on_disk = {p.stem for p in root.rglob("*.run")} | |
| known_bad = load_known_bad(root) | |
| # The listing endpoint intermittently reports a stale total (e.g. 359 | |
| # instead of ~114k) from a cache replica; insist on a fresh-looking total. | |
| first = await probe_first_page(client, max(9000, len(on_disk))) | |
| if first is None: | |
| typer.echo("gave up waiting for fresh listing", err=True) | |
| return | |
| total, total_pages = first["total"], first["total_pages"] | |
| typer.echo( | |
| f"spire-codex: {total} runs across {total_pages} pages; " | |
| f"{len(on_disk)} on disk, {len(known_bad)} known-unretrievable" | |
| ) | |
| skip = on_disk | known_bad | |
| if since_newest: | |
| todo, unique = await list_until_known( | |
| client, first, skip, total_pages, list_concurrency | |
| ) | |
| else: | |
| rows = await list_all_rows(client, first, total_pages, list_concurrency) | |
| todo, unique = select_new_rows(rows, skip) | |
| if max_runs is not None: | |
| todo = todo[:max_runs] | |
| typer.echo(f"listed {unique} unique runs; {len(todo)} new to download") | |
| downloaded, errors, failures = await download_all(client, todo, root, concurrency) | |
| typer.echo(f"done: downloaded={downloaded} skipped={len(on_disk)} errors={errors} root={root}") | |
| if failures: | |
| write_failures(root, failures) | |
| def main( | |
| concurrency: int = typer.Option(32, help="Concurrent shared-run detail fetches."), | |
| list_concurrency: int = typer.Option(32, help="Concurrent list-page fetches."), | |
| max_runs: int | None = typer.Option(None, help="Stop after N new downloads."), | |
| since_newest: bool = typer.Option( | |
| False, | |
| "--since-newest", | |
| help="Incremental top-up: walk newest-first and stop once caught up " | |
| "(cheap when only a few runs are new; skips full enumeration).", | |
| ), | |
| ) -> None: | |
| try: | |
| asyncio.run(main_async(concurrency, list_concurrency, max_runs, since_newest)) | |
| except KeyboardInterrupt: | |
| typer.echo("interrupted", err=True) | |
| sys.exit(130) | |
| if __name__ == "__main__": | |
| typer.run(main) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment