New bawiki parser plus the ba-bawiki-events source, giving the game its two live and upcoming events at day precision. It reads the rendered /wiki/Events page, which is the opposite call to the Fandom source next door and for the same kind of reason: bluearchive.wiki is Miraheze, whose robots.txt disallows /w/ and /*?action=, so there the API is the closed route and the page is the surface * is allowed. It serves our own User-Agent a 200, sets no Content-Signal and no Crawl-delay for us. The Fandom wiki was declined earlier as a JP archive yielding nothing live; this is the live source that assessment pointed at. Three page facts shape the parser, each a way to publish a confidently wrong date. The schedule is a JP/Global tabber and the Japanese version runs four to nine months ahead, so only Global is published — the akwiki hazard. The Global tab's nav button carries the id tabber-Global_version-label and precedes both panels, so slicing from the first matching id reads the Japanese schedule while believing it read ours; the first version of this parser did exactly that and published a JP-only event. And three tabs on the page are named Global, the schedule plus Mini-Event and Joint Firing Drill, so the schedule is found by its Name (EN) header rather than by position, with canParse asserting the same lookup — a renamed tab or column fails the run instead of emptying the lane. The page states no time of day and no timezone anywhere, which is why the dates are day precision and why ba gets no resetOffsets. It also settles the five other schedule tables, which do carry a wall clock but name no zone and mostly do not say which server: reading those as UTC would invent the fact that matters most, and rounding to a day would not save it, because a 04:00 local boundary falls either side of UTC midnight depending on the offset assumed and the start's day is part of the event ID. They are left unparsed deliberately. The two-event count is asserted against an independent extraction off the fixture, and both starts corroborate against the per-event infoboxes elsewhere on the page — GL 2026-08-04 and GL 2026-09-01, where the JP reruns those tabs would have given are 2026-04-01 and 2026-05-06. Three comment counts were already stale before this and are corrected to what the tree now holds rather than to what it held yesterday. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
200 lines
6.1 KiB
TypeScript
200 lines
6.1 KiB
TypeScript
import type { GachaEvent, GameId } from "../../shared/schema.ts";
|
|
import { mergeEvents, type MergeResult } from "../merge.ts";
|
|
import { parserById } from "../parsers/index.ts";
|
|
import { sanitizeEvents } from "../sanitize.ts";
|
|
import { SIX_HOURS_MS, type Adapter, type ParseContext } from "./types.ts";
|
|
|
|
/**
|
|
* The source registry.
|
|
*
|
|
* One entry per (game, page). Adding a source for a site we already parse is a
|
|
* single entry here. Adding a new site means a parser in `../parsers` first.
|
|
*/
|
|
|
|
interface SourceSpec {
|
|
id: string;
|
|
game: GameId;
|
|
url: string;
|
|
parserId: string;
|
|
priority?: number;
|
|
minIntervalMs?: number;
|
|
}
|
|
|
|
const SOURCES: SourceSpec[] = [
|
|
{
|
|
id: "genshin-game8-events",
|
|
game: "genshin",
|
|
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "hsr-game8-events",
|
|
game: "hsr",
|
|
url: "https://game8.co/games/Honkai-Star-Rail/archives/408749",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "wuwa-game8-events",
|
|
game: "wuwa",
|
|
url: "https://game8.co/games/Wuthering-Waves/archives/453473",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "zzz-game8-events",
|
|
game: "zzz",
|
|
url: "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "endfield-game8-events",
|
|
game: "endfield",
|
|
url: "https://game8.co/games/Arknights-Endfield/archives/535443",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "endfield-wikigg-events",
|
|
game: "endfield",
|
|
url: "https://endfield.wiki.gg/wiki/Event",
|
|
parserId: "wikigg",
|
|
// Exact timestamps and per-region ends beat Game8's day-precision prose,
|
|
// so this source wins when the two disagree.
|
|
priority: 10,
|
|
},
|
|
{
|
|
id: "arknights-akwiki-events",
|
|
game: "arknights",
|
|
url: "https://arknights.wiki.gg/wiki/Event",
|
|
parserId: "akwiki",
|
|
},
|
|
{
|
|
id: "nte-game8-events",
|
|
game: "nte",
|
|
url: "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "nikki-game8-events",
|
|
game: "nikki",
|
|
url: "https://game8.co/games/Infinity-Nikki/archives/487445",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "p5x-game8-events",
|
|
game: "p5x",
|
|
url: "https://game8.co/games/Persona-5-Phantom-X/archives/532244",
|
|
parserId: "game8",
|
|
},
|
|
{
|
|
id: "r1999-fandom-events",
|
|
game: "r1999",
|
|
// The MediaWiki API, not `/wiki/Events`. The rendered page answers a
|
|
// non-browser client with a Cloudflare interstitial, while this wiki's
|
|
// robots.txt allows `/api.php?action=` for `User-agent: *` and the endpoint
|
|
// serves our real User-Agent a 200. See `parsers/fandom.ts` for the full
|
|
// reasoning; the short version is that this is the surface the site put in
|
|
// writing, reached without pretending to be anything we are not.
|
|
url: "https://reverse1999.fandom.com/api.php?action=parse&page=Events&prop=text&formatversion=2&format=json",
|
|
parserId: "fandom",
|
|
},
|
|
{
|
|
id: "ba-bawiki-events",
|
|
game: "ba",
|
|
// The rendered page, deliberately, and the opposite call to the Fandom
|
|
// source above: this wiki is Miraheze, whose robots.txt disallows `/w/` and
|
|
// `/*?action=`, so the API route is the one that is closed here and
|
|
// `/wiki/Events` is the surface the site permits.
|
|
url: "https://bluearchive.wiki/wiki/Events",
|
|
parserId: "bawiki",
|
|
},
|
|
];
|
|
|
|
function toAdapter(spec: SourceSpec): Adapter {
|
|
const parser = parserById(spec.parserId);
|
|
if (parser === undefined) {
|
|
throw new Error(
|
|
`source '${spec.id}' references unknown parser '${spec.parserId}'`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
id: spec.id,
|
|
game: spec.game,
|
|
url: spec.url,
|
|
parserId: spec.parserId,
|
|
minIntervalMs: spec.minIntervalMs ?? SIX_HOURS_MS,
|
|
priority: spec.priority ?? 0,
|
|
parse(html: string, ctx: ParseContext): GachaEvent[] {
|
|
// A site redesign should fail the run loudly rather than publish an empty
|
|
// calendar, which would read as "no events" to a user.
|
|
if (!parser.canParse(html)) {
|
|
throw new Error(
|
|
`${spec.id}: document does not match the '${parser.label}' template; the source has likely been redesigned`,
|
|
);
|
|
}
|
|
|
|
// The trust boundary. Everything a parser produces came from a page we do
|
|
// not control, and this is the one place every source passes through:
|
|
// `ADAPTERS` is built from `SOURCES` via this function, so a source added
|
|
// tomorrow is sanitised without its author doing anything, and a parser
|
|
// cannot opt out. Sanitising here rather than inside the parsers also
|
|
// keeps parsers what they are — pure readers of one site's markup.
|
|
//
|
|
// `sanitizeEvents` logs to console.warn by default, so a repaired or
|
|
// dropped event is never silent (AGENTS.md § Silent drops).
|
|
return sanitizeEvents(parser.parse(html, ctx), {
|
|
sourceId: ctx.sourceId,
|
|
fallbackUrl: ctx.sourceUrl,
|
|
}).events;
|
|
},
|
|
};
|
|
}
|
|
|
|
export const ADAPTERS: Adapter[] = SOURCES.map(toAdapter);
|
|
|
|
export function adaptersForGame(game: GameId): Adapter[] {
|
|
return ADAPTERS.filter((a) => a.game === game);
|
|
}
|
|
|
|
export function adapterById(id: string): Adapter | undefined {
|
|
return ADAPTERS.find((a) => a.id === id);
|
|
}
|
|
|
|
export function gamesWithSources(): GameId[] {
|
|
return [...new Set(ADAPTERS.map((a) => a.game))];
|
|
}
|
|
|
|
/**
|
|
* Parse every source for one game and combine them.
|
|
*
|
|
* Callers supply already-fetched documents so this stays pure and offline —
|
|
* fetching is stage 1's job, not the parser's.
|
|
*/
|
|
export function parseGame(
|
|
game: GameId,
|
|
documents: Map<string, string>,
|
|
now: string,
|
|
): MergeResult {
|
|
const groups = adaptersForGame(game)
|
|
.sort((a, b) => b.priority - a.priority)
|
|
.flatMap((adapter) => {
|
|
const html = documents.get(adapter.id);
|
|
if (html === undefined) return [];
|
|
return [
|
|
adapter.parse(html, {
|
|
now,
|
|
sourceUrl: adapter.url,
|
|
sourceId: adapter.id,
|
|
game: adapter.game,
|
|
}),
|
|
];
|
|
});
|
|
|
|
return mergeEvents(groups);
|
|
}
|
|
|
|
// Convenience handles for tests and scripts.
|
|
export const genshinGame8 = ADAPTERS.find(
|
|
(a) => a.id === "genshin-game8-events",
|
|
)!;
|
|
export const nteGame8 = ADAPTERS.find((a) => a.id === "nte-game8-events")!;
|