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:
@@ -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 {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user