Commit Graph
14 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5 4dff14087f feat: add Girls' Frontline 2, from IOP Wiki
The best date material this project has after wiki.gg: every row states an
exact instant on both boundaries and names the zone, so nothing is converted
and nothing is assumed. `parseIsoClockRangeUtc` requires that `(UTC)` rather
than defaulting to it — a row that ever loses it should drop out instead of
landing hours off a boundary the reader is watching in-game.

The hazard here is the Server column. CN, EN and JP rows share one table and
the Chinese schedule runs about a year ahead, which is the akwiki CN-column
problem verbatim: publish EN, skip the rest. Betas are fenced off separately,
being dated exactly like everything else and playable by nobody, and the page
is an archive back to 2023, so currency is decided against ctx.now as in
bawiki. That leaves one live event today — thin, and true.

No resetOffsets: the EN boundaries land on three different clocks, which is a
patch window rather than a reset hour.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 03:50:05 +02:00
Lucas WintherandClaude Opus 5 e42ac84b40 feat(ingest): read a summoning campaign as a banner
Every game names its gacha something else, and inferType only knew the
five words the game8 sources use. Fate/Grand Order calls its banners
"Summoning Campaign" — the whole of that page's banner vocabulary — so
without this every FGO pull lands as "other" and the type filter stops
meaning anything for that lane.

Nothing on the current English page carries the word, so no checked-in
expected output moves; the unit case is what pins the behaviour until
the next refresh brings a banner in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 21:30:09 +02:00
Lucas WintherandClaude Opus 5 4698ebc0c7 feat(fgo): read the English server, not the Japanese one
The source landed pointed at Event_List, whose first sentence is "This
page lists all Events in Fate/Grand Order Japan". Every date it
published was a JP date on a calendar whose other twelve games are
global — the akwiki CN column again, and the failure this product
exists to prevent. Event_List_(US) is the English schedule and is what
the adapter reads now; a test asserts the URL so it cannot drift back.

The page is a different template from the Reverse: 1999 one, so the
parser branches, the way game8 branches over seven shapes. Its sections
are fenced by banner images with the label drawn in a positioned div —
no heading, no id — and only ONGOING EVENTS is read. FUTURE EVENTS
states a month with no day, and PAST EVENTS states no year at all, so
neither can be dated and neither is guessed at. canParse asserts both
dividers and the Duration label, which turns a redesign into a stalled
source rather than a lane that quietly empties.

The rewrite drops what the JP branch needed and this page does not: the
hand-rolled month/year rollover (parseFullRange already reads both
stated years), the any-typed locals, and a heading-to-duration regex
whose nested lazy quantifiers hung for minutes on a 620KB body the
moment one anchor stopped matching. Durations name a zone but no clock,
so the stated calendar day is kept rather than shifted into UTC —
there is nothing to anchor a conversion to, and the start's day is half
the event ID.

Also reverts two things done to quiet tests rather than answer them.
The parser no longer normalises NFKC: that was pre-empting the
sanitizer so "real fixtures need no repair at all" would pass, and the
seam is where repairs belong. And renderedHtml no longer reaches into
a legacy {"*": …} envelope, which every source here rules out by
pinning formatversion=2.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 21:29:59 +02:00
Lucas WintherandClaude Opus 5 7ab801f409 feat(holodori): track hololive Dreams from its own wiki
New holodoriwiki parser plus the holodori-holodoriwiki-events source,
giving the game its two live and upcoming events at exact precision.

holodori.wiki is Miraheze, so this is the same call bawiki makes and for
the same reason: robots.txt disallows /w/ and /*?action=, which closes the
API route and leaves the rendered /wiki/Events page as the surface * is
allowed. It serves our own User-Agent a 200, sets no Content-Signal and no
Crawl-delay for us, and the wiki is CC BY-SA 4.0.

What makes this source unusually good is that it states its timezone on
every cell — 08/17/2026 8:00PM (JST) — so it is the only wiki source here
publishing exact precision on both boundaries without a per-region timer.
parseSlashClockZone requires that zone rather than defaulting to UTC: a row
that ever loses it drops out instead of silently landing nine hours off,
and an abbreviation absent from the table is refused rather than guessed,
which is why the table holds only JST and not the plausible-looking
candidates that are not fixed offsets. The 12-hour clock gets its own
handling and its own tests, because 12PM and 12AM are the two readings a
naive parser gets wrong while still producing a valid-looking instant.

Inclusion is fenced by the Current Events <h2> and bounded by the next one.
That matters more than it looks: Past Events sits directly below with
identical columns, so a reader taking every wikitable would put the back
catalogue on the calendar with nothing to mark it. Rows are still checked
against ctx.now on top of the heading, because Current is maintained by
hand and goes stale before anyone moves a row down.

Two rows are deliberately missing. Beginner Mission runs Game Launch →
Unknown, and no start means no event ID — a permanent tutorial chore is not
what a calendar of deadlines is for. An Unknown end is kept as endsAt null,
and unlike bawiki this parser does not drop a started-but-undated row: the
heading has already said the event is running, which is the fact bawiki
lacks when reading an archive.

Every event title is still a red link — the wiki has no article for any of
them yet — so each points at ?action=edit&redlink=1, a create-page form on
a path this robots.txt disallows. Refusing a href with a query keeps those
out and falls back to the events page; the articles get linked when they
exist, with no change here.

holodori gets resetOffsets of UTC+9 across all three regions, evidenced
rather than assumed the way Arknights' is: the game launched worldwide
simultaneously on one service, every boundary is stated in JST, 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.
Setting it now is free, because no reader has a day key for a game the app
has never shipped — the same override a year from now would re-label ticks
already logged.

Both boundaries and the exclusions are asserted against an independent
extraction off the fixture, computed through a timezone library rather than
by the parser's own arithmetic.

Several counts in AGENTS.md and PRD.md were already stale before this — the
Fate/Grand Order source did not update them — and are corrected to what the
tree now holds rather than to what it held yesterday.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 03:39:49 +02:00
Lucas Winther 5cc46a0c9a feat: add fgo to games. 2026-08-18 02:43:56 +02:00
Lucas WintherandClaude Opus 5 d843c7cae7 feat(ba): track Blue Archive from its own wiki
New bawiki parser plus the ba-bawiki-events source, giving the game its
two live and upcoming events at day precision.

It reads the rendered /wiki/Events page, which is the opposite call to the
Fandom source next door and for the same kind of reason: bluearchive.wiki
is Miraheze, whose robots.txt disallows /w/ and /*?action=, so there the
API is the closed route and the page is the surface * is allowed. It
serves our own User-Agent a 200, sets no Content-Signal and no Crawl-delay
for us. The Fandom wiki was declined earlier as a JP archive yielding
nothing live; this is the live source that assessment pointed at.

Three page facts shape the parser, each a way to publish a confidently
wrong date. The schedule is a JP/Global tabber and the Japanese version
runs four to nine months ahead, so only Global is published — the akwiki
hazard. The Global tab's nav button carries the id
tabber-Global_version-label and precedes both panels, so slicing from the
first matching id reads the Japanese schedule while believing it read
ours; the first version of this parser did exactly that and published a
JP-only event. And three tabs on the page are named Global, the schedule
plus Mini-Event and Joint Firing Drill, so the schedule is found by its
Name (EN) header rather than by position, with canParse asserting the same
lookup — a renamed tab or column fails the run instead of emptying the
lane.

The page states no time of day and no timezone anywhere, which is why the
dates are day precision and why ba gets no resetOffsets. It also settles
the five other schedule tables, which do carry a wall clock but name no
zone and mostly do not say which server: reading those as UTC would invent
the fact that matters most, and rounding to a day would not save it,
because a 04:00 local boundary falls either side of UTC midnight depending
on the offset assumed and the start's day is part of the event ID. They
are left unparsed deliberately.

The two-event count is asserted against an independent extraction off the
fixture, and both starts corroborate against the per-event infoboxes
elsewhere on the page — GL 2026-08-04 and GL 2026-09-01, where the JP
reruns those tabs would have given are 2026-04-01 and 2026-05-06.

Three comment counts were already stale before this and are corrected to
what the tree now holds rather than to what it held yesterday.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 23:24:11 +02:00
Lucas WintherandClaude Opus 5 3377fcb211 feat(ingest): add the Reverse: 1999 event source via Fandom's API
New `fandom` parser plus the r1999-fandom-events source, giving the game
six live and upcoming events at exact precision.

It fetches api.php?action=parse, not /wiki/Events. The rendered page
answers a non-browser client with a Cloudflare challenge, and getting past
that would be defeating an access control — the reason uma.moe was
declined. The wiki's robots.txt instead allows /api.php?action= for *, and
that endpoint serves our real User-Agent a 200, so this reads the
sanctioned surface with our own headers and no impersonation. The body is
therefore JSON, which is what canParse checks first: a challenge page or
an error payload must fail loudly, not parse to zero events.

Two page facts shape the parser. Titles come from each row's <b>, because
a missing banner image renders as a red link reading "File:<Event>
Banner.png" that a cell-text reader would publish as the event name. And
the page is an archive of 154 rows since v1.1 with no ongoing section to
gate on, so inclusion is decided against ctx.now — the six-event count is
asserted against an independent extraction off the fixture, per
docs/INGESTION.md § Testing.

Because robots.txt is unreadable from a challenged address, the gate fails
closed in CI and the source is skipped there — a warning, not a broken
build. Refreshing it means running `bun run refresh` from an address
Fandom serves.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 22:46:47 +02:00
Lucas WintherandClaude Opus 5 1dd58d3f52 feat(arknights): track Arknights from its own wiki
The most-named game in the release thread, and the cheapest one to add: the
GameId, hue and dailyTasks have been sitting in games.ts since launch with no
source behind them.

arknights.wiki.gg is a better source than Game8 for this game, but it is not the
mp-event shape wikigg.ts reads, so it gets its own parser. Two properties of the
page drive the code:

- Every row lists CN and Global, about five months apart. Only Global is
  published; a row without one yields no event rather than borrowing the CN
  date. A CN date on a Global calendar is a confidently wrong date.
- Only the next boundary carries a machine-readable timer — the end while an
  event runs, the start while it is upcoming — so precision differs per side and
  flips when the event goes live. An exact instant is accepted only when it
  falls on the same UTC day as the date beside it, because startsAt.slice(0, 10)
  is part of the event ID: a start that moved a day would orphan every
  completion mark on the morning the event began.

Also sets resetOffsets for all three regions to UTC-7. Not a blanket per-game
offset — Arknights genuinely runs one Global server for every region we model,
and the page evidences the clock: every ending event ends at 10:59:59Z, which is
03:59:59 at UTC-7, one second before a 04:00 reset.

Six events, counted independently against the page before and after parsing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:05:01 +02:00
Lucas WintherandClaude Opus 5 d8b539bce1 feat(p5x): track Persona 5: The Phantom X
Adds the game and its Game8 events page, the second of the games named in
the release thread. `parseAdjacentFullRange` reads the shape this page
uses: two full dates divided by an `<hr>` rather than a dash, so a
tag-stripping reader sees only whitespace between them. It is anchored at
both ends and needs a year on each half, because without that "August 12,
2026 Day 3 rewards" reads "Day 3" as an end.

Three of the four live rows publish. "Take Your Heart" ends 30 days after
each player makes an account, so it has no calendar date and no honest
place on a calendar. "Login Campaigns" names two candidate ends
("July 16/30, 2026") — it keeps its real start and takes no end, and the
leftover is not shown as a summary either, since a date the parser
refused to trust must not reappear dressed as information.

The page lists each live event twice: once in a bare Event|Duration table
and again under its own heading with Start Date / End Date rows and a
paragraph of prose. The second copy corroborates the dates, so deduping
now fills a missing summary from the copy it drops. Dates are still taken
wholesale from the better-dated copy and never blended.

Verified by re-extracting the page independently of the parser: 4 rows,
3 events, the fourth correctly skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 17:21:10 +02:00
Lucas WintherandClaude Opus 5 67951e81b4 feat(nikki): track Infinity Nikki
Adds the game and its Game8 events page. One `SOURCES` entry against the
existing game8 parser, plus one date shape that page needs:
`parseLabelledStartEnd` reads a duration cell holding `Start: <date>` and
`End: <date>` split by a `<br>`, which a tag-stripping reader flattens
into one run of text.

Four of the seven events say `End: Permanent`. That is the source telling
us there is no deadline, so they publish with `endsAt: null` and
`endPrecision: "unknown"` rather than a plausible date. The labelled cell
is also structure end to end, so it never becomes a summary — stripping
the leading half would leave "End: Permanent" standing where a
description belongs.

Verified by re-extracting the page with a throwaway script independent of
the parser: 7 rows in, 7 events out, every date matching.

Note the source itself is stale — its "Current Events" table still lists
January-May 2025 and "Upcoming Events" says there are none. The adapter is
right; the page looks abandoned, and Nikki coverage worth relying on needs
a second source at a higher priority.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 17:20:54 +02:00
Lucas WintherandClaude Opus 5 dd596b5a43 feat: add wiki.gg as a second Endfield source
First non-Game8 parser, and the first source that states region-scoped ends:
wiki.gg emits ISO timestamps with one timer per server region, so Endfield
events now carry exact times and separate Asia / Americas-Europe ends. That
gap is up to 13 hours, which is what regionEnds was built for.

Two sources for one game also exercised merge for real. It caught both
overlaps — including "Bedazzling Dawnstar" against Game8's "Bedazzling
Dawnstar Sign-In" — and flagged that the two disagree on the end date by 70
hours rather than averaging them into a date neither states.

Matching an appended qualifier needed a prefix test, not a subset test: a
subset would also fuse "Gold Clash" with "Gold Rush Clash Royale".

Also fixes build-feed picking fixtures by game rather than by source, which
handed the wiki.gg page to the Game8 parser once Endfield had two.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:17:17 +02:00
Lucas WintherandClaude Opus 5 ad45b52c84 feat: add Arknights: Endfield, and stop passing off requirements as summaries
Endfield was previously written off as undatable. That was wrong: a first
pass only inspected its Duration rows (all "Permanently Available") and its
year-less version grid, and missed an "Event | Schedule & Summary" table
whose cells read "Period: 08/09/26 - 08/30/26 During the event...". Two real
events, with a year.

Adds MM/DD/YY range parsing for that shape. Month-first ordering is not
assumed lightly — Endfield's own version grid reads 01/22, 04/17, 07/16 for
versions 1.0, 1.2 and 1.4, chronological only if the month comes first.

Also stops presenting unlock conditions as descriptions. Several templates
put "Reach Union Level 8" where a blurb would go; filling the summary slot
with text that never says what the event is is worse than leaving it empty.
Wuthering Waves correctly drops to zero summaries as a result.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 01:07:39 +02:00
Lucas WintherandClaude Opus 5 817ad41dcd feat: add Star Rail, Wuthering Waves and Zenless Zone Zero
Three more Game8 templates. ZZZ spans each event name across two rows with a
rowspan, so a flat cell reader sees [title, "Start", date] then ["End",
date]; losing that pairing would silently halve the calendar.

Section matching also had to widen — these pages head their sections
"Featured Events", "List of Events and Schedule" and "Ongoing Events".

Verified independently of the parser: HSR's year-less summary table lists
six ongoing events and all six are captured via their detail rows, so
nothing is silently dropped.

28 events added across the three games.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:47:50 +02:00
Lucas WintherandClaude Opus 5 489ce92ef5 feat: add Game8 parser with Genshin and NTE adapters
Parsers are keyed by site template, not game: one Game8 parser serves
every Game8 page, and adapters bind a URL and game to a parser. Adding a
source for a known site is one SOURCES entry.

Game8 uses at least three templates. This handles label/value detail
tables (Genshin, 9 events) and column tables (NTE, 13 events). Its
image-grid schedule has no year and no end date, so Arknights: Endfield
is not supportable from that source and gets no adapter.

canParse is a structural tripwire: a site redesign fails the run loudly
instead of publishing an empty calendar that reads as "no events".

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:10:53 +02:00