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:
co-authored by
Claude Opus 5
parent
62f673161b
commit
6fffd283a5
+18
-3
@@ -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(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " ");
|
||||
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)) {
|
||||
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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>,
|
||||
|
||||
Reference in New Issue
Block a user