diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index f8ef6ba..027f44e 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -292,7 +292,86 @@ }, }; - const ADAPTERS = [asura, demonic, comix]; + 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//reader/ — 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/ + 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 " - 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; @@ -1694,7 +1773,7 @@ // Exposes pure logic only — see userscript/test/logic.test.js. // ============================================================ if (typeof window === "undefined" && typeof module === "object" && module.exports) { - module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, anchorsFromHTML, statusOf }; + module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf }; } // ============================================================ diff --git a/userscript/test/logic.test.js b/userscript/test/logic.test.js index 7ed1677..f6b96c3 100644 --- a/userscript/test/logic.test.js +++ b/userscript/test/logic.test.js @@ -57,6 +57,7 @@ const { asura, demonic, comix, + kagane, anchorsFromHTML, statusOf, } = require("../manga-bookmark.user.js"); @@ -274,6 +275,90 @@ test("asura and demonic ignore the seriesId argument", () => { }); }); +// ============================================================ +// 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 // ============================================================