Identify the Infinity Nikki page by its headings, not by finding a row
The wiki emptied both its Current and Upcoming tables on 2026-09-03 — 2.7's events had ended and 2.8 was not listed yet — and said so in words: "There are no Events in this category". Nothing else moved; Past Events still carries twenty tables of the same shape. `isInfinityNikkiEventPage` looked for a *populated* table, so it read that as a redesign. The source spent four cycles reporting "the source has likely been redesigned" at a page nobody had touched, reached the broken tier, and went on serving a snapshot whose every row expired on 27 August. A check that reads data cannot tell a rewrite from a quiet week. It now reads the section headings, which an empty table does not take with it — either heading rather than both, since requiring the pair would fail the source over a renamed heading it does not even read, and one already separates this page from the other three Fandom templates. docs/INGESTION.md asked for structural checks all along; this one had drifted into content. `statesNoEvents` then reports the page's own declaration, so the runner can tell an answer from a failure. It insists on the statement rather than an empty table, because a redesign yields an empty table too. The emptied page is pinned as a fixture: an empty answer is a shape, and it is the one that used to read as a redesign. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0809fa1122
commit
a73f35b7cd
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
File diff suppressed because one or more lines are too long
@@ -440,13 +440,61 @@ function nikkeTitle(cell: string): string | null {
|
|||||||
/** Headings whose tables are a schedule our readers can act on. */
|
/** Headings whose tables are a schedule our readers can act on. */
|
||||||
const IN_INCLUDED_SECTION = /^(current|upcoming) events$/i;
|
const IN_INCLUDED_SECTION = /^(current|upcoming) events$/i;
|
||||||
|
|
||||||
|
/** The page's own words when a section is currently listing nothing. */
|
||||||
|
const IN_STATES_EMPTY = /There are no Events in this category/i;
|
||||||
|
|
||||||
|
/** Every `<h2>` on the page, flattened to lower-case text. */
|
||||||
|
function h2Headings(rendered: string): Set<string> {
|
||||||
|
const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " ");
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const node of flat.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)) {
|
||||||
|
out.add(text(node[1] ?? "").trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True for the Infinity Nikki wiki's `Event` page, and the gate on the branch
|
* True for the Infinity Nikki wiki's `Event` page, and the gate on the branch
|
||||||
* below. Asserts the heading *and* the table shape, because the page's
|
* below.
|
||||||
* `Permanent Events` table shares neither.
|
*
|
||||||
|
* **Identity only — never the presence of a row.** This asserted a populated
|
||||||
|
* `Current`/`Upcoming` table until 2026-09-03, which conflated the two things a
|
||||||
|
* tripwire has to keep apart: a page that was rewritten, and a game that has
|
||||||
|
* nothing on. Infinity Nikki emptied both tables the week 2.7's events ended
|
||||||
|
* and 2.8 was not yet listed, and the source spent four cycles reporting
|
||||||
|
* `the source has likely been redesigned` at a page whose markup had not moved
|
||||||
|
* — long enough to reach the `broken` tier and fail the workflow. `canParse` is
|
||||||
|
* documented as a *structural* check for exactly this reason
|
||||||
|
* (docs/INGESTION.md § The adapter registry), so it reads the section headings,
|
||||||
|
* which an empty table does not take with it.
|
||||||
|
*
|
||||||
|
* *Either* heading, not both — the same doc's warning against over-fitting. The
|
||||||
|
* live page carries both, but requiring the pair would fail the source over a
|
||||||
|
* renamed heading it does not even read, and one is already unique: the other
|
||||||
|
* three Fandom templates carry neither. `Past Events` is deliberately not in
|
||||||
|
* the set, or an archive with nothing current would identify as this page.
|
||||||
*/
|
*/
|
||||||
function isInfinityNikkiEventPage(rendered: string): boolean {
|
function isInfinityNikkiEventPage(rendered: string): boolean {
|
||||||
return infinityNikkiTables(rendered).length > 0;
|
const headings = h2Headings(rendered);
|
||||||
|
return headings.has("current events") || headings.has("upcoming events");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this page tells us, in its own words, that it currently lists no
|
||||||
|
* events — the state Infinity Nikki sits in between versions.
|
||||||
|
*
|
||||||
|
* This is what lets the refresh gate tell "we read the page and there is
|
||||||
|
* nothing on" from "we read nothing", and it is deliberately the page's
|
||||||
|
* statement rather than a row count: a redesign that broke every selector would
|
||||||
|
* also yield zero rows, and storing *that* as an empty snapshot is the silent
|
||||||
|
* emptying of a calendar the zero-events gate exists to prevent.
|
||||||
|
*/
|
||||||
|
function infinityNikkiStatesNoEvents(rendered: string): boolean {
|
||||||
|
if (!isInfinityNikkiEventPage(rendered)) return false;
|
||||||
|
// A table we can still read means the page is listing something, whatever
|
||||||
|
// else it says further down.
|
||||||
|
if (infinityNikkiTables(rendered).length > 0) return false;
|
||||||
|
return IN_STATES_EMPTY.test(rendered);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InTable {
|
interface InTable {
|
||||||
@@ -765,5 +813,12 @@ export const fandomParser: SourceParser = {
|
|||||||
isInfinityNikkiEventPage(rendered)
|
isInfinityNikkiEventPage(rendered)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
statesNoEvents(body: string): boolean {
|
||||||
|
const rendered = renderedHtml(body);
|
||||||
|
if (rendered === null) return false;
|
||||||
|
// Only the Infinity Nikki template states this. The other three say nothing
|
||||||
|
// either way when they are empty, so they keep the strict gate.
|
||||||
|
return infinityNikkiStatesNoEvents(rendered);
|
||||||
|
},
|
||||||
parse: parseFandomEventsPage,
|
parse: parseFandomEventsPage,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
|||||||
{ adapter: adapter("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" },
|
{ adapter: adapter("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" },
|
||||||
{ adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-events-2026-08-19" },
|
{ adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-events-2026-08-19" },
|
||||||
{ adapter: adapter("nikki-fandom-events"), fixture: "fixtures/nikki/fandom-events-2026-08-19" },
|
{ adapter: adapter("nikki-fandom-events"), fixture: "fixtures/nikki/fandom-events-2026-08-19" },
|
||||||
|
// The same page a week later, listing nothing: 2.7's events had ended and 2.8
|
||||||
|
// was not up yet, so both tables read "There are no Events in this category."
|
||||||
|
// Pinned because an empty answer is a shape too — it is what the detector
|
||||||
|
// used to read as a redesign.
|
||||||
|
{ adapter: adapter("nikki-fandom-events"), fixture: "fixtures/nikki/fandom-events-2026-09-03" },
|
||||||
// Honkai Impact 3rd. Every boundary in this expected file is a week-bucket
|
// Honkai Impact 3rd. Every boundary in this expected file is a week-bucket
|
||||||
// edge the source labels `ESTIMATED WEEK`, not an announced date — see
|
// edge the source labels `ESTIMATED WEEK`, not an announced date — see
|
||||||
// src/ingest/parsers/arustats.ts and docs/SOURCES.md § 14.
|
// src/ingest/parsers/arustats.ts and docs/SOURCES.md § 14.
|
||||||
@@ -1767,3 +1772,54 @@ describe("Infinity Nikki wiki (the fourth Fandom template)", () => {
|
|||||||
expect(shop?.summary).toBeTruthy();
|
expect(shop?.summary).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Infinity Nikki wiki between versions (the page states it is empty)", () => {
|
||||||
|
const nikki = adapter("nikki-fandom-events");
|
||||||
|
const emptied = "fixtures/nikki/fandom-events-2026-09-03";
|
||||||
|
const populated = "fixtures/nikki/fandom-events-2026-08-19";
|
||||||
|
|
||||||
|
function parse(html: string) {
|
||||||
|
return nikki.parse(html, {
|
||||||
|
now: NOW,
|
||||||
|
sourceUrl: nikki.url,
|
||||||
|
sourceId: nikki.id,
|
||||||
|
game: nikki.game,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("still recognises the template when both tables have been emptied", async () => {
|
||||||
|
// 2.7's events ended on 27 August and 2.8 was not listed yet, so the wiki
|
||||||
|
// replaced both tables with "There are no Events in this category." The
|
||||||
|
// page is otherwise untouched — `Past Events` still carries twenty tables
|
||||||
|
// of the same shape. A detector that needs a populated row reads that as a
|
||||||
|
// redesign and the runner reports a broken source that is merely quiet.
|
||||||
|
const html = await Bun.file(`${emptied}.html`).text();
|
||||||
|
expect(fandomParser.canParse(html)).toBe(true);
|
||||||
|
expect(() => parse(html)).not.toThrow();
|
||||||
|
expect(parse(html)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("says the page states no events, and says it only when the page does", async () => {
|
||||||
|
// The distinction the refresh gate turns on: a page that tells us it lists
|
||||||
|
// nothing is a source we read correctly, not one that broke. Anything
|
||||||
|
// weaker than the page's own words would let a real redesign through as an
|
||||||
|
// empty calendar, which is the failure the zero-events gate exists for.
|
||||||
|
const empty = await Bun.file(`${emptied}.html`).text();
|
||||||
|
const full = await Bun.file(`${populated}.html`).text();
|
||||||
|
expect(fandomParser.statesNoEvents?.(empty)).toBe(true);
|
||||||
|
expect(fandomParser.statesNoEvents?.(full)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not claim emptiness for the other three Fandom templates", async () => {
|
||||||
|
// `statesNoEvents` is asked of every Fandom source, so a loose check would
|
||||||
|
// let a redesigned FGO or Nikke page store an empty snapshot.
|
||||||
|
for (const fixture of [
|
||||||
|
"fixtures/fgo/fandom-events-2026-08-18",
|
||||||
|
"fixtures/nikke/fandom-events-2026-08-19",
|
||||||
|
"fixtures/r1999/fandom-events-2026-08-17",
|
||||||
|
]) {
|
||||||
|
const html = await Bun.file(`${fixture}.html`).text();
|
||||||
|
expect(fandomParser.statesNoEvents?.(html)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user