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
+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[];
}