Files
gacha-event-tracker/src/ingest/html.ts
T
Lucas WintherandClaude Opus 5 6fffd283a5 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]>
2026-08-15 00:39:42 +02:00

137 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Minimal, dependency-free HTML reading for adapters.
*
* Deliberately not a general HTML parser. It handles the one shape adapters
* need — a linear walk of headings and flat tables — and is only safe because
* adapters assert their fixtures have no nested tables. If a source ever needs
* more than this, use Bun's built-in HTMLRewriter rather than growing regexes.
*/
const ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ndash: "",
mdash: "—",
hellip: "…",
rsquo: "",
lsquo: "",
ldquo: "“",
rdquo: "”",
};
export function decodeEntities(input: string): string {
return input
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) =>
String.fromCodePoint(parseInt(hex, 16)),
)
.replace(/&#(\d+);/g, (_, dec: string) =>
String.fromCodePoint(parseInt(dec, 10)),
)
.replace(/&([a-zA-Z]+);/g, (whole, name: string) => ENTITIES[name] ?? whole);
}
/** Strip tags, decode entities, collapse whitespace. */
export function text(html: string): string {
return decodeEntities(html.replace(/<[^>]*>/g, " "))
.replace(/\s+/g, " ")
.trim();
}
export interface TableNode {
kind: "table";
/** Every <th> → next-sibling <td> pair, text-only. Empty for column tables. */
pairs: Array<{ label: string; value: string }>;
/** All <th> texts, in order — lets a caller recognise a header-row table. */
headers: string[];
/** Every <tr> as its cell texts, header row included. For column tables. */
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
| ParagraphNode;
/**
* Walk a document in source order, yielding h2/h3 headings and tables.
*
* Pure: no network, no clock. Given identical input it always yields identical
* output, which is what makes fixture tests meaningful.
*/
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>|<p\b[^>]*>([\s\S]*?)<\/p>/gi;
for (const m of html.matchAll(re)) {
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) });
}
}
return nodes;
}
function readTable(body: string): TableNode {
const headers: string[] = [];
for (const m of body.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)) {
headers.push(text(m[1] ?? ""));
}
// Label/value rows: a <th> immediately followed by a <td>.
const pairs: Array<{ label: string; value: string }> = [];
const pairRe = /<th\b[^>]*>([\s\S]*?)<\/th>\s*<td\b[^>]*>([\s\S]*?)<\/td>/gi;
for (const m of body.matchAll(pairRe)) {
pairs.push({ label: text(m[1] ?? ""), value: text(m[2] ?? "") });
}
// Row/cell grid, for column-oriented tables.
const rows: string[][] = [];
for (const tr of body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
const cells: string[] = [];
for (const cell of (tr[1] ?? "").matchAll(
/<t([dh])\b[^>]*>([\s\S]*?)<\/t\1>/gi,
)) {
cells.push(text(cell[2] ?? ""));
}
if (cells.length > 0) rows.push(cells);
}
return { kind: "table", pairs, headers, rows };
}
/** True when the document contains no nested <table> elements. */
export function assertFlatTables(rawHtml: string): boolean {
let depth = 0;
for (const m of rawHtml.matchAll(/<table\b[^>]*>|<\/table>/gi)) {
depth += m[0].startsWith("</") ? -1 : 1;
if (depth > 1) return false;
}
return depth === 0;
}