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:
@@ -37,8 +37,8 @@ A web app that aggregates live and upcoming events across popular gacha games, p
|
|||||||
calendar, sorts them by end date or by what the reader is partway through, tracks day-by-day
|
calendar, sorts them by end date or by what the reader is partway through, tracks day-by-day
|
||||||
progress on events that repeat daily, and lets a user mark events completed.
|
progress on events that repeat daily, and lets a user mark events completed.
|
||||||
|
|
||||||
**Status: working app, refreshing itself on a schedule.** Schema, nine parsers, twenty-one sources across
|
**Status: working app, refreshing itself on a schedule.** Schema, ten parsers, twenty-two sources across
|
||||||
nineteen games, the full interface, offline support, a static server, a Docker image and CI all exist and
|
twenty games, the full interface, offline support, a static server, a Docker image and CI all exist and
|
||||||
are tested. The refresh runner (`bun run refresh`) fetches, caches raw snapshots and rebuilds the
|
are tested. The refresh runner (`bun run refresh`) fetches, caches raw snapshots and rebuilds the
|
||||||
feed; `.github/workflows/refresh.yml` runs it twice a day and commits only when a page actually
|
feed; `.github/workflows/refresh.yml` runs it twice a day and commits only when a page actually
|
||||||
changed. The SQLite layer and the review queue are still specified in `docs/` but not built, so the
|
changed. The SQLite layer and the review queue are still specified in `docs/` but not built, so the
|
||||||
@@ -133,7 +133,7 @@ src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.
|
|||||||
src/ingest/ html.ts, dates.ts (sixteen formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts
|
src/ingest/ html.ts, dates.ts (sixteen formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts
|
||||||
health.ts — which of the three empties a source's zero was; pure
|
health.ts — which of the three empties a source's zero was; pure
|
||||||
parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts,
|
parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts,
|
||||||
stellasora.ts — keyed by SITE, not game
|
stellasora.ts, arustats.ts, karendar.ts — keyed by SITE, not game
|
||||||
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
||||||
src/client/ React app, service worker, manifest
|
src/client/ React app, service worker, manifest
|
||||||
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
||||||
@@ -145,7 +145,7 @@ src/client/ React app, service worker, manifest
|
|||||||
theme.ts — dark or light, and what a game hue reads as on each
|
theme.ts — dark or light, and what a game hue reads as on each
|
||||||
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 1,071 tests
|
test/ 1,099 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
snapshots/ current page per source, rewritten by refresh — see its README
|
||||||
```
|
```
|
||||||
@@ -386,6 +386,7 @@ re-litigated each pass:
|
|||||||
| `infinitynikki.miraheze.org` | **Declined.** Exists and serves `robots.txt`, but the wiki is abandoned — front page last edited 11 February 2025 and `/wiki/Events` returns a permission error. Checked as a replacement for the stale Infinity Nikki Game8 page |
|
| `infinitynikki.miraheze.org` | **Declined.** Exists and serves `robots.txt`, but the wiki is abandoned — front page last edited 11 February 2025 and `/wiki/Events` returns a permission error. Checked as a replacement for the stale Infinity Nikki Game8 page |
|
||||||
| `prydwen.gg/infinity-nikki` | **Declined.** 404 — prydwen does not cover Infinity Nikki |
|
| `prydwen.gg/infinity-nikki` | **Declined.** 404 — prydwen does not cover Infinity Nikki |
|
||||||
| `grayravens.com` (Punishing: Gray Raven) | **Declined.** Conduct is fine; the data is not. The whole 626 KB `/wiki/Events` page contains exactly one date range, written as prose, one event per six-week patch |
|
| `grayravens.com` (Punishing: Gray Raven) | **Declined.** Conduct is fine; the data is not. The whole 626 KB `/wiki/Events` page contains exactly one date range, written as prose, one event per six-week patch |
|
||||||
|
| `karendar.com` (Punishing: Gray Raven) | **Built** (2026-09-12). Fan-made PGR event calendar for Global. Full SSR HTML with 57 active events across week/ongoing/upcoming. Exact UTC timestamps, `robots.txt` allows `/`, no auth or API required |
|
||||||
| `guardian-tales.fandom.com` | **Declined.** Parses fine and contains no 2026 date at all — newest dated entry is 2025. The `bluearchive.fandom.com` failure again: parses cleanly to nothing live |
|
| `guardian-tales.fandom.com` | **Declined.** Parses fine and contains no 2026 date at all — newest dated entry is 2025. The `bluearchive.fandom.com` failure again: parses cleanly to nothing live |
|
||||||
| `blhx.fandom.com`, `azurlane-archive.fandom.com` | **Declined.** The two Fandom alternatives to the declined koumakan wiki are dead archives — `Event_Calendar` stops in **2021**, and the archive wiki's headings have nothing under them. Azur Lane still has no source |
|
| `blhx.fandom.com`, `azurlane-archive.fandom.com` | **Declined.** The two Fandom alternatives to the declined koumakan wiki are dead archives — `Event_Calendar` stops in **2021**, and the archive wiki's headings have nothing under them. Azur Lane still has no source |
|
||||||
| Aether Gazer | **Do not build.** The developer confirmed no further content updates after 23 July 2026, with store listings removed 17 October 2026. The wiki dates nothing anyway — `Event_Guide_List` is an image gallery. A lane that will be empty by winter |
|
| Aether Gazer | **Do not build.** The developer confirmed no further content updates after 23 July 2026, with store listings removed 17 October 2026. The wiki dates nothing anyway — `Event_Guide_List` is an image gallery. A lane that will be empty by winter |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ review queue are specified but not built.
|
|||||||
| Piece | State |
|
| Piece | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Event schema, date parsing, Game8 parser | Built, tested |
|
| Event schema, date parsing, Game8 parser | Built, tested |
|
||||||
| Twenty-one sources across nineteen games | Built, tested |
|
| Twenty-two sources across twenty games | Built, tested |
|
||||||
| Your own games and events, one-off or repeating | Built, tested |
|
| Your own games and events, one-off or repeating | Built, tested |
|
||||||
| Cross-source merge and conflict detection | Built, tested |
|
| Cross-source merge and conflict detection | Built, tested |
|
||||||
| Input sanitization at the ingest boundary | Built, tested |
|
| Input sanitization at the ingest boundary | Built, tested |
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Consequences worth internalising:
|
|||||||
| `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 |
|
| `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 |
|
||||||
| `stellasorawiki` | stellasora.miraheze.org's front-page `Current Banners` module — `<time datetime>` pairs inside `stellasora-home-banner` blocks | Stella Sora |
|
| `stellasorawiki` | stellasora.miraheze.org's front-page `Current Banners` module — `<time datetime>` pairs inside `stellasora-home-banner` blocks | Stella Sora |
|
||||||
| `arustats` | arustats.com's Next.js `__NEXT_DATA__` blob — a per-version week grid, with events carrying `startWeek`/`endWeek` **integers** rather than dates. **Estimated boundaries** — see AGENTS.md § Honkai Impact 3rd | Honkai Impact 3rd |
|
| `arustats` | arustats.com's Next.js `__NEXT_DATA__` blob — a per-version week grid, with events carrying `startWeek`/`endWeek` **integers** rather than dates. **Estimated boundaries** — see AGENTS.md § Honkai Impact 3rd | Honkai Impact 3rd |
|
||||||
|
| `karendar` | karendar.com's server-rendered `<article>` cards under `week`, `ongoing`, and `upcoming` sections — exact UTC timestamps | Punishing: Gray Raven |
|
||||||
|
|
||||||
`wikigg` is the better shape by a distance: it emits ISO timestamps with one timer per server
|
`wikigg` is the better shape by a distance: it emits ISO timestamps with one timer per server
|
||||||
region, so its events carry exact precision and real `regionEnds`. Prefer a source like that over a
|
region, so its events carry exact precision and real `regionEnds`. Prefer a source like that over a
|
||||||
|
|||||||
+33
-17
@@ -55,7 +55,8 @@ Two things this method cannot tell you, and both matter:
|
|||||||
| **Girls' Frontline 2, Stella Sora, Chaos Zero Nightmare, Umamusume** | **Built** (2026-08-19) |
|
| **Girls' Frontline 2, Stella Sora, Chaos Zero Nightmare, Umamusume** | **Built** (2026-08-19) |
|
||||||
| Nikke | **Built** (2026-08-19), once the `robots.txt` was read in a browser — see § 3 |
|
| Nikke | **Built** (2026-08-19), once the `robots.txt` was read in a browser — see § 3 |
|
||||||
| Azur Lane | Declined on conduct (`azurlane.koumakan.jp` `ai-input=no`), and both Fandom alternatives are dead archives |
|
| Azur Lane | Declined on conduct (`azurlane.koumakan.jp` `ai-input=no`), and both Fandom alternatives are dead archives |
|
||||||
| Punishing: Gray Raven, Guardian Tales, Aether Gazer | Declined — see §§ 6, 7, 9 |
|
| Punishing: Gray Raven | **Built** (2026-09-12) on `karendar.com` — see § 6 |
|
||||||
|
| Guardian Tales, Aether Gazer | Declined — see §§ 7, 9 |
|
||||||
| Infinity Nikki | **Rebuilt** (2026-08-19) on `infinity-nikki.fandom.com` at day precision, replacing a Game8 page stale since August 2025 — see § 11 |
|
| Infinity Nikki | **Rebuilt** (2026-08-19) on `infinity-nikki.fandom.com` at day precision, replacing a Game8 page stale since August 2025 — see § 11 |
|
||||||
| Silver Palace | Unreleased |
|
| Silver Palace | Unreleased |
|
||||||
| Honkai Impact 3rd | **Built** (2026-08-27) on `arustats.com`, and the only lane here whose dates are **estimates** — see § 14 |
|
| Honkai Impact 3rd | **Built** (2026-08-27) on `arustats.com`, and the only lane here whose dates are **estimates** — see § 14 |
|
||||||
@@ -70,7 +71,7 @@ Two things this method cannot tell you, and both matter:
|
|||||||
| Nikke | `nikke-…-international.fandom.com/api.php?…page=Event` | `fandom`, third template | **no** — Fandom robots fails closed | **BUILT** 2026-08-19 — story events *and* dated pickup banners, with an evidenced reset clock |
|
| Nikke | `nikke-…-international.fandom.com/api.php?…page=Event` | `fandom`, third template | **no** — Fandom robots fails closed | **BUILT** 2026-08-19 — story events *and* dated pickup banners, with an evidenced reset clock |
|
||||||
| Chaos Zero Nightmare | `game8.co/games/Chaos-Zero-Nightmare/archives/559899` | **existing** `game8` | **no** — game8 202s the runner | **BUILT** 2026-08-19 — no parser work, fixture-only lane |
|
| Chaos Zero Nightmare | `game8.co/games/Chaos-Zero-Nightmare/archives/559899` | **existing** `game8` | **no** — game8 202s the runner | **BUILT** 2026-08-19 — no parser work, fixture-only lane |
|
||||||
| Umamusume | `game8.co/games/Umamusume-Pretty-Derby/archives/536311` | `game8` + section and column vocabulary | **no** | **BUILT** 2026-08-19 — cost more than "two header words"; see § 5 |
|
| Umamusume | `game8.co/games/Umamusume-Pretty-Derby/archives/536311` | `game8` + section and column vocabulary | **no** | **BUILT** 2026-08-19 — cost more than "two header words"; see § 5 |
|
||||||
| Punishing: Gray Raven | `grayravens.com/wiki/Events` | — | — | **Decline for now** — one dated string on the whole page |
|
| Punishing: Gray Raven | `karendar.com` | new (`karendar`) | yes (Cloudflare / open robots) | **BUILT** 2026-09-12 — 57 active events across week/ongoing/upcoming, exact UTC timestamps |
|
||||||
| Guardian Tales | `guardian-tales.fandom.com/wiki/Events` | — | — | **Decline** — wiki stopped dating events in 2025 |
|
| Guardian Tales | `guardian-tales.fandom.com/wiki/Events` | — | — | **Decline** — wiki stopped dating events in 2025 |
|
||||||
| Azur Lane | none | — | — | **Still declined** — the Fandom alternatives are 2021 archives |
|
| Azur Lane | none | — | — | **Still declined** — the Fandom alternatives are 2021 archives |
|
||||||
| Aether Gazer | — | — | — | **Do not build. The game is shutting down.** |
|
| Aether Gazer | — | — | — | **Do not build. The game is shutting down.** |
|
||||||
@@ -298,26 +299,41 @@ Stella Sora, and worth doing as two commits (parser vocabulary, then the source)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Punishing: Gray Raven — decline for now
|
## 6. Punishing: Gray Raven — BUILT 2026-09-12
|
||||||
|
|
||||||
`grayravens.com` is a Miraheze wiki and the community's officially supported fansite; conduct is fine
|
**Source:** `https://karendar.com/` (Karendar, community PGR event calendar)
|
||||||
(`/wiki/` allowed, `/w/` and `?action=` disallowed, `Crawl-delay` only for named bots). The data is
|
|
||||||
the problem.
|
|
||||||
|
|
||||||
`/wiki/Events` is a single patch guide — "Ongoing Events" is one version's content, written as prose.
|
Previously declined on `grayravens.com` (a single date range in 626 KB of prose) and Fandom (abandoned).
|
||||||
Stripped of markup, the **entire 626 KB page contains exactly one date range**:
|
`karendar.com` is a dedicated, active Punishing: Gray Raven event tracker for the Global server.
|
||||||
|
|
||||||
```
|
### Conduct and robots
|
||||||
Duration: July 17th to August 18th 2026.
|
|
||||||
```
|
|
||||||
|
|
||||||
Ordinal day, year on the second boundary only, one event per six-week patch. `/wiki/PGR_Roadmap` adds
|
- `robots.txt` has:
|
||||||
a `Patch | Est. Release | …` table — an estimate, and a start with no end. The Fandom wiki is worse:
|
```
|
||||||
`Events` does not exist, `Upcoming Content` is 2025-era and says its dates are "ESTIMATED", and the
|
User-agent: *
|
||||||
search API returns nothing for `intitle:Event`.
|
Allow: /
|
||||||
|
|
||||||
An adapter here would publish one guessed-ish event per patch. Decline, and recheck if grayravens
|
Disallow: /login
|
||||||
ever puts the schedule in a table.
|
Disallow: /this-week
|
||||||
|
Disallow: /api/
|
||||||
|
```
|
||||||
|
- Target path is `/`, which is explicitly allowed. `/api/`, `/this-week`, and `/login` are not touched.
|
||||||
|
- No automated access prohibition or scraping restrictions in `/about` or `/privacy`.
|
||||||
|
- Serves server-rendered HTML with no client JavaScript required to read event data.
|
||||||
|
|
||||||
|
### Structure and dates
|
||||||
|
|
||||||
|
The page server-renders `<article>` elements grouped under section headers:
|
||||||
|
- `week` ("Ends this week"): ongoing events ending in the current week (moved out of `ongoing` rather than duplicated).
|
||||||
|
- `ongoing` ("On-going"): live ongoing events.
|
||||||
|
- `upcoming` ("Upcoming"): announced upcoming banners, events, and maintenance.
|
||||||
|
- `archive` ("Archive"): past concluded events; skipped.
|
||||||
|
- `tbc` ("TBC"): unannounced dates (both start and end marked "Unknown"); skipped because start is undated.
|
||||||
|
- `codes` ("Active codes"): in-game redemption codes; skipped.
|
||||||
|
|
||||||
|
Every event specifies an explicit minute-precision UTC instant (`Mon, 7 Sept 2026, 07:00 UTC`).
|
||||||
|
Indefinite or permanent events (`Permanent` or `Unknown` ends) map cleanly to `endsAt: null` with `endPrecision: "unknown"`.
|
||||||
|
Yields 57 events on initial ingestion with zero conflicts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -247,6 +247,16 @@ const SOURCES: SourceSpec[] = [
|
|||||||
url: "https://www.arustats.com/en-us/hi3/timeline",
|
url: "https://www.arustats.com/en-us/hi3/timeline",
|
||||||
parserId: "arustats",
|
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 {
|
function toAdapter(spec: SourceSpec): Adapter {
|
||||||
|
|||||||
@@ -692,3 +692,30 @@ export function parseZonelessClockRange(
|
|||||||
end: { iso: endIso, precision: "day" },
|
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 { game8Parser } from "./game8.ts";
|
||||||
import { holodoriWikiParser } from "./holodori.ts";
|
import { holodoriWikiParser } from "./holodori.ts";
|
||||||
import { iopWikiParser } from "./iopwiki.ts";
|
import { iopWikiParser } from "./iopwiki.ts";
|
||||||
|
import { karendarParser } from "./karendar.ts";
|
||||||
import { stellaSoraWikiParser } from "./stellasora.ts";
|
import { stellaSoraWikiParser } from "./stellasora.ts";
|
||||||
import { wikiGgParser } from "./wikigg.ts";
|
import { wikiGgParser } from "./wikigg.ts";
|
||||||
import type { SourceParser } from "./types.ts";
|
import type { SourceParser } from "./types.ts";
|
||||||
@@ -24,6 +25,7 @@ export const PARSERS: SourceParser[] = [
|
|||||||
iopWikiParser,
|
iopWikiParser,
|
||||||
stellaSoraWikiParser,
|
stellaSoraWikiParser,
|
||||||
aruStatsParser,
|
aruStatsParser,
|
||||||
|
karendarParser,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function parserById(id: string): SourceParser | undefined {
|
export function parserById(id: string): SourceParser | undefined {
|
||||||
@@ -38,5 +40,6 @@ export { fandomParser } from "./fandom.ts";
|
|||||||
export { game8Parser } from "./game8.ts";
|
export { game8Parser } from "./game8.ts";
|
||||||
export { holodoriWikiParser } from "./holodori.ts";
|
export { holodoriWikiParser } from "./holodori.ts";
|
||||||
export { iopWikiParser } from "./iopwiki.ts";
|
export { iopWikiParser } from "./iopwiki.ts";
|
||||||
|
export { karendarParser } from "./karendar.ts";
|
||||||
export { stellaSoraWikiParser } from "./stellasora.ts";
|
export { stellaSoraWikiParser } from "./stellasora.ts";
|
||||||
export { wikiGgParser } from "./wikigg.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
|
// 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.
|
// 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" },
|
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);
|
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.
|
// first segment of every completion key their game will ever have.
|
||||||
"nikke", // Goddess of Victory: Nikke
|
"nikke", // Goddess of Victory: Nikke
|
||||||
"hi3", // Honkai Impact 3rd
|
"hi3", // Honkai Impact 3rd
|
||||||
|
"pgr", // Punishing: Gray Raven
|
||||||
]);
|
]);
|
||||||
export type GameId = z.infer<typeof GameId>;
|
export type GameId = z.infer<typeof GameId>;
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
|||||||
// 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.
|
||||||
{ adapter: adapter("hi3-arustats-events"), fixture: "fixtures/hi3/arustats-events-2026-08-27" },
|
{ adapter: adapter("hi3-arustats-events"), fixture: "fixtures/hi3/arustats-events-2026-08-27" },
|
||||||
|
// Punishing: Gray Raven (karendar.com). Exact UTC timestamps for the Global server.
|
||||||
|
{ adapter: adapter("pgr-karendar-events"), fixture: "fixtures/pgr/karendar-events-2026-09-12" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
parseYearFirstSlashRange,
|
parseYearFirstSlashRange,
|
||||||
parseIsoClockRangeUtc,
|
parseIsoClockRangeUtc,
|
||||||
parseIsoOffsetInstant,
|
parseIsoOffsetInstant,
|
||||||
|
parseWeekdayDayMonthYearUtc,
|
||||||
} from "../src/ingest/dates.ts";
|
} from "../src/ingest/dates.ts";
|
||||||
|
|
||||||
describe("parseMonthDayYear", () => {
|
describe("parseMonthDayYear", () => {
|
||||||
@@ -539,3 +540,44 @@ describe("parseIsoOffsetInstant", () => {
|
|||||||
expect(parseIsoOffsetInstant("Starts 2026-08-03T21:00-07:00")).toBeNull();
|
expect(parseIsoOffsetInstant("Starts 2026-08-03T21:00-07:00")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseWeekdayDayMonthYearUtc", () => {
|
||||||
|
test("parses full weekday date with exact UTC time", () => {
|
||||||
|
expect(
|
||||||
|
parseWeekdayDayMonthYearUtc("Mon, 7 Sept 2026, 07:00 UTC"),
|
||||||
|
).toEqual({
|
||||||
|
iso: "2026-09-07T07:00:00.000Z",
|
||||||
|
precision: "exact",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
parseWeekdayDayMonthYearUtc("Thu, 20 Aug 2026, 05:00 UTC"),
|
||||||
|
).toEqual({
|
||||||
|
iso: "2026-08-20T05:00:00.000Z",
|
||||||
|
precision: "exact",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts input without weekday or comma", () => {
|
||||||
|
expect(parseWeekdayDayMonthYearUtc("7 Sept 2026 07:00 UTC")?.iso).toBe(
|
||||||
|
"2026-09-07T07:00:00.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional seconds", () => {
|
||||||
|
expect(
|
||||||
|
parseWeekdayDayMonthYearUtc("Wed, 23 Sept 2026, 23:00:00 UTC")?.iso,
|
||||||
|
).toBe("2026-09-23T23:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for non-date strings like Permanent or Unknown", () => {
|
||||||
|
expect(parseWeekdayDayMonthYearUtc("Permanent")).toBeNull();
|
||||||
|
expect(parseWeekdayDayMonthYearUtc("Unknown")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing year or invalid dates", () => {
|
||||||
|
expect(parseWeekdayDayMonthYearUtc("Mon, 7 Sept, 07:00 UTC")).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseWeekdayDayMonthYearUtc("Mon, 30 Feb 2026, 07:00 UTC"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user