Files
gacha-event-tracker/scripts/build-feed.ts
T
Lucas Winther 81ac9fb3dd add Genshin Impact Fandom event source
Add genshin-impact.fandom.com as a second source for Genshin Impact,
which has been fixture-backed in CI because Game8 CloudFront-blocks the
Actions runner with 202s.

- Add fifth Fandom template parser in src/ingest/parsers/fandom.ts
  extracting titles from caption display text, stripping dated subpage
  suffixes, and checking currency via latestBoundaryMs.
- Register genshin-fandom-events in src/ingest/adapters/index.ts and
  set genshin-game8-events priority: 10 so existing event IDs survive
  near-match ties, preventing localStorage completion marks from
  orphaning.
- Explicitly sort adapter groups by priority descending in
  scripts/build-feed.ts to align with parseGame.
- Pin fixtures and add test coverage in test/adapters/game8.test.ts.
- Update AGENTS.md, README.md, docs/INGESTION.md, and docs/SOURCES.md.
2026-09-12 05:20:56 +02:00

131 lines
4.7 KiB
TypeScript

/**
* 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 { sourceHealth } from "../src/ingest/health.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 `<site>-events-<date>`
* against adapter ids of `<game>-<site>-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<GameId, Array<{ priority: number; events: GachaEvent[] }>>();
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,
});
// Which of the three empties this is, decided in a module a test can reach
// rather than here — see `src/ingest/health.ts` for why that matters.
const health = sourceHealth(adapter, html, at, events.length);
const { parsedCount } = health;
const groups = byGame.get(adapter.game) ?? [];
groups.push({ priority: adapter.priority, events });
byGame.set(adapter.game, groups);
sources.push(health);
// A source that came back with nothing says which nothing it was, because a
// bare "0 events" reads as a fault and two of the three are not one.
const note = health.statesNoEvents
? " (the page states it currently lists none)"
: 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 sorted = groups
.sort((a, b) => b.priority - a.priority)
.map((g) => g.events);
const merged = mergeEvents(sorted);
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;
}