From ae701de74eedac5b57b9506ad202182fec52a517 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 14 Oct 2026 02:34:45 +0200 Subject: [PATCH] feat(refresh): record a lapsed source instead of failing every run on it holodori.wiki still answers 200 and still parses, but its editors stopped adding events after 19 Sep 2026, so every cycle parsed to zero, was rejected, and after three cycles turned the refresh workflow red over something no code here can fix. No fetchable replacement exists. A registry entry can now carry `lapsed: { recorded, reason }`, written by a person. Refresh reports such a source's empty 200s as a warning rather than broken; any other status is still broken, and the first cycle that lists events again warns that the record should come out. Readers see no change: the held snapshot keeps ageing and the staleness footer still says so. The 2026-10-14 page is kept as a fixture, pinning that the template still matches and only the current table is empty. Co-Authored-By: Claude Opus 5.5 --- .../holodoriwiki-events-2026-10-14.html | 905 ++++++++++++++++++ scripts/refresh-sources.ts | 22 +- src/ingest/adapters/index.ts | 9 +- src/ingest/adapters/types.ts | 17 + test/adapters/game8.test.ts | 19 + test/refresh.test.ts | 59 ++ 6 files changed, 1029 insertions(+), 2 deletions(-) create mode 100644 fixtures/holodori/holodoriwiki-events-2026-10-14.html diff --git a/fixtures/holodori/holodoriwiki-events-2026-10-14.html b/fixtures/holodori/holodoriwiki-events-2026-10-14.html new file mode 100644 index 0000000..4d56730 --- /dev/null +++ b/fixtures/holodori/holodoriwiki-events-2026-10-14.html @@ -0,0 +1,905 @@ + + + + +Events - hololive Dreams Wiki + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+Jump to content +
+ + + + +
+
+ + + Toggle menu +
+
+
+
+ +
+ +
    +
  • + + 498 + articles +
  • +
  • + + 1.3K + files +
  • +
  • + + 35 + users +
  • +
  • + + 9.6K + edits +
  • +
+ +
hololive Dreams Wiki
+
+
+
+ + + + + + +
+
+
+
+
+
+
+ + + Toggle preferences menu + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + + Toggle personal menu + +
+
+
+
+
+
Not logged in
+ +
+
+
Your IP address will be publicly visible if you make any edits.
+
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+

Events

+ +
+
+
From hololive Dreams Wiki
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+ + +
+ + + + +
+
+
+ + + More actions + +
+ +
+
+
+
+
+ +
+
+
+ + +

Events are in-game campaigns that allow players to earn rewards by completing tasks. Each event has a corresponding Gacha and story that can be unlocked with event points. +

+
+

For more information, see Point Rally Events, Score Challenge Events, and Spotlight Events.
+In Events, players may complete Lives or Minigames to obtain limited-time Event Points that determine ranking and event badges that may be exchanged for items. The High Score Ranking is based on the scores players achieve on the Event Set Pieces, which are a selection of songs released alongside the event. Each Set Piece has a separate High Score Ranking, and each ranking rewards its own decorative badge. +

+
  • Point Rally Events: Aim for a high score by challenging the Event Set Pieces and earn Event Pts, which can unlock various rewards and a limited-time Event Story. Players are ranked by total Event Pts and high score for each Set Piece.
  • +
  • Score Challenge Events: Aim for a high score by challenging the Event Set Pieces and place in the Total High Score Ranking. Players are also ranked by high score for each Set Piece.
  • +
  • Mission Events: Complete various missions to unlock rewards like Lesson Pts, Hologold, etc.
+

Current Events

+ + edit + + edit source + +
+
+ + + + + + + + + + + + + + +
Logo +Event +Type +Start Date +End Date +Featured Members +
+
Beginner Mission +Mission +Game Launch +Unknown +N/A +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Logo +Event +Type +Start Date +End Date +Featured Members +
+
A Dreamy Summer Escape +Spotlight +09/19/2026 8:00PM (JST) +09/25/2026 8:00PM (JST) + +
+
Innocent Pairs, Watchful eyes +Score Challenge +09/08/2026 12:00PM (JST) +09/17/2026 7:59PM (JST) + +
+
Shadowbound Chase Grand Opening Celebration Missions +Mission +08/29/2026 11:00AM (JST) +09/23/2026 3:59AM (JST) +N/A +
+
Summer Survival on the Island! +Point Rally +08/29/2026 12:00PM (JST) +09/06/2026 7:59PM (JST) + +
+
Ultimate Summer For Me? +Spotlight +08/17/2026 8:00PM (JST) +08/27/2026 7:59PM (JST) + +
+
Training Support Missions +Mission +08/07/2026 11:00AM (JST) +08/30/2026 3:59AM (JST) +N/A +
+
Synced Summer Sparkles +Score Challenge +08/07/2026 12:00PM (JST) +08/15/2026 7:59PM (JST) + +
+
Hello! A Brand New Summer! +Point Rally +07/28/2026 12:00PM (JST) +08/05/2026 7:59PM (JST) + +
+ + + + + + +
+
+
+ + + +
+ +
+ + +
+
+ +
+
+
Cookies help us deliver our services. By using our services, you agree to our use of cookies.
+
+ + + +
+ +
+ +
+
+
+
+
+ + +
+
+
+ + + + + + \ No newline at end of file diff --git a/scripts/refresh-sources.ts b/scripts/refresh-sources.ts index 2bdcb6e..7339157 100644 --- a/scripts/refresh-sources.ts +++ b/scripts/refresh-sources.ts @@ -247,6 +247,14 @@ export async function runRefresh( summary.warnings.push(`${adapter.id}: ${outcome.note}`); } + // A record that outlives its reason would excuse the next real failure, so + // the first cycle that lists events again asks for it to be taken out. + if (adapter.lapsed && outcome.result === "fetched" && (outcome.eventCount ?? 0) > 0) { + summary.warnings.push( + `${adapter.id} is listing events again; remove \`lapsed\` from its registry entry`, + ); + } + options.log( ` ${adapter.id.padEnd(24)} ${outcome.result.padEnd(17)} ${outcome.note}`, ); @@ -257,7 +265,19 @@ export async function runRefresh( // this reports a standing condition, not one bad afternoon. if (!options.dryRun) { const health = await options.store.readState(adapter.id); - if (health.consecutiveFailures >= BROKEN_AFTER_FAILURES) { + // A source recorded as lapsed is excused only while it keeps answering: + // its 200s are a page with nothing current, which a person has already + // looked at. Any other status is a new failure and is reported as one. + if ( + health.consecutiveFailures >= BROKEN_AFTER_FAILURES && + adapter.lapsed && + health.lastStatus === 200 + ) { + summary.warnings.push( + `${adapter.id}: lapsed (recorded ${adapter.lapsed.recorded}, ${adapter.lapsed.reason}); ` + + `${health.consecutiveFailures} cycles with nothing current, not counted as broken`, + ); + } else if (health.consecutiveFailures >= BROKEN_AFTER_FAILURES) { summary.broken.push({ sourceId: adapter.id, consecutiveFailures: health.consecutiveFailures, diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts index 5f00399..6f7abb3 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -2,7 +2,7 @@ import type { GachaEvent, GameId } from "../../shared/schema.ts"; import { mergeEvents, type MergeResult } from "../merge.ts"; import { parserById } from "../parsers/index.ts"; import { sanitizeEvents } from "../sanitize.ts"; -import { SIX_HOURS_MS, type Adapter, type ParseContext } from "./types.ts"; +import { SIX_HOURS_MS, type Adapter, type Lapsed, type ParseContext } from "./types.ts"; /** * The source registry. @@ -18,6 +18,7 @@ interface SourceSpec { parserId: string; priority?: number; minIntervalMs?: number; + lapsed?: Lapsed; } const SOURCES: SourceSpec[] = [ @@ -153,6 +154,11 @@ const SOURCES: SourceSpec[] = [ // closed routes here and `/wiki/` is the one `*` is allowed. url: "https://holodori.wiki/wiki/Events", parserId: "holodoriwiki", + // Read on 2026-10-14: the page answers and parses, but nothing has been + // added since 19 Sep while the game kept running events, and none of the + // alternatives can be fetched (docs/SOURCES.md § 16). Kept registered so it + // recovers by itself; refresh says when it lists events again. + lapsed: { recorded: "2026-10-14", reason: "no event added since 19 Sep" }, }, { id: "gfl2-iopwiki-events", @@ -274,6 +280,7 @@ function toAdapter(spec: SourceSpec): Adapter { parserId: spec.parserId, minIntervalMs: spec.minIntervalMs ?? SIX_HOURS_MS, priority: spec.priority ?? 0, + ...(spec.lapsed === undefined ? {} : { lapsed: spec.lapsed }), statesNoEvents(html: string): boolean { return parser.statesNoEvents?.(html) ?? false; }, diff --git a/src/ingest/adapters/types.ts b/src/ingest/adapters/types.ts index 7ea2b4c..15842f1 100644 --- a/src/ingest/adapters/types.ts +++ b/src/ingest/adapters/types.ts @@ -41,6 +41,23 @@ export interface Adapter { * `SourceParser.statesNoEvents`. */ statesNoEvents?(html: string): boolean; + + /** + * A person's record that this source still answers but its editors have + * stopped adding events, and that no other source can stand in for it. + * `scripts/refresh-sources.ts` then reports an empty answer from it as a + * warning rather than a broken source — and only an answer: one that stops + * answering is broken as before. Never inferred; see AGENTS.md § Scraping + * conduct, *A source that has lapsed*. + */ + lapsed?: Lapsed; +} + +export interface Lapsed { + /** The day a person looked and wrote this down, `YYYY-MM-DD`. */ + recorded: string; + /** What they saw, in a few words. */ + reason: string; } export const SIX_HOURS_MS = 6 * 60 * 60 * 1000; diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index cd0f022..1998bf8 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -1068,6 +1068,25 @@ describe("hololive Dreams wiki", () => { expect(events[0]?.sourceUrl).toBe("https://holodori.wiki/wiki/Ultimate_Summer"); }); + test("the page as of 2026-10-14 is the same template with nothing current — lapsed, not redesigned", async () => { + // The zero CI rejected for three weeks. The template still matches, so the + // parser is reading the page; what changed is that its editors stopped: + // the newest row is 19 Sep, under Past Events, while the game ran a score + // challenge 7–13 Oct that the page never listed. Current Events holds only + // `Beginner Mission`, which has no start and is skipped by design. + const html = await Bun.file("fixtures/holodori/holodoriwiki-events-2026-10-14.html").text(); + expect(holodoriWikiParser.canParse(html)).toBe(true); + expect( + holo.parse(html, { + now: "2026-10-14T00:23:07.000Z", + sourceUrl: holo.url, + sourceId: holo.id, + game: holo.game, + }), + ).toEqual([]); + expect(holo.lapsed?.recorded).toBe("2026-10-14"); + }); + test("reports one global end rather than inventing per-region ones", async () => { // One worldwide service on a Japanese clock: the page states each boundary // once, in JST, with no per-region column anywhere. diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 7577996..02b17e0 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -900,6 +900,65 @@ describe("a source that has stopped answering", () => { }); }); +describe("a source recorded as lapsed", () => { + // hololive Dreams, 2026-10-14: holodori.wiki answers 200 every cycle with an + // intact page whose editors stopped adding events, and no other source can + // be fetched. A person recorded that; the run should say so without turning + // red every cycle over something nobody here can fix. + const LAPSED = { recorded: "2026-10-14", reason: "editors stopped adding events" }; + + async function emptyCycles(lapsedAdapter: Adapter, body: string, status = 200) { + await seed("", NOW.toISOString(), 1); + let summary = await runRefresh( + options({ adapters: [lapsedAdapter], responder: () => new Response(body, { status }) }).opts, + ); + for (let i = 1; i <= BROKEN_AFTER_FAILURES; i += 1) { + const later = new Date(NOW.getTime() + i * SIX_HOURS_MS); + summary = await runRefresh({ + ...options({ + adapters: [lapsedAdapter], + responder: () => new Response(body, { status }), + }).opts, + now: () => later, + }); + } + return summary; + } + + test("an empty page it keeps answering with is a warning, not broken", async () => { + const summary = await emptyCycles(adapter({ lapsed: LAPSED }), ""); + + expect(summary.outcomes[0]?.result).toBe("rejected"); + expect(summary.broken).toEqual([]); + expect(summary.warnings.join("\n")).toInclude( + "genshin-game8-events: lapsed (recorded 2026-10-14, editors stopped adding events)", + ); + }); + + test("it is still broken when it stops answering", async () => { + const summary = await emptyCycles(adapter({ lapsed: LAPSED }), "denied", 403); + expect(summary.broken).toHaveLength(1); + }); + + test("a source with no such record is broken as before", async () => { + const summary = await emptyCycles(adapter(), ""); + expect(summary.broken).toHaveLength(1); + }); + + test("events coming back say the record is out of date", async () => { + const { opts } = options({ + adapters: [adapter({ lapsed: LAPSED })], + responder: () => new Response(""), + }); + const summary = await runRefresh(opts); + + expect(summary.outcomes[0]?.result).toBe("fetched"); + expect(summary.warnings.join("\n")).toInclude( + "genshin-game8-events is listing events again; remove `lapsed` from its registry entry", + ); + }); +}); + describe("what the runner reports to the runner", () => { // Warnings on stdout are invisible unless someone opens the log, which is how // six of seven sources failed every cycle for three days under a green tick.