feat: extract event descriptions from Game8 detail tables

Column tables already carried a description column, but label/value detail
tables did not — Genshin events had no blurb at all. The page follows each
detail table with a sentence of prose, so take that, skipping the
call-to-action paragraphs, which are navigation rather than description.

Summary coverage goes from 13/22 events to 22/22.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:39:42 +02:00
co-authored by Claude Opus 5
parent 62f673161b
commit 6fffd283a5
3 changed files with 58 additions and 16 deletions
@@ -4,7 +4,7 @@
"game": "genshin", "game": "genshin",
"title": "To Temper Thyself and Journey Far", "title": "To Temper Thyself and Journey Far",
"type": "other", "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", "startsAt": "2026-08-10T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-11-02T00:00:00.000Z", "endsAt": "2026-11-02T00:00:00.000Z",
@@ -25,7 +25,7 @@
"game": "genshin", "game": "genshin",
"title": "Battle Pass - Frostfarer", "title": "Battle Pass - Frostfarer",
"type": "other", "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", "startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-09-21T00:00:00.000Z", "endsAt": "2026-09-21T00:00:00.000Z",
@@ -46,7 +46,7 @@
"game": "genshin", "game": "genshin",
"title": "Character Test Runs", "title": "Character Test Runs",
"type": "challenge", "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", "startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-09-01T00:00:00.000Z", "endsAt": "2026-09-01T00:00:00.000Z",
@@ -67,7 +67,7 @@
"game": "genshin", "game": "genshin",
"title": "Mutual Aid in Bloom: Into the Frostlands", "title": "Mutual Aid in Bloom: Into the Frostlands",
"type": "other", "type": "other",
"summary": null, "summary": "Take photos and complete objectives in this Snezhnaya exploration event!",
"startsAt": "2026-08-12T00:00:00.000Z", "startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-08-24T00:00:00.000Z", "endsAt": "2026-08-24T00:00:00.000Z",
@@ -88,7 +88,7 @@
"game": "genshin", "game": "genshin",
"title": "Snezhnaya Travel Guide Web Event", "title": "Snezhnaya Travel Guide Web Event",
"type": "other", "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", "startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-08-25T00:00:00.000Z", "endsAt": "2026-08-25T00:00:00.000Z",
@@ -109,7 +109,7 @@
"game": "genshin", "game": "genshin",
"title": "Stygian Onslaught", "title": "Stygian Onslaught",
"type": "challenge", "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", "startsAt": "2026-08-19T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-09-22T00:00:00.000Z", "endsAt": "2026-09-22T00:00:00.000Z",
@@ -130,7 +130,7 @@
"game": "genshin", "game": "genshin",
"title": "Great Expeditionist Challenge", "title": "Great Expeditionist Challenge",
"type": "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", "startsAt": "2026-08-28T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-09-14T00:00:00.000Z", "endsAt": "2026-09-14T00:00:00.000Z",
@@ -151,7 +151,7 @@
"game": "genshin", "game": "genshin",
"title": "Trial of the Bastion", "title": "Trial of the Bastion",
"type": "challenge", "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", "startsAt": "2026-09-07T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-09-17T00:00:00.000Z", "endsAt": "2026-09-17T00:00:00.000Z",
@@ -172,7 +172,7 @@
"game": "genshin", "game": "genshin",
"title": "Overflowing Abundance Rerun", "title": "Overflowing Abundance Rerun",
"type": "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", "startsAt": "2026-09-14T00:00:00.000Z",
"startPrecision": "day", "startPrecision": "day",
"endsAt": "2026-09-21T00:00:00.000Z", "endsAt": "2026-09-21T00:00:00.000Z",
+18 -3
View File
@@ -51,10 +51,22 @@ export interface TableNode {
rows: string[][]; 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 = export type DocNode =
| { kind: "h2"; text: string } | { kind: "h2"; text: string }
| { kind: "h3"; text: string } | { kind: "h3"; text: string }
| TableNode; | TableNode
| ParagraphNode;
/** /**
* Walk a document in source order, yielding h2/h3 headings and tables. * 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(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " "); const html = rawHtml.replace(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " ");
const nodes: DocNode[] = []; const nodes: DocNode[] = [];
const re = /<h2\b[^>]*>([\s\S]*?)<\/h2>|<h3\b[^>]*>([\s\S]*?)<\/h3>|<table\b[^>]*>([\s\S]*?)<\/table>/gi; const re =
/<h2\b[^>]*>([\s\S]*?)<\/h2>|<h3\b[^>]*>([\s\S]*?)<\/h3>|<table\b[^>]*>([\s\S]*?)<\/table>|<p\b[^>]*>([\s\S]*?)<\/p>/gi;
for (const m of html.matchAll(re)) { for (const m of html.matchAll(re)) {
const [, h2, h3, table] = m; const [whole, h2, h3, table, p] = m;
if (h2 !== undefined) { if (h2 !== undefined) {
nodes.push({ kind: "h2", text: text(h2) }); nodes.push({ kind: "h2", text: text(h2) });
} else if (h3 !== undefined) { } else if (h3 !== undefined) {
nodes.push({ kind: "h3", text: text(h3) }); nodes.push({ kind: "h3", text: text(h3) });
} else if (table !== undefined) { } else if (table !== undefined) {
nodes.push(readTable(table)); nodes.push(readTable(table));
} else if (p !== undefined) {
nodes.push({ kind: "p", text: text(p), isButton: /a-btn/.test(whole) });
} }
} }
+31 -4
View File
@@ -9,7 +9,7 @@ import {
parseSlashDateTimeRange, parseSlashDateTimeRange,
type ParsedInstant, type ParsedInstant,
} from "../dates.ts"; } 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 { ParseContext } from "../adapters/types.ts";
import type { SourceParser } from "./types.ts"; import type { SourceParser } from "./types.ts";
@@ -84,7 +84,10 @@ export function parseGame8EventsPage(
let sectionIncluded = false; let sectionIncluded = false;
let currentTitle: string | null = null; 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 // 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 // tracked at whichever level actually names the section. A heading matching
// neither list leaves the current state alone — it is an event name. // neither list leaves the current state alone — it is an event name.
@@ -102,7 +105,7 @@ export function parseGame8EventsPage(
continue; continue;
} }
if (!sectionIncluded) continue; if (!sectionIncluded || node.kind !== "table") continue;
const fromColumns = readColumnTable(node.rows); const fromColumns = readColumnTable(node.rows);
if (fromColumns.length > 0) { if (fromColumns.length > 0) {
@@ -114,7 +117,13 @@ export function parseGame8EventsPage(
const dates = readLabelledDates(node.pairs); const dates = readLabelledDates(node.pairs);
if (dates === null) continue; 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 // One dated table per heading; ignore follow-on reward tables until the
// next heading. // next heading.
currentTitle = null; currentTitle = null;
@@ -123,6 +132,24 @@ export function parseGame8EventsPage(
return dedupe(candidates.map((c) => toEvent(c, ctx))); 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. */ /** Shape 1: `Event Start` / `Event End` / `Availability Period` rows. */
function readLabelledDates( function readLabelledDates(
pairs: Array<{ label: string; value: string }>, pairs: Array<{ label: string; value: string }>,