feat(sources): Star Rail's Nameless Honor pass windows from Fandom

New `fandomtable` parser (src/ingest/parsers/fandomTable.ts) reads Fandom's
dated pass-history table by its Name/Start/End headings and emits runs not
yet ended by ctx.now, at day precision. Titles come from a per-source
PASS_SOURCES table so the next pass pages are one entry each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-16 06:17:00 +02:00
co-authored by Claude Opus 5.5
parent 40d0dbef5a
commit 0a4be4e339
10 changed files with 444 additions and 14 deletions
+21
View File
@@ -116,6 +116,27 @@ const SOURCES: SourceSpec[] = [
url: "https://genshin-impact.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json",
parserId: "fandom",
},
{
id: "hsr-fandom-nameless-honor",
game: "hsr",
// The API, as every Fandom source (AGENTS.md § Fandom). robots.txt of
// honkai-star-rail.fandom.com read with the runner's own client
// (`RobotsCache`, the real User-Agent) on 2026-10-16: HTTP 200,
// `robots.txt ok`, and this API path allowed for `User-agent: *`
// (`Allow: /api.php?action=`, no `Disallow: /` for `*`). The groups that
// do `Disallow: /` name other crawlers (GPTBot, CCBot, OAI-SearchBot,
// ClaudeBot, ImagesiftBot, Grok*, Semrush, serpstatbot), not us; the only
// `Crawl-delay` (1) is `ias_crawler`'s, and the runner's 2s host gap
// exceeds it anyway.
//
// A pass history, not an event page: one `Name | Start | End | Version`
// table with a row per run, at day precision (the page prints no times).
// Read by the `fandomtable` parser, which emits only runs that have not
// ended by `ctx.now`. The title comes from `PASS_SOURCES` in
// `parsers/fandomTable.ts`, keyed by this id.
url: "https://honkai-star-rail.fandom.com/api.php?action=parse&page=Nameless_Honor&prop=text&formatversion=2&format=json",
parserId: "fandomtable",
},
{
id: "r1999-fandom-events",
game: "r1999",
+227
View File
@@ -0,0 +1,227 @@
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",
},
};
/** 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,
};
+3
View File
@@ -2,6 +2,7 @@ import { arknightsWikiParser } from "./akwiki.ts";
import { aruStatsParser } from "./arustats.ts";
import { blueArchiveWikiParser } from "./bawiki.ts";
import { fandomParser } from "./fandom.ts";
import { fandomTableParser } from "./fandomTable.ts";
import { game8Parser } from "./game8.ts";
import { holodoriWikiParser } from "./holodori.ts";
import { iopWikiParser } from "./iopwiki.ts";
@@ -20,6 +21,7 @@ export const PARSERS: SourceParser[] = [
wikiGgParser,
arknightsWikiParser,
fandomParser,
fandomTableParser,
blueArchiveWikiParser,
holodoriWikiParser,
iopWikiParser,
@@ -37,6 +39,7 @@ export { arknightsWikiParser } from "./akwiki.ts";
export { aruStatsParser } from "./arustats.ts";
export { blueArchiveWikiParser } from "./bawiki.ts";
export { fandomParser } from "./fandom.ts";
export { fandomTableParser } from "./fandomTable.ts";
export { game8Parser } from "./game8.ts";
export { holodoriWikiParser } from "./holodori.ts";
export { iopWikiParser } from "./iopwiki.ts";