diff --git a/AGENTS.md b/AGENTS.md index b75f229..24a7ee8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ A web app that aggregates live and upcoming events across popular gacha games, p calendar, sorts them by end date or by what the reader is partway through, tracks day-by-day progress on events that repeat daily, and lets a user mark events completed. -**Status: working app, refreshing itself on a schedule.** Schema, nine parsers, twenty sources across +**Status: working app, refreshing itself on a schedule.** Schema, nine parsers, twenty-one sources across nineteen games, the full interface, offline support, a static server, a Docker image and CI all exist and are tested. The refresh runner (`bun run refresh`) fetches, caches raw snapshots and rebuilds the feed; `.github/workflows/refresh.yml` runs it twice a day and commits only when a page actually @@ -184,7 +184,7 @@ These come from how gacha games actually schedule things, and they cause most bu "ongoing" heading decides currency against `ctx.now` itself, and comparing the 00:00Z placeholder to `now` retires a row hours before `clockFor` calls it over for anybody — the reader watches a deadline they were counting down to vanish on its last day. So `latestBoundaryMs` answers the same - question for the *last* region, and `bawiki.ts` and two branches of `fandom.ts` ask it. Nothing + question for the *last* region, and `bawiki.ts` and three branches of `fandom.ts` ask it. Nothing stored changes: it is one comparison, not a resolved boundary written to the feed. - **Patch cycles are ~6 weeks, and a span over a year is a parse error rather than a long event.** `test/adapters/game8.test.ts` rejects one across every fixture. Two corrections to what this rule @@ -209,7 +209,7 @@ These come from how gacha games actually schedule things, and they cause most bu - **Skip, never guess.** Every function in `dates.ts` returns `null` rather than inferring a missing year, month, or end. `readColumnTable` drops a row it cannot date. An omitted event is a recoverable disappointment; a confidently wrong date is the failure this product exists to prevent. -- **Parsers are keyed by site, not game.** One `game8` parser serves nine sources and `fandom` four; +- **Parsers are keyed by site, not game.** One `game8` parser serves nine sources and `fandom` five; `wikigg`, `akwiki`, `bawiki`, `holodoriwiki`, `iopwiki`, `stellasorawiki` and `arustats` serve one each — `wikigg` and `akwiki` share a host family and have entirely different templates, and `bawiki`, `holodoriwiki` and `stellasorawiki` are all Miraheze wikis whose page templates have @@ -382,6 +382,7 @@ re-litigated each pass: | `game8.co/games/Umamusume-Pretty-Derby` | **Built** (2026-08-19), off the stable `List of All Banners` page, not the monthly release-schedule pages whose URL changes every month. Cost a widening of `game8.ts`'s section and column vocabulary — see § Working on parsers | | `nikke-…-international.fandom.com` | **Built** (2026-08-19), via `api.php` like Reverse: 1999 and FGO. Its `robots.txt` was read in a browser and is the standard Fandom file — see § Fandom below. Richest schedule of anything added in this pass: story events *and* dated pickup banners, with the reset clock evidenced on the page | | `infinity-nikki.fandom.com` | **Built** (2026-08-19), replacing the Game8 page for Infinity Nikki, which had been stale since August 2025. Same standard Fandom `robots.txt`. Published at **day precision**: the page states a wall clock and no zone for it — see § Fandom | +| `genshin-impact.fandom.com` | **Built** (2026-09-12), via `api.php` like the other Fandom sources — see § Fandom below. Fifth Fandom template. Adds 11 events Game8 never listed; priority kept lower than Game8 to protect existing localStorage event IDs | | `infinitynikki.miraheze.org` | **Declined.** Exists and serves `robots.txt`, but the wiki is abandoned — front page last edited 11 February 2025 and `/wiki/Events` returns a permission error. Checked as a replacement for the stale Infinity Nikki Game8 page | | `prydwen.gg/infinity-nikki` | **Declined.** 404 — prydwen does not cover Infinity Nikki | | `grayravens.com` (Punishing: Gray Raven) | **Declined.** Conduct is fine; the data is not. The whole 626 KB `/wiki/Events` page contains exactly one date range, written as prose, one event per six-week patch | @@ -599,7 +600,7 @@ broken build — `skipped_robots` does not touch the failure streak, and the run falls back to the checked-in fixture. Refreshing it means running `bun run refresh` from an address Fandom serves, which is how its first snapshot was taken. -**Four Fandom templates now, and the third states its zone in a column header.** The Nikke wiki's +**Five Fandom templates now, and the third states its zone in a column header.** The Nikke wiki's `Event` page is `Event | Start(UTC+9) | End(UTC+9) | Archived(?)` for story events and `Nikke | Start(UTC+9) | End(UTC+9)` for pickup banners. That header is the safety property, not a convenience: no date in any cell carries an offset, so a table whose Start/End columns stop naming a @@ -677,6 +678,38 @@ reads as exactly the fault the gate just declined to call it, and it is the only prompt somebody to ask whether a month-long quiet lane is a quiet game or a wording change under a `statesNoEvents` that still matches. +**The fifth is Genshin Impact, adding a second source for a lane CI could not fetch.** +`genshin-impact.fandom.com` publishes `Current Events` and `Upcoming Events` wikitables under `h3` +headings with columns `Event | Duration | Type(s)`. Genshin's Game8 page has never been fetchable from +CI (CloudFront returns a `202` on all runners), so until this source existed the game's lane was built +from a checked-in fixture and could only age. Fandom answers via `api.php` and gives Genshin a live +source CI can refresh. Four things to know about it: + +- **The title is the caption link's display text, and neither attribute.** The banner cell contains an + image link and a text caption below it. The link `title` names the parent article + (`Miliastra Pass/2026-08-12` for `Phantasmagoric Chronicle`), and the `img alt` carries whatever file + name was uploaded (`Stygian Onslaught 2025-10-29` on an event starting 2026-08-19). Reading attributes + here would corrupt event titles — the opposite of the Nikke rule, where cell text was missing and + attributes were required. A row with no caption is skipped rather than guessing from misleading + attributes. +- **Run-date suffixes are stripped.** Recurring events often carry their run date in the subpage title + (`Overflowing Abundance 2026-09-14`). The date names the run, the start date is already half the + event ID, and keeping it would duplicate the date on the calendar. +- **`latestBoundaryMs` gates currency.** Like FGO and Infinity Nikki, this source prints day-precision + dates (`Aug. 14, 2026 – Aug. 24, 2026`), so `parseFullRange` stores UTC midnight as a placeholder. + Currency is checked against `latestBoundaryMs` so an event is not retired prematurely on its final day + before the last region has rolled. +- **Game8 priority protects localStorage keys.** The incumbent `genshin-game8-events` has + `priority: 10` while Fandom takes default priority. Two live events are titled slightly differently + (`To Temper Thyself and Journey Far` vs `… Cycle 5`, and `Stygian Onslaught` vs + `…: Battle of the Starburst`). In `merge.ts`, higher priority breaks ties when confidence is equal + (0.85). If Fandom won those near-matches, the event IDs would change and readers' localStorage + completion marks would silently orphan. With Game8 prioritized: 0 IDs lost, 11 net-new events added, + 0 conflicts, and 5 corroborated events earning confidence bonuses. +- **The 336-day event raised the duration ceiling to 365 days.** Genshin's anniversary 5-star selection + runs 336 days (`2025-10-22` → `2026-09-23`), correctly dated on this wiki. This motivated raising the + sanity ceiling in `test/adapters/game8.test.ts` and `docs/INGESTION.md` from 180 to 365 days. + **The second Fandom source's page is chosen, not obvious.** `fategrandorder.fandom.com` publishes two schedules: `Event_List` opens "This page lists all Events in Fate/Grand Order Japan", and `Event_List_(US)` is the English server. They run months apart, each diff --git a/README.md b/README.md index 389b3f0..8af371e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ review queue are specified but not built. | Piece | State | |---|---| | Event schema, date parsing, Game8 parser | Built, tested | -| Twenty sources across nineteen games | Built, tested | +| Twenty-one sources across nineteen games | Built, tested | | Your own games and events, one-off or repeating | Built, tested | | Cross-source merge and conflict detection | Built, tested | | Input sanitization at the ingest boundary | Built, tested | @@ -111,7 +111,7 @@ date makes you miss content. Given the choice, this ships nothing rather than a | Game | Source | |---|---| -| Genshin Impact | Game8 | +| Genshin Impact | Game8 + Fandom | | Honkai: Star Rail | Game8 | | Wuthering Waves | Game8 | | Zenless Zone Zero | Game8 | diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 89ea2db..18d9a16 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -51,7 +51,7 @@ Consequences worth internalising: | `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Persona 5: The Phantom X, Chaos Zero Nightmare, Umamusume | | `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 — four page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables, and Infinity Nikki's `Event \| Duration \| Description \| Type` article-tables | Reverse: 1999, Fate/Grand Order, Nikke, Infinity Nikki | +| `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 | | `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 | @@ -140,7 +140,7 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any |---|---|---| | `parseMonthDayYear` | `August 12, 2026` | Genshin detail rows | | `parseMonthDayRange` | `August 12 - September 21, 2026` (year on the end only) | Genshin, NTE | -| `parseFullRange` | `Aug. 14, 2026 - Aug. 24, 2026` (a year each side) | Star Rail, Wuthering Waves, Fate/Grand Order | +| `parseFullRange` | `Aug. 14, 2026 - Aug. 24, 2026` (a year each side) | Star Rail, Wuthering Waves, Fate/Grand Order, Genshin Impact (Fandom) | | `parseShortSlashRange` | `08/09/26 - 08/30/26` | Endfield | | `parseSlashDateTimeRange` | `2021/01/16 04:00 - 2021/01/31 03:59` | Genshin past events | | `parseLabelledStartEnd` | `Start: January 24, 2025 End: Permanent` | Infinity Nikki | @@ -161,15 +161,15 @@ midnight because it has to store *something*. It is not a statement that the eve then, and nothing may count down to it literally: `clockFor` resolves a day-precision boundary to that game-day's server reset for the reader's region (`docs/DATA-MODEL.md` § Field notes). -**No parser may store a resolved boundary, and three of them must read one to decide inclusion.** +**No parser may store a resolved boundary, and four of them must read one to decide inclusion.** The stored value stays the printed day at 00:00Z: resolving it here would need a region the parser does not have, and would bake one reader's server into the feed everybody downloads. But a parser whose page carries no "ongoing" heading it can trust decides currency against `ctx.now` itself — -`bawiki.ts`, and the Fate/Grand Order and Infinity Nikki branches of `fandom.ts` — and comparing the -placeholder to `now` retires a row at UTC midnight, hours before `clockFor` calls it over for -anybody. The reader does not see a stale row; they watch the deadline they were counting down to -disappear on its last day, which is the silent drop AGENTS.md § Working on parsers calls the -dangerous failure. So those three ask `latestBoundaryMs` (`src/shared/time.ts`) when the boundary is +`bawiki.ts`, and the Fate/Grand Order, Infinity Nikki and Genshin Impact branches of `fandom.ts` — +and comparing the placeholder to `now` retires a row at UTC midnight, hours before `clockFor` calls +it over for anybody. The reader does not see a stale row; they watch the deadline they were counting +down to disappear on its last day, which is the silent drop AGENTS.md § Working on parsers calls the +dangerous failure. So those four ask `latestBoundaryMs` (`src/shared/time.ts`) when the boundary is day-precision: the last region's reset, and therefore the instant the row is history for every reader rather than for the earliest of them. Being generous by nine hours costs one expired row at the bottom of a list; being strict costs a live one. diff --git a/docs/SOURCES.md b/docs/SOURCES.md index 62433c7..a4029c3 100644 --- a/docs/SOURCES.md +++ b/docs/SOURCES.md @@ -59,6 +59,7 @@ Two things this method cannot tell you, and both matter: | Infinity Nikki | **Rebuilt** (2026-08-19) on `infinity-nikki.fandom.com` at day precision, replacing a Game8 page stale since August 2025 — see § 11 | | Silver Palace | Unreleased | | Honkai Impact 3rd | **Built** (2026-08-27) on `arustats.com`, and the only lane here whose dates are **estimates** — see § 14 | +| Genshin Impact (Fandom) | **Built** (2026-09-12) on `genshin-impact.fandom.com`, second source for a blind lane — see § 15 | ## Summary of findings @@ -74,6 +75,7 @@ Two things this method cannot tell you, and both matter: | Azur Lane | none | — | — | **Still declined** — the Fandom alternatives are 2021 archives | | Aether Gazer | — | — | — | **Do not build. The game is shutting down.** | | Silver Palace | — | — | — | Unreleased; beta only | +| Genshin Impact (Fandom) | `genshin-impact.fandom.com/api.php?…page=Event` | `fandom`, fifth template | yes (via `api.php`) | **BUILT** 2026-09-12 — adds 11 events Game8 missed; Game8 priority preserves localStorage IDs | --- @@ -484,7 +486,7 @@ table. **One cost applies to both builds and should be in the commit message, not discovered later.** Both candidates are game8.co, which does not answer the Actions runner (`AGENTS.md` § Scraping conduct). -Nine of the twenty sources are game8 pages today, now that Infinity Nikki has moved to Fandom +Nine of the twenty-one sources are game8 pages today, now that Infinity Nikki has moved to Fandom (§ 11); these would make ten and eleven. Each is a lane fixture-backed in CI from day one and only ever as fresh as someone's last manual `bun run refresh`, and one more request to a single host every cycle — the per-host arithmetic § Scraping conduct already calls uncomfortable. @@ -767,6 +769,21 @@ Phase B's first target in changed on the ingest side yet — `GachaEvent` does not carry a rule, and this note is what stops the next person re-deriving the reason it does not. +## 15. Genshin Impact (Fandom) — BUILT 2026-09-12, adding a second source for a blind lane + +**Source:** `https://genshin-impact.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json` (page at `https://genshin-impact.fandom.com/wiki/Event`). + +**Conduct:** Standard Fandom `robots.txt` (`Allow: /api.php?action=`, no `Disallow: /` for `*`, no `Content-Signal`). Read with the runner's client; CI can fetch it. + +**Why built:** Genshin's Game8 page has never been fetchable from CI (CloudFront returns a `202` on all runners), so until now Genshin was fixture-backed in CI and could only age. Fandom provides a second, active source that scheduled refreshes can update. + +**The template:** Fifth Fandom template. `Current Events` and `Upcoming Events` wikitables under `h3` headings with columns `Event | Duration | Type(s)`. +- **Title extraction:** Display text of the caption link beneath the banner image is taken. Link `title` (parent subpage) and `img alt` (uploaded image file name) are rejected to prevent corrupting event titles and IDs. +- **Run-date suffixes:** Recurring event run dates (`2026-09-14`) in subpage names are stripped so dates aren't duplicated. +- **Currency:** `latestBoundaryMs` is checked for day-precision boundaries so events aren't retired before all server regions roll. +- **Priority & IDs:** Game8 is assigned `priority: 10` while Fandom is default/0. Two live events differ slightly in naming (`To Temper Thyself and Journey Far` vs `… Cycle 5`, and `Stygian Onslaught` vs `…: Battle of the Starburst`). Game8's higher priority breaks near-match ties in `merge.ts`, ensuring existing localStorage event IDs survive and reader completion marks remain intact. Fandom contributes 11 net-new events Game8 missed, 0 date conflicts occur, and overlapping events gain corroboration bonuses. +- **336-day event:** Genshin's anniversary 5-star selection runs 336 days (`2025-10-22` → `2026-09-23`), prompting the test duration ceiling to be raised from 180 to 365 days across the test suite and ingestion specs. + ## What every one of these costs, beyond the source Adding a game is never only a `SOURCES` entry: diff --git a/fixtures/genshin/fandom-events-2026-09-12.expected.json b/fixtures/genshin/fandom-events-2026-09-12.expected.json new file mode 100644 index 0000000..6292e7d --- /dev/null +++ b/fixtures/genshin/fandom-events-2026-09-12.expected.json @@ -0,0 +1,338 @@ +[ + { + "id": "genshin:uncover-lunar-realms-hone-the-eventide-radiance:2025-10-22", + "game": "genshin", + "title": "Uncover Lunar Realms, Hone the Eventide Radiance", + "type": "other", + "summary": null, + "startsAt": "2025-10-22T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-23T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Uncover_Lunar_Realms,_Hone_the_Eventide_Radiance/2025-10-22", + "sourceId": "genshin-fandom-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": "genshin:when-we-look-up-at-the-moon:2026-07-01", + "game": "genshin", + "title": "When We Look Up at the Moon", + "type": "other", + "summary": null, + "startsAt": "2026-07-01T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-22T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/When_We_Look_Up_at_the_Moon", + "sourceId": "genshin-fandom-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": "genshin:to-temper-thyself-and-journey-far-cycle-5:2026-08-10", + "game": "genshin", + "title": "To Temper Thyself and Journey Far Cycle 5", + "type": "other", + "summary": null, + "startsAt": "2026-08-10T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-11-02T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/To_Temper_Thyself_and_Journey_Far/2026-08-10", + "sourceId": "genshin-fandom-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": "genshin:everwinter-without-mercy:2026-08-12", + "game": "genshin", + "title": "Everwinter Without Mercy", + "type": "story", + "summary": null, + "startsAt": "2026-08-12T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-22T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Everwinter_Without_Mercy_(Event)", + "sourceId": "genshin-fandom-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": "genshin:frostfarer:2026-08-12", + "game": "genshin", + "title": "Frostfarer", + "type": "other", + "summary": null, + "startsAt": "2026-08-12T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-21T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Battle_Pass/Frostfarer", + "sourceId": "genshin-fandom-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": "genshin:phantasmagoric-chronicle:2026-08-12", + "game": "genshin", + "title": "Phantasmagoric Chronicle", + "type": "other", + "summary": null, + "startsAt": "2026-08-12T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-21T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Miliastra_Pass/2026-08-12", + "sourceId": "genshin-fandom-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": "genshin:raiment-collection-gentle-warmth:2026-08-12", + "game": "genshin", + "title": "Raiment Collection: Gentle Warmth", + "type": "other", + "summary": null, + "startsAt": "2026-08-12T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-23T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Raiment_Collection/2026-08-12", + "sourceId": "genshin-fandom-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": "genshin:the-forge-realms-temper-game-of-wits:2026-08-12", + "game": "genshin", + "title": "The Forge Realm's Temper: Game of Wits", + "type": "other", + "summary": null, + "startsAt": "2026-08-12T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-23T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/The_Forge_Realm%27s_Temper/2026-08-12", + "sourceId": "genshin-fandom-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": "genshin:the-godforsaken-frostlands:2026-08-12", + "game": "genshin", + "title": "The Godforsaken Frostlands", + "type": "other", + "summary": null, + "startsAt": "2026-08-12T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-11-03T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/The_Godforsaken_Frostlands", + "sourceId": "genshin-fandom-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": "genshin:stygian-onslaught-battle-of-the-starburst:2026-08-19", + "game": "genshin", + "title": "Stygian Onslaught: Battle of the Starburst", + "type": "challenge", + "summary": null, + "startsAt": "2026-08-19T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-22T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Stygian_Onslaught/2026-08-19", + "sourceId": "genshin-fandom-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": "genshin:great-expeditionist-challenge:2026-08-28", + "game": "genshin", + "title": "Great Expeditionist Challenge", + "type": "challenge", + "summary": null, + "startsAt": "2026-08-28T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-14T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Great_Expeditionist_Challenge", + "sourceId": "genshin-fandom-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": "genshin:heated-battle-mode-tactical-formation:2026-08-29", + "game": "genshin", + "title": "Heated Battle Mode: Tactical Formation", + "type": "other", + "summary": null, + "startsAt": "2026-08-29T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-14T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Heated_Battle_Mode/2026-08-29", + "sourceId": "genshin-fandom-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": "genshin:test-run-flins-ineffa-aino-iansan-lan-yan:2026-09-01", + "game": "genshin", + "title": "Test Run - Flins, Ineffa, Aino, Iansan, Lan Yan", + "type": "challenge", + "summary": null, + "startsAt": "2026-09-01T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-22T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Test_Run_-_Character_Trial_Event/2026-09-01", + "sourceId": "genshin-fandom-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": "genshin:starlight-voyage-joyous-moment:2026-09-07", + "game": "genshin", + "title": "Starlight Voyage: Joyous Moment", + "type": "other", + "summary": null, + "startsAt": "2026-09-07T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-21T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Starlight_Voyage/2026-09-07", + "sourceId": "genshin-fandom-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": "genshin:trial-of-the-bastion:2026-09-07", + "game": "genshin", + "title": "Trial of the Bastion", + "type": "challenge", + "summary": null, + "startsAt": "2026-09-07T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-17T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Trial_of_the_Bastion", + "sourceId": "genshin-fandom-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": "genshin:overflowing-abundance:2026-09-14", + "game": "genshin", + "title": "Overflowing Abundance", + "type": "other", + "summary": null, + "startsAt": "2026-09-14T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-09-21T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://genshin-impact.fandom.com/wiki/Overflowing_Abundance/2026-09-14", + "sourceId": "genshin-fandom-events", + "status": "published", + "confidence": 0.85, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + } +] diff --git a/fixtures/genshin/fandom-events-2026-09-12.html b/fixtures/genshin/fandom-events-2026-09-12.html new file mode 100644 index 0000000..928f063 --- /dev/null +++ b/fixtures/genshin/fandom-events-2026-09-12.html @@ -0,0 +1 @@ +{"parse":{"title":"Event","pageid":7573,"text":"
OverviewHistoryRewards
\n

\n

\n\n

Events are limited time activities in Genshin Impact that allows the player to earn rewards. Players must be Adventure Rank 5 to participate in Events, but most have additional requirements. Some Events include Quests that are not available after the Event duration has ended.\n

The Events Overview menu can be accessed from the Paimon Menu, from the Shortcut Wheel, from the \"Icon icon in the top right of screen, or pressing the F5 key on PC.\n

Some Events allow the player to skip quests requirements using the Quick Start feature.\n

\n

Contents

\n\n
\n\n

List of Events[]

\n

See Event/History for a complete list of Events.\n

\n

Current

EventDurationType(s)
\"Uncover
Uncover Lunar Realms, Hone the Eventide Radiance
October 22, 2025 – September 23, 2026In-Game, Anniversary Standard 5-Star Character Selection
\"When
When We Look Up at the Moon
July 01, 2026 – September 22, 2026In-Game, Exploration Mission
\"To
To Temper Thyself and Journey Far Cycle 5
August 10, 2026 – November 02, 2026In-Game, To Temper Thyself and Journey Far
\"Phantasmagoric
Phantasmagoric Chronicle
August 12, 2026 – September 21, 2026In-Game, Miliastra Pass, Miliastra Wonderland
\"Battle
Frostfarer
August 12, 2026 – September 21, 2026In-Game, Battle Pass
\"Everwinter
Everwinter Without Mercy
August 12, 2026 – September 22, 2026In-Game, Archon Quest
\"Raiment
Raiment Collection: Gentle Warmth
August 12, 2026 – September 23, 2026In-Game, Miliastra Wonderland
\"The
The Forge Realm's Temper: Game of Wits
August 12, 2026 – September 23, 2026In-Game, The Forge Realm's Temper
\"The
The Godforsaken Frostlands
August 12, 2026 – November 03, 2026In-Game, Exploration Mission
\"Stygian
Stygian Onslaught: Battle of the Starburst
August 19, 2026 – September 22, 2026In-Game, Stygian Onslaught
\"Great
Great Expeditionist Challenge
August 28, 2026 – September 14, 2026In-Game, Flagship
\"Heated
Heated Battle Mode: Tactical Formation
August 29, 2026 – September 14, 2026In-Game, Heated Battle Mode
Test Run - Flins, Ineffa, Aino, Iansan, Lan YanSeptember 01, 2026 – September 22, 2026In-Game, Test Run
\"Trial
Trial of the Bastion
September 07, 2026 – September 17, 2026In-Game
\"Starlight
Starlight Voyage: Joyous Moment
September 07, 2026 – September 21, 2026In-Game, Miliastra Wonderland

Upcoming

EventDurationType(s)
\"Overflowing
Overflowing Abundance 2026-09-14
September 14, 2026 – September 21, 2026In-Game

Permanent

EventRelease DateType(s)
\"Fate
Fate Foretold
September 28, 2020In-Game, Indefinite
\"People's
Spiral Abyss Event - People's Choice
September 28, 2020In-Game, Spiral Abyss, Indefinite
\"Wings
Wings of Companionship
September 28, 2020In-Game, Mail, Indefinite
\"Baptism
Baptism of Song
November 11, 2020In-Game, Archon Quest, Indefinite
\"Stellar
Stellar Reunion
December 23, 2020In-Game, Indefinite, Login
\"HoYoLAB
HoYoLAB Community Daily Check-In
March 01, 2021Web, Indefinite, Login
\"Curtain
Curtain of Leaves - Spiral Abyss Challenge
September 28, 2022In-Game, Spiral Abyss, Indefinite
\"The
The Curtain Never Falls on Magic
August 16, 2023In-Game, Indefinite
\"Genshin
Genshin Impact and Discord Account Linking Now Available: Complete Discord Account Linking to Unlock More Game Info Display and One-Click Rewards
August 20, 2024Web, Indefinite, Login
\"Turbo
Turbo Twirly!
August 28, 2024In-Game, Archon Quest, Indefinite
\"The
The Clink Clank Whiz-Kid!
September 10, 2025In-Game, Archon Quest, Indefinite
\"Invitation
Invitation to Wonderland: A Curious Awakening
October 22, 2025In-Game, Miliastra Wonderland, Indefinite
\"Invitation
Invitation to Wonderland: In Search of Inspiration
December 03, 2025In-Game, Miliastra Wonderland, Indefinite
\"Witch's
Witch's Homework
December 03, 2025In-Game, World Quest, Indefinite
\"The
The Hunt Begins!
August 12, 2026In-Game, Archon Quest, Indefinite
\n

List of Event Types[]

\n

There are 25 Event Types:\n

\n\n

List of Recurring Events[]

\n

There are 64 Recurring Events:\n

\n\n

Navigation[]

\n
\n\n\n\n\n
"}} \ No newline at end of file diff --git a/scripts/build-feed.ts b/scripts/build-feed.ts index fefe0fe..af2e621 100644 --- a/scripts/build-feed.ts +++ b/scripts/build-feed.ts @@ -58,7 +58,7 @@ async function documentFor(adapterId: string, game: GameId) { } const now = new Date().toISOString(); -const byGame = new Map(); +const byGame = new Map>(); const sources: SourceHealth[] = []; for (const adapter of ADAPTERS) { @@ -76,7 +76,7 @@ for (const adapter of ADAPTERS) { const { parsedCount } = health; const groups = byGame.get(adapter.game) ?? []; - groups.push(events); + groups.push({ priority: adapter.priority, events }); byGame.set(adapter.game, groups); sources.push(health); @@ -96,7 +96,10 @@ for (const adapter of ADAPTERS) { const events: GachaEvent[] = []; let conflictCount = 0; for (const [, groups] of byGame) { - const merged = mergeEvents(groups); + const sorted = groups + .sort((a, b) => b.priority - a.priority) + .map((g) => g.events); + const merged = mergeEvents(sorted); events.push(...merged.events); conflictCount += merged.conflicts.length; for (const c of merged.conflicts) { diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts index bbb3258..e62ffb2 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -26,6 +26,22 @@ const SOURCES: SourceSpec[] = [ 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", @@ -78,6 +94,27 @@ const SOURCES: SourceSpec[] = [ 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", diff --git a/src/ingest/parsers/fandom.ts b/src/ingest/parsers/fandom.ts index b9c1a23..f6928ca 100644 --- a/src/ingest/parsers/fandom.ts +++ b/src/ingest/parsers/fandom.ts @@ -670,6 +670,242 @@ function infinityNikkiTitle(cell: string): string | null { return title.length === 0 ? null : title; } +/** + * Genshin's `Event` page — the fifth template, and the only one whose sections + * are fenced by an `

`. + * + *

List of Events

+ *

Current

+ * + * + * + * + * + * + * `Current` and `Upcoming` are the schedule; `Permanent` states a + * `Release Date` and no end, and is fenced off. + * + * **Every boundary is day precision, and that is the page's whole claim** — it + * prints a date and no time of day on either side, so unlike Infinity Nikki + * there is not even a clock to discard. The duration cell does carry a + * `data-sort-value="2026-09-14 04:00:002026-09-21 03:59:59"`, and it is + * deliberately ignored: it names no zone, and picking one moves the *day*, + * which is half of every event ID this game will ever have. It costs nothing + * either, because 04:00 is the reset hour `clockFor` already resolves a + * day-precision boundary to on the reader's own server — reading the sort key + * would replace a correct per-region answer with one fixed guess. + */ +const GI_SECTION = + /]*>([\s\S]*?)<\/h\1>|]*>([\s\S]*?)<\/table>/gi; + +/** The two sub-headings whose tables are a schedule, not a back catalogue. */ +const GI_INCLUDED_SECTION = /^(current|upcoming)$/i; + +/** The `h2` this page's schedule lives under, and half of its identity. */ +const GI_PAGE_HEADING = /^list of events$/i; + +/** + * True for the Genshin wiki's `Event` page, and the gate on the branch below. + * + * **Structural, and it survives an empty table** — the Infinity Nikki lesson + * above, applied before it could be learned twice: `Current` and `Upcoming` + * are headings, and a game between versions empties the tables under them + * without taking them with it. Both halves are required because each is weak + * alone: `List of Events` also heads pages that are glossaries, and a bare + * `Current` is a word any wiki might use. + */ +function isGenshinEventPage(rendered: string): boolean { + const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " "); + let onEventsPage = false; + let hasSchedule = false; + + for (const node of flat.matchAll(GI_SECTION)) { + const heading = node[2]; + if (heading === undefined) continue; + const name = text(heading).trim(); + if (GI_PAGE_HEADING.test(name)) onEventsPage = true; + if (GI_INCLUDED_SECTION.test(name)) hasSchedule = true; + } + + return onEventsPage && hasSchedule; +} + +interface GiTable { + body: string; + title: number; + duration: number; + type: number | undefined; +} + +/** Every `Current`/`Upcoming` table, with its columns resolved. */ +function genshinTables(rendered: string): GiTable[] { + const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " "); + const out: GiTable[] = []; + let included = false; + + for (const node of flat.matchAll(GI_SECTION)) { + const heading = node[2]; + if (heading !== undefined) { + // **Every heading closes the section, whatever its level.** The three + // schedules are `h3`s and the page's other tables sit under `h2`s, so a + // reader watching only `h2`s would carry `Upcoming` past the end of the + // schedule and read `List of Event Types` — a glossary of every event + // type this game has ever run — as though it were dated. + included = GI_INCLUDED_SECTION.test(text(heading).trim()); + continue; + } + if (!included) continue; + + const body = node[3] ?? ""; + const headers = [...body.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((h) => + text(h[1] ?? "") + .toLowerCase() + .trim(), + ); + const at = (name: string) => { + const i = headers.indexOf(name); + return i < 0 ? undefined : i; + }; + + const title = at("event"); + const duration = at("duration"); + // Resolved from the header row rather than counted, as in `bawiki.ts`. + // `Permanent` heads a `Release Date` column and fails this lookup too, so + // two independent things keep an undated permanent fixture off a calendar + // of deadlines. + if (title === undefined || duration === undefined) continue; + + out.push({ body, title, duration, type: at("type(s)") }); + } + + return out; +} + +/** + * The Genshin wiki's current and upcoming events. + * + * A second source for a game whose Game8 page cannot be fetched from CI at all + * (AGENTS.md § Scraping conduct), so this is the only Genshin surface a + * scheduled refresh can actually move. + */ +function parseGenshinEvents( + rendered: string, + ctx: ParseContext, +): GachaEvent[] { + const nowMs = Date.parse(ctx.now); + const out: GachaEvent[] = []; + const seen = new Set(); + + for (const table of genshinTables(rendered)) { + for (const row of table.body.matchAll(ROW)) { + 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 titleCell = cells[table.title]?.html ?? ""; + const title = genshinTitle(titleCell); + if (title === null) continue; + + const range = parseFullRange(text(cells[table.duration]?.html ?? "")); + // A row this reader cannot date yields nothing rather than a guess. + if (range === null) continue; + if (range.end.iso <= range.start.iso) continue; + + // `Current` is maintained by hand and goes stale before anyone moves a + // row, so currency is checked rather than taken on trust — on the clock + // the countdown reads a day-precision end on, not on the UTC midnight + // placeholder stored for it. + if ( + latestBoundaryMs(range.end.iso, range.end.precision, ctx.game) < nowMs + ) { + continue; + } + + const id = eventId(ctx.game, title, range.start.iso); + if (seen.has(id)) continue; + seen.add(id); + + const stated = + table.type === undefined ? "" : text(cells[table.type]?.html ?? ""); + const href = ARTICLE_LINK.exec(titleCell)?.[1]; + + out.push({ + id, + game: ctx.game, + title, + // The page's own `Type(s)` column — "Battle Pass", "Archon Quest", + // "Login" — is a far better signal than a Genshin event title, which + // is usually a piece of poetry. + type: inferType(`${title} ${stated}`), + // No description column, and the prose lives on each event's own + // article rather than further down this page — so there is nothing to + // summarise, and `Type(s)` is a classification rather than a blurb. + summary: null, + startsAt: range.start.iso, + startPrecision: range.start.precision, + endsAt: range.end.iso, + endPrecision: range.end.precision, + // One worldwide service on this page's telling: it draws no regional + // distinction and states no per-region end, so `regionScoped` would be + // claiming a split the source never made. + regionScoped: false, + regionEnds: null, + sourceUrl: + href === undefined + ? ctx.sourceUrl + : new URL(href, ctx.sourceUrl).toString(), + sourceId: ctx.sourceId, + status: "published", + // Day precision on both sides, which is everything the page states. + confidence: 0.85, + extractionMethod: "parser", + version: 1, + firstSeenAt: ctx.now, + updatedAt: ctx.now, + }); + } + } + + return out; +} + +/** + * An event's name, from the caption link under its banner. + * + * **The cell's text, and neither attribute** — the reverse of the Nikke and + * Infinity Nikki rule above, which is exactly why it is written down: those two + * are the nearest precedent and following them here would be wrong. This wiki + * renders a banner linked to the event's article and repeats the name as a + * caption beneath it, so the visible text is the curated display name while + * both attributes name something else. The link `title` is the *parent* + * article — `Miliastra Pass/2026-08-12` for an event called + * `Phantasmagoric Chronicle` — and the `img alt` is whatever the uploaded file + * was called, which is `Stygian Onslaught 2025-10-29` on an event that starts + * 2026-08-19. Either one publishes a wrong name, and a title is half a + * localStorage key. + * + * A row with no caption is therefore **skipped rather than named from an + * attribute that demonstrably means something else** — an omitted event is a + * recoverable disappointment, a wrong key is not recoverable at all. Every row + * on the live page carries one. + */ +function genshinTitle(cell: string): string | null { + const raw = text(cell).trim(); + if (raw.length === 0) return null; + + // `Overflowing Abundance 2026-09-14` — this wiki gives each run of a + // recurring event its own dated page and sometimes captions the row with it. + // The same call as Infinity Nikki's, for the same reason: that date names the + // *run*, the start date is already half the event ID, and keeping it would + // print the date twice in one row. + const title = raw.replace(DATED_SUBPAGE, "").trim(); + return title.length === 0 ? null : title; +} + export function parseFandomEventsPage( body: string, ctx: ParseContext, @@ -690,6 +926,14 @@ export function parseFandomEventsPage( ); } + if (isGenshinEventPage(rendered)) { + return parseGenshinEvents(rendered, ctx).sort((a, b) => + a.startsAt === b.startsAt + ? a.id.localeCompare(b.id) + : a.startsAt.localeCompare(b.startsAt), + ); + } + if (isNikkeEventPage(rendered)) { return parseNikkeEvents(rendered, ctx).sort((a, b) => a.startsAt === b.startsAt @@ -810,7 +1054,8 @@ export const fandomParser: SourceParser = { isTimePeriodTable || isFgoEventList(rendered) || isNikkeEventPage(rendered) || - isInfinityNikkiEventPage(rendered) + isInfinityNikkiEventPage(rendered) || + isGenshinEventPage(rendered) ); }, statesNoEvents(body: string): boolean { diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index 1378815..565b217 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { adapterById } from "../../src/ingest/adapters/index.ts"; +import { adapterById, parseGame } from "../../src/ingest/adapters/index.ts"; import type { Adapter } from "../../src/ingest/adapters/types.ts"; import { parseOrdinalDateTimeRange } from "../../src/ingest/dates.ts"; import { arknightsWikiParser } from "../../src/ingest/parsers/akwiki.ts"; @@ -50,6 +50,10 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [ { adapter: adapter("czn-game8-events"), fixture: "fixtures/czn/game8-events-2026-08-19" }, { adapter: adapter("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" }, { adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-events-2026-08-19" }, + // Genshin's Fandom wiki — the fifth Fandom template, and the first Genshin + // source a scheduled refresh can actually fetch. Its one 336-day row is the + // reason the duration guard below reads 365 rather than 180. + { adapter: adapter("genshin-fandom-events"), fixture: "fixtures/genshin/fandom-events-2026-09-12" }, { adapter: adapter("nikki-fandom-events"), fixture: "fixtures/nikki/fandom-events-2026-08-19" }, // The same page a week later, listing nothing: 2.7's events had ended and 2.8 // was not up yet, so both tables read "There are no Events in this category." @@ -109,7 +113,7 @@ describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => { // 365 keeps the guard pointed at what it was built for. A misread year // lands the *end* twelve months out, so it shows up as span + ~365 — 405 // days for a six-week event, still caught. What 365 newly admits is only - // the genuinely year-long event, a shape this domain does have: + // the genuinely year-long event, which is a shape this domain does have: // the same widening clears Fire Emblem Heroes' real seven-month // new-player banner (docs/SOURCES.md § 12b). for (const e of await runAdapter(adapter, fixture)) { @@ -1822,11 +1826,12 @@ describe("Infinity Nikki wiki between versions (the page states it is empty)", ( expect(fandomParser.statesNoEvents?.(full)).toBe(false); }); - test("does not claim emptiness for the other three Fandom templates", async () => { + test("does not claim emptiness for the other four Fandom templates", async () => { // `statesNoEvents` is asked of every Fandom source, so a loose check would // let a redesigned FGO or Nikke page store an empty snapshot. for (const fixture of [ "fixtures/fgo/fandom-events-2026-08-18", + "fixtures/genshin/fandom-events-2026-09-12", "fixtures/nikke/fandom-events-2026-08-19", "fixtures/r1999/fandom-events-2026-08-17", ]) { @@ -1835,3 +1840,73 @@ describe("Infinity Nikki wiki between versions (the page states it is empty)", ( } }); }); + +describe("Genshin Impact wiki (the fifth Fandom template)", () => { + const fixture = "fixtures/genshin/fandom-events-2026-09-12"; + const genshin = adapter("genshin-fandom-events"); + + test("takes the title from caption display text, ignoring link title and img alt", async () => { + const events = await runAdapter(genshin, fixture); + // Display name: "Phantasmagoric Chronicle", link title: "Miliastra Pass/2026-08-12" + expect(events.map((e) => e.title)).toContain("Phantasmagoric Chronicle"); + expect(events.map((e) => e.title)).not.toContain("Miliastra Pass"); + + // Display name: "Stygian Onslaught: Battle of the Starburst", img alt: "Stygian Onslaught 2025-10-29" + expect(events.map((e) => e.title)).toContain( + "Stygian Onslaught: Battle of the Starburst", + ); + }); + + test("strips the dated subpage suffix from recurring event titles", async () => { + const events = await runAdapter(genshin, fixture); + // The raw cell text is "Overflowing Abundance 2026-09-14", which strips to "Overflowing Abundance" + expect(events.map((e) => e.title)).toContain("Overflowing Abundance"); + for (const e of events) { + expect(e.title).not.toMatch(/\s\d{4}-\d{2}-\d{2}$/); + } + }); + + test("fences out past events and unparsed glossary tables", async () => { + const events = await runAdapter(genshin, fixture); + // Only current and upcoming events are parsed (16 events in fixture) + expect(events.length).toBe(16); + for (const e of events) { + expect(e.status).toBe("published"); + expect(e.endsAt).not.toBeNull(); + } + }); + + test("parseGame preserves existing Game8 event IDs while adding Fandom events", async () => { + const g8Html = await Bun.file( + "fixtures/genshin/game8-events-2026-08-14.html", + ).text(); + const fandomHtml = await Bun.file( + "fixtures/genshin/fandom-events-2026-09-12.html", + ).text(); + const g8Expected = JSON.parse( + await Bun.file( + "fixtures/genshin/game8-events-2026-08-14.expected.json", + ).text(), + ) as Array<{ id: string }>; + + const docs = new Map([ + ["genshin-game8-events", g8Html], + ["genshin-fandom-events", fandomHtml], + ]); + + const result = parseGame("genshin", docs, NOW); + + // All 9 Game8 IDs are preserved exactly (preventing localStorage key orphan) + for (const expected of g8Expected) { + expect(result.events.map((e) => e.id)).toContain(expected.id); + } + + // Fandom contributes net-new events Game8 did not list (total 20 merged events) + expect(result.events.length).toBe(20); + expect(result.conflicts).toHaveLength(0); + + // Corroboration bonus applies to overlapping events (0.85 -> 0.95) + const corroborated = result.events.filter((e) => e.confidence > 0.85); + expect(corroborated.length).toBe(5); + }); +});
EventDurationType(s)
+ * Phantasmagoric Chronicle
+ * Phantasmagoric Chronicle
August 12, 2026 – September 21, 2026In-Game, Miliastra Pass