feat: add Punishing: Gray Raven with karendar.com source

Add karendar.com as the event source for Punishing: Gray Raven (PGR).
Karendar publishes a dedicated Global event calendar with exact UTC
timestamps in clean server-rendered HTML.

- Register 'pgr' in GameId and GAMES registry (#CC292B, Kuro Games).
- Add parseWeekdayDayMonthYearUtc in src/ingest/dates.ts with unit tests.
- Implement karendarParser in src/ingest/parsers/karendar.ts reading
  'week', 'ongoing', and 'upcoming' sections, mapping indefinite/permanent
  ends to endsAt: null, and categorizing tags to EventType.
- Register pgr-karendar-events in src/ingest/adapters/index.ts.
- Add pinned fixture, expected output, and comprehensive test suites.
- Update documentation in AGENTS.md, README.md, docs/INGESTION.md,
  and docs/SOURCES.md.
This commit is contained in:
Lucas Winther
2026-09-12 09:10:02 +02:00
parent 2d6299445c
commit 4382ed35ee
15 changed files with 1645 additions and 22 deletions
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, test } from "bun:test";
import { adapterById } from "../../src/ingest/adapters/index.ts";
import { karendarParser } from "../../src/ingest/parsers/karendar.ts";
const FIXTURE = "fixtures/pgr/karendar-events-2026-09-12.html";
const NOW = "2026-09-12T00:00:00.000Z";
const pgr = adapterById("pgr-karendar-events");
if (pgr === undefined) throw new Error("no adapter 'pgr-karendar-events'");
function parse(html: string) {
return pgr!.parse(html, {
now: NOW,
sourceUrl: pgr!.url,
sourceId: pgr!.id,
game: pgr!.game,
});
}
describe("Karendar parser (Punishing: Gray Raven)", () => {
test("structural detector recognises Karendar pages and rejects others", () => {
expect(
karendarParser.canParse(
'<main><section id="ongoing"></section><section id="upcoming"></section><footer>Karendar</footer></main>',
),
).toBe(true);
expect(karendarParser.canParse("<html><body>Different site</body></html>")).toBe(
false,
);
expect(
karendarParser.canParse(
'<main><section id="ongoing"></section><footer>Karendar</footer></main>',
),
).toBe(false);
});
test("publishes exact UTC timestamps with exact precision", async () => {
const html = await Bun.file(FIXTURE).text();
const events = parse(html);
// War Zone (weekly recurring combat)
const warZone = events.find((e) => e.title === "War Zone");
expect(warZone).toBeDefined();
expect(warZone?.startsAt).toBe("2026-09-07T07:00:00.000Z");
expect(warZone?.endsAt).toBe("2026-09-13T18:00:00.000Z");
expect(warZone?.startPrecision).toBe("exact");
expect(warZone?.endPrecision).toBe("exact");
expect(warZone?.regionScoped).toBe(false);
expect(warZone?.regionEnds).toBeNull();
});
test("handles indefinite and permanent ends as endsAt null with precision unknown", async () => {
const html = await Bun.file(FIXTURE).text();
const events = parse(html);
// Main Story: Steering By Light (Permanent)
const story = events.find(
(e) => e.title === "Main Story: Steering By Light",
);
expect(story).toBeDefined();
expect(story?.startsAt).toBe("2026-08-20T05:00:00.000Z");
expect(story?.endsAt).toBeNull();
expect(story?.endPrecision).toBe("unknown");
// Upcoming banner with unannounced end date
const adelyde = events.find((e) =>
e.title.includes("Adelyde: Anabasis"),
);
expect(adelyde).toBeDefined();
expect(adelyde?.startsAt).toBe("2026-09-24T05:00:00.000Z");
expect(adelyde?.endsAt).toBeNull();
expect(adelyde?.endPrecision).toBe("unknown");
});
test("skips archive (ended), tbc (unknown start), and codes sections", async () => {
const html = await Bun.file(FIXTURE).text();
const events = parse(html);
// In archive:
expect(
events.some((e) => e.title.includes("Version Maintenance Steering By Light")),
).toBe(false);
expect(
events.some((e) => e.title.includes("One Heart, One Hertz Concert")),
).toBe(false);
// In TBC:
expect(events.some((e) => e.title === "Circuit Calculus")).toBe(false);
expect(events.some((e) => e.title === "Wreck-It Huhu")).toBe(false);
// In codes:
expect(events.some((e) => e.title.includes("PUNISHING0924"))).toBe(false);
});
test("spot-checks specific events matching live page content", async () => {
const html = await Bun.file(FIXTURE).text();
const events = parse(html);
// 1. Karenina: Effulgence character debut banner
const karenina = events.find((e) => e.title === "Karenina: Effulgence");
expect(karenina).toBeDefined();
expect(karenina?.type).toBe("banner");
expect(karenina?.startsAt).toBe("2026-08-20T05:00:00.000Z");
expect(karenina?.endsAt).toBe("2026-09-23T23:00:00.000Z");
// 2. Patch End Maintenance
const maint = events.find(
(e) => e.title === "Patch End Maintenance",
);
expect(maint).toBeDefined();
expect(maint?.type).toBe("maintenance");
expect(maint?.startsAt).toBe("2026-09-23T23:00:00.000Z");
expect(maint?.endsAt).toBe("2026-09-24T05:00:00.000Z");
// 3. Collab banner: PGR x Date A Live V
const collab = events.find((e) =>
e.title.includes("PGR × Date A Live V Kurumi Tokisaki"),
);
expect(collab).toBeDefined();
expect(collab?.type).toBe("banner");
expect(collab?.startsAt).toBe("2026-09-29T05:00:00.000Z");
expect(collab?.endsAt).toBeNull();
expect(collab?.endPrecision).toBe("unknown");
// 4. Overclock Simulation
const overclock = events.find((e) => e.title === "Overclock Simulation");
expect(overclock).toBeDefined();
expect(overclock?.type).toBe("challenge");
expect(overclock?.startsAt).toBe("2026-09-29T10:00:00.000Z");
expect(overclock?.endsAt).toBe("2026-11-04T05:00:00.000Z");
});
});