From ec669675edcfa8707cb541f68185a5d9c52b1cad Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 16 Oct 2026 06:24:40 +0200 Subject: [PATCH] feat(sources): Infinity Nikki's Mira Journey window, from its version Co-Authored-By: Claude Opus 5.5 --- docs/INGESTION.md | 1 + docs/SOURCES.md | 13 ++ ...ndom-mira-journey-2026-10-16.expected.json | 23 ++ .../nikki/fandom-mira-journey-2026-10-16.html | 1 + src/ingest/adapters/index.ts | 19 ++ src/ingest/parsers/index.ts | 3 + src/ingest/parsers/versionTable.ts | 206 ++++++++++++++++++ test/adapters/game8.test.ts | 2 + test/adapters/versionTable.test.ts | 124 +++++++++++ 9 files changed, 392 insertions(+) create mode 100644 fixtures/nikki/fandom-mira-journey-2026-10-16.expected.json create mode 100644 fixtures/nikki/fandom-mira-journey-2026-10-16.html create mode 100644 src/ingest/parsers/versionTable.ts create mode 100644 test/adapters/versionTable.test.ts diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 0cc7150..a3979be 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -53,6 +53,7 @@ Consequences worth internalising: | `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) | +| `fandomversion` | Fandom's **version table** via the `action=parse` API — tables headed `Version Name \| Version Number \| Release Date \| End Date \| Duration` (the `Duration` heading is what `fandomtable` refuses, so the readers are disjoint). A pass is emitted for each version row not yet ended by `ctx.now`, only where the source's `VERSION_PASSES` entry states the pass follows the version, with who said so and when; day precision | Infinity Nikki (Mira Journey) | | `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 f321dff..da9a250 100644 --- a/docs/SOURCES.md +++ b/docs/SOURCES.md @@ -91,6 +91,7 @@ Two things this method cannot tell you, and both matter: | 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 | | Genshin — Miliastra Pass | `genshin-impact.fandom.com/api.php?…page=Miliastra_Pass` | existing (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the live pass run as one `other` event, day precision | | ZZZ — New Eridu City Fund | `zenless-zone-zero.fandom.com/api.php?…page=New_Eridu_City_Fund` | existing (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the live fund run as one `other` event, day precision | +| Infinity Nikki — Mira Journey | `infinity-nikki.fandom.com/api.php?…page=Version` | new (`fandomversion`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the current version's window as one `other` event, on the owner's in-game confirmation; 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 | --- @@ -887,6 +888,18 @@ passed. A `fetched` with more than zero events means the wiki is back. **Conduct:** the `RobotsCache` gets `403` (the interstitial challenge) for this host's `robots.txt`. The repository owner read it in a browser on 2026-10-16: the `User-agent: *` group allows `/api.php?`, `/api.php?action=` and `/api.php?*&action=` and disallows only `/wiki/Special:`, `/wiki/User_talk:`, `/wiki/Template:`, `/wiki/Template_talk:`, `/wiki/Help:`, `/wiki/User:`, `/wiki/UserProfile:` (and language variants); `Disallow: /` applies only to named crawlers (SemrushBot, serpstatbot, GPTBot, ImagesiftBot, CCBot, OAI-SearchBot, ClaudeBot, GrokBot, xAI-SearchBot, xAI-Grok, GrokApp, xAI-Bot); `ias_crawler` has `Crawl-delay: 1`. With the standing `--assume-robots-on-403` decision that covers this source. The API answered `200` to the real User-Agent for the single fixture fetch. +## 20. Infinity Nikki — Mira Journey window, from its version (Fandom) — BUILT 2026-10-16 + +**Source:** `https://infinity-nikki.fandom.com/api.php?action=parse&page=Version&prop=text&formatversion=2&format=json` (page `https://infinity-nikki.fandom.com/wiki/Version`), source id `nikki-fandom-mira-journey`, parser `fandomversion` (`src/ingest/parsers/versionTable.ts`). + +**What it is:** Mira Journey has no dated page. The `Version` page dates *versions*: three tables headed `Version Name | Version Number | Release Date | End Date | Duration` (2.x, 1.x, prerelease), 25 rows in all (counted independently of the parser), e.g. `The Feather of Justice | 2.10 | September 23, 2026 | October 29, 2026 | 36 Days`. A version stands in for the pass only because the owner confirmed in game, 2026-10-16, that the pass ends with it (it read 13d 19h at 00:47 UTC, ending 2026-10-29 with version 2.10). That claim and its evidence are one entry in `VERSION_PASSES`, keyed by source id; the event's summary repeats it (`Follows version 2.10 (The Feather of Justice). That the pass ends with the version was confirmed in game, 2026-10-16.`). Rows already ended are skipped, a row without a readable end is skipped; at capture exactly one row survives: 2026-09-23 to 2026-10-29, day precision, titled `Mira Journey`. The page states no zone, so nothing is region-scoped. + +**Not the history-table reader:** `fandomtable` refuses any table with a `Duration` heading and this reader requires one, so neither claims the other's page (tested across every fixture). + +**Conduct:** same host as `nikki-fandom-events` (§ 11), whose recorded robots reading (the standard Fandom file) applies. The `RobotsCache` with the real User-Agent got `403` (the interstitial challenge) on 2026-10-16, so that reading and the standing `--assume-robots-on-403` decision cover it. The API answered `200` to the real User-Agent for the single fixture fetch. + +**Reuse:** Endfield's wiki.gg source can copy the entry shape (title, stated link, evidence); whether it shares this reader is its own call, as wiki.gg's markup differs. + ## What every one of these costs, beyond the source Adding a game is never only a `SOURCES` entry: diff --git a/fixtures/nikki/fandom-mira-journey-2026-10-16.expected.json b/fixtures/nikki/fandom-mira-journey-2026-10-16.expected.json new file mode 100644 index 0000000..4cc3625 --- /dev/null +++ b/fixtures/nikki/fandom-mira-journey-2026-10-16.expected.json @@ -0,0 +1,23 @@ +[ + { + "id": "nikki:mira-journey:2026-09-23", + "game": "nikki", + "title": "Mira Journey", + "type": "other", + "summary": "Follows version 2.10 (The Feather of Justice). That the pass ends with the version was confirmed in game, 2026-10-16.", + "startsAt": "2026-09-23T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-10-29T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://infinity-nikki.fandom.com/wiki/Version", + "sourceId": "nikki-fandom-mira-journey", + "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/nikki/fandom-mira-journey-2026-10-16.html b/fixtures/nikki/fandom-mira-journey-2026-10-16.html new file mode 100644 index 0000000..384d421 --- /dev/null +++ b/fixtures/nikki/fandom-mira-journey-2026-10-16.html @@ -0,0 +1 @@ +{"parse":{"title":"Version","pageid":6305,"text":"

\n

\n\nVersions (or Updates) are important updates in Infinity Nikki, typically lasting 25 days. \nSee Events for the current and past events found in Version updates!\n

\n\n\n

Latest Version[]

\n
\n\n
The Feather of Justice
Release Date: September 23, 2026
Previous: 2.9
Law does not descend from heaven, nor are those who uphold it born great;
At either end of the scales, the crown and the law rest in silent balance.
The stone walls tremble; echoes of a thousand years stir beneath the dust.
The golden feather has come, and all that lies unresolved shall reach its final chapter.
\n

Version History[]

\n

Update logs/patch notes, bug fixes, optimizations, and details for each version are listed under their respective page.\n

\n

Version 2[]

\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
Version Name\nVersion Number\nRelease Date\nEnd Date\nDuration\n
\"Version
The Feather of Justice\n
2.10\nSeptember 23, 2026\nOctober 29, 2026\n36 Days\n
\"Version
Bloom Beneath Bright Skies\n
2.9\nAugust 27, 2026\nSeptember 23, 2026\n27 Days\n
\"Version
Golden Dust\n
2.8\nJuly 16, 2026\nAugust 27, 2026\n42 Days\n
\"Version
Where Stars Yearn\n
2.7\nJune 23, 2026\nJuly 16, 2026\n23 Days\n
\"Version
Until Tide Ends\n
2.6\nMay 28, 2026\nJune 23, 2026\n26 Days\n
\"Version
Where All Souls Return\n
2.5\nApril 23, 2026\nMay 28, 2026\n35 Days\n
\"Version
The Forest Need Not Listen\n
2.4\nMarch 26, 2026\nApril 23, 2026\n28 Days\n
\"Version
Uncrowned Heart\n
2.3\nMarch 2, 2026\nMarch 26, 2026\n24 Days\n
\"Version
Chromatic Beings Everbright\n
2.2\nJanuary 29, 2026\nMarch 2, 2026\n32 Days\n
\"Version
Life as a Poem\n
2.1\nJanuary 6, 2026\nJanuary 29, 2026\n23 Days\n
\"Version
Terra's Call\n
2.0\nNovember 25, 2025\nJanuary 6, 2026\n42 Days\n
\n

Version 1[]

\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
Version Name\nVersion Number\nRelease Date\nEnd Date\nDuration\n
\"Version
Encore Season\n
1.11\nOctober 27, 2025\nNovember 25, 2025\n29 Days\n
\"Version
Golden Season\n
1.10\nSeptember 28, 2025\nOctober 27, 2025\n29 Days\n
\"Version
Music Season\n
1.9\nSeptember 1, 2025\nSeptember 28, 2025\n27 Days\n
\"Version
Danqing Season\n
1.8\nJuly 29, 2025\nSeptember 1, 2025\n34 Days\n
\"Version
Blue Tears Season\n
1.7\nJuly 7, 2025\nJuly 29, 2025\n22 Days\n
\"Version
Playful Season\n
1.6\nJune 12, 2025\nJuly 7, 2025\n25 Days\n
\"Version
Bubble Season\n
1.5\nApril 28, 2025\nJune 12, 2025\n45 Days\n
\"Version
Revelry Season\n
1.4\nMarch 25, 2025\nApril 28, 2025\n34 Days\n
\"Version
Eerie Season\n
1.3\nFebruary 25, 2025\nMarch 25, 2025\n28 Days\n
\"Version
Firework Season\n
1.2\nJanuary 23, 2025\nFebruary 25, 2025\n33 Days\n
\"Version
Shooting Star Season\n
1.1\nDecember 29, 2024\nJanuary 23, 2025\n25 Days\n
\"Version
Exploration Season\n
1.0\nDecember 5, 2024\nDecember 29, 2024\n24 Days\n
\n

Pre-release[]

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Version Name\nVersion Number\nRelease Date\nEnd Date\nDuration\n
\"Reunion
Reunion Playtest\n
0.9\nOctober 7, 2024\nOctober 22, 2024\n15 Days\n
\"0.7
Whim Playtest\n
0.7\nJune 28, 2024\nJuly 5, 2024\n7 Days\n
\n

Navigation[]

\n
\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 9038ee7..bd56aeb 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -263,6 +263,25 @@ const SOURCES: SourceSpec[] = [ url: "https://infinity-nikki.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json", parserId: "fandom", }, + { + id: "nikki-fandom-mira-journey", + game: "nikki", + // Same host as `nikki-fandom-events` above, whose robots.txt reading (the + // standard Fandom file) is recorded there. Re-checked with the runner's own + // client (`RobotsCache`, the real User-Agent) on 2026-10-16: it answered + // 403 (the interstitial challenge), so that person-recorded reading and the + // standing `--assume-robots-on-403` decision (AGENTS.md § Scraping conduct) + // cover this source, as they do the other Fandom sources. The API path + // answered 200 to the real User-Agent for the single fixture fetch; no + // browser-shaped headers. + // + // A version table, not a pass page: Mira Journey has no dated page, so the + // event takes the version's window, on the owner's in-game confirmation + // that the pass ends with the version. That claim and its evidence live in + // `VERSION_PASSES` in `parsers/versionTable.ts`, keyed by this id. + url: "https://infinity-nikki.fandom.com/api.php?action=parse&page=Version&prop=text&formatversion=2&format=json", + parserId: "fandomversion", + }, { id: "nikke-fandom-events", game: "nikke", diff --git a/src/ingest/parsers/index.ts b/src/ingest/parsers/index.ts index 75a3f0d..9a484a0 100644 --- a/src/ingest/parsers/index.ts +++ b/src/ingest/parsers/index.ts @@ -3,6 +3,7 @@ import { aruStatsParser } from "./arustats.ts"; import { blueArchiveWikiParser } from "./bawiki.ts"; import { fandomParser } from "./fandom.ts"; import { fandomTableParser } from "./fandomTable.ts"; +import { fandomVersionParser } from "./versionTable.ts"; import { game8Parser } from "./game8.ts"; import { holodoriWikiParser } from "./holodori.ts"; import { iopWikiParser } from "./iopwiki.ts"; @@ -22,6 +23,7 @@ export const PARSERS: SourceParser[] = [ arknightsWikiParser, fandomParser, fandomTableParser, + fandomVersionParser, blueArchiveWikiParser, holodoriWikiParser, iopWikiParser, @@ -40,6 +42,7 @@ export { aruStatsParser } from "./arustats.ts"; export { blueArchiveWikiParser } from "./bawiki.ts"; export { fandomParser } from "./fandom.ts"; export { fandomTableParser } from "./fandomTable.ts"; +export { fandomVersionParser } from "./versionTable.ts"; export { game8Parser } from "./game8.ts"; export { holodoriWikiParser } from "./holodori.ts"; export { iopWikiParser } from "./iopwiki.ts"; diff --git a/src/ingest/parsers/versionTable.ts b/src/ingest/parsers/versionTable.ts new file mode 100644 index 0000000..05a3b68 --- /dev/null +++ b/src/ingest/parsers/versionTable.ts @@ -0,0 +1,206 @@ +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 *version table* — Infinity Nikki's `Version` page, one table per + * major version — + * + * Version Name | Version Number | Release Date | End Date | Duration + * + * (`The Feather of Justice | 2.10 | September 23, 2026 | October 29, 2026 | + * 36 Days`). It dates a **version**, not a pass. A pass is emitted from it only + * where somebody has *stated* that the pass runs with the version, and that + * statement is written down once, per source, in `VERSION_PASSES` below with + * who made it and when. A source id with no entry emits nothing. + * + * This is the other half of `fandomTable.ts`: that reader takes a table headed + * `Name | Start | End` and refuses one with a `Duration` heading; this one + * requires the version headings and a `Duration`. The two cannot claim each + * other's page. + * + * Cells print a bare date, so every event is day precision. Inclusion is + * decided against `ctx.now` only: a row already ended is skipped, a current or + * upcoming row is emitted, and a row without a readable end is skipped, never + * given one. + * + * ## Reuse + * + * Another site's version page (Endfield's wiki.gg) is a different markup and + * its own reader; what carries over is the *entry shape* — a title, the stated + * link, and the evidence for it — so the claim lives beside the code that + * relies on it. + */ + +/** Who said the pass follows the version, and when: never inferred. */ +export interface VersionLinkEvidence { + /** `owner` (in game) or `wiki` (a sentence the source prints). */ + readonly by: "owner" | "wiki"; + /** ISO date of the reading or capture. */ + readonly on: string; + /** What was seen, in the words of the person or the wiki. */ + readonly reading: string; +} + +/** What one source claims about the pass and its version. */ +export interface VersionPass { + /** The event title. */ + readonly title: string; + /** The sentence the summary closes with: the stated link, with its source. */ + readonly link: string; + readonly evidence: VersionLinkEvidence; +} + +/** Keyed by source id, because the claim is a fact about the source. */ +export const VERSION_PASSES: Readonly> = { + "nikki-fandom-mira-journey": { + title: "Mira Journey", + link: "That the pass ends with the version was confirmed in game, 2026-10-16.", + evidence: { + by: "owner", + on: "2026-10-16", + reading: + "in game, the pass read 13d 19h at 00:47 UTC, ending 2026-10-29 with version 2.10", + }, + }, +}; + +interface VersionRow { + name: string; + number: string; + start: ParsedInstant; + end: ParsedInstant; +} + +function cells(row: string, tag: "th" | "td"): string[] { + return [ + ...row.matchAll(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, "gi")), + ].map((c) => c[1] ?? ""); +} + +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); +} + +/** + * Every row of every table headed `Version Name | Version Number | Release + * Date | End Date | Duration` (found by heading, any order). All five are + * required: the Duration column is what marks this template. + */ +function versionRows(rendered: string): { tables: number; rows: VersionRow[] } { + const rows: VersionRow[] = []; + let tables = 0; + + for (const table of rendered.matchAll(/]*>([\s\S]*?)<\/table>/gi)) { + const body = table[1] ?? ""; + if (//gi) ?? []; + const head = rowHtml[0]; + if (head === undefined) continue; + + const headings = cells(head, "th").map((h) => + text(h).toLowerCase().replace(/\s+/g, " "), + ); + const name = headings.indexOf("version name"); + const number = headings.indexOf("version number"); + const start = headings.indexOf("release date"); + const end = headings.indexOf("end date"); + if ( + name === -1 || + number === -1 || + start === -1 || + end === -1 || + !headings.includes("duration") + ) { + continue; + } + tables += 1; + + for (const row of rowHtml.slice(1)) { + const raw = cells(row, "td"); + if (raw.length !== headings.length) continue; + const startsAt = wholeDate(raw[start] ?? ""); + const endsAt = wholeDate(raw[end] ?? ""); + const rowName = text(raw[name] ?? ""); + const rowNumber = text(raw[number] ?? ""); + if (startsAt === null || endsAt === null) continue; + if (rowName === "" || rowNumber === "") continue; + rows.push({ name: rowName, number: rowNumber, start: startsAt, end: endsAt }); + } + } + + return { tables, rows }; +} + +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 parseFandomVersionTable( + body: string, + ctx: ParseContext, +): GachaEvent[] { + const pass = VERSION_PASSES[ctx.sourceId]; + if (pass === undefined) return []; + + const rendered = renderedHtml(body); + if (rendered === null) return []; + + const nowMs = Date.parse(ctx.now); + const out: GachaEvent[] = []; + + for (const row of versionRows(rendered).rows) { + if (row.end.iso <= row.start.iso) continue; + // Day-precision end: judged by the last moment any reset could put it at, + // not the UTC-midnight placeholder (AGENTS.md § Domain rules). + if (latestBoundaryMs(row.end.iso, row.end.precision, ctx.game) < nowMs) { + continue; + } + + out.push({ + id: eventId(ctx.game, pass.title, row.start.iso), + game: ctx.game, + title: pass.title, + type: "other", + summary: `Follows version ${row.number} (${row.name}). ${pass.link}`, + startsAt: row.start.iso, + startPrecision: row.start.precision, + endsAt: row.end.iso, + endPrecision: row.end.precision, + regionScoped: false, + regionEnds: null, + sourceUrl: pageUrl(ctx.sourceUrl), + sourceId: ctx.sourceId, + status: "published", + confidence: 0.85, + extractionMethod: "parser", + version: 1, + firstSeenAt: ctx.now, + updatedAt: ctx.now, + }); + } + + return out; +} + +export const fandomVersionParser: SourceParser = { + id: "fandomversion", + label: "Fandom version table", + canParse(body: string): boolean { + // Structural: an `action=parse` envelope holding a table headed with the + // version columns and a Duration. Never a row. + const rendered = renderedHtml(body); + return rendered !== null && versionRows(rendered).tables > 0; + }, + parse: parseFandomVersionTable, +}; diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index 116d310..8e3eace 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -75,6 +75,8 @@ const CASES: Array<{ adapter: Adapter; fixture: string; now?: string }> = [ { adapter: adapter("hsr-fandom-nameless-honor"), fixture: "fixtures/hsr/fandom-nameless-honor-2026-10-16", now: "2026-10-16T00:00:00.000Z" }, // Genshin's Miliastra Pass history (same reader). { adapter: adapter("genshin-fandom-miliastra-pass"), fixture: "fixtures/genshin/fandom-miliastra-pass-2026-10-16", now: "2026-10-16T00:00:00.000Z" }, + // Infinity Nikki's Mira Journey, from the Version table. + { adapter: adapter("nikki-fandom-mira-journey"), fixture: "fixtures/nikki/fandom-mira-journey-2026-10-16", now: "2026-10-16T00:00:00.000Z" }, // ZZZ's New Eridu City Fund history (same reader). { adapter: adapter("zzz-fandom-city-fund"), fixture: "fixtures/zzz/fandom-city-fund-2026-10-16", now: "2026-10-16T00:00:00.000Z" }, ]; diff --git a/test/adapters/versionTable.test.ts b/test/adapters/versionTable.test.ts new file mode 100644 index 0000000..1bb6669 --- /dev/null +++ b/test/adapters/versionTable.test.ts @@ -0,0 +1,124 @@ +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 } from "../../src/ingest/parsers/fandomTable.ts"; +import { + fandomVersionParser, + VERSION_PASSES, +} from "../../src/ingest/parsers/versionTable.ts"; + +const FIXTURE = "fixtures/nikki/fandom-mira-journey-2026-10-16.html"; +const SOURCE_ID = "nikki-fandom-mira-journey"; + +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 version table. */ +function body(rows: Array<[string, string, string, string]>, headings = HEAD) { + const th = headings.split(" | ").map((h) => `${h}`).join(""); + const tr = rows + .map(([n, v, s, e]) => `${n}${v}${s}${e}x Days`) + .join(""); + return JSON.stringify({ parse: { title: "T", text: `${th}${tr}
` } }); +} +const HEAD = "Version Name | Version Number | Release Date | End Date | Duration"; + +describe("fandomversion parser", () => { + const html = Bun.file(FIXTURE).text(); + + test("the page has 25 version rows and 1 not yet ended (independent count)", async () => { + const page = await html; + // Counted off the raw markup, not through the parser: `Duration` cells + // are ` Days` and exist once per data row. + const rows = (page.match(/\d+ Days/g) ?? []).length; + expect(rows).toBe(25); + // A clock before the first row: every row parses. + const all = fandomVersionParser.parse(page, ctx("2020-01-01T00:00:00.000Z")); + expect(all).toHaveLength(25); + expect(new Set(all.map((e) => e.id)).size).toBe(25); + // Only 2.10 (to October 29) has not ended on 2026-10-16. + const now = fandomVersionParser.parse(page, ctx("2026-10-16T00:00:00.000Z")); + expect(now.map((e) => [e.title, e.startsAt, e.endsAt])).toEqual([ + ["Mira Journey", "2026-09-23T00:00:00.000Z", "2026-10-29T00:00:00.000Z"], + ]); + }); + + test("the summary names the version and the in-game confirmation", async () => { + const [e] = fandomVersionParser.parse(await html, ctx("2026-10-16T00:00:00.000Z")); + expect(e?.summary).toBe( + "Follows version 2.10 (The Feather of Justice). That the pass ends with the version was confirmed in game, 2026-10-16.", + ); + expect(e?.startPrecision).toBe("day"); + expect(e?.endPrecision).toBe("day"); + expect(e?.type).toBe("other"); + }); + + test("after 2026-10-29 the 2.10 row is gone", async () => { + expect(fandomVersionParser.parse(await html, ctx("2026-10-30T00:00:00.000Z"))).toEqual([]); + }); + + test("the row stays on its last day", async () => { + expect( + fandomVersionParser.parse(await html, ctx("2026-10-29T05:00:00.000Z")), + ).toHaveLength(1); + }); + + test("a row without a readable end is skipped, not given one", () => { + const events = fandomVersionParser.parse( + body([ + ["A", "3.0", "September 1, 2026", "TBA"], + ["B", "3.1", "September 1, 2026", ""], + ["C", "3.2", "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 entry emits nothing", () => { + const b = body([["A", "3.0", "September 1, 2026", "October 12, 2026"]]); + expect(fandomVersionParser.parse(b, ctx("2026-09-10T00:00:00.000Z"))).toHaveLength(1); + expect(fandomVersionParser.parse(b, ctx("2026-09-10T00:00:00.000Z", "x-unknown"))).toEqual([]); + }); + + test("every entry carries its evidence and is a registered source on this parser", () => { + for (const [id, pass] of Object.entries(VERSION_PASSES)) { + expect(adapterById(id)?.parserId).toBe("fandomversion"); + expect(pass.evidence.reading.length).toBeGreaterThan(0); + expect(pass.evidence.on).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + test("the two Fandom table readers do not claim each other's tables", () => { + const version = body([], HEAD); + const history = body([], "Image | Name | Start | End | Version"); + expect(fandomVersionParser.canParse(version)).toBe(true); + expect(fandomTableParser.canParse(version)).toBe(false); + expect(fandomVersionParser.canParse(history)).toBe(false); + expect(fandomTableParser.canParse(history)).toBe(true); + // A table with Duration but without version headings is neither. + const other = body([], "Name | Start | End | Duration"); + expect(fandomVersionParser.canParse(other)).toBe(false); + expect(fandomTableParser.canParse(other)).toBe(false); + }); +}); + +describe("fandomversion canParse across every fixture", () => { + const files = [...new Glob("fixtures/**/*.html").scanSync(".")].sort(); + + test("true on the Mira Journey fixture only", async () => { + const accepted: string[] = []; + for (const f of files) { + if (fandomVersionParser.canParse(await Bun.file(f).text())) accepted.push(f); + } + expect(accepted).toEqual([FIXTURE]); + }); + + test("no other parser claims the Version page, and fandomtable stays off it", async () => { + const text = await Bun.file(FIXTURE).text(); + expect(PARSERS.filter((p) => p.canParse(text)).map((p) => p.id)).toEqual(["fandomversion"]); + }); +});