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", // **This priority protects event IDs, not data quality.** The Fandom source // below is the fresher page by a wide margin — it is the only Genshin // surface CI can fetch at all — but `priority` here decides which copy // *survives* a near-match, and the survivor's title is its ID, which is a // localStorage key (AGENTS.md § Event IDs are localStorage keys). The two // pages title two live events differently: `To Temper Thyself and Journey // Far` vs `… Cycle 5`, and `Stygian Onslaught` vs `…: Battle of the // Starburst`. Letting Fandom win those re-mints both IDs and silently // orphans every completion mark on them, with no server-side recovery — so // the incumbent keeps identity and Fandom contributes the events Game8 // never listed. Measured: 0 IDs lost this way, 2 lost the other way. // // It costs nothing on dates today, because the two sources agree exactly // wherever they overlap (0 conflicts). Should they ever diverge, `merge.ts` // flags it rather than resolving it, which is the gate working. priority: 10, }, { 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: "p5x-game8-events", game: "p5x", url: "https://game8.co/games/Persona-5-Phantom-X/archives/532244", parserId: "game8", }, { id: "genshin-fandom-events", game: "genshin", // The API, not `/wiki/Event` — the whole § Fandom argument in AGENTS.md. // This wiki's robots.txt is the standard Fandom file, read with the // runner's own client on 2026-09-12: `Allow: /api.php?action=` for // `User-agent: *`, no `Disallow: /`, no `Content-Signal`, and the AI // crawlers it names (GPTBot, CCBot, OAI-SearchBot, ImagesiftBot, // ClaudeBot) are not us. // // Genshin's Game8 page has never been fetchable from CI (CloudFront answers // every runner with a 202), so until this source existed the game's lane was // built from a checked-in fixture and could only age. This page is a fifth // template — `Event | Duration | Type(s)` under `h3` fences — and publishes // at day precision, which is everything it states. // // Lower priority than the Game8 source above on purpose, and the comment // there says why: it is an identity question, not a quality one. url: "https://genshin-impact.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json", parserId: "fandom", }, { 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", }, { id: "fgo-fandom-events", game: "fgo", // `Event_List` is the Japanese server and `Event_List_(US)` is ours; they // run months apart. See parsers/fandom.ts § the English-server half. url: "https://fategrandorder.fandom.com/api.php?action=parse&page=Event_List_(US)&prop=text&formatversion=2&format=json", parserId: "fandom", }, { id: "holodori-holodoriwiki-events", game: "holodori", // The rendered page, the same call as `ba-bawiki-events` above and for the // same reason: holodori.wiki is Miraheze, so `/w/` and `?action=` are the // closed routes here and `/wiki/` is the one `*` is allowed. url: "https://holodori.wiki/wiki/Events", parserId: "holodoriwiki", }, { id: "gfl2-iopwiki-events", game: "gfl2", // IOP Wiki, the Girls' Frontline universe wiki. Its robots.txt is two lines // — `User-agent: *` and `Crawl-Delay: 20` — with no Disallow anywhere, and // the page answers our own User-Agent with a 200 and a real `Last-Modified`, // so the conditional request below costs it a 304 on an unchanged day. url: "https://iopwiki.com/wiki/GFL2_Events", parserId: "iopwiki", }, { id: "stellasora-stellasorawiki-events", game: "stellasora", // The front page, and deliberately not `/wiki/Banner_List`: the list has // full coverage and states no timezone anywhere, while this module emits // the same instants as `