From 4b2b3d09587ae899f225ccda1e4e70abeabbdcee Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:45:57 +0200 Subject: [PATCH] Store an empty parse when the page itself says it lists nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/refresh-sources.ts | 37 +++++++++++++++++++++++++++++-------- test/refresh.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/scripts/refresh-sources.ts b/scripts/refresh-sources.ts index 2dfe16a..f607be3 100644 --- a/scripts/refresh-sources.ts +++ b/scripts/refresh-sources.ts @@ -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,9 +568,14 @@ async function refreshOne( return { sourceId: adapter.id, result: "fetched", - note: dropped - ? `${events} events — down from ${previousCount}, check the page shape` - : `${events} events`, + // 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, eventCount: events, }; diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 3bafb66..a17f72c 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -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("", "2026-08-01T00:00:00.000Z", 1); + const { opts } = options({ + adapters: [ + adapter({ statesNoEvents: (html: string) => html.includes("") }), + ], + responder: () => new Response(""), + }); + 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("", "2026-08-01T00:00:00.000Z", 1); + const { opts } = options({ + adapters: [ + adapter({ statesNoEvents: (html: string) => html.includes("") }), + ], + responder: () => new Response("redesigned"), + }); + 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("" + "".repeat(10) + "", "2026-08-01T00:00:00.000Z", 10); const { opts } = options({