import { eventId, type GachaEvent } from "../../shared/schema.ts"; import { latestBoundaryMs } from "../../shared/time.ts"; import { parseDayMonthYearClock, parseFullRange, parseOrdinalDateTimeRange, parseZonelessClockRange, } from "../dates.ts"; import { decodeEntities, text } from "../html.ts"; import type { ParseContext } from "../adapters/types.ts"; import { inferType } from "./game8.ts"; import type { SourceParser } from "./types.ts"; /** * Fandom wikis, read through the MediaWiki action API. * * **Why the API and not the page.** `reverse1999.fandom.com/wiki/Events` * answers a non-browser client with a Cloudflare interstitial ("Just a * moment…", HTTP 403), and dressing our fetcher up as a browser to get past it * would be defeating a deliberate access control — the reason `uma.moe` was * declined in AGENTS.md § Scraping conduct. The wiki's own `robots.txt` instead * *allows* `/api.php?action=` for `User-agent: *`, and that endpoint answers our * real User-Agent with a 200. So this parser reads the sanctioned surface with * no impersonation anywhere: same headers, a path the site put in writing. * * The body is therefore JSON rather than HTML: * * {"parse":{"title":"Events","pageid":3479,"text":"
"}} * * `parse.text` is the rendered wikitext, and the shape below is what this family * of pages puts in it — `wikitable`s under one `h2` per section: * *

Version Events

* * * * * * * Two details decide most of the code. * * **The title is the ``, never the cell text.** The cell leads with a banner * image, and when that image is missing MediaWiki renders a red link whose * visible text is `File:A Stranger to Memory Lane Banner.png`. A cell-text * reader publishes that as the event's name. The `` holds the title in both * cases. * * **These pages are archives, not schedules.** All five tables list every event * since 1.1 — 154 rows, of which six had not yet ended when the fixture was * captured. Publishing the rest would put three years of finished events on the * calendar and hand the validator a hundred rows whose start predates its * two-year sanity window. So inclusion is decided against `ctx.now`, which is * injected precisely so a parser can be time-aware without reading the clock. */ const SECTION = /]*>([\s\S]*?)<\/h2>|]*>([\s\S]*?)<\/table>/gi; const ROW = /]*>([\s\S]*?)<\/tr>/gi; const CELL = /]*>([\s\S]*?)<\/t\1>/gi; const BOLD = /]*>([\s\S]*?)<\/b>/i; const EDIT_SECTION = /]*class="[^"]*mw-editsection[^"]*"[^>]*>[\s\S]*?<\/span>/gi; /** * A link to the event's own article. `Special:` is excluded deliberately: a * missing banner image renders as a `Special:Upload` link, which is both the * wrong page to send a reader to and a path this wiki's robots.txt disallows. */ const ARTICLE_LINK = /]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i; /** * `Event_List_(US)` fences its three sections with banner images that carry * their label in an absolutely-positioned `
` drawn over the picture. That * label is the only thing in the markup naming a section — there is no heading * and no id — so it is what inclusion is decided on. */ const FGO_ONGOING_DIVIDER = />\s*ONGOING EVENTS\s*\s*FUTURE EVENTS\s*]*href="(\/wiki\/(?!Special:)[^"#?]+)"[^>]*>([^<]*)<\/a>/i; const FGO_DURATION = /\s*Duration:\s*<\/b>([^<]*)/i; /** * The wiki disambiguates an English article from its Japanese counterpart by * appending `(US)` to the article name — `Archetype Inception Chapter Release` * exists twice, once per server. Every row this source publishes therefore * carries the suffix, which makes it constant noise on a calendar that shows * one server's schedule and names no other. It is stripped here rather than in * the sanitizer because it is a fact about *this page's naming convention*, * which is a parser's job to know. */ 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 image for the event yet. * * Both the Nikke and Infinity Nikki pages render every event title as an image, * with the name recoverable from the wrapping ``. The * newest row is the one most likely to have no image uploaded, and then the * cell is a red link reading `File:Persona on Frontline logo.png`. A reader that * only understood `` would silently drop the single most important row * on either page and publish a calendar missing what is on right now. */ const FILE_TITLE = /^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. */ export function renderedHtml(body: string): string | null { let payload: unknown; try { payload = JSON.parse(body); } catch { return null; } const html = (payload as { parse?: { text?: unknown } } | null)?.parse?.text; return typeof html === "string" ? html : null; } /** * True for `Event_List_(US)`, and the gate on the branch below. * * Every anchor the FGO reader depends on, asserted together: the two dividers * that fence the ongoing section, and the label carrying its dates. Asserting * them here rather than discovering them missing mid-parse is what turns a * template change into a stalled source — the runner rejects a body that parses * worse than the one it holds — instead of a lane that quietly goes empty. */ function isFgoEventList(rendered: string): boolean { return ( FGO_ONGOING_DIVIDER.test(rendered) && FGO_FUTURE_DIVIDER.test(rendered) && FGO_DURATION.test(rendered) ); } /** * The English-server half of the Fate/Grand Order wiki. * * **The page is chosen, not incidental.** This wiki publishes two schedules: * `Event_List` opens "This page lists all Events in Fate/Grand Order Japan", * and `Event_List_(US)` is the English server. They run months apart and the * Japanese one is the trap — the same hazard as the CN column on `akwiki` and * the JP tab on `bawiki`, and the same answer: publish the server our readers * are on. Each page links the other, so landing on the wrong one is easy and * silent. * * **Only the ongoing section is parsed**, of the three the page fences off: * * - `ONGOING EVENTS` — one `

` per event with a `Duration:` line * stating both boundaries. This is the section with dates in it. * - `FUTURE EVENTS` — an `Upcoming Events | ETA` table whose ETA column reads * `August 2026`. A month with no day is not a start date, and a start date is * half of an event id, so these are skipped rather than pinned to the 1st. * - `PAST EVENTS` — 111 monthly tables of finished events. Unlike the Japanese * page, whose equivalent tables carry the month in a `MMYYYY` table id, these * state no year anywhere in the markup. Undatable, and history regardless. * * **The dates state a zone but no clock.** Every duration ends `PDT`, which is * what makes the single Pacific-time server visible, but the boundaries are * bare calendar days. So they are read as day precision on the day the page * states and *not* shifted into UTC: with no time of day to anchor, converting * would move the stated day for a fact the page never published. `PDT` is also * a daylight abbreviation the page swaps for `PST` in winter, which is exactly * the shifting-offset case `NAMED_ZONE_OFFSET_MS` refuses to carry. * * **The section is sliced by index, then split on `

`.** A single regex * spanning heading-to-duration needs nested lazy quantifiers, and on a 620KB * body those backtrack catastrophically the moment one of the anchors stops * matching — a renamed `Duration:` label would hang the refresh runner instead * of yielding nothing. Bounded character classes cannot do that. */ function parseFgoOngoingEvents( rendered: string, ctx: ParseContext, ): GachaEvent[] { const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " "); const start = flat.search(FGO_ONGOING_DIVIDER); if (start === -1) return []; const rest = flat.slice(start); const end = rest.search(FGO_FUTURE_DIVIDER); // Both dividers are asserted by `canParse`. Bailing rather than reading to // the end of the body keeps a renamed divider from sweeping the whole past // archive into the ongoing section. if (end === -1) return []; const nowMs = Date.parse(ctx.now); const out: GachaEvent[] = []; for (const block of rest.slice(0, end).split(/ 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(/]*>([\s\S]*?)<\/table>/gi)) { const body = table[1] ?? ""; const headers = [...body.matchAll(/]*>([\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( /]*>([\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(); 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 { // `decodeEntities`, not `text`: this comes out of an attribute value, which // never passes through the tag stripper, so `'` would otherwise survive // into the title and from there into the event ID. const linked = attributeTitle(cell); 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 = FILE_TITLE.exec(raw)?.[1]; const title = (named ?? raw).trim(); 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; /** The page's own words when a section is currently listing nothing. */ const IN_STATES_EMPTY = /There are no Events in this category/i; /** Every `

` on the page, flattened to lower-case text. */ function h2Headings(rendered: string): Set { const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " "); const out = new Set(); for (const node of flat.matchAll(/]*>([\s\S]*?)<\/h2>/gi)) { out.add(text(node[1] ?? "").trim().toLowerCase()); } return out; } /** * True for the Infinity Nikki wiki's `Event` page, and the gate on the branch * below. * * **Identity only — never the presence of a row.** This asserted a populated * `Current`/`Upcoming` table until 2026-09-03, which conflated the two things a * tripwire has to keep apart: a page that was rewritten, and a game that has * nothing on. Infinity Nikki emptied both tables the week 2.7's events ended * and 2.8 was not yet listed, and the source spent four cycles reporting * `the source has likely been redesigned` at a page whose markup had not moved * — long enough to reach the `broken` tier and fail the workflow. `canParse` is * documented as a *structural* check for exactly this reason * (docs/INGESTION.md § The adapter registry), so it reads the section headings, * which an empty table does not take with it. * * *Either* heading, not both — the same doc's warning against over-fitting. The * live page carries both, but requiring the pair would fail the source over a * renamed heading it does not even read, and one is already unique: the other * three Fandom templates carry neither. `Past Events` is deliberately not in * the set, or an archive with nothing current would identify as this page. */ function isInfinityNikkiEventPage(rendered: string): boolean { const headings = h2Headings(rendered); return headings.has("current events") || headings.has("upcoming events"); } /** * True when this page tells us, in its own words, that it currently lists no * events — the state Infinity Nikki sits in between versions. * * This is what lets the refresh gate tell "we read the page and there is * nothing on" from "we read nothing", and it is deliberately the page's * statement rather than a row count: a redesign that broke every selector would * also yield zero rows, and storing *that* as an empty snapshot is the silent * emptying of a calendar the zero-events gate exists to prevent. */ function infinityNikkiStatesNoEvents(rendered: string): boolean { if (!isInfinityNikkiEventPage(rendered)) return false; // A table we can still read means the page is listing something, whatever // else it says further down. if (infinityNikkiTables(rendered).length > 0) return false; return IN_STATES_EMPTY.test(rendered); } 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(/]*>([\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(); 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 — on the same // clock the countdown reads a day-precision end on, not on the UTC // midnight placeholder stored for it. if (latestBoundaryMs(range.end.iso, range.end.precision, ctx.game) < 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 * `'` — 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 = /]*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; } /** * Genshin's `Event` page — the fifth template, and the only one whose sections * are fenced by an `

`. * *

List of Events

*

Current

*

EventTime PeriodVersion

TITLE
August 13th, 05:00 - September 21st, 2026, 04:59 (UTC-5)3.7
* * * * * * `Current` and `Upcoming` are the schedule; `Permanent` states a * `Release Date` and no end, and is fenced off. * * **Every boundary is day precision, and that is the page's whole claim** — it * prints a date and no time of day on either side, so unlike Infinity Nikki * there is not even a clock to discard. The duration cell does carry a * `data-sort-value="2026-09-14 04:00:002026-09-21 03:59:59"`, and it is * deliberately ignored: it names no zone, and picking one moves the *day*, * which is half of every event ID this game will ever have. It costs nothing * either, because 04:00 is the reset hour `clockFor` already resolves a * day-precision boundary to on the reader's own server — reading the sort key * would replace a correct per-region answer with one fixed guess. */ const GI_SECTION = /]*>([\s\S]*?)<\/h\1>|]*>([\s\S]*?)<\/table>/gi; /** The two sub-headings whose tables are a schedule, not a back catalogue. */ const GI_INCLUDED_SECTION = /^(current|upcoming)$/i; /** The `h2` this page's schedule lives under, and half of its identity. */ const GI_PAGE_HEADING = /^list of events$/i; /** * True for the Genshin wiki's `Event` page, and the gate on the branch below. * * **Structural, and it survives an empty table** — the Infinity Nikki lesson * above, applied before it could be learned twice: `Current` and `Upcoming` * are headings, and a game between versions empties the tables under them * without taking them with it. Both halves are required because each is weak * alone: `List of Events` also heads pages that are glossaries, and a bare * `Current` is a word any wiki might use. */ function isGenshinEventPage(rendered: string): boolean { const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " "); let onEventsPage = false; let hasSchedule = false; for (const node of flat.matchAll(GI_SECTION)) { const heading = node[2]; if (heading === undefined) continue; const name = text(heading).trim(); if (GI_PAGE_HEADING.test(name)) onEventsPage = true; if (GI_INCLUDED_SECTION.test(name)) hasSchedule = true; } return onEventsPage && hasSchedule; } interface GiTable { body: string; title: number; duration: number; type: number | undefined; } /** Every `Current`/`Upcoming` table, with its columns resolved. */ function genshinTables(rendered: string): GiTable[] { const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " "); const out: GiTable[] = []; let included = false; for (const node of flat.matchAll(GI_SECTION)) { const heading = node[2]; if (heading !== undefined) { // **Every heading closes the section, whatever its level.** The three // schedules are `h3`s and the page's other tables sit under `h2`s, so a // reader watching only `h2`s would carry `Upcoming` past the end of the // schedule and read `List of Event Types` — a glossary of every event // type this game has ever run — as though it were dated. included = GI_INCLUDED_SECTION.test(text(heading).trim()); continue; } if (!included) continue; const body = node[3] ?? ""; const headers = [...body.matchAll(/]*>([\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, as in `bawiki.ts`. // `Permanent` heads a `Release Date` column and fails this lookup too, so // two independent things keep an undated permanent fixture off a calendar // of deadlines. if (title === undefined || duration === undefined) continue; out.push({ body, title, duration, type: at("type(s)") }); } return out; } /** * The Genshin wiki's current and upcoming events. * * A second source for a game whose Game8 page cannot be fetched from CI at all * (AGENTS.md § Scraping conduct), so this is the only Genshin surface a * scheduled refresh can actually move. */ function parseGenshinEvents( rendered: string, ctx: ParseContext, ): GachaEvent[] { const nowMs = Date.parse(ctx.now); const out: GachaEvent[] = []; const seen = new Set(); for (const table of genshinTables(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 = genshinTitle(titleCell); if (title === null) continue; const range = parseFullRange(text(cells[table.duration]?.html ?? "")); // A row this reader cannot date yields nothing rather than a guess. 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 — on the clock // the countdown reads a day-precision end on, not on the UTC midnight // placeholder stored for it. if ( latestBoundaryMs(range.end.iso, range.end.precision, ctx.game) < nowMs ) { continue; } const id = eventId(ctx.game, title, range.start.iso); if (seen.has(id)) continue; seen.add(id); 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(s)` column — "Battle Pass", "Archon Quest", // "Login" — is a far better signal than a Genshin event title, which // is usually a piece of poetry. type: inferType(`${title} ${stated}`), // No description column, and the prose lives on each event's own // article rather than further down this page — so there is nothing to // summarise, and `Type(s)` is a classification rather than a blurb. summary: null, startsAt: range.start.iso, startPrecision: range.start.precision, endsAt: range.end.iso, endPrecision: range.end.precision, // One worldwide service on this page's telling: it draws no regional // distinction and states no per-region end, so `regionScoped` would be // claiming a split the source never made. 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, which is everything the page states. confidence: 0.85, extractionMethod: "parser", version: 1, firstSeenAt: ctx.now, updatedAt: ctx.now, }); } } return out; } /** * An event's name, from the caption link under its banner. * * **The cell's text, and neither attribute** — the reverse of the Nikke and * Infinity Nikki rule above, which is exactly why it is written down: those two * are the nearest precedent and following them here would be wrong. This wiki * renders a banner linked to the event's article and repeats the name as a * caption beneath it, so the visible text is the curated display name while * both attributes name something else. The link `title` is the *parent* * article — `Miliastra Pass/2026-08-12` for an event called * `Phantasmagoric Chronicle` — and the `img alt` is whatever the uploaded file * was called, which is `Stygian Onslaught 2025-10-29` on an event that starts * 2026-08-19. Either one publishes a wrong name, and a title is half a * localStorage key. * * A row with no caption is therefore **skipped rather than named from an * attribute that demonstrably means something else** — an omitted event is a * recoverable disappointment, a wrong key is not recoverable at all. Every row * on the live page carries one. */ function genshinTitle(cell: string): string | null { const raw = text(cell).trim(); if (raw.length === 0) return null; // `Overflowing Abundance 2026-09-14` — this wiki gives each run of a // recurring event its own dated page and sometimes captions the row with it. // The same call as Infinity Nikki's, for the same reason: that date names the // *run*, the start date is already half the event ID, and keeping it would // print the date twice in one row. const title = raw.replace(DATED_SUBPAGE, "").trim(); return title.length === 0 ? null : title; } export function parseFandomEventsPage( body: string, ctx: ParseContext, ): GachaEvent[] { const rendered = renderedHtml(body); if (rendered === null) return []; // Two Fandom wikis, two page templates, one host family — the same split // `game8.ts` carries for seven shapes. The divider layout is what tells them // 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 (isInfinityNikkiEventPage(rendered)) { return parseInfinityNikkiEvents(rendered, ctx).sort((a, b) => a.startsAt === b.startsAt ? a.id.localeCompare(b.id) : a.startsAt.localeCompare(b.startsAt), ); } if (isGenshinEventPage(rendered)) { return parseGenshinEvents(rendered, ctx).sort((a, b) => a.startsAt === b.startsAt ? a.id.localeCompare(b.id) : a.startsAt.localeCompare(b.startsAt), ); } 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 ? a.id.localeCompare(b.id) : a.startsAt.localeCompare(b.startsAt), ); } const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " "); const nowMs = Date.parse(ctx.now); const out: GachaEvent[] = []; let section = ""; for (const node of flat.matchAll(SECTION)) { const heading = node[1]; if (heading !== undefined) { section = text(heading); continue; } const table = node[2] ?? ""; // Every section table states the same three columns. Checking them keeps a // navbox or an infobox elsewhere in the page from being read as a schedule. const headers = [...table.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map( (h) => text(h[1] ?? "").toLowerCase(), ); if (!headers.includes("event") || !headers.includes("time period")) continue; for (const row of table.matchAll(ROW)) { const cells = [...(row[1] ?? "").matchAll(CELL)].map((c) => ({ tag: c[1] ?? "", html: c[2] ?? "", })); if (cells.length < 2 || cells.some((c) => c.tag === "h")) continue; const titleCell = cells[0]?.html ?? ""; const title = text(BOLD.exec(titleCell)?.[1] ?? ""); if (title.length === 0) continue; const range = parseOrdinalDateTimeRange(text(cells[1]?.html ?? "")); // A row stating no year on either half is unresolvable — the fixture has // exactly one — and a row this reader cannot date yields no event rather // than a guessed one. if (range === null) continue; const { start, end } = range; if (end.iso <= start.iso) continue; // Live and upcoming only. An event whose end has passed is history this // page keeps and the calendar does not want. if (Date.parse(end.iso) < nowMs) continue; const href = ARTICLE_LINK.exec(titleCell)?.[1]; out.push({ id: eventId(ctx.game, title, start.iso), game: ctx.game, title, // The section heading is the source's own classification, and a better // signal than the title alone — "Character Story Events" names a story // event whose title says nothing about it. type: inferType(`${title} ${section}`), summary: section.length > 0 ? section : null, startsAt: start.iso, startPrecision: start.precision, endsAt: end.iso, endPrecision: end.precision, // One global server on a single stated offset: every row on the page // reads (UTC-5), and the page draws no distinction between regions. // `regionScoped` means the source separates them, and this one does not. regionScoped: false, regionEnds: null, sourceUrl: href === undefined ? ctx.sourceUrl : new URL(href, ctx.sourceUrl).toString(), sourceId: ctx.sourceId, status: "published", // Both boundaries are exact instants converted from a stated offset, // with nothing inferred — the same footing as the wiki.gg timers. confidence: 0.95, extractionMethod: "parser", version: 1, firstSeenAt: ctx.now, updatedAt: ctx.now, }); } } return out.sort((a, b) => a.startsAt === b.startsAt ? a.id.localeCompare(b.id) : a.startsAt.localeCompare(b.startsAt), ); } export const fandomParser: SourceParser = { id: "fandom", label: "Fandom", canParse(body: string): boolean { // Structural, and deliberately about the envelope as much as the content: // this source is an API, so a body that is not an `action=parse` response // is the failure worth catching loudly — an error payload, a login wall, or // the Cloudflare interstitial the plain page serves would all land here. const rendered = renderedHtml(body); if (rendered === null) return false; const isTimePeriodTable = /class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered); return ( isTimePeriodTable || isFgoEventList(rendered) || isNikkeEventPage(rendered) || isInfinityNikkiEventPage(rendered) || isGenshinEventPage(rendered) ); }, statesNoEvents(body: string): boolean { const rendered = renderedHtml(body); if (rendered === null) return false; // Only the Infinity Nikki template states this. The other three say nothing // either way when they are empty, so they keep the strict gate. return infinityNikkiStatesNoEvents(rendered); }, parse: parseFandomEventsPage, };
EventDurationType(s)
* Phantasmagoric Chronicle
* Phantasmagoric Chronicle
August 12, 2026 – September 21, 2026In-Game, Miliastra Pass