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;