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
+10
View File
@@ -247,6 +247,16 @@ const SOURCES: SourceSpec[] = [
url: "https://www.arustats.com/en-us/hi3/timeline",
parserId: "arustats",
},
{
id: "pgr-karendar-events",
game: "pgr",
// Karendar is a fan-made PGR event calendar for the Global server.
// The home page server-renders all active, ongoing, and upcoming
// events in clean semantic HTML with exact UTC timestamps. robots.txt
// permits / while disallowing /login, /this-week, and /api/.
url: "https://karendar.com/",
parserId: "karendar",
},
];
function toAdapter(spec: SourceSpec): Adapter {
+27
View File
@@ -692,3 +692,30 @@ export function parseZonelessClockRange(
end: { iso: endIso, precision: "day" },
};
}
/**
* "Mon, 7 Sept 2026, 07:00 UTC" → 2026-09-07T07:00:00.000Z, exact precision.
*
* Sourced from Karendar (Punishing: Gray Raven). The weekday and UTC zone are explicit.
* "Unknown", "Permanent", or non-date input returns null.
*/
export function parseWeekdayDayMonthYearUtc(
input: string,
): ParsedInstant | null {
const re =
/^\s*(?:[A-Za-z]+,\s+)?(\d{1,2})\s+([A-Za-z]+)\.?\s+(\d{4}),?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*UTC\s*$/i;
const m = re.exec(input);
if (!m) return null;
const month = monthNumber(m[2] ?? "");
if (month === null) return null;
const day = Number(m[1]);
const year = Number(m[3]);
const hh = Number(m[4]);
const mm = Number(m[5]);
const ss = Number(m[6] ?? 0);
const value = iso(year, month, day, hh, mm, ss);
return value === null ? null : { iso: value, precision: "exact" };
}
+3
View File
@@ -5,6 +5,7 @@ import { fandomParser } from "./fandom.ts";
import { game8Parser } from "./game8.ts";
import { holodoriWikiParser } from "./holodori.ts";
import { iopWikiParser } from "./iopwiki.ts";
import { karendarParser } from "./karendar.ts";
import { stellaSoraWikiParser } from "./stellasora.ts";
import { wikiGgParser } from "./wikigg.ts";
import type { SourceParser } from "./types.ts";
@@ -24,6 +25,7 @@ export const PARSERS: SourceParser[] = [
iopWikiParser,
stellaSoraWikiParser,
aruStatsParser,
karendarParser,
];
export function parserById(id: string): SourceParser | undefined {
@@ -38,5 +40,6 @@ export { fandomParser } from "./fandom.ts";
export { game8Parser } from "./game8.ts";
export { holodoriWikiParser } from "./holodori.ts";
export { iopWikiParser } from "./iopwiki.ts";
export { karendarParser } from "./karendar.ts";
export { stellaSoraWikiParser } from "./stellasora.ts";
export { wikiGgParser } from "./wikigg.ts";
+184
View File
@@ -0,0 +1,184 @@
import {
eventId,
type EventType,
type GachaEvent,
} from "../../shared/schema.ts";
import { parseWeekdayDayMonthYearUtc } from "../dates.ts";
import { decodeEntities } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
import { inferType } from "./game8.ts";
import type { SourceParser } from "./types.ts";
/**
* Karendar: Punishing: Gray Raven community event calendar (karendar.com).
*
* Sourced directly from the server-rendered HTML of the home page.
*
* Key details:
* 1. All published times are for the Global server in UTC with explicit minute
* precision ("Mon, 7 Sept 2026, 07:00 UTC").
* 2. Active sections are `week` ("Ends this week"), `ongoing` ("On-going"),
* and `upcoming` ("Upcoming"). The site moves events that end in the current
* week into `week` instead of duplicating them in `ongoing`.
* 3. `archive` holds past ended events and is deliberately skipped.
* 4. `tbc` holds unannounced events whose dates are "Unknown" for both start and
* end. Skipped because startsAt is unknown.
* 5. `codes` holds in-game redemption codes, not time-boxed calendar events.
* 6. "Permanent" or "Unknown" ends become `endsAt: null` with `endPrecision: "unknown"`.
*/
const ACTIVE_SECTIONS = ["week", "ongoing", "upcoming"];
function inferKarendarType(tags: string[], title: string): EventType {
const t = tags.map((x) => x.toLowerCase());
const titleLower = title.toLowerCase();
if (
t.includes("maintenance") ||
t.includes("patch end") ||
titleLower.includes("maintenance")
) {
return "maintenance";
}
if (titleLower.includes("rerun") || t.includes("rerun")) {
return "rerun";
}
if (
t.includes("banner") ||
t.includes("construct") ||
t.includes("cub") ||
t.includes("weapon") ||
/\b(rate-up|banner)\b/i.test(title)
) {
return "banner";
}
if (/\b(sign-in|login|check-in)\b/i.test(title)) {
return "login";
}
if (t.includes("story") || t.includes("affection")) {
return "story";
}
if (t.includes("combat") || t.includes("challenge") || t.includes("boss")) {
return "challenge";
}
if (
t.includes("shop") ||
t.includes("coating") ||
t.includes("weapon coating")
) {
return "shop";
}
return inferType(`${tags.join(" ")} ${title}`);
}
export function parseKarendarEventsPage(
html: string,
ctx: ParseContext,
): GachaEvent[] {
const events: GachaEvent[] = [];
for (const secId of ACTIVE_SECTIONS) {
const secMarker = `id="${secId}"`;
const secStart = html.indexOf(secMarker);
if (secStart === -1) continue;
const nextSecStart = html.indexOf("<section id=", secStart + secMarker.length);
const sectionChunk = html.slice(
secStart,
nextSecStart !== -1 ? nextSecStart : undefined,
);
const articleChunks = sectionChunk
.split("<article ")
.slice(1)
.map((a) => "<article " + a.split("</article>")[0] + "</article>");
for (const article of articleChunks) {
const titleMatch = /<h3[^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/.exec(article);
if (!titleMatch || !titleMatch[1]) continue;
const title = decodeEntities(titleMatch[1].trim());
if (!title) continue;
const startMatch = /<dt[^>]*>Start<\/dt>\s*<dd[^>]*>([^<]+)<\/dd>/.exec(
article,
);
if (!startMatch || !startMatch[1]) continue;
const startInstant = parseWeekdayDayMonthYearUtc(startMatch[1].trim());
if (startInstant === null) continue;
const endMatch = /<dt[^>]*>End<\/dt>\s*<dd[^>]*>([^<]+)<\/dd>/.exec(article);
const endRaw = endMatch?.[1]?.trim() ?? "Unknown";
const isIndefinite =
endRaw === "Permanent" ||
endRaw === "Unknown" ||
endRaw.toLowerCase() === "tba" ||
endRaw.toLowerCase() === "tbd";
const endInstant = isIndefinite
? null
: parseWeekdayDayMonthYearUtc(endRaw);
const tagMatches = [
...article.matchAll(
/<span class="rounded-full px-2 py-0\.5 text-xs font-medium[^"]*">([^<]+)<\/span>/g,
),
];
const tags = tagMatches.map((m) => decodeEntities((m[1] ?? "").trim()));
const descMatch = /<p class="mt-1 text-sm text-muted">([\s\S]*?)<\/p>/.exec(
article,
);
const summary =
descMatch && descMatch[1] && descMatch[1].trim().length > 0
? decodeEntities(descMatch[1].trim()).slice(0, 500)
: null;
let confidence = 0.95;
if (startInstant.precision === "day") confidence -= 0.05;
if (endInstant === null) confidence -= 0.15;
else if (endInstant.precision === "day") confidence -= 0.05;
const ev: GachaEvent = {
id: eventId(ctx.game, title, startInstant.iso),
game: ctx.game,
title,
type: inferKarendarType(tags, title),
summary,
startsAt: startInstant.iso,
startPrecision: startInstant.precision,
endsAt: endInstant ? endInstant.iso : null,
endPrecision: endInstant ? endInstant.precision : "unknown",
regionScoped: false,
regionEnds: null,
sourceUrl: ctx.sourceUrl,
sourceId: ctx.sourceId,
status: "published",
confidence,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
};
events.push(ev);
}
}
return events.sort((a, b) =>
a.startsAt === b.startsAt
? a.id.localeCompare(b.id)
: a.startsAt.localeCompare(b.startsAt),
);
}
export const karendarParser: SourceParser = {
id: "karendar",
label: "Karendar",
canParse(html: string): boolean {
return (
/id=['"]ongoing['"]/.test(html) &&
/id=['"]upcoming['"]/.test(html) &&
/karendar/i.test(html)
);
},
parse: parseKarendarEventsPage,
};
+4
View File
@@ -162,6 +162,10 @@ export const GAMES: Record<GameId, GameMeta> = {
// per-region split, so there is nothing here that could evidence a server
// map even if a clock appeared. See src/ingest/parsers/arustats.ts.
hi3: { id: "hi3", name: "Honkai Impact 3rd", short: "Honkai 3rd", hue: "#8B5CF6", studio: "HoYoverse", dailyTasks: "Daily missions, stamina" },
// No `resetOffsets`: Karendar states all times for the Global server in UTC
// with no per-region columns and no stated reset hour, so PGR takes the
// default regional reset until a source evidences an override.
pgr: { id: "pgr", name: "Punishing: Gray Raven", short: "PGR", hue: "#CC292B", studio: "Kuro Games", dailyTasks: "Daily missions, serum" },
};
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
+1
View File
@@ -22,6 +22,7 @@ export const GameId = z.enum([
// first segment of every completion key their game will ever have.
"nikke", // Goddess of Victory: Nikke
"hi3", // Honkai Impact 3rd
"pgr", // Punishing: Gray Raven
]);
export type GameId = z.infer<typeof GameId>;