feat: add Star Rail, Wuthering Waves and Zenless Zone Zero

Three more Game8 templates. ZZZ spans each event name across two rows with a
rowspan, so a flat cell reader sees [title, "Start", date] then ["End",
date]; losing that pairing would silently halve the calendar.

Section matching also had to widen — these pages head their sections
"Featured Events", "List of Events and Schedule" and "Ongoing Events".

Verified independently of the parser: HSR's year-less summary table lists
six ongoing events and all six are captured via their detail rows, so
nothing is silently dropped.

28 events added across the three games.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:47:50 +02:00
co-authored by Claude Opus 5
parent 90be312942
commit 817ad41dcd
9 changed files with 10064 additions and 13 deletions
@@ -0,0 +1,128 @@
[
{
"id": "hsr:stellar-companion:2026-04-22",
"game": "hsr",
"title": "Stellar Companion",
"type": "other",
"summary": "Log in from 4.2 to the end of 4.4 to get a Golden Companion Spirit, which can be exchanged for a free 5-Star!",
"startsAt": "2026-04-22T00:00:00.000Z",
"startPrecision": "day",
"endsAt": null,
"endPrecision": "unknown",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/408749",
"sourceId": "hsr-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": "hsr:antigraft-brickbuster:2026-07-15",
"game": "hsr",
"title": "Antigraft Brickbuster",
"type": "other",
"summary": "Help Aventurine investigate IPC corruption by hacking into their database. Stack as many bricks of data to dismantle the firewall and complete each challenge!",
"startsAt": "2026-07-15T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-26T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/408749",
"sourceId": "hsr-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": "hsr:fate-gift:2026-07-15",
"game": "hsr",
"title": "Fate Gift",
"type": "other",
"summary": "Spend 200 Star Rail Special Passes on any Version 4.4 banner to get either Gilgamesh or Archer's Light Cone!",
"startsAt": "2026-07-15T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-26T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/408749",
"sourceId": "hsr-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": "hsr:fate-star-rail-night:2026-07-24",
"game": "hsr",
"title": "Fate/Star Rail Night",
"type": "other",
"summary": "Team up with Rin Tohsaka in this combat event and unlock various Noble Phantasms to unleash strong abilities against foes!",
"startsAt": "2026-07-24T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-26T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/408749",
"sourceId": "hsr-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": "hsr:free-gilgamesh-or-archer:2026-07-24",
"game": "hsr",
"title": "Free Gilgamesh or Archer",
"type": "other",
"summary": "Log in during the Fate Collab update until the end of Version 4.6 to claim either a free Gilgamesh or Archer !",
"startsAt": "2026-07-24T00:00:00.000Z",
"startPrecision": "day",
"endsAt": null,
"endPrecision": "unknown",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/408749",
"sourceId": "hsr-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": "hsr:4-4-garden-of-plenty:2026-08-14",
"game": "hsr",
"title": "4.4 Garden of Plenty",
"type": "other",
"summary": "Get double the rewards when challenging Golden and Crimson Calyxes!",
"startsAt": "2026-08-14T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-24T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Honkai-Star-Rail/archives/408749",
"sourceId": "hsr-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,212 @@
[
{
"id": "wuwa:a-glimpse-of-xuanfang:2026-07-10",
"game": "wuwa",
"title": "A Glimpse of Xuanfang",
"type": "other",
"summary": null,
"startsAt": "2026-07-10T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:gifts-of-aftertune:2026-07-10",
"game": "wuwa",
"title": "Gifts of Aftertune",
"type": "other",
"summary": null,
"startsAt": "2026-07-10T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:gifts-of-starpath:2026-07-10",
"game": "wuwa",
"title": "Gifts of Starpath",
"type": "other",
"summary": null,
"startsAt": "2026-07-10T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:shape-of-yesterday:2026-07-10",
"game": "wuwa",
"title": "Shape of Yesterday",
"type": "other",
"summary": null,
"startsAt": "2026-07-10T00:00:00.000Z",
"startPrecision": "day",
"endsAt": null,
"endPrecision": "unknown",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:lament-recon-tacet-crisis:2026-07-11",
"game": "wuwa",
"title": "Lament Recon: Tacet Crisis",
"type": "other",
"summary": null,
"startsAt": "2026-07-11T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:ascendant-aces:2026-07-30",
"game": "wuwa",
"title": "Ascendant Aces",
"type": "other",
"summary": null,
"startsAt": "2026-07-30T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:in-search-of-lost-jade:2026-07-30",
"game": "wuwa",
"title": "In Search of Lost Jade",
"type": "other",
"summary": null,
"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/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:virtual-crisis-quadrant-trials:2026-07-30",
"game": "wuwa",
"title": "Virtual Crisis: Quadrant Trials",
"type": "other",
"summary": null,
"startsAt": "2026-07-30T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:lollo-campaign-new-journey:2026-08-06",
"game": "wuwa",
"title": "Lollo Campaign: New Journey",
"type": "other",
"summary": null,
"startsAt": "2026-08-06T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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": "wuwa:chord-cleansing:2026-08-12",
"game": "wuwa",
"title": "Chord Cleansing",
"type": "other",
"summary": null,
"startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Wuthering-Waves/archives/453473",
"sourceId": "wuwa-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,254 @@
[
{
"id": "zzz:the-final-callback:2026-07-09",
"game": "zzz",
"title": "The Final Callback",
"type": "other",
"summary": null,
"startsAt": "2026-07-09T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:filmgoer-thank-you:2026-07-29",
"game": "zzz",
"title": "Filmgoer Thank You",
"type": "other",
"summary": null,
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-09T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:gift-from-the-clouds:2026-07-29",
"game": "zzz",
"title": "Gift From the Clouds",
"type": "other",
"summary": null,
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-08T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:phaethons-grand-reveal-of-the-year:2026-07-29",
"game": "zzz",
"title": "Phaethon's Grand Reveal of the Year",
"type": "other",
"summary": null,
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-09T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:summer-waves-rolls-in:2026-07-29",
"game": "zzz",
"title": "Summer Waves Rolls In",
"type": "other",
"summary": null,
"startsAt": "2026-07-29T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-07T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:snap-focus-showdown:2026-08-07",
"game": "zzz",
"title": "Snap Focus Showdown",
"type": "other",
"summary": null,
"startsAt": "2026-08-07T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-24T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:extensive-patrol-triple-bounty:2026-08-12",
"game": "zzz",
"title": "Extensive Patrol - Triple Bounty",
"type": "other",
"summary": null,
"startsAt": "2026-08-12T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-08-17T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:crispy-meal-deployment-plan:2026-08-19",
"game": "zzz",
"title": "Crispy Meal Deployment Plan",
"type": "other",
"summary": null,
"startsAt": "2026-08-19T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-07T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:the-great-en-nah-giveaway:2026-08-19",
"game": "zzz",
"title": "The Great En-Nah Giveaway",
"type": "other",
"summary": null,
"startsAt": "2026-08-19T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-08T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:dangerous-fugitives-leisurely-vacation:2026-08-24",
"game": "zzz",
"title": "Dangerous Fugitive's Leisurely Vacation",
"type": "other",
"summary": null,
"startsAt": "2026-08-24T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-07T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:ding-dong-delivery-training-in-progress:2026-08-28",
"game": "zzz",
"title": "Ding-Dong Delivery Training In Progress",
"type": "other",
"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/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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": "zzz:combat-training-triple-bounty:2026-09-02",
"game": "zzz",
"title": "Combat Training - Triple Bounty",
"type": "challenge",
"summary": null,
"startsAt": "2026-09-02T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-09-07T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
"sourceId": "zzz-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
+18
View File
@@ -26,6 +26,24 @@ const SOURCES: SourceSpec[] = [
url: "https://game8.co/games/Genshin-Impact/archives/301601",
parserId: "game8",
},
{
id: "hsr-game8-events",
game: "hsr",
url: "https://game8.co/games/Honkai-Star-Rail/archives/408749",
parserId: "game8",
},
{
id: "wuwa-game8-events",
game: "wuwa",
url: "https://game8.co/games/Wuthering-Waves/archives/453473",
parserId: "game8",
},
{
id: "zzz-game8-events",
game: "zzz",
url: "https://game8.co/games/Zenless-Zone-Zero/archives/457176",
parserId: "game8",
},
{
id: "nte-game8-events",
game: "nte",
+68 -4
View File
@@ -4,8 +4,10 @@ import {
type GachaEvent,
} from "../../shared/schema.ts";
import {
parseFullRange,
parseMonthDayRange,
parseMonthDayYear,
parseOpenRange,
parseSlashDateTimeRange,
type ParsedInstant,
} from "../dates.ts";
@@ -34,8 +36,11 @@ import type { SourceParser } from "./types.ts";
const INCLUDED_SECTIONS = [
/current events/i,
/upcoming events/i,
/^recurring events$/i,
/recurring events/i,
/events? schedule/i,
/featured events/i,
/list of events/i,
/ongoing events/i,
];
/**
@@ -57,7 +62,10 @@ const RANGE_LABEL = /^(availability period|event period|duration|period|dates)$/
/** Column-table header matchers. */
const COL_TITLE = /^(.*\b)?events?$/i;
const COL_RANGE = /^(duration|dates?|period|availability period|schedule)$/i;
const COL_RANGE =
/^(event |all )?(duration|dates?|event date|period|availability period|schedule)$/i;
const COL_START = /^start$/i;
const COL_END = /^end$/i;
const COL_SUMMARY = /^(event )?(details?|description|overview)$/i;
interface Candidate {
@@ -107,6 +115,12 @@ export function parseGame8EventsPage(
if (!sectionIncluded || node.kind !== "table") continue;
const fromStartEnd = readStartEndTable(node.headers, node.rows);
if (fromStartEnd.length > 0) {
candidates.push(...fromStartEnd);
continue;
}
const fromColumns = readColumnTable(node.rows);
if (fromColumns.length > 0) {
candidates.push(...fromColumns);
@@ -203,10 +217,60 @@ function readColumnTable(rows: string[][]): Candidate[] {
return out;
}
/**
* Try every known range shape, most specific first. `parseOpenRange` is last
* because it is the most permissive — it accepts any leading full date and
* reports no end.
*/
/**
* Shape 4: a `Event | Duration | Start | End` table where the event name spans
* two rows and each row carries one labelled boundary:
*
* | Summer Waves Rolls In | Start | July 29, 2026 |
* | | End | September 7, 2026 |
*
* The rowspan is invisible to a flat cell reader, so the shape is recovered
* from cell counts: a row that introduces a title, then a continuation row.
*/
function readStartEndTable(headers: string[], rows: string[][]): Candidate[] {
if (!headers.some((h) => COL_START.test(h)) || !headers.some((h) => COL_END.test(h))) {
return [];
}
const out: Candidate[] = [];
for (const row of rows) {
const cells = row.map((c) => c.trim());
// [title, "Start", date] — begins an event.
if (cells.length >= 3 && COL_START.test(cells[1] ?? "")) {
const title = cells[0] ?? "";
const start = parseMonthDayYear(cells[2] ?? "");
if (title.length > 0 && start !== null) {
out.push({ title, summary: null, start, end: null });
}
continue;
}
// ["End", date] — completes the event opened above.
if (cells.length >= 2 && COL_END.test(cells[0] ?? "")) {
const current = out.at(-1);
if (current !== undefined && current.end === null) {
current.end = parseMonthDayYear(cells[1] ?? "");
}
}
}
return out;
}
function parseRange(
value: string,
): { start: ParsedInstant; end: ParsedInstant } | null {
return parseSlashDateTimeRange(value) ?? parseMonthDayRange(value);
): { start: ParsedInstant; end: ParsedInstant | null } | null {
return (
parseSlashDateTimeRange(value) ??
parseFullRange(value) ??
parseMonthDayRange(value) ??
parseOpenRange(value)
);
}
/**
+53 -9
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { genshinGame8, nteGame8 } from "../../src/ingest/adapters/index.ts";
import { adapterById } 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";
@@ -10,15 +10,21 @@ import { GachaEvent, type EventType } from "../../src/shared/schema.ts";
*/
const NOW = "2026-08-14T00:00:00.000Z";
function adapter(id: string): Adapter {
const found = adapterById(id);
if (found === undefined) throw new Error(`no adapter '${id}'`);
return found;
}
const genshinGame8 = adapter("genshin-game8-events");
const nteGame8 = adapter("nte-game8-events");
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",
},
{ adapter: genshinGame8, fixture: "fixtures/genshin/game8-events-2026-08-14" },
{ adapter: nteGame8, fixture: "fixtures/nte/game8-events-2026-08-14" },
{ adapter: adapter("hsr-game8-events"), fixture: "fixtures/hsr/game8-events-2026-08-14" },
{ adapter: adapter("wuwa-game8-events"), fixture: "fixtures/wuwa/game8-events-2026-08-14" },
{ adapter: adapter("zzz-game8-events"), fixture: "fixtures/zzz/game8-events-2026-08-14" },
];
async function runAdapter(adapter: Adapter, fixture: string) {
@@ -161,3 +167,41 @@ describe("inferType", () => {
expect(inferType(title)).toBe(expected);
});
});
describe("new source shapes", () => {
test("zzz recovers events from rowspan Start/End rows", async () => {
// The event name spans two rows, so a flat cell reader sees
// [title, "Start", date] then ["End", date]. Losing the pairing would
// silently halve the calendar.
const events = await runAdapter(
adapter("zzz-game8-events"),
"fixtures/zzz/game8-events-2026-08-14",
);
const summer = events.find((e) => e.title === "Summer Waves Rolls In");
expect(summer?.startsAt).toBe("2026-07-29T00:00:00.000Z");
expect(summer?.endsAt).toBe("2026-09-07T00:00:00.000Z");
expect(events.every((e) => e.endsAt !== null)).toBe(true);
});
test("hsr keeps events whose end is not announced", async () => {
// "Jul. 24, 2026 - End of 4.6" has a real start and no knowable end.
// Publishing it with a guessed end would be the worst possible outcome.
const events = await runAdapter(
adapter("hsr-game8-events"),
"fixtures/hsr/game8-events-2026-08-14",
);
const open = events.filter((e) => e.endsAt === null);
expect(open.length).toBeGreaterThan(0);
for (const e of open) expect(e.endPrecision).toBe("unknown");
});
test("wuwa parses ranges carrying a year on both sides", async () => {
const events = await runAdapter(
adapter("wuwa-game8-events"),
"fixtures/wuwa/game8-events-2026-08-14",
);
const jade = events.find((e) => e.title === "In Search of Lost Jade");
expect(jade?.startsAt).toBe("2026-07-30T00:00:00.000Z");
expect(jade?.endsAt).toBe("2026-08-13T00:00:00.000Z");
});
});