feat: add Stella Sora, from the wiki's front page rather than its banner list
This wiki publishes the same schedule twice and the fuller surface is the worse one. /wiki/Banner_List has 55 clean rows with full wall clocks and states no timezone anywhere on the page; the front page's Current Banners module emits the same instants as real <time datetime="...-07:00"> elements. The two agree exactly, which is strong evidence the list is UTC and is still only evidence — so we read the surface that says what it means and pay four live banners instead of a full history for it. If an editor ever states the zone on Banner_List, that page becomes the better source immediately. Two ways this could have failed silently. The template writes its BEM underscores as __, so a selector written against the class name a browser shows matches nothing at all; and banner names are red links to ?action=edit&redlink=1, which Miraheze's robots.txt disallows, so a href carrying a query is refused and the page URL stands in. No resetOffsets, and here the source did state an offset: -07:00, which is US Pacific and therefore shifts twice a year. That is the Fate/Grand Order gap arriving through a page that looks like it answered the question. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4dff14087f
commit
ecbf9c0422
@@ -37,8 +37,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
|
||||
progress on events that repeat daily, and lets a user mark events completed.
|
||||
|
||||
**Status: working app, refreshing itself on a schedule.** Schema, seven parsers, fifteen sources across
|
||||
fourteen games, the full interface, offline support, a static server, a Docker image and CI all exist and
|
||||
**Status: working app, refreshing itself on a schedule.** Schema, eight parsers, sixteen sources across
|
||||
fifteen 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
|
||||
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
|
||||
@@ -106,9 +106,9 @@ 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
|
||||
custom.ts — reader-authored games and events, and their key spaces
|
||||
src/ingest/ html.ts, dates.ts (thirteen formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts
|
||||
parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts,
|
||||
iopwiki.ts — keyed by SITE, not game
|
||||
src/ingest/ html.ts, dates.ts (fourteen formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts
|
||||
parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts,
|
||||
stellasora.ts — keyed by SITE, not game
|
||||
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
||||
src/client/ React app, service worker, manifest
|
||||
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
||||
@@ -119,7 +119,7 @@ src/client/ React app, service worker, manifest
|
||||
theme.ts — dark or light, and what a game hue reads as on each
|
||||
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||
serve.ts static server + /api/health
|
||||
test/ 641 tests
|
||||
test/ 660 tests
|
||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||
snapshots/ current page per source, rewritten by refresh — see its README
|
||||
```
|
||||
@@ -165,7 +165,7 @@ These come from how gacha games actually schedule things, and they cause most bu
|
||||
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.
|
||||
- **Parsers are keyed by site, not game.** One `game8` parser serves eight sources and `fandom` two;
|
||||
`wikigg`, `akwiki`, `bawiki`, `holodoriwiki` and `iopwiki` serve one each — the first two share a host family
|
||||
`wikigg`, `akwiki`, `bawiki`, `holodoriwiki`, `iopwiki` and `stellasorawiki` serve one each — the first two share a host family
|
||||
and have entirely different templates, and the last two are both Miraheze wikis whose page
|
||||
templates have nothing in common. Adding a source for a known site is one `SOURCES` entry; a new
|
||||
site is a parser module.
|
||||
@@ -243,7 +243,7 @@ Sources are community wikis. Treat them as a guest would:
|
||||
- Honor `robots.txt`; set a descriptive `User-Agent` with a contact URL.
|
||||
- One request per source per refresh cycle, minimum 6 hours apart.
|
||||
- **Space requests to one host**, honouring its `Crawl-delay` and defaulting to 2s. Eight of the
|
||||
fifteen sources are game8.co pages, so the per-source floor alone still permits one cycle to arrive
|
||||
sixteen sources are game8.co pages, so the per-source floor alone still permits one cycle to arrive
|
||||
as eight back-to-back requests to a single site — which is the shape an edge network throttles, and
|
||||
what a burst looks like from the far end regardless of our intent.
|
||||
- Send `If-None-Match` / `If-Modified-Since`; treat `304` as "skip, unchanged".
|
||||
@@ -284,6 +284,7 @@ re-litigated each pass:
|
||||
| `holodori.wiki` | **Built** (2026-08-18), from the rendered `/wiki/Events` page. Miraheze again, so the same call as Blue Archive; CC BY-SA 4.0, no `Content-Signal`, no `Crawl-delay` for `*` |
|
||||
| `prydwen.gg`, `gametora.com` | **Cleared, unbuilt.** `User-agent: *` allows the paths we would want. prydwen sets `Crawl-delay: 10`, far below our one-per-6h |
|
||||
| `iopwiki.com` | **Built** (2026-08-19), Girls' Frontline 2 — see § IOP Wiki below. `robots.txt` is two lines, `User-agent: *` and `Crawl-Delay: 20`, no `Disallow` anywhere |
|
||||
| `stellasora.miraheze.org` | **Built** (2026-08-19), from the front page's `Current Banners` module and **not** `/wiki/Banner_List` — see § Stella Sora below |
|
||||
|
||||
`.github/ISSUE_TEMPLATE/feature_request.yml` points readers at that table by heading, so a source
|
||||
request can be checked against it before anyone writes it up — the loudest feedback on the first
|
||||
@@ -414,6 +415,26 @@ boundaries land on three different clocks (22:59, 08:59 and 02:59 UTC), which is
|
||||
rather than a reset hour — Arknights and Reverse: 1999 each earned an override from a single
|
||||
boundary their whole page agreed on.
|
||||
|
||||
**Stella Sora: the front page, not the article — the opposite call to Blue Archive.** Miraheze
|
||||
again, so `/wiki/` is open and `/w/` and `?action=` are closed. But this wiki publishes its schedule
|
||||
twice, and the fuller surface is the worse one:
|
||||
|
||||
- `/wiki/Banner_List` has 55 clean rows with full wall clocks and states **no timezone anywhere**.
|
||||
- The front page's `Current Banners` module emits the same instants as real
|
||||
`<time datetime="2026-08-17T20:00-07:00">` elements.
|
||||
|
||||
The two agree exactly, which is strong evidence the table is UTC and is still only evidence — so we
|
||||
read the surface that says what it means and pay for it in coverage: four live banners instead of a
|
||||
full history. If an editor ever states the zone on `Banner_List`, that page becomes the better
|
||||
source immediately. Two traps in the markup: the template writes its BEM underscores as `__`,
|
||||
so a selector written against the name a browser shows finds **nothing at all**; and banner names are
|
||||
red links to `?action=edit&redlink=1`, which robots.txt disallows, so a href with a query is refused
|
||||
and the page URL stands in — the `holodori.ts` rule.
|
||||
|
||||
Stella Sora takes no `resetOffsets` either, and for the opposite reason to most: it states an offset
|
||||
outright, and the offset is `-07:00` — US Pacific, which shifts by an hour twice a year. That is the
|
||||
Fate/Grand Order problem arriving through a source that looks like it answered the question.
|
||||
|
||||
`scripts/refresh-sources.ts` enforces all of the above in code — the 6h floor, one request, no
|
||||
retries, conditional headers, per-host spacing, robots (failing closed when `robots.txt` cannot be
|
||||
read). Anything that would make it fetch more often is a change to this section first.
|
||||
|
||||
+8
-3
@@ -10,7 +10,7 @@ import { z } from "zod";
|
||||
|
||||
export const GameId = z.enum([
|
||||
"genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x", "r1999",
|
||||
"ba", "fgo", "holodori", "gfl2",
|
||||
"ba", "fgo", "holodori", "gfl2", "stellasora",
|
||||
]);
|
||||
|
||||
export const EventType = z.enum([
|
||||
@@ -310,13 +310,18 @@ logged under the old clock. Get it right at introduction or accept a migration.
|
||||
|
||||
Infinity Nikki, P5X and Blue Archive carry **no `resetOffsets`
|
||||
entry**, so they take the regional default. That is an assumption, not a verified server map — none
|
||||
of those sources states one. The 2026-08-19 addition below is worth separating out, because it is
|
||||
silent for a different reason than the usual one:
|
||||
of those sources states one. Two of the 2026-08-19 additions are worth separating out, because they
|
||||
are silent for different reasons and neither is the usual one:
|
||||
|
||||
- **Girls' Frontline 2** states an exact UTC instant on every boundary, so the data is there and
|
||||
still settles nothing: its EN events end at 22:59 (33 of them), 08:59 (11) and 02:59 (5). Arknights
|
||||
and Reverse: 1999 each earned an override from a single boundary the whole page agreed on; three
|
||||
of them is a patch window, not a reset hour.
|
||||
- **Stella Sora** states an offset outright — `-07:00` — and that is precisely why it gets no entry.
|
||||
`-07:00` is US Pacific in summer and `-08:00` in winter, so one fixed number is wrong for half the
|
||||
year in either direction. This is the Fate/Grand Order gap, arriving through a source that looks
|
||||
like it answered the question.
|
||||
|
||||
|
||||
Blue Archive is the case where the assumption is most likely wrong and still the right entry: the
|
||||
game does run one worldwide server, but `bluearchive.wiki` states no time of day and no timezone
|
||||
|
||||
@@ -55,6 +55,7 @@ Consequences worth internalising:
|
||||
| `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — a JP/Global tabber over `Name (EN) \| Start date \| End date \| Notes` wikitables | Blue Archive |
|
||||
| `holodoriwiki` | holodori.wiki's rendered `/wiki/Events` — `Current Events` and `Past Events` wikitables over `Event \| Type \| Start Date \| End Date` | hololive Dreams |
|
||||
| `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 |
|
||||
| `stellasorawiki` | stellasora.miraheze.org's front-page `Current Banners` module — `<time datetime>` pairs inside `stellasora-home-banner` blocks | Stella Sora |
|
||||
|
||||
`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
|
||||
@@ -142,6 +143,7 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any
|
||||
| `parseIsoDay` | `2026-08-04` (one boundary per column, so nothing to split) | Blue Archive |
|
||||
| `parseSlashClockZone` | `08/17/2026 8:00PM (JST)` (one boundary per column, 12-hour clock, **named** zone) | hololive Dreams |
|
||||
| `parseIsoClockRangeUtc` | `2026-08-06 13:00 - 2026-08-26 22:59 (UTC)` (whole range in one cell, **zone required**, nothing converted) | Girls' Frontline 2 |
|
||||
| `parseIsoOffsetInstant` | `2026-08-03T21:00-07:00` (a machine-readable `<time datetime>` attribute; **offset required**) | Stella Sora |
|
||||
| `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves |
|
||||
|
||||
**A day-precision result is 00:00Z, and that is a placeholder rather than a time.** Every reader
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ have a source — this table is the launch scope, not a roster to keep in sync.
|
||||
|
||||
Adding a game must require no change to `GachaEvent` — only a `GameId` entry, its `games.ts`
|
||||
metadata, and a source registration. That is the test of whether the data model is right, and it has
|
||||
held: the fourteen games here have cost the event schema nothing. Per-game *metadata* does occasionally
|
||||
held: the fifteen games here have cost the event schema nothing. Per-game *metadata* does occasionally
|
||||
grow (Reverse: 1999 needed `resetHourLocal` for a 05:00 reset), which is a different file and moves
|
||||
no stored key. A game may have several sources; see `docs/INGESTION.md` § Three layers.
|
||||
|
||||
@@ -90,7 +90,7 @@ default `"game"` — the board every existing reader already has). Merged, the o
|
||||
`endingSoonestFirst`, the same comparator behind the list's "Ending soonest" (F2), so the two views
|
||||
cannot mean different things by the same words and an unannounced end still sorts behind every dated
|
||||
one rather than claiming a place in the queue. Merged there is also no lane heading to say whose
|
||||
event a bar is, and hue alone cannot answer that once fourteen games share a stack — so each bar
|
||||
event a bar is, and hue alone cannot answer that once fifteen games share a stack — so each bar
|
||||
carries its game's short name, with the full name in its tooltip. A bar too narrow for a tag and a
|
||||
title both keeps the title: a chopped game name reads as a broken word, and the hue and the tooltip
|
||||
still answer it.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
[
|
||||
{
|
||||
"id": "stellasora:a-breezy-romance:2026-08-04",
|
||||
"game": "stellasora",
|
||||
"title": "A Breezy Romance",
|
||||
"type": "banner",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-04T04:00:00.000Z",
|
||||
"startPrecision": "exact",
|
||||
"endsAt": "2026-08-24T19:59:00.000Z",
|
||||
"endPrecision": "exact",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://stellasora.miraheze.org/wiki/Main_Page",
|
||||
"sourceId": "stellasora-stellasorawiki-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "stellasora:afternoon-glimmer-into-the-green:2026-08-04",
|
||||
"game": "stellasora",
|
||||
"title": "Afternoon Glimmer into the Green",
|
||||
"type": "banner",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-04T04:00:00.000Z",
|
||||
"startPrecision": "exact",
|
||||
"endsAt": "2026-08-25T02:59:00.000Z",
|
||||
"endPrecision": "exact",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://stellasora.miraheze.org/wiki/Afternoon_Glimmer_into_the_Green/2026-08-03",
|
||||
"sourceId": "stellasora-stellasorawiki-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "stellasora:bloom-to-the-bright-sun:2026-08-18",
|
||||
"game": "stellasora",
|
||||
"title": "Bloom to the Bright Sun",
|
||||
"type": "banner",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-18T03:00:00.000Z",
|
||||
"startPrecision": "exact",
|
||||
"endsAt": "2026-09-08T02:59:00.000Z",
|
||||
"endPrecision": "exact",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://stellasora.miraheze.org/wiki/Bloom_to_the_Bright_Sun/2026-08-17",
|
||||
"sourceId": "stellasora-stellasorawiki-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "stellasora:tinges-of-rainbow:2026-08-18",
|
||||
"game": "stellasora",
|
||||
"title": "Tinges of Rainbow",
|
||||
"type": "banner",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-18T03:00:00.000Z",
|
||||
"startPrecision": "exact",
|
||||
"endsAt": "2026-09-08T02:59:00.000Z",
|
||||
"endPrecision": "exact",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://stellasora.miraheze.org/wiki/Tinges_of_Rainbow/2026-08-17",
|
||||
"sourceId": "stellasora-stellasorawiki-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"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
@@ -133,6 +133,16 @@ const SOURCES: SourceSpec[] = [
|
||||
url: "https://iopwiki.com/wiki/GFL2_Events",
|
||||
parserId: "iopwiki",
|
||||
},
|
||||
{
|
||||
id: "stellasora-stellasorawiki-events",
|
||||
game: "stellasora",
|
||||
// The front page, and deliberately not `/wiki/Banner_List`: the list has
|
||||
// full coverage and states no timezone anywhere, while this module emits
|
||||
// the same instants as `<time datetime>` with the offset in the markup.
|
||||
// See `parsers/stellasora.ts` for why coverage loses that argument.
|
||||
url: "https://stellasora.miraheze.org/wiki/Main_Page",
|
||||
parserId: "stellasorawiki",
|
||||
},
|
||||
];
|
||||
|
||||
function toAdapter(spec: SourceSpec): Adapter {
|
||||
|
||||
@@ -516,3 +516,42 @@ export function parseIsoClockRangeUtc(
|
||||
end: { iso: endIso, precision: "exact" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `2026-08-03T21:00-07:00` → 2026-08-04T04:00:00.000Z, exact precision.
|
||||
*
|
||||
* A machine-readable instant, which is rare enough here to be worth naming:
|
||||
* the Stella Sora wiki's front page emits its banner window as real
|
||||
* `<time datetime>` elements, so the offset is stated in the markup rather than
|
||||
* printed for a human to interpret.
|
||||
*
|
||||
* **The offset is required.** `Z` or `±HH:MM` both pass; a bare local datetime
|
||||
* does not, and that is the same call every other reader here makes. It matters
|
||||
* more than usual on this source, because the page's sibling `Banner_List`
|
||||
* prints the identical instants with no zone anywhere — reading those as UTC
|
||||
* would be an assumption that happens to be right today and is unfalsifiable
|
||||
* from the page, which is exactly the kind of fact this file refuses to invent.
|
||||
*
|
||||
* Anchored at both ends: an attribute value is a whole cell, not prose.
|
||||
*/
|
||||
export function parseIsoOffsetInstant(input: string): ParsedInstant | null {
|
||||
const re =
|
||||
/^\s*(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?\s*(Z|[+-]\d{2}:?\d{2})\s*$/i;
|
||||
const m = re.exec(input);
|
||||
if (!m) return null;
|
||||
|
||||
// Validated on the stated local fields before the offset shifts anything:
|
||||
// converting first would quietly turn February 30 into a real March instant.
|
||||
const local = iso(
|
||||
Number(m[1]),
|
||||
Number(m[2]),
|
||||
Number(m[3]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
);
|
||||
if (local === null) return null;
|
||||
|
||||
const value = Date.parse(input.trim());
|
||||
if (Number.isNaN(value)) return null;
|
||||
return { iso: new Date(value).toISOString(), precision: "exact" };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fandomParser } from "./fandom.ts";
|
||||
import { game8Parser } from "./game8.ts";
|
||||
import { holodoriWikiParser } from "./holodori.ts";
|
||||
import { iopWikiParser } from "./iopwiki.ts";
|
||||
import { stellaSoraWikiParser } from "./stellasora.ts";
|
||||
import { wikiGgParser } from "./wikigg.ts";
|
||||
import type { SourceParser } from "./types.ts";
|
||||
|
||||
@@ -20,6 +21,7 @@ export const PARSERS: SourceParser[] = [
|
||||
blueArchiveWikiParser,
|
||||
holodoriWikiParser,
|
||||
iopWikiParser,
|
||||
stellaSoraWikiParser,
|
||||
];
|
||||
|
||||
export function parserById(id: string): SourceParser | undefined {
|
||||
@@ -33,4 +35,5 @@ export { fandomParser } from "./fandom.ts";
|
||||
export { game8Parser } from "./game8.ts";
|
||||
export { holodoriWikiParser } from "./holodori.ts";
|
||||
export { iopWikiParser } from "./iopwiki.ts";
|
||||
export { stellaSoraWikiParser } from "./stellasora.ts";
|
||||
export { wikiGgParser } from "./wikigg.ts";
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { eventId, type GachaEvent } from "../../shared/schema.ts";
|
||||
import { parseIsoOffsetInstant } from "../dates.ts";
|
||||
import { text } from "../html.ts";
|
||||
import type { ParseContext } from "../adapters/types.ts";
|
||||
import type { SourceParser } from "./types.ts";
|
||||
|
||||
/**
|
||||
* The Stella Sora wiki's `Current Banners` module (`stellasora.miraheze.org`).
|
||||
*
|
||||
* Miraheze again, so the same call as `bawiki.ts` and `holodori.ts`: `/wiki/` is
|
||||
* the surface `*` is allowed, `/w/` and `?action=` are disallowed, and this page
|
||||
* answers our own User-Agent with a 200 under CC BY-SA 4.0.
|
||||
*
|
||||
* **The front page, and not the article — which is the opposite of the usual
|
||||
* call, so the reasoning matters.** This wiki publishes the same schedule twice:
|
||||
*
|
||||
* - `/wiki/Banner_List` has two clean `Image | Name | Start | End` tables, 28
|
||||
* and 27 rows, with full wall clocks: `2026-08-18 03:00:00`. It states **no
|
||||
* timezone anywhere** — not in a header, not in a footnote, not once on the
|
||||
* page.
|
||||
* - The front page's `Current Banners` module emits the same instants as real
|
||||
* `<time datetime="2026-08-17T20:00-07:00">` elements. Zone stated,
|
||||
* machine-readable, nothing to interpret.
|
||||
*
|
||||
* The two agree exactly — `2026-08-17T20:00-07:00` is `2026-08-18T03:00Z`, and
|
||||
* `Banner_List` prints `2026-08-18 03:00:00` for that banner — which is strong
|
||||
* evidence the table is UTC and still only evidence. Reading a bare wall clock
|
||||
* as UTC invents the fact that matters most, and rounding it to a day does not
|
||||
* save it, because a boundary near midnight lands either side of it depending on
|
||||
* the offset assumed and the start's day is half an event ID
|
||||
* (`AGENTS.md` § Blue Archive). So we read the surface that says what it means,
|
||||
* and pay for it in coverage: four live banners instead of 55 rows of history.
|
||||
*
|
||||
* If the wiki's editors ever state the zone on `Banner_List`, that page becomes
|
||||
* the better source immediately — full coverage, no dependency on a front-page
|
||||
* template. Until then this is the honest half.
|
||||
*
|
||||
* Two things about the markup decide the code below.
|
||||
*
|
||||
* **The class names arrive HTML-escaped.** The template writes its BEM
|
||||
* underscores as `__`, so the container is
|
||||
* `stellasora-home-current__banners` in the bytes and
|
||||
* `stellasora-home-current__banners` only after decoding. A selector written
|
||||
* against the name a browser shows finds nothing at all — a silent empty lane,
|
||||
* which is the failure mode this codebase treats as worse than a loud one.
|
||||
*
|
||||
* **Banner names are red links.** `A Breezy Romance` links to
|
||||
* `/wiki/A_Breezy_Romance/2026-08-03?action=edit&redlink=1` — a create-page
|
||||
* form, and a `?action=` URL this wiki's robots.txt disallows. So a href
|
||||
* carrying a query is refused and the module's own page stands in, exactly as
|
||||
* `holodori.ts` does it; the banners whose articles do exist link normally and
|
||||
* get linked.
|
||||
*
|
||||
* Banners only. This wiki dates no story events — `/wiki/Events` is a list of
|
||||
* names with no dates at all — so the lane is a banner calendar, and that is
|
||||
* what the source has rather than a choice made here.
|
||||
*/
|
||||
|
||||
/** The module, with its underscores however the template escaped them. */
|
||||
const CONTAINER = /stellasora-home-current(?:_|_){2}banners/i;
|
||||
/** One banner inside it. */
|
||||
const BANNER = /<div class="stellasora-home-banner">([\s\S]*?)(?=<div class="stellasora-home-banner">|$)/gi;
|
||||
const NAME = /banner(?:_|_){2}name">([\s\S]*?)<\/div>/i;
|
||||
const TIME = /<time\b[^>]*datetime="([^"]+)"/gi;
|
||||
/** The same probe without `g`: `.test()` on a global regex carries state. */
|
||||
const HAS_TIME = /<time\b[^>]*datetime="/i;
|
||||
/**
|
||||
* The banner's own article. A href with a query is refused: on this wiki those
|
||||
* are `?action=edit&redlink=1` create-page forms, which robots.txt disallows and
|
||||
* which are the wrong page to send a reader to.
|
||||
*/
|
||||
const ARTICLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i;
|
||||
|
||||
/**
|
||||
* The `Current Banners` card, bounded at the next card.
|
||||
*
|
||||
* Bounding matters even though the card is the first on the page: the front page
|
||||
* carries five more (`Recent Trekkers`, `Recent Discs`, `Navigation`,
|
||||
* `Birthdays`, `Links`), and an unbounded slice would start reading whichever of
|
||||
* them grows a `<time>` element next.
|
||||
*/
|
||||
function module_(html: string): string | null {
|
||||
const start = CONTAINER.exec(html);
|
||||
if (start === null) return null;
|
||||
|
||||
const from = start.index;
|
||||
const next = html.indexOf("stellasora-home-card--", from + 1);
|
||||
return next < 0 ? html.slice(from) : html.slice(from, next);
|
||||
}
|
||||
|
||||
export function parseStellaSoraMainPage(
|
||||
html: string,
|
||||
ctx: ParseContext,
|
||||
): GachaEvent[] {
|
||||
const section = module_(html.replace(/\s+/g, " "));
|
||||
if (section === null) return [];
|
||||
|
||||
const nowMs = Date.parse(ctx.now);
|
||||
const out: GachaEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const block of section.matchAll(BANNER)) {
|
||||
const body = block[1] ?? "";
|
||||
|
||||
const nameCell = NAME.exec(body)?.[1] ?? "";
|
||||
const title = text(nameCell);
|
||||
if (title.length === 0) continue;
|
||||
|
||||
// Two `<time>` elements per banner, in source order: the window's start and
|
||||
// its end. Both or neither — a module that emits one is a shape this parser
|
||||
// does not understand, and a start with an end read off the next banner
|
||||
// would be a confidently wrong date.
|
||||
const stamps = [...body.matchAll(TIME)].map((m) => m[1] ?? "");
|
||||
if (stamps.length < 2) continue;
|
||||
|
||||
const start = parseIsoOffsetInstant(stamps[0] ?? "");
|
||||
const end = parseIsoOffsetInstant(stamps[1] ?? "");
|
||||
if (start === null || end === null) continue;
|
||||
if (end.iso <= start.iso) continue;
|
||||
|
||||
// The module is titled "Current", and it is maintained by hand like every
|
||||
// other wiki section — so currency is checked against `ctx.now` rather than
|
||||
// taken on trust, as it is in `holodori.ts`.
|
||||
if (Date.parse(end.iso) < nowMs) continue;
|
||||
|
||||
const id = eventId(ctx.game, title, start.iso);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
const href = ARTICLE_LINK.exec(nameCell)?.[1];
|
||||
|
||||
out.push({
|
||||
id,
|
||||
game: ctx.game,
|
||||
title,
|
||||
// Every row in this module is a gacha rate-up window. The wiki labels
|
||||
// none of them, and inferring from the name would only ever guess wrong:
|
||||
// "A Breezy Romance" carries no vocabulary at all.
|
||||
type: "banner",
|
||||
summary: null,
|
||||
startsAt: start.iso,
|
||||
startPrecision: start.precision,
|
||||
endsAt: end.iso,
|
||||
endPrecision: end.precision,
|
||||
// One worldwide banner window, stated once with an offset. The page draws
|
||||
// no per-region distinction, so there is nothing region-scoped to report.
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl:
|
||||
href === undefined
|
||||
? ctx.sourceUrl
|
||||
: new URL(href, ctx.sourceUrl).toString(),
|
||||
sourceId: ctx.sourceId,
|
||||
status: "published",
|
||||
// Both boundaries exact, both carrying their own offset in the markup.
|
||||
confidence: 0.95,
|
||||
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 stellaSoraWikiParser: SourceParser = {
|
||||
id: "stellasorawiki",
|
||||
label: "Stella Sora Wiki",
|
||||
canParse(html: string): boolean {
|
||||
// Sourcing a wiki's front page is more fragile than sourcing an article, so
|
||||
// this asserts both halves of what the parser depends on: the module is
|
||||
// still there, and it still holds `<time datetime>` children. Either one
|
||||
// going is a redesign, and a redesign should fail the source rather than
|
||||
// report that Stella Sora has no banners on.
|
||||
const section = module_(html.replace(/\s+/g, " "));
|
||||
return section !== null && HAS_TIME.test(section);
|
||||
},
|
||||
parse: parseStellaSoraMainPage,
|
||||
};
|
||||
@@ -123,6 +123,15 @@ export const GAMES: Record<GameId, GameMeta> = {
|
||||
// is a patch window, not a reset hour. So this game takes the regional
|
||||
// default until something states its server clock outright.
|
||||
gfl2: { id: "gfl2", name: "Girls' Frontline 2: Exilium", short: "GFL2", hue: "#A9C23F", studio: "Sunborn", dailyTasks: "Daily missions" },
|
||||
// No `resetOffsets`, and this is the Fate/Grand Order gap rather than the
|
||||
// Blue Archive one: the source states an offset on every boundary, and the
|
||||
// offset it states is `-07:00` in August. That is US Pacific in summer, which
|
||||
// means it is `-08:00` in winter — one fixed number is wrong for half the year
|
||||
// in either direction, and `resetOffsets` holds one fixed number. The page
|
||||
// also never lands its boundaries on a single local hour (20:00 and 21:00
|
||||
// starts, 12:59 and 19:59 ends), so there is no reset hour to read off it
|
||||
// either. See the fgo entry below for the same reasoning at more length.
|
||||
stellasora: { id: "stellasora", name: "Stella Sora", short: "Stella Sora", hue: "#2E9E9E", studio: "Yostar", dailyTasks: "Daily missions" },
|
||||
};
|
||||
|
||||
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||
|
||||
@@ -15,6 +15,7 @@ export const GameId = z.enum([
|
||||
"fgo", // Fate/Grand Order
|
||||
"holodori", // hololive Dreams
|
||||
"gfl2", // Girls' Frontline 2: Exilium
|
||||
"stellasora", // Stella Sora
|
||||
]);
|
||||
export type GameId = z.infer<typeof GameId>;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
{ adapter: adapter("fgo-fandom-events"), fixture: "fixtures/fgo/fandom-events-2026-08-18" },
|
||||
{ adapter: adapter("holodori-holodoriwiki-events"), fixture: "fixtures/holodori/holodoriwiki-events-2026-08-18" },
|
||||
{ adapter: adapter("gfl2-iopwiki-events"), fixture: "fixtures/gfl2/iopwiki-events-2026-08-19" },
|
||||
{ adapter: adapter("stellasora-stellasorawiki-events"), fixture: "fixtures/stellasora/stellasorawiki-events-2026-08-19" },
|
||||
];
|
||||
|
||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||
@@ -1184,3 +1185,132 @@ describe("IOP Wiki (Girls' Frontline 2)", () => {
|
||||
).toThrow(/redesigned/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stella Sora wiki", () => {
|
||||
const fixture = "fixtures/stellasora/stellasorawiki-events-2026-08-19";
|
||||
const ss = adapter("stellasora-stellasorawiki-events");
|
||||
|
||||
function parse(html: string) {
|
||||
return ss.parse(html, {
|
||||
now: NOW,
|
||||
sourceUrl: ss.url,
|
||||
sourceId: ss.id,
|
||||
game: ss.game,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The module as the template actually emits it — BEM underscores escaped as
|
||||
* `__`, which is the detail a selector written from a browser's view
|
||||
* of the page gets wrong.
|
||||
*/
|
||||
const banner = (name: string, href: string, start: string, end: string) =>
|
||||
`<div class="stellasora-home-banner">
|
||||
<div class="stellasora-home-banner__name"><a href="${href}">${name}</a></div>
|
||||
<div class="stellasora-home-banner__period">
|
||||
<time class="stellasora-time" datetime="${start}">x</time> —
|
||||
<time class="stellasora-time" datetime="${end}">y</time>
|
||||
</div></div>`;
|
||||
|
||||
const carded = (banners: string) =>
|
||||
`<div class="stellasora-home-card stellasora-home-card--current stellasora-home-current">
|
||||
<div class="stellasora-home-current__banners">${banners}</div></div>
|
||||
<div class="stellasora-home-card stellasora-home-card--navigation">
|
||||
<div class="stellasora-home-banner">
|
||||
<div class="stellasora-home-banner__name">Not a banner</div>
|
||||
<div><time datetime="2026-08-01T00:00-07:00">z</time>
|
||||
<time datetime="2026-12-01T00:00-07:00">z</time></div></div></div>`;
|
||||
|
||||
test("reads the four live banners, converting the stated offset", async () => {
|
||||
const events = await runAdapter(ss, fixture);
|
||||
expect(events.map((e) => e.title)).toEqual([
|
||||
"A Breezy Romance",
|
||||
"Afternoon Glimmer into the Green",
|
||||
"Bloom to the Bright Sun",
|
||||
"Tinges of Rainbow",
|
||||
]);
|
||||
// 2026-08-17T20:00-07:00 → 03:00Z the next day. Cross-checked against the
|
||||
// wiki's own `Banner_List`, which prints `2026-08-18 03:00:00` for this
|
||||
// banner — the agreement that shows the unzoned table is UTC, and is still
|
||||
// only evidence, which is why we read this page instead.
|
||||
const bloom = events.find((e) => e.title === "Bloom to the Bright Sun");
|
||||
expect(bloom?.startsAt).toBe("2026-08-18T03:00:00.000Z");
|
||||
expect(bloom?.endsAt).toBe("2026-09-08T02:59:00.000Z");
|
||||
for (const e of events) {
|
||||
expect(e.startPrecision).toBe("exact");
|
||||
expect(e.endPrecision).toBe("exact");
|
||||
expect(e.type).toBe("banner");
|
||||
}
|
||||
});
|
||||
|
||||
test("finds the module through its HTML-escaped class name", () => {
|
||||
// The template writes `__` where a browser shows `__`. A selector
|
||||
// written against the decoded name matches nothing and empties the lane
|
||||
// with no error anywhere, which is the failure this codebase ranks worst.
|
||||
const events = parse(
|
||||
carded(
|
||||
banner("A Breezy Romance", "/wiki/A_Breezy_Romance", "2026-08-03T21:00-07:00", "2026-08-24T12:59-07:00"),
|
||||
),
|
||||
);
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("stops at the next card rather than reading the whole page", () => {
|
||||
// Five more cards follow this one. An unbounded slice would publish
|
||||
// whichever of them grows a `<time>` element next.
|
||||
const events = parse(
|
||||
carded(
|
||||
banner("A Breezy Romance", "/wiki/A_Breezy_Romance", "2026-08-03T21:00-07:00", "2026-08-24T12:59-07:00"),
|
||||
),
|
||||
);
|
||||
expect(events.map((e) => e.title)).toEqual(["A Breezy Romance"]);
|
||||
});
|
||||
|
||||
test("refuses a red link's `?action=` href and falls back to the page", async () => {
|
||||
// Miraheze's robots.txt disallows `?action=`, and a create-page form is the
|
||||
// wrong place to send a reader. `holodori.ts` makes the same call.
|
||||
const events = await runAdapter(ss, fixture);
|
||||
const red = events.find((e) => e.title === "A Breezy Romance");
|
||||
expect(red?.sourceUrl).toBe("https://stellasora.miraheze.org/wiki/Main_Page");
|
||||
// The ones whose articles exist do get linked.
|
||||
expect(events.find((e) => e.title === "Tinges of Rainbow")?.sourceUrl).toBe(
|
||||
"https://stellasora.miraheze.org/wiki/Tinges_of_Rainbow/2026-08-17",
|
||||
);
|
||||
});
|
||||
|
||||
test("drops a banner whose datetime states no offset", () => {
|
||||
// The sibling `Banner_List` prints exactly this — a wall clock with no zone
|
||||
// anywhere on the page — and reading it as UTC is the assumption this
|
||||
// source was chosen to avoid.
|
||||
const events = parse(
|
||||
carded(banner("Zoneless", "/wiki/Zoneless", "2026-08-03T21:00", "2026-08-24T12:59")),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
test("drops a banner missing one of its two timestamps", () => {
|
||||
// A start paired with an end read off the next banner would be a
|
||||
// confidently wrong date rather than a missing one.
|
||||
const events = parse(
|
||||
carded(
|
||||
`<div class="stellasora-home-banner">
|
||||
<div class="stellasora-home-banner__name">Half</div>
|
||||
<div><time datetime="2026-08-03T21:00-07:00">x</time></div></div>`,
|
||||
),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
test("a redesign fails the source instead of emptying the lane", () => {
|
||||
expect(() =>
|
||||
parse('<div class="stellasora-home-card">no module here</div>'),
|
||||
).toThrow(/redesigned/);
|
||||
// The module present but its `<time>` children gone is equally a redesign.
|
||||
expect(() =>
|
||||
parse(
|
||||
`<div class="stellasora-home-current__banners">
|
||||
<div class="stellasora-home-banner">Aug 3, 2026 — Aug 24, 2026</div></div>`,
|
||||
),
|
||||
).toThrow(/redesigned/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseSlashDateTimeRange,
|
||||
parseYearFirstSlashRange,
|
||||
parseIsoClockRangeUtc,
|
||||
parseIsoOffsetInstant,
|
||||
} from "../src/ingest/dates.ts";
|
||||
|
||||
describe("parseMonthDayYear", () => {
|
||||
@@ -472,3 +473,40 @@ describe("parseIsoClockRangeUtc", () => {
|
||||
).toBe("2026-08-06T13:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIsoOffsetInstant", () => {
|
||||
test("converts a stated offset to UTC", () => {
|
||||
// Stella Sora's front page emits its banner window as real `<time datetime>`
|
||||
// attributes, so the offset is in the markup rather than printed for a
|
||||
// human to interpret.
|
||||
expect(parseIsoOffsetInstant("2026-08-03T21:00-07:00")?.iso).toBe(
|
||||
"2026-08-04T04:00:00.000Z",
|
||||
);
|
||||
expect(parseIsoOffsetInstant("2026-08-03T21:00-07:00")?.precision).toBe(
|
||||
"exact",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts Z and seconds", () => {
|
||||
expect(parseIsoOffsetInstant("2026-08-03T21:00:00Z")?.iso).toBe(
|
||||
"2026-08-03T21:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
test("requires the offset rather than defaulting to UTC", () => {
|
||||
// The sibling `Banner_List` prints the same instants with no zone anywhere
|
||||
// on the page. Reading those as UTC is the assumption this reader exists to
|
||||
// refuse.
|
||||
expect(parseIsoOffsetInstant("2026-08-03T21:00")).toBeNull();
|
||||
expect(parseIsoOffsetInstant("2026-08-03 21:00-07:00")).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an impossible calendar day, before the offset shifts it", () => {
|
||||
// Converting first would quietly turn February 30 into a real March instant.
|
||||
expect(parseIsoOffsetInstant("2026-02-30T21:00-07:00")).toBeNull();
|
||||
});
|
||||
|
||||
test("is anchored, so it cannot read an instant out of prose", () => {
|
||||
expect(parseIsoOffsetInstant("Starts 2026-08-03T21:00-07:00")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user