feat: add Game8 parser with Genshin and NTE adapters

Parsers are keyed by site template, not game: one Game8 parser serves
every Game8 page, and adapters bind a URL and game to a parser. Adding a
source for a known site is one SOURCES entry.

Game8 uses at least three templates. This handles label/value detail
tables (Genshin, 9 events) and column tables (NTE, 13 events). Its
image-grid schedule has no year and no end date, so Arknights: Endfield
is not supportable from that source and gets no adapter.

canParse is a structural tripwire: a site redesign fails the run loudly
instead of publishing an empty calendar that reads as "no events".

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:10:53 +02:00
co-authored by Claude Opus 5
parent caefe6b0d7
commit 489ce92ef5
12 changed files with 9274 additions and 0 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,191 @@
[
{
"id": "genshin:to-temper-thyself-and-journey-far:2026-08-10",
"game": "genshin",
"title": "To Temper Thyself and Journey Far",
"type": "other",
"summary": null,
"startsAt": "2026-08-10T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-11-02T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:battle-pass-frostfarer:2026-08-12",
"game": "genshin",
"title": "Battle Pass - Frostfarer",
"type": "other",
"summary": null,
"startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-21T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:character-test-runs:2026-08-12",
"game": "genshin",
"title": "Character Test Runs",
"type": "challenge",
"summary": null,
"startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-01T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:mutual-aid-in-bloom-into-the-frostlands:2026-08-12",
"game": "genshin",
"title": "Mutual Aid in Bloom: Into the Frostlands",
"type": "other",
"summary": null,
"startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-24T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:snezhnaya-travel-guide-web-event:2026-08-12",
"game": "genshin",
"title": "Snezhnaya Travel Guide Web Event",
"type": "other",
"summary": null,
"startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-25T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:stygian-onslaught:2026-08-19",
"game": "genshin",
"title": "Stygian Onslaught",
"type": "challenge",
"summary": null,
"startsAt": "2026-08-19T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-22T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:great-expeditionist-challenge:2026-08-28",
"game": "genshin",
"title": "Great Expeditionist Challenge",
"type": "challenge",
"summary": null,
"startsAt": "2026-08-28T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-14T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:trial-of-the-bastion:2026-09-07",
"game": "genshin",
"title": "Trial of the Bastion",
"type": "challenge",
"summary": null,
"startsAt": "2026-09-07T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-17T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "genshin:overflowing-abundance-rerun:2026-09-14",
"game": "genshin",
"title": "Overflowing Abundance Rerun",
"type": "rerun",
"summary": null,
"startsAt": "2026-09-14T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-21T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
"sourceId": "genshin-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,275 @@
[
{
"id": "nte:999-nights:2026-07-08",
"game": "nte",
"title": "999 Nights",
"type": "other",
"summary": "The party's assembled! Ready to conquer the Warren Continent!",
"startsAt": "2026-07-08T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:circle-gift:2026-07-08",
"game": "nte",
"title": "Circle Gift",
"type": "other",
"summary": "Log in to NTE for a week days to get 10 Solid Dice!",
"startsAt": "2026-07-08T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:mews-flash:2026-07-08",
"game": "nte",
"title": "Mews Flash",
"type": "other",
"summary": "Scratch cards daily at the Midnight Catzebo for massive amounts of Fons!",
"startsAt": "2026-07-08T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:neon-rift-mystery-box:2026-07-08",
"game": "nte",
"title": "Neon Rift Mystery Box",
"type": "other",
"summary": "Win the coveted Draco motorcycle, a new outfit, and more in this mystery box event!",
"startsAt": "2026-07-08T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:shadow-n-seek:2026-07-17",
"game": "nte",
"title": "Shadow-n-Seek",
"type": "other",
"summary": "One, two, three. Don't move! A 6-player game of hide-and-seek!",
"startsAt": "2026-07-17T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:gold-clash:2026-07-20",
"game": "nte",
"title": "Gold Clash",
"type": "challenge",
"summary": "Earn Fons twice as fast in Pink Paws Heist.",
"startsAt": "2026-07-20T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-03T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:going-going-gone:2026-07-29",
"game": "nte",
"title": "Going, Going, Gone",
"type": "other",
"summary": "Win or lose, it all comes down to one swing of the gavel.",
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:market-opening-rehearsal:2026-07-29",
"game": "nte",
"title": "Market Opening Rehearsal",
"type": "other",
"summary": "This is a free combat trial for the Appraisers Iroi, Haniel, Aurelia, and Skia.",
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:wayfarer-perks:2026-07-29",
"game": "nte",
"title": "Wayfarer Perks",
"type": "other",
"summary": "Pull for characters a certain amount of times to receive cosmetics!",
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:fishing-frenzy:2026-08-03",
"game": "nte",
"title": "Fishing Frenzy",
"type": "other",
"summary": "A rod's just the beginning, there's more than one way to land your catch!",
"startsAt": "2026-08-03T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:pixel-surge:2026-08-03",
"game": "nte",
"title": "Pixel Surge",
"type": "other",
"summary": "During the event, spend Character Pixels in Houdini's Magic Stage, Houdini's Schemes, Bubble Can Factory, and Rabbit Hole to receive double rewards.",
"startsAt": "2026-08-03T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-10T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:warren-lucky-flip:2026-08-05",
"game": "nte",
"title": "Warren Lucky Flip",
"type": "other",
"summary": "Complete daily quests to earn card flips.",
"startsAt": "2026-08-05T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nte:fons-rush:2026-08-10",
"game": "nte",
"title": "Fons Rush",
"type": "other",
"summary": "During the event, spend City Stamina Hethereau Hobbies activities to earn double the Fons.",
"startsAt": "2026-08-10T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-17T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
"sourceId": "nte-game8-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
}
]
File diff suppressed because one or more lines are too long
+54
View File
@@ -0,0 +1,54 @@
/**
* Run an adapter against a checked-in fixture and print the events it yields.
*
* Offline and free — this never touches the network. Use it to eyeball an
* adapter's output before writing the .expected.json, and to regenerate that
* file after an intentional change.
*
* bun run parse <adapter-id> <fixture-path> [--now ISO] [--json]
*/
import { adapterById, ADAPTERS } from "../src/ingest/adapters/index.ts";
import { GachaEvent } from "../src/shared/schema.ts";
const [adapterId, fixturePath] = Bun.argv.slice(2);
const nowFlag = Bun.argv.indexOf("--now");
const now =
nowFlag !== -1 ? (Bun.argv[nowFlag + 1] ?? "") : "2026-08-14T00:00:00.000Z";
const asJson = Bun.argv.includes("--json");
if (!adapterId || !fixturePath) {
console.error("usage: bun run parse <adapter-id> <fixture-path> [--now ISO] [--json]");
console.error(`adapters: ${ADAPTERS.map((a) => a.id).join(", ")}`);
process.exit(1);
}
const adapter = adapterById(adapterId);
if (!adapter) {
console.error(`unknown adapter '${adapterId}'`);
console.error(`adapters: ${ADAPTERS.map((a) => a.id).join(", ")}`);
process.exit(1);
}
const html = await Bun.file(fixturePath).text();
const events = adapter.parse(html, {
now,
sourceUrl: adapter.url,
sourceId: adapter.id,
game: adapter.game,
});
// Validate here too — the script should fail loudly on a schema violation
// rather than print something the pipeline would later reject.
for (const e of events) GachaEvent.parse(e);
if (asJson) {
console.log(JSON.stringify(events, null, 2));
} else {
console.log(`${adapter.id}: ${events.length} events\n`);
for (const e of events) {
const end = e.endsAt ? e.endsAt.slice(0, 10) : " (unknown)";
console.log(
` ${e.startsAt.slice(0, 10)}${end} [${e.type.padEnd(11)}] ${e.title}`,
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import type { GachaEvent, GameId } from "../../shared/schema.ts";
import { mergeEvents, type MergeResult } from "../merge.ts";
import { parserById } from "../parsers/index.ts";
import { SIX_HOURS_MS, type Adapter, type ParseContext } from "./types.ts";
/**
* The source registry.
*
* One entry per (game, page). Adding a source for a site we already parse is a
* single entry here. Adding a new site means a parser in `../parsers` first.
*/
interface SourceSpec {
id: string;
game: GameId;
url: string;
parserId: string;
priority?: number;
minIntervalMs?: number;
}
const SOURCES: SourceSpec[] = [
{
id: "genshin-game8-events",
game: "genshin",
url: "https://game8.co/games/Genshin-Impact/archives/301601",
parserId: "game8",
},
{
id: "nte-game8-events",
game: "nte",
url: "https://game8.co/games/Neverness-to-Everness/archives/592073",
parserId: "game8",
},
];
function toAdapter(spec: SourceSpec): Adapter {
const parser = parserById(spec.parserId);
if (parser === undefined) {
throw new Error(
`source '${spec.id}' references unknown parser '${spec.parserId}'`,
);
}
return {
id: spec.id,
game: spec.game,
url: spec.url,
parserId: spec.parserId,
minIntervalMs: spec.minIntervalMs ?? SIX_HOURS_MS,
priority: spec.priority ?? 0,
parse(html: string, ctx: ParseContext): GachaEvent[] {
// A site redesign should fail the run loudly rather than publish an empty
// calendar, which would read as "no events" to a user.
if (!parser.canParse(html)) {
throw new Error(
`${spec.id}: document does not match the '${parser.label}' template; the source has likely been redesigned`,
);
}
return parser.parse(html, ctx);
},
};
}
export const ADAPTERS: Adapter[] = SOURCES.map(toAdapter);
export function adaptersForGame(game: GameId): Adapter[] {
return ADAPTERS.filter((a) => a.game === game);
}
export function adapterById(id: string): Adapter | undefined {
return ADAPTERS.find((a) => a.id === id);
}
export function gamesWithSources(): GameId[] {
return [...new Set(ADAPTERS.map((a) => a.game))];
}
/**
* Parse every source for one game and combine them.
*
* Callers supply already-fetched documents so this stays pure and offline —
* fetching is stage 1's job, not the parser's.
*/
export function parseGame(
game: GameId,
documents: Map<string, string>,
now: string,
): MergeResult {
const groups = adaptersForGame(game)
.sort((a, b) => b.priority - a.priority)
.flatMap((adapter) => {
const html = documents.get(adapter.id);
if (html === undefined) return [];
return [
adapter.parse(html, {
now,
sourceUrl: adapter.url,
sourceId: adapter.id,
game: adapter.game,
}),
];
});
return mergeEvents(groups);
}
// Convenience handles for tests and scripts.
export const genshinGame8 = ADAPTERS.find(
(a) => a.id === "genshin-game8-events",
)!;
export const nteGame8 = ADAPTERS.find((a) => a.id === "nte-game8-events")!;
+38
View File
@@ -0,0 +1,38 @@
import type { GachaEvent, GameId } from "../../shared/schema.ts";
export interface ParseContext {
/**
* Injected wall-clock, never read from Date.now() inside a parser. This is
* what lets a fixture captured months ago assert byte-identical output.
*/
now: string;
sourceUrl: string;
sourceId: string;
game: GameId;
}
/**
* An adapter binds one URL, for one game, to one parser.
*
* It holds no parsing logic of its own — that lives in `../parsers`, keyed by
* site template. A game may have several adapters (several sources); their
* outputs are combined by `mergeEvents`.
*/
export interface Adapter {
/** Unique, stable: "<game>-<site>-<page>", e.g. "genshin-game8-events". */
id: string;
game: GameId;
url: string;
/** Parser id from `../parsers`. */
parserId: string;
minIntervalMs: number;
/**
* Higher wins when two sources disagree and neither is clearly better.
* Official feeds should outrank community wikis.
*/
priority: number;
parse(html: string, ctx: ParseContext): GachaEvent[];
}
export const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
+274
View File
@@ -0,0 +1,274 @@
import {
eventId,
type EventType,
type GachaEvent,
} from "../../shared/schema.ts";
import {
parseMonthDayRange,
parseMonthDayYear,
parseSlashDateTimeRange,
type ParsedInstant,
} from "../dates.ts";
import { assertFlatTables, scanDocument } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
import type { SourceParser } from "./types.ts";
/**
* Shared parsing for Game8 event-calendar pages.
*
* Game8 does not use one template. Across games there are (at least) three:
*
* 1. Label/value detail tables — `Event Start` / `Event End` rows under a
* per-event `h3`. Full dates with year. (Genshin Impact)
* 2. Column tables — `Event | Duration | Event Details | Rewards`, one row per
* event, under a section heading. (Neverness to Everness)
* 3. Image-grid schedules with a bare `MM/DD` and no end date. Unsupportable
* without inventing a year and an end, so it yields nothing. (Endfield)
*
* Shapes 1 and 2 are both handled here; a page may contain either or both.
* Anything undated is skipped rather than guessed — see docs/PRD.md § Quality
* bar.
*/
/** Section headings whose events belong on the calendar. */
const INCLUDED_SECTIONS = [
/current events/i,
/upcoming events/i,
/^recurring events$/i,
/events? schedule/i,
];
/**
* Sections deliberately skipped. Permanent events have no end and are not
* time-boxed; past events are over and would bury what is live.
*/
const EXCLUDED_SECTIONS = [
/permanent events/i,
/past events/i,
/previous events/i,
/ended events/i,
];
/** Label/value rows carrying a single boundary instant. */
const START_LABEL = /^(event|test run|banner)\s+start$/i;
const END_LABEL = /^(event|test run|banner)\s+end$/i;
/** Label/value rows carrying a whole range in one cell. */
const RANGE_LABEL = /^(availability period|event period|duration|period|dates)$/i;
/** Column-table header matchers. */
const COL_TITLE = /^(.*\b)?events?$/i;
const COL_RANGE = /^(duration|dates?|period|availability period|schedule)$/i;
const COL_SUMMARY = /^(event )?(details?|description|overview)$/i;
interface Candidate {
title: string;
summary: string | null;
start: ParsedInstant;
end: ParsedInstant | null;
}
export function parseGame8EventsPage(
html: string,
ctx: ParseContext,
): GachaEvent[] {
// The table reader assumes flat tables. Assert rather than mis-parse silently.
if (!assertFlatTables(html)) {
throw new Error(
`${ctx.sourceId}: source contains nested tables; the flat-table reader cannot parse it safely`,
);
}
const nodes = scanDocument(html);
const candidates: Candidate[] = [];
let sectionIncluded = false;
let currentTitle: string | null = null;
for (const node of nodes) {
// Sections are marked by h2 on some pages and h3 on others, so inclusion is
// tracked at whichever level actually names the section. A heading matching
// neither list leaves the current state alone — it is an event name.
if (node.kind === "h2" || node.kind === "h3") {
const heading = node.text;
if (EXCLUDED_SECTIONS.some((re) => re.test(heading))) {
sectionIncluded = false;
currentTitle = null;
} else if (INCLUDED_SECTIONS.some((re) => re.test(heading))) {
sectionIncluded = true;
currentTitle = null;
} else {
currentTitle = heading;
}
continue;
}
if (!sectionIncluded) continue;
const fromColumns = readColumnTable(node.rows);
if (fromColumns.length > 0) {
candidates.push(...fromColumns);
continue;
}
if (currentTitle === null) continue;
const dates = readLabelledDates(node.pairs);
if (dates === null) continue;
candidates.push({ title: currentTitle, summary: null, ...dates });
// One dated table per heading; ignore follow-on reward tables until the
// next heading.
currentTitle = null;
}
return dedupe(candidates.map((c) => toEvent(c, ctx)));
}
/** Shape 1: `Event Start` / `Event End` / `Availability Period` rows. */
function readLabelledDates(
pairs: Array<{ label: string; value: string }>,
): { start: ParsedInstant; end: ParsedInstant | null } | null {
let start: ParsedInstant | null = null;
let end: ParsedInstant | null = null;
for (const { label, value } of pairs) {
if (RANGE_LABEL.test(label)) {
const range = parseRange(value);
if (range) return range;
// "Permanently Available", "TBD" — not an error, just not datable.
continue;
}
if (START_LABEL.test(label)) start ??= parseMonthDayYear(value);
if (END_LABEL.test(label)) end ??= parseMonthDayYear(value);
}
return start === null ? null : { start, end };
}
/** Shape 2: one row per event, with a title column and a range column. */
function readColumnTable(rows: string[][]): Candidate[] {
if (rows.length < 2) return [];
const header = rows[0];
if (header === undefined) return [];
const titleIdx = header.findIndex((h) => COL_TITLE.test(h));
const rangeIdx = header.findIndex((h) => COL_RANGE.test(h));
if (titleIdx === -1 || rangeIdx === -1) return [];
const summaryIdx = header.findIndex((h) => COL_SUMMARY.test(h));
const out: Candidate[] = [];
for (const row of rows.slice(1)) {
const title = row[titleIdx]?.trim();
const rangeCell = row[rangeIdx];
if (!title || rangeCell === undefined) continue;
const range = parseRange(rangeCell);
// A row we cannot date is skipped, not guessed. This is also what keeps
// year-less summary tables ("08/12 - 08/24") from producing events.
if (range === null) continue;
const summaryCell = summaryIdx === -1 ? undefined : row[summaryIdx];
const summary =
summaryCell && summaryCell.length > 0 ? summaryCell.slice(0, 500) : null;
out.push({ title, summary, start: range.start, end: range.end });
}
return out;
}
function parseRange(
value: string,
): { start: ParsedInstant; end: ParsedInstant } | null {
return parseSlashDateTimeRange(value) ?? parseMonthDayRange(value);
}
/**
* Type is inferred from the title by keyword. This is presentation metadata
* used for filtering, not a date, so a conservative "other" default beats a
* confident mislabel.
*/
export function inferType(title: string): EventType {
const t = title.toLowerCase();
if (/\brerun\b/.test(t)) return "rerun";
if (/\b(banner|wish|warp|convene|gacha)\b/.test(t)) return "banner";
if (/\b(login|log-in|sign-in|check-in|daily bonus)\b/.test(t)) return "login";
if (/\b(challenge|trial|onslaught|abyss|tower|test runs?|clash|combat)\b/.test(t))
return "challenge";
if (/\b(shop|exchange|store)\b/.test(t)) return "shop";
if (/\bmaintenance\b/.test(t)) return "maintenance";
if (/\b(story|chapter|quest|act)\b/.test(t)) return "story";
return "other";
}
function toEvent(c: Candidate, ctx: ParseContext): GachaEvent {
// Confidence reflects how much the source actually pinned down. Day precision
// and unknown ends are legitimate and common, but they are weaker evidence
// than an exact range and the gate should be able to see that.
let confidence = 0.95;
if (c.start.precision === "day") confidence -= 0.05;
if (c.end === null) confidence -= 0.15;
else if (c.end.precision === "day") confidence -= 0.05;
return {
id: eventId(ctx.game, c.title, c.start.iso),
game: ctx.game,
title: c.title,
type: inferType(c.title),
summary: c.summary,
startsAt: c.start.iso,
startPrecision: c.start.precision,
endsAt: c.end?.iso ?? null,
endPrecision: c.end?.precision ?? "unknown",
// Game8 does not state whether an end follows per-region reset, so these
// are recorded as global rather than guessing a region split. A source that
// does state it should populate these properly.
regionScoped: false,
regionEnds: null,
sourceUrl: ctx.sourceUrl,
sourceId: ctx.sourceId,
status: "published",
confidence: Math.round(confidence * 100) / 100,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
};
}
/** Same event listed in two shapes on one page — keep the better-dated one. */
function dedupe(events: GachaEvent[]): GachaEvent[] {
const byId = new Map<string, GachaEvent>();
for (const e of events) {
const existing = byId.get(e.id);
if (existing === undefined || e.confidence > existing.confidence) {
byId.set(e.id, e);
}
}
return [...byId.values()].sort((a, b) =>
a.startsAt === b.startsAt
? a.id.localeCompare(b.id)
: a.startsAt.localeCompare(b.startsAt),
);
}
/**
* Game8 as a pluggable source parser. Registered in `parsers/index.ts`; bound
* to concrete URLs by the adapters in `adapters/index.ts`.
*/
export const game8Parser: SourceParser = {
id: "game8",
label: "Game8",
canParse(html: string): boolean {
// Structural markers, not content: if Game8 redesigns, this goes false and
// the run fails loudly instead of quietly returning zero events.
//
// Quote style varies between Game8 pages — the Genshin page emits
// class="a-table", the NTE page class='a-table' — so match either. Every
// regex in html.ts is attribute-agnostic for the same reason.
return (
/class=['"][^'"]*a-table/.test(html) &&
/class=['"][^'"]*a-header--3/.test(html)
);
},
parse: parseGame8EventsPage,
};
+16
View File
@@ -0,0 +1,16 @@
import { game8Parser } from "./game8.ts";
import type { SourceParser } from "./types.ts";
/**
* Every known site template. Adding a source for a site already listed here is
* an entry in `adapters/index.ts`; adding a new *site* means a parser module
* here and one line below.
*/
export const PARSERS: SourceParser[] = [game8Parser];
export function parserById(id: string): SourceParser | undefined {
return PARSERS.find((p) => p.id === id);
}
export type { SourceParser } from "./types.ts";
export { game8Parser } from "./game8.ts";
+30
View File
@@ -0,0 +1,30 @@
import type { GachaEvent } from "../../shared/schema.ts";
import type { ParseContext } from "../adapters/types.ts";
/**
* A parser understands one *site template* — not one game.
*
* The split matters as sources multiply: Game8 publishes calendars for a dozen
* games with the same markup, so one parser serves all of them. Adding a second
* site (an official JSON feed, a Fandom wiki) means a new parser here, and the
* adapters that use it are three lines each.
*
* Parsers must be pure over their input: no network, no clock, no randomness.
* `now` arrives via ParseContext.
*/
export interface SourceParser {
/** Stable identifier, e.g. "game8". Recorded on runs for diagnostics. */
id: string;
/** Human-readable, for the review UI and health output. */
label: string;
/**
* Cheap structural check: does this document look like something this parser
* can read? Used to fail loudly when a site is redesigned, instead of
* silently returning zero events.
*/
canParse(html: string): boolean;
parse(html: string, ctx: ParseContext): GachaEvent[];
}
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, test } from "bun:test";
import { genshinGame8, nteGame8 } from "../../src/ingest/adapters/index.ts";
import type { Adapter } from "../../src/ingest/adapters/types.ts";
import { inferType } from "../../src/ingest/parsers/game8.ts";
import { GachaEvent, type EventType } from "../../src/shared/schema.ts";
/**
* Pinned clock. Parsers take `now` from context and never read it themselves,
* so a fixture captured months ago still asserts byte-identical output.
*/
const NOW = "2026-08-14T00:00:00.000Z";
const CASES: Array<{ adapter: Adapter; fixture: string }> = [
{
adapter: genshinGame8,
fixture: "fixtures/genshin/game8-events-2026-08-14",
},
{
adapter: nteGame8,
fixture: "fixtures/nte/game8-events-2026-08-14",
},
];
async function runAdapter(adapter: Adapter, fixture: string) {
const html = await Bun.file(`${fixture}.html`).text();
return adapter.parse(html, {
now: NOW,
sourceUrl: adapter.url,
sourceId: adapter.id,
game: adapter.game,
});
}
describe.each(CASES)("$adapter.id", ({ adapter, fixture }) => {
test("matches the checked-in expected output", async () => {
const events = await runAdapter(adapter, fixture);
const expected = await Bun.file(`${fixture}.expected.json`).json();
expect(events).toEqual(expected);
});
test("every event satisfies the schema", async () => {
for (const event of await runAdapter(adapter, fixture)) {
expect(() => GachaEvent.parse(event)).not.toThrow();
}
});
test("is deterministic across runs", async () => {
const a = await runAdapter(adapter, fixture);
const b = await runAdapter(adapter, fixture);
expect(a).toEqual(b);
});
test("never emits an end before its start", async () => {
for (const e of await runAdapter(adapter, fixture)) {
if (e.endsAt !== null) expect(e.endsAt > e.startsAt).toBe(true);
}
});
test("no event runs longer than 180 days", async () => {
// Patch cycles are ~6 weeks. A longer span means a misread year, which is
// the failure mode most likely to reach a user as a confident wrong date.
for (const e of await runAdapter(adapter, fixture)) {
if (e.endsAt === null) continue;
const days =
(Date.parse(e.endsAt) - Date.parse(e.startsAt)) / 86_400_000;
expect(days).toBeLessThanOrEqual(180);
}
});
test("event IDs are unique and stable in shape", async () => {
const events = await runAdapter(adapter, fixture);
const ids = events.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
for (const e of events) {
expect(e.id).toBe(
`${e.game}:${e.id.split(":")[1]}:${e.startsAt.slice(0, 10)}`,
);
}
});
test("excludes permanent and past sections", async () => {
const titles = (await runAdapter(adapter, fixture)).map((e) => e.title);
// Permanent entries carry no dates; past entries ended before the fixture
// date. Neither belongs on a "what's live / what's next" calendar.
for (const t of titles) expect(t).not.toMatch(/permanent/i);
});
});
describe("genshin fixture specifics", () => {
test("yields the nine dated events on the page", async () => {
const events = await runAdapter(
genshinGame8,
"fixtures/genshin/game8-events-2026-08-14",
);
expect(events).toHaveLength(9);
const byTitle = new Map(events.map((e) => [e.title, e]));
const mutual = byTitle.get("Mutual Aid in Bloom: Into the Frostlands");
expect(mutual?.startsAt).toBe("2026-08-12T00:00:00.000Z");
expect(mutual?.endsAt).toBe("2026-08-24T00:00:00.000Z");
expect(mutual?.startPrecision).toBe("day");
// Sourced from a one-cell "Availability Period" range rather than
// Start/End rows — the other table shape on the same page.
expect(byTitle.get("Battle Pass - Frostfarer")?.endsAt).toBe(
"2026-09-21T00:00:00.000Z",
);
});
test("year-less summary rows produce no events", async () => {
// The page's summary tables show "08/12 - 08/24" with no year. Those must
// be skipped, not year-guessed — and they must not duplicate the detail
// tables that carry the same events with real years.
const events = await runAdapter(
genshinGame8,
"fixtures/genshin/game8-events-2026-08-14",
);
const dupes = events.filter(
(e) => e.title === "Mutual Aid in Bloom: Into the Frostlands",
);
expect(dupes).toHaveLength(1);
});
});
describe("nte fixture specifics", () => {
test("yields current and upcoming events only", async () => {
const events = await runAdapter(
nteGame8,
"fixtures/nte/game8-events-2026-08-14",
);
expect(events).toHaveLength(13); // 9 current + 4 upcoming
const titles = events.map((e) => e.title);
expect(titles).toContain("Market Opening Rehearsal"); // current
expect(titles).toContain("Fons Rush"); // upcoming
expect(titles).not.toContain("Login Gift"); // permanent
expect(titles).not.toContain("Tiger Perks"); // previous
});
test("carries the summary column through", async () => {
const events = await runAdapter(
nteGame8,
"fixtures/nte/game8-events-2026-08-14",
);
const circleGift = events.find((e) => e.title === "Circle Gift");
expect(circleGift?.summary).toContain("Log in");
});
});
describe("inferType", () => {
const cases: Array<[string, EventType]> = [
["Overflowing Abundance Rerun", "rerun"],
["Stygian Onslaught", "challenge"],
["Character Test Runs", "challenge"],
["Gold Clash", "challenge"],
["Seize the Day Login Bonus", "login"],
["Epitome Invocation Banner", "banner"],
["Mutual Aid in Bloom: Into the Frostlands", "other"],
];
test.each(cases)("%s → %s", (title, expected) => {
expect(inferType(title)).toBe(expected);
});
});