add Genshin Impact Fandom event source
Add genshin-impact.fandom.com as a second source for Genshin Impact, which has been fixture-backed in CI because Game8 CloudFront-blocks the Actions runner with 202s. - Add fifth Fandom template parser in src/ingest/parsers/fandom.ts extracting titles from caption display text, stripping dated subpage suffixes, and checking currency via latestBoundaryMs. - Register genshin-fandom-events in src/ingest/adapters/index.ts and set genshin-game8-events priority: 10 so existing event IDs survive near-match ties, preventing localStorage completion marks from orphaning. - Explicitly sort adapter groups by priority descending in scripts/build-feed.ts to align with parseGame. - Pin fixtures and add test coverage in test/adapters/game8.test.ts. - Update AGENTS.md, README.md, docs/INGESTION.md, and docs/SOURCES.md.
This commit is contained in:
@@ -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
|
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.
|
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
|
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
|
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
|
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
|
"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
|
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
|
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.
|
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.**
|
- **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
|
`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
|
- **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
|
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.
|
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
|
`wikigg`, `akwiki`, `bawiki`, `holodoriwiki`, `iopwiki`, `stellasorawiki` and `arustats` serve one
|
||||||
each — `wikigg` and `akwiki` share a host family and have entirely different templates, and
|
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
|
`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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
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.
|
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
|
`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
|
`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
|
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
|
prompt somebody to ask whether a month-long quiet lane is a quiet game or a wording change under a
|
||||||
`statesNoEvents` that still matches.
|
`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.**
|
**The second Fandom source's page is chosen, not obvious.**
|
||||||
`fategrandorder.fandom.com` publishes two schedules: `Event_List` opens "This page lists all Events
|
`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
|
in Fate/Grand Order Japan", and `Event_List_(US)` is the English server. They run months apart, each
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ review queue are specified but not built.
|
|||||||
| Piece | State |
|
| Piece | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Event schema, date parsing, Game8 parser | Built, tested |
|
| 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 |
|
| Your own games and events, one-off or repeating | Built, tested |
|
||||||
| Cross-source merge and conflict detection | Built, tested |
|
| Cross-source merge and conflict detection | Built, tested |
|
||||||
| Input sanitization at the ingest boundary | 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 |
|
| Game | Source |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Genshin Impact | Game8 |
|
| Genshin Impact | Game8 + Fandom |
|
||||||
| Honkai: Star Rail | Game8 |
|
| Honkai: Star Rail | Game8 |
|
||||||
| Wuthering Waves | Game8 |
|
| Wuthering Waves | Game8 |
|
||||||
| Zenless Zone Zero | Game8 |
|
| Zenless Zone Zero | Game8 |
|
||||||
|
|||||||
+8
-8
@@ -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 |
|
| `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 |
|
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
|
||||||
| `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `parseMonthDayYear` | `August 12, 2026` | Genshin detail rows |
|
||||||
| `parseMonthDayRange` | `August 12 - September 21, 2026` (year on the end only) | Genshin, NTE |
|
| `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 |
|
| `parseShortSlashRange` | `08/09/26 - 08/30/26` | Endfield |
|
||||||
| `parseSlashDateTimeRange` | `2021/01/16 04:00 - 2021/01/31 03:59` | Genshin past events |
|
| `parseSlashDateTimeRange` | `2021/01/16 04:00 - 2021/01/31 03:59` | Genshin past events |
|
||||||
| `parseLabelledStartEnd` | `Start: January 24, 2025 End: Permanent` | Infinity Nikki |
|
| `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
|
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).
|
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
|
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
|
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 —
|
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
|
`bawiki.ts`, and the Fate/Grand Order, Infinity Nikki and Genshin Impact branches of `fandom.ts` —
|
||||||
placeholder to `now` retires a row at UTC midnight, hours before `clockFor` calls it over for
|
and comparing the placeholder to `now` retires a row at UTC midnight, hours before `clockFor` calls
|
||||||
anybody. The reader does not see a stale row; they watch the deadline they were counting down to
|
it over for anybody. The reader does not see a stale row; they watch the deadline they were counting
|
||||||
disappear on its last day, which is the silent drop AGENTS.md § Working on parsers calls the
|
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
|
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
|
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
|
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.
|
the bottom of a list; being strict costs a live one.
|
||||||
|
|||||||
+18
-1
@@ -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 |
|
| 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 |
|
| Silver Palace | Unreleased |
|
||||||
| Honkai Impact 3rd | **Built** (2026-08-27) on `arustats.com`, and the only lane here whose dates are **estimates** — see § 14 |
|
| 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
|
## 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 |
|
| Azur Lane | none | — | — | **Still declined** — the Fandom alternatives are 2021 archives |
|
||||||
| Aether Gazer | — | — | — | **Do not build. The game is shutting down.** |
|
| Aether Gazer | — | — | — | **Do not build. The game is shutting down.** |
|
||||||
| Silver Palace | — | — | — | Unreleased; beta only |
|
| 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
|
**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).
|
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
|
(§ 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
|
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.
|
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
|
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.
|
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
|
## What every one of these costs, beyond the source
|
||||||
|
|
||||||
Adding a game is never only a `SOURCES` entry:
|
Adding a game is never only a `SOURCES` entry:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
@@ -58,7 +58,7 @@ async function documentFor(adapterId: string, game: GameId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const byGame = new Map<GameId, GachaEvent[][]>();
|
const byGame = new Map<GameId, Array<{ priority: number; events: GachaEvent[] }>>();
|
||||||
const sources: SourceHealth[] = [];
|
const sources: SourceHealth[] = [];
|
||||||
|
|
||||||
for (const adapter of ADAPTERS) {
|
for (const adapter of ADAPTERS) {
|
||||||
@@ -76,7 +76,7 @@ for (const adapter of ADAPTERS) {
|
|||||||
const { parsedCount } = health;
|
const { parsedCount } = health;
|
||||||
|
|
||||||
const groups = byGame.get(adapter.game) ?? [];
|
const groups = byGame.get(adapter.game) ?? [];
|
||||||
groups.push(events);
|
groups.push({ priority: adapter.priority, events });
|
||||||
byGame.set(adapter.game, groups);
|
byGame.set(adapter.game, groups);
|
||||||
|
|
||||||
sources.push(health);
|
sources.push(health);
|
||||||
@@ -96,7 +96,10 @@ for (const adapter of ADAPTERS) {
|
|||||||
const events: GachaEvent[] = [];
|
const events: GachaEvent[] = [];
|
||||||
let conflictCount = 0;
|
let conflictCount = 0;
|
||||||
for (const [, groups] of byGame) {
|
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);
|
events.push(...merged.events);
|
||||||
conflictCount += merged.conflicts.length;
|
conflictCount += merged.conflicts.length;
|
||||||
for (const c of merged.conflicts) {
|
for (const c of merged.conflicts) {
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ const SOURCES: SourceSpec[] = [
|
|||||||
game: "genshin",
|
game: "genshin",
|
||||||
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||||
parserId: "game8",
|
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",
|
id: "hsr-game8-events",
|
||||||
@@ -78,6 +94,27 @@ const SOURCES: SourceSpec[] = [
|
|||||||
url: "https://game8.co/games/Persona-5-Phantom-X/archives/532244",
|
url: "https://game8.co/games/Persona-5-Phantom-X/archives/532244",
|
||||||
parserId: "game8",
|
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",
|
id: "r1999-fandom-events",
|
||||||
game: "r1999",
|
game: "r1999",
|
||||||
|
|||||||
@@ -670,6 +670,242 @@ function infinityNikkiTitle(cell: string): string | null {
|
|||||||
return title.length === 0 ? null : title;
|
return title.length === 0 ? null : title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genshin's `Event` page — the fifth template, and the only one whose sections
|
||||||
|
* are fenced by an `<h3>`.
|
||||||
|
*
|
||||||
|
* <h2>List of Events</h2>
|
||||||
|
* <h3><span class="mw-headline" id="Current">Current</span></h3>
|
||||||
|
* <table class="wikitable sortable">
|
||||||
|
* <tr><th>Event</th><th>Duration</th><th>Type(s)</th></tr>
|
||||||
|
* <tr><td><span typeof="mw:File"><a title="Miliastra Pass/2026-08-12">
|
||||||
|
* <img alt="Phantasmagoric Chronicle" …></a></span><br />
|
||||||
|
* <a href="/wiki/Miliastra_Pass/2026-08-12">Phantasmagoric Chronicle</a></td>
|
||||||
|
* <td>August 12, 2026 – September 21, 2026</td>
|
||||||
|
* <td>In-Game, Miliastra Pass</td>
|
||||||
|
*
|
||||||
|
* `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 =
|
||||||
|
/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>|<table\b[^>]*>([\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(/<th\b[^>]*>([\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<string>();
|
||||||
|
|
||||||
|
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(
|
export function parseFandomEventsPage(
|
||||||
body: string,
|
body: string,
|
||||||
ctx: ParseContext,
|
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)) {
|
if (isNikkeEventPage(rendered)) {
|
||||||
return parseNikkeEvents(rendered, ctx).sort((a, b) =>
|
return parseNikkeEvents(rendered, ctx).sort((a, b) =>
|
||||||
a.startsAt === b.startsAt
|
a.startsAt === b.startsAt
|
||||||
@@ -810,7 +1054,8 @@ export const fandomParser: SourceParser = {
|
|||||||
isTimePeriodTable ||
|
isTimePeriodTable ||
|
||||||
isFgoEventList(rendered) ||
|
isFgoEventList(rendered) ||
|
||||||
isNikkeEventPage(rendered) ||
|
isNikkeEventPage(rendered) ||
|
||||||
isInfinityNikkiEventPage(rendered)
|
isInfinityNikkiEventPage(rendered) ||
|
||||||
|
isGenshinEventPage(rendered)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
statesNoEvents(body: string): boolean {
|
statesNoEvents(body: string): boolean {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 type { Adapter } from "../../src/ingest/adapters/types.ts";
|
||||||
import { parseOrdinalDateTimeRange } from "../../src/ingest/dates.ts";
|
import { parseOrdinalDateTimeRange } from "../../src/ingest/dates.ts";
|
||||||
import { arknightsWikiParser } from "../../src/ingest/parsers/akwiki.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("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("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" },
|
||||||
{ adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-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" },
|
{ 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
|
// 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."
|
// 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
|
// 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
|
// 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
|
// 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
|
// the same widening clears Fire Emblem Heroes' real seven-month
|
||||||
// new-player banner (docs/SOURCES.md § 12b).
|
// new-player banner (docs/SOURCES.md § 12b).
|
||||||
for (const e of await runAdapter(adapter, fixture)) {
|
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);
|
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
|
// `statesNoEvents` is asked of every Fandom source, so a loose check would
|
||||||
// let a redesigned FGO or Nikke page store an empty snapshot.
|
// let a redesigned FGO or Nikke page store an empty snapshot.
|
||||||
for (const fixture of [
|
for (const fixture of [
|
||||||
"fixtures/fgo/fandom-events-2026-08-18",
|
"fixtures/fgo/fandom-events-2026-08-18",
|
||||||
|
"fixtures/genshin/fandom-events-2026-09-12",
|
||||||
"fixtures/nikke/fandom-events-2026-08-19",
|
"fixtures/nikke/fandom-events-2026-08-19",
|
||||||
"fixtures/r1999/fandom-events-2026-08-17",
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user