Keep the sources' Miliastra, battle-pass and TCG distinctions so rotating names do not need a new title table every patch. Classification survives cross-source merges while event IDs, dates and source identities stay fixed. Conflicting classifications are reported instead of selecting a destination.
155 lines
5.5 KiB
TypeScript
155 lines
5.5 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, pickFixture } 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 `<id less the
|
|
* game>-<date>` (`fandom-events-2026-09-12` for `genshin-fandom-events`).
|
|
* Globbing by game alone, or by site, hands one page to another page's parser —
|
|
* see `pickFixture`.
|
|
*/
|
|
async function latestFixture(adapterId: string, game: GameId) {
|
|
const files = [...new Bun.Glob(`fixtures/${game}/*.html`).scanSync(".")];
|
|
const file = pickFixture(adapterId, game, files);
|
|
if (file === undefined) {
|
|
throw new Error(`no fixture found for ${adapterId} (fixtures/${game}/${adapterId.slice(game.length + 1)}-<date>.html)`);
|
|
}
|
|
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) {
|
|
const rawConfirmed = cached.meta.lastConfirmedAt ?? cached.state.lastConfirmedAt;
|
|
const contentChangedAt = cached.meta.contentChangedAt;
|
|
const lastConfirmedAt =
|
|
rawConfirmed !== null &&
|
|
contentChangedAt !== null &&
|
|
Date.parse(rawConfirmed) >= Date.parse(contentChangedAt)
|
|
? rawConfirmed
|
|
: (contentChangedAt ?? rawConfirmed);
|
|
return {
|
|
file: snapshots.bodyPath(adapterId),
|
|
html: cached.html,
|
|
at: freshnessAt(cached),
|
|
lastConfirmedAt,
|
|
contentChangedAt,
|
|
};
|
|
}
|
|
const { file, html } = await latestFixture(adapterId, game);
|
|
const date = fixtureDate(file);
|
|
return {
|
|
file,
|
|
html,
|
|
at: date,
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: date,
|
|
};
|
|
}
|
|
|
|
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, lastConfirmedAt, contentChangedAt } = 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, {
|
|
lastConfirmedAt,
|
|
contentChangedAt,
|
|
});
|
|
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;
|
|
conflictCount += merged.classificationConflicts.length;
|
|
for (const c of merged.classificationConflicts) console.warn(` ! classification conflict: "${c.kept.title}" between ${c.kept.sourceId} and ${c.rejected.sourceId}; routing left unresolved`);
|
|
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;
|
|
}
|