feat(ingest): read a stated end from a Genshin article
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74c91a7143
commit
7f086ece36
7 files changed
+373
-11
No files matched your search
@@ -0,0 +1 @@
|
||||
{"parse":{"title":"Event","pageid":7573,"text":"<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\"><div class=\"custom-tabs-default custom-tabs\"><span class=\"active-tab\"><strong class=\"mw-selflink selflink\">Overview</strong></span><span class=\"inactive-tab\"><a href=\"/wiki/Event/History\" title=\"Event/History\">History</a></span><span class=\"inactive-tab\"><a href=\"/wiki/Event/Rewards\" title=\"Event/Rewards\">Rewards</a></span></div>\n<p class=\"mw-empty-elt\">\n</p><aside role=\"region\" class=\"portable-infobox pi-background pi-border-color pi-theme-wikia pi-layout-default\">\n<h2 class=\"pi-item pi-item-spacing pi-title pi-secondary-background\" data-source=\"title\">Event</h2>\n\n<figure class=\"pi-item pi-image\" data-source=\"image\">\n\t<a href=\"https://static.wikia.nocookie.net/gensin-impact/images/d/d4/Icon_Events.png/revision/latest?cb=20230427092958\" class=\"image image-thumbnail\" title=\"Icon Events\">\n\t\t<img src=\"https://static.wikia.nocookie.net/gensin-impact/images/d/d4/Icon_Events.png/revision/latest?cb=20230427092958\" srcset=\"https://static.wikia.nocookie.net/gensin-impact/images/d/d4/Icon_Events.png/revision/latest?cb=20230427092958 1x, https://static.wikia.nocookie.net/gensin-impact/images/d/d4/Icon_Events.png/revision/latest?cb=20230427092958 2x\" class=\"pi-image-thumbnail\" data-relevant=\"1\" alt=\"Icon Events\" width=\"72\" height=\"72\" data-image-key=\"Icon_Events.png\" data-image-name=\"Icon Events.png\" />\n\t\t\n\t</a>\n\t\n</figure>\n\n<div class=\"pi-item pi-data pi-item-spacing pi-border-color\" data-source=\"type\">\n\t\n\t\t<h3 class=\"pi-data-label pi-secondary-font\">Type</h3>\n\t\n\t<div class=\"pi-data-value pi-font\"><a href=\"/wiki/Game_Systems\" class=\"mw-redirect\" title=\"Game Systems\">Game Systems</a></div>\n</div>\n\n<div class=\"pi-item pi-data pi-item-spacing pi-border-color\" data-source=\"group\">\n\t\n\t\t<h3 class=\"pi-data-label pi-secondary-font\">Group</h3>\n\t\n\t<div class=\"pi-data-value pi-font\"><a href=\"/wiki/Menus\" class=\"mw-redirect\" title=\"Menus\">Menus</a></div>\n</div>\n</aside>\n\n<p class=\"mw-empty-elt\"></p><p><b>Events</b> are limited time activities in <i><a href=\"/wiki/Genshin_Impact\" title=\"Genshin Impact\">Genshin Impact</a></i> that allows the player to earn <a href=\"/wiki/Event/Rewards\" title=\"Event/Rewards\">rewards</a>. Players must be <a href=\"/wiki/Adventure_Rank\" title=\"Adventure Rank\">Adventure Rank</a> 5 to participate in Events, but most have additional requirements. Some Events include <a href=\"/wiki/Quest\" title=\"Quest\">Quests</a> that are not available after the Event duration has ended.\n</p><p>The Events Overview menu can be accessed from the <a href=\"/wiki/Paimon_Menu\" title=\"Paimon Menu\">Paimon Menu</a>, from the <a href=\"/wiki/Shortcut_Wheel\" title=\"Shortcut Wheel\">Shortcut Wheel</a>, from the <span><span typeof=\"mw:File\"><span><img alt=\"Icon Events\" src=\"https://static.wikia.nocookie.net/gensin-impact/images/d/d4/Icon_Events.png/revision/latest/scale-to-width-down/20?cb=20230427092958\" decoding=\"async\" loading=\"lazy\" width=\"20\" height=\"20\" class=\"mw-file-element\" data-image-name=\"Icon Events.png\" data-image-key=\"Icon_Events.png\" data-relevant=\"0\" /></span></span></span> icon in the top right of screen, or pressing the <kbd class=\"custom-key nowrap\" title=\"F5\">F5</kbd> key on PC.\n</p><p>Some Events allow the player to skip quests requirements using the <a href=\"/wiki/Quick_Start\" title=\"Quick Start\">Quick Start</a> feature.\n</p>\n<div id=\"toc\" class=\"toc\" role=\"navigation\" aria-labelledby=\"mw-toc-heading\"><input type=\"checkbox\" role=\"button\" id=\"toctogglecheckbox\" class=\"toctogglecheckbox\" style=\"display:none\" /><div class=\"toctitle\" lang=\"en\" dir=\"ltr\"><h2 id=\"mw-toc-heading\">Contents</h2><span class=\"toctogglespan\"><label class=\"toctogglelabel\" for=\"toctogglecheckbox\"></label></span></div>\n<ul>\n<li class=\"toclevel-1 tocsection-1\"><a href=\"#List_of_Events\"><span class=\"tocnumber\">1</span> <span class=\"toctext\">List of Events</span></a>\n<ul>\n<li class=\"toclevel-2\"><a href=\"#Current\"><span class=\"tocnumber\">1.1</span> <span class=\"toctext\">Current</span></a></li>\n<li class=\"toclevel-2\"><a href=\"#Upcoming\"><span class=\"tocnumber\">1.2</span> <span class=\"toctext\">Upcoming</span></a></li>\n<li class=\"toclevel-2\"><a href=\"#Permanent\"><span class=\"tocnumber\">1.3</span> <span class=\"toctext\">Permanent</span></a></li>\n</ul>\n</li>\n<li class=\"toclevel-1 tocsection-2\"><a href=\"#List_of_Event_Types\"><span class=\"tocnumber\">2</span> <span class=\"toctext\">List of Event Types</span></a></li>\n<li class=\"toclevel-1 tocsection-3\"><a href=\"#List_of_Recurring_Events\"><span class=\"tocnumber\">3</span> <span class=\"toctext\">List of Recurring Events</span></a></li>\n<li class=\"toclevel-1 tocsection-4\"><a href=\"#Navigation\"><span class=\"tocnumber\">Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"parse":{"title":"Hour of Ruin (Event)","pageid":500845,"text":"<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\"><i><div role=\"note\" class=\"notice hatnote context-link about dablink\">This article is about the <a href=\"/wiki/Version_7.2\" class=\"mw-redirect\" title=\"Version 7.2\">Version 7.2</a> event. For the Archon Quest, see <a href=\"/wiki/Hour_of_Ruin\" title=\"Hour of Ruin\">Hour of Ruin</a>.</div></i>\n<div class=\"mbox stub\"><div class=\"mbox__content\"><div class=\"mbox__content__image mw-collapsible\" id=\"mw-customcollapsible-stub\"><span typeof=\"mw:File\"><span><img alt=\"Icon Emoji Paimon's Paintings 02 Qiqi 2\" src=\"https://static.wikia.nocookie.net/gensin-impact/images/f/f7/Icon_Emoji_Paimon%27s_Paintings_02_Qiqi_2.png/revision/latest/scale-to-width-down/65?cb=20240303114600\" decoding=\"async\" loading=\"lazy\" width=\"65\" height=\"65\" class=\"mw-file-element\" data-image-name=\"Icon Emoji Paimon's Paintings 02 Qiqi 2.png\" data-image-key=\"Icon_Emoji_Paimon%27s_Paintings_02_Qiqi_2.png\" data-relevant=\"1\" /></span></span></div><div class=\"mbox__content__wrapper\"><div class=\"mbox__content__header\">This <a href=\"/wiki/Category:Article_Stubs\" title=\"Category:Article Stubs\">page</a> is in need of information.</div><div class=\"mbox__content__text mw-collapsible\" id=\"mw-customcollapsible-stub\">Can you help out? <a target=\"_blank\" rel=\"noreferrer noopener\" class=\"text\" href=\"https://genshin-impact.fandom.com/wiki/Hour_of_Ruin_(Event)?action=edit\">Click here to add more.</a><div class=\"mbox__content__text__comment\">Unspecified reason</div></div><span class=\"mbox__close mw-customtoggle-stub\" title=\"Dismiss\"></span></div></div></div><p class=\"mw-empty-elt\">\n\n</p><aside role=\"region\" class=\"portable-infobox pi-background pi-border-color pi-theme-wikia pi-layout-default\">\n<h2 class=\"pi-item pi-item-spacing pi-title pi-secondary-background\" data-source=\"name\">Hour of Ruin</h2>\n\n<figure class=\"pi-item pi-image\" data-source=\"image\">\n\t<a href=\"https://static.wikia.nocookie.net/gensin-impact/images/1/14/Hour_of_Ruin_Event.png/revision/latest?cb=20261102042244\" class=\"image image-thumbnail\" title=\"Hour of Ruin Event\">\n\t\t<img src=\"https://static.wikia.nocookie.net/gensin-impact/images/1/14/Hour_of_Ruin_Event.png/revision/latest/scale-to-width-down/266?cb=20261102042244\" srcset=\"https://static.wikia.nocookie.net/gensin-impact/images/1/14/Hour_of_Ruin_Event.png/revision/latest/scale-to-width-down/266?cb=20261102042244 1x, https://static.wikia.nocookie.net/gensin-impact/images/1/14/Hour_of_Ruin_Event.png/revision/latest/scale-to-width-down/535?cb=20261102042244 2x\" class=\"pi-image-thumbnail\" data-relevant=\"1\" alt=\"Hour of Ruin Event\" width=\"270\" height=\"98\" data-image-key=\"Hour_of_Ruin_Event.png\" data-image-name=\"Hour of Ruin Event.png\" />\n\t\t\n\t</a>\n\t\n</figure>\n<section class=\"pi-item pi-group pi-border-color\">\n\t<table class=\"pi-horizontal-group\">\n\t<caption class=\"pi-header pi-secondary-font pi-secondary-background pi-item-spacing\"><center>Duration</center></caption>\n\t\n\t<thead>\n\t\t<tr>\n\t\t\n\t\t\t<th class=\"pi-horizontal-group-item pi-data-label pi-secondary-font pi-border-color pi-item-spacing\" data-source=\"time_start\">Start</th>\n\t\t\n\t\t\t<th class=\"pi-horizontal-group-item pi-data-label pi-secondary-font pi-border-color pi-item-spacing\" data-source=\"time_end\">End</th>\n\t\t\n\t\t</tr>\n\t</thead>\n\t\n\t<tbody>\n\t<tr>\n\t\n\t\t<td class=\"pi-horizontal-group-item pi-data-value pi-font pi-border-color pi-item-spacing\" data-source=\"time_start\">November 4, 2026 11:00 GMT+8</td>\n\t\n\t\t<td class=\"pi-horizontal-group-item pi-data-value pi-font pi-border-color pi-item-spacing\" data-source=\"time_end\">December 15, 2026 14:59 (Server Time)</td>\n\t\n\t</tr>\n\t</tbody>\n</table>\n\n</section>\n<section class=\"pi-item pi-group pi-border-color\">\n\t\n\t<section class=\"pi-item pi-panel pi-border-color wds-tabber\">\n\t\t\n\t\t\n\t\t\t<div class=\"wds-tabs__wrapper\">\n\t\t\t\t<ul class=\"wds-tabs\">\n\t\t\t\t\t\n\t\t\t\t\t\t<li class=\"wds-tabs__tab wds-is-current\">\n\t\t\t\t\t\t\t<div class=\"wds-tabs__tab-label\">\n\t\t\t\t\t\t\t\tInformation\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\n\t\t\t\t\t\t<li class=\"wds-tabs__tab\">\n\t\t\t\t\t\t\t<div class=\"wds-tabs__tab-label\">\n\t\t\t\t\t\t\t\tDetails\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\n\t\t\t\t</ul>\n\t\t\t</div>\n\t\t\n\t\t\n\t\t\t<div class=\"wds-tab__content wds-is-current\">\n\t\t\t\t\n<div class=\"pi-item pi-data pi-item-spacing pi-border-color\" data-source=\"link\">\n\t\n\t\t<h3 class=\"pi-data-label pi-secondary-font\">Link</h3>\n\t\n\t<div class=\"pi-data-value pi-font\"><a target=\"_blank\" rel=\"nofollow noreferrer noopener\" class=\"external text\" href=\"htLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"parse":{"title":"Raiment Collection/2026-11-04","pageid":501128,"text":"<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\"><div class=\"mbox stub\"><div class=\"mbox__content\"><div class=\"mbox__content__image mw-collapsible\" id=\"mw-customcollapsible-stub\"><span typeof=\"mw:File\"><span><img alt=\"Icon Emoji Paimon's Paintings 02 Qiqi 2\" src=\"https://static.wikia.nocookie.net/gensin-impact/images/f/f7/Icon_Emoji_Paimon%27s_Paintings_02_Qiqi_2.png/revision/latest/scale-to-width-down/65?cb=20240303114600\" decoding=\"async\" loading=\"lazy\" width=\"65\" height=\"65\" class=\"mw-file-element\" data-image-name=\"Icon Emoji Paimon's Paintings 02 Qiqi 2.png\" data-image-key=\"Icon_Emoji_Paimon%27s_Paintings_02_Qiqi_2.png\" data-relevant=\"1\" /></span></span></div><div class=\"mbox__content__wrapper\"><div class=\"mbox__content__header\">This <a href=\"/wiki/Category:Article_Stubs\" title=\"Category:Article Stubs\">page</a> is in need of information.</div><div class=\"mbox__content__text mw-collapsible\" id=\"mw-customcollapsible-stub\">Can you help out? <a target=\"_blank\" rel=\"noreferrer noopener\" class=\"text\" href=\"https://genshin-impact.fandom.com/wiki/Raiment_Collection/2026-11-04?action=edit\">Click here to add more.</a><div class=\"mbox__content__text__comment\">Unspecified reason</div></div><span class=\"mbox__close mw-customtoggle-stub\" title=\"Dismiss\"></span></div></div></div><p class=\"mw-empty-elt\">\n\n</p><aside role=\"region\" class=\"portable-infobox pi-background pi-border-color pi-theme-wikia pi-layout-default\">\n<h2 class=\"pi-item pi-item-spacing pi-title pi-secondary-background\" data-source=\"name\">Raiment Collection: Radiance Ablaze</h2>\n\n<figure class=\"pi-item pi-image\" data-source=\"image\">\n\t<a href=\"https://static.wikia.nocookie.net/gensin-impact/images/9/9f/Raiment_Collection_Radiance_Ablaze.png/revision/latest?cb=20261104060358\" class=\"image image-thumbnail\" title=\"Raiment Collection Radiance Ablaze\">\n\t\t<img src=\"https://static.wikia.nocookie.net/gensin-impact/images/9/9f/Raiment_Collection_Radiance_Ablaze.png/revision/latest/scale-to-width-down/266?cb=20261104060358\" srcset=\"https://static.wikia.nocookie.net/gensin-impact/images/9/9f/Raiment_Collection_Radiance_Ablaze.png/revision/latest/scale-to-width-down/266?cb=20261104060358 1x, https://static.wikia.nocookie.net/gensin-impact/images/9/9f/Raiment_Collection_Radiance_Ablaze.png/revision/latest/scale-to-width-down/535?cb=20261104060358 2x\" class=\"pi-image-thumbnail\" data-relevant=\"1\" alt=\"Raiment Collection Radiance Ablaze\" width=\"270\" height=\"98\" data-image-key=\"Raiment_Collection_Radiance_Ablaze.png\" data-image-name=\"Raiment Collection Radiance Ablaze.png\" />\n\t\t\n\t</a>\n\t\n</figure>\n<section class=\"pi-item pi-group pi-border-color\">\n\t<table class=\"pi-horizontal-group\">\n\t<caption class=\"pi-header pi-secondary-font pi-secondary-background pi-item-spacing\"><center>Duration</center></caption>\n\t\n\t<thead>\n\t\t<tr>\n\t\t\n\t\t\t<th class=\"pi-horizontal-group-item pi-data-label pi-secondary-font pi-border-color pi-item-spacing\" data-source=\"time_start\">Start</th>\n\t\t\n\t\t\t<th class=\"pi-horizontal-group-item pi-data-label pi-secondary-font pi-border-color pi-item-spacing\" data-source=\"time_end\">End</th>\n\t\t\n\t\t</tr>\n\t</thead>\n\t\n\t<tbody>\n\t<tr>\n\t\n\t\t<td class=\"pi-horizontal-group-item pi-data-value pi-font pi-border-color pi-item-spacing\" data-source=\"time_start\">November 4, 2026 11:00 GMT+8</td>\n\t\n\t\t<td class=\"pi-horizontal-group-item pi-data-value pi-font pi-border-color pi-item-spacing\" data-source=\"time_end\">December 16, 2026 05:59 GMT+8</td>\n\t\n\t</tr>\n\t</tbody>\n</table>\n\n</section>\n<section class=\"pi-item pi-group pi-border-color\">\n\t\n\t<section class=\"pi-item pi-panel pi-border-color wds-tabber\">\n\t\t\n\t\t\n\t\t\t<div class=\"wds-tabs__wrapper\">\n\t\t\t\t<ul class=\"wds-tabs\">\n\t\t\t\t\t\n\t\t\t\t\t\t<li class=\"wds-tabs__tab wds-is-current\">\n\t\t\t\t\t\t\t<div class=\"wds-tabs__tab-label\">\n\t\t\t\t\t\t\t\tInformation\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\n\t\t\t\t\t\t<li class=\"wds-tabs__tab\">\n\t\t\t\t\t\t\t<div class=\"wds-tabs__tab-label\">\n\t\t\t\t\t\t\t\tDetails\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\n\t\t\t\t</ul>\n\t\t\t</div>\n\t\t\n\t\t\n\t\t\t<div class=\"wds-tab__content wds-is-current\">\n\t\t\t\t\n<div class=\"pi-item pi-data pi-item-spacing pi-border-color\" data-source=\"link\">\n\t\n\t\t<h3 class=\"pi-data-label pi-secondary-font\">Link</h3>\n\t\n\t<div class=\"pi-data-value pi-font\"><a target=\"_blank\" rel=\"nofollow noreferrer noopener\" class=\"external text\" href=\"https://www.hoyolab.com/article/47298205\">Official Announcement</a></div>\n</div>\n\n\t\t\t</div>\n\t\t\n\t\t\t<div class=\"wds-tab__content\">\n\t\t\Line truncated
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GachaEvent, GameId } from "../../shared/schema.ts";
|
||||
import type { StatedEnd } from "../articleEnds.ts";
|
||||
|
||||
export interface ParseContext {
|
||||
/**
|
||||
@@ -9,6 +10,11 @@ export interface ParseContext {
|
||||
sourceUrl: string;
|
||||
sourceId: string;
|
||||
game: GameId;
|
||||
/**
|
||||
* Ends stated by a row's own article, keyed by page title. Only ever decides
|
||||
* whether a row is still live: the parser never writes one onto a row.
|
||||
*/
|
||||
articleEnds?: ReadonlyMap<string, StatedEnd>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Region, type GachaEvent } from "../shared/schema.ts";
|
||||
import { REGION_RESET_UTC_OFFSET } from "../shared/time.ts";
|
||||
import { text } from "./html.ts";
|
||||
|
||||
/**
|
||||
* The end a Genshin event's own wiki article states.
|
||||
*
|
||||
* Fandom's `Event` table prints a date, and the same printed date can mean 03:59
|
||||
* server time, 14:59 server time or one instant worldwide. So the table cannot
|
||||
* settle the time and the article's infobox can
|
||||
* (docs/superpowers/specs/2026-11-07-exact-end-times-design.md). Pure and
|
||||
* offline: fetching these is `scripts/refresh-articles.ts`'s job.
|
||||
*/
|
||||
export interface StatedEnd {
|
||||
/** The calendar date as printed, `YYYY-MM-DD`, for comparing with the table. */
|
||||
day: string;
|
||||
/** The instant, or the earliest region's: never promise more than a region gets. */
|
||||
endsAt: string;
|
||||
regionEnds: Record<Region, string> | null;
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
"January", "February", "March", "April", "May", "June", "July",
|
||||
"August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const END_CELL = /<td\b[^>]*data-source="time_end"[^>]*>([\s\S]*?)<\/td>/i;
|
||||
const PRINTED =
|
||||
/^([A-Z][a-z]+) (\d{1,2}), (\d{4}) (\d{1,2}):(\d{2}) (\(Server Time\)|GMT\+8)$/;
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** `body` is the API's JSON response. Anything but a stated time is `null`. */
|
||||
export function parseArticleEnd(body: string): StatedEnd | null {
|
||||
let rendered: unknown;
|
||||
try {
|
||||
rendered = (JSON.parse(body) as { parse?: { text?: unknown } } | null)?.parse?.text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof rendered !== "string") return null;
|
||||
|
||||
const cell = END_CELL.exec(rendered)?.[1];
|
||||
if (cell === undefined) return null;
|
||||
const printed = PRINTED.exec(text(cell));
|
||||
if (printed === null) return null;
|
||||
|
||||
const [, monthName, dayText, yearText, hourText, minuteText, zone] = printed;
|
||||
const month = MONTHS.indexOf(monthName ?? "");
|
||||
const day = Number(dayText);
|
||||
const year = Number(yearText);
|
||||
const hour = Number(hourText);
|
||||
const minute = Number(minuteText);
|
||||
if (month < 0 || hour > 23 || minute > 59) return null;
|
||||
|
||||
const wall = Date.UTC(year, month, day, hour, minute);
|
||||
// Date.UTC rolls 45 December into January rather than refusing it.
|
||||
if (new Date(wall).getUTCDate() !== day) return null;
|
||||
const printedDay = new Date(wall).toISOString().slice(0, 10);
|
||||
const at = (offsetHours: number) => new Date(wall - offsetHours * HOUR_MS).toISOString();
|
||||
|
||||
if (zone === "GMT+8") return { day: printedDay, endsAt: at(8), regionEnds: null };
|
||||
|
||||
const regionEnds = {
|
||||
asia: at(REGION_RESET_UTC_OFFSET.asia),
|
||||
america: at(REGION_RESET_UTC_OFFSET.america),
|
||||
europe: at(REGION_RESET_UTC_OFFSET.europe),
|
||||
};
|
||||
const endsAt = Object.values(regionEnds).sort()[0] ?? at(REGION_RESET_UTC_OFFSET.asia);
|
||||
return { day: printedDay, endsAt, regionEnds };
|
||||
}
|
||||
|
||||
/** The decoded page title of a Genshin wiki article's URL, else `null`. */
|
||||
export function articleTitle(sourceUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(sourceUrl);
|
||||
if (url.hostname !== "genshin-impact.fandom.com") return null;
|
||||
if (!url.pathname.startsWith("/wiki/")) return null;
|
||||
const title = decodeURIComponent(url.pathname.slice("/wiki/".length));
|
||||
return title.length === 0 ? null : title;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The instant a stated end has passed for every reader, whatever their region. */
|
||||
export function latestStatedMs(end: StatedEnd): number {
|
||||
const all = end.regionEnds === null ? [end.endsAt] : Object.values(end.regionEnds);
|
||||
return Math.max(...all.map((iso) => Date.parse(iso)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write stated ends onto the merged rows. After the merge on purpose: it keeps
|
||||
* the higher-confidence copy, which for several rows is Game8's day-precision one.
|
||||
*
|
||||
* `listed` is the Fandom parse before merging, the only place a row's article
|
||||
* link and its table date survive. Only the four end fields ever change: `id` and
|
||||
* `startsAt` are a reader's localStorage key.
|
||||
*/
|
||||
export function applyArticleEnds(
|
||||
merged: GachaEvent[],
|
||||
listed: GachaEvent[],
|
||||
ends: ReadonlyMap<string, StatedEnd>,
|
||||
warn: (line: string) => void,
|
||||
): GachaEvent[] {
|
||||
const byId = new Map<string, { end: StatedEnd; tableDay: string | undefined }>();
|
||||
for (const row of listed) {
|
||||
const title = articleTitle(row.sourceUrl);
|
||||
const end = title === null ? undefined : ends.get(title);
|
||||
if (end !== undefined) byId.set(row.id, { end, tableDay: row.endsAt?.slice(0, 10) });
|
||||
}
|
||||
|
||||
return merged.map((event) => {
|
||||
const found = [event.id, ...(event.mergedIds ?? [])]
|
||||
.map((id) => byId.get(id))
|
||||
.find((f) => f !== undefined);
|
||||
if (found === undefined) return event;
|
||||
// Another source stated this end to the minute; an article does not outrank it.
|
||||
if (event.endPrecision !== "day") return event;
|
||||
|
||||
const { end, tableDay } = found;
|
||||
if (tableDay !== end.day || event.endsAt?.slice(0, 10) !== end.day) {
|
||||
warn(
|
||||
`"${event.title}": article says ${end.day}, table ${tableDay ?? "none"}, ` +
|
||||
`row ${event.endsAt?.slice(0, 10) ?? "none"}; keeping the row's date`,
|
||||
);
|
||||
return event;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
endsAt: end.endsAt,
|
||||
endPrecision: "exact" as const,
|
||||
regionScoped: end.regionEnds !== null,
|
||||
regionEnds: end.regionEnds,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../dates.ts";
|
||||
import { decodeEntities, text } from "../html.ts";
|
||||
import type { ParseContext } from "../adapters/types.ts";
|
||||
import { articleTitle, latestStatedMs } from "../articleEnds.ts";
|
||||
import { inferType } from "./game8.ts";
|
||||
import { classifySourceLabels } from "../../shared/classification.ts";
|
||||
import type { SourceParser } from "./types.ts";
|
||||
@@ -819,12 +820,19 @@ function parseGenshinEvents(
|
||||
// `Current` is maintained by hand and goes stale before anyone moves a
|
||||
// row, so currency is checked rather than taken on trust — on the clock
|
||||
// the countdown reads a day-precision end on, not on the UTC midnight
|
||||
// placeholder stored for it.
|
||||
if (
|
||||
latestBoundaryMs(range.end.iso, range.end.precision, ctx.game) < nowMs
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// placeholder stored for it. An article that states a time on that same
|
||||
// date is the better clock: a row ending 14:59 must not vanish at 04:00.
|
||||
const href = ARTICLE_LINK.exec(titleCell)?.[1];
|
||||
const articleUrl =
|
||||
href === undefined ? null : new URL(href, ctx.sourceUrl).toString();
|
||||
const pageTitle = articleUrl === null ? null : articleTitle(articleUrl);
|
||||
const statedEnd =
|
||||
pageTitle === null ? undefined : ctx.articleEnds?.get(pageTitle);
|
||||
const lastMs =
|
||||
statedEnd !== undefined && statedEnd.day === range.end.iso.slice(0, 10)
|
||||
? latestStatedMs(statedEnd)
|
||||
: latestBoundaryMs(range.end.iso, range.end.precision, ctx.game);
|
||||
if (lastMs < nowMs) continue;
|
||||
|
||||
const id = eventId(ctx.game, title, range.start.iso);
|
||||
if (seen.has(id)) continue;
|
||||
@@ -832,7 +840,6 @@ function parseGenshinEvents(
|
||||
|
||||
const stated =
|
||||
table.type === undefined ? "" : text(cells[table.type]?.html ?? "");
|
||||
const href = ARTICLE_LINK.exec(titleCell)?.[1];
|
||||
|
||||
out.push({
|
||||
id,
|
||||
@@ -856,10 +863,7 @@ function parseGenshinEvents(
|
||||
// claiming a split the source never made.
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl:
|
||||
href === undefined
|
||||
? ctx.sourceUrl
|
||||
: new URL(href, ctx.sourceUrl).toString(),
|
||||
sourceUrl: articleUrl ?? ctx.sourceUrl,
|
||||
sourceId: ctx.sourceId,
|
||||
status: "published",
|
||||
// Day precision on both sides, which is everything the page states.
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { ADAPTERS } from "../src/ingest/adapters/index.ts";
|
||||
import {
|
||||
applyArticleEnds,
|
||||
articleTitle,
|
||||
latestStatedMs,
|
||||
parseArticleEnd,
|
||||
type StatedEnd,
|
||||
} from "../src/ingest/articleEnds.ts";
|
||||
import { mergeEvents } from "../src/ingest/merge.ts";
|
||||
import type { GachaEvent } from "../src/shared/schema.ts";
|
||||
|
||||
const dir = "fixtures/genshin/articles";
|
||||
const hourOfRuin = await Bun.file(`${dir}/hour-of-ruin-2026-11-07.html`).text();
|
||||
const raiment = await Bun.file(`${dir}/raiment-collection-2026-11-04-2026-11-07.html`).text();
|
||||
const table = await Bun.file(`${dir}/event-table-2026-11-06.html`).text();
|
||||
|
||||
const withEnd = (value: string) =>
|
||||
hourOfRuin.replace("December 15, 2026 14:59 (Server Time)", value);
|
||||
|
||||
const fandom = ADAPTERS.find((a) => a.id === "genshin-fandom-events")!;
|
||||
const parseTable = (now: string, articleEnds?: ReadonlyMap<string, StatedEnd>) =>
|
||||
fandom.parse(table, {
|
||||
now,
|
||||
sourceUrl: fandom.url,
|
||||
sourceId: fandom.id,
|
||||
game: fandom.game,
|
||||
...(articleEnds ? { articleEnds } : {}),
|
||||
});
|
||||
|
||||
const NOW = "2026-11-07T00:00:00.000Z";
|
||||
const listed = parseTable(NOW);
|
||||
const row = (title: string): GachaEvent => listed.find((e) => e.title === title)!;
|
||||
const HOUR_OF_RUIN_ID = "genshin:hour-of-ruin:2026-11-04";
|
||||
const ends = new Map([["Hour_of_Ruin_(Event)", parseArticleEnd(hourOfRuin)!]]);
|
||||
|
||||
test("server time gives every region its own end", () => {
|
||||
const end = parseArticleEnd(hourOfRuin)!;
|
||||
expect(end.day).toBe("2026-12-15");
|
||||
expect(end.regionEnds).toEqual({
|
||||
asia: "2026-12-15T06:59:00.000Z",
|
||||
europe: "2026-12-15T13:59:00.000Z",
|
||||
america: "2026-12-15T19:59:00.000Z",
|
||||
});
|
||||
// The earliest, as wikigg.ts does: never promise more than some region gets.
|
||||
expect(end.endsAt).toBe("2026-12-15T06:59:00.000Z");
|
||||
});
|
||||
|
||||
test("GMT+8 is one instant", () => {
|
||||
expect(parseArticleEnd(raiment)).toEqual({
|
||||
day: "2026-12-16",
|
||||
endsAt: "2026-12-15T21:59:00.000Z",
|
||||
regionEnds: null,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
"December 15, 2026",
|
||||
"TBA",
|
||||
"December 15, 2026 14:59 UTC",
|
||||
"December 15, 2026 14:59 GMT+9",
|
||||
"Smarch 15, 2026 14:59 (Server Time)",
|
||||
"December 45, 2026 14:59 (Server Time)",
|
||||
"December 15, 2026 25:59 (Server Time)",
|
||||
"",
|
||||
])("%p is no usable time", (value) => {
|
||||
expect(parseArticleEnd(withEnd(value))).toBeNull();
|
||||
});
|
||||
|
||||
test("malformed JSON is no usable time", () => {
|
||||
expect(parseArticleEnd("<html>not the API</html>")).toBeNull();
|
||||
});
|
||||
|
||||
test("a page with no infobox is no usable time", () => {
|
||||
expect(parseArticleEnd(JSON.stringify({ parse: { text: "<p>Stub.</p>" } }))).toBeNull();
|
||||
});
|
||||
|
||||
test("the End header cell is not mistaken for the value", () => {
|
||||
// The <th> carries data-source="time_end" too, and precedes the value.
|
||||
const noValue = JSON.stringify({
|
||||
parse: { text: '<table><tr><th data-source="time_end">End</th></tr></table>' },
|
||||
});
|
||||
expect(parseArticleEnd(noValue)).toBeNull();
|
||||
});
|
||||
|
||||
test("markup inside the value cell is read as its text", () => {
|
||||
expect(
|
||||
parseArticleEnd(withEnd("<span>December 15, 2026 14:59</span> (Server Time)"))?.day,
|
||||
).toBe("2026-12-15");
|
||||
});
|
||||
|
||||
test("the latest stated instant is the last region's", () => {
|
||||
expect(latestStatedMs(parseArticleEnd(hourOfRuin)!)).toBe(Date.parse("2026-12-15T19:59:00.000Z"));
|
||||
expect(latestStatedMs(parseArticleEnd(raiment)!)).toBe(Date.parse("2026-12-15T21:59:00.000Z"));
|
||||
});
|
||||
|
||||
test("an article title is decoded from a wiki URL and nothing else", () => {
|
||||
expect(articleTitle("https://genshin-impact.fandom.com/wiki/The_Forge_Realm%27s_Temper/2026-11-04")).toBe(
|
||||
"The_Forge_Realm's_Temper/2026-11-04",
|
||||
);
|
||||
expect(articleTitle(fandom.url)).toBeNull();
|
||||
expect(articleTitle("https://game8.co/wiki/Hour_of_Ruin")).toBeNull();
|
||||
});
|
||||
|
||||
test("the end lands on the row an article describes", () => {
|
||||
const [applied] = applyArticleEnds([row("Hour of Ruin")], listed, ends, () => {});
|
||||
expect(applied?.endPrecision).toBe("exact");
|
||||
expect(applied?.regionScoped).toBe(true);
|
||||
expect(applied?.regionEnds?.europe).toBe("2026-12-15T13:59:00.000Z");
|
||||
expect(applied?.endsAt).toBe("2026-12-15T06:59:00.000Z");
|
||||
});
|
||||
|
||||
test("a global end leaves the row unscoped", () => {
|
||||
const raimentRow = row("Raiment Collection: Radiance Ablaze");
|
||||
const [applied] = applyArticleEnds(
|
||||
[raimentRow],
|
||||
listed,
|
||||
new Map([["Raiment_Collection/2026-11-04", parseArticleEnd(raiment)!]]),
|
||||
() => {},
|
||||
);
|
||||
expect(applied?.endPrecision).toBe("exact");
|
||||
expect(applied?.regionScoped).toBe(false);
|
||||
expect(applied?.regionEnds).toBeNull();
|
||||
expect(applied?.endsAt).toBe("2026-12-15T21:59:00.000Z");
|
||||
});
|
||||
|
||||
test("a merged Game8 survivor takes the end through mergedIds", () => {
|
||||
const survivor: GachaEvent = {
|
||||
...row("Hour of Ruin"),
|
||||
id: "genshin:game8-title:2026-11-04",
|
||||
sourceId: "genshin-game8-events",
|
||||
sourceUrl: "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
mergedIds: [HOUR_OF_RUIN_ID],
|
||||
};
|
||||
const [applied] = applyArticleEnds([survivor], listed, ends, () => {});
|
||||
expect(applied?.endPrecision).toBe("exact");
|
||||
expect(applied?.regionEnds?.america).toBe("2026-12-15T19:59:00.000Z");
|
||||
});
|
||||
|
||||
test("a survivor of a real merge keeps its identity and takes the end", () => {
|
||||
const game8Copy: GachaEvent = {
|
||||
...row("Hour of Ruin"),
|
||||
sourceId: "genshin-game8-events",
|
||||
sourceUrl: "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
confidence: 0.95,
|
||||
};
|
||||
const { events } = mergeEvents([[game8Copy], [row("Hour of Ruin")]]);
|
||||
const [applied] = applyArticleEnds(events, listed, ends, () => {});
|
||||
expect(applied?.sourceId).toBe("genshin-game8-events");
|
||||
expect(applied?.endPrecision).toBe("exact");
|
||||
});
|
||||
|
||||
test("id, startsAt, sourceUrl, sourceId and classification never change", () => {
|
||||
const before = { ...row("Hour of Ruin"), classification: { lane: "miliastra" as const } };
|
||||
const [after] = applyArticleEnds([before], listed, ends, () => {});
|
||||
expect(after?.id).toBe(before.id);
|
||||
expect(after?.startsAt).toBe(before.startsAt);
|
||||
expect(after?.sourceUrl).toBe(before.sourceUrl);
|
||||
expect(after?.sourceId).toBe(before.sourceId);
|
||||
expect(after?.classification).toEqual(before.classification);
|
||||
});
|
||||
|
||||
test("an article whose date disagrees with the table keeps the row and warns", () => {
|
||||
const stale = new Map([
|
||||
["Hour_of_Ruin_(Event)", parseArticleEnd(withEnd("December 20, 2026 14:59 (Server Time)"))!],
|
||||
]);
|
||||
const warnings: string[] = [];
|
||||
const input = [row("Hour of Ruin")];
|
||||
expect(applyArticleEnds(input, listed, stale, (w) => warnings.push(w))).toEqual(input);
|
||||
expect(warnings.length).toBe(1);
|
||||
expect(warnings[0]).toContain("Hour of Ruin");
|
||||
});
|
||||
|
||||
test("a survivor whose date disagrees with the table keeps the row and warns", () => {
|
||||
const survivor: GachaEvent = { ...row("Hour of Ruin"), endsAt: "2026-12-17T00:00:00.000Z" };
|
||||
const warnings: string[] = [];
|
||||
expect(applyArticleEnds([survivor], listed, ends, (w) => warnings.push(w))).toEqual([survivor]);
|
||||
expect(warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
test("a survivor already at exact precision is left alone without a warning", () => {
|
||||
const survivor: GachaEvent = {
|
||||
...row("Hour of Ruin"),
|
||||
endsAt: "2026-12-15T10:00:00.000Z",
|
||||
endPrecision: "exact",
|
||||
};
|
||||
const warnings: string[] = [];
|
||||
expect(applyArticleEnds([survivor], listed, ends, (w) => warnings.push(w))).toEqual([survivor]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
test("no articles means the same contents as merged", () => {
|
||||
expect(applyArticleEnds(listed, listed, new Map(), () => {})).toEqual(listed);
|
||||
});
|
||||
|
||||
test("a live row outlives the table's day on its stated end", () => {
|
||||
// Past the last region's 04:00 on the printed day, before America's 14:59.
|
||||
const lastDay = "2026-12-15T15:00:00.000Z";
|
||||
expect(parseTable(lastDay).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false);
|
||||
expect(parseTable(lastDay, ends).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(true);
|
||||
});
|
||||
|
||||
test("a stated end also retires a row, once every region has passed it", () => {
|
||||
expect(parseTable("2026-12-15T20:00:00.000Z", ends).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false);
|
||||
});
|
||||
|
||||
test("an article that disagrees on the date does not keep a row alive", () => {
|
||||
const stale = new Map([
|
||||
["Hour_of_Ruin_(Event)", parseArticleEnd(withEnd("December 20, 2026 14:59 (Server Time)"))!],
|
||||
]);
|
||||
expect(parseTable("2026-12-15T15:00:00.000Z", stale).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false);
|
||||
});
|
||||
Reference in new issue
Block a user