/** * Build the static event feed from cached snapshots, falling back to fixtures. * * Offline: this reads files on disk, never the network. Fetching is * `scripts/refresh-sources.ts`'s job; this stage only parses what that left in * the snapshot cache. On a clean checkout — and in the container build — no * snapshot exists and the checked-in fixture is used instead, so the build * stays reproducible and a wiki being down never breaks it. * * bun run build:feed */ import { ADAPTERS } from "../src/ingest/adapters/index.ts"; import { mergeEvents } from "../src/ingest/merge.ts"; import { SnapshotStore, freshnessAt } from "../src/ingest/snapshots.ts"; import { EventFeed, SCHEMA_VERSION, type SourceHealth } from "../src/shared/feed.ts"; import type { GachaEvent, GameId } from "../src/shared/schema.ts"; const OUT = "public/data/events.v1.json"; const snapshots = new SnapshotStore(process.env["SNAPSHOT_DIR"] ?? "snapshots"); /** * Newest fixture for one *source*, not one game. * * A game can have several sources, and fixtures are named `-events-` * against adapter ids of `--events`. Globbing by game alone hands * one site's page to another site's parser. */ async function latestFixture(adapterId: string, game: GameId) { const site = adapterId.replace(`${game}-`, "").replace(/-events$/, ""); const pattern = `fixtures/${game}/${site}-*.html`; const files = [...new Bun.Glob(pattern).scanSync(".")].sort(); const file = files.at(-1); if (file === undefined) { throw new Error(`no fixture found for ${adapterId} (${pattern})`); } return { file, html: await Bun.file(file).text() }; } /** * The document to parse for one source: the live snapshot when the refresh * runner has cached one, otherwise the newest checked-in fixture. * * `at` is what the UI's staleness badge reads, so it must never claim to be * fresher than the bytes actually are — a fixture reports its capture date. */ async function documentFor(adapterId: string, game: GameId) { const cached = await snapshots.read(adapterId); if (cached !== null) { return { file: snapshots.bodyPath(adapterId), html: cached.html, at: freshnessAt(cached), }; } const { file, html } = await latestFixture(adapterId, game); return { file, html, at: fixtureDate(file) }; } const now = new Date().toISOString(); const byGame = new Map(); const sources: SourceHealth[] = []; for (const adapter of ADAPTERS) { const { file, html, at } = await documentFor(adapter.id, adapter.game); const events = adapter.parse(html, { now, sourceUrl: adapter.url, sourceId: adapter.id, game: adapter.game, }); // Parsed a second time as of the document's own capture date, when nothing // in it had expired yet. That figure is what separates "this parser has // stopped reading the page" from "this page's events have all finished // since it was captured" — the two are the same zero once expiry has been // applied, and only the first means our code is wrong. // Null when we do not know when these bytes were current: there is no date // to parse "as of", and inventing one would manufacture a figure the check // then trusts. Unknown is a real answer here, and `brokenSources` declines // to fail a build on it. const parsedCount = at === null ? null : adapter.parse(html, { now: at, sourceUrl: adapter.url, sourceId: adapter.id, game: adapter.game, }).length; const groups = byGame.get(adapter.game) ?? []; groups.push(events); byGame.set(adapter.game, groups); sources.push({ sourceId: adapter.id, game: adapter.game, url: adapter.url, // When the bytes were last confirmed live; a fixture's capture date when // this source has never been refreshed. lastSuccessAt: at, eventCount: events.length, parsedCount, }); // A source that parsed events and then lost them all to the calendar says // so on the build log, because a bare "0 events" reads as a fault. const note = events.length === 0 && parsedCount !== null && parsedCount > 0 ? ` (all ${parsedCount} have ended — stale page)` : ""; console.log( ` ${adapter.id.padEnd(24)} ${String(events.length).padStart(3)} events ← ${file}${note}`, ); } const events: GachaEvent[] = []; let conflictCount = 0; for (const [, groups] of byGame) { const merged = mergeEvents(groups); events.push(...merged.events); conflictCount += merged.conflicts.length; for (const c of merged.conflicts) { console.warn( ` ! conflict: "${c.kept.title}" ${c.field} differs by ${c.deltaHours}h between sources`, ); } } events.sort((a, b) => a.startsAt.localeCompare(b.startsAt) || a.id.localeCompare(b.id)); const feed = EventFeed.parse({ schemaVersion: SCHEMA_VERSION, generatedAt: now, events, sources, }); await Bun.write(OUT, `${JSON.stringify(feed, null, 2)}\n`); console.log( `\n${OUT}: ${events.length} events across ${byGame.size} games, ${conflictCount} conflicts`, ); /** "game8-events-2026-08-14.html" → ISO timestamp. */ function fixtureDate(path: string): string | null { const m = /(\d{4}-\d{2}-\d{2})\.html$/.exec(path); return m?.[1] ? `${m[1]}T00:00:00.000Z` : null; }