Unscope lnw chapter scan, truncate at comment thread (#87)

The Poll scoped the lightnovelworld scan to the slug derived from the
stored address, so a Series publishing chapters under more than one
Chapter Slug froze its Latest Chapter. The branch now matches any
chapter-shaped address on the host (lnwChapterRe) and scans only the
body before the wpdiscuz thread marker (wpd-threads); a body without
the marker is skipped and logged with its length, never scanned whole.
The size-cap comment now states the measured ~3.5x headroom.
This commit is contained in:
2026-08-11 12:59:21 +07:00
parent e864770eb8
commit ecce453d13
3 changed files with 63 additions and 20 deletions
+3 -2
View File
@@ -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
+37 -12
View File
@@ -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,11 @@ 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 for a stronger reason: its chapter addresses
// carry the Chapter Slug, which is not the Series identity, so no stored-slug
// pattern can cover a Series' whole list (see lnwChapterRe). Instead of
// scoping, its branch scans only the body before the comment thread marker and
// skips the page when the marker is absent.
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 +147,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 body length distinguishes a genuine 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; skipping", 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
} }
+23 -6
View File
@@ -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
@@ -408,19 +411,33 @@ 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 (that is the next case's job); the marker is
// kept, because a body without it is skipped, not scanned.
{ {
name: "lightnovelworld takes the max on a split-slug novel", name: "lightnovelworld max spans both chapter slugs",
site: "lightnovelworld",
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not/",
body: strings.SplitN(lnwSeriesFixture, lnwCommentMarker, 2)[0] + lnwCommentMarker,
wantOK: true, wantNum: 423, wantLabel: "Chapter 423",
},
{
name: "lightnovelworld comment anchor cannot set the latest chapter",
site: "lightnovelworld", site: "lightnovelworld",
seriesURL: "https://lightnovelworld.net/novel/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/",
body: lnwSeriesFixture, body: lnwSeriesFixture,
wantOK: true, wantNum: 423, wantLabel: "Chapter 423", 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 tolerates a series url with no trailing slash", name: "lightnovelworld body without the comment marker is skipped",
site: "lightnovelworld", site: "lightnovelworld",
seriesURL: "https://lightnovelworld.net/novel/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/",
body: lnwSeriesFixture, body: strings.ReplaceAll(lnwSeriesFixture, lnwCommentMarker, ""),
wantOK: true, wantNum: 423, wantLabel: "Chapter 423", wantOK: false,
}, },
{ {
name: "lightnovelworld yields nothing on a challenge page", name: "lightnovelworld yields nothing on a challenge page",