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:
co-authored by
Claude Opus 5.5
parent
40d0dbef5a
commit
0a4be4e339
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user