feat(ingest): add the Reverse: 1999 event source via Fandom's API
New `fandom` parser plus the r1999-fandom-events source, giving the game six live and upcoming events at exact precision. It fetches api.php?action=parse, not /wiki/Events. The rendered page answers a non-browser client with a Cloudflare challenge, and getting past that would be defeating an access control — the reason uma.moe was declined. The wiki's robots.txt instead allows /api.php?action= for *, and that endpoint serves our real User-Agent a 200, so this reads the sanctioned surface with our own headers and no impersonation. The body is therefore JSON, which is what canParse checks first: a challenge page or an error payload must fail loudly, not parse to zero events. Two page facts shape the parser. Titles come from each row's <b>, because a missing banner image renders as a red link reading "File:<Event> Banner.png" that a cell-text reader would publish as the event name. And the page is an archive of 154 rows since v1.1 with no ongoing section to gate on, so inclusion is decided against ctx.now — the six-event count is asserted against an independent extraction off the fixture, per docs/INGESTION.md § Testing. Because robots.txt is unreadable from a challenged address, the gate fails closed in CI and the source is skipped there — a warning, not a broken build. Refreshing it means running `bun run refresh` from an address Fandom serves. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a65a1f06c
commit
3377fcb211
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adapterById } from "../../src/ingest/adapters/index.ts";
|
||||
import type { Adapter } from "../../src/ingest/adapters/types.ts";
|
||||
import { parseOrdinalDateTimeRange } from "../../src/ingest/dates.ts";
|
||||
import { arknightsWikiParser } from "../../src/ingest/parsers/akwiki.ts";
|
||||
import { fandomParser, renderedHtml } from "../../src/ingest/parsers/fandom.ts";
|
||||
import { inferType } from "../../src/ingest/parsers/game8.ts";
|
||||
import { GachaEvent, type EventType } from "../../src/shared/schema.ts";
|
||||
|
||||
@@ -31,6 +33,10 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
|
||||
{ adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" },
|
||||
{ adapter: adapter("arknights-akwiki-events"), fixture: "fixtures/arknights/akwiki-events-2026-08-17" },
|
||||
// A `.html` fixture holding JSON, deliberately: this source is the MediaWiki
|
||||
// action API, and `snapshots/` names every stored body `<id>.html` whatever
|
||||
// its content type. The fixture is the bytes the fetcher would store.
|
||||
{ adapter: adapter("r1999-fandom-events"), fixture: "fixtures/r1999/fandom-events-2026-08-17" },
|
||||
];
|
||||
|
||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||
@@ -421,3 +427,92 @@ describe("wiki.gg parser", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fandom parser", () => {
|
||||
const fixture = "fixtures/r1999/fandom-events-2026-08-17";
|
||||
const r1999 = adapter("r1999-fandom-events");
|
||||
|
||||
test("publishes only the events that have not ended", async () => {
|
||||
// The page is an archive: 154 rows spanning every version since 1.1, of
|
||||
// which six had not ended at the pinned clock. Counted independently off
|
||||
// the fixture before this expectation was written — the count is the guard
|
||||
// against a shape change making events vanish silently.
|
||||
const events = await runAdapter(r1999, fixture);
|
||||
expect(events).toHaveLength(6);
|
||||
for (const e of events) {
|
||||
expect(e.endsAt).not.toBeNull();
|
||||
expect(Date.parse(e.endsAt!)).toBeGreaterThanOrEqual(Date.parse(NOW));
|
||||
}
|
||||
});
|
||||
|
||||
test("converts the stated UTC-5 offset rather than reading it as UTC", async () => {
|
||||
// "August 13th, 05:00 - September 21st, 2026, 04:59 (UTC-5)". Reading the
|
||||
// wall clock as UTC would put every boundary five hours early — and a start
|
||||
// that crossed a UTC midnight would move the event's ID with it.
|
||||
const events = await runAdapter(r1999, fixture);
|
||||
const version = events.find((e) => e.title === "On Another's Sorrow");
|
||||
expect(version?.startsAt).toBe("2026-08-13T10:00:00.000Z");
|
||||
expect(version?.endsAt).toBe("2026-09-21T09:59:00.000Z");
|
||||
expect(version?.startPrecision).toBe("exact");
|
||||
expect(version?.endPrecision).toBe("exact");
|
||||
});
|
||||
|
||||
test("takes the title from the <b>, not the cell text", async () => {
|
||||
// A missing banner image renders as a red link whose visible text is
|
||||
// "File:A Stranger to Memory Lane Banner.png". A cell-text reader publishes
|
||||
// that filename as the event's name.
|
||||
const events = await runAdapter(r1999, fixture);
|
||||
const titles = events.map((e) => e.title);
|
||||
expect(titles).toContain("A Stranger to Memory Lane");
|
||||
for (const t of titles) {
|
||||
expect(t).not.toMatch(/^File:/);
|
||||
expect(t).not.toMatch(/\.png/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("carries the section heading as the summary and types from it", async () => {
|
||||
const events = await runAdapter(r1999, fixture);
|
||||
const story = events.find((e) => e.title === "The You That's Meant to Be");
|
||||
// The title alone says nothing about what kind of event it is; the section
|
||||
// it sits under does.
|
||||
expect(story?.summary).toBe("Character Story Events");
|
||||
expect(story?.type).toBe("story");
|
||||
// The [edit] link MediaWiki renders inside every heading is not part of it.
|
||||
for (const e of events) expect(e.summary).not.toMatch(/edit/i);
|
||||
});
|
||||
|
||||
test("states one global end, not per-region ends", async () => {
|
||||
// Every row reads (UTC-5) and the page draws no regional distinction.
|
||||
for (const e of await runAdapter(r1999, fixture)) {
|
||||
expect(e.regionScoped).toBe(false);
|
||||
expect(e.regionEnds).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a body that is not an action=parse response", async () => {
|
||||
// The plain wiki page answers a non-browser client with a Cloudflare
|
||||
// interstitial. Feeding that to the parser must fail loudly rather than
|
||||
// report zero events, which reads downstream as "nothing is on".
|
||||
const interstitial = "<html><head><title>Just a moment...</title></head></html>";
|
||||
expect(fandomParser.canParse(interstitial)).toBe(false);
|
||||
expect(fandomParser.canParse('{"error":{"code":"missingtitle"}}')).toBe(false);
|
||||
expect(() =>
|
||||
r1999.parse(interstitial, {
|
||||
now: NOW,
|
||||
sourceUrl: r1999.url,
|
||||
sourceId: r1999.id,
|
||||
game: r1999.game,
|
||||
}),
|
||||
).toThrow(/redesigned/);
|
||||
});
|
||||
|
||||
test("a row stating no year on either half yields no event", async () => {
|
||||
// "February 20th, 05:00 - March 27th, 04:59 (UTC-5)" — the fixture has
|
||||
// exactly one, and there is no honest year to give it.
|
||||
const body = await Bun.file(`${fixture}.html`).text();
|
||||
const rendered = renderedHtml(body);
|
||||
expect(rendered).not.toBeNull();
|
||||
expect(rendered!).toContain("February 20th, 05:00 - March 27th, 04:59");
|
||||
expect(parseOrdinalDateTimeRange("February 20th, 05:00 - March 27th, 04:59 (UTC-5)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user