fix(ingest): support redesigned Blue Archive wiki event table
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:
@@ -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
|
||||
exactly as the Fandom one does.
|
||||
|
||||
Three things about that page are worth knowing before touching it, all of them ways to publish a
|
||||
confidently wrong date:
|
||||
The page was redesigned in September 2026 from a per-version tabber into a unified `#eventtable`
|
||||
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.**
|
||||
Same hazard as the CN column on `akwiki`, same answer: publish Global only. The tab's *nav button*
|
||||
carries the id `tabber-Global_version-label` and sits above **both** panels, so a reader that slices
|
||||
from the first id match reads the Japanese schedule while believing it read ours.
|
||||
- **There are three Global tabs, not one** — the schedule, plus Mini-Event and Joint Firing Drill
|
||||
further down, whose ids are the same name with `_2` and `_3`. The parser finds the schedule by its
|
||||
`Name (EN)` header rather than by position, and `canParse` asserts that lookup, so a renamed tab or
|
||||
column fails the run instead of quietly emptying the lane.
|
||||
- **Global is ours, Japanese runs four to nine months ahead.** In `#eventtable`, JP and GL periods
|
||||
sit side-by-side; the parser resolves `GL Period`, and draws titles and links from `.event-region-gl`
|
||||
(falling back to `.event-region-jp` if unlocalized). In the legacy tabber format, JP and Global sat in
|
||||
separate panels, and the Global tab's nav button (`tabber-Global_version-label`) preceded both panels.
|
||||
- **Identify tables by their headers, not by position.** In `#eventtable`, column names (`Event`,
|
||||
`GL Period`) identify the schedule. In the legacy tabber, `Name (EN)` distinguished the main schedule
|
||||
from Mini-Event and Joint Firing Drill tabbers below. Both are asserted in `canParse`.
|
||||
- **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,
|
||||
Attendance bonuses, Guide missions, Joint Firing Drill) *do* carry a wall clock — `08/12/2026 11:00`
|
||||
— but name no zone for it, and **three of the five do not say which server they describe**. Those
|
||||
are deliberately unparsed, and that second clause is the whole reason: a clock whose server is
|
||||
unknown cannot even be labelled with a day, because you do not know whose day it is. Attendance
|
||||
bonuses would be a real dailies source if a zone is ever stated. For the same reason `ba` has no
|
||||
`resetOffsets`: Blue Archive Global does run one worldwide server, but nothing in this source says
|
||||
on what clock.
|
||||
`YYYY-MM-DD` (or `YYYY/MM/DD` in `datetime` elements), which is honest day precision. Its subsidiary
|
||||
tables (Mini-Event, Reward campaigns, Attendance bonuses, Guide missions, Joint Firing Drill) *do*
|
||||
carry a wall clock — `08/12/2026 11:00` — but name no zone for it, and **three of the five do not say
|
||||
which server they describe**. Those are deliberately unparsed, and that second clause is the whole
|
||||
reason: a clock whose server is unknown cannot even be labelled with a day, because you do not know
|
||||
whose day it is. Attendance bonuses would be a real dailies source if a zone is ever stated. For the
|
||||
same reason `ba` has no `resetOffsets`: Blue Archive Global does run one worldwide server, but nothing
|
||||
in this source says on what clock.
|
||||
|
||||
**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
|
||||
|
||||
@@ -76,7 +76,7 @@ src/
|
||||
wikigg.ts wiki.gg mp-event templates ✓ built
|
||||
akwiki.ts arknights.wiki.gg mrfz-wtable ✓ 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
|
||||
index.ts parser registry ✓ built
|
||||
adapters/
|
||||
|
||||
+13
-11
@@ -52,7 +52,7 @@ Consequences worth internalising:
|
||||
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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
|
||||
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
|
||||
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`
|
||||
hazard. The Global tab's nav *button* precedes both panels, so slicing from the first matching id
|
||||
reads the Japanese schedule.
|
||||
- **Three tabs are named Global**, the schedule plus Mini-Event and Joint Firing Drill. The schedule
|
||||
is identified by its `Name (EN)` header rather than by position, and `canParse` runs the same
|
||||
lookup so a rename fails the run rather than emptying the lane.
|
||||
- **Nothing on the page states a time of day or a timezone.** The schedule's bare ISO dates are day
|
||||
precision; the five tables that do carry a wall clock name no zone for it and are left unparsed
|
||||
rather than read as UTC.
|
||||
- **Redesigned schedule (`#eventtable`)**: A unified table (`Preview | Event | JP Period | GL Period | Notes`)
|
||||
plots both releases side-by-side. The parser resolves columns by name, reads English titles and links
|
||||
from `.event-region-gl` (falling back to JP if unlocalized), extracts day-precision dates from `GL Period`
|
||||
spans, and maps release badges (`data-release="rerun"`) and Notes to event types.
|
||||
- **Legacy tabber format**: Identifies the schedule panel inside `<article id="tabber-Global_version">`
|
||||
by its `Name (EN)` column header rather than by position, fencing out the Japanese panel and the
|
||||
undated Mini-Event / Joint Firing Drill tabbers further down.
|
||||
- **Nothing on the page states a time of day or a timezone.** Boundaries are bare `YYYY-MM-DD` (or formatted
|
||||
`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`
|
||||
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 one or more lines are too long
+332
-2624
File diff suppressed because one or more lines are too long
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"sourceId": "ba-bawiki-events",
|
||||
"url": "https://bluearchive.wiki/wiki/Events",
|
||||
"contentHash": "b68e2a8359e9ad1649620a67f6e0eeebf052857cd3cf56dbfc1881527ddc2cb8",
|
||||
"bytes": 327300,
|
||||
"contentHash": "a780d27a41e3bc7065c47b8d7997ab9a52d65d70a866213436d3e4399f8eba8d",
|
||||
"bytes": 657276,
|
||||
"etag": null,
|
||||
"lastModified": "Fri, 11 Sep 2026 06:31:24 GMT",
|
||||
"contentChangedAt": "2026-09-12T03:21:43.894Z",
|
||||
"lastModified": "Tue, 15 Sep 2026 03:23:55 GMT",
|
||||
"contentChangedAt": "2026-09-17T08:48:32.390Z",
|
||||
"eventCount": 1,
|
||||
"charset": "utf-8"
|
||||
}
|
||||
|
||||
+187
-13
@@ -77,6 +77,8 @@ const CELL = /<t([dh])\b[^>]*>([\s\S]*?)<\/t\1>/gi;
|
||||
*/
|
||||
const ARTICLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i;
|
||||
|
||||
const EVENT_TABLE = /<table\b[^>]*\bid="eventtable"[^>]*>([\s\S]*?)<\/table>/i;
|
||||
|
||||
interface Columns {
|
||||
title: number;
|
||||
start: number;
|
||||
@@ -84,8 +86,48 @@ interface Columns {
|
||||
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
|
||||
* 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
|
||||
* Mini-Event and Joint Firing Drill tabbers further down, whose ids are the same
|
||||
@@ -154,13 +196,132 @@ function globalSchedule(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseBlueArchiveWikiEventsPage(
|
||||
html: string,
|
||||
function parseNewEventTable(
|
||||
schedule: { cols: EventTableColumns; body: string },
|
||||
ctx: ParseContext,
|
||||
): GachaEvent[] {
|
||||
const schedule = globalSchedule(html.replace(/\s+/g, " "));
|
||||
if (schedule === null) return [];
|
||||
const { cols } = schedule;
|
||||
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 nowMs = Date.parse(ctx.now);
|
||||
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 = {
|
||||
id: "bawiki",
|
||||
label: "Blue Archive Wiki",
|
||||
canParse(html: string): boolean {
|
||||
// The same lookup `parse` does, which is the point: this page has sixty-odd
|
||||
// wikitables and three Global panels, so "a table exists" proves nothing
|
||||
// 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;
|
||||
const norm = html.replace(/\s+/g, " ");
|
||||
return globalEventTable(norm) !== null || globalSchedule(norm) !== null;
|
||||
},
|
||||
parse: parseBlueArchiveWikiEventsPage,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,9 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
// 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("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("holodori-holodoriwiki-events"), fixture: "fixtures/holodori/holodoriwiki-events-2026-08-18" },
|
||||
{ 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")),
|
||||
).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", () => {
|
||||
|
||||
Reference in New Issue
Block a user