Store an empty parse when the page itself says it lists nothing

Refusing every zero-event body is right when the alternative is silently
emptying a calendar, and wrong when the page has told us it is empty. A
gacha game goes quiet between versions, and read strictly that is a source
failing every cycle until the next patch ships: three of them reach the
broken tier and fail the workflow over a lane that is correctly empty,
while the snapshot being held ages out of date. Infinity Nikki sat in
exactly that state for four cycles.

So an empty parse is stored when the source's `statesNoEvents` vouches for
it, and the cycle counts as confirmed rather than failed. Everything else
is unchanged: a body that parses to nothing on its own still keeps the
previous snapshot, and a first fetch that yields nothing still stores
nothing.

The gate turns on the page's statement, never on the adapter merely being
able to make one — otherwise implementing `statesNoEvents` would quietly
switch the zero-events gate off for that source. A test pins that.

The run says which of the two empties it saw. "0 events — down from 6"
reads as the shape change it is not, and that note is what somebody
checking on a quiet lane actually sees.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-03 18:45:57 +02:00
co-authored by Claude Opus 5
parent a73f35b7cd
commit 4b2b3d0958
2 changed files with 67 additions and 8 deletions
+27 -6
View File
@@ -496,12 +496,27 @@ async function refreshOne(
};
}
// Zero events is never a useful snapshot: every source in the registry
// yields events by construction, so an empty parse means the page changed
// shape. Refusing it keeps the previous snapshot — or, on a first run, the
// checked-in fixture — as the thing the feed is built from.
// Zero events is almost never a useful snapshot: a source in the registry
// yields events by construction, so an empty parse normally means the page
// changed shape. Refusing it keeps the previous snapshot — or, on a first
// run, the checked-in fixture — as the thing the feed is built from.
//
// The exception is a page that says so itself. A gacha calendar goes quiet
// between versions, and Infinity Nikki's wiki spent the week after 2.7 ended
// stating "There are no Events in this category" under both headings
// (2026-09-03). Read strictly, that is a source failing every cycle until the
// next version ships, reaching the `broken` tier in a day and a half and
// failing the workflow over a game that is merely between patches — while the
// held snapshot goes on ageing. So `statesNoEvents` lets a parser distinguish
// "we read the page and there is nothing on" from "we read nothing", and only
// the first is stored.
//
// The distinction has to come from the page's own words rather than a row
// count, because a redesign that broke every selector also yields zero rows —
// and storing *that* is the silently emptied calendar this gate exists for.
const previousCount = meta?.eventCount ?? null;
if (events === 0) {
const statesNoEvents = events === 0 && adapter.statesNoEvents?.(html) === true;
if (events === 0 && !statesNoEvents) {
await store.recordCheck(adapter.id, {
at: nowIso,
status: response.status,
@@ -545,6 +560,7 @@ async function refreshOne(
}
const dropped =
!statesNoEvents &&
previousCount !== null &&
previousCount > 0 &&
events < previousCount * DROP_WARNING_RATIO;
@@ -552,7 +568,12 @@ async function refreshOne(
return {
sourceId: adapter.id,
result: "fetched",
note: dropped
// Say which of the two empties this is. "0 events — down from 6" reads as
// the shape change it is not, and this note is what a reader checking on a
// quiet lane actually sees.
note: statesNoEvents
? "0 events — the page states it currently lists none"
: dropped
? `${events} events — down from ${previousCount}, check the page shape`
: `${events} events`,
status: response.status,
+38
View File
@@ -516,6 +516,44 @@ describe("a source being down never blanks the feed", () => {
expect(await store.read("genshin-game8-events")).toBeNull();
});
test("a page that states it lists no events is stored, not rejected", async () => {
// Infinity Nikki, 2026-09-03: 2.7's events had ended, 2.8 was not listed
// yet, and the wiki replaced both tables with "There are no Events in this
// category." Rejecting that costs the source its snapshot every cycle until
// the next version ships, and three cycles of it reach the `broken` tier —
// a build failing over a game that is merely between patches.
await seed("<html><event></event></html>", "2026-08-01T00:00:00.000Z", 1);
const { opts } = options({
adapters: [
adapter({ statesNoEvents: (html: string) => html.includes("<nothing-on>") }),
],
responder: () => new Response("<html><nothing-on></nothing-on></html>"),
});
const summary = await runRefresh(opts);
expect(summary.outcomes[0]?.result).toBe("fetched");
expect(summary.outcomes[0]?.eventCount).toBe(0);
expect((await store.read("genshin-game8-events"))?.meta.eventCount).toBe(0);
expect(summary.broken).toEqual([]);
});
test("an empty parse the page does not vouch for is still rejected", async () => {
// The gate turns on the page's statement, never on the adapter merely being
// able to make one — otherwise implementing `statesNoEvents` would quietly
// switch off the zero-events gate for that source.
await seed("<html><event></event></html>", "2026-08-01T00:00:00.000Z", 1);
const { opts } = options({
adapters: [
adapter({ statesNoEvents: (html: string) => html.includes("<nothing-on>") }),
],
responder: () => new Response("<html>redesigned</html>"),
});
const summary = await runRefresh(opts);
expect(summary.outcomes[0]?.result).toBe("rejected");
expect((await store.read("genshin-game8-events"))?.meta.eventCount).toBe(1);
});
test("a steep drop is stored but flagged", async () => {
await seed("<html>" + "<event></event>".repeat(10) + "</html>", "2026-08-01T00:00:00.000Z", 10);
const { opts } = options({