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
@@ -52,6 +52,7 @@ Consequences worth internalising:
|
||||
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
|
||||
| `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights |
|
||||
| `fandom` | Fandom wikis via the MediaWiki `action=parse` API — five page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables, Infinity Nikki's `Event \| Duration \| Description \| Type` article-tables, and Genshin Impact's `Event \| Duration \| Type(s)` tables | Reverse: 1999, Fate/Grand Order, Nikke, Infinity Nikki, Genshin Impact |
|
||||
| `fandomtable` | Fandom's dated **history table** via the same `action=parse` API — one `article-table` headed `Name \| Start \| End` (plus Image/Version), a row per run of a battle pass, found by heading rather than column position and skipped when its heading row names `Duration` (that is a version table). Titles come from a per-source `PASS_SOURCES` entry; only rows not yet ended by `ctx.now` are emitted, at day precision | Star Rail (Nameless Honor) |
|
||||
| `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — redesigned `#eventtable` unified schedule (and legacy JP/Global tabber) | Blue Archive |
|
||||
| `holodoriwiki` | holodori.wiki's rendered `/wiki/Events` — `Current Events` and `Past Events` wikitables over `Event \| Type \| Start Date \| End Date` | hololive Dreams |
|
||||
| `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 |
|
||||
|
||||
@@ -88,6 +88,7 @@ Two things this method cannot tell you, and both matter:
|
||||
| Aether Gazer | — | — | — | **Do not build. The game is shutting down.** |
|
||||
| Silver Palace | — | — | — | Unreleased; beta only |
|
||||
| Genshin Impact (Fandom) | `genshin-impact.fandom.com/api.php?…page=Event` | `fandom`, fifth template | yes (via `api.php`) | **BUILT** 2026-09-12 — adds 11 events Game8 missed; Game8 priority preserves localStorage IDs |
|
||||
| Star Rail — Nameless Honor | `honkai-star-rail.fandom.com/api.php?…page=Nameless_Honor` | new (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the pass window as one `other` event, day precision |
|
||||
| hololive Dreams | `holodori.wiki/wiki/Events` (still) | `holodoriwiki` | yes, but nothing new to fetch | **LAPSED** 2026-10-14 — NamuWiki is behind a Cloudflare challenge, the official site posts no events, Game8 has no page |
|
||||
|
||||
---
|
||||
@@ -854,6 +855,20 @@ page lists events again, the refresh warns that the source is listing events aga
|
||||
**Re-check:** `bun run refresh --only holodori-holodoriwiki-events` once the six-hour floor has
|
||||
passed. A `fetched` with more than zero events means the wiki is back.
|
||||
|
||||
## 17. Star Rail — Nameless Honor pass window (Fandom) — BUILT 2026-10-16
|
||||
|
||||
**Source:** `https://honkai-star-rail.fandom.com/api.php?action=parse&page=Nameless_Honor&prop=text&formatversion=2&format=json` (page `https://honkai-star-rail.fandom.com/wiki/Nameless_Honor`), source id `hsr-fandom-nameless-honor`. First of the pass-window sources in `docs/superpowers/specs/2026-10-16-pass-windows-design.md`.
|
||||
|
||||
**What it is:** Star Rail's battle pass. Event pages state no pass dates, but this page keeps a history table, one row per run, with the version each belonged to. Each row that has not ended by `ctx.now` becomes one event titled exactly `Nameless Honor`, type `other`.
|
||||
|
||||
**Conduct:** `robots.txt` read with the runner's own `RobotsCache` and real User-Agent on 2026-10-16: `200`, allowed. `Allow: /api.php?action=` for `*`, no `Disallow: /` for `*`; the `Disallow: /` groups name other crawlers (GPTBot, CCBot, OAI-SearchBot, ClaudeBot, ImagesiftBot, Grok*, Semrush, serpstatbot), and the only `Crawl-delay` belongs to `ias_crawler`. Recorded in the `SOURCES` entry.
|
||||
|
||||
**Shape:** the second `article-table` on the page, `Image | Name | Start | End | Version`, 31 rows (2023-04-26 to 2026-09-28; verified by counting the page's `<tr>`s independently of the parser). Cells print a bare date (`September 28, 2026`). Each cell also has a `data-sort-value` with a clock (`11:00:00` starts, `03:59:59` ends) that the page ties to no zone, so it is not read.
|
||||
|
||||
**Precision:** day, start and end. Nothing is inferred: no clock, no region, no end for a row without one (skipped). At capture one row is live, 2026-09-28 to 2026-11-09 (4.6); each row ends on the day the next one starts. The Name cell reads `Nameless Honor <start date>`, so the title is constant and the start is the only thing that tells runs apart in the ID.
|
||||
|
||||
**Reuse:** the parser is `fandomtable` (`src/ingest/parsers/fandomTable.ts`). The next pass pages (ZZZ `New_Eridu_City_Fund`, Genshin `Miliastra_Pass`) are one `PASS_SOURCES` entry each (title as a function of the row's Name cell) plus a `SOURCES` entry; columns are found by heading. A table headed with `Duration` is deliberately not this template (it is a version table).
|
||||
|
||||
## What every one of these costs, beyond the source
|
||||
|
||||
Adding a game is never only a `SOURCES` entry:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": "hsr:nameless-honor:2026-09-28",
|
||||
"game": "hsr",
|
||||
"title": "Nameless Honor",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-09-28T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-11-09T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://honkai-star-rail.fandom.com/wiki/Nameless_Honor/2026-09-28",
|
||||
"sourceId": "hsr-fandom-nameless-honor",
|
||||
"status": "published",
|
||||
"confidence": 0.85,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-10-16T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-16T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -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";
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Glob } from "bun";
|
||||
import { adapterById } from "../../src/ingest/adapters/index.ts";
|
||||
import { PARSERS } from "../../src/ingest/parsers/index.ts";
|
||||
import {
|
||||
fandomTableParser,
|
||||
PASS_SOURCES,
|
||||
} from "../../src/ingest/parsers/fandomTable.ts";
|
||||
|
||||
const FIXTURE = "fixtures/hsr/fandom-nameless-honor-2026-10-16.html";
|
||||
const SOURCE_ID = "hsr-fandom-nameless-honor";
|
||||
|
||||
function ctx(now: string, sourceId = SOURCE_ID) {
|
||||
const a = adapterById(SOURCE_ID)!;
|
||||
return { now, sourceUrl: a.url, sourceId, game: a.game };
|
||||
}
|
||||
|
||||
/** A minimal `action=parse` body holding one pass table. */
|
||||
function body(rows: Array<[string, string, string]>, headings = "Name | Start | End") {
|
||||
const th = headings.split(" | ").map((h) => `<th>${h}</th>`).join("");
|
||||
const tr = rows
|
||||
.map(([n, s, e]) => `<tr><td>${n}</td><td>${s}</td><td>${e}</td></tr>`)
|
||||
.join("");
|
||||
return JSON.stringify({
|
||||
parse: { title: "T", text: `<table><tr>${th}</tr>${tr}</table>` },
|
||||
});
|
||||
}
|
||||
|
||||
describe("fandomtable parser", () => {
|
||||
const html = Bun.file(FIXTURE).text();
|
||||
|
||||
test("reads every row of the page's table (31, an independent count)", async () => {
|
||||
// Early enough that no row has ended: all 31 `<tr>` data rows must parse.
|
||||
const events = fandomTableParser.parse(await html, ctx("2020-01-01T00:00:00.000Z"));
|
||||
expect(events).toHaveLength(31);
|
||||
expect(new Set(events.map((e) => e.id)).size).toBe(31);
|
||||
});
|
||||
|
||||
test("current and upcoming rows only: past rows are absent", async () => {
|
||||
const events = fandomTableParser.parse(await html, ctx("2026-10-16T00:00:00.000Z"));
|
||||
expect(events.map((e) => [e.title, e.startsAt, e.endsAt])).toEqual([
|
||||
["Nameless Honor", "2026-09-28T00:00:00.000Z", "2026-11-09T00:00:00.000Z"],
|
||||
]);
|
||||
// Earlier clock: the live row, the one that follows it, and 4.6 upcoming.
|
||||
const earlier = fandomTableParser.parse(await html, ctx("2026-08-14T00:00:00.000Z"));
|
||||
expect(earlier.map((e) => e.startsAt.slice(0, 10))).toEqual([
|
||||
"2026-07-15",
|
||||
"2026-08-26",
|
||||
"2026-09-28",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a row stays on its last day: the placeholder midnight is not the end", async () => {
|
||||
const events = fandomTableParser.parse(await html, ctx("2026-11-09T05:00:00.000Z"));
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("day precision, never a clock the page did not state", async () => {
|
||||
for (const e of fandomTableParser.parse(await html, ctx("2020-01-01T00:00:00.000Z"))) {
|
||||
expect(e.startPrecision).toBe("day");
|
||||
expect(e.endPrecision).toBe("day");
|
||||
expect(e.type).toBe("other");
|
||||
expect(e.startsAt.endsWith("T00:00:00.000Z")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a row without a readable end is skipped, not given one", () => {
|
||||
const events = fandomTableParser.parse(
|
||||
body([
|
||||
["A", "September 1, 2026", "TBA"],
|
||||
["B", "September 1, 2026", ""],
|
||||
["C", "September 1, 2026", "October 6"],
|
||||
["D", "September 1, 2026", "October 12, 2026"],
|
||||
]),
|
||||
ctx("2026-09-10T00:00:00.000Z"),
|
||||
);
|
||||
expect(events.map((e) => e.endsAt)).toEqual(["2026-10-12T00:00:00.000Z"]);
|
||||
});
|
||||
|
||||
test("a source id with no title entry emits nothing", () => {
|
||||
const b = body([["A", "September 1, 2026", "October 12, 2026"]]);
|
||||
expect(fandomTableParser.parse(b, ctx("2026-09-10T00:00:00.000Z"))).toHaveLength(1);
|
||||
expect(fandomTableParser.parse(b, ctx("2026-09-10T00:00:00.000Z", "x-unknown"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("every PASS_SOURCES key is a registered source on this parser", () => {
|
||||
for (const id of Object.keys(PASS_SOURCES)) {
|
||||
expect(adapterById(id)?.parserId).toBe("fandomtable");
|
||||
}
|
||||
});
|
||||
|
||||
test("non-API bodies are refused rather than read as empty", () => {
|
||||
expect(fandomTableParser.canParse("<html><table></table></html>")).toBe(false);
|
||||
expect(fandomTableParser.canParse('{"error":{"code":"missingtitle"}}')).toBe(false);
|
||||
});
|
||||
|
||||
test("a table with a Duration column (a version table) is not this template", () => {
|
||||
expect(
|
||||
fandomTableParser.canParse(
|
||||
body([], "Name | Version | Start | End | Duration"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(fandomTableParser.canParse(body([], "Image | Name | Start | End | Version"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fandomtable canParse across every fixture", () => {
|
||||
const files = [...new Glob("fixtures/**/*.html").scanSync(".")].sort();
|
||||
|
||||
test("there are fixtures to check", () => {
|
||||
expect(files.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test("true on the Nameless Honor fixture only", async () => {
|
||||
const accepted: string[] = [];
|
||||
for (const f of files) {
|
||||
if (fandomTableParser.canParse(await Bun.file(f).text())) accepted.push(f);
|
||||
}
|
||||
expect(accepted).toEqual([FIXTURE]);
|
||||
});
|
||||
|
||||
test("no other parser claims the Nameless Honor fixture", async () => {
|
||||
const text = await Bun.file(FIXTURE).text();
|
||||
const claimants = PARSERS.filter((p) => p.canParse(text)).map((p) => p.id);
|
||||
expect(claimants).toEqual(["fandomtable"]);
|
||||
});
|
||||
});
|
||||
+16
-12
@@ -24,7 +24,7 @@ function adapter(id: string): Adapter {
|
||||
const genshinGame8 = adapter("genshin-game8-events");
|
||||
const nteGame8 = adapter("nte-game8-events");
|
||||
|
||||
const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
const CASES: Array<{ adapter: Adapter; fixture: string; now?: string }> = [
|
||||
{ adapter: genshinGame8, fixture: "fixtures/genshin/game8-events-2026-08-14" },
|
||||
{ adapter: nteGame8, fixture: "fixtures/nte/game8-events-2026-08-14" },
|
||||
{ adapter: adapter("hsr-game8-events"), fixture: "fixtures/hsr/game8-events-2026-08-14" },
|
||||
@@ -69,39 +69,43 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
{ adapter: adapter("hi3-arustats-events"), fixture: "fixtures/hi3/arustats-events-2026-08-27" },
|
||||
// Punishing: Gray Raven (karendar.com). Exact UTC timestamps for the Global server.
|
||||
{ adapter: adapter("pgr-karendar-events"), fixture: "fixtures/pgr/karendar-events-2026-09-12" },
|
||||
// Star Rail's Nameless Honor pass history (Fandom, `fandomtable` parser).
|
||||
// Pinned at the capture day, not NOW: the page is a history, so which rows
|
||||
// survive depends on the clock, and at capture exactly one does (4.6).
|
||||
{ adapter: adapter("hsr-fandom-nameless-honor"), fixture: "fixtures/hsr/fandom-nameless-honor-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
|
||||
];
|
||||
|
||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||
async function runAdapter(adapter: Adapter, fixture: string, now: string = NOW) {
|
||||
const html = await Bun.file(`${fixture}.html`).text();
|
||||
return adapter.parse(html, {
|
||||
now: NOW,
|
||||
now,
|
||||
sourceUrl: adapter.url,
|
||||
sourceId: adapter.id,
|
||||
game: adapter.game,
|
||||
});
|
||||
}
|
||||
|
||||
describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
|
||||
describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture, now }) => {
|
||||
test("matches the checked-in expected output", async () => {
|
||||
const events = await runAdapter(adapter, fixture);
|
||||
const events = await runAdapter(adapter, fixture, now);
|
||||
const expected = await Bun.file(`${fixture}.expected.json`).json();
|
||||
expect(events).toEqual(expected);
|
||||
});
|
||||
|
||||
test("every event satisfies the schema", async () => {
|
||||
for (const event of await runAdapter(adapter, fixture)) {
|
||||
for (const event of await runAdapter(adapter, fixture, now)) {
|
||||
expect(() => GachaEvent.parse(event)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("is deterministic across runs", async () => {
|
||||
const a = await runAdapter(adapter, fixture);
|
||||
const b = await runAdapter(adapter, fixture);
|
||||
const a = await runAdapter(adapter, fixture, now);
|
||||
const b = await runAdapter(adapter, fixture, now);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test("never emits an end before its start", async () => {
|
||||
for (const e of await runAdapter(adapter, fixture)) {
|
||||
for (const e of await runAdapter(adapter, fixture, now)) {
|
||||
if (e.endsAt !== null) expect(e.endsAt > e.startsAt).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -121,7 +125,7 @@ describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
|
||||
// the genuinely year-long event, which is a shape this domain does have:
|
||||
// the same widening clears Fire Emblem Heroes' real seven-month
|
||||
// new-player banner (docs/SOURCES.md § 12b).
|
||||
for (const e of await runAdapter(adapter, fixture)) {
|
||||
for (const e of await runAdapter(adapter, fixture, now)) {
|
||||
if (e.endsAt === null) continue;
|
||||
const days =
|
||||
(Date.parse(e.endsAt) - Date.parse(e.startsAt)) / 86_400_000;
|
||||
@@ -130,7 +134,7 @@ describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
|
||||
});
|
||||
|
||||
test("event IDs are unique and stable in shape", async () => {
|
||||
const events = await runAdapter(adapter, fixture);
|
||||
const events = await runAdapter(adapter, fixture, now);
|
||||
const ids = events.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const e of events) {
|
||||
@@ -141,7 +145,7 @@ describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
|
||||
});
|
||||
|
||||
test("excludes permanent and past sections", async () => {
|
||||
const titles = (await runAdapter(adapter, fixture)).map((e) => e.title);
|
||||
const titles = (await runAdapter(adapter, fixture, now)).map((e) => e.title);
|
||||
// Permanent entries carry no dates; past entries ended before the fixture
|
||||
// date. Neither belongs on a "what's live / what's next" calendar.
|
||||
for (const t of titles) expect(t).not.toMatch(/permanent/i);
|
||||
|
||||
+10
-2
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adapterById, ADAPTERS } from "../src/ingest/adapters/index.ts";
|
||||
import { parserById } from "../src/ingest/parsers/index.ts";
|
||||
import { pickFixture } from "../src/ingest/snapshots.ts";
|
||||
import {
|
||||
LIMITS,
|
||||
sanitizeEvent,
|
||||
@@ -537,8 +538,15 @@ describe("the adapter seam", () => {
|
||||
const parser = parserById(adapter.parserId);
|
||||
if (parser === undefined) throw new Error(`no parser ${adapter.parserId}`);
|
||||
|
||||
const pattern = `fixtures/${adapter.game}/${adapter.parserId}-*.html`;
|
||||
const file = [...new Bun.Glob(pattern).scanSync(".")].sort().at(-1);
|
||||
// The source's own fixture, selected exactly as `build-feed` selects it
|
||||
// (`<id without game>-<date>.html`). The parser id is not the prefix for
|
||||
// a template that shares a site with another: `fandomtable` reads
|
||||
// `fandom-nameless-honor-*`.
|
||||
const file = pickFixture(
|
||||
adapter.id,
|
||||
adapter.game,
|
||||
[...new Bun.Glob(`fixtures/${adapter.game}/*.html`).scanSync(".")],
|
||||
);
|
||||
if (file === undefined) throw new Error(`no fixture for ${adapter.id}`);
|
||||
|
||||
const html = await Bun.file(file).text();
|
||||
|
||||
Reference in New Issue
Block a user