feat: rebuild Infinity Nikki on a live source, at day precision

The Game8 page stopped being updated on 31 August 2025 and mentions 2026 zero
times, and it had been publishing five year-old events with endsAt: null — which
the app renders as live-with-unknown-end indefinitely. That is worse than an
empty lane, and nothing in the pipeline catches it, because a stale page is not
a broken one: it fetches, it parses, it passes every test.

The replacement is the game's Fandom wiki, permitted by the same standard
robots.txt that cleared Nikke, maintained to within two days, and shaped as
Current / Upcoming / Permanent / Past tables of Event | Duration | Description
| Type.

Its durations state a wall clock on both sides and no zone anywhere for that
column, so parseZonelessClockRange reads the clock and discards it, publishing
the printed date at day precision. That invents nothing and treats these cells
exactly as every Game8 date already is. Converting instead would mean picking an
offset, and the offset moves the day: July 16 20:00 read as UTC-7 is July 17,
and the start's day is half of every event ID this game will ever have.

The Game8 entry is removed rather than kept as a second opinion, since a source
whose every row is wrong is not corroboration. Its fixture stays: it is the only
page here carrying the labelled "Start: ... End: Permanent" shape, and the test
now drives it through the parser directly.

Titles come from a link's title attribute, which never passes through text(), so
they are entity-decoded by hand — otherwise "Alison's Travel Shop" becomes a
slug, and a slug is a localStorage key. The ingest sanitiser caught exactly that
during the build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 04:23:24 +02:00
co-authored by Claude Opus 5
parent 195fd75ba4
commit 2985484883
8 changed files with 560 additions and 31 deletions
+5
View File
@@ -319,6 +319,11 @@ Note also that `nikke` and `nikki` are one letter apart and are different games
Nikke and Infinity Nikki. Both are the first segment of every completion key their game will ever Nikke and Infinity Nikki. Both are the first segment of every completion key their game will ever
have, so a typo in either direction is silent data loss for real readers. have, so a typo in either direction is silent data loss for real readers.
Infinity Nikki's source changed on 2026-08-19 and its silence changed shape with it: the Fandom wiki
states a wall clock on every boundary and no zone for it, so there is still nothing to read a server
map off — the clock is discarded at ingest and only the printed day is published. An offset invented
here would move real readers' day keys.
Infinity Nikki, P5X, Blue Archive, Chaos Zero Nightmare and Umamusume carry **no `resetOffsets` Infinity Nikki, P5X, Blue Archive, Chaos Zero Nightmare and Umamusume carry **no `resetOffsets`
entry**, so they take the regional default. That is an assumption, not a verified server map — none entry**, so they take the regional default. That is an assumption, not a verified server map — none
of those sources states one. Two of the 2026-08-19 additions are worth separating out, because they of those sources states one. Two of the 2026-08-19 additions are worth separating out, because they
+3 -2
View File
@@ -48,10 +48,10 @@ Consequences worth internalising:
| Parser | Site | Sources using it | | Parser | Site | Sources using it |
|---|---|---| |---|---|---|
| `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Infinity Nikki, Persona 5: The Phantom X, Chaos Zero Nightmare, Umamusume | | `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Persona 5: The Phantom X, Chaos Zero Nightmare, Umamusume |
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield | | `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
| `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights | | `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights |
| `fandom` | Fandom wikis via the MediaWiki `action=parse` API — three page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, and Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables | Reverse: 1999, Fate/Grand Order, Nikke | | `fandom` | Fandom wikis via the MediaWiki `action=parse` API — four page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables, and Infinity Nikki's `Event \| Duration \| Description \| Type` article-tables | Reverse: 1999, Fate/Grand Order, Nikke, Infinity Nikki |
| `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — a JP/Global tabber over `Name (EN) \| Start date \| End date \| Notes` wikitables | Blue Archive | | `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — a JP/Global tabber over `Name (EN) \| Start date \| End date \| Notes` wikitables | Blue Archive |
| `holodoriwiki` | holodori.wiki's rendered `/wiki/Events``Current Events` and `Past Events` wikitables over `Event \| Type \| Start Date \| End Date` | hololive Dreams | | `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 | | `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 |
@@ -145,6 +145,7 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any
| `parseIsoClockRangeUtc` | `2026-08-06 13:00 - 2026-08-26 22:59 (UTC)` (whole range in one cell, **zone required**, nothing converted) | Girls' Frontline 2 | | `parseIsoClockRangeUtc` | `2026-08-06 13:00 - 2026-08-26 22:59 (UTC)` (whole range in one cell, **zone required**, nothing converted) | Girls' Frontline 2 |
| `parseIsoOffsetInstant` | `2026-08-03T21:00-07:00` (a machine-readable `<time datetime>` attribute; **offset required**) | Stella Sora | | `parseIsoOffsetInstant` | `2026-08-03T21:00-07:00` (a machine-readable `<time datetime>` attribute; **offset required**) | Stella Sora |
| `parseDayMonthYearClock` | `12 August 2026`, `10 September 202604:59:59` (day-first; the offset comes from the **column header**, and a clockless boundary keeps its printed day) | Nikke | | `parseDayMonthYearClock` | `12 August 2026`, `10 September 202604:59:59` (day-first; the offset comes from the **column header**, and a clockless boundary keeps its printed day) | Nikke |
| `parseZonelessClockRange` | `July 20, 2026 04:00 August 10, 2026 03:49` (reads the clock and **discards** it — the page states no zone, so only the printed day is publishable) | Infinity Nikki |
| `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves | | `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves |
**A day-precision result is 00:00Z, and that is a placeholder rather than a time.** Every reader **A day-precision result is 00:00Z, and that is a placeholder rather than a time.** Every reader
@@ -0,0 +1,149 @@
[
{
"id": "nikki:raised-your-shroomling-today:2026-07-11",
"game": "nikki",
"title": "Raised Your Shroomling Today?",
"type": "other",
"summary": "Unlock your Shroomling today! Complete tasks for rewards including Diamonds, Resonite Crystals, and Revelation Crystals.",
"startsAt": "2026-07-11T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-27T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Raised_Your_Shroomling_Today%3F",
"sourceId": "nikki-fandom-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nikki:dawnlit-gambit:2026-07-16",
"game": "nikki",
"title": "Dawnlit Gambit",
"type": "other",
"summary": "During the [Dawnlit Gambit] event, gain [Board Notes] via eligible purchases. When [Board Notes] reaches certain milestones, you can unlock corresponding rewards.",
"startsAt": "2026-07-16T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-27T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Dawnlit_Gambit",
"sourceId": "nikki-fandom-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nikki:lithe-prelude:2026-07-16",
"game": "nikki",
"title": "Lithe Prelude",
"type": "shop",
"summary": "The Grace and Rhythm Box is available for a limited time. Purchase to receive all the rewards in the Lithe Spin Box and the Gilded Melody Box!",
"startsAt": "2026-07-16T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-27T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Lithe_Prelude",
"sourceId": "nikki-fandom-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nikki:memory-hall:2026-07-16",
"game": "nikki",
"title": "Memory Hall",
"type": "shop",
"summary": "Visit the Store to shop reruns of limited-time products!",
"startsAt": "2026-07-16T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-27T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Memory_Hall/2026-07-16",
"sourceId": "nikki-fandom-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nikki:rainbow-colored-gift:2026-07-16",
"game": "nikki",
"title": "Rainbow-Colored Gift",
"type": "other",
"summary": "Complete the dyeing quest and infuse inspiration into your designs to receive the Rainbow-Colored Gift!",
"startsAt": "2026-07-16T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-27T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Rainbow-Colored_Gift/2026-07-17",
"sourceId": "nikki-fandom-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nikki:alisons-travel-shop:2026-08-06",
"game": "nikki",
"title": "Alison's Travel Shop",
"type": "shop",
"summary": "Stylists can visit Alison and exchange materials from the list for awesome rewards. The checklist refreshes daily at 04:00. Alison has brought extra special gifts this time! Unlock a bonus reward after completing 7 exchanges!",
"startsAt": "2026-08-06T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-27T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Alison%27s_Travel_Shop/2026-08-06",
"sourceId": "nikki-fandom-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "nikki:inspiration-burst:2026-08-15",
"game": "nikki",
"title": "Inspiration Burst",
"type": "other",
"summary": "Get double rewards in the Realm of Eureka !",
"startsAt": "2026-08-15T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-22T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Inspiration_Burst/2026-08-15",
"sourceId": "nikki-fandom-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
}
]
File diff suppressed because one or more lines are too long
+15 -6
View File
@@ -72,12 +72,6 @@ const SOURCES: SourceSpec[] = [
url: "https://game8.co/games/Neverness-to-Everness/archives/592073", url: "https://game8.co/games/Neverness-to-Everness/archives/592073",
parserId: "game8", parserId: "game8",
}, },
{
id: "nikki-game8-events",
game: "nikki",
url: "https://game8.co/games/Infinity-Nikki/archives/487445",
parserId: "game8",
},
{ {
id: "p5x-game8-events", id: "p5x-game8-events",
game: "p5x", game: "p5x",
@@ -143,6 +137,21 @@ const SOURCES: SourceSpec[] = [
url: "https://stellasora.miraheze.org/wiki/Main_Page", url: "https://stellasora.miraheze.org/wiki/Main_Page",
parserId: "stellasorawiki", parserId: "stellasorawiki",
}, },
{
id: "nikki-fandom-events",
game: "nikki",
// Infinity Nikki, replacing a Game8 page that stopped being updated in
// August 2025 and had been publishing year-old events as live ever since.
//
// `infinitynikki.fandom.com` 301s here; this is the canonical host. Its
// robots.txt was read in a browser on 2026-08-19 and is the standard Fandom
// file, the same one that cleared Nikke below.
//
// Day precision on both boundaries by choice: the page states a wall clock
// and names no zone for it. See `parsers/fandom.ts` and docs/SOURCES.md § 11.
url: "https://infinity-nikki.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json",
parserId: "fandom",
},
{ {
id: "nikke-fandom-events", id: "nikke-fandom-events",
game: "nikke", game: "nikke",
+42
View File
@@ -610,3 +610,45 @@ export function parseDayMonthYearClock(
); );
return value === null ? null : { iso: value, precision: "exact" }; return value === null ? null : { iso: value, precision: "exact" };
} }
/**
* "July 20, 2026 04:00 August 10, 2026 03:49" → both days, day precision.
*
* **The clock is read and deliberately thrown away.** The Infinity Nikki wiki
* states a wall clock on both boundaries and names no zone for it anywhere on
* the page — only prose elsewhere dating version launches `(UTC-7)` and a note
* that rewards reset at `04:00 (Server Time)`. Its durations do run 04:00 →
* 03:59, which only lands on a reset boundary if the column is server-local, so
* the case for UTC-7 is strong and it is still circumstantial.
*
* Publishing the clock would mean picking an offset, and the offset moves the
* *day*: `July 16, 2026 20:00` read as UTC-7 is `2026-07-17T03:00Z`, and the
* start's day is half of every event ID this game will ever have. So the
* printed date stands on its own, at day precision, exactly as every Game8 date
* does — Game8 states no zone either, and `clockFor` exists to resolve such a
* boundary against the reader's own server reset.
*
* Matching the clock rather than ignoring it is the point: a cell whose shape
* this reader does not fully recognise yields null instead of a half-read date.
*/
export function parseZonelessClockRange(
input: string,
): { start: ParsedInstant; end: ParsedInstant } | null {
const re =
/^\s*([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\s*[-–—]\s*([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\s*$/;
const m = re.exec(input);
if (!m) return null;
const startMonth = monthNumber(m[1] ?? "");
const endMonth = monthNumber(m[4] ?? "");
if (startMonth === null || endMonth === null) return null;
const startIso = iso(Number(m[3]), startMonth, Number(m[2]));
const endIso = iso(Number(m[6]), endMonth, Number(m[5]));
if (startIso === null || endIso === null) return null;
return {
start: { iso: startIso, precision: "day" },
end: { iso: endIso, precision: "day" },
};
}
+221 -13
View File
@@ -3,8 +3,9 @@ import {
parseDayMonthYearClock, parseDayMonthYearClock,
parseFullRange, parseFullRange,
parseOrdinalDateTimeRange, parseOrdinalDateTimeRange,
parseZonelessClockRange,
} from "../dates.ts"; } from "../dates.ts";
import { text } from "../html.ts"; import { decodeEntities, text } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts"; import type { ParseContext } from "../adapters/types.ts";
import { inferType } from "./game8.ts"; import { inferType } from "./game8.ts";
import type { SourceParser } from "./types.ts"; import type { SourceParser } from "./types.ts";
@@ -101,17 +102,29 @@ const FGO_ARTICLE_SUFFIX = /\s*\(US\)\s*$/;
const NIKKE_ZONE_HEADER = /^(start|end)\s*\(utc([+-]\d{1,2})\)$/; const NIKKE_ZONE_HEADER = /^(start|end)\s*\(utc([+-]\d{1,2})\)$/;
/** /**
* A title cell's fallback, for when the wiki has no logo for the event yet. * A title cell's fallback, for when the wiki has no image for the event yet.
* *
* Every title on this page is an image and the name is normally recoverable * Both the Nikke and Infinity Nikki pages render every event title as an image,
* from the wrapping `<a title="Project Matis">`. The newest row — today's live * with the name recoverable from the wrapping `<a title="Project Matis">`. The
* event — is the one most likely to have no logo uploaded, and then the cell is * newest row is the one most likely to have no image uploaded, and then the
* a red link reading `File:Persona on Frontline logo.png`. A reader that only * cell is a red link reading `File:Persona on Frontline logo.png`. A reader that
* understood `<a title>` would silently drop the single most important row on * only understood `<a title>` would silently drop the single most important row
* the page and publish a calendar missing what is on right now. * on either page and publish a calendar missing what is on right now.
*/ */
const NIKKE_FILE_TITLE = const FILE_TITLE = /^File:\s*(.+?)\s*(?:logo)?\.(?:png|jpe?g|gif|webp)$/i;
/^File:\s*(.+?)\s*(?:logo)?\.(?:png|jpe?g|gif|webp)$/i;
/**
* A dated suffix on an Infinity Nikki title — `Deep Breakthrough/2026-07-20`
* from a subpage link, or `Alison's Travel Shop 2026-08-06` from the file name
* behind a red link. Slash or space, because the wiki uses both.
*
* The wiki gives each run of a recurring event its own dated page, so that date
* names the *run* rather than the event. Stripping it is safe because the start
* date is already half the event ID, which is what keeps two runs of
* `Alison's Travel Shop` apart — and keeping it would put the date on screen
* twice, in the row and in its own title.
*/
const DATED_SUBPAGE = /[\s/]\d{4}-\d{2}-\d{2}$/;
/** The rendered HTML inside an `action=parse` response, or null. */ /** The rendered HTML inside an `action=parse` response, or null. */
export function renderedHtml(body: string): string | null { export function renderedHtml(body: string): string | null {
@@ -402,17 +415,201 @@ function parseNikkeEvents(rendered: string, ctx: ParseContext): GachaEvent[] {
/** A title cell's event name: the link's title, or the file name behind it. */ /** A title cell's event name: the link's title, or the file name behind it. */
function nikkeTitle(cell: string): string | null { function nikkeTitle(cell: string): string | null {
const linked = /<a\b[^>]*title="([^"]*)"/i.exec(cell)?.[1]; // `decodeEntities`, not `text`: this comes out of an attribute value, which
// never passes through the tag stripper, so `&#39;` would otherwise survive
// into the title and from there into the event ID.
const linked = attributeTitle(cell);
const raw = (linked ?? text(cell)).trim(); const raw = (linked ?? text(cell)).trim();
if (raw.length === 0) return null; if (raw.length === 0) return null;
// "File:Persona on Frontline logo.png" -> "Persona on Frontline". Applied to // "File:Persona on Frontline logo.png" -> "Persona on Frontline". Applied to
// the link title too: a red link carries the file name in both places. // the link title too: a red link carries the file name in both places.
const named = NIKKE_FILE_TITLE.exec(raw)?.[1]; const named = FILE_TITLE.exec(raw)?.[1];
const title = (named ?? raw).trim(); const title = (named ?? raw).trim();
return title.length === 0 ? null : title; return title.length === 0 ? null : title;
} }
/** Headings whose tables are a schedule our readers can act on. */
const IN_INCLUDED_SECTION = /^(current|upcoming) events$/i;
/**
* True for the Infinity Nikki wiki's `Event` page, and the gate on the branch
* below. Asserts the heading *and* the table shape, because the page's
* `Permanent Events` table shares neither.
*/
function isInfinityNikkiEventPage(rendered: string): boolean {
return infinityNikkiTables(rendered).length > 0;
}
interface InTable {
body: string;
title: number;
duration: number;
description: number | undefined;
type: number | undefined;
}
/** Every `Current`/`Upcoming Events` table, with its columns resolved. */
function infinityNikkiTables(rendered: string): InTable[] {
const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " ");
const out: InTable[] = [];
let included = false;
for (const node of flat.matchAll(SECTION)) {
const heading = node[1];
if (heading !== undefined) {
included = IN_INCLUDED_SECTION.test(text(heading).trim());
continue;
}
if (!included) continue;
const body = node[2] ?? "";
const headers = [...body.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)].map((h) =>
text(h[1] ?? "").toLowerCase().trim(),
);
const at = (name: string) => {
const i = headers.indexOf(name);
return i < 0 ? undefined : i;
};
const title = at("event");
const duration = at("duration");
// Resolved from the header row rather than counted, for the reason
// `bawiki.ts` does it: the page's other tables are shaped differently, and
// fixed indices would hand a description to the date reader and empty the
// lane with no error.
if (title === undefined || duration === undefined) continue;
out.push({
body,
title,
duration,
description: at("description"),
type: at("type"),
});
}
return out;
}
/**
* The Infinity Nikki wiki's current and upcoming events.
*
* This source replaces a Game8 page that stopped being updated in August 2025
* and had been publishing year-old events as live ever since.
*
* **Every boundary here is day precision, and the clock the page prints is
* deliberately discarded** — see `parseZonelessClockRange`. The wiki states a
* wall clock on both sides and names no zone for it, so publishing an instant
* would mean picking an offset, and the offset moves the day: the start's day is
* half of every event ID this game will ever have. The printed date stands on
* its own, exactly as every Game8 date already does.
*
* Two sections only. `Permanent Events` has no end and is not time-boxed, and
* `Past Events` is history the page keeps and a calendar of deadlines does not
* want — and both are fenced off by their heading rather than by position.
*/
function parseInfinityNikkiEvents(
rendered: string,
ctx: ParseContext,
): GachaEvent[] {
const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
const seen = new Set<string>();
for (const table of infinityNikkiTables(rendered)) {
for (const row of table.body.matchAll(ROW)) {
const cells = [...(row[1] ?? "").matchAll(CELL)].map((c) => ({
tag: c[1] ?? "",
html: c[2] ?? "",
}));
if (cells.length === 0 || cells.some((c) => c.tag === "h")) continue;
const titleCell = cells[table.title]?.html ?? "";
const title = infinityNikkiTitle(titleCell);
if (title === null) continue;
const range = parseZonelessClockRange(text(cells[table.duration]?.html ?? ""));
// A row this reader cannot date yields nothing rather than a guess — the
// page carries undated entries such as "Permanent".
if (range === null) continue;
if (range.end.iso <= range.start.iso) continue;
// "Current" is maintained by hand and goes stale before anyone moves a
// row, so currency is checked rather than taken on trust.
if (Date.parse(range.end.iso) < nowMs) continue;
const id = eventId(ctx.game, title, range.start.iso);
if (seen.has(id)) continue;
seen.add(id);
const described =
table.description === undefined
? ""
: text(cells[table.description]?.html ?? "");
const stated =
table.type === undefined ? "" : text(cells[table.type]?.html ?? "");
const href = ARTICLE_LINK.exec(titleCell)?.[1];
out.push({
id,
game: ctx.game,
title,
// The page's own Type column — "Check-in", "Store", "Quest" — is a
// better signal than the title, which for this game is usually a mood.
type: inferType(`${title} ${stated}`),
summary: described.length > 0 ? described.slice(0, 500) : null,
startsAt: range.start.iso,
startPrecision: range.start.precision,
endsAt: range.end.iso,
endPrecision: range.end.precision,
// One worldwide service, and the page draws no regional distinction.
regionScoped: false,
regionEnds: null,
sourceUrl:
href === undefined
? ctx.sourceUrl
: new URL(href, ctx.sourceUrl).toString(),
sourceId: ctx.sourceId,
status: "published",
// Day precision on both sides, by choice rather than by omission: the
// source stated more than this and could not say what zone it meant.
confidence: 0.85,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
});
}
}
return out;
}
/**
* A link's `title` attribute, decoded.
*
* Attribute values never pass through `text()`, so an apostrophe arrives as
* `&#39;` — and an undecoded title becomes an undecoded slug, which is a
* localStorage key. The sanitiser at the ingest boundary repairs exactly this,
* and a parser that needs repairing on its own fixture is a parser with a bug.
*/
function attributeTitle(cell: string): string | undefined {
const raw = /<a\b[^>]*title="([^"]*)"/i.exec(cell)?.[1];
return raw === undefined ? undefined : decodeEntities(raw);
}
/** An event's name, from its link title, its file name, or its text. */
function infinityNikkiTitle(cell: string): string | null {
const linked = attributeTitle(cell);
const raw = (linked ?? text(cell)).trim();
if (raw.length === 0) return null;
const named = FILE_TITLE.exec(raw)?.[1] ?? raw;
const title = named.replace(DATED_SUBPAGE, "").trim();
return title.length === 0 ? null : title;
}
export function parseFandomEventsPage( export function parseFandomEventsPage(
body: string, body: string,
ctx: ParseContext, ctx: ParseContext,
@@ -425,6 +622,14 @@ export function parseFandomEventsPage(
// apart, and `canParse` asserts it, so a template change fails the source // apart, and `canParse` asserts it, so a template change fails the source
// loudly instead of routing an FGO page through the `Time Period` reader and // loudly instead of routing an FGO page through the `Time Period` reader and
// emptying the lane. // emptying the lane.
if (isInfinityNikkiEventPage(rendered)) {
return parseInfinityNikkiEvents(rendered, ctx).sort((a, b) =>
a.startsAt === b.startsAt
? a.id.localeCompare(b.id)
: a.startsAt.localeCompare(b.startsAt),
);
}
if (isNikkeEventPage(rendered)) { if (isNikkeEventPage(rendered)) {
return parseNikkeEvents(rendered, ctx).sort((a, b) => return parseNikkeEvents(rendered, ctx).sort((a, b) =>
a.startsAt === b.startsAt a.startsAt === b.startsAt
@@ -542,7 +747,10 @@ export const fandomParser: SourceParser = {
const isTimePeriodTable = const isTimePeriodTable =
/class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered); /class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered);
return ( return (
isTimePeriodTable || isFgoEventList(rendered) || isNikkeEventPage(rendered) isTimePeriodTable ||
isFgoEventList(rendered) ||
isNikkeEventPage(rendered) ||
isInfinityNikkiEventPage(rendered)
); );
}, },
parse: parseFandomEventsPage, parse: parseFandomEventsPage,
+124 -10
View File
@@ -32,7 +32,6 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
{ adapter: adapter("zzz-game8-events"), fixture: "fixtures/zzz/game8-events-2026-08-14" }, { adapter: adapter("zzz-game8-events"), fixture: "fixtures/zzz/game8-events-2026-08-14" },
{ adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" }, { adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" },
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" }, { adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
{ adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" }, { adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" },
{ adapter: adapter("arknights-akwiki-events"), fixture: "fixtures/arknights/akwiki-events-2026-08-17" }, { adapter: adapter("arknights-akwiki-events"), fixture: "fixtures/arknights/akwiki-events-2026-08-17" },
// A `.html` fixture holding JSON, deliberately: this source is the MediaWiki // A `.html` fixture holding JSON, deliberately: this source is the MediaWiki
@@ -47,6 +46,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
{ adapter: adapter("czn-game8-events"), fixture: "fixtures/czn/game8-events-2026-08-19" }, { adapter: adapter("czn-game8-events"), fixture: "fixtures/czn/game8-events-2026-08-19" },
{ adapter: adapter("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" }, { adapter: adapter("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" },
{ adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-events-2026-08-19" }, { adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-events-2026-08-19" },
{ adapter: adapter("nikki-fandom-events"), fixture: "fixtures/nikki/fandom-events-2026-08-19" },
]; ];
async function runAdapter(adapter: Adapter, fixture: string) { async function runAdapter(adapter: Adapter, fixture: string) {
@@ -247,14 +247,27 @@ describe("endfield", () => {
}); });
}); });
describe("nikki", () => { describe("game8 labelled Start/End cells (retired Infinity Nikki fixture)", () => {
// The source is gone — that Game8 page stopped being updated in August 2025
// and Infinity Nikki now comes from Fandom — but the fixture stays, because
// it is the only page here carrying shape 6: a duration cell holding
// "Start: January 24, 2025 End: Permanent" as two labelled halves split by a
// <br>, which a tag-stripping reader sees as one run of text. Driven through
// the parser directly, since there is no adapter to route it any more.
const fixture = "fixtures/nikki/game8-events-2026-08-17"; const fixture = "fixtures/nikki/game8-events-2026-08-17";
async function parse() {
const html = await Bun.file(`${fixture}.html`).text();
return game8Parser.parse(html, {
now: NOW,
sourceUrl: "https://game8.co/games/Infinity-Nikki/archives/487445",
sourceId: "nikki-game8-events",
game: "nikki",
});
}
test("reads a labelled Start/End cell, and takes no end from 'Permanent'", async () => { test("reads a labelled Start/End cell, and takes no end from 'Permanent'", async () => {
// The duration cell is "Start: January 24, 2025 End: Permanent" — two const events = await parse();
// labelled halves separated by a <br>, which a tag-stripping reader sees as
// one run of text.
const events = await runAdapter(adapter("nikki-game8-events"), fixture);
expect(events).toHaveLength(7); // every row of the current-events table expect(events).toHaveLength(7); // every row of the current-events table
const fiesta = events.find((e) => e.title === "Fireworks Fiesta"); const fiesta = events.find((e) => e.title === "Fireworks Fiesta");
@@ -271,10 +284,7 @@ describe("nikki", () => {
test("never presents the labelled cell's own text as a summary", async () => { test("never presents the labelled cell's own text as a summary", async () => {
// "End: Permanent" is structure, not a blurb. Leaving it in the summary // "End: Permanent" is structure, not a blurb. Leaving it in the summary
// slot would show a reader a date the parser deliberately refused to use. // slot would show a reader a date the parser deliberately refused to use.
const events = await runAdapter(adapter("nikki-game8-events"), fixture); for (const e of await parse()) expect(e.summary).toBeNull();
for (const e of events) {
expect(e.summary).toBeNull();
}
}); });
}); });
@@ -1554,3 +1564,107 @@ describe("Nikke wiki (the third Fandom template)", () => {
"fixtures/fgo/fandom-events-2026-08-18")).length).toBeGreaterThan(0); "fixtures/fgo/fandom-events-2026-08-18")).length).toBeGreaterThan(0);
}); });
}); });
describe("Infinity Nikki wiki (the fourth Fandom template)", () => {
const fixture = "fixtures/nikki/fandom-events-2026-08-19";
const nikki = adapter("nikki-fandom-events");
function parse(html: string) {
return nikki.parse(html, {
now: NOW,
sourceUrl: nikki.url,
sourceId: nikki.id,
game: nikki.game,
});
}
const HEAD =
"<tr><th>Event</th><th>Duration</th><th>Description</th><th>Type</th></tr>";
const row = (title: string, duration: string, type = "Task") =>
`<tr><td>${title}</td><td>${duration}</td><td>Blurb</td><td>${type}</td></tr>`;
const wrapped = (sections: string) =>
JSON.stringify({ parse: { text: sections } });
const section = (heading: string, rows: string) =>
`<h2><span class="mw-headline" id="x">${heading}</span></h2>
<table class="article-table">${HEAD}${rows}</table>`;
test("takes the printed date at day precision and drops the clock", async () => {
// The page states a wall clock on both sides and names no zone for it
// anywhere. Publishing an instant would mean picking an offset, and the
// offset moves the day — the start's day is half of every event ID.
const events = await runAdapter(nikki, fixture);
const gambit = events.find((e) => e.title === "Dawnlit Gambit");
// "July 16, 2026 20:00 August 27, 2026 12:49"
expect(gambit?.startsAt).toBe("2026-07-16T00:00:00.000Z");
expect(gambit?.endsAt).toBe("2026-08-27T00:00:00.000Z");
for (const e of events) {
expect(e.startPrecision).toBe("day");
expect(e.endPrecision).toBe("day");
expect(e.startsAt.endsWith("T00:00:00.000Z")).toBe(true);
}
});
test("publishes 2026 events, which is the whole point of the source", async () => {
// The Game8 page this replaces was last updated in August 2025 and had been
// publishing year-old events as live ever since.
const events = await runAdapter(nikki, fixture);
expect(events.length).toBeGreaterThan(0);
for (const e of events) expect(e.startsAt >= "2026-01-01").toBe(true);
});
test("reads Current and Upcoming, never Permanent or Past", () => {
const events = parse(
wrapped(
section("Current Events", row("Live", "July 16, 2026 20:00 August 27, 2026 12:49")) +
section("Upcoming Events", row("Soon", "August 15, 2026 04:00 August 22, 2026 03:59")) +
section("Permanent Events", row("Forever", "August 1, 2026 04:00 December 1, 2026 03:59")) +
section("Past Events", row("Over", "August 1, 2026 04:00 December 1, 2026 03:59")),
),
);
expect(events.map((e) => e.title).sort()).toEqual(["Live", "Soon"]);
});
test("recovers a name from a red link's file, dated suffix and all", () => {
// Both shapes appear on the page: a real subpage link carrying
// "Name/2026-08-06", and a red link whose file is "Name 2026-08-06.png".
// That date names the run, not the event, and the start date already keys it.
const events = parse(
wrapped(
section(
"Current Events",
row(
`<a href="/wiki/Special:Upload?wpDestFile=x.png" title="File:Alison&#39;s Travel Shop 2026-08-06.png">File:x.png</a>`,
"August 6, 2026 20:00 August 27, 2026 12:49",
) +
row(
`<a href="/wiki/Deep_Breakthrough/2026-07-20" title="Deep Breakthrough/2026-07-20">img</a>`,
"July 20, 2026 04:00 August 27, 2026 03:49",
),
),
),
);
expect(events.map((e) => e.title).sort()).toEqual([
"Alison's Travel Shop",
"Deep Breakthrough",
]);
// Decoded, because an undecoded title becomes an undecoded localStorage key.
expect(events.map((e) => e.id)).toContain(
"nikki:alisons-travel-shop:2026-08-06",
);
});
test("a row it cannot date yields nothing rather than a guess", () => {
const events = parse(
wrapped(section("Current Events", row("Undated", "Permanent"))),
);
expect(events).toEqual([]);
});
test("carries the page's own type and description through", async () => {
const events = await runAdapter(nikki, fixture);
const shop = events.find((e) => e.title === "Memory Hall");
// Type column says "Store".
expect(shop?.type).toBe("shop");
expect(shop?.summary).toBeTruthy();
});
});