Author SHA1 Message Date
Lucas WintherandClaude Opus 5.5 a5700ef607 Merge branch 'feat/kakaopage': KakaoPage source (spec 004), per-host request headers
Continuous Integration / lint-and-test (push) Successful in 7s
Continuous Integration / docker-build-and-push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:31:25 +02:00
Lucas WintherandClaude Opus 5.5 4afd812ea6 docs: remove the finished plan 004 (the spec is the record)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:31:25 +02:00
Lucas WintherandClaude Opus 5.5 f79343ceb9 docs: deferred KakaoPage review minors in TODO
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:31:25 +02:00
Lucas WintherandClaude Opus 5.5 cfa0376bdb docs: plan 004 ticked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:23:24 +02:00
Lucas WintherandClaude Opus 5.5 5b2ad867b3 feat: KakaoPage source (웹툰 and 웹소설), registered; correct the Tapas cookie claim
KakaoPage is read from its data endpoint (current covers) and from archived
server-rendered pages under both addresses (history). The userscript never sent
Tapas cookies (anonymous: true): docs and the age-check warning now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:20:29 +02:00
Lucas WintherandClaude Opus 5.5 1d48796d05 feat(core): parse KakaoPage's overview answer and archived pages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:19:07 +02:00
Lucas WintherandClaude Opus 5.5 a47151c785 feat: per-host request headers (Referer for KakaoPage's data endpoint) in the proxy and the userscript
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:18:26 +02:00
Lucas WintherandClaude Opus 5.5 cd3a7b869a feat(core): KakaoPage links, image URLs and same-series check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:17:49 +02:00
Lucas WintherandClaude Opus 5.5 899e8a0d54 docs: spec 004 (KakaoPage source), its plan, and captured KakaoPage fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:17:16 +02:00
Lucas WintherandClaude Opus 5.5 80185210ac Merge branch 'chore/analytics'
Continuous Integration / lint-and-test (push) Successful in 6s
Continuous Integration / docker-build-and-push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:27:11 +02:00
Lucas WintherandClaude Opus 5.5 91d32352e4 chore(web): page views via our Shynet instance (page only, not the userscript panel)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:27:11 +02:00
Lucas WintherandClaude Opus 5.5 e0bd630bb6 Merge branch 'feat/tapas': Tapas source, comics and novels (spec 003)
Continuous Integration / lint-and-test (push) Successful in 7s
Continuous Integration / docker-build-and-push (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:12:44 +02:00
Lucas WintherandClaude Opus 5.5 6e97e95ce9 docs: remove the finished plan 003 (the spec is the record)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:12:44 +02:00
Lucas WintherandClaude Opus 5.5 d16bdf7545 docs: deferred Tapas review minors in TODO
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:12:44 +02:00
Lucas WintherandClaude Opus 5.5 bc81fc3282 docs: plan 003 ticked; possible early-click race noted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:09:53 +02:00
Lucas WintherandClaude Opus 5.5 556a365071 feat: Tapas in the copy, userscript @connect and docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:56:37 +02:00
Lucas WintherandClaude Opus 5.5 187e85fcaa feat(core): Tapas source (comics and novels), registered
Series by slug, id or episode link; portrait original and banner; age-checked
mature series resolve with a warning and no current covers; Wayback history
keeps only this series' captures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:56:03 +02:00
Lucas WintherandClaude Opus 5.5 f914c85545 feat(core): parse Tapas series, reader and age-check pages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:54:52 +02:00
Lucas WintherandClaude Opus 5.5 137402072e feat(core): Tapas links, image URLs and same-series check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:54:19 +02:00
Lucas WintherandClaude Opus 5.5 d5c07465da docs: spec 003 (Tapas source), its plan, and captured Tapas fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:53:36 +02:00
Lucas WintherandClaude Opus 5.5 a7253940fa Merge branch 'feat/download-and-history': download covers as a ZIP, history of searches (spec 002)
Continuous Integration / lint-and-test (push) Successful in 6s
Continuous Integration / docker-build-and-push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:47:11 +02:00
Lucas WintherandClaude Opus 5.5 a7ff9ae20d docs: remove the finished plan 002 (the spec is the record)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:46:05 +02:00
Lucas WintherandClaude Opus 5.5 49c9efa704 docs: history keeper in architecture; deferred review minors in TODO; plan ticked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:46:05 +02:00
Lucas WintherandClaude Opus 5.5 0d4a1a61fa fix: final review — cached links keep their expiry; history removal sticks; saves flushed
- A link reused from the rehost cache keeps the expiry it was uploaded with, and
  links near expiry are uploaded again (a dead litterbox link could be filed).
- History saving moves into HistoryKeeper (tested): removing an entry or clearing
  the history cancels a pending save and stops tracking the series on screen;
  a pending edit is saved before a new search, before opening another entry and
  when the page is hidden; opening an entry no longer re-saves the current one.
- The "couldn't be saved" line sits outside the History list, so it shows even
  when the list is empty; a refused IndexedDB says history is off.
- Recently searched bands wrap their meta line instead of cutting the count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:45:48 +02:00
Lucas WintherandClaude Opus 5.5 1cf31b26cf fix(web): history focus rings inside the rows, aligned Clear history, no duplicate reopen note; DESIGN.md
From the impeccable finish review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:52 +02:00
Lucas WintherandClaude Opus 5.5 2a0e786e6f docs: download and history in README, architecture, decisions; test refused storage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:38:34 +02:00
Lucas WintherandClaude Opus 5.5 2da992fd81 feat(web): history of searches on the page
Each search is kept in IndexedDB with its covers and edits; the History list and
a shelf of recent series on the start screen reopen it instantly. Searching a
series again keeps the edits of covers that are still there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:35:06 +02:00
Lucas WintherandClaude Opus 5.5 81ee5f0efa feat(web): download the selected covers as a ZIP
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:32:51 +02:00
Lucas WintherandClaude Opus 5.5 01c2253051 feat(web): history view helpers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:31:51 +02:00
Lucas WintherandClaude Opus 5.5 2293d2810f feat(core): saved searches for the history
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:31:32 +02:00
Lucas WintherandClaude Opus 5.5 414a272d40 feat(core): remember where and until when a cover is hosted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:30:27 +02:00
Lucas WintherandClaude Opus 5.5 7569c0bb29 feat(core): download bundle of the selected covers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:29:56 +02:00
Lucas WintherandClaude Opus 5.5 3eb0332c87 feat(core): store-only ZIP writer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:29:04 +02:00
Lucas WintherandClaude Opus 5.5 d1b8350263 feat(web): old covers from the Wayback Machine start off
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:28:27 +02:00
Lucas WintherandClaude Opus 5.5 e7af273a6d docs: spec 002 (download covers, history of searches) and its plan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:28:13 +02:00
Lucas WintherandClaude Opus 5.5 6c9bc62186 Merge branch 'fix/main-band-selection'
Continuous Integration / lint-and-test (push) Successful in 6s
Continuous Integration / docker-build-and-push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:24:27 +02:00
Lucas WintherandClaude Opus 5.5 a68159c958 fix(web): visible text selection on the main-cover band
The page-wide highlight is band red at 30%, invisible on the red band, so its
text looked unselectable. Reverse the band's colours for the selection there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:24:27 +02:00
Lucas WintherandClaude Opus 5.5 9ae2ba3241 Merge branch 'fix/darkreader'
Continuous Integration / lint-and-test (push) Successful in 6s
Continuous Integration / docker-build-and-push (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:07:43 +02:00
Lucas WintherandClaude Opus 5.5 a64b2009df fix(web): use the page's own dark theme when Dark Reader is on
Dark Reader marks <html data-darkreader-scheme="dark">; the page then sets
data-theme="dark" and adds <meta name="darkreader-lock"> so Dark Reader
removes its generated styles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:07:43 +02:00
Lucas WintherandClaude Opus 5.5 f6d27aa5cc Merge branch 'chore/docker': Docker image and Gitea CI
Continuous Integration / lint-and-test (push) Successful in 9s
Continuous Integration / docker-build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:46:09 +02:00
Lucas WintherandClaude Opus 5.5 ec51b7b5fc chore: Dockerfile, Gitea CI image build, docker-compose example
The server now stops on SIGTERM/SIGINT so docker stop is immediate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:45:13 +02:00
Lucas WintherandClaude Opus 5.5 2282897b4b Merge branch 'feat/webtoons-covers': mangabaka-seeder v0.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:55:11 +02:00
Lucas WintherandClaude Opus 5.5 b2eb621ebb docs: remove the finished plan (the spec is the record)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:50:01 +02:00
Lucas WintherandClaude Opus 5.5 baa3476b73 fix(server): inert proxy responses, API-call header, image-only uploads; origin check by host
From the final review: proxied archived HTML could run on our origin and read a
remembered token; /api/upload accepted any Wayback URL and GIF-prefixed bytes; the
origin check broke behind a TLS proxy; one unreadable snapshot could fail a run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:49:52 +02:00
Lucas WintherandClaude Opus 5.5 5084f8c557 docs: v0.1 README, architecture and TODO
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:40:39 +02:00
Lucas WintherandClaude Opus 5.5 df7e1bcd42 feat(userscript): Seed covers panel on MangaBaka series pages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:40:04 +02:00
Lucas WintherandClaude Opus 5.5 162ab4afb0 feat(userscript): GM_xmlhttpRequest transport, GM storage, metadata banner and build
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:38:20 +02:00
Lucas WintherandClaude Opus 5.5 17ecebc4a4 feat(web): Obi Shelf design in a console layout, with a timeline view; record DESIGN.md
The user chose the direction over two mockup rounds (samples/mockups, gitignored):
the Obi Shelf's shelf and colours, Graphite Console side panels, the Annotated
Edition timeline as a second view, and warm off-colour grounds in both themes.
Selects look editable; labels are source-neutral. impeccable finish review: ship.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:38:00 +02:00
Lucas WintherandClaude Opus 5.5 aea4a1a35c fix(server): keep connections open 240 s; Bun's 10 s default cut slow Wayback lookups off
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:43:47 +02:00
Lucas WintherandClaude Opus 5.5 6f43ef0615 feat(web): working page (two links → review cards → rehost → copy or preview/confirm)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:44:58 +02:00
Lucas WintherandClaude Opus 5.5 9a13111589 feat(server): Bun.serve entry with folder storage and daily sweep; document running it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:39:53 +02:00
Lucas WintherandClaude Opus 5.5 2debb3d513 feat(server): web-standard handler (config, allowlisted fetch proxy, upload, covers, assets)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:39:19 +02:00
Lucas WintherandClaude Opus 5.5 4197b174b5 feat(server): config from env, trusted client IP, per-IP rate limiter, safe asset paths
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:39:13 +02:00
Lucas WintherandClaude Opus 5.5 9019d99c59 feat(server): size-capped covers folder with a 30-day sweep; in-memory storage for tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:39:06 +02:00
Lucas WintherandClaude Opus 5.5 260ae2f5c0 feat(core): shared review session (cards, main cover, rehost, manual text, token flow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:38:54 +02:00
Lucas WintherandClaude Opus 5.5 0ed017056f feat(core): compose the pipeline (links → series → current + history → inspect → compare)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:38:48 +02:00
Lucas WintherandClaude Opus 5.5 a6c02cb548 feat(core): image hosts (server, litterbox, catbox, direct) and per-image rehosting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:38:22 +02:00
Lucas WintherandClaude Opus 5.5 1e548701ca feat(core): build the cover seed (whole set, one default) and manual-entry text
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:38:15 +02:00
Lucas WintherandClaude Opus 5.5 dd1604ea91 feat(core): MangaBaka client (paginated public reads; snapshot, preview, save for review)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:38:08 +02:00
Lucas WintherandClaude Opus 5.5 7247e096f7 feat(core): Wayback history with 1/3/6/12-month granularity; stop on refusal
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:37:59 +02:00
Lucas WintherandClaude Opus 5.5 8f2109471c feat(core): merge, dedupe, compare with MangaBaka, pick the default, write notes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:37:48 +02:00
Lucas WintherandClaude Opus 5.5 6c846f80d8 feat(core): inspect images (size from headers, sha256, shape incl. landing-page art)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:37:41 +02:00
Lucas WintherandClaude Opus 5.5 5cc317ea26 feat(core): Source interface, registry and the Webtoons source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:37:30 +02:00
Lucas WintherandClaude Opus 5.5 470424e0d1 feat(core): parse Webtoons series pages (current and archived) into cover candidates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:37:03 +02:00
Lucas WintherandClaude Opus 5.5 995b0178d5 feat(core): parse MangaBaka and Webtoons links; normalise Webtoons image URLs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:36:52 +02:00
Lucas WintherandClaude Opus 5.5 dfb21a6c01 feat(core): model types and transport policy (pacing, retries, timeouts, token scope)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:36:34 +02:00
Lucas WintherandClaude Opus 5.5 8912fe74ef test: keep fixtures byte-exact (no line-ending conversion)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:36:04 +02:00
Lucas WintherandClaude Opus 5.5 ed97c789a5 test: capture Webtoons, Wayback and MangaBaka fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:35:58 +02:00
Lucas WintherandClaude Opus 5.5 a76926b999 chore: scaffold Bun + TypeScript with core boundary test; record verified commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:34:18 +02:00
Lucas WintherandClaude Opus 5.5 a4ab7ad914 docs: implementation plan 001 (draft, awaiting approval)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:31:20 +02:00
Lucas WintherandClaude Opus 5.5 3e927099c5 docs: amend spec 001 from tests against real Webtoons, Wayback and MangaBaka
History granularity, honest Wayback User-Agent and 10 s pacing, tall
landing-art class, one default cover per series, paginated public
images. Ignore samples/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:03:15 +02:00
154 changed files with 30547 additions and 81 deletions
+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)",
"Bash(git rev-parse:*)", "Bash(git add:*)", "Bash(git commit:*)"
"Bash(git rev-parse:*)", "Bash(git add:*)", "Bash(git commit:*)",
"Bash(bun install --frozen-lockfile)", "Bash(bun test:*)", "Bash(bun run typecheck)", "Bash(bun run build)", "Bash(bun run build:web)", "Bash(bun run build:userscript)"
],
"ask": [
"Bash(git push:*)", "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git switch:*)",
+19
View File
@@ -0,0 +1,19 @@
.git
.gitea
.claude
.codex
.gemini
.impeccable
.superpowers
.worktrees
node_modules
dist
samples
docs
test
*.md
.env
.env.*
.dev.vars
docker-compose*.yml
Dockerfile
+2 -1
View File
@@ -3,7 +3,8 @@
"allowed": [
"run_shell_command(git status)", "run_shell_command(git diff)", "run_shell_command(git log)",
"run_shell_command(git show)", "run_shell_command(git branch --list)",
"run_shell_command(git rev-parse)", "run_shell_command(git add)", "run_shell_command(git commit)"
"run_shell_command(git rev-parse)", "run_shell_command(git add)", "run_shell_command(git commit)",
"run_shell_command(bun install --frozen-lockfile)", "run_shell_command(bun test)", "run_shell_command(bun run typecheck)", "run_shell_command(bun run build)", "run_shell_command(bun run build:web)", "run_shell_command(bun run build:userscript)"
],
"exclude": [
"run_shell_command(git push --force)", "run_shell_command(git push -f)",
+2
View File
@@ -0,0 +1,2 @@
# Captured responses must stay byte-exact.
test/fixtures/** -text
+99
View File
@@ -0,0 +1,99 @@
name: Continuous Integration
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: gitea.lucaswinther.info
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Run test suite
run: bun test
- name: Build page and userscript
run: bun run build
docker-build-and-push:
runs-on: ubuntu-latest
needs: [lint-and-test]
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Gitea's job token cannot publish packages; use a package-write PAT.
- name: Log in to Gitea Container Registry
if: gitea.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
build-args: |
BUILD_VERSION=${{ gitea.sha }}
BUILD_REPOSITORY=https://gitea.lucaswinther.info/${{ gitea.repository }}
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Read-only smoke test: no storage, no outbound calls; the page, config and userscript must be served.
- name: Pull and smoke-test published image
if: gitea.event_name != 'pull_request'
env:
PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }}
run: |
image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)"
docker pull "$image"
docker run -d --rm --name mangabaka-seeder-ci "$image"
trap 'docker stop mangabaka-seeder-ci' EXIT
docker exec mangabaka-seeder-ci bun -e '
const base = "http://127.0.0.1:8787";
let ready = false;
for (let attempt = 0; attempt < 30; attempt++) {
try {
const response = await fetch(base + "/");
if (response.ok && (await response.text()).includes("MangaBaka Seeder")) {
ready = true;
break;
}
} catch {}
await Bun.sleep(1000);
}
if (!ready) throw new Error("App did not serve its HTML");
const config = await (await fetch(base + "/api/config")).json();
if (config.storage !== false) throw new Error("expected no storage without MBSEED_STORAGE_DIR");
const script = await fetch(base + "/mbseed.user.js");
if (!script.ok || !(await script.text()).startsWith("// ==UserScript==")) throw new Error("/mbseed.user.js missing");
const refused = await fetch(base + "/api/fetch?url=https://example.org/");
if (refused.status !== 403) throw new Error(`/api/fetch without x-mbseed returned ${refused.status}`);
'
+6
View File
@@ -7,3 +7,9 @@ dist/
CLAUDE.local.md
.claude/settings.local.json
.worktrees/
samples/
.impeccable/review/
.impeccable/mocks/
.impeccable/build/
.impeccable/config.local.json
.superpowers/
+657
View File
@@ -0,0 +1,657 @@
{
"schemaVersion": 2,
"generatedAt": "2026-10-14T23:37:28+00:00",
"title": "Design System: MangaBaka Seeder",
"extensions": {
"colorMeta": {
"paper-page": {
"role": "neutral",
"displayName": "Book-Jacket Paper",
"purpose": "Page and middle-column ground (light).",
"canonical": "oklch(93.6% 0.015 80.7)",
"tonalRamp": [
"#0d0b07",
"#28251f",
"#46413a",
"#656158",
"#878279",
"#a9a49c",
"#cdc8c1",
"#f2eee7"
]
},
"paper-panel": {
"role": "neutral",
"displayName": "Desk Paper",
"purpose": "Side desks (light).",
"canonical": "oklch(90.6% 0.019 83.1)",
"tonalRamp": [
"#0e0b06",
"#29251d",
"#464138",
"#666056",
"#888276",
"#aaa49a",
"#cec8bf",
"#f3eee5"
]
},
"paper-slip": {
"role": "neutral",
"displayName": "Slip Paper",
"purpose": "Slips, obi bands, selects, ticks, chosen host row (light).",
"canonical": "oklch(96.8% 0.011 84.6)",
"tonalRamp": [
"#0d0b08",
"#272520",
"#44423c",
"#64615a",
"#85827b",
"#a8a59e",
"#ccc9c3",
"#f1eee9"
]
},
"sumi-ink": {
"role": "neutral",
"displayName": "Sumi Ink",
"purpose": "Text, 3px rules, outline buttons, active view and plate (light).",
"canonical": "oklch(22.7% 0.014 298.4)",
"tonalRamp": [
"#0c0a0f",
"#26242a",
"#434148",
"#626068",
"#838189",
"#a6a4ac",
"#cac8cf",
"#efedf4"
]
},
"sumi-ink-muted": {
"role": "neutral",
"displayName": "Faded Ink",
"purpose": "Labels, hints, band facts, inactive rows (light).",
"canonical": "oklch(44.0% 0.017 308.0)",
"tonalRamp": [
"#0c0a0f",
"#27232a",
"#444048",
"#645f69",
"#85808a",
"#a7a3ac",
"#cbc7d0",
"#f1ecf5"
]
},
"obi-red": {
"role": "primary",
"displayName": "Obi Red",
"purpose": "Main cover band, stamps, failed plate, notice rule (light).",
"canonical": "oklch(57.9% 0.198 27.8)",
"tonalRamp": [
"#310000",
"#5a0000",
"#860000",
"#b41718",
"#dd463c",
"#fe7467",
"#ffa091",
"#ffcbbc"
]
},
"obi-red-deep": {
"role": "primary",
"displayName": "Obi Red Deep",
"purpose": "Stamp hover, main band top rule (light).",
"canonical": "oklch(42.6% 0.150 28.2)",
"tonalRamp": [
"#290000",
"#4f0000",
"#781a15",
"#a3382f",
"#ca5b4f",
"#eb8376",
"#ffac9f",
"#ffd5c8"
]
},
"obi-ink": {
"role": "primary",
"displayName": "Obi Ink",
"purpose": "Text on obi red, both schemes.",
"canonical": "oklch(98.8% 0.008 61.5)",
"tonalRamp": [
"#0d0b09",
"#272422",
"#45413f",
"#64605d",
"#86817e",
"#a8a4a1",
"#ccc8c5",
"#f1eeeb"
]
},
"obi-red-text": {
"role": "primary",
"displayName": "Obi Red Text",
"purpose": "Small red text: year keys, errors, removed lines, link hover (light).",
"canonical": "oklch(47.2% 0.162 28.4)",
"tonalRamp": [
"#2b0000",
"#520000",
"#7c120e",
"#a73229",
"#cf574a",
"#f08072",
"#ffa99b",
"#ffd2c5"
]
},
"rehost-teal": {
"role": "secondary",
"displayName": "Rehost Teal",
"purpose": "Done, rehosted, added, focus ring (light).",
"canonical": "oklch(49.9% 0.066 188.3)",
"tonalRamp": [
"#00110f",
"#002d2a",
"#144c48",
"#316d68",
"#538f89",
"#7bb1ac",
"#a4d4cf",
"#cef9f4"
]
},
"kraft-rail": {
"role": "tertiary",
"displayName": "Kraft Rail",
"purpose": "Shelf rail, note bullets, scrollbar (light).",
"canonical": "oklch(70.8% 0.058 78.3)",
"tonalRamp": [
"#140800",
"#312208",
"#513f22",
"#735e3c",
"#957f5c",
"#b7a282",
"#d9c6a9",
"#fdecd2"
]
},
"rail-lip": {
"role": "tertiary",
"displayName": "Rail Lip",
"purpose": "2px edge under the rail (light).",
"canonical": "oklch(52.9% 0.055 78.4)",
"tonalRamp": [
"#130900",
"#30220b",
"#503f24",
"#725e3f",
"#947f5f",
"#b5a284",
"#d8c6ab",
"#fcecd3"
]
},
"hairline": {
"role": "neutral",
"displayName": "Hairline",
"purpose": "Dividers and box edges (light, translucent ink).",
"canonical": "rgba(29, 27, 34, 0.2)"
},
"cover-frame": {
"role": "neutral",
"displayName": "Cover Frame",
"purpose": "1px inset frame on every cover; select edges (light).",
"canonical": "rgba(29, 27, 34, 0.35)"
},
"track": {
"role": "neutral",
"displayName": "Track",
"purpose": "Seen-range and progress tracks (light).",
"canonical": "rgba(29, 27, 34, 0.12)"
},
"cast-shadow": {
"role": "neutral",
"displayName": "Cast Shadow",
"purpose": "Book and panel shadow colour (light).",
"canonical": "rgba(29, 20, 10, 0.5)"
},
"umber-page": {
"role": "neutral",
"displayName": "Warm Umber (dark)",
"purpose": "Page and middle-column ground (dark).",
"canonical": "oklch(26.8% 0.012 67.3)",
"tonalRamp": [
"#0d0b08",
"#282420",
"#46413c",
"#66605b",
"#87817b",
"#a9a49e",
"#cdc8c3",
"#f2eee9"
]
},
"umber-panel": {
"role": "neutral",
"displayName": "Desk Paper (dark)",
"purpose": "Side desks (dark).",
"canonical": "oklch(30.2% 0.014 67.3)",
"tonalRamp": [
"#0e0a07",
"#292420",
"#46413b",
"#66605a",
"#88817a",
"#aaa49d",
"#cec8c2",
"#f3ede8"
]
},
"umber-slip": {
"role": "neutral",
"displayName": "Slip Paper (dark)",
"purpose": "Slips, obi bands, selects, ticks, chosen host row (dark).",
"canonical": "oklch(33.0% 0.016 67.2)",
"tonalRamp": [
"#0e0a07",
"#29241f",
"#47413b",
"#676058",
"#898179",
"#aba49c",
"#cfc8c1",
"#f3ede7"
]
},
"umber-ink": {
"role": "neutral",
"displayName": "Sumi Ink (dark)",
"purpose": "Text, 3px rules, outline buttons, active view and plate (dark).",
"canonical": "oklch(92.1% 0.021 81.8)",
"tonalRamp": [
"#0e0b05",
"#29241c",
"#474137",
"#676055",
"#888175",
"#aba499",
"#cec8be",
"#f3eee4"
]
},
"umber-ink-muted": {
"role": "neutral",
"displayName": "Faded Ink (dark)",
"purpose": "Labels, hints, band facts, inactive rows (dark).",
"canonical": "oklch(76.8% 0.026 77.4)",
"tonalRamp": [
"#0f0a04",
"#2a241a",
"#494135",
"#696052",
"#8b8172",
"#ada496",
"#d0c8bb",
"#f5ede2"
]
},
"umber-obi-red": {
"role": "primary",
"displayName": "Obi Red (dark)",
"purpose": "Main cover band, stamps, failed plate, notice rule (dark).",
"canonical": "oklch(54.1% 0.187 28.1)",
"tonalRamp": [
"#2f0000",
"#580000",
"#830000",
"#b0221e",
"#d84c41",
"#fa786a",
"#ffa394",
"#ffcdbf"
]
},
"umber-obi-red-deep": {
"role": "primary",
"displayName": "Obi Red Deep (dark)",
"purpose": "Stamp hover, main band top rule (dark).",
"canonical": "oklch(42.6% 0.147 28.0)",
"tonalRamp": [
"#290000",
"#4f0000",
"#771b16",
"#a23930",
"#c95c51",
"#ea8478",
"#ffaca0",
"#ffd5c9"
]
},
"umber-obi-red-text": {
"role": "primary",
"displayName": "Obi Red Text (dark)",
"purpose": "Small red text: year keys, errors, removed lines, link hover (dark).",
"canonical": "oklch(73.5% 0.134 26.9)",
"tonalRamp": [
"#260000",
"#4c0305",
"#73211d",
"#9d3e38",
"#c36158",
"#e4887e",
"#ffafa5",
"#ffd8ce"
]
},
"umber-teal": {
"role": "secondary",
"displayName": "Rehost Teal (dark)",
"purpose": "Done, rehosted, added, focus ring (dark).",
"canonical": "oklch(71.8% 0.073 184.2)",
"tonalRamp": [
"#00110e",
"#002d28",
"#0b4d46",
"#2a6e66",
"#4e9087",
"#77b2a9",
"#a1d6cd",
"#cbfaf2"
]
},
"umber-kraft-rail": {
"role": "tertiary",
"displayName": "Kraft Rail (dark)",
"purpose": "Shelf rail, note bullets, scrollbar (dark).",
"canonical": "oklch(48.0% 0.048 74.6)",
"tonalRamp": [
"#130900",
"#30220f",
"#4f3f29",
"#715e44",
"#937f64",
"#b5a289",
"#d8c6af",
"#fcecd7"
]
},
"umber-rail-lip": {
"role": "tertiary",
"displayName": "Rail Lip (dark)",
"purpose": "2px edge under the rail (dark).",
"canonical": "oklch(36.6% 0.035 73.4)",
"tonalRamp": [
"#110902",
"#2d2316",
"#4c4030",
"#6d5f4d",
"#8f806d",
"#b1a391",
"#d4c7b6",
"#f9ecde"
]
},
"umber-hairline": {
"role": "neutral",
"displayName": "Hairline (dark)",
"purpose": "Dividers and box edges (dark, translucent ink).",
"canonical": "rgba(236, 228, 214, 0.16)"
},
"umber-cover-frame": {
"role": "neutral",
"displayName": "Cover Frame (dark)",
"purpose": "1px inset frame on every cover; select edges (dark).",
"canonical": "rgba(236, 228, 214, 0.35)"
},
"umber-track": {
"role": "neutral",
"displayName": "Track (dark)",
"purpose": "Seen-range and progress tracks (dark).",
"canonical": "rgba(236, 228, 214, 0.14)"
},
"umber-cast-shadow": {
"role": "neutral",
"displayName": "Cast Shadow (dark)",
"purpose": "Book and panel shadow colour (dark).",
"canonical": "rgba(0, 0, 0, 0.6)"
}
},
"typographyMeta": {
"display": {
"displayName": "Display",
"purpose": "Series title over the review."
},
"headline": {
"displayName": "Headline",
"purpose": "Empty-state statement."
},
"title": {
"displayName": "Title",
"purpose": "Brand line on the left desk, over a 3px ink rule."
},
"body": {
"displayName": "Body",
"purpose": "Base text and slip inputs."
},
"body-small": {
"displayName": "Body Small",
"purpose": "Settings, buttons, host rows, status lines."
},
"band": {
"displayName": "Band",
"purpose": "Obi band facts, notes and per-cover fields; tabular numerals."
},
"label": {
"displayName": "Printed Label",
"purpose": "Tracked uppercase for desk caps, slip labels, band roles, stage plates."
},
"stamp": {
"displayName": "Stamp",
"purpose": "Red stamp buttons only."
},
"mono": {
"displayName": "Mono",
"purpose": "Paste-out text area and the catbox userhash."
}
},
"shadows": [
{
"name": "book-cast",
"value": "0 8px 14px -8px var(--mb-shadow)",
"purpose": "Under every cover image as it stands on its rail."
},
{
"name": "rail-lip",
"value": "0 2px 0 var(--mb-rail)",
"purpose": "Edge of the kraft rail under each shelved book; furniture, not a card shadow."
},
{
"name": "floating-panel",
"value": "0 18px 40px -18px var(--mb-shadow)",
"purpose": "Userscript panel over mangabaka.org only."
}
],
"motion": [
{
"name": "ease-shelve",
"value": "cubic-bezier(0.16, 1, 0.3, 1)",
"purpose": "Shelving a new run and an unselected book leaning back."
},
{
"name": "shelve",
"value": "translateY(14px) and opacity 0.35 to rest, 0.55s, 28ms stagger capped at 24",
"purpose": "The one authored motion: covers shelved left to right after a run."
},
{
"name": "plate-blink",
"value": "opacity 0.3 at 50%, 1s steps(2) infinite",
"purpose": "Active stage plate pip."
},
{
"name": "state",
"value": "0.12s to 0.3s",
"purpose": "Ticks, button fills, plate changes, grayscale."
},
{
"name": "reduced-motion",
"value": "all animation and transition off",
"purpose": "prefers-reduced-motion: reduce."
}
],
"breakpoints": [
{
"name": "stack",
"value": "1000px (container)",
"purpose": "Columns stack; the userscript panel form."
},
{
"name": "timeline-single",
"value": "760px (container)",
"purpose": "Timeline rail moves to a left gutter."
},
{
"name": "default-row",
"value": "700px (container)",
"purpose": "Main-cover row stacks."
},
{
"name": "wide-span",
"value": "420px (container)",
"purpose": "Wide books stop spanning two slots."
}
]
},
"components": [
{
"name": "Outline Button",
"kind": "button",
"refersTo": "button-outline",
"description": "Default desk action (Rehost selected, Copy all selected, Preview changes).",
"html": "<div class=\"ds-scope\"><button class=\"ds-btn\">Copy all selected</button></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-btn{display:block;width:100%;padding:8px 12px;border:1.5px solid var(--mb-ink);border-radius:0;background:transparent;color:var(--mb-ink);font:700 13px var(--mb-sans);cursor:pointer;transition:background-color .15s,color .15s;} .ds-btn:hover{background:var(--mb-ink);color:var(--mb-page);} .ds-btn:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;}"
},
{
"name": "Stamp Button",
"kind": "button",
"refersTo": "button-stamp",
"description": "The red stamp: Find covers, and the final file-for-review action only.",
"html": "<div class=\"ds-scope\"><button class=\"ds-stamp\">Find covers</button></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-stamp{display:block;width:100%;padding:10px 12px;border:1.5px solid var(--mb-obi);border-radius:0;background:var(--mb-obi);color:var(--mb-obi-ink);font:800 12.5px var(--mb-sans);letter-spacing:.14em;text-transform:uppercase;cursor:pointer;transition:background-color .15s;} .ds-stamp:hover{background:var(--mb-obi-deep);border-color:var(--mb-obi-deep);} .ds-stamp:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;}"
},
{
"name": "Slip Field",
"kind": "input",
"refersTo": "slip-field",
"description": "A paper slip holding a tracked label, a dotted-underline input and an optional hint.",
"html": "<div class=\"ds-scope\"><label class=\"ds-slip\"><span class=\"ds-slip-label\">Source series link</span><input class=\"ds-slip-input\" placeholder=\"https://...\"><span class=\"ds-slip-hint\">The series page on the source site.</span></label></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-slip{display:grid;gap:3px;padding:7px 10px 8px;background:var(--mb-slip);border:1px solid var(--mb-line);} .ds-slip:focus-within{border-color:var(--mb-ink);} .ds-slip-label{font:700 10px var(--mb-sans);letter-spacing:.12em;text-transform:uppercase;color:var(--mb-ink-2);} .ds-slip-hint{font-size:11.5px;color:var(--mb-ink-2);} .ds-slip-input{width:100%;padding:2px 0;border:0;border-bottom:1px dotted var(--mb-ink-2);background:transparent;color:var(--mb-ink);font:14px var(--mb-sans);} .ds-slip-input:focus{outline:none;border-bottom:1.5px solid var(--mb-ink);}"
},
{
"name": "Editable Select",
"kind": "input",
"refersTo": "select",
"description": "Every select is visibly editable: boxed, 2px ink bottom edge, drawn chevron.",
"html": "<div class=\"ds-scope\"><select class=\"ds-select\"><option>every year</option><option>every month</option></select></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-select{appearance:none;-webkit-appearance:none;padding:4px 26px 4px 9px;border:1px solid var(--mb-frame);border-bottom:2px solid var(--mb-ink);border-radius:0;background:var(--mb-slip) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%231d1b22' stroke-width='1.6'/%3E%3C/svg%3E\") no-repeat right 9px center;color:var(--mb-ink);font:600 13px var(--mb-sans);cursor:pointer;} .ds-select:hover{border-color:var(--mb-ink);background-color:var(--mb-page);} .ds-select:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;}"
},
{
"name": "View Switch",
"kind": "nav",
"refersTo": "view-switch",
"description": "Shelf | Timeline segmented pair in one ink border.",
"html": "<div class=\"ds-scope\"><div class=\"ds-views\" role=\"group\" aria-label=\"View\"><button class=\"ds-view\" aria-pressed=\"true\">Shelf</button><button class=\"ds-view\" aria-pressed=\"false\">Timeline</button></div></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-views{display:inline-flex;border:1.5px solid var(--mb-ink);} .ds-view{border:0;border-radius:0;background:transparent;color:var(--mb-ink);padding:4px 12px;font:700 12.5px var(--mb-sans);cursor:pointer;} .ds-view:hover{text-decoration:underline;} .ds-view[aria-pressed=\"true\"]{background:var(--mb-ink);color:var(--mb-page);} .ds-view:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;}"
},
{
"name": "Stage Plates",
"kind": "custom",
"refersTo": "stage-plate",
"description": "Run stages that change in place: pending, done (teal pip), active (inverted, blinking red pip), failed (red).",
"html": "<div class=\"ds-scope\"><ol class=\"ds-plates\"><li data-state=\"done\"><span>MangaBaka</span><em>#9999</em></li><li data-state=\"active\"><span>Archive</span><em>4 / 12</em></li><li><span>Images</span><em></em></li><li data-state=\"failed\"><span>Compare</span><em></em></li></ol></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-plates{list-style:none;margin:0;padding:0;display:grid;} .ds-plates li{display:flex;align-items:center;gap:8px;margin-top:-1px;padding:5px 10px;border:1px solid var(--mb-line);font:700 10.5px var(--mb-sans);letter-spacing:.1em;text-transform:uppercase;color:var(--mb-ink-2);transition:background-color .3s,color .3s;} .ds-plates li span{flex:1;} .ds-plates li em{font:500 11.5px var(--mb-sans);font-style:normal;letter-spacing:0;text-transform:none;font-variant-numeric:tabular-nums;} .ds-plates li::before{content:\"\";width:7px;height:7px;border:1px solid currentColor;flex:none;} .ds-plates li[data-state=\"done\"]{color:var(--mb-ink);} .ds-plates li[data-state=\"done\"]::before{background:var(--mb-teal);border-color:var(--mb-teal);} .ds-plates li[data-state=\"active\"]{background:var(--mb-ink);color:var(--mb-page);} .ds-plates li[data-state=\"active\"]::before{background:var(--mb-obi);border-color:var(--mb-obi);animation:ds-blink 1s steps(2) infinite;} .ds-plates li[data-state=\"failed\"]{background:var(--mb-obi);color:var(--mb-obi-ink);} @keyframes ds-blink{50%{opacity:.3;}} @media (prefers-reduced-motion:reduce){.ds-plates li::before{animation:none!important;}}"
},
{
"name": "Host List",
"kind": "custom",
"refersTo": "host-option",
"description": "Rehost targets as a ruled table of radio rows; the chosen row lifts to slip paper.",
"html": "<div class=\"ds-scope\"><fieldset class=\"ds-hosts\"><label><input type=\"radio\" name=\"h\" checked><span>This server</span><small>30 days</small></label><label><input type=\"radio\" name=\"h\"><span>litterbox</span><small>72 hours</small></label></fieldset></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-hosts{margin:0;padding:0;border:0;display:grid;} .ds-hosts label{display:grid;grid-template-columns:auto 1fr;column-gap:8px;align-items:baseline;padding:6px 8px;margin-top:-1px;border:1px solid var(--mb-line);font-size:13px;cursor:pointer;color:var(--mb-ink-2);} .ds-hosts label:hover{color:var(--mb-ink);} .ds-hosts label:has(input:checked){background:var(--mb-slip);color:var(--mb-ink);font-weight:600;} .ds-hosts span,.ds-hosts small{grid-column:2;} .ds-hosts small{font-weight:400;font-size:11.5px;color:var(--mb-ink-2);} .ds-hosts input{appearance:none;-webkit-appearance:none;width:15px;height:15px;margin:2px 0 0;border:1.5px solid var(--mb-ink);border-radius:50%;background:var(--mb-slip);display:inline-grid;place-content:center;} .ds-hosts input::before{content:\"\";width:7px;height:7px;border-radius:50%;background:var(--mb-ink);transform:scale(0);transition:transform .12s;} .ds-hosts input:checked::before{transform:scale(1);} .ds-hosts input:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;}"
},
{
"name": "Book with Obi Band",
"kind": "card",
"refersTo": "obi-band",
"description": "Signature: a framed cover standing on a kraft rail, with an include tick and an obi band printing its evidence. Add .is-main for the red main-cover band, .is-off for an unselected book.",
"html": "<div class=\"ds-scope\"><div class=\"ds-shelf\"><article class=\"ds-book\"><label class=\"ds-jacket\"><img alt=\"\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23ffffff'/%3E%3C/svg%3E\"><input type=\"checkbox\" class=\"ds-tick\" checked aria-label=\"Include this poster\"></label><div class=\"ds-obi\"><p class=\"ds-obi-role\">Poster</p><p class=\"ds-obi-facts\">510×510 · 20 KB · seen 2015-03</p><div class=\"ds-seen\"><i style=\"left:10%;right:30%\"></i></div></div></article><article class=\"ds-book is-main\"><label class=\"ds-jacket\"><img alt=\"\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23000000'/%3E%3C/svg%3E\"><input type=\"checkbox\" class=\"ds-tick\" checked aria-label=\"Include the main cover\"></label><div class=\"ds-obi\"><p class=\"ds-obi-role\">Main cover</p><p class=\"ds-obi-facts\">480×623 · on the page now</p><div class=\"ds-seen\"><i style=\"left:60%;right:0\"></i></div></div></article></div></div>",
"css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-shelf{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));align-items:end;column-gap:0;row-gap:32px;} .ds-book{display:flex;flex-direction:column;justify-content:flex-end;padding:0 10px;border-bottom:10px solid var(--mb-kraft);box-shadow:0 2px 0 var(--mb-rail);} .ds-jacket{position:relative;display:block;cursor:pointer;} .ds-jacket img{display:block;width:100%;height:auto;max-height:250px;object-fit:contain;object-position:bottom;outline:1px solid var(--mb-frame);outline-offset:-1px;box-shadow:0 8px 14px -8px var(--mb-shadow);transition:filter .25s,opacity .25s,transform .3s cubic-bezier(.16,1,.3,1);} .ds-tick{position:absolute;top:0;left:0;margin:0;width:22px;height:22px;appearance:none;-webkit-appearance:none;background:var(--mb-slip);border:1.5px solid var(--mb-ink);display:grid;place-items:center;cursor:pointer;} .ds-tick::after{content:\"\";width:10px;height:10px;background:var(--mb-ink);transform:scale(0);transition:transform .15s;} .ds-tick:checked::after{transform:scale(1);} .ds-tick:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;} .ds-obi{position:relative;margin-top:-14px;display:grid;gap:4px;padding:6px 8px 8px;background:var(--mb-slip);border:1px solid var(--mb-line);border-top:3px solid var(--mb-ink);font-size:11.5px;} .ds-obi p{margin:0;} .ds-obi-role{font:800 10.5px var(--mb-sans);letter-spacing:.12em;text-transform:uppercase;} .ds-obi-facts{color:var(--mb-ink-2);font-variant-numeric:tabular-nums;} .ds-seen{position:relative;height:5px;background:var(--mb-track);} .ds-seen i{position:absolute;top:0;bottom:0;background:var(--mb-ink);} .ds-book.is-main .ds-obi{background:var(--mb-obi);color:var(--mb-obi-ink);border-color:var(--mb-obi);border-top-color:var(--mb-obi-deep);} .ds-book.is-main .ds-obi-facts{color:var(--mb-obi-ink);} .ds-book.is-main .ds-seen{background:rgba(255,255,255,.3);} .ds-book.is-main .ds-seen i{background:var(--mb-obi-ink);} .ds-book.is-off .ds-jacket img{filter:grayscale(1) contrast(.85);opacity:.5;transform:translateY(8px);} .ds-jacket:hover img{opacity:.92;}"
}
],
"narrative": {
"northStar": "The Obi Shelf",
"overview": "Every cover a series has ever worn stands on one bookshop shelf, each wrapped in an obi band that prints its evidence: role, size, where and when it was seen, how long the archive saw it. The shelf sits inside a working console: a sticky left desk where links are written on paper slips and the run is stamped off, a middle shelf (or, as a second view, an annotated timeline of years), and a sticky right desk for rehosting and filing. The materials are book-jacket paper, sumi ink, obi red, kraft board, and one deep teal for \"done\".\n\nDensity is that of a well-kept tool, not a dashboard: small tracked uppercase labels, printed hairlines, square corners, tabular numerals. Depth comes from the books themselves, which cast a soft shadow onto their rail; nothing else floats except the userscript panel over mangabaka.org. The system refuses the category default (a white card grid of rounded tiles with checkboxes and select boxes) and anything corporate or SaaS-flavoured.\n\nThe whole world is scoped so it can live inside a shadow root on mangabaka.org: every rule hangs off the `mbseed-` scope, there are no webfonts and no external assets, and drawn details (the select chevron) are inline SVG data. Desktop and the userscript panel come first; the narrow, stacked layout exists for the panel, and phone layouts are deferred.",
"keyCharacteristics": [
"Warm paper (light) and warm umber (dark) grounds; never pure black or white.",
"Covers stand on kraft rails, each with an obi band carrying its evidence.",
"Obi red marks the main cover and the two stamps; teal marks done, rehosted, added, and focus.",
"Square corners and printed hairlines; the radio is the only round form.",
"Unselected covers go grayscale and lean back.",
"Two views of one set: Shelf and Timeline."
],
"rules": [
{
"name": "The Warm Ground Rule",
"body": "No ground is ever pure black or white. Every cover carries a 1px inset frame in `cover-frame`, so black- or white-ground art still shows its edge on either scheme.",
"section": "colors"
},
{
"name": "The Red Is a Band Rule",
"body": "Obi red is a fill: the main cover's band, the stamps, failure. It appears once per desk as a stamp and once on the shelf as the main cover. Small type in red on paper or umber does not reach 4.5:1 at the current values, so status and error text should not rely on red alone.",
"section": "colors"
},
{
"name": "The Teal Means Done Rule",
"body": "Teal is the only \"finished/safe\" signal: done plates, progress, rehosted, added, and keyboard focus. Do not use it decoratively.",
"section": "colors"
},
{
"name": "The Printed Label Rule",
"body": "Uppercase tracking is reserved for things printed on an object: slip labels, band roles, plate names, desk section caps, stamps. Running text and headings stay in sentence case.",
"section": "typography"
},
{
"name": "The Tabular Facts Rule",
"body": "Every count, size, date and year uses tabular numerals so columns of facts align on the bands and plates.",
"section": "typography"
},
{
"name": "The Shelf Shadow Rule",
"body": "Only books and the floating panel cast shadows. Cards, buttons, bands, and desks never do.",
"section": "elevation"
}
],
"dos": [
"**Do** frame every cover with the 1px inset `cover-frame` so dark and light art keeps its edge on both grounds.",
"**Do** keep every select boxed with a 2px ink bottom edge and a drawn chevron; on a band, keep the underline and chevron.",
"**Do** label inputs and data source-neutrally (\"Source series link\", \"Series page\"); the source's name comes from data, never from the chrome.",
"**Do** scope every rule under the `mbseed-` scope and use only system font stacks and inline assets, so the same CSS runs in the userscript's shadow root.",
"**Do** reserve obi red for the main cover's band, the stamps, and failure; use teal for done, rehosted, added, and focus.",
"**Do** turn unselected covers grayscale and lower them rather than hiding them.",
"**Do** design for the desktop console and the stacked userscript panel first."
],
"donts": [
"**Don't** use pure black or pure white as a ground in either scheme.",
"**Don't** build a white card grid of rounded, shadowed tiles, or anything that reads as corporate or SaaS.",
"**Don't** round corners; the radio is the only round form.",
"**Don't** add shadows beyond the book cast, the rail lip, and the floating userscript panel.",
"**Don't** name WEBTOON (or any one source) in chrome labels or headings; a source name appears only as data or as an honest note of what is supported today.",
"**Don't** load webfonts, icon fonts, or external scripts.",
"**Don't** set small text in band red (`--mb-obi`): it is 3.9:1 on paper. Small red text (year keys, errors, removed lines, link hover) uses `--mb-obi-text` (#a3261f light, #f2867b dark; 5.5–6.1:1), and never as the only signal of an error or state."
]
}
}
@@ -0,0 +1,20 @@
---
version: 1
slug: "src-web-template-ts"
primary_target: "src/web/template.ts"
related_targets: ["src/web/styles.css","src/web/render.ts"]
---
# Surface brief — the seeder page and userscript panel
Scope: `src/web/` (template.ts, styles.css, render.ts) mounted by the page and, in a shadow root, by the userscript panel on mangabaka.org. Mode: Operate.
Audience: volunteer MangaBaka catalogue editors, at a desk (desktop first; the panel is equally important; mobile later). Job: turn two links into a reviewed set of covers, rehost them, then paste them into MangaBaka or file an edit request.
Constraints: keep every `id` in template.ts (test/template.test.ts); all CSS scoped under `.mbseed-app` / `.mbseed-panel` so it works in a shadow root; no external fonts or scripts; light theme first, dark via prefers-color-scheme; must not feel corporate/SaaS (user, 2026-10-14).
## Direction contract
THESIS: Every cover the series ever wore stands on one shelf, each wrapped in an obi band that carries its evidence. Refuses the category default: a white card grid with checkboxes and select boxes.
OWN-WORLD: Console layout from Graphite Console; shelf and colours from the Obi Shelf; timeline view from Annotated Edition (user's choice, 2026-10-15). Book-jacket paper ground (#efe9df), sumi ink text (#1d1b22), obi red (#d6362f) for the main cover and the primary action, deep teal (#2f6f6a) for rehosted/done state, kraft (#c9b79c) for the shelf rail and inactive bands. Obi bands are full-width paper strips across the lower third of each cover with a printed label face; the shelf is a heavy rail line under each row. Square corners, printed hairlines, no shadows except the one cast by a book on its shelf. Unselected covers turn grayscale and lean back (desaturated, lowered). Dark mode is warm umber (#2a2520), never pure black or white, and every cover has a hairline frame, so black- or white-ground art stays distinguishable (user, 2026-10-15). Labels never assume WEBTOON is the only source.
STORY: The editor pastes two links on the "desk" strip, watches the stage plates flip as the run digs through the archive, sees every cover shelved in time order with its dates, unticks landing art, rehosts, and copies or files.
FIRST VIEWPORT: Three columns (approved round-2 mockup, samples/mockups/6-shelf-console.html, 2026-10-15). Left sticky panel: "MangaBaka series link" and "Source series link" slips, history and rating as visibly editable selects (box + chevron), the red "Find covers" stamp, five stage plates with counts, notes. Middle: series title, Shelf | Timeline switch, main-cover toggle, then the shelf (books on kraft rails, obi bands) or the timeline (newest first round a year rail). Right sticky panel: rehost hosts, Copy all selected, then the token block with the red "File" stamp isolated at the bottom. Under ~1000px the columns stack (userscript panel).
FORM: Tankōbon shelf with obi bands; my rank 4 of 7; seed key c13b5313. Raises: fixed furniture per cover and grayscale when unselected (character goods); seen-range bar to scale on the obi (labanotation); whole-book reflow on a visible rail grid (Crouwel); stage plates change in place (split-flap).
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
+3 -2
View File
@@ -9,8 +9,9 @@ Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TOD
## Commands
TODO(maintainer): confirm commands once tooling exists. Planned (spec §6), none verified yet:
`bun install --frozen-lockfile`, `bun test`, `bun test test/<file>.test.ts`, `bunx tsc --noEmit`.
Verified 2026-10-14 (see `docs/PERMISSIONS.md`):
`bun install --frozen-lockfile` · `bun test` · `bun test test/<file>.test.ts` · `bun run typecheck` ·
`bun run build` (page + userscript into `dist/web/`) · `bun run start` (server; env vars in `docs/ARCHITECTURE.md`).
Use `bun`/`bunx` — never `npm`, `npx`, `node` or `vitest`.
## Workflow
+322 -5
View File
@@ -1,7 +1,324 @@
# Design
---
name: MangaBaka Seeder
description: Every cover a series has worn, standing on one shelf in a three-column console.
colors:
paper-page: "#efe9df"
paper-panel: "#e6dfd2"
paper-slip: "#f8f4ec"
sumi-ink: "#1d1b22"
sumi-ink-muted: "#55505a"
obi-red: "#d6362f"
obi-red-deep: "#8f1d18"
obi-ink: "#fffaf6"
obi-red-text: "#a3261f"
rehost-teal: "#2f6f6a"
kraft-rail: "#b59d78"
rail-lip: "#7d6847"
hairline: "rgba(29, 27, 34, 0.2)"
cover-frame: "rgba(29, 27, 34, 0.35)"
track: "rgba(29, 27, 34, 0.12)"
cast-shadow: "rgba(29, 20, 10, 0.5)"
umber-page: "#2a2520"
umber-panel: "#332d27"
umber-slip: "#3b342d"
umber-ink: "#ece4d6"
umber-ink-muted: "#bdb2a2"
umber-obi-red: "#c42f28"
umber-obi-red-deep: "#8e1f1a"
umber-obi-red-text: "#f2867b"
umber-teal: "#6db3a9"
umber-kraft-rail: "#6e5a3f"
umber-rail-lip: "#4a3c2a"
umber-hairline: "rgba(236, 228, 214, 0.16)"
umber-cover-frame: "rgba(236, 228, 214, 0.35)"
umber-track: "rgba(236, 228, 214, 0.14)"
umber-cast-shadow: "rgba(0, 0, 0, 0.6)"
typography:
display:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "clamp(24px, 3.2cqi, 38px)"
fontWeight: 800
lineHeight: 1.1
letterSpacing: "-0.025em"
headline:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "clamp(22px, 3cqi, 30px)"
fontWeight: 800
letterSpacing: "-0.02em"
title:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "17px"
fontWeight: 800
letterSpacing: "-0.01em"
body:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "14px"
fontWeight: 400
lineHeight: 1.5
body-small:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "13px"
fontWeight: 400
lineHeight: 1.5
band:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "11.5px"
fontWeight: 400
fontFeature: "\"tnum\""
label:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "10.5px"
fontWeight: 700
lineHeight: 1.2
letterSpacing: "0.14em"
stamp:
fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
fontSize: "12.5px"
fontWeight: 800
letterSpacing: "0.14em"
mono:
fontFamily: "ui-monospace, \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace"
fontSize: "11.5px"
lineHeight: 1.45
rounded:
none: "0"
radio: "50%"
spacing:
xs: "4px"
sm: "8px"
md: "12px"
lg: "18px"
xl: "26px"
shelf-row: "32px"
components:
button-outline:
backgroundColor: "transparent"
textColor: "{colors.sumi-ink}"
typography: "{typography.body-small}"
rounded: "{rounded.none}"
padding: "8px 12px"
width: "100%"
button-outline-hover:
backgroundColor: "{colors.sumi-ink}"
textColor: "{colors.paper-page}"
button-stamp:
backgroundColor: "{colors.obi-red}"
textColor: "{colors.obi-ink}"
typography: "{typography.stamp}"
rounded: "{rounded.none}"
padding: "10px 12px"
width: "100%"
button-stamp-hover:
backgroundColor: "{colors.obi-red-deep}"
textColor: "{colors.obi-ink}"
button-quiet:
backgroundColor: "transparent"
textColor: "{colors.sumi-ink}"
padding: "4px"
slip-field:
backgroundColor: "{colors.paper-slip}"
textColor: "{colors.sumi-ink}"
typography: "{typography.body}"
rounded: "{rounded.none}"
padding: "7px 10px 8px"
select:
backgroundColor: "{colors.paper-slip}"
textColor: "{colors.sumi-ink}"
typography: "{typography.body-small}"
rounded: "{rounded.none}"
padding: "4px 26px 4px 9px"
select-hover:
backgroundColor: "{colors.paper-page}"
view-switch:
backgroundColor: "transparent"
textColor: "{colors.sumi-ink}"
padding: "4px 12px"
view-switch-active:
backgroundColor: "{colors.sumi-ink}"
textColor: "{colors.paper-page}"
stage-plate:
backgroundColor: "transparent"
textColor: "{colors.sumi-ink-muted}"
typography: "{typography.label}"
padding: "5px 10px"
stage-plate-active:
backgroundColor: "{colors.sumi-ink}"
textColor: "{colors.paper-page}"
stage-plate-failed:
backgroundColor: "{colors.obi-red}"
textColor: "{colors.obi-ink}"
obi-band:
backgroundColor: "{colors.paper-slip}"
textColor: "{colors.sumi-ink}"
typography: "{typography.band}"
rounded: "{rounded.none}"
padding: "6px 8px 8px"
obi-band-main:
backgroundColor: "{colors.obi-red}"
textColor: "{colors.obi-ink}"
host-option:
backgroundColor: "transparent"
textColor: "{colors.sumi-ink-muted}"
typography: "{typography.body-small}"
padding: "6px 8px"
host-option-selected:
backgroundColor: "{colors.paper-slip}"
textColor: "{colors.sumi-ink}"
---
No UI exists yet, so there is no design system to record.
# Design System: MangaBaka Seeder
TODO(maintainer): establish the visual direction with impeccable when building `src/web/` (the
first UI task), then record tokens, typography and components here from what ships. The
userscript panel lives inside mangabaka.org's pages and should sit comfortably next to them.
## Overview
**Creative North Star: "The Obi Shelf"**
Every cover a series has ever worn stands on one bookshop shelf, each wrapped in an obi band that prints its evidence: role, size, where and when it was seen, how long the archive saw it. The shelf sits inside a working console: a sticky left desk where links are written on paper slips and the run is stamped off, a middle shelf (or, as a second view, an annotated timeline of years), and a sticky right desk for rehosting and filing. The materials are book-jacket paper, sumi ink, obi red, kraft board, and one deep teal for "done".
Density is that of a well-kept tool, not a dashboard: small tracked uppercase labels, printed hairlines, square corners, tabular numerals. Depth comes from the books themselves, which cast a soft shadow onto their rail; nothing else floats except the userscript panel over mangabaka.org. The system refuses the category default (a white card grid of rounded tiles with checkboxes and select boxes) and anything corporate or SaaS-flavoured.
The whole world is scoped so it can live inside a shadow root on mangabaka.org: every rule hangs off the `mbseed-` scope, there are no webfonts and no external assets, and drawn details (the select chevron) are inline SVG data. Desktop and the userscript panel come first; the narrow, stacked layout exists for the panel, and phone layouts are deferred.
**Key Characteristics:**
- Warm paper (light) and warm umber (dark) grounds; never pure black or white.
- Covers stand on kraft rails, each with an obi band carrying its evidence.
- Obi red marks the main cover and the two stamps; teal marks done, rehosted, added, and focus.
- Square corners and printed hairlines; the radio is the only round form.
- Unselected covers go grayscale and lean back.
- Two views of one set: Shelf and Timeline.
## Colors
A bookshop palette: paper and umber grounds, sumi ink, one red band, one teal for completion, kraft for the furniture. Each light token has a dark (umber) counterpart under `prefers-color-scheme: dark`, repeated under `:root[data-theme="dark"]` for the page when Dark Reader is on (`src/web/dark-reader.ts`); `obi-ink` is shared by both schemes.
### Primary
- **Obi Red** (`obi-red`; dark `umber-obi-red`): the band of the main cover, the stamp buttons ("Find covers", "Rehost and file for review"), a failed stage plate, the active plate's blinking pip, the rule above notices, and the main-cover checkbox. Its depth companion **Obi Red Deep** (`obi-red-deep`) is the stamp's hover and the main band's top rule. **Obi Ink** (`obi-ink`) is the only text colour on red.
### Secondary
- **Rehost Teal** (`rehost-teal`; dark `umber-teal`): completion and safety. Done-stage pips, the progress fill, the "Rehosted" mark on a band, added lines in the change preview, and the focus ring.
### Tertiary
- **Kraft Rail** (`kraft-rail`; dark `umber-kraft-rail`): the 10px shelf rail under every book, the note bullets, the side-panel scrollbar. **Rail Lip** (`rail-lip`) is the darker 2px edge under the rail.
### Neutral
- **Book-Jacket Paper** (`paper-page`) / **Warm Umber** (`umber-page`): the page and the middle column.
- **Desk Paper** (`paper-panel` / `umber-panel`): the two side desks, one step darker than the page.
- **Slip Paper** (`paper-slip` / `umber-slip`): raised paper: link slips, obi bands, selects, ticks, the selected host row, text areas.
- **Sumi Ink** (`sumi-ink` / `umber-ink`): text, the 3px rules (brand underline, obi top edge), outline buttons, the active view and plate.
- **Faded Ink** (`sumi-ink-muted` / `umber-ink-muted`): labels, hints, facts on the band, inactive plates and host rows.
- **Hairline** (`hairline`), **Cover Frame** (`cover-frame`), **Track** (`track`): translucent ink for dividers and box edges, the 1px frame on every cover and select box, and the seen-range and progress tracks.
### Named Rules
**The Warm Ground Rule.** No ground is ever pure black or white. Every cover carries a 1px inset frame in `cover-frame`, so black- or white-ground art still shows its edge on either scheme.
**The Red Is a Band Rule.** Obi red is a fill: the main cover's band, the stamps, failure. It appears once per desk as a stamp and once on the shelf as the main cover. Small type in red on paper or umber does not reach 4.5:1 at the current values, so status and error text should not rely on red alone.
**The Teal Means Done Rule.** Teal is the only "finished/safe" signal: done plates, progress, rehosted, added, and keyboard focus. Do not use it decoratively.
## Typography
**Display Font:** the system UI sans stack (no webfonts; the userscript shadow root forbids external fonts)
**Label/Mono Font:** the system monospace stack, for the paste-out text and the catbox userhash
**Character:** One family, hierarchy carried by weight and tracking: heavy, tightly set titles (800, negative tracking) against small, widely tracked uppercase labels that read like print on a band or a stamp.
### Hierarchy
- **Display** (800, clamp 24-38px on container width, 1.1): the series title over the review.
- **Headline** (800, clamp 22-30px): the empty-state statement; balanced wrapping.
- **Title** (800, 17px): the brand line at the top of the left desk, over a 3px ink rule.
- **Body** (400, 14px, 1.5): base text and slip inputs; empty-state paragraphs run at 15px, capped at 62ch.
- **Body Small** (13px): settings, buttons, host rows, status lines; hints step down to 12.5px.
- **Band** (11.5px, tabular numerals): obi band facts, notes, and per-cover fields.
- **Label** (700, 10.5px, 0.14em, uppercase): desk section caps, slip labels (10px, 0.12em), obi role lines (800, 0.12em), stage plates (0.1em).
- **Stamp** (800, 12.5px, 0.14em, uppercase): red stamp buttons only.
### Named Rules
**The Printed Label Rule.** Uppercase tracking is reserved for things printed on an object: slip labels, band roles, plate names, desk section caps, stamps. Running text and headings stay in sentence case.
**The Tabular Facts Rule.** Every count, size, date and year uses tabular numerals so columns of facts align on the bands and plates.
## Layout
A three-column console on a container query, not a viewport query: left desk (250-290px), the shelf (fluid), right desk (260-300px). Both desks are sticky, full-height, and scroll on their own with a thin kraft scrollbar; they are separated from the shelf by hairlines. At a container width of 1000px or less the columns stack (left desk, shelf, right desk), the desks stop sticking, and a 3px ink rule opens the right desk. This stacked form is what the userscript panel shows.
The shelf is a dense auto-fill grid of 150px-minimum slots with no column gap, so rails run continuously across a row; rows sit 32px apart and books align to the rail at their bottom. Wide art (banners) spans two slots, one below 420px. The timeline is a centre double rail with a 64px year column, covers alternating left and right (the right side dropped 46px to stagger); at 760px or less the rail moves to a 56px left gutter and every cover stacks to its right.
Spacing rhythm: 4 / 8 / 12 / 18 / 26 / 32px, with tight 2-6px gaps inside slips and bands. Middle column padding is 18px 26px 56px (16px when stacked); desks are 18px 16px 24px with 14px between blocks. Desktop and panel first; phone layouts are deferred.
## Elevation & Depth
Flat paper with exactly three shadows, each with a physical cause. Everything else separates by hairline, ground step (page, desk, slip), and 3px ink rules.
### Shadow Vocabulary
- **Book cast** (`box-shadow: 0 8px 14px -8px var(--mb-shadow)`): under every cover image, as it stands on its rail.
- **Rail lip** (`box-shadow: 0 2px 0 var(--mb-rail)`): the edge of the kraft rail under each shelved book. It is shelf furniture, not a card shadow.
- **Floating panel** (`box-shadow: 0 18px 40px -18px var(--mb-shadow)`): the userscript panel over mangabaka.org only.
### Named Rules
**The Shelf Shadow Rule.** Only books and the floating panel cast shadows. Cards, buttons, bands, and desks never do.
## Shapes
Square corners throughout (`rounded.none`): slips, bands, buttons, selects, plates, checkboxes, ticks, covers. The one exception is the radio, which is round so single choice reads differently from multiple choice (`rounded.radio`). Edges are printed: 1px hairlines for boxes, 1.5px ink for buttons and checks, 3px ink for rules that open a thing (the brand, the obi's top edge, the stacked right desk). Stacked lists (plates, host rows) overlap their borders by 1px so they print as one ruled table. Inputs are underlined, not boxed: dotted at rest, solid on focus.
## Components
### Buttons
Printed, square, and decisive.
- **Shape:** square, 1.5px ink border, full width of the desk.
- **Outline (default):** transparent with ink text and border; fills ink with page-coloured text on hover (0.15s).
- **Stamp:** obi red fill, obi-ink tracked uppercase; deepens to obi-red-deep on hover. Used for "Find covers" and the final "Rehost and file for review" only, the latter isolated at the bottom of the right desk below a dashed hairline.
- **Quiet:** borderless underlined text for secondary run actions (skip history, cancel).
- **Disabled:** 0.4 opacity, not-allowed cursor.
### Inputs / Fields
- **Slip:** a slip-paper box with a hairline edge holding a tracked label, an underlined (dotted) input, and an optional hint. On focus the underline goes solid ink and the slip's edge turns ink.
- **Select:** always visibly editable: slip-paper box, cover-frame edge, a 2px ink bottom edge, and a drawn SVG chevron. Hover darkens the edge to ink and steps the fill to page.
- **Select on a band:** printed onto the obi: underline and chevron only, boxed on hover or focus. On the red main band the chevron is dropped.
- **Checkbox and radio:** custom, 15px, 1.5px ink edge on slip, filled with an ink square (radio: dot) when checked; the main-cover checkbox fills red.
- **Text area:** slip paper, hairline, mono 11.5px.
### Navigation
- **View switch (Shelf | Timeline):** a segmented pair inside one 1.5px ink border, right-aligned in the review head; the pressed view is ink-filled with page text.
### Stage Plates
A ruled table of five run stages that change in place like a split-flap board: tracked uppercase name, count at the right in tabular numerals, a 7px square pip. Pending is faded ink; done turns ink with a teal pip; active inverts to ink with a blinking red pip; failed fills red. A 3px teal progress line sits under the table.
### Host List
Rehost targets as a ruled table of radio rows (name plus a one-line retention fact). The chosen row lifts to slip paper with full ink and weight 600; a warning line appears only under the chosen row.
### Book and Obi Band (signature)
Each cover is a book: the image (max 250px tall on the shelf, 170px in the timeline, contained, framed, casting the book shadow) with a 22px square include tick in its top-left corner, and an obi band overlapping its foot by 14px. The band is slip paper with a 3px ink top edge and prints: role (tracked uppercase), facts (tabular, " · " separated), a 5px seen-range bar drawn to scale with a teal "Rehosted" mark, the role and rating selects, and an underlined moderator note. The main cover's band is obi red with obi-ink text. An unselected book goes grayscale, fades to half opacity, and drops 8px (on the shelf; it stays put in the timeline).
### Timeline (second view)
The Annotated Edition: a centre double ink rail with year keys set in heavy tabular numerals on a page-coloured knockout, books beside it as image-plus-band pairs.
### History (page only)
- **History list (left desk, under the run):** a ruled list printed like the stage plates (-1px stacked hairlines). Each row: a 30x40 framed thumbnail, the title (700, ink), "time · N new · M on MangaBaka" (tabular, faded ink), and what was done in teal (downloaded, rehosted on X, filed #N), with a square remove cell holding a drawn SVG cross that fills ink on hover. The open entry lifts to slip paper. Focus rings sit inside the rows (the list scrolls). A quiet "Clear history" ends it.
- **Recently searched (start screen):** up to six of the same books on the same kraft rail, smaller (jacket up to 160px), each with a small obi band (3px ink top, slip paper) carrying the title and "time · N new"; the jacket lifts 4px on hover.
- **Found line:** under the review head, "Found <time>." and a quiet "Search again", only for an entry reopened from the history.
### Add by hand (2a)
"Download N covers (ZIP)" is an outline button at the top of the block, with a one-line hint, then the paste text, "Copy all selected", the cover form link and a status line.
### Motion
One authored motion: when a run finishes, books are shelved left to right (rise 14px, 0.55s, `cubic-bezier(0.16, 1, 0.3, 1)`, 28ms stagger capped at 24 books). The active plate's pip blinks in two steps. Everything else is 0.12-0.3s state transitions. `prefers-reduced-motion` removes all animation and transitions.
## Do's and Don'ts
### Do:
- **Do** frame every cover with the 1px inset `cover-frame` so dark and light art keeps its edge on both grounds.
- **Do** keep every select boxed with a 2px ink bottom edge and a drawn chevron; on a band, keep the underline and chevron.
- **Do** label inputs and data source-neutrally ("Source series link", "Series page"); the source's name comes from data, never from the chrome.
- **Do** scope every rule under the `mbseed-` scope and use only system font stacks and inline assets, so the same CSS runs in the userscript's shadow root.
- **Do** reserve obi red for the main cover's band, the stamps, and failure; use teal for done, rehosted, added, and focus.
- **Do** turn unselected covers grayscale and lower them rather than hiding them.
- **Do** design for the desktop console and the stacked userscript panel first.
### Don't:
- **Don't** use pure black or pure white as a ground in either scheme.
- **Don't** build a white card grid of rounded, shadowed tiles, or anything that reads as corporate or SaaS.
- **Don't** round corners; the radio is the only round form.
- **Don't** add shadows beyond the book cast, the rail lip, and the floating userscript panel.
- **Don't** name WEBTOON (or any one source) in chrome labels or headings; a source name appears only as data or as an honest note of what is supported today.
- **Don't** load webfonts, icon fonts, or external scripts.
- **Don't** set small text in band red (`--mb-obi`): it is 3.9:1 on paper. Small red text (year keys, errors, removed lines, link hover) uses `--mb-obi-text` (#a3261f light, #f2867b dark; 5.5–6.1:1), and never as the only signal of an error or state.
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# mangabaka-seeder: the page, the userscript and the Bun server in one image.
# Configure with MBSEED_* environment variables (docs/ARCHITECTURE.md, "Running the server").
FROM oven/bun:1.3.14-slim AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
COPY scripts ./scripts
RUN bun run build:web
FROM oven/bun:1.3.14-slim
ARG BUILD_VERSION=dev
ARG BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder
LABEL org.opencontainers.image.title="mangabaka-seeder" \
org.opencontainers.image.description="Seed WEBTOON covers into MangaBaka cover edit requests" \
org.opencontainers.image.source="${BUILD_REPOSITORY}" \
org.opencontainers.image.revision="${BUILD_VERSION}"
WORKDIR /app
# No runtime dependencies: the server, the page and the userscript build need only Bun.
COPY --from=build /app/package.json ./
COPY --from=build /app/src ./src
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/dist ./dist
# Storage is off unless MBSEED_STORAGE_DIR is set (with MBSEED_PUBLIC_URL); /data/covers is the conventional place.
RUN mkdir -p /data/covers && chown -R bun:bun /data /app/dist
ENV MBSEED_HOST=0.0.0.0 \
MBSEED_PORT=8787 \
MBSEED_ASSETS_DIR=/app/dist/web
USER bun
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["bun", "-e", "fetch('http://127.0.0.1:' + (process.env.MBSEED_PORT ?? 8787) + '/api/config').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
# The userscript is built at start so its @connect list includes MBSEED_PUBLIC_URL's host.
CMD ["sh", "-c", "bun run scripts/build-userscript.ts && exec bun run src/server/bun.ts"]
+59 -6
View File
@@ -4,11 +4,12 @@ A tool for seeding edits to the [MangaBaka](https://mangabaka.org) manga/comics
database, in the spirit of [yambs](https://codeberg.org/derat/yambs) for
MusicBrainz.
> **Status:** designed, not built yet. This README describes the planned
> behaviour; see [the spec](docs/specs/001-webtoons-covers.md).
> **Status:** v0.1 works end to end locally (Tower of God: current and archived
> covers found, checked and rehosted); not deployed yet. Design:
> [the spec](docs/specs/001-webtoons-covers.md).
Today's goal is one thing done well: **given a MangaBaka series link and a
Webtoons series link, find every cover the Webtoons series has used, current
WEBTOON, Tapas or KakaoPage series link, find every cover that series has used, current
and historical, and request them added to the MangaBaka series**. The current
poster becomes the main (default) cover for the edition's language.
@@ -33,7 +34,8 @@ A command-line version (Bun) may follow.
2. **Collect covers.**
- **Current:** the portrait poster, the square thumbnail, and the banner and
hero art.
- **Historical:** cover images in archived copies of the series page on the
- **Historical** (tick "Old covers from the Wayback Machine"; off by default,
it takes minutes): cover images in archived copies of the series page on the
[Wayback Machine](https://web.archive.org), up to one snapshot per month.
3. **Check each image.** It downloads each image to get its size and hash,
classifies it by shape (portrait → `volume`, square → `other`, wide →
@@ -54,11 +56,19 @@ A command-line version (Bun) may follow.
A link must still work when a MangaBaka moderator looks at the request. If
it has expired by then, run the tool again.
6. **Hand it to you.**
- **Without API access (most users):** copy the hosted URLs and paste them
into MangaBaka's cover form for the series (the page links to it).
- **Without API access (most users):** press **Download selected (ZIP)** for
the cover files plus a `covers.txt` with each one's type, rating and note,
and upload them in MangaBaka's cover form for the series (the page links to
it). No rehosting needed. Or rehost them and paste the hosted URLs instead.
- **With a personal access token:** the tool shows MangaBaka's preview diff
and, after you confirm, submits the request for review.
The page keeps a **history** of the series you searched, in your browser: the
covers, your ticks, notes and ratings, and what you did with them (downloaded,
rehosted, filed). Pick one from **History** or the start screen to reopen it
instantly; **Search again** refreshes it and keeps your edits. It holds the 30
latest series (up to 300 MB) and stays in this browser only.
Nothing is uploaded or sent to MangaBaka without your action, and submissions
always go to review. You choose the content rating (default `safe`); titles
Webtoons marks as mature show a warning. Your token is sent only to
@@ -72,6 +82,13 @@ least 25 approved submissions.
| Source | Links | Current covers | Historical covers |
|---|---|---|---|
| WEBTOON (official & canvas) | `https://www.webtoons.com/<lang>/…/list?title_no=N`, `m.webtoons.com` | poster, square, banner, foreground | Wayback Machine |
| KakaoPage (웹툰 & 웹소설) | `https://page.kakao.com/content/N` (or the old `…/home?seriesId=N`) | portrait cover (full size), landscape thumbnail | Wayback Machine |
| Tapas (comics & novels) | `https://tapas.io/series/<name>` (or `…/info`, the series number, or an `/episode/N` link) | portrait cover (full size), banner | Wayback Machine |
Tapas is English only; KakaoPage is Korean. Mature Tapas series are shown only
to signed-in adults: their current covers can't be read (the tool says so and
still searches the archive). KakaoPage 15+ and 19+ titles are read normally and
show the content-rating warning.
Each Webtoons language edition has its own `title_no`. Run the tool once per
edition; the covers are tagged with that edition's language.
@@ -85,6 +102,42 @@ edition; the covers are tagged with that edition's language.
- **Requests are rate-limited** to stay polite to Webtoons, the Wayback Machine
and MangaBaka.
## Running it
Needs [Bun](https://bun.sh) 1.3 or newer.
```sh
bun install --frozen-lockfile
bun run build # the page and the userscript into dist/web/
MBSEED_STORAGE_DIR=/srv/mbseed/covers MBSEED_PUBLIC_URL=https://mbseed.example.org bun run start
```
Open the address it prints. Without `MBSEED_STORAGE_DIR` the server runs without
its own storage and the page offers litterbox, catbox and no rehosting. All
settings, and how to put it behind a reverse proxy, are in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#running-the-server).
### With Docker
Every push to `main` builds an image and publishes it to
`gitea.lucaswinther.info/lucasw89/mangabaka-seeder` (`latest`, the branch name,
`sha-<commit>`, and the version for `v*` tags). Copy
[`docker-compose.example.yml`](docker-compose.example.yml) to `docker-compose.yml`,
set `MBSEED_PUBLIC_URL`, and run `docker compose up -d`. To build it yourself:
`docker build -t mangabaka-seeder .`
The CI workflow (`.gitea/workflows/ci.yml`) publishes with a repository or user
secret `REGISTRY_TOKEN`: a Gitea access token with package write permission
(Gitea's job token cannot publish packages).
### The userscript
Install `/mbseed.user.js` from the running server (or `dist/web/mbseed.user.js`)
in Tampermonkey or Violentmonkey. A **Seed covers** button appears on MangaBaka
series pages. To rehost on your server from the userscript, enter its address
under *Settings* in the panel; build with `MBSEED_PUBLIC_URL` set so the script
is allowed to reach it.
## Development
Working on this repo, by hand or with an AI agent: start at
+66
View File
@@ -0,0 +1,66 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "mangabaka-seeder",
"devDependencies": {
"@types/bun": "^1.4.3",
"typescript": "^7.0.2",
},
},
},
"packages": {
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"typescript": ["[email protected]", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici-types": ["[email protected]", "", {}, "sha512-nCiEcIx87oMw9u3Yzj2DuRBzv6p0e7akhy4HUPtpBiVWmAY3o6Phznst8QhmY1oLMttLgJ2tlAvsKuzCgBSoAw=="],
"@types/node/undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
}
}
+26
View File
@@ -0,0 +1,26 @@
# Example: run mangabaka-seeder behind your reverse proxy.
# Copy to docker-compose.yml, set MBSEED_PUBLIC_URL to the address your proxy serves, then:
# docker compose up -d
# All settings: docs/ARCHITECTURE.md, "Running the server".
services:
mangabaka-seeder:
image: gitea.lucaswinther.info/lucasw89/mangabaka-seeder:latest
restart: unless-stopped
# Only the reverse proxy should reach it; it terminates TLS and appends X-Forwarded-For.
ports:
- "127.0.0.1:8787:8787"
environment:
# Public origin (no path). Stored covers are served from it, and the userscript may connect to it.
MBSEED_PUBLIC_URL: https://mbseed.example.org
# Rehost covers on this server for 30 days. Remove both lines to run without storage
# (the page then offers litterbox, catbox and no rehosting).
MBSEED_STORAGE_DIR: /data/covers
MBSEED_STORAGE_MAX_BYTES: "1073741824" # 1 GiB; uploads past it get 507, nothing is evicted early
# Rate-limit on the address your proxy appends to X-Forwarded-For. Leave unset without a proxy.
MBSEED_TRUST_PROXY: "1"
volumes:
# A named volume works as is. For a bind mount, make the directory writable by uid 1000 (the image's "bun" user).
- covers:/data/covers
volumes:
covers:
+44 -8
View File
@@ -3,24 +3,56 @@
How `mangabaka-seeder` is built today. Update in the same commit as any structural change.
## Overview
No code exists yet. The design is in [`specs/001-webtoons-covers.md`](specs/001-webtoons-covers.md):
v0.1 is built: the core (`src/core/`), the self-hosted server, the page and the userscript. `pipeline.ts` composes the stages of spec §3; `session.ts` holds the review state both front ends share; the page and the userscript mount the same markup (`src/web/template.ts`) and wiring (`src/web/app.ts`). The design is in [`specs/001-webtoons-covers.md`](specs/001-webtoons-covers.md):
one pure TypeScript core (`src/core/`) that turns a MangaBaka series link and a Webtoons series
link into a cover submission, used by three front ends — a static page served by a small
self-hosted Bun server (primary), a userscript, and later a Bun CLI. Replace this section with reality as modules land.
self-hosted Bun server (primary), a userscript, and later a Bun CLI.
## Map (planned — spec §4)
## Map
| Path | Responsibility |
|---|---|
| `src/core/` | Pure pipeline: sources, Wayback history, image inspection, dedupe, seed, MangaBaka client. No DOM, Bun, Node or `GM_*` APIs. |
| `src/web/` | Static page UI. |
| `src/server/` | Web-standard `fetch` handler (`app.ts`) run by `bun.ts` (`Bun.serve`, behind our reverse proxy). Static assets, `/api/fetch` allowlisted proxy, `/api/upload`, `/covers/*` from the storage folder (30-day sweep, total size cap). Gate and Workers entry deferred. |
| `src/userscript/` | Userscript on mangabaka.org; network via `GM_xmlhttpRequest`. |
| `src/core/session.ts` | Review state shared by the page and the userscript: cards, main cover, rehosting (cards remember host, cache key and expiry), manual text, the download bundle, `restore` from history, token flow (preview → confirm → save for review). |
| `src/core/zip.ts`, `download.ts` | Store-only ZIP writer (CRC-32, UTF-8 names); the download bundle: file names `NN_role_lang[_YYYY]_WxH.ext` and `covers.txt` (spec 002 §2). |
| `src/core/saved.ts` | History model (spec 002 §3): `SavedSearch` (plain data, source by id), `toSaved`/`fromSaved` (drops expired links), `carryEdits` (search again), `pruneIds` (30 entries, 300 MB), `HistoryStore` interface and an in-memory one. |
| `src/core/sources/` | `webtoons/`, `tapas/` (spec 003) and `kakaopage/` (spec 004; reads a JSON endpoint, not the page): each `urls.ts` (links, image URL normalisation, same-series), `parse.ts` (pure HTML parsing, also used on Wayback snapshots), `index.ts` (the `Source`); `registry.ts` lists them and builds the proxy and upload allowlists. |
| `src/web/` | Built. The page ("Obi Shelf" design, see `DESIGN.md`): `template.ts` markup shared with the userscript panel, `app.ts` wiring (`mountApp`), `render.ts` shelf and timeline views, `view-model.ts` pure helpers, `transport.ts` (direct to MangaBaka/litterbox, everything else via `/api/fetch`), `styles.css` scoped for a shadow root, `save-file.ts` (Blob download, works in the panel's shadow root), `history-db.ts` (IndexedDB `HistoryStore`; null when storage is refused), `history-keeper.ts` (when the series on screen is saved: after a search, debounced edits, marks; flushes before a new search, an open and page hide; removal/clear stop tracking), `recent.ts` (History list and the start screen's recent shelf; the page only), `dark-reader.ts` (switches to the page's own dark theme and locks Dark Reader out when that extension is darkening the page). |
| `src/server/` | Built. `app.ts`: web-standard handler (`createApp(env)`) — static assets, `/api/config`, `/api/fetch` allowlisted proxy (redirects re-checked, 20 MB cap), `/api/upload` (store or catbox; takes a source URL, never bytes), `/covers/*`. `storage.ts` (interface, in-memory) and `storage-dir.ts` (folder, size cap, 30-day sweep). `config.ts` (env, client IP), `rate-limit.ts`, `assets-path.ts`. `bun.ts`: thin `Bun.serve` entry. Gate and Workers entry deferred. |
| `src/userscript/` | Built. `gm.ts`: `GM_xmlhttpRequest` transport (no proxy; honest User-Agent for Wayback) and GM storage. `main.ts`: a "Seed covers" launcher on MangaBaka series pages (follows client-side navigation) opening the page's app in a shadow-root panel; optional server URL in its settings. Built by `scripts/build-userscript.ts` into `dist/web/mbseed.user.js`, served at `/mbseed.user.js`. |
| `src/cli/` | Later: Bun CLI. |
| `test/fixtures/` | Saved real responses; tests never hit the network. |
## Data flow
Links → series id + `source.resolve` → current + Wayback candidates → inspect (bytes, size,
sha256) → dedupe against MangaBaka → seed → user reviews → rehost (explicit) → submit or manual entry.
`runCovers` (pipeline.ts): links → MangaBaka series, links and images (direct) → `source.resolve` and
`currentCovers` → `waybackHistory` (optional) → `inspectAll` (bytes, size, sha256, shape) → `dedupeByHash`
→ `splitExisting` against MangaBaka → `pickDefault`. `Session` (session.ts) turns that into review cards; the
user edits them, then `bundle` → ZIP download, or `rehost` (explicit) → `manualText` for the cover form, or
`preview` → confirm → `submit` (`save_mode: "review"`). On the page each search is saved to IndexedDB
(`toSaved` → `saveAndPrune`) after the run, edits, rehosting, download and filing; opening an entry runs
`fromSaved` → `Session.restore`, and a new search of a saved series runs `carryEdits`.
## Running the server
`bun run build` then `bun run start`. Environment:
| Variable | Default | Meaning |
|---|---|---|
| `MBSEED_PORT` | `8787` | Listen port. |
| `MBSEED_HOST` | `127.0.0.1` | Listen address (behind the reverse proxy). |
| `MBSEED_PUBLIC_URL` | — | Public origin, e.g. `https://mbseed.example.org`; required with storage (stored covers are served from it). |
| `MBSEED_STORAGE_DIR` | — | Covers folder; unset = no server storage. |
| `MBSEED_STORAGE_MAX_BYTES` | `1073741824` | Folder size cap; uploads past it get 507. |
| `MBSEED_TRUST_PROXY` | unset | `1` = rate-limit on the last `X-Forwarded-For` entry. |
| `MBSEED_ASSETS_DIR` | `dist/web` | Built page and userscript. |
Put it behind a reverse proxy that terminates TLS and **appends** the client address to
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, Caddy's default), then set `MBSEED_TRUST_PROXY=1`.
**Docker.** `Dockerfile` builds the page in one stage and runs the server as user `bun` (uid 1000) on
`0.0.0.0:8787`; `MBSEED_ASSETS_DIR=/app/dist/web`. Storage stays off unless `MBSEED_STORAGE_DIR` is set
(`/data/covers` exists for it). The container builds the userscript at start, so its `@connect` list
includes `MBSEED_PUBLIC_URL`'s host. `bun.ts` stops on SIGTERM/SIGINT (Bun as PID 1 ignores them otherwise).
`.gitea/workflows/ci.yml` typechecks, tests and builds, then builds and pushes the image (not for pull
requests) and smoke-tests it. Example deployment: `docker-compose.example.yml`.
## External dependencies
| Service | Reached via | If unavailable |
@@ -29,6 +61,8 @@ sha256) → dedupe against MangaBaka → seed → user reviews → rehost (expli
| Webtoons HTML / `swebtoon-phinf.pstatic.net` | server proxy or `GM_xmlhttpRequest` | Nothing works. |
| Wayback (`web.archive.org`) | same | Warning; current covers only. |
| Server storage (folder on our server) | server | `server` host hidden; litterbox/catbox/direct still work. |
| KakaoPage (`bff-page.kakao.com`, images on `page-images.kakaoentcdn.com`) | server proxy or `GM_xmlhttpRequest`, with a `page.kakao.com` Referer (`requestHeadersFor`) | That source fails. |
| Shynet (`analytics.lucaswinther.info`) | `<script>` on the page only | No page-view counts; nothing else changes. |
| litterbox / catbox | browser directly (litterbox), server or `GM_xmlhttpRequest` (catbox) | That host fails; others still work. |
## Invariants
@@ -38,6 +72,8 @@ sha256) → dedupe against MangaBaka → seed → user reviews → rehost (expli
no file is deleted before its 30 days.
- Nothing is uploaded or submitted without an explicit user action; saves use `save_mode: "review"`.
- `/api/fetch` only fetches allowlisted hosts; `/api/upload` only takes a source URL, never bytes.
- Proxied bodies are inert (`application/octet-stream`, sandbox CSP, attachment), and `/api/*` fetch/upload need
the `x-mbseed: 1` header; `/api/upload` only rehosts from source image hosts or their Wayback copies.
## Known rough edges
See the open questions in spec §7 and [`TODO.md`](TODO.md).
+160
View File
@@ -16,6 +16,166 @@ the repo every session — measurably slower and more tokens).
**Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working
artefacts: kept after merge they go stale and contradict the code, so the spec is the record.
## 2026-10-15 — KakaoPage source; per-host request headers (spec 004)
**Status:** accepted
**Context:** The user asked for KakaoPage next. Its pages render in the browser; the data comes from
`bff-page.kakao.com/api/gateway/api/v1/content/overview`, which answers 403 without a `page.kakao.com` Referer.
**Decision:** A third `Source` that reads that endpoint for current covers (`thumbnail` portrait, `land_thumbnail`
landscape; originals via `filename=o1`) and archived server-rendered pages (both the old `/home?seriesId=` and
`/content/` addresses) for history. `core/transport.ts` gains `requestHeadersFor(url)` (User-Agent as before, plus
a Referer for `bff-page.kakao.com`); the server proxy and the userscript send it. `age_grade >= 15` sets `mature`
(the rating warning). Kakao Webtoon (`webtoon.kakao.com`) is a separate site and not included.
**Alternatives:** Rendering the page in a headless browser (heavy, server-only); reading the JS bundle's API
calls at run time (brittle).
**Consequences:** We depend on an internal API and its Referer check. A Referer is a browser-like header, as the
Chrome User-Agent is for Webtoons.
## 2026-10-15 — Correction: the userscript does not send Tapas cookies
**Status:** accepted (corrects "Tapas source (spec 003)" below)
**Context:** The Tapas entry and spec 003 said a signed-in adult gets mature Tapas pages through the userscript.
`gmTransport` uses `anonymous: true`, so no cookies are sent.
**Decision:** The docs and the warning now say only the Wayback history helps for age-checked Tapas series.
Sending cookies would need `anonymous: false` for tapas.io only; deferred (TODO).
**Consequences:** None in code; the earlier claim was never true.
## 2026-10-15 — Page views via our Shynet instance
**Status:** accepted
**Context:** The user asked for analytics from their self-hosted Shynet.
**Decision:** Shynet's snippet (deferred script plus `<noscript>` pixel) at the end of the page's `<body>`
(`src/web/index.html`). The userscript panel is not tracked: mangabaka.org isn't ours to measure.
**Alternatives:** none considered; the user named the tool.
**Consequences:** A third-party script runs on the page's origin, so it could read `localStorage`, including a
MangaBaka token saved with "Remember on this device". It is the user's own instance; anyone pointing the page at
another analytics host must keep that in mind.
## 2026-10-15 — Tapas source (spec 003)
**Status:** accepted
**Context:** The user asked for tapas.io next; comics and novels (chosen in chat).
**Decision:** A second `Source` beside Webtoons. The series is read from `/series/<slug>/info` (the plain
`/series/<slug>` is the episode reader); id and episode links resolve to it. Covers: the `.thumb-wrapper`
portrait with its size suffix removed (960×1440 original) and the `og:image` banner. Image URLs are normalised
to `us-a.tapas.io` (old pages name a CloudFront host that serves the same paths). Mature series sit behind an
age check that carries no data: they resolve from the check's `from` link as mature with no current covers and
a warning; we don't fake an age or a session. In the userscript the user's own Tapas cookies go along, so a
signed-in adult gets the real page. The same-series check compares the exact slug or id, which also keeps
`<slug>-novel` captures out of the Wayback history.
**Alternatives:** Tapas' episode JSON endpoint (no series images); getting past the age check (not ours to do).
**Consequences:** On the page, mature Tapas series only get archived covers.
## 2026-10-15 — Download as a ZIP, and a history in IndexedDB (spec 002)
**Status:** accepted
**Context:** First user review: contributors without a token want the cover files to upload by hand, and a
way back to series they searched before. Design approved in chat (instant restore; history on the page only;
one ZIP).
**Decision:**
- Our own store-only ZIP writer (`core/zip.ts`, ~100 lines): covers don't compress, and it avoids a
dependency. Downloads go through a Blob URL and `<a download>`; this works in the userscript's shadow root
on mangabaka.org (checked in Chrome under the site's CSP), so `GM_download` isn't needed.
- History lives in IndexedDB on the page's origin (`summaries` for the list, `searches` with the bytes),
one entry per MangaBaka series, 30 entries / 300 MB, newest always kept. Stored as plain data with the
source by id (and its name for the list). Expired litterbox/server links are dropped on restore; cards
record `hosting` (host, cache key, expiry) when rehosted.
- Searching a saved series again carries the user's edits over by SHA-256, and keeps the entry's marks.
**Alternatives:** A ZIP library (fflate) — a new dependency for ~100 lines; history in localStorage (5 MB,
strings) or re-running the search on open (minutes with Wayback); history on our server (privacy, accounts).
**Consequences:** History is per browser and lost with site data. Old entries can show covers MangaBaka has
since gained; filing always fetches a fresh snapshot, and the "Found …" line shows the age.
## 2026-10-15 — Wayback history starts off
**Status:** accepted (amends spec 001 §4.9 "include history (on)")
**Context:** First user review: a Wayback run takes minutes at the 10 s pace, while the current covers
come back in seconds.
**Decision:** "Old covers from the Wayback Machine" is unticked by default; the granularity stays 6 months.
**Alternatives:** Keep it on and rely on "Skip the rest of the history".
**Consequences:** Historical covers need one extra click.
## 2026-10-15 — Use our own dark theme when Dark Reader is on
**Status:** accepted
**Context:** With a light system theme the page is light, so the Dark Reader extension restyled it with
its generated colours instead of the designed umber theme (the user reported Dark Reader did not see it as
a dark site). Dark Reader marks `<html data-darkreader-scheme="dark">` while active and removes its styles
when `<meta name="darkreader-lock">` appears, even after load (read in its source, 2026-10-15).
**Decision:** `src/web/dark-reader.ts` watches for that mark; on `dark` it sets `<html data-theme="dark">`
(which turns on a copy of the dark tokens in `styles.css`) and adds the lock. One-way for the page's
lifetime; the `dimmed` (light) scheme is ignored. A test keeps the two dark token blocks identical.
The userscript panel is untouched: the rule cannot match inside its shadow root.
**Alternatives:** A static `darkreader-lock` (Dark Reader users on a light system would get the light
page); a manual theme toggle (not asked for).
**Consequences:** The dark tokens exist twice in `styles.css`. Turning Dark Reader off keeps the page
dark until reload. Relies on Dark Reader's attribute name, which is not a documented API.
## 2026-10-15 — Docker image built by Gitea CI
**Status:** accepted
**Context:** The user wants a Dockerfile that Gitea builds automatically, plus a docker-compose example,
mirroring the working workflow in their `budget2.0` repository.
**Decision:** Two-stage image on `oven/bun:1.3.14-slim` (no runtime dependencies, so only `src`, `scripts`
and the built page are copied). It runs as `bun`, listens on `0.0.0.0:8787`, and has a healthcheck on
`/api/config`. Storage is off by default; the compose example turns it on in a named volume at `/data/covers`.
The userscript is built when the container starts, not in the image, because its `@connect` list depends on
`MBSEED_PUBLIC_URL`, which is only known at run time. `bun.ts` handles SIGTERM/SIGINT: without it Bun as
PID 1 ignored `docker stop` and was killed after 10 s. The workflow copies budget2.0: tests first, then
Buildx, push to the Gitea registry with a `REGISTRY_TOKEN` secret (a package-write token; Gitea's job
token cannot publish packages), and a read-only smoke test of the pushed image.
**Alternatives:** Building the userscript in the image (wrong `@connect` for every deployment);
a distroless or compiled-binary image (smaller, but harder to debug, and Bun's slim image is enough).
**Consequences:** The `REGISTRY_TOKEN` secret must exist in Gitea before the image job can push. Starting
the container takes about a second longer for the userscript build.
## 2026-10-15 — Harden the server proxy and uploads (final review)
**Status:** accepted (amends "Implementation details: server API" below)
**Context:** The whole-branch review found that `/api/fetch` passed the upstream content type through. Anyone can
archive an HTML page on Wayback and link a user to our `/api/fetch?url=…` for it: the script would run on our origin
and could read a MangaBaka token saved with "remember on this device". `/api/upload` also accepted any Wayback URL
and any bytes starting with `GIF`, so our domain could be used as a 30-day file drop and the store filled.
**Decision:**
- Proxied bodies are always `application/octet-stream` with `nosniff`, `Content-Security-Policy: sandbox;
default-src 'none'` and `Content-Disposition: attachment`. Nothing in our clients reads the content type.
- `/api/fetch` and `/api/upload` require the header `x-mbseed: 1`, which our page and userscript send and a link or
form cannot.
- `/api/upload` rehosts only from a source's image hosts (`Source.imageHosts`) or Wayback's copy of one, and only
images 1–10 000 px a side.
- The origin check compares hosts, so `https://host` passes behind a TLS proxy that Bun sees as `http://host`.
**Alternatives:** A session cookie or the password gate (deferred, spec §4.11); serving the page and the proxy on
different origins (more deployment work).
**Consequences:** A new source must list its image hosts. Clients other than ours must send `x-mbseed: 1`.
## 2026-10-14 — Implementation details: server API, Webtoons fetch forms
**Status:** accepted
**Context:** Decisions made while implementing plan 001 that the spec leaves open.
**Decision:**
- `createApp(env)` returns the handler (spec §4.10 says `handle(req, env)`) so rate-limit state is bound once.
- `/api/*` accepts `Origin: https://mangabaka.org` besides our own, for the userscript.
- Behind the proxy, the rate limiter keys on the **last** `X-Forwarded-For` entry; earlier ones come from the client.
- `/api/fetch` buffers responses up to 20 MB instead of streaming: a simple cap; pages and covers are far smaller.
- Webtoons is fetched as `/-/-/-/list?title_no=N` (official) or `/-/canvas/-/list?title_no=N` (canvas); the wrong form is a 404, so the other is tried. A made-up slug made Webtoons hang, so the user's own path is never fetched.
- Test fixtures are marked `-text` in `.gitattributes` to stay byte-exact.
**Alternatives:** Stream `/api/fetch` (needs a counting stream for the cap); trust the first `X-Forwarded-For` entry (spoofable).
**Consequences:** Reverse proxies must append to `X-Forwarded-For` (see `docs/ARCHITECTURE.md`).
## 2026-10-14 — Spec amendments from testing the real services
**Status:** accepted (amends spec 001 §2.2–§4.5)
**Context:** Before planning, we parsed real pages and downloaded every cover for Tower of God
(current + Wayback every 6 months, 2015–2026) and the canvas example; the user reviewed them in
`samples/` (gitignored). Findings: Wayback answered `429` "suspected abusive bot traffic" to a fake
Chrome User-Agent but nothing at all to an honest one at one request per 10 s; most historical
`og:image`s are character or landing-page art, not covers; MangaBaka requires exactly one
`is_default` per series; the public images endpoint is paginated and lacks `is_default`.
**Decision:**
- History granularity (every 1 / 3 / 6 / 12 months, default 6) over the span of captures, one
snapshot per bucket — the user's idea, replacing "at most 60 snapshots".
- Wayback gets `mangabaka-seeder/<version> (+repo URL)` as User-Agent and one request per 10 s; a
snapshot that fails after retries stops the history and keeps partial results.
- Images taller than 1.6 : 1 are "landing-page art": type `other`, not selected by default.
- "Make this the series' main cover" is a checkbox, on by default, warning that it replaces the
main cover for every language.
- Retries only for `GET`/`HEAD`, never `POST`.
**Alternatives:** A fixed snapshot limit (arbitrary; either misses years or wastes requests);
spoofing a browser more convincingly (dishonest, and the honest UA simply works); dropping tall
art entirely (it is genuine Webtoons art some editors may want).
**Consequences:** A default run costs ~4–5 minutes of Wayback time, so the UI needs progress and
cancel. Our server makes every user's Wayback requests from one IP — if that gets blocked, the
userscript (user's own IP) still works.
## 2026-10-14 — Size cap on the covers folder; refuse when full, never evict
**Status:** accepted (extends "Server storage is a plain folder" below)
**Context:** The user asked for storage limits on the local folder. The site is open (no gate), so
+8 -7
View File
@@ -12,22 +12,23 @@ Run in this session. Only `worked` rows may appear in a tier, a tool file, or `A
|---|---|
| `git status`, `git log`, `git diff`, `git mv` | worked |
| `bun --version` | worked (1.3.14) |
| `bun test` | failed — no test files yet (no `package.json`, no code) |
| `bun install --frozen-lockfile` | not run — no `package.json` / `bun.lock` yet |
| `bunx tsc --noEmit` | not run — `typescript` not installed yet |
TODO(maintainer): confirm every row above that is not worked, once the Bun scaffold exists, then
add the verified ones to Allow here and in each tool file.
| `bun install --frozen-lockfile` | worked |
| `bun test` | worked |
| `bun run typecheck` (`tsc --noEmit`) | worked |
| `bun run build` (`build:web`, `build:userscript`) | worked |
| `bun run start` | worked (local smoke run) |
## Tiers
| Tier | Commands | Why |
|---|---|---|
| Allow | `git status`, `git diff`, `git log`, `git show`, `git branch --list`, `git rev-parse`, `git add`, `git commit` | Inspection and the dev loop; no prompt needed. |
| Allow | `git status`, `git diff`, `git log`, `git show`, `git branch --list`, `git rev-parse`, `git add`, `git commit`; `bun install --frozen-lockfile`, `bun test`, `bun run typecheck`, `bun run build` | Inspection and the dev loop; no prompt needed. |
| Ask | `git push`, `git merge`, `git rebase`, `git switch`, `git restore`, `git commit --amend`, `rm` (any); `bun add`, `bun remove`, `bun update`, `bun upgrade`; `bunx`, `npx` (download and run code, incl. `wrangler deploy`) | Leaves the machine, rewrites work, changes dependencies, or deploys — a human decides. |
| Deny | `git push --force` / `-f` / `--force-with-lease`, `git reset --hard`, `git clean`, `git add -f` / `--force`, `git commit --no-verify` / `-n`, `rm -r` / `-R` / `-rf` / `-fr` / `-Rf` / `-fR` / `--recursive`, `sudo`; reading `.env`, `.env.*`, `.dev.vars`, `secrets/**` | Destroys work, skips checks, or exposes secrets. |
## Known gaps
- `bun run start` stays out of Allow: it opens a port and fetches from the internet, so it prompts.
- Codex: the allowed bun commands need no rule — they run inside the `workspace-write` sandbox.
- Allowlists save prompts; they are not a security boundary. Use the tool's sandbox for isolation.
- Prefix rules are bypassable: `git -C dir push`, flags placed later (`git push origin main --force`),
`+refspec` pushes. Claude Code's `:*` matches at a word boundary, so each flag cluster must be
+44 -26
View File
@@ -5,34 +5,30 @@ Small, specific, independently actionable. Remove only when actually done.
Design: [specs/001-webtoons-covers.md](specs/001-webtoons-covers.md).
## Now
- [ ] Write the implementation plan with `superpowers:writing-plans` into `docs/plans/001-webtoons-covers.md`,
and **show it to the user before implementing** (the user asked for this in the design session).
- [ ] **User smoke test** in a real browser: the page (local server) and the userscript in Tampermonkey or
Violentmonkey on a MangaBaka series page — Tower of God (9999 / title_no 95). The agent ran both in
headless Chrome (the userscript with a GM shim); a real GM install is still untested.
- [ ] **Gitea secret `REGISTRY_TOKEN`** (needs the user): an access token with package write permission, as a
repository or user secret, so CI can push the image. Then check the first run of `.gitea/workflows/ci.yml`.
- [ ] **Deploy** (needs the user): which server and domain (`MBSEED_PUBLIC_URL`), where the covers folder lives,
whether the 1 GiB cap suits the disk, reverse proxy with TLS that appends `X-Forwarded-For`.
## Next (rough order; the plan will refine it)
- [ ] **Scaffold with Bun.** `package.json`, `bun.lock`, TypeScript, `.gitignore` (already exists).
Scripts for test, typecheck, build (page, Worker, userscript). Then fill the Commands in
`AGENTS.md` and the Verified commands in `docs/PERMISSIONS.md` from runs that worked.
- [ ] **Capture fixtures from real responses:** Webtoons pages (Tower of God, crow-time canvas,
title_no 6065 mature, a 2015 Wayback snapshot), Wayback CDX output, MangaBaka
`series`, `links`, `images` for 9999, and a snapshot/preview if a token is available.
- [ ] **Re-download the OpenAPI spec** from `https://mangabaka.org/api.json`; derive
`core/mangabaka/types.ts` (`SaveSubmissionBody`, series-images snapshot and preview,
`V1_Series_Cover_Image`).
- [ ] `core/`: model, transport (rate limit, retries, timeouts, token scoping), links, sources +
registry, webtoons (urls, parse, index), history/wayback, images, covers, mangabaka client,
hosting (server, litterbox, catbox, direct), seed.
- [ ] `server/`: `app.ts` (assets, `/api/config`, `/api/fetch` allowlist, `/api/upload` store|catbox,
`/covers/*`, per-IP rate limit), `Storage` + folder storage with a daily 30-day sweep and a total size cap (507 when full),
entry `bun.ts`.
- [ ] `web/`: two-link form, review cards, host picker with lifetimes, rehost, copy,
token flow (preview → confirm → save).
- [ ] `userscript/`: panel on mangabaka.org series pages, `GM_xmlhttpRequest` transport, token in
`GM_setValue`, direct litterbox/catbox uploads, optional server URL,
build to `mbseed.user.js` with metadata banner.
- [ ] Manual smoke test against Tower of God (9999 / title_no 95).
- [ ] Update README, `docs/ARCHITECTURE.md` and `PRODUCT.md`/`DESIGN.md` to match reality.
## Next
- [ ] Capture MangaBaka snapshot / preview / save responses once someone with API access runs the token flow;
replace the inline OpenAPI-shaped bodies in `test/mangabaka-client.test.ts` and `test/session.test.ts`.
- [ ] Check whether MangaBaka's `hashes.sha256` matches the original bytes (spec §7.7): compare a cover we
submitted with its hash on MangaBaka. If it doesn't, "already on MangaBaka" never matches.
- [ ] Userscript: fill MangaBaka's cover form for users without a token (spec §4.9) — needs the logged-in
form's markup and a check that it accepts programmatic input (spec §7.5). The panel offers copy-and-paste instead.
- [ ] Small fixes from the final review (deferred as minor): a malformed `Location` on a proxied redirect gives 500
instead of 502; cap the `/api/upload` JSON body size (`maxRequestBodySize`); after Preview, re-run `draw()`
so the button respects an unconfirmed link mismatch and the counts refresh after a partial upload;
label the "direct" host's URL as "Source URL", not "Rehosted".
- [ ] Mobile layout (the user deferred it, 2026-10-15): the page stacks under 1000px but was only checked at 760px.
## Verify with real services (once possible)
- [ ] Download from the userscript panel in **Firefox** with Tampermonkey/Violentmonkey (checked in Chrome only);
if the Blob link is blocked there, add `GM_download` (spec 002 §6).
- [ ] Which of our servers and domain hosts it (`MBSEED_PUBLIC_URL`); where the covers folder lives.
- [ ] Does catbox accept uploads from our server?
- [ ] Does MangaBaka accept our storage URLs, litterbox/catbox URLs, and
@@ -42,6 +38,27 @@ Design: [specs/001-webtoons-covers.md](specs/001-webtoons-covers.md).
- [ ] Where are personal access tokens created in the MangaBaka UI? Add it to the README.
- [ ] Does the MangaBaka cover form accept fields filled by a userscript?
- [ ] Small fixes from the spec 002 review (deferred as minor): Search again should use the stored inputs, not the
fields; a prune failure after a good save reports "couldn't be saved"; ZIP times are UTC, not local; accented
titles lose letters in the ZIP name (Pokémon → pok-mon); covers.txt shows the date, not the time; the
"rehosted" mark says "rehosted on direct" for no rehosting and outlives expired links; `openHistory` reads
`globalThis.indexedDB` outside its `try`.
- [ ] Small Tapas fixes from the spec 003 review (deferred as minor): a non-series tapas.io link gets the generic
"No supported source" error instead of "isn't a series"; an episode link to a mature series says it can't
find the series (check `ageGateTarget` there); a mature series pasted by slug keeps the slug as its id, so a
MangaBaka link by number warns; an all-digit slug is read as an id; accept a quoted `pageMeta` id; archived
history covers fall back to the 400×600 `_z` copy when the original is gone.
- [ ] Mature Tapas series in the userscript: send the user's tapas.io cookies (`anonymous: false` for tapas.io only), then check that a signed-in adult's info page parses.
- [ ] Small KakaoPage fixes from the spec 004 review (deferred as minor): 2020 archive fallbacks point at the new-host
`o1` URL instead of the `dn-img-page…th2` one the page used (only matters once a key is deleted); accept `-`/`_`
in image keys; use a `Map` for the Referer table; zero-padded ids fail; translate `api_content_not_found`
("KakaoPage has no series N") instead of showing the Korean message.
- [ ] Check in Tampermonkey/Violentmonkey that the userscript's Referer reaches `bff-page.kakao.com` (only checked
through the server proxy in Chrome).
- [ ] Possible race (unconfirmed): pressing **Find covers** before the page has mounted (it awaits `/api/config`
and IndexedDB first) may submit the form natively and reload. Seen once in a scripted run; not reproduced.
## Someday
- [ ] S3-compatible bucket as a `Storage` option, if a single server's disk stops being enough.
- [ ] Password gate (spec §4.11) — only if the open site gets abused or traffic grows too large.
@@ -65,4 +82,5 @@ Design: [specs/001-webtoons-covers.md](specs/001-webtoons-covers.md).
file, and compare steps and token use.
## Known issues
- None yet (no code).
- Wayback answers vary run to run: the same Tower of God run found 17 or 18 covers (a capture month
can be missing from one index response). Rerunning is the remedy; nothing is lost.
+89 -25
View File
@@ -27,6 +27,10 @@ Signed off by the user 2026-10-14, with two changes applied here: the server
is **self-hosted** (Bun), and the **password gate is deferred** until abuse or
traffic calls for it (§4.11 keeps the design). The Cloudflare entry is
deferred too; the handler stays web-standard so it can be added later.
Amended 2026-10-14 after testing against the real services (approved by the
user): history granularity instead of a snapshot limit, a "tall" landing-art
class, an honest User-Agent and 10 s pacing for Wayback, and one default cover
per series (§2.2, §2.3, §4.2–§4.5).
---
@@ -129,6 +133,20 @@ Verified on 2026-10-14. Treat them as assumptions that the fixtures in
- **CORS:** no `Access-Control-Allow-Origin` seen on snapshots. CDX returned
503 during the check, so treat Wayback as unavailable from a plain page and
flaky in general.
- **Bot detection (tested 2026-10-14).** Requests sending a fake browser
User-Agent from a non-browser client were answered `429` with the body
"flagged … as suspected abusive bot traffic", even at one request per 10 s.
With an honest User-Agent (`mangabaka-seeder/<version> (+<repo URL>)`),
2 CDX lookups and 24 snapshots at one request per 10 s ran with no `429` at
all. So Wayback always gets the honest User-Agent; only Webtoons gets a
browser one.
- **Mobile pages.** `m.webtoons.com` has its own captures (35 months for Tower
of God, against 121 for `www.`); they fill gaps in the `www.` history.
- **What the history holds (Tower of God, every 6 months, 2015–2026):**
20 distinct images, all still live on `swebtoon-phinf.pstatic.net`. The
2015–2020 `og:image`s are 510×510 character art, the 2020-10 – 2026-03 ones
are 720×1230 landing-page art with an empty lower half, and only the current
one (480×623) is a cover with the logo. Hence the "tall" class in §4.4.
### 2.3 MangaBaka
@@ -145,8 +163,11 @@ Verified on 2026-10-14. Treat them as assumptions that the fixtures in
- `GET /v1/series/{id}` and `GET /v1/series/{id}/links` return the series
and its links. Links are canonicalised, e.g.
`https://www.webtoons.com/-/-/-/list?title_no=95`.
- `GET /v1/series/{id}/images` returns covers with `type`, `language`,
`index`, `note`, `content_rating`, `is_default` and `hashes.sha256`.
- `GET /v1/series/{id}/images` returns covers with `id`, `type`, `language`,
`index`, `note`, `content_rating` and `hashes.sha256`, but **not**
`is_default`. It is **paginated** (`limit` ≤ 50; Tower of God has 31
images, `pagination.next` links the next page), so the client follows
`next` until it is `null`.
- Rate limits: about 180 requests per minute (search, which we no longer
use, is about 30).
- **Image `type` values:** `volume` (default), `volume_back`, `chapter`,
@@ -161,6 +182,14 @@ Verified on 2026-10-14. Treat them as assumptions that the fixtures in
- `POST /v0/my/submissions/series-images/{id}` saves, with body
`{ version, user_note, save_mode, data: { images: [...] } }`.
- `images` **replaces the whole set**, so it must contain the existing images.
- **Exactly one image in the set must have `is_default: true`** when the set
is non-empty (OpenAPI `series-images` schema). There is one main cover per
*series*, not per language.
- Snapshot: `{ data: { data: { images: [...] }, version } }`. Each image has
`id`, `series_id`, `work_id`, `url`, `index`, `index_numeric`, `language`,
`type`, `note`, `content_rating`, `is_default`; the keys `work_id`, `note`
and `index_numeric` are required even when `null`.
- Preview answers `{ data: { has_changes, changes: SubmissionDiff[] } }`.
- A stale `version` gives 409.
- Users without the contributor role and with fewer than 25 approved
submissions get 403.
@@ -355,9 +384,13 @@ export interface Transport {
Shared behaviour lives in `core/transport.ts` and wraps whichever transport is
injected:
- limits each host to 1 request per second, and Wayback to 1 every 1.5 s;
- times out after 20 s;
- retries twice with backoff on 429/5xx;
- limits each host to 1 request per second, and Wayback to 1 every 10 s;
- times out after 20 s (uploads may ask for longer);
- retries twice with backoff on 429/5xx, honouring `Retry-After`, but **only
for `GET`/`HEAD`**: a retried `POST` could file a second edit request or
upload twice;
- sends Wayback the honest User-Agent (§2.2). (Browsers don't let a page set
`User-Agent`; the server proxy and the userscript set it per host.)
- never sends the MangaBaka token anywhere except `api.mangabaka.org`. In
particular the token never passes through our server.
@@ -366,17 +399,28 @@ injected:
`history/wayback.ts` knows nothing about any one source. For each prefix in
`series.archiveUrls` it:
1. queries CDX with `collapse=timestamp:6` (one snapshot per month, capped by a
history limit, default 60);
2. fetches each snapshot with the `id_` modifier;
3. calls `source.extractCovers(html, originalUrl)`;
4. normalises the image URLs and tags each candidate with `seenAt` and
1. queries CDX with `collapse=timestamp:6`, which lists every month that has a
capture (one cheap request per prefix), and drops rows whose page is not
the same series (`matchType=prefix` on `title_no=95` also matches
`title_no=950`);
2. splits the span from the first capture to now into buckets of the user's
**granularity** — every 1, 3, 6 (default) or 12 months — and keeps one
capture per bucket, preferring `www.` over `m.`;
3. fetches each chosen snapshot with the `id_` modifier;
4. calls `source.extractCovers(html, originalUrl)`;
5. normalises the image URLs and tags each candidate with `seenAt` and
`archivedUrl`.
Tower of God at 6 months is 2 CDX lookups and 24 snapshots, about 4–5 minutes
at one request per 10 s, so the UI shows progress and can cancel; a cancelled
run keeps what it found.
Candidates that share a normalised URL are merged; the merged candidate records
the first and last time it was seen. Wayback failures (timeouts, 5xx, rate
limits) produce a warning and an empty history; they are never fatal. The user
can turn history off.
limits) produce a warning, never a fatal error. A snapshot that still fails
after its retries **stops the history** (Wayback is blocking or down; waiting
on each remaining snapshot would take minutes apiece) and keeps the covers
found so far. The user can turn history off.
### 4.4 Image inspection and classification
@@ -390,12 +434,16 @@ a warning.
Classification by aspect ratio (h / w):
| Shape | Ratio | MangaBaka `type` |
|---|---|---|
| portrait | ≥ 1.15 | `volume` |
| square-ish | 0.85 – 1.15 | `other` |
| wide | < 0.5 | `banner` |
| otherwise | – | `other` |
| Shape | Ratio | MangaBaka `type` | Selected by default |
|---|---|---|---|
| tall (landing-page art) | > 1.6 | `other` | no — labelled "landing-page art" |
| portrait | 1.15 – 1.6 | `volume` | yes |
| square-ish | 0.85 – 1.15 | `other` | yes |
| wide | < 0.5 | `banner` | yes |
| otherwise | – | `other` | yes |
Real Webtoons posters are about 1.3 (480×623); landing-page art is 1.7
(720×1230) with an empty lower half (§2.2).
### 4.5 Series and dedupe (`links.ts`, `covers.ts`)
@@ -412,10 +460,15 @@ Classification by aspect ratio (h / w):
keeps the live URL and the widest `seenAt` range.
- **Already on MangaBaka.** Candidates whose `sha256` matches an existing
MangaBaka image are dropped and shown as "already present".
- **Default selection.** The default is the current live poster, or the current
live square if there is none (canvas). The new default gets
`is_default: true` and every existing image gets `is_default: false`. The
reviewer can still reject that.
- **Default selection.** The suggested default is the current live poster, or
the current live square if there is none (canvas). MangaBaka has **one**
default per series (§2.3), so making a Webtoons edition's poster the default
replaces the series' main cover for every language. The UI therefore shows
a checkbox "Make this the series' main cover", on by default, with that
warning. When on, that image gets `is_default: true` and every other image
`false`; when the suggested image is already on MangaBaka, the existing
image is the one marked. When off, existing defaults are left alone. The
reviewer can still reject it.
- **Notes.** Every new image gets a note, e.g. `"WEBTOON poster (current)"` or
`"WEBTOON poster, seen 2019-03 – 2021-01"`.
@@ -526,7 +579,7 @@ language: series.language, type, note, content_rating, is_default }`.
**Static page** (`src/web/`, served by our server):
1. Two inputs: MangaBaka series link, Webtoons series link. Optional: "include
history" (on), history limit.
history" (off since 2026-10-15, spec 002 §1), granularity (every 1 / 3 / 6 / 12 months, default 6).
2. Runs the pipeline and shows one card per new cover (thumbnail, dimensions,
suggested type, language, note, content-rating selector, checkbox), plus
"already present" and skipped covers.
@@ -565,7 +618,7 @@ language: series.language, type, note, content_rating, is_default }`.
| `GET /*` | Static assets of the page. |
| `GET /api/config` | `{ storage: boolean, storageFull: boolean }`, so the page knows which hosts to offer (a full store is shown disabled, with the reason). |
| `GET /covers/<sha256>.<ext>` | Serves a stored cover (§4.6). Public: MangaBaka fetches these. |
| `GET /api/fetch?url=` | Fetches `url` only if its host is in the allowlist (union of `Source.hosts` plus `web.archive.org`), with a browser User-Agent; streams the response back. Anything else: 403. |
| `GET /api/fetch?url=` | Fetches `url` only if its host is in the allowlist (union of `Source.hosts` plus `web.archive.org`), with a browser User-Agent for source hosts and the honest one for Wayback (§2.2); follows at most 5 redirects, each re-checked against the allowlist; returns the response. Anything else: 403. |
| `POST /api/upload` | `{ url, host: "store" \| "catbox", userhash? }` (§4.6). `url` must pass the same allowlist. |
The API routes reject other origins (except the userscript, which is not bound
@@ -657,7 +710,10 @@ An optional shared password locks the **whole** service: page, assets and API.
4. **Where users create a personal access token** in the MangaBaka UI.
5. **Whether the cover form accepts fields filled by a userscript.**
6. **Whether catbox accepts uploads from our server** (catbox-via-server).
7. **Hosting details:** which of our servers and domain (`MBSEED_PUBLIC_URL`),
7. **Whether MangaBaka's `hashes.sha256` is of the original bytes.** Dedupe
against existing covers relies on it; if MangaBaka re-encodes images, the
"already present" check misses and the reviewer sees duplicates.
8. **Hosting details:** which of our servers and domain (`MBSEED_PUBLIC_URL`),
where the covers folder lives (`MBSEED_STORAGE_DIR`), and whether the
1 GiB default cap suits that disk (~6 MB per run at most, kept 30 days).
@@ -679,6 +735,14 @@ live https://swebtoon-phinf.pstatic.net/20250204_46/17386458444547o1b2_JPEG/95.
The 2014, 2015 and 2019 images are still live on `swebtoon-phinf.pstatic.net`;
the 2021 one is gone from Wayback's own copy but live on pstatic.
A 6-month sweep on 2026-10-14 found 20 distinct images (5 posters as `og:image`,
4 squares, 6 banners, 5 foregrounds), all live; see §2.2 for what they are.
2015 snapshots write `background:url(…)` without quotes and use the hosts
`swebtoon.phinf.naver.net` and `webtoon.phinf.naver.net`; both map to
`swebtoon-phinf.pstatic.net` with the same path. On the canvas example the
`og:image` and `twitter:image` URLs differ but the bytes are identical (merged
by `sha256`).
Other test inputs:
- canvas: `https://www.webtoons.com/en/canvas/crow-time/list?title_no=693372`
+146
View File
@@ -0,0 +1,146 @@
---
status: approved
---
# 002 — Download covers, and a history of searches
Two additions from the first user review (2026-10-15). Design approved by the user in chat on 2026-10-15,
with these answers: history restores instantly (images kept in the browser); download everywhere, history on
the page only; one ZIP. Builds on [001](001-webtoons-covers.md).
Also from the review: **"Old covers from the Wayback Machine" starts off** (it was on, spec 001 §4.9).
A Wayback run takes minutes at the polite pace; the current covers come back in seconds, and the user
ticks it when they want the history.
## 1. Goals and non-goals
**Goals**
- Contributors without API access (spec 001 §9: fewer than 25 approved submissions) can **download the
selected covers** and upload them by hand in MangaBaka's cover form, without rehosting first.
- The page keeps a **history** of searched series: reopen one instantly as it was left (covers, ticks,
types, notes, ratings, main cover, rehosted links), see what was done with it, search it again.
**Non-goals**
- History in the userscript panel. The panel opens on the series being viewed; its storage would be
mangabaka.org's origin or GM storage, a poor place for megabytes of images.
- Syncing history between browsers or devices, or keeping it on our server.
- Per-cover download buttons (the ZIP of the selection covers it; untick what you don't want).
## 2. Download selected (ZIP)
On the page and in the panel, in the block renamed **"2a · Add by hand"** (it now holds the paste text, the
download and the form link): a **Download selected (ZIP)** button. It needs no rehosting and no network:
the bytes are already in memory from inspection (spec 001 §4.4).
- **Contents:** the selected covers, in shelf order, with their **original bytes** (no recompression), plus
`covers.txt`.
- **File names:** `NN_role_lang[_YYYY]_WxH.ext`, e.g. `01_poster_en_480x623.jpg`,
`03_poster_en_2019_480x623.jpg`. `NN` is the position (two digits, more when needed), `role` the
candidate role (`poster`, `square`, `banner`, `foreground`, `other`), `lang` the series language, `YYYY`
the year of the earliest archive capture (omitted for covers only seen live), `WxH` the pixel size.
Names are ASCII; the language code is kept as is (`zh-Hant`).
- **`covers.txt`:** a header (MangaBaka title and link, source title and link, the cover form link, the
time it was made), then one paragraph per file: file name, `type · language · rating`, `MAIN COVER` when
it is the chosen main cover, the note, and the source URL (the URL the bytes came from; the original
URL too when it differs).
- **ZIP name:** `<slug of the MangaBaka title>-<seriesId>-covers.zip`, e.g. `tower-of-god-9999-covers.zip`.
- **Format:** a store-only ZIP (method 0, no compression: JPEG/PNG/WebP don't shrink), UTF-8 name flag set,
CRC-32 per entry, written by our own `src/core/zip.ts` (no dependency). ZIP64 is not needed: a selection is
tens of MB at most; over 4 GiB it refuses.
- **Delivery:** a Blob URL and an `<a download>` click, on the page and in the panel's shadow root.
`GM_download` is the fallback only if the Blob link turns out not to work on mangabaka.org.
- Disabled when nothing is selected. Downloading marks the history entry *downloaded* (page).
## 3. History (page only)
### 3.1 What is kept
One entry per MangaBaka series (key: `seriesId`). A new search of the same series replaces its entry.
An entry holds:
- the inputs: both links, Wayback on/off and granularity, the rating chosen for new covers;
- the run result (spec 001 `RunResult`) as data: the source is stored by `Source.id` and looked up in the
registry on restore (an entry whose source no longer exists can't be opened and says so);
- the review state: per cover `selected`, `type`, `note`, `contentRating`, `hostedUrl`, plus when that link
expires and which host made it; the main-cover checkbox;
- status marks with times: *downloaded*, *rehosted* (host), *filed for review* (submission id);
- `savedAt`, `foundAt` (when the search ran) and the total image bytes (for pruning).
Saved automatically when a search finishes, after each edit (debounced, ~500 ms), after rehosting, after a
download and after filing.
### 3.2 Storage
IndexedDB on our page's origin, database `mbseed`, two object stores keyed by `seriesId`:
`summaries` (small: title, source, counts, marks, times, one thumbnail's bytes) and `searches` (the full
entry with every image's bytes). Both are written in one transaction; the list reads only `summaries`.
Pruning after each save: keep the 30 most recently saved series, and drop the oldest beyond 300 MB of image
bytes in total (the newest entry is always kept). A failed write (quota, private window, blocked storage)
never breaks the page: the history line says it couldn't be saved.
### 3.3 Restoring
Opening an entry shows the review exactly as it was, without network, with a line
"Found <relative time> · Search again". The inputs are filled in too.
- **Expired links are dropped:** litterbox links past their chosen lifetime and our server's past 30 days
are cleared on restore, so "2a" never hands out a dead link. catbox and `direct` links don't expire.
- **Rehost cache:** restored litterbox/catbox links go back into the session's rehost cache, so rehosting
again doesn't upload the same image twice.
- **Search again** runs the search with the stored inputs. When it finishes, the edits of covers that are
still there (same SHA-256) carry over: selection, type, note, rating, unexpired link. New covers get the
defaults. Then the entry is replaced.
- **Staleness is safe:** filing with a token always fetches a fresh MangaBaka snapshot first (spec 001 §4.9),
and MangaBaka may have changed since the search; the "Found …" line says how old it is.
### 3.4 Interface
- **Left column**, under the run plates: **History** — newest first; each row has the thumbnail, the
MangaBaka title, the relative time, "N new · M on MangaBaka", and the marks. Click (or Enter) opens it.
Each row has a remove button; the list ends with "Clear history" (asks to confirm).
- **Start screen** (before any search): the most recent entries (up to 6) as a small shelf of covers under
the intro, each opening its entry.
- Hidden entirely when there is no history yet, and in the userscript panel.
## 4. Modules
| Module | What |
|---|---|
| `src/core/zip.ts` | `crc32(bytes)`, `zipStore(files: {name, bytes, modified}[]): Uint8Array`. Pure. |
| `src/core/download.ts` | `downloadBundle(session-like input) → {zipName, files}`: names, `covers.txt`. Pure. |
| `src/core/saved.ts` | `SavedSearch` type; `saveSearch(session, inputs, marks, now)`; `restoreSearch(saved, sources, now)` → result + cards with expired links dropped; `carryEdits(oldCards, newCards, now)`; `pruneIds(summaries)`. Pure. |
| `src/core/hosting/host.ts` | `linkExpiry(host, options, now)`: when a hosted link stops working (null = never). |
| `src/core/session.ts` | `ReviewCard` gains `hostedBy` and `hostedUntil` (set by `rehost`); `restore(result, cards, makeDefault)`; `hostedCache` refilled on restore. |
| `src/web/history-db.ts` | `HistoryStore` interface and its IndexedDB implementation (thin; checked in a browser). |
| `src/web/recent.ts` | Rendering of the History list and the start-screen shelf. |
| `src/web/app.ts` | Download button; history wiring when `AppOptions.history` is given (the page passes it, the userscript doesn't). |
| `src/userscript/main.ts` | Nothing new beyond the shared template; check the download in the panel. |
`src/core` stays free of DOM, Bun, node and GM APIs: IndexedDB, Blob and `<a download>` live in `src/web`.
## 5. Testing
Unit tests, no network, no DOM:
- `zip.ts`: CRC-32 against known values; a written archive passes `unzip -t` and `unzip -p` returns the
original bytes (the test spawns `unzip` on a temp file); UTF-8 flag; refuses over 4 GiB (by size check).
- `download.ts`: names (order, two-digit padding, year from `seenAt`, no year when live only), `covers.txt`
content including the main-cover mark, zip name slug.
- `saved.ts`: save → restore round trip keeps every edit and the bytes; unknown source refused; expired
litterbox/server links dropped and catbox kept; `carryEdits` by hash; pruning by count and by bytes,
newest kept.
- `linkExpiry` per host; `Session.rehost` sets `hostedBy`/`hostedUntil`.
- `HistoryStore` is used through an in-memory fake in app-level tests where needed.
In a real browser (headless Chrome through the DevTools protocol, as for 001): search, edit, reload, open the
entry from History, check the restored state; Search again carries the edits; download the ZIP on the page
and in the userscript panel (GM shim) and check it with `unzip -t`.
## 6. Risks
- **Browser storage limits:** IndexedDB quota differs per browser; Firefox asks above a threshold in some
setups. Pruning at 300 MB keeps it moderate; a refused write is reported, not fatal.
- **`hostedUntil` for old entries:** links rehosted before this change have no expiry recorded; there are none
(history starts with this change).
- **Download in the panel:** mangabaka.org's CSP could block the Blob link; then `GM_download` (needs a
`@grant`).
+70
View File
@@ -0,0 +1,70 @@
---
status: approved
---
# 003 — Tapas source
A second source (spec 001 §4.1): covers of a [Tapas](https://tapas.io) series, comics and novels. Design approved
by the user in chat on 2026-10-15 ("Comics and novels"). Everything else — inspection, dedupe, Wayback history,
hosting, download, history — is the existing pipeline.
## 1. Verified facts (2026-10-15)
Checked against *Villains Are Destined to Die* (comic, id 186414) and its novel (id 266599), a mature series
(id 286112) and Wayback captures since 2020-10. Fixtures in `test/fixtures/tapas/` and `test/fixtures/wayback/tapas-*`.
- **Series page:** `https://tapas.io/series/<slug>/info` (its own canonical URL). `/series/<slug>` serves the
episode reader (canonical `/episode/<id>`). `/series/<id>/info` redirects to the slug form. Slugs may contain
capitals and underscores (`/series/RU_Screwed`).
- **Series id:** on the info page `pageMeta : { id: <id>, type: 'series_id' }`; on the reader `seriesId: <id>`.
- **Comic or novel:** the stats row links `/static-landing/genre?category=COMIC` or `…=NOVEL`. `og:type` is
`comicpanda:webcomic_series` for both.
- **Title:** the cover image's `alt` in `.thumb-wrapper`; `og:title` is "Read <title> | Tapas Web Comics".
- **Images** (`us-a.tapas.io/sa/<xx>/<uuid>[_<size>].<ext>`, no token, no Referer needed):
- **Portrait cover:** the `<img>` in `.thumb-wrapper` (both the info page and the 2020 reader page),
`…_z.jpg` = 400×600. Without the suffix: the original, 960×1440.
- **Banner:** `og:image` (also the `js-top-banner` / `header-layer` background), 1280×460, no suffix.
- Lists use `_m` (180 px) of other images; `pc/…` images are signed promotions of other series. Neither is used.
- Older pages use `d30womf5coomej.cloudfront.net/sa/…`; the same paths answer on `us-a.tapas.io`.
- **Mature series:** without a signed-in, age-verified account, `/series/…/info` redirects to
`/account/verify-age?from=https://tapas.io/series/<slug>/info`, a page with no series data.
*Corrected 2026-10-15 (spec 004 work):* the userscript's `GM_xmlhttpRequest` runs with `anonymous: true`, so it
does **not** send the user's tapas.io cookies; the userscript sees the age check too.
- **Language:** English only (MangaBaka `en`).
- **Wayback:** `tapas.io/series/<slug>` captured about monthly since 2020; the prefix also matches
`<slug>-novel` and other longer slugs.
## 2. Behaviour
- **Links accepted:** `tapas.io/series/<slug>`, `…/info`, `…/<id>`, `…/<id>/info`, and `tapas.io/episode/<id>`
(the reader page names its series id). `www.` and `m.` hosts too. Fetched as `https://tapas.io/series/<slug or
id>/info`; an episode link first fetches the episode page for its series id.
- **Series:** `externalId` = the numeric id, `title`, `language: "en"`, `canonicalUrl` =
`https://tapas.io/series/<slug>/info`, `flags.novel`, `archiveUrls` = [`tapas.io/series/<slug>`].
- **Current covers:** portrait (role `poster`, suffix removed) and banner (role `banner`). The poster is the
suggested main cover as for Webtoons.
- **Mature (age check):** the series is resolved from the `from=` link (slug; title from the slug), `mature: true`,
no current covers, and a warning that Tapas shows it only to signed-in adults; tick the Wayback history.
History still runs.
- **Image URLs:** `normalizeImageUrl` maps both image hosts to `us-a.tapas.io`, drops `_<letters>` size suffixes
after the UUID and any query.
- **Same series:** a link is the same series when it is a tapas.io `/series/<slug>[/info][/]` with the same slug
(case-insensitive) or `/series/<id>[/info]` with the same id. This also filters Wayback rows (`-novel`).
- **Hosts:** fetch `tapas.io`, `www.tapas.io`, `m.tapas.io`, `us-a.tapas.io`, `d30womf5coomej.cloudfront.net`;
images `us-a.tapas.io`, `d30womf5coomej.cloudfront.net`. The userscript `@connect`s `tapas.io` and the
CloudFront host.
- **Chrome:** source-neutral where it named WEBTOON: the link hint ("WEBTOON or Tapas for now"), the
"No rehosting" warning ("the source's own image URL"), the userscript description.
## 3. Testing
Unit tests on the captured pages, no network: link parsing (every form, rejects), page parsing (comic, novel,
reader, 2020 archive, age check), URL normalisation, same-series (slug, id, `-novel` rejected), and the source
end to end through the fake transport (info page, episode link, age check, `/series/<id>` redirect).
Then a real run in headless Chrome for one comic and one novel.
## 4. Risks
- Tapas markup changes; the parser looks for two stable hooks (`thumb-wrapper`, `og:image`) and the page type link.
- The info page may show a mature badge to signed-in users that we never saw; mature is only known from the
age check.
+64
View File
@@ -0,0 +1,64 @@
---
status: approved
---
# 004 — KakaoPage source
A third source (spec 001 §4.1): covers of a [KakaoPage](https://page.kakao.com) series, comics (웹툰) and
novels (웹소설). Design approved by the user in chat on 2026-10-15. Kakao Webtoon (`webtoon.kakao.com`) is a
different site and out of scope.
## 1. Verified facts (2026-10-15)
Checked against *악역의 엔딩은 죽음뿐* (Villains Are Destined to Die; comic 54845189, novel 52654302) and
*울프 인더 하우스 [19세 완전판]* (63659962, 19+). Fixtures in `test/fixtures/kakaopage/` and
`test/fixtures/wayback/kakaopage-*`.
- **Pages are rendered in the browser.** `page.kakao.com/content/<id>` is an 11 KB shell with a generic
`og:image`; the data comes from
`GET https://bff-page.kakao.com/api/gateway/api/v1/content/overview?series_id=<id>`.
- **That endpoint needs a `page.kakao.com` Referer (or Origin)**; without one it answers 403. It answers 19+
series without a login.
- **Its `result.content`:** `series_id`, `title`, `category` (`웹툰` / `웹소설`), `age_grade` (0, 12, 15, 19),
`lang` (`ko`), `thumbnail` (portrait key, e.g. 720×1030), `land_thumbnail` (landscape key, 540×370; may be null).
An unknown id answers with an error (`상품 정보를 찾을 수 없습니다`, "product not found").
- **Images:** `https://page-images.kakaoentcdn.com/download/resource?kid=<key>&filename=o1` is the original
(`th2`, `th3` and `/dims/resize/…` are smaller). No Referer needed. Older pages use `dn-img-page.kakao.com` with
the same query; old keys answer on the new host.
- **Wayback:** `page.kakao.com/home?seriesId=<id>` (the old address, 2020–2022) and `page.kakao.com/content/<id>`
(2022 on) are captured server-rendered. Every capture's `og:image` is this series' image (landscape in 2020–22,
portrait in 2024). 2020 captures also hold `props.initialState.series.series.thumbnailUrl` and
`landThumbnailUrl` in `__NEXT_DATA__`. Other images on the page (`sameSeriesList`, comments) are other series
or users.
## 2. Behaviour
- **Links:** `page.kakao.com/content/<id>` (with or without a trailing path/query) and
`page.kakao.com/home?seriesId=<id>`; `m.` hosts too.
- **Series:** from the overview endpoint: `externalId` = id, `title`, `language` = `lang` (`ko`),
`canonicalUrl` = `https://page.kakao.com/content/<id>`, `mature` = `age_grade >= 15` (the rating warning),
`flags.novel`, `archiveUrls` = [`page.kakao.com/content/<id>`, `page.kakao.com/home?seriesId=<id>`].
- **Current covers:** `thumbnail` → role `poster` (the suggested main cover), `land_thumbnail` → role `banner`.
- **History:** from a capture: `og:image` (role `other`: portrait or landscape, the shape decides the type) and,
when present, the series' own `thumbnailUrl` (`poster`) and `landThumbnailUrl` (`banner`).
- **Same series:** a link is the same series when its id (`/content/<id>` or `seriesId=<id>`) equals ours exactly,
so `seriesId=548451890` is another series.
- **Image URLs:** both hosts → `page-images.kakaoentcdn.com`, `filename=o1`, the `kid` kept, any `/dims/…`
dropped.
- **Request headers per host:** `requestHeadersFor(url)` in `core/transport.ts` (User-Agent as before, plus
`Referer: https://page.kakao.com/` for `bff-page.kakao.com`). The server proxy and the userscript send them.
- **Hosts:** fetch `page.kakao.com`, `m.page.kakao.com`, `bff-page.kakao.com`, `page-images.kakaoentcdn.com`,
`dn-img-page.kakao.com`; images the last two. The userscript `@connect`s `kakao.com` and `kakaoentcdn.com`.
- **Chrome:** the link hint names "WEBTOON, Tapas or KakaoPage".
## 3. Testing
Offline unit tests on the captures: link parsing, URL normalisation, same-series, overview parsing (comic,
novel, 19+, unknown id), archived pages (2020, 2022, 2024), Wayback rows through both prefixes, request headers.
Then a real run in headless Chrome (current covers, then yearly history).
## 4. Risks
- The overview endpoint is an internal API: its shape or its Referer check can change.
- Sending a `page.kakao.com` Referer is what KakaoPage's own page sends; like the browser User-Agent for
Webtoons, it is a browser-like header, not an identity.
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mangabaka-seeder",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"build:web": "bun build ./src/web/index.html --outdir dist/web --minify",
"build:userscript": "bun run scripts/build-userscript.ts",
"build": "bun run build:web && bun run build:userscript",
"start": "bun run src/server/bun.ts"
},
"devDependencies": {
"@types/bun": "^1.4.3",
"typescript": "^7.0.2"
}
}
+14
View File
@@ -0,0 +1,14 @@
// bun run build:userscript → dist/web/mbseed.user.js (served by our server at /mbseed.user.js).
// Set MBSEED_PUBLIC_URL to add our server to @connect.
import pkg from "../package.json";
import { userscriptBanner } from "./userscript-banner";
const result = await Bun.build({ entrypoints: ["src/userscript/main.ts"], target: "browser", format: "iife" });
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
const code = await result.outputs[0]!.text();
const out = userscriptBanner(pkg.version, process.env.MBSEED_PUBLIC_URL || undefined) + code;
await Bun.write("dist/web/mbseed.user.js", out);
console.log(`wrote dist/web/mbseed.user.js (${Math.round(out.length / 1024)} KB)`);
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Re-captures test/fixtures from the real services. Run by hand, never from tests.
# Wayback: honest User-Agent and 10 s between requests (spec §2.2), or it flags us as a bot.
set -euo pipefail
cd "$(dirname "$0")/.."
F=test/fixtures
mkdir -p "$F/webtoons" "$F/wayback" "$F/mangabaka" "$F/images"
BROWSER='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36'
TOOL='mangabaka-seeder/0.1.0 (+https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder)'
get() { curl -sSfL --max-time 90 -A "$1" -o "$2" "$3"; echo "ok $2 ($(wc -c <"$2") bytes)"; }
wb() { get "$TOOL" "$1" "$2"; sleep 10; }
# Webtoons: the /-/-/-/ form (official) or /-/canvas/-/ form (canvas) 301s to the canonical page.
get "$BROWSER" "$F/webtoons/tower-of-god.html" 'https://www.webtoons.com/-/-/-/list?title_no=95'
get "$BROWSER" "$F/webtoons/crow-time-canvas.html" 'https://www.webtoons.com/-/canvas/-/list?title_no=693372'
get "$BROWSER" "$F/webtoons/zombie-revelation-mature.html" 'https://www.webtoons.com/-/-/-/list?title_no=6065'
CDX='https://web.archive.org/cdx/search/cdx?matchType=prefix&filter=statuscode:200&collapse=timestamp:6&fl=timestamp,original&url='
wb "$F/wayback/tower-of-god-cdx-www.txt" "${CDX}www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95"
wb "$F/wayback/tower-of-god-cdx-m.txt" "${CDX}m.webtoons.com/en/fantasy/tower-of-god/list?title_no=95"
row() { grep -m1 "^$1" "$F/wayback/tower-of-god-cdx-www.txt"; }
read -r TS ORIG <<<"$(row 2015)"; wb "$F/wayback/tower-of-god-2015.html" "https://web.archive.org/web/${TS}id_/${ORIG}"
read -r TS ORIG <<<"$(row 2023)"; wb "$F/wayback/tower-of-god-2023.html" "https://web.archive.org/web/${TS}id_/${ORIG}"
API='https://api.mangabaka.org/v1/series/9999'
get "$TOOL" "$F/mangabaka/series-9999.json" "$API"
get "$TOOL" "$F/mangabaka/links-9999.json" "$API/links?limit=100"
get "$TOOL" "$F/mangabaka/images-9999-page1.json" "$API/images?limit=24&page=1"
get "$TOOL" "$F/mangabaka/images-9999-page2.json" "$API/images?limit=24&page=2"
IMG='https://swebtoon-phinf.pstatic.net'
get "$BROWSER" "$F/images/poster-95.jpg" "$IMG/20250204_46/17386458444547o1b2_JPEG/95.jpg"
get "$BROWSER" "$F/images/foreground-95.png" "$IMG/20230324_64/1679612177711Smf0u_PNG/6TowerOfGod_landingpage_desktop_fg.png"
get "$BROWSER" "$F/images/landing-2023.jpg" "$IMG/20230324_114/1679612146968KhLLz_JPEG/6TowerOfGod_landingpage_mobile.jpg"
echo "done"
+21
View File
@@ -0,0 +1,21 @@
/** Tampermonkey/Violentmonkey metadata. @connect lists every host GM_xmlhttpRequest may reach. */
export function userscriptBanner(version: string, serverUrl?: string): string {
const connects = ["api.mangabaka.org", "webtoons.com", "pstatic.net", "tapas.io", "d30womf5coomej.cloudfront.net", "kakao.com", "kakaoentcdn.com", "web.archive.org", "catbox.moe"];
if (serverUrl) connects.push(new URL(serverUrl).hostname);
const lines = [
"// ==UserScript==",
"// @name MangaBaka Seeder",
"// @namespace https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder",
`// @version ${version}`,
"// @description Find every cover of a WEBTOON, Tapas or KakaoPage series and turn them into a MangaBaka cover edit request",
"// @match https://mangabaka.org/*",
"// @grant GM_xmlhttpRequest",
"// @grant GM_getValue",
"// @grant GM_setValue",
"// @grant GM_deleteValue",
...connects.map((host) => `// @connect ${host}`),
"// @run-at document-idle",
"// ==/UserScript==",
];
return `${lines.join("\n")}\n\n`;
}
+90
View File
@@ -0,0 +1,90 @@
import type { CoverCandidate, CoverRole, ImageType, InspectedCover } from "./model";
/** A cover already on MangaBaka (from the public images endpoint). */
export interface ExistingImage {
id: number;
sha256: string | null;
language: string;
type: ImageType;
}
const earlier = (a?: string, b?: string) => (!a ? b : !b ? a : a < b ? a : b);
const later = (a?: string, b?: string) => (!a ? b : !b ? a : a > b ? a : b);
/** Keeps `a`'s URL; widest seen range; current if either is. */
export function mergeCandidates(a: CoverCandidate, b: CoverCandidate): CoverCandidate {
const merged: CoverCandidate = {
...a,
role: a.current || !b.current ? a.role : b.role,
current: a.current || b.current,
};
const seenAt = earlier(a.seenAt, b.seenAt);
const lastSeenAt = later(a.lastSeenAt ?? a.seenAt, b.lastSeenAt ?? b.seenAt);
const originalUrl = a.originalUrl ?? b.originalUrl;
const archivedUrl = a.archivedUrl ?? b.archivedUrl;
if (seenAt) merged.seenAt = seenAt;
if (lastSeenAt) merged.lastSeenAt = lastSeenAt;
if (originalUrl) merged.originalUrl = originalUrl;
if (archivedUrl) merged.archivedUrl = archivedUrl;
return merged;
}
export function mergeByUrl(list: readonly CoverCandidate[]): CoverCandidate[] {
const byUrl = new Map<string, CoverCandidate>();
for (const c of list) {
const prev = byUrl.get(c.url);
byUrl.set(c.url, prev ? mergeCandidates(prev, c) : { ...c });
}
return [...byUrl.values()];
}
const rank = (c: InspectedCover) => (c.live ? 2 : 0) + (c.candidate.current ? 1 : 0);
/** Same bytes = same cover (spec §4.5): keep the live URL and the widest seen range. */
export function dedupeByHash(list: readonly InspectedCover[]): InspectedCover[] {
const bySha = new Map<string, InspectedCover>();
for (const c of list) {
const prev = bySha.get(c.sha256);
if (!prev) {
bySha.set(c.sha256, c);
continue;
}
const [keep, other] = rank(c) > rank(prev) ? [c, prev] : [prev, c];
bySha.set(c.sha256, { ...keep, candidate: mergeCandidates(keep.candidate, other.candidate) });
}
return [...bySha.values()];
}
export function splitExisting(list: readonly InspectedCover[], existing: readonly ExistingImage[]) {
const bySha = new Map(existing.filter((e) => e.sha256).map((e) => [e.sha256!.toLowerCase(), e]));
const fresh: InspectedCover[] = [];
const present: { cover: InspectedCover; existing: ExistingImage }[] = [];
for (const cover of list) {
const e = bySha.get(cover.sha256);
if (e) present.push({ cover, existing: e });
else fresh.push(cover);
}
return { fresh, present };
}
/** Current live poster, else current live square (canvas). Never landing-page art. */
export function pickDefault(list: readonly InspectedCover[]): InspectedCover | null {
const live = list.filter((c) => c.candidate.current && c.live && c.shape !== "tall");
return live.find((c) => c.candidate.role === "poster") ?? live.find((c) => c.candidate.role === "square") ?? null;
}
const ROLE_LABEL: Readonly<Record<CoverRole, string>> = {
poster: "poster", square: "square thumbnail", banner: "banner", foreground: "foreground art", other: "image",
};
export const formatMonth = (ts: string): string => `${ts.slice(0, 4)}-${ts.slice(4, 6)}`;
export function coverNote(cover: InspectedCover, sourceName: string): string {
const { role, current, seenAt, lastSeenAt } = cover.candidate;
const base = `${sourceName} ${ROLE_LABEL[role]}${cover.shape === "tall" ? " (landing-page art)" : ""}`;
if (!seenAt) return `${base} (current)`;
const from = formatMonth(seenAt);
if (current) return `${base} (current; archived since ${from})`;
const to = formatMonth(lastSeenAt ?? seenAt);
return from === to ? `${base}, seen ${from}` : `${base}, seen ${from} – ${to}`;
}
+59
View File
@@ -0,0 +1,59 @@
import { coverFormUrl } from "./mangabaka/client";
import type { SourceSeries } from "./model";
import type { ReviewCard } from "./session";
import type { ZipEntry } from "./zip";
/** Spec 002 §2: what "Download selected (ZIP)" puts in the archive. */
export interface BundleInput {
seriesId: number;
mbTitle: string;
mbLink: string;
sourceName: string;
series: SourceSeries;
/** The selected cards, in shelf order. */
cards: readonly ReviewCard[];
mainSha256: string | null;
now: Date;
}
/** `NN_role_lang[_YYYY]_WxH.ext`; NN is as wide as the count needs (at least two digits). */
export function coverFileName(position: number, total: number, card: ReviewCard, language: string): string {
const c = card.cover;
const year = c.candidate.seenAt?.slice(0, 4);
const nn = String(position).padStart(Math.max(2, String(total).length), "0");
return [nn, c.candidate.role, language, ...(year ? [year] : []), `${c.width}x${c.height}`].join("_") + `.${c.ext}`;
}
export function slug(title: string): string {
const s = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return s || "series";
}
export function downloadBundle(input: BundleInput): { zipName: string; files: ZipEntry[] } {
const { cards, series, now } = input;
const files: ZipEntry[] = cards.map((card, i) => ({
name: coverFileName(i + 1, cards.length, card, series.language),
bytes: card.cover.bytes,
modified: now,
}));
const lines = [
`${input.mbTitle} (MangaBaka #${input.seriesId}): ${input.mbLink}`,
`${input.sourceName} “${series.title}”: ${series.canonicalUrl}`,
`Cover form: ${coverFormUrl(input.seriesId)}`,
`Made by mangabaka-seeder on ${now.toISOString().slice(0, 10)}. One cover per entry below; upload each with these fields.`,
"",
...cards.flatMap((card, i) => {
const c = card.cover;
return [
files[i]!.name,
` type: ${card.type} · language: ${series.language} · rating: ${card.contentRating}${c.sha256 === input.mainSha256 ? " · MAIN COVER" : ""}`,
` note: ${card.note}`,
` source: ${c.fetchedFrom}`,
...(c.candidate.url !== c.fetchedFrom ? [` original: ${c.candidate.url}`] : []),
"",
];
}),
];
files.push({ name: "covers.txt", bytes: new TextEncoder().encode(lines.join("\n")), modified: now });
return { zipName: `${slug(input.mbTitle)}-${input.seriesId}-covers.zip`, files };
}
+125
View File
@@ -0,0 +1,125 @@
import { formatMonth, mergeByUrl } from "../covers";
import type { CoverCandidate, Granularity, SourceSeries } from "../model";
import type { Source } from "../sources/source";
import type { Transport } from "../transport";
export const WAYBACK = "https://web.archive.org";
export interface CdxRow {
timestamp: string;
original: string;
}
export interface HistoryOptions {
every: Granularity;
transport: Transport;
warn(message: string): void;
onProgress?(done: number, total: number): void;
signal?: AbortSignal;
}
/** Every month with a capture (collapse=timestamp:6) — one cheap request per prefix (spec §4.3). */
export function cdxUrl(prefix: string): string {
const q = new URLSearchParams({
url: prefix, matchType: "prefix", filter: "statuscode:200", collapse: "timestamp:6", fl: "timestamp,original",
});
return `${WAYBACK}/cdx/search/cdx?${q}`;
}
export function parseCdx(text: string): CdxRow[] {
const rows: CdxRow[] = [];
for (const line of text.split("\n")) {
const [timestamp, original] = line.trim().split(/\s+/);
if (timestamp && original && /^\d{14}$/.test(timestamp)) rows.push({ timestamp, original });
}
return rows;
}
const monthIndex = (ts: string) => Number(ts.slice(0, 4)) * 12 + Number(ts.slice(4, 6)) - 1;
const hostIs = (row: CdxRow, host: string) => {
try {
return new URL(row.original).hostname === host;
} catch {
return false;
}
};
/** Buckets of `every` months from the first capture; first capture per bucket, preferring `preferHost`. */
export function pickSnapshots(rows: readonly CdxRow[], every: Granularity, preferHost: string): CdxRow[] {
const sorted = [...rows].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
if (!sorted.length) return [];
const first = monthIndex(sorted[0]!.timestamp);
const buckets = new Map<number, CdxRow>();
for (const row of sorted) {
const bucket = Math.floor((monthIndex(row.timestamp) - first) / every);
const current = buckets.get(bucket);
if (!current || (!hostIs(current, preferHost) && hostIs(row, preferHost))) buckets.set(bucket, row);
}
return [...buckets.values()].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
}
export const snapshotUrl = (row: CdxRow): string => `${WAYBACK}/web/${row.timestamp}id_/${row.original}`;
export const archivedImageUrl = (timestamp: string, originalImageUrl: string): string =>
`${WAYBACK}/web/${timestamp}id_/${originalImageUrl}`;
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
export async function waybackHistory(source: Source, series: SourceSeries, opts: HistoryOptions): Promise<CoverCandidate[]> {
if (!source.extractCovers) return [];
const rows: CdxRow[] = [];
for (const prefix of series.archiveUrls) {
try {
const res = await opts.transport.fetch(cdxUrl(prefix), { signal: opts.signal });
if (!res.ok) {
await res.body?.cancel();
opts.warn(`The Wayback index didn't answer (HTTP ${res.status}); no history from ${prefix}.`);
continue;
}
// matchType=prefix on title_no=95 also matches title_no=950 (spec §4.3).
rows.push(...parseCdx(await res.text()).filter((r) => source.isSameSeries(r.original, series)));
} catch (err) {
if (opts.signal?.aborted) return [];
opts.warn(`The Wayback index didn't answer (${message(err)}); no history from ${prefix}.`);
}
}
const preferHost = series.archiveUrls[0]?.split("/")[0] ?? "";
const picks = pickSnapshots(rows, opts.every, preferHost);
const found: CoverCandidate[] = [];
let done = 0;
for (const row of picks) {
if (opts.signal?.aborted) break;
let res: Response;
try {
res = await opts.transport.fetch(snapshotUrl(row), { signal: opts.signal });
} catch (err) {
if (opts.signal?.aborted) break;
opts.warn(`Wayback stopped answering (${message(err)}); history stops at ${formatMonth(row.timestamp)}.`);
break;
}
if (res.status === 429 || res.status >= 500) {
await res.body?.cancel();
opts.warn(`Wayback is refusing requests (HTTP ${res.status}); history stops at ${formatMonth(row.timestamp)}.`);
break;
}
if (res.ok) {
try {
const pageUrl = new URL(row.original);
for (const c of source.extractCovers(await res.text(), pageUrl)) {
const candidate: CoverCandidate = { ...c, current: false, seenAt: row.timestamp, lastSeenAt: row.timestamp };
if (c.originalUrl) candidate.archivedUrl = archivedImageUrl(row.timestamp, c.originalUrl);
found.push(candidate);
}
} catch (err) {
// One unreadable snapshot never costs the rest of the history (spec §4.3).
if (opts.signal?.aborted) break;
opts.warn(`Snapshot ${formatMonth(row.timestamp)} couldn't be read (${message(err)}).`);
}
} else {
await res.body?.cancel();
opts.warn(`Snapshot ${formatMonth(row.timestamp)} is unavailable (HTTP ${res.status}).`);
}
opts.onProgress?.(++done, picks.length);
}
return mergeByUrl(found);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Transport } from "../transport";
import { HostError, UPLOAD_TIMEOUT_MS, fileFor, viaServer, type ImageHost } from "./host";
export const CATBOX_API = "https://catbox.moe/user/api.php";
/** Used directly by the userscript and by our server (for the page). */
export async function uploadToCatbox(transport: Transport, file: Blob, name: string, userhash?: string): Promise<{ url: string }> {
const form = new FormData();
form.set("reqtype", "fileupload");
if (userhash) form.set("userhash", userhash);
form.set("fileToUpload", file, name);
const res = await transport.fetch(CATBOX_API, { method: "POST", body: form, timeoutMs: UPLOAD_TIMEOUT_MS });
const body = (await res.text()).trim();
if (!res.ok || !/^https:\/\/files\.catbox\.moe\/\S+$/.test(body)) {
throw new HostError(`catbox: ${body.slice(0, 200) || `HTTP ${res.status}`}`, res.status);
}
return { url: body };
}
export const catbox: ImageHost = {
id: "catbox",
lifetime: () => "permanent",
upload(image, ctx) {
const userhash = ctx.options.catboxUserhash?.trim() || undefined;
if (ctx.catboxVia === "server") return viaServer(image, ctx, { host: "catbox", ...(userhash ? { userhash } : {}) });
return uploadToCatbox(ctx.transport, fileFor(image), `${image.sha256}.${image.ext}`, userhash);
},
};
+11
View File
@@ -0,0 +1,11 @@
import { HostError, type ImageHost } from "./host";
/** No rehosting: submit the source URL itself. */
export const direct: ImageHost = {
id: "direct",
lifetime: () => "as long as the source keeps it",
async upload(image) {
if (!image.live) throw new HostError("This image is only on archive.org now; pick a host that rehosts it.");
return { url: image.sourceUrl };
},
};
+78
View File
@@ -0,0 +1,78 @@
import { API_CALL_HEADER, type Transport } from "../transport";
export type HostId = "server" | "litterbox" | "catbox" | "direct";
export type LitterboxTime = "1h" | "12h" | "24h" | "72h";
export const LITTERBOX_TIMES: readonly LitterboxTime[] = ["1h", "12h", "24h", "72h"];
const HOUR_MS = 3_600_000;
const LITTERBOX_HOURS: Readonly<Record<LitterboxTime, number>> = { "1h": 1, "12h": 12, "24h": 24, "72h": 72 };
/** Our server sweeps stored covers after this many days (spec 001 §4.6). */
export const SERVER_KEEP_DAYS = 30;
/** When a link made now by `host` stops working; null = it doesn't expire (spec 002 §3.3). */
export function linkExpiry(host: HostId, options: HostOptions, now: number): number | null {
if (host === "litterbox") return now + LITTERBOX_HOURS[options.litterboxTime ?? "72h"] * HOUR_MS;
if (host === "server") return now + SERVER_KEEP_DAYS * 24 * HOUR_MS;
return null;
}
export interface HostOptions {
litterboxTime?: LitterboxTime;
catboxUserhash?: string;
}
/** One inspected image, ready to rehost. `sourceUrl` is where its bytes came from. */
export interface HostImage {
sourceUrl: string;
live: boolean;
bytes: Uint8Array;
sha256: string;
contentType: string;
ext: string;
}
export interface HostContext {
transport: Transport;
/** Our server's origin, or null when there is none (userscript without a server). */
serverBase: string | null;
/** catbox has no CORS: the page goes through our server, the userscript straight there (spec §4.6). */
catboxVia: "server" | "direct";
options: HostOptions;
}
export interface ImageHost {
readonly id: HostId;
/** Shown next to the host in the UI. */
lifetime(opts: HostOptions): string;
upload(image: HostImage, ctx: HostContext): Promise<{ url: string }>;
}
export class HostError extends Error {
constructor(message: string, readonly status?: number) {
super(message);
this.name = "HostError";
}
}
export const UPLOAD_TIMEOUT_MS = 120_000;
export const fileFor = (image: HostImage): Blob => new Blob([image.bytes as Uint8Array<ArrayBuffer>], { type: image.contentType });
/** `POST /api/upload`: the server fetches `sourceUrl` itself, so it can't be used to host arbitrary bytes. */
export async function viaServer(
image: HostImage,
ctx: HostContext,
body: { host: "store" | "catbox"; userhash?: string },
): Promise<{ url: string }> {
if (ctx.serverBase === null) throw new HostError("No mangabaka-seeder server is configured.");
const res = await ctx.transport.fetch(`${ctx.serverBase}/api/upload`, {
method: "POST",
headers: { "content-type": "application/json", [API_CALL_HEADER]: "1" },
body: JSON.stringify({ url: image.sourceUrl, ...body }),
timeoutMs: UPLOAD_TIMEOUT_MS,
});
const reply = (await res.json().catch(() => null)) as { url?: string; sha256?: string; error?: string } | null;
if (!res.ok || !reply?.url) throw new HostError(reply?.error ?? `Upload failed (HTTP ${res.status}).`, res.status);
if (reply.sha256 !== image.sha256) throw new HostError("The server fetched different bytes than the ones checked; not using them.");
return { url: reply.url };
}
+22
View File
@@ -0,0 +1,22 @@
import { HostError, UPLOAD_TIMEOUT_MS, fileFor, type ImageHost, type LitterboxTime } from "./host";
export const LITTERBOX_API = "https://litterbox.catbox.moe/resources/internals/api.php";
const LABEL: Readonly<Record<LitterboxTime, string>> = { "1h": "1 hour", "12h": "12 hours", "24h": "24 hours", "72h": "72 hours" };
/** Temporary (≤ 72 h); CORS is open, so the page uploads straight from the browser. */
export const litterbox: ImageHost = {
id: "litterbox",
lifetime: (opts) => LABEL[opts.litterboxTime ?? "72h"],
async upload(image, ctx) {
const form = new FormData();
form.set("reqtype", "fileupload");
form.set("time", ctx.options.litterboxTime ?? "72h");
form.set("fileToUpload", fileFor(image), `${image.sha256}.${image.ext}`);
const res = await ctx.transport.fetch(LITTERBOX_API, { method: "POST", body: form, timeoutMs: UPLOAD_TIMEOUT_MS });
const body = (await res.text()).trim();
if (!res.ok || !/^https:\/\/litter\.catbox\.moe\/\S+$/.test(body)) {
throw new HostError(`litterbox: ${body.slice(0, 200) || `HTTP ${res.status}`}`, res.status);
}
return { url: body };
},
};
+8
View File
@@ -0,0 +1,8 @@
import { catbox } from "./catbox";
import { direct } from "./direct";
import type { HostId, ImageHost } from "./host";
import { litterbox } from "./litterbox";
import { serverHost } from "./server";
/** Adding a host: one file in this folder plus one line here. */
export const HOSTS: Readonly<Record<HostId, ImageHost>> = { server: serverHost, litterbox, catbox, direct };
+46
View File
@@ -0,0 +1,46 @@
import { HostError, type HostContext, type HostId, type HostImage, type HostOptions, type ImageHost } from "./host";
export interface RehostResult {
hosted: Map<string, string>;
failures: { sha256: string; message: string }[];
allFailed: boolean;
}
/** litterbox and catbox uploads are remembered by hash; server storage is re-uploaded on purpose (restarts its 30 days). */
export function cacheKey(host: HostId, options: HostOptions, sha256: string): string | null {
if (host === "litterbox") return `litterbox:${options.litterboxTime ?? "72h"}:${sha256}`;
if (host === "catbox") return `catbox:${sha256}`;
return null;
}
/** Failures are per image (spec §4.6). A full store (507) stops the rest: they would fail the same way. */
export async function rehostAll(
images: readonly HostImage[],
host: ImageHost,
ctx: HostContext,
cache: Map<string, string>,
onProgress?: (done: number, total: number) => void,
): Promise<RehostResult> {
const hosted = new Map<string, string>();
const failures: { sha256: string; message: string }[] = [];
let stopped: string | null = null;
let done = 0;
for (const image of images) {
if (stopped) {
failures.push({ sha256: image.sha256, message: stopped });
} else {
const key = cacheKey(host.id, ctx.options, image.sha256);
try {
const url = (key && cache.get(key)) || (await host.upload(image, ctx)).url;
if (key) cache.set(key, url);
hosted.set(image.sha256, url);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failures.push({ sha256: image.sha256, message });
if (err instanceof HostError && err.status === 507) stopped = message;
}
}
onProgress?.(++done, images.length);
}
return { hosted, failures, allFailed: images.length > 0 && hosted.size === 0 };
}
+8
View File
@@ -0,0 +1,8 @@
import { viaServer, type ImageHost } from "./host";
/** Our server's covers folder; swept after 30 days (spec §4.6). */
export const serverHost: ImageHost = {
id: "server",
lifetime: () => "30 days",
upload: (image, ctx) => viaServer(image, ctx, { host: "store" }),
};
+41
View File
@@ -0,0 +1,41 @@
// Small, forgiving HTML helpers. Not a parser: enough for <meta>, <link>, <div style>, <img src>.
const NAMED: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
export function decodeEntities(s: string): string {
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
const e = entity.toLowerCase();
if (e.startsWith("#")) {
const code = e.startsWith("#x") ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
// Malformed pages carry entities like &#99999999;: leave them as written rather than throw.
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
}
return NAMED[e] ?? match;
});
}
export function parseAttributes(tag: string): Record<string, string> {
const out: Record<string, string> = {};
const body = tag.replace(/^<\s*[\w-]+/, "").replace(/\/?\s*>$/, "");
for (const m of body.matchAll(/([^\s=/"'>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g)) {
const name = m[1]!.toLowerCase();
if (!(name in out)) out[name] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? "");
}
return out;
}
export function findTags(html: string, name: string): Record<string, string>[] {
return [...html.matchAll(new RegExp(`<${name}\\b[^>]*>`, "gi"))].map((m) => parseAttributes(m[0]));
}
/** `<meta property|name="key" content="…">` */
export function metaContent(html: string, key: string): string | undefined {
for (const a of findTags(html, "meta")) {
if ((a.property ?? a.name) === key && a.content) return a.content;
}
return undefined;
}
export function textOf(fragment: string): string {
return decodeEntities(fragment.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
}
+174
View File
@@ -0,0 +1,174 @@
import type { CoverCandidate, ImageExt, ImageType, InspectedCover, Shape } from "./model";
import type { Transport } from "./transport";
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
export class ImageError extends Error {
override name = "ImageError";
}
export interface ImageInfo {
width: number;
height: number;
contentType: string;
ext: ImageExt;
}
const u16be = (b: Uint8Array, i: number) => (b[i]! << 8) | b[i + 1]!;
const u16le = (b: Uint8Array, i: number) => b[i]! | (b[i + 1]! << 8);
const u24le = (b: Uint8Array, i: number) => b[i]! | (b[i + 1]! << 8) | (b[i + 2]! << 16);
const u32be = (b: Uint8Array, i: number) => ((b[i]! << 24) | (b[i + 1]! << 16) | (b[i + 2]! << 8) | b[i + 3]!) >>> 0;
const ascii = (b: Uint8Array, i: number, n: number) => String.fromCharCode(...b.subarray(i, i + n));
/** Dimensions from the header bytes, so no DOM is needed (spec §4.4). */
export function probeImage(b: Uint8Array): ImageInfo | null {
if (b.length >= 24 && u32be(b, 0) === 0x89504e47) {
return { width: u32be(b, 16), height: u32be(b, 20), contentType: "image/png", ext: "png" };
}
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) return probeJpeg(b);
if (b.length >= 10 && ascii(b, 0, 3) === "GIF") {
return { width: u16le(b, 6), height: u16le(b, 8), contentType: "image/gif", ext: "gif" };
}
if (b.length >= 30 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") return probeWebp(b);
return null;
}
function probeJpeg(b: Uint8Array): ImageInfo | null {
let i = 2;
while (i + 9 < b.length) {
if (b[i] !== 0xff) {
i++;
continue;
}
const marker = b[i + 1]!;
if (marker === 0xff) {
i++;
continue;
}
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
i += 2;
continue;
}
// SOF0–SOF15, except DHT (C4), JPG (C8) and DAC (CC)
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { width: u16be(b, i + 7), height: u16be(b, i + 5), contentType: "image/jpeg", ext: "jpg" };
}
i += 2 + u16be(b, i + 2);
}
return null;
}
function probeWebp(b: Uint8Array): ImageInfo | null {
const base = { contentType: "image/webp", ext: "webp" as const };
switch (ascii(b, 12, 4)) {
case "VP8 ":
return { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff, ...base };
case "VP8L": {
const bits = (b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24)) >>> 0;
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1, ...base };
}
case "VP8X":
return { width: u24le(b, 24) + 1, height: u24le(b, 27) + 1, ...base };
default:
return null;
}
}
export async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
return [...new Uint8Array(digest)].map((x) => x.toString(16).padStart(2, "0")).join("");
}
/** Spec §4.4: h/w > 1.6 is landing-page art; real posters are ~1.3. */
export function shapeOf(width: number, height: number): Shape {
const r = height / width;
if (r > 1.6) return "tall";
if (r >= 1.15) return "portrait";
if (r >= 0.85) return "square";
if (r < 0.5) return "wide";
return "other";
}
export function typeForShape(shape: Shape): ImageType {
return shape === "portrait" ? "volume" : shape === "wide" ? "banner" : "other";
}
export async function readCapped(res: Response, max = MAX_IMAGE_BYTES): Promise<Uint8Array> {
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > max) {
await res.body?.cancel();
throw new ImageError(`larger than ${Math.round(max / 1024 / 1024)} MB`);
}
if (!res.body) return new Uint8Array(0);
const reader = res.body.getReader();
const parts: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > max) {
await reader.cancel();
throw new ImageError(`larger than ${Math.round(max / 1024 / 1024)} MB`);
}
parts.push(value);
}
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.byteLength;
}
return out;
}
/** Live URL first, then the Wayback copy (spec §4.4). */
export async function inspectCandidate(c: CoverCandidate, transport: Transport, signal?: AbortSignal): Promise<InspectedCover> {
const attempts: [string, boolean][] = [[c.url, true]];
if (c.archivedUrl) attempts.push([c.archivedUrl, false]);
const tried: string[] = [];
for (const [url, live] of attempts) {
try {
const res = await transport.fetch(url, { signal });
if (!res.ok) {
await res.body?.cancel();
tried.push(`${url}: HTTP ${res.status}`);
continue;
}
const bytes = await readCapped(res);
const info = probeImage(bytes);
if (!info) {
tried.push(`${url}: not an image`);
continue;
}
const shape = shapeOf(info.width, info.height);
return {
candidate: c, fetchedFrom: url, live, width: info.width, height: info.height,
sha256: await sha256Hex(bytes), contentType: info.contentType, ext: info.ext, bytes, shape, type: typeForShape(shape),
};
} catch (err) {
if (signal?.aborted) throw err;
tried.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
}
}
throw new ImageError(`Could not download ${c.url} (${tried.join("; ")})`);
}
export async function inspectAll(
candidates: readonly CoverCandidate[],
transport: Transport,
opts: { warn(message: string): void; onProgress?(done: number, total: number): void; signal?: AbortSignal },
): Promise<InspectedCover[]> {
const out: InspectedCover[] = [];
let done = 0;
for (const c of candidates) {
try {
out.push(await inspectCandidate(c, transport, opts.signal));
} catch (err) {
if (opts.signal?.aborted) throw err;
opts.warn(err instanceof Error ? err.message : String(err));
}
opts.onProgress?.(++done, candidates.length);
}
return out;
}
+36
View File
@@ -0,0 +1,36 @@
export class LinkError extends Error {
override name = "LinkError";
}
/** Parses a pasted link; a missing scheme means https. */
export function parseLink(input: string): URL {
const s = input.trim();
if (!s) throw new LinkError("Paste a link first.");
try {
return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`);
} catch {
throw new LinkError(`That doesn't look like a link: ${s}`);
}
}
/**
* Accepts a bare id or any mangabaka.org link to a series. `/9999` redirects to the canonical
* `/manhwa/9999/Tower-of-God` and `/series/9999` is a 404 (spec §4.5), so we take the first
* all-digits path segment.
*/
export function parseMangaBakaSeriesId(input: string): number {
const s = input.trim();
if (/^\d+$/.test(s)) return positive(Number(s), s);
const url = parseLink(s);
if (!/^(www\.)?mangabaka\.org$/i.test(url.hostname)) {
throw new LinkError(`Not a MangaBaka link: ${url.hostname}. Paste a mangabaka.org series link or its number.`);
}
const segment = url.pathname.split("/").find((p) => /^\d+$/.test(p));
if (!segment) throw new LinkError("That MangaBaka link has no series number in it.");
return positive(Number(segment), s);
}
function positive(n: number, input: string): number {
if (!Number.isSafeInteger(n) || n <= 0) throw new LinkError(`Not a MangaBaka series number: ${input}`);
return n;
}
+114
View File
@@ -0,0 +1,114 @@
import type { Transport } from "../transport";
import type {
ImageSnapshot, PreviewResult, PublicImage, SaveResult, SeriesLink, SeriesSummary, SubmissionImage,
} from "./types";
export const API_BASE = "https://api.mangabaka.org";
export const SITE_BASE = "https://mangabaka.org";
export const coverFormUrl = (seriesId: number): string => `${SITE_BASE}/data/submissions/series/${seriesId}/images`;
export class MangaBakaError extends Error {
constructor(message: string, readonly status: number, readonly body?: unknown) {
super(message);
this.name = "MangaBakaError";
}
}
const MAX_PAGES = 50;
/** Talks to api.mangabaka.org directly (CORS is open); the token never goes anywhere else. */
export class MangaBakaClient {
constructor(private readonly transport: Transport, private readonly token?: string) {}
async getSeries(id: number): Promise<SeriesSummary> {
return (await this.get<{ data: SeriesSummary }>(`/v1/series/${id}`)).data;
}
getLinks(id: number): Promise<SeriesLink[]> {
return this.getAllPages<SeriesLink>(`/v1/series/${id}/links?limit=100`);
}
/** Paginated, `limit` ≤ 50 (spec §2.3). */
getImages(id: number): Promise<PublicImage[]> {
return this.getAllPages<PublicImage>(`/v1/series/${id}/images?limit=50`);
}
async getImageSnapshot(id: number): Promise<ImageSnapshot> {
const body = await this.get<{ data: { data: { images: SubmissionImage[] }; version: number } }>(
`/v0/my/submissions/series-images/${id}`, true);
return { version: body.data.version, images: body.data.data.images };
}
async previewImages(id: number, version: number, images: SubmissionImage[]): Promise<PreviewResult> {
return (await this.post<{ data: PreviewResult }>(`/v0/my/submissions/series-images/${id}/preview`,
{ version, data: { images } })).data;
}
/** Files a real edit request, always for review. Call only after the user confirmed the preview. */
async saveImagesForReview(id: number, version: number, images: SubmissionImage[], userNote: string): Promise<SaveResult> {
return (await this.post<{ data: SaveResult }>(`/v0/my/submissions/series-images/${id}`,
{ version, user_note: userNote, save_mode: "review", data: { images } })).data;
}
private headers(auth: boolean): Record<string, string> {
const headers: Record<string, string> = { accept: "application/json" };
if (auth) {
if (!this.token) throw new MangaBakaError("This needs a MangaBaka personal access token.", 401);
headers["x-api-key"] = this.token;
}
return headers;
}
private async get<T>(path: string, auth = false): Promise<T> {
const headers = this.headers(auth);
return this.parse<T>(await this.transport.fetch(`${API_BASE}${path}`, { headers }), path);
}
private async post<T>(path: string, body: unknown): Promise<T> {
const headers = { ...this.headers(true), "content-type": "application/json" };
const res = await this.transport.fetch(`${API_BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
return this.parse<T>(res, path);
}
private async getAllPages<T>(path: string): Promise<T[]> {
const out: T[] = [];
let url: string | null = `${API_BASE}${path}`;
for (let page = 0; url && page < MAX_PAGES; page++) {
const res = await this.transport.fetch(url, { headers: this.headers(false) });
const body = await this.parse<{ data: T[]; pagination?: { next?: string | null } }>(res, path);
out.push(...body.data);
const next = body.pagination?.next ?? null;
url = next && new URL(next).origin === API_BASE ? next : null;
}
return out;
}
private async parse<T>(res: Response, path: string): Promise<T> {
const text = await res.text();
let body: unknown;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = undefined;
}
if (!res.ok) throw new MangaBakaError(explain(res.status, body, path), res.status, body);
if (body === undefined) throw new MangaBakaError(`MangaBaka sent something that isn't JSON for ${path}.`, res.status);
return body as T;
}
}
function explain(status: number, body: unknown, path: string): string {
const detail = body && typeof body === "object" && "message" in body ? String((body as { message: unknown }).message) : "";
switch (status) {
case 401:
return "MangaBaka rejected the token (401). Check your personal access token.";
case 403:
return "MangaBaka refused (403): the submissions API needs the contributor role or 25 approved submissions. Use the copy-and-paste path instead.";
case 404:
return `MangaBaka has no such series (${path}).`;
case 409:
return "The series' covers changed since they were loaded (409).";
default:
return `MangaBaka answered HTTP ${status}${detail ? `: ${detail}` : ""}.`;
}
}
+70
View File
@@ -0,0 +1,70 @@
// Subset of https://mangabaka.org/api.json (downloaded 2026-10-14) that we use. See spec §2.3.
import type { ExistingImage } from "../covers";
import type { ContentRating, ImageType } from "../model";
export interface SeriesSummary {
id: number;
title: string;
type: string;
content_rating: string | null;
}
export interface SeriesLink {
url: string;
name: string;
language: string | null;
}
/** GET /v1/series/{id}/images item. Has no `is_default`. */
export interface PublicImage {
id: number;
series_id: number;
type: ImageType;
language: string;
note: string | null;
content_rating: ContentRating;
hashes: { sha256?: string | null } | null;
}
/** OpenAPI `series-images` item. `work_id`, `note` and `index_numeric` keys are required, even when null. */
export interface SubmissionImage {
id: number | null;
series_id: number | null;
work_id: string | null;
url: string;
index: string | null;
index_numeric: number | null;
language: string;
type: ImageType;
note: string | null;
content_rating: ContentRating;
is_default: boolean;
}
export interface ImageSnapshot {
version: number;
images: SubmissionImage[];
}
export interface SubmissionChange {
field: string;
type: "added" | "removed" | "changed";
identity?: string;
label?: string;
details?: Record<string, { old?: unknown; new?: unknown }>;
}
export interface PreviewResult {
has_changes: boolean;
changes: SubmissionChange[];
}
export interface SaveResult {
submission_id: number;
status: string;
changes: SubmissionChange[];
}
export function toExisting(img: PublicImage): ExistingImage {
return { id: img.id, sha256: img.hashes?.sha256 ?? null, language: img.language, type: img.type };
}
+44
View File
@@ -0,0 +1,44 @@
export type CoverRole = "poster" | "square" | "banner" | "foreground" | "other";
export type ImageType = "volume" | "volume_back" | "chapter" | "season" | "banner" | "audiobook" | "other";
export type ContentRating = "safe" | "suggestive" | "erotica" | "pornographic";
export const CONTENT_RATINGS: readonly ContentRating[] = ["safe", "suggestive", "erotica", "pornographic"];
export type Shape = "tall" | "portrait" | "square" | "wide" | "other";
export type ImageExt = "jpg" | "png" | "gif" | "webp";
/** Wayback history: one snapshot per this many months (spec §4.3). */
export type Granularity = 1 | 3 | 6 | 12;
export const GRANULARITIES: readonly Granularity[] = [1, 3, 6, 12];
export interface SourceSeries {
source: string; // Source.id
externalId: string; // e.g. Webtoons title_no "95"
title: string;
language: string; // MangaBaka language code, e.g. "en", "zh-Hant", or "unknown"
canonicalUrl: string;
archiveUrls: string[]; // URL prefixes (no scheme) to look up in Wayback
mature: boolean;
flags: Record<string, boolean>; // e.g. { canvas: true }
}
export interface CoverCandidate {
url: string; // normalised, hotlinkable
role: CoverRole;
current: boolean; // shown on the live page now
seenAt?: string; // earliest archive timestamp (YYYYMMDDhhmmss) if historical
lastSeenAt?: string; // latest archive timestamp after merging
originalUrl?: string; // as written in the page (absolute), before normalisation
archivedUrl?: string; // Wayback copy of the image itself
}
export interface InspectedCover {
candidate: CoverCandidate;
fetchedFrom: string; // URL that served the bytes (candidate.url, or archivedUrl)
live: boolean; // true if fetchedFrom === candidate.url
width: number;
height: number;
sha256: string; // lowercase hex
contentType: string; // image/jpeg | image/png | image/gif | image/webp
ext: ImageExt;
bytes: Uint8Array;
shape: Shape;
type: ImageType; // suggested MangaBaka type
}
+106
View File
@@ -0,0 +1,106 @@
import { dedupeByHash, mergeByUrl, pickDefault, splitExisting, type ExistingImage } from "./covers";
import { waybackHistory } from "./history/wayback";
import { inspectAll } from "./images";
import { LinkError, parseLink, parseMangaBakaSeriesId } from "./links";
import { MangaBakaClient } from "./mangabaka/client";
import { toExisting, type SeriesSummary } from "./mangabaka/types";
import type { CoverCandidate, Granularity, InspectedCover, SourceSeries } from "./model";
import { SOURCES, findSource } from "./sources/registry";
import type { Source } from "./sources/source";
import type { Transport } from "./transport";
export interface RunInput {
mangabakaLink: string;
sourceLink: string;
/** Wayback granularity in months, or null for no history. */
history: Granularity | null;
}
export type Stage = "series" | "source" | "history" | "inspect" | "compare";
export interface RunEvents {
onStage?(stage: Stage): void;
onProgress?(stage: Stage, done: number, total: number): void;
onWarning?(message: string): void;
/** Cancels the whole run. */
signal?: AbortSignal;
/** Stops the history early and carries on with what it found (spec §4.3). */
stopHistory?: AbortSignal;
}
export interface RunResult {
seriesId: number;
mbSeries: SeriesSummary;
source: Source;
series: SourceSeries;
linkMatches: boolean;
fresh: InspectedCover[];
present: { cover: InspectedCover; existing: ExistingImage }[];
suggestedDefault: InspectedCover | null;
warnings: string[];
}
/** Spec §3. The only place the stages are composed. */
export async function runCovers(
input: RunInput,
deps: { transport: Transport; sources?: readonly Source[] },
events: RunEvents = {},
): Promise<RunResult> {
const warnings: string[] = [];
const warn = (message: string) => {
warnings.push(message);
events.onWarning?.(message);
};
const sources = deps.sources ?? SOURCES;
const seriesId = parseMangaBakaSeriesId(input.mangabakaLink);
const sourceUrl = parseLink(input.sourceLink);
const source = findSource(sourceUrl, sources);
if (!source) {
throw new LinkError(`No supported source for ${sourceUrl.hostname}. Supported: ${sources.map((s) => s.name).join(", ")}.`);
}
const { transport } = deps;
const ctx = { transport, signal: events.signal };
events.onStage?.("series");
const mb = new MangaBakaClient(transport);
const [mbSeries, links, images] = await Promise.all([mb.getSeries(seriesId), mb.getLinks(seriesId), mb.getImages(seriesId)]);
events.onStage?.("source");
const series = await source.resolve(sourceUrl, ctx);
const linkMatches = links.some((l) => source.isSameSeries(l.url, series));
if (!linkMatches) {
warn(`MangaBaka series ${seriesId} has no link to this ${source.name} series (${series.canonicalUrl}). Check that the two links belong together.`);
}
if (series.flags.ageGated) {
warn(`${source.name} shows this series only to signed-in adults, so its current covers couldn't be read. Tick "Old covers from the Wayback Machine" for archived ones.`);
}
if (series.language === "unknown") warn(`Couldn't tell the language of ${series.canonicalUrl}; covers will be marked "unknown".`);
const current = await source.currentCovers(series, ctx);
let historical: CoverCandidate[] = [];
if (input.history !== null) {
events.onStage?.("history");
const signals = [events.signal, events.stopHistory].filter((s): s is AbortSignal => !!s);
historical = await waybackHistory(source, series, {
every: input.history,
transport,
warn,
signal: signals.length ? AbortSignal.any(signals) : undefined,
onProgress: (done, total) => events.onProgress?.("history", done, total),
});
if (events.signal?.aborted) throw events.signal.reason;
if (source.historicalCovers) historical.push(...(await source.historicalCovers(series, ctx)));
}
events.onStage?.("inspect");
const inspected = await inspectAll(mergeByUrl([...current, ...historical]), transport, {
warn,
signal: events.signal,
onProgress: (done, total) => events.onProgress?.("inspect", done, total),
});
events.onStage?.("compare");
const { fresh, present } = splitExisting(dedupeByHash(inspected), images.map(toExisting));
const suggestedDefault = pickDefault([...fresh, ...present.map((p) => p.cover)]);
return { seriesId, mbSeries, source, series, linkMatches, fresh, present, suggestedDefault, warnings };
}
+161
View File
@@ -0,0 +1,161 @@
import type { HostId } from "./hosting/host";
import type { ContentRating, InspectedCover } from "./model";
import type { RunInput, RunResult } from "./pipeline";
import type { ReviewCard } from "./session";
import type { Source } from "./sources/source";
// Spec 002 §3: the page's history of searches. Plain data, so IndexedDB stores it as is.
/** What was done with a search, with ISO times. */
export interface Marks {
downloaded?: string;
rehosted?: { at: string; host: HostId };
filed?: { at: string; submissionId: number };
}
/** A review card without its cover (the cover lives once, in the result) and without its transient error. */
export type SavedCard = Omit<ReviewCard, "cover" | "error"> & { sha256: string };
export interface SavedSearch {
version: 1;
seriesId: number;
input: RunInput;
rating: ContentRating;
result: Omit<RunResult, "source"> & { sourceId: string; sourceName: string };
cards: SavedCard[];
makeDefault: boolean;
marks: Marks;
foundAt: string;
savedAt: string;
/** Image bytes in the entry, for pruning. */
bytes: number;
}
/** The small part the History list reads. */
export interface SearchSummary {
seriesId: number;
title: string;
sourceName: string;
language: string;
fresh: number;
present: number;
marks: Marks;
foundAt: string;
savedAt: string;
bytes: number;
thumb: { bytes: Uint8Array; contentType: string } | null;
}
export interface HistoryStore {
/** Newest first. */
list(): Promise<SearchSummary[]>;
get(seriesId: number): Promise<SavedSearch | null>;
put(search: SavedSearch): Promise<void>;
remove(seriesId: number): Promise<void>;
clear(): Promise<void>;
}
export class SavedSearchError extends Error {
override name = "SavedSearchError";
}
export const HISTORY_LIMITS = { count: 30, bytes: 300 * 2 ** 20 };
const coversOf = (r: Pick<RunResult, "fresh" | "present">): InspectedCover[] => [...r.fresh, ...r.present.map((p) => p.cover)];
export function toSaved(a: {
result: RunResult;
cards: readonly ReviewCard[];
makeDefault: boolean;
input: RunInput;
rating: ContentRating;
marks: Marks;
foundAt: string;
now: Date;
}): SavedSearch {
const { source, ...rest } = a.result;
return {
version: 1,
seriesId: a.result.seriesId,
input: a.input,
rating: a.rating,
result: { ...rest, sourceId: source.id, sourceName: source.name },
cards: a.cards.map(({ cover, error: _error, ...card }) => ({ ...card, sha256: cover.sha256 })),
makeDefault: a.makeDefault,
marks: a.marks,
foundAt: a.foundAt,
savedAt: a.now.toISOString(),
bytes: coversOf(a.result).reduce((sum, c) => sum + c.bytes.length, 0),
};
}
export function summarize(s: SavedSearch): SearchSummary {
const r = s.result;
const thumb = r.suggestedDefault ?? r.fresh[0] ?? r.present[0]?.cover ?? null;
return {
seriesId: s.seriesId, title: r.mbSeries.title, sourceName: r.sourceName, language: r.series.language,
fresh: r.fresh.length, present: r.present.length, marks: s.marks, foundAt: s.foundAt, savedAt: s.savedAt, bytes: s.bytes,
thumb: thumb ? { bytes: thumb.bytes, contentType: thumb.contentType } : null,
};
}
const expired = (card: Pick<ReviewCard, "hosting">, now: number) => card.hosting?.until != null && card.hosting.until <= now;
/** A saved search, ready for `Session.restore`. Links that have expired since are dropped (spec 002 §3.3). */
export function fromSaved(s: SavedSearch, sources: readonly Source[], now: number) {
const { sourceId, sourceName: _name, ...rest } = s.result;
const source = sources.find((x) => x.id === sourceId);
if (!source) throw new SavedSearchError(`This entry comes from a source this version doesn't know (${sourceId}).`);
const result: RunResult = { ...rest, source };
const bySha = new Map(result.fresh.map((c) => [c.sha256, c]));
const cards: ReviewCard[] = s.cards.flatMap(({ sha256, ...card }) => {
const cover = bySha.get(sha256);
if (!cover) return [];
const gone = expired(card, now);
return [{ ...card, cover, error: null, hostedUrl: gone ? null : card.hostedUrl, hosting: gone ? undefined : card.hosting }];
});
return { result, cards, makeDefault: s.makeDefault, input: s.input, rating: s.rating, marks: s.marks, foundAt: s.foundAt };
}
/** After searching a series again: the user's edits of covers that are still there carry over, by hash. */
export function carryEdits(previous: readonly SavedCard[], next: ReviewCard[], now: number): void {
const before = new Map(previous.map((c) => [c.sha256, c]));
for (const card of next) {
const old = before.get(card.cover.sha256);
if (!old) continue;
Object.assign(card, { selected: old.selected, type: old.type, note: old.note, contentRating: old.contentRating });
if (old.hostedUrl && !expired(old, now)) Object.assign(card, { hostedUrl: old.hostedUrl, hosting: old.hosting });
}
}
/** Ids to drop: beyond the newest `count`, or once the newest entries already hold `bytes`. The newest stays. */
export function pruneIds(
summaries: readonly Pick<SearchSummary, "seriesId" | "savedAt" | "bytes">[],
limits = HISTORY_LIMITS,
): number[] {
const newest = [...summaries].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
let total = 0;
return newest.flatMap((s, i) => {
total += s.bytes;
return i > 0 && (i >= limits.count || total > limits.bytes) ? [s.seriesId] : [];
});
}
export async function saveAndPrune(store: HistoryStore, search: SavedSearch, limits = HISTORY_LIMITS): Promise<number[]> {
await store.put(search);
const drop = pruneIds(await store.list(), limits);
for (const id of drop) await store.remove(id);
return drop;
}
/** For tests, and a stand-in when the browser has no IndexedDB. */
export function memoryHistory(): HistoryStore {
const m = new Map<number, SavedSearch>();
return {
list: async () => [...m.values()].map(summarize).sort((a, b) => b.savedAt.localeCompare(a.savedAt)),
get: async (id) => m.get(id) ?? null,
put: async (s) => void m.set(s.seriesId, s),
remove: async (id) => void m.delete(id),
clear: async () => m.clear(),
};
}
+75
View File
@@ -0,0 +1,75 @@
import type { SubmissionImage } from "../mangabaka/types";
import type { ContentRating, ImageType, InspectedCover, SourceSeries } from "../model";
/** One new cover as the user left it in the review list. */
export interface CoverChoice {
cover: InspectedCover;
url: string;
type: ImageType;
note: string;
contentRating: ContentRating;
}
/** Which image becomes the series' one main cover; null leaves existing defaults alone. */
export type DefaultChoice = { kind: "new"; sha256: string } | { kind: "existing"; id: number } | null;
export interface CoverSeed {
seriesId: number;
userNote: string;
images: SubmissionImage[];
added: CoverChoice[];
}
export class SeedError extends Error {
override name = "SeedError";
}
/** Spec §4.8. `images` replaces the whole set, so every existing image is carried over. */
export function buildCoverSeed(input: {
seriesId: number;
series: SourceSeries;
sourceName: string;
existing: readonly SubmissionImage[];
added: readonly CoverChoice[];
makeDefault: DefaultChoice;
}): CoverSeed {
const { seriesId, series, existing, added } = input;
const target = effectiveDefault(input.makeDefault, existing, added);
const images: SubmissionImage[] = [
...existing.map((e) => ({ ...e, is_default: target ? target.kind === "existing" && target.id === e.id : e.is_default })),
...added.map((c): SubmissionImage => ({
id: null, series_id: seriesId, work_id: null, url: c.url, index: null, index_numeric: null,
language: series.language, type: c.type, note: c.note, content_rating: c.contentRating,
is_default: target?.kind === "new" && target.sha256 === c.cover.sha256,
})),
];
const defaults = images.filter((i) => i.is_default).length;
if (images.length > 0 && defaults !== 1) {
throw new SeedError(`MangaBaka needs exactly one main cover; this set would have ${defaults}. Tick "Make this the series' main cover".`);
}
return { seriesId, userNote: `Add ${input.sourceName} covers from ${series.canonicalUrl}`, images, added: [...added] };
}
function effectiveDefault(d: DefaultChoice, existing: readonly SubmissionImage[], added: readonly CoverChoice[]): DefaultChoice {
if (d?.kind === "new" && added.some((c) => c.cover.sha256 === d.sha256)) return d;
if (d?.kind === "existing" && existing.some((e) => e.id === d.id)) return d;
return null;
}
export function withHostedUrls(seed: CoverSeed, hosted: ReadonlyMap<string, string>): CoverSeed {
const added = seed.added.map((c) => ({ ...c, url: hosted.get(c.cover.sha256) ?? c.url }));
const newUrl = new Map(seed.added.map((c, i) => [c.url, added[i]!.url]));
const images = seed.images.map((img) => (img.id === null ? { ...img, url: newUrl.get(img.url) ?? img.url } : img));
return { ...seed, added, images };
}
/** For users without API access: what to paste into MangaBaka's cover form, cover by cover. */
export function manualEntryText(choices: readonly CoverChoice[], language: string, defaultSha256: string | null): string {
return choices
.map((c) => [
c.url,
`type: ${c.type} · language: ${language} · rating: ${c.contentRating}${c.cover.sha256 === defaultSha256 ? " · main cover" : ""}`,
`note: ${c.note}`,
].join("\n"))
.join("\n\n");
}
+269
View File
@@ -0,0 +1,269 @@
import { coverNote } from "./covers";
import { downloadBundle } from "./download";
import { HostError, linkExpiry, type HostId, type HostImage, type HostOptions } from "./hosting/host";
import { HOSTS } from "./hosting/registry";
import { cacheKey, rehostAll, type RehostResult } from "./hosting/rehost";
import { MangaBakaClient, MangaBakaError, coverFormUrl } from "./mangabaka/client";
import type { PreviewResult, SaveResult, SubmissionImage } from "./mangabaka/types";
import type { ContentRating, ImageType, InspectedCover } from "./model";
import { runCovers, type RunEvents, type RunInput, type RunResult } from "./pipeline";
import { SeedError, buildCoverSeed, manualEntryText, type CoverChoice, type CoverSeed, type DefaultChoice } from "./seed/covers";
import type { Source } from "./sources/source";
import type { Transport } from "./transport";
import type { ZipEntry } from "./zip";
/** Browser storage on the page, GM storage in the userscript, a Map in tests. */
export interface KeyValueStore {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}
export function memoryStore(): KeyValueStore {
const m = new Map<string, string>();
return { get: (k) => m.get(k) ?? null, set: (k, v) => void m.set(k, v), remove: (k) => void m.delete(k) };
}
export const TOKEN_KEY = "mbseed.token";
export const HOSTED_KEY = "mbseed.hosted";
const HOSTED_LIMIT = 500;
export const loadToken = (store: KeyValueStore): string | null => store.get(TOKEN_KEY);
export function saveToken(store: KeyValueStore, token: string | null): void {
if (token) store.set(TOKEN_KEY, token);
else store.remove(TOKEN_KEY);
}
export interface ReviewCard {
cover: InspectedCover;
selected: boolean;
type: ImageType;
note: string;
contentRating: ContentRating;
hostedUrl: string | null;
/** Who hosts `hostedUrl`, its rehost cache key, and when it expires (epoch ms; null = never). */
hosting?: { by: HostId; key: string | null; until: number | null };
error: string | null;
}
export interface SessionDeps {
transport: Transport;
serverBase: string | null;
catboxVia: "server" | "direct";
store: KeyValueStore;
/** The userscript remembers catbox uploads across runs; the page only within a session (spec §4.6). */
persistHostedUrls: boolean;
sources?: readonly Source[];
}
export interface PreparedSubmission {
version: number;
existing: SubmissionImage[];
seed: CoverSeed;
preview: PreviewResult;
}
export type SubmitOutcome =
| { status: "saved"; result: SaveResult }
| { status: "changed"; prepared: PreparedSubmission; reason: string };
const hostImage = (c: InspectedCover): HostImage => ({
sourceUrl: c.fetchedFrom, live: c.live, bytes: c.bytes, sha256: c.sha256, contentType: c.contentType, ext: c.ext,
});
/** The review state behind both front ends: cards, main cover, rehosting, manual text, token flow. */
export class Session {
result: RunResult | null = null;
/** The search behind `result`: its links name the series in downloads and history. */
input: RunInput | null = null;
cards: ReviewCard[] = [];
/** "Make this the series' main cover" (spec §4.5). */
makeDefault = true;
private conflicts = 0;
private readonly hostedCache: Map<string, string>;
/** When each cached link expires (epoch ms; null = never). A cached link must not outlive its host. */
private readonly cacheUntil = new Map<string, number | null>();
constructor(private readonly deps: SessionDeps) {
this.hostedCache = deps.persistHostedUrls ? loadHosted(deps.store) : new Map();
}
async run(input: RunInput, events: RunEvents = {}, rating: ContentRating = "safe"): Promise<RunResult> {
const result = await runCovers(input, { transport: this.deps.transport, sources: this.deps.sources }, events);
this.load(result, rating);
this.input = input;
return result;
}
load(result: RunResult, rating: ContentRating = "safe"): void {
this.result = result;
this.conflicts = 0;
this.makeDefault = result.suggestedDefault !== null;
this.cards = result.fresh.map((cover) => ({
cover,
selected: cover.shape !== "tall",
type: cover.type,
note: coverNote(cover, result.source.name),
contentRating: rating,
hostedUrl: null,
error: null,
}));
}
/** A saved search, back as it was left (spec 002 §3.3). Its hosted links refill the rehost cache. */
restore(result: RunResult, cards: ReviewCard[], makeDefault: boolean, input: RunInput | null): void {
this.result = result;
this.cards = cards;
this.makeDefault = makeDefault && result.suggestedDefault !== null;
this.input = input;
this.conflicts = 0;
const now = Date.now();
for (const c of cards) {
if (c.hostedUrl && c.hosting?.key && !expiresSoon(c.hosting.until, now)) {
this.hostedCache.set(c.hosting.key, c.hostedUrl);
this.cacheUntil.set(c.hosting.key, c.hosting.until);
}
}
}
update(sha256: string, patch: Partial<Pick<ReviewCard, "selected" | "type" | "note" | "contentRating">>): void {
const card = this.cards.find((c) => c.cover.sha256 === sha256);
if (!card) throw new Error(`No cover ${sha256}`);
Object.assign(card, patch);
}
setRatingForAll(rating: ContentRating): void {
for (const c of this.cards) c.contentRating = rating;
}
selected(): ReviewCard[] {
return this.cards.filter((c) => c.selected);
}
defaultChoice(): DefaultChoice {
const suggested = this.result?.suggestedDefault;
if (!this.makeDefault || !suggested) return null;
const present = this.result!.present.find((p) => p.cover.sha256 === suggested.sha256);
if (present) return { kind: "existing", id: present.existing.id };
return this.selected().some((c) => c.cover.sha256 === suggested.sha256) ? { kind: "new", sha256: suggested.sha256 } : null;
}
choices(): CoverChoice[] {
return this.selected().map((c) => ({
cover: c.cover, url: c.hostedUrl ?? c.cover.fetchedFrom, type: c.type, note: c.note, contentRating: c.contentRating,
}));
}
manualText(): string {
const d = this.defaultChoice();
return manualEntryText(this.choices(), this.requireResult().series.language, d?.kind === "new" ? d.sha256 : null);
}
/** The selected covers and covers.txt, for "Download selected (ZIP)" (spec 002 §2). */
bundle(now = new Date()): { zipName: string; files: ZipEntry[] } {
const r = this.requireResult();
const d = this.defaultChoice();
return downloadBundle({
seriesId: r.seriesId, mbTitle: r.mbSeries.title, mbLink: this.input?.mangabakaLink ?? coverFormUrl(r.seriesId),
sourceName: r.source.name, series: r.series, cards: this.selected(), mainSha256: d?.kind === "new" ? d.sha256 : null, now,
});
}
/** Explicit user action only (spec §4.6). */
async rehost(hostId: HostId, options: HostOptions, onProgress?: (done: number, total: number) => void): Promise<RehostResult> {
const cards = this.selected();
const now = Date.now();
for (const [key, until] of this.cacheUntil) {
if (expiresSoon(until, now)) {
this.hostedCache.delete(key);
this.cacheUntil.delete(key);
}
}
const cachedBefore = new Set(this.hostedCache.keys());
const res = await rehostAll(
cards.map((c) => hostImage(c.cover)),
HOSTS[hostId],
{ transport: this.deps.transport, serverBase: this.deps.serverBase, catboxVia: this.deps.catboxVia, options },
this.hostedCache,
onProgress,
);
const fresh = linkExpiry(hostId, options, now);
for (const c of cards) {
c.hostedUrl = res.hosted.get(c.cover.sha256) ?? null;
const key = cacheKey(hostId, options, c.cover.sha256);
// A link reused from the cache keeps the expiry it was uploaded with.
const until = key && cachedBefore.has(key) ? (this.cacheUntil.get(key) ?? fresh) : fresh;
if (key && c.hostedUrl) this.cacheUntil.set(key, until);
c.hosting = c.hostedUrl ? { by: hostId, key, until } : undefined;
c.error = res.failures.find((f) => f.sha256 === c.cover.sha256)?.message ?? null;
}
if (this.deps.persistHostedUrls) saveHosted(this.deps.store, this.hostedCache);
return res;
}
async preview(token: string): Promise<PreparedSubmission> {
const r = this.requireResult();
if (!this.selected().length) throw new SeedError("Select at least one cover first.");
const mb = new MangaBakaClient(this.deps.transport, token);
const snapshot = await mb.getImageSnapshot(r.seriesId);
const seed = this.seed(snapshot.images);
const preview = await mb.previewImages(r.seriesId, snapshot.version, seed.images);
return { version: snapshot.version, existing: snapshot.images, seed, preview };
}
/** After the user confirmed `prepared`: rehost, then file the edit request for review (spec §4.9). */
async submit(token: string, prepared: PreparedSubmission, hostId: HostId, options: HostOptions): Promise<SubmitOutcome> {
const r = this.requireResult();
const uploads = await this.rehost(hostId, options);
if (uploads.allFailed) throw new HostError(`Every upload failed: ${uploads.failures[0]?.message ?? "unknown error"}`);
if (uploads.failures.length) {
for (const f of uploads.failures) this.update(f.sha256, { selected: false });
return {
status: "changed",
prepared: await this.preview(token),
reason: `${uploads.failures.length} upload(s) failed and were left out. Check the new preview.`,
};
}
const seed = this.seed(prepared.existing);
const mb = new MangaBakaClient(this.deps.transport, token);
try {
return { status: "saved", result: await mb.saveImagesForReview(r.seriesId, prepared.version, seed.images, seed.userNote) };
} catch (err) {
if (err instanceof MangaBakaError && err.status === 409 && this.conflicts++ === 0) {
return { status: "changed", prepared: await this.preview(token), reason: "The series' covers changed meanwhile. Check the new preview." };
}
throw err;
}
}
private seed(existing: SubmissionImage[]): CoverSeed {
const r = this.requireResult();
return buildCoverSeed({
seriesId: r.seriesId, series: r.series, sourceName: r.source.name, existing, added: this.choices(), makeDefault: this.defaultChoice(),
});
}
private requireResult(): RunResult {
if (!this.result) throw new Error("Run the covers search first.");
return this.result;
}
}
/** Links this close to expiry are uploaded again rather than reused. */
const EXPIRY_MARGIN_MS = 5 * 60_000;
const expiresSoon = (until: number | null | undefined, now: number): boolean => until != null && until <= now + EXPIRY_MARGIN_MS;
/** Only catbox links are kept across runs: litterbox links die within 72 hours. */
function loadHosted(store: KeyValueStore): Map<string, string> {
try {
const raw = JSON.parse(store.get(HOSTED_KEY) ?? "{}") as Record<string, unknown>;
return new Map(Object.entries(raw).filter((e): e is [string, string] => typeof e[1] === "string"));
} catch {
return new Map();
}
}
function saveHosted(store: KeyValueStore, cache: Map<string, string>): void {
const kept = [...cache].filter(([k]) => k.startsWith("catbox:")).slice(-HOSTED_LIMIT);
store.set(HOSTED_KEY, JSON.stringify(Object.fromEntries(kept)));
}
+57
View File
@@ -0,0 +1,57 @@
import type { CoverCandidate, SourceSeries } from "../../model";
import { SourceError, type Source, type SourceContext } from "../source";
import { extractCovers, parseOverview, type Overview } from "./parse";
import {
API_HOST, IMAGE_HOSTS, PAGE_HOSTS, contentUrl, isKakaoPageUrl, isSameKakaoSeries, normalizeImageUrl, overviewUrl,
parseKakaoPageUrl,
} from "./urls";
/** Covers read by resolve(), so currentCovers() doesn't ask twice. */
const currentBySeries = new WeakMap<SourceSeries, CoverCandidate[]>();
/** The page is rendered in the browser; its data endpoint has what we need (spec 004 §1). */
async function fetchOverview(id: string, ctx: SourceContext): Promise<Overview> {
const res = await ctx.transport.fetch(overviewUrl(id), { signal: ctx.signal });
const body: unknown = await res.json().catch(() => null);
if (!res.ok && !body) throw new SourceError(`KakaoPage answered HTTP ${res.status} for series ${id}.`);
const overview = parseOverview(body);
if (overview.id !== id) throw new SourceError(`KakaoPage sent series ${overview.id} when asked for ${id}.`);
return overview;
}
export const kakaopage: Source = {
id: "kakaopage",
name: "KakaoPage",
hosts: [...PAGE_HOSTS, API_HOST, ...IMAGE_HOSTS],
imageHosts: [...IMAGE_HOSTS],
matches: (url) => isKakaoPageUrl(url),
async resolve(url, ctx) {
const id = parseKakaoPageUrl(url);
const o = await fetchOverview(id, ctx);
const series: SourceSeries = {
source: "kakaopage",
externalId: id,
title: o.title,
language: o.language,
canonicalUrl: contentUrl(id),
archiveUrls: [`page.kakao.com/content/${id}`, `page.kakao.com/home?seriesId=${id}`],
// 15+ and 19+ titles: suggest a rating above "safe" (spec 004 §2).
mature: o.ageGrade >= 15,
flags: { novel: o.novel },
};
currentBySeries.set(series, o.covers.map((c) => ({ ...c, current: true })));
return series;
},
async currentCovers(series, ctx) {
const cached = currentBySeries.get(series);
if (cached) return cached;
return (await fetchOverview(series.externalId, ctx)).covers.map((c) => ({ ...c, current: true }));
},
extractCovers: (html, pageUrl) => extractCovers(html, pageUrl),
normalizeImageUrl: (url) => normalizeImageUrl(url),
isSameSeries: (linkUrl, series) => isSameKakaoSeries(linkUrl, series.externalId),
};
+89
View File
@@ -0,0 +1,89 @@
import { metaContent } from "../../html";
import type { CoverCandidate, CoverRole } from "../../model";
import { SourceError } from "../source";
import { normalizeImageUrl } from "./urls";
export interface Overview {
id: string;
title: string;
language: string;
ageGrade: number;
novel: boolean;
covers: CoverCandidate[];
}
interface OverviewContent {
series_id?: number;
title?: string;
lang?: string;
age_grade?: number;
category?: string;
thumbnail?: string | null;
land_thumbnail?: string | null;
}
/** `GET …/content/overview?series_id=` (spec 004 §1). */
export function parseOverview(body: unknown): Overview {
const b = body as { result?: { content?: OverviewContent } | null; message?: string } | null;
const c = typeof b === "object" && b !== null ? b.result?.content : undefined;
if (!c?.series_id) {
const why = typeof b === "object" && b !== null && typeof b.message === "string" ? `: ${b.message}` : "";
throw new SourceError(`KakaoPage didn't return this series${why}`);
}
return {
id: String(c.series_id),
title: c.title?.trim() || `series ${c.series_id}`,
language: c.lang || "ko",
ageGrade: c.age_grade ?? 0,
novel: c.category === "웹소설",
covers: candidates([["poster", c.thumbnail], ["banner", c.land_thumbnail]]),
};
}
/** A Wayback capture: the series' own thumbnails (2020 pages) and the og:image (spec 004 §2). */
export function extractCovers(html: string, pageUrl: URL): CoverCandidate[] {
const series = nextData(html)?.props?.initialState?.series?.series;
const og = metaContent(html, "og:image");
return candidates(
[["poster", series?.thumbnailUrl], ["banner", series?.landThumbnailUrl], ["other", og && !/\/shared\/ogImage\./.test(og) ? og : null]],
pageUrl,
);
}
interface NextData {
props?: { initialState?: { series?: { series?: { thumbnailUrl?: string; landThumbnailUrl?: string } } } };
}
function nextData(html: string): NextData | null {
const m = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(html);
if (!m) return null;
try {
return JSON.parse(m[1]!) as NextData;
} catch {
return null;
}
}
/** The URL as the page wrote it (for the Wayback copy of the image); a bare key has none. */
function absolute(raw: string): string | null {
const s = raw.trim();
if (!/^(https?:)?\/\//.test(s)) return null;
try {
return new URL(s.startsWith("//") ? `https:${s}` : s).toString();
} catch {
return null;
}
}
function candidates(found: [CoverRole, string | null | undefined][], base?: URL): CoverCandidate[] {
const seen = new Set<string>();
const out: CoverCandidate[] = [];
for (const [role, raw] of found) {
if (!raw?.trim()) continue;
const url = normalizeImageUrl(raw, base);
if (!url.startsWith("https://") || seen.has(url)) continue;
seen.add(url);
out.push({ url, role, current: false, originalUrl: absolute(raw) ?? url });
}
return out;
}
+54
View File
@@ -0,0 +1,54 @@
import { LinkError } from "../../links";
export const PAGE_HOSTS: ReadonlySet<string> = new Set(["page.kakao.com", "m.page.kakao.com"]);
export const API_HOST = "bff-page.kakao.com";
export const IMAGE_HOST = "page-images.kakaoentcdn.com";
/** Older pages name dn-img-page.kakao.com; the same keys answer on IMAGE_HOST (spec 004 §1). */
export const IMAGE_HOSTS: ReadonlySet<string> = new Set([IMAGE_HOST, "dn-img-page.kakao.com"]);
const CONTENT_PATH = /^\/content\/(\d+)(?:\/|$)/;
/** An image key as the API and old pages give it: "mW3j5/dJMcaacSrAK/WVoJJAYNlS9rayXUWi6AY0". */
const BARE_KEY = /^[A-Za-z0-9]+\/[A-Za-z0-9]+\/[A-Za-z0-9]+$/;
function idOf(url: URL): string | null {
if (!PAGE_HOSTS.has(url.hostname)) return null;
const content = CONTENT_PATH.exec(url.pathname)?.[1];
if (content) return content;
const home = url.pathname.replace(/\/+$/, "") === "/home" ? url.searchParams.get("seriesId") : null;
return home && /^\d+$/.test(home) ? home : null;
}
export const isKakaoPageUrl = (url: URL): boolean => idOf(url) !== null;
export function parseKakaoPageUrl(url: URL): string {
const id = idOf(url);
if (id) return id;
if (!PAGE_HOSTS.has(url.hostname)) throw new LinkError(`Not a KakaoPage link: ${url.hostname}`);
throw new LinkError("That KakaoPage link isn't a series. Use the series page (page.kakao.com/content/…).");
}
export const overviewUrl = (id: string): string => `https://${API_HOST}/api/gateway/api/v1/content/overview?series_id=${id}`;
export const contentUrl = (id: string): string => `https://page.kakao.com/content/${id}`;
const originalFor = (kid: string) => `https://${IMAGE_HOST}/download/resource?kid=${kid}&filename=o1`;
/** The original image for a key or any of its URLs; other URLs pass through. */
export function normalizeImageUrl(raw: string, base?: string | URL): string {
const s = raw.trim();
if (BARE_KEY.test(s)) return originalFor(s);
let u: URL;
try {
u = new URL(s.startsWith("//") ? `https:${s}` : s, base);
} catch {
return s;
}
const kid = u.searchParams.get("kid");
return IMAGE_HOSTS.has(u.hostname) && u.pathname === "/download/resource" && kid && BARE_KEY.test(kid) ? originalFor(kid) : u.toString();
}
export function isSameKakaoSeries(linkUrl: string, id: string): boolean {
try {
return idOf(new URL(linkUrl)) === id;
} catch {
return false;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { WAYBACK_HOST } from "../transport";
import type { Source } from "./source";
import { kakaopage } from "./kakaopage";
import { tapas } from "./tapas";
import { webtoons } from "./webtoons";
export const SOURCES: readonly Source[] = [webtoons, tapas, kakaopage];
export function findSource(url: URL, sources: readonly Source[] = SOURCES): Source | undefined {
return sources.find((s) => s.matches(url));
}
/** What `/api/fetch` may fetch: every source's hosts plus Wayback (spec §4.10). */
/** Where cover images may come from: what `/api/upload` will rehost (directly or as a Wayback copy). */
export function allowedImageHosts(sources: readonly Source[] = SOURCES): Set<string> {
return new Set(sources.flatMap((s) => s.imageHosts));
}
export function allowedFetchHosts(sources: readonly Source[] = SOURCES): Set<string> {
return new Set([...sources.flatMap((s) => s.hosts), WAYBACK_HOST]);
}
+31
View File
@@ -0,0 +1,31 @@
import type { CoverCandidate, SourceSeries } from "../model";
import type { Transport } from "../transport";
export interface SourceContext {
transport: Transport;
signal?: AbortSignal;
}
export class SourceError extends Error {
override name = "SourceError";
}
/** Extension point (spec §4.1): a new source is one module plus one line in registry.ts. */
export interface Source {
/** Stable id, e.g. "webtoons". Used in notes and output. */
readonly id: string;
/** Human-readable name, e.g. "WEBTOON". */
readonly name: string;
/** Hosts this source fetches from; the server proxy allowlist is built from these. */
readonly hosts: readonly string[];
/** Hosts its cover images live on, current and historical; the only sources `/api/upload` rehosts from. */
readonly imageHosts: readonly string[];
matches(url: URL): boolean;
resolve(url: URL, ctx: SourceContext): Promise<SourceSeries>;
currentCovers(series: SourceSeries, ctx: SourceContext): Promise<CoverCandidate[]>;
/** Pure extraction from a series page's HTML. Sources that implement it get Wayback history. */
extractCovers?(html: string, pageUrl: URL): CoverCandidate[];
historicalCovers?(series: SourceSeries, ctx: SourceContext): Promise<CoverCandidate[]>;
normalizeImageUrl(url: string): string;
isSameSeries(linkUrl: string, series: SourceSeries): boolean;
}
+91
View File
@@ -0,0 +1,91 @@
import type { CoverCandidate, SourceSeries } from "../../model";
import { SourceError, type Source, type SourceContext } from "../source";
import { ageGateTarget, episodeSeriesId, extractCovers, parseInfoPage } from "./parse";
import {
IMAGE_HOST, IMAGE_HOSTS, LEGACY_IMAGE_HOST, TAPAS_HOSTS, episodeUrl, infoUrl, isSameTapasSeries, isTapasUrl,
normalizeImageUrl, parseTapasUrl, slugOf,
} from "./urls";
/** Covers parsed by resolve(), so currentCovers() doesn't fetch the page twice. */
const currentBySeries = new WeakMap<SourceSeries, CoverCandidate[]>();
async function fetchHtml(url: string, ctx: SourceContext): Promise<string> {
const res = await ctx.transport.fetch(url, { signal: ctx.signal });
if (!res.ok) {
await res.body?.cancel();
throw new SourceError(`Tapas answered HTTP ${res.status} for ${url}.`);
}
return res.text();
}
/** "dog-and-bird-mature" → "Dog and bird mature": all we know of a series behind the age check. */
const titleFromSlug = (slug: string) => {
const words = slug.replace(/[-_]+/g, " ").trim();
return words.charAt(0).toUpperCase() + words.slice(1);
};
export const tapas: Source = {
id: "tapas",
name: "Tapas",
hosts: [...TAPAS_HOSTS, IMAGE_HOST, LEGACY_IMAGE_HOST],
imageHosts: [...IMAGE_HOSTS],
matches: (url) => isTapasUrl(url),
async resolve(url, ctx) {
const ref = parseTapasUrl(url);
let key: string;
if (ref.kind === "episode") {
const id = episodeSeriesId(await fetchHtml(episodeUrl(ref.id), ctx));
if (!id) throw new SourceError(`Couldn't tell which series Tapas episode ${ref.id} belongs to. Use the series page.`);
key = id;
} else {
key = ref.key;
}
const html = await fetchHtml(infoUrl(key), ctx);
// Mature series: only signed-in adults see the page (spec 003 §2). History can still find covers.
const gate = ageGateTarget(html);
if (gate) {
const slug = slugOf(gate) ?? key;
const series: SourceSeries = {
source: "tapas", externalId: /^\d+$/.test(key) ? key : slug, title: titleFromSlug(slug), language: "en",
canonicalUrl: infoUrl(slug), archiveUrls: [`tapas.io/series/${slug}`], mature: true,
flags: { novel: false, ageGated: true },
};
currentBySeries.set(series, []);
return series;
}
const page = parseInfoPage(html, new URL(infoUrl(key)));
const canonicalUrl = page.canonicalUrl ?? infoUrl(key);
const slug = slugOf(canonicalUrl) ?? key;
if (/^\d+$/.test(key) && page.id && page.id !== key) {
throw new SourceError(`Tapas sent series ${page.id} when asked for ${key}.`);
}
const series: SourceSeries = {
source: "tapas",
externalId: page.id ?? slug,
title: page.title ?? titleFromSlug(slug),
language: "en",
canonicalUrl,
archiveUrls: [`tapas.io/series/${slug}`],
mature: false,
flags: { novel: page.novel, ageGated: false },
};
currentBySeries.set(series, page.covers.map((c) => ({ ...c, current: true })));
return series;
},
async currentCovers(series, ctx) {
const cached = currentBySeries.get(series);
if (cached) return cached;
if (series.flags.ageGated) return [];
const html = await fetchHtml(series.canonicalUrl, ctx);
return extractCovers(html, new URL(series.canonicalUrl)).map((c) => ({ ...c, current: true }));
},
extractCovers: (html, pageUrl) => extractCovers(html, pageUrl),
normalizeImageUrl: (url) => normalizeImageUrl(url),
isSameSeries: (linkUrl, series) => isSameTapasSeries(linkUrl, slugOf(series.canonicalUrl), series.externalId),
};
+84
View File
@@ -0,0 +1,84 @@
import { decodeEntities, findTags, metaContent } from "../../html";
import type { CoverCandidate, CoverRole } from "../../model";
import { normalizeImageUrl } from "./urls";
export interface InfoPage {
id: string | null;
title: string | null;
canonicalUrl: string | null;
novel: boolean;
covers: CoverCandidate[];
}
export function parseInfoPage(html: string, pageUrl: URL): InfoPage {
return {
id: /pageMeta\s*:\s*\{\s*id\s*:\s*(\d+)/.exec(html)?.[1] ?? episodeSeriesId(html),
title: titleOf(html),
canonicalUrl: canonicalOf(html),
novel: /\/static-landing\/genre\?category=NOVEL\b/.test(html),
covers: extractCovers(html, pageUrl),
};
}
/** Spec 003 §1: the portrait in `.thumb-wrapper` (info page and the older reader page) and the banner. */
export function extractCovers(html: string, pageUrl: URL): CoverCandidate[] {
const found: { role: CoverRole; raw: string | undefined }[] = [
{ role: "poster", raw: portraitImg(html)?.src },
{ role: "banner", raw: metaContent(html, "og:image") ?? bannerBackground(html) },
];
const seen = new Set<string>();
const out: CoverCandidate[] = [];
for (const { role, raw } of found) {
if (!raw || /^\s*data:/i.test(raw)) continue;
let originalUrl: string;
try {
originalUrl = new URL(raw.trim(), pageUrl).toString();
} catch {
continue;
}
const url = normalizeImageUrl(originalUrl);
if (seen.has(url)) continue;
seen.add(url);
out.push({ url, role, current: false, originalUrl });
}
return out;
}
/** The reader page (`/episode/…`, and `/series/<slug>`) says which series it belongs to. */
export function episodeSeriesId(html: string): string | null {
return /\bseriesId\s*[:=]\s*['"]?(\d+)/.exec(html)?.[1] ?? null;
}
/** Mature series redirect to an age check; its form carries the series link (spec 003 §1). */
export function ageGateTarget(html: string): string | null {
if (!/\/account\/verify-age/.test(metaContent(html, "og:url") ?? "")) return null;
return findTags(html, "input").find((i) => i.name === "from" && i.value)?.value ?? null;
}
function portraitImg(html: string): Record<string, string> | undefined {
const i = html.search(/class="thumb-wrapper\b/);
return i < 0 ? undefined : findTags(html.slice(i, i + 1500), "img")[0];
}
function bannerBackground(html: string): string | undefined {
for (const div of findTags(html, "div")) {
if (!/(^|\s)(js-top-banner|header-layer)(\s|$)/.test(div.class ?? "")) continue;
const m = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/i.exec(div.style ?? "");
if (m) return m[1];
}
return undefined;
}
function titleOf(html: string): string | null {
const alt = portraitImg(html)?.alt?.trim();
if (alt) return alt;
const og = metaContent(html, "og:title")?.trim();
return og ? decodeEntities(og).replace(/^Read\s+/, "").replace(/\s*\|\s*Tapas.*$/, "") || null : null;
}
function canonicalOf(html: string): string | null {
for (const link of findTags(html, "link")) {
if ((link.rel ?? "").toLowerCase() === "canonical" && link.href) return link.href;
}
return null;
}
+59
View File
@@ -0,0 +1,59 @@
import { LinkError } from "../../links";
export const TAPAS_HOSTS: ReadonlySet<string> = new Set(["tapas.io", "www.tapas.io", "m.tapas.io"]);
export const IMAGE_HOST = "us-a.tapas.io";
/** Older pages name CloudFront; the same paths answer on IMAGE_HOST (spec 003 §1). */
export const LEGACY_IMAGE_HOST = "d30womf5coomej.cloudfront.net";
export const IMAGE_HOSTS: ReadonlySet<string> = new Set([IMAGE_HOST, LEGACY_IMAGE_HOST]);
/** A series by slug or numeric id, or an episode whose page names its series. */
export type TapasRef = { kind: "series"; key: string } | { kind: "episode"; id: string };
const SERIES_PATH = /^\/series\/([A-Za-z0-9_-]+)(?:\/info)?\/?$/;
const EPISODE_PATH = /^\/episode\/(\d+)\/?$/;
export function isTapasUrl(url: URL): boolean {
return TAPAS_HOSTS.has(url.hostname) && (SERIES_PATH.test(url.pathname) || EPISODE_PATH.test(url.pathname));
}
export function parseTapasUrl(url: URL): TapasRef {
if (!TAPAS_HOSTS.has(url.hostname)) throw new LinkError(`Not a Tapas link: ${url.hostname}`);
const series = SERIES_PATH.exec(url.pathname);
if (series) return { kind: "series", key: series[1]! };
const episode = EPISODE_PATH.exec(url.pathname);
if (episode) return { kind: "episode", id: episode[1]! };
throw new LinkError("That Tapas link isn't a series. Use the series page (tapas.io/series/…).");
}
/** The series page with covers; `/series/<id>/info` redirects to the slug form. */
export const infoUrl = (key: string): string => `https://tapas.io/series/${key}/info`;
export const episodeUrl = (id: string): string => `https://tapas.io/episode/${id}`;
export function slugOf(seriesUrl: string): string | null {
try {
const u = new URL(seriesUrl);
return TAPAS_HOSTS.has(u.hostname) ? (SERIES_PATH.exec(u.pathname)?.[1] ?? null) : null;
} catch {
return null;
}
}
/** The original image: IMAGE_HOST, no `_z`/`_m` size suffix after the UUID, no query. */
export function normalizeImageUrl(raw: string, base?: string | URL): string {
let u: URL;
try {
u = new URL(raw.trim(), base);
} catch {
return raw.trim();
}
if (!IMAGE_HOSTS.has(u.hostname)) return u.toString();
const path = u.pathname.replace(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_[a-z]{1,2}(\.[a-z]+)$/i, "$1$2");
return `https://${IMAGE_HOST}${path}`;
}
/** Same series: the same slug (any case) or the same numeric id. A longer slug (`…-novel`) is another series. */
export function isSameTapasSeries(linkUrl: string, slug: string | null, id: string): boolean {
const key = slugOf(linkUrl);
if (!key) return false;
return /^\d+$/.test(key) ? key === id : slug !== null && key.toLowerCase() === slug.toLowerCase();
}
+71
View File
@@ -0,0 +1,71 @@
import type { CoverCandidate, SourceSeries } from "../../model";
import { SourceError, type Source, type SourceContext } from "../source";
import { extractCovers, parseSeriesPage } from "./parse";
import {
IMAGE_HOST, IMAGE_HOSTS, archivePrefixes, fetchUrlFor, isSameWebtoonsSeries, isWebtoonsSeriesUrl, mangabakaLanguage,
normalizeImageUrl, parseWebtoonsUrl, type WebtoonsRef,
} from "./urls";
/** Covers parsed by resolve(), so currentCovers() doesn't fetch the page twice. */
const currentBySeries = new WeakMap<SourceSeries, CoverCandidate[]>();
/** Tries the official or canvas form first, as the link suggests; the wrong form is a 404. */
async function fetchPage(titleNo: string, canvas: boolean, ctx: SourceContext): Promise<string> {
let res = await ctx.transport.fetch(fetchUrlFor(titleNo, canvas), { signal: ctx.signal });
if (res.status === 404) {
await res.body?.cancel();
res = await ctx.transport.fetch(fetchUrlFor(titleNo, !canvas), { signal: ctx.signal });
}
if (!res.ok) {
await res.body?.cancel();
throw new SourceError(`WEBTOON answered HTTP ${res.status} for title_no ${titleNo}.`);
}
return res.text();
}
export const webtoons: Source = {
id: "webtoons",
name: "WEBTOON",
hosts: ["www.webtoons.com", "m.webtoons.com", IMAGE_HOST],
imageHosts: [...IMAGE_HOSTS],
matches: (url) => isWebtoonsSeriesUrl(url),
async resolve(url, ctx) {
const ref = parseWebtoonsUrl(url);
const page = parseSeriesPage(await fetchPage(ref.titleNo, ref.canvas, ctx), url);
const canonicalUrl = page.canonicalUrl ?? url.toString();
let canonical: WebtoonsRef = ref;
try {
canonical = parseWebtoonsUrl(new URL(canonicalUrl));
} catch {
// keep the user's link if the page has no usable canonical URL
}
if (canonical.titleNo !== ref.titleNo) {
throw new SourceError(`WEBTOON sent title_no ${canonical.titleNo} when asked for ${ref.titleNo}.`);
}
const series: SourceSeries = {
source: "webtoons",
externalId: ref.titleNo,
title: page.title ?? `title_no ${ref.titleNo}`,
language: mangabakaLanguage(canonical.languageSegment ?? ref.languageSegment),
canonicalUrl,
archiveUrls: archivePrefixes(canonicalUrl),
mature: page.mature,
flags: { canvas: page.canvas || canonical.canvas },
};
currentBySeries.set(series, page.covers.map((c) => ({ ...c, current: true })));
return series;
},
async currentCovers(series, ctx) {
const cached = currentBySeries.get(series);
if (cached) return cached;
const html = await fetchPage(series.externalId, series.flags.canvas ?? false, ctx);
return extractCovers(html, new URL(series.canonicalUrl), series.flags.canvas).map((c) => ({ ...c, current: true }));
},
extractCovers: (html, pageUrl) => extractCovers(html, pageUrl),
normalizeImageUrl: (url) => normalizeImageUrl(url),
isSameSeries: (linkUrl, series) => isSameWebtoonsSeries(linkUrl, series.externalId),
};
+80
View File
@@ -0,0 +1,80 @@
import { findTags, metaContent, textOf } from "../../html";
import type { CoverCandidate, CoverRole } from "../../model";
import { normalizeImageUrl } from "./urls";
export interface ParsedPage {
title: string | null;
canonicalUrl: string | null;
canvas: boolean;
mature: boolean;
covers: CoverCandidate[];
}
const isCanvas = (html: string, pageUrl: URL) =>
/\/(canvas|challenge)\//i.test(pageUrl.pathname) || /isChallenge\s*:\s*true/.test(html);
export function parseSeriesPage(html: string, pageUrl: URL): ParsedPage {
const canvas = isCanvas(html, pageUrl);
return {
title: titleOf(html),
canonicalUrl: canonicalOf(html),
canvas,
mature: /ageGradeNotice\s*:\s*true/.test(html),
covers: extractCovers(html, pageUrl, canvas),
};
}
/** Spec §2.1 table. Canvas pages have no portrait poster: their og:image is a square. */
export function extractCovers(html: string, pageUrl: URL, canvas = isCanvas(html, pageUrl)): CoverCandidate[] {
const found: { role: CoverRole; raw: string | undefined }[] = [
{ role: canvas ? "square" : "poster", raw: metaContent(html, "og:image") },
{ role: "square", raw: metaContent(html, "twitter:image") },
{ role: "banner", raw: backgroundOf(html) },
{ role: canvas ? "square" : "foreground", raw: heroImageOf(html) },
];
const seen = new Set<string>();
const out: CoverCandidate[] = [];
for (const { role, raw } of found) {
if (!raw || /^\s*data:/i.test(raw)) continue;
let originalUrl: string;
try {
originalUrl = new URL(raw.trim(), pageUrl).toString();
} catch {
continue;
}
const url = normalizeImageUrl(originalUrl);
if (seen.has(url)) continue;
seen.add(url);
out.push({ url, role, current: false, originalUrl });
}
return out;
}
function backgroundOf(html: string): string | undefined {
for (const div of findTags(html, "div")) {
if (!/(^|\s)detail_bg(\s|$)/.test(div.class ?? "")) continue;
const m = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/i.exec(div.style ?? "");
if (m) return m[1];
}
return undefined;
}
function heroImageOf(html: string): string | undefined {
const i = html.search(/class="detail_header\b/);
if (i < 0) return undefined;
return findTags(html.slice(i, i + 3000), "img")[0]?.src || undefined;
}
function titleOf(html: string): string | null {
const og = metaContent(html, "og:title");
if (og?.trim()) return og.trim();
const m = /<h[13]\s[^>]*class="[^"]*\bsubj\b[^"]*"[^>]*>([\s\S]*?)<\/h[13]>/i.exec(html);
return m ? textOf(m[1]!) || null : null;
}
function canonicalOf(html: string): string | null {
for (const link of findTags(html, "link")) {
if ((link.rel ?? "").toLowerCase() === "canonical" && link.href) return link.href;
}
return null;
}
+76
View File
@@ -0,0 +1,76 @@
import { LinkError } from "../../links";
export const WEBTOONS_HOSTS: ReadonlySet<string> = new Set(["www.webtoons.com", "m.webtoons.com", "webtoons.com"]);
export const IMAGE_HOST = "swebtoon-phinf.pstatic.net";
/** Hosts that serve the same image paths; only IMAGE_HOST works without a Webtoons Referer (spec §2.1). */
export const IMAGE_HOSTS: ReadonlySet<string> = new Set([
IMAGE_HOST, "webtoon-phinf.pstatic.net", "swebtoon.phinf.naver.net", "webtoon.phinf.naver.net",
]);
/** Webtoons path segment → MangaBaka language code. */
const LANGUAGES: Readonly<Record<string, string>> = {
en: "en", es: "es", fr: "fr", de: "de", th: "th", id: "id", "zh-hant": "zh-Hant",
};
export interface WebtoonsRef {
titleNo: string;
languageSegment: string | null;
canvas: boolean;
}
export function isWebtoonsSeriesUrl(url: URL): boolean {
return WEBTOONS_HOSTS.has(url.hostname) && /^\d+$/.test(url.searchParams.get("title_no") ?? "");
}
export function parseWebtoonsUrl(url: URL): WebtoonsRef {
if (!WEBTOONS_HOSTS.has(url.hostname)) throw new LinkError(`Not a WEBTOON link: ${url.hostname}`);
const titleNo = url.searchParams.get("title_no") ?? "";
if (!/^\d+$/.test(titleNo)) {
throw new LinkError("That WEBTOON link has no title_no. Use the series page (…/list?title_no=…).");
}
const segments = url.pathname.split("/").filter(Boolean).map((s) => s.toLowerCase());
const first = segments[0];
return {
titleNo,
languageSegment: first && LANGUAGES[first] ? first : null,
canvas: segments.includes("canvas") || segments.includes("challenge"),
};
}
export function mangabakaLanguage(segment: string | null): string {
return (segment && LANGUAGES[segment]) || "unknown";
}
/**
* `/-/-/-/list` (official) and `/-/canvas/-/list` (canvas) 301 to the canonical page; the wrong
* form is a 404 (verified 2026-10-14). A made-up slug like `/en/x/x/` made Webtoons hang instead,
* so we never fetch the user's own path.
*/
export function fetchUrlFor(titleNo: string, canvas = false): string {
return `https://www.webtoons.com/-/${canvas ? "canvas" : "-"}/-/list?title_no=${titleNo}`;
}
export function archivePrefixes(canonicalUrl: string): string[] {
const u = new URL(canonicalUrl);
const rest = `${u.pathname}?title_no=${u.searchParams.get("title_no") ?? ""}`;
return [`www.webtoons.com${rest}`, `m.webtoons.com${rest}`];
}
/** Largest, hotlinkable form: IMAGE_HOST, same path, no `?type=` resize query. */
export function normalizeImageUrl(raw: string, base?: string | URL): string {
let u: URL;
try {
u = new URL(raw.trim(), base);
} catch {
return raw.trim();
}
return IMAGE_HOSTS.has(u.hostname) ? `https://${IMAGE_HOST}${u.pathname}` : u.toString();
}
export function isSameWebtoonsSeries(linkUrl: string, titleNo: string): boolean {
try {
const u = new URL(linkUrl);
return WEBTOONS_HOSTS.has(u.hostname) && u.searchParams.get("title_no") === titleNo;
} catch {
return false;
}
}
+151
View File
@@ -0,0 +1,151 @@
import { REPO_URL, VERSION } from "./version";
export interface FetchInit {
method?: string;
headers?: Record<string, string>;
body?: BodyInit | null;
signal?: AbortSignal;
/** Overrides the policy timeout for this one request (uploads). */
timeoutMs?: number;
}
/** How a front end reaches the network (spec §4.2). Returns a standard Response. */
export interface Transport {
fetch(url: string, init?: FetchInit): Promise<Response>;
}
export const MANGABAKA_API_HOST = "api.mangabaka.org";
/** Our page and userscript send this on every API call; a plain navigation can't (see server app.ts). */
export const API_CALL_HEADER = "x-mbseed";
export const WAYBACK_HOST = "web.archive.org";
/** Hosts a browser page may call directly because they send CORS headers (spec §2.3, §2.4). */
export const CORS_OPEN_HOSTS: ReadonlySet<string> = new Set([MANGABAKA_API_HOST, "litterbox.catbox.moe"]);
export const BROWSER_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36";
export const TOOL_USER_AGENT = `mangabaka-seeder/${VERSION} (+${REPO_URL})`;
/** Wayback flags fake browser User-Agents as bots (spec §2.2); sources get a browser one. */
export function userAgentFor(url: string): string {
return hostOf(url) === WAYBACK_HOST ? TOOL_USER_AGENT : BROWSER_USER_AGENT;
}
/** Hosts that answer only with their own page as Referer, as a browser sends it (spec 004 §1). */
const REFERERS: Readonly<Record<string, string>> = { "bff-page.kakao.com": "https://page.kakao.com/" };
/** Every request to a source or Wayback carries these: the User-Agent, and a Referer where a host needs one. */
export function requestHeadersFor(url: string): Record<string, string> {
const referer = REFERERS[hostOf(url)];
return { "user-agent": userAgentFor(url), ...(referer ? { referer } : {}) };
}
export function hostOf(url: string): string {
try {
return new URL(url).hostname;
} catch {
return "";
}
}
export class TransportError extends Error {
constructor(message: string, readonly url: string, readonly status?: number) {
super(message);
this.name = "TransportError";
}
}
export interface PolicyOptions {
/** Minimum gap between requests to one host. Default: 10 s for Wayback, 1 s otherwise. */
minIntervalMs?: (host: string) => number;
timeoutMs?: number;
retries?: number;
backoffMs?: number;
now?: () => number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
}
export const defaultInterval = (host: string): number => (host === WAYBACK_HOST ? 10_000 : 1_000);
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(timer);
reject(signal!.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export function assertTokenScope(host: string, headers?: Record<string, string>): void {
if (!headers) return;
const carriesToken = Object.keys(headers).some((k) => ["x-api-key", "authorization"].includes(k.toLowerCase()));
if (carriesToken && host !== MANGABAKA_API_HOST) {
throw new TransportError(`refusing to send credentials to ${host || "an unknown host"}`, host);
}
}
const RETRYABLE_METHODS = new Set(["GET", "HEAD"]);
const isRetryableStatus = (status: number) => status === 429 || status >= 500;
/** Wraps any transport with the shared behaviour of spec §4.2. */
export function withPolicy(inner: Transport, opts: PolicyOptions = {}): Transport {
const interval = opts.minIntervalMs ?? defaultInterval;
const timeoutMs = opts.timeoutMs ?? 20_000;
const retries = opts.retries ?? 2;
const backoffMs = opts.backoffMs ?? 2_000;
const now = opts.now ?? Date.now;
const sleep = opts.sleep ?? abortableSleep;
const nextSlot = new Map<string, number>();
async function waitTurn(host: string, signal?: AbortSignal) {
const t = now();
const at = Math.max(t, nextSlot.get(host) ?? 0);
nextSlot.set(host, at + interval(host));
if (at > t) await sleep(at - t, signal);
}
function attempt(url: string, init: FetchInit): Promise<Response> {
const { timeoutMs: perRequest, ...rest } = init;
const timeout = AbortSignal.timeout(perRequest ?? timeoutMs);
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
return inner.fetch(url, { ...rest, signal });
}
return {
async fetch(url, init = {}) {
const host = hostOf(url);
assertTokenScope(host, init.headers);
const method = (init.method ?? "GET").toUpperCase();
const attempts = RETRYABLE_METHODS.has(method) ? retries + 1 : 1;
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
await waitTurn(host, init.signal);
try {
const res = await attempt(url, init);
if (!isRetryableStatus(res.status) || i === attempts - 1) return res;
await res.body?.cancel();
await sleep(retryDelay(res, backoffMs, i), init.signal);
} catch (err) {
if (init.signal?.aborted) throw err;
lastError = err;
if (i < attempts - 1) await sleep(backoffMs * 2 ** i, init.signal);
}
}
throw new TransportError(`request to ${host} failed: ${describe(lastError)}`, url);
},
};
}
function retryDelay(res: Response, base: number, attempt: number): number {
const seconds = Number(res.headers.get("retry-after"));
return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 120) * 1000 : base * 2 ** attempt;
}
function describe(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+2
View File
@@ -0,0 +1,2 @@
export const VERSION = "0.1.0";
export const REPO_URL = "https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder";
+99
View File
@@ -0,0 +1,99 @@
// A store-only ZIP writer (spec 002 §2): covers are JPEG/PNG/WebP and don't shrink, so no compression.
export interface ZipEntry {
name: string;
bytes: Uint8Array;
modified: Date;
}
export class ZipError extends Error {
override name = "ZipError";
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
export function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
/** MS-DOS time and date, from the UTC fields (ZIP has no time zone). */
function dosTime(d: Date): { time: number; date: number } {
return {
time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1),
date: ((Math.max(d.getUTCFullYear(), 1980) - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(),
};
}
const UTF8_NAMES = 0x0800;
const LIMIT = 0xffffffff;
export function zipStore(entries: readonly ZipEntry[]): Uint8Array {
const names = entries.map((e) => new TextEncoder().encode(e.name));
const local = entries.reduce((sum, e, i) => sum + 30 + names[i]!.length + e.bytes.length, 0);
const central = names.reduce((sum, n) => sum + 46 + n.length, 0);
if (local + central + 22 > LIMIT) throw new ZipError("These covers add up to more than 4 GiB; select fewer.");
const out = new Uint8Array(local + central + 22);
const view = new DataView(out.buffer);
const offsets: number[] = [];
const crcs: number[] = [];
let at = 0;
entries.forEach((e, i) => {
const name = names[i]!;
const { time, date } = dosTime(e.modified);
const crc = crc32(e.bytes);
offsets.push(at);
crcs.push(crc);
view.setUint32(at, 0x04034b50, true);
view.setUint16(at + 4, 20, true);
view.setUint16(at + 6, UTF8_NAMES, true);
view.setUint16(at + 8, 0, true);
view.setUint16(at + 10, time, true);
view.setUint16(at + 12, date, true);
view.setUint32(at + 14, crc, true);
view.setUint32(at + 18, e.bytes.length, true);
view.setUint32(at + 22, e.bytes.length, true);
view.setUint16(at + 26, name.length, true);
view.setUint16(at + 28, 0, true);
out.set(name, at + 30);
out.set(e.bytes, at + 30 + name.length);
at += 30 + name.length + e.bytes.length;
});
const centralStart = at;
entries.forEach((e, i) => {
const name = names[i]!;
const { time, date } = dosTime(e.modified);
view.setUint32(at, 0x02014b50, true);
view.setUint16(at + 4, 20, true);
view.setUint16(at + 6, 20, true);
view.setUint16(at + 8, UTF8_NAMES, true);
view.setUint16(at + 10, 0, true);
view.setUint16(at + 12, time, true);
view.setUint16(at + 14, date, true);
view.setUint32(at + 16, crcs[i]!, true);
view.setUint32(at + 20, e.bytes.length, true);
view.setUint32(at + 24, e.bytes.length, true);
view.setUint16(at + 28, name.length, true);
// extra, comment, disk number, internal attributes: 0
view.setUint32(at + 38, 0, true);
view.setUint32(at + 42, offsets[i]!, true);
out.set(name, at + 46);
at += 46 + name.length;
});
view.setUint32(at, 0x06054b50, true);
view.setUint16(at + 8, entries.length, true);
view.setUint16(at + 10, entries.length, true);
view.setUint32(at + 12, at - centralStart, true);
view.setUint32(at + 16, centralStart, true);
return out;
}
+228
View File
@@ -0,0 +1,228 @@
// The portable request handler (spec §4.10): web-standard APIs only, so a Workers entry can wrap it later.
import { uploadToCatbox } from "../core/hosting/catbox";
import { ImageError, MAX_IMAGE_BYTES, probeImage, readCapped, sha256Hex } from "../core/images";
import { allowedFetchHosts, allowedImageHosts } from "../core/sources/registry";
import { API_CALL_HEADER, TOOL_USER_AGENT, WAYBACK_HOST, requestHeadersFor, type Transport } from "../core/transport";
import { resolveAssetPath } from "./assets-path";
import { RateLimiter } from "./rate-limit";
import { CONTENT_TYPES, COVER_NAME, StorageFullError, type Storage } from "./storage";
export type Upstream = (url: string, init?: RequestInit) => Promise<Response>;
export interface ServerEnv {
storage: Storage | null;
publicUrl: string | null;
/** Serves a built file by its path relative to the assets root, or null. */
assets(relativePath: string): Promise<Response | null>;
clientIp(req: Request): string;
upstream?: Upstream;
allowedHosts?: ReadonlySet<string>;
limits?: { fetch: RateLimiter; upload: RateLimiter };
}
export const MAX_REDIRECTS = 5;
export const UPSTREAM_TIMEOUT_MS = 30_000;
/** Pages whose scripts may call our API besides our own (the userscript runs on mangabaka.org). */
const EXTRA_ORIGINS = new Set(["https://mangabaka.org"]);
/** Covers are a few hundred pixels to ~2000 wide; anything outside this is not one (or not an image at all). */
const MAX_IMAGE_SIDE = 10_000;
/** Proxied bodies are data for our scripts, never a document: they must not render or run on our origin. */
const INERT_HEADERS = {
"content-type": "application/octet-stream",
"x-content-type-options": "nosniff",
"content-security-policy": "sandbox; default-src 'none'",
"content-disposition": "attachment",
"cache-control": "no-store",
} as const;
const error = (status: number, message: string) =>
Response.json({ error: message }, { status, headers: { "x-mbseed-error": "1", "cache-control": "no-store" } });
export function createApp(env: ServerEnv): (req: Request) => Promise<Response> {
const hosts = env.allowedHosts ?? allowedFetchHosts();
// A run is ~60–100 proxied requests over several minutes (spec §5).
const limits = env.limits ?? { fetch: new RateLimiter(240, 60_000), upload: new RateLimiter(60, 60_000) };
const upstream: Upstream = env.upstream ?? ((url, init) => fetch(url, init));
const imageHosts = allowedImageHosts();
/** Behind a TLS proxy Bun sees http://host while the browser says https://host, so compare hosts. */
const isOwnOrigin = (req: Request, origin: string) => {
let host: string;
try {
host = new URL(origin).host;
} catch {
return false;
}
const own = [new URL(req.url).host, req.headers.get("host") ?? ""];
if (env.publicUrl) own.push(new URL(env.publicUrl).host);
return own.includes(host);
};
/** `/api/upload` rehosts cover images only: a source image host, or Wayback's copy of one. */
const isImageSource = (u: URL) => {
if (imageHosts.has(u.hostname)) return true;
if (u.hostname !== WAYBACK_HOST) return false;
const inner = /^\/web\/\d{1,14}(?:id_|im_)?\/(.+)$/.exec(u.pathname + u.search)?.[1];
try {
return inner !== undefined && imageHosts.has(new URL(inner).hostname);
} catch {
return false;
}
};
const allowed = (u: URL) =>
(u.protocol === "https:" || u.protocol === "http:") && hosts.has(u.hostname) && ["", "80", "443"].includes(u.port);
/** GET through the allowlist, re-checking every redirect hop. */
async function proxyGet(target: string | null): Promise<Response> {
if (!target) return error(400, "Missing url.");
let url: URL;
try {
url = new URL(target);
} catch {
return error(400, "Not a URL.");
}
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (!allowed(url)) return error(403, `Host not allowed: ${url.hostname}`);
let res: Response;
try {
res = await upstream(url.toString(), {
redirect: "manual",
headers: { ...requestHeadersFor(url.toString()), accept: "*/*" },
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
} catch (err) {
return error(502, `Upstream failed: ${err instanceof Error ? err.message : String(err)}`);
}
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
await res.body?.cancel();
url = new URL(location, url);
continue;
}
let body: Uint8Array;
try {
body = await readCapped(res, MAX_IMAGE_BYTES);
} catch (err) {
if (err instanceof ImageError) return error(413, "Response larger than 20 MB.");
throw err;
}
return new Response(body as Uint8Array<ArrayBuffer>, {
status: res.status,
headers: { ...INERT_HEADERS, "x-mbseed-final-url": url.toString() },
});
}
return error(502, "Too many redirects.");
}
const catboxTransport: Transport = {
fetch: (url, init = {}) =>
upstream(url, {
method: init.method,
headers: { "user-agent": TOOL_USER_AGENT, ...init.headers },
body: init.body,
signal: AbortSignal.timeout(init.timeoutMs ?? UPSTREAM_TIMEOUT_MS),
}),
};
async function upload(req: Request): Promise<Response> {
let body: { url?: unknown; host?: unknown; userhash?: unknown };
try {
body = await req.json();
} catch {
return error(400, "Body must be JSON.");
}
if (typeof body.url !== "string") return error(400, "url is required.");
if (body.host !== "store" && body.host !== "catbox") return error(400, 'host must be "store" or "catbox".');
if (body.userhash !== undefined && (typeof body.userhash !== "string" || !/^[A-Za-z0-9]{1,64}$/.test(body.userhash))) {
return error(400, "Bad userhash.");
}
if (body.host === "store" && !env.storage) return error(404, "This server has no storage.");
let source: URL;
try {
source = new URL(body.url);
} catch {
return error(400, "Not a URL.");
}
if (!isImageSource(source)) return error(403, "Only cover images from a supported source (or their Wayback copies) can be rehosted.");
// The client sends the source URL, never bytes, so this can't host arbitrary files (spec §4.6).
const fetched = await proxyGet(body.url);
if (fetched.headers.get("x-mbseed-error")) return fetched;
if (!fetched.ok) return error(502, `Could not fetch the image (HTTP ${fetched.status}).`);
const bytes = new Uint8Array(await fetched.arrayBuffer());
const info = probeImage(bytes);
if (!info || info.width < 1 || info.height < 1 || info.width > MAX_IMAGE_SIDE || info.height > MAX_IMAGE_SIDE) {
return error(415, "That URL is not a cover image.");
}
const sha256 = await sha256Hex(bytes);
if (body.host === "store") {
const name = `${sha256}.${info.ext}`;
try {
await env.storage!.put(name, bytes);
} catch (err) {
if (err instanceof StorageFullError) return error(507, "This server's storage is full; use litterbox or catbox.");
throw err;
}
return Response.json({ url: `${env.publicUrl}/covers/${name}`, sha256 });
}
try {
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: info.contentType });
const { url } = await uploadToCatbox(catboxTransport, blob, `${sha256}.${info.ext}`, body.userhash as string | undefined);
return Response.json({ url, sha256 });
} catch (err) {
return error(502, err instanceof Error ? err.message : "catbox upload failed.");
}
}
async function cover(name: string): Promise<Response> {
if (!env.storage || !COVER_NAME.test(name)) return error(404, "Not found.");
const blob = await env.storage.get(name);
if (!blob) return error(404, "Not found.");
return new Response(blob, {
headers: {
"content-type": CONTENT_TYPES[name.split(".").pop()!]!,
"cache-control": "public, max-age=86400",
"x-content-type-options": "nosniff",
},
});
}
async function route(req: Request): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
const isRead = req.method === "GET" || req.method === "HEAD";
if (path.startsWith("/api/")) {
const origin = req.headers.get("origin");
if (origin && !isOwnOrigin(req, origin) && !EXTRA_ORIGINS.has(origin)) return error(403, "Cross-origin requests are not allowed.");
// Only our scripts send this header; a link or a form can't, so a navigation can't make us fetch.
const fromOurScript = req.headers.get(API_CALL_HEADER) === "1";
if ((path === "/api/fetch" || path === "/api/upload") && !fromOurScript) return error(403, "API calls only.");
if (path === "/api/config" && isRead) {
return Response.json({ storage: env.storage !== null, storageFull: env.storage?.isFull() ?? false }, { headers: { "cache-control": "no-store" } });
}
if (path === "/api/fetch" && req.method === "GET") {
if (!limits.fetch.take(env.clientIp(req))) return error(429, "Too many requests; slow down.");
return proxyGet(url.searchParams.get("url"));
}
if (path === "/api/upload" && req.method === "POST") {
if (!limits.upload.take(env.clientIp(req))) return error(429, "Too many uploads; slow down.");
return upload(req);
}
return error(404, "No such API route.");
}
if (!isRead) return error(405, "Method not allowed.");
if (path.startsWith("/covers/")) return cover(path.slice("/covers/".length));
const file = resolveAssetPath(path);
return (file && (await env.assets(file))) || error(404, "Not found.");
}
return async (req) => {
try {
return await route(req);
} catch (err) {
console.error("request failed:", req.method, new URL(req.url).pathname, err);
return error(500, "Internal error.");
}
};
}
+17
View File
@@ -0,0 +1,17 @@
/** Maps a URL path to a file path relative to the assets root, or null. No dot-segments, no dotfiles. */
export function resolveAssetPath(pathname: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
return null;
}
if (decoded.includes("\0") || decoded.includes("\\")) return null;
const segments: string[] = [];
for (const segment of decoded.split("/")) {
if (segment === "") continue;
if (segment.startsWith(".")) return null;
segments.push(segment);
}
return segments.length ? segments.join("/") : "index.html";
}
+46
View File
@@ -0,0 +1,46 @@
// Self-hosted entry: Bun.serve behind our reverse proxy (spec §4.10). Keep it thin.
import { join, resolve } from "node:path";
import { createApp } from "./app";
import { SERVER_IDLE_TIMEOUT_S, clientIp, readConfig } from "./config";
import { DirStorage } from "./storage-dir";
const config = readConfig(process.env);
const storage = config.storageDir ? await DirStorage.open(config.storageDir, config.storageMaxBytes) : null;
if (storage) {
const sweep = () =>
storage
.sweep()
.then((n) => n > 0 && console.log(`swept ${n} expired cover(s)`))
.catch((err) => console.error("sweep failed:", err));
await sweep();
setInterval(sweep, 24 * 60 * 60 * 1000);
}
const assetsRoot = resolve(config.assetsDir);
let server: ReturnType<typeof Bun.serve>;
const app = createApp({
storage,
publicUrl: config.publicUrl,
// `relativePath` is already checked by resolveAssetPath (no dot-segments, no dotfiles).
assets: async (relativePath) => {
const file = Bun.file(join(assetsRoot, relativePath));
return (await file.exists()) ? new Response(file) : null;
},
clientIp: (req) => clientIp(req, config.trustProxy, server.requestIP(req)?.address),
});
server = Bun.serve({ port: config.port, hostname: config.hostname, idleTimeout: SERVER_IDLE_TIMEOUT_S, fetch: app });
console.log(
`mangabaka-seeder on http://${server.hostname}:${server.port} · storage: ${
storage ? `${config.storageDir} (${storage.usedBytes()} of ${config.storageMaxBytes} bytes)` : "off"
}`,
);
// In a container Bun runs as PID 1, which ignores SIGTERM without a handler: stop cleanly on docker stop.
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.on(signal, () => {
void server.stop().finally(() => process.exit(0));
});
}
+73
View File
@@ -0,0 +1,73 @@
// Web-standard: parses the environment; no Bun or node APIs.
export interface ServerConfig {
port: number;
hostname: string;
publicUrl: string | null;
storageDir: string | null;
storageMaxBytes: number;
trustProxy: boolean;
assetsDir: string;
}
export class ConfigError extends Error {
override name = "ConfigError";
}
export const DEFAULT_STORAGE_MAX_BYTES = 1024 ** 3;
/**
* Seconds the Bun server keeps a quiet connection open. Its default (10 s) dropped slow Wayback lookups
* mid-request; this must outlast a proxied fetch plus a catbox upload. Bun's maximum is 255.
*/
export const SERVER_IDLE_TIMEOUT_S = 240;
export function readConfig(env: Record<string, string | undefined>): ServerConfig {
const publicUrl = parsePublicUrl(env.MBSEED_PUBLIC_URL);
const storageDir = env.MBSEED_STORAGE_DIR?.trim() || null;
if (storageDir && !publicUrl) {
throw new ConfigError("MBSEED_STORAGE_DIR needs MBSEED_PUBLIC_URL: MangaBaka must be able to download stored covers.");
}
return {
port: wholeNumber(env, "MBSEED_PORT", 8787),
hostname: env.MBSEED_HOST?.trim() || "127.0.0.1",
publicUrl,
storageDir,
storageMaxBytes: wholeNumber(env, "MBSEED_STORAGE_MAX_BYTES", DEFAULT_STORAGE_MAX_BYTES),
trustProxy: env.MBSEED_TRUST_PROXY === "1",
assetsDir: env.MBSEED_ASSETS_DIR?.trim() || "dist/web",
};
}
function parsePublicUrl(raw: string | undefined): string | null {
const value = raw?.trim();
if (!value) return null;
let url: URL;
try {
url = new URL(value);
} catch {
throw new ConfigError(`MBSEED_PUBLIC_URL is not a URL: ${value}`);
}
if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" || url.search || url.hash) {
throw new ConfigError(`MBSEED_PUBLIC_URL must be an origin like https://mbseed.example.org, got ${value}`);
}
return url.origin;
}
function wholeNumber(env: Record<string, string | undefined>, name: string, fallback: number): number {
const raw = env[name]?.trim();
if (!raw) return fallback;
if (!/^\d+$/.test(raw)) throw new ConfigError(`${name} must be a whole number, got "${raw}".`);
return Number(raw);
}
/**
* The address rate limits key on. Behind our reverse proxy (MBSEED_TRUST_PROXY=1) that is the
* last X-Forwarded-For entry — the one the proxy appended; earlier entries come from the client.
*/
export function clientIp(req: Request, trustProxy: boolean, peer?: string): string {
if (trustProxy) {
const last = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()).filter(Boolean).at(-1);
if (last) return last;
}
return peer ?? "unknown";
}
+23
View File
@@ -0,0 +1,23 @@
/** Fixed-window counter per key (client IP). In memory: one server process. */
export class RateLimiter {
private readonly hits = new Map<string, { start: number; count: number }>();
constructor(readonly limit: number, readonly windowMs: number, private readonly now: () => number = Date.now) {}
take(key: string): boolean {
const t = this.now();
const hit = this.hits.get(key);
if (!hit || t - hit.start >= this.windowMs) {
if (this.hits.size > 10_000) this.prune(t);
this.hits.set(key, { start: t, count: 1 });
return true;
}
if (hit.count >= this.limit) return false;
hit.count++;
return true;
}
private prune(t: number): void {
for (const [key, hit] of this.hits) if (t - hit.start >= this.windowMs) this.hits.delete(key);
}
}
+97
View File
@@ -0,0 +1,97 @@
// The covers folder on our server (spec §4.6). Bun/node APIs: used only by bun.ts.
import { mkdir, readdir, rename, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
import { COVER_NAME, FULL_MARGIN_BYTES, StorageFullError, assertCoverName, type Storage } from "./storage";
export const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export class DirStorage implements Storage {
private used = 0;
private readonly sizes = new Map<string, number>();
/** Writes and sweeps run one at a time, so two uploads can't race past the cap. */
private queue: Promise<unknown> = Promise.resolve();
private constructor(readonly dir: string, readonly maxBytes: number, private readonly now: () => number) {}
static async open(dir: string, maxBytes: number, now: () => number = Date.now): Promise<DirStorage> {
await mkdir(dir, { recursive: true });
const storage = new DirStorage(dir, maxBytes, now);
for (const entry of await readdir(dir)) {
const path = join(dir, entry);
if (entry.endsWith(".tmp")) {
await unlink(path).catch(() => {});
continue;
}
if (!COVER_NAME.test(entry)) continue;
const { size } = await stat(path);
storage.sizes.set(entry, size);
storage.used += size;
}
return storage;
}
put(name: string, bytes: Uint8Array): Promise<void> {
return this.serial(async () => {
assertCoverName(name);
const previous = this.sizes.get(name) ?? 0;
if (this.used - previous + bytes.byteLength > this.maxBytes) {
throw new StorageFullError(`The covers folder is full (${this.used} of ${this.maxBytes} bytes used).`);
}
// Temp file + rename: a reader never sees half a file. Rewriting also restarts the 30 days.
const tmp = join(this.dir, `${name}.${crypto.randomUUID()}.tmp`);
await Bun.write(tmp, bytes);
await rename(tmp, join(this.dir, name));
this.sizes.set(name, bytes.byteLength);
this.used += bytes.byteLength - previous;
});
}
async get(name: string): Promise<Blob | null> {
if (!COVER_NAME.test(name) || !this.sizes.has(name)) return null;
const file = Bun.file(join(this.dir, name));
return (await file.exists()) ? file : null;
}
isFull(): boolean {
return this.maxBytes - this.used < FULL_MARGIN_BYTES;
}
usedBytes(): number {
return this.used;
}
/** Deletes covers last written more than `maxAgeMs` ago. Returns how many. */
sweep(maxAgeMs = RETENTION_MS): Promise<number> {
return this.serial(async () => {
const cutoff = this.now() - maxAgeMs;
let removed = 0;
for (const name of [...this.sizes.keys()]) {
const path = join(this.dir, name);
let mtime: number;
try {
mtime = (await stat(path)).mtimeMs;
} catch {
this.forget(name);
continue;
}
if (mtime < cutoff) {
await unlink(path);
this.forget(name);
removed++;
}
}
return removed;
});
}
private forget(name: string): void {
this.used -= this.sizes.get(name) ?? 0;
this.sizes.delete(name);
}
private serial<T>(fn: () => Promise<T>): Promise<T> {
const run = this.queue.then(fn, fn);
this.queue = run.catch(() => {});
return run;
}
}
+54
View File
@@ -0,0 +1,54 @@
// Web-standard: the Storage interface and an in-memory implementation for tests.
/** Covers are stored as <sha256>.<ext>; nothing else is ever read or written (spec §4.6). */
export const COVER_NAME = /^[0-9a-f]{64}\.(jpg|png|gif|webp)$/;
export const CONTENT_TYPES: Readonly<Record<string, string>> = {
jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp",
};
/** `/api/config` reports full when less than this is left; a typical cover is ≤ 650 KB. */
export const FULL_MARGIN_BYTES = 1024 * 1024;
export class StorageFullError extends Error {
override name = "StorageFullError";
}
export interface Storage {
/** Throws StorageFullError when the write would pass the cap; never evicts. */
put(name: string, bytes: Uint8Array): Promise<void>;
get(name: string): Promise<Blob | null>;
isFull(): boolean;
}
export function assertCoverName(name: string): void {
if (!COVER_NAME.test(name)) throw new Error(`Not a cover name: ${name}`);
}
export class MemoryStorage implements Storage {
private readonly files = new Map<string, Uint8Array>();
private used = 0;
constructor(private readonly maxBytes: number) {}
async put(name: string, bytes: Uint8Array): Promise<void> {
assertCoverName(name);
const previous = this.files.get(name)?.byteLength ?? 0;
if (this.used - previous + bytes.byteLength > this.maxBytes) {
throw new StorageFullError(`Storage is full (${this.used} of ${this.maxBytes} bytes used).`);
}
this.files.set(name, bytes);
this.used += bytes.byteLength - previous;
}
async get(name: string): Promise<Blob | null> {
const b = this.files.get(name);
return b ? new Blob([b as Uint8Array<ArrayBuffer>]) : null;
}
isFull(): boolean {
return this.maxBytes - this.used < FULL_MARGIN_BYTES;
}
usedBytes(): number {
return this.used;
}
}
+5
View File
@@ -0,0 +1,5 @@
// `import css from "./styles.css" with { type: "text" }` (Bun) — used by the userscript build.
declare module "*.css" {
const text: string;
export default text;
}
+8
View File
@@ -0,0 +1,8 @@
import type { GmXhr } from "./gm";
declare global {
const GM_xmlhttpRequest: GmXhr;
function GM_getValue(key: string): string | undefined;
function GM_setValue(key: string, value: string): void;
function GM_deleteValue(key: string): void;
}
+79
View File
@@ -0,0 +1,79 @@
import type { KeyValueStore } from "../core/session";
import { TransportError, requestHeadersFor, type Transport } from "../core/transport";
export interface GmResponse {
status: number;
statusText: string;
responseHeaders: string;
response: ArrayBuffer | null;
}
export interface GmRequest {
method: string;
url: string;
headers?: Record<string, string>;
data?: unknown;
responseType: "arraybuffer";
anonymous?: boolean;
timeout?: number;
onload(r: GmResponse): void;
onerror(r: unknown): void;
ontimeout(): void;
onabort(): void;
}
export type GmXhr = (req: GmRequest) => { abort(): void };
export function parseHeaders(raw: string): Headers {
const headers = new Headers();
for (const line of raw.split(/\r?\n/)) {
const i = line.indexOf(":");
if (i <= 0) continue;
try {
headers.append(line.slice(0, i).trim(), line.slice(i + 1).trim());
} catch {
// skip header names the Headers API rejects
}
}
return headers;
}
const NULL_BODY = new Set([204, 205, 304]);
/** GM_xmlhttpRequest is not bound by CORS (spec §4.2), so the userscript needs no proxy. */
export function gmTransport(gm: GmXhr): Transport {
return {
fetch(url, init = {}) {
return new Promise<Response>((resolve, reject) => {
if (init.signal?.aborted) return reject(init.signal.reason);
const request = gm({
method: (init.method ?? "GET").toUpperCase(),
url,
headers: { ...requestHeadersFor(url), ...init.headers },
data: init.body ?? undefined,
responseType: "arraybuffer",
anonymous: true,
timeout: init.timeoutMs,
onload: (r) => {
const status = r.status >= 200 && r.status <= 599 ? r.status : 502;
resolve(new Response(NULL_BODY.has(status) ? null : r.response, {
status, statusText: r.statusText, headers: parseHeaders(r.responseHeaders),
}));
},
onerror: () => reject(new TransportError(`Network error: ${url}`, url)),
ontimeout: () => reject(new TransportError(`Timed out: ${url}`, url)),
onabort: () => reject(init.signal?.reason ?? new DOMException("Aborted", "AbortError")),
});
init.signal?.addEventListener("abort", () => request.abort(), { once: true });
});
},
};
}
export function gmStore(
get: (key: string) => string | undefined,
set: (key: string, value: string) => void,
del: (key: string) => void,
): KeyValueStore {
return { get: (k) => get(k) ?? null, set, remove: del };
}
+90
View File
@@ -0,0 +1,90 @@
import { Session } from "../core/session";
import { withPolicy } from "../core/transport";
import { mountApp } from "../web/app";
import styles from "../web/styles.css" with { type: "text" };
import { APP_HTML } from "../web/template";
import type { ServerConfig } from "../web/view-model";
import { gmStore, gmTransport } from "./gm";
const SERVER_KEY = "mbseed.server";
/** MangaBaka series pages: /<media type>/<id>/<slug>. */
const SERIES_PATH = /^\/(manga|manhwa|manhua|novel|oel|other)\/\d+(\/|$)/;
const store = gmStore(GM_getValue, GM_setValue, GM_deleteValue);
const transport = withPolicy(gmTransport(GM_xmlhttpRequest));
let panel: HTMLElement | null = null;
let panelPath: string | null = null;
async function loadConfig(server: string | null): Promise<ServerConfig | null> {
if (!server) return null;
try {
const res = await transport.fetch(`${server}/api/config`);
return res.ok ? ((await res.json()) as ServerConfig) : null;
} catch {
return null;
}
}
function closePanel(): void {
panel?.remove();
panel = null;
panelPath = null;
}
async function openPanel(): Promise<void> {
closePanel();
panelPath = location.pathname;
const server = store.get(SERVER_KEY);
panel = document.createElement("mbseed-panel");
panel.style.cssText = "position:fixed;top:16px;right:16px;bottom:16px;width:min(40rem,calc(100vw - 32px));overflow:auto;z-index:2147483647;";
const shadow = panel.attachShadow({ mode: "open" });
shadow.innerHTML = `<style>${styles}</style>
<div class="mbseed-panel">
<header class="mbseed-panel-bar"><strong>Seed covers</strong>
<button type="button" id="mbseed-close" class="mbseed-panel-close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="2"/></svg>
</button></header>
<details class="mbseed-settings-panel"><summary>Settings</summary>
<label>mangabaka-seeder server (optional) <input id="mbseed-server" type="url" placeholder="https://mbseed.example.org"></label>
<small>Only needed to rehost on that server. litterbox and catbox work without one.</small>
</details>
${APP_HTML}
</div>`;
document.body.append(panel);
const serverInput = shadow.querySelector<HTMLInputElement>("#mbseed-server")!;
serverInput.value = server ?? "";
serverInput.addEventListener("change", () => {
const value = serverInput.value.trim().replace(/\/+$/, "");
if (value) store.set(SERVER_KEY, value);
else store.remove(SERVER_KEY);
void openPanel();
});
shadow.querySelector("#mbseed-close")!.addEventListener("click", closePanel);
const session = new Session({ transport, serverBase: server, catboxVia: "direct", store, persistHostedUrls: true });
mountApp({ root: shadow, session, store, config: await loadConfig(server), catboxNeedsServer: false, seriesLink: location.origin + location.pathname });
}
/** The launcher lives in its own shadow root so MangaBaka's styles can't reach it (DESIGN.md: the stamp). */
const launcherHost = document.createElement("mbseed-launcher");
launcherHost.style.cssText = "position:fixed;right:16px;bottom:16px;z-index:2147483646;";
const launcherRoot = launcherHost.attachShadow({ mode: "open" });
launcherRoot.innerHTML = `<style>
button { font: 800 12.5px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: .14em; text-transform: uppercase;
background: #d6362f; color: #fffaf6; border: 0; padding: 11px 16px; cursor: pointer; box-shadow: 0 8px 14px -8px rgba(29,20,10,.5); }
button:hover { background: #8f1d18; }
button:focus-visible { outline: 2px solid #2f6f6a; outline-offset: 2px; }
</style><button type="button">Seed covers</button>`;
const launcher = launcherRoot.querySelector("button")!;
launcher.addEventListener("click", () => void openPanel());
document.body.append(launcherHost);
/** MangaBaka is a single-page app: follow client-side navigation. */
function sync(): void {
const onSeries = SERIES_PATH.test(location.pathname);
launcherHost.hidden = !onSeries;
if (panel && panelPath !== location.pathname) closePanel();
}
sync();
setInterval(sync, 1000);
+476
View File
@@ -0,0 +1,476 @@
import { LITTERBOX_TIMES, type HostId, type HostOptions, type LitterboxTime } from "../core/hosting/host";
import { HOSTS } from "../core/hosting/registry";
import { coverFormUrl } from "../core/mangabaka/client";
import { CONTENT_RATINGS, GRANULARITIES, type ContentRating, type Granularity } from "../core/model";
import type { RunResult } from "../core/pipeline";
import type { HistoryStore } from "../core/saved";
import { loadToken, saveToken, type KeyValueStore, type PreparedSubmission, type Session } from "../core/session";
import { zipStore } from "../core/zip";
import { HistoryKeeper } from "./history-keeper";
import { renderRecent } from "./recent";
import { renderCards, renderPresent, renderPreview } from "./render";
import { saveFile } from "./save-file";
import { GRANULARITY_LABELS, defaultHost, hostChoices, relativeTime, stageText, type ServerConfig } from "./view-model";
export const USERHASH_KEY = "mbseed.catbox-userhash";
export const VIEW_KEY = "mbseed.view";
export interface AppOptions {
root: ParentNode;
session: Session;
store: KeyValueStore;
config: ServerConfig | null;
/** The page reaches catbox through our server; the userscript goes direct. */
catboxNeedsServer: boolean;
/** Userscript: the MangaBaka series page it runs on. */
seriesLink?: string;
/** The page's history of searches (spec 002 §3): null when the browser refused storage; the userscript has none. */
history?: HistoryStore | null;
}
/** Wires APP_HTML to a Session. Nothing is uploaded or sent without a click (spec §1). */
export function mountApp(o: AppOptions): void {
const q = <T extends Element>(id: string): T => {
const found = o.root.querySelector<T>(`#${id}`);
if (!found) throw new Error(`mbseed: #${id} is missing from the template`);
return found;
};
const ui = {
form: q<HTMLFormElement>("run-form"),
mbLink: q<HTMLInputElement>("mb-link"),
sourceLink: q<HTMLInputElement>("source-link"),
historyOn: q<HTMLInputElement>("history-on"),
historyEvery: q<HTMLSelectElement>("history-every"),
ratingAll: q<HTMLSelectElement>("rating-all"),
run: q<HTMLButtonElement>("run"),
cancel: q<HTMLButtonElement>("cancel"),
skipHistory: q<HTMLButtonElement>("skip-history"),
stage: q<HTMLElement>("stage"),
progress: q<HTMLProgressElement>("progress"),
warnings: q<HTMLUListElement>("warnings"),
review: q<HTMLElement>("review"),
seriesTitle: q<HTMLElement>("series-title"),
seriesSource: q<HTMLElement>("series-source"),
matureWarning: q<HTMLElement>("mature-warning"),
linkWarning: q<HTMLElement>("link-warning"),
linkConfirm: q<HTMLInputElement>("link-confirm"),
makeDefault: q<HTMLInputElement>("make-default"),
defaultNote: q<HTMLElement>("default-note"),
selectedCount: q<HTMLElement>("selected-count"),
cards: q<HTMLElement>("cards"),
present: q<HTMLElement>("present-list"),
hosts: q<HTMLElement>("hosts"),
litterboxTime: q<HTMLSelectElement>("litterbox-time"),
userhash: q<HTMLInputElement>("catbox-userhash"),
userhashRemember: q<HTMLInputElement>("catbox-remember"),
rehost: q<HTMLButtonElement>("rehost"),
rehostStatus: q<HTMLElement>("rehost-status"),
manualText: q<HTMLTextAreaElement>("manual-text"),
download: q<HTMLButtonElement>("download"),
manualStatus: q<HTMLElement>("manual-status"),
copy: q<HTMLButtonElement>("copy"),
formLink: q<HTMLAnchorElement>("form-link"),
token: q<HTMLInputElement>("token"),
tokenRemember: q<HTMLInputElement>("token-remember"),
preview: q<HTMLButtonElement>("preview"),
previewDiff: q<HTMLElement>("preview-diff"),
confirm: q<HTMLButtonElement>("confirm"),
submitStatus: q<HTMLElement>("submit-status"),
recent: q<HTMLElement>("recent"),
recentList: q<HTMLElement>("recent-list"),
recentStatus: q<HTMLElement>("recent-status"),
recentClear: q<HTMLButtonElement>("recent-clear"),
recentShelfWrap: q<HTMLElement>("recent-shelf-wrap"),
recentShelf: q<HTMLElement>("recent-shelf"),
foundLine: q<HTMLElement>("found-line"),
foundWhen: q<HTMLElement>("found-when"),
searchAgain: q<HTMLButtonElement>("search-again"),
};
const plates = [...o.root.querySelectorAll<HTMLElement>(".mbseed-plates [data-stage]")];
let currentStage = "";
/** Stage plates change state in place: done, active, failed, or pending. */
/** Each plate shows a short count once its stage has something to say (images, language, n/n…). */
const setCount = (stage: string, text: string) => {
const em = plates.find((p) => p.dataset.stage === stage)?.querySelector("em");
if (em) em.textContent = text;
};
const markStage = (stage: string, outcome: "running" | "done" | "failed" | "reset" = "running") => {
currentStage = stage;
const at = plates.findIndex((p) => p.dataset.stage === stage);
plates.forEach((plate, i) => {
plate.dataset.state =
outcome === "reset" ? "" : outcome === "done" ? "done" : i < at ? "done" : i === at ? (outcome === "failed" ? "failed" : "active") : "";
});
};
let running: AbortController | null = null;
let stopHistory: AbortController | null = null;
let prepared: PreparedSubmission | null = null;
const keeper = o.history
? new HistoryKeeper(o.history, o.session, {
rating: () => ui.ratingAll.value as ContentRating,
onStatus: (error) => {
status(ui.recentStatus, error ?? "", error !== null);
void refreshRecent();
},
})
: null;
const fill = (select: HTMLSelectElement, values: readonly (string | number)[], label: (v: string) => string, selected: string) =>
select.replaceChildren(...values.map(String).map((v) => new Option(label(v), v, false, v === selected)));
fill(ui.historyEvery, GRANULARITIES, (v) => GRANULARITY_LABELS[Number(v) as Granularity], "6");
fill(ui.ratingAll, CONTENT_RATINGS, (v) => v, "safe");
fill(ui.litterboxTime, LITTERBOX_TIMES, (v) => HOSTS.litterbox.lifetime({ litterboxTime: v as LitterboxTime }), "72h");
if (o.seriesLink) {
ui.mbLink.value = o.seriesLink;
ui.mbLink.readOnly = true;
}
const savedToken = loadToken(o.store);
if (savedToken) {
ui.token.value = savedToken;
ui.tokenRemember.checked = true;
}
const savedUserhash = o.store.get(USERHASH_KEY);
if (savedUserhash) {
ui.userhash.value = savedUserhash;
ui.userhashRemember.checked = true;
}
const viewButtons = [...o.root.querySelectorAll<HTMLButtonElement>(".mbseed-view[data-view]")];
const showView = (view: string) => {
ui.cards.dataset.view = view === "timeline" ? "timeline" : "shelf";
for (const b of viewButtons) b.setAttribute("aria-pressed", String(b.dataset.view === ui.cards.dataset.view));
};
showView(o.store.get(VIEW_KEY) ?? "shelf");
for (const b of viewButtons) {
b.addEventListener("click", () => {
showView(b.dataset.view ?? "shelf");
o.store.set(VIEW_KEY, ui.cards.dataset.view ?? "shelf");
draw();
});
}
const hostOptions = (): HostOptions => ({
litterboxTime: ui.litterboxTime.value as LitterboxTime,
catboxUserhash: ui.userhash.value.trim() || undefined,
});
const selectedHost = (): HostId | null =>
(o.root.querySelector<HTMLInputElement>('input[name="mbseed-host"]:checked')?.value as HostId | undefined) ?? null;
function drawHosts(): void {
const choices = hostChoices(o.config, hostOptions(), { catboxNeedsServer: o.catboxNeedsServer });
const keep = selectedHost();
const current = choices.some((c) => c.id === keep && !c.disabled) ? keep : defaultHost(choices);
ui.hosts.replaceChildren(...choices.map((c) => {
const input = Object.assign(document.createElement("input"), {
type: "radio", name: "mbseed-host", value: c.id, checked: c.id === current, disabled: c.disabled !== null,
});
const label = document.createElement("label");
label.append(input, Object.assign(document.createElement("span"), { textContent: c.label }),
Object.assign(document.createElement("small"), { textContent: c.lifetime }));
const why = c.disabled ?? c.warning;
if (why) label.append(Object.assign(document.createElement("small"), { className: c.disabled ? "is-why" : "is-warn", textContent: why }));
return label;
}));
}
const status = (where: HTMLElement, text: string, isError = false) => {
where.textContent = text;
where.classList.toggle("is-error", isError);
};
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** Any edit after a preview invalidates it: what is confirmed must be what was previewed. */
function edited(): void {
prepared = null;
ui.confirm.hidden = true;
ui.previewDiff.replaceChildren();
draw();
keeper?.edited();
}
async function refreshRecent(): Promise<void> {
if (!o.history) return;
const items = await o.history.list().catch(() => []);
ui.recent.hidden = items.length === 0;
ui.recentShelfWrap.hidden = items.length === 0;
renderRecent(ui.recentList, ui.recentShelf, items, keeper?.currentId ?? null, new Date(), { open: (id) => void open(id), remove: (id) => void forget(id) });
}
/** Shows a finished search: plates, notes, the series head and the shelf. */
function show(r: RunResult, historyOff: boolean): void {
markStage("compare", "done");
setCount("series", `#${r.seriesId}`);
setCount("source", r.series.language);
if (historyOff) setCount("history", "off");
setCount("inspect", String(r.fresh.length + r.present.length));
setCount("compare", `${r.present.length} on MangaBaka`);
const notes = [...r.warnings];
const landing = r.fresh.filter((c) => c.shape === "tall").length;
if (landing) {
notes.push(`${landing} tall image${landing === 1 ? " looks" : "s look"} like landing-page art and start${landing === 1 ? "s" : ""} unselected.`);
}
ui.warnings.replaceChildren(...notes.map((textContent) => Object.assign(document.createElement("li"), { textContent })));
ui.progress.max = 1;
ui.progress.value = 1;
ui.seriesTitle.textContent = r.mbSeries.title;
ui.seriesSource.textContent = `← ${r.source.name} “${r.series.title}” · ${r.series.language}`;
ui.matureWarning.hidden = !r.series.mature;
ui.formLink.href = coverFormUrl(r.seriesId);
ui.review.hidden = false;
draw();
}
/** Clears what belonged to the previous series on screen. */
function resetReview(): void {
prepared = null;
ui.confirm.hidden = true;
ui.previewDiff.replaceChildren();
ui.linkConfirm.checked = false;
for (const line of [ui.rehostStatus, ui.manualStatus, ui.submitStatus]) status(line, "");
}
async function open(seriesId: number): Promise<void> {
if (!keeper || running) return;
try {
const back = await keeper.open(seriesId, Date.now());
resetReview();
if (!ui.mbLink.readOnly) ui.mbLink.value = back.input.mangabakaLink;
ui.sourceLink.value = back.input.sourceLink;
ui.historyOn.checked = back.input.history !== null;
if (back.input.history !== null) ui.historyEvery.value = String(back.input.history);
ui.ratingAll.value = back.rating;
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
show(back.result, back.input.history === null);
ui.foundWhen.textContent = `Found ${relativeTime(back.foundAt)}.`;
ui.foundLine.hidden = false;
status(ui.stage, "");
} catch (err) {
status(ui.stage, errorText(err), true);
}
void refreshRecent();
}
async function forget(seriesId: number): Promise<void> {
await keeper?.forget(seriesId);
void refreshRecent();
}
function draw(): void {
const r = o.session.result;
if (!r) return;
renderCards(ui.cards, o.session, edited);
renderPresent(ui.present, r.present);
const selected = o.session.selected().length;
ui.selectedCount.textContent = `${selected} of ${o.session.cards.length} new cover(s) selected`;
ui.linkWarning.hidden = r.linkMatches;
ui.makeDefault.checked = o.session.makeDefault;
ui.makeDefault.disabled = !r.suggestedDefault;
ui.defaultNote.textContent = r.suggestedDefault
? "One main cover per series: this replaces it for every language."
: "No current poster was found to suggest as the main cover.";
ui.rehost.textContent = `Rehost ${selected} cover${selected === 1 ? "" : "s"}`;
ui.download.textContent = `Download ${selected} cover${selected === 1 ? "" : "s"} (ZIP)`;
ui.download.disabled = selected === 0;
ui.manualText.value = o.session.manualText();
const blocked = selected === 0 || (!r.linkMatches && !ui.linkConfirm.checked);
ui.rehost.disabled = blocked;
ui.preview.disabled = blocked;
}
async function run(): Promise<void> {
if (running) return;
running = new AbortController();
// An edit still waiting to be saved must reach the history before this search reads it back.
await keeper?.flush();
stopHistory = new AbortController();
prepared = null;
ui.run.disabled = true;
ui.cancel.hidden = false;
ui.warnings.replaceChildren();
ui.review.hidden = true;
resetReview();
ui.foundLine.hidden = true;
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
status(ui.stage, "Starting…");
try {
const r = await o.session.run(
{
mangabakaLink: ui.mbLink.value,
sourceLink: ui.sourceLink.value,
history: ui.historyOn.checked ? (Number(ui.historyEvery.value) as Granularity) : null,
},
{
signal: running.signal,
stopHistory: stopHistory.signal,
onStage: (stage) => {
markStage(stage);
status(ui.stage, stageText(stage));
ui.progress.removeAttribute("value");
ui.skipHistory.hidden = stage !== "history";
},
onProgress: (stage, done, total) => {
setCount(stage, `${done} / ${total}`);
status(ui.stage, stageText(stage, done, total));
ui.progress.max = total;
ui.progress.value = done;
},
onWarning: (message) => ui.warnings.append(Object.assign(document.createElement("li"), { textContent: message })),
},
ui.ratingAll.value as ContentRating,
);
// Searching a series again keeps what the user did with it (spec 002 §3.3).
await keeper?.searched(r, Date.now());
show(r, !ui.historyOn.checked);
status(ui.stage, `Found ${r.fresh.length} new cover(s); ${r.present.length} already on MangaBaka.`);
} catch (err) {
if (running?.signal.aborted) {
markStage(currentStage, "reset");
status(ui.stage, "Cancelled.");
} else {
markStage(currentStage, "failed");
status(ui.stage, errorText(err), true);
}
} finally {
running = null;
stopHistory = null;
ui.run.disabled = false;
ui.cancel.hidden = true;
ui.skipHistory.hidden = true;
}
}
function token(): string | null {
const t = ui.token.value.trim();
saveToken(o.store, ui.tokenRemember.checked && t ? t : null);
return t || null;
}
function rememberUserhash(): void {
const hash = ui.userhash.value.trim();
if (ui.userhashRemember.checked && hash) o.store.set(USERHASH_KEY, hash);
else o.store.remove(USERHASH_KEY);
}
ui.form.addEventListener("submit", (e) => {
e.preventDefault();
void run();
});
ui.cancel.addEventListener("click", () => running?.abort());
ui.skipHistory.addEventListener("click", () => stopHistory?.abort());
ui.linkConfirm.addEventListener("change", draw);
ui.makeDefault.addEventListener("change", () => {
o.session.makeDefault = ui.makeDefault.checked;
edited();
});
ui.ratingAll.addEventListener("change", () => {
if (!o.session.result) return;
o.session.setRatingForAll(ui.ratingAll.value as ContentRating);
edited();
});
ui.litterboxTime.addEventListener("change", drawHosts);
ui.userhash.addEventListener("change", rememberUserhash);
ui.userhashRemember.addEventListener("change", rememberUserhash);
ui.tokenRemember.addEventListener("change", () => void token());
ui.rehost.addEventListener("click", async () => {
const host = selectedHost();
if (!host) return status(ui.rehostStatus, "Pick a host first.", true);
ui.rehost.disabled = true;
status(ui.rehostStatus, "Uploading…");
try {
const res = await o.session.rehost(host, hostOptions(), (done, total) => status(ui.rehostStatus, `Uploading… ${done}/${total}`));
if (res.allFailed) status(ui.rehostStatus, `Every upload failed: ${res.failures[0]?.message ?? "unknown error"}`, true);
else {
const done = host === "direct" ? `${res.hosted.size} ready with their source URLs (not rehosted)` : `${res.hosted.size} rehosted`;
keeper?.mark({ rehosted: { at: new Date().toISOString(), host } });
status(ui.rehostStatus, `${done}${res.failures.length ? `, ${res.failures.length} failed (see the covers)` : ""}.`);
}
} catch (err) {
status(ui.rehostStatus, errorText(err), true);
} finally {
edited();
}
});
ui.download.addEventListener("click", () => {
try {
const bundle = o.session.bundle();
saveFile(zipStore(bundle.files), bundle.zipName, "application/zip", ui.download);
status(ui.manualStatus, `Saved ${bundle.zipName} (${bundle.files.length - 1} covers and covers.txt).`);
keeper?.mark({ downloaded: new Date().toISOString() });
} catch (err) {
status(ui.manualStatus, errorText(err), true);
}
});
ui.copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(ui.manualText.value);
ui.copy.textContent = "Copied";
setTimeout(() => (ui.copy.textContent = "Copy all selected"), 1500);
} catch {
ui.manualText.select();
}
});
ui.preview.addEventListener("click", async () => {
const t = token();
if (!t) return status(ui.submitStatus, "Paste your MangaBaka personal access token first.", true);
ui.preview.disabled = true;
status(ui.submitStatus, "Asking MangaBaka for a preview…");
try {
prepared = await o.session.preview(t);
renderPreview(ui.previewDiff, prepared.preview);
ui.confirm.hidden = false;
status(ui.submitStatus, "Check the changes. Confirming rehosts the covers and files the edit request for review.");
} catch (err) {
status(ui.submitStatus, errorText(err), true);
} finally {
ui.preview.disabled = false;
}
});
ui.confirm.addEventListener("click", async () => {
const t = token();
const host = selectedHost();
if (!t || !prepared || !host) return;
ui.confirm.disabled = true;
status(ui.submitStatus, "Rehosting and filing the edit request…");
try {
const outcome = await o.session.submit(t, prepared, host, hostOptions());
if (outcome.status === "saved") {
status(ui.submitStatus, `Filed edit request #${outcome.result.submission_id} (${outcome.result.status}). A MangaBaka moderator will review it.`);
keeper?.mark({ filed: { at: new Date().toISOString(), submissionId: outcome.result.submission_id } });
prepared = null;
ui.confirm.hidden = true;
} else {
prepared = outcome.prepared;
renderPreview(ui.previewDiff, prepared.preview);
status(ui.submitStatus, outcome.reason, true);
}
} catch (err) {
status(ui.submitStatus, errorText(err), true);
} finally {
ui.confirm.disabled = false;
renderCards(ui.cards, o.session, edited);
}
});
ui.searchAgain.addEventListener("click", () => void run());
ui.recentClear.addEventListener("click", async () => {
if (!keeper || !confirm("Clear the whole history? The saved covers are deleted from this browser.")) return;
await keeper.clear();
void refreshRecent();
});
// Edits made just before the tab closes or is hidden still reach the history.
if (keeper) {
addEventListener("pagehide", () => void keeper.flush());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") void keeper.flush();
});
}
drawHosts();
if (o.history === null) status(ui.recentStatus, "History is off: this browser doesn't let the page store data.");
void refreshRecent();
}
+46
View File
@@ -0,0 +1,46 @@
// Dark Reader marks <html data-darkreader-scheme="dark"> while it restyles a page. The page has its own
// dark theme, so when that mark appears we switch to it and add <meta name="darkreader-lock">, which makes
// Dark Reader remove its styles. One-way for the page's lifetime: removing its theme also removes the mark.
export interface ThemeDocument {
documentElement: { getAttribute(name: string): string | null; setAttribute(name: string, value: string): void };
addLockMeta(): void;
/** Calls onChange when <html>'s attributes change; returns a disconnect function. */
observeRoot(onChange: () => void): () => void;
}
export function followDarkReader(doc: ThemeDocument): void {
const adopt = (): boolean => {
if (doc.documentElement.getAttribute("data-darkreader-scheme") !== "dark") return false;
doc.documentElement.setAttribute("data-theme", "dark");
doc.addLockMeta();
return true;
};
let adopted = false;
const disconnect = doc.observeRoot(() => {
if (!adopted && adopt()) {
adopted = true;
disconnect();
}
});
if (adopt()) {
adopted = true;
disconnect();
}
}
export function browserThemeDocument(document: Document): ThemeDocument {
return {
documentElement: document.documentElement,
addLockMeta: () => {
const meta = document.createElement("meta");
meta.name = "darkreader-lock";
document.head.append(meta);
},
observeRoot: (onChange) => {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-darkreader-scheme"] });
return () => observer.disconnect();
},
};
}
+60
View File
@@ -0,0 +1,60 @@
import { summarize, type HistoryStore, type SavedSearch, type SearchSummary } from "../core/saved";
// Spec 002 §3.2: IndexedDB on the page's origin. `summaries` is what the list reads; `searches` holds the bytes.
const DB = "mbseed";
const SUMMARIES = "summaries";
const SEARCHES = "searches";
const done = <T>(req: IDBRequest<T>): Promise<T> =>
new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed"));
});
const committed = (tx: IDBTransaction): Promise<void> =>
new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
});
/** The page's history, or null when this browser won't give us IndexedDB (private windows, blocked storage). */
export async function openHistory(factory: IDBFactory | undefined = globalThis.indexedDB): Promise<HistoryStore | null> {
if (!factory) return null;
let db: IDBDatabase;
try {
const req = factory.open(DB, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(SUMMARIES, { keyPath: "seriesId" });
req.result.createObjectStore(SEARCHES, { keyPath: "seriesId" });
};
db = await done(req);
} catch {
return null;
}
const write = async (run: (summaries: IDBObjectStore, searches: IDBObjectStore) => void) => {
const tx = db.transaction([SUMMARIES, SEARCHES], "readwrite");
run(tx.objectStore(SUMMARIES), tx.objectStore(SEARCHES));
await committed(tx);
};
return {
list: async () => {
const all = await done(db.transaction(SUMMARIES).objectStore(SUMMARIES).getAll() as IDBRequest<SearchSummary[]>);
return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
},
get: async (id) => (await done(db.transaction(SEARCHES).objectStore(SEARCHES).get(id) as IDBRequest<SavedSearch | undefined>)) ?? null,
put: (s) => write((summaries, searches) => {
summaries.put(summarize(s));
searches.put(s);
}),
remove: (id) => write((summaries, searches) => {
summaries.delete(id);
searches.delete(id);
}),
clear: () => write((summaries, searches) => {
summaries.clear();
searches.clear();
}),
};
}
+123
View File
@@ -0,0 +1,123 @@
import type { ContentRating } from "../core/model";
import type { RunResult } from "../core/pipeline";
import { carryEdits, fromSaved, saveAndPrune, toSaved, type HistoryStore, type Marks } from "../core/saved";
import type { Session } from "../core/session";
import { SOURCES } from "../core/sources/registry";
// Spec 002 §3: when the series on screen is written to the history. No DOM, so it is tested directly.
export interface KeeperOptions {
/** The rating chosen for new covers, stored with the inputs. */
rating(): ContentRating;
/** null once saving works (again), else a message for the user. */
onStatus(error: string | null): void;
schedule?(fn: () => void, ms: number): unknown;
cancel?(handle: unknown): void;
delayMs?: number;
}
export const SAVE_REFUSED = "This search couldn't be saved: the browser refused the storage.";
export class HistoryKeeper {
marks: Marks = {};
foundAt = "";
currentId: number | null = null;
/** False after the entry on screen was removed or the history cleared, until the next search or open. */
private tracked = false;
private pending = false;
private timer: unknown;
private chain: Promise<void> = Promise.resolve();
private readonly schedule: (fn: () => void, ms: number) => unknown;
private readonly cancel: (handle: unknown) => void;
constructor(private readonly store: HistoryStore, private readonly session: Session, private readonly opts: KeeperOptions) {
this.schedule = opts.schedule ?? ((fn, ms) => setTimeout(fn, ms));
this.cancel = opts.cancel ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
}
/** After a search: keep what the user did with this series before (spec 002 §3.3), then save at once. */
async searched(r: RunResult, now: number): Promise<void> {
this.stopTimer();
const previous = await this.store.get(r.seriesId).catch(() => null);
if (previous) carryEdits(previous.cards, this.session.cards, now);
this.marks = previous?.marks ?? {};
this.foundAt = new Date(now).toISOString();
this.currentId = r.seriesId;
this.tracked = true;
await this.save();
}
/** Reopens an entry into the session. Saves the one on screen first only if it has unsaved edits. */
async open(seriesId: number, now: number) {
await this.flush();
const entry = await this.store.get(seriesId);
if (!entry) throw new Error("That entry is no longer in the history.");
const back = fromSaved(entry, SOURCES, now);
this.session.restore(back.result, back.cards, back.makeDefault, back.input);
this.marks = back.marks;
this.foundAt = back.foundAt;
this.currentId = seriesId;
this.tracked = true;
return back;
}
edited(): void {
if (!this.tracked) return;
this.stopTimer();
this.pending = true;
this.timer = this.schedule(() => void this.save(), this.opts.delayMs ?? 500);
}
mark(patch: Marks): void {
this.marks = { ...this.marks, ...patch };
if (this.tracked) void this.save();
}
/** Saves now if an edit is waiting (before a new search, opening another entry, or leaving the page). */
async flush(): Promise<void> {
if (this.pending) await this.save();
else await this.idle();
}
idle(): Promise<void> {
return this.chain;
}
async forget(seriesId: number): Promise<void> {
if (this.currentId === seriesId) this.untrack();
await this.idle();
await this.store.remove(seriesId).catch(() => undefined);
}
async clear(): Promise<void> {
this.untrack();
await this.idle();
await this.store.clear().catch(() => undefined);
}
async save(): Promise<void> {
this.stopTimer();
const r = this.session.result;
const input = this.session.input;
if (!this.tracked || !r || !input) return;
const entry = toSaved({
result: r, cards: this.session.cards, makeDefault: this.session.makeDefault, input,
rating: this.opts.rating(), marks: this.marks, foundAt: this.foundAt, now: new Date(),
});
this.chain = this.chain
.then(() => saveAndPrune(this.store, entry))
.then(() => this.opts.onStatus(null), () => this.opts.onStatus(SAVE_REFUSED));
await this.chain;
}
private untrack(): void {
this.stopTimer();
this.tracked = false;
}
private stopTimer(): void {
if (this.timer !== undefined) this.cancel(this.timer);
this.timer = undefined;
this.pending = false;
}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>MangaBaka Seeder</title>
<link rel="stylesheet" href="./styles.css">
<script type="module" src="./main.ts"></script>
</head>
<body class="mbseed-page">
<div id="app"></div>
<!-- Page views, self-hosted Shynet (the page only; the userscript panel isn't tracked). -->
<noscript>
<img src="https://analytics.lucaswinther.info/ingress/2c42f566-eee1-499e-99b8-0e9d2a4cb5da/pixel.gif" alt="">
</noscript>
<script defer src="https://analytics.lucaswinther.info/ingress/2c42f566-eee1-499e-99b8-0e9d2a4cb5da/script.js"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
import { Session } from "../core/session";
import { withPolicy } from "../core/transport";
import { mountApp } from "./app";
import { browserThemeDocument, followDarkReader } from "./dark-reader";
import { openHistory } from "./history-db";
import { APP_HTML } from "./template";
import { localStore, pageTransport } from "./transport";
import type { ServerConfig } from "./view-model";
followDarkReader(browserThemeDocument(document));
const app = document.getElementById("app")!;
app.innerHTML = APP_HTML;
const config = await fetch("/api/config")
.then((r) => (r.ok ? (r.json() as Promise<ServerConfig>) : null))
.catch(() => null);
const store = localStore();
const history = await openHistory();
const session = new Session({
transport: withPolicy(pageTransport(location.origin)),
serverBase: location.origin,
catboxVia: "server",
store,
persistHostedUrls: false,
});
mountApp({ root: document, session, store, config, catboxNeedsServer: true, history });
+63
View File
@@ -0,0 +1,63 @@
import type { SearchSummary } from "../core/saved";
import { el } from "./render";
import { markText, relativeTime, summaryLine } from "./view-model";
// Spec 002 §3.4: the History list on the left desk and the shelf of recent series on the start screen.
export interface RecentHandlers {
open(seriesId: number): void;
remove(seriesId: number): void;
}
const SHELF_SIZE = 6;
/** Thumbnail Blob URLs of the last render, revoked on the next one. */
let thumbUrls: string[] = [];
function thumb(s: SearchSummary): HTMLImageElement | HTMLSpanElement {
if (!s.thumb) return el("span", { className: "mbseed-recent-thumb is-blank" });
const url = URL.createObjectURL(new Blob([s.thumb.bytes as Uint8Array<ArrayBuffer>], { type: s.thumb.contentType }));
thumbUrls.push(url);
return el("img", { className: "mbseed-recent-thumb", src: url, alt: "", decoding: "async" });
}
const CROSS = `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" stroke="currentColor" stroke-width="1.6"/></svg>`;
function row(s: SearchSummary, current: boolean, now: Date, h: RecentHandlers): HTMLLIElement {
const marks = markText(s.marks);
const open = el("button", { type: "button", className: "mbseed-recent-open" },
thumb(s),
el("span", { className: "mbseed-recent-text" },
el("span", { className: "mbseed-recent-title", textContent: s.title }),
el("span", { className: "mbseed-recent-meta", textContent: `${relativeTime(s.savedAt, now)} · ${summaryLine(s)}` }),
...(marks.length ? [el("span", { className: "mbseed-recent-marks", textContent: marks.join(" · ") })] : [])));
if (current) open.setAttribute("aria-current", "true");
open.addEventListener("click", () => h.open(s.seriesId));
const remove = el("button", { type: "button", className: "mbseed-recent-remove", title: "Remove from history", ariaLabel: `Remove ${s.title} from history` });
remove.innerHTML = CROSS;
remove.addEventListener("click", () => h.remove(s.seriesId));
return el("li", {}, open, remove);
}
function shelfBook(s: SearchSummary, now: Date, h: RecentHandlers): HTMLButtonElement {
const book = el("button", { type: "button", className: "mbseed-recent-book", ariaLabel: `Open ${s.title}` },
el("span", { className: "mbseed-recent-jacket" }, thumb(s)),
el("span", { className: "mbseed-recent-band" },
el("span", { className: "mbseed-recent-title", textContent: s.title }),
el("span", { className: "mbseed-recent-meta", textContent: `${relativeTime(s.savedAt, now)} · ${s.fresh}\u00a0new` })));
book.addEventListener("click", () => h.open(s.seriesId));
return book;
}
export function renderRecent(
list: HTMLElement,
shelf: HTMLElement,
items: readonly SearchSummary[],
current: number | null,
now: Date,
h: RecentHandlers,
): void {
for (const url of thumbUrls) URL.revokeObjectURL(url);
thumbUrls = [];
list.replaceChildren(...items.map((s) => row(s, s.seriesId === current, now, h)));
shelf.replaceChildren(...items.slice(0, SHELF_SIZE).map((s) => shelfBook(s, now, h)));
}
+115
View File
@@ -0,0 +1,115 @@
import type { ExistingImage } from "../core/covers";
import type { PreviewResult } from "../core/mangabaka/types";
import { CONTENT_RATINGS, type CoverRole, type ImageType, type InspectedCover } from "../core/model";
import type { ReviewCard, Session } from "../core/session";
import { cardFacts, seenBar, seenSpan, timelineGroups, type Span } from "./view-model";
const IMAGE_TYPES: readonly ImageType[] = ["volume", "volume_back", "chapter", "season", "banner", "audiobook", "other"];
const ROLE: Readonly<Record<CoverRole, string>> = {
poster: "Poster", square: "Square thumbnail", banner: "Banner", foreground: "Foreground art", other: "Image",
};
/** Results already shelved once: the shelving motion plays on a new run, not on every edit. */
const shelved = new WeakSet<object>();
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Partial<HTMLElementTagNameMap[K]> = {},
...children: (Node | string)[]
): HTMLElementTagNameMap[K] {
const node = Object.assign(document.createElement(tag), props);
node.append(...children);
return node;
}
function picker<T extends string>(values: readonly T[], value: T, label: string, onChange: (v: T) => void): HTMLSelectElement {
const select = el("select", { className: "mbseed-select is-small", ariaLabel: label }, ...values.map((v) => new Option(v, v, false, v === value)));
select.addEventListener("change", () => onChange(select.value as T));
return select;
}
/** One book per new cover: the jacket (click to include) and its obi band with the evidence and fields. */
function book(card: ReviewCard, index: number, session: Session, onEdit: () => void, span: Span, mainSha: string | null): HTMLElement {
const c = card.cover;
const edit = (patch: Parameters<Session["update"]>[1]) => {
session.update(c.sha256, patch);
onEdit();
};
const isMain = c.sha256 === mainSha && card.selected;
const include = el("input", { type: "checkbox", className: "mbseed-tick", checked: card.selected, ariaLabel: `Include this ${ROLE[c.candidate.role].toLowerCase()}` });
include.addEventListener("change", () => edit({ selected: include.checked }));
const note = el("input", { type: "text", className: "mbseed-note", value: card.note, ariaLabel: "Note for the moderator" });
note.addEventListener("change", () => edit({ note: note.value }));
const bar = seenBar(c, span);
const run = el("i");
run.style.left = `${bar.left}%`;
run.style.width = `${bar.width}%`;
const seen = el("div", { className: "mbseed-seen" }, run);
seen.setAttribute("aria-hidden", "true");
const classes = ["mbseed-book", `is-${c.shape}`, card.selected ? "is-selected" : "is-off", isMain && "is-main"].filter(Boolean).join(" ");
const item = el("article", { className: classes },
el("label", { className: "mbseed-jacket" },
el("img", { src: card.hostedUrl ?? c.fetchedFrom, alt: card.note, loading: "lazy", decoding: "async", referrerPolicy: "no-referrer" }),
include),
el("div", { className: "mbseed-obi" },
el("p", { className: "mbseed-obi-role", textContent: isMain ? "Main cover" : ROLE[c.candidate.role] }),
el("p", { className: "mbseed-obi-facts", textContent: cardFacts(card).join(" · ") }),
el("div", { className: "mbseed-obi-seen-row" },
seen,
card.hostedUrl
? el("a", { className: "mbseed-obi-hosted", href: card.hostedUrl, target: "_blank", rel: "noopener", title: `Rehosted copy: ${card.hostedUrl}`, textContent: "Rehosted" })
: ""),
el("div", { className: "mbseed-obi-fields" },
picker(IMAGE_TYPES, card.type, "Type", (type) => edit({ type })),
picker(CONTENT_RATINGS, card.contentRating, "Content rating", (contentRating) => edit({ contentRating }))),
note,
card.error ? el("p", { className: "mbseed-obi-error", role: "alert", textContent: card.error }) : ""));
item.style.setProperty("--i", String(index));
return item;
}
/** The covers as a shelf (default) or a timeline, chosen by `container.dataset.view`. */
export function renderCards(container: HTMLElement, session: Session, onEdit: () => void): void {
const result = session.result;
if (!result) return void container.replaceChildren();
const fresh = !shelved.has(result);
shelved.add(result);
container.classList.toggle("is-fresh", fresh);
const span = seenSpan(session.cards.map((c) => c.cover));
const mainSha = session.makeDefault ? (result.suggestedDefault?.sha256 ?? null) : null;
let n = 0;
const books = (cards: readonly ReviewCard[]) => cards.map((card) => book(card, n++, session, onEdit, span, mainSha));
if (container.dataset.view === "timeline") {
// A centre rail of years; within each year the covers alternate left and right of it.
container.replaceChildren(el("ol", { className: "mbseed-timeline" },
...timelineGroups(session.cards).map((group) => {
const placed = books(group.cards);
return el("li", { className: "mbseed-year" },
el("div", { className: "mbseed-year-side is-left" }, ...placed.filter((_, i) => i % 2 === 0)),
el("h4", { className: "mbseed-year-key", textContent: String(group.year) }),
el("div", { className: "mbseed-year-side is-right" }, ...placed.filter((_, i) => i % 2 === 1)));
})));
} else {
container.replaceChildren(el("div", { className: "mbseed-shelf" }, ...books(session.cards)));
}
}
export function renderPresent(container: HTMLElement, present: readonly { cover: InspectedCover; existing: ExistingImage }[]): void {
container.replaceChildren(
present.length
? el("ul", { className: "mbseed-present-list" }, ...present.map((p) => el("li", {},
el("img", { src: p.cover.fetchedFrom, alt: "", loading: "lazy", referrerPolicy: "no-referrer" }),
el("span", { textContent: `${p.cover.width}×${p.cover.height} · MangaBaka image #${p.existing.id} (${p.existing.language}, ${p.existing.type})` }))))
: el("p", { className: "mbseed-hint", textContent: "None of the found covers is on MangaBaka yet." }),
);
}
export function renderPreview(container: HTMLElement, preview: PreviewResult): void {
container.replaceChildren(
preview.has_changes
? el("ul", { className: "mbseed-diff" }, ...preview.changes.map((c) =>
el("li", { className: `is-${c.type}`, textContent: `${c.type}: ${c.label ?? c.identity ?? c.field}` })))
: el("p", { className: "mbseed-hint", textContent: "MangaBaka sees no changes: these covers are already there." }),
);
}
+10
View File
@@ -0,0 +1,10 @@
/** Hands bytes to the browser as a download. Works from the page and from the userscript's shadow root. */
export function saveFile(bytes: Uint8Array, name: string, type: string, beside: Element): void {
const url = URL.createObjectURL(new Blob([bytes as Uint8Array<ArrayBuffer>], { type }));
const a = Object.assign(document.createElement("a"), { href: url, download: name, hidden: true });
beside.after(a);
a.click();
a.remove();
// Revoking at once can cancel the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
+470
View File
@@ -0,0 +1,470 @@
/* mangabaka-seeder — the Obi Shelf in a console layout (DESIGN.md).
Every rule is scoped under .mbseed-app / .mbseed-panel / .mbseed-page: the same file is injected
into a shadow root on mangabaka.org by the userscript. Never pure black or white grounds: covers
with black or white backgrounds must still show their edge. */
.mbseed-page, .mbseed-frame, .mbseed-panel {
--mb-page: #efe9df;
--mb-panel: #e6dfd2;
--mb-slip: #f8f4ec;
--mb-ink: #1d1b22;
--mb-ink-2: #55505a;
--mb-obi: #d6362f;
--mb-obi-deep: #8f1d18;
--mb-obi-ink: #fffaf6;
--mb-obi-text: #a3261f; /* red for small text: 6.1:1 on the page, 5.5:1 on the panel */
--mb-teal: #2f6f6a;
--mb-kraft: #b59d78;
--mb-rail: #7d6847;
--mb-line: rgba(29, 27, 34, 0.2);
--mb-frame: rgba(29, 27, 34, 0.35);
--mb-track: rgba(29, 27, 34, 0.12);
--mb-shadow: rgba(29, 20, 10, 0.5);
--mb-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "Helvetica Neue", Arial, sans-serif;
--mb-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
--mb-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%231d1b22' stroke-width='1.6'/%3E%3C/svg%3E");
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
.mbseed-page, .mbseed-frame, .mbseed-panel {
--mb-page: #2a2520;
--mb-panel: #332d27;
--mb-slip: #3b342d;
--mb-ink: #ece4d6;
--mb-ink-2: #bdb2a2;
--mb-obi: #c42f28;
--mb-obi-deep: #8e1f1a;
--mb-obi-text: #f2867b;
--mb-teal: #6db3a9;
--mb-kraft: #6e5a3f;
--mb-rail: #4a3c2a;
--mb-line: rgba(236, 228, 214, 0.16);
--mb-frame: rgba(236, 228, 214, 0.35);
--mb-track: rgba(236, 228, 214, 0.14);
--mb-shadow: rgba(0, 0, 0, 0.6);
--mb-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23ece4d6' stroke-width='1.6'/%3E%3C/svg%3E");
color-scheme: dark;
}
}
/* The same dark tokens when the page forces dark (dark-reader.ts sets <html data-theme="dark">).
Keep identical to the block above; test/dark-reader.test.ts checks it. Never matches inside the
userscript's shadow root. */
:root[data-theme="dark"] .mbseed-page, :root[data-theme="dark"] .mbseed-frame, :root[data-theme="dark"] .mbseed-panel {
--mb-page: #2a2520;
--mb-panel: #332d27;
--mb-slip: #3b342d;
--mb-ink: #ece4d6;
--mb-ink-2: #bdb2a2;
--mb-obi: #c42f28;
--mb-obi-deep: #8e1f1a;
--mb-obi-text: #f2867b;
--mb-teal: #6db3a9;
--mb-kraft: #6e5a3f;
--mb-rail: #4a3c2a;
--mb-line: rgba(236, 228, 214, 0.16);
--mb-frame: rgba(236, 228, 214, 0.35);
--mb-track: rgba(236, 228, 214, 0.14);
--mb-shadow: rgba(0, 0, 0, 0.6);
--mb-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23ece4d6' stroke-width='1.6'/%3E%3C/svg%3E");
color-scheme: dark;
}
/* ---------- the page around the app ---------- */
.mbseed-page { margin: 0; background: var(--mb-page); color: var(--mb-ink); font: 14px/1.5 var(--mb-sans); }
/* ---------- app frame: three columns, stacking in narrow containers ---------- */
/* The frame is the size container: a container query cannot restyle the container itself. */
.mbseed-frame { container-type: inline-size; }
.mbseed-app {
display: grid;
grid-template-columns: minmax(250px, 290px) minmax(0, 1fr) minmax(260px, 300px);
background: var(--mb-page);
color: var(--mb-ink);
font: 14px/1.5 var(--mb-sans);
min-height: 100%;
}
.mbseed-app *, .mbseed-app *::before, .mbseed-app *::after { box-sizing: border-box; }
.mbseed-app [hidden] { display: none !important; }
.mbseed-app ::selection { background: color-mix(in srgb, var(--mb-obi) 30%, transparent); }
.mbseed-app :focus-visible { outline: 2px solid var(--mb-teal); outline-offset: 2px; }
.mbseed-app .mbseed-review { display: contents; }
.mbseed-app .mbseed-side {
background: var(--mb-panel);
padding: 18px 16px 24px;
display: flex; flex-direction: column; gap: 14px;
position: sticky; top: 0; align-self: start;
min-height: 100vh; max-height: 100vh; overflow: auto;
scrollbar-width: thin; scrollbar-color: var(--mb-kraft) transparent;
}
.mbseed-app .mbseed-side-left { grid-column: 1; grid-row: 1; border-right: 1px solid var(--mb-line); }
.mbseed-app .mbseed-side-right { grid-column: 3; grid-row: 1; border-left: 1px solid var(--mb-line); }
.mbseed-app .mbseed-main, .mbseed-app .mbseed-empty { grid-column: 2; grid-row: 1; padding: 18px 26px 56px; min-width: 0; }
.mbseed-app:has(#review:not([hidden])) .mbseed-empty { display: none; }
@container (max-width: 1000px) {
.mbseed-app { grid-template-columns: minmax(0, 1fr); }
.mbseed-app .mbseed-side { position: static; min-height: 0; max-height: none; border: 0; }
.mbseed-app .mbseed-side-left { grid-column: 1; grid-row: 1; border-bottom: 1px solid var(--mb-line); }
.mbseed-app .mbseed-main, .mbseed-app .mbseed-empty { grid-column: 1; grid-row: 2; padding: 16px; }
.mbseed-app .mbseed-side-right { grid-column: 1; grid-row: 3; border-top: 3px solid var(--mb-ink); }
}
/* ---------- type and small parts ---------- */
.mbseed-app h2, .mbseed-app h3, .mbseed-app h4 { margin: 0; }
.mbseed-app .mbseed-cap {
font: 700 10.5px/1.2 var(--mb-sans); letter-spacing: 0.14em; text-transform: uppercase; color: var(--mb-ink-2);
}
.mbseed-app .mbseed-hint { margin: 0; font-size: 12.5px; color: var(--mb-ink-2); }
.mbseed-app .mbseed-line { margin: 0; font-size: 13px; min-height: 1.5em; }
.mbseed-app .is-error { color: var(--mb-obi-text); font-weight: 600; }
.mbseed-app a { color: inherit; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.mbseed-app a:hover { color: var(--mb-obi-text); }
/* ---------- desk: slips, settings, the stamp ---------- */
.mbseed-app .mbseed-brand { margin: 0; padding-bottom: 8px; border-bottom: 3px solid var(--mb-ink); font-size: 17px; font-weight: 800; letter-spacing: -0.01em; }
.mbseed-app .mbseed-install { margin-top: auto; font-size: 12.5px; }
.mbseed-panel .mbseed-install, .mbseed-panel .mbseed-brand { display: none; }
.mbseed-app .mbseed-desk { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.mbseed-app .mbseed-slip {
display: grid; gap: 3px; padding: 7px 10px 8px;
background: var(--mb-slip); border: 1px solid var(--mb-line);
}
.mbseed-app .mbseed-slip-label { font: 700 10px var(--mb-sans); letter-spacing: 0.12em; text-transform: uppercase; color: var(--mb-ink-2); }
.mbseed-app .mbseed-slip-hint { font-size: 11.5px; color: var(--mb-ink-2); }
.mbseed-app .mbseed-slip input {
width: 100%; min-width: 0; padding: 2px 0; border: 0; border-bottom: 1px dotted var(--mb-ink-2);
background: transparent; color: var(--mb-ink); font: 14px var(--mb-sans); text-overflow: ellipsis;
}
.mbseed-app .mbseed-slip input:focus { outline: none; border-bottom: 1.5px solid var(--mb-ink); }
.mbseed-app .mbseed-slip:focus-within { border-color: var(--mb-ink); }
.mbseed-app .mbseed-slip input::placeholder { color: var(--mb-ink-2); opacity: 0.7; }
.mbseed-app .mbseed-settings { display: grid; gap: 6px; }
.mbseed-app .mbseed-setting {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
font-size: 13px; color: var(--mb-ink-2); white-space: nowrap;
}
.mbseed-app .mbseed-settings { grid-template-columns: minmax(0, 1fr); }
.mbseed-app .mbseed-setting > span { flex: none; }
.mbseed-app .mbseed-setting > select { flex: 1 1 auto; min-width: 0; max-width: 11rem; text-overflow: ellipsis; }
.mbseed-app .mbseed-setting.is-stacked { flex-direction: column; align-items: stretch; gap: 3px; }
.mbseed-app .mbseed-setting.is-stacked input {
padding: 5px 8px; border: 1px solid var(--mb-line); background: var(--mb-slip); color: var(--mb-ink); font: 13px var(--mb-mono);
}
/* Editable at a glance: every select is a boxed field with a drawn chevron. */
.mbseed-app select, .mbseed-app .mbseed-select {
appearance: none; -webkit-appearance: none;
padding: 4px 26px 4px 9px;
border: 1px solid var(--mb-frame); border-bottom: 2px solid var(--mb-ink);
background: var(--mb-slip) var(--mb-chevron) no-repeat right 9px center;
color: var(--mb-ink); font: 600 13px var(--mb-sans); cursor: pointer;
}
.mbseed-app select:hover { border-color: var(--mb-ink); background-color: var(--mb-page); }
.mbseed-app select.is-small { padding: 2px 22px 2px 6px; font-size: 11.5px; background-position: right 7px center; min-width: 0; }
.mbseed-app select option { color: initial; }
.mbseed-app .mbseed-check { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; cursor: pointer; }
/* Checkboxes and radios share the jacket ticks' square language. */
.mbseed-app input[type="checkbox"]:not(.mbseed-tick), .mbseed-app input[type="radio"] {
appearance: none; -webkit-appearance: none; flex: none; width: 15px; height: 15px; margin: 2px 0 0;
border: 1.5px solid var(--mb-ink); background: var(--mb-slip); display: inline-grid; place-content: center; cursor: pointer;
}
.mbseed-app input[type="checkbox"]:not(.mbseed-tick)::before, .mbseed-app input[type="radio"]::before {
content: ""; width: 7px; height: 7px; background: var(--mb-ink); transform: scale(0); transition: transform 0.12s;
}
.mbseed-app input[type="checkbox"]:checked::before, .mbseed-app input[type="radio"]:checked::before { transform: scale(1); }
/* Single choice reads round; multiple choice stays square like the jacket ticks. */
.mbseed-app input[type="radio"], .mbseed-app input[type="radio"]::before { border-radius: 50%; }
.mbseed-app .mbseed-check.is-main input:checked { border-color: var(--mb-obi); }
.mbseed-app .mbseed-check.is-main input::before { background: var(--mb-obi); }
.mbseed-app input:disabled { opacity: 0.45; cursor: not-allowed; }
.mbseed-app .mbseed-button {
display: block; width: 100%; padding: 8px 12px;
border: 1.5px solid var(--mb-ink); background: transparent; color: var(--mb-ink);
font: 700 13px var(--mb-sans); cursor: pointer; text-align: center;
transition: background-color 0.15s, color 0.15s;
}
.mbseed-app .mbseed-button:hover:not(:disabled) { background: var(--mb-ink); color: var(--mb-page); }
.mbseed-app .mbseed-button.is-stamp {
background: var(--mb-obi); border-color: var(--mb-obi); color: var(--mb-obi-ink);
font-weight: 800; font-size: 12.5px; letter-spacing: 0.14em; text-transform: uppercase; padding: 10px 12px;
}
.mbseed-app .mbseed-button.is-stamp:hover:not(:disabled) { background: var(--mb-obi-deep); border-color: var(--mb-obi-deep); color: var(--mb-obi-ink); }
.mbseed-app .mbseed-button.is-quiet { border-color: transparent; text-decoration: underline; font-weight: 600; padding: 4px; }
.mbseed-app .mbseed-button:disabled { opacity: 0.4; cursor: not-allowed; }
.mbseed-app .mbseed-run-actions { display: grid; gap: 2px; }
.mbseed-app .mbseed-run-actions:not(:has(button:not([hidden]))) { display: none; }
/* ---------- run status: stage plates that change in place ---------- */
.mbseed-app .mbseed-status { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
.mbseed-app .mbseed-plates { list-style: none; margin: 0; padding: 0; display: grid; }
.mbseed-app .mbseed-plates li span { flex: 1; }
.mbseed-app .mbseed-plates li em { font: 500 11.5px var(--mb-sans); font-style: normal; letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
.mbseed-app .mbseed-plates li {
display: flex; align-items: center; gap: 8px; margin-top: -1px; padding: 5px 10px;
border: 1px solid var(--mb-line); font: 700 10.5px var(--mb-sans); letter-spacing: 0.1em; text-transform: uppercase;
color: var(--mb-ink-2); transition: background-color 0.3s, color 0.3s;
}
.mbseed-app .mbseed-plates li::before { content: ""; width: 7px; height: 7px; border: 1px solid currentColor; flex: none; }
.mbseed-app .mbseed-plates li[data-state="done"] { color: var(--mb-ink); }
.mbseed-app .mbseed-plates li[data-state="done"]::before { background: var(--mb-teal); border-color: var(--mb-teal); }
.mbseed-app .mbseed-plates li[data-state="active"] { background: var(--mb-ink); color: var(--mb-page); }
.mbseed-app .mbseed-plates li[data-state="active"]::before { background: var(--mb-obi); border-color: var(--mb-obi); animation: mbseed-blink 1s steps(2) infinite; }
.mbseed-app .mbseed-plates li[data-state="failed"] { background: var(--mb-obi); color: var(--mb-obi-ink); }
.mbseed-app .mbseed-progress { width: 100%; height: 3px; appearance: none; border: 0; background: var(--mb-track); }
.mbseed-app .mbseed-progress::-webkit-progress-bar { background: var(--mb-track); }
.mbseed-app .mbseed-progress::-webkit-progress-value { background: var(--mb-teal); }
.mbseed-app .mbseed-progress::-moz-progress-bar { background: var(--mb-teal); }
.mbseed-app .mbseed-stage { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
.mbseed-app .mbseed-notes-cap:has(+ .mbseed-warnings:empty) { display: none; }
.mbseed-app .mbseed-warnings { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; max-height: 14rem; overflow: auto; }
.mbseed-app .mbseed-warnings li { font-size: 12px; color: var(--mb-ink-2); padding-left: 12px; position: relative; overflow-wrap: anywhere; }
.mbseed-app .mbseed-warnings li::before { content: ""; position: absolute; left: 0; top: 0.6em; width: 5px; height: 5px; background: var(--mb-kraft); }
/* ---------- history: a ruled list on the left desk (spec 002 §3.4) ---------- */
.mbseed-app .mbseed-recent { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
.mbseed-app .mbseed-recent-list {
list-style: none; margin: 0; padding: 0; display: grid; max-height: 24rem; overflow: auto;
scrollbar-width: thin; scrollbar-color: var(--mb-kraft) transparent;
}
.mbseed-app .mbseed-recent-list li { display: flex; margin-top: -1px; border: 1px solid var(--mb-line); }
.mbseed-app .mbseed-recent-open {
flex: 1; min-width: 0; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; align-items: center;
padding: 6px 8px; border: 0; background: transparent; color: var(--mb-ink-2); font: inherit; text-align: left; cursor: pointer;
transition: background-color 0.15s;
}
.mbseed-app .mbseed-recent-open:hover, .mbseed-app .mbseed-recent-open[aria-current="true"] { background: var(--mb-slip); }
.mbseed-app .mbseed-recent-list .mbseed-recent-thumb {
display: block; width: 30px; height: 40px; object-fit: contain; background: var(--mb-page);
outline: 1px solid var(--mb-frame); outline-offset: -1px;
}
.mbseed-app .mbseed-recent-text { display: grid; gap: 1px; min-width: 0; }
.mbseed-app .mbseed-recent-title { font-size: 13px; font-weight: 700; color: var(--mb-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mbseed-app .mbseed-recent-meta { font-size: 11.5px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mbseed-app .mbseed-recent-marks { font-size: 11.5px; font-weight: 600; color: var(--mb-teal); }
.mbseed-app .mbseed-recent-remove {
flex: none; width: 28px; display: grid; place-items: center; padding: 0;
border: 0; border-left: 1px solid var(--mb-line); background: transparent; color: var(--mb-ink-2); cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.mbseed-app .mbseed-recent-remove:hover { background: var(--mb-ink); color: var(--mb-page); }
/* The list scrolls, so an outside ring would be clipped: draw it inside the row. */
.mbseed-app .mbseed-recent-open:focus-visible, .mbseed-app .mbseed-recent-remove:focus-visible { outline-offset: -3px; }
.mbseed-app .mbseed-recent-status { font-size: 12.5px; }
.mbseed-app .mbseed-recent-status:not(.is-error) { color: var(--mb-ink-2); }
.mbseed-app .mbseed-recent-status:empty { display: none; }
.mbseed-app .mbseed-recent > .is-quiet { justify-self: start; width: auto; margin-left: -5.5px; font-size: 12.5px; color: var(--mb-ink-2); }
/* ---------- empty state ---------- */
.mbseed-app .mbseed-empty { max-width: 46rem; }
.mbseed-app .mbseed-empty h2 { font-size: clamp(22px, 3cqi, 30px); font-weight: 800; letter-spacing: -0.02em; margin: 24px 0 10px; text-wrap: balance; }
.mbseed-app .mbseed-empty p { color: var(--mb-ink-2); max-width: 62ch; margin: 0 0 10px; font-size: 14px; }
.mbseed-app .mbseed-empty strong { color: var(--mb-ink); }
/* Recently searched: the same books on the same kraft rail, smaller, each opening its history entry. */
.mbseed-app .mbseed-recent-shelf-wrap { display: grid; gap: 14px; margin-top: 34px; }
.mbseed-app .mbseed-recent-shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); align-items: end; column-gap: 0; row-gap: 30px; }
.mbseed-app .mbseed-recent-book {
display: flex; flex-direction: column; justify-content: flex-end; min-width: 0; padding: 0 9px;
border: 0; border-bottom: 10px solid var(--mb-kraft); box-shadow: 0 2px 0 var(--mb-rail);
background: transparent; color: var(--mb-ink); font: inherit; text-align: left; cursor: pointer;
}
.mbseed-app .mbseed-recent-jacket { display: block; }
.mbseed-app .mbseed-recent-book .mbseed-recent-thumb {
display: block; width: 100%; height: auto; max-height: 160px; object-fit: contain; object-position: bottom;
outline: 1px solid var(--mb-frame); outline-offset: -1px; box-shadow: 0 8px 14px -8px var(--mb-shadow);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.mbseed-app .mbseed-recent-book .mbseed-recent-thumb.is-blank { height: 120px; background: var(--mb-panel); box-shadow: none; }
.mbseed-app .mbseed-recent-book:hover .mbseed-recent-thumb { transform: translateY(-4px); }
.mbseed-app .mbseed-recent-band {
position: relative; margin-top: -12px; display: grid; gap: 1px; padding: 5px 7px 6px;
background: var(--mb-slip); border: 1px solid var(--mb-line); border-top: 3px solid var(--mb-ink);
}
.mbseed-app .mbseed-recent-band .mbseed-recent-title { font-size: 12px; }
.mbseed-app .mbseed-recent-band .mbseed-recent-meta { color: var(--mb-ink-2); white-space: normal; text-overflow: clip; }
/* ---------- review head ---------- */
.mbseed-app .mbseed-review-head {
display: flex; align-items: baseline; gap: 6px 14px; flex-wrap: wrap;
border-bottom: 1px solid var(--mb-line); padding-bottom: 10px;
}
.mbseed-app .mbseed-title { font-size: clamp(24px, 3.2cqi, 38px); line-height: 1.1; font-weight: 800; letter-spacing: -0.025em; overflow-wrap: anywhere; }
.mbseed-app .mbseed-source { margin: 0; color: var(--mb-ink-2); font-size: 14px; }
.mbseed-app .mbseed-views { margin-left: auto; display: flex; border: 1.5px solid var(--mb-ink); }
.mbseed-app .mbseed-view { border: 0; background: transparent; color: var(--mb-ink); padding: 4px 12px; font: 700 12.5px var(--mb-sans); cursor: pointer; }
.mbseed-app .mbseed-view[aria-pressed="true"] { background: var(--mb-ink); color: var(--mb-page); }
.mbseed-app .mbseed-found { margin: 8px 0 0; display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; font-size: 13px; color: var(--mb-ink-2); }
.mbseed-app .mbseed-found .is-quiet { width: auto; color: var(--mb-ink); }
.mbseed-app .mbseed-notice {
margin: 12px 0 0; padding: 8px 12px; font-size: 13px;
background: color-mix(in srgb, var(--mb-obi) 12%, var(--mb-page)); border-top: 2px solid var(--mb-obi);
}
.mbseed-app .mbseed-default { display: grid; grid-template-columns: auto 1fr auto; gap: 2px 18px; align-items: baseline; margin-top: 10px; }
.mbseed-app .mbseed-default .mbseed-check { font-weight: 700; }
.mbseed-app .mbseed-count { margin: 0; color: var(--mb-ink-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
@container (max-width: 700px) { .mbseed-app .mbseed-default { grid-template-columns: 1fr; } }
/* ---------- the shelf ---------- */
.mbseed-app .mbseed-shelf {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-auto-flow: dense;
align-items: end; column-gap: 0; row-gap: 32px; margin-top: 18px;
}
.mbseed-app .mbseed-book { display: flex; flex-direction: column; justify-content: flex-end; min-width: 0; }
.mbseed-app .mbseed-shelf .mbseed-book {
padding: 0 10px; border-bottom: 10px solid var(--mb-kraft); box-shadow: 0 2px 0 var(--mb-rail);
}
.mbseed-app .mbseed-shelf .mbseed-book.is-wide { grid-column: span 2; }
@container (max-width: 420px) { .mbseed-app .mbseed-shelf .mbseed-book.is-wide { grid-column: span 1; } }
.mbseed-app .mbseed-jacket { position: relative; display: block; cursor: pointer; }
.mbseed-app .mbseed-jacket img {
display: block; width: 100%; height: auto; max-height: 250px; object-fit: contain; object-position: bottom;
outline: 1px solid var(--mb-frame); outline-offset: -1px;
box-shadow: 0 8px 14px -8px var(--mb-shadow);
transition: filter 0.25s, opacity 0.25s, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.mbseed-app .mbseed-tick {
position: absolute; top: 0; left: 0; margin: 0; width: 22px; height: 22px; cursor: pointer;
appearance: none; -webkit-appearance: none; background: var(--mb-slip); border: 1.5px solid var(--mb-ink);
display: grid; place-items: center;
}
.mbseed-app .mbseed-tick::after { content: ""; width: 10px; height: 10px; background: var(--mb-ink); transform: scale(0); transition: transform 0.15s; }
.mbseed-app .mbseed-tick:checked::after { transform: scale(1); }
.mbseed-app .mbseed-obi {
position: relative; margin-top: -14px; display: grid; gap: 4px; padding: 6px 8px 8px;
background: var(--mb-slip); border: 1px solid var(--mb-line); border-top: 3px solid var(--mb-ink);
font-size: 11.5px; transition: opacity 0.25s;
}
.mbseed-app .mbseed-obi p { margin: 0; }
.mbseed-app .mbseed-obi-role { font: 800 10.5px var(--mb-sans); letter-spacing: 0.12em; text-transform: uppercase; }
.mbseed-app .mbseed-obi-facts { color: var(--mb-ink-2); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.mbseed-app .mbseed-seen { position: relative; height: 5px; background: var(--mb-track); }
.mbseed-app .mbseed-seen i { position: absolute; top: 0; bottom: 0; background: var(--mb-ink); }
.mbseed-app .mbseed-obi-fields { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 6px; }
.mbseed-app .mbseed-obi-fields select { width: 100%; min-width: 0; text-overflow: ellipsis; }
.mbseed-app .mbseed-obi-seen-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.mbseed-app .mbseed-obi-seen-row .mbseed-seen { flex: 1; min-width: 0; }
/* Per-cover fields are printed on the band: underline and chevron, boxed only on hover or focus. */
.mbseed-app .mbseed-obi select.is-small {
border: 1px solid transparent; border-bottom: 1px solid currentColor; background-color: transparent; color: inherit;
padding: 1px 14px 1px 0; font-weight: 600; background-position: right 1px center;
}
.mbseed-app .mbseed-obi select.is-small:hover, .mbseed-app .mbseed-obi select.is-small:focus-visible {
border-color: var(--mb-frame); border-bottom-color: currentColor; background-color: var(--mb-page); color: var(--mb-ink); padding-left: 4px;
}
.mbseed-app .mbseed-note {
width: 100%; min-width: 0; padding: 2px 0; border: 0; border-bottom: 1px dotted var(--mb-ink-2);
background: transparent; color: inherit; font: 11.5px var(--mb-sans); text-overflow: ellipsis;
}
.mbseed-app .mbseed-note:focus { outline: none; border-bottom: 1.5px solid currentColor; }
.mbseed-app .mbseed-obi-hosted { color: var(--mb-teal); font-weight: 700; font-size: 10.5px; flex: none; }
.mbseed-app .mbseed-obi-error { color: var(--mb-obi-text); font-weight: 600; overflow-wrap: anywhere; }
.mbseed-app .mbseed-book.is-main .mbseed-obi { background: var(--mb-obi); color: var(--mb-obi-ink); border-color: var(--mb-obi); border-top-color: var(--mb-obi-deep); }
.mbseed-app .mbseed-book.is-main .mbseed-obi-facts { color: var(--mb-obi-ink); }
.mbseed-app .mbseed-book.is-main select.is-small { background-image: none; }
.mbseed-app .mbseed-book.is-main .mbseed-seen { background: color-mix(in srgb, var(--mb-obi-ink) 30%, transparent); }
.mbseed-app .mbseed-book.is-main .mbseed-seen i { background: var(--mb-obi-ink); }
.mbseed-app .mbseed-book.is-main .mbseed-obi-hosted { color: var(--mb-obi-ink); }
.mbseed-app .mbseed-book.is-main .mbseed-obi-error { color: var(--mb-obi-ink); }
/* The page-wide highlight is red too: reverse the band's colours so a selection shows. */
.mbseed-app .mbseed-book.is-main ::selection { background: var(--mb-obi-ink); color: var(--mb-obi-deep); }
.mbseed-app .mbseed-book.is-off .mbseed-jacket img { filter: grayscale(1) contrast(0.85); opacity: 0.5; transform: translateY(8px); }
.mbseed-app .mbseed-book.is-off .mbseed-obi-role { color: var(--mb-ink-2); }
/* The one authored motion: a new run's covers are shelved, left to right. */
.mbseed-app .is-fresh .mbseed-book { animation: mbseed-shelve 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; animation-delay: calc(min(var(--i), 24) * 28ms); }
@keyframes mbseed-shelve { from { transform: translateY(14px); opacity: 0.35; } }
@keyframes mbseed-blink { 50% { opacity: 0.3; } }
/* ---------- the timeline: a centre rail of years, covers alternating either side ---------- */
.mbseed-app .mbseed-timeline { list-style: none; margin: 22px 0 0; padding: 0; position: relative; }
.mbseed-app .mbseed-timeline::before {
content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 5px; transform: translateX(-50%);
border-left: 1px solid var(--mb-ink); border-right: 1px solid var(--mb-ink);
}
.mbseed-app .mbseed-year { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr); align-items: start; padding-bottom: 28px; }
.mbseed-app .mbseed-year-side { display: grid; gap: 22px; }
.mbseed-app .mbseed-year-side.is-right { padding-top: 46px; }
.mbseed-app .mbseed-year-key {
position: relative; z-index: 1; grid-column: 2; grid-row: 1; justify-self: center; padding: 3px 0;
background: var(--mb-page); color: var(--mb-obi-text); font: 800 13px var(--mb-sans); letter-spacing: 0.04em; font-variant-numeric: tabular-nums;
}
.mbseed-app .mbseed-year-side.is-left { grid-column: 1; grid-row: 1; }
.mbseed-app .mbseed-year-side.is-right { grid-column: 3; grid-row: 1; }
.mbseed-app .mbseed-timeline .mbseed-book { display: grid; grid-template-columns: 116px minmax(0, 1fr); gap: 12px; align-items: end; }
.mbseed-app .mbseed-timeline .mbseed-book.is-wide { grid-template-columns: minmax(0, 1fr); }
.mbseed-app .mbseed-timeline .mbseed-jacket img { max-height: 170px; object-position: bottom left; }
.mbseed-app .mbseed-timeline .mbseed-obi { margin-top: 0; }
.mbseed-app .mbseed-timeline .mbseed-book.is-off .mbseed-jacket img { transform: none; }
@container (max-width: 760px) {
.mbseed-app .mbseed-timeline::before { left: 28px; }
.mbseed-app .mbseed-year { grid-template-columns: 56px minmax(0, 1fr); }
.mbseed-app .mbseed-year-key { grid-column: 1; }
.mbseed-app .mbseed-year-side.is-left, .mbseed-app .mbseed-year-side.is-right { grid-column: 2; grid-row: auto; padding-top: 0; }
.mbseed-app .mbseed-year-side.is-right { margin-top: 22px; }
}
/* ---------- already on MangaBaka ---------- */
.mbseed-app .mbseed-present { margin-top: 36px; border-top: 1px solid var(--mb-line); padding-top: 10px; }
.mbseed-app .mbseed-present summary { cursor: pointer; font-weight: 700; }
.mbseed-app .mbseed-present-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 6px; }
.mbseed-app .mbseed-present-list li { display: flex; gap: 10px; align-items: center; font-size: 12.5px; color: var(--mb-ink-2); }
.mbseed-app .mbseed-present-list img { width: 48px; height: 48px; object-fit: contain; outline: 1px solid var(--mb-frame); outline-offset: -1px; }
/* ---------- right panel: rehost, paste, file ---------- */
.mbseed-app .mbseed-block { display: grid; gap: 8px; }
.mbseed-app .mbseed-hosts { margin: 0; padding: 0; border: 0; display: grid; }
.mbseed-app .mbseed-hosts label {
display: grid; grid-template-columns: auto 1fr; column-gap: 8px; align-items: baseline; padding: 6px 8px; margin-top: -1px;
border: 1px solid var(--mb-line); font-size: 13px; cursor: pointer; color: var(--mb-ink-2);
}
.mbseed-app .mbseed-hosts label:has(input:checked) { background: var(--mb-slip); color: var(--mb-ink); font-weight: 600; }
.mbseed-app .mbseed-hosts label:has(input:disabled) { opacity: 0.55; cursor: not-allowed; }
.mbseed-app .mbseed-hosts label > span { grid-column: 2; }
.mbseed-app .mbseed-hosts small { grid-column: 2; font-weight: 400; font-size: 11.5px; color: var(--mb-ink-2); }
.mbseed-app .mbseed-hosts small.is-warn { display: none; }
.mbseed-app .mbseed-hosts label:has(input:checked) small.is-warn { display: block; color: var(--mb-ink); }
.mbseed-app textarea {
width: 100%; resize: vertical; padding: 8px; border: 1px solid var(--mb-line); background: var(--mb-slip); color: var(--mb-ink);
font: 11.5px/1.45 var(--mb-mono);
}
.mbseed-app .mbseed-link { font-size: 13px; }
.mbseed-app .mbseed-final { margin-top: auto; padding-top: 14px; border-top: 1px dashed var(--mb-line); }
.mbseed-app .mbseed-final .is-stamp { margin-top: 10px; }
.mbseed-app .mbseed-diff { margin: 0; padding: 0; list-style: none; font-size: 12px; display: grid; gap: 2px; max-height: 12rem; overflow: auto; }
.mbseed-app .mbseed-diff li { overflow-wrap: anywhere; }
.mbseed-app .mbseed-diff .is-added { color: var(--mb-teal); }
.mbseed-app .mbseed-diff .is-removed { color: var(--mb-obi-text); }
@media (prefers-reduced-motion: reduce) {
.mbseed-app *, .mbseed-app *::before, .mbseed-app *::after { animation: none !important; transition: none !important; }
}
/* ---------- userscript panel chrome ---------- */
.mbseed-panel { background: var(--mb-page); color: var(--mb-ink); font: 14px/1.5 var(--mb-sans); border: 1px solid var(--mb-frame); box-shadow: 0 18px 40px -18px var(--mb-shadow); min-height: 100%; }
.mbseed-panel *, .mbseed-panel *::before, .mbseed-panel *::after { box-sizing: border-box; }
.mbseed-panel .mbseed-panel-bar {
position: sticky; top: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: var(--mb-panel); border-bottom: 3px solid var(--mb-ink);
font-size: 14px; font-weight: 800; letter-spacing: -0.01em;
}
.mbseed-panel .mbseed-panel-close {
display: grid; place-items: center; width: 30px; height: 30px; padding: 0; cursor: pointer;
border: 1.5px solid var(--mb-ink); background: transparent; color: var(--mb-ink);
}
.mbseed-panel .mbseed-panel-close:hover { background: var(--mb-ink); color: var(--mb-page); }
.mbseed-panel .mbseed-settings-panel { padding: 10px 16px; border-bottom: 1px solid var(--mb-line); background: var(--mb-panel); font-size: 13px; }
.mbseed-panel .mbseed-settings-panel summary { cursor: pointer; font-weight: 700; }
.mbseed-panel .mbseed-settings-panel label { display: grid; gap: 3px; margin-top: 8px; color: var(--mb-ink-2); }
.mbseed-panel .mbseed-settings-panel input {
padding: 5px 8px; border: 1px solid var(--mb-line); background: var(--mb-slip); color: var(--mb-ink); font: 13px var(--mb-mono);
}
.mbseed-panel .mbseed-settings-panel small { display: block; margin-top: 4px; color: var(--mb-ink-2); }
.mbseed-panel :focus-visible { outline: 2px solid var(--mb-teal); outline-offset: 2px; }
+130
View File
@@ -0,0 +1,130 @@
/** Mounted by the page (into #app) and by the userscript (into a shadow root). Keep every id: app.ts looks them up. */
export const APP_HTML = /* html */ `
<div class="mbseed-frame"><div class="mbseed-app">
<aside class="mbseed-side mbseed-side-left">
<h1 class="mbseed-brand">MangaBaka Seeder</h1>
<form id="run-form" class="mbseed-desk">
<label class="mbseed-slip">
<span class="mbseed-slip-label">MangaBaka series link</span>
<input id="mb-link" name="mb" required autocomplete="off" spellcheck="false" placeholder="https://mangabaka.org/manhwa/9999/Tower-of-God">
</label>
<label class="mbseed-slip">
<span class="mbseed-slip-label">Source series link</span>
<input id="source-link" name="source" required autocomplete="off" spellcheck="false" placeholder="https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95">
<span class="mbseed-slip-hint">The series page on the manga, manhwa or webcomic site. WEBTOON, Tapas or KakaoPage for now.</span>
</label>
<div class="mbseed-settings">
<label class="mbseed-check"><input id="history-on" type="checkbox"> Old covers from the Wayback Machine</label>
<label class="mbseed-setting"><span>Check the archive</span> <select id="history-every"></select></label>
<label class="mbseed-setting"><span>Rating for new covers</span> <select id="rating-all"></select></label>
</div>
<button id="run" class="mbseed-button is-stamp" type="submit">Find covers</button>
<div class="mbseed-run-actions">
<button id="skip-history" class="mbseed-button is-quiet" type="button" hidden>Skip the rest of the history</button>
<button id="cancel" class="mbseed-button is-quiet" type="button" hidden>Cancel</button>
</div>
</form>
<section class="mbseed-status" aria-live="polite">
<h3 class="mbseed-cap">Run</h3>
<ol class="mbseed-plates">
<li data-stage="series"><span>MangaBaka</span><em></em></li>
<li data-stage="source"><span>Series page</span><em></em></li>
<li data-stage="history"><span>Archive</span><em></em></li>
<li data-stage="inspect"><span>Images</span><em></em></li>
<li data-stage="compare"><span>Compare</span><em></em></li>
</ol>
<progress id="progress" class="mbseed-progress" max="1" value="0"></progress>
<p id="stage" class="mbseed-stage"></p>
<h3 class="mbseed-cap mbseed-notes-cap">Notes</h3>
<ul id="warnings" class="mbseed-warnings"></ul>
</section>
<p id="recent-status" class="mbseed-line mbseed-recent-status" aria-live="polite"></p>
<section id="recent" class="mbseed-recent" hidden>
<h3 class="mbseed-cap">History</h3>
<ol id="recent-list" class="mbseed-recent-list"></ol>
<button id="recent-clear" class="mbseed-button is-quiet" type="button">Clear history</button>
</section>
<a class="mbseed-link mbseed-install" href="/mbseed.user.js">Install the userscript</a>
</aside>
<div class="mbseed-empty">
<h2>Every cover a series has worn, on one shelf</h2>
<p>Paste a MangaBaka series link and the series page on its source site, then press <strong>Find covers</strong>.
Current covers come straight from the source; older ones from the Wayback Machine.</p>
<p>Nothing is uploaded or sent to MangaBaka until you press a button for it.</p>
<section id="recent-shelf-wrap" class="mbseed-recent-shelf-wrap" hidden>
<h3 class="mbseed-cap">Recently searched</h3>
<div id="recent-shelf" class="mbseed-recent-shelf"></div>
</section>
</div>
<div id="review" class="mbseed-review" hidden>
<main class="mbseed-main">
<header class="mbseed-review-head">
<h2 id="series-title" class="mbseed-title"></h2>
<p id="series-source" class="mbseed-source"></p>
<div class="mbseed-views" role="group" aria-label="View">
<button type="button" class="mbseed-view" data-view="shelf" aria-pressed="true">Shelf</button>
<button type="button" class="mbseed-view" data-view="timeline" aria-pressed="false">Timeline</button>
</div>
</header>
<p id="found-line" class="mbseed-found" hidden>
<span id="found-when"></span>
<button id="search-again" class="mbseed-button is-quiet" type="button">Search again</button>
</p>
<p id="mature-warning" class="mbseed-notice" hidden>The source marks this series as mature. Consider a content rating above "safe".</p>
<p id="link-warning" class="mbseed-notice" hidden>
This MangaBaka series has no link to this source series.
<label class="mbseed-check"><input id="link-confirm" type="checkbox"> They are the same series</label>
</p>
<div class="mbseed-default">
<label class="mbseed-check is-main"><input id="make-default" type="checkbox"> Make the current poster the series' main cover</label>
<p id="default-note" class="mbseed-hint"></p>
<p id="selected-count" class="mbseed-count"></p>
</div>
<div id="cards" class="mbseed-covers" data-view="shelf"></div>
<details class="mbseed-present">
<summary>Already on MangaBaka</summary>
<div id="present-list"></div>
</details>
</main>
<aside class="mbseed-side mbseed-side-right">
<section class="mbseed-block">
<h3 class="mbseed-cap">1 · Rehost selected</h3>
<fieldset id="hosts" class="mbseed-hosts" aria-label="Where to rehost"></fieldset>
<label class="mbseed-setting"><span>litterbox keeps them</span> <select id="litterbox-time"></select></label>
<label class="mbseed-setting is-stacked">catbox userhash (optional) <input id="catbox-userhash" autocomplete="off" spellcheck="false"></label>
<label class="mbseed-check"><input id="catbox-remember" type="checkbox"> Remember the userhash</label>
<button id="rehost" class="mbseed-button" type="button">Rehost selected</button>
<p id="rehost-status" class="mbseed-line" aria-live="polite"></p>
</section>
<section class="mbseed-block">
<h3 class="mbseed-cap">2a · Add by hand</h3>
<button id="download" class="mbseed-button" type="button">Download selected (ZIP)</button>
<p class="mbseed-hint">The cover files, plus covers.txt with each one's type, rating and note for the form.</p>
<textarea id="manual-text" readonly rows="4" spellcheck="false" aria-label="Covers to paste"></textarea>
<button id="copy" class="mbseed-button" type="button">Copy all selected</button>
<a id="form-link" class="mbseed-link" target="_blank" rel="noopener">Open MangaBaka's cover form</a>
<p id="manual-status" class="mbseed-line" aria-live="polite"></p>
</section>
<section class="mbseed-block mbseed-final">
<h3 class="mbseed-cap">2b · Edit request (token)</h3>
<label class="mbseed-slip">
<span class="mbseed-slip-label">Personal access token</span>
<input id="token" type="password" autocomplete="off" spellcheck="false">
</label>
<label class="mbseed-check"><input id="token-remember" type="checkbox"> Remember on this device</label>
<button id="preview" class="mbseed-button" type="button">Preview changes</button>
<div id="preview-diff" class="mbseed-diff-wrap"></div>
<button id="confirm" class="mbseed-button is-stamp" type="button" hidden>Rehost and file for review</button>
<p class="mbseed-hint">The token goes only to api.mangabaka.org. Requests always go to MangaBaka's review queue.</p>
<p id="submit-status" class="mbseed-line" aria-live="polite"></p>
</section>
</aside>
</div>
</div></div>`;
+56
View File
@@ -0,0 +1,56 @@
import { API_CALL_HEADER, CORS_OPEN_HOSTS, TransportError, type Transport } from "../core/transport";
type RawFetch = (url: string, init?: RequestInit) => Promise<Response>;
/**
* Spec §4.2: MangaBaka and litterbox send CORS headers, so the page calls them directly (the
* token never touches our server). Everything else is read through our /api/fetch proxy.
*/
export function pageTransport(origin: string, rawFetch: RawFetch = (url, init) => fetch(url, init)): Transport {
return {
async fetch(url, init = {}) {
const target = new URL(url, origin);
const { timeoutMs: _timeout, ...rest } = init;
if (target.origin === origin || CORS_OPEN_HOSTS.has(target.hostname)) return rawFetch(target.toString(), rest);
if ((init.method ?? "GET").toUpperCase() !== "GET") {
throw new TransportError(`The page can't ${init.method} to ${target.hostname} (no CORS).`, url);
}
const res = await rawFetch(`${origin}/api/fetch?url=${encodeURIComponent(target.toString())}`, {
signal: init.signal,
headers: { [API_CALL_HEADER]: "1" },
});
if (res.headers.get("x-mbseed-error")) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new TransportError(`Server proxy: ${body.error ?? `HTTP ${res.status}`}`, url, res.status);
}
return res;
},
};
}
/** localStorage, but never fatal: private windows and blocked storage just forget. */
export function localStore() {
return {
get: (k: string) => {
try {
return localStorage.getItem(k);
} catch {
return null;
}
},
set: (k: string, v: string) => {
try {
localStorage.setItem(k, v);
} catch {
// storage unavailable: remembering is a convenience
}
},
remove: (k: string) => {
try {
localStorage.removeItem(k);
} catch {
// see above
}
},
};
}
+148
View File
@@ -0,0 +1,148 @@
import { formatMonth } from "../core/covers";
import type { HostId, HostOptions } from "../core/hosting/host";
import { HOSTS } from "../core/hosting/registry";
import type { Granularity, InspectedCover } from "../core/model";
import type { Stage } from "../core/pipeline";
import type { Marks, SearchSummary } from "../core/saved";
import type { ReviewCard } from "../core/session";
export interface ServerConfig {
storage: boolean;
storageFull: boolean;
}
export interface HostChoice {
id: HostId;
label: string;
lifetime: string;
/** Why it can't be used right now, or null. */
disabled: string | null;
warning: string | null;
}
/** Spec §4.6 / §4.10: server storage only when configured; a full store is shown disabled with the reason. */
export function hostChoices(config: ServerConfig | null, opts: HostOptions, front: { catboxNeedsServer: boolean }): HostChoice[] {
const choices: HostChoice[] = [];
if (config?.storage) {
choices.push({
id: "server", label: "This server", lifetime: HOSTS.server.lifetime(opts),
disabled: config.storageFull ? "Storage is full until older covers expire. Use litterbox or catbox." : null, warning: null,
});
}
choices.push(
{
id: "litterbox", label: "litterbox", lifetime: HOSTS.litterbox.lifetime(opts), disabled: null,
warning: "A MangaBaka review can take longer than this; the links then stop working.",
},
{
id: "catbox", label: "catbox.moe", lifetime: HOSTS.catbox.lifetime(opts),
disabled: front.catboxNeedsServer && !config ? "Needs this site's server, which isn't answering." : null, warning: null,
},
{
id: "direct", label: "No rehosting", lifetime: HOSTS.direct.lifetime(opts), disabled: null,
warning: "Submits the source's own image URL. Covers only on archive.org can't use this.",
},
);
return choices;
}
export const defaultHost = (choices: readonly HostChoice[]): HostId | null => choices.find((c) => !c.disabled)?.id ?? null;
const STAGE_TEXT: Readonly<Record<Stage, string>> = {
series: "Reading the MangaBaka series",
source: "Reading the source page",
history: "Looking through the Wayback Machine",
inspect: "Checking images",
compare: "Comparing with MangaBaka",
};
export function stageText(stage: Stage, done?: number, total?: number): string {
return total ? `${STAGE_TEXT[stage]}… ${done ?? 0}/${total}` : `${STAGE_TEXT[stage]}…`;
}
export const GRANULARITY_LABELS: Readonly<Record<Granularity, string>> = {
1: "every month", 3: "every 3 months", 6: "every 6 months", 12: "every year",
};
/** The evidence shown on each card (PRODUCT.md: show where and when each cover was seen). */
export function cardFacts(card: ReviewCard): string[] {
const c = card.cover;
const facts = [`${c.width}×${c.height}`, `${Math.max(1, Math.round(c.bytes.byteLength / 1024))} KB`];
if (c.shape === "tall") facts.push("landing-page art");
if (c.candidate.current) {
facts.push("on the page now");
} else {
const from = formatMonth(c.candidate.seenAt ?? "");
const to = formatMonth(c.candidate.lastSeenAt ?? c.candidate.seenAt ?? "");
facts.push(from === to ? `seen ${from}` : `seen ${from} – ${to}`);
}
if (!c.live) facts.push("only on archive.org");
return facts;
}
/** Months since year 0, from an archive timestamp (YYYYMM…) or a date. */
const monthOf = (ts: string) => Number(ts.slice(0, 4)) * 12 + Number(ts.slice(4, 6)) - 1;
const monthNow = (now: Date) => now.getUTCFullYear() * 12 + now.getUTCMonth();
export interface Span {
from: number;
to: number;
}
/** From the earliest sighting of any cover to now, in months. */
export function seenSpan(covers: readonly InspectedCover[], now = new Date()): Span {
const to = monthNow(now);
const starts = covers.flatMap((c) => (c.candidate.seenAt ? [monthOf(c.candidate.seenAt)] : []));
return { from: Math.min(to, ...starts), to };
}
function rangeOf(c: InspectedCover, now: Date): Span {
const to = monthNow(now);
const { seenAt, lastSeenAt, current } = c.candidate;
const start = seenAt ? monthOf(seenAt) : to;
return { from: start, to: current ? to : lastSeenAt ? monthOf(lastSeenAt) : start };
}
/** Where a cover's seen-period sits on the series' history, in percent, drawn to scale. */
export function seenBar(c: InspectedCover, span: Span, now = new Date()): { left: number; width: number } {
const total = Math.max(1, span.to - span.from + 1);
const r = rangeOf(c, now);
const left = ((r.from - span.from) / total) * 100;
return { left, width: Math.max(1.5, Math.min(100 - left, ((r.to - r.from + 1) / total) * 100)) };
}
/** Timeline view: covers grouped by the year they were last seen (now, for current ones), newest first. */
export function timelineGroups(cards: readonly ReviewCard[], now = new Date()): { year: number; cards: ReviewCard[] }[] {
const groups = new Map<number, ReviewCard[]>();
const sorted = [...cards].sort((a, b) => rangeOf(b.cover, now).to - rangeOf(a.cover, now).to);
for (const card of sorted) {
const year = Math.floor(rangeOf(card.cover, now).to / 12);
groups.set(year, [...(groups.get(year) ?? []), card]);
}
return [...groups].map(([year, list]) => ({ year, cards: list }));
}
/** History list times: recent ones relative, older ones as a date. */
export function relativeTime(iso: string, now = new Date()): string {
const ms = now.getTime() - new Date(iso).getTime();
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min} min ago`;
const h = Math.floor(min / 60);
if (h < 24) return `${h} h ago`;
const days = Math.floor(h / 24);
if (days === 1) return "yesterday";
if (days < 7) return `${days} days ago`;
return iso.slice(0, 10);
}
export function markText(m: Marks): string[] {
return [
...(m.downloaded ? ["downloaded"] : []),
...(m.rehosted ? [`rehosted on ${m.rehosted.host}`] : []),
...(m.filed ? [`filed #${m.filed.submissionId}`] : []),
];
}
export const summaryLine = (s: Pick<SearchSummary, "fresh" | "present">): string =>
`${s.fresh} new${s.present ? ` · ${s.present} on MangaBaka` : ""}`;
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { Glob } from "bun";
// AGENTS.md: src/core runs in a browser, a userscript and Bun.
const CORE_FORBIDDEN: [RegExp, string][] = [
[/\bdocument\b|\bwindow\b|\blocalStorage\b|\bsessionStorage\b/, "DOM global"],
[/\bBun\./, "Bun API"],
[/from\s+["']node:|import\(["']node:/, "node:* import"],
[/\bGM_\w+|\bGM\./, "GM_* API"],
[/from\s+["'](?:\.\.\/)+(?:web|server|userscript|cli)\//, "import from a front end"],
];
// spec §4: these server files stay web-standard so a Workers entry can be added later.
const PORTABLE_SERVER = ["app.ts", "storage.ts", "rate-limit.ts", "config.ts", "assets-path.ts"];
const PORTABLE_FORBIDDEN: [RegExp, string][] = [
[/\bBun\./, "Bun API"],
[/from\s+["']node:|import\(["']node:/, "node:* import"],
];
async function violations(pattern: string, rules: [RegExp, string][], only?: string[]) {
const problems: string[] = [];
for await (const file of new Glob(pattern).scan(".")) {
if (only && !only.some((name) => file.endsWith(`/${name}`))) continue;
const text = await Bun.file(file).text();
for (const [re, what] of rules) if (re.test(text)) problems.push(`${file}: ${what}`);
}
return problems;
}
test("src/core uses no platform APIs and imports no front end", async () => {
expect(await violations("src/core/**/*.ts", CORE_FORBIDDEN)).toEqual([]);
});
test("portable server files use only web-standard APIs", async () => {
expect(await violations("src/server/*.ts", PORTABLE_FORBIDDEN, PORTABLE_SERVER)).toEqual([]);
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import { coverNote, dedupeByHash, mergeByUrl, pickDefault, splitExisting } from "../src/core/covers";
import { inspected } from "./helpers/covers";
describe("mergeByUrl", () => {
test("merges current and historical sightings of one URL", () => {
const url = "https://swebtoon-phinf.pstatic.net/a.jpg";
const merged = mergeByUrl([
{ url, role: "poster", current: false, seenAt: "20190301000000", lastSeenAt: "20190301000000", archivedUrl: "A" },
{ url, role: "poster", current: true },
{ url, role: "poster", current: false, seenAt: "20210101000000", lastSeenAt: "20210101000000", archivedUrl: "B" },
{ url: "https://swebtoon-phinf.pstatic.net/b.jpg", role: "banner", current: true },
]);
expect(merged).toHaveLength(2);
expect(merged[0]).toEqual({
url, role: "poster", current: true, seenAt: "20190301000000", lastSeenAt: "20210101000000", archivedUrl: "A",
});
});
});
describe("dedupeByHash", () => {
test("same bytes under two URLs become one cover; the live, current one is kept", () => {
const sha = "f".repeat(64);
const archivedOnly = inspected({ sha256: sha, live: false, candidate: { url: "https://x/old.jpg", current: false, seenAt: "20150101000000" } });
const current = inspected({ sha256: sha, candidate: { url: "https://x/new.jpg", current: true } });
const out = dedupeByHash([archivedOnly, current]);
expect(out).toHaveLength(1);
expect(out[0]!.candidate).toMatchObject({ url: "https://x/new.jpg", current: true, seenAt: "20150101000000" });
expect(out[0]!.live).toBe(true);
});
});
test("splitExisting drops what MangaBaka already has, by sha256", () => {
const a = inspected();
const b = inspected();
const { fresh, present } = splitExisting([a, b], [
{ id: 7193, sha256: b.sha256.toUpperCase(), language: "en", type: "volume" },
{ id: 1, sha256: null, language: "ko", type: "volume" },
]);
expect(fresh).toEqual([a]);
expect(present).toEqual([{ cover: b, existing: { id: 7193, sha256: b.sha256.toUpperCase(), language: "en", type: "volume" } }]);
});
describe("pickDefault", () => {
test("current live poster", () => {
const square = inspected({ candidate: { role: "square" }, shape: "square" });
const poster = inspected({ candidate: { role: "poster" } });
expect(pickDefault([square, poster])).toBe(poster);
});
test("canvas: current live square when there is no poster", () => {
const square = inspected({ candidate: { role: "square" }, shape: "square" });
expect(pickDefault([square])).toBe(square);
});
test("never landing-page art, a historical cover, or an archive-only copy", () => {
expect(pickDefault([
inspected({ shape: "tall" }),
inspected({ candidate: { current: false } }),
inspected({ live: false }),
])).toBeNull();
});
});
describe("coverNote", () => {
test.each([
[{ current: true }, "portrait", "WEBTOON poster (current)"],
[{ current: false, seenAt: "20190318000000", lastSeenAt: "20210127000000" }, "portrait", "WEBTOON poster, seen 2019-03 – 2021-01"],
[{ current: false, seenAt: "20190318000000", lastSeenAt: "20190330000000" }, "portrait", "WEBTOON poster, seen 2019-03"],
[{ current: true, seenAt: "20230401000000" }, "portrait", "WEBTOON poster (current; archived since 2023-04)"],
[{ current: false, seenAt: "20230401000000", role: "poster" as const }, "tall", "WEBTOON poster (landing-page art), seen 2023-04"],
[{ current: true, role: "foreground" as const }, "wide", "WEBTOON foreground art (current)"],
] as const)("%o", (candidate, shape, note) => {
expect(coverNote(inspected({ candidate, shape }), "WEBTOON")).toBe(note);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from "bun:test";
import { followDarkReader, type ThemeDocument } from "../src/web/dark-reader";
type Fake = ThemeDocument & { attrs: Map<string, string>; metas: string[]; fire(): void; disconnected: boolean };
function fakeDocument(scheme?: string): Fake {
const attrs = new Map<string, string>();
if (scheme) attrs.set("data-darkreader-scheme", scheme);
const metas: string[] = [];
let callback = () => {};
const doc: Fake = {
attrs,
metas,
disconnected: false,
fire: () => callback(),
documentElement: {
getAttribute: (name) => attrs.get(name) ?? null,
setAttribute: (name, value) => void attrs.set(name, value),
},
addLockMeta: () => void metas.push("darkreader-lock"),
observeRoot: (onChange) => {
callback = onChange;
return () => {
doc.disconnected = true;
};
},
};
return doc;
}
test("Dark Reader already dark at start: our dark theme, and Dark Reader is told to stand down", () => {
const doc = fakeDocument("dark");
followDarkReader(doc);
expect(doc.attrs.get("data-theme")).toBe("dark");
expect(doc.metas).toEqual(["darkreader-lock"]);
expect(doc.disconnected).toBe(true);
});
test("Dark Reader turning on after load is followed once", () => {
const doc = fakeDocument();
followDarkReader(doc);
expect(doc.attrs.has("data-theme")).toBe(false);
doc.attrs.set("data-darkreader-scheme", "dark");
doc.fire();
expect(doc.attrs.get("data-theme")).toBe("dark");
expect(doc.disconnected).toBe(true);
doc.fire();
expect(doc.metas).toEqual(["darkreader-lock"]);
});
test("no Dark Reader, or its dimmed (light) scheme: the page keeps following the system", () => {
for (const doc of [fakeDocument(), fakeDocument("dimmed")]) {
followDarkReader(doc);
doc.fire();
expect(doc.attrs.has("data-theme")).toBe(false);
expect(doc.metas).toEqual([]);
expect(doc.disconnected).toBe(false);
}
});
test("the forced dark theme uses exactly the system dark tokens", async () => {
const css = await Bun.file("src/web/styles.css").text();
const block = (start: string) => {
const at = css.indexOf(start);
expect(at).toBeGreaterThan(-1);
const open = css.indexOf("{", css.indexOf(".mbseed-page", at));
return css.slice(open + 1, css.indexOf("}", open)).replace(/\s+/g, " ").trim();
};
const system = block("@media (prefers-color-scheme: dark)");
expect(system).toContain("--mb-page: #2a2520;");
expect(block(':root[data-theme="dark"]')).toBe(system);
});
+67
View File
@@ -0,0 +1,67 @@
import { expect, test } from "bun:test";
import { coverFileName, downloadBundle, slug, type BundleInput } from "../src/core/download";
import type { SourceSeries } from "../src/core/model";
import type { ReviewCard } from "../src/core/session";
import { inspected } from "./helpers/covers";
const series: SourceSeries = {
source: "webtoons", externalId: "95", title: "Tower of God", language: "en",
canonicalUrl: "https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95", archiveUrls: [], mature: false, flags: {},
};
const card = (cover = inspected(), over: Partial<ReviewCard> = {}): ReviewCard =>
({ cover, selected: true, type: cover.type, note: "WEBTOON poster (current)", contentRating: "safe", hostedUrl: null, error: null, ...over });
test("file names: position, role, language, first-seen year, size", () => {
expect(coverFileName(1, 4, card(), "en")).toBe("01_poster_en_480x623.jpg");
const old = inspected({ candidate: { role: "poster", current: false, seenAt: "20190414075103" }, ext: "png" });
expect(coverFileName(3, 4, card(old), "zh-Hant")).toBe("03_poster_zh-Hant_2019_480x623.png");
const banner = inspected({ candidate: { role: "banner" }, width: 1920, height: 321 });
expect(coverFileName(2, 4, card(banner), "en")).toBe("02_banner_en_1920x321.jpg");
});
test("file numbers pad to the count's width", () => {
expect(coverFileName(7, 120, card(), "en")).toMatch(/^007_/);
expect(coverFileName(7, 9, card(), "en")).toMatch(/^07_/);
});
test("slug: ASCII words joined by dashes; falls back when the title has none", () => {
expect(slug("Tower of God")).toBe("tower-of-god");
expect(slug(" Solo Leveling: Ragnarök!! ")).toBe("solo-leveling-ragnar-k");
expect(slug("나 혼자만 레벨업")).toBe("series");
});
function input(over: Partial<BundleInput> = {}): BundleInput {
const main = inspected({ fetchedFrom: "https://swebtoon-phinf.pstatic.net/a/95.jpg", candidate: { url: "https://swebtoon-phinf.pstatic.net/a/95.jpg" } });
const archived = inspected({
candidate: { url: "https://swebtoon-phinf.pstatic.net/b/old.jpg", role: "poster", current: false, seenAt: "20190414075103" },
fetchedFrom: "https://web.archive.org/web/20190414075103id_/https://swebtoon-phinf.pstatic.net/b/old.jpg",
live: false,
});
return {
seriesId: 9999, mbTitle: "Tower of God", mbLink: "https://mangabaka.org/manhwa/9999/Tower-of-God", sourceName: "WEBTOON", series,
cards: [card(main), card(archived, { type: "other", note: "WEBTOON poster, seen 2019-04", contentRating: "suggestive" })],
mainSha256: main.sha256, now: new Date("2026-10-15T12:00:00Z"), ...over,
};
}
test("the bundle: covers with their bytes, then covers.txt", () => {
const b = downloadBundle(input());
expect(b.zipName).toBe("tower-of-god-9999-covers.zip");
expect(b.files.map((f) => f.name)).toEqual(["01_poster_en_480x623.jpg", "02_poster_en_2019_480x623.jpg", "covers.txt"]);
const txt = new TextDecoder().decode(b.files[2]!.bytes);
expect(txt).toContain("Tower of God (MangaBaka #9999): https://mangabaka.org/manhwa/9999/Tower-of-God");
expect(txt).toContain("WEBTOON “Tower of God”: https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95");
expect(txt).toContain("Cover form: https://mangabaka.org/data/submissions/series/9999/images");
expect(txt).toContain("01_poster_en_480x623.jpg\n type: volume · language: en · rating: safe · MAIN COVER\n note: WEBTOON poster (current)\n source: https://swebtoon-phinf.pstatic.net/a/95.jpg");
expect(txt).toContain("02_poster_en_2019_480x623.jpg\n type: other · language: en · rating: suggestive\n note: WEBTOON poster, seen 2019-04\n" +
" source: https://web.archive.org/web/20190414075103id_/https://swebtoon-phinf.pstatic.net/b/old.jpg\n" +
" original: https://swebtoon-phinf.pstatic.net/b/old.jpg");
expect(txt.match(/MAIN COVER/g)).toHaveLength(1);
});
test("the cover bytes go into the ZIP untouched", () => {
const i = input();
const b = downloadBundle(i);
expect(b.files[0]!.bytes).toBe(i.cards[0]!.cover.bytes);
expect(b.files[0]!.modified).toEqual(i.now);
});
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test";
import { fixtureBytes, fixtureJson, fixtureText } from "./helpers/fixtures";
test("captured fixtures are the pages and payloads we expect", async () => {
expect(await fixtureText("webtoons/tower-of-god.html")).toContain('property="og:image"');
expect(await fixtureText("webtoons/crow-time-canvas.html")).toMatch(/isChallenge\s*:\s*true/);
expect(await fixtureText("webtoons/zombie-revelation-mature.html")).toMatch(/ageGradeNotice\s*:\s*true/);
expect(await fixtureText("wayback/tower-of-god-cdx-www.txt")).toMatch(/^2015\d{10} /m);
const page1 = await fixtureJson<{ data: unknown[]; pagination: { next: string | null } }>("mangabaka/images-9999-page1.json");
expect(page1.data.length).toBe(24);
expect(page1.pagination.next).toContain("page=2");
expect((await fixtureBytes("images/poster-95.jpg"))[0]).toBe(0xff);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"result":{"page_continue":{"first_read_product_id":63709590,"first_read_product_title":"울프 인 더 하우스 1화","first_read_is_free":true,"has_new_single":true,"has_unread_single":true},"display_ad":{"section_uid":"A15","banner_uid":"A15","trevi_uid":"","moment_uid":"DAN-9PaWqQNQpqtf5FFM"},"content":{"service_property":{"view_count":364548,"rating_count":3568,"bookmark_count":0,"highlighter_count":0,"rating_sum":35634,"comment_count":420,"preview":{"item":{"service_property":{},"operator_property":{},"asset_property":{},"uid":10525027,"product_id":63709590,"title":"울프 인 더 하우스 1화","thumbnail":"fg3Uw/hAdNQaSK14/b3yEf3a7xB6yICVVjkd5Hk","is_free":true,"state":"ST61","slide_type":"SD03","last_release_dt":"2024-03-18T18:01:47+09:00","size":3.0148,"page_count":33,"hidden":false,"free_change_dt":"1980-01-01T00:00:00+09:00","start_sale_dt":"2024-03-25T22:00:05+09:00","order_value":1,"version":"1.0"},"next_item":{"service_property":{},"operator_property":{},"asset_property":{},"uid":10525028,"product_id":63709592,"title":"울프 인 더 하우스 2화","thumbnail":"yffS8/hAd47i5f5Z/JwevO0PEbZFUa7hX7sRGEK","is_free":false,"state":"ST61","slide_type":"SD03","last_release_dt":"2024-03-18T18:01:47+09:00","size":2.9127,"page_count":33,"hidden":false,"free_change_dt":"1980-01-01T00:00:00+09:00","start_sale_dt":"2024-03-25T22:00:05+09:00","order_value":2,"version":"1.0"},"using_scroll":true}},"operator_property":{"is_manga":false},"asset_property":{},"series_id":63659962,"title":"울프 인더 하우스 [19세 완전판] ","thumbnail":"bn2lQs/hAd4XgsZ5Z/MtPVaiw3jf5BioxFC2tA91","category_uid":10,"category":"웹툰","sub_category_uid":119,"sub_category":"BL","is_all_free":false,"is_waitfree":true,"age_grade":19,"state":"ST61","on_issue":"N","series_type":"P","business_model":"T","authors":"박지연","description":"마지못해 사귀어왔던 애인과의 뻔한 이별. 후련한 마음으로 삶의 전환점을 찾던 민석은 대형견 백산이를 임시로 보호하게 된다. \n구질구질 구남친 대신 귀여운 멍멍이와 함께 자유를 만끽할 줄 알았는데... 생각보다 많이 엉뚱한 존재가 삶에 들어와버렸다!","free_slide_count":1,"last_slide_added_dt":"2024-03-25T22:00:05+09:00","start_sale_dt":"2024-03-25T22:00:05+09:00","waitfree_block_count":10,"waitfree_period_by_minute":1440,"sale_method":"TICKET","publisher_uid":33761,"on_sale_count":79,"talk_viewer_type":"TAP_SCROLL","first_product_id":63709590,"per_price_type":"S","translate_property":{"sub_category":{"th":"วาย","ko":"BL","en":"BL"},"category":{"th":"เว็บตูน","ko":"웹툰","en":"Comic"}},"lang":"ko","id":63659962}},"result_code":0,"response_time":"2026-10-15T21:15:32+09:00","message":"성공","message_key":"api_common_success"}
+1
View File
@@ -0,0 +1 @@
{"result_code":-205,"response_time":"2026-10-15T21:19:40+09:00","message":"상품 정보를 찾을 수 없습니다.","message_key":"api_content_not_found"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"status":200,"pagination":{"count":11,"next":null,"previous":null,"page":1,"limit":100},"data":[{"id":"019dbbde-a937-7bb8-acdb-0d079fce76cf","url":"https://comic.naver.com/webtoon/list?titleId=183559","name":"comic.naver.com","name_display":"NAVER","type":"webplatform","language":"ko","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:43:44.933Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbde-a937-7bd6-adae-8a1d5b560bd3","url":"https://series.naver.com/comic/detail.series?productNo=2140867","name":"series.naver.com","name_display":"NAVER Series","type":"webplatform","language":"ko","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:43:44.933Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbde-a937-7bf1-b7b9-8b822ae51ba9","url":"https://www.webtoons.com/-/-/-/list?title_no=1718","name":"www.webtoons.com","name_display":"WEBTOON","type":"webplatform","language":"es","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:43:44.933Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbde-a937-7c0c-914b-a656dbe65a45","url":"https://www.webtoons.com/-/-/-/list?title_no=1832","name":"www.webtoons.com","name_display":"WEBTOON","type":"webplatform","language":"fr","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:43:44.933Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbde-a937-7c26-9e16-c786512c7840","url":"https://www.webtoons.com/-/-/-/list?title_no=318","name":"www.webtoons.com","name_display":"WEBTOON","type":"webplatform","language":"th","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:43:44.933Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbde-a937-7c40-bd8c-f7a98a589963","url":"https://www.webtoons.com/zh-hant/fantasy/tower-of-god/list?title_no=160","name":"www.webtoons.com","name_display":"WEBTOON","type":"webplatform","language":"zh","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:43:44.933Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbde-a937-7cc4-9683-54b1222acb86","url":"https://www.webtoons.com/-/-/-/list?title_no=95","name":"www.webtoons.com","name_display":"WEBTOON","type":"webplatform","language":"en","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:43:44.933Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbe2-41f7-78e6-bc1d-334869f39016","url":"https://ko.wikipedia.org/wiki/%EC%8B%A0%EC%9D%98_%ED%83%91","name":"ko.wikipedia.org","name_display":"Wikipedia","type":"info","language":"ko","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:47:40.635Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dbbe2-41f7-7957-9eb4-00df3b734aa2","url":"https://namu.wiki/w/%EC%8B%A0%EC%9D%98%20%ED%83%91","name":"namu.wiki","name_display":"Namuwiki","type":"info","language":"ko","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-23T19:47:40.635Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"019dd924-8ecb-7274-90b2-1c6e53a61272","url":"https://www.webtoons.com/-/-/-/list?title_no=749","name":"www.webtoons.com","name_display":"WEBTOON","type":"webplatform","language":"id","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-04-29T12:09:04.969Z","updated_at":"2026-05-26T00:37:53.321Z"},{"id":"36471ede-dc4a-40e6-bd13-4665d1709fbb","url":"https://www.presenca.pt/collections/serie-tower-of-god","name":"www.presenca.pt","name_display":"Editorial Presença","type":"publisher","language":"pt","og_title":null,"og_description":null,"og_image":null,"og_site_name":null,"last_checked_at":null,"status":null,"status_history":null,"created_at":"2026-05-26T00:37:53.669Z","updated_at":"2026-05-26T00:37:53.669Z"}]}
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More