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 &#95;&#95;, 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:
Lucas Winther
2026-08-19 03:50:20 +02:00
co-authored by Claude Opus 5
parent 4dff14087f
commit ecbf9c0422
14 changed files with 1286 additions and 13 deletions
+29 -8
View File
@@ -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 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, seven parsers, fifteen sources across **Status: working app, refreshing itself on a schedule.** Schema, eight parsers, sixteen sources across
fourteen games, the full interface, offline support, a static server, a Docker image and CI all exist and 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 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
@@ -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 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 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 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, parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts,
iopwiki.ts — keyed by SITE, not game stellasora.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
@@ -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 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) scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health serve.ts static server + /api/health
test/ 641 tests test/ 660 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
``` ```
@@ -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 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 and `fandom` two; - **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 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 templates have nothing in common. Adding a source for a known site is one `SOURCES` entry; a new
site is a parser module. 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. - Honor `robots.txt`; set a descriptive `User-Agent` with a contact URL.
- One request per source per refresh cycle, minimum 6 hours apart. - 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 - **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 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. 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". - 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 `*` | | `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 | | `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 | | `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 `.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 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 rather than a reset hour — Arknights and Reverse: 1999 each earned an override from a single
boundary their whole page agreed on. 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 `&#95;&#95;`,
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 `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 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. read). Anything that would make it fetch more often is a change to this section first.
+8 -3
View File
@@ -10,7 +10,7 @@ import { z } from "zod";
export const GameId = z.enum([ export const GameId = z.enum([
"genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x", "r1999", "genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x", "r1999",
"ba", "fgo", "holodori", "gfl2", "ba", "fgo", "holodori", "gfl2", "stellasora",
]); ]);
export const EventType = z.enum([ 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` 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 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 of those sources states one. Two of the 2026-08-19 additions are worth separating out, because they
silent for a different reason than the usual one: 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 - **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 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 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. 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 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 game does run one worldwide server, but `bluearchive.wiki` states no time of day and no timezone
+2
View File
@@ -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 | | `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 | | `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 | | `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 `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
@@ -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 | | `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 | | `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 | | `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 | | `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 **A day-precision result is 00:00Z, and that is a placeholder rather than a time.** Every reader
+2 -2
View File
@@ -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` 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 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 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. 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 `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 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 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 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 title both keeps the title: a chopped game name reads as a broken word, and the hue and the tooltip
still answer it. 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
+10
View File
@@ -133,6 +133,16 @@ const SOURCES: SourceSpec[] = [
url: "https://iopwiki.com/wiki/GFL2_Events", url: "https://iopwiki.com/wiki/GFL2_Events",
parserId: "iopwiki", 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 { function toAdapter(spec: SourceSpec): Adapter {
+39
View File
@@ -516,3 +516,42 @@ export function parseIsoClockRangeUtc(
end: { iso: endIso, precision: "exact" }, 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" };
}
+3
View File
@@ -4,6 +4,7 @@ import { fandomParser } from "./fandom.ts";
import { game8Parser } from "./game8.ts"; import { game8Parser } from "./game8.ts";
import { holodoriWikiParser } from "./holodori.ts"; import { holodoriWikiParser } from "./holodori.ts";
import { iopWikiParser } from "./iopwiki.ts"; import { iopWikiParser } from "./iopwiki.ts";
import { stellaSoraWikiParser } from "./stellasora.ts";
import { wikiGgParser } from "./wikigg.ts"; import { wikiGgParser } from "./wikigg.ts";
import type { SourceParser } from "./types.ts"; import type { SourceParser } from "./types.ts";
@@ -20,6 +21,7 @@ export const PARSERS: SourceParser[] = [
blueArchiveWikiParser, blueArchiveWikiParser,
holodoriWikiParser, holodoriWikiParser,
iopWikiParser, iopWikiParser,
stellaSoraWikiParser,
]; ];
export function parserById(id: string): SourceParser | undefined { export function parserById(id: string): SourceParser | undefined {
@@ -33,4 +35,5 @@ export { fandomParser } from "./fandom.ts";
export { game8Parser } from "./game8.ts"; export { game8Parser } from "./game8.ts";
export { holodoriWikiParser } from "./holodori.ts"; export { holodoriWikiParser } from "./holodori.ts";
export { iopWikiParser } from "./iopwiki.ts"; export { iopWikiParser } from "./iopwiki.ts";
export { stellaSoraWikiParser } from "./stellasora.ts";
export { wikiGgParser } from "./wikigg.ts"; export { wikiGgParser } from "./wikigg.ts";
+184
View File
@@ -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 `&#95;&#95;`, so the container is
* `stellasora-home-current&#95;&#95;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(?:&#95;|_){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(?:&#95;|_){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,
};
+9
View File
@@ -123,6 +123,15 @@ export const GAMES: Record<GameId, GameMeta> = {
// is a patch window, not a reset hour. So this game takes the regional // is a patch window, not a reset hour. So this game takes the regional
// default until something states its server clock outright. // 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" }, 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); export const GAME_LIST: GameMeta[] = Object.values(GAMES);
+1
View File
@@ -15,6 +15,7 @@ export const GameId = z.enum([
"fgo", // Fate/Grand Order "fgo", // Fate/Grand Order
"holodori", // hololive Dreams "holodori", // hololive Dreams
"gfl2", // Girls' Frontline 2: Exilium "gfl2", // Girls' Frontline 2: Exilium
"stellasora", // Stella Sora
]); ]);
export type GameId = z.infer<typeof GameId>; export type GameId = z.infer<typeof GameId>;
+130
View File
@@ -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("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("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("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) { async function runAdapter(adapter: Adapter, fixture: string) {
@@ -1184,3 +1185,132 @@ describe("IOP Wiki (Girls' Frontline 2)", () => {
).toThrow(/redesigned/); ).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
* `&#95;&#95;`, 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&#95;&#95;name"><a href="${href}">${name}</a></div>
<div class="stellasora-home-banner&#95;&#95;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&#95;&#95;banners">${banners}</div></div>
<div class="stellasora-home-card stellasora-home-card--navigation">
<div class="stellasora-home-banner">
<div class="stellasora-home-banner&#95;&#95;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 `&#95;&#95;` 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&#95;&#95;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&#95;&#95;banners">
<div class="stellasora-home-banner">Aug 3, 2026 — Aug 24, 2026</div></div>`,
),
).toThrow(/redesigned/);
});
});
+38
View File
@@ -12,6 +12,7 @@ import {
parseSlashDateTimeRange, parseSlashDateTimeRange,
parseYearFirstSlashRange, parseYearFirstSlashRange,
parseIsoClockRangeUtc, parseIsoClockRangeUtc,
parseIsoOffsetInstant,
} from "../src/ingest/dates.ts"; } from "../src/ingest/dates.ts";
describe("parseMonthDayYear", () => { describe("parseMonthDayYear", () => {
@@ -472,3 +473,40 @@ describe("parseIsoClockRangeUtc", () => {
).toBe("2026-08-06T13:00:00.000Z"); ).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();
});
});