feat: manga bookmark sync backend + Bromite userscript

Backend (Go, stdlib net/http + modernc.org/sqlite, CGO-free static binary):
- GET/PUT/DELETE /bookmarks{,/key} + /healthz
- bearer auth (constant-time), CORS origin reflection + 204 preflight
- SQLite store keyed <site>:<series_id>, last-write-wins, server-set updated_at
- httptest + temp-sqlite tests (auth, CORS, round-trip); go vet clean
- multi-stage Dockerfile (distroless static nonroot) + compose (base + prod proxy override)

Userscript (single Bromite-compatible IIFE, no GM_* APIs):
- Asura + Demonic adapters, URL-regex ids + og: title/cover
- Shadow-DOM floating UI, localStorage cache, optimistic sync
- auto-progress (no regress) + manual override; framework-agnostic nav watcher

Live-verified adapters (Playwright, 2026-07-24): asurascans.com /comics/<slug-hash>,
demonicscans.org /manga/<slug> + /title/<slug>/chapter/<n> — corrects the plan's
assumed /series/ paths and asuracomic.net domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-07-24 16:48:28 +07:00
parent d47a07af46
commit f58d113934
15 changed files with 1494 additions and 0 deletions
+601
View File
@@ -0,0 +1,601 @@
// ==UserScript==
// @name Manga Bookmark Sync
// @namespace mangabm
// @version 1.0.0
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
// @author you
// @match https://asuracomic.net/*
// @match https://asurascans.com/*
// @match https://demonicscans.org/*
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
"use strict";
// ============================================================
// CONFIG — fill these in before installing.
// ============================================================
const API_BASE = "https://manga-api.example.com"; // your backend origin, no trailing slash
const API_TOKEN = "PASTE_YOUR_API_TOKEN_HERE"; // must equal backend API_TOKEN
// Safe in Bromite's isolated world: the page's own JS cannot read these.
const CACHE_KEY = "mangabm:cache";
// ============================================================
// Site adapters
//
// Page type + IDs come from URL regex (most stable); title/cover come from
// og: meta tags. Verified live 2026-07-24 against asurascans.com and
// demonicscans.org — see README "Adapter reference".
// ============================================================
function meta(prop) {
const el = document.querySelector('meta[property="' + prop + '"]');
return el ? el.getAttribute("content") : null;
}
const asura = {
site: "asura",
// asuracomic.net currently 301s to asurascans.com; match both.
matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname),
detect(loc) {
const path = loc.pathname;
// /comics/<slug-hash>/chapter/<n>
let m = path.match(/^\/comics\/([^/]+)\/chapter\/([^/?#]+)/);
if (m) {
const num = parseFloat(m[2]);
return {
type: "chapter",
site: this.site,
seriesId: m[1],
title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/comics/" + m[1],
chapterLabel: "Chapter " + m[2],
chapterNum: isNaN(num) ? null : num,
chapterUrl: loc.href,
};
}
// /comics/<slug-hash>
m = path.match(/^\/comics\/([^/?#]+)\/?$/);
if (m) {
return {
type: "series",
site: this.site,
seriesId: m[1],
title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/comics/" + m[1],
chapterLabel: null,
chapterNum: null,
chapterUrl: null,
};
}
return { type: "other" };
// Strips "Chapter N - Read Online" and the " | Asura Scans" suffix.
function cleanTitle(t) {
if (!t) return "";
return t
.replace(/\s*Chapter\s+[\d.]+\s*-\s*Read Online\s*/i, " ")
.replace(/\s*\|\s*Asura Scans\s*$/i, "")
.trim();
}
},
};
const demonic = {
site: "demonic",
matches: (loc) => /(^|\.)demonicscans\.org$/.test(loc.hostname),
detect(loc) {
const path = loc.pathname;
// Canonical reader: /title/<slug>/chapter/<n>/<page>
// (chaptered.php?manga=<id>&chapter=<n> 301s to this before we run.)
let m = path.match(/^\/title\/([^/]+)\/chapter\/([^/?#]+)/);
if (m) {
const num = parseFloat(m[2]);
return {
type: "chapter",
site: this.site,
seriesId: decodeURIComponent(m[1]),
title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/manga/" + m[1],
chapterLabel: "Chapter " + m[2],
chapterNum: isNaN(num) ? null : num,
chapterUrl: loc.href,
};
}
// Series: /manga/<slug>
m = path.match(/^\/manga\/([^/?#]+)/);
if (m) {
return {
type: "series",
site: this.site,
seriesId: decodeURIComponent(m[1]),
title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/manga/" + m[1],
chapterLabel: null,
chapterNum: null,
chapterUrl: null,
};
}
return { type: "other" };
// Demonic chapter og:title is "<Title> Chapter N"; series is clean.
function cleanTitle(t) {
if (!t) return "";
return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim();
}
},
};
const ADAPTERS = [asura, demonic];
function detect() {
const loc = window.location;
for (const a of ADAPTERS) {
if (a.matches(loc)) return a.detect(loc);
}
return { type: "other" };
}
function keyOf(page) {
return page.site + ":" + page.seriesId;
}
// ============================================================
// API client + local cache
// ============================================================
function loadCache() {
try {
const raw = localStorage.getItem(CACHE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveCache(list) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(list));
} catch (e) {
/* quota / private mode — ignore */
}
}
function authHeaders(extra) {
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
}
async function apiGet() {
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
return res.json();
}
async function apiPut(key, obj) {
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
method: "PUT",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(obj),
});
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
return res.json();
}
async function apiDelete(key) {
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
method: "DELETE",
headers: authHeaders(),
});
if (!res.ok) throw new Error("DELETE /bookmarks " + res.status);
}
// ============================================================
// State
// ============================================================
const state = {
list: loadCache(), // last-known bookmarks (array)
byKey: {},
page: { type: "other" },
};
reindex();
function reindex() {
state.byKey = {};
for (const b of state.list) state.byKey[b.key] = b;
state.list.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
}
function setList(list) {
state.list = Array.isArray(list) ? list : [];
reindex();
saveCache(state.list);
}
function upsertLocal(bm) {
const i = state.list.findIndex((b) => b.key === bm.key);
if (i >= 0) state.list[i] = bm;
else state.list.push(bm);
reindex();
saveCache(state.list);
}
function removeLocal(key) {
state.list = state.list.filter((b) => b.key !== key);
reindex();
saveCache(state.list);
}
// ============================================================
// Mutations (optimistic: update UI/cache first, then sync)
// ============================================================
async function bookmarkCurrent() {
const p = state.page;
if (p.type !== "series" && p.type !== "chapter") return;
const key = keyOf(p);
const existing = state.byKey[key];
const bm = {
key: key,
site: p.site,
series_id: p.seriesId,
title: p.title || (existing && existing.title) || p.seriesId,
series_url: p.seriesUrl || (existing && existing.series_url) || "",
cover: p.cover || (existing && existing.cover) || "",
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
last_chapter_num:
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
last_chapter_url: p.chapterUrl || (existing && existing.last_chapter_url) || "",
updated_at: Date.now(),
};
await syncUpsert(bm, "Bookmarked");
}
async function updateToCurrentChapter() {
const p = state.page;
if (p.type !== "chapter") return;
const key = keyOf(p);
const existing = state.byKey[key] || {};
const bm = Object.assign({}, existing, {
key: key,
site: p.site,
series_id: p.seriesId,
title: existing.title || p.title || p.seriesId,
series_url: existing.series_url || p.seriesUrl || "",
cover: existing.cover || p.cover || "",
last_chapter: p.chapterLabel || "",
last_chapter_num: p.chapterNum,
last_chapter_url: p.chapterUrl || "",
updated_at: Date.now(),
});
await syncUpsert(bm, "Progress updated");
}
// Manual override from the panel input — forces any value.
async function setChapterManual(key, label, num, url) {
const existing = state.byKey[key];
if (!existing) return;
const bm = Object.assign({}, existing, {
last_chapter: label,
last_chapter_num: isNaN(num) ? existing.last_chapter_num : num,
last_chapter_url: url || existing.last_chapter_url,
updated_at: Date.now(),
});
await syncUpsert(bm, "Progress set");
}
async function syncUpsert(bm, okMsg) {
upsertLocal(bm); // optimistic
render();
try {
const saved = await apiPut(bm.key, bm);
upsertLocal(saved); // adopt server updated_at
render();
toast(okMsg);
} catch (e) {
toast("Offline — saved locally, will retry", true);
}
}
async function removeBookmark(key) {
removeLocal(key); // optimistic
render();
try {
await apiDelete(key);
toast("Removed");
} catch (e) {
toast("Offline — remove will retry", true);
}
}
// Auto-record on opening a chapter of a bookmarked series.
async function maybeAutoUpdate() {
const p = state.page;
if (p.type !== "chapter") return;
const existing = state.byKey[keyOf(p)];
if (!existing) return; // only track series the user bookmarked
const cur = p.chapterNum;
const prev = existing.last_chapter_num;
// Advance when current >= stored, or when either is unparseable (set current).
const advance = cur == null || prev == null || cur >= prev;
if (!advance) return;
if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) {
return; // already recorded
}
await updateToCurrentChapter();
}
// ============================================================
// UI — Shadow DOM (isolated from site CSS)
// ============================================================
let root, panelOpen = false;
function buildUI() {
const host = document.createElement("div");
host.id = "mangabm-host";
host.style.cssText = "all: initial;";
(document.body || document.documentElement).appendChild(host);
root = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = CSS;
root.appendChild(style);
const wrap = document.createElement("div");
wrap.id = "wrap";
wrap.innerHTML = TEMPLATE;
root.appendChild(wrap);
root.getElementById("fab").addEventListener("click", togglePanel);
root.getElementById("backdrop").addEventListener("click", togglePanel);
root.getElementById("closeBtn").addEventListener("click", togglePanel);
}
function togglePanel() {
panelOpen = !panelOpen;
render();
if (panelOpen) refresh(); // pull latest from server on open
}
function el(tag, props, children) {
const e = document.createElement(tag);
if (props) for (const k in props) {
if (k === "text") e.textContent = props[k];
else if (k === "html") e.innerHTML = props[k];
else if (k.slice(0, 2) === "on") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
else e.setAttribute(k, props[k]);
}
if (children) for (const c of children) if (c) e.appendChild(c);
return e;
}
function render() {
if (!root) return;
root.getElementById("panel").classList.toggle("open", panelOpen);
root.getElementById("backdrop").classList.toggle("open", panelOpen);
// Context header for the current page.
const ctx = root.getElementById("context");
ctx.innerHTML = "";
const p = state.page;
if (p.type === "series" || p.type === "chapter") {
const key = keyOf(p);
const existing = state.byKey[key];
ctx.appendChild(el("div", { class: "ctx-title", text: p.title || p.seriesId }));
if (!existing) {
ctx.appendChild(el("button", { class: "btn primary", text: "+ Bookmark this", onclick: () => bookmarkCurrent() }));
} else {
const cur = existing.last_chapter || "—";
ctx.appendChild(el("div", { class: "ctx-sub", text: "Tracked · last read: " + cur }));
if (p.type === "chapter" && p.chapterLabel && p.chapterLabel !== existing.last_chapter) {
ctx.appendChild(el("button", { class: "btn primary", text: "Update to " + p.chapterLabel, onclick: () => updateToCurrentChapter() }));
}
}
} else {
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
}
// Bookmark list.
const listEl = root.getElementById("list");
listEl.innerHTML = "";
if (state.list.length === 0) {
listEl.appendChild(el("div", { class: "empty", text: "No bookmarks yet." }));
}
for (const b of state.list) {
listEl.appendChild(renderItem(b));
}
}
function renderItem(b) {
const cont = b.last_chapter_url || b.series_url || "#";
const row = el("div", { class: "item" }, [
b.cover
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
: el("div", { class: "cover ph" }),
el("div", { class: "meta" }, [
el("div", { class: "t", text: b.title || b.series_id }),
el("div", { class: "c", text: (b.last_chapter || "—") + " · " + b.site }),
el("div", { class: "actions" }, [
el("a", { class: "btn small", href: cont, text: "Continue" }),
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
]),
]),
]);
return row;
}
function editChapter(b) {
const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || "");
if (raw == null) return;
const label = raw.trim();
const num = parseFloat((label.match(/[\d.]+/) || [])[0]);
setChapterManual(b.key, label, num, b.last_chapter_url);
}
// Toast inside the shadow root.
function toast(msg, isErr) {
if (!root) return;
const t = el("div", { class: "toast" + (isErr ? " err" : ""), text: msg });
root.getElementById("toasts").appendChild(t);
setTimeout(() => t.classList.add("show"), 10);
setTimeout(() => {
t.classList.remove("show");
setTimeout(() => t.remove(), 300);
}, 2600);
}
// ============================================================
// Refresh + navigation
// ============================================================
async function refresh() {
try {
const list = await apiGet();
setList(list);
render();
} catch (e) {
render(); // fall back to cache
}
}
let lastUrl = location.href;
function onNavigate() {
state.page = detect();
render();
maybeAutoUpdate();
}
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
// so it works whether the site is client-routed (Asura) or reloads (Demonic).
function installNavWatcher() {
const fire = () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
// Meta tags update slightly after the URL on client routes.
setTimeout(onNavigate, 300);
}
};
const wrap = (name) => {
const orig = history[name];
history[name] = function () {
const r = orig.apply(this, arguments);
fire();
return r;
};
};
wrap("pushState");
wrap("replaceState");
window.addEventListener("popstate", fire);
setInterval(fire, 1500); // catch routes that bypass history
}
// ============================================================
// Boot
// ============================================================
function init() {
buildUI();
state.page = detect();
render();
installNavWatcher();
refresh().then(() => maybeAutoUpdate()); // sync, then auto-record this page
}
if (document.body) init();
else document.addEventListener("DOMContentLoaded", init);
// ============================================================
// Markup + styles (kept at bottom for readability)
// ============================================================
const TEMPLATE = `
<button id="fab" aria-label="Manga bookmarks">📑</button>
<div id="backdrop"></div>
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
<header>
<span>Manga Bookmarks</span>
<button id="closeBtn" aria-label="Close">✕</button>
</header>
<section id="context"></section>
<div id="list"></div>
</aside>
<div id="toasts"></div>
`;
const CSS = `
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
#fab {
position: fixed; right: 16px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
width: 52px; height: 52px; border-radius: 50%; border: none;
background: #6d28d9; color: #fff; font-size: 22px; line-height: 52px;
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
}
#fab:active { transform: scale(.94); }
#backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
}
#backdrop.open { opacity: 1; pointer-events: auto; }
#panel {
position: fixed; top: 0; right: 0; height: 100%;
width: min(400px, 92vw); background: #1b1b22; color: #eee;
transform: translateX(105%); transition: transform .25s ease;
z-index: 2147483647; display: flex; flex-direction: column;
padding-bottom: env(safe-area-inset-bottom, 0px);
box-shadow: -4px 0 20px rgba(0,0,0,.5);
}
#panel.open { transform: translateX(0); }
header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
}
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
#context {
padding: 12px 16px; border-bottom: 1px solid #33333d;
display: flex; flex-direction: column; gap: 8px;
}
.ctx-title { font-weight: 600; font-size: 15px; }
.ctx-sub { color: #9a9aa5; font-size: 13px; }
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
.item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; }
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
.cover.ph { display: block; }
.meta { min-width: 0; flex: 1; }
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.btn {
background: #33333d; color: #eee; border: none; border-radius: 6px;
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
}
.btn:active { opacity: .8; }
.btn.primary { background: #6d28d9; color: #fff; }
.btn.small { padding: 6px 10px; font-size: 12px; }
.btn.danger { background: #7f1d1d; }
#toasts {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center;
}
.toast {
background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px;
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s;
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.err { background: #7f1d1d; }
`;
})();