feat: add Game8 parser with Genshin and NTE adapters
Parsers are keyed by site template, not game: one Game8 parser serves every Game8 page, and adapters bind a URL and game to a parser. Adding a source for a known site is one SOURCES entry. Game8 uses at least three templates. This handles label/value detail tables (Genshin, 9 events) and column tables (NTE, 13 events). Its image-grid schedule has no year and no end date, so Arknights: Endfield is not supportable from that source and gets no adapter. canParse is a structural tripwire: a site redesign fails the run loudly instead of publishing an empty calendar that reads as "no events". Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
caefe6b0d7
commit
489ce92ef5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,191 @@
|
||||
[
|
||||
{
|
||||
"id": "genshin:to-temper-thyself-and-journey-far:2026-08-10",
|
||||
"game": "genshin",
|
||||
"title": "To Temper Thyself and Journey Far",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-10T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-11-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:battle-pass-frostfarer:2026-08-12",
|
||||
"game": "genshin",
|
||||
"title": "Battle Pass - Frostfarer",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-21T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:character-test-runs:2026-08-12",
|
||||
"game": "genshin",
|
||||
"title": "Character Test Runs",
|
||||
"type": "challenge",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-01T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:mutual-aid-in-bloom-into-the-frostlands:2026-08-12",
|
||||
"game": "genshin",
|
||||
"title": "Mutual Aid in Bloom: Into the Frostlands",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-24T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:snezhnaya-travel-guide-web-event:2026-08-12",
|
||||
"game": "genshin",
|
||||
"title": "Snezhnaya Travel Guide Web Event",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-25T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:stygian-onslaught:2026-08-19",
|
||||
"game": "genshin",
|
||||
"title": "Stygian Onslaught",
|
||||
"type": "challenge",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-19T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-22T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:great-expeditionist-challenge:2026-08-28",
|
||||
"game": "genshin",
|
||||
"title": "Great Expeditionist Challenge",
|
||||
"type": "challenge",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-28T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-14T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:trial-of-the-bastion:2026-09-07",
|
||||
"game": "genshin",
|
||||
"title": "Trial of the Bastion",
|
||||
"type": "challenge",
|
||||
"summary": null,
|
||||
"startsAt": "2026-09-07T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-17T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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": "genshin:overflowing-abundance-rerun:2026-09-14",
|
||||
"game": "genshin",
|
||||
"title": "Overflowing Abundance Rerun",
|
||||
"type": "rerun",
|
||||
"summary": null,
|
||||
"startsAt": "2026-09-14T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-21T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
"sourceId": "genshin-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
@@ -0,0 +1,275 @@
|
||||
[
|
||||
{
|
||||
"id": "nte:999-nights:2026-07-08",
|
||||
"game": "nte",
|
||||
"title": "999 Nights",
|
||||
"type": "other",
|
||||
"summary": "The party's assembled! Ready to conquer the Warren Continent!",
|
||||
"startsAt": "2026-07-08T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:circle-gift:2026-07-08",
|
||||
"game": "nte",
|
||||
"title": "Circle Gift",
|
||||
"type": "other",
|
||||
"summary": "Log in to NTE for a week days to get 10 Solid Dice!",
|
||||
"startsAt": "2026-07-08T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:mews-flash:2026-07-08",
|
||||
"game": "nte",
|
||||
"title": "Mews Flash",
|
||||
"type": "other",
|
||||
"summary": "Scratch cards daily at the Midnight Catzebo for massive amounts of Fons!",
|
||||
"startsAt": "2026-07-08T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:neon-rift-mystery-box:2026-07-08",
|
||||
"game": "nte",
|
||||
"title": "Neon Rift Mystery Box",
|
||||
"type": "other",
|
||||
"summary": "Win the coveted Draco motorcycle, a new outfit, and more in this mystery box event!",
|
||||
"startsAt": "2026-07-08T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:shadow-n-seek:2026-07-17",
|
||||
"game": "nte",
|
||||
"title": "Shadow-n-Seek",
|
||||
"type": "other",
|
||||
"summary": "One, two, three. Don't move! A 6-player game of hide-and-seek!",
|
||||
"startsAt": "2026-07-17T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:gold-clash:2026-07-20",
|
||||
"game": "nte",
|
||||
"title": "Gold Clash",
|
||||
"type": "challenge",
|
||||
"summary": "Earn Fons twice as fast in Pink Paws Heist.",
|
||||
"startsAt": "2026-07-20T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-03T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:going-going-gone:2026-07-29",
|
||||
"game": "nte",
|
||||
"title": "Going, Going, Gone",
|
||||
"type": "other",
|
||||
"summary": "Win or lose, it all comes down to one swing of the gavel.",
|
||||
"startsAt": "2026-07-29T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:market-opening-rehearsal:2026-07-29",
|
||||
"game": "nte",
|
||||
"title": "Market Opening Rehearsal",
|
||||
"type": "other",
|
||||
"summary": "This is a free combat trial for the Appraisers Iroi, Haniel, Aurelia, and Skia.",
|
||||
"startsAt": "2026-07-29T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:wayfarer-perks:2026-07-29",
|
||||
"game": "nte",
|
||||
"title": "Wayfarer Perks",
|
||||
"type": "other",
|
||||
"summary": "Pull for characters a certain amount of times to receive cosmetics!",
|
||||
"startsAt": "2026-07-29T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:fishing-frenzy:2026-08-03",
|
||||
"game": "nte",
|
||||
"title": "Fishing Frenzy",
|
||||
"type": "other",
|
||||
"summary": "A rod's just the beginning, there's more than one way to land your catch!",
|
||||
"startsAt": "2026-08-03T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:pixel-surge:2026-08-03",
|
||||
"game": "nte",
|
||||
"title": "Pixel Surge",
|
||||
"type": "other",
|
||||
"summary": "During the event, spend Character Pixels in Houdini's Magic Stage, Houdini's Schemes, Bubble Can Factory, and Rabbit Hole to receive double rewards.",
|
||||
"startsAt": "2026-08-03T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-10T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:warren-lucky-flip:2026-08-05",
|
||||
"game": "nte",
|
||||
"title": "Warren Lucky Flip",
|
||||
"type": "other",
|
||||
"summary": "Complete daily quests to earn card flips.",
|
||||
"startsAt": "2026-08-05T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-19T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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": "nte:fons-rush:2026-08-10",
|
||||
"game": "nte",
|
||||
"title": "Fons Rush",
|
||||
"type": "other",
|
||||
"summary": "During the event, spend City Stamina Hethereau Hobbies activities to earn double the Fons.",
|
||||
"startsAt": "2026-08-10T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-17T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
"sourceId": "nte-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
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Run an adapter against a checked-in fixture and print the events it yields.
|
||||
*
|
||||
* Offline and free — this never touches the network. Use it to eyeball an
|
||||
* adapter's output before writing the .expected.json, and to regenerate that
|
||||
* file after an intentional change.
|
||||
*
|
||||
* bun run parse <adapter-id> <fixture-path> [--now ISO] [--json]
|
||||
*/
|
||||
import { adapterById, ADAPTERS } from "../src/ingest/adapters/index.ts";
|
||||
import { GachaEvent } from "../src/shared/schema.ts";
|
||||
|
||||
const [adapterId, fixturePath] = Bun.argv.slice(2);
|
||||
const nowFlag = Bun.argv.indexOf("--now");
|
||||
const now =
|
||||
nowFlag !== -1 ? (Bun.argv[nowFlag + 1] ?? "") : "2026-08-14T00:00:00.000Z";
|
||||
const asJson = Bun.argv.includes("--json");
|
||||
|
||||
if (!adapterId || !fixturePath) {
|
||||
console.error("usage: bun run parse <adapter-id> <fixture-path> [--now ISO] [--json]");
|
||||
console.error(`adapters: ${ADAPTERS.map((a) => a.id).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const adapter = adapterById(adapterId);
|
||||
if (!adapter) {
|
||||
console.error(`unknown adapter '${adapterId}'`);
|
||||
console.error(`adapters: ${ADAPTERS.map((a) => a.id).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const html = await Bun.file(fixturePath).text();
|
||||
const events = adapter.parse(html, {
|
||||
now,
|
||||
sourceUrl: adapter.url,
|
||||
sourceId: adapter.id,
|
||||
game: adapter.game,
|
||||
});
|
||||
|
||||
// Validate here too — the script should fail loudly on a schema violation
|
||||
// rather than print something the pipeline would later reject.
|
||||
for (const e of events) GachaEvent.parse(e);
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(events, null, 2));
|
||||
} else {
|
||||
console.log(`${adapter.id}: ${events.length} events\n`);
|
||||
for (const e of events) {
|
||||
const end = e.endsAt ? e.endsAt.slice(0, 10) : " (unknown)";
|
||||
console.log(
|
||||
` ${e.startsAt.slice(0, 10)} → ${end} [${e.type.padEnd(11)}] ${e.title}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { GachaEvent, GameId } from "../../shared/schema.ts";
|
||||
import { mergeEvents, type MergeResult } from "../merge.ts";
|
||||
import { parserById } from "../parsers/index.ts";
|
||||
import { SIX_HOURS_MS, type Adapter, type ParseContext } from "./types.ts";
|
||||
|
||||
/**
|
||||
* The source registry.
|
||||
*
|
||||
* One entry per (game, page). Adding a source for a site we already parse is a
|
||||
* single entry here. Adding a new site means a parser in `../parsers` first.
|
||||
*/
|
||||
|
||||
interface SourceSpec {
|
||||
id: string;
|
||||
game: GameId;
|
||||
url: string;
|
||||
parserId: string;
|
||||
priority?: number;
|
||||
minIntervalMs?: number;
|
||||
}
|
||||
|
||||
const SOURCES: SourceSpec[] = [
|
||||
{
|
||||
id: "genshin-game8-events",
|
||||
game: "genshin",
|
||||
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
parserId: "game8",
|
||||
},
|
||||
{
|
||||
id: "nte-game8-events",
|
||||
game: "nte",
|
||||
url: "https://game8.co/games/Neverness-to-Everness/archives/592073",
|
||||
parserId: "game8",
|
||||
},
|
||||
];
|
||||
|
||||
function toAdapter(spec: SourceSpec): Adapter {
|
||||
const parser = parserById(spec.parserId);
|
||||
if (parser === undefined) {
|
||||
throw new Error(
|
||||
`source '${spec.id}' references unknown parser '${spec.parserId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: spec.id,
|
||||
game: spec.game,
|
||||
url: spec.url,
|
||||
parserId: spec.parserId,
|
||||
minIntervalMs: spec.minIntervalMs ?? SIX_HOURS_MS,
|
||||
priority: spec.priority ?? 0,
|
||||
parse(html: string, ctx: ParseContext): GachaEvent[] {
|
||||
// A site redesign should fail the run loudly rather than publish an empty
|
||||
// calendar, which would read as "no events" to a user.
|
||||
if (!parser.canParse(html)) {
|
||||
throw new Error(
|
||||
`${spec.id}: document does not match the '${parser.label}' template; the source has likely been redesigned`,
|
||||
);
|
||||
}
|
||||
return parser.parse(html, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ADAPTERS: Adapter[] = SOURCES.map(toAdapter);
|
||||
|
||||
export function adaptersForGame(game: GameId): Adapter[] {
|
||||
return ADAPTERS.filter((a) => a.game === game);
|
||||
}
|
||||
|
||||
export function adapterById(id: string): Adapter | undefined {
|
||||
return ADAPTERS.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
export function gamesWithSources(): GameId[] {
|
||||
return [...new Set(ADAPTERS.map((a) => a.game))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every source for one game and combine them.
|
||||
*
|
||||
* Callers supply already-fetched documents so this stays pure and offline —
|
||||
* fetching is stage 1's job, not the parser's.
|
||||
*/
|
||||
export function parseGame(
|
||||
game: GameId,
|
||||
documents: Map<string, string>,
|
||||
now: string,
|
||||
): MergeResult {
|
||||
const groups = adaptersForGame(game)
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.flatMap((adapter) => {
|
||||
const html = documents.get(adapter.id);
|
||||
if (html === undefined) return [];
|
||||
return [
|
||||
adapter.parse(html, {
|
||||
now,
|
||||
sourceUrl: adapter.url,
|
||||
sourceId: adapter.id,
|
||||
game: adapter.game,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
return mergeEvents(groups);
|
||||
}
|
||||
|
||||
// Convenience handles for tests and scripts.
|
||||
export const genshinGame8 = ADAPTERS.find(
|
||||
(a) => a.id === "genshin-game8-events",
|
||||
)!;
|
||||
export const nteGame8 = ADAPTERS.find((a) => a.id === "nte-game8-events")!;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { GachaEvent, GameId } from "../../shared/schema.ts";
|
||||
|
||||
export interface ParseContext {
|
||||
/**
|
||||
* Injected wall-clock, never read from Date.now() inside a parser. This is
|
||||
* what lets a fixture captured months ago assert byte-identical output.
|
||||
*/
|
||||
now: string;
|
||||
sourceUrl: string;
|
||||
sourceId: string;
|
||||
game: GameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* An adapter binds one URL, for one game, to one parser.
|
||||
*
|
||||
* It holds no parsing logic of its own — that lives in `../parsers`, keyed by
|
||||
* site template. A game may have several adapters (several sources); their
|
||||
* outputs are combined by `mergeEvents`.
|
||||
*/
|
||||
export interface Adapter {
|
||||
/** Unique, stable: "<game>-<site>-<page>", e.g. "genshin-game8-events". */
|
||||
id: string;
|
||||
game: GameId;
|
||||
url: string;
|
||||
/** Parser id from `../parsers`. */
|
||||
parserId: string;
|
||||
minIntervalMs: number;
|
||||
/**
|
||||
* Higher wins when two sources disagree and neither is clearly better.
|
||||
* Official feeds should outrank community wikis.
|
||||
*/
|
||||
priority: number;
|
||||
|
||||
parse(html: string, ctx: ParseContext): GachaEvent[];
|
||||
}
|
||||
|
||||
export const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
eventId,
|
||||
type EventType,
|
||||
type GachaEvent,
|
||||
} from "../../shared/schema.ts";
|
||||
import {
|
||||
parseMonthDayRange,
|
||||
parseMonthDayYear,
|
||||
parseSlashDateTimeRange,
|
||||
type ParsedInstant,
|
||||
} from "../dates.ts";
|
||||
import { assertFlatTables, scanDocument } 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,
|
||||
];
|
||||
|
||||
/**
|
||||
* 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,
|
||||
];
|
||||
|
||||
/** 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 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 = /^(duration|dates?|period|availability period|schedule)$/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 (const node of nodes) {
|
||||
// Sections are marked by h2 on some pages and h3 on others, so inclusion is
|
||||
// tracked at whichever level actually names the section. A heading matching
|
||||
// neither list leaves the current state alone — it is an event name.
|
||||
if (node.kind === "h2" || node.kind === "h3") {
|
||||
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 {
|
||||
currentTitle = heading;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sectionIncluded) 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, summary: null, ...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)));
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
const summaryCell = summaryIdx === -1 ? undefined : row[summaryIdx];
|
||||
const summary =
|
||||
summaryCell && summaryCell.length > 0 ? summaryCell.slice(0, 500) : null;
|
||||
|
||||
out.push({ title, summary, start: range.start, end: range.end });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseRange(
|
||||
value: string,
|
||||
): { start: ParsedInstant; end: ParsedInstant } | null {
|
||||
return parseSlashDateTimeRange(value) ?? parseMonthDayRange(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) {
|
||||
byId.set(e.id, e);
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { game8Parser } from "./game8.ts";
|
||||
import type { SourceParser } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Every known site template. Adding a source for a site already listed here is
|
||||
* an entry in `adapters/index.ts`; adding a new *site* means a parser module
|
||||
* here and one line below.
|
||||
*/
|
||||
export const PARSERS: SourceParser[] = [game8Parser];
|
||||
|
||||
export function parserById(id: string): SourceParser | undefined {
|
||||
return PARSERS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export type { SourceParser } from "./types.ts";
|
||||
export { game8Parser } from "./game8.ts";
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { GachaEvent } from "../../shared/schema.ts";
|
||||
import type { ParseContext } from "../adapters/types.ts";
|
||||
|
||||
/**
|
||||
* A parser understands one *site template* — not one game.
|
||||
*
|
||||
* The split matters as sources multiply: Game8 publishes calendars for a dozen
|
||||
* games with the same markup, so one parser serves all of them. Adding a second
|
||||
* site (an official JSON feed, a Fandom wiki) means a new parser here, and the
|
||||
* adapters that use it are three lines each.
|
||||
*
|
||||
* Parsers must be pure over their input: no network, no clock, no randomness.
|
||||
* `now` arrives via ParseContext.
|
||||
*/
|
||||
export interface SourceParser {
|
||||
/** Stable identifier, e.g. "game8". Recorded on runs for diagnostics. */
|
||||
id: string;
|
||||
|
||||
/** Human-readable, for the review UI and health output. */
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Cheap structural check: does this document look like something this parser
|
||||
* can read? Used to fail loudly when a site is redesigned, instead of
|
||||
* silently returning zero events.
|
||||
*/
|
||||
canParse(html: string): boolean;
|
||||
|
||||
parse(html: string, ctx: ParseContext): GachaEvent[];
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { genshinGame8, nteGame8 } from "../../src/ingest/adapters/index.ts";
|
||||
import type { Adapter } from "../../src/ingest/adapters/types.ts";
|
||||
import { inferType } from "../../src/ingest/parsers/game8.ts";
|
||||
import { GachaEvent, type EventType } from "../../src/shared/schema.ts";
|
||||
|
||||
/**
|
||||
* Pinned clock. Parsers take `now` from context and never read it themselves,
|
||||
* so a fixture captured months ago still asserts byte-identical output.
|
||||
*/
|
||||
const NOW = "2026-08-14T00:00:00.000Z";
|
||||
|
||||
const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
{
|
||||
adapter: genshinGame8,
|
||||
fixture: "fixtures/genshin/game8-events-2026-08-14",
|
||||
},
|
||||
{
|
||||
adapter: nteGame8,
|
||||
fixture: "fixtures/nte/game8-events-2026-08-14",
|
||||
},
|
||||
];
|
||||
|
||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||
const html = await Bun.file(`${fixture}.html`).text();
|
||||
return adapter.parse(html, {
|
||||
now: NOW,
|
||||
sourceUrl: adapter.url,
|
||||
sourceId: adapter.id,
|
||||
game: adapter.game,
|
||||
});
|
||||
}
|
||||
|
||||
describe.each(CASES)("$adapter.id", ({ adapter, fixture }) => {
|
||||
test("matches the checked-in expected output", async () => {
|
||||
const events = await runAdapter(adapter, fixture);
|
||||
const expected = await Bun.file(`${fixture}.expected.json`).json();
|
||||
expect(events).toEqual(expected);
|
||||
});
|
||||
|
||||
test("every event satisfies the schema", async () => {
|
||||
for (const event of await runAdapter(adapter, fixture)) {
|
||||
expect(() => GachaEvent.parse(event)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("is deterministic across runs", async () => {
|
||||
const a = await runAdapter(adapter, fixture);
|
||||
const b = await runAdapter(adapter, fixture);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test("never emits an end before its start", async () => {
|
||||
for (const e of await runAdapter(adapter, fixture)) {
|
||||
if (e.endsAt !== null) expect(e.endsAt > e.startsAt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("no event runs longer than 180 days", async () => {
|
||||
// Patch cycles are ~6 weeks. A longer span means a misread year, which is
|
||||
// the failure mode most likely to reach a user as a confident wrong date.
|
||||
for (const e of await runAdapter(adapter, fixture)) {
|
||||
if (e.endsAt === null) continue;
|
||||
const days =
|
||||
(Date.parse(e.endsAt) - Date.parse(e.startsAt)) / 86_400_000;
|
||||
expect(days).toBeLessThanOrEqual(180);
|
||||
}
|
||||
});
|
||||
|
||||
test("event IDs are unique and stable in shape", async () => {
|
||||
const events = await runAdapter(adapter, fixture);
|
||||
const ids = events.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const e of events) {
|
||||
expect(e.id).toBe(
|
||||
`${e.game}:${e.id.split(":")[1]}:${e.startsAt.slice(0, 10)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("excludes permanent and past sections", async () => {
|
||||
const titles = (await runAdapter(adapter, fixture)).map((e) => e.title);
|
||||
// Permanent entries carry no dates; past entries ended before the fixture
|
||||
// date. Neither belongs on a "what's live / what's next" calendar.
|
||||
for (const t of titles) expect(t).not.toMatch(/permanent/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("genshin fixture specifics", () => {
|
||||
test("yields the nine dated events on the page", async () => {
|
||||
const events = await runAdapter(
|
||||
genshinGame8,
|
||||
"fixtures/genshin/game8-events-2026-08-14",
|
||||
);
|
||||
expect(events).toHaveLength(9);
|
||||
|
||||
const byTitle = new Map(events.map((e) => [e.title, e]));
|
||||
const mutual = byTitle.get("Mutual Aid in Bloom: Into the Frostlands");
|
||||
expect(mutual?.startsAt).toBe("2026-08-12T00:00:00.000Z");
|
||||
expect(mutual?.endsAt).toBe("2026-08-24T00:00:00.000Z");
|
||||
expect(mutual?.startPrecision).toBe("day");
|
||||
|
||||
// Sourced from a one-cell "Availability Period" range rather than
|
||||
// Start/End rows — the other table shape on the same page.
|
||||
expect(byTitle.get("Battle Pass - Frostfarer")?.endsAt).toBe(
|
||||
"2026-09-21T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
test("year-less summary rows produce no events", async () => {
|
||||
// The page's summary tables show "08/12 - 08/24" with no year. Those must
|
||||
// be skipped, not year-guessed — and they must not duplicate the detail
|
||||
// tables that carry the same events with real years.
|
||||
const events = await runAdapter(
|
||||
genshinGame8,
|
||||
"fixtures/genshin/game8-events-2026-08-14",
|
||||
);
|
||||
const dupes = events.filter(
|
||||
(e) => e.title === "Mutual Aid in Bloom: Into the Frostlands",
|
||||
);
|
||||
expect(dupes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nte fixture specifics", () => {
|
||||
test("yields current and upcoming events only", async () => {
|
||||
const events = await runAdapter(
|
||||
nteGame8,
|
||||
"fixtures/nte/game8-events-2026-08-14",
|
||||
);
|
||||
expect(events).toHaveLength(13); // 9 current + 4 upcoming
|
||||
|
||||
const titles = events.map((e) => e.title);
|
||||
expect(titles).toContain("Market Opening Rehearsal"); // current
|
||||
expect(titles).toContain("Fons Rush"); // upcoming
|
||||
expect(titles).not.toContain("Login Gift"); // permanent
|
||||
expect(titles).not.toContain("Tiger Perks"); // previous
|
||||
});
|
||||
|
||||
test("carries the summary column through", async () => {
|
||||
const events = await runAdapter(
|
||||
nteGame8,
|
||||
"fixtures/nte/game8-events-2026-08-14",
|
||||
);
|
||||
const circleGift = events.find((e) => e.title === "Circle Gift");
|
||||
expect(circleGift?.summary).toContain("Log in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferType", () => {
|
||||
const cases: Array<[string, EventType]> = [
|
||||
["Overflowing Abundance Rerun", "rerun"],
|
||||
["Stygian Onslaught", "challenge"],
|
||||
["Character Test Runs", "challenge"],
|
||||
["Gold Clash", "challenge"],
|
||||
["Seize the Day Login Bonus", "login"],
|
||||
["Epitome Invocation Banner", "banner"],
|
||||
["Mutual Aid in Bloom: Into the Frostlands", "other"],
|
||||
];
|
||||
test.each(cases)("%s → %s", (title, expected) => {
|
||||
expect(inferType(title)).toBe(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user