diff --git a/fixtures/genshin/game8-events-2026-08-14.expected.json b/fixtures/genshin/game8-events-2026-08-14.expected.json index bc6bf1d..f5421c5 100644 --- a/fixtures/genshin/game8-events-2026-08-14.expected.json +++ b/fixtures/genshin/game8-events-2026-08-14.expected.json @@ -4,7 +4,7 @@ "game": "genshin", "title": "To Temper Thyself and Journey Far", "type": "other", - "summary": null, + "summary": "In the To Temper Thyself and Journey Far event, players can complete the Daily and Weekly Training Goals to claim various rewards, including a 5-Star constellation from a character of their choice in the selection pool!", "startsAt": "2026-08-10T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-11-02T00:00:00.000Z", @@ -25,7 +25,7 @@ "game": "genshin", "title": "Battle Pass - Frostfarer", "type": "other", - "summary": null, + "summary": "Participate in the free Sojourner's Battle Pass and the for-purchase Gnostic Hymn to receive prizes as you increase your BP Level.", "startsAt": "2026-08-12T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-09-21T00:00:00.000Z", @@ -46,7 +46,7 @@ "game": "genshin", "title": "Character Test Runs", "type": "challenge", - "summary": null, + "summary": "The Character Test Run is available at the same time as the Character Banners in Phase 1 of Version 7.0 from August 12, 2026 to September 1, 2026!", "startsAt": "2026-08-12T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-09-01T00:00:00.000Z", @@ -67,7 +67,7 @@ "game": "genshin", "title": "Mutual Aid in Bloom: Into the Frostlands", "type": "other", - "summary": null, + "summary": "Take photos and complete objectives in this Snezhnaya exploration event!", "startsAt": "2026-08-12T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-08-24T00:00:00.000Z", @@ -88,7 +88,7 @@ "game": "genshin", "title": "Snezhnaya Travel Guide Web Event", "type": "other", - "summary": null, + "summary": "Earn rewards from progressing the 7.0 quests, initiating new combat and exploration mechanics, and connecting with other players via invitation codes!", "startsAt": "2026-08-12T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-08-25T00:00:00.000Z", @@ -109,7 +109,7 @@ "game": "genshin", "title": "Stygian Onslaught", "type": "challenge", - "summary": null, + "summary": "The Stygian Onslaught event is a recurring boss rush event where you must defeat three field bosses in different difficulties, solo or in Co-Op.", "startsAt": "2026-08-19T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-09-22T00:00:00.000Z", @@ -130,7 +130,7 @@ "game": "genshin", "title": "Great Expeditionist Challenge", "type": "challenge", - "summary": null, + "summary": "Join the Adventurer's Guild Tournament to play minigames and win prizes in the 7.0 flagship event, Great Expeditionist Challenge!", "startsAt": "2026-08-28T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-09-14T00:00:00.000Z", @@ -151,7 +151,7 @@ "game": "genshin", "title": "Trial of the Bastion", "type": "challenge", - "summary": null, + "summary": "Construct mechanisms and use firearms to defend your stronghold in the base defense event, Trial of the Bastion!", "startsAt": "2026-09-07T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-09-17T00:00:00.000Z", @@ -172,7 +172,7 @@ "game": "genshin", "title": "Overflowing Abundance Rerun", "type": "rerun", - "summary": null, + "summary": "Get double the Talent Level-Up and Weapon Ascension rewards from domains in the return of the Overflowing Abundance event!", "startsAt": "2026-09-14T00:00:00.000Z", "startPrecision": "day", "endsAt": "2026-09-21T00:00:00.000Z", diff --git a/src/ingest/html.ts b/src/ingest/html.ts index 66e53d4..3826929 100644 --- a/src/ingest/html.ts +++ b/src/ingest/html.ts @@ -51,10 +51,22 @@ export interface TableNode { rows: string[][]; } +export interface ParagraphNode { + kind: "p"; + text: string; + /** + * True when the paragraph is really a call-to-action link rather than prose. + * Game8 wraps its "… Event Guide" buttons in the same paragraph class as body + * copy, so callers need to tell them apart. + */ + isButton: boolean; +} + export type DocNode = | { kind: "h2"; text: string } | { kind: "h3"; text: string } - | TableNode; + | TableNode + | ParagraphNode; /** * Walk a document in source order, yielding h2/h3 headings and tables. @@ -66,16 +78,19 @@ export function scanDocument(rawHtml: string): DocNode[] { const html = rawHtml.replace(//g, "").replace(/\s+/g, " "); const nodes: DocNode[] = []; - const re = /]*>([\s\S]*?)<\/h2>|]*>([\s\S]*?)<\/h3>|]*>([\s\S]*?)<\/table>/gi; + const re = + /]*>([\s\S]*?)<\/h2>|]*>([\s\S]*?)<\/h3>|]*>([\s\S]*?)<\/table>|]*>([\s\S]*?)<\/p>/gi; for (const m of html.matchAll(re)) { - const [, h2, h3, table] = m; + const [whole, h2, h3, 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 (table !== undefined) { nodes.push(readTable(table)); + } else if (p !== undefined) { + nodes.push({ kind: "p", text: text(p), isButton: /a-btn/.test(whole) }); } } diff --git a/src/ingest/parsers/game8.ts b/src/ingest/parsers/game8.ts index 47f1891..3a7a384 100644 --- a/src/ingest/parsers/game8.ts +++ b/src/ingest/parsers/game8.ts @@ -9,7 +9,7 @@ import { parseSlashDateTimeRange, type ParsedInstant, } from "../dates.ts"; -import { assertFlatTables, scanDocument } from "../html.ts"; +import { assertFlatTables, scanDocument, type DocNode } from "../html.ts"; import type { ParseContext } from "../adapters/types.ts"; import type { SourceParser } from "./types.ts"; @@ -84,7 +84,10 @@ export function parseGame8EventsPage( let sectionIncluded = false; let currentTitle: string | null = null; - for (const node of nodes) { + for (let i = 0; i < nodes.length; i += 1) { + 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. @@ -102,7 +105,7 @@ export function parseGame8EventsPage( continue; } - if (!sectionIncluded) continue; + if (!sectionIncluded || node.kind !== "table") continue; const fromColumns = readColumnTable(node.rows); if (fromColumns.length > 0) { @@ -114,7 +117,13 @@ export function parseGame8EventsPage( const dates = readLabelledDates(node.pairs); if (dates === null) continue; - candidates.push({ title: currentTitle, summary: null, ...dates }); + candidates.push({ + title: currentTitle, + // Detail tables carry no description column, but the page follows them + // with a sentence of prose. That sentence is the event blurb. + summary: summaryAfter(nodes, i), + ...dates, + }); // One dated table per heading; ignore follow-on reward tables until the // next heading. currentTitle = null; @@ -123,6 +132,24 @@ export function parseGame8EventsPage( return dedupe(candidates.map((c) => toEvent(c, ctx))); } +/** + * The event blurb that follows a detail table. + * + * Scans forward only to the next heading or table, so a description never + * leaks from one event onto another. Call-to-action paragraphs ("… Event + * Guide") are skipped — they are navigation, not description. + */ +function summaryAfter(nodes: DocNode[], from: number): string | null { + for (let i = from + 1; i < nodes.length; i += 1) { + const node = nodes[i]; + if (node === undefined) break; + if (node.kind !== "p") break; + if (node.isButton || node.text.length === 0) continue; + return node.text.slice(0, 500); + } + return null; +} + /** Shape 1: `Event Start` / `Event End` / `Availability Period` rows. */ function readLabelledDates( pairs: Array<{ label: string; value: string }>,