Add comix.to and kagane.to support (#13)
Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #13.
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
// ==UserScript==
|
||||
// @name Manga Bookmark Sync
|
||||
// @namespace mangabm
|
||||
// @version 1.5.0
|
||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||
// @version 1.6.0
|
||||
// @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||
// @author you
|
||||
// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||
// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||
// @match https://asuracomic.net/*
|
||||
// @match https://asurascans.com/*
|
||||
// @match https://demonicscans.org/*
|
||||
// @match https://comix.to/*
|
||||
// @match https://kagane.to/*
|
||||
// @run-at document-idle
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
@@ -80,6 +82,14 @@
|
||||
return slug.replace(/-[0-9a-f]{8}$/, "");
|
||||
}
|
||||
|
||||
// comix path segments are "<id>-<slug>", where the slug is a rendering of the
|
||||
// current title and changes when a series is renamed. Only the id is the
|
||||
// identity; seriesUrl keeps the full segment because navigation needs it.
|
||||
function comixSeriesId(segment) {
|
||||
const i = segment.indexOf("-");
|
||||
return i === -1 ? segment : segment.slice(0, i);
|
||||
}
|
||||
|
||||
const asura = {
|
||||
site: "asura",
|
||||
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
|
||||
@@ -208,7 +218,162 @@
|
||||
},
|
||||
};
|
||||
|
||||
const ADAPTERS = [asura, demonic];
|
||||
const comix = {
|
||||
site: "comix",
|
||||
matches: (loc) => /(^|\.)comix\.to$/.test(loc.hostname),
|
||||
detect(loc) {
|
||||
const path = loc.pathname;
|
||||
// /title/<id>-<slug>/<uploadId>-chapter-<n>. Several uploads (different
|
||||
// groups or languages) share one chapter number; the number is the
|
||||
// progress identity, the upload id is not.
|
||||
let m = path.match(/^\/title\/([^/]+)\/[^/]*-chapter-([\d.]+)/);
|
||||
if (m) {
|
||||
const num = parseFloat(m[2]);
|
||||
return {
|
||||
type: "chapter",
|
||||
site: this.site,
|
||||
seriesId: comixSeriesId(m[1]),
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: coverFromPage(),
|
||||
seriesUrl: loc.origin + "/title/" + m[1],
|
||||
chapterLabel: "Chapter " + m[2],
|
||||
chapterNum: isNaN(num) ? null : num,
|
||||
chapterUrl: loc.href,
|
||||
};
|
||||
}
|
||||
// /title/<id>-<slug>
|
||||
m = path.match(/^\/title\/([^/?#]+)\/?$/);
|
||||
if (m) {
|
||||
return {
|
||||
type: "series",
|
||||
site: this.site,
|
||||
seriesId: comixSeriesId(m[1]),
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: coverFromPage(),
|
||||
seriesUrl: loc.origin + "/title/" + m[1],
|
||||
chapterLabel: null,
|
||||
chapterNum: null,
|
||||
chapterUrl: null,
|
||||
};
|
||||
}
|
||||
return { type: "other" };
|
||||
|
||||
// comix chapter og:title is "<Title> · Ch.<n>"; series is clean.
|
||||
function cleanTitle(t) {
|
||||
if (!t) return "";
|
||||
return t.replace(/\s*·\s*Ch\.[\d.]+\s*$/i, "").trim();
|
||||
}
|
||||
|
||||
// comix serves no og:image, so this is the one adapter that has to read
|
||||
// the DOM for a cover. Matching on alt rather than a class keeps it off
|
||||
// the site's styling: the cover is the image whose alt is the title.
|
||||
// Do not "simplify" this into meta("og:image") — that returns null.
|
||||
function coverFromPage() {
|
||||
const title = cleanTitle(meta("og:title"));
|
||||
if (!title || !document.querySelectorAll) return "";
|
||||
for (const img of document.querySelectorAll("img[alt]")) {
|
||||
if (img.getAttribute("alt") === title) return img.getAttribute("src") || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
},
|
||||
// Scoped to this series' own id prefix so a recommendation strip's links
|
||||
// cannot win the maximum. seriesId is passed in because the anchors alone
|
||||
// do not say which series the page belongs to.
|
||||
latestChapterFromAnchors(anchors, seriesId) {
|
||||
let best = null;
|
||||
const re = new RegExp("^/title/" + seriesId + "-[^/]*/[^/]*-chapter-([\\d.]+)");
|
||||
for (const a of anchors) {
|
||||
const m = a.href.match(re);
|
||||
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 kagane = {
|
||||
site: "kagane",
|
||||
matches: (loc) => /(^|\.)kagane\.to$/.test(loc.hostname),
|
||||
detect(loc) {
|
||||
const path = loc.pathname;
|
||||
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||
// /series/<uuid>/reader/<bookUuid> — no chapter number anywhere in the
|
||||
// URL, so it comes out of og:title instead.
|
||||
let m = path.match(new RegExp("^/series/(" + UUID + ")/reader/" + UUID));
|
||||
if (m) {
|
||||
const num = chapterNumFromTitle(meta("og:title"));
|
||||
return {
|
||||
type: "chapter",
|
||||
site: this.site,
|
||||
seriesId: m[1],
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: meta("og:image") || "",
|
||||
seriesUrl: loc.origin + "/series/" + m[1],
|
||||
chapterLabel: num === null ? null : "Chapter " + num,
|
||||
chapterNum: num,
|
||||
chapterUrl: loc.href,
|
||||
};
|
||||
}
|
||||
// /series/<uuid>
|
||||
m = path.match(new RegExp("^/series/(" + UUID + ")/?$"));
|
||||
if (m) {
|
||||
return {
|
||||
type: "series",
|
||||
site: this.site,
|
||||
seriesId: m[1],
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: meta("og:image") || "",
|
||||
seriesUrl: loc.origin + "/series/" + m[1],
|
||||
chapterLabel: null,
|
||||
chapterNum: null,
|
||||
chapterUrl: null,
|
||||
};
|
||||
}
|
||||
return { type: "other" };
|
||||
|
||||
// Reader og:title is "<Title> - Chapter <n> - <episode name>".
|
||||
function chapterNumFromTitle(t) {
|
||||
const m = t && t.match(/\s-\sChapter\s([\d.]+)\s/);
|
||||
if (!m) return null;
|
||||
const num = parseFloat(m[1]);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
function cleanTitle(t) {
|
||||
if (!t) return "";
|
||||
return t.replace(/\s-\sChapter\s[\d.]+\s-\s.*$/i, "").trim();
|
||||
}
|
||||
},
|
||||
// Reader hrefs are uuids with no number in them, so no maximum can be taken
|
||||
// from anchors at all. latestChapterFromApi replaces this path entirely.
|
||||
latestChapterFromAnchors() {
|
||||
return null;
|
||||
},
|
||||
// Same-origin only: the request needs the Cloudflare clearance cookie that
|
||||
// this browser already holds for kagane.to. Called cross-origin it would be
|
||||
// challenged and return nothing.
|
||||
async latestChapterFromApi(seriesId) {
|
||||
try {
|
||||
const res = await fetch("/api/v2/series/" + seriesId);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
let best = null;
|
||||
for (const b of (data && data.series_books) || []) {
|
||||
const num = parseFloat(b.chapter_no);
|
||||
if (isNaN(num)) continue;
|
||||
if (!best || num > best.num) best = { num, label: "Chapter " + b.chapter_no };
|
||||
}
|
||||
return best;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ADAPTERS = [asura, demonic, comix, kagane];
|
||||
|
||||
function detect() {
|
||||
const loc = window.location;
|
||||
@@ -227,9 +392,11 @@
|
||||
}
|
||||
|
||||
// Highest chapter the site lists, or null when the markup yields nothing.
|
||||
function computeLatestChapter(site, anchors) {
|
||||
// seriesId is only consulted by adapters whose pages carry other series'
|
||||
// chapter links; the rest ignore it.
|
||||
function computeLatestChapter(site, anchors, seriesId) {
|
||||
const a = adapterFor(site);
|
||||
return a ? a.latestChapterFromAnchors(anchors) : null;
|
||||
return a ? a.latestChapterFromAnchors(anchors, seriesId) : null;
|
||||
}
|
||||
|
||||
function currentSite() {
|
||||
@@ -671,8 +838,8 @@
|
||||
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: p.chapterLabel || existing.last_chapter || "",
|
||||
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
||||
last_chapter_url: p.chapterUrl || "",
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
@@ -750,14 +917,15 @@
|
||||
if (!existing) return;
|
||||
applyLatestChapterIfChanged(
|
||||
existing,
|
||||
computeLatestChapter(p.site, anchorsFromDocument(document))
|
||||
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Everything else is only learned by fetching a series page — or, where the
|
||||
// site offers one, its JSON API. 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. A few series per navigation keeps
|
||||
// it indistinguishable from browsing.
|
||||
async function backgroundRefreshLatest() {
|
||||
const site = currentSite();
|
||||
if (!site) return;
|
||||
@@ -772,15 +940,21 @@
|
||||
.slice(0, LATEST_CHECK_BATCH);
|
||||
if (due.length === 0) return;
|
||||
|
||||
const adapter = adapterFor(site);
|
||||
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));
|
||||
let latest;
|
||||
if (adapter && adapter.latestChapterFromApi) {
|
||||
latest = await adapter.latestChapterFromApi(bm.series_id);
|
||||
} else {
|
||||
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
||||
if (!res.ok) continue;
|
||||
const html = await res.text();
|
||||
latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
|
||||
}
|
||||
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
||||
} catch (e) {
|
||||
/* offline or blocked — try again after the throttle window */
|
||||
@@ -1414,6 +1588,8 @@
|
||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">Web</a>
|
||||
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||
<a class="chip" href="https://comix.to" target="_blank" rel="noopener">Comix</a>
|
||||
<a class="chip" href="https://kagane.to" target="_blank" rel="noopener">Kagane</a>
|
||||
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
||||
</div>
|
||||
<section id="context"></section>
|
||||
@@ -1608,7 +1784,7 @@
|
||||
// Exposes pure logic only — see userscript/test/logic.test.js.
|
||||
// ============================================================
|
||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||
module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf };
|
||||
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user