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:
2026-08-03 19:53:45 +07:00
committed by sulthan
parent 3c935ba7c3
commit 180ee78b1f
49 changed files with 2233 additions and 1027 deletions
+193 -17
View File
@@ -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 };
}
// ============================================================
+193
View File
@@ -31,6 +31,9 @@ globalThis.location = {
// og: meta tags the adapters read through meta(). Reassigned per test.
let metaTags = {};
// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each
// entry is {alt, src}.
let pageImages = [];
globalThis.document = {
querySelector(sel) {
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
@@ -38,14 +41,23 @@ globalThis.document = {
const v = metaTags[m[1]];
return v == null ? null : { getAttribute: () => v };
},
querySelectorAll(sel) {
if (sel !== "img[alt]") return [];
return pageImages.map((img) => ({
getAttribute: (attr) => img[attr] ?? null,
}));
},
addEventListener() {},
body: undefined,
};
const {
stripBuildHash,
comixSeriesId,
asura,
demonic,
comix,
kagane,
anchorsFromHTML,
statusOf,
} = require("../manga-bookmark.user.js");
@@ -166,6 +178,187 @@ test("demonic.latestChapterFromAnchors parses chaptered.php links, including &am
assert.deepEqual(best, { num: 12, label: "Chapter 12" });
});
// ============================================================
// comix — the id prefix is the stable identity; the slug tail is a title
// rendering that changes when a series is renamed.
// ============================================================
test("comixSeriesId keeps only the prefix before the first dash", () => {
assert.equal(comixSeriesId("n8we-dungeons-and-crayons"), "n8we");
assert.equal(comixSeriesId("20xzd-the-baddest-villainess-is-back"), "20xzd");
});
test("comixSeriesId leaves a bare id untouched", () => {
assert.equal(comixSeriesId("n8we"), "n8we");
});
test("comix detects a series page", () => {
metaTags = { "og:title": "Dungeons and Crayons" };
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
assert.equal(p.type, "series");
assert.equal(p.site, "comix");
assert.equal(p.seriesId, "n8we");
assert.equal(p.title, "Dungeons and Crayons");
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
assert.equal(p.chapterNum, null);
});
test("comix detects a chapter page and strips the Ch. suffix from the title", () => {
metaTags = { "og:title": "Dungeons and Crayons · Ch.80" };
const p = comix.detect(
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
);
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "n8we");
assert.equal(p.title, "Dungeons and Crayons");
assert.equal(p.chapterNum, 80);
assert.equal(p.chapterLabel, "Chapter 80");
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
});
test("comix parses decimal chapter numbers", () => {
metaTags = { "og:title": "Dungeons and Crayons · Ch.80.5" };
const p = comix.detect(
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80.5")
);
assert.equal(p.chapterNum, 80.5);
});
test("comix.detect reads the cover from an img whose alt matches the cleaned title", () => {
metaTags = { "og:title": "Dungeons and Crayons · Ch.80" };
pageImages = [
{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" },
{ alt: "Dungeons and Crayons", src: "https://cdn.example/cover.jpg" },
];
const p = comix.detect(
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
);
assert.equal(p.cover, "https://cdn.example/cover.jpg");
});
test("comix.detect leaves cover empty when no img alt matches the title", () => {
metaTags = { "og:title": "Dungeons and Crayons" };
pageImages = [{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" }];
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
assert.equal(p.cover, "");
pageImages = [];
});
test("comix ignores unrelated paths", () => {
assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other");
});
test("comix takes the max chapter and ignores other series' links", () => {
const anchors = anchorsFromHTML(`
<a href="/title/n8we-dungeons-and-crayons/11123327-chapter-79">Chapter 79</a>
<a href="/title/n8we-dungeons-and-crayons/11139891-chapter-80">Chapter 80</a>
<a href="/title/n8we-dungeons-and-crayons/10794753-chapter-78">Chapter 78</a>
<a href="/title/qqwrm-full-time-awakening/99999999-chapter-999">Chapter 999</a>
`);
assert.deepEqual(comix.latestChapterFromAnchors(anchors, "n8we"), {
num: 80,
label: "Chapter 80",
});
});
test("comix latest returns null when no chapter links are present", () => {
assert.equal(comix.latestChapterFromAnchors(anchorsFromHTML("<a href='/browse'>x</a>"), "n8we"), null);
});
test("asura and demonic ignore the seriesId argument", () => {
const asuraAnchors = anchorsFromHTML(
`<a href="/comics/x-aabbccdd/chapter/12"><span>Chapter 12</span></a>`
);
assert.deepEqual(asura.latestChapterFromAnchors(asuraAnchors, "ignored"), {
num: 12,
label: "Chapter 12",
});
});
// ============================================================
// kagane — reader URLs carry uuids and no chapter number, so the number has to
// come out of og:title. When that fails, chapterNum is null and the existing
// progress logic records the current chapter rather than guessing.
// ============================================================
const KAGANE_SERIES = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b";
const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e";
test("kagane detects a series page", () => {
metaTags = {
"og:title": "Infinite Decryption: The Strongest Level 0",
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
};
const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES));
assert.equal(p.type, "series");
assert.equal(p.site, "kagane");
assert.equal(p.seriesId, KAGANE_SERIES);
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
assert.equal(p.cover, "https://kagane.to/api/v2/image/abc/compressed");
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
});
test("kagane reads the chapter number out of og:title", () => {
metaTags = {
"og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41",
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
};
const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
);
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, KAGANE_SERIES);
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
assert.equal(p.chapterNum, 41);
assert.equal(p.chapterLabel, "Chapter 41");
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
});
test("kagane yields a null chapterNum when og:title has no chapter", () => {
metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" };
const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
);
assert.equal(p.type, "chapter");
assert.equal(p.chapterNum, null);
});
test("kagane ignores unrelated paths", () => {
assert.equal(kagane.detect(loc("https://kagane.to/search")).type, "other");
});
test("kagane anchor scanning is structurally impossible and returns null", () => {
const anchors = anchorsFromHTML(
`<a href="/series/${KAGANE_SERIES}/reader/${KAGANE_BOOK}">Chapter 41</a>`
);
assert.equal(kagane.latestChapterFromAnchors(anchors, KAGANE_SERIES), null);
});
test("kagane latestChapterFromApi takes the max chapter_no", async () => {
globalThis.fetch = async (url) => {
assert.equal(url, "/api/v2/series/" + KAGANE_SERIES);
return {
ok: true,
json: async () => ({
series_books: [
{ chapter_no: "1", title: "Episode 1" },
{ chapter_no: "41", title: "Episode 41" },
{ chapter_no: "40.5", title: "Episode 40.5" },
],
}),
};
};
assert.deepEqual(await kagane.latestChapterFromApi(KAGANE_SERIES), {
num: 41,
label: "Chapter 41",
});
});
test("kagane latestChapterFromApi returns null on a challenge or error", async () => {
globalThis.fetch = async () => ({ ok: false, status: 403 });
assert.equal(await kagane.latestChapterFromApi(KAGANE_SERIES), null);
});
// ============================================================
// Shared helpers
// ============================================================