feat: add Nikke, the third Fandom template

Its robots.txt was read in a browser and is the standard Fandom file: no
Disallow: / for *, /api.php?action= explicitly allowed, and only the namespaces
we never want refused. The AI crawlers it blocks by name are not us. That was
the one thing missing — the API itself has always answered our own User-Agent
with a 200, and it is only robots.txt that a datacentre address cannot read.

The zone lives in the column header, Start(UTC+9) / End(UTC+9), and no date in
any cell carries an offset. So the header is the safety property: a table that
stops naming its zone is refused rather than read as UTC, which is the Blue
Archive hazard arriving one column to the left.

Two shapes worth knowing. Every title is an image read from <a title>, and the
newest row is the one whose logo has not been uploaded yet — it renders as a
red link reading "File:Persona on Frontline logo.png", so a reader that only
understood the link title would silently drop today's live event. And story
events state a clock only on the end, so the start keeps the day the page
printed rather than being shifted nine hours into the previous one; the start's
day is half an event ID. That is the FGO rule applied to the opposite gap.

resetOffsets and resetHourLocal ship with the game because the page evidences
both: events end 04:59:59 and the banner replacing them starts 05:00:00, one
second later, on a stated UTC+9. Adding that later would re-label day keys
readers had already logged ticks under.

Seconds now survive the date helpers, since rounding 04:59:59 to the minute
would make an event overlap the banner that succeeds it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 04:11:03 +02:00
co-authored by Claude Opus 5
parent c9e9359808
commit bd385855c0
12 changed files with 533 additions and 31 deletions
+16
View File
@@ -143,6 +143,22 @@ const SOURCES: SourceSpec[] = [
url: "https://stellasora.miraheze.org/wiki/Main_Page",
parserId: "stellasorawiki",
},
{
id: "nikke-fandom-events",
game: "nikke",
// The MediaWiki API, like the two Fandom sources above. This wiki's
// robots.txt was read in a browser on 2026-08-19 and is the standard Fandom
// file: no `Disallow: /` for `*`, `/api.php?action=` explicitly allowed,
// and only `Special:`, `User:`, `User_talk:`, `Template:`, `Template_talk:`,
// `Help:` and `UserProfile:` refused. The named AI crawlers it blocks
// (GPTBot, CCBot, OAI-SearchBot, ImagesiftBot) are not us.
//
// As with r1999 and fgo, the robots gate still fails closed from a
// datacentre address, so the scheduled refresh reports skipped_robots and
// this lane is fixture-backed until refreshed from an address Fandom serves.
url: "https://nikke-goddess-of-victory-international.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json",
parserId: "fandom",
},
{
id: "czn-game8-events",
game: "czn",
+58 -3
View File
@@ -34,9 +34,12 @@ function iso(
d: number,
hh = 0,
mm = 0,
ss = 0,
): string | null {
if (m < 1 || m > 12 || d < 1 || d > 31 || hh > 23 || mm > 59) return null;
const date = new Date(Date.UTC(y, m - 1, d, hh, mm, 0, 0));
if (m < 1 || m > 12 || d < 1 || d > 31 || hh > 23 || mm > 59 || ss > 59) {
return null;
}
const date = new Date(Date.UTC(y, m - 1, d, hh, mm, ss, 0));
// Rejects impossible calendar dates such as February 30.
if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
return date.toISOString();
@@ -379,8 +382,9 @@ function offsetIso(
hh: number,
mm: number,
offsetMs: number,
ss = 0,
): string | null {
const local = iso(y, m, d, hh, mm);
const local = iso(y, m, d, hh, mm, ss);
if (local === null) return null;
return new Date(Date.parse(local) - offsetMs).toISOString();
}
@@ -555,3 +559,54 @@ export function parseIsoOffsetInstant(input: string): ParsedInstant | null {
if (Number.isNaN(value)) return null;
return { iso: new Date(value).toISOString(), precision: "exact" };
}
/**
* "12 August 2026" → 2026-08-12T00:00:00.000Z, day precision.
* "10 September 202604:59:59" → converted from `offsetMs`, exact precision.
*
* The Nikke wiki's schedule states its zone in the *column header*
* (`Start(UTC+9)`), not in the cell, so the offset arrives as an argument here
* rather than being read out of the text. A caller that cannot prove the zone
* must not call this.
*
* **A boundary with no clock keeps the day the page printed, unconverted.**
* That is the Fate/Grand Order rule (`AGENTS.md` § Fandom): there is no time of
* day to anchor a conversion to, and shifting a bare date by nine hours would
* move it to the previous calendar day — and the start's day is half an event
* ID. A boundary that does state a clock is converted, because then there is
* something real to convert.
*
* Day-first, unlike `parseMonthDayYear`: this wiki writes `12 August 2026`
* where Game8 writes `August 12, 2026`. The date and the clock arrive with no
* separator between them because they are separate elements in the markup, and
* reference markers (`[1]`) trail some cells, so the tail is tolerated rather
* than anchored.
*/
export function parseDayMonthYearClock(
input: string,
offsetMs: number,
): ParsedInstant | null {
const re = /^\s*(\d{1,2})\s+([A-Za-z]+)\.?\s+(\d{4})\s*(?:(\d{1,2}):(\d{2})(?::(\d{2}))?)?/;
const m = re.exec(input.replace(/\[\d+\]/g, " "));
if (!m) return null;
const month = monthNumber(m[2] ?? "");
if (month === null) return null;
const day = Number(m[1]);
const year = Number(m[3]);
if (m[4] === undefined) {
// No clock: the printed day stands, exactly as it does on FGO's page.
const value = iso(year, month, day);
return value === null ? null : { iso: value, precision: "day" };
}
// Seconds matter here and are carried: this page ends its events at
// 04:59:59 and starts the next banner at 05:00:00, one second apart, and
// rounding that to the minute would make the two overlap.
const value = offsetIso(
year, month, day, Number(m[4]), Number(m[5]), offsetMs, Number(m[6] ?? 0),
);
return value === null ? null : { iso: value, precision: "exact" };
}
+209 -2
View File
@@ -1,5 +1,9 @@
import { eventId, type GachaEvent } from "../../shared/schema.ts";
import { parseFullRange, parseOrdinalDateTimeRange } from "../dates.ts";
import {
parseDayMonthYearClock,
parseFullRange,
parseOrdinalDateTimeRange,
} from "../dates.ts";
import { text } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
import { inferType } from "./game8.ts";
@@ -83,6 +87,32 @@ const FGO_DURATION = /<b>\s*Duration:\s*<\/b>([^<]*)/i;
*/
const FGO_ARTICLE_SUFFIX = /\s*\(US\)\s*$/;
/**
* The Nikke wiki's schedule tables — the third template this parser reads, and
* the only one that states its timezone in the *header* rather than the cell:
* `Event | Start(UTC+9) | End(UTC+9) | Archived(?)` for story events, and
* `Nikke | Start(UTC+9) | End(UTC+9)` for pickup banners.
*
* That header is the safety property rather than a convenience. No date on this
* page carries an offset next to it, so a table whose Start/End columns stop
* naming a zone is one this reader must refuse rather than read as UTC — the
* Blue Archive hazard, arriving one column to the left.
*/
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.
*
* Every title on this page is an image and the name is normally recoverable
* from the wrapping `<a title="Project Matis">`. The newest row — today's live
* event — is the one most likely to have no logo uploaded, and then the cell is
* a red link reading `File:Persona on Frontline logo.png`. A reader that only
* understood `<a title>` would silently drop the single most important row on
* the page and publish a calendar missing what is on right now.
*/
const NIKKE_FILE_TITLE =
/^File:\s*(.+?)\s*(?:logo)?\.(?:png|jpe?g|gif|webp)$/i;
/** The rendered HTML inside an `action=parse` response, or null. */
export function renderedHtml(body: string): string | null {
let payload: unknown;
@@ -216,6 +246,173 @@ function parseFgoOngoingEvents(
return out;
}
/**
* True for the Nikke wiki's `Event` page, and the gate on the branch below.
*
* Asserts a schedule table whose Start/End headers name a zone, which is the
* one fact the reader cannot get from anywhere else on the page.
*/
function isNikkeEventPage(rendered: string): boolean {
return nikkeTables(rendered).length > 0;
}
interface NikkeTable {
body: string;
startIdx: number;
endIdx: number;
offsetMs: number;
/** Pickup banners head their title column `Nikke`; story events, `Event`. */
banner: boolean;
/** That first header, unsquashed — the table's own name for its rows. */
label: string;
}
/** Every table on the page whose Start/End columns state their offset. */
function nikkeTables(rendered: string): NikkeTable[] {
const out: NikkeTable[] = [];
for (const table of rendered.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
const body = table[1] ?? "";
const headers = [...body.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)].map((h) =>
text(h[1] ?? "").toLowerCase().replace(/\s+/g, ""),
);
let startIdx = -1;
let endIdx = -1;
let offsetMs: number | null = null;
headers.forEach((h, i) => {
const m = NIKKE_ZONE_HEADER.exec(h);
if (m === null) return;
// Both columns must agree on the offset; a table stating two different
// ones is a shape this reader does not understand.
const hours = Number(m[2]);
const ms = hours * 60 * 60 * 1000;
if (offsetMs !== null && offsetMs !== ms) return;
offsetMs = ms;
if (m[1] === "start") startIdx = i;
else endIdx = i;
});
if (startIdx < 0 || endIdx < 0 || offsetMs === null) continue;
const first = headers[0] ?? "";
const label = text(
/<th\b[^>]*>([\s\S]*?)<\/th>/i.exec(body)?.[1] ?? "",
);
out.push({
body,
startIdx,
endIdx,
offsetMs,
banner: first === "nikke",
label,
});
}
return out;
}
/**
* The Nikke wiki's story events and pickup banners.
*
* Both tables are read because both are schedules our readers act on, and both
* state their zone the same way. What differs is how much they pin down: a
* story event's start is a bare date and its end carries a clock, while a
* banner usually carries one on both. `parseDayMonthYearClock` keeps a
* clockless boundary on the day the page printed rather than shifting it nine
* hours into the previous one — the Fate/Grand Order rule, and here it matters
* doubly because the start's day is half an event ID.
*/
function parseNikkeEvents(rendered: string, ctx: ParseContext): GachaEvent[] {
const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
const seen = new Set<string>();
for (const table of nikkeTables(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[0]?.html ?? "";
const title = nikkeTitle(titleCell);
if (title === null) continue;
const start = parseDayMonthYearClock(
text(cells[table.startIdx]?.html ?? ""),
table.offsetMs,
);
if (start === null) continue;
const end = parseDayMonthYearClock(
text(cells[table.endIdx]?.html ?? ""),
table.offsetMs,
);
if (end === null || end.iso <= start.iso) continue;
// Five year-tabbed tables of history sit alongside the live one, so
// inclusion is decided against ctx.now as it is everywhere else here.
if (Date.parse(end.iso) < nowMs) continue;
const id = eventId(ctx.game, title, start.iso);
if (seen.has(id)) continue;
seen.add(id);
const href = ARTICLE_LINK.exec(titleCell)?.[1];
out.push({
id,
game: ctx.game,
title,
// The table names what its rows are — "Costume Gacha", "Popularity
// Poll" — which the title alone never does. A pickup table is a
// character banner outright; everything else goes through the shared
// vocabulary with that label alongside the title.
type: table.banner ? "banner" : inferType(`${title} ${table.label}`),
summary: null,
startsAt: start.iso,
startPrecision: start.precision,
endsAt: end.iso,
endPrecision: end.precision,
// One worldwide server on a single stated offset, and the page draws no
// distinction between regions.
regionScoped: false,
regionEnds: null,
sourceUrl:
href === undefined
? ctx.sourceUrl
: new URL(href, ctx.sourceUrl).toString(),
sourceId: ctx.sourceId,
status: "published",
// Docked where the source pinned less down: a start with no clock is a
// day, not an instant.
confidence: start.precision === "day" ? 0.9 : 0.95,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
});
}
}
return out;
}
/** A title cell's event name: the link's title, or the file name behind it. */
function nikkeTitle(cell: string): string | null {
const linked = /<a\b[^>]*title="([^"]*)"/i.exec(cell)?.[1];
const raw = (linked ?? text(cell)).trim();
if (raw.length === 0) return null;
// "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.
const named = NIKKE_FILE_TITLE.exec(raw)?.[1];
const title = (named ?? raw).trim();
return title.length === 0 ? null : title;
}
export function parseFandomEventsPage(
body: string,
ctx: ParseContext,
@@ -228,6 +425,14 @@ export function parseFandomEventsPage(
// 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
// emptying the lane.
if (isNikkeEventPage(rendered)) {
return parseNikkeEvents(rendered, ctx).sort((a, b) =>
a.startsAt === b.startsAt
? a.id.localeCompare(b.id)
: a.startsAt.localeCompare(b.startsAt),
);
}
if (isFgoEventList(rendered)) {
return parseFgoOngoingEvents(rendered, ctx).sort((a, b) =>
a.startsAt === b.startsAt
@@ -336,7 +541,9 @@ export const fandomParser: SourceParser = {
if (rendered === null) return false;
const isTimePeriodTable =
/class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered);
return isTimePeriodTable || isFgoEventList(rendered);
return (
isTimePeriodTable || isFgoEventList(rendered) || isNikkeEventPage(rendered)
);
},
parse: parseFandomEventsPage,
};
+11
View File
@@ -139,6 +139,17 @@ export const GAMES: Record<GameId, GameMeta> = {
// which states the zone the *dates* are in and says nothing about where the
// server's day rolls — and every row on it is a bare date anyway, with no
// time of day to read a reset out of. Same silence as p5x and czn.
// Nikke runs one worldwide server on a Japanese clock that rolls its day at
// **05:00**, not 04:00 — and both halves come from the source rather than
// from habit. Every schedule column on the wiki is headed `Start(UTC+9)` /
// `End(UTC+9)`, and the rows themselves show the boundary: story events end
// at 04:59:59 and the pickup banner replacing them starts at 05:00:00, one
// second apart. That is the Reverse: 1999 evidence pattern exactly.
//
// Set in the same commit that ships the game, which costs nothing now and
// could not be added later without re-labelling day keys readers had already
// logged ticks under.
nikke: { id: "nikke", name: "Goddess of Victory: Nikke", short: "Nikke", hue: "#E4572E", studio: "Shift Up", dailyTasks: "Daily missions, outpost", resetOffsets: { asia: 9, america: 9, europe: 9 }, resetHourLocal: 5 },
uma: { id: "uma", name: "Umamusume: Pretty Derby", short: "Umamusume", hue: "#6FBF44", studio: "Cygames", dailyTasks: "Daily races, missions" },
};
+3
View File
@@ -18,6 +18,9 @@ export const GameId = z.enum([
"stellasora", // Stella Sora
"czn", // Chaos Zero Nightmare
"uma", // Umamusume: Pretty Derby
// NOTE: one letter from "nikki" (Infinity Nikki) above, and both are the
// first segment of every completion key their game will ever have.
"nikke", // Goddess of Victory: Nikke
]);
export type GameId = z.infer<typeof GameId>;