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
@@ -0,0 +1,65 @@
|
||||
[
|
||||
{
|
||||
"id": "p5x:1st-anniversary-celebration:2026-06-25",
|
||||
"game": "p5x",
|
||||
"title": "1st Anniversary Celebration",
|
||||
"type": "other",
|
||||
"summary": "June 26, 2026 marks the first anniversary for the Global and Japanese versions of Persona 5: The Phantom X!",
|
||||
"startsAt": "2026-06-25T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-07-30T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/532244",
|
||||
"sourceId": "p5x-game8-events",
|
||||
"status": "published",
|
||||
"confidence": 0.85,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "p5x:login-campaigns:2026-06-25",
|
||||
"game": "p5x",
|
||||
"title": "Login Campaigns",
|
||||
"type": "login",
|
||||
"summary": null,
|
||||
"startsAt": "2026-06-25T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": null,
|
||||
"endPrecision": "unknown",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/532244",
|
||||
"sourceId": "p5x-game8-events",
|
||||
"status": "published",
|
||||
"confidence": 0.75,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "p5x:haunted-beach-shack-summer-event-part-2:2026-07-30",
|
||||
"game": "p5x",
|
||||
"title": "Haunted Beach Shack Summer Event (Part 2)",
|
||||
"type": "other",
|
||||
"summary": "The Haunted Beach Shack Summer Event Part 2 went live on July 30, 2026 during the Version 4.6 update . Players will be given until August 13, 2026, to progress through the event and claim all of its rewards. Activities in the event include a Sea Fishing Cruise and a Boss Battle against Sahimochi-no-Kami .",
|
||||
"startsAt": "2026-07-30T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-13T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/532244",
|
||||
"sourceId": "p5x-game8-events",
|
||||
"status": "published",
|
||||
"confidence": 0.85,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -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) =>
|
||||
|
||||
@@ -54,6 +54,10 @@ export const GAMES: Record<GameId, GameMeta> = {
|
||||
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions", resetOffsets: { europe: -5 } },
|
||||
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" },
|
||||
nikki: { id: "nikki", name: "Infinity Nikki", short: "Nikki", hue: "#F27BB0" , studio: "Infold Games", dailyTasks: "Daily tasks, Vital Energy" },
|
||||
// No `resetOffsets`: nothing in the source states a server map that differs
|
||||
// from the regional default, and an offset invented here would move real
|
||||
// readers' day keys. Add one only against evidence — see games.ts § resetOffsets.
|
||||
p5x: { id: "p5x", name: "Persona 5: The Phantom X", short: "P5X", hue: "#D62246" , studio: "Perfect World", dailyTasks: "Daily missions, stamina" },
|
||||
};
|
||||
|
||||
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||
|
||||
@@ -9,6 +9,7 @@ export const GameId = z.enum([
|
||||
"endfield",
|
||||
"nte", // Neverness to Everness
|
||||
"nikki", // Infinity Nikki
|
||||
"p5x", // Persona 5: The Phantom X
|
||||
]);
|
||||
export type GameId = z.infer<typeof GameId>;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
{ adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" },
|
||||
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
|
||||
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
|
||||
{ adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" },
|
||||
];
|
||||
|
||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||
@@ -258,6 +259,59 @@ describe("nikki", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("p5x", () => {
|
||||
const fixture = "fixtures/p5x/game8-events-2026-08-17";
|
||||
|
||||
test("reads a range whose halves are separated by an <hr>", async () => {
|
||||
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
||||
const beach = events.find((e) =>
|
||||
e.title.startsWith("Haunted Beach Shack Summer Event"),
|
||||
);
|
||||
expect(beach?.startsAt).toBe("2026-07-30T00:00:00.000Z");
|
||||
expect(beach?.endsAt).toBe("2026-08-13T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("keeps the finished-events back catalogue off the calendar", async () => {
|
||||
// Fifty-odd past events sit in a table fenced off by nothing but an
|
||||
// <h4>Finished Events</h4> inside a collapsed accordion. A reader blind to
|
||||
// h4 sees one uninterrupted run of tables and publishes the lot.
|
||||
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
||||
expect(events).toHaveLength(3);
|
||||
const titles = events.map((e) => e.title);
|
||||
expect(titles).not.toContain("Tycoon Season 1"); // finished
|
||||
expect(titles).not.toContain("New Year's Gifts"); // finished
|
||||
});
|
||||
|
||||
test("skips a row whose duration states no date at all", async () => {
|
||||
// "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.
|
||||
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
||||
expect(events.map((e) => e.title)).not.toContain("Take Your Heart");
|
||||
});
|
||||
|
||||
test("takes no end from an ambiguous one, and does not show it as prose", async () => {
|
||||
// "June 25, 2026 July 16/30, 2026" names two candidate ends. Picking either
|
||||
// would be a guess, and echoing the leftover into the summary would dress
|
||||
// the same guess up as information.
|
||||
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
||||
const login = events.find((e) => e.title === "Login Campaigns");
|
||||
expect(login?.startsAt).toBe("2026-06-25T00:00:00.000Z");
|
||||
expect(login?.endsAt).toBeNull();
|
||||
expect(login?.summary).toBeNull();
|
||||
});
|
||||
|
||||
test("recovers the blurb from the event's own section", async () => {
|
||||
// The event is listed twice: once in a bare Event|Duration table, and again
|
||||
// under its own heading with a paragraph of prose. Deduping by ID must not
|
||||
// throw the prose away.
|
||||
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
||||
const anniversary = events.find(
|
||||
(e) => e.title === "1st Anniversary Celebration",
|
||||
);
|
||||
expect(anniversary?.summary).toContain("first anniversary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wiki.gg parser", () => {
|
||||
test("reads exact per-region timers", async () => {
|
||||
// The first source that states region-scoped ends. Asia and the Americas
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseAdjacentFullRange,
|
||||
parseFullRange,
|
||||
parseLabelledStartEnd,
|
||||
parseMonthDayRange,
|
||||
@@ -129,6 +130,39 @@ describe("parseOpenRange", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAdjacentFullRange", () => {
|
||||
test("reads two dates separated by nothing but whitespace", () => {
|
||||
// Persona 5 separates the halves of a duration cell with an <hr>, which a
|
||||
// tag-stripping reader flattens to a single space.
|
||||
expect(parseAdjacentFullRange("July 30, 2026 August 13, 2026")).toEqual({
|
||||
start: { iso: "2026-07-30T00:00:00.000Z", precision: "day" },
|
||||
end: { iso: "2026-08-13T00:00:00.000Z", precision: "day" },
|
||||
});
|
||||
});
|
||||
|
||||
test("requires a year on both halves", () => {
|
||||
expect(parseAdjacentFullRange("July 30, 2026 August 13")).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses a second half that is only partly a date", () => {
|
||||
// "July 16/30" names two candidate ends. Reading either as the end would
|
||||
// be a coin flip presented as a fact.
|
||||
expect(parseAdjacentFullRange("June 25, 2026 July 16/30, 2026")).toBeNull();
|
||||
});
|
||||
|
||||
test("is anchored, so prose after a date is not read as an end", () => {
|
||||
// Without the anchors, "Day 3" parses as a month and a day.
|
||||
expect(
|
||||
parseAdjacentFullRange("August 12, 2026 Day 3 rewards are doubled"),
|
||||
).toBeNull();
|
||||
expect(parseAdjacentFullRange("Starts August 12, 2026")).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an impossible calendar date rather than rolling it over", () => {
|
||||
expect(parseAdjacentFullRange("February 30, 2026 March 4, 2026")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLabelledStartEnd", () => {
|
||||
test("reads a labelled cell", () => {
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user