feat(userscript): add comix.to site adapter
This commit is contained in:
@@ -80,6 +80,14 @@
|
|||||||
return slug.replace(/-[0-9a-f]{8}$/, "");
|
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 = {
|
const asura = {
|
||||||
site: "asura",
|
site: "asura",
|
||||||
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
|
// 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/<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 ADAPTERS = [asura, demonic, comix];
|
||||||
|
|
||||||
function detect() {
|
function detect() {
|
||||||
const loc = window.location;
|
const loc = window.location;
|
||||||
@@ -1608,7 +1692,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, asura, demonic, anchorsFromHTML, statusOf };
|
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, anchorsFromHTML, statusOf };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ globalThis.document = {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
stripBuildHash,
|
stripBuildHash,
|
||||||
|
comixSeriesId,
|
||||||
asura,
|
asura,
|
||||||
demonic,
|
demonic,
|
||||||
|
comix,
|
||||||
anchorsFromHTML,
|
anchorsFromHTML,
|
||||||
statusOf,
|
statusOf,
|
||||||
} = require("../manga-bookmark.user.js");
|
} = 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" });
|
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
|
// Shared helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user