feat(arknights): track Arknights from its own wiki
The most-named game in the release thread, and the cheapest one to add: the GameId, hue and dailyTasks have been sitting in games.ts since launch with no source behind them. arknights.wiki.gg is a better source than Game8 for this game, but it is not the mp-event shape wikigg.ts reads, so it gets its own parser. Two properties of the page drive the code: - Every row lists CN and Global, about five months apart. Only Global is published; a row without one yields no event rather than borrowing the CN date. A CN date on a Global calendar is a confidently wrong date. - Only the next boundary carries a machine-readable timer — the end while an event runs, the start while it is upcoming — so precision differs per side and flips when the event goes live. An exact instant is accepted only when it falls on the same UTC day as the date beside it, because startsAt.slice(0, 10) is part of the event ID: a start that moved a day would orphan every completion mark on the morning the event began. Also sets resetOffsets for all three regions to UTC-7. Not a blanket per-game offset — Arknights genuinely runs one Global server for every region we model, and the page evidences the clock: every ending event ends at 10:59:59Z, which is 03:59:59 at UTC-7, one second before a 04:00 reset. Six events, counted independently against the page before and after parsing. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8c2ec19c22
commit
1dd58d3f52
@@ -8,8 +8,8 @@ A web app that aggregates live and upcoming events across popular gacha games, p
|
|||||||
calendar, sorts them by end date or by what the reader is partway through, tracks day-by-day
|
calendar, sorts them by end date or by what the reader is partway through, tracks day-by-day
|
||||||
progress on events that repeat daily, and lets a user mark events completed.
|
progress on events that repeat daily, and lets a user mark events completed.
|
||||||
|
|
||||||
**Status: working app, refreshing itself on a schedule.** Schema, two parsers, nine sources across
|
**Status: working app, refreshing itself on a schedule.** Schema, three parsers, ten sources across
|
||||||
eight games, the full interface, offline support, a static server, a Docker image and CI all exist and
|
nine games, the full interface, offline support, a static server, a Docker image and CI all exist and
|
||||||
are tested. The refresh runner (`bun run refresh`) fetches, caches raw snapshots and rebuilds the
|
are tested. The refresh runner (`bun run refresh`) fetches, caches raw snapshots and rebuilds the
|
||||||
feed; `.github/workflows/refresh.yml` runs it twice a day and commits only when a page actually
|
feed; `.github/workflows/refresh.yml` runs it twice a day and commits only when a page actually
|
||||||
changed. The SQLite layer and the review queue are still specified in `docs/` but not built, so the
|
changed. The SQLite layer and the review queue are still specified in `docs/` but not built, so the
|
||||||
@@ -76,15 +76,15 @@ re-verify a sample against the live page afterward.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.ts, feed.ts
|
src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.ts, feed.ts
|
||||||
src/ingest/ html.ts, dates.ts (eight formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts
|
src/ingest/ html.ts, dates.ts (nine formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts
|
||||||
parsers/ game8.ts, wikigg.ts — keyed by SITE, not game
|
parsers/ game8.ts, wikigg.ts, akwiki.ts — keyed by SITE, not game
|
||||||
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
||||||
src/client/ React app, service worker, manifest
|
src/client/ React app, service worker, manifest
|
||||||
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
||||||
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
||||||
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 348 tests
|
test/ 366 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
snapshots/ current page per source, rewritten by refresh — see its README
|
||||||
```
|
```
|
||||||
@@ -116,8 +116,12 @@ These come from how gacha games actually schedule things, and they cause most bu
|
|||||||
- **Skip, never guess.** Every function in `dates.ts` returns `null` rather than inferring a missing
|
- **Skip, never guess.** Every function in `dates.ts` returns `null` rather than inferring a missing
|
||||||
year, month, or end. `readColumnTable` drops a row it cannot date. An omitted event is a
|
year, month, or end. `readColumnTable` drops a row it cannot date. An omitted event is a
|
||||||
recoverable disappointment; a confidently wrong date is the failure this product exists to prevent.
|
recoverable disappointment; a confidently wrong date is the failure this product exists to prevent.
|
||||||
- **Parsers are keyed by site, not game.** One `game8` parser serves eight sources; `wikigg` serves
|
- **Parsers are keyed by site, not game.** One `game8` parser serves eight sources; `wikigg` and
|
||||||
one. Adding a source for a known site is one `SOURCES` entry; a new site is a parser module.
|
`akwiki` serve one each — same host family, entirely different templates. Adding a source for a
|
||||||
|
known site is one `SOURCES` entry; a new site is a parser module.
|
||||||
|
- **A source may publish more than one region's schedule.** Arknights' wiki lists CN and Global on
|
||||||
|
every row, five months apart. Publish the one our readers are on and skip the row that lacks it —
|
||||||
|
a CN date on a Global calendar is a confidently wrong date, not a near miss.
|
||||||
- **Game8 has no single template.** Seven shapes are known and a page may mix them: label/value
|
- **Game8 has no single template.** Seven shapes are known and a page may mix them: label/value
|
||||||
detail tables, column tables, image-grid schedules (unsupportable), combined label+range+blurb
|
detail tables, column tables, image-grid schedules (unsupportable), combined label+range+blurb
|
||||||
cells, rowspan Start/End pairs, labelled `Start: … End: …` cells, and `<hr>`-separated date pairs.
|
cells, rowspan Start/End pairs, labelled `Start: … End: …` cells, and `<hr>`-separated date pairs.
|
||||||
|
|||||||
@@ -255,9 +255,16 @@ reader cannot see.
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Genshin, Star Rail, ZZZ, Wuwa, NTE | 04:00 | region (EU = UTC+1) | 03:00 | 05:00 / 04:00 |
|
| Genshin, Star Rail, ZZZ, Wuwa, NTE | 04:00 | region (EU = UTC+1) | 03:00 | 05:00 / 04:00 |
|
||||||
| Infinity Nikki, P5X | 04:00 | region (assumed) | 03:00 | 05:00 / 04:00 |
|
| Infinity Nikki, P5X | 04:00 | region (assumed) | 03:00 | 05:00 / 04:00 |
|
||||||
|
| Arknights, all regions | 04:00 | UTC-7 (one Global server) | 11:00 | 13:00 / 12:00 |
|
||||||
| Endfield, Europe | 04:00 | UTC-5 (on the Americas server) | 09:00 | 11:00 / 10:00 |
|
| Endfield, Europe | 04:00 | UTC-5 (on the Americas server) | 09:00 | 11:00 / 10:00 |
|
||||||
| Endfield, Asia / Americas | 04:00 | regional default | 20:00 / 09:00 | — |
|
| Endfield, Asia / Americas | 04:00 | regional default | 20:00 / 09:00 | — |
|
||||||
|
|
||||||
|
Arknights is the one game whose override covers **all three** regions, and it is not a blanket
|
||||||
|
per-game offset of the kind this section warns about below: the game genuinely runs a single Global
|
||||||
|
server for every region we model. The offset is read off the source rather than assumed — every
|
||||||
|
ending event on arknights.wiki.gg carries an exact end of `10:59:59Z`, which is `03:59:59` at UTC-7,
|
||||||
|
one second before a 04:00 reset.
|
||||||
|
|
||||||
Infinity Nikki and P5X carry **no `resetOffsets` entry**, so they take the regional default. That is
|
Infinity Nikki and P5X carry **no `resetOffsets` entry**, so they take the regional default. That is
|
||||||
an assumption, not a verified server map — neither source states one. Confirm it against the games
|
an assumption, not a verified server map — neither source states one. Confirm it against the games
|
||||||
before relying on it, and note that adding an override later re-labels the game-day of ticks readers
|
before relying on it, and note that adding an override later re-labels the game-day of ticks readers
|
||||||
|
|||||||
@@ -47,11 +47,25 @@ Consequences worth internalising:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Infinity Nikki, Persona 5: The Phantom X |
|
| `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Infinity Nikki, Persona 5: The Phantom X |
|
||||||
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
|
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
|
||||||
|
| `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights |
|
||||||
|
|
||||||
`wikigg` is the better shape by a distance: it emits ISO timestamps with one timer per server
|
`wikigg` is the better shape by a distance: it emits ISO timestamps with one timer per server
|
||||||
region, so its events carry exact precision and real `regionEnds`. Prefer a source like that over a
|
region, so its events carry exact precision and real `regionEnds`. Prefer a source like that over a
|
||||||
prose wiki when both exist, and give it a higher `priority`.
|
prose wiki when both exist, and give it a higher `priority`.
|
||||||
|
|
||||||
|
`akwiki` shares a host family with `wikigg` and nothing else — arknights.wiki.gg has no `mp-event`
|
||||||
|
cards, so the two are separate modules rather than one parser with a branch. Two things about that
|
||||||
|
page shape are worth knowing before touching it:
|
||||||
|
|
||||||
|
- **Every row states two schedules, CN and Global, about five months apart.** Only Global is
|
||||||
|
published. A row with no Global line yields no event rather than borrowing the CN one.
|
||||||
|
- **Only the next boundary is machine-readable.** The countdown sits on the end while an event runs
|
||||||
|
and on the start while it is still upcoming, so one side is exact and the other is the table's
|
||||||
|
date — and which is which flips when the event goes live. An exact instant is therefore accepted
|
||||||
|
only when it falls on the same UTC day as the date beside it, because `startsAt.slice(0, 10)` is
|
||||||
|
part of the event ID and a start that moved a day would orphan every completion mark on the
|
||||||
|
morning the event began.
|
||||||
|
|
||||||
### Date formats understood
|
### Date formats understood
|
||||||
|
|
||||||
All live in `src/ingest/dates.ts`, each returning null rather than inferring anything:
|
All live in `src/ingest/dates.ts`, each returning null rather than inferring anything:
|
||||||
@@ -65,6 +79,7 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any
|
|||||||
| `parseSlashDateTimeRange` | `2021/01/16 04:00 - 2021/01/31 03:59` | Genshin past events |
|
| `parseSlashDateTimeRange` | `2021/01/16 04:00 - 2021/01/31 03:59` | Genshin past events |
|
||||||
| `parseLabelledStartEnd` | `Start: January 24, 2025 End: Permanent` | Infinity Nikki |
|
| `parseLabelledStartEnd` | `Start: January 24, 2025 End: Permanent` | Infinity Nikki |
|
||||||
| `parseAdjacentFullRange` | `July 30, 2026 August 13, 2026` (halves split by an `<hr>`) | Persona 5: The Phantom X |
|
| `parseAdjacentFullRange` | `July 30, 2026 August 13, 2026` (halves split by an `<hr>`) | Persona 5: The Phantom X |
|
||||||
|
| `parseYearFirstSlashRange` | `2026/07/30 – 2026/08/20` (year first, so field order is not inferred) | Arknights |
|
||||||
| `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves |
|
| `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves |
|
||||||
|
|
||||||
`parseOpenRange` is tried last because it is the most permissive — it accepts any leading full date
|
`parseOpenRange` is tried last because it is the most permissive — it accepts any leading full date
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "arknights:vector-breakthrough-2-trial-from-misery:2026-07-30",
|
||||||
|
"game": "arknights",
|
||||||
|
"title": "Vector Breakthrough #2: Trial from Misery",
|
||||||
|
"type": "challenge",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2026-07-30T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": "2026-08-20T10:59:59.000Z",
|
||||||
|
"endPrecision": "exact",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://arknights.wiki.gg/wiki/Vector_Breakthrough_Trial_from_Misery",
|
||||||
|
"sourceId": "arknights-akwiki-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arknights:story-collection-crossing:2026-08-13",
|
||||||
|
"game": "arknights",
|
||||||
|
"title": "[Story Collection] Crossing",
|
||||||
|
"type": "story",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2026-08-13T00:00:00.000Z",
|
||||||
|
"startPrecision": "day",
|
||||||
|
"endsAt": "2026-08-20T10:59:59.000Z",
|
||||||
|
"endPrecision": "exact",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://arknights.wiki.gg/wiki/Crossing",
|
||||||
|
"sourceId": "arknights-akwiki-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arknights:stronghold-protocol-alliance-2nd-half:2026-08-20",
|
||||||
|
"game": "arknights",
|
||||||
|
"title": "Stronghold Protocol Alliance 2nd Half",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2026-08-20T15:00:00.000Z",
|
||||||
|
"startPrecision": "exact",
|
||||||
|
"endsAt": "2026-09-17T00:00:00.000Z",
|
||||||
|
"endPrecision": "day",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://arknights.wiki.gg/wiki/Stronghold_Protocol_Alliance_2nd_Half",
|
||||||
|
"sourceId": "arknights-akwiki-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arknights:rerun-when-elegies-are-ashes-rerun:2026-08-27",
|
||||||
|
"game": "arknights",
|
||||||
|
"title": "[Rerun] When Elegies Are Ashes Rerun",
|
||||||
|
"type": "rerun",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2026-08-27T11:00:00.000Z",
|
||||||
|
"startPrecision": "exact",
|
||||||
|
"endsAt": "2026-09-06T00:00:00.000Z",
|
||||||
|
"endPrecision": "day",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://arknights.wiki.gg/wiki/When_Elegies_Are_Ashes_Rerun",
|
||||||
|
"sourceId": "arknights-akwiki-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arknights:side-story-people-a-people:2026-09-16",
|
||||||
|
"game": "arknights",
|
||||||
|
"title": "[Side Story] People, A People",
|
||||||
|
"type": "story",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2026-09-16T15:00:00.000Z",
|
||||||
|
"startPrecision": "exact",
|
||||||
|
"endsAt": "2026-09-30T00:00:00.000Z",
|
||||||
|
"endPrecision": "day",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://arknights.wiki.gg/wiki/People,_A_People",
|
||||||
|
"sourceId": "arknights-akwiki-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arknights:celebration-critical-phase-transition:2026-10-14",
|
||||||
|
"game": "arknights",
|
||||||
|
"title": "[Celebration] Critical Phase Transition",
|
||||||
|
"type": "other",
|
||||||
|
"summary": null,
|
||||||
|
"startsAt": "2026-10-14T15:00:00.000Z",
|
||||||
|
"startPrecision": "exact",
|
||||||
|
"endsAt": "2026-10-28T00:00:00.000Z",
|
||||||
|
"endPrecision": "day",
|
||||||
|
"regionScoped": false,
|
||||||
|
"regionEnds": null,
|
||||||
|
"sourceUrl": "https://arknights.wiki.gg/wiki/Critical_Phase_Transition",
|
||||||
|
"sourceId": "arknights-akwiki-events",
|
||||||
|
"status": "published",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"extractionMethod": "parser",
|
||||||
|
"version": 1,
|
||||||
|
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,12 @@ const SOURCES: SourceSpec[] = [
|
|||||||
// so this source wins when the two disagree.
|
// so this source wins when the two disagree.
|
||||||
priority: 10,
|
priority: 10,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "arknights-akwiki-events",
|
||||||
|
game: "arknights",
|
||||||
|
url: "https://arknights.wiki.gg/wiki/Event",
|
||||||
|
parserId: "akwiki",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "nte-game8-events",
|
id: "nte-game8-events",
|
||||||
game: "nte",
|
game: "nte",
|
||||||
|
|||||||
@@ -223,6 +223,37 @@ export function parseOpenRange(
|
|||||||
return start === null ? null : { start, end: null };
|
return start === null ? null : { start, end: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "2026/07/30 – 2026/08/20" → both instants, day precision.
|
||||||
|
*
|
||||||
|
* Year-first, unlike `parseShortSlashRange`'s MM/DD/YY, so there is nothing to
|
||||||
|
* infer about field order: a four-digit leading number can only be the year.
|
||||||
|
* The Arknights wiki writes its release windows this way, one line per server.
|
||||||
|
*
|
||||||
|
* Anchored on the four-digit year at both ends deliberately. Without it the
|
||||||
|
* looser MM/DD/YY reader matches the tail of "2026/07/30" as "26/07/30" and
|
||||||
|
* calls month 26 an invalid date — null either way, but by accident rather than
|
||||||
|
* by rule.
|
||||||
|
*/
|
||||||
|
export function parseYearFirstSlashRange(
|
||||||
|
input: string,
|
||||||
|
): { start: ParsedInstant; end: ParsedInstant } | null {
|
||||||
|
const re =
|
||||||
|
/(\d{4})\/(\d{1,2})\/(\d{1,2})\s*[-–—]\s*(\d{4})\/(\d{1,2})\/(\d{1,2})/;
|
||||||
|
const m = re.exec(input);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
const n = (i: number) => Number(m[i]);
|
||||||
|
const startIso = iso(n(1), n(2), n(3));
|
||||||
|
const endIso = iso(n(4), n(5), n(6));
|
||||||
|
if (startIso === null || endIso === null) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: { iso: startIso, precision: "day" },
|
||||||
|
end: { iso: endIso, precision: "day" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "2021/01/16 04:00 - 2021/01/31 03:59" → both instants, exact precision.
|
* "2021/01/16 04:00 - 2021/01/31 03:59" → both instants, exact precision.
|
||||||
* Trailing prose after the range (e.g. "Currently Unavailable") is ignored.
|
* Trailing prose after the range (e.g. "Currently Unavailable") is ignored.
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { eventId, type GachaEvent } from "../../shared/schema.ts";
|
||||||
|
import { parseYearFirstSlashRange, type ParsedInstant } from "../dates.ts";
|
||||||
|
import { text } from "../html.ts";
|
||||||
|
import type { ParseContext } from "../adapters/types.ts";
|
||||||
|
import { inferType } from "./game8.ts";
|
||||||
|
import type { SourceParser } from "./types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Arknights wiki's own event table (`arknights.wiki.gg`).
|
||||||
|
*
|
||||||
|
* Same host family as `wikigg.ts` but a different template entirely — that
|
||||||
|
* parser reads the `mp-event` main-page cards, and this page has none. What it
|
||||||
|
* has is an "Ongoing/upcoming" table, one row per event:
|
||||||
|
*
|
||||||
|
* <table class="mrfz-wtable flex-table">
|
||||||
|
* <tr><td><b><a href="/wiki/SLUG">TITLE</a></b><img …></td>
|
||||||
|
* <td><div><b>CN:</b> 2026/02/24 – 2026/03/17</div>
|
||||||
|
* <div><b>Global:</b> 2026/07/30 – 2026/08/20
|
||||||
|
* <div class="countdown">; ends in
|
||||||
|
* <span class="countdowndate">2026-08-20T10:59:59+00:00</span>
|
||||||
|
*
|
||||||
|
* Two things about this source shape decide most of the code below.
|
||||||
|
*
|
||||||
|
* **Only the Global line is ours.** Every row also carries the CN release,
|
||||||
|
* which runs roughly five months ahead. A reader on the Global server told a CN
|
||||||
|
* date would be told an event ended in March that has not started yet — the
|
||||||
|
* exact confidently-wrong failure this codebase exists to prevent. CN, JP and
|
||||||
|
* KR lines are skipped, and a row with no Global line yields no event at all
|
||||||
|
* rather than borrowing one.
|
||||||
|
*
|
||||||
|
* **Each event states exactly one boundary precisely.** The countdown is on
|
||||||
|
* whichever boundary is next: the end while an event is running, the start
|
||||||
|
* while it is still upcoming. So one side is an exact instant from the source
|
||||||
|
* and the other is the day-precision date from the table text, and which side
|
||||||
|
* is which flips as the event goes live.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Whitespace as this page writes it — sometimes a space, sometimes ` `. */
|
||||||
|
const OTHER_SERVER = /<b>\s*(?:CN|JP|KR|TW)\s*:?\s*<\/b>/i;
|
||||||
|
const GLOBAL_LINE = /<b>\s*Global\s*:?\s*<\/b>([\s\S]*)$/i;
|
||||||
|
const TABLE = /<table\b[^>]*>([\s\S]*?)<\/table>/i;
|
||||||
|
const ROW = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||||
|
const CELL = /<t([dh])\b[^>]*>([\s\S]*?)<\/t\1>/gi;
|
||||||
|
const WIKI_LINK = /<a href="(\/wiki\/[^"]+)"/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `; ends in <span class="countdowndate">2026-08-20T10:59:59+00:00</span>`.
|
||||||
|
*
|
||||||
|
* The gap between the word and the span is ` ` rather than a space, so this
|
||||||
|
* allows a short run of any non-tag text between them instead of matching
|
||||||
|
* whitespace.
|
||||||
|
*/
|
||||||
|
const COUNTDOWN =
|
||||||
|
/(starts|ends)[^<]{0,24}<span[^>]*class="[^"]*countdowndate[^"]*"[^>]*>([^<]+)<\/span>/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Ongoing/upcoming" section only.
|
||||||
|
*
|
||||||
|
* The same page lists every event the game has ever run under "By year"
|
||||||
|
* further down, in the same table class. Publishing those would put a hundred
|
||||||
|
* finished events on the calendar, so inclusion stops at the next heading.
|
||||||
|
*/
|
||||||
|
function ongoingSection(html: string): string | null {
|
||||||
|
const anchor = /id="Ongoing(?:\/|\.2F)upcoming"/i.exec(html);
|
||||||
|
if (anchor === null) return null;
|
||||||
|
|
||||||
|
const from = anchor.index;
|
||||||
|
const afterHeading = html.indexOf("</h3>", from);
|
||||||
|
const body = html.slice(afterHeading < 0 ? from : afterHeading + 5);
|
||||||
|
const next = body.search(/<h[23]\b/i);
|
||||||
|
return next < 0 ? body : body.slice(0, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Global server's half of a date cell, with the other servers' cut off. */
|
||||||
|
function globalHalf(cell: string): string | null {
|
||||||
|
const m = GLOBAL_LINE.exec(cell);
|
||||||
|
if (m === null) return null;
|
||||||
|
const rest = m[1] ?? "";
|
||||||
|
const other = OTHER_SERVER.exec(rest);
|
||||||
|
return other === null ? rest : rest.slice(0, other.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameUtcDay(a: string, b: string): boolean {
|
||||||
|
return a.slice(0, 10) === b.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a day-precision boundary with the source's exact instant — but only
|
||||||
|
* when the two fall on the same UTC day.
|
||||||
|
*
|
||||||
|
* This guard is about event IDs, not about accuracy. An ID ends in
|
||||||
|
* `startsAt.slice(0, 10)`, and this page publishes an exact *start* only while
|
||||||
|
* an event is upcoming: the moment it goes live the countdown switches to the
|
||||||
|
* end and the start falls back to the table's date. If an exact start could
|
||||||
|
* ever carry a different UTC day than the date beside it, the event's ID would
|
||||||
|
* change on the day it began — silently orphaning every completion mark anyone
|
||||||
|
* had made against it. Keeping the day fixed makes that impossible rather than
|
||||||
|
* unlikely.
|
||||||
|
*/
|
||||||
|
function refine(day: ParsedInstant, exact: string): ParsedInstant {
|
||||||
|
const ms = Date.parse(exact);
|
||||||
|
if (Number.isNaN(ms)) return day;
|
||||||
|
const iso = new Date(ms).toISOString();
|
||||||
|
return sameUtcDay(iso, day.iso) ? { iso, precision: "exact" } : day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArknightsWikiEventsPage(
|
||||||
|
html: string,
|
||||||
|
ctx: ParseContext,
|
||||||
|
): GachaEvent[] {
|
||||||
|
const section = ongoingSection(html.replace(/\s+/g, " "));
|
||||||
|
if (section === null) return [];
|
||||||
|
|
||||||
|
const table = TABLE.exec(section)?.[1];
|
||||||
|
if (table === undefined) return [];
|
||||||
|
|
||||||
|
const out: GachaEvent[] = [];
|
||||||
|
|
||||||
|
for (const row of table.matchAll(ROW)) {
|
||||||
|
const body = row[1] ?? "";
|
||||||
|
const cells = [...body.matchAll(CELL)].map((c) => ({
|
||||||
|
tag: c[1] ?? "",
|
||||||
|
html: c[2] ?? "",
|
||||||
|
}));
|
||||||
|
// The header row is `<th>Event</th><th>Date</th>`.
|
||||||
|
if (cells.length < 2 || cells.some((c) => c.tag === "h")) continue;
|
||||||
|
|
||||||
|
const titleCell = cells[0]?.html ?? "";
|
||||||
|
const title = text(titleCell);
|
||||||
|
if (title.length === 0) continue;
|
||||||
|
|
||||||
|
const half = globalHalf(cells[1]?.html ?? "");
|
||||||
|
// No Global line means the event has only been announced for another
|
||||||
|
// server. There is no honest date to publish, so there is no event.
|
||||||
|
if (half === null) continue;
|
||||||
|
|
||||||
|
const range = parseYearFirstSlashRange(text(half));
|
||||||
|
if (range === null) continue;
|
||||||
|
|
||||||
|
let { start, end } = range;
|
||||||
|
const countdown = COUNTDOWN.exec(half);
|
||||||
|
if (countdown !== null) {
|
||||||
|
const exact = countdown[2] ?? "";
|
||||||
|
if (/^ends$/i.test(countdown[1] ?? "")) end = refine(end, exact);
|
||||||
|
else start = refine(start, exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end.iso <= start.iso) continue;
|
||||||
|
|
||||||
|
// Same scoring as the Game8 parser: an exact boundary is firmer evidence
|
||||||
|
// than a date with the time of day left off.
|
||||||
|
let confidence = 0.95;
|
||||||
|
if (start.precision === "day") confidence -= 0.05;
|
||||||
|
if (end.precision === "day") confidence -= 0.05;
|
||||||
|
|
||||||
|
const href = WIKI_LINK.exec(titleCell)?.[1];
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
id: eventId(ctx.game, title, start.iso),
|
||||||
|
game: ctx.game,
|
||||||
|
title,
|
||||||
|
type: inferType(title),
|
||||||
|
summary: null,
|
||||||
|
startsAt: start.iso,
|
||||||
|
startPrecision: start.precision,
|
||||||
|
endsAt: end.iso,
|
||||||
|
endPrecision: end.precision,
|
||||||
|
// Arknights runs one Global server for all three of our regions, so an
|
||||||
|
// end really is a single worldwide instant here. This is the opposite of
|
||||||
|
// Endfield next door, and saying so is not a shortcut: `regionScoped`
|
||||||
|
// means the source distinguishes regions, and this one does not.
|
||||||
|
regionScoped: false,
|
||||||
|
regionEnds: null,
|
||||||
|
sourceUrl:
|
||||||
|
href === undefined
|
||||||
|
? ctx.sourceUrl
|
||||||
|
: new URL(href, ctx.sourceUrl).toString(),
|
||||||
|
sourceId: ctx.sourceId,
|
||||||
|
status: "published",
|
||||||
|
confidence: Math.round(confidence * 100) / 100,
|
||||||
|
extractionMethod: "parser",
|
||||||
|
version: 1,
|
||||||
|
firstSeenAt: ctx.now,
|
||||||
|
updatedAt: ctx.now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.sort((a, b) =>
|
||||||
|
a.startsAt === b.startsAt
|
||||||
|
? a.id.localeCompare(b.id)
|
||||||
|
: a.startsAt.localeCompare(b.startsAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const arknightsWikiParser: SourceParser = {
|
||||||
|
id: "akwiki",
|
||||||
|
label: "Arknights Wiki",
|
||||||
|
canParse(html: string): boolean {
|
||||||
|
// Both halves matter. The table class alone also appears under "By year",
|
||||||
|
// and the anchor alone would survive the table being replaced by the image
|
||||||
|
// grid this wiki uses elsewhere — either on its own could return zero
|
||||||
|
// events and read downstream as "Arknights has nothing on".
|
||||||
|
return (
|
||||||
|
/mrfz-wtable/.test(html) && /id="Ongoing(?:\/|\.2F)upcoming"/i.test(html)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
parse: parseArknightsWikiEventsPage,
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { arknightsWikiParser } from "./akwiki.ts";
|
||||||
import { game8Parser } from "./game8.ts";
|
import { game8Parser } from "./game8.ts";
|
||||||
import { wikiGgParser } from "./wikigg.ts";
|
import { wikiGgParser } from "./wikigg.ts";
|
||||||
import type { SourceParser } from "./types.ts";
|
import type { SourceParser } from "./types.ts";
|
||||||
@@ -7,12 +8,17 @@ import type { SourceParser } from "./types.ts";
|
|||||||
* an entry in `adapters/index.ts`; adding a new *site* means a parser module
|
* an entry in `adapters/index.ts`; adding a new *site* means a parser module
|
||||||
* here and one line below.
|
* here and one line below.
|
||||||
*/
|
*/
|
||||||
export const PARSERS: SourceParser[] = [game8Parser, wikiGgParser];
|
export const PARSERS: SourceParser[] = [
|
||||||
|
game8Parser,
|
||||||
|
wikiGgParser,
|
||||||
|
arknightsWikiParser,
|
||||||
|
];
|
||||||
|
|
||||||
export function parserById(id: string): SourceParser | undefined {
|
export function parserById(id: string): SourceParser | undefined {
|
||||||
return PARSERS.find((p) => p.id === id);
|
return PARSERS.find((p) => p.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { SourceParser } from "./types.ts";
|
export type { SourceParser } from "./types.ts";
|
||||||
|
export { arknightsWikiParser } from "./akwiki.ts";
|
||||||
export { game8Parser } from "./game8.ts";
|
export { game8Parser } from "./game8.ts";
|
||||||
export { wikiGgParser } from "./wikigg.ts";
|
export { wikiGgParser } from "./wikigg.ts";
|
||||||
|
|||||||
+8
-1
@@ -45,7 +45,14 @@ export const GAMES: Record<GameId, GameMeta> = {
|
|||||||
hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" , studio: "HoYoverse", dailyTasks: "Daily training, Trailblaze Power" },
|
hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" , studio: "HoYoverse", dailyTasks: "Daily training, Trailblaze Power" },
|
||||||
zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse", dailyTasks: "Daily missions, battery" },
|
zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse", dailyTasks: "Daily missions, battery" },
|
||||||
wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" , studio: "Kuro Games", dailyTasks: "Daily activity, waveplate" },
|
wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" , studio: "Kuro Games", dailyTasks: "Daily activity, waveplate" },
|
||||||
arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" , studio: "Hypergryph", dailyTasks: "Daily missions, sanity" },
|
// Arknights runs a single Global (EN) server for all three of our regions on
|
||||||
|
// a fixed UTC-7, so every region gets the same override rather than the
|
||||||
|
// regional default. Evidenced by the source rather than assumed: every ending
|
||||||
|
// event on arknights.wiki.gg carries an exact end of 10:59:59Z, which is
|
||||||
|
// 03:59:59 at UTC-7 — one second before a 04:00 reset. Without this a
|
||||||
|
// European reader's Arknights day would roll at 03:00 UTC while the game
|
||||||
|
// rolls at 11:00, ticking the wrong box for eight hours.
|
||||||
|
arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" , studio: "Hypergryph", dailyTasks: "Daily missions, sanity", resetOffsets: { asia: -7, america: -7, europe: -7 } },
|
||||||
// Endfield has two server groups, not three: Europe is served off the same
|
// Endfield has two server groups, not three: Europe is served off the same
|
||||||
// machine as the Americas, on a fixed UTC-5. So a European player's day rolls
|
// machine as the Americas, on a fixed UTC-5. So a European player's day rolls
|
||||||
// at 09:00 UTC — 11:00 in Copenhagen in summer, 10:00 in winter — six hours
|
// at 09:00 UTC — 11:00 in Copenhagen in summer, 10:00 in winter — six hours
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { adapterById } from "../../src/ingest/adapters/index.ts";
|
import { adapterById } from "../../src/ingest/adapters/index.ts";
|
||||||
import type { Adapter } from "../../src/ingest/adapters/types.ts";
|
import type { Adapter } from "../../src/ingest/adapters/types.ts";
|
||||||
|
import { arknightsWikiParser } from "../../src/ingest/parsers/akwiki.ts";
|
||||||
import { inferType } from "../../src/ingest/parsers/game8.ts";
|
import { inferType } from "../../src/ingest/parsers/game8.ts";
|
||||||
import { GachaEvent, type EventType } from "../../src/shared/schema.ts";
|
import { GachaEvent, type EventType } from "../../src/shared/schema.ts";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
|||||||
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
|
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
|
||||||
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
|
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
|
||||||
{ adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" },
|
{ adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" },
|
||||||
|
{ adapter: adapter("arknights-akwiki-events"), fixture: "fixtures/arknights/akwiki-events-2026-08-17" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||||
@@ -312,6 +314,83 @@ describe("p5x", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("arknights wiki", () => {
|
||||||
|
const fixture = "fixtures/arknights/akwiki-events-2026-08-17";
|
||||||
|
const akwiki = adapter("arknights-akwiki-events");
|
||||||
|
|
||||||
|
test("publishes the Global schedule and never the CN one", async () => {
|
||||||
|
// Every row carries both, and CN runs about five months ahead. A reader on
|
||||||
|
// Global told a CN date would be told an event ended in March that has not
|
||||||
|
// started yet — the confidently-wrong date this product exists to prevent.
|
||||||
|
const events = await runAdapter(akwiki, fixture);
|
||||||
|
expect(events).toHaveLength(6); // the six rows of the Ongoing/upcoming table
|
||||||
|
|
||||||
|
const crossing = events.find((e) => e.title === "[Story Collection] Crossing");
|
||||||
|
expect(crossing?.startsAt).toBe("2026-08-13T00:00:00.000Z"); // Global
|
||||||
|
// CN ran 2026/03/10 - 2026/03/17. Nothing in the output may fall there.
|
||||||
|
for (const e of events) expect(e.startsAt > "2026-06-01").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("takes the exact boundary the countdown states, on whichever side it is", async () => {
|
||||||
|
// The page puts a machine-readable timer on the *next* boundary only: the
|
||||||
|
// end while an event is running, the start while it is still upcoming. So
|
||||||
|
// precision legitimately differs per event and per side.
|
||||||
|
const events = await runAdapter(akwiki, fixture);
|
||||||
|
|
||||||
|
const running = events.find((e) => e.title.startsWith("Vector Breakthrough"));
|
||||||
|
expect(running?.endPrecision).toBe("exact");
|
||||||
|
expect(running?.endsAt).toBe("2026-08-20T10:59:59.000Z");
|
||||||
|
expect(running?.startPrecision).toBe("day");
|
||||||
|
|
||||||
|
const upcoming = events.find((e) => e.title === "[Side Story] People, A People");
|
||||||
|
expect(upcoming?.startPrecision).toBe("exact");
|
||||||
|
expect(upcoming?.startsAt).toBe("2026-09-16T15:00:00.000Z");
|
||||||
|
expect(upcoming?.endPrecision).toBe("day");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an exact instant never moves the day an event ID is built from", async () => {
|
||||||
|
// The ID ends in startsAt.slice(0, 10). When an upcoming event goes live
|
||||||
|
// its exact start disappears from the page and the day-precision date takes
|
||||||
|
// over, so the two must agree on the day or every completion mark for that
|
||||||
|
// event is orphaned the morning it starts.
|
||||||
|
const events = await runAdapter(akwiki, fixture);
|
||||||
|
for (const e of events) {
|
||||||
|
expect(e.id.endsWith(e.startsAt.slice(0, 10))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports one global end rather than inventing per-region ones", async () => {
|
||||||
|
// Arknights serves all three of our regions from one Global server, which
|
||||||
|
// is the opposite of Endfield. regionScoped means the source distinguishes
|
||||||
|
// regions; this one does not.
|
||||||
|
for (const e of await runAdapter(akwiki, fixture)) {
|
||||||
|
expect(e.regionScoped).toBe(false);
|
||||||
|
expect(e.regionEnds).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the back catalogue under 'By year' stays off the calendar", async () => {
|
||||||
|
// The same table class repeats further down the page for every event the
|
||||||
|
// game has ever run. Inclusion has to stop at the next heading.
|
||||||
|
const titles = (await runAdapter(akwiki, fixture)).map((e) => e.title);
|
||||||
|
expect(titles).not.toContain("Ceremonial Rite");
|
||||||
|
expect(titles.length).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails loudly if the wiki drops the section or the table", async () => {
|
||||||
|
const html = await Bun.file(`${fixture}.html`).text();
|
||||||
|
const parser = arknightsWikiParser;
|
||||||
|
expect(parser.canParse(html)).toBe(true);
|
||||||
|
expect(parser.canParse(html.replace(/mrfz-wtable/g, "x-table"))).toBe(false);
|
||||||
|
// MediaWiki emits both the modern anchor and a legacy percent-escaped one
|
||||||
|
// (`Ongoing.2Fupcoming`) on the same heading, so a rename has to take both
|
||||||
|
// away before the parser should give up on the page.
|
||||||
|
expect(
|
||||||
|
parser.canParse(html.replace(/id="Ongoing(\/|\.2F)upcoming"/g, 'id="Now"')),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("wiki.gg parser", () => {
|
describe("wiki.gg parser", () => {
|
||||||
test("reads exact per-region timers", async () => {
|
test("reads exact per-region timers", async () => {
|
||||||
// The first source that states region-scoped ends. Asia and the Americas
|
// The first source that states region-scoped ends. Asia and the Americas
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
parseMonthDayYear,
|
parseMonthDayYear,
|
||||||
parseOpenRange,
|
parseOpenRange,
|
||||||
parseSlashDateTimeRange,
|
parseSlashDateTimeRange,
|
||||||
|
parseYearFirstSlashRange,
|
||||||
} from "../src/ingest/dates.ts";
|
} from "../src/ingest/dates.ts";
|
||||||
|
|
||||||
describe("parseMonthDayYear", () => {
|
describe("parseMonthDayYear", () => {
|
||||||
@@ -198,3 +199,38 @@ describe("parseLabelledStartEnd", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseYearFirstSlashRange", () => {
|
||||||
|
test("reads a year-first range at day precision", () => {
|
||||||
|
expect(parseYearFirstSlashRange("2026/07/30 – 2026/08/20")).toEqual({
|
||||||
|
start: { iso: "2026-07-30T00:00:00.000Z", precision: "day" },
|
||||||
|
end: { iso: "2026-08-20T00:00:00.000Z", precision: "day" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a hyphen as well as an en dash", () => {
|
||||||
|
expect(parseYearFirstSlashRange("2026/07/30 - 2026/08/20")?.end.iso).toBe(
|
||||||
|
"2026-08-20T00:00:00.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores an ISO timestamp sitting beside the range", () => {
|
||||||
|
// The Arknights wiki prints the countdown target in the same cell:
|
||||||
|
// "2026/07/30 – 2026/08/20; ends in 2026-08-20T10:59:59+00:00". The dashes
|
||||||
|
// in the ISO half must not be read as a second range.
|
||||||
|
const r = parseYearFirstSlashRange(
|
||||||
|
"2026/07/30 – 2026/08/20; ends in 2026-08-20T10:59:59+00:00",
|
||||||
|
);
|
||||||
|
expect(r?.start.iso).toBe("2026-07-30T00:00:00.000Z");
|
||||||
|
expect(r?.end.iso).toBe("2026-08-20T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an impossible calendar date rather than rolling it over", () => {
|
||||||
|
expect(parseYearFirstSlashRange("2026/02/30 – 2026/03/17")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when either side is missing its year", () => {
|
||||||
|
expect(parseYearFirstSlashRange("07/30 – 2026/08/20")).toBeNull();
|
||||||
|
expect(parseYearFirstSlashRange("2026/07/30 – 08/20")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user