# Ingestion Pipeline Seven stages, run per source. Every stage writes its outcome to `ingest_runs` so a failure two days ago can be diagnosed without re-running or re-paying. ``` fetch → clean → parse|extract → validate → reconcile → gate → publish │ └──► quarantine ``` ## The adapter contract An adapter is the only per-game code. Everything downstream of `parse` is shared. ```ts export interface Adapter { id: string; // 'genshin-wiki-events' game: GameId; url: string; strategy: "parser" | "llm" | "parser_then_llm"; minIntervalMs?: number; // default 6h /** Narrow the cleaned document to just the region containing event data. */ select?(cleaned: string): string; /** * Deterministic parse. Return null to fall through to LLM extraction * (only meaningful when strategy is 'parser_then_llm'). * Pure over its input — no network, no clock, no randomness. This is what * makes fixture tests possible. */ parse?(cleaned: string, ctx: ParseContext): RawEvent[] | null; /** Extra instructions appended to the shared extraction prompt. */ extractionHints?: string; /** Game-specific normalization: reset times, region offsets, patch cadence. */ normalize(raw: RawEvent, ctx: ParseContext): GachaEvent; } export interface ParseContext { now: string; // injected, never Date.now() — keeps parse pure and testable sourceUrl: string; sourceId: string; game: GameId; } ``` **`parse` must not read the clock.** It takes `now` from `ctx`. This is what lets a fixture test assert exact output for a page captured last March. ### Choosing a strategy | Source shape | Strategy | |---|---| | JSON API, or a stable HTML table with consistent headers | `parser` | | Free-form patch notes, announcement prose, inconsistent markup | `llm` | | Mostly-stable markup that occasionally changes | `parser_then_llm` | Prefer `parser`. It is free, deterministic, and instantly testable. The LLM exists for sources that genuinely cannot be parsed reliably, not as the default. A source with a clean API that goes through the model is a bug. ## Stage 1 — fetch - Send `If-None-Match` / `If-Modified-Since` from `sources.etag` / `last_modified`. A `304` ends the run as `skipped_unchanged` with zero further cost. - `User-Agent: gacha-event-tracker/1.0 (+https://github.com//gacha-event-tracker)`. - Honor `robots.txt`. Cache the parsed robots per host for 24h. - 20s timeout; retry twice with exponential backoff on 5xx and network errors; never retry 4xx. - Store the raw bytes in `snapshots`. On failure: increment `consecutive_failures`, leave published events untouched, end the run as `failed`. A source being down never mutates the feed. ## Stage 2 — clean Reduce the document before it costs anything. This stage is the second-biggest cost lever after the content-hash skip. - Drop `