feat(fgo): read the English server, not the Japanese one
The source landed pointed at Event_List, whose first sentence is "This
page lists all Events in Fate/Grand Order Japan". Every date it
published was a JP date on a calendar whose other twelve games are
global — the akwiki CN column again, and the failure this product
exists to prevent. Event_List_(US) is the English schedule and is what
the adapter reads now; a test asserts the URL so it cannot drift back.
The page is a different template from the Reverse: 1999 one, so the
parser branches, the way game8 branches over seven shapes. Its sections
are fenced by banner images with the label drawn in a positioned div —
no heading, no id — and only ONGOING EVENTS is read. FUTURE EVENTS
states a month with no day, and PAST EVENTS states no year at all, so
neither can be dated and neither is guessed at. canParse asserts both
dividers and the Duration label, which turns a redesign into a stalled
source rather than a lane that quietly empties.
The rewrite drops what the JP branch needed and this page does not: the
hand-rolled month/year rollover (parseFullRange already reads both
stated years), the any-typed locals, and a heading-to-duration regex
whose nested lazy quantifiers hung for minutes on a 620KB body the
moment one anchor stopped matching. Durations name a zone but no clock,
so the stated calendar day is kept rather than shifted into UTC —
there is nothing to anchor a conversion to, and the start's day is half
the event ID.
Also reverts two things done to quiet tests rather than answer them.
The parser no longer normalises NFKC: that was pre-empting the
sanitizer so "real fixtures need no repair at all" would pass, and the
seam is where repairs belong. And renderedHtml no longer reaches into
a legacy {"*": …} envelope, which every source here rules out by
pinning formatversion=2.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bab32328da
commit
4698ebc0c7
@@ -1,17 +1,38 @@
|
||||
[
|
||||
{
|
||||
"id": "fgo:fate-grand-order-fes-2026-chaldea-academy-festival-11th-anniversary-main-quest-c:2026-07-22",
|
||||
"id": "fgo:fgo-summer-2026-event:2026-07-15",
|
||||
"game": "fgo",
|
||||
"title": "Fate/Grand Order Fes. 2026 Chaldea Academy Festival ~11th Anniversary~ Main Quest Completion Support Campaign",
|
||||
"title": "FGO Summer 2026 Event",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-07-15T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-01T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/FGO_Summer_2026_Event_(US)",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:archetype-inception-chapter-release:2026-07-29",
|
||||
"game": "fgo",
|
||||
"title": "Archetype Inception Chapter Release",
|
||||
"type": "story",
|
||||
"summary": null,
|
||||
"startsAt": "2026-07-22T00:00:00.000Z",
|
||||
"startsAt": "2026-07-29T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-31T00:00:00.000Z",
|
||||
"endsAt": "2026-09-08T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Fate/Grand_Order_Fes._2026_Chaldea_Academy_Festival_~11th_Anniversary~_Main_Quest_Completion_Support_Campaign",
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Archetype_Inception_Chapter_Release_(US)",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
@@ -21,18 +42,18 @@
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:fate-grand-order-11th-anniversary-summoning-campaign:2026-08-02",
|
||||
"id": "fgo:ordeal-call-iii-clear-support-campaign:2026-07-29",
|
||||
"game": "fgo",
|
||||
"title": "Fate/Grand Order ~11th Anniversary~ Summoning Campaign",
|
||||
"title": "Ordeal Call III Clear Support Campaign",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-02T00:00:00.000Z",
|
||||
"startsAt": "2026-07-29T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-23T00:00:00.000Z",
|
||||
"endsAt": "2026-09-30T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Fate/Grand_Order_%EF%BD%9E11th_Anniversary%EF%BD%9E_Summoning_Campaign",
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Ordeal_Call_III_Clear_Support_Campaign_(US)",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
@@ -42,102 +63,18 @@
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-1:2026-08-12",
|
||||
"id": "fgo:archetype-inception-part-3-release-campaign:2026-08-12",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign 1",
|
||||
"title": "Archetype Inception Part 3 Release Campaign",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endsAt": "2026-08-26T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_1",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-male:2026-08-12",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign (Male)",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_(Male)",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown:2026-08-12",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-2:2026-08-14",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign 2",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-14T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_2",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-3:2026-08-14",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign 3",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-14T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_3",
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Archetype_Inception_Part_3_Release_Campaign_(US)",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -109,7 +109,9 @@ const SOURCES: SourceSpec[] = [
|
||||
{
|
||||
id: "fgo-fandom-events",
|
||||
game: "fgo",
|
||||
url: "https://fategrandorder.fandom.com/api.php?action=parse&page=Event_List&prop=text&formatversion=2&format=json",
|
||||
// `Event_List` is the Japanese server and `Event_List_(US)` is ours; they
|
||||
// run months apart. See parsers/fandom.ts § the English-server half.
|
||||
url: "https://fategrandorder.fandom.com/api.php?action=parse&page=Event_List_(US)&prop=text&formatversion=2&format=json",
|
||||
parserId: "fandom",
|
||||
},
|
||||
{
|
||||
|
||||
+125
-128
@@ -1,5 +1,5 @@
|
||||
import { eventId, type GachaEvent } from "../../shared/schema.ts";
|
||||
import { parseMonthDayRange, parseOpenRange, parseOrdinalDateTimeRange } from "../dates.ts";
|
||||
import { parseFullRange, parseOrdinalDateTimeRange } from "../dates.ts";
|
||||
import { text } from "../html.ts";
|
||||
import type { ParseContext } from "../adapters/types.ts";
|
||||
import { inferType } from "./game8.ts";
|
||||
@@ -59,8 +59,29 @@ const EDIT_SECTION = /<span\b[^>]*class="[^"]*mw-editsection[^"]*"[^>]*>[\s\S]*?
|
||||
*/
|
||||
const ARTICLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i;
|
||||
|
||||
const FGO_TABLE = /<table\b[^>]*id="(\d{2})(\d{4})"[^>]*class="[^"]*wikitable"[^>]*>([\s\S]*?)<\/table>/gi;
|
||||
const FGO_ROW_BLOCK = /<th\b[^>]*colspan="2"[^>]*>([\s\S]*?)<\/th>[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>\s*<td\b[^>]*>([\s\S]*?)<\/td>/gi;
|
||||
/**
|
||||
* `Event_List_(US)` fences its three sections with banner images that carry
|
||||
* their label in an absolutely-positioned `<div>` 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*</i;
|
||||
const FGO_FUTURE_DIVIDER = />\s*FUTURE EVENTS\s*</i;
|
||||
|
||||
/** The title link, and the duration line, inside one ongoing block. */
|
||||
const FGO_TITLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"[^>]*>([^<]*)<\/a>/i;
|
||||
const FGO_DURATION = /<b>\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 rendered HTML inside an `action=parse` response, or null. */
|
||||
export function renderedHtml(body: string): string | null {
|
||||
@@ -72,66 +93,116 @@ export function renderedHtml(body: string): string | null {
|
||||
}
|
||||
|
||||
const html = (payload as { parse?: { text?: unknown } } | null)?.parse?.text;
|
||||
if (typeof html === "object" && html !== null && '*' in html) {
|
||||
return (html as any)['*'] as string;
|
||||
}
|
||||
return typeof html === "string" ? html : null;
|
||||
}
|
||||
|
||||
function parseFgoEventsPage(rendered: string, ctx: ParseContext): GachaEvent[] {
|
||||
/**
|
||||
* 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 `<h2>` per event with a `<b>Duration:</b>` 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 `<h2>`.** 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[] = [];
|
||||
|
||||
const ONGOING_BLOCK = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>[\s\S]*?<b>Duration:<\/b>\s*([\s\S]*?)(?:<\/p>|<br\s*\/?>)/gi;
|
||||
for (const block of rest.slice(0, end).split(/<h2\b/i).slice(1)) {
|
||||
const link = FGO_TITLE_LINK.exec(block);
|
||||
if (link === null) continue;
|
||||
|
||||
for (const match of flat.matchAll(ONGOING_BLOCK)) {
|
||||
const href = match[1] ?? "";
|
||||
const titleHtml = match[2] ?? "";
|
||||
const title = text(titleHtml).normalize("NFKC").trim();
|
||||
if (!title || /permanent/i.test(title)) continue;
|
||||
const title = text(link[2] ?? "").replace(FGO_ARTICLE_SUFFIX, "");
|
||||
if (title === "") continue;
|
||||
|
||||
let rawDate = text(match[3] ?? "").replace(/ /g, " ").replace(/~/g, "-").replace(/JST/i, "").trim();
|
||||
if (!rawDate) continue;
|
||||
const duration = FGO_DURATION.exec(block);
|
||||
if (duration === null) continue;
|
||||
|
||||
rawDate = rawDate.replace(/(,\s*\d{4})\s*-/, " -");
|
||||
// The page separates the two boundaries with a tilde. Everything else about
|
||||
// the line — both years stated, trailing zone — `parseFullRange` already
|
||||
// reads, and it returns null rather than inferring a missing half.
|
||||
const range = parseFullRange(text(duration[1] ?? "").replace(/~/g, "-"));
|
||||
if (range === null) continue;
|
||||
if (range.end.iso <= range.start.iso) continue;
|
||||
|
||||
const parts = rawDate.split(/[-–—]/).map((s) => s.trim());
|
||||
let start: any = null;
|
||||
let end: any = null;
|
||||
|
||||
if (parts.length === 1 && parts[0]) {
|
||||
const openMatch = parseOpenRange(parts[0]);
|
||||
if (openMatch) {
|
||||
start = openMatch.start;
|
||||
end = null;
|
||||
}
|
||||
} else if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
const rangeMatch = parseMonthDayRange(rawDate);
|
||||
if (rangeMatch) {
|
||||
start = rangeMatch.start;
|
||||
end = rangeMatch.end;
|
||||
}
|
||||
}
|
||||
|
||||
if (!start) continue;
|
||||
if (end && end.iso <= start.iso) continue;
|
||||
const endMs = end ? Date.parse(end.iso) : Date.parse(start.iso) + 86400000;
|
||||
if (endMs < nowMs) continue;
|
||||
// "Ongoing" is maintained by hand and goes stale before anyone moves a row,
|
||||
// so the heading vouching for an event is not enough on its own.
|
||||
if (Date.parse(range.end.iso) < nowMs) continue;
|
||||
|
||||
out.push({
|
||||
id: eventId(ctx.game, title, start.iso),
|
||||
id: eventId(ctx.game, title, range.start.iso),
|
||||
game: ctx.game,
|
||||
title,
|
||||
type: inferType(title),
|
||||
summary: null,
|
||||
startsAt: start.iso,
|
||||
startPrecision: start.precision,
|
||||
endsAt: end ? end.iso : null,
|
||||
endPrecision: end ? end.precision : "unknown",
|
||||
startsAt: range.start.iso,
|
||||
startPrecision: range.start.precision,
|
||||
endsAt: range.end.iso,
|
||||
endPrecision: range.end.precision,
|
||||
// One worldwide server on Pacific time — the `PDT` on every duration is
|
||||
// the evidence — so there is no per-region end to scope.
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: new URL(href, ctx.sourceUrl).toString(),
|
||||
sourceUrl: new URL(link[1] ?? "", ctx.sourceUrl).toString(),
|
||||
sourceId: ctx.sourceId,
|
||||
status: "published",
|
||||
confidence: 0.95,
|
||||
@@ -142,83 +213,6 @@ function parseFgoEventsPage(rendered: string, ctx: ParseContext): GachaEvent[] {
|
||||
});
|
||||
}
|
||||
|
||||
for (const tableMatch of flat.matchAll(FGO_TABLE)) {
|
||||
const tableYear = parseInt(tableMatch[2]!, 10);
|
||||
const innerHtml = tableMatch[3] ?? "";
|
||||
|
||||
for (const blockMatch of innerHtml.matchAll(FGO_ROW_BLOCK)) {
|
||||
const thHtml = blockMatch[1] ?? "";
|
||||
const imgTdHtml = blockMatch[2] ?? "";
|
||||
const dateTdHtml = blockMatch[3] ?? "";
|
||||
|
||||
// Normalize NFKC immediately so it matches the sanitizer's fixed point.
|
||||
const title = text(thHtml).normalize("NFKC").trim();
|
||||
if (!title) continue;
|
||||
|
||||
let rawDate = text(dateTdHtml).replace(/ /g, " ").replace(/~/g, "-").trim();
|
||||
if (!rawDate) continue;
|
||||
|
||||
const parts = rawDate.split(/[-–—]/).map((s) => s.trim());
|
||||
let start: any = null;
|
||||
let end: any = null;
|
||||
|
||||
if (parts.length === 1 && parts[0]) {
|
||||
const openMatch = parseOpenRange(parts[0] + ", " + tableYear);
|
||||
if (openMatch) {
|
||||
start = openMatch.start;
|
||||
end = null;
|
||||
}
|
||||
} else if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
const mStart = /([A-Za-z]+)/.exec(parts[0])?.[1]?.toLowerCase() || "";
|
||||
const mEnd = /([A-Za-z]+)/.exec(parts[1])?.[1]?.toLowerCase() || "";
|
||||
const monthsList = ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"];
|
||||
const startM = monthsList.findIndex((x) => mStart.startsWith(x));
|
||||
const endM = monthsList.findIndex((x) => mEnd.startsWith(x));
|
||||
|
||||
let endYear = tableYear;
|
||||
if (startM > -1 && endM > -1 && startM > endM) {
|
||||
endYear = tableYear + 1;
|
||||
}
|
||||
|
||||
const rangeStr = parts[0] + " - " + parts[1] + ", " + endYear;
|
||||
const rangeMatch = parseMonthDayRange(rangeStr);
|
||||
if (rangeMatch) {
|
||||
start = rangeMatch.start;
|
||||
end = rangeMatch.end;
|
||||
}
|
||||
}
|
||||
|
||||
if (!start) continue;
|
||||
if (/permanent/i.test(title)) continue;
|
||||
if (end && end.iso <= start.iso) continue;
|
||||
const endMs = end ? Date.parse(end.iso) : Date.parse(start.iso) + 86400000;
|
||||
if (endMs < nowMs) continue;
|
||||
|
||||
const href = ARTICLE_LINK.exec(thHtml)?.[1] || ARTICLE_LINK.exec(imgTdHtml)?.[1];
|
||||
|
||||
out.push({
|
||||
id: eventId(ctx.game, title, start.iso),
|
||||
game: ctx.game,
|
||||
title,
|
||||
type: inferType(title),
|
||||
summary: null,
|
||||
startsAt: start.iso,
|
||||
startPrecision: start.precision,
|
||||
endsAt: end ? end.iso : null,
|
||||
endPrecision: end ? end.precision : "unknown",
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: href === undefined ? ctx.sourceUrl : new URL(href, ctx.sourceUrl).toString(),
|
||||
sourceId: ctx.sourceId,
|
||||
status: "published",
|
||||
confidence: 0.95,
|
||||
extractionMethod: "parser",
|
||||
version: 1,
|
||||
firstSeenAt: ctx.now,
|
||||
updatedAt: ctx.now,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -229,10 +223,13 @@ export function parseFandomEventsPage(
|
||||
const rendered = renderedHtml(body);
|
||||
if (rendered === null) return [];
|
||||
|
||||
// Branch for FGO
|
||||
if (/<table\b[^>]*id="\d{6}"[^>]*class="[^"]*wikitable"/i.test(rendered)) {
|
||||
const events = parseFgoEventsPage(rendered, ctx);
|
||||
return events.sort((a, b) =>
|
||||
// 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 (isFgoEventList(rendered)) {
|
||||
return parseFgoOngoingEvents(rendered, ctx).sort((a, b) =>
|
||||
a.startsAt === b.startsAt
|
||||
? a.id.localeCompare(b.id)
|
||||
: a.startsAt.localeCompare(b.startsAt),
|
||||
@@ -337,9 +334,9 @@ export const fandomParser: SourceParser = {
|
||||
// the Cloudflare interstitial the plain page serves would all land here.
|
||||
const rendered = renderedHtml(body);
|
||||
if (rendered === null) return false;
|
||||
const isStandard = /class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered);
|
||||
const isFgo = /<table\b[^>]*id="\d{6}"[^>]*class="[^"]*wikitable"/i.test(rendered);
|
||||
return isStandard || isFgo;
|
||||
const isTimePeriodTable =
|
||||
/class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered);
|
||||
return isTimePeriodTable || isFgoEventList(rendered);
|
||||
},
|
||||
parse: parseFandomEventsPage,
|
||||
};
|
||||
|
||||
@@ -919,3 +919,142 @@ describe("hololive Dreams wiki", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fate/Grand Order wiki", () => {
|
||||
const fixture = "fixtures/fgo/fandom-events-2026-08-18";
|
||||
const fgo = adapter("fgo-fandom-events");
|
||||
|
||||
/** The page's three section dividers, as it draws them. */
|
||||
const divider = (label: string) =>
|
||||
`<div style="position: absolute;"><b><span style="color:#fff;">${label}</span></b></div>`;
|
||||
|
||||
const block = (title: string, duration: string) =>
|
||||
`<h2><span class="mw-headline"><a href="/wiki/${title.replace(/ /g, "_")}"` +
|
||||
` title="${title}">${title}</a></span></h2>` +
|
||||
`<p><b>Duration:</b> ${duration}</p>`;
|
||||
|
||||
const envelope = (inner: string) =>
|
||||
JSON.stringify({ parse: { title: "Event List (US)", text: inner } });
|
||||
|
||||
function parse(inner: string) {
|
||||
return fgo.parse(envelope(inner), {
|
||||
now: NOW,
|
||||
sourceUrl: fgo.url,
|
||||
sourceId: fgo.id,
|
||||
game: fgo.game,
|
||||
});
|
||||
}
|
||||
|
||||
test("reads the English server, and the source url says so", () => {
|
||||
// The wiki publishes two schedules months apart — `Event_List` opens "This
|
||||
// page lists all Events in Fate/Grand Order Japan" — and the adapter must
|
||||
// be pointed at the other one. A JP date on an English calendar is the
|
||||
// akwiki CN column again.
|
||||
expect(fgo.url).toContain("Event_List_(US)");
|
||||
expect(fgo.url).not.toMatch(/page=Event_List&/);
|
||||
});
|
||||
|
||||
test("publishes every ongoing event on the page and nothing else", async () => {
|
||||
// Counted independently off the fixture: four `<h2>` blocks carry a
|
||||
// `Duration:` line, all four between the ONGOING and FUTURE dividers.
|
||||
const events = await runAdapter(fgo, fixture);
|
||||
expect(events.map((e) => e.title)).toEqual([
|
||||
"FGO Summer 2026 Event",
|
||||
"Archetype Inception Chapter Release",
|
||||
"Ordeal Call III Clear Support Campaign",
|
||||
"Archetype Inception Part 3 Release Campaign",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips the FUTURE section, whose ETAs are a month with no day", async () => {
|
||||
// "August 2026" is not a start date, and a start date is half of an event
|
||||
// id. The upcoming table names events we would otherwise want.
|
||||
const events = await runAdapter(fgo, fixture);
|
||||
for (const e of events) {
|
||||
expect(e.title).not.toMatch(/Back to School|Downloads Campaign/);
|
||||
// Nothing on this page reaches us without both boundaries stated.
|
||||
expect(e.endsAt).not.toBeNull();
|
||||
expect(e.startPrecision).toBe("day");
|
||||
expect(e.endPrecision).toBe("day");
|
||||
}
|
||||
});
|
||||
|
||||
test("skips the PAST section, 111 tables the page states no year for", async () => {
|
||||
const events = await runAdapter(fgo, fixture);
|
||||
expect(events).toHaveLength(4);
|
||||
for (const e of events) expect(e.title).not.toMatch(/Part 2 Release/);
|
||||
});
|
||||
|
||||
test("strips the (US) article suffix from the title, never from the url", async () => {
|
||||
// The suffix disambiguates the English article from the Japanese one. It
|
||||
// is the article's name, so it stays in the link, and it is noise on a
|
||||
// calendar that shows one server, so it leaves the title.
|
||||
const [summer] = await runAdapter(fgo, fixture);
|
||||
expect(summer?.title).toBe("FGO Summer 2026 Event");
|
||||
expect(summer?.sourceUrl).toBe(
|
||||
"https://fategrandorder.fandom.com/wiki/FGO_Summer_2026_Event_(US)",
|
||||
);
|
||||
expect(summer?.id).toBe("fgo:fgo-summer-2026-event:2026-07-15");
|
||||
});
|
||||
|
||||
test("keeps the day the page states rather than shifting it out of PDT", async () => {
|
||||
// The durations name a zone but no clock, so there is no time of day to
|
||||
// anchor a conversion to — and the day is half the event id.
|
||||
const [summer] = await runAdapter(fgo, fixture);
|
||||
expect(summer?.startsAt).toBe("2026-07-15T00:00:00.000Z");
|
||||
expect(summer?.endsAt).toBe("2026-09-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("drops an ongoing row the wiki has not got round to moving", () => {
|
||||
// "Ongoing" is maintained by hand. holodori.wiki taught this one.
|
||||
const events = parse(
|
||||
divider("ONGOING EVENTS") +
|
||||
block("Finished Campaign", "June 1, 2026 ~ July 1, 2026 PDT") +
|
||||
block("Live Campaign", "August 1, 2026 ~ September 1, 2026 PDT") +
|
||||
divider("FUTURE EVENTS"),
|
||||
);
|
||||
expect(events.map((e) => e.title)).toEqual(["Live Campaign"]);
|
||||
});
|
||||
|
||||
test("skips a duration missing a boundary rather than inventing one", () => {
|
||||
const events = parse(
|
||||
divider("ONGOING EVENTS") +
|
||||
block("Open Ended", "August 1, 2026 ~ TBD PDT") +
|
||||
block("No Year", "August 1 ~ September 1 PDT") +
|
||||
divider("FUTURE EVENTS"),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
test("yields nothing for the Japanese page, and canParse says why", () => {
|
||||
// `Event_List` has no dividers and no `Duration:` label on its archive
|
||||
// tables. Refusing it in `canParse` is what makes the runner reject the
|
||||
// body and hold the previous snapshot, instead of the lane going empty.
|
||||
const japanese =
|
||||
'<div><p>This page lists all Events in Fate/Grand Order Japan.</p>' +
|
||||
'<table id="082026" class="wikitable"><tr><th colspan="2">Azrael Mausoleum</th></tr>' +
|
||||
"<tr><td></td><td>August 2 ~ August 12</td></tr></table></div>";
|
||||
expect(fandomParser.canParse(envelope(japanese))).toBe(false);
|
||||
// And the seam turns that refusal into a loud failure rather than an
|
||||
// empty list, which is what makes the runner hold the previous snapshot.
|
||||
expect(() => parse(japanese)).toThrow(/redesigned/);
|
||||
});
|
||||
|
||||
test("a renamed divider fails the source instead of emptying the lane", async () => {
|
||||
const html = JSON.parse(
|
||||
await Bun.file(`${fixture}.html`).text(),
|
||||
).parse.text as string;
|
||||
expect(fandomParser.canParse(envelope(html))).toBe(true);
|
||||
expect(
|
||||
fandomParser.canParse(envelope(html.replace("ONGOING EVENTS", "LIVE NOW"))),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("reports one global end rather than inventing per-region ones", async () => {
|
||||
// One worldwide server on Pacific time — the PDT on every duration.
|
||||
for (const e of await runAdapter(fgo, fixture)) {
|
||||
expect(e.regionScoped).toBe(false);
|
||||
expect(e.regionEnds).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user