feat: add HTML table reader and date parsing

Every date function returns null rather than inferring a missing year,
month or end date. A source that does not state an end yields
endsAt: null, never a plausible guess — a confidently wrong end date is
the failure this product exists to prevent.

Handles the three formats seen on Game8: "August 12, 2026",
"August 12 - September 21, 2026" (year applies to both ends, rolling the
start back when the range crosses New Year), and a slash datetime range.
Abbreviated months parse too, so a source switching to "Apr. 29" does not
silently drop events.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:10:53 +02:00
co-authored by Claude Opus 5
parent e8293c7e8c
commit caefe6b0d7
3 changed files with 317 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* 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 type DocNode =
| { kind: "h2"; text: string }
| { kind: "h3"; text: string }
| TableNode;
/**
* 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>/gi;
for (const m of html.matchAll(re)) {
const [, h2, h3, table] = 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));
}
}
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;
}