diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 8806be4..0cc7150 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -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 | diff --git a/docs/SOURCES.md b/docs/SOURCES.md index 27eddd0..599bd98 100644 --- a/docs/SOURCES.md +++ b/docs/SOURCES.md @@ -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 ``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 `, 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: diff --git a/fixtures/hsr/fandom-nameless-honor-2026-10-16.expected.json b/fixtures/hsr/fandom-nameless-honor-2026-10-16.expected.json new file mode 100644 index 0000000..62bccde --- /dev/null +++ b/fixtures/hsr/fandom-nameless-honor-2026-10-16.expected.json @@ -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" + } +] diff --git a/fixtures/hsr/fandom-nameless-honor-2026-10-16.html b/fixtures/hsr/fandom-nameless-honor-2026-10-16.html new file mode 100644 index 0000000..a74b236 --- /dev/null +++ b/fixtures/hsr/fandom-nameless-honor-2026-10-16.html @@ -0,0 +1 @@ +{"parse":{"title":"Nameless Honor","pageid":9602,"text":"

\n

\n\nNameless Honor is a recurring Battle Pass event in Honkai: Star Rail. It is unlocked after the completion of Trailblaze Mission Long Wait for the Blade's Edge. Once unlocked, players can complete various weekly and \"This Period\" missions in order to gain \"The\"The The Nameless EXP.\n

Each level requires \"The\"The The Nameless EXP ×800.[Note 1] Upon reaching each of the 70 levels, players will unlock rewards depending on their Battle Pass type (Nameless Gift or Nameless Glory). \"Stellar\"Stellar Stellar Jade ×150 can be used to purchase a level.\n

\n\n\n

Event Runs[]

\n
Current Event Run
\n

\n\n

\n

Asia
Ends in:
November 08, 2026 07:59:59 PM UTC
Europe
Ends in:
November 09, 2026 02:59:59 AM UTC
America
Ends in:
November 09, 2026 08:59:59 AM UTC
\n

Nameless Honor is the thirty-first Battle Pass.\n

\n
\n

Nameless Gift[]

\n

Nameless Gift is unlocked by default at the start of each Nameless Honor Period. It awards the following items after reaching the appropriate level in Nameless Honor:\n

\n
\"Fuel\" 6 Fuel
\"Credit\" 510,000 Credit
\n

Nameless Glory[]

\n

Nameless Glory can be purchased for $9.99 USD and will unlock a second tier for the current Nameless Honor Period, as well as grant \"Stellar\"Stellar Stellar Jade ×680. It includes an extra reward at Nameless Honor Lv. 30 where the player can choose one of the \"4 Light Cones from \"Jewels\"Jewels Jewels of the Starry Seas.\n

Purchasing Nameless Glory awards the following items in addition to those obtained in Nameless Gift after reaching the appropriate level in Nameless Honor:\n

\n
\"Fuel\" 6 Fuel
\"Credit\" 1,240,000 Credit
\n

Nameless Medal[]

\n

Nameless Medal can be purchased for $19.99 USD (or $11.99 if the player has already purchased Nameless Glory). It will advance Nameless Honor by 10 levels, unlock Nameless Medal Profile Pictures exclusive to the current Nameless Honor Period, and award \"Stellar\"Stellar Stellar Jade ×200 and \"Fuel\"\"Fuel\" Fuel ×5.\n

\n
\"Fuel\" 5 Fuel
\n

History[]

\n

Nameless Honor has 31 occurrences:\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Image\nName\nStart\nEnd\nVersion\n
\"Event\nNameless Honor 2023-04-26\nApril 26, 2023\nJune 5, 2023\n1.0\n
\"Event\nNameless Honor 2023-06-07\nJune 7, 2023\nJuly 17, 2023\n1.1\n
\"Event\nNameless Honor 2023-07-19\nJuly 19, 2023\nAugust 28, 2023\n1.2\n
\"Event\nNameless Honor 2023-08-30\nAugust 30, 2023\nOctober 9, 2023\n1.3\n
\"Event\nNameless Honor 2023-10-11\nOctober 11, 2023\nNovember 13, 2023\n1.4\n
\"Event\nNameless Honor 2023-11-15\nNovember 15, 2023\nDecember 25, 2023\n1.5\n
\"Event\nNameless Honor 2023-12-27\nDecember 27, 2023\nFebruary 5, 2024\n1.6\n
\"Event\nNameless Honor 2024-02-06\nFebruary 6, 2024\nMarch 25, 2024\n2.0\n
\"Event\nNameless Honor 2024-03-27\nMarch 27, 2024\nMay 6, 2024\n2.1\n
\"Event\nNameless Honor 2024-05-08\nMay 8, 2024\nJune 17, 2024\n2.2\n
\"Event\nNameless Honor 2024-06-19\nJune 19, 2024\nJuly 29, 2024\n2.3\n
\"Event\nNameless Honor 2024-07-31\nJuly 31, 2024\nSeptember 9, 2024\n2.4\n
\"Event\nNameless Honor 2024-09-10\nSeptember 10, 2024\nOctober 21, 2024\n2.5\n
\"Event\nNameless Honor 2024-10-23\nOctober 23, 2024\nDecember 2, 2024\n2.6\n
\"Event\nNameless Honor 2024-12-04\nDecember 4, 2024\nJanuary 13, 2025\n2.7\n
\"Event\nNameless Honor 2025-01-15\nJanuary 15, 2025\nFebruary 24, 2025\n3.0\n
\"Event\nNameless Honor 2025-02-26\nFebruary 26, 2025\nApril 7, 2025\n3.1\n
\"Event\nNameless Honor 2025-04-09\nApril 9, 2025\nMay 19, 2025\n3.2\n
\"Event\nNameless Honor 2025-05-21\nMay 21, 2025\nJune 30, 2025\n3.3\n
\"Event\nNameless Honor 2025-07-02\nJuly 2, 2025\nAugust 11, 2025\n3.4\n
\"Event\nNameless Honor 2025-08-13\nAugust 13, 2025\nSeptember 22, 2025\n3.5\n
\"Event\nNameless Honor 2025-09-24\nSeptember 24, 2025\nNovember 3, 2025\n3.6\n
\"Event\nNameless Honor 2025-11-05\nNovember 5, 2025\nDecember 15, 2025\n3.7\n
\"Event\nNameless Honor 2025-12-17\nDecember 17, 2025\nFebruary 9, 2026\n3.8\n
\"Event\nNameless Honor 2026-02-13\nFebruary 13, 2026\nMarch 23, 2026\n4.0\n
\"Event\nNameless Honor 2026-03-25\nMarch 25, 2026\nApril 20, 2026\n4.1\n
\"Event\nNameless Honor 2026-04-22\nApril 22, 2026\nJune 1, 2026\n4.2\n
\"Event\nNameless Honor 2026-06-01\nJune 1, 2026\nJuly 13, 2026\n4.3\n
\"Event\nNameless Honor 2026-07-15\nJuly 15, 2026\nAugust 24, 2026\n4.4\n
\"Event\nNameless Honor 2026-08-26\nAugust 26, 2026\nSeptember 28, 2026\n4.5\n
\"Event\nNameless Honor 2026-09-28\nSeptember 28, 2026\nNovember 9, 2026\n4.6\n
\n

Notes[]

\n
    \n
  1. ↑ With the exceptions of 2023-10-11 and 2026-08-26 (where each level was \"The\"The The Nameless EXP ×700) and 2026-03-25 (where each level was \"The\"The The Nameless EXP ×600) due to shortened Version lengths.\n
  2. \n
\n

Other Languages[]

\n
LanguageOfficial Name
EnglishNameless Honor
Chinese
(Simplified)
无名勋礼
Chinese
(Traditional)
無名勳禮
Japaneseナナシの勲功
Korean무명의 공훈
SpanishHonor anónimo
FrenchHonneur des Sans Noms
RussianЧесть Безымянных
Thaiเกียรติยศนิรนาม
VietnameseVinh Dự Vô Danh
GermanNamenlose Ehre
IndonesianKehormatan Tanpa Nama
PortugueseHonra dos Inominados
\n

Change History[]

\n
\n

Version 3.5\n

\n\n
\n

Version 3.4\n

\n\n
\n

Version 3.2\n

\n\n
\n

Version 3.0\n

\n\n
\n

Version 1.6\n

\n\n
\n

Version 1.5\n

\n\n
\n

Version 1.0\n

\n
  • Nameless Honor was released.
\n\n\n\n\n
"}} \ No newline at end of file diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts index 6f7abb3..6fa41cc 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -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", diff --git a/src/ingest/parsers/fandomTable.ts b/src/ingest/parsers/fandomTable.ts new file mode 100644 index 0000000..5c987ff --- /dev/null +++ b/src/ingest/parsers/fandomTable.ts @@ -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: `). 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> = { + "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; +} + +function cells(row: string, tag: "th" | "td"): string[] { + return [ + ...row.matchAll(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, "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(/]*>([\s\S]*?)<\/table>/gi)) { + const body = table[1] ?? ""; + // A nested table would cut this match short and shift every column. + if (//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 = []; + for (const row of rowHtml.slice(1)) { + const raw = cells(row, "td"); + if (raw.length !== width) continue; + rows.push(raw); + links.push(/]*\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, +}; diff --git a/src/ingest/parsers/index.ts b/src/ingest/parsers/index.ts index f3e67e9..75a3f0d 100644 --- a/src/ingest/parsers/index.ts +++ b/src/ingest/parsers/index.ts @@ -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"; diff --git a/test/adapters/fandomTable.test.ts b/test/adapters/fandomTable.test.ts new file mode 100644 index 0000000..ccb60a2 --- /dev/null +++ b/test/adapters/fandomTable.test.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) => `${h}`).join(""); + const tr = rows + .map(([n, s, e]) => `${n}${s}${e}`) + .join(""); + return JSON.stringify({ + parse: { title: "T", text: `${th}${tr}
` }, + }); +} + +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 `` 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("
")).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"]); + }); +}); diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index 5fe0add..12b3e3d 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -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); diff --git a/test/sanitize.test.ts b/test/sanitize.test.ts index 164e200..0352d4a 100644 --- a/test/sanitize.test.ts +++ b/test/sanitize.test.ts @@ -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 + // (`-.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();