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:
@@ -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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user