diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 865ccb2..a50c6f6 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Manga Bookmark Sync // @namespace mangabm -// @version 1.1.0 +// @version 1.2.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/* @@ -23,6 +23,12 @@ // Safe in Bromite's isolated world: the page's own JS cannot read these. const CACHE_KEY = "mangabm:cache"; + // Per-device record of when each series was last checked for new chapters. + // Deliberately not synced: each device does its own checking. + const LASTCHECKED_KEY = "mangabm:lastchecked"; + const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000; + const LATEST_CHECK_BATCH = 1; // series fetched per navigation + // ============================================================ // Site adapters // @@ -36,9 +42,36 @@ return el ? el.getAttribute("content") : null; } + // Chapter lists are read from two places: the page we are standing on, and + // series pages fetched in the background. Both are reduced to {href, text} + // pairs so each adapter needs only one rule for picking the latest chapter. + + function anchorsFromDocument(doc) { + return Array.from(doc.querySelectorAll("a[href]")).map((a) => ({ + href: a.getAttribute("href") || "", + text: a.textContent || "", + })); + } + + // A fetched page is raw text; DOMParser would run against markup we do not + // control, so anchors are pulled out directly instead. + function anchorsFromHTML(html) { + const re = /]*\shref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi; + const out = []; + let m; + while ((m = re.exec(html))) { + out.push({ href: m[1], text: m[2].replace(/<[^>]*>/g, " ") }); + } + return out; + } + const asura = { site: "asura", - // asuracomic.net currently 301s to asurascans.com; match both. + // asuracomic.net deep links 301 to the asurascans.com *root*, dropping the + // path, and that happens at the edge before this script gets a document — + // so those URLs cannot be handled here at all (checked 2026-07-25). It stays + // matched in case the redirect starts preserving paths again; until then, + // reach series through asurascans.com. matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname), detect(loc) { const path = loc.pathname; @@ -84,6 +117,20 @@ .trim(); } }, + // Series pages link every chapter. The link text check skips the + // "First Chapter" shortcut, which points at a chapter but is not one + // of the list entries. + latestChapterFromAnchors(anchors) { + let best = null; + for (const a of anchors) { + const m = a.href.match(/\/chapter\/([\d.]+)$/); + if (!m || !/Chapter\s+[\d.]+/i.test(a.text)) continue; + const num = parseFloat(m[1]); + if (isNaN(num)) continue; + if (!best || num > best.num) best = { num, label: "Chapter " + m[1] }; + } + return best; + }, }; const demonic = { @@ -131,6 +178,19 @@ return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim(); } }, + // Series pages still link chapters through the pre-redirect chaptered.php + // form. Their order is not dependable, so take the highest number. + latestChapterFromAnchors(anchors) { + let best = null; + for (const a of anchors) { + const m = a.href.match(/chaptered\.php\?manga=\d+&(?:amp;)?chapter=([\d.]+)/); + if (!m) continue; + const num = parseFloat(m[1]); + if (isNaN(num)) continue; + if (!best || num > best.num) best = { num, label: "Chapter " + m[1] }; + } + return best; + }, }; const ADAPTERS = [asura, demonic]; @@ -147,6 +207,21 @@ return page.site + ":" + page.seriesId; } + function adapterFor(site) { + return ADAPTERS.find((a) => a.site === site) || null; + } + + // Highest chapter the site lists, or null when the markup yields nothing. + function computeLatestChapter(site, anchors) { + const a = adapterFor(site); + return a ? a.latestChapterFromAnchors(anchors) : null; + } + + function currentSite() { + for (const a of ADAPTERS) if (a.matches(window.location)) return a.site; + return null; + } + // ============================================================ // API client + local cache // ============================================================ @@ -168,6 +243,23 @@ } } + function loadLastChecked() { + try { + const raw = localStorage.getItem(LASTCHECKED_KEY); + return raw ? JSON.parse(raw) : {}; + } catch (e) { + return {}; + } + } + + function saveLastChecked(map) { + try { + localStorage.setItem(LASTCHECKED_KEY, JSON.stringify(map)); + } catch (e) { + /* quota / private mode — ignore */ + } + } + function authHeaders(extra) { return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {}); } @@ -305,6 +397,93 @@ } } + async function toggleFavorite(key) { + const existing = state.byKey[key]; + if (!existing) return; + const bm = Object.assign({}, existing, { + favorite: !existing.favorite, + updated_at: Date.now(), // candidate only; the server keeps the stored one + }); + upsertLocal(bm); // optimistic + render(); + try { + const saved = await apiPut(bm.key, bm); + upsertLocal(saved); + render(); + } catch (e) { + toast("Offline — saved locally, will retry", true); + } + } + + // Records the newest chapter a site has published. Silent: this fires from + // page visits and background checks the user did not ask for, and it never + // reorders the list — updated_at is a candidate the server discards unless + // reading progress moved. + async function applyLatestChapterIfChanged(existing, latest) { + if (!existing || !latest) return; + if (existing.latest_chapter_num === latest.num) return; + const bm = Object.assign({}, existing, { + latest_chapter: latest.label, + latest_chapter_num: latest.num, + updated_at: Date.now(), + }); + upsertLocal(bm); + render(); + try { + const saved = await apiPut(bm.key, bm); + upsertLocal(saved); + render(); + } catch (e) { + /* offline — the local cache still shows it, retried on a later visit */ + } + } + + // Standing on a bookmarked series page: its chapter list is already here. + function maybeCaptureLatestOnSeriesPage() { + const p = state.page; + if (p.type !== "series") return; + const existing = state.byKey[keyOf(p)]; + if (!existing) return; + applyLatestChapterIfChanged( + existing, + computeLatestChapter(p.site, anchorsFromDocument(document)) + ); + } + + // Everything else is only learned by fetching a series page. Same-origin + // only: these requests carry the session that gets us past the site's bot + // checks, which a request to the other site (or from a server) would not. + // One series per navigation keeps it indistinguishable from browsing. + async function backgroundRefreshLatest() { + const site = currentSite(); + if (!site) return; + + const checked = loadLastChecked(); + const now = Date.now(); + const due = state.list + .filter((b) => b.site === site && b.series_url) + .filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS) + .sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0)) + .slice(0, LATEST_CHECK_BATCH); + if (due.length === 0) return; + + for (const bm of due) { + // Recorded even when the fetch fails, so a broken series is retried on + // the next throttle window rather than on every single page load. + checked[bm.key] = Date.now(); + try { + const res = await fetch(bm.series_url, { credentials: "same-origin" }); + if (!res.ok) continue; + const html = await res.text(); + const latest = computeLatestChapter(bm.site, anchorsFromHTML(html)); + await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest); + } catch (e) { + /* offline or blocked — try again after the throttle window */ + } + } + saveLastChecked(checked); + } + async function removeBookmark(key) { removeLocal(key); // optimistic render(); @@ -372,6 +551,7 @@ // ============================================================ let root, panelOpen = false; + let activeTab = "all"; // "all" | "favorites"; resets each page, by design function buildUI() { const host = document.createElement("div"); @@ -400,6 +580,13 @@ root.getElementById("backdrop").addEventListener("click", togglePanel); root.getElementById("closeBtn").addEventListener("click", togglePanel); + for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"]]) { + root.getElementById(id).addEventListener("click", () => { + activeTab = tab; + render(); + }); + } + placeFab(fab); makeDraggable(fab); window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation @@ -565,27 +752,52 @@ ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." })); } - // Bookmark list. + // Bookmark list. Favorites only narrows what is drawn — state.list keeps + // every bookmark, so favouriting one never removes it from "All". + const favorites = activeTab === "favorites"; + root.getElementById("tabAll").classList.toggle("active", !favorites); + root.getElementById("tabFav").classList.toggle("active", favorites); + const listEl = root.getElementById("list"); listEl.innerHTML = ""; - if (state.list.length === 0) { - listEl.appendChild(el("div", { class: "empty", text: "No bookmarks yet." })); + const items = favorites ? state.list.filter((b) => b.favorite) : state.list; + if (items.length === 0) { + listEl.appendChild( + el("div", { + class: "empty", + text: favorites ? "No favourites yet." : "No bookmarks yet.", + }) + ); } - for (const b of state.list) { + for (const b of items) { listEl.appendChild(renderItem(b)); } } function renderItem(b) { const cont = b.last_chapter_url || b.series_url || "#"; + // Only worth naming the latest chapter while it is ahead of the read one. + const behind = + b.latest_chapter && + b.latest_chapter_num != null && + (b.last_chapter_num == null || b.latest_chapter_num > b.last_chapter_num); + const sub = behind + ? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter + : (b.last_chapter || "—") + " · " + b.site; 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: "c" + (behind ? " behind" : ""), text: sub }), el("div", { class: "actions" }, [ + el("button", { + class: "btn small star" + (b.favorite ? " on" : ""), + text: b.favorite ? "★" : "☆", + title: b.favorite ? "Remove from favourites" : "Add to favourites", + onclick: () => toggleFavorite(b.key), + }), 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) }), @@ -634,6 +846,10 @@ state.page = detect(); render(); maybeAutoUpdate(); + maybeCaptureLatestOnSeriesPage(); + // Asura is client-routed, so full loads are rare — checking here too is + // what keeps its bookmarks current. The throttle still caps the rate. + backgroundRefreshLatest(); } // Framework-agnostic URL-change watcher: patch history + poll as a fallback, @@ -669,7 +885,13 @@ state.page = detect(); render(); installNavWatcher(); - refresh().then(() => maybeAutoUpdate()); // sync, then auto-record this page + // Sync first: both auto-record and the latest-chapter checks below need to + // know which series are bookmarked and how fresh they are. + refresh().then(() => { + maybeAutoUpdate(); + maybeCaptureLatestOnSeriesPage(); + backgroundRefreshLatest(); + }); } // ============================================================ @@ -688,6 +910,10 @@
+
+ + +
@@ -740,6 +966,13 @@ } .ctx-title { font-weight: 600; font-size: 15px; } .ctx-sub { color: #9a9aa5; font-size: 13px; } + #tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; } + .tab { + background: none; border: none; color: #9a9aa5; cursor: pointer; + font-size: 13px; font-weight: 600; padding: 4px 0 9px; + border-bottom: 2px solid transparent; margin-bottom: -1px; + } + .tab.active { color: #eee; border-bottom-color: #6d28d9; } #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; } @@ -748,6 +981,7 @@ .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; } + .c.behind { color: #c4b5fd; } .actions { display: flex; gap: 6px; flex-wrap: wrap; } .btn { background: #33333d; color: #eee; border: none; border-radius: 6px; @@ -757,6 +991,8 @@ .btn.primary { background: #6d28d9; color: #fff; } .btn.small { padding: 6px 10px; font-size: 12px; } .btn.danger { background: #7f1d1d; } + .btn.star { line-height: 1; } + .btn.star.on { background: #6d28d9; color: #fff; } #toasts { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(80px + env(safe-area-inset-bottom, 0px));