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:
co-authored by
Claude Opus 5
parent
c9e9359808
commit
bd385855c0
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user