Implements spec #80 / ADR-0008 — Gitea issues #86, #87, #88, #89, #90, all closed. A Reader bookmarks a novel on lightnovelworld and it never shows a New Chapter, because the Series identity was derived from the chapter address instead of read from the page. One Series can publish under several Chapter Slugs, so the derived key points at a slug that 404s. - **#89** — the userscript's lnw adapter stops deriving `seriesUrl`/`seriesId` from the path. It reads the page's own pointer (`a[aria-label='All Chapter']`), falling back to the microdata breadcrumb's second crumb, and carries `chapterSlug` on the page object, stored nowhere. - **#87** — the Poll's lnw chapter scan is unscoped (no stored-slug pattern can cover a Series' whole list) and truncated at the `wpd-threads` comment thread, the one region a visitor can write to. Marker absent means skip and log with the body length, never scan whole. Corrects the `maxBodyBytes` headroom comment to the measured 3.5x. - **#86** — the scan fixture is now text trimmed from a real, wholly-fetched Series page instead of a hand-written cross-series anchor that no live page carries. - **#90** — stale stored rows repair themselves on the next chapter visit: a pure transform over cache, queue and last-checked map, silent to the Reader, with progress, favourite and lifecycle bucket preserved when two rows merge. - **#88** — an env-gated live canary (`SMOKE_LNW_SERIES_URL`) proving the marker still occurs exactly once and still follows the last chapter anchor, asserted against the production symbols themselves. Verified on the merged branch: `go test ./...` green, `gofmt -l internal/latest/` silent, both userscripts `node --check` clean, 35/35 + 29/29 logic tests. Live canary green (marker once at byte 612,182 of 651,795). #90 verified on device with Playwright. Open follow-up: **#91** — the userscript's client-side latest-chapter scan is still scoped to the derived slug. Reviewed-on: #92 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #92.
This commit is contained in:
@@ -11,8 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// maxBodyBytes caps what a single series page can cost in memory. Real pages
|
// maxBodyBytes caps what a single series page can cost in memory. Real pages
|
||||||
// measured 100-400 KB on 2026-07-26, so this is roughly 10x headroom and mostly
|
// measured 100-400 KB on 2026-07-26; lightnovelworld runs larger — 685 KB and
|
||||||
// guards against a proxy handing back something enormous.
|
// 1.18 MB measured 2026-08-11 — so the headroom there is roughly 3.5x, and the
|
||||||
|
// cap mostly guards against a proxy handing back something enormous.
|
||||||
const maxBodyBytes = 4 << 20
|
const maxBodyBytes = 4 << 20
|
||||||
|
|
||||||
// chromeUA matches the client profile below. A Chrome fingerprint paired with a
|
// chromeUA matches the client profile below. A Chrome fingerprint paired with a
|
||||||
|
|||||||
@@ -1010,10 +1010,10 @@ func TestRunOnceFillsBlankCoverFromSeriesPage(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "lightnovelworld novel",
|
name: "lightnovelworld novel",
|
||||||
key: "lightnovelworld:a-will-eternal", site: "lightnovelworld",
|
key: "lightnovelworld:all-jobs-and-classes-i-just-wanted-one-skill-not-them-all", site: "lightnovelworld",
|
||||||
seriesID: "a-will-eternal", seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/",
|
seriesID: "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all", seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||||
kind: store.KindNovel, body: lnwSeriesFixture + lnwCoverFixture,
|
kind: store.KindNovel, body: lnwSeriesFixture + lnwCoverFixture,
|
||||||
wantCover: "https://lightnovelworld.net/wp-content/uploads/2026/03/a-will-eternal-1.webp",
|
wantCover: "https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "kagane manga",
|
name: "kagane manga",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package latest
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html"
|
"html"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -58,11 +59,26 @@ var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`)
|
|||||||
// "/<slug>/chapter-<n>[-<title-slug>].html". Verified live 2026-08-05.
|
// "/<slug>/chapter-<n>[-<title-slug>].html". Verified live 2026-08-05.
|
||||||
var novelfullSlugRe = regexp.MustCompile(`^/([^/?#]+)\.html$`)
|
var novelfullSlugRe = regexp.MustCompile(`^/([^/?#]+)\.html$`)
|
||||||
|
|
||||||
// lnwSlugRe does the same for lightnovelworld, whose series pages live under
|
// lnwChapterRe matches any chapter-shaped address on lightnovelworld. Unlike
|
||||||
// /novel/<slug>/ while its chapter URLs are flat at the site root:
|
// asura, novelfull and comix — which scope to their stored series slug so a
|
||||||
// "/<slug>-chapter-<n>/", absolute in the page's own anchors. Verified live
|
// foreign chapter link cannot contribute — this Site's chapter addresses carry
|
||||||
// 2026-08-05.
|
// the Chapter Slug, which is not the Series identity: one Series may publish
|
||||||
var lnwSlugRe = regexp.MustCompile(`^/novel/([^/?#]+)/?$`)
|
// under several Chapter Slugs (measured 2026-08-11: a sampled novel serves
|
||||||
|
// 1-99 under one slug and 100-423 under another), so no stored-slug pattern can
|
||||||
|
// cover a Series' whole list. An unscoped match is safe because
|
||||||
|
// latestChapterFrom truncates the body at the comment thread before scanning
|
||||||
|
// (lnwCommentMarker); without that, a visitor's comment could set the Latest
|
||||||
|
// Chapter on the shared Series row.
|
||||||
|
var lnwChapterRe = regexp.MustCompile(`lightnovelworld\.net/[a-z0-9-]+-chapter-([0-9.]+)/`)
|
||||||
|
|
||||||
|
// lnwCommentMarker is the boundary of lightnovelworld's server-rendered
|
||||||
|
// wpdiscuz comment thread. It occurs exactly once per page and follows every
|
||||||
|
// chapter anchor (measured 2026-08-11,
|
||||||
|
// docs/research/lightnovelworld-chapter-vs-series-slug.md §6), so cutting the
|
||||||
|
// body at its first occurrence keeps the whole chapter list while excluding a
|
||||||
|
// region any visitor can write to. Absent means the page shape changed: the
|
||||||
|
// body is skipped, never scanned whole.
|
||||||
|
const lnwCommentMarker = "wpd-threads"
|
||||||
|
|
||||||
// latestChapterFrom returns the highest chapter number body advertises for this
|
// latestChapterFrom returns the highest chapter number body advertises for this
|
||||||
// series. ok is false when the body yields nothing usable — an unknown site, an
|
// series. ok is false when the body yields nothing usable — an unknown site, an
|
||||||
@@ -82,6 +98,7 @@ var lnwSlugRe = regexp.MustCompile(`^/novel/([^/?#]+)/?$`)
|
|||||||
// such guarantee — demonicChapterRe matches any chaptered.php?manga=<id> anchor
|
// such guarantee — demonicChapterRe matches any chaptered.php?manga=<id> anchor
|
||||||
// with no per-series scoping, because the stored series_id for demonic is a
|
// with no per-series scoping, because the stored series_id for demonic is a
|
||||||
// slug, not the numeric id the URL carries, so it cannot easily be scoped.
|
// slug, not the numeric id the URL carries, so it cannot easily be scoped.
|
||||||
|
// lightnovelworld is unscoped and body-truncated instead — see lnwChapterRe.
|
||||||
func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
||||||
var re *regexp.Regexp
|
var re *regexp.Regexp
|
||||||
switch site {
|
switch site {
|
||||||
@@ -126,15 +143,19 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
|||||||
// and neither may contribute to the maximum.
|
// and neither may contribute to the maximum.
|
||||||
re = regexp.MustCompile(`/` + regexp.QuoteMeta(m[1]) + `/chapter-([0-9.]+)`)
|
re = regexp.MustCompile(`/` + regexp.QuoteMeta(m[1]) + `/chapter-([0-9.]+)`)
|
||||||
case "lightnovelworld":
|
case "lightnovelworld":
|
||||||
u, err := url.Parse(seriesURL)
|
// The comment thread below the chapter list is the one region of the
|
||||||
if err != nil {
|
// page any visitor can write to, so the scan never reads past it (see
|
||||||
|
// lnwChapterRe). A body without the marker is skipped, never scanned
|
||||||
|
// whole — a redesign must degrade into staleness, not into a wrong
|
||||||
|
// shared value; the logged body length tells a markup change from a
|
||||||
|
// body the size cap cut short.
|
||||||
|
i := strings.Index(body, lnwCommentMarker)
|
||||||
|
if i < 0 {
|
||||||
|
log.Printf("latest poll %q: no %s marker in %d bytes", seriesURL, lnwCommentMarker, len(body))
|
||||||
return latestChapter{}, false
|
return latestChapter{}, false
|
||||||
}
|
}
|
||||||
m := lnwSlugRe.FindStringSubmatch(u.Path)
|
body = body[:i]
|
||||||
if m == nil {
|
re = lnwChapterRe
|
||||||
return latestChapter{}, false
|
|
||||||
}
|
|
||||||
re = regexp.MustCompile(`lightnovelworld\.net/` + regexp.QuoteMeta(m[1]) + `-chapter-([0-9.]+)/`)
|
|
||||||
default:
|
default:
|
||||||
return latestChapter{}, false
|
return latestChapter{}, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package latest
|
package latest
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||||||
// fetched 2026-07-26. The first anchor is the "First Chapter" shortcut: it is a
|
// fetched 2026-07-26. The first anchor is the "First Chapter" shortcut: it is a
|
||||||
@@ -69,14 +72,113 @@ const novelfullSeriesFixture = `
|
|||||||
<a href="/release-that-witch/chapter-9999.html">Chapter 9999</a>
|
<a href="/release-that-witch/chapter-9999.html">Chapter 9999</a>
|
||||||
`
|
`
|
||||||
|
|
||||||
// Trimmed from https://lightnovelworld.net/novel/a-will-eternal/ fetched
|
// Trimmed from
|
||||||
// 2026-08-05. Its chapter anchors are absolute and flat — /<slug>-chapter-<n>/
|
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
|
||||||
// at the site root, not under /novel/. The last anchor is another series'.
|
// fetched 2026-08-11, whole document (~307 KB decoded; the wire body is ~32 KB
|
||||||
|
// zstd-compressed). This
|
||||||
|
// novel publishes its chapters under two Chapter Slugs: 1–99 at
|
||||||
|
// …-not-chapter-<n>/ and 100–423 at …-not-them-all-chapter-<n>/, and the page
|
||||||
|
// lists them newest-first, so the …-not-them-all anchors precede the …-not
|
||||||
|
// anchors. Every anchor through the comment-thread marker is verbatim page
|
||||||
|
// text (the site renders this novel's chapter titles as "[ ... words ]"). The
|
||||||
|
// comment block after the marker is the real wpdiscuz comment #wpd-comm-358_0
|
||||||
|
// from https://lightnovelworld.net/novel/the-sword-illuminates-the-great-wilderness/
|
||||||
|
// — the pinned page serves zero comments — with its share/link/vote/reply
|
||||||
|
// boilerplate trimmed. The comment's body carried no link, so the bare <a
|
||||||
|
// href> to https://lightnovelworld.net/overgeared-chapter-2059/ inside
|
||||||
|
// wpd-comment-text is the one composed element; that URL is a real chapter of
|
||||||
|
// a real different novel (overgeared; fetched, HTTP 200).
|
||||||
const lnwSeriesFixture = `
|
const lnwSeriesFixture = `
|
||||||
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1/">Chapter 1</a>
|
<li data-ID="102741">
|
||||||
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1317/">Chapter 1317</a>
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-404/">
|
||||||
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1298/">Chapter 1298</a>
|
<div class="epl-num">Vol. 1 Ch. 404</div>
|
||||||
<a href="https://lightnovelworld.net/overgeared-chapter-9999/">Chapter 9999</a>
|
<div class="epl-title">[ ... words ]</div>
|
||||||
|
<div class="epl-date">April 12, 2026</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-ID="102780">
|
||||||
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-423/">
|
||||||
|
<div class="epl-num">Vol. 1 Ch. 423</div>
|
||||||
|
<div class="epl-title">[ ... words ]</div>
|
||||||
|
<div class="epl-date">April 7, 2026</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-ID="102527">
|
||||||
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-300/">
|
||||||
|
<div class="epl-num">Vol. 1 Ch. 300</div>
|
||||||
|
<div class="epl-title">[ ... words ]</div>
|
||||||
|
<div class="epl-date">April 4, 2026</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-ID="102325">
|
||||||
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-200/">
|
||||||
|
<div class="epl-num">Vol. 1 Ch. 200</div>
|
||||||
|
<div class="epl-title">[ ... words ]</div>
|
||||||
|
<div class="epl-date">March 29, 2026</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-ID="102121">
|
||||||
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-100/">
|
||||||
|
<div class="epl-num">Vol. 1 Ch. 100</div>
|
||||||
|
<div class="epl-title">[ ... words ]</div>
|
||||||
|
<div class="epl-date">March 22, 2026</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-ID="26014">
|
||||||
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-99/">
|
||||||
|
<div class="epl-num">Vol. 1 Ch. 99</div>
|
||||||
|
<div class="epl-title">Chapter 99</div>
|
||||||
|
<div class="epl-date">November 5, 2025</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-ID="25916">
|
||||||
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-50/">
|
||||||
|
<div class="epl-num">Vol. 1 Ch. 50</div>
|
||||||
|
<div class="epl-title">Chapter 50</div>
|
||||||
|
<div class="epl-date">October 29, 2025</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class='tseplsfrst' data-ID="25818">
|
||||||
|
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-1/">
|
||||||
|
<div class="epl-num">Vol. 1 Ch. 1</div>
|
||||||
|
<div class="epl-title">Chapter 01</div>
|
||||||
|
<div class="epl-date">October 11, 2025</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<div id="wpd-threads" class="wpd-thread-wrapper">
|
||||||
|
<div class="wpd-thread-list">
|
||||||
|
<div id='wpd-comm-358_0' class='comment byuser comment-author-jimbear even thread-even depth-1 wpd-comment wpd_comment_level-1'><div class="wpd-comment-wrap wpd-blog-user wpd-blog-subscriber">
|
||||||
|
<div class="wpd-comment-left ">
|
||||||
|
<div class="wpd-avatar ">
|
||||||
|
<img alt='hasbi asy' src='https://secure.gravatar.com/avatar/3c1792cb31cab842f90e7c463f0948e98536cf938aebbd2f678f937ca60fb799?s=64&d=mm&r=g' srcset='https://secure.gravatar.com/avatar/3c1792cb31cab842f90e7c463f0948e98536cf938aebbd2f678f937ca60fb799?s=128&d=mm&r=g 2x' class='avatar avatar-64 photo' height='64' width='64' decoding='async'/>
|
||||||
|
</div>
|
||||||
|
<div class="wpd-comment-label" wpd-tooltip="Member" wpd-tooltip-position="right">
|
||||||
|
<span>Member</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div id="comment-358" class="wpd-comment-right">
|
||||||
|
<div class="wpd-comment-header">
|
||||||
|
<div class="wpd-comment-author ">
|
||||||
|
hasbi asy
|
||||||
|
</div>
|
||||||
|
<div class="wpd-comment-date" title="July 9, 2026 1:44 am">
|
||||||
|
<i class='far fa-clock' aria-hidden='true'></i>
|
||||||
|
1 month ago
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wpd-comment-text">
|
||||||
|
<p>where’s everyone</p>
|
||||||
|
|
||||||
|
<a href="https://lightnovelworld.net/overgeared-chapter-2059/">https://lightnovelworld.net/overgeared-chapter-2059/</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id='wpdiscuz_form_anchor-358_0'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||||||
@@ -98,8 +200,10 @@ const kaganeCoverFixture = `{"series_covers":[{"cover_id":"019fe11a-84d1-714b-9c
|
|||||||
// Trimmed from https://novelfull.com/reverend-insanity.html on 2026-08-10.
|
// Trimmed from https://novelfull.com/reverend-insanity.html on 2026-08-10.
|
||||||
const novelfullCoverFixture = `<meta name="image" content="https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp">`
|
const novelfullCoverFixture = `<meta name="image" content="https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp">`
|
||||||
|
|
||||||
// Trimmed from https://lightnovelworld.net/novel/a-will-eternal/ on 2026-08-10.
|
// Trimmed from
|
||||||
const lnwCoverFixture = `<meta content='https://lightnovelworld.net/wp-content/uploads/2026/03/a-will-eternal-1.webp' property='og:image'>`
|
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
|
||||||
|
// on 2026-08-11.
|
||||||
|
const lnwCoverFixture = `<meta property="og:image" content="https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg" />`
|
||||||
|
|
||||||
func TestCoverFrom(t *testing.T) {
|
func TestCoverFrom(t *testing.T) {
|
||||||
const comixURL = "https://comix.to/title/n8we-dungeons-and-crayons"
|
const comixURL = "https://comix.to/title/n8we-dungeons-and-crayons"
|
||||||
@@ -139,7 +243,7 @@ func TestCoverFrom(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "lightnovelworld reads og image",
|
name: "lightnovelworld reads og image",
|
||||||
site: "lightnovelworld", body: lnwCoverFixture, wantOK: true,
|
site: "lightnovelworld", body: lnwCoverFixture, wantOK: true,
|
||||||
wantCover: "https://lightnovelworld.net/wp-content/uploads/2026/03/a-will-eternal-1.webp",
|
wantCover: "https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "later metadata cover survives empty match",
|
name: "later metadata cover survives empty match",
|
||||||
@@ -307,24 +411,38 @@ func TestLatestChapterFrom(t *testing.T) {
|
|||||||
body: novelfullSeriesFixture,
|
body: novelfullSeriesFixture,
|
||||||
wantOK: false,
|
wantOK: false,
|
||||||
},
|
},
|
||||||
|
// Stored before the slug split, so the address carries the ...-not
|
||||||
|
// Chapter Slug; the 100-423 block under the other slug must still win.
|
||||||
|
// The body is the chapter-list portion of lnwSeriesFixture with the
|
||||||
|
// comment block omitted; the marker is kept, because a body without it
|
||||||
|
// is skipped, not scanned.
|
||||||
{
|
{
|
||||||
name: "lightnovelworld takes the max and ignores another series",
|
name: "lightnovelworld max spans both chapter slugs",
|
||||||
site: "lightnovelworld",
|
site: "lightnovelworld",
|
||||||
seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/",
|
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not/",
|
||||||
body: lnwSeriesFixture,
|
body: strings.SplitN(lnwSeriesFixture, lnwCommentMarker, 2)[0] + lnwCommentMarker,
|
||||||
wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317",
|
wantOK: true, wantNum: 423, wantLabel: "Chapter 423",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "lightnovelworld tolerates a series url with no trailing slash",
|
name: "lightnovelworld comment anchor cannot set the latest chapter",
|
||||||
site: "lightnovelworld",
|
site: "lightnovelworld",
|
||||||
seriesURL: "https://lightnovelworld.net/novel/a-will-eternal",
|
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||||
body: lnwSeriesFixture,
|
body: lnwSeriesFixture,
|
||||||
wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317",
|
wantOK: true, wantNum: 423, wantLabel: "Chapter 423",
|
||||||
|
},
|
||||||
|
// Marker removed from the fixture, comment block still present: a
|
||||||
|
// redesign must degrade into a skip, never into the comment's number.
|
||||||
|
{
|
||||||
|
name: "lightnovelworld body without the comment marker is skipped",
|
||||||
|
site: "lightnovelworld",
|
||||||
|
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||||
|
body: strings.ReplaceAll(lnwSeriesFixture, lnwCommentMarker, ""),
|
||||||
|
wantOK: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "lightnovelworld yields nothing on a challenge page",
|
name: "lightnovelworld yields nothing on a challenge page",
|
||||||
site: "lightnovelworld",
|
site: "lightnovelworld",
|
||||||
seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/",
|
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||||
body: challengeFixture,
|
body: challengeFixture,
|
||||||
wantOK: false,
|
wantOK: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package latest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lnwSeriesPageFloor is the smallest body that can still be a whole
|
||||||
|
// lightnovelworld Series page. Whole pages measured 685 KB..1.18 MB on
|
||||||
|
// 2026-08-11 and carry the marker at ~94% of the document, so a body under
|
||||||
|
// 100 KB is a challenge, a notice, or a truncated read — asserting on it
|
||||||
|
// would report the marker missing when it was never fetched.
|
||||||
|
const lnwSeriesPageFloor = 100 << 10
|
||||||
|
|
||||||
|
// TestSmokeLnwCommentBoundary is the live proof that the comment-thread marker
|
||||||
|
// the lightnovelworld chapter scan truncates at (lnwCommentMarker,
|
||||||
|
// "wpd-threads") still holds on the Site. The scan depends on it: when the
|
||||||
|
// marker vanishes every Series is skipped and logged — correct, but silent
|
||||||
|
// until a Reader notices their Latest Chapter has stopped moving. It runs only
|
||||||
|
// when SMOKE_LNW_SERIES_URL is set — the URL of the live Series page to check.
|
||||||
|
// The immortality-simulator page measured 2026-08-11
|
||||||
|
// (docs/research/lightnovelworld-chapter-vs-series-slug.md) is the default to
|
||||||
|
// point it at:
|
||||||
|
//
|
||||||
|
// SMOKE_LNW_SERIES_URL=https://lightnovelworld.net/novel/immortality-simulator/ go test -v -run TestSmokeLnwCommentBoundary ./internal/latest
|
||||||
|
//
|
||||||
|
// A red run means the Site's markup has moved — the marker is gone, occurs
|
||||||
|
// more than once, or no longer follows the last chapter anchor — and the scan
|
||||||
|
// in sites.go is now skipping this Site. Revisit sites.go before anything
|
||||||
|
// else; the test is not flaky. A Cloudflare challenge or a non-200 is
|
||||||
|
// distinguished from a marker failure by the "not a marker failure" messages
|
||||||
|
// below, which carry the observed status and body length.
|
||||||
|
func TestSmokeLnwCommentBoundary(t *testing.T) {
|
||||||
|
seriesURL := os.Getenv("SMOKE_LNW_SERIES_URL")
|
||||||
|
if seriesURL == "" {
|
||||||
|
t.Skip("SMOKE_LNW_SERIES_URL unset")
|
||||||
|
}
|
||||||
|
if !fetchableSeriesURL("lightnovelworld", seriesURL) {
|
||||||
|
t.Fatalf("%q is not a fetchable lightnovelworld series URL", seriesURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := NewTLSFetcher()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTLSFetcher: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
body, status, err := f.Get(ctx, seriesURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body %d bytes — not a marker failure; the Site did not answer this IP with a Series page", status, len(body))
|
||||||
|
}
|
||||||
|
if len(body) < lnwSeriesPageFloor {
|
||||||
|
t.Fatalf("body %d bytes — not a whole Series page (measured 685 KB..1.18 MB); not a marker failure, likely a Cloudflare challenge or a non-Series response", len(body))
|
||||||
|
}
|
||||||
|
if !lnwChapterRe.MatchString(body) {
|
||||||
|
t.Fatalf("no chapter anchor in %d bytes — not a lightnovelworld Series page; not a marker failure, likely a Cloudflare challenge or a non-Series response", len(body))
|
||||||
|
}
|
||||||
|
markerIdx := strings.Index(body, lnwCommentMarker)
|
||||||
|
if failures := checkLnwCommentBoundary(body); len(failures) > 0 {
|
||||||
|
t.Fatalf("%s (body %d bytes)", strings.Join(failures, "; "), len(body))
|
||||||
|
}
|
||||||
|
t.Logf("ok: %q once at byte %d, body %d bytes", lnwCommentMarker, markerIdx, len(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkLnwCommentBoundary verifies the three marker assertions against a
|
||||||
|
// fetched Series body: the marker occurs exactly once, every chapter anchor
|
||||||
|
// precedes it, and at least one anchor precedes it at all. It returns one
|
||||||
|
// human-readable failure per broken assertion — with observed offsets and body
|
||||||
|
// length — and empty when the page is healthy.
|
||||||
|
func checkLnwCommentBoundary(body string) []string {
|
||||||
|
markerIdx := strings.Index(body, lnwCommentMarker)
|
||||||
|
switch n := strings.Count(body, lnwCommentMarker); {
|
||||||
|
case n == 0:
|
||||||
|
return []string{fmt.Sprintf("%q occurs 0 times in %d bytes, want exactly 1", lnwCommentMarker, len(body))}
|
||||||
|
case n != 1:
|
||||||
|
return []string{fmt.Sprintf("%q occurs %d times in %d bytes (first at byte %d), want exactly 1", lnwCommentMarker, n, len(body), markerIdx)}
|
||||||
|
}
|
||||||
|
lastAnchor, anchorsBefore := -1, 0
|
||||||
|
for _, m := range lnwChapterRe.FindAllStringIndex(body, -1) {
|
||||||
|
if m[0] < markerIdx {
|
||||||
|
anchorsBefore++
|
||||||
|
}
|
||||||
|
lastAnchor = m[0]
|
||||||
|
}
|
||||||
|
var failures []string
|
||||||
|
if lastAnchor >= markerIdx {
|
||||||
|
failures = append(failures, fmt.Sprintf("last chapter anchor at byte %d does not precede the marker at byte %d", lastAnchor, markerIdx))
|
||||||
|
}
|
||||||
|
if anchorsBefore == 0 {
|
||||||
|
failures = append(failures, fmt.Sprintf("no chapter anchor before the marker at byte %d — the truncated prefix the scan sees yields nothing", markerIdx))
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
@@ -15,10 +15,8 @@
|
|||||||
"13": "Web UI Handlers",
|
"13": "Web UI Handlers",
|
||||||
"14": "Go Error Handling",
|
"14": "Go Error Handling",
|
||||||
"15": "CDP Browser Client",
|
"15": "CDP Browser Client",
|
||||||
"16": "Cover Storage",
|
|
||||||
"17": "Go Code Style Guide",
|
"17": "Go Code Style Guide",
|
||||||
"18": "Agent Skills",
|
"18": "Agent Skills",
|
||||||
"19": "Store Migrations",
|
|
||||||
"20": "I/O Performance Patterns",
|
"20": "I/O Performance Patterns",
|
||||||
"21": "CPU Optimization",
|
"21": "CPU Optimization",
|
||||||
"22": "Caching Patterns",
|
"22": "Caching Patterns",
|
||||||
@@ -37,7 +35,6 @@
|
|||||||
"35": "Web UI Filter Logic",
|
"35": "Web UI Filter Logic",
|
||||||
"36": "Userscript Test Harness",
|
"36": "Userscript Test Harness",
|
||||||
"37": "Product & Security Context",
|
"37": "Product & Security Context",
|
||||||
"38": "Session Store Tests",
|
|
||||||
"39": "UI Critique 2026-07-26A",
|
"39": "UI Critique 2026-07-26A",
|
||||||
"40": "UI Critique 2026-07-26B",
|
"40": "UI Critique 2026-07-26B",
|
||||||
"41": "DB Cutover Runbook",
|
"41": "DB Cutover Runbook",
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# Graph Report - mangaBookmark (2026-08-11)
|
# Graph Report - mangaBookmark (2026-08-11)
|
||||||
|
|
||||||
## Corpus Check
|
## Corpus Check
|
||||||
- 107 files · ~261,410 words
|
- 108 files · ~265,471 words
|
||||||
- Verdict: corpus is large enough that graph structure adds value.
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- 1634 nodes · 3179 edges · 280 communities (65 shown, 215 thin omitted)
|
- 1644 nodes · 3206 edges · 277 communities (62 shown, 215 thin omitted)
|
||||||
- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 309 edges (avg confidence: 0.77)
|
- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 312 edges (avg confidence: 0.77)
|
||||||
- Token cost: 0 input · 0 output
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
## Graph Freshness
|
## Graph Freshness
|
||||||
- Built from commit: `c400c91a`
|
- Built from commit: `27dda601`
|
||||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
- Run `graphify update .` after code changes (no API cost).
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
@@ -31,10 +31,8 @@
|
|||||||
- [[_COMMUNITY_Web UI Handlers|Web UI Handlers]]
|
- [[_COMMUNITY_Web UI Handlers|Web UI Handlers]]
|
||||||
- [[_COMMUNITY_Go Error Handling|Go Error Handling]]
|
- [[_COMMUNITY_Go Error Handling|Go Error Handling]]
|
||||||
- [[_COMMUNITY_CDP Browser Client|CDP Browser Client]]
|
- [[_COMMUNITY_CDP Browser Client|CDP Browser Client]]
|
||||||
- [[_COMMUNITY_Cover Storage|Cover Storage]]
|
|
||||||
- [[_COMMUNITY_Go Code Style Guide|Go Code Style Guide]]
|
- [[_COMMUNITY_Go Code Style Guide|Go Code Style Guide]]
|
||||||
- [[_COMMUNITY_Agent Skills|Agent Skills]]
|
- [[_COMMUNITY_Agent Skills|Agent Skills]]
|
||||||
- [[_COMMUNITY_Store Migrations|Store Migrations]]
|
|
||||||
- [[_COMMUNITY_IO Performance Patterns|I/O Performance Patterns]]
|
- [[_COMMUNITY_IO Performance Patterns|I/O Performance Patterns]]
|
||||||
- [[_COMMUNITY_CPU Optimization|CPU Optimization]]
|
- [[_COMMUNITY_CPU Optimization|CPU Optimization]]
|
||||||
- [[_COMMUNITY_Caching Patterns|Caching Patterns]]
|
- [[_COMMUNITY_Caching Patterns|Caching Patterns]]
|
||||||
@@ -53,7 +51,6 @@
|
|||||||
- [[_COMMUNITY_Web UI Filter Logic|Web UI Filter Logic]]
|
- [[_COMMUNITY_Web UI Filter Logic|Web UI Filter Logic]]
|
||||||
- [[_COMMUNITY_Userscript Test Harness|Userscript Test Harness]]
|
- [[_COMMUNITY_Userscript Test Harness|Userscript Test Harness]]
|
||||||
- [[_COMMUNITY_Product & Security Context|Product & Security Context]]
|
- [[_COMMUNITY_Product & Security Context|Product & Security Context]]
|
||||||
- [[_COMMUNITY_Session Store Tests|Session Store Tests]]
|
|
||||||
- [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]]
|
- [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]]
|
||||||
- [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]]
|
- [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]]
|
||||||
- [[_COMMUNITY_DB Cutover Runbook|DB Cutover Runbook]]
|
- [[_COMMUNITY_DB Cutover Runbook|DB Cutover Runbook]]
|
||||||
@@ -300,7 +297,7 @@
|
|||||||
2. `newWebTestServer()` - 49 edges
|
2. `newWebTestServer()` - 49 edges
|
||||||
3. `newTestStore()` - 42 edges
|
3. `newTestStore()` - 42 edges
|
||||||
4. `newTestStore()` - 41 edges
|
4. `newTestStore()` - 41 edges
|
||||||
5. `e()` - 32 edges
|
5. `e()` - 33 edges
|
||||||
6. `Handler` - 29 edges
|
6. `Handler` - 29 edges
|
||||||
7. `ne()` - 28 edges
|
7. `ne()` - 28 edges
|
||||||
8. `De()` - 28 edges
|
8. `De()` - 28 edges
|
||||||
@@ -333,7 +330,7 @@
|
|||||||
- **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85]
|
- **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85]
|
||||||
- **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85]
|
- **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85]
|
||||||
|
|
||||||
## Communities (280 total, 215 thin omitted)
|
## Communities (277 total, 215 thin omitted)
|
||||||
|
|
||||||
### Community 0 - "HTMX Library Internals"
|
### Community 0 - "HTMX Library Internals"
|
||||||
Cohesion: 0.08
|
Cohesion: 0.08
|
||||||
@@ -345,19 +342,19 @@ Nodes (84): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecor
|
|||||||
|
|
||||||
### Community 2 - "Manga Userscript Adapters"
|
### Community 2 - "Manga Userscript Adapters"
|
||||||
Cohesion: 0.06
|
Cohesion: 0.06
|
||||||
Nodes (77): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+69 more)
|
Nodes (76): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+68 more)
|
||||||
|
|
||||||
### Community 3 - "Novel Userscript Adapters"
|
### Community 3 - "Novel Userscript Adapters"
|
||||||
Cohesion: 0.06
|
Cohesion: 0.06
|
||||||
Nodes (74): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+66 more)
|
Nodes (79): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+71 more)
|
||||||
|
|
||||||
### Community 4 - "Series Acquisition Tests"
|
### Community 4 - "Series Acquisition Tests"
|
||||||
Cohesion: 0.10
|
Cohesion: 0.10
|
||||||
Nodes (67): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+59 more)
|
Nodes (67): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+59 more)
|
||||||
|
|
||||||
### Community 5 - "Bookmarks API Tests"
|
### Community 5 - "Bookmarks API Tests"
|
||||||
Cohesion: 0.07
|
Cohesion: 0.08
|
||||||
Nodes (71): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+63 more)
|
Nodes (67): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+59 more)
|
||||||
|
|
||||||
### Community 7 - "Cover & Acquire Internals"
|
### Community 7 - "Cover & Acquire Internals"
|
||||||
Cohesion: 0.08
|
Cohesion: 0.08
|
||||||
@@ -376,8 +373,8 @@ Cohesion: 0.05
|
|||||||
Nodes (39): Test Helpers, Test Timeout, Basic Handler Test, HTTP Handler Testing, Query Parameters and Headers, Docker Compose Fixture, Integration Testing, SQL Schema Fixture (+31 more)
|
Nodes (39): Test Helpers, Test Timeout, Basic Handler Test, HTTP Handler Testing, Query Parameters and Headers, Docker Compose Fixture, Integration Testing, SQL Schema Fixture (+31 more)
|
||||||
|
|
||||||
### Community 11 - "Store Tests"
|
### Community 11 - "Store Tests"
|
||||||
Cohesion: 0.14
|
Cohesion: 0.05
|
||||||
Nodes (44): scanSeries(), Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secondReader(), seedForCheck() (+36 more)
|
Nodes (75): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID(), applyMigration(), CoverContentType() (+67 more)
|
||||||
|
|
||||||
### Community 12 - "Bookmarks API Handler"
|
### Community 12 - "Bookmarks API Handler"
|
||||||
Cohesion: 0.08
|
Cohesion: 0.08
|
||||||
@@ -392,21 +389,13 @@ Cohesion: 0.06
|
|||||||
Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more)
|
Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more)
|
||||||
|
|
||||||
### Community 15 - "CDP Browser Client"
|
### Community 15 - "CDP Browser Client"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.08
|
||||||
Nodes (22): Action, awaitPromise(), browserConnectionLost(), classifyBrowserError(), Context, Mutex, isInterstitial(), jsString() (+14 more)
|
Nodes (29): Action, awaitPromise(), browserConnectionLost(), classifyBrowserError(), Context, Mutex, isInterstitial(), jsString() (+21 more)
|
||||||
|
|
||||||
### Community 16 - "Cover Storage"
|
|
||||||
Cohesion: 0.09
|
|
||||||
Nodes (7): CoverContentType(), coverRelativePath(), coverSourceAddress(), displayChapter(), Store, TestDisplayChapter(), Bookmark
|
|
||||||
|
|
||||||
### Community 17 - "Go Code Style Guide"
|
### Community 17 - "Go Code Style Guide"
|
||||||
Cohesion: 0.08
|
Cohesion: 0.08
|
||||||
Nodes (23): Code Style Details, Extract Complex Conditions, Value vs Pointer Arguments, Code Organization Within Files, Complex Conditions & Init Scope, Composite Literals, Control Flow, Cross-References (+15 more)
|
Nodes (23): Code Style Details, Extract Complex Conditions, Value vs Pointer Arguments, Code Organization Within Files, Complex Conditions & Init Scope, Composite Literals, Control Flow, Cross-References (+15 more)
|
||||||
|
|
||||||
### Community 19 - "Store Migrations"
|
|
||||||
Cohesion: 0.19
|
|
||||||
Nodes (18): applyMigration(), migrate(), Open(), refreshOwnerToken(), seedOwner(), TestCoverIsContentAddressedOnFilesystem(), TestCoverPersistsAcrossReopen(), TestMigration0002BackfillsExistingBookmarks() (+10 more)
|
|
||||||
|
|
||||||
### Community 20 - "I/O Performance Patterns"
|
### Community 20 - "I/O Performance Patterns"
|
||||||
Cohesion: 0.11
|
Cohesion: 0.11
|
||||||
Nodes (18): Avoid io.ReadAll for large payloads, Batch Operations, Buffered I/O, Cgo Overhead, Channel: batch processing from a stream, Concurrent Multi-Stage Pipelines, Connection pooling, Database: batch inserts over row-by-row (+10 more)
|
Nodes (18): Avoid io.ReadAll for large payloads, Batch Operations, Buffered I/O, Cgo Overhead, Channel: batch processing from a stream, Concurrent Multi-Stage Pipelines, Connection pooling, Database: batch inserts over row-by-row (+10 more)
|
||||||
@@ -463,10 +452,6 @@ Nodes (5): closeCardPanels(), setActiveTab(), toggleChapterForm(), toggleConfirm
|
|||||||
Cohesion: 0.17
|
Cohesion: 0.17
|
||||||
Nodes (11): Accessibility & Inclusion, Brand Commitments, Capabilities and Constraints, Evidence on Hand, Operating Context, Platform, Positioning, Product (+3 more)
|
Nodes (11): Accessibility & Inclusion, Brand Commitments, Capabilities and Constraints, Evidence on Hand, Operating Context, Platform, Positioning, Product (+3 more)
|
||||||
|
|
||||||
### Community 38 - "Session Store Tests"
|
|
||||||
Cohesion: 0.48
|
|
||||||
Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID()
|
|
||||||
|
|
||||||
### Community 39 - "UI Critique 2026-07-26A"
|
### Community 39 - "UI Critique 2026-07-26A"
|
||||||
Cohesion: 0.29
|
Cohesion: 0.29
|
||||||
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
|
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
|
||||||
@@ -592,19 +577,19 @@ Cohesion: 0.50
|
|||||||
Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||||
|
|
||||||
## Knowledge Gaps
|
## Knowledge Gaps
|
||||||
- **544 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+539 more)
|
- **545 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+540 more)
|
||||||
These have ≤1 connection - possible missing edges or undocumented components.
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
- **215 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
- **215 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
## Suggested Questions
|
## Suggested Questions
|
||||||
_Questions this graph is uniquely positioned to answer:_
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Web UI Handlers`, `Store Migrations`?**
|
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Store Tests`, `Web UI Handlers`?**
|
||||||
_High betweenness centrality (0.037) - this node is a cross-community bridge._
|
_High betweenness centrality (0.041) - this node is a cross-community bridge._
|
||||||
- **Why does `Open()` connect `Store Migrations` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `Store Tests`, `Cover Storage`, `PG Test Infrastructure`?**
|
- **Why does `Open()` connect `Store Tests` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `PG Test Infrastructure`?**
|
||||||
_High betweenness centrality (0.033) - this node is a cross-community bridge._
|
_High betweenness centrality (0.035) - this node is a cross-community bridge._
|
||||||
- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`?**
|
- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`?**
|
||||||
_High betweenness centrality (0.020) - this node is a cross-community bridge._
|
_High betweenness centrality (0.022) - this node is a cross-community bridge._
|
||||||
- **Are the 47 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?**
|
- **Are the 47 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?**
|
||||||
_`testConfig()` has 47 INFERRED edges - model-reasoned connections that need verification._
|
_`testConfig()` has 47 INFERRED edges - model-reasoned connections that need verification._
|
||||||
- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?**
|
- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?**
|
||||||
@@ -612,4 +597,4 @@ _Questions this graph is uniquely positioned to answer:_
|
|||||||
- **Are the 6 inferred relationships involving `newTestStore()` (e.g. with `TestCreateAndGetSession()` and `TestDeleteSessionIsPerReader()`) actually correct?**
|
- **Are the 6 inferred relationships involving `newTestStore()` (e.g. with `TestCreateAndGetSession()` and `TestDeleteSessionIsPerReader()`) actually correct?**
|
||||||
_`newTestStore()` has 6 INFERRED edges - model-reasoned connections that need verification._
|
_`newTestStore()` has 6 INFERRED edges - model-reasoned connections that need verification._
|
||||||
- **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?**
|
- **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?**
|
||||||
_588 weakly-connected nodes found - possible documentation gaps or missing edges._
|
_589 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||||
File diff suppressed because one or more lines are too long
+826
-437
File diff suppressed because it is too large
Load Diff
+31
-26
@@ -140,7 +140,7 @@
|
|||||||
"semantic_hash": "3a08979e4603aae5c32a58d5b6c39765"
|
"semantic_hash": "3a08979e4603aae5c32a58d5b6c39765"
|
||||||
},
|
},
|
||||||
"CLAUDE.md": {
|
"CLAUDE.md": {
|
||||||
"mtime": 1786421860.5298452,
|
"mtime": 1786446007.0443642,
|
||||||
"ast_hash": "8f79ab45cb9c6466da7c4b65f0bceadf",
|
"ast_hash": "8f79ab45cb9c6466da7c4b65f0bceadf",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
"semantic_hash": "e69a8340a371579ca3ea689660f7d7bd"
|
"semantic_hash": "e69a8340a371579ca3ea689660f7d7bd"
|
||||||
},
|
},
|
||||||
"AGENTS.md": {
|
"AGENTS.md": {
|
||||||
"mtime": 1786421860.5298452,
|
"mtime": 1786446007.0443642,
|
||||||
"ast_hash": "8f79ab45cb9c6466da7c4b65f0bceadf",
|
"ast_hash": "8f79ab45cb9c6466da7c4b65f0bceadf",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
@@ -255,9 +255,9 @@
|
|||||||
"semantic_hash": "e900f92971486f47d7ef76e9a95217fe"
|
"semantic_hash": "e900f92971486f47d7ef76e9a95217fe"
|
||||||
},
|
},
|
||||||
"backend/internal/latest/fetch.go": {
|
"backend/internal/latest/fetch.go": {
|
||||||
"mtime": 1785873769.5475056,
|
"mtime": 1786446006.9219902,
|
||||||
"ast_hash": "398a3dc942a2cdbef6786d9868acb216",
|
"ast_hash": "3e20ad86aa46783e9aa95c2b746551ee",
|
||||||
"semantic_hash": "398a3dc942a2cdbef6786d9868acb216"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/internal/latest/poller.go": {
|
"backend/internal/latest/poller.go": {
|
||||||
"mtime": 1786363889.5565126,
|
"mtime": 1786363889.5565126,
|
||||||
@@ -265,19 +265,19 @@
|
|||||||
"semantic_hash": "ebb0ee4e57d62b6aed97b8d266639a23"
|
"semantic_hash": "ebb0ee4e57d62b6aed97b8d266639a23"
|
||||||
},
|
},
|
||||||
"backend/internal/latest/poller_test.go": {
|
"backend/internal/latest/poller_test.go": {
|
||||||
"mtime": 1786363889.5602942,
|
"mtime": 1786446006.9219902,
|
||||||
"ast_hash": "d14b179fb7eaab2885374b0aa9cf6967",
|
"ast_hash": "91151a7671a3fb1efc957137cfc6839b",
|
||||||
"semantic_hash": "d14b179fb7eaab2885374b0aa9cf6967"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/internal/latest/sites.go": {
|
"backend/internal/latest/sites.go": {
|
||||||
"mtime": 1786363889.5602942,
|
"mtime": 1786446006.9219902,
|
||||||
"ast_hash": "3c1dfa79e1d50d43f5d4b26f0e612913",
|
"ast_hash": "a424fb3b3e1c5afb4ab545c2e88b167c",
|
||||||
"semantic_hash": "3c1dfa79e1d50d43f5d4b26f0e612913"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/internal/latest/sites_test.go": {
|
"backend/internal/latest/sites_test.go": {
|
||||||
"mtime": 1786363889.5602942,
|
"mtime": 1786446006.9219902,
|
||||||
"ast_hash": "da1bfc7239936ae1fa70fcaf37d4106b",
|
"ast_hash": "eabca9014a306e3c71d238b0ae499f61",
|
||||||
"semantic_hash": "da1bfc7239936ae1fa70fcaf37d4106b"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/internal/latest/smoke_image_test.go": {
|
"backend/internal/latest/smoke_image_test.go": {
|
||||||
"mtime": 1786363889.5602942,
|
"mtime": 1786363889.5602942,
|
||||||
@@ -395,14 +395,14 @@
|
|||||||
"semantic_hash": "8008a187690764436540fab47ba0cfcc"
|
"semantic_hash": "8008a187690764436540fab47ba0cfcc"
|
||||||
},
|
},
|
||||||
"userscript/novel-bookmark.user.js": {
|
"userscript/novel-bookmark.user.js": {
|
||||||
"mtime": 1786363889.5716388,
|
"mtime": 1786446006.925938,
|
||||||
"ast_hash": "3c8c241f7b67ddb00d2fc5f9c5b927c7",
|
"ast_hash": "64f8d2848294c7aeb8b2e9d43a049fd0",
|
||||||
"semantic_hash": "3c8c241f7b67ddb00d2fc5f9c5b927c7"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"userscript/test/novel-logic.test.js": {
|
"userscript/test/novel-logic.test.js": {
|
||||||
"mtime": 1786363889.5716388,
|
"mtime": 1786446006.925938,
|
||||||
"ast_hash": "c54599d4beb533b5c522bf48ebacea1f",
|
"ast_hash": "be732600e57fe4ad2e585e9c84d6f341",
|
||||||
"semantic_hash": "c54599d4beb533b5c522bf48ebacea1f"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
".opencode/agent/implementer.md": {
|
".opencode/agent/implementer.md": {
|
||||||
"mtime": 1785873769.5402634,
|
"mtime": 1785873769.5402634,
|
||||||
@@ -510,14 +510,14 @@
|
|||||||
"semantic_hash": "69114d07ed792d6bb1d13758ba5435e1"
|
"semantic_hash": "69114d07ed792d6bb1d13758ba5435e1"
|
||||||
},
|
},
|
||||||
"userscript/AGENTS.md": {
|
"userscript/AGENTS.md": {
|
||||||
"mtime": 1786363889.5678573,
|
"mtime": 1786446006.9219902,
|
||||||
"ast_hash": "36ec1d31cb1f4f6a0263f570a146ecfa",
|
"ast_hash": "954caa07b01ab323ab205e29f0bcacd8",
|
||||||
"semantic_hash": "36ec1d31cb1f4f6a0263f570a146ecfa"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"userscript/CLAUDE.md": {
|
"userscript/CLAUDE.md": {
|
||||||
"mtime": 1786363889.5678573,
|
"mtime": 1786446006.9219902,
|
||||||
"ast_hash": "36ec1d31cb1f4f6a0263f570a146ecfa",
|
"ast_hash": "954caa07b01ab323ab205e29f0bcacd8",
|
||||||
"semantic_hash": "36ec1d31cb1f4f6a0263f570a146ecfa"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/internal/web/static/login-art.png": {
|
"backend/internal/web/static/login-art.png": {
|
||||||
"mtime": 1786022513.9585779,
|
"mtime": 1786022513.9585779,
|
||||||
@@ -603,5 +603,10 @@
|
|||||||
"mtime": 1786417841.7163916,
|
"mtime": 1786417841.7163916,
|
||||||
"ast_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53",
|
"ast_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53",
|
||||||
"semantic_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53"
|
"semantic_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53"
|
||||||
|
},
|
||||||
|
"backend/internal/latest/smoke_lnw_test.go": {
|
||||||
|
"mtime": 1786446006.9219902,
|
||||||
|
"ast_hash": "2d65da8a081759172918fdf159760f45",
|
||||||
|
"semantic_hash": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,9 +70,13 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
|
|||||||
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. The chapter path's slug is a
|
||||||
title on a series page and `<Title> Chapter <n>` on a chapter page. Its series
|
Chapter Slug, not an identity: the Series address is read off the page's
|
||||||
page lists every chapter with an
|
`a[aria-label='All Chapter']` (fallback: the BreadcrumbList's second crumb),
|
||||||
|
and a Series may publish under several Chapter Slugs. A chapter page with no
|
||||||
|
pointer resolves to `other`, so no Bookmark is offered. `h1.entry-title` is
|
||||||
|
the clean title on a series page and `<Title> Chapter <n>` on a chapter page.
|
||||||
|
Its series 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`
|
||||||
|
|||||||
@@ -132,9 +132,30 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The host shape this adapter owns. One definition so the pointer validator
|
||||||
|
// and `matches` cannot drift apart (a leading subdomain is allowed).
|
||||||
|
const lnwHostRe = /(^|\.)lightnovelworld\.net$/;
|
||||||
|
|
||||||
|
// A chapter page's pointer is its own link back to its Series. The href is
|
||||||
|
// page markup, so validate before trusting: resolve it against the page
|
||||||
|
// address, require the host above and the /novel/<slug>/ Series path.
|
||||||
|
// Anything else is not a pointer.
|
||||||
|
function seriesIdFromLnwPointer(href, base) {
|
||||||
|
if (!href) return null;
|
||||||
|
let u;
|
||||||
|
try {
|
||||||
|
u = new URL(href, base);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!lnwHostRe.test(u.hostname)) return null;
|
||||||
|
const m = u.pathname.match(/^\/novel\/([^/]+)\/?$/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
const lightnovelworld = {
|
const lightnovelworld = {
|
||||||
site: "lightnovelworld",
|
site: "lightnovelworld",
|
||||||
matches: (loc) => /(^|\.)lightnovelworld\.net$/.test(loc.hostname),
|
matches: (loc) => lnwHostRe.test(loc.hostname),
|
||||||
detect(loc) {
|
detect(loc) {
|
||||||
const path = loc.pathname;
|
const path = loc.pathname;
|
||||||
// /<slug>-chapter-<n>/ — flat, at the site root, not under /novel/. The
|
// /<slug>-chapter-<n>/ — flat, at the site root, not under /novel/. The
|
||||||
@@ -142,16 +163,38 @@
|
|||||||
// words still resolves to the right series.
|
// words still resolves to the right series.
|
||||||
let m = path.match(/^\/(.+)-chapter-([0-9]+(?:\.[0-9]+)?)\/?$/);
|
let m = path.match(/^\/(.+)-chapter-([0-9]+(?:\.[0-9]+)?)\/?$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
|
// The address is not the identity on this Site: the slug in the path
|
||||||
|
// is a Chapter Slug, which can differ from the Series slug and is
|
||||||
|
// never computable from it. The Series address is read from the
|
||||||
|
// page's own pointer — a silent fallback to derivation is the defect
|
||||||
|
// this replaced, not a safety net.
|
||||||
|
const chapterSlug = m[1];
|
||||||
|
const pointer = document.querySelector("a[aria-label='All Chapter']");
|
||||||
|
let seriesId = pointer ? seriesIdFromLnwPointer(pointer.getAttribute("href"), loc.href) : null;
|
||||||
|
if (!seriesId) {
|
||||||
|
// Fallback: the microdata breadcrumb's second crumb is the Series.
|
||||||
|
// Scoped to the BreadcrumbList because itemprop="item" is not
|
||||||
|
// unique to it (the header nav uses microdata too).
|
||||||
|
const crumb = document.querySelector(
|
||||||
|
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]'
|
||||||
|
);
|
||||||
|
seriesId = crumb ? seriesIdFromLnwPointer(crumb.getAttribute("href"), loc.href) : null;
|
||||||
|
}
|
||||||
|
// Neither pointer present, nor either pointing at a /novel/<slug>/
|
||||||
|
// address on this host: not a page the script understands, so no
|
||||||
|
// Bookmark under an invented identity.
|
||||||
|
if (!seriesId) return { type: "other" };
|
||||||
const num = parseFloat(m[2]);
|
const num = parseFloat(m[2]);
|
||||||
const h1 = document.querySelector("h1.entry-title");
|
const h1 = document.querySelector("h1.entry-title");
|
||||||
const heading = h1 ? h1.textContent || "" : "";
|
const heading = h1 ? h1.textContent || "" : "";
|
||||||
return {
|
return {
|
||||||
type: "chapter",
|
type: "chapter",
|
||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: m[1],
|
seriesId: seriesId,
|
||||||
|
chapterSlug: chapterSlug,
|
||||||
// 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(),
|
||||||
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
seriesUrl: "https://lightnovelworld.net/novel/" + seriesId + "/",
|
||||||
chapterLabel: "Chapter " + m[2],
|
chapterLabel: "Chapter " + m[2],
|
||||||
chapterNum: isNaN(num) ? null : num,
|
chapterNum: isNaN(num) ? null : num,
|
||||||
chapterUrl: loc.href,
|
chapterUrl: loc.href,
|
||||||
@@ -165,6 +208,7 @@
|
|||||||
type: "series",
|
type: "series",
|
||||||
site: this.site,
|
site: this.site,
|
||||||
seriesId: m[1],
|
seriesId: m[1],
|
||||||
|
chapterSlug: null,
|
||||||
title: h1 ? (h1.textContent || "").trim() : "",
|
title: h1 ? (h1.textContent || "").trim() : "",
|
||||||
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
||||||
chapterLabel: null,
|
chapterLabel: null,
|
||||||
@@ -200,6 +244,102 @@
|
|||||||
return ADAPTERS.find((a) => a.site === site) || null;
|
return ADAPTERS.find((a) => a.site === site) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stale lightnovelworld row is one keyed under the Chapter Slug its
|
||||||
|
// address was built from, while the page's pointer names a different
|
||||||
|
// Series. All three storage sites must move together: the cache row alone
|
||||||
|
// leaves a queued write replaying under a key whose row no longer exists
|
||||||
|
// (that write is silently lost), and a last-checked timestamp left behind
|
||||||
|
// re-fetches the repaired row on the next visit. A repair must never cost a
|
||||||
|
// Reader the chapter they were tracking, so when a row already holds the
|
||||||
|
// repaired key the two rows merge and the farther-ahead progress wins.
|
||||||
|
// Pure: no storage access, no module state, no clock.
|
||||||
|
function repairLnwStaleRow(list, queue, lastChecked, page) {
|
||||||
|
if (
|
||||||
|
!page ||
|
||||||
|
page.site !== "lightnovelworld" ||
|
||||||
|
!page.chapterSlug ||
|
||||||
|
!page.seriesId ||
|
||||||
|
page.chapterSlug === page.seriesId
|
||||||
|
) {
|
||||||
|
return { list, queue, lastChecked };
|
||||||
|
}
|
||||||
|
const oldKey = "lightnovelworld:" + page.chapterSlug;
|
||||||
|
const newKey = "lightnovelworld:" + page.seriesId;
|
||||||
|
const stale = list.find((b) => b.key === oldKey);
|
||||||
|
if (!stale) return { list, queue, lastChecked };
|
||||||
|
|
||||||
|
let listOut;
|
||||||
|
const existing = list.find((b) => b.key === newKey);
|
||||||
|
if (existing) {
|
||||||
|
// Duplicate case (spec user story 2): one row must survive, and it is
|
||||||
|
// the one already under the repaired key — with the stale row's
|
||||||
|
// progress carried across when it is ahead, favourite OR'd, and the
|
||||||
|
// stronger lifecycle bucket kept (finished > archived > reading, so a
|
||||||
|
// merge can never silently un-archive or un-finish a row).
|
||||||
|
const merged = Object.assign({}, existing);
|
||||||
|
if (
|
||||||
|
existing.last_chapter_num == null ||
|
||||||
|
(stale.last_chapter_num != null && stale.last_chapter_num > existing.last_chapter_num)
|
||||||
|
) {
|
||||||
|
merged.last_chapter = stale.last_chapter;
|
||||||
|
merged.last_chapter_num = stale.last_chapter_num;
|
||||||
|
merged.last_chapter_url = stale.last_chapter_url;
|
||||||
|
}
|
||||||
|
merged.favorite = !!(existing.favorite || stale.favorite);
|
||||||
|
const rank = (s) => ({ finished: 2, archived: 1, reading: 0 }[s || "reading"] || 0);
|
||||||
|
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
|
||||||
|
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
|
||||||
|
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
|
||||||
|
} else {
|
||||||
|
listOut = list.map((b) =>
|
||||||
|
b.key === oldKey
|
||||||
|
? Object.assign({}, b, {
|
||||||
|
key: newKey,
|
||||||
|
series_id: page.seriesId,
|
||||||
|
series_url: page.seriesUrl,
|
||||||
|
})
|
||||||
|
: b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queueOut = queue;
|
||||||
|
const oldEntry = queue.find((e) => e.key === oldKey);
|
||||||
|
if (oldEntry) {
|
||||||
|
const survivor = queue.find((x) => x.key === newKey);
|
||||||
|
if (!survivor) {
|
||||||
|
queueOut = queue.map((e) => (e.key === oldKey ? Object.assign({}, e, { key: newKey }) : e));
|
||||||
|
} else {
|
||||||
|
// Both keys hold a marker and one row survives, so the two collapse
|
||||||
|
// into one entry under the repaired key (the queue's one-entry-per-key
|
||||||
|
// invariant). sendStatus is sticky — an archive intent from either
|
||||||
|
// marker survives, the queue's own rule — and the worse attempts
|
||||||
|
// count wins. The stale-key marker's op is dropped: the row it
|
||||||
|
// described is retired by the merge itself.
|
||||||
|
queueOut = queue
|
||||||
|
.filter((e) => e.key !== oldKey && e.key !== newKey)
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
key: newKey,
|
||||||
|
op: survivor.op,
|
||||||
|
sendStatus: survivor.sendStatus || oldEntry.sendStatus,
|
||||||
|
attempts: Math.max(survivor.attempts || 0, oldEntry.attempts || 0),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastCheckedOut = lastChecked;
|
||||||
|
if (oldKey in lastChecked) {
|
||||||
|
lastCheckedOut = Object.assign({}, lastChecked);
|
||||||
|
// Max, not overwrite: a timestamp already under the repaired key must
|
||||||
|
// not be rolled back to an older one.
|
||||||
|
lastCheckedOut[newKey] = Math.max(lastCheckedOut[newKey] || 0, lastCheckedOut[oldKey]);
|
||||||
|
delete lastCheckedOut[oldKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { list: listOut, queue: queueOut, lastChecked: lastCheckedOut };
|
||||||
|
}
|
||||||
|
|
||||||
// Highest chapter the site lists, or null when the markup yields nothing.
|
// Highest chapter the site lists, or null when the markup yields nothing.
|
||||||
// seriesId is only consulted by adapters whose pages carry other series'
|
// seriesId is only consulted by adapters whose pages carry other series'
|
||||||
// chapter links; the rest ignore it.
|
// chapter links; the rest ignore it.
|
||||||
@@ -1199,7 +1339,8 @@
|
|||||||
// Refresh + navigation
|
// Refresh + navigation
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh(awaitFirst) {
|
||||||
|
if (awaitFirst) await awaitFirst; // repair sync lands before we adopt the server's view of it
|
||||||
await drain(); // push what we owe before adopting the server's view of it
|
await drain(); // push what we owe before adopting the server's view of it
|
||||||
loading = true;
|
loading = true;
|
||||||
render();
|
render();
|
||||||
@@ -1214,9 +1355,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs the repair on every navigation, silently: rewrites a stale
|
||||||
|
// lightnovelworld row to the page's discovered identity, persists all three
|
||||||
|
// sites, and syncs through the queue-backed path so an offline repair parks
|
||||||
|
// and replays later. No toast — the Reader never asked for this. Returns
|
||||||
|
// the sync promise (or null when nothing changed) so init can hold the
|
||||||
|
// server view until the repair has landed. When the transform returns its
|
||||||
|
// inputs unchanged there is nothing to do.
|
||||||
|
function applyLnwStaleRowRepair() {
|
||||||
|
const lastChecked = loadLastChecked();
|
||||||
|
const out = repairLnwStaleRow(state.list, queue, lastChecked, state.page);
|
||||||
|
if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
state.list = out.list;
|
||||||
|
reindex(); // byKey answers under the old key until rebuilt
|
||||||
|
saveCache(state.list);
|
||||||
|
queue.splice(0, queue.length, ...out.queue); // closures hold this array instance
|
||||||
|
saveQueue(queue);
|
||||||
|
saveLastChecked(out.lastChecked);
|
||||||
|
// Queue-backed sync, outcome swallowed: the repaired row is PUT (with its
|
||||||
|
// bucket when archived — the only status a userscript write may send) and
|
||||||
|
// the old server bookmark is deleted so no duplicate survives on the
|
||||||
|
// wire. The delete parks under the retired key while offline — the
|
||||||
|
// teardown of the old identity, not a new record of the Chapter Slug.
|
||||||
|
const key = keyOf(state.page);
|
||||||
|
return Promise.all([
|
||||||
|
pushBookmark(key, statusOf(state.byKey[key]) === "archived"),
|
||||||
|
pushDelete("lightnovelworld:" + state.page.chapterSlug),
|
||||||
|
]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
let lastUrl = location.href;
|
let lastUrl = location.href;
|
||||||
function onNavigate() {
|
function onNavigate() {
|
||||||
state.page = detect();
|
state.page = detect();
|
||||||
|
applyLnwStaleRowRepair();
|
||||||
render();
|
render();
|
||||||
maybeAutoUpdate();
|
maybeAutoUpdate();
|
||||||
maybeCaptureLatestOnSeriesPage();
|
maybeCaptureLatestOnSeriesPage();
|
||||||
@@ -1329,13 +1502,14 @@
|
|||||||
function init() {
|
function init() {
|
||||||
buildUI();
|
buildUI();
|
||||||
state.page = detect();
|
state.page = detect();
|
||||||
|
const repair = applyLnwStaleRowRepair();
|
||||||
render();
|
render();
|
||||||
installNavWatcher();
|
installNavWatcher();
|
||||||
installLongPress();
|
installLongPress();
|
||||||
window.addEventListener("online", drain); // signal returned while the page stayed open
|
window.addEventListener("online", drain); // signal returned while the page stayed open
|
||||||
// Sync first: both auto-record and the latest-chapter checks below need to
|
// Sync first: both auto-record and the latest-chapter checks below need to
|
||||||
// know which series are bookmarked and how fresh they are.
|
// know which series are bookmarked and how fresh they are.
|
||||||
refresh().then(() => {
|
refresh(repair).then(() => {
|
||||||
maybeAutoUpdate();
|
maybeAutoUpdate();
|
||||||
maybeCaptureLatestOnSeriesPage();
|
maybeCaptureLatestOnSeriesPage();
|
||||||
backgroundRefreshLatest();
|
backgroundRefreshLatest();
|
||||||
@@ -1576,7 +1750,7 @@
|
|||||||
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
|
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||||
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe };
|
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, repairLnwStaleRow };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin
|
|||||||
|
|
||||||
let metaTags = {};
|
let metaTags = {};
|
||||||
let elements = {};
|
let elements = {};
|
||||||
|
// Attribute selectors (the lightnovelworld Series pointer) answer with an
|
||||||
|
// element exposing getAttribute, like the meta branch below.
|
||||||
|
let attrEls = {};
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
|
const attr = attrEls[sel];
|
||||||
|
if (attr != null) return attr;
|
||||||
const 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]];
|
||||||
@@ -42,6 +47,7 @@ const {
|
|||||||
lightnovelworld,
|
lightnovelworld,
|
||||||
kindOf,
|
kindOf,
|
||||||
maxChapter,
|
maxChapter,
|
||||||
|
repairLnwStaleRow,
|
||||||
} = require("../novel-bookmark.user.js");
|
} = require("../novel-bookmark.user.js");
|
||||||
|
|
||||||
function loc(href) {
|
function loc(href) {
|
||||||
@@ -52,6 +58,7 @@ function loc(href) {
|
|||||||
function reset() {
|
function reset() {
|
||||||
metaTags = {};
|
metaTags = {};
|
||||||
elements = {};
|
elements = {};
|
||||||
|
attrEls = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -115,11 +122,18 @@ test("lightnovelworld.detect reads a series page", () => {
|
|||||||
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");
|
||||||
|
// A series page's address *is* its identity; there is no Chapter Slug.
|
||||||
|
assert.equal(p.chapterSlug, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
||||||
reset();
|
reset();
|
||||||
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/",
|
||||||
|
},
|
||||||
|
};
|
||||||
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
|
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
|
||||||
const p = lightnovelworld.detect(loc(url));
|
const p = lightnovelworld.detect(loc(url));
|
||||||
assert.equal(p.type, "chapter");
|
assert.equal(p.type, "chapter");
|
||||||
@@ -130,6 +144,170 @@ test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
|||||||
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld.detect reads the Series identity from the page pointer on a chapter page", () => {
|
||||||
|
reset();
|
||||||
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
||||||
|
attrEls = {
|
||||||
|
// Verbatim from the real page (research note §3): the All Chapter anchor
|
||||||
|
// carries the absolute Series address.
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
||||||
|
assert.equal(p.chapterSlug, "a-will-eternal");
|
||||||
|
assert.equal(p.chapterNum, 1298);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld.detect falls back to the breadcrumb when the pointer is absent", () => {
|
||||||
|
reset();
|
||||||
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
||||||
|
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
||||||
|
// Same divergent page, both pointers from the real markup (research note
|
||||||
|
// §3): breadcrumb position 2 must say what the All Chapter anchor says.
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const viaPointer = lightnovelworld.detect(loc(url));
|
||||||
|
attrEls = {
|
||||||
|
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]': {
|
||||||
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const viaBreadcrumb = lightnovelworld.detect(loc(url));
|
||||||
|
assert.equal(viaBreadcrumb.type, "chapter");
|
||||||
|
assert.equal(viaBreadcrumb.seriesId, viaPointer.seriesId);
|
||||||
|
assert.equal(viaBreadcrumb.seriesUrl, viaPointer.seriesUrl);
|
||||||
|
assert.equal(viaBreadcrumb.seriesId, "immortality-simulator");
|
||||||
|
assert.equal(viaBreadcrumb.chapterSlug, "my-longevity-simulation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld.detect resolves a relative pointer against the page address", () => {
|
||||||
|
// Defensive: every measured page ships an absolute pointer href, but a
|
||||||
|
// theme change could go relative — the pointer is still this page's own
|
||||||
|
// link back to its Series.
|
||||||
|
reset();
|
||||||
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": { getAttribute: () => "/novel/a-will-eternal/" },
|
||||||
|
};
|
||||||
|
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/a-will-eternal-chapter-1298/"));
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.seriesId, "a-will-eternal");
|
||||||
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld.detect rejects a pointer that is not a /novel/ address on its own host", () => {
|
||||||
|
// Counterfactual pointers, exercising the criterion that a pointer "present
|
||||||
|
// but not parseable as /novel/<slug>/ on lightnovelworld.net" must not
|
||||||
|
// become a seriesUrl the backend is later asked to Poll: off-host, and
|
||||||
|
// on-host but the wrong path shape.
|
||||||
|
reset();
|
||||||
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
||||||
|
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () => "https://evil.example/novel/immortality-simulator/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(lightnovelworld.detect(loc(url)).type, "other");
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () => "https://lightnovelworld.net/fiction/immortality-simulator/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(lightnovelworld.detect(loc(url)).type, "other");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld.detect resolves to other when the page carries no pointer", () => {
|
||||||
|
reset();
|
||||||
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
||||||
|
const p = lightnovelworld.detect(
|
||||||
|
loc("https://lightnovelworld.net/my-longevity-simulation-chapter-1/")
|
||||||
|
);
|
||||||
|
assert.equal(p.type, "other");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld pins the divergent novel: the pointer's slug wins over the address's", () => {
|
||||||
|
// Regression pin for the derivation defect (research note §2/§3): the
|
||||||
|
// chapter address is built from "my-longevity-simulation" but the Series
|
||||||
|
// is published as "immortality-simulator". If the adapter ever derives the
|
||||||
|
// identity from the address again, this test goes red.
|
||||||
|
reset();
|
||||||
|
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
||||||
|
const p = lightnovelworld.detect(loc(url));
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.seriesId, "immortality-simulator");
|
||||||
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/immortality-simulator/");
|
||||||
|
assert.equal(p.chapterSlug, "my-longevity-simulation");
|
||||||
|
assert.notEqual(p.chapterSlug, p.seriesId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld.detect resolves a novel whose heading ends in a chapter number", () => {
|
||||||
|
// The split novel from research §4.4, chapter 200 (published under the
|
||||||
|
// current slug). Its heading ends "…Not Them All Chapter 200", which would
|
||||||
|
// false-match a selector that looks for the text "All Chapter".
|
||||||
|
reset();
|
||||||
|
elements = {
|
||||||
|
"h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Them All Chapter 200",
|
||||||
|
};
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () =>
|
||||||
|
"https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const url =
|
||||||
|
"https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-200/";
|
||||||
|
const p = lightnovelworld.detect(loc(url));
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
||||||
|
assert.equal(p.title, "All Jobs and Classes I Just Wanted One Skill Not Them All");
|
||||||
|
assert.equal(p.chapterNum, 200);
|
||||||
|
assert.equal(p.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lightnovelworld.detect resolves both Chapter Slugs of the split novel to one Series", () => {
|
||||||
|
// Research note §4.4: this Series serves chapters 1-99 under one Chapter
|
||||||
|
// Slug and 100-423 under another; both chapter addresses are live and both
|
||||||
|
// point back at the same Series. An old deep link must not get a different
|
||||||
|
// identity than a current one.
|
||||||
|
reset();
|
||||||
|
elements = { "h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Chapter 1" };
|
||||||
|
attrEls = {
|
||||||
|
"a[aria-label='All Chapter']": {
|
||||||
|
getAttribute: () =>
|
||||||
|
"https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const oldSlug = lightnovelworld.detect(
|
||||||
|
loc("https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-1/")
|
||||||
|
);
|
||||||
|
const currentSlug = lightnovelworld.detect(
|
||||||
|
loc(
|
||||||
|
"https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-404/"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.equal(oldSlug.type, "chapter");
|
||||||
|
assert.equal(oldSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
||||||
|
assert.equal(oldSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not");
|
||||||
|
assert.equal(currentSlug.type, "chapter");
|
||||||
|
assert.equal(currentSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
||||||
|
assert.equal(currentSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
|
||||||
|
});
|
||||||
|
|
||||||
test("lightnovelworld.detect returns other for non-series paths", () => {
|
test("lightnovelworld.detect returns other for non-series paths", () => {
|
||||||
reset();
|
reset();
|
||||||
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other");
|
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other");
|
||||||
@@ -154,6 +332,213 @@ test("latestChapterFromAnchors returns null when nothing matches", () => {
|
|||||||
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// repairLnwStaleRow — the row migration (spec seam 3)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// A row as bookmarkCurrent builds it, keyed under the invented Chapter Slug
|
||||||
|
// identity the old adapter derived from the chapter address.
|
||||||
|
function staleRow(over) {
|
||||||
|
return Object.assign(
|
||||||
|
{
|
||||||
|
key: "lightnovelworld:my-longevity-simulation",
|
||||||
|
site: "lightnovelworld",
|
||||||
|
kind: "novel",
|
||||||
|
series_id: "my-longevity-simulation",
|
||||||
|
title: "My Longevity Simulation",
|
||||||
|
series_url: "https://lightnovelworld.net/novel/my-longevity-simulation/",
|
||||||
|
last_chapter: "Chapter 7",
|
||||||
|
last_chapter_num: 7,
|
||||||
|
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-7/",
|
||||||
|
updated_at: 1000,
|
||||||
|
},
|
||||||
|
over
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The divergent novel from research §3: the address's slug is a Chapter Slug,
|
||||||
|
// the page pointer names the real Series.
|
||||||
|
function lnwPage(over) {
|
||||||
|
return Object.assign(
|
||||||
|
{
|
||||||
|
type: "chapter",
|
||||||
|
site: "lightnovelworld",
|
||||||
|
seriesId: "immortality-simulator",
|
||||||
|
chapterSlug: "my-longevity-simulation",
|
||||||
|
seriesUrl: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
|
},
|
||||||
|
over
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("repairLnwStaleRow rewrites the row, the queue entry and the last-checked map together", () => {
|
||||||
|
const list = [staleRow()];
|
||||||
|
const queue = [
|
||||||
|
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: false, attempts: 2 },
|
||||||
|
];
|
||||||
|
const lastChecked = { "lightnovelworld:my-longevity-simulation": 12345 };
|
||||||
|
const out = repairLnwStaleRow(list, queue, lastChecked, lnwPage());
|
||||||
|
assert.equal(out.list.length, 1);
|
||||||
|
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
|
||||||
|
assert.equal(out.list[0].series_id, "immortality-simulator");
|
||||||
|
assert.equal(out.list[0].series_url, "https://lightnovelworld.net/novel/immortality-simulator/");
|
||||||
|
assert.deepEqual(out.queue, [
|
||||||
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 2 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 12345 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow keeps Progress, Favourite and Lifecycle bucket on the rewritten row", () => {
|
||||||
|
const stale = staleRow({
|
||||||
|
last_chapter: "Chapter 42",
|
||||||
|
last_chapter_num: 42,
|
||||||
|
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
|
||||||
|
favorite: true,
|
||||||
|
status: "archived",
|
||||||
|
updated_at: 777,
|
||||||
|
});
|
||||||
|
const out = repairLnwStaleRow([stale], [], {}, lnwPage());
|
||||||
|
const b = out.list[0];
|
||||||
|
assert.equal(b.last_chapter, "Chapter 42");
|
||||||
|
assert.equal(b.last_chapter_num, 42);
|
||||||
|
assert.equal(
|
||||||
|
b.last_chapter_url,
|
||||||
|
"https://lightnovelworld.net/my-longevity-simulation-chapter-42/"
|
||||||
|
);
|
||||||
|
assert.equal(b.favorite, true);
|
||||||
|
assert.equal(b.status, "archived");
|
||||||
|
assert.equal(b.updated_at, 777);
|
||||||
|
assert.equal(b.title, "My Longevity Simulation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow changes nothing when the Chapter Slug and Series slug agree", () => {
|
||||||
|
const list = [
|
||||||
|
staleRow({ key: "lightnovelworld:a-will-eternal", series_id: "a-will-eternal" }),
|
||||||
|
];
|
||||||
|
const queue = [{ key: "lightnovelworld:a-will-eternal", op: "put", sendStatus: true, attempts: 1 }];
|
||||||
|
const lastChecked = { "lightnovelworld:a-will-eternal": 99 };
|
||||||
|
const out = repairLnwStaleRow(
|
||||||
|
list,
|
||||||
|
queue,
|
||||||
|
lastChecked,
|
||||||
|
lnwPage({
|
||||||
|
chapterSlug: "a-will-eternal",
|
||||||
|
seriesId: "a-will-eternal",
|
||||||
|
seriesUrl: "https://lightnovelworld.net/novel/a-will-eternal/",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.equal(out.list, list); // same references, nothing rewritten
|
||||||
|
assert.equal(out.queue, queue);
|
||||||
|
assert.equal(out.lastChecked, lastChecked);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow changes nothing when no row sits under the old key", () => {
|
||||||
|
const list = [
|
||||||
|
staleRow({
|
||||||
|
key: "lightnovelworld:immortality-simulator",
|
||||||
|
series_id: "immortality-simulator",
|
||||||
|
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
|
||||||
|
const out = repairLnwStaleRow(list, [], lastChecked, lnwPage());
|
||||||
|
assert.equal(out.list, list);
|
||||||
|
assert.equal(out.queue.length, 0);
|
||||||
|
assert.equal(out.lastChecked, lastChecked);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow ignores a page that carries no Chapter Slug", () => {
|
||||||
|
const list = [
|
||||||
|
staleRow({
|
||||||
|
key: "lightnovelworld:immortality-simulator",
|
||||||
|
series_id: "immortality-simulator",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
|
||||||
|
// A series page (chapterSlug null) and another site both stay untouched.
|
||||||
|
const series = repairLnwStaleRow(list, [], lastChecked, lnwPage({ chapterSlug: null }));
|
||||||
|
const other = repairLnwStaleRow(list, [], lastChecked, {
|
||||||
|
type: "chapter",
|
||||||
|
site: "novelfull",
|
||||||
|
seriesId: "x",
|
||||||
|
});
|
||||||
|
assert.equal(series.list, list);
|
||||||
|
assert.equal(other.list, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow still moves the row and the last-checked map when the queue has no entry", () => {
|
||||||
|
const out = repairLnwStaleRow(
|
||||||
|
[staleRow()],
|
||||||
|
[],
|
||||||
|
{ "lightnovelworld:my-longevity-simulation": 5 },
|
||||||
|
lnwPage()
|
||||||
|
);
|
||||||
|
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
|
||||||
|
assert.deepEqual(out.queue, []);
|
||||||
|
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow carries a queued change to the repaired identity with its marker intact", () => {
|
||||||
|
const queue = [
|
||||||
|
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
|
||||||
|
];
|
||||||
|
const out = repairLnwStaleRow([staleRow()], queue, {}, lnwPage());
|
||||||
|
assert.deepEqual(out.queue, [
|
||||||
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the farther-ahead progress", () => {
|
||||||
|
const canonical = staleRow({
|
||||||
|
key: "lightnovelworld:immortality-simulator",
|
||||||
|
series_id: "immortality-simulator",
|
||||||
|
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
|
last_chapter: "Chapter 5",
|
||||||
|
last_chapter_num: 5,
|
||||||
|
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-5/",
|
||||||
|
favorite: false,
|
||||||
|
});
|
||||||
|
const stale = staleRow({
|
||||||
|
last_chapter: "Chapter 42",
|
||||||
|
last_chapter_num: 42,
|
||||||
|
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
|
||||||
|
favorite: true,
|
||||||
|
status: "archived",
|
||||||
|
});
|
||||||
|
const queue = [
|
||||||
|
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
|
||||||
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 },
|
||||||
|
];
|
||||||
|
const out = repairLnwStaleRow([canonical, stale], queue, {}, lnwPage());
|
||||||
|
assert.equal(out.list.length, 1);
|
||||||
|
const merged = out.list[0];
|
||||||
|
assert.equal(merged.key, "lightnovelworld:immortality-simulator");
|
||||||
|
assert.equal(merged.last_chapter_num, 42); // the stale row is ahead — progress must not be lost
|
||||||
|
assert.equal(merged.favorite, true); // favourite survives from either row
|
||||||
|
assert.equal(merged.status, "archived"); // the stronger bucket survives
|
||||||
|
// One marker under the repaired key: sendStatus is sticky (the archive
|
||||||
|
// intent from the stale-key marker survives) and the worse attempts count
|
||||||
|
// wins — the queue's own coalescing rules.
|
||||||
|
assert.deepEqual(out.queue, [
|
||||||
|
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairLnwStaleRow does not regress progress when the repaired-key row is ahead", () => {
|
||||||
|
const canonical = staleRow({
|
||||||
|
key: "lightnovelworld:immortality-simulator",
|
||||||
|
series_id: "immortality-simulator",
|
||||||
|
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
|
last_chapter: "Chapter 100",
|
||||||
|
last_chapter_num: 100,
|
||||||
|
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-100/",
|
||||||
|
});
|
||||||
|
const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42 });
|
||||||
|
const out = repairLnwStaleRow([canonical, stale], [], {}, lnwPage());
|
||||||
|
assert.equal(out.list.length, 1);
|
||||||
|
assert.equal(out.list[0].last_chapter_num, 100);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// kindOf
|
// kindOf
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user