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