feat: add Chaos Zero Nightmare, which the game8 parser already reads

No parser work at all — a GameId, a games entry, a SOURCES line, a fixture and
a test. The page lists six current events and the parser publishes four; the
two it skips print "Start Date: -", and no start means no event ID, so that is
the skip-never-guess rule working rather than a silent drop.

The cost is worth stating plainly: this is the ninth game8 source, and game8's
edge answers the Actions runner with a 202 and a bot-management body, so the
lane is built from a checked-in fixture in CI from day one and only a manual
`bun run refresh` moves it. freshness() discloses that in the footer, which is
what it is for.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 03:50:33 +02:00
co-authored by Claude Opus 5
parent ecbf9c0422
commit 6c7369db10
10 changed files with 1120 additions and 15 deletions
+11 -10
View File
@@ -37,8 +37,8 @@ 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, eight parsers, sixteen sources across
fifteen games, the full interface, offline support, a static server, a Docker image and CI all exist and
**Status: working app, refreshing itself on a schedule.** Schema, eight parsers, seventeen sources across
sixteen 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
changed. The SQLite layer and the review queue are still specified in `docs/` but not built, so the
@@ -119,7 +119,7 @@ src/client/ React app, service worker, manifest
theme.ts — dark or light, and what a game hue reads as on each
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health
test/ 660 tests
test/ 670 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README
```
@@ -164,7 +164,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 eight sources and `fandom` two;
- **Parsers are keyed by site, not game.** One `game8` parser serves nine sources and `fandom` two;
`wikigg`, `akwiki`, `bawiki`, `holodoriwiki`, `iopwiki` and `stellasorawiki` serve one each — the first two share a host family
and have entirely different templates, and the last two are both Miraheze wikis whose page
templates have nothing in common. Adding a source for a known site is one `SOURCES` entry; a new
@@ -242,9 +242,9 @@ Sources are community wikis. Treat them as a guest would:
- Honor `robots.txt`; set a descriptive `User-Agent` with a contact URL.
- One request per source per refresh cycle, minimum 6 hours apart.
- **Space requests to one host**, honouring its `Crawl-delay` and defaulting to 2s. Eight of the
sixteen sources are game8.co pages, so the per-source floor alone still permits one cycle to arrive
as eight back-to-back requests to a single site — which is the shape an edge network throttles, and
- **Space requests to one host**, honouring its `Crawl-delay` and defaulting to 2s. Nine of the
seventeen sources are game8.co pages, so the per-source floor alone still permits one cycle to arrive
as nine back-to-back requests to a single site — which is the shape an edge network throttles, and
what a burst looks like from the far end regardless of our intent.
- Send `If-None-Match` / `If-Modified-Since`; treat `304` as "skip, unchanged".
- Cache raw snapshots so re-parsing never re-fetches. **Iterate against fixtures, not the network.**
@@ -256,9 +256,9 @@ training, and no `User-agent: *` rule applies to our paths. Keep it that way: do
rate, and do not add an LLM that consumes page content.
**game8.co does not answer a GitHub Actions runner** (confirmed 2026-08-17). Its edge returns
`202 Accepted` with a bot-management body to every one of the eight game8 sources, from the first
`202 Accepted` with a bot-management body to every one of the nine game8 sources, from the first
scheduled cycle onward — `last confirmed: never` — while the same URLs return `200` and parse
cleanly from a normal address. So `robots.txt` permits us and the network does not, and those eight
cleanly from a normal address. So `robots.txt` permits us and the network does not, and those nine
games have only ever been built from checked-in fixtures in CI.
The per-host spacing above does not fix this and was not meant to: a 202 on the very first request
@@ -285,6 +285,7 @@ re-litigated each pass:
| `prydwen.gg`, `gametora.com` | **Cleared, unbuilt.** `User-agent: *` allows the paths we would want. prydwen sets `Crawl-delay: 10`, far below our one-per-6h |
| `iopwiki.com` | **Built** (2026-08-19), Girls' Frontline 2 — see § IOP Wiki below. `robots.txt` is two lines, `User-agent: *` and `Crawl-Delay: 20`, no `Disallow` anywhere |
| `stellasora.miraheze.org` | **Built** (2026-08-19), from the front page's `Current Banners` module and **not** `/wiki/Banner_List` — see § Stella Sora below |
| `game8.co/games/Chaos-Zero-Nightmare` | **Built** (2026-08-19). Zero parser work — the existing `game8` parser reads it. The ninth game8 source, so fixture-backed in CI from day one |
`.github/ISSUE_TEMPLATE/feature_request.yml` points readers at that table by heading, so a source
request can be checked against it before anyone writes it up — the loudest feedback on the first
@@ -610,6 +611,6 @@ to an open page). Four things hold it up:
(PRD F7). `freshness()` in `src/shared/feed.ts` is the one definition: it takes the newest
`lastSuccessAt` and **never `generatedAt`**, which is a build stamp that would call a
fixture-backed calendar minutes old, and it treats a game as only as fresh as its *oldest* source,
so one live wiki cannot vouch for a stalled sibling. Given that eight sources cannot be fetched
so one live wiki cannot vouch for a stalled sibling. Given that nine sources cannot be fetched
from CI at all (§ Scraping conduct), this disclosure is the only thing standing between a reader and
a confidently stale calendar — do not let a future change source it from the build clock.
+2 -2
View File
@@ -10,7 +10,7 @@ import { z } from "zod";
export const GameId = z.enum([
"genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x", "r1999",
"ba", "fgo", "holodori", "gfl2", "stellasora",
"ba", "fgo", "holodori", "gfl2", "stellasora", "czn",
]);
export const EventType = z.enum([
@@ -308,7 +308,7 @@ nothing to add, which is the point worth carrying to the next game: an override
the game moves nobody, while the same override added a year later re-labels every tick already
logged under the old clock. Get it right at introduction or accept a migration.
Infinity Nikki, P5X and Blue Archive carry **no `resetOffsets`
Infinity Nikki, P5X, Blue Archive and Chaos Zero Nightmare carry **no `resetOffsets`
entry**, so they take the regional default. That is an assumption, not a verified server map — none
of those sources states one. Two of the 2026-08-19 additions are worth separating out, because they
are silent for different reasons and neither is the usual one:
+1 -1
View File
@@ -48,7 +48,7 @@ Consequences worth internalising:
| Parser | Site | Sources using it |
|---|---|---|
| `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Infinity Nikki, Persona 5: The Phantom X |
| `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Infinity Nikki, Persona 5: The Phantom X, Chaos Zero Nightmare |
| `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 — two page templates: `Event \| Time Period \| Version` wikitables, and FGO's picture-fenced `ONGOING EVENTS` blocks | Reverse: 1999, Fate/Grand Order |
+2 -2
View File
@@ -59,7 +59,7 @@ have a source — this table is the launch scope, not a roster to keep in sync.
Adding a game must require no change to `GachaEvent` — only a `GameId` entry, its `games.ts`
metadata, and a source registration. That is the test of whether the data model is right, and it has
held: the fifteen games here have cost the event schema nothing. Per-game *metadata* does occasionally
held: the sixteen games here have cost the event schema nothing. Per-game *metadata* does occasionally
grow (Reverse: 1999 needed `resetHourLocal` for a 05:00 reset), which is a different file and moves
no stored key. A game may have several sources; see `docs/INGESTION.md` § Three layers.
@@ -90,7 +90,7 @@ default `"game"` — the board every existing reader already has). Merged, the o
`endingSoonestFirst`, the same comparator behind the list's "Ending soonest" (F2), so the two views
cannot mean different things by the same words and an unannounced end still sorts behind every dated
one rather than claiming a place in the queue. Merged there is also no lane heading to say whose
event a bar is, and hue alone cannot answer that once fifteen games share a stack — so each bar
event a bar is, and hue alone cannot answer that once sixteen games share a stack — so each bar
carries its game's short name, with the full name in its tooltip. A bar too narrow for a tag and a
title both keeps the title: a chopped game name reads as a broken word, and the hue and the tooltip
still answer it.
@@ -0,0 +1,86 @@
[
{
"id": "czn:following-the-foxs-footsteps:2026-05-27",
"game": "czn",
"title": "Following the Fox's Footsteps",
"type": "other",
"summary": "Check out Hilde's story by Following the Fox's Footsteps!",
"startsAt": "2026-05-27T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-06-17T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/559899",
"sourceId": "czn-game8-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": "czn:beach-cafe-festival:2026-07-29",
"game": "czn",
"title": "Beach Cafe Festival",
"type": "other",
"summary": "Beach Cafe Festival has you collecting puzzle pieces by fulfilling seasonal missions to unlock pieces of Midsummer Night Memory photos, and earn rewards based on the number of puzzle pieces you've acquired.",
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-30T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/559899",
"sourceId": "czn-game8-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": "czn:chasing-the-remanants-of-light:2026-07-29",
"game": "czn",
"title": "Chasing the Remanants of Light",
"type": "other",
"summary": "Login daily for a total of 14 days and earn rewards!",
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-08T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/559899",
"sourceId": "czn-game8-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": "czn:virtual-tactical-simulation-hilde:2026-07-29",
"game": "czn",
"title": "Virtual Tactical Simulation - Hilde",
"type": "other",
"summary": "Virtual Tactical Simulation for combatant Hilde will be available for use during Hilde's banner duration. Play her trial stage using fixed save data to obtain rewards while also testing the new combatant.",
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Chaos-Zero-Nightmare/archives/559899",
"sourceId": "czn-game8-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
+11
View File
@@ -143,6 +143,17 @@ const SOURCES: SourceSpec[] = [
url: "https://stellasora.miraheze.org/wiki/Main_Page",
parserId: "stellasorawiki",
},
{
id: "czn-game8-events",
game: "czn",
// The ninth game8 source, and the cost is worth stating plainly: game8's
// edge answers the Actions runner with a 202 and a bot-management body
// (AGENTS.md § Scraping conduct), so this lane is built from a checked-in
// fixture in CI from day one and only a manual `bun run refresh` moves it.
// `freshness()` discloses that in the footer, which is what it is for.
url: "https://game8.co/games/Chaos-Zero-Nightmare/archives/559899",
parserId: "game8",
},
];
function toAdapter(spec: SourceSpec): Adapter {
+3
View File
@@ -132,6 +132,9 @@ export const GAMES: Record<GameId, GameMeta> = {
// starts, 12:59 and 19:59 ends), so there is no reset hour to read off it
// either. See the fgo entry below for the same reasoning at more length.
stellasora: { id: "stellasora", name: "Stella Sora", short: "Stella Sora", hue: "#2E9E9E", studio: "Yostar", dailyTasks: "Daily missions" },
// No `resetOffsets`: the source is Game8, which states day-precision prose
// and no clock at all — the same silence p5x has, and the same answer.
czn: { id: "czn", name: "Chaos Zero Nightmare", short: "CZN", hue: "#B84A9C", studio: "Smilegate", dailyTasks: "Daily missions" },
};
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
+1
View File
@@ -16,6 +16,7 @@ export const GameId = z.enum([
"holodori", // hololive Dreams
"gfl2", // Girls' Frontline 2: Exilium
"stellasora", // Stella Sora
"czn", // Chaos Zero Nightmare
]);
export type GameId = z.infer<typeof GameId>;
+37
View File
@@ -44,6 +44,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
{ adapter: adapter("holodori-holodoriwiki-events"), fixture: "fixtures/holodori/holodoriwiki-events-2026-08-18" },
{ adapter: adapter("gfl2-iopwiki-events"), fixture: "fixtures/gfl2/iopwiki-events-2026-08-19" },
{ adapter: adapter("stellasora-stellasorawiki-events"), fixture: "fixtures/stellasora/stellasorawiki-events-2026-08-19" },
{ adapter: adapter("czn-game8-events"), fixture: "fixtures/czn/game8-events-2026-08-19" },
];
async function runAdapter(adapter: Adapter, fixture: string) {
@@ -1314,3 +1315,39 @@ describe("Stella Sora wiki", () => {
).toThrow(/redesigned/);
});
});
describe("Chaos Zero Nightmare (game8)", () => {
const fixture = "fixtures/czn/game8-events-2026-08-19";
const czn = adapter("czn-game8-events");
test("reuses the game8 parser with no new parsing code", () => {
// The whole point of this source: a ninth game8 page is a registry entry,
// a fixture and a test. If this ever stops being true the page has moved
// to a template the parser does not know.
expect(czn.parserId).toBe("game8");
});
test("publishes the four datable events and skips the two without a start", async () => {
// The page carries six current events. Two print `Start Date: -`, and no
// start means no event ID — so skipping them is the rule working rather
// than a silent drop. Counted independently off the fixture: six
// `Start Date` rows, two of them `-`.
const events = await runAdapter(czn, fixture);
expect(events).toHaveLength(4);
const titles = events.map((e) => e.title);
expect(titles).toContain("Beach Cafe Festival");
expect(titles).toContain("Chasing the Remanants of Light");
// Both appear on the page and neither states a start.
expect(titles).not.toContain("Full-Scale Offensive Season 3");
expect(titles).not.toContain("Virtual Tactical Simulation - Yuki");
});
test("keeps Game8's day precision rather than inventing a clock", async () => {
// Game8 prints "July 29, 2026" and no time of day anywhere on this page.
for (const e of await runAdapter(czn, fixture)) {
expect(e.startPrecision).toBe("day");
expect(e.startsAt.endsWith("T00:00:00.000Z")).toBe(true);
}
});
});