feat(p5x): track Persona 5: The Phantom X
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
67951e81b4
commit
d8b539bce1
@@ -72,6 +72,12 @@ const SOURCES: SourceSpec[] = [
|
||||
url: "https://game8.co/games/Infinity-Nikki/archives/487445",
|
||||
parserId: "game8",
|
||||
},
|
||||
{
|
||||
id: "p5x-game8-events",
|
||||
game: "p5x",
|
||||
url: "https://game8.co/games/Persona-5-Phantom-X/archives/532244",
|
||||
parserId: "game8",
|
||||
},
|
||||
];
|
||||
|
||||
function toAdapter(spec: SourceSpec): Adapter {
|
||||
|
||||
@@ -147,6 +147,37 @@ export function parseShortSlashRange(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "July 30, 2026 August 13, 2026" → both instants.
|
||||
*
|
||||
* Game8 separates the two halves of a duration cell with `<hr>` rather than a
|
||||
* dash on some templates, and a tag-stripping reader sees only whitespace
|
||||
* between them. Both anchors and both years are required: without the anchors
|
||||
* this would happily read "August 12, 2026 Day 3 rewards" as a range, and a
|
||||
* bare "Month D" second half could belong to any year.
|
||||
*/
|
||||
export function parseAdjacentFullRange(
|
||||
input: string,
|
||||
): { start: ParsedInstant; end: ParsedInstant } | null {
|
||||
const re =
|
||||
/^\s*([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})\s+([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})\s*$/;
|
||||
const m = re.exec(input);
|
||||
if (!m) return null;
|
||||
|
||||
const startMonth = monthNumber(m[1] ?? "");
|
||||
const endMonth = monthNumber(m[4] ?? "");
|
||||
if (startMonth === null || endMonth === null) return null;
|
||||
|
||||
const startIso = iso(Number(m[3]), startMonth, Number(m[2]));
|
||||
const endIso = iso(Number(m[6]), endMonth, Number(m[5]));
|
||||
if (startIso === null || endIso === null) return null;
|
||||
|
||||
return {
|
||||
start: { iso: startIso, precision: "day" },
|
||||
end: { iso: endIso, precision: "day" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Start: January 24, 2025 End: Permanent" → the start, and whatever the end
|
||||
* half turns out to be.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type GachaEvent,
|
||||
} from "../../shared/schema.ts";
|
||||
import {
|
||||
parseAdjacentFullRange,
|
||||
parseFullRange,
|
||||
parseLabelledStartEnd,
|
||||
parseMonthDayRange,
|
||||
@@ -41,7 +42,8 @@ const INCLUDED_SECTIONS = [
|
||||
/recurring events/i,
|
||||
/events? schedule/i,
|
||||
/featured events/i,
|
||||
/list of events/i,
|
||||
/list of (all )?events/i,
|
||||
/all available events/i,
|
||||
/ongoing events/i,
|
||||
];
|
||||
|
||||
@@ -57,9 +59,15 @@ const EXCLUDED_SECTIONS = [
|
||||
/finished events/i,
|
||||
];
|
||||
|
||||
/** Label/value rows carrying a single boundary instant. */
|
||||
const START_LABEL = /^(event|test run|banner)\s+start$/i;
|
||||
const END_LABEL = /^(event|test run|banner)\s+end$/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;
|
||||
|
||||
@@ -284,6 +292,13 @@ function readStartEndTable(headers: string[], rows: string[][]): Candidate[] {
|
||||
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
|
||||
@@ -294,7 +309,9 @@ const RANGE_PREFIX = new RegExp(
|
||||
ONE_DATE +
|
||||
String.raw`(?:\s*[-\u2013\u2014]\s*(?:` +
|
||||
ONE_DATE +
|
||||
String.raw`|permanent|tbd|ongoing|end of [\d.]+))?\s*`,
|
||||
String.raw`|permanent|tbd|ongoing|end of [\d.]+)|\s+` +
|
||||
DATED_YEAR +
|
||||
String.raw`)?\s*`,
|
||||
"i",
|
||||
);
|
||||
|
||||
@@ -325,6 +342,12 @@ function proseAfterDates(cell: string): string | null {
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -337,6 +360,7 @@ function parseRange(
|
||||
parseShortSlashRange(value) ??
|
||||
parseMonthDayRange(value) ??
|
||||
parseLabelledStartEnd(value) ??
|
||||
parseAdjacentFullRange(value) ??
|
||||
parseOpenRange(value)
|
||||
);
|
||||
}
|
||||
@@ -400,7 +424,19 @@ function dedupe(events: GachaEvent[]): GachaEvent[] {
|
||||
for (const e of events) {
|
||||
const existing = byId.get(e.id);
|
||||
if (existing === undefined || e.confidence > existing.confidence) {
|
||||
byId.set(e.id, e);
|
||||
// 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) =>
|
||||
|
||||
Reference in New Issue
Block a user