New bawiki parser plus the ba-bawiki-events source, giving the game its two live and upcoming events at day precision. It reads the rendered /wiki/Events page, which is the opposite call to the Fandom source next door and for the same kind of reason: bluearchive.wiki is Miraheze, whose robots.txt disallows /w/ and /*?action=, so there the API is the closed route and the page is the surface * is allowed. It serves our own User-Agent a 200, sets no Content-Signal and no Crawl-delay for us. The Fandom wiki was declined earlier as a JP archive yielding nothing live; this is the live source that assessment pointed at. Three page facts shape the parser, each a way to publish a confidently wrong date. The schedule is a JP/Global tabber and the Japanese version runs four to nine months ahead, so only Global is published — the akwiki hazard. The Global tab's nav button carries the id tabber-Global_version-label and precedes both panels, so slicing from the first matching id reads the Japanese schedule while believing it read ours; the first version of this parser did exactly that and published a JP-only event. And three tabs on the page are named Global, the schedule plus Mini-Event and Joint Firing Drill, so the schedule is found by its Name (EN) header rather than by position, with canParse asserting the same lookup — a renamed tab or column fails the run instead of emptying the lane. The page states no time of day and no timezone anywhere, which is why the dates are day precision and why ba gets no resetOffsets. It also settles the five other schedule tables, which do carry a wall clock but name no zone and mostly do not say which server: reading those as UTC would invent the fact that matters most, and rounding to a day would not save it, because a 04:00 local boundary falls either side of UTC midnight depending on the offset assumed and the start's day is part of the event ID. They are left unparsed deliberately. The two-event count is asserted against an independent extraction off the fixture, and both starts corroborate against the per-event infoboxes elsewhere on the page — GL 2026-08-04 and GL 2026-09-01, where the JP reruns those tabs would have given are 2026-04-01 and 2026-05-06. Three comment counts were already stale before this and are corrected to what the tree now holds rather than to what it held yesterday. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
291 lines
17 KiB
Markdown
291 lines
17 KiB
Markdown
# Architecture
|
||
|
||
## Shape
|
||
|
||
One Bun process serves the static React build, exposes a read-only JSON API, and runs the
|
||
ingestion scheduler on a timer. SQLite is the only datastore. There is no auth layer because there
|
||
are no users.
|
||
|
||
```
|
||
┌──────────────────────────────────────────────┐
|
||
game wikis ───► │ Bun process │
|
||
news pages │ │
|
||
│ scheduler (every 6h) │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ingest pipeline │
|
||
│ fetch → parse → merge → validate │
|
||
│ → reconcile → gate and publish │
|
||
│ │ │ │
|
||
│ │ └──► quarantine │
|
||
│ ▼ │ │
|
||
│ ┌─────────────┐ │ │
|
||
│ │ SQLite │◄────────────────┘ │
|
||
│ └─────────────┘ /review (127.0.0.1) │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ GET /api/events static React build │
|
||
└────────┬─────────────────────────┬───────────┘
|
||
│ │
|
||
▼ ▼
|
||
browser fetch browser render
|
||
│
|
||
▼
|
||
localStorage: progress, daily, ignored,
|
||
prefs, the reader's own games and events
|
||
← never leaves the device
|
||
```
|
||
|
||
**That is the design, and the scheduler half of it is not built yet.** What runs today is
|
||
`scripts/refresh-sources.ts` on a GitHub Actions cron, writing snapshots to disk, and a feed built
|
||
from those files rather than from SQLite — see § Today. The stages, the layering and the review gate
|
||
are unchanged by that; only what wakes them up and where they land is.
|
||
|
||
The pipeline makes no third-party API calls beyond fetching source pages. There is no inference
|
||
anywhere, at ingest time or in a request path.
|
||
|
||
## Layout
|
||
|
||
```
|
||
src/
|
||
server/
|
||
index.ts Bun.serve entry: static + API + scheduler bootstrap
|
||
routes/
|
||
events.ts GET /api/events, /api/events.json
|
||
games.ts GET /api/games
|
||
review.ts /review UI + approve/reject (localhost-bound)
|
||
health.ts GET /api/health
|
||
db/
|
||
client.ts bun:sqlite handle, WAL, pragmas
|
||
migrations/ NNN-name.sql, applied in order at boot
|
||
queries.ts all SQL lives here — no SQL in route handlers
|
||
ingest/
|
||
scheduler.ts timer + jitter + per-source lock [not built]
|
||
pipeline.ts the 6 stages, orchestration only [not built]
|
||
html.ts flat-table HTML reader (no dependency) ✓ built
|
||
dates.ts ten deterministic date formats ✓ built
|
||
merge.ts cross-source dedupe, corroboration ✓ built
|
||
sanitize.ts the trust boundary — stage 2.5 ✓ built
|
||
robots.ts robots.txt parsing, matching, host cache ✓ built
|
||
snapshots.ts raw snapshot cache + conditional headers ✓ built
|
||
validate.ts zod parse + calendar sanity rules [not built]
|
||
reconcile.ts diff vs published, confidence, conflicts [not built]
|
||
parsers/
|
||
types.ts SourceParser interface ✓ built
|
||
game8.ts game8.co article calendars ✓ built
|
||
wikigg.ts wiki.gg mp-event templates ✓ built
|
||
akwiki.ts arknights.wiki.gg mrfz-wtable ✓ built
|
||
fandom.ts Fandom via the action=parse API ✓ built
|
||
bawiki.ts bluearchive.wiki JP/Global tabber ✓ built
|
||
index.ts parser registry ✓ built
|
||
adapters/
|
||
types.ts Adapter interface, ParseContext ✓ built
|
||
index.ts SOURCES registry, parseGame(), sanitize ✓ built
|
||
shared/
|
||
schema.ts zod schemas — the contract, both sides ✓ built
|
||
time.ts clocks, urgency, region resets, captions ✓ built
|
||
games.ts per-game name, hue, and reset clock ✓ built
|
||
daily.ts which events repeat, and game-day keys ✓ built
|
||
effort.ts effort estimates and the runway heuristic ✓ built
|
||
custom.ts the reader's own games and events (F13) ✓ built
|
||
feed.ts the /api/events.json wire contract ✓ built
|
||
client/ ✓ all built
|
||
main.tsx render + service worker registration
|
||
App.tsx shell, views, filters, onboarding gate
|
||
api.ts typed feed fetch, schemaVersion refusal
|
||
sw.js offline: shell cache, feed fallback
|
||
manifest.webmanifest, icon.svg
|
||
components/
|
||
NextUp.tsx the hero countdown (PRD F1)
|
||
EventRow.tsx row + meter + caption (F2, F3)
|
||
Meter.tsx the depletion meter
|
||
Legend.tsx what the bars and colours mean
|
||
Timeline.tsx calendar lanes (F1)
|
||
EventDetail.tsx detail sheet, ignore action (F9)
|
||
ProgressControls status, effort, note (F12)
|
||
Dailies.tsx today's strip, per-game reset clocks
|
||
DailyChecklist one repeating event's whole run
|
||
Fireworks.tsx the burst when the last daily lands
|
||
GameFocus.tsx one game at a time (F4a)
|
||
Controls.tsx games, region, export/import(F4, F5, F6)
|
||
Welcome.tsx first-run game picker (F8)
|
||
Toast.tsx undo an ignore
|
||
UpdateNotice a newer app is installed and waiting (F14)
|
||
YourOwn.tsx the reader's own games, in settings (F13)
|
||
CustomForms.tsx the game and event forms behind it (F13)
|
||
Colophon.tsx credit, disclaimer, repo link
|
||
state/
|
||
storage.ts namespaced, versioned localStorage
|
||
useMarkSet.ts ignores (and the superseded completions shape)
|
||
useProgress.ts status, effort, note, daily override (F12)
|
||
useDailyLog.ts which game-days are ticked off
|
||
usePrefs.ts region, filters, focus, onboarding flags
|
||
useCustom.ts the reader's own games and events (F13)
|
||
gameMeta.tsx lane id → name, label, hue; resolves custom lanes too
|
||
sort.ts deadline order, or what you're partway through
|
||
lens.ts who sees which rows — focus, outstanding, next-to-expire
|
||
useAppUpdate.ts is a newer build waiting, and taking it (F14)
|
||
serve.ts static server + /api/health ✓ built
|
||
scripts/
|
||
build-feed.ts fixtures → public/data/events.v1.json ✓ built
|
||
build-static.ts shell + worker into public/, build-stamped ✓ built
|
||
parse-fixture.ts run one adapter offline ✓ built
|
||
refresh-sources.ts fetch, cache, rebuild — the only network ✓ built
|
||
fixtures/<game>/ checked-in raw HTML + expected parse output
|
||
snapshots/ the current page per source, rewritten by refresh
|
||
```
|
||
|
||
## Request paths
|
||
|
||
| Route | Purpose | Notes |
|
||
|---|---|---|
|
||
| `GET /` + assets | React SPA | Served from the `bun build` output |
|
||
| `GET /api/events?from&to&game` | Filtered feed | `ETag` + `Cache-Control: public, max-age=300` |
|
||
| `GET /api/events.json` | Whole published feed | Cheap; the client mostly uses this and filters locally |
|
||
| `GET /api/games` | Game metadata: id, name, color, lastUpdatedAt | Drives the freshness badges (F7) |
|
||
| `GET /api/health` | Per-source last-success, quarantine depth | For an operator, not the UI |
|
||
| `GET /review` | Quarantine review UI | **Bound to `127.0.0.1` only** |
|
||
| `POST /api/review/:id/approve` \| `/reject` | Promote or discard a quarantined event | Same binding |
|
||
|
||
### Why `/review` needs no auth
|
||
|
||
`Bun.serve` runs two listeners: the public one on `0.0.0.0:PORT` with the SPA and `/api/*`, and a
|
||
second on `127.0.0.1:ADMIN_PORT` with `/review` and `/api/review/*`. The review routes are not
|
||
registered on the public listener at all — they are unreachable from off-box, so there is nothing
|
||
to authenticate. This is the mechanism that satisfies "no logins" without leaving an open admin
|
||
endpoint on the internet.
|
||
|
||
**This is load-bearing.** If someone later puts a reverse proxy in front of the admin port, or
|
||
merges the two listeners "to simplify", the review UI becomes a public write endpoint. Any change
|
||
in that area needs an explicit auth story first.
|
||
|
||
## Data flow, concretely
|
||
|
||
1. **Scheduler** wakes every 6h (± jitter). For each source not fetched within its `minIntervalMs`,
|
||
it acquires a per-source lock row and enqueues a run.
|
||
2. **Pipeline** executes the six stages in `docs/INGESTION.md`. Every stage writes to
|
||
`ingest_runs` so a failure is diagnosable after the fact without re-running.
|
||
3. **Publish** upserts into `events` by stable ID, bumping `version` and `updatedAt` when any field
|
||
changed. Events that vanish from a source are *not* deleted — they are marked
|
||
`status = 'delisted'` so a source outage cannot silently empty the calendar.
|
||
4. **Client** fetches the feed, joins it against `localStorage` by event ID — progress, ticked
|
||
days, ignores, and the reader's own events — and renders. That join is client-side only; the
|
||
server never learns what the reader completed, skipped, or typed in.
|
||
|
||
## Concurrency and failure
|
||
|
||
- One in-flight run per source, enforced by a lock row with a stale-lock timeout of 15 minutes.
|
||
- A source that fails keeps its previously published events. A failed run never deletes or blanks
|
||
data — worst case, the game's lane goes stale and gets a warning badge (F7).
|
||
- Three consecutive failures for one source raises its `health` to `failing` in `/api/health`, and
|
||
is what the refresh runner reports as `broken`. It never stops the schedule or the commit — a wiki
|
||
being down for an afternoon is normal — but it does fail the run afterwards, because at two cycles
|
||
a day that game's calendar has been served from a checked-in fixture for a day and a half.
|
||
- Raw snapshots are cached by content hash, so a parser change is always evaluated offline against
|
||
stored pages rather than by re-fetching.
|
||
|
||
## Deployment
|
||
|
||
Single process, single SQLite file, no external services at all.
|
||
|
||
```
|
||
PORT=3000
|
||
ADMIN_PORT=3001 # bound to 127.0.0.1
|
||
DATABASE_PATH=./data/events.sqlite
|
||
INGEST_INTERVAL_MS=21600000
|
||
INGEST_ENABLED=true # false for local UI work — never touches the network
|
||
CONFIDENCE_THRESHOLD=0.8
|
||
BASE_PATH=/ # trailing slash; set when hosting under a subpath
|
||
```
|
||
|
||
`INGEST_ENABLED=false` is the default for local development. Frontend work should run against a
|
||
seeded SQLite file and cost nothing.
|
||
|
||
### Today
|
||
|
||
`serve.ts` serves `public/` plus `/api/health`, and the feed is generated offline by
|
||
`bun run build:feed` from `snapshots/`, falling back to `fixtures/` for a source with no snapshot. It
|
||
emits exactly the shape `/api/events.json` will, so the real server slots in without the client
|
||
changing. Reads are confined to `public/` by resolving the path and checking it stays inside the root
|
||
— string-matching `..` is not enough, because encodings and URL normalisation both change what the
|
||
string looks like.
|
||
|
||
`scripts/refresh-sources.ts` (`bun run refresh`) is what fills `snapshots/`, and it is the only code
|
||
here that touches the network. `.github/workflows/refresh.yml` runs it at 05:27 and 17:27 UTC and
|
||
commits only when a page actually changed. It stands in for the unbuilt scheduler and enforces the
|
||
same conduct in code — the 6h floor, one request, no retries, conditional headers, per-host spacing,
|
||
robots failing closed. A source that has failed `BROKEN_AFTER_FAILURES` (3) cycles running is
|
||
reported as `broken` and fails the run *after* the commit; see `AGENTS.md` § Scraping conduct for why
|
||
that ordering is load-bearing.
|
||
|
||
`Dockerfile` builds and serves this; the image runs typecheck and tests during build, ships no source
|
||
or toolchain, and runs unprivileged. `.github/workflows/ci.yml` and `.gitlab-ci.yml` run the same
|
||
gates and publish it.
|
||
|
||
### Hosting under a subpath
|
||
|
||
Assets resolve against a `<base href>` substituted at build time, the feed URL resolves against
|
||
`document.baseURI` so deep links work, and the service worker derives its paths from its own
|
||
registration scope. `BASE_PATH=/gacha-event-tracker/ bun run build` for GitHub Pages; without it a
|
||
subpath deploy 404s on every asset.
|
||
|
||
### Offline
|
||
|
||
The service worker caches the shell and webfonts (cache-first) and the feed (network-first, falling
|
||
back to the last copy seen). Countdowns run off the device clock, so the app stays useful with no
|
||
network. Offline state is surfaced in the header and above the footer — stale data must never be
|
||
presented as current.
|
||
|
||
### Shipping a new version to an open page
|
||
|
||
A cache-first shell is what makes the offline story work and what makes a deploy invisible: the
|
||
reader this app is built for leaves the tab open for days, so a new game, a repaired parser or a
|
||
corrected date reaches their device and then sits there unused. Presenting an old app as current is
|
||
the same failure as presenting old events as current, so it is disclosed the same way.
|
||
|
||
```
|
||
build:static ──► sw.js stamped with a hash of the built shell
|
||
│
|
||
browser byte-compares sw.js on navigation, hourly, and when the tab is
|
||
revealed (registration.update)
|
||
│
|
||
bytes differ ──► new worker installs, precaches, and WAITS
|
||
│
|
||
registration.waiting ≠ null and a controller exists
|
||
│
|
||
UpdateNotice: "A new version of Event Clock is ready." [Reload] [×]
|
||
│
|
||
Reload ──► postMessage {type:"skip-waiting"} ──► worker activates
|
||
──► controllerchange ──► location.reload()
|
||
```
|
||
|
||
Four properties this depends on, each of them load-bearing:
|
||
|
||
- **The worker never calls `skipWaiting()` on install.** Claiming an open page unasked leaves the
|
||
running bundle and the cached shell on two different builds, with nothing on screen saying so. It
|
||
activates only on the message the reader's tap sends. A first install has no worker to wait for and
|
||
activates immediately regardless — and is deliberately *not* announced, since nothing is being
|
||
replaced.
|
||
- **The build id is derived, not remembered.** `scripts/build-static.ts` hashes the built shell
|
||
(`index.html`, `main.js`, `styles.css`, `sw.js` source) and substitutes it for `__BUILD__` in the
|
||
worker, so any shell change alters the worker's bytes and is therefore offered. The predecessor was
|
||
a hand-bumped `CACHE_VERSION`, which had already been forgotten once. The substitution **throws**
|
||
if the placeholder is gone, because the failure mode is silent.
|
||
- **The feed is not part of the id.** It is rewritten twice a day and served network-first, so new
|
||
events reach an open page without a reload. Calling that a new version would teach readers to
|
||
dismiss the notice unread.
|
||
- **The cache name does not move with the build.** Everything in it is refetched (`cache: "reload"`,
|
||
since none of these URLs are fingerprinted) on install, so a per-build bucket would buy nothing and
|
||
would discard the stored feed — the copy an offline reader is reading.
|
||
|
||
`src/client/state/useAppUpdate.ts` holds the client half; `sw.js` and the hook cannot import each
|
||
other, so `test/update.test.tsx` pins both ends of the `skip-waiting` handshake and the placeholder.
|
||
|
||
## Deliberate non-choices
|
||
|
||
- **No ORM.** `bun:sqlite` plus hand-written SQL in `queries.ts`. The schema is five tables.
|
||
- **No Redis / job queue.** The scheduler is a timer and a lock row. Restarting the process resumes
|
||
cleanly because state is in SQLite.
|
||
- **No server-side rendering.** The feed is small and cacheable; a static SPA is enough.
|
||
- **No websockets.** Events change on a scale of hours; a 5-minute cache is more than adequate.
|