fix(game8): decide a column table's header by what it produces

Game8's banner pages lay two schedules side by side inside one <table> under a
spanning label row: "Standard Banners | Banner | Rating | Availability | Paid
Banners | ...". That row is not merely unhelpful, it is plausible — it contains
both column words, so it resolves and puts the range at index 3, which no
three-cell data row has. Every row then fails to date and the table yields
nothing at all, with no error anywhere. So readColumnTable now falls back to
row 1, and only when row 0 produced nothing, which is what keeps every page
that parses today parsing identically.

Those pages also schedule banners rather than events and head their sections
accordingly, so the vocabulary learns "List of All Banners", "All Current
Banners" and — the one that matters — "Previous Banners", whose rows are dated
identically to the live ones directly above them.

This parser serves nine sources, so the widening was measured rather than
trusted: every pinned fixture and every live snapshot was parsed before and
after, and no existing source's output changed by a single event.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 03:51:48 +02:00
co-authored by Claude Opus 5
parent 6c7369db10
commit 220e73ff7a
4 changed files with 154 additions and 16 deletions
+59 -11
View File
@@ -45,6 +45,13 @@ const INCLUDED_SECTIONS = [
/list of (all )?events/i,
/all available events/i,
/ongoing events/i,
// Some Game8 wikis schedule banners rather than events, and head their
// sections accordingly — Umamusume's page is `List of All Banners` →
// `All Current Banners`. Kept as separate patterns rather than making
// "events?" optional above, so "Banner Guides" (a nav table) still matches
// nothing.
/list of (all )?banners/i,
/current banners/i,
];
/**
@@ -57,6 +64,12 @@ const EXCLUDED_SECTIONS = [
/previous events/i,
/ended events/i,
/finished events/i,
// The banner-scheduling pages need their own back-catalogue heading for the
// same reason: Umamusume's finished rows sit under `Previous Banners`, which
// `previous events` does not match, and they are dated exactly like the live
// ones directly above them.
/previous banners/i,
/past banners/i,
];
/**
@@ -72,9 +85,9 @@ const END_LABEL = /^(event|test run|banner)?\s*end(\s+date)?$/i;
const RANGE_LABEL = /^(availability period|event period|duration|period|dates)$/i;
/** Column-table header matchers. */
const COL_TITLE = /^(.*\b)?events?$/i;
const COL_TITLE = /^(.*\b)?(events?|banners?)$/i;
const COL_RANGE =
/^(event |all )?(duration|dates?|event date|period|availability period|schedule)( ?& ?summary| and summary)?$/i;
/^(event |all )?(duration|dates?|event date|period|availability(?: period)?(?: \(utc\))?|schedule)( ?& ?summary| and summary)?$/i;
const COL_START = /^start$/i;
const COL_END = /^end$/i;
const COL_SUMMARY = /^(event )?(details?|description|overview)$/i;
@@ -205,18 +218,32 @@ function readLabelledDates(
/** Shape 2: one row per event, with a title column and a range column. */
function readColumnTable(rows: string[][]): Candidate[] {
if (rows.length < 2) return [];
const header = rows[0];
if (header === undefined) return [];
// Usually row 0 heads the table. Where it does not, row 1 does: Game8 lays
// two schedules side by side inside one `<table>` and gives the pair a
// spanning label row — Umamusume's current banners are headed
// `Standard Banners | Banner | Rating | Availability | Paid Banners | …`,
// with the real header on the row below and three-cell data rows under that.
//
// That label row is not merely unhelpful, it is *plausible*: it contains the
// word "Banners" and the word "Availability", so it resolves both columns and
// puts the range at index 3, which no data row has. Every row then fails to
// date and the table yields nothing at all. So the header is decided by what
// it produces rather than by where it sits — and row 0 still wins whenever it
// produces anything, which is what keeps every page that parses today parsing
// exactly as it did.
const fromFirst = readRowsUnder(rows, 0);
return fromFirst.length > 0 ? fromFirst : readRowsUnder(rows, 1);
}
const titleIdx = header.findIndex((h) => COL_TITLE.test(h));
const rangeIdx = header.findIndex((h) => COL_RANGE.test(h));
if (titleIdx === -1 || rangeIdx === -1) return [];
const summaryIdx = header.findIndex((h) => COL_SUMMARY.test(h));
/** Read `rows`, treating row `headerIdx` as the header and the rest as data. */
function readRowsUnder(rows: string[][], headerIdx: number): Candidate[] {
if (rows.length < headerIdx + 2) return [];
const layout = columnLayout(rows[headerIdx]);
if (layout === null) return [];
const { titleIdx, rangeIdx, summaryIdx } = layout;
const out: Candidate[] = [];
for (const row of rows.slice(1)) {
for (const row of rows.slice(headerIdx + 1)) {
const title = row[titleIdx]?.trim();
const rangeCell = row[rangeIdx];
if (!title || rangeCell === undefined) continue;
@@ -241,6 +268,27 @@ function readColumnTable(rows: string[][]): Candidate[] {
return out;
}
interface ColumnLayout {
titleIdx: number;
rangeIdx: number;
summaryIdx: number;
}
/** Where the columns sit, if this row is a header row at all. */
function columnLayout(header: string[] | undefined): ColumnLayout | null {
if (header === undefined) return null;
const titleIdx = header.findIndex((h) => COL_TITLE.test(h));
const rangeIdx = header.findIndex((h) => COL_RANGE.test(h));
if (titleIdx === -1 || rangeIdx === -1) return null;
return {
titleIdx,
rangeIdx,
summaryIdx: header.findIndex((h) => COL_SUMMARY.test(h)),
};
}
/**
* Try every known range shape, most specific first. `parseOpenRange` is last
* because it is the most permissive — it accepts any leading full date and