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
+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
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
+1 -1
View File
@@ -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
View File
@@ -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
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -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
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,
};
+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.
{ 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", () => {