From 243c7d70ddcab6705c619133bf5a342d15ea13b4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 17 Aug 2026 17:20:31 +0200 Subject: [PATCH] fix(game8): let an h4 fence a section off without naming one Game8 uses h4 for two opposite jobs. Persona 5 hides its entire finished back catalogue behind nothing but an `

Finished Events

` inside a collapsed accordion, so a reader blind to h4 sees one uninterrupted run of tables and would publish fifty dead events. Genshin uses h4 the other way round, for sub-headings *within* a single event ("Availability Period", "Characters & Rewards for this Test Run"). So h4 now gates sections but never names one: an unrecognised h4 leaves the current event title alone, where an unrecognised h2 or h3 still sets it. Letting one claim the title renames "Character Test Runs" to the label sitting above its own date table, which the Genshin fixture catches. Co-Authored-By: Claude Opus 5 (1M context) --- src/ingest/html.ts | 15 ++++++++++++--- src/ingest/parsers/game8.ts | 18 +++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/ingest/html.ts b/src/ingest/html.ts index f696f7c..36c126c 100644 --- a/src/ingest/html.ts +++ b/src/ingest/html.ts @@ -79,11 +79,18 @@ export interface ParagraphNode { export type DocNode = | { kind: "h2"; text: string } | { kind: "h3"; text: string } + | { kind: "h4"; text: string } | TableNode | ParagraphNode; /** - * Walk a document in source order, yielding h2/h3 headings and tables. + * Walk a document in source order, yielding h2/h3/h4 headings and tables. + * + * h4 matters because it is sometimes the only thing separating a live table + * from a finished one — Game8's Persona 5 page puts its whole back catalogue + * under an `

Finished Events

` inside a collapsed accordion. A reader + * blind to h4 sees one uninterrupted run of tables and cannot tell where the + * live section stops. * * Pure: no network, no clock. Given identical input it always yields identical * output, which is what makes fixture tests meaningful. @@ -93,14 +100,16 @@ export function scanDocument(rawHtml: string): DocNode[] { const nodes: DocNode[] = []; const re = - /]*>([\s\S]*?)<\/h2>|]*>([\s\S]*?)<\/h3>|]*>([\s\S]*?)<\/table>|]*>([\s\S]*?)<\/p>/gi; + /]*>([\s\S]*?)<\/h2>|]*>([\s\S]*?)<\/h3>|]*>([\s\S]*?)<\/h4>|]*>([\s\S]*?)<\/table>|]*>([\s\S]*?)<\/p>/gi; for (const m of html.matchAll(re)) { - const [whole, h2, h3, table, p] = m; + const [whole, h2, h3, h4, table, p] = m; if (h2 !== undefined) { nodes.push({ kind: "h2", text: text(h2) }); } else if (h3 !== undefined) { nodes.push({ kind: "h3", text: text(h3) }); + } else if (h4 !== undefined) { + nodes.push({ kind: "h4", text: text(h4) }); } else if (table !== undefined) { nodes.push(readTable(table)); } else if (p !== undefined) { diff --git a/src/ingest/parsers/game8.ts b/src/ingest/parsers/game8.ts index a0093dd..9c58bb6 100644 --- a/src/ingest/parsers/game8.ts +++ b/src/ingest/parsers/game8.ts @@ -53,6 +53,7 @@ const EXCLUDED_SECTIONS = [ /past events/i, /previous events/i, /ended events/i, + /finished events/i, ]; /** Label/value rows carrying a single boundary instant. */ @@ -97,10 +98,12 @@ export function parseGame8EventsPage( const node = nodes[i]; if (node === undefined) continue; - // 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") { + // Sections are marked by h2 on some pages, h3 or h4 on others, so inclusion + // is tracked at whichever level actually names the section — Persona 5's + // finished-events table is fenced off by nothing but an h4. A heading + // matching neither list leaves the current state alone — it is an event + // name. + if (node.kind === "h2" || node.kind === "h3" || node.kind === "h4") { const heading = node.text; if (EXCLUDED_SECTIONS.some((re) => re.test(heading))) { sectionIncluded = false; @@ -108,7 +111,12 @@ export function parseGame8EventsPage( } else if (INCLUDED_SECTIONS.some((re) => re.test(heading))) { sectionIncluded = true; currentTitle = null; - } else { + } else if (node.kind !== "h4") { + // An unrecognised h2/h3 names an event. An unrecognised h4 does not — + // Genshin uses them for sub-headings *within* one event ("Availability + // Period", "Characters & Rewards for this Test Run"), so letting one + // claim the title would rename "Character Test Runs" to the label above + // its own date table. currentTitle = heading; } continue;