diff --git a/AGENTS.md b/AGENTS.md index 29ce919..539cf3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,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, five parsers, twelve sources across -eleven 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, six parsers, fourteen sources across +thirteen 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 @@ -105,8 +105,8 @@ re-verify a sample against the live page afterward. ``` src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.ts, feed.ts custom.ts — reader-authored games and events, and their key spaces -src/ingest/ html.ts, dates.ts (eleven formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts - parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts — keyed by SITE, not game +src/ingest/ html.ts, dates.ts (twelve formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts + parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts — keyed by SITE, not game adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam src/client/ React app, service worker, manifest state/ progress, daily log, ignores, prefs, sort — all localStorage @@ -114,7 +114,7 @@ src/client/ React app, service worker, manifest lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) serve.ts static server + /api/health -test/ 536 tests +test/ 560 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` @@ -146,10 +146,11 @@ 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; `wikigg`, - `akwiki`, `fandom` and `bawiki` serve one each — the first two share a host family and have - entirely different templates. Adding a source for a known site is one `SOURCES` entry; a new site is - a parser module. +- **Parsers are keyed by site, not game.** One `game8` parser serves eight sources and `fandom` two; + `wikigg`, `akwiki`, `bawiki` and `holodoriwiki` 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 + site is a parser module. - **A source may publish more than one region's schedule.** Arknights' wiki lists CN and Global on every row, five months apart. Publish the one our readers are on and skip the row that lacks it — a CN date on a Global calendar is a confidently wrong date, not a near miss. @@ -215,7 +216,7 @@ 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 - twelve sources are game8.co pages, so the per-source floor alone still permits one cycle to arrive + fourteen 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 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". @@ -251,6 +252,7 @@ A source whose ToS forbids automated access does not get an adapter. Flag it and | `reverse1999.fandom.com` | **Built** (2026-08-17), via `api.php`, not the wiki page — see § Fandom below | | `bluearchive.fandom.com` | **Declined.** Fetches and parses fine; the page is the problem. Its `Event/Event_List` is a JP-server archive whose newest entry ended 2026-02-18, so all 88 rows are history and it yields **zero** live or upcoming events. An adapter would put an empty lane on the calendar and, because the runner rejects a body that parses to nothing, report a broken source forever. Same failure as the Infinity Nikki Game8 page, further along | | `bluearchive.wiki` | **Built** (2026-08-17), from the rendered `/wiki/Events` page — see § Blue Archive below | +| `holodori.wiki` | **Built** (2026-08-18), from the rendered `/wiki/Events` page. Miraheze again, so the same call as Blue Archive; CC BY-SA 4.0, no `Content-Signal`, no `Crawl-delay` for `*` | | `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 | `.github/ISSUE_TEMPLATE/feature_request.yml` points readers at that table by heading, so a source @@ -313,6 +315,31 @@ confidently wrong date: dailies source if a zone is ever stated. For the same reason `ba` has no `resetOffsets`: Blue Archive Global does run one worldwide server, but nothing in this source says on what clock. +**hololive Dreams: the same Miraheze call as Blue Archive, and the opposite data.** `holodori.wiki` +is Miraheze too, so `/wiki/Events` is the surface `*` is allowed and `/w/` and `?action=` are closed +— `parsers/holodori.ts` takes the route `bawiki.ts` takes, for the reason it takes it. What differs +is the quality of what is there, and three things are worth knowing: + +- **It states its timezone on every cell.** Every boundary is `08/17/2026 8:00PM (JST)`, which makes + this the only wiki source here publishing `exact` precision on both sides without a per-region + timer. `parseSlashClockZone` **requires** the zone rather than defaulting to UTC, so a row that + ever loses it drops out instead of landing nine hours off. That is also where `holodori`'s + `resetOffsets` of UTC+9 comes from — evidenced, not assumed; see docs/DATA-MODEL.md. +- **Inclusion is fenced by an `

`, and the two tables are identical.** `Current Events` and + `Past Events` have the same columns, so a reader that took every `wikitable` would put the back + catalogue on the calendar with nothing to mark it. Rows are checked against `ctx.now` on top of + the heading, because "Current" is maintained by hand and goes stale before anyone moves a row. +- **Every event title is still a red link.** The wiki has no article for any of them yet, so each + links to `?action=edit&redlink=1` — a create-page form, and a `?action=` URL this wiki's + robots.txt disallows. The parser refuses a href with a query and falls back to the events page; + when the articles exist, they get linked with no change. + +Two rows on the page are not events and are meant to be missing. `Beginner Mission` runs +`Game Launch` → `Unknown`: no start means no event ID, and a permanent tutorial chore is not what a +calendar of deadlines is for. An `Unknown` **end** is kept, though — that is `endsAt: null`, and +unlike `bawiki.ts` this parser does not drop a started-but-undated row, because the heading has +already said the event is running. + `scripts/refresh-sources.ts` enforces all of the above in code — the 6h floor, one request, no retries, conditional headers, per-host spacing, robots (failing closed when `robots.txt` cannot be read). Anything that would make it fetch more often is a change to this section first. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cf2bc2a..c27ef93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -77,6 +77,7 @@ src/ akwiki.ts arknights.wiki.gg mrfz-wtable ✓ built fandom.ts Fandom via the action=parse API ✓ built bawiki.ts bluearchive.wiki JP/Global tabber ✓ built + holodori.ts holodori.wiki Current/Past Events tables ✓ built index.ts parser registry ✓ built adapters/ types.ts Adapter interface, ParseContext ✓ built diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index d858370..58cbf23 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -260,12 +260,21 @@ reader cannot see. | Endfield, Europe | 04:00 | UTC-5 (on the Americas server) | 09:00 | 11:00 / 10:00 | | Endfield, Asia / Americas | 04:00 | regional default | 20:00 / 09:00 | — | | Reverse: 1999, all regions | **05:00** | UTC-5 (one global server) | 10:00 | 12:00 / 11:00 | +| hololive Dreams, all regions | 04:00 | UTC+9 (one worldwide server) | 19:00 | 21:00 / 20:00 | -Arknights is the one game whose override covers **all three** regions, and it is not a blanket -per-game offset of the kind this section warns about below: the game genuinely runs a single Global -server for every region we model. The offset is read off the source rather than assumed — every -ending event on arknights.wiki.gg carries an exact end of `10:59:59Z`, which is `03:59:59` at UTC-7, -one second before a 04:00 reset. +Arknights and hololive Dreams are the games whose override covers **all three** regions, and neither +is a blanket per-game offset of the kind this section warns about below: both genuinely run a single +worldwide server for every region we model. Arknights' offset is read off the source rather than +assumed — every ending event on arknights.wiki.gg carries an exact end of `10:59:59Z`, which is +`03:59:59` at UTC-7, one second before a 04:00 reset. + +hololive Dreams is read the same way and is the cleaner case of the two, because the source states +its zone outright: every boundary on holodori.wiki carries `(JST)`, the game launched worldwide +simultaneously on one service, and `Training Support Missions` ends at `3:59AM JST` — one minute +before a 04:00 local reset. Only the offset is overridden; the hour is the default. It also cost +nothing to add, which is the point worth carrying to the next game: an override that arrives with +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` entry**, so they take the regional default. That is an assumption, not a verified server map — none of the three sources states one. diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 8d3429e..9a7584a 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -51,8 +51,9 @@ Consequences worth internalising: | `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Infinity Nikki, Persona 5: The Phantom X | | `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 — `Event \| Time Period \| Version` wikitables | Reverse: 1999 | +| `fandom` | Fandom wikis via the MediaWiki `action=parse` API — `Event \| Time Period \| Version` wikitables | Reverse: 1999, Fate/Grand Order | | `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 | `wikigg` is the better shape by a distance: it emits ISO timestamps with one timer per server region, so its events carry exact precision and real `regionEnds`. Prefer a source like that over a @@ -118,6 +119,7 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any | `parseYearFirstSlashRange` | `2026/07/30 – 2026/08/20` (year first, so field order is not inferred) | Arknights | | `parseOrdinalDateTimeRange` | `November 9th, 05:00 - December 4th, 2023, 04:59 (UTC-5)` (ordinal days, stated offset) | Reverse: 1999 | | `parseIsoDay` | `2026-08-04` (one boundary per column, so nothing to split) | Blue Archive | +| `parseSlashClockZone` | `08/17/2026 8:00PM (JST)` (one boundary per column, 12-hour clock, **named** zone) | hololive Dreams | | `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves | `parseOpenRange` is tried last because it is the most permissive — it accepts any leading full date diff --git a/docs/PRD.md b/docs/PRD.md index b1b4e9c..9610438 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -51,13 +51,14 @@ event, because a missing event sends them to a wiki while a wrong one makes them | Neverness to Everness | `nte` | Added since launch, on the strength of the release thread (`docs/FEEDBACK.md` § P1): Infinity Nikki -(`nikki`), Persona 5: The Phantom X (`p5x`), Reverse: 1999 (`r1999`), Blue Archive (`ba`). +(`nikki`), Persona 5: The Phantom X (`p5x`), Reverse: 1999 (`r1999`), Blue Archive (`ba`), +Fate/Grand Order (`fgo`), hololive Dreams (`holodori`). **`GameId` in `src/shared/schema.ts` is the live answer** and `SOURCES` says which of them actually 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 eleven games here have cost the event schema nothing. Per-game *metadata* does occasionally +held: the thirteen 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. diff --git a/fixtures/holodori/holodoriwiki-events-2026-08-18.expected.json b/fixtures/holodori/holodoriwiki-events-2026-08-18.expected.json new file mode 100644 index 0000000..ff0a178 --- /dev/null +++ b/fixtures/holodori/holodoriwiki-events-2026-08-18.expected.json @@ -0,0 +1,44 @@ +[ + { + "id": "holodori:ultimate-summer-for-me:2026-08-17", + "game": "holodori", + "title": "Ultimate Summer For Me?", + "type": "banner", + "summary": "Spotlight", + "startsAt": "2026-08-17T11:00:00.000Z", + "startPrecision": "exact", + "endsAt": "2026-08-27T10:59:00.000Z", + "endPrecision": "exact", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://holodori.wiki/wiki/Events", + "sourceId": "holodori-holodoriwiki-events", + "status": "published", + "confidence": 0.95, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "holodori:training-support-missions:2026-08-07", + "game": "holodori", + "title": "Training Support Missions", + "type": "other", + "summary": "Mission", + "startsAt": "2026-08-07T02:00:00.000Z", + "startPrecision": "exact", + "endsAt": "2026-08-29T18:59:00.000Z", + "endPrecision": "exact", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://holodori.wiki/wiki/Events", + "sourceId": "holodori-holodoriwiki-events", + "status": "published", + "confidence": 0.95, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + } +] diff --git a/fixtures/holodori/holodoriwiki-events-2026-08-18.html b/fixtures/holodori/holodoriwiki-events-2026-08-18.html new file mode 100644 index 0000000..9b14b63 --- /dev/null +++ b/fixtures/holodori/holodoriwiki-events-2026-08-18.html @@ -0,0 +1,843 @@ + + + + +Events - hololive Dreams Wiki + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Jump to content +
+ + + + +
+
+ + + Toggle menu +
+
+
+
+ +
+
+
+ + 437 +
+
+ + 743 +
+
+ + 25 +
+
+ + 8.4K +
+
+
hololive Dreams Wiki
+
+
+
+ + + + + + +
+
+
+
+
+
+
+ + + Toggle preferences menu + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + + Toggle personal menu + +
+
+
+
+
+
Not logged in
+ +
+
+
Your IP address will be publicly visible if you make any edits.
+
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+

Events

+ +
+
+
From hololive Dreams Wiki
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+ + + + + + + +
+
+
+
+ +
+
+
+ + +

Events are in-game campaigns that allow players to earn rewards by completing tasks. Each event has a corresponding Gacha and story that can be unlocked with event points. +

+
+

For more information, see Point Rally Events and Score Challenge Events.
+In Events, players may complete Lives or Minigames to obtain limited-time Event Points that determine ranking and event badges that may be exchanged for items. The High Score Ranking is based on the scores players achieve on the Event Set Pieces, which are a selection of songs released alongside the event. Each Set Piece has a separate High Score Ranking, and each ranking rewards its own decorative badge. +

+
  • Point Rally Events: Aim for a high score by challenging the Event Set Pieces and earn Event Pts, which can unlock various rewards and a limited-time Event Story. Players are ranked by total Event Pts and high score for each Set Piece.
  • +
  • Score Challenge Events: Aim for a high score by challenging the Event Set Pieces and place in the Total High Score Ranking. Players are also ranked by high score for each Set Piece.
  • +
  • Mission Events: Complete various missions to unlock rewards like Lesson Pts, Hologold, etc.
+

Current Events

+ + edit + + edit source + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Logo +Event +Type +Start Date +End Date +Featured Members +
+
Ultimate Summer For Me? +Spotlight +08/17/2026 8:00PM (JST) +08/27/2026 7:59PM (JST) + +
+
Training Support Missions +Mission +08/07/2026 11:00AM (JST) +08/30/2026 3:59AM (JST) +N/A +
+
Beginner Mission +Mission +Game Launch +Unknown +N/A +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
Logo +Event +Type +Start Date +End Date +Featured Members +
+
Synced Summer Sparkles +Score Challenge +08/07/2026 12:00PM (JST) +08/15/2026 7:59PM (JST) + +
+
Hello! A Brand New Summer! +Point Rally +07/28/2026 12:00PM (JST) +08/05/2026 7:59PM (JST) + +
+ + + + +
+
+
+ +
+ + +
+
+ + + Contents + +
+ +
+
+
+ +
+ + +
+
+ +
+
+
Cookies help us deliver our services. By using our services, you agree to our use of cookies.
+
+ + + +
+ +
+ +
+
+
+
+
+ + +
+
+
+ + + + + + \ No newline at end of file diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts index a61b98d..e059fb2 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -112,6 +112,15 @@ const SOURCES: SourceSpec[] = [ url: "https://fategrandorder.fandom.com/api.php?action=parse&page=Event_List&prop=text&formatversion=2&format=json", parserId: "fandom", }, + { + id: "holodori-holodoriwiki-events", + game: "holodori", + // The rendered page, the same call as `ba-bawiki-events` above and for the + // same reason: holodori.wiki is Miraheze, so `/w/` and `?action=` are the + // closed routes here and `/wiki/` is the one `*` is allowed. + url: "https://holodori.wiki/wiki/Events", + parserId: "holodoriwiki", + }, ]; function toAdapter(spec: SourceSpec): Adapter { diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index 11115ff..c39b51d 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -410,3 +410,72 @@ export function parseSlashDateTimeRange( end: { iso: endIso, precision: "exact" }, }; } + +/** + * Named timezone abbreviations this file will convert from, in milliseconds. + * + * Deliberately only the ones a source actually states, and deliberately only + * ones with a fixed offset. An abbreviation absent here parses to null, which + * is the same answer this file gives every other missing fact — and the reason + * the map is not pre-populated with the obvious candidates is that half of them + * are not fixed: `CST` names three different zones and `PT` shifts by an hour + * twice a year, so a plausible-looking entry is a wrong date waiting for the + * source to use it. + */ +const NAMED_ZONE_OFFSET_MS: Record = { + // Japan observes no daylight saving, so UTC+9 holds all year. + jst: 9 * 60 * 60 * 1000, +}; + +/** + * "08/17/2026 8:00PM (JST)" → 2026-08-17T11:00:00.000Z, exact precision. + * + * One boundary, not a range: the hololive Dreams wiki gives Start Date and End + * Date their own columns, so there is nothing to split and the whole cell is + * anchored at both ends. + * + * Three things separate this from the readers above: + * + * - **A 12-hour clock**, which is the detail most likely to be got wrong + * silently. `12:00PM` is noon and `12:00AM` is midnight — the hour is not + * `12 + 12` in the first case and not `12` in the second — and a naive + * reading puts an event's start twelve hours out without ever failing. + * - **The zone is named, not offset.** `parseOrdinalDateTimeRange` reads a + * stated `(UTC-5)`; here the source writes `(JST)`, so the abbreviation is + * resolved through `NAMED_ZONE_OFFSET_MS` and an unrecognised one returns + * null rather than being read as UTC. + * - **The zone is required.** A cell stating a wall clock and no zone is a + * missing fact, and this page always states one — so the day a row loses it, + * that row should vanish rather than silently land nine hours off. + * + * Month-first, like `parseShortSlashRange`: the source is written for an + * English-speaking audience and its own rows settle it — `08/17/2026` and + * `08/27/2026` are both readable either way, but `08/30/2026` is not a day-first + * date at all. + */ +export function parseSlashClockZone(input: string): ParsedInstant | null { + const re = + /^\s*(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})\s*([AP])M\s*\(([A-Za-z]+)\)\s*$/i; + const m = re.exec(input); + if (!m) return null; + + const offsetMs = NAMED_ZONE_OFFSET_MS[(m[7] ?? "").toLowerCase()]; + if (offsetMs === undefined) return null; + + const hour12 = Number(m[4]); + if (hour12 < 1 || hour12 > 12) return null; + const pm = (m[6] ?? "").toUpperCase() === "P"; + // Noon and midnight are the two readings a 12-hour clock gets wrong: 12PM is + // hour 12 and 12AM is hour 0, so the wrap happens before the PM shift. + const hour = (hour12 % 12) + (pm ? 12 : 0); + + const value = offsetIso( + Number(m[3]), + Number(m[1]), + Number(m[2]), + hour, + Number(m[5]), + offsetMs, + ); + return value === null ? null : { iso: value, precision: "exact" }; +} diff --git a/src/ingest/parsers/holodori.ts b/src/ingest/parsers/holodori.ts new file mode 100644 index 0000000..08a00ce --- /dev/null +++ b/src/ingest/parsers/holodori.ts @@ -0,0 +1,278 @@ +import { eventId, type EventType, type GachaEvent } from "../../shared/schema.ts"; +import { parseSlashClockZone, type ParsedInstant } from "../dates.ts"; +import { text } from "../html.ts"; +import type { ParseContext } from "../adapters/types.ts"; +import { inferType } from "./game8.ts"; +import type { SourceParser } from "./types.ts"; + +/** + * The hololive Dreams wiki's event schedule (`holodori.wiki`, a Miraheze wiki). + * + * The rendered page, not the API — the same call `bawiki.ts` makes and for the + * same reason: this is Miraheze, whose `robots.txt` disallows `/w/` and + * `/*?action=` while allowing `/wiki/`, so the route `fandom.ts` takes is the + * one that is closed here. `/wiki/Events` answers our own User-Agent with a + * `200`, carries no `Content-Signal`, and the wiki is CC BY-SA 4.0. + * + * The page is two `wikitable`s under two `

`s: + * + *

Current Events

+ * + * "; + + const row = (title: string, type: string, start: string, end: string) => + ``; + + test("publishes Current Events and never the Past Events table below it", async () => { + // Counted independently off the fixture: the Current table holds three + // rows and the Past table two, and the two tables are identically shaped — + // so a reader that took every `wikitable` would put the back catalogue on + // the calendar with nothing to distinguish it. + const events = await runAdapter(holo, fixture); + expect(events.map((e) => e.title)).toEqual([ + "Ultimate Summer For Me?", + "Training Support Missions", + ]); + for (const e of events) { + expect(e.title).not.toMatch(/Synced Summer Sparkles|Brand New Summer/); + } + }); + + test("converts JST to UTC on both boundaries, to the minute", async () => { + // This is the only wiki source here that states a timezone on every cell, + // which is what earns `exact` on both sides without a per-region timer. + const [spotlight, missions] = await runAdapter(holo, fixture); + + // 08/17/2026 8:00PM (JST) → 11:00Z the same day. + expect(spotlight?.startsAt).toBe("2026-08-17T11:00:00.000Z"); + // 08/27/2026 7:59PM (JST) → 10:59Z. + expect(spotlight?.endsAt).toBe("2026-08-27T10:59:00.000Z"); + // 08/30/2026 3:59AM (JST) → 18:59Z on the 29th: a small-hours JST boundary + // lands on the *previous* UTC day, which is exactly the shift that makes + // storing the source's own wall clock unusable. + expect(missions?.endsAt).toBe("2026-08-29T18:59:00.000Z"); + + for (const e of await runAdapter(holo, fixture)) { + expect(e.startPrecision).toBe("exact"); + expect(e.endPrecision).toBe("exact"); + expect(e.confidence).toBe(0.95); + } + }); + + test("reads 12PM as noon and 12AM as midnight", async () => { + // The failure this guards is silent: a naive 12-hour reading puts a start + // twelve hours out and still produces a valid-looking instant. + const events = parse( + paged( + HEADER + + row("Noon", "Mission", "08/20/2026 12:00PM (JST)", "08/21/2026 12:00AM (JST)"), + HEADER, + ), + ); + expect(events).toHaveLength(1); + // 12:00 JST → 03:00Z, not 00:00Z. + expect(events[0]?.startsAt).toBe("2026-08-20T03:00:00.000Z"); + // 00:00 JST on the 21st → 15:00Z on the 20th, not noon. + expect(events[0]?.endsAt).toBe("2026-08-20T15:00:00.000Z"); + }); + + test("drops a row whose start is not a date, and keeps one whose end is not", async () => { + // Both appear on the real page. `Beginner Mission` runs "Game Launch" → + // "Unknown": no start means no event ID and no place on a calendar of + // deadlines. An unannounced *end* is the opposite — it is a fact worth + // publishing, and inventing one is the failure this product exists to + // prevent. + expect((await runAdapter(holo, fixture)).map((e) => e.title)).not.toContain( + "Beginner Mission", + ); + + const events = parse( + paged( + HEADER + + row("Open ended", "Mission", "08/20/2026 12:00PM (JST)", "Unknown") + + row("No start", "Mission", "Game Launch", "08/21/2026 12:00PM (JST)"), + HEADER, + ), + ); + expect(events.map((e) => e.title)).toEqual(["Open ended"]); + expect(events[0]?.endsAt).toBeNull(); + expect(events[0]?.endPrecision).toBe("unknown"); + // An unannounced end is weaker evidence and the gate should see that. + expect(events[0]?.confidence).toBe(0.8); + }); + + test("requires the stated timezone rather than falling back to UTC", async () => { + // A cell that loses its `(JST)` is a missing fact, and a missing fact is a + // dropped row here — not a nine-hour error nobody can see. + const events = parse( + paged( + HEADER + + row("Zoneless", "Mission", "08/20/2026 12:00PM", "08/21/2026 12:00PM") + + row("Unknown zone", "Mission", "08/20/2026 12:00PM (XYZ)", "08/21/2026 12:00PM (XYZ)"), + HEADER, + ), + ); + expect(events).toEqual([]); + }); + + test("takes the type from the source's own column, not from the title", async () => { + // "Spotlight" is this game's word for a rate-up banner, and no reading of + // "Ultimate Summer For Me?" would ever produce it. + const [spotlight, missions] = await runAdapter(holo, fixture); + expect(spotlight?.type).toBe("banner"); + expect(spotlight?.summary).toBe("Spotlight"); + expect(missions?.type).toBe("other"); + + const events = parse( + paged( + HEADER + + row("Rally", "Point Rally", "08/20/2026 12:00PM (JST)", "08/28/2026 12:00PM (JST)") + + row("Scored", "Score Challenge", "08/20/2026 12:00PM (JST)", "08/28/2026 12:00PM (JST)"), + HEADER, + ), + ); + // The game's two ranked scoring formats land in the same bucket, because + // the game does not draw a distinction between them that we can act on. + expect(events.map((e) => e.type)).toEqual(["challenge", "challenge"]); + }); + + test("a stale Current Events heading does not keep a finished event live", async () => { + // The heading is maintained by hand and lags the dates by a few days. The + // dates are the fact; the heading is someone's housekeeping. + const events = parse( + paged( + HEADER + + row("Over", "Mission", "07/01/2026 12:00PM (JST)", "07/20/2026 12:00PM (JST)"), + HEADER, + ), + ); + expect(events).toEqual([]); + }); + + test("columns are resolved by name, so an inserted column moves nothing", async () => { + // The first column is a logo and the last a cast list. Counting from either + // end means one added column shifts every date one place, every row fails + // to parse, and the lane empties with no error anywhere. + const events = parse( + `

Current Events

LogoEventTypeStart DateEnd DateFeatured Members + *
Ultimate Summer For Me?Spotlight + * 08/17/2026 8:00PM (JST)08/27/2026 7:59PM (JST)… + *

Past Events

+ * … same columns … + * + * Four things about it decide the code below. + * + * **The page states its timezone, on every single cell.** All eight boundaries + * in the captured fixture end `(JST)`, which is why this is the only wiki source + * here publishing `exact` precision on both sides without a per-region timer. + * It is worth guarding rather than assuming: `parseSlashClockZone` requires the + * zone, so a row that ever loses it drops out instead of quietly landing nine + * hours off. + * + * **A section is fenced by its `

`, and only `Current Events` is ours.** + * Unlike `bluearchive.wiki`, this page says outright which rows it considers + * live, and the two tables are otherwise identical — a reader that took every + * `wikitable` would put the whole back catalogue on the calendar. Rows are still + * checked against `ctx.now` on top of that, because the heading is maintained by + * hand and "Current" goes stale a few days before someone moves the row. + * + * **Not every row is datable, and that is the page being honest.** `Beginner + * Mission` runs from `Game Launch` to `Unknown` — a permanent tutorial chore + * with no schedule. Its start is what decides it: an event ID is built from a + * start date, so a row without one cannot be published at all, and inventing + * launch day to fill the hole would put a permanent fixture on a calendar of + * deadlines. An `Unknown` *end* is different and is kept — that is `endsAt: + * null`, the honest answer this codebase exists to preserve. + * + * **The Type column is a controlled vocabulary, so it beats the title.** + * `Spotlight` is this game's word for a rate-up banner — the row carries a + * `Featured Members` column to prove it — and no amount of reading + * "Ultimate Summer For Me?" would ever produce that. See `TYPES` below. + */ + +const HEADING = /]*>([\s\S]*?)<\/h2>/gi; +const TABLE = /]*>([\s\S]*?)<\/table>/i; +const ROW = /]*>([\s\S]*?)<\/tr>/gi; +const CELL = /]*>([\s\S]*?)<\/t\1>/gi; +/** Non-global deliberately: `.exec` on `ROW` would advance a shared `lastIndex`. */ +const FIRST_ROW = /]*>([\s\S]*?)<\/tr>/i; +/** + * The event's own article, when it has one. + * + * `Special:` is excluded because Miraheze's robots.txt disallows it, exactly as + * in `bawiki.ts`. Excluding a query string matters more here than it does there, + * though, and not as a nicety: **every title on this page is currently a red + * link.** The wiki has no article for any of these events yet, so it writes + * `/wiki/Ultimate_Summer_For_Me%3F?action=edit&redlink=1` — a *create this + * page* form, which is both the wrong place to send a reader and a `?action=` + * URL that this wiki's robots.txt disallows outright. Refusing a href with a + * query is what keeps one out of the feed, and the fallback to the events page + * is the right answer until the articles exist. + */ +const ARTICLE_LINK = /]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i; + +/** + * The site's own event vocabulary, mapped onto our `EventType`. + * + * A fixed list rather than word-matching, because this column is a controlled + * vocabulary: the wiki writes one of these four words and nothing else. Two of + * them are not derivable from the title in any form — + * + * - **`Spotlight`** is the gacha. Rate-up banners are what the `Featured + * Members` column on the same row lists, and "spotlight" is the game's term + * for the window they run in. + * - **`Point Rally`** and **`Score Challenge`** are the game's two ranked + * scoring formats — play the event's Set Pieces for points against a + * leaderboard. `challenge` is the bucket for a scored cycle you grind, and + * putting the two formats in different buckets would be describing a + * difference the game does not draw. + * + * `Mission` maps to nothing better than `other`, which is stated here rather + * than left to the fallback so that a *new* word in this column is the only + * thing that ever reaches `inferType` — that is the case worth noticing, and + * the fallback is what keeps it from being a crash. + */ +const TYPES: Record = { + spotlight: "banner", + "point rally": "challenge", + "score challenge": "challenge", + mission: "other", +}; + +interface Columns { + title: number; + type: number | undefined; + start: number; + end: number; +} + +/** + * Where each column sits, read off the header row rather than counted. + * + * Counting is how a silent drop starts here: the first column is a logo and the + * last is a cast list, so a table that gains or loses one of those shifts every + * date one place and every row fails to parse — an empty lane with no error + * anywhere. Returning null is also what tells a `wikitable` that is not a + * schedule from one that is. + */ +function columns(headers: string[]): Columns | null { + const at = (name: string) => { + const i = headers.indexOf(name); + return i < 0 ? undefined : i; + }; + + const title = at("event"); + const start = at("start date"); + const end = at("end date"); + if (title === undefined || start === undefined || end === undefined) { + return null; + } + return { title, start, end, type: at("type") }; +} + +/** + * The schedule table under the `Current Events` heading, and where its columns + * sit. + * + * Bounded by the next `

`, which is what keeps `Past Events` — an identically + * shaped table directly below — off the calendar. Matching the heading by its + * text rather than by position is the same choice `bawiki.ts` makes about tabs: + * position works today and starts reading the archive the day the page is + * reordered. + */ +function currentSchedule(html: string): { cols: Columns; body: string } | null { + let section: string | null = null; + for (const m of html.matchAll(HEADING)) { + if (text(m[1] ?? "").toLowerCase() !== "current events") continue; + const from = m.index + m[0].length; + const next = html.slice(from).search(/` in the table. A row header + // appearing further down would otherwise join the list and shift every + // resolved index by one — which is the silent drop that reading columns by + // name is supposed to rule out, arriving by a different door. + const first = FIRST_ROW.exec(body)?.[1] ?? ""; + const headers = [...first.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((h) => + text(h[1] ?? "").toLowerCase(), + ); + const cols = columns(headers); + return cols === null ? null : { cols, body }; +} + +export function parseHolodoriWikiEventsPage( + html: string, + ctx: ParseContext, +): GachaEvent[] { + const schedule = currentSchedule(html.replace(/\s+/g, " ")); + if (schedule === null) return []; + + const { cols } = schedule; + const nowMs = Date.parse(ctx.now); + const out: GachaEvent[] = []; + + for (const row of schedule.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[cols.title]?.html ?? ""; + const title = text(titleCell); + if (title.length === 0) continue; + + // No start, no event. `Game Launch` is the real value this drops, and it is + // not a date the page failed to write down — it is a permanent tutorial + // chore, which is not what a calendar of deadlines is for. + const start = parseSlashClockZone(text(cells[cols.start]?.html ?? "")); + if (start === null) continue; + + // `Unknown` lands here, and is kept rather than dropped. It is the whole + // point of `endPrecision: "unknown"`: the heading has already said this + // event is running, so the honest report is that it is live and its end has + // not been announced. That differs from `bawiki.ts`, which drops a + // started-but-undated row — it has to, because it is reading an archive + // with no heading to tell a live row from a finished one. + const end: ParsedInstant | null = parseSlashClockZone( + text(cells[cols.end]?.html ?? ""), + ); + + if (end !== null) { + if (end.iso <= start.iso) continue; + // "Current Events" is maintained by hand and goes stale a few days before + // anyone moves a row down to "Past Events". The dates are the fact; the + // heading is someone's housekeeping. + if (Date.parse(end.iso) < nowMs) continue; + } + + const type = cols.type === undefined + ? "" + : text(cells[cols.type]?.html ?? ""); + + const href = ARTICLE_LINK.exec(titleCell)?.[1]; + + // Both boundaries are stated to the minute in a named zone, so there is no + // day-precision penalty here — only the one an unannounced end carries. + const confidence = end === null ? 0.95 - 0.15 : 0.95; + + out.push({ + id: eventId(ctx.game, title, start.iso), + game: ctx.game, + title, + type: TYPES[type.toLowerCase()] ?? inferType(`${title} ${type}`), + summary: type.length > 0 ? type : null, + startsAt: start.iso, + startPrecision: start.precision, + endsAt: end === null ? null : end.iso, + endPrecision: end === null ? "unknown" : end.precision, + // hololive Dreams launched worldwide simultaneously on one service, and + // every boundary on this page is stated once, in JST, with no per-region + // column anywhere. So there is one global end instant and nothing + // region-scoped to report — see `games.ts` for the server clock itself. + regionScoped: false, + regionEnds: null, + sourceUrl: + href === undefined + ? ctx.sourceUrl + : new URL(href, ctx.sourceUrl).toString(), + sourceId: ctx.sourceId, + status: "published", + confidence: Math.round(confidence * 100) / 100, + extractionMethod: "parser", + version: 1, + firstSeenAt: ctx.now, + updatedAt: ctx.now, + }); + } + + return out; +} + +export const holodoriWikiParser: SourceParser = { + id: "holodoriwiki", + label: "hololive Dreams Wiki (holodori.wiki)", + + /** + * Asserts the two things the parser navigates by: the `Current Events` + * heading, and a table under it headed with the columns it reads. A renamed + * heading or a renamed column fails the run loudly rather than emptying the + * lane, which is the whole reason `canParse` is checked separately from a + * zero-event parse. + */ + canParse(html: string): boolean { + return currentSchedule(html.replace(/\s+/g, " ")) !== null; + }, + + parse: parseHolodoriWikiEventsPage, +}; diff --git a/src/ingest/parsers/index.ts b/src/ingest/parsers/index.ts index 915ba51..9a3d85a 100644 --- a/src/ingest/parsers/index.ts +++ b/src/ingest/parsers/index.ts @@ -2,6 +2,7 @@ import { arknightsWikiParser } from "./akwiki.ts"; import { blueArchiveWikiParser } from "./bawiki.ts"; import { fandomParser } from "./fandom.ts"; import { game8Parser } from "./game8.ts"; +import { holodoriWikiParser } from "./holodori.ts"; import { wikiGgParser } from "./wikigg.ts"; import type { SourceParser } from "./types.ts"; @@ -16,6 +17,7 @@ export const PARSERS: SourceParser[] = [ arknightsWikiParser, fandomParser, blueArchiveWikiParser, + holodoriWikiParser, ]; export function parserById(id: string): SourceParser | undefined { @@ -27,4 +29,5 @@ export { arknightsWikiParser } from "./akwiki.ts"; export { blueArchiveWikiParser } from "./bawiki.ts"; export { fandomParser } from "./fandom.ts"; export { game8Parser } from "./game8.ts"; +export { holodoriWikiParser } from "./holodori.ts"; export { wikiGgParser } from "./wikigg.ts"; diff --git a/src/shared/games.ts b/src/shared/games.ts index 0a08e94..ddeeb8d 100644 --- a/src/shared/games.ts +++ b/src/shared/games.ts @@ -95,6 +95,16 @@ export const GAMES: Record = { // readers' day keys, and a wrong guess ticks the wrong box every night. ba: { id: "ba", name: "Blue Archive", short: "Blue Archive", hue: "#3FCBDD" , studio: "Nexon Games", dailyTasks: "Daily missions, AP" }, fgo: { id: "fgo", name: "Fate/Grand Order", short: "FGO", hue: "#1D3A8F", studio: "Lasengle", dailyTasks: "Daily missions, AP" }, + // hololive Dreams runs a single worldwide service on a Japanese clock, and + // both halves of that come from the source rather than from habit: every + // boundary on holodori.wiki is stated once, in `(JST)`, with no per-region + // column anywhere, and the game launched worldwide simultaneously. The reset + // hour is evidenced the way Arknights' is — `Training Support Missions` ends + // at 3:59AM JST, one minute before a 04:00 local reset — so the default hour + // stands and only the offset is overridden. Setting it now costs nothing: no + // reader has a day key for a game the app has never shipped, whereas adding + // the same override later would re-label ticks they had already logged. + holodori: { id: "holodori", name: "hololive Dreams", short: "holodori", hue: "#5FD3F3", studio: "QualiArts / COVER", dailyTasks: "Daily missions, stamina", resetOffsets: { asia: 9, america: 9, europe: 9 } }, }; export const GAME_LIST: GameMeta[] = Object.values(GAMES); diff --git a/src/shared/schema.ts b/src/shared/schema.ts index ab0cffd..02b8757 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -13,6 +13,7 @@ export const GameId = z.enum([ "r1999", // Reverse: 1999 "ba", // Blue Archive "fgo", // Fate/Grand Order + "holodori", // hololive Dreams ]); export type GameId = z.infer; diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index c87e799..94276e6 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -6,6 +6,7 @@ import { arknightsWikiParser } from "../../src/ingest/parsers/akwiki.ts"; import { blueArchiveWikiParser } from "../../src/ingest/parsers/bawiki.ts"; import { fandomParser, renderedHtml } from "../../src/ingest/parsers/fandom.ts"; import { inferType } from "../../src/ingest/parsers/game8.ts"; +import { holodoriWikiParser } from "../../src/ingest/parsers/holodori.ts"; import { GachaEvent, type EventType } from "../../src/shared/schema.ts"; /** @@ -40,6 +41,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [ { adapter: adapter("r1999-fandom-events"), fixture: "fixtures/r1999/fandom-events-2026-08-17" }, { adapter: adapter("ba-bawiki-events"), fixture: "fixtures/ba/bawiki-events-2026-08-17" }, { adapter: adapter("fgo-fandom-events"), fixture: "fixtures/fgo/fandom-events-2026-08-18" }, + { adapter: adapter("holodori-holodoriwiki-events"), fixture: "fixtures/holodori/holodoriwiki-events-2026-08-18" }, ]; async function runAdapter(adapter: Adapter, fixture: string) { @@ -705,3 +707,215 @@ describe("blue archive wiki", () => { ).toBe(false); }); }); + +describe("hololive Dreams wiki", () => { + const fixture = "fixtures/holodori/holodoriwiki-events-2026-08-18"; + const holo = adapter("holodori-holodoriwiki-events"); + + function parse(html: string) { + return holo.parse(html, { + now: NOW, + sourceUrl: holo.url, + sourceId: holo.id, + game: holo.game, + }); + } + + /** A stand-in page with the two-heading shape this parser navigates. */ + function paged(current: string, past: string): string { + return `
+

Current Events

${current}
+

Past Events

${past}
+ `; + } + + const HEADER = + "
LogoEventTypeStart DateEnd DateFeatured Members
${title}${type}${start}${end}
+ + +
LogoBannerEventTypeStart DateEnd Date
ShiftedSpotlight08/20/2026 12:00PM (JST)08/28/2026 12:00PM (JST)
`, + ); + expect(events.map((e) => e.title)).toEqual(["Shifted"]); + expect(events[0]?.startsAt).toBe("2026-08-20T03:00:00.000Z"); + }); + + test("a renamed heading or column fails the run instead of emptying the lane", () => { + const good = paged(HEADER, HEADER); + expect(holodoriWikiParser.canParse(good)).toBe(true); + + // The heading the parser navigates by. + expect(holodoriWikiParser.canParse(good.replace("Current Events", "Live Events"))).toBe(false); + // A column it reads. + expect(holodoriWikiParser.canParse(good.replace(/Start Date<\/th>/g, "Begins"))).toBe(false); + }); + + test("links to the events page while every article is still a red link", async () => { + // The wiki has no article for any of these events yet, so each title links + // to `?action=edit&redlink=1` — a create-page form, and a `?action=` URL + // this wiki's robots.txt disallows. Neither belongs in the feed. + for (const e of await runAdapter(holo, fixture)) { + expect(e.sourceUrl).toBe("https://holodori.wiki/wiki/Events"); + expect(e.sourceUrl).not.toMatch(/action=|redlink|Special:/); + } + + // A real article, once one exists, is linked. + const events = parse( + paged( + HEADER + + row( + 'Ultimate Summer', + "Spotlight", + "08/20/2026 12:00PM (JST)", + "08/28/2026 12:00PM (JST)", + ), + HEADER, + ), + ); + expect(events[0]?.sourceUrl).toBe("https://holodori.wiki/wiki/Ultimate_Summer"); + }); + + test("reports one global end rather than inventing per-region ones", async () => { + // One worldwide service on a Japanese clock: the page states each boundary + // once, in JST, with no per-region column anywhere. + for (const e of await runAdapter(holo, fixture)) { + expect(e.regionScoped).toBe(false); + expect(e.regionEnds).toBeNull(); + } + }); +}); diff --git a/test/dates.test.ts b/test/dates.test.ts index dc13005..6827170 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -8,6 +8,7 @@ import { parseMonthDayYear, parseOpenRange, parseOrdinalDateTimeRange, + parseSlashClockZone, parseSlashDateTimeRange, parseYearFirstSlashRange, } from "../src/ingest/dates.ts"; @@ -367,3 +368,51 @@ describe("parseIsoDay", () => { expect(parseIsoDay("")).toBeNull(); }); }); + +describe("parseSlashClockZone", () => { + test("converts a stated JST wall clock to UTC", () => { + expect(parseSlashClockZone("08/17/2026 8:00PM (JST)")).toEqual({ + iso: "2026-08-17T11:00:00.000Z", + precision: "exact", + }); + }); + + test("a small-hours JST boundary lands on the previous UTC day", () => { + // The shift that makes storing the source's own wall clock unusable. + expect(parseSlashClockZone("08/30/2026 3:59AM (JST)")?.iso).toBe( + "2026-08-29T18:59:00.000Z", + ); + }); + + test("12PM is noon and 12AM is midnight", () => { + expect(parseSlashClockZone("08/20/2026 12:00PM (JST)")?.iso).toBe( + "2026-08-20T03:00:00.000Z", + ); + expect(parseSlashClockZone("08/21/2026 12:00AM (JST)")?.iso).toBe( + "2026-08-20T15:00:00.000Z", + ); + }); + + test("an unstated or unknown zone is null, never UTC", () => { + expect(parseSlashClockZone("08/20/2026 12:00PM")).toBeNull(); + // Not in the table, and deliberately: `CST` names three different zones. + expect(parseSlashClockZone("08/20/2026 12:00PM (CST)")).toBeNull(); + }); + + test("rejects a non-date and an impossible calendar day", () => { + expect(parseSlashClockZone("Game Launch")).toBeNull(); + expect(parseSlashClockZone("Unknown")).toBeNull(); + // Validated on the stated local fields, before the offset shifts anything: + // converting first would quietly turn Feb 30 into a real instant in March. + expect(parseSlashClockZone("02/30/2026 12:00PM (JST)")).toBeNull(); + expect(parseSlashClockZone("08/20/2026 13:00PM (JST)")).toBeNull(); + }); + + test("is a whole-cell reader, not a scanner", () => { + // Anchored at both ends: letting this match mid-prose is how a reader + // starts finding dates inside sentences. + expect( + parseSlashClockZone("Starts 08/20/2026 12:00PM (JST) after maintenance"), + ).toBeNull(); + }); +});