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:
@@ -3,6 +3,7 @@ package latest
|
||||
import (
|
||||
"encoding/json"
|
||||
"html"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -58,11 +59,26 @@ var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`)
|
||||
// "/<slug>/chapter-<n>[-<title-slug>].html". Verified live 2026-08-05.
|
||||
var novelfullSlugRe = regexp.MustCompile(`^/([^/?#]+)\.html$`)
|
||||
|
||||
// lnwSlugRe does the same for lightnovelworld, whose series pages live under
|
||||
// /novel/<slug>/ while its chapter URLs are flat at the site root:
|
||||
// "/<slug>-chapter-<n>/", absolute in the page's own anchors. Verified live
|
||||
// 2026-08-05.
|
||||
var lnwSlugRe = regexp.MustCompile(`^/novel/([^/?#]+)/?$`)
|
||||
// lnwChapterRe matches any chapter-shaped address on lightnovelworld. Unlike
|
||||
// asura, novelfull and comix — which scope to their stored series slug so a
|
||||
// foreign chapter link cannot contribute — this Site's chapter addresses carry
|
||||
// the Chapter Slug, which is not the Series identity: one Series may publish
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// lightnovelworld is unscoped and body-truncated instead — see lnwChapterRe.
|
||||
func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
||||
var re *regexp.Regexp
|
||||
switch site {
|
||||
@@ -126,15 +143,19 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
||||
// and neither may contribute to the maximum.
|
||||
re = regexp.MustCompile(`/` + regexp.QuoteMeta(m[1]) + `/chapter-([0-9.]+)`)
|
||||
case "lightnovelworld":
|
||||
u, err := url.Parse(seriesURL)
|
||||
if err != nil {
|
||||
// The comment thread below the chapter list is the one region of the
|
||||
// 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
|
||||
}
|
||||
m := lnwSlugRe.FindStringSubmatch(u.Path)
|
||||
if m == nil {
|
||||
return latestChapter{}, false
|
||||
}
|
||||
re = regexp.MustCompile(`lightnovelworld\.net/` + regexp.QuoteMeta(m[1]) + `-chapter-([0-9.]+)/`)
|
||||
body = body[:i]
|
||||
re = lnwChapterRe
|
||||
default:
|
||||
return latestChapter{}, false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user