rebrand: MangaBM → BookmarkManager, add novel library support #15
@@ -44,3 +44,21 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
|
||||
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||
2026-07-28.
|
||||
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
||||
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
||||
`h3.title` (series) or `a.truyen-title` (chapter), cover from
|
||||
`meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint
|
||||
clears, so the backend polls it through the headless browser.
|
||||
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
||||
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
|
||||
title on a series page and `<Title> Chapter <n>` on a chapter page. Chapter
|
||||
pages carry no `og:image`. Its series page lists every chapter with an
|
||||
absolute href, so the backend polls it with the plain TLS client.
|
||||
|
||||
### Second script: `novel-bookmark.user.js`
|
||||
|
||||
A copy of the manga script with two adapters, `LIBRARY = "novel"` and
|
||||
`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous
|
||||
installation to carry keys over from). Installed alongside the manga script;
|
||||
both write to the same backend with the same `LIBRARY` column discriminating
|
||||
them.
|
||||
|
||||
@@ -44,3 +44,21 @@ Guidance for Claude Code working under `userscript/`. See root `CLAUDE.md` for t
|
||||
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||
2026-07-28.
|
||||
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
||||
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
||||
`h3.title` (series) or `a.truyen-title` (chapter), cover from
|
||||
`meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint
|
||||
clears, so the backend polls it through the headless browser.
|
||||
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
||||
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
|
||||
title on a series page and `<Title> Chapter <n>` on a chapter page. Chapter
|
||||
pages carry no `og:image`. Its series page lists every chapter with an
|
||||
absolute href, so the backend polls it with the plain TLS client.
|
||||
|
||||
### Second script: `novel-bookmark.user.js`
|
||||
|
||||
A copy of the manga script with two adapters, `LIBRARY = "novel"` and
|
||||
`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous
|
||||
installation to carry keys over from). Installed alongside the manga script;
|
||||
both write to the same backend with the same `LIBRARY` column discriminating
|
||||
them.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
|
||||
// ============================================================
|
||||
// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...]
|
||||
// branch: novelfull ships no og: tags, so its cover comes from name="image".
|
||||
// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded
|
||||
// that never fires and no network call is ever made.
|
||||
// ============================================================
|
||||
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" };
|
||||
|
||||
let metaTags = {};
|
||||
let namedMetas = {};
|
||||
let elements = {};
|
||||
globalThis.document = {
|
||||
querySelector(sel) {
|
||||
let m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||
if (m) {
|
||||
const v = metaTags[m[1]];
|
||||
return v == null ? null : { getAttribute: () => v };
|
||||
}
|
||||
m = sel.match(/^meta\[name="([^"]+)"\]$/);
|
||||
if (m) {
|
||||
const v = namedMetas[m[1]];
|
||||
return v == null ? null : { getAttribute: () => v };
|
||||
}
|
||||
const text = elements[sel];
|
||||
return text == null ? null : { textContent: text };
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
addEventListener() {},
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
novelfull,
|
||||
lightnovelworld,
|
||||
kindOf,
|
||||
maxChapter,
|
||||
} = require("../novel-bookmark.user.js");
|
||||
|
||||
function loc(href) {
|
||||
const u = new URL(href);
|
||||
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
metaTags = {};
|
||||
namedMetas = {};
|
||||
elements = {};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// novelfull adapter
|
||||
// ============================================================
|
||||
|
||||
test("novelfull.detect reads a series page", () => {
|
||||
reset();
|
||||
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
||||
elements = { "h3.title": "Reverend Insanity" };
|
||||
const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html"));
|
||||
assert.equal(p.type, "series");
|
||||
assert.equal(p.site, "novelfull");
|
||||
assert.equal(p.seriesId, "reverend-insanity");
|
||||
assert.equal(p.title, "Reverend Insanity");
|
||||
assert.equal(p.cover, "https://novelfull.com/uploads/thumbs/ri.jpg");
|
||||
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
||||
assert.equal(p.chapterNum, null);
|
||||
});
|
||||
|
||||
test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => {
|
||||
reset();
|
||||
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
||||
elements = { "a.truyen-title": "Reverend Insanity" };
|
||||
const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html";
|
||||
const p = novelfull.detect(loc(url));
|
||||
assert.equal(p.type, "chapter");
|
||||
assert.equal(p.seriesId, "reverend-insanity");
|
||||
assert.equal(p.chapterNum, 2334);
|
||||
assert.equal(p.chapterLabel, "Chapter 2334");
|
||||
assert.equal(p.chapterUrl, url);
|
||||
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
||||
assert.equal(p.title, "Reverend Insanity");
|
||||
});
|
||||
|
||||
test("novelfull.detect returns other for non-series paths", () => {
|
||||
reset();
|
||||
assert.equal(novelfull.detect(loc("https://novelfull.com/")).type, "other");
|
||||
assert.equal(novelfull.detect(loc("https://novelfull.com/genre/Fantasy")).type, "other");
|
||||
});
|
||||
|
||||
test("novelfull.latestChapterFromAnchors takes the max and ignores other series", () => {
|
||||
const best = novelfull.latestChapterFromAnchors(
|
||||
[
|
||||
{ href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" },
|
||||
{ href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" },
|
||||
{ href: "/reverend-insanity/chapter-2.html", text: "Chapter 2" },
|
||||
{ href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" },
|
||||
],
|
||||
"reverend-insanity"
|
||||
);
|
||||
assert.deepEqual(best, { num: 2334, label: "Chapter 2334" });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// lightnovelworld adapter
|
||||
// ============================================================
|
||||
|
||||
test("lightnovelworld.detect reads a series page", () => {
|
||||
reset();
|
||||
metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" };
|
||||
elements = { "h1.entry-title": "A Will Eternal" };
|
||||
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/"));
|
||||
assert.equal(p.type, "series");
|
||||
assert.equal(p.site, "lightnovelworld");
|
||||
assert.equal(p.seriesId, "a-will-eternal");
|
||||
assert.equal(p.title, "A Will Eternal");
|
||||
assert.equal(p.cover, "https://lightnovelworld.net/wp-content/uploads/awe.webp");
|
||||
});
|
||||
|
||||
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
||||
reset();
|
||||
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
||||
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
|
||||
const p = lightnovelworld.detect(loc(url));
|
||||
assert.equal(p.type, "chapter");
|
||||
assert.equal(p.seriesId, "a-will-eternal");
|
||||
assert.equal(p.chapterNum, 1298);
|
||||
assert.equal(p.chapterLabel, "Chapter 1298");
|
||||
assert.equal(p.title, "A Will Eternal");
|
||||
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
||||
// Chapter pages have no cover; the merge in bookmarkCurrent keeps the stored one.
|
||||
assert.equal(p.cover, "");
|
||||
});
|
||||
|
||||
test("lightnovelworld.detect returns other for non-series paths", () => {
|
||||
reset();
|
||||
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other");
|
||||
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other");
|
||||
});
|
||||
|
||||
test("lightnovelworld.latestChapterFromAnchors takes the max and ignores other series", () => {
|
||||
const best = lightnovelworld.latestChapterFromAnchors(
|
||||
[
|
||||
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" },
|
||||
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" },
|
||||
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" },
|
||||
{ href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" },
|
||||
],
|
||||
"a-will-eternal"
|
||||
);
|
||||
assert.deepEqual(best, { num: 1317, label: "Chapter 1317" });
|
||||
});
|
||||
|
||||
test("latestChapterFromAnchors returns null when nothing matches", () => {
|
||||
assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null);
|
||||
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// kindOf
|
||||
// ============================================================
|
||||
|
||||
test("kindOf defaults a missing kind to manga", () => {
|
||||
assert.equal(kindOf({}), "manga");
|
||||
});
|
||||
|
||||
test("kindOf passes through novel", () => {
|
||||
assert.equal(kindOf({ kind: "novel" }), "novel");
|
||||
});
|
||||
Reference in New Issue
Block a user