feat(userscript): add novel-bookmark.user.js for novelfull and lightnovelworld
This commit is contained in:
@@ -44,3 +44,21 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
|
|||||||
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||||
2026-07-28.
|
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
|
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||||
2026-07-28.
|
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