Add karendar.com as the event source for Punishing: Gray Raven (PGR).
Karendar publishes a dedicated Global event calendar with exact UTC
timestamps in clean server-rendered HTML.
- Register 'pgr' in GameId and GAMES registry (#CC292B, Kuro Games).
- Add parseWeekdayDayMonthYearUtc in src/ingest/dates.ts with unit tests.
- Implement karendarParser in src/ingest/parsers/karendar.ts reading
'week', 'ongoing', and 'upcoming' sections, mapping indefinite/permanent
ends to endsAt: null, and categorizing tags to EventType.
- Register pgr-karendar-events in src/ingest/adapters/index.ts.
- Add pinned fixture, expected output, and comprehensive test suites.
- Update documentation in AGENTS.md, README.md, docs/INGESTION.md,
and docs/SOURCES.md.
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.
Patch cycles are ~6 weeks, so a span over 180 days previously indicated
a misread year. However, real year-long events exist in this domain:
Genshin's anniversary 5-star selection runs 336 days (2025-10-22 to
2026-09-23), correctly dated on its wiki, and Fire Emblem Heroes runs a
seven-month new-player banner (docs/SOURCES.md § 12b).
Widening the ceiling to 365 days admits genuinely year-long events while
still catching misread years, which place the end 12 months out (span +
~365, or 405+ days for a 6-week event).
Update test/adapters/game8.test.ts duration test, AGENTS.md domain rules,
and docs/INGESTION.md sanity checks.
The build log prints a count per source, and CI now declines to fail on
a page that states its own emptiness — which leaves that source showing
an unexplained 0, reading as exactly the fault the gate just decided it
was not. The zero we are content with is the one that has to say why.
It is also the only line that would ever prompt anyone to ask whether a
lane quiet for a month is a game between patches or a wiki that reworded
the sentence a statesNoEvents check is still matching. Nothing else in
the pipeline can tell those apart.
The test count in the layout block was stale at 898 and moves with these
two commits, so it is corrected here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Infinity Nikki's wiki prints "There are no Events in this category"
between versions, and the refresh runner has honoured that since
2026-09-03 — it stores the empty parse as the source's real answer
rather than letting a quiet lane reach the broken tier. The feed build
never asked, so the same page arrived at CI as parsedCount 0,
indistinguishable from a parser that has stopped reading a redesigned
page, and brokenSources failed every build while every refresh stayed
green.
The runner's verdict cannot travel on its own: only a parser has seen
the page, and by the time brokenSources runs there is nothing left but
the feed. So the fact rides on SourceHealth, defaulted so an older feed
the service worker cached keeps validating and reads as the strict
answer.
Both ends now ask it the same way — of an empty parse only, from the
page's own words only — because a redesign yields zero rows too, and
excusing that is the silently emptied calendar the gate exists for.
The rule sits in a module rather than in build-feed.ts, which writes
public/ and so runs a build if a test imports it. That is how the two
ends drifted apart with nothing to catch it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The two preceding changes make sentences in docs/ false. INGESTION.md
described `canParse` as a structural check without saying that "structural"
excludes the presence of a row — which is exactly the drift that let this
bug in — and described the Stage 1 gate as rejecting every empty parse.
SOURCES.md § 11 left a reader to conclude that an empty Infinity Nikki lane
means the source has died.
AGENTS.md gets the rule in both places somebody would look: § Working on
parsers, next to the bullet about a source resting on one row, and § Fandom
where the fourth template is described.
The general lesson is worth more than the incident. Any page that can list
nothing can produce this, so the guidance is about what identifies a page
versus what happens to be on it — not about this wiki.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The footer's staleness notice is an instruction, and the only remedy it
offers is "go and check that game's source page". That is not something a
reader can act on for a game they turned off and cannot see a single row
of — and with nineteen lanes, naming eleven they do not play buries the one
they do. A new lane arrives switched off (F8), so an untouched install was
being warned about most of the catalogue.
Scoped in two places, not one: the named list, and the count the
summarising branch measures against. Without the second, that branch would
never fire for a reader with most of the calendar off and they would get a
list where a sentence was the readable answer.
The notice now also states whose games it counted, in the words NextUp
already uses for the same set. Narrowing what the footer measured while
still printing "nothing has refreshed" would turn a claim about four games
into one about eighteen, in the one paragraph on the page whose job is
being trusted about age.
The headline age and the credits stay whole: the first is a fact about the
feed and feeds the bug form, the second is attribution owed to every source
we read regardless of what is on screen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
SOURCES.md § 13 declined this game's only known source partly because every
boundary was a week bucket, and we have now built a different source with that
same objection standing. Leaving that contradiction unwritten is how a later
pass rediscovers the trade the expensive way, so § 14 records the decision
itself: the conduct evidence, which three of § 13's four objections arustats
answers, which one it does not, and that any source stating a date per event
retires it. § 13 is marked superseded rather than rewritten — its verdict on
marisaimpact.com still stands and was not re-tested.
AGENTS.md gets the same warning where a parser author will hit it, because the
estimate is not visible from the code: the numbers look like every other
day-precision date in the project.
Also corrects the counts this game made false — nine parsers, twenty sources,
nineteen games. The "stopped working at eighteen" passages are left alone: they
date the settings-panel redesign, and bumping them would falsify the history
rather than update a count.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two things the last two commits taught, written down where the next person
looking at a parser will hit them.
The date table now carries the slash-dated open range, and the prose beside it
says the year is required and why — that reader is tried last, so a guess there
reaches the feed unchallenged. It also names the second copy of the open-end
vocabulary, because the two lists being out of step is not a hypothetical
failure any more.
The second is the one worth having on record. `eventCount` in a snapshot's meta
is not a count of the page. Endfield's Game8 lane read 1 for a week — one row of
an *upcoming* table, while four live events sat in a card grid the parser cannot
read — so the first time the page moved, the lane read 0 and the gate reported a
shape change. Neither number described the page. A count far below what the page
shows is a shape being skipped, and it is worth finding before it reaches zero
and the gate has to guess on your behalf.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both files stated the h4 rule as a flat prohibition — an unrecognised h4
must never claim the event title — which is no longer what the code does.
They now state the narrower claim: an h4 may fill an empty event title but
never take one, with the reason each page needs it. Genshin's labels arrive
after its h3 has claimed the slot; Wuthering Waves' block sits under a
section heading that leaves the slot empty.
INGESTION.md gains the block as shape 9, and the part that is easy to get
wrong on the way back: its heading has to be *in* the section vocabulary
rather than merely fall through, because an unrecognised heading is read as
an event name and would spend the title on the section.
AGENTS.md also gains a bullet for where a missing blurb hides. Two
templates now put unlock conditions in the cell where a description
belongs, and in both the real prose is in a per-event section further down
the page — under an h3 on one and an h4 on the other.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
They were one group called "Reading", grouped on "both are how do I read
this?" — which summarised as "Europe · Dark", two unrelated answers joined
by a dot, under a name for neither of them, in a panel whose whole premise
is that a closed line answers its own group.
The region is not a reading preference. Region-scoped ends, a date printed
without a time, and every daily streak are all cut on that server's clock,
so it is the one control in this panel that can make a countdown wrong —
and it was filed behind a word for the theme. Split, each line answers one
question, and the region group says what it governs when opened.
The eyebrow above each pill row goes with it, since the group summary now
asks the question the eyebrow was repeating; aria-label keeps the
accessible name, which is what stops six unlabelled buttons in a row from
being all a screen reader hears. The summary assertions are looked up by
group name rather than by index, so splitting a group no longer moves every
assertion below it onto a neighbour.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Looked at on a 1440px screen it was the one block on the page not using
the column it sits in. The rows were held to max-w-3xl inside an 1150px
shell, but the section's own rule spans the shell — so every hairline
inside the panel stopped a third of the column short of the one above it,
between a two-column checklist and a three-column footer that both go
full width. The measure was there to keep a name and its state reading as
one line, but that pairing is what "running now" already does with a
title and its countdown directly above, so full width is the house
pattern rather than a stretch. Prose inside a group keeps its measure.
The game list was the worse half: eighteen rows of four controls at
max-w-md is a thousand pixels of ribbon down the left with two thirds of
the screen empty beside it. It takes two columns past lg once there are
nine or more games, flowing down then across so the numbers still read in
a straight line and an arrow still swaps a row with its neighbour. Below
nine, and on a phone, it is unchanged.
The last group also loses its bottom rule, which was a second hairline a
few pixels above the footer's own.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`build:js` was shipping React's development build. That cost 222 KB of the
566 KB bundle (61 KB of 164 KB gzipped), ran every element creation through
dev-only validation, and left `StrictMode` double-invoking effects — so
`fetchFeed` fired twice and every reader downloaded the feed twice on every
single load.
On a 4G/4×-CPU profile with the cache cold: first contentful paint 1480ms →
972ms, the feed on screen 1766ms → 1261ms, and two feed requests → one.
The flag is `--production` and not `--define process.env.NODE_ENV='"production"'`,
which is the obvious-looking version and produces a bundle that does not run at
all: the define flips React to its production build while the JSX transform
keeps emitting `jsxDEV`, so the page dies on `z is not a function` with a blank
body. Both variants build clean, typecheck clean and pass all 843 tests, because
nothing in the suite executes the bundle — so AGENTS.md now says to open a
browser before believing this one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The panel was one open block of everything in two columns. That reads fine at
four games and stops working at eighteen: the game list alone is eighteen rows
of four controls each, and it sat above the checkbox somebody had scrolled down
here to tick. Nothing was findable and everything was in the way.
Collapsing it into groups is only half an answer, though — a group showing
nothing but its name turns "what is my region set to?" into a click, which
trades one kind of friction for another. So each summary carries its group's
current state: "Europe · Dark", "17 of 18 on · A–Z", "plus finished, not
started". Closed, the panel is a five-line report of how the app is configured;
opening one is for changing an answer rather than reading it. The states are
derived from `prefs` at render, never stored, so they cannot drift from the
controls they describe.
Native `<details>`, for the reason the reorder arrows are ordinary buttons:
keyboard and screen reader reach it with no second implementation. `summary` is
not an `a`, `button` or `[tabindex]`, so it needed its own focus-visible rule —
the shared one does not reach it.
Two things fell out along the way. The region and appearance rows had no
accessible name at all, so a screen reader read six unlabelled buttons in a row;
they and the board's split pills are one `PillGroup` now, with the `role="group"`
the board's own controls already carry. And the empty states that point at
"Show events that haven't started" now name the group holding it, which is what
makes shipping the groups closed safe.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This project has no linter and does not want one, but `strict` says nothing
about a binding that is simply never read — and that is the one kind of dead
code a reader cannot tell apart from a deliberate seam. `tsc` already walks
every file, so it may as well answer the question.
Three had accumulated: an unread `type View` import in App, and a
`useGameMeta()` resolver in Controls and YourOwn, both of which draw their
colours from somewhere else — the game-order list has its own resolver, and
YourOwn reads the reader's own records directly. None of them were wrong, they
were just leftovers that read as intent.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The rules a future change could undo without noticing, written where whoever
touches that area next will read them.
Three of them are traps rather than descriptions. An absent `gameOrder` means
the reader has never placed a game, not an empty order — reading it the other
way hands every existing install a blank list, which is the `knownGames`
mistake again. `games` is left in feed order deliberately, because the code
that hides a reader's games diffs it. And the catch-up window bounds display
and never storage: a fortnight's streak exists on one device and nowhere else,
so nothing prunes the log against a window.
The spec that produced the work goes in alongside, including the helper it
specified and the implementation dropped, so the next reader sees why rather
than wondering.
Also corrects the test count in AGENTS.md, which said 772 and was already
stale before this.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Switching refresh.yml back to ubuntu-latest left this file asserting that it
runs on a self-hosted runner "because the address is the legitimate lever". The
lever is still the address; that particular address just was not a better one.
Says so explicitly, because the revert is easy to misread as the idea being
wrong rather than one address not being the one — and the next person here will
otherwise try the same runner again.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The runner move was recorded here as untested for game8 because that cycle
interval-skipped all nine. The next one asked them: 202 CloudFront, every one.
So safe-ip is challenged by Cloudflare and refused by CloudFront alike and
bought nothing by itself — worth stating plainly, since "try a different runner"
is the first idea anybody has here and it has now been tried.
The Fandom half is fixed, by the cron passing --assume-robots-on-403 rather
than by the address: the next cycle put all four through at 200, two with fresh
bytes.
Also records why game8 cannot have the same treatment. Its 202 is the page
itself being withheld, not a permission we hold and cannot re-read, so there is
nothing for a recorded decision to stand on.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Four Fandom sources have skipped on a challenged robots.txt every cycle, so
their calendars are only ever as fresh as the last manual run. For a product
whose promise is a trustworthy end date, four stale lanes are the worse failure
— so the scheduled run now passes --assume-robots-on-403 and the owner re-reads
those files by hand over time.
Kept asymmetric on purpose: --force is still refused on an unattended run.
Forcing every cycle really is just a shorter interval with extra steps, and
nothing about this decision touches that.
The accepted risk is narrower than "crawling against robots.txt", and worth
stating precisely. A plain 403 still fails closed, so a host that turns us away
still stops the run — that is what the challenge-or-refusal split buys. What is
invisible is a robots.txt edited to disallow us, because from a challenged
address a withdrawal looks exactly like the challenge we already expect. No
code can catch that; only the re-read can.
Which makes the per-cycle warning the compensating control rather than a
courtesy, so it is now pinned as one: every host named, and surviving as a
run-page annotation on a completely green cycle where nothing else draws the
eye. A dispatch can set the input to false to see the real state.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both overrides stand in for a person deciding something, and the gate that
enforced it asked the coarser question. `isCi()` got the case that matters
wrong: a `workflow_dispatch` sets CI=true, so somebody clicking "Run workflow"
was refused exactly as the cron was, which made both flags unreachable from the
workflow at all. A dispatch is a person, and a better-evidenced one than a local
shell — GitHub records which one, and the run log keeps it.
`runAttendance` draws the line where it belongs: a local shell is a person, a
dispatch is a person and names the actor, and a schedule — or any other runner
event, erring that way on purpose — is not. The run now prints which override
was used and who authorised it, because a local shell leaves no trace anybody
else can read.
The schedule stays refused, and for the robots override that is not ceremony.
From an address that gets a challenge we never receive robots.txt at all, so a
cron standing on the recorded permission has no way to notice the host
withdrawing it, and that permission has no expiry. The challenge-or-refusal
narrowing does not close this: a plain 403 still stops us, but a robots.txt
edited to disallow us would be invisible, because a challenge arrives instead of
a file. A person re-reading it is the only thing that ever re-validates it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The runner move to [self-hosted, safe-ip] was recorded here as untested, with
the next cycle to settle it. It ran, and all four Fandom sources came back
403 (an interstitial challenge) — so that address is challenged too, and these
four still only move when a person refreshes them on the recorded permission.
Worth being exact about what the cycle did not settle, because the summary
looks fuller than it is: the nine game8 sources were all skipped_interval, not
due until 23:46 UTC, so nothing asked game8 anything and its 202 is unmeasured
on the new runner.
The reason string is doing the job it was added for one commit ago — the
challenge-or-refusal distinction is what says this is covered by the flag
rather than a host that turned us away, and it answered that from the summary
alone.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This file recorded a 403 as Fandom's posture, holding across every wiki and
every address, which made those four sources permanently unschedulable. It was
measured with curl, and curl is not what fetches: on one address, in the same
minute, curl takes a 403 with a bare, a Chrome and our own User-Agent alike,
while Bun's fetch is served the real robots.txt on all five hosts and the gate
passes with no override.
Two variables were folded into one there, and they are worth holding apart. The
client mattered — but so does the address, and the client does not rescue it:
the 17:46 UTC run on ubuntu-latest still skipped all four Fandom sources using
that same Bun client. So a claim that these refresh on a schedule is a claim
about the address the runner has, and on the new one it is untested rather than
established. The next cycle settles it.
Three traps recorded so the episode is not repeated: verify a fetch gate with
the real RobotsCache from the address that will run it, never a shell client;
the User-Agent is not a lever in either direction, so a 200 that appears when
you drop it is the challenge's probabilistic half and not a header you tuned;
and neither block is a rate limit, since every failure is first contact with
that host, the spacing is already applied, and a challenge carries no
Retry-After. More delay buys nothing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`--assume-robots-on-403` stands in for rules a person read in a browser, for a
host that will not serve us the file. It always claimed it would not override a
host that had turned us away — but it could not tell the two 403s apart, so it
excused both. A managed challenge ("we cannot tell what you are") is a question
our fetcher cannot answer and the operators never asked, which is what makes a
human reading the rules a fair substitute. A bare 403 is the site itself saying
no, and nothing recorded on our side may talk over that.
`isInterstitialChallenge` now decides, on Cloudflare's own `cf-mitigated` header
with the challenge page's markers as a fallback; an unreadable body counts as
unclassifiable rather than challenged. This only ever narrows what the flag
opens, so nothing that passed the gate before stops passing it.
Every 403 is classified whether or not the flag is set, and the kind goes into
the reason string the run reports. `robots.txt returned 403` read identically
whether the answer was to refresh by hand on the recorded permission or to stop
fetching the source, and the summary is where somebody has to decide that.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The link sits last in the existing row beside @StereotypicalCat and Source
code, in that row's own muted grey, with the same hover and the same small
mark. No accent colour, no button, no sentence asking for anything, and the
footer still contains no appeal anywhere — a test asserts that. This page earns
its keep by being trusted about dates, and a tip jar competing with the
disclaimer two paragraphs above spends that trust to make an ask. As one more
link it costs nothing and is there for a reader already looking.
The row also moves above "Additional ideas and design from". That row is who
built this and where to find them; the credit below it is other people. Reading
order now follows that split, instead of routing a reader past five strangers'
handles to reach the author's own links.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The interval gate has no override, so a page you know has just changed cannot
be fetched until six hours after the last attempt. The only workaround was
deleting snapshots/<id>.state.json, which also wipes consecutiveFailures and
lastConfirmedAt — resetting the broken-source streak and making the footer age
every source from when its bytes last changed rather than when we last
confirmed them. A flag that says what it means is better than a side effect
nobody documented.
It sets aside the interval and nothing else. Conditional headers still go out,
which is what makes forcing defensible at all: the host is asked, not
re-served, and an unchanged page costs it a 304. Per-host spacing, robots, one
request per source and the no-retry rule all still apply — a source that was
not due and is also disallowed stays skipped, for the reason that matters.
Refused under CI, like --assume-robots-on-403 and for the same reason: a
schedule that forces every cycle is a shorter interval with extra steps, and
the interval is the obligation, not the default. So AGENTS.md § Scraping
conduct is amended rather than left to be quietly contradicted by a flag.
Every source asked early is named in summary.forced and warned about. A run
that was due anyway is never reported as forced — a summary that cried "forced"
on an ordinary run would train the reader to ignore the word.
Also repoints the "unknown flag" test, which used --force as its example and
stopped testing anything the moment --force existed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The previous commit landed the first live pages for nikke and nikki, which
retires the last version of a sentence that has been wrong twice in one session
— it counted which of the four had ever been fetched, and that number kept
moving underneath it.
So it no longer counts. What matters about these four is not how many have a
snapshot but that every one was taken by hand and always will be: their
freshness is exactly as old as the last person who ran the refresh themselves,
because the schedule cannot read Fandom's robots.txt. Points at
--assume-robots-on-403, which is what makes that run possible.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The "only ever commit your own work" rule already said not to `git add -A`, and
it was written as though the tree only ever holds work someone left there
earlier. It also holds work someone is doing *right now*: sessions run
concurrently here and the index is shared, so a status that was clean when a task
started can carry four staged files by the time it commits.
Today that cost a real commit — a source assessment finished in one session was
swept into another's, and published under a one-line message about snapshot
freshness. So the bullet now names the concurrent case, asks for a
`git status --porcelain` immediately before committing rather than at the start,
and gives the form that is safe when someone else's work is already staged:
`git commit -- <paths>`, which never reads the rest of the index.
The second half is the recovery, and it is deliberately not automatic. Splitting
a mixed commit is easy; doing it to a commit another session authored while that
session may still be running is not, so it is a thing to ask about rather than
fix quietly.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Its conduct is the cleanest of anything looked at here — a robots.txt of pure
comments, no directive, no Content-Signal, and a plain 200 to our own
User-Agent — so the reason not to build it is entirely in the data, and is worth
writing down rather than re-deriving next time the URL is offered.
The calendar prints no year anywhere, and heads its only date row "Estimated
date for Regional Servers" over a page that says the schedule is the CN
server's. Every date it could publish would therefore be a guess twice over: a
year we inferred, on a boundary the page itself declines to stand behind. The
week-column grid also rounds an end to a bucket rather than to a printed day,
and the URL carries the version number, so a SOURCES entry pinned to calendar89
would be publishing history from tomorrow.
The markup is recorded anyway, because it is readable and that is not the
problem — if the site ever states a year and a real regional schedule, the work
is a positional parser and nothing else.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A hand-run refresh landed one at 02:36 on 2026-08-19, four events. The sentence
two commits ago said it had never had one, which was true when written and is
the kind of claim that decides whether someone bothers trying — so it is worth
being exact about: r1999 and fgo have snapshots, both taken by hand today;
nikke and nikki are still served from their fixtures.
The conclusion is unchanged. None of the four will ever be refreshed on a
schedule, because CI cannot read Fandom's robots.txt.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fandom answers our fetcher 403 on /robots.txt itself, from every address we
have, while api.php?action=parse answers the same User-Agent with a 200. The
gate fails closed on the unreadable file, so r1999, fgo, nikke and nikki can
never refresh — even though their rules are not unknown: a person read them in
a browser and wrote them into AGENTS.md, verbatim, which is how three of those
four were cleared in the first place.
This flag is that recorded permission, and nothing wider. What it deliberately
does not do is most of the design:
- 403 only. A 401, a 5xx, a timeout or a soft 404 still mean we do not know
what the site permits, and unknown is still not permission.
- It never overrides a robots.txt we could read. A file that answers and
disallows us is an answer, and it still wins. So this is no use for game8.co
— whose robots.txt reads fine and welcomes us while its edge refuses the
pages — and it must not become one.
- Refused under CI. It stands in for a human having read a file this morning,
and there is no human on a runner. A scheduled job asserting a permission
nobody re-checked is exactly how "temporary" stops being temporary.
- Loud. Every host it applied to is warned about by name, in the run log and
in the summary, with a line saying to go re-read the file. An override that
reports nothing is one nobody withdraws.
Nothing else about being a guest relaxes: one request per source, six hours
apart, spaced per host, conditional headers, no retries.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nothing in the client prunes reader data today — no store filters against the
feed, knownGames only appends, metaFor renders a lane whose game is gone — so a
retired source, page or game leaves completion marks, streaks and hand-entered
events untouched. That was true by construction and pinned nowhere.
The load path is what makes it worth pinning. useCustom reads through
validRecords, which drops a record that fails its schema, and the survivors are
what the next write persists. A record that stops parsing is therefore not
hidden until someone notices — it is deleted from the device, permanently, by
the act of opening the app, with no server-side copy to recover it from.
Which makes CustomEvent.game being z.string() rather than GameId the whole
safety property, and it currently reads like validation someone forgot. Anyone
narrowing it to the enum would be tightening a schema and arming every future
game removal to erase reader data on next launch. The tests state the premise,
the survival, and that one unreadable neighbour still does not take the rest
down; the field says why it is loose.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nikke and Infinity Nikki were added on 2026-08-19 and the paragraph describing
what the Fandom robots gate costs was not extended to cover them. It still said
"the two built Fandom sources", named only r1999 and fgo as permanently
fixture-backed, and counted three wikis where the API answers our own
User-Agent.
The correction matters more than the arithmetic: it is this paragraph that
tells the next agent which lanes the scheduled refresh will never update, and
two of the four it now silently omitted are the two most recently built. Only
r1999 has ever had a snapshot; fgo, nikke and nikki have never had one and will
not get one from CI.
Also drops "Two Fandom sources now" from the Fate/Grand Order heading, which
counted the sources rather than describing the thing the paragraph is about.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three parsers decide currency against ctx.now, because their page has no
"ongoing" heading worth trusting: bawiki.ts, and the Fate/Grand Order and
Infinity Nikki branches of fandom.ts. All three compared Date.parse(endsAt) to
now, and all three publish day-precision ends — so the comparison was against
the 00:00Z placeholder, which § Domain rules says is not an instant and nothing
may read literally.
clockFor already knows that and resolves such a boundary to the reset opening
that game-day on the reader's server. The parsers did not, so the feed retired
a row up to nine hours before the app, the countdown and the game all agreed it
was over. On the pinned Infinity Nikki fixture, "Inspiration Burst" left the
feed at 2026-08-22T01:00Z while clockFor still called it live for an American
reader until 09:00Z, and the page said it ran to 03:59 server time. The reader
does not see a stale row; they see the deadline they were counting down to
disappear on its last day, which is the silent drop this codebase treats as the
dangerous failure.
latestBoundaryMs answers clockFor's question for the last region rather than
one reader's, so a row is history only once it is history everywhere. It follows
a game's own server map and reset hour for the same reason clockFor does —
Endfield and Reverse: 1999 both move. Nothing stored changes: this is one
comparison, not a resolved boundary written into the feed, and no expected.json
moves.
Being generous by nine hours costs an expired row at the bottom of a list.
Being strict costs a live one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
As written, the Blue Archive rule said a wall clock with no stated zone is
unparseable and that rounding it to a day "does not save it". Taken literally
that condemns every Game8 date in this repository — those state no zone either,
and are published at day precision without anyone minding.
What those Blue Archive tables actually were is narrower and worse: three of the
five did not say which server they described. A clock whose server is unknown
cannot even be labelled with a day, because you do not know whose day it is.
That is the case the rule governs, and it now says so.
Converting an unzoned clock by picking an offset stays forbidden, which is the
part that protects event IDs: the offset moves the day, and the start's day is
half of every ID.
Recorded because the rule and the Infinity Nikki source contradicted each other,
and AGENTS.md says that disagreement is a bug to fix rather than reconcile in
your head.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Its robots.txt was read in a browser and is the standard Fandom file: no
Disallow: / for *, /api.php?action= explicitly allowed, and only the namespaces
we never want refused. The AI crawlers it blocks by name are not us. That was
the one thing missing — the API itself has always answered our own User-Agent
with a 200, and it is only robots.txt that a datacentre address cannot read.
The zone lives in the column header, Start(UTC+9) / End(UTC+9), and no date in
any cell carries an offset. So the header is the safety property: a table that
stops naming its zone is refused rather than read as UTC, which is the Blue
Archive hazard arriving one column to the left.
Two shapes worth knowing. Every title is an image read from <a title>, and the
newest row is the one whose logo has not been uploaded yet — it renders as a
red link reading "File:Persona on Frontline logo.png", so a reader that only
understood the link title would silently drop today's live event. And story
events state a clock only on the end, so the start keeps the day the page
printed rather than being shifted nine hours into the previous one; the start's
day is half an event ID. That is the FGO rule applied to the opposite gap.
resetOffsets and resetHourLocal ship with the game because the page evidences
both: events end 04:59:59 and the banner replacing them starts 05:00:00, one
second later, on a stated UTC+9. Adding that later would re-label day keys
readers had already logged ticks under.
Seconds now survive the date helpers, since rounding 04:59:59 to the minute
would make an event overlap the banner that succeeds it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every one of the fourteen sources was fetched and parsed. Twelve are healthy.
The two findings that are not about a game we just added:
Fandom's posture tightened on 2026-08-19 and now covers every wiki. Yesterday
blhx.fandom.com served robots.txt a 200, which is what the r1999 permission
argument leaned on; today every Fandom wiki tried answers 403 to our fetcher
and gives a real browser a challenge that never resolves. So r1999 and fgo are
permanently fixture-backed, and no new Fandom source can be added at all —
which is what blocks Nikke, whose data is the best of anything unbuilt. It is
blocked rather than declined: one person on an address Fandom serves can read
that file and unblock it.
Infinity Nikki's source is stale and there is nowhere to move it. The Game8
page was last updated 31 August 2025, mentions 2026 zero times, and publishes
five events with endsAt: null that read as live indefinitely — worse than an
empty lane, through a source that looks healthy to the runner because a stale
page is not a broken one. Every replacement was checked and none works. That
one is a decision for the owner, not a fix: retiring the lane drops a GameId
that prefixes every completion key its readers hold.
SOURCES.md carries each game's outcome and the reasoning behind the declines,
so the next pass does not re-derive any of it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Near matching exists to reconcile two sources describing one event under
different titles. Within one source it has no such job: the page has already
told us these are two rows, the parser has already dropped repeats of the same
id, and fusing them overrules a distinction the publisher made on purpose.
The loose 0.80 threshold that makes the rule useful across sources makes it
actively wrong within one. Umamusume's banner list runs "3 Star Guaranteed 1.5
Anniversary Scout (Character)" and "(Support)" concurrently — titles differing
by a single parenthetical, starting the same day — and fusing them dropped a
live banner off the calendar with nothing anywhere reporting it. A silent drop
is the failure this codebase ranks as the dangerous one.
Same-id fusion is untouched, so nothing duplicates.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
uma.moe stays declined — its API sits behind a Cloudflare Turnstile proof
header, and an adapter would mean defeating a deliberate access control. This
is the surface that is simply open, and it needed the parser change in the
commit before this one to be readable at all.
The stable URL matters: Game8 also publishes monthly "August 2026 Release
Schedule" pages whose id changes every month, which a static registry cannot
follow. "List of All Banners" does not move.
Four current banners, both columns of the side-by-side table, and none of the
Previous Banners below them. The tenth game8 source, so it carries the same CI
blindness as the other nine and only a manual refresh moves it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Game8's banner pages lay two schedules side by side inside one <table> under a
spanning label row: "Standard Banners | Banner | Rating | Availability | Paid
Banners | ...". That row is not merely unhelpful, it is plausible — it contains
both column words, so it resolves and puts the range at index 3, which no
three-cell data row has. Every row then fails to date and the table yields
nothing at all, with no error anywhere. So readColumnTable now falls back to
row 1, and only when row 0 produced nothing, which is what keeps every page
that parses today parsing identically.
Those pages also schedule banners rather than events and head their sections
accordingly, so the vocabulary learns "List of All Banners", "All Current
Banners" and — the one that matters — "Previous Banners", whose rows are dated
identically to the live ones directly above them.
This parser serves nine sources, so the widening was measured rather than
trusted: every pinned fixture and every live snapshot was parsed before and
after, and no existing source's output changed by a single event.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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 wiki publishes the same schedule twice and the fuller surface is the worse
one. /wiki/Banner_List has 55 clean rows with full wall clocks and states no
timezone anywhere on the page; the front page's Current Banners module emits
the same instants as real <time datetime="...-07:00"> elements. The two agree
exactly, which is strong evidence the list is UTC and is still only evidence —
so we read the surface that says what it means and pay four live banners
instead of a full history for it. If an editor ever states the zone on
Banner_List, that page becomes the better source immediately.
Two ways this could have failed silently. The template writes its BEM
underscores as __, so a selector written against the class name a
browser shows matches nothing at all; and banner names are red links to
?action=edit&redlink=1, which Miraheze's robots.txt disallows, so a href
carrying a query is refused and the page URL stands in.
No resetOffsets, and here the source did state an offset: -07:00, which is US
Pacific and therefore shifts twice a year. That is the Fate/Grand Order gap
arriving through a page that looks like it answered the question.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
PRD gets F15 and the fork that asked for it, DATA-MODEL the prefs field
and the fact that a script outside the bundle now reads that key, and
ARCHITECTURE the pre-paint script — the one piece of this that is neither
CSS nor React.
The AGENTS rule is the part worth reading twice: a component that names a
colour instead of a token now looks right in one theme and wrong in the
other, and nothing will tell you which.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The loudest feedback on the first release was "not enough games", so the
same wikis get researched again every pass — and the expensive half of
that work is not finding a page, it is establishing that a page which
fetches and parses cleanly is a 2021 archive, or states a wall clock with
no timezone, or belongs to a game that is shutting down in October.
This writes down what was actually checked on 2026-08-18: what each page
publishes, which existing parser can already read it, whether CI could
ever fetch it, and the hazard that would produce a confidently wrong date.
The verdicts stay proposals — once one is acted on the ruling belongs in
AGENTS.md § Scraping conduct, next to the sources already declined.
Indexed in the doc table so it is found before the search starts, not
after.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
F1 described the scale only by how far out it goes — three months on a
screen — which left the close end reading as decoration rather than the
answer to two events ending a day apart. The suite count moves with the
test the previous commit added.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
F1 described "one lane per game" as a fact about the timeline, which is
now false half the time, and the prefs key space did not list the pref
that decides it. Both say why the merged board sorts with
endingSoonestFirst rather than a bare end date, since that is the part a
later change is most likely to "simplify".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
F4a called it "a bar at the top of the page", which the previous two
commits made false above `lg` — the working agreement's two-column bullet
and the PRD's desktop split both described a rail the bar was not in.
"Above everything it affects" is the rule that survived the move; where
that lands now depends on whether the layout has a rail.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A source that prints "August 19, 2026" and no time is stored at 00:00Z,
which is a placeholder for "somewhere in this day" rather than a claim
about when the day opens. The countdown read it literally, which turns it
into exactly that claim — and the UTC day is nobody's. Wuthering Waves
events dated the 19th were still running three hours after we had retired
them, because a European player's day opens at 04:00 on a UTC+1 server;
Asia was four hours out the other way and America nine. Sixty-two of the
eighty-six published events were affected, across ten of thirteen games,
so the reader was reading a wrong number far more often than a right one.
A day-precision boundary now resolves to the reset that opens that
game-day on the reader's server — the same grid daily.ts keys every tick
by, and the only clock we hold for a game. That is a reading of the date
the source printed, not a time invented for it: nothing stored moves, no
event ID moves, and the parsers still refuse to guess.
Two boundaries are never re-anchored. A regionEnds value is already the
instant a source stated per server, so anchoring it would throw a fact
away. And an event the reader typed in was resolved by readerInstant in
their own timezone, which is a stated time too — theirs.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The adapter shipped with no docs at all, which left the one thing worth
writing down unwritten: this wiki publishes two servers' schedules on
two cross-linked pages, and the Japanese one parses just as cleanly as
ours. That now has a row in the assessed-sources table, a section under
Fandom, and a line in the ingestion pitfalls table alongside the akwiki
CN column and the bawiki JP tab — three sources bitten the same way is
a pattern, not a coincidence.
Also records what resetOffsets cannot express, since fgo is the first
game to need it: a server clock that shifts with daylight saving.
DATA-MODEL's GameId listing was two games behind the schema, missing
holodori as well as fgo.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
One density cannot answer both questions the board is asked. A patch cycle is
six weeks, a login campaign runs for months, and 13px a day is a compromise
between "what am I in the middle of this week?" and "how do the next three
months line up?" that serves neither well.
A pair of controls steps through a ladder of day widths and the choice is
remembered, on the same argument as the view tabs: a reader who has said how
they want to read this should not say it again on the next load.
Two things it had to get right. Zooming holds the middle of the view still —
rescaling around the left edge of a three-month board throws away whatever
they had scrolled to, and re-opening at today would undo the scrolling that
got them there. And the dated ticks thin out as the scale shrinks, because a
week is 42px at the widest setting and the dates would sit on top of each
other; the gridlines stay weekly, since they carry the rhythm rather than the
reading.
The scale is stored as the measurement, not a step number, and read through
`snapDayWidth`. An export written against a different ladder then opens on
something close to what its reader chose, and a corrupt value opens on the
default rather than a board one pixel wide.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>