From e392ec3de0e9306e73e0b1ca495cf8edbc3f9abf Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 3 Aug 2026 16:57:14 +0700 Subject: [PATCH] feat(userscript): add comix.to site adapter --- userscript/manga-bookmark.user.js | 88 ++++++++++++++++++++++++++++++- userscript/test/logic.test.js | 69 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 9392de1..9f9b4a1 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -80,6 +80,14 @@ return slug.replace(/-[0-9a-f]{8}$/, ""); } + // comix path segments are "-", 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 +216,83 @@ }, }; - const ADAPTERS = [asura, demonic]; + const comix = { + site: "comix", + matches: (loc) => /(^|\.)comix\.to$/.test(loc.hostname), + detect(loc) { + const path = loc.pathname; + // /title/-/-chapter-. 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/- + 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 " · 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 ADAPTERS = [asura, demonic, comix]; function detect() { const loc = window.location; @@ -1608,7 +1692,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, anchorsFromHTML, statusOf }; } // ============================================================ diff --git a/userscript/test/logic.test.js b/userscript/test/logic.test.js index 00990cf..3f79812 100644 --- a/userscript/test/logic.test.js +++ b/userscript/test/logic.test.js @@ -44,8 +44,10 @@ globalThis.document = { const { stripBuildHash, + comixSeriesId, asura, demonic, + comix, anchorsFromHTML, statusOf, } = require("../manga-bookmark.user.js"); @@ -166,6 +168,73 @@ 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 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); +}); + // ============================================================ // Shared helpers // ============================================================