CI failed on Infinity Nikki yielding no events, and was wrong to. Its snapshot parses to six events; every one of them had ended by the morning the build ran. The parser is fine — `--now 2026-08-14` gives six, today gives none — and the page simply has nothing current left on it. eventCount is counted after expired events are dropped, so "this parser has stopped reading a redesigned page" and "this page's events have all finished" arrived as the same zero. Only the first means our code is wrong, and only the first should redden a build. So the feed now records what each document yields parsed as of its own capture date, before expiry, and the check fails on that instead. Nikki cannot refresh itself out of this, either: docs/SOURCES.md records that Fandom refuses the Actions runner. A lane with an empty calendar is a real problem, but it is a refresh problem, so it is reported on the build log and left visible rather than thrown. parsedCount is nullable and defaulted, never required: the client validates the whole feed with safeParse and the service worker serves the last feed it downloaded, so a required field would have made every cached feed fail validation and taken the offline promise with it. Null also covers a source whose bytes were never confirmed live — there is no date to parse "as of", and a build is not failed on missing information. The rule moved to shared/feed.ts. A test did pin the old one, by grepping the workflow for the string — which proved the check existed, never that it was right. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
149 lines
5.2 KiB
TypeScript
149 lines
5.2 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 { 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, 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,
|
|
});
|
|
|
|
// 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;
|
|
}
|