fix(ingest): support redesigned Blue Archive wiki event table
CI / Typecheck, test, feed (push) Successful in 12s
CI / Build site (push) Successful in 7s
CI / Container image (push) Successful in 29s

The Blue Archive Wiki (bluearchive.wiki/wiki/Events) redesigned its
schedule, replacing the per-version MediaWiki tabber with a unified
#eventtable displaying JP and Global periods side-by-side.

- Parse #eventtable resolving GL Period, event titles, and article links
  from .event-region-gl, with fallback to .event-region-jp.
- Maintain backwards compatibility with legacy tabber schedule format.
- Add pinned fixture fixtures/ba/bawiki-events-2026-09-17 and tests.
- Update snapshot, AGENTS.md, ARCHITECTURE.md, and INGESTION.md.
This commit is contained in:
Lucas Winther committed 2026-09-17 10:53:05 +02:00
1 parent b73c253de2
commit 2bc61e7d12
9 files changed
+3490 -2671

No files matched your search

+17 -18
View File
@@ -752,26 +752,25 @@ surface `*` is allowed — it answers our own `User-Agent` with a `200`, no `Con
`Crawl-delay` for us. `Special:` is disallowed too, which is why `parsers/bawiki.ts` skips those links `Crawl-delay` for us. `Special:` is disallowed too, which is why `parsers/bawiki.ts` skips those links
exactly as the Fandom one does. exactly as the Fandom one does.
Three things about that page are worth knowing before touching it, all of them ways to publish a The page was redesigned in September 2026 from a per-version tabber into a unified `#eventtable`
confidently wrong date: schedule (`Preview | Event | JP Period | GL Period | Notes`). The parser supports both shapes. Key rules:
- **It states JP and Global in separate tabs, and the Japanese one runs four to nine months ahead.** - **Global is ours, Japanese runs four to nine months ahead.** In `#eventtable`, JP and GL periods
Same hazard as the CN column on `akwiki`, same answer: publish Global only. The tab's *nav button* sit side-by-side; the parser resolves `GL Period`, and draws titles and links from `.event-region-gl`
carries the id `tabber-Global_version-label` and sits above **both** panels, so a reader that slices (falling back to `.event-region-jp` if unlocalized). In the legacy tabber format, JP and Global sat in
from the first id match reads the Japanese schedule while believing it read ours. separate panels, and the Global tab's nav button (`tabber-Global_version-label`) preceded both panels.
- **There are three Global tabs, not one** — the schedule, plus Mini-Event and Joint Firing Drill - **Identify tables by their headers, not by position.** In `#eventtable`, column names (`Event`,
further down, whose ids are the same name with `_2` and `_3`. The parser finds the schedule by its `GL Period`) identify the schedule. In the legacy tabber, `Name (EN)` distinguished the main schedule
`Name (EN)` header rather than by position, and `canParse` asserts that lookup, so a renamed tab or from Mini-Event and Joint Firing Drill tabbers below. Both are asserted in `canParse`.
column fails the run instead of quietly emptying the lane.
- **The page states no time of day and no timezone anywhere.** The schedule's dates are bare - **The page states no time of day and no timezone anywhere.** The schedule's dates are bare
`YYYY-MM-DD`, which is honest day precision. Its five other tables (Mini-Event, Reward campaigns, `YYYY-MM-DD` (or `YYYY/MM/DD` in `datetime` elements), which is honest day precision. Its subsidiary
Attendance bonuses, Guide missions, Joint Firing Drill) *do* carry a wall clock — `08/12/2026 11:00` tables (Mini-Event, Reward campaigns, Attendance bonuses, Guide missions, Joint Firing Drill) *do*
— but name no zone for it, and **three of the five do not say which server they describe**. Those carry a wall clock — `08/12/2026 11:00` — but name no zone for it, and **three of the five do not say
are deliberately unparsed, and that second clause is the whole reason: a clock whose server is which server they describe**. Those are deliberately unparsed, and that second clause is the whole
unknown cannot even be labelled with a day, because you do not know whose day it is. Attendance reason: a clock whose server is unknown cannot even be labelled with a day, because you do not know
bonuses would be a real dailies source if a zone is ever stated. For the same reason `ba` has no whose day it is. Attendance bonuses would be a real dailies source if a zone is ever stated. For the
`resetOffsets`: Blue Archive Global does run one worldwide server, but nothing in this source says same reason `ba` has no `resetOffsets`: Blue Archive Global does run one worldwide server, but nothing
on what clock. in this source says on what clock.
**This rule governs a clock with no known server, not a clock with no stated zone** — a **This rule governs a clock with no known server, not a clock with no stated zone** — a
distinction worth drawing precisely, because it was drawn the wrong way once. As first written it distinction worth drawing precisely, because it was drawn the wrong way once. As first written it
+1 -1
View File
@@ -76,7 +76,7 @@ src/
wikigg.ts wiki.gg mp-event templates ✓ built wikigg.ts wiki.gg mp-event templates ✓ built
akwiki.ts arknights.wiki.gg mrfz-wtable ✓ built akwiki.ts arknights.wiki.gg mrfz-wtable ✓ built
fandom.ts Fandom via the action=parse API ✓ built fandom.ts Fandom via the action=parse API ✓ built
bawiki.ts bluearchive.wiki JP/Global tabber ✓ built bawiki.ts bluearchive.wiki schedule (#eventtable & tabber) ✓ built
holodori.ts holodori.wiki Current/Past Events tables ✓ built holodori.ts holodori.wiki Current/Past Events tables ✓ built
index.ts parser registry ✓ built index.ts parser registry ✓ built
adapters/ adapters/
+13 -11
View File
@@ -52,7 +52,7 @@ Consequences worth internalising:
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield | | `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
| `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights | | `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights |
| `fandom` | Fandom wikis via the MediaWiki `action=parse` API — five page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables, Infinity Nikki's `Event \| Duration \| Description \| Type` article-tables, and Genshin Impact's `Event \| Duration \| Type(s)` tables | Reverse: 1999, Fate/Grand Order, Nikke, Infinity Nikki, Genshin Impact | | `fandom` | Fandom wikis via the MediaWiki `action=parse` API — five page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables, Infinity Nikki's `Event \| Duration \| Description \| Type` article-tables, and Genshin Impact's `Event \| Duration \| Type(s)` tables | Reverse: 1999, Fate/Grand Order, Nikke, Infinity Nikki, Genshin Impact |
| `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — a JP/Global tabber over `Name (EN) \| Start date \| End date \| Notes` wikitables | Blue Archive | | `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — redesigned `#eventtable` unified schedule (and legacy JP/Global tabber) | Blue Archive |
| `holodoriwiki` | holodori.wiki's rendered `/wiki/Events` — `Current Events` and `Past Events` wikitables over `Event \| Type \| Start Date \| End Date` | hololive Dreams | | `holodoriwiki` | holodori.wiki's rendered `/wiki/Events` — `Current Events` and `Past Events` wikitables over `Event \| Type \| Start Date \| End Date` | hololive Dreams |
| `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 | | `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 |
| `stellasorawiki` | stellasora.miraheze.org's front-page `Current Banners` module — `<time datetime>` pairs inside `stellasora-home-banner` blocks | Stella Sora | | `stellasorawiki` | stellasora.miraheze.org's front-page `Current Banners` module — `<time datetime>` pairs inside `stellasora-home-banner` blocks | Stella Sora |
@@ -108,17 +108,19 @@ route on the same check, and the differences are worth knowing before touching e
`bawiki` is the mirror image of `fandom`: same MediaWiki software, opposite conclusion about which `bawiki` is the mirror image of `fandom`: same MediaWiki software, opposite conclusion about which
surface to read. bluearchive.wiki is Miraheze, whose `robots.txt` disallows `/w/` and `/*?action=`, surface to read. bluearchive.wiki is Miraheze, whose `robots.txt` disallows `/w/` and `/*?action=`,
so the API is closed and the rendered `/wiki/Events` page is what `*` is allowed — and it serves our so the API is closed and the rendered `/wiki/Events` page is what `*` is allowed — and it serves our
own `User-Agent` a `200`. Three page facts drive the parser (all three in AGENTS.md § Blue Archive): own `User-Agent` a `200`. The parser handles both the redesigned schedule table (`#eventtable`,
shipped in September 2026) and the legacy version tabber format:
- **JP and Global are separate tabs, months apart**, so only Global is published — the `akwiki` - **Redesigned schedule (`#eventtable`)**: A unified table (`Preview | Event | JP Period | GL Period | Notes`)
hazard. The Global tab's nav *button* precedes both panels, so slicing from the first matching id plots both releases side-by-side. The parser resolves columns by name, reads English titles and links
reads the Japanese schedule. from `.event-region-gl` (falling back to JP if unlocalized), extracts day-precision dates from `GL Period`
- **Three tabs are named Global**, the schedule plus Mini-Event and Joint Firing Drill. The schedule spans, and maps release badges (`data-release="rerun"`) and Notes to event types.
is identified by its `Name (EN)` header rather than by position, and `canParse` runs the same - **Legacy tabber format**: Identifies the schedule panel inside `<article id="tabber-Global_version">`
lookup so a rename fails the run rather than emptying the lane. by its `Name (EN)` column header rather than by position, fencing out the Japanese panel and the
- **Nothing on the page states a time of day or a timezone.** The schedule's bare ISO dates are day undated Mini-Event / Joint Firing Drill tabbers further down.
precision; the five tables that do carry a wall clock name no zone for it and are left unparsed - **Nothing on the page states a time of day or a timezone.** Boundaries are bare `YYYY-MM-DD` (or formatted
rather than read as UTC. `YYYY/MM/DD`), so all boundaries are parsed at day precision. The subsidiary tables that carry wall
clocks state no timezone and are left unparsed rather than read as UTC.
`akwiki` shares a host family with `wikigg` and nothing else — arknights.wiki.gg has no `mp-event` `akwiki` shares a host family with `wikigg` and nothing else — arknights.wiki.gg has no `mp-event`
cards, so the two are separate modules rather than one parser with a branch. Two things about that cards, so the two are separate modules rather than one parser with a branch. Two things about that
@@ -0,0 +1,107 @@
[
{
"id": "ba:code-box-the-shadow-approaching-millennium:2026-08-04",
"game": "ba",
"title": "Code: BOX - The Shadow Approaching Millennium",
"type": "rerun",
"summary": "Rerun",
"startsAt": "2026-08-04T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-18T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://bluearchive.wiki/wiki/Code:_BOX_Shadow_Looming_Over_Millennium_%EF%BD%9EOne_Question_and_Two_Answers%EF%BD%9E/Rerun",
"sourceId": "ba-bawiki-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "ba:special-mission-lore-pursuit:2026-08-18",
"game": "ba",
"title": "Special Mission: Lore Pursuit",
"type": "other",
"summary": null,
"startsAt": "2026-08-18T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-01T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://bluearchive.wiki/wiki/Special_Operation:_Lore_Pursuit",
"sourceId": "ba-bawiki-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "ba:pray-ball-swing-for-the-grand-slam:2026-09-01",
"game": "ba",
"title": "Pray-Ball! Swing for the Grand Slam!",
"type": "rerun",
"summary": "Rerun",
"startsAt": "2026-09-01T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-15T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://bluearchive.wiki/wiki/Pray-Ball!_%EF%BD%9E_Aim_for_a_Grand_Slam_Home_Run_%EF%BD%9E/Rerun",
"sourceId": "ba-bawiki-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "ba:from-opera-0068-with-love:2026-09-15",
"game": "ba",
"title": "From Opera 0068 with Love!",
"type": "other",
"summary": null,
"startsAt": "2026-09-15T00:00:00.000Z",
"startPrecision": "day",
"endsAt": null,
"endPrecision": "unknown",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://bluearchive.wiki/wiki/0068_from_Opera_with_Love!/Permanent",
"sourceId": "ba-bawiki-events",
"status": "published",
"confidence": 0.75,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
},
{
"id": "ba:special-mission-lore-pursuit:2026-09-15",
"game": "ba",
"title": "Special Mission: Lore Pursuit",
"type": "other",
"summary": "Season 2",
"startsAt": "2026-09-15T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-29T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://bluearchive.wiki/wiki/Special_Operation:_Lore_Pursuit_(Season_2)",
"sourceId": "ba-bawiki-events",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-08-14T00:00:00.000Z",
"updatedAt": "2026-08-14T00:00:00.000Z"
}
]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,11 +1,11 @@
{ {
"sourceId": "ba-bawiki-events", "sourceId": "ba-bawiki-events",
"url": "https://bluearchive.wiki/wiki/Events", "url": "https://bluearchive.wiki/wiki/Events",
"contentHash": "b68e2a8359e9ad1649620a67f6e0eeebf052857cd3cf56dbfc1881527ddc2cb8", "contentHash": "a780d27a41e3bc7065c47b8d7997ab9a52d65d70a866213436d3e4399f8eba8d",
"bytes": 327300, "bytes": 657276,
"etag": null, "etag": null,
"lastModified": "Fri, 11 Sep 2026 06:31:24 GMT", "lastModified": "Tue, 15 Sep 2026 03:23:55 GMT",
"contentChangedAt": "2026-09-12T03:21:43.894Z", "contentChangedAt": "2026-09-17T08:48:32.390Z",
"eventCount": 1, "eventCount": 1,
"charset": "utf-8" "charset": "utf-8"
} }
+187 -13
View File
@@ -77,6 +77,8 @@ const CELL = /<t([dh])\b[^>]*>([\s\S]*?)<\/t\1>/gi;
*/ */
const ARTICLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i; const ARTICLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i;
const EVENT_TABLE = /<table\b[^>]*\bid="eventtable"[^>]*>([\s\S]*?)<\/table>/i;
interface Columns { interface Columns {
title: number; title: number;
start: number; start: number;
@@ -84,8 +86,48 @@ interface Columns {
notes: number | undefined; notes: number | undefined;
} }
interface EventTableColumns {
event: number;
glPeriod: number;
notes: number | undefined;
}
/** /**
* Where each column sits, read off the header row. * Where each column sits in the redesigned unified schedule table (#eventtable).
*/
function eventTableColumns(headers: string[]): EventTableColumns | null {
const at = (match: (h: string) => boolean) => {
const i = headers.findIndex((h) => match(h));
return i < 0 ? undefined : i;
};
const event = at((h) => h === "event" || h === "name (en)");
const glPeriod = at((h) => h === "gl period" || h === "global period");
if (event === undefined || glPeriod === undefined) {
return null;
}
return { event, glPeriod, notes: at((h) => h === "notes") };
}
/**
* The redesigned unified schedule table, located by its ID (#eventtable).
*/
function globalEventTable(
html: string,
): { cols: EventTableColumns; body: string } | null {
const body = EVENT_TABLE.exec(html)?.[1];
if (body === undefined) return null;
const headers = [...body.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)].map(
(h) => text(h[1] ?? "").toLowerCase(),
);
const cols = eventTableColumns(headers);
if (cols === null) return null;
return { cols, body };
}
/**
* Where each column sits, read off the header row of the legacy tabber.
* *
* Returns null when the table is not a schedule at all, which is what keeps the * Returns null when the table is not a schedule at all, which is what keeps the
* per-event infoboxes elsewhere on the page from being read as one. * per-event infoboxes elsewhere on the page from being read as one.
@@ -109,7 +151,7 @@ function columns(headers: string[]): Columns | null {
} }
/** /**
* The Global version panel's schedule table, and where its columns sit. * The Global version panel's schedule table, and where its columns sit (legacy format).
* *
* There are three Global panels on this page, not one: the schedule, plus the * There are three Global panels on this page, not one: the schedule, plus the
* Mini-Event and Joint Firing Drill tabbers further down, whose ids are the same * Mini-Event and Joint Firing Drill tabbers further down, whose ids are the same
@@ -154,13 +196,132 @@ function globalSchedule(
return null; return null;
} }
export function parseBlueArchiveWikiEventsPage( function parseNewEventTable(
html: string, schedule: { cols: EventTableColumns; body: string },
ctx: ParseContext, ctx: ParseContext,
): GachaEvent[] { ): GachaEvent[] {
const schedule = globalSchedule(html.replace(/\s+/g, " ")); const { cols } = schedule;
if (schedule === null) return []; const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
for (const row of schedule.body.matchAll(ROW)) {
const rowAttrs = row[0] ?? "";
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 eventCell = cells[cols.event]?.html ?? "";
// Prefer the Global title if present; fallback to the JP title or the whole cell
const glBlock =
/<div\b[^>]*class="[^"]*event-region-gl[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/i.exec(
eventCell,
);
const targetBlock = glBlock?.[1] ?? eventCell;
const titleSpan =
/<span\b[^>]*class="[^"]*event-title[^"]*"[^>]*>([\s\S]*?)<\/span>/i.exec(
targetBlock,
);
const titleInner = titleSpan?.[1] ?? targetBlock;
const title = text(titleInner);
if (title.length === 0) continue;
const glCell = cells[cols.glPeriod]?.html ?? "";
if (glCell.includes("event-unannounced") || /\bTBD\b/i.test(glCell)) continue;
const isoDates = [
...glCell.matchAll(/data-datetime="(\d{4}-\d{2}-\d{2})/g),
].map((m) => m[1]);
let start: ParsedInstant | null = null;
let end: ParsedInstant | null = null;
if (isoDates.length > 0) {
start = parseIsoDay(isoDates[0]!);
if (isoDates.length > 1 && isoDates[1]) {
end = parseIsoDay(isoDates[1]);
}
} else {
const textDates = [
...glCell.matchAll(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/g),
]
.map((m) =>
parseIsoDay(
`${m[1]}-${m[2]?.padStart(2, "0")}-${m[3]?.padStart(2, "0")}`,
),
)
.filter((d): d is ParsedInstant => d !== null);
if (textDates.length > 0) {
start = textDates[0]!;
if (textDates.length > 1 && textDates[1]) {
end = textDates[1];
}
}
}
if (start === null) continue;
if (end === null) {
if (latestBoundaryMs(start.iso, start.precision, ctx.game) < nowMs) {
continue;
}
} else {
if (end.iso <= start.iso) continue;
if (latestBoundaryMs(end.iso, end.precision, ctx.game) < nowMs) continue;
}
const release = /data-release="([^"]*)"/i.exec(rowAttrs)?.[1]?.toLowerCase();
const notes =
cols.notes === undefined ? "" : text(cells[cols.notes]?.html ?? "");
const summary =
notes.length > 0 ? notes : release === "rerun" ? "Rerun" : null;
const type = release === "rerun" ? "rerun" : inferType(`${title} ${notes}`);
const href =
ARTICLE_LINK.exec(titleInner)?.[1] ??
ARTICLE_LINK.exec(eventCell)?.[1];
let confidence = 0.95 - 0.05;
confidence -= end === null ? 0.15 : 0.05;
out.push({
id: eventId(ctx.game, title, start.iso),
game: ctx.game,
title,
type,
summary,
startsAt: start.iso,
startPrecision: start.precision,
endsAt: end === null ? null : end.iso,
endPrecision: end === null ? "unknown" : end.precision,
regionScoped: false,
regionEnds: null,
sourceUrl:
href === undefined
? ctx.sourceUrl
: new URL(href, ctx.sourceUrl).toString(),
sourceId: ctx.sourceId,
status: "published",
confidence: Math.round(confidence * 100) / 100,
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),
);
}
function parseLegacySchedule(
schedule: { cols: Columns; body: string },
ctx: ParseContext,
): GachaEvent[] {
const { cols } = schedule; const { cols } = schedule;
const nowMs = Date.parse(ctx.now); const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = []; const out: GachaEvent[] = [];
@@ -255,17 +416,30 @@ export function parseBlueArchiveWikiEventsPage(
); );
} }
export function parseBlueArchiveWikiEventsPage(
html: string,
ctx: ParseContext,
): GachaEvent[] {
const norm = html.replace(/\s+/g, " ");
const newSchedule = globalEventTable(norm);
if (newSchedule !== null) {
return parseNewEventTable(newSchedule, ctx);
}
const legacySchedule = globalSchedule(norm);
if (legacySchedule !== null) {
return parseLegacySchedule(legacySchedule, ctx);
}
return [];
}
export const blueArchiveWikiParser: SourceParser = { export const blueArchiveWikiParser: SourceParser = {
id: "bawiki", id: "bawiki",
label: "Blue Archive Wiki", label: "Blue Archive Wiki",
canParse(html: string): boolean { canParse(html: string): boolean {
// The same lookup `parse` does, which is the point: this page has sixty-odd const norm = html.replace(/\s+/g, " ");
// wikitables and three Global panels, so "a table exists" proves nothing return globalEventTable(norm) !== null || globalSchedule(norm) !== null;
// about the one that matters. Asserting the schedule is *findable* is what
// makes a renamed tab or a renamed column fail the run loudly instead of
// emptying the lane, which reads downstream as "Blue Archive has nothing
// on" rather than as a failure.
return globalSchedule(html.replace(/\s+/g, " ")) !== null;
}, },
parse: parseBlueArchiveWikiEventsPage, parse: parseBlueArchiveWikiEventsPage,
}; };
+43
View File
@@ -43,6 +43,9 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
// its content type. The fixture is the bytes the fetcher would store. // its content type. The fixture is the bytes the fetcher would store.
{ adapter: adapter("r1999-fandom-events"), fixture: "fixtures/r1999/fandom-events-2026-08-17" }, { adapter: adapter("r1999-fandom-events"), fixture: "fixtures/r1999/fandom-events-2026-08-17" },
{ adapter: adapter("ba-bawiki-events"), fixture: "fixtures/ba/bawiki-events-2026-08-17" }, { adapter: adapter("ba-bawiki-events"), fixture: "fixtures/ba/bawiki-events-2026-08-17" },
// The redesigned page (September 2026): replacing the per-version tabber
// with a unified schedule table (#eventtable) showing JP and GL side-by-side.
{ adapter: adapter("ba-bawiki-events"), fixture: "fixtures/ba/bawiki-events-2026-09-17" },
{ adapter: adapter("fgo-fandom-events"), fixture: "fixtures/fgo/fandom-events-2026-08-18" }, { adapter: adapter("fgo-fandom-events"), fixture: "fixtures/fgo/fandom-events-2026-08-18" },
{ adapter: adapter("holodori-holodoriwiki-events"), fixture: "fixtures/holodori/holodoriwiki-events-2026-08-18" }, { adapter: adapter("holodori-holodoriwiki-events"), fixture: "fixtures/holodori/holodoriwiki-events-2026-08-18" },
{ adapter: adapter("gfl2-iopwiki-events"), fixture: "fixtures/gfl2/iopwiki-events-2026-08-19" }, { adapter: adapter("gfl2-iopwiki-events"), fixture: "fixtures/gfl2/iopwiki-events-2026-08-19" },
@@ -821,6 +824,46 @@ describe("blue archive wiki", () => {
blueArchiveWikiParser.canParse(html.replace(/Name \(EN\)/g, "Event")), blueArchiveWikiParser.canParse(html.replace(/Name \(EN\)/g, "Event")),
).toBe(false); ).toBe(false);
}); });
test("parses the redesigned unified schedule table (#eventtable)", async () => {
const newFixture = "fixtures/ba/bawiki-events-2026-09-17";
const html = await Bun.file(`${newFixture}.html`).text();
expect(blueArchiveWikiParser.canParse(html)).toBe(true);
const events = await runAdapter(ba, newFixture);
// At NOW (2026-08-14), 5 events are live/upcoming on Global:
// 1. Code: BOX - The Shadow Approaching Millennium (2026-08-04 to 2026-08-18)
// 2. Special Mission: Lore Pursuit (2026-08-18 to 2026-09-01)
// 3. Pray-Ball! Swing for the Grand Slam! (2026-09-01 to 2026-09-15)
// 4. From Opera 0068 with Love! (starts 2026-09-15, endsAt null)
// 5. Special Mission: Lore Pursuit (2026-09-15 to 2026-09-29)
expect(events).toHaveLength(5);
expect(events[0]?.title).toBe(
"Code: BOX - The Shadow Approaching Millennium",
);
expect(events[0]?.type).toBe("rerun");
expect(events[0]?.summary).toBe("Rerun");
// Spot-check upcoming event without end date
const permanent = events.find(
(e) => e.title === "From Opera 0068 with Love!",
);
expect(permanent).toBeDefined();
expect(permanent?.endsAt).toBeNull();
expect(permanent?.endPrecision).toBe("unknown");
// Fails loudly if #eventtable or its columns are missing
expect(
blueArchiveWikiParser.canParse(
html.replace(/id="eventtable"/g, 'id="othertable"'),
),
).toBe(false);
expect(
blueArchiveWikiParser.canParse(
html.replace(/GL Period/gi, "Global Schedule"),
),
).toBe(false);
});
}); });
describe("hololive Dreams wiki", () => { describe("hololive Dreams wiki", () => {