feat(userscript): add kagane.to site adapter
This commit is contained in:
@@ -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/<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() {
|
function detect() {
|
||||||
const loc = window.location;
|
const loc = window.location;
|
||||||
@@ -1694,7 +1773,7 @@
|
|||||||
// Exposes pure logic only — see userscript/test/logic.test.js.
|
// Exposes pure logic only — see userscript/test/logic.test.js.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const {
|
|||||||
asura,
|
asura,
|
||||||
demonic,
|
demonic,
|
||||||
comix,
|
comix,
|
||||||
|
kagane,
|
||||||
anchorsFromHTML,
|
anchorsFromHTML,
|
||||||
statusOf,
|
statusOf,
|
||||||
} = require("../manga-bookmark.user.js");
|
} = 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
|
// Shared helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user