Closes #60. Spec: #55. Originating bug: #47. Architecture: `docs/adr/0007-backend-hosts-cover-bytes.md`. Neither #47 nor #55 is closed from here. ## What this branch does The panel now renders Covers from the deployment's own origin, and both userscripts stop having an opinion about where a Cover lives. **The public route was already in place.** `GET /covers/{address}` landed with #59 (`92eba07`) and is registered on the bare mux, outside `httpmw.Auth` and outside the web UI's Discord session — `backend/main.go:210-214`, handler `backend/internal/api/handlers.go:142-158`. It reads no cookie and no header, answers `404` for an address that was never stored (and for a row whose file has gone missing — recorded-but-gone is not-found, never a fabricated body), refuses anything that is not `^[0-9a-f]{64}$` *before* the value becomes a path, and sets `Cache-Control: public, max-age=604800, immutable`. Those four properties are asserted by `backend/cover_test.go:231-278`. This branch re-verified them rather than re-implementing them; the only backend line it touches is a comment. **Both userscripts lose cover scraping entirely.** Every adapter's `cover:` field is gone, along with the two helpers that fed them: the manga script's `coverFromPage()` (the `img[alt]` DOM scan comix needed, because comix publishes no `og:image`) and the novel script's `metaName()` plus the now-callerless module-level `meta()`. Nothing under `userscript/` reads `og:image`, `meta[name=image]`, or `img[alt]` any more. **Nothing sends a cover either.** `delete body.cover` sits in `apiPut` — `manga-bookmark.user.js:486`, `novel-bookmark.user.js:275` — which is the single chokepoint every write passes through (`pushBookmark`, the retry-queue flush, `toggleFavorite`, `toggleArchive`). It operates on the `Object.assign` copy, so the in-memory row keeps the cover it renders with. This matters beyond tidiness: a Reader upgrading from an older copy has `localStorage` rows carrying third-party scraped URLs, and without the strip those would ride back up on the next write. The handler discards the field regardless (`handlers.go:53-59`) — it is permanently inert, not pending removal. **Failed loads get the designed empty state, not the broken-image glyph.** `onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" }))` on the cover `<img>` in both card renderers (`manga:1380-1390`, `novel:1134-1144`). The replacement is byte-identical to the existing no-cover branch on the very next line, so it picks up the `.cover.ph` styling already in the panel CSS — no new tokens, no new rule. `el()` routes any `on*` prop through `addEventListener`, so this is a listener, not an inline attribute string, and the swap is a `createElement` + DOM call with no markup parsing anywhere near it. This is the half of #47 that was visible on kagane. **The deleted scraping's tests went with it**: the two comix cover cases, the `pageImages` and `namedMetas` fixtures, the `img[alt]` and `meta[name=...]` stub branches, the now-dead `querySelectorAll` stub member, and every stale `og:image` fixture and `p.cover` assertion across both suites. The export lists needed no change and that was checked, not assumed — `coverFromPage` and `metaName` were module-private on `origin/main` and no cover symbol ever appeared in `module.exports`. Docs that described the deleted behaviour were corrected in the same breath, because leaving them would instruct the next agent to put the scraping back: `userscript/AGENTS.md` (adapter contract + the per-site notes for comix, kagane and novelfull), the README's adapter reference, and the userscript testing skill's stub table. ## Verification - `go test -count=1 ./...` — green across all nine packages (`backend` 29.8s, `latest`, `store`, `session`, `token`, `userscript`, `web`). - `node --check` clean on both userscripts; `node --test` on both logic suites — 46 tests, 46 pass. - `gofmt -l` clean; `go build ./...` clean. - The `onerror` swap is DOM behaviour and deliberately has no coverage in the Node harness — that harness stubs a browser precisely so it never needs a DOM, and #60 says not to invent coverage for it. It was instead exercised for real: the `el()` helper and the exact render expression were loaded into a headless Chromium with a deliberately unloadable `src`, and the resulting DOM was `<div class="cover ph"></div>`. Ad hoc, not committed. - **Not done, needs you:** the on-device criterion — a comix Series bookmarked mid-chapter showing its Cover in the panel. That needs a real install against the deployment and is the one box left unticked on #60. ## Reviewed Both `/code-review` axes ran against `cc0fa92`. Spec found no missed requirement and no scope creep; standards found the diff clean on the four areas it scrutinised (the `delete body.cover` placement, the `onerror` handler's DOM safety, comment quality, dead-code removal). Their combined findings — the dead `querySelectorAll` stub, the stale README and skill text, and the handler comment whose premise this change invalidates — are fixed in `8b58019`. ## Out of scope, deliberately The kagane-specific cover proxy still exists and still carries its session gate (#63 deletes it). The poll's blank-Cover fill (#61) and browser-backed Sites joining the pipeline (#62) are untouched. Reviewed-on: #69 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #69.
This commit is contained in:
@@ -14,7 +14,7 @@ parsers, helpers. UI, network, and storage behaviour are verified on-device.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --check userscript/manga-bookmark.user.js # parse check, silent on success
|
node --check userscript/manga-bookmark.user.js # parse check, silent on success
|
||||||
node --test userscript/test/logic.test.js # 14 tests as of 2026-07-28
|
node --test userscript/test/logic.test.js # 35 tests as of 2026-08-10
|
||||||
```
|
```
|
||||||
|
|
||||||
Run both before every commit that touches the userscript.
|
Run both before every commit that touches the userscript.
|
||||||
@@ -31,7 +31,7 @@ The test file installs four globals **before** requiring the userscript:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `localStorage` | `Map`-backed stub | `loadCache`, `loadQueue`, and the key-migration IIFE touch it at module scope |
|
| `localStorage` | `Map`-backed stub | `loadCache`, `loadQueue`, and the key-migration IIFE touch it at module scope |
|
||||||
| `location` | `{href, hostname, pathname, origin}` | read during boot |
|
| `location` | `{href, hostname, pathname, origin}` | read during boot |
|
||||||
| `document` | `querySelector` for `meta[property="…"]` only, plus a no-op `addEventListener` | adapters read `og:title`/`og:image` |
|
| `document` | `querySelector` for `meta[property="…"]` only, plus a no-op `addEventListener` | adapters read `og:title` (covers are the backend's, never scraped) |
|
||||||
| `document.body` | **left `undefined`** | this is the whole trick |
|
| `document.body` | **left `undefined`** | this is the whole trick |
|
||||||
|
|
||||||
`document.body === undefined` sends the userscript's boot block down its `else`
|
`document.body === undefined` sends the userscript's boot block down its `else`
|
||||||
|
|||||||
@@ -261,8 +261,10 @@ an API.
|
|||||||
|
|
||||||
## Adapter reference (verified live 2026-07-24)
|
## Adapter reference (verified live 2026-07-24)
|
||||||
|
|
||||||
The site adapters key everything off URL regex, with `title`/`cover` from
|
The site adapters key everything off URL regex, with `title` from `og:title`
|
||||||
`og:title` / `og:image`. Confirmed against live pages via Playwright:
|
(or the page's own heading where a site ships none). No adapter reads a cover:
|
||||||
|
the backend acquires, stores and serves every Cover from its own origin
|
||||||
|
(ADR-0007). Confirmed against live pages via Playwright:
|
||||||
|
|
||||||
| Site | Series URL | Chapter URL | `series_id` |
|
| Site | Series URL | Chapter URL | `series_id` |
|
||||||
|------|-----------|-------------|-------------|
|
|------|-----------|-------------|-------------|
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A body may carry a cover, and it is discarded here rather than
|
// A body may carry a cover, and it is discarded here rather than
|
||||||
// rejected: every installed userscript still sends one, and ADR-0004's
|
// rejected: an older installed userscript may still send one, and
|
||||||
// compatibility argument depends on those scripts continuing to work. The
|
// ADR-0004's compatibility argument depends on those scripts continuing
|
||||||
// Cover is acquired server-side (ADR-0007), so the field is permanently
|
// to work. The Cover is acquired server-side (ADR-0007), so the field is
|
||||||
// inert - not pending removal, and not a value any later code should
|
// permanently inert - not pending removal, and not a value any later code
|
||||||
// start reading.
|
// should start reading.
|
||||||
b.Cover = ""
|
b.Cover = ""
|
||||||
|
|
||||||
// Path key is authoritative; derive site/series_id from it when the body
|
// Path key is authoritative; derive site/series_id from it when the body
|
||||||
|
|||||||
+11
-10
@@ -2,7 +2,7 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
|
|||||||
|
|
||||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||||
|
|
||||||
1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title` from **`og:title`** (or the page heading where a site ships no og: tags), not CSS classes. **No adapter reads a cover**: the backend acquires, stores and serves every Cover from its own origin (ADR-0007), the wire's `cover` is already an address on our origin, and `apiPut` strips any `cover` off an outgoing body.
|
||||||
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `bmgr:manga:cache` for instant render + offline fallback.
|
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `bmgr:manga:cache` for instant render + offline fallback.
|
||||||
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
||||||
4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
|
4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
|
||||||
@@ -52,26 +52,27 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
|
|||||||
"Comix — Read Comics online for free" and after an in-page hop it is the
|
"Comix — Read Comics online for free" and after an in-page hop it is the
|
||||||
*previous* series' name. `document.title` is the one thing client routing does
|
*previous* series' name. `document.title` is the one thing client routing does
|
||||||
update, so titles come from there, with the chapter page's `" · Ch.<n>"` tail
|
update, so titles come from there, with the chapter page's `" · Ch.<n>"` tail
|
||||||
stripped. Covers likewise: `og:image` is absent, so the cover is the `img`
|
stripped. It publishes no `og:image` either, which is one of the reasons cover
|
||||||
whose `alt` matches the cleaned title — verified live 2026-08-08.
|
acquisition moved to the backend.
|
||||||
- **kagane.to**: series `/series/<uuid>`, reader
|
- **kagane.to**: series `/series/<uuid>`, reader
|
||||||
`/series/<uuid>/reader/<bookUuid>`. Reader URLs carry no chapter number, so
|
`/series/<uuid>/reader/<bookUuid>`. Reader URLs carry no chapter number, so
|
||||||
the number comes out of `og:title`. Two shapes exist: `"<Series> - Chapter
|
the number comes out of `og:title`. Two shapes exist: `"<Series> - Chapter
|
||||||
<n>[ - Episode <n>]"` and, for volume-numbered series, `"<Series> - Volume <v>
|
<n>[ - Episode <n>]"` and, for volume-numbered series, `"<Series> - Volume <v>
|
||||||
Chapter <n>"` with no episode name — both must yield a bare series title, or
|
Chapter <n>"` with no episode name — both must yield a bare series title, or
|
||||||
the volume tail lands in the bookmark's title. Its covers are challenge- and
|
the volume tail lands in the bookmark's title.
|
||||||
CORP-protected, so the web UI proxies them; the userscript still stores the
|
Its covers are challenge- and CORP-protected, so nothing outside kagane.to can
|
||||||
raw `og:image`. Behind a Cloudflare JS challenge, so the backend polls it
|
load one directly; the panel renders the backend's own cover address like every
|
||||||
|
other Site. Behind a Cloudflare JS challenge, so the backend polls it
|
||||||
through the headless browser.
|
through the headless browser.
|
||||||
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
||||||
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
||||||
`h3.title` (series) or `a.truyen-title` (chapter), cover from
|
`h3.title` (series) or `a.truyen-title` (chapter); the script reads no cover.
|
||||||
`meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint
|
Behind a Cloudflare JS challenge no TLS fingerprint
|
||||||
clears, so the backend polls it through the headless browser.
|
clears, so the backend polls it through the headless browser.
|
||||||
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
||||||
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
|
`/<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
|
title on a series page and `<Title> Chapter <n>` on a chapter page. Its series
|
||||||
pages carry no `og:image`. Its series page lists every chapter with an
|
page lists every chapter with an
|
||||||
absolute href, so the backend polls it with the plain TLS client.
|
absolute href, so the backend polls it with the plain TLS client.
|
||||||
|
|
||||||
### Second script: `novel-bookmark.user.js`
|
### Second script: `novel-bookmark.user.js`
|
||||||
|
|||||||
@@ -56,9 +56,10 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Site adapters
|
// Site adapters
|
||||||
//
|
//
|
||||||
// Page type + IDs come from URL regex (most stable); title/cover come from
|
// Page type + IDs come from URL regex (most stable); the title comes from
|
||||||
// og: meta tags. Verified live 2026-07-24 against asurascans.com and
|
// og: meta tags. Covers are never read here: the backend acquires and serves
|
||||||
// demonicscans.org — see README "Adapter reference".
|
// them itself (ADR-0007). Verified live 2026-07-24 against asurascans.com
|
||||||
|
// and demonicscans.org — see README "Adapter reference".
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function meta(prop) {
|
function meta(prop) {
|
||||||
@@ -128,7 +129,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: stripBuildHash(m[1]),
|
seriesId: stripBuildHash(m[1]),
|
||||||
title: cleanTitle(meta("og:title")),
|
title: cleanTitle(meta("og:title")),
|
||||||
cover: meta("og:image") || "",
|
|
||||||
seriesUrl: loc.origin + "/comics/" + m[1],
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
||||||
chapterLabel: "Chapter " + m[2],
|
chapterLabel: "Chapter " + m[2],
|
||||||
chapterNum: isNaN(num) ? null : num,
|
chapterNum: isNaN(num) ? null : num,
|
||||||
@@ -143,7 +143,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: stripBuildHash(m[1]),
|
seriesId: stripBuildHash(m[1]),
|
||||||
title: cleanTitle(meta("og:title")),
|
title: cleanTitle(meta("og:title")),
|
||||||
cover: meta("og:image") || "",
|
|
||||||
seriesUrl: loc.origin + "/comics/" + m[1],
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
||||||
chapterLabel: null,
|
chapterLabel: null,
|
||||||
chapterNum: null,
|
chapterNum: null,
|
||||||
@@ -192,7 +191,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: decodeURIComponent(m[1]),
|
seriesId: decodeURIComponent(m[1]),
|
||||||
title: cleanTitle(meta("og:title")),
|
title: cleanTitle(meta("og:title")),
|
||||||
cover: meta("og:image") || "",
|
|
||||||
seriesUrl: loc.origin + "/manga/" + m[1],
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
||||||
chapterLabel: "Chapter " + m[2],
|
chapterLabel: "Chapter " + m[2],
|
||||||
chapterNum: isNaN(num) ? null : num,
|
chapterNum: isNaN(num) ? null : num,
|
||||||
@@ -207,7 +205,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: decodeURIComponent(m[1]),
|
seriesId: decodeURIComponent(m[1]),
|
||||||
title: cleanTitle(meta("og:title")),
|
title: cleanTitle(meta("og:title")),
|
||||||
cover: meta("og:image") || "",
|
|
||||||
seriesUrl: loc.origin + "/manga/" + m[1],
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
||||||
chapterLabel: null,
|
chapterLabel: null,
|
||||||
chapterNum: null,
|
chapterNum: null,
|
||||||
@@ -259,7 +256,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: comixSeriesId(m[1]),
|
seriesId: comixSeriesId(m[1]),
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
cover: coverFromPage(pageTitle),
|
|
||||||
seriesUrl: loc.origin + "/title/" + m[1],
|
seriesUrl: loc.origin + "/title/" + m[1],
|
||||||
chapterLabel: "Chapter " + m[2],
|
chapterLabel: "Chapter " + m[2],
|
||||||
chapterNum: isNaN(num) ? null : num,
|
chapterNum: isNaN(num) ? null : num,
|
||||||
@@ -274,7 +270,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: comixSeriesId(m[1]),
|
seriesId: comixSeriesId(m[1]),
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
cover: coverFromPage(pageTitle),
|
|
||||||
seriesUrl: loc.origin + "/title/" + m[1],
|
seriesUrl: loc.origin + "/title/" + m[1],
|
||||||
chapterLabel: null,
|
chapterLabel: null,
|
||||||
chapterNum: null,
|
chapterNum: null,
|
||||||
@@ -289,17 +284,6 @@
|
|||||||
return t.replace(/\s*·\s*Ch\.[\d.]+\s*$/i, "").trim();
|
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(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
|
// 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
|
// cannot win the maximum. seriesId is passed in because the anchors alone
|
||||||
@@ -342,7 +326,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: m[1],
|
seriesId: m[1],
|
||||||
title: cleanTitle(meta("og:title")),
|
title: cleanTitle(meta("og:title")),
|
||||||
cover: meta("og:image") || "",
|
|
||||||
seriesUrl: loc.origin + "/series/" + m[1],
|
seriesUrl: loc.origin + "/series/" + m[1],
|
||||||
chapterLabel: num === null ? null : "Chapter " + num,
|
chapterLabel: num === null ? null : "Chapter " + num,
|
||||||
chapterNum: num,
|
chapterNum: num,
|
||||||
@@ -357,7 +340,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: m[1],
|
seriesId: m[1],
|
||||||
title: cleanTitle(meta("og:title")),
|
title: cleanTitle(meta("og:title")),
|
||||||
cover: meta("og:image") || "",
|
|
||||||
seriesUrl: loc.origin + "/series/" + m[1],
|
seriesUrl: loc.origin + "/series/" + m[1],
|
||||||
chapterLabel: null,
|
chapterLabel: null,
|
||||||
chapterNum: null,
|
chapterNum: null,
|
||||||
@@ -498,6 +480,10 @@
|
|||||||
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
||||||
const body = Object.assign({}, obj);
|
const body = Object.assign({}, obj);
|
||||||
if (!sendStatus) delete body.status;
|
if (!sendStatus) delete body.status;
|
||||||
|
// Covers belong to the backend, which acquires and serves them itself
|
||||||
|
// (ADR-0007) and ignores an incoming one; a third-party address must never
|
||||||
|
// go back on the wire.
|
||||||
|
delete body.cover;
|
||||||
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
@@ -855,7 +841,6 @@
|
|||||||
series_id: p.seriesId,
|
series_id: p.seriesId,
|
||||||
title: p.title || (existing && existing.title) || p.seriesId,
|
title: p.title || (existing && existing.title) || p.seriesId,
|
||||||
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
||||||
cover: p.cover || (existing && existing.cover) || "",
|
|
||||||
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
|
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
|
||||||
last_chapter_num:
|
last_chapter_num:
|
||||||
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
|
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
|
||||||
@@ -878,7 +863,6 @@
|
|||||||
series_id: p.seriesId,
|
series_id: p.seriesId,
|
||||||
title: existing.title || p.title || p.seriesId,
|
title: existing.title || p.title || p.seriesId,
|
||||||
series_url: existing.series_url || p.seriesUrl || "",
|
series_url: existing.series_url || p.seriesUrl || "",
|
||||||
cover: existing.cover || p.cover || "",
|
|
||||||
last_chapter: p.chapterLabel || existing.last_chapter || "",
|
last_chapter: p.chapterLabel || existing.last_chapter || "",
|
||||||
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
||||||
last_chapter_url: p.chapterUrl || "",
|
last_chapter_url: p.chapterUrl || "",
|
||||||
@@ -1394,7 +1378,15 @@
|
|||||||
return el("div", { class: "item" + heat }, [
|
return el("div", { class: "item" + heat }, [
|
||||||
el("a", { class: "go", href: cont }, [
|
el("a", { class: "go", href: cont }, [
|
||||||
b.cover
|
b.cover
|
||||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
? el("img", {
|
||||||
|
class: "cover",
|
||||||
|
src: b.cover,
|
||||||
|
loading: "lazy",
|
||||||
|
alt: "",
|
||||||
|
// A Cover that will not load shows the designed placeholder
|
||||||
|
// rather than the browser's broken-image glyph (#47).
|
||||||
|
onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" })),
|
||||||
|
})
|
||||||
: el("div", { class: "cover ph" }),
|
: el("div", { class: "cover ph" }),
|
||||||
]),
|
]),
|
||||||
el("div", { class: "meta" }, [
|
el("div", { class: "meta" }, [
|
||||||
|
|||||||
@@ -33,16 +33,11 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Site adapters
|
// Site adapters
|
||||||
//
|
//
|
||||||
// Page type + IDs come from URL regex (most stable); title/cover come from
|
// Page type + IDs come from URL regex (most stable); the title comes from
|
||||||
// og: meta tags (with the novelfull name= meta as the exception).
|
// og: meta tags or the page's own heading. Covers are never read here: the
|
||||||
|
// backend acquires and serves them itself (ADR-0007).
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
|
||||||
function meta(prop) {
|
|
||||||
const el = document.querySelector('meta[property="' + prop + '"]');
|
|
||||||
return el ? el.getAttribute("content") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapter lists are read from two places: the page we are standing on, and
|
// Chapter lists are read from two places: the page we are standing on, and
|
||||||
// series pages fetched in the background. Both are reduced to {href, text}
|
// series pages fetched in the background. Both are reduced to {href, text}
|
||||||
// pairs so each adapter needs only one rule for picking the latest chapter.
|
// pairs so each adapter needs only one rule for picking the latest chapter.
|
||||||
@@ -66,12 +61,6 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// novelfull ships no og: tags at all — its cover lives on a name= meta.
|
|
||||||
function metaName(name) {
|
|
||||||
const el = document.querySelector('meta[name="' + name + '"]');
|
|
||||||
return el ? el.getAttribute("content") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRe(s) {
|
function escapeRe(s) {
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
@@ -108,7 +97,6 @@
|
|||||||
// h3.title on a chapter page is the *chapter's* title; the breadcrumb
|
// h3.title on a chapter page is the *chapter's* title; the breadcrumb
|
||||||
// link back to the series page carries the series name.
|
// link back to the series page carries the series name.
|
||||||
title: back ? (back.textContent || "").trim() : "",
|
title: back ? (back.textContent || "").trim() : "",
|
||||||
cover: metaName("image") || "",
|
|
||||||
seriesUrl: loc.origin + "/" + m[1] + ".html",
|
seriesUrl: loc.origin + "/" + m[1] + ".html",
|
||||||
chapterLabel: "Chapter " + m[2],
|
chapterLabel: "Chapter " + m[2],
|
||||||
chapterNum: isNaN(num) ? null : num,
|
chapterNum: isNaN(num) ? null : num,
|
||||||
@@ -124,7 +112,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: m[1],
|
seriesId: m[1],
|
||||||
title: h3 ? (h3.textContent || "").trim() : "",
|
title: h3 ? (h3.textContent || "").trim() : "",
|
||||||
cover: metaName("image") || "",
|
|
||||||
seriesUrl: loc.origin + "/" + m[1] + ".html",
|
seriesUrl: loc.origin + "/" + m[1] + ".html",
|
||||||
chapterLabel: null,
|
chapterLabel: null,
|
||||||
chapterNum: null,
|
chapterNum: null,
|
||||||
@@ -157,9 +144,6 @@
|
|||||||
seriesId: m[1],
|
seriesId: m[1],
|
||||||
// The heading is "<Series> Chapter <n>"; drop the suffix.
|
// The heading is "<Series> Chapter <n>"; drop the suffix.
|
||||||
title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(),
|
title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(),
|
||||||
// Chapter pages carry no og:image. Empty is safe: every write merges
|
|
||||||
// against the cached row, which keeps the cover the series page gave.
|
|
||||||
cover: "",
|
|
||||||
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
||||||
chapterLabel: "Chapter " + m[2],
|
chapterLabel: "Chapter " + m[2],
|
||||||
chapterNum: isNaN(num) ? null : num,
|
chapterNum: isNaN(num) ? null : num,
|
||||||
@@ -175,7 +159,6 @@
|
|||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: m[1],
|
seriesId: m[1],
|
||||||
title: h1 ? (h1.textContent || "").trim() : "",
|
title: h1 ? (h1.textContent || "").trim() : "",
|
||||||
cover: meta("og:image") || "",
|
|
||||||
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
||||||
chapterLabel: null,
|
chapterLabel: null,
|
||||||
chapterNum: null,
|
chapterNum: null,
|
||||||
@@ -286,6 +269,10 @@
|
|||||||
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
||||||
const body = Object.assign({}, obj);
|
const body = Object.assign({}, obj);
|
||||||
if (!sendStatus) delete body.status;
|
if (!sendStatus) delete body.status;
|
||||||
|
// Covers belong to the backend, which acquires and serves them itself
|
||||||
|
// (ADR-0007) and ignores an incoming one; a third-party address must never
|
||||||
|
// go back on the wire.
|
||||||
|
delete body.cover;
|
||||||
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
@@ -613,7 +600,6 @@
|
|||||||
series_id: p.seriesId,
|
series_id: p.seriesId,
|
||||||
title: p.title || (existing && existing.title) || p.seriesId,
|
title: p.title || (existing && existing.title) || p.seriesId,
|
||||||
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
||||||
cover: p.cover || (existing && existing.cover) || "",
|
|
||||||
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
|
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
|
||||||
last_chapter_num:
|
last_chapter_num:
|
||||||
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
|
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
|
||||||
@@ -636,7 +622,6 @@
|
|||||||
series_id: p.seriesId,
|
series_id: p.seriesId,
|
||||||
title: existing.title || p.title || p.seriesId,
|
title: existing.title || p.title || p.seriesId,
|
||||||
series_url: existing.series_url || p.seriesUrl || "",
|
series_url: existing.series_url || p.seriesUrl || "",
|
||||||
cover: existing.cover || p.cover || "",
|
|
||||||
last_chapter: p.chapterLabel || existing.last_chapter || "",
|
last_chapter: p.chapterLabel || existing.last_chapter || "",
|
||||||
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
||||||
last_chapter_url: p.chapterUrl || "",
|
last_chapter_url: p.chapterUrl || "",
|
||||||
@@ -1147,7 +1132,15 @@
|
|||||||
return el("div", { class: "item" + heat }, [
|
return el("div", { class: "item" + heat }, [
|
||||||
el("a", { class: "go", href: cont }, [
|
el("a", { class: "go", href: cont }, [
|
||||||
b.cover
|
b.cover
|
||||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
? el("img", {
|
||||||
|
class: "cover",
|
||||||
|
src: b.cover,
|
||||||
|
loading: "lazy",
|
||||||
|
alt: "",
|
||||||
|
// A Cover that will not load shows the designed placeholder
|
||||||
|
// rather than the browser's broken-image glyph (#47).
|
||||||
|
onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" })),
|
||||||
|
})
|
||||||
: el("div", { class: "cover ph" }),
|
: el("div", { class: "cover ph" }),
|
||||||
]),
|
]),
|
||||||
el("div", { class: "meta" }, [
|
el("div", { class: "meta" }, [
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ let metaTags = {};
|
|||||||
// document.title. comix's SPA rewrites this on client routing but never
|
// document.title. comix's SPA rewrites this on client routing but never
|
||||||
// og:title, so the comix adapter reads it instead. Reassigned per test.
|
// og:title, so the comix adapter reads it instead. Reassigned per test.
|
||||||
let docTitle = "";
|
let docTitle = "";
|
||||||
// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each
|
|
||||||
// entry is {alt, src}.
|
|
||||||
let pageImages = [];
|
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||||
@@ -44,12 +41,6 @@ globalThis.document = {
|
|||||||
const v = metaTags[m[1]];
|
const v = metaTags[m[1]];
|
||||||
return v == null ? null : { getAttribute: () => v };
|
return v == null ? null : { getAttribute: () => v };
|
||||||
},
|
},
|
||||||
querySelectorAll(sel) {
|
|
||||||
if (sel !== "img[alt]") return [];
|
|
||||||
return pageImages.map((img) => ({
|
|
||||||
getAttribute: (attr) => img[attr] ?? null,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
get title() {
|
get title() {
|
||||||
return docTitle;
|
return docTitle;
|
||||||
@@ -100,7 +91,7 @@ test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
test("asura.detect reads a series page, stripping the hash from the id only", () => {
|
test("asura.detect reads a series page, stripping the hash from the id only", () => {
|
||||||
metaTags = { "og:title": "Solo Leveling | Asura Scans", "og:image": "https://cdn.example/x.jpg" };
|
metaTags = { "og:title": "Solo Leveling | Asura Scans" };
|
||||||
const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
|
const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
|
||||||
assert.equal(p.type, "series");
|
assert.equal(p.type, "series");
|
||||||
assert.equal(p.site, "asura");
|
assert.equal(p.site, "asura");
|
||||||
@@ -108,12 +99,11 @@ test("asura.detect reads a series page, stripping the hash from the id only", ()
|
|||||||
// seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
|
// seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
|
||||||
assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
|
assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
|
||||||
assert.equal(p.title, "Solo Leveling");
|
assert.equal(p.title, "Solo Leveling");
|
||||||
assert.equal(p.cover, "https://cdn.example/x.jpg");
|
|
||||||
assert.equal(p.chapterNum, null);
|
assert.equal(p.chapterNum, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("asura.detect reads a chapter page including a decimal number", () => {
|
test("asura.detect reads a chapter page including a decimal number", () => {
|
||||||
metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans", "og:image": "" };
|
metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans" };
|
||||||
const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
|
const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
|
||||||
const p = asura.detect(loc(url));
|
const p = asura.detect(loc(url));
|
||||||
assert.equal(p.type, "chapter");
|
assert.equal(p.type, "chapter");
|
||||||
@@ -150,7 +140,7 @@ test("asura.latestChapterFromAnchors returns null when nothing matches", () => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
test("demonic.detect reads a series page", () => {
|
test("demonic.detect reads a series page", () => {
|
||||||
metaTags = { "og:title": "The World After The Fall", "og:image": "https://cdn.example/y.jpg" };
|
metaTags = { "og:title": "The World After The Fall" };
|
||||||
const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
|
const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
|
||||||
assert.equal(p.type, "series");
|
assert.equal(p.type, "series");
|
||||||
assert.equal(p.site, "demonic");
|
assert.equal(p.site, "demonic");
|
||||||
@@ -159,7 +149,7 @@ test("demonic.detect reads a series page", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
|
test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
|
||||||
metaTags = { "og:title": "The World After The Fall Chapter 3", "og:image": "" };
|
metaTags = { "og:title": "The World After The Fall Chapter 3" };
|
||||||
const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
|
const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
|
||||||
assert.equal(p.type, "chapter");
|
assert.equal(p.type, "chapter");
|
||||||
assert.equal(p.chapterNum, 3);
|
assert.equal(p.chapterNum, 3);
|
||||||
@@ -247,26 +237,6 @@ test("comix parses decimal chapter numbers", () => {
|
|||||||
assert.equal(p.chapterNum, 80.5);
|
assert.equal(p.chapterNum, 80.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("comix.detect reads the cover from an img whose alt matches the cleaned title", () => {
|
|
||||||
metaTags = { "og:title": COMIX_STALE_HOME };
|
|
||||||
docTitle = "Dungeons and Crayons";
|
|
||||||
pageImages = [
|
|
||||||
{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" },
|
|
||||||
{ alt: "Dungeons and Crayons", src: "https://cdn.example/cover.jpg" },
|
|
||||||
];
|
|
||||||
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
|
|
||||||
assert.equal(p.cover, "https://cdn.example/cover.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("comix.detect leaves cover empty when no img alt matches the title", () => {
|
|
||||||
metaTags = {};
|
|
||||||
docTitle = "Dungeons and Crayons";
|
|
||||||
pageImages = [{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" }];
|
|
||||||
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
|
|
||||||
assert.equal(p.cover, "");
|
|
||||||
pageImages = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
test("comix ignores unrelated paths", () => {
|
test("comix ignores unrelated paths", () => {
|
||||||
assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other");
|
assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other");
|
||||||
});
|
});
|
||||||
@@ -308,23 +278,18 @@ const KAGANE_SERIES = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b";
|
|||||||
const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e";
|
const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e";
|
||||||
|
|
||||||
test("kagane detects a series page", () => {
|
test("kagane detects a series page", () => {
|
||||||
metaTags = {
|
metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" };
|
||||||
"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));
|
const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES));
|
||||||
assert.equal(p.type, "series");
|
assert.equal(p.type, "series");
|
||||||
assert.equal(p.site, "kagane");
|
assert.equal(p.site, "kagane");
|
||||||
assert.equal(p.seriesId, KAGANE_SERIES);
|
assert.equal(p.seriesId, KAGANE_SERIES);
|
||||||
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
|
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);
|
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("kagane reads the chapter number out of og:title", () => {
|
test("kagane reads the chapter number out of og:title", () => {
|
||||||
metaTags = {
|
metaTags = {
|
||||||
"og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41",
|
"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(
|
const p = kagane.detect(
|
||||||
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
||||||
@@ -341,10 +306,7 @@ test("kagane reads the chapter number out of og:title", () => {
|
|||||||
// no episode name, because the book carries volume_no and an empty title.
|
// no episode name, because the book carries volume_no and an empty title.
|
||||||
// Captured live 2026-08-08 from SP Baby.
|
// Captured live 2026-08-08 from SP Baby.
|
||||||
test("kagane reads through a Volume-numbered chapter suffix", () => {
|
test("kagane reads through a Volume-numbered chapter suffix", () => {
|
||||||
metaTags = {
|
metaTags = { "og:title": "SP Baby - Volume 1 Chapter 1" };
|
||||||
"og:title": "SP Baby - Volume 1 Chapter 1",
|
|
||||||
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
|
||||||
};
|
|
||||||
const p = kagane.detect(
|
const p = kagane.detect(
|
||||||
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ const test = require("node:test");
|
|||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...]
|
// Minimal browser stub. Same shape as logic.test.js.
|
||||||
// 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
|
// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded
|
||||||
// that never fires and no network call is ever made.
|
// that never fires and no network call is ever made.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -20,20 +19,14 @@ globalThis.localStorage = {
|
|||||||
globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" };
|
globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" };
|
||||||
|
|
||||||
let metaTags = {};
|
let metaTags = {};
|
||||||
let namedMetas = {};
|
|
||||||
let elements = {};
|
let elements = {};
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
let m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const v = metaTags[m[1]];
|
const v = metaTags[m[1]];
|
||||||
return v == null ? null : { getAttribute: () => v };
|
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];
|
const text = elements[sel];
|
||||||
return text == null ? null : { textContent: text };
|
return text == null ? null : { textContent: text };
|
||||||
},
|
},
|
||||||
@@ -58,7 +51,6 @@ function loc(href) {
|
|||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
metaTags = {};
|
metaTags = {};
|
||||||
namedMetas = {};
|
|
||||||
elements = {};
|
elements = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,21 +60,18 @@ function reset() {
|
|||||||
|
|
||||||
test("novelfull.detect reads a series page", () => {
|
test("novelfull.detect reads a series page", () => {
|
||||||
reset();
|
reset();
|
||||||
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
|
||||||
elements = { "h3.title": "Reverend Insanity" };
|
elements = { "h3.title": "Reverend Insanity" };
|
||||||
const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html"));
|
const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html"));
|
||||||
assert.equal(p.type, "series");
|
assert.equal(p.type, "series");
|
||||||
assert.equal(p.site, "novelfull");
|
assert.equal(p.site, "novelfull");
|
||||||
assert.equal(p.seriesId, "reverend-insanity");
|
assert.equal(p.seriesId, "reverend-insanity");
|
||||||
assert.equal(p.title, "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.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
||||||
assert.equal(p.chapterNum, null);
|
assert.equal(p.chapterNum, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => {
|
test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => {
|
||||||
reset();
|
reset();
|
||||||
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
|
||||||
elements = { "a.truyen-title": "Reverend Insanity" };
|
elements = { "a.truyen-title": "Reverend Insanity" };
|
||||||
const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html";
|
const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html";
|
||||||
const p = novelfull.detect(loc(url));
|
const p = novelfull.detect(loc(url));
|
||||||
@@ -120,14 +109,12 @@ test("novelfull.latestChapterFromAnchors takes the max and ignores other series"
|
|||||||
|
|
||||||
test("lightnovelworld.detect reads a series page", () => {
|
test("lightnovelworld.detect reads a series page", () => {
|
||||||
reset();
|
reset();
|
||||||
metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" };
|
|
||||||
elements = { "h1.entry-title": "A Will Eternal" };
|
elements = { "h1.entry-title": "A Will Eternal" };
|
||||||
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/"));
|
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/"));
|
||||||
assert.equal(p.type, "series");
|
assert.equal(p.type, "series");
|
||||||
assert.equal(p.site, "lightnovelworld");
|
assert.equal(p.site, "lightnovelworld");
|
||||||
assert.equal(p.seriesId, "a-will-eternal");
|
assert.equal(p.seriesId, "a-will-eternal");
|
||||||
assert.equal(p.title, "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", () => {
|
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
||||||
@@ -141,8 +128,6 @@ test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
|||||||
assert.equal(p.chapterLabel, "Chapter 1298");
|
assert.equal(p.chapterLabel, "Chapter 1298");
|
||||||
assert.equal(p.title, "A Will Eternal");
|
assert.equal(p.title, "A Will Eternal");
|
||||||
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/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", () => {
|
test("lightnovelworld.detect returns other for non-series paths", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user