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:
co-authored by
Claude Opus 5
parent
e8293c7e8c
commit
caefe6b0d7
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user