Files
gacha-event-tracker/src/ingest/parsers/fandomTable.ts
T

239 lines
8.7 KiB
TypeScript

import { eventId, type GachaEvent } from "../../shared/schema.ts";
import { latestBoundaryMs } from "../../shared/time.ts";
import { parseMonthDayYear, type ParsedInstant } from "../dates.ts";
import { text } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
import { renderedHtml } from "./fandom.ts";
import type { SourceParser } from "./types.ts";
/**
* Fandom's dated *history table* — the template behind the battle-pass pages
* (`Nameless_Honor`, and next `New_Eridu_City_Fund` and `Miliastra_Pass`).
*
* It shares nothing with the event pages `fandom.ts` reads: there is no section
* fence and no "ongoing" heading, only one `article-table` with a row per past
* and current run of the pass —
*
* Image | Name | Start | End | Version
*
* — whose Start and End cells print a bare date (`September 28, 2026`). The
* cells also carry a `data-sort-value` with a clock (`2026-09-28 11:00:00`),
* which is deliberately **not read**: the page states no zone for it, and the
* visible text is the only claim the editors made. Every event is therefore
* day precision, as in the Infinity Nikki reader (AGENTS.md § Fandom).
*
* Because the table is a history, inclusion is decided against `ctx.now` and
* nothing else: a row whose end has passed is skipped, a current or upcoming
* row is emitted. A row without a readable end is skipped, never given one.
*
* ## Adding the next source
*
* Reuse is one `PASS_SOURCES` entry below plus one `SOURCES` entry. Columns are
* found by their *headings*, not by position, so a page that drops the Image
* column or the Version column still reads. The entry says how that source
* titles an event: a constant for a pass with one name, or a function of the
* row's own name cell (`Miliastra Pass: <name>`). A source id with no entry
* here emits nothing — a missing title is skipped rather than guessed.
*/
/** How one source titles the events it reads from a history table. */
export interface PassSource {
/** The wiki's own words for what this page lists, for the doc and the log. */
readonly pass: string;
/** The event title, given the row's Name cell (already tag-stripped). */
title(rowName: string): string;
}
/** Keyed by source id, because the title is a fact about the source. */
export const PASS_SOURCES: Readonly<Record<string, PassSource>> = {
"hsr-fandom-nameless-honor": {
// The Name cell reads `Nameless Honor 2026-09-28` — the run's dated
// subpage, which `eventId` already carries as the start. Constant title.
pass: "Nameless Honor",
title: () => "Nameless Honor",
},
"genshin-fandom-miliastra-pass": {
// The Name cell is the run's own chronicle name (`Silvery Moonlight
// Chronicle`), a new one each version, so the title carries it.
pass: "Miliastra Pass",
title: (rowName) => `Miliastra Pass: ${rowName}`,
},
"zzz-fandom-city-fund": {
// One fund, one name; the Name cell is the run's own label, so a constant.
pass: "New Eridu City Fund",
title: () => "New Eridu City Fund",
},
};
/** The columns a pass table is recognised by, as normalised heading text. */
const NAME_HEADINGS = new Set(["name", "pass", "pass name", "event"]);
interface Columns {
name: number;
start: number;
end: number;
}
interface Table {
columns: Columns;
rows: string[][];
/** The anchor in each row's Name cell, for `sourceUrl`; `null` when absent. */
links: Array<string | null>;
}
function cells(row: string, tag: "th" | "td"): string[] {
return [
...row.matchAll(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, "gi")),
].map((c) => c[1] ?? "");
}
/**
* Every table on the page whose headings are `Name | Start | End` (any order,
* any extra columns except `Duration`).
*
* `Duration` is the one exclusion, and it is structural: the Version page
* (Infinity Nikki's) also heads a table `Name | Version | Start | End`, but
* adds a `Duration` column, and that table is a different template with a
* different reader — it dates a *version*, not a pass.
*/
function passTables(rendered: string): Table[] {
const out: Table[] = [];
for (const table of rendered.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
const body = table[1] ?? "";
// A nested table would cut this match short and shift every column.
if (/<table\b/i.test(body)) continue;
const rowHtml = body.match(/<tr\b[\s\S]*?<\/tr>/gi) ?? [];
const head = rowHtml[0];
if (head === undefined) continue;
const headings = cells(head, "th").map((h) =>
text(h).toLowerCase().replace(/\s+/g, " "),
);
if (headings.includes("duration")) continue;
const name = headings.findIndex((h) => NAME_HEADINGS.has(h));
const start = headings.indexOf("start");
const end = headings.indexOf("end");
if (name === -1 || start === -1 || end === -1) continue;
// Row cells only line up with the headings when the row has the same
// number of them: a rowspan'd or short row is skipped, not realigned.
const width = headings.length;
const rows: string[][] = [];
const links: Array<string | null> = [];
for (const row of rowHtml.slice(1)) {
const raw = cells(row, "td");
if (raw.length !== width) continue;
rows.push(raw);
links.push(/<a\b[^>]*\bhref="([^"]+)"/i.exec(raw[name] ?? "")?.[1] ?? null);
}
out.push({ columns: { name, start, end }, rows, links });
}
return out;
}
/** A cell that is exactly one `Month D, YYYY` date and nothing else. */
function wholeDate(cell: string): ParsedInstant | null {
const value = text(cell);
if (!/^[A-Za-z]+\.? \d{1,2}, \d{4}$/.test(value)) return null;
return parseMonthDayYear(value);
}
/**
* The wiki page the source reads, as a URL, from the API URL's `page=`.
* Pure, and only used when a row's own Name cell carries no link.
*/
function pageUrl(sourceUrl: string): string {
const url = new URL(sourceUrl);
const page = url.searchParams.get("page");
return page === null
? url.origin
: `${url.origin}/wiki/${encodeURIComponent(page).replace(/%2F/g, "/")}`;
}
export function parseFandomHistoryTable(
body: string,
ctx: ParseContext,
): GachaEvent[] {
const source = PASS_SOURCES[ctx.sourceId];
if (source === undefined) return [];
const rendered = renderedHtml(body);
if (rendered === null) return [];
const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
for (const table of passTables(rendered)) {
const { name, start, end } = table.columns;
table.rows.forEach((row, i) => {
const rowName = text(row[name] ?? "");
if (rowName === "") return;
const startsAt = wholeDate(row[start] ?? "");
const endsAt = wholeDate(row[end] ?? "");
// No readable end is not an open end here: a history row is one run of a
// pass, and a run with no stated end is skipped rather than guessed.
if (startsAt === null || endsAt === null) return;
if (endsAt.iso <= startsAt.iso) return;
// `latestBoundaryMs`, not `Date.parse`: the end is day precision, and the
// raw value is UTC midnight — a placeholder the countdown resolves to the
// reader's own reset (AGENTS.md § Domain rules). Retiring the row on the
// placeholder would drop a pass hours before the game does.
if (latestBoundaryMs(endsAt.iso, endsAt.precision, ctx.game) < nowMs) {
return;
}
const title = source.title(rowName);
const href = table.links[i];
out.push({
id: eventId(ctx.game, title, startsAt.iso),
game: ctx.game,
title,
type: "other",
summary: null,
startsAt: startsAt.iso,
startPrecision: startsAt.precision,
endsAt: endsAt.iso,
endPrecision: endsAt.precision,
// The page prints a date and no clock or server; nothing here is per
// region, and nothing is invented to make it so.
regionScoped: false,
regionEnds: null,
sourceUrl:
href === null || href === undefined
? pageUrl(ctx.sourceUrl)
: new URL(href, ctx.sourceUrl).toString(),
sourceId: ctx.sourceId,
status: "published",
confidence: 0.85,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
});
});
}
return out;
}
export const fandomTableParser: SourceParser = {
id: "fandomtable",
label: "Fandom dated history table",
canParse(body: string): boolean {
// Structural: an `action=parse` envelope holding a table headed
// `Name | Start | End`. Never a row, so a pass page that is between runs
// still identifies itself.
const rendered = renderedHtml(body);
return rendered !== null && passTables(rendered).length > 0;
},
parse: parseFandomHistoryTable,
};