Adds the game and its Game8 events page, the second of the games named in
the release thread. `parseAdjacentFullRange` reads the shape this page
uses: two full dates divided by an `<hr>` rather than a dash, so a
tag-stripping reader sees only whitespace between them. It is anchored at
both ends and needs a year on each half, because without that "August 12,
2026 Day 3 rewards" reads "Day 3" as an end.
Three of the four live rows publish. "Take Your Heart" ends 30 days after
each player makes an account, so it has no calendar date and no honest
place on a calendar. "Login Campaigns" names two candidate ends
("July 16/30, 2026") — it keeps its real start and takes no end, and the
leftover is not shown as a summary either, since a date the parser
refused to trust must not reappear dressed as information.
The page lists each live event twice: once in a bare Event|Duration table
and again under its own heading with Start Date / End Date rows and a
paragraph of prose. The second copy corroborates the dates, so deduping
now fills a missing summary from the copy it drops. Dates are still taken
wholesale from the better-dated copy and never blended.
Verified by re-extracting the page independently of the parser: 4 rows,
3 events, the fourth correctly skipped.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import {
|
||
eventId,
|
||
type EventType,
|
||
type GachaEvent,
|
||
} from "../../shared/schema.ts";
|
||
import {
|
||
parseAdjacentFullRange,
|
||
parseFullRange,
|
||
parseLabelledStartEnd,
|
||
parseMonthDayRange,
|
||
parseMonthDayYear,
|
||
parseOpenRange,
|
||
parseShortSlashRange,
|
||
parseSlashDateTimeRange,
|
||
type ParsedInstant,
|
||
} from "../dates.ts";
|
||
import { assertFlatTables, scanDocument, type DocNode } from "../html.ts";
|
||
import type { ParseContext } from "../adapters/types.ts";
|
||
import type { SourceParser } from "./types.ts";
|
||
|
||
/**
|
||
* Shared parsing for Game8 event-calendar pages.
|
||
*
|
||
* Game8 does not use one template. Across games there are (at least) three:
|
||
*
|
||
* 1. Label/value detail tables — `Event Start` / `Event End` rows under a
|
||
* per-event `h3`. Full dates with year. (Genshin Impact)
|
||
* 2. Column tables — `Event | Duration | Event Details | Rewards`, one row per
|
||
* event, under a section heading. (Neverness to Everness)
|
||
* 3. Image-grid schedules with a bare `MM/DD` and no end date. Unsupportable
|
||
* without inventing a year and an end, so it yields nothing. (Endfield)
|
||
*
|
||
* Shapes 1 and 2 are both handled here; a page may contain either or both.
|
||
* Anything undated is skipped rather than guessed — see docs/PRD.md § Quality
|
||
* bar.
|
||
*/
|
||
|
||
/** Section headings whose events belong on the calendar. */
|
||
const INCLUDED_SECTIONS = [
|
||
/current events/i,
|
||
/upcoming events/i,
|
||
/recurring events/i,
|
||
/events? schedule/i,
|
||
/featured events/i,
|
||
/list of (all )?events/i,
|
||
/all available events/i,
|
||
/ongoing events/i,
|
||
];
|
||
|
||
/**
|
||
* Sections deliberately skipped. Permanent events have no end and are not
|
||
* time-boxed; past events are over and would bury what is live.
|
||
*/
|
||
const EXCLUDED_SECTIONS = [
|
||
/permanent events/i,
|
||
/past events/i,
|
||
/previous events/i,
|
||
/ended events/i,
|
||
/finished events/i,
|
||
];
|
||
|
||
/**
|
||
* Label/value rows carrying a single boundary instant.
|
||
*
|
||
* The qualifier is optional because Persona 5 labels these `Start Date` /
|
||
* `End Date` where Genshin writes `Event Start`. Anchored at both ends, so a
|
||
* cell that merely mentions a start is not mistaken for one.
|
||
*/
|
||
const START_LABEL = /^(event|test run|banner)?\s*start(\s+date)?$/i;
|
||
const END_LABEL = /^(event|test run|banner)?\s*end(\s+date)?$/i;
|
||
/** Label/value rows carrying a whole range in one cell. */
|
||
const RANGE_LABEL = /^(availability period|event period|duration|period|dates)$/i;
|
||
|
||
/** Column-table header matchers. */
|
||
const COL_TITLE = /^(.*\b)?events?$/i;
|
||
const COL_RANGE =
|
||
/^(event |all )?(duration|dates?|event date|period|availability period|schedule)( ?& ?summary| and summary)?$/i;
|
||
const COL_START = /^start$/i;
|
||
const COL_END = /^end$/i;
|
||
const COL_SUMMARY = /^(event )?(details?|description|overview)$/i;
|
||
|
||
interface Candidate {
|
||
title: string;
|
||
summary: string | null;
|
||
start: ParsedInstant;
|
||
end: ParsedInstant | null;
|
||
}
|
||
|
||
export function parseGame8EventsPage(
|
||
html: string,
|
||
ctx: ParseContext,
|
||
): GachaEvent[] {
|
||
// The table reader assumes flat tables. Assert rather than mis-parse silently.
|
||
if (!assertFlatTables(html)) {
|
||
throw new Error(
|
||
`${ctx.sourceId}: source contains nested tables; the flat-table reader cannot parse it safely`,
|
||
);
|
||
}
|
||
|
||
const nodes = scanDocument(html);
|
||
const candidates: Candidate[] = [];
|
||
|
||
let sectionIncluded = false;
|
||
let currentTitle: string | null = null;
|
||
|
||
for (let i = 0; i < nodes.length; i += 1) {
|
||
const node = nodes[i];
|
||
if (node === undefined) continue;
|
||
|
||
// Sections are marked by h2 on some pages, h3 or h4 on others, so inclusion
|
||
// is tracked at whichever level actually names the section — Persona 5's
|
||
// finished-events table is fenced off by nothing but an h4. A heading
|
||
// matching neither list leaves the current state alone — it is an event
|
||
// name.
|
||
if (node.kind === "h2" || node.kind === "h3" || node.kind === "h4") {
|
||
const heading = node.text;
|
||
if (EXCLUDED_SECTIONS.some((re) => re.test(heading))) {
|
||
sectionIncluded = false;
|
||
currentTitle = null;
|
||
} else if (INCLUDED_SECTIONS.some((re) => re.test(heading))) {
|
||
sectionIncluded = true;
|
||
currentTitle = null;
|
||
} else if (node.kind !== "h4") {
|
||
// An unrecognised h2/h3 names an event. An unrecognised h4 does not —
|
||
// Genshin uses them for sub-headings *within* one event ("Availability
|
||
// Period", "Characters & Rewards for this Test Run"), so letting one
|
||
// claim the title would rename "Character Test Runs" to the label above
|
||
// its own date table.
|
||
currentTitle = heading;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (!sectionIncluded || node.kind !== "table") continue;
|
||
|
||
const fromStartEnd = readStartEndTable(node.headers, node.rows);
|
||
if (fromStartEnd.length > 0) {
|
||
candidates.push(...fromStartEnd);
|
||
continue;
|
||
}
|
||
|
||
const fromColumns = readColumnTable(node.rows);
|
||
if (fromColumns.length > 0) {
|
||
candidates.push(...fromColumns);
|
||
continue;
|
||
}
|
||
|
||
if (currentTitle === null) continue;
|
||
const dates = readLabelledDates(node.pairs);
|
||
if (dates === null) continue;
|
||
|
||
candidates.push({
|
||
title: currentTitle,
|
||
// Detail tables carry no description column, but the page follows them
|
||
// with a sentence of prose. That sentence is the event blurb.
|
||
summary: summaryAfter(nodes, i),
|
||
...dates,
|
||
});
|
||
// One dated table per heading; ignore follow-on reward tables until the
|
||
// next heading.
|
||
currentTitle = null;
|
||
}
|
||
|
||
return dedupe(candidates.map((c) => toEvent(c, ctx)));
|
||
}
|
||
|
||
/**
|
||
* The event blurb that follows a detail table.
|
||
*
|
||
* Scans forward only to the next heading or table, so a description never
|
||
* leaks from one event onto another. Call-to-action paragraphs ("… Event
|
||
* Guide") are skipped — they are navigation, not description.
|
||
*/
|
||
function summaryAfter(nodes: DocNode[], from: number): string | null {
|
||
for (let i = from + 1; i < nodes.length; i += 1) {
|
||
const node = nodes[i];
|
||
if (node === undefined) break;
|
||
if (node.kind !== "p") break;
|
||
if (node.isButton || node.text.length === 0) continue;
|
||
return isRequirementOnly(node.text) ? null : node.text.slice(0, 500);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Shape 1: `Event Start` / `Event End` / `Availability Period` rows. */
|
||
function readLabelledDates(
|
||
pairs: Array<{ label: string; value: string }>,
|
||
): { start: ParsedInstant; end: ParsedInstant | null } | null {
|
||
let start: ParsedInstant | null = null;
|
||
let end: ParsedInstant | null = null;
|
||
|
||
for (const { label, value } of pairs) {
|
||
if (RANGE_LABEL.test(label)) {
|
||
const range = parseRange(value);
|
||
if (range) return range;
|
||
// "Permanently Available", "TBD" — not an error, just not datable.
|
||
continue;
|
||
}
|
||
if (START_LABEL.test(label)) start ??= parseMonthDayYear(value);
|
||
if (END_LABEL.test(label)) end ??= parseMonthDayYear(value);
|
||
}
|
||
|
||
return start === null ? null : { start, end };
|
||
}
|
||
|
||
/** Shape 2: one row per event, with a title column and a range column. */
|
||
function readColumnTable(rows: string[][]): Candidate[] {
|
||
if (rows.length < 2) return [];
|
||
const header = rows[0];
|
||
if (header === undefined) return [];
|
||
|
||
const titleIdx = header.findIndex((h) => COL_TITLE.test(h));
|
||
const rangeIdx = header.findIndex((h) => COL_RANGE.test(h));
|
||
if (titleIdx === -1 || rangeIdx === -1) return [];
|
||
|
||
const summaryIdx = header.findIndex((h) => COL_SUMMARY.test(h));
|
||
|
||
const out: Candidate[] = [];
|
||
for (const row of rows.slice(1)) {
|
||
const title = row[titleIdx]?.trim();
|
||
const rangeCell = row[rangeIdx];
|
||
if (!title || rangeCell === undefined) continue;
|
||
|
||
const range = parseRange(rangeCell);
|
||
// A row we cannot date is skipped, not guessed. This is also what keeps
|
||
// year-less summary tables ("08/12 - 08/24") from producing events.
|
||
if (range === null) continue;
|
||
|
||
// Some templates fold the schedule and the blurb into one cell
|
||
// ("Period: 08/09/26 - 08/30/26 During the event, gather..."). With no
|
||
// separate column, recover the prose from what follows the dates.
|
||
const summaryCell =
|
||
summaryIdx === -1 ? proseAfterDates(rangeCell) : row[summaryIdx];
|
||
const summary =
|
||
summaryCell && summaryCell.length > 0 && !isRequirementOnly(summaryCell)
|
||
? summaryCell.slice(0, 500)
|
||
: null;
|
||
|
||
out.push({ title, summary, start: range.start, end: range.end });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Try every known range shape, most specific first. `parseOpenRange` is last
|
||
* because it is the most permissive — it accepts any leading full date and
|
||
* reports no end.
|
||
*/
|
||
/**
|
||
* Shape 4: a `Event | Duration | Start | End` table where the event name spans
|
||
* two rows and each row carries one labelled boundary:
|
||
*
|
||
* | Summer Waves Rolls In | Start | July 29, 2026 |
|
||
* | | End | September 7, 2026 |
|
||
*
|
||
* The rowspan is invisible to a flat cell reader, so the shape is recovered
|
||
* from cell counts: a row that introduces a title, then a continuation row.
|
||
*/
|
||
function readStartEndTable(headers: string[], rows: string[][]): Candidate[] {
|
||
if (!headers.some((h) => COL_START.test(h)) || !headers.some((h) => COL_END.test(h))) {
|
||
return [];
|
||
}
|
||
|
||
const out: Candidate[] = [];
|
||
for (const row of rows) {
|
||
const cells = row.map((c) => c.trim());
|
||
|
||
// [title, "Start", date] — begins an event.
|
||
if (cells.length >= 3 && COL_START.test(cells[1] ?? "")) {
|
||
const title = cells[0] ?? "";
|
||
const start = parseMonthDayYear(cells[2] ?? "");
|
||
if (title.length > 0 && start !== null) {
|
||
out.push({ title, summary: null, start, end: null });
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// ["End", date] — completes the event opened above.
|
||
if (cells.length >= 2 && COL_END.test(cells[0] ?? "")) {
|
||
const current = out.at(-1);
|
||
if (current !== undefined && current.end === null) {
|
||
current.end = parseMonthDayYear(cells[1] ?? "");
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* One date, in any shape this parser understands.
|
||
*/
|
||
const ONE_DATE =
|
||
String.raw`(?:\d{1,2}/\d{1,2}/\d{2,4}|[A-Za-z]+\.?\s+\d{1,2}(?:,\s*\d{4})?)`;
|
||
|
||
/**
|
||
* A date that carries its own year. Required of the second half when nothing
|
||
* but whitespace separates the two \u2014 "August 12, 2026 Day 3 rewards" would
|
||
* otherwise read "Day 3" as the end and eat the description.
|
||
*/
|
||
const DATED_YEAR = String.raw`(?:\d{1,2}/\d{1,2}/\d{2,4}|[A-Za-z]+\.?\s+\d{1,2},\s*\d{4})`;
|
||
|
||
/**
|
||
* A leading range, whose end may be a date or a stated non-date such as
|
||
* "Permanent" or "End of 4.6". Those words are listed rather than matched
|
||
* loosely, so a real description is never mistaken for a range end.
|
||
*/
|
||
const RANGE_PREFIX = new RegExp(
|
||
String.raw`^\s*` +
|
||
ONE_DATE +
|
||
String.raw`(?:\s*[-\u2013\u2014]\s*(?:` +
|
||
ONE_DATE +
|
||
String.raw`|permanent|tbd|ongoing|end of [\d.]+)|\s+` +
|
||
DATED_YEAR +
|
||
String.raw`)?\s*`,
|
||
"i",
|
||
);
|
||
|
||
/**
|
||
* Prose that only states how to qualify for an event, not what it is.
|
||
*
|
||
* Several templates put unlock conditions where a description would go
|
||
* ("Reach Union Level 8", "Unlocked by default"). Showing that as the summary
|
||
* fills the slot with something that never answers "what is this event?", so
|
||
* it is dropped in favour of no summary at all.
|
||
*/
|
||
function isRequirementOnly(text: string): boolean {
|
||
return /^(reach|unlock|unlocks|unlocked|require|requires|required|complete|completing|clear|clearing|obtain|available|becomes available|must |need |finish)\b/i.test(
|
||
text.trim(),
|
||
);
|
||
}
|
||
|
||
/** Strip a leading label and date range, leaving any description behind it. */
|
||
function proseAfterDates(cell: string): string | null {
|
||
// A fully labelled "Start: … End: …" cell is structure end to end. Stripping
|
||
// the leading half would leave "End: Permanent" standing where a description
|
||
// belongs — a date masquerading as a blurb.
|
||
if (parseLabelledStartEnd(cell) !== null) return null;
|
||
|
||
const rest = cell
|
||
.replace(/^\s*(period|duration|schedule|dates?)\s*[::]\s*/i, "")
|
||
.replace(RANGE_PREFIX, "")
|
||
.trim();
|
||
// Too short to be a description — probably leftover punctuation.
|
||
if (rest.length < 12) return null;
|
||
// What is left still looks like a date, so the cell was a range shape we only
|
||
// partly understood ("June 25, 2026 July 16/30, 2026" — two candidate ends,
|
||
// which is why no end was taken). Showing the leftover as the blurb would
|
||
// present a date the parser deliberately refused to trust as if it were
|
||
// information about the event.
|
||
if (/^(?:\d{1,2}[/.]|[A-Za-z]+\.?\s+\d)/.test(rest)) return null;
|
||
return isRequirementOnly(rest) ? null : rest;
|
||
}
|
||
|
||
function parseRange(
|
||
value: string,
|
||
): { start: ParsedInstant; end: ParsedInstant | null } | null {
|
||
return (
|
||
parseSlashDateTimeRange(value) ??
|
||
parseFullRange(value) ??
|
||
parseShortSlashRange(value) ??
|
||
parseMonthDayRange(value) ??
|
||
parseLabelledStartEnd(value) ??
|
||
parseAdjacentFullRange(value) ??
|
||
parseOpenRange(value)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Type is inferred from the title by keyword. This is presentation metadata
|
||
* used for filtering, not a date, so a conservative "other" default beats a
|
||
* confident mislabel.
|
||
*/
|
||
export function inferType(title: string): EventType {
|
||
const t = title.toLowerCase();
|
||
if (/\brerun\b/.test(t)) return "rerun";
|
||
if (/\b(banner|wish|warp|convene|gacha)\b/.test(t)) return "banner";
|
||
if (/\b(login|log-in|sign-in|check-in|daily bonus)\b/.test(t)) return "login";
|
||
if (/\b(challenge|trial|onslaught|abyss|tower|test runs?|clash|combat)\b/.test(t))
|
||
return "challenge";
|
||
if (/\b(shop|exchange|store)\b/.test(t)) return "shop";
|
||
if (/\bmaintenance\b/.test(t)) return "maintenance";
|
||
if (/\b(story|chapter|quest|act)\b/.test(t)) return "story";
|
||
return "other";
|
||
}
|
||
|
||
function toEvent(c: Candidate, ctx: ParseContext): GachaEvent {
|
||
// Confidence reflects how much the source actually pinned down. Day precision
|
||
// and unknown ends are legitimate and common, but they are weaker evidence
|
||
// than an exact range and the gate should be able to see that.
|
||
let confidence = 0.95;
|
||
if (c.start.precision === "day") confidence -= 0.05;
|
||
if (c.end === null) confidence -= 0.15;
|
||
else if (c.end.precision === "day") confidence -= 0.05;
|
||
|
||
return {
|
||
id: eventId(ctx.game, c.title, c.start.iso),
|
||
game: ctx.game,
|
||
title: c.title,
|
||
type: inferType(c.title),
|
||
summary: c.summary,
|
||
startsAt: c.start.iso,
|
||
startPrecision: c.start.precision,
|
||
endsAt: c.end?.iso ?? null,
|
||
endPrecision: c.end?.precision ?? "unknown",
|
||
// Game8 does not state whether an end follows per-region reset, so these
|
||
// are recorded as global rather than guessing a region split. A source that
|
||
// does state it should populate these properly.
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: ctx.sourceUrl,
|
||
sourceId: ctx.sourceId,
|
||
status: "published",
|
||
confidence: Math.round(confidence * 100) / 100,
|
||
extractionMethod: "parser",
|
||
version: 1,
|
||
firstSeenAt: ctx.now,
|
||
updatedAt: ctx.now,
|
||
};
|
||
}
|
||
|
||
/** Same event listed in two shapes on one page — keep the better-dated one. */
|
||
function dedupe(events: GachaEvent[]): GachaEvent[] {
|
||
const byId = new Map<string, GachaEvent>();
|
||
for (const e of events) {
|
||
const existing = byId.get(e.id);
|
||
if (existing === undefined || e.confidence > existing.confidence) {
|
||
// The loser can still carry a blurb the winner lacks: Persona 5 lists an
|
||
// event in a bare `Event | Duration` table and again under its own
|
||
// heading with a paragraph of prose. Dates are taken wholesale from the
|
||
// better-dated copy and never blended — only a *missing* summary is
|
||
// filled, so this can add information but never contradict any.
|
||
byId.set(
|
||
e.id,
|
||
e.summary === null && existing?.summary != null
|
||
? { ...e, summary: existing.summary }
|
||
: e,
|
||
);
|
||
} else if (existing.summary === null && e.summary !== null) {
|
||
byId.set(e.id, { ...existing, summary: e.summary });
|
||
}
|
||
}
|
||
return [...byId.values()].sort((a, b) =>
|
||
a.startsAt === b.startsAt
|
||
? a.id.localeCompare(b.id)
|
||
: a.startsAt.localeCompare(b.startsAt),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Game8 as a pluggable source parser. Registered in `parsers/index.ts`; bound
|
||
* to concrete URLs by the adapters in `adapters/index.ts`.
|
||
*/
|
||
export const game8Parser: SourceParser = {
|
||
id: "game8",
|
||
label: "Game8",
|
||
canParse(html: string): boolean {
|
||
// Structural markers, not content: if Game8 redesigns, this goes false and
|
||
// the run fails loudly instead of quietly returning zero events.
|
||
//
|
||
// Quote style varies between Game8 pages — the Genshin page emits
|
||
// class="a-table", the NTE page class='a-table' — so match either. Every
|
||
// regex in html.ts is attribute-agnostic for the same reason.
|
||
return (
|
||
/class=['"][^'"]*a-table/.test(html) &&
|
||
/class=['"][^'"]*a-header--3/.test(html)
|
||
);
|
||
},
|
||
parse: parseGame8EventsPage,
|
||
};
|