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
2026-09-17 10:53:05 +02:00
parent b73c253de2
commit 2bc61e7d12
9 changed files with 3490 additions and 2671 deletions
+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 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,
};