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({