Merge ticket/87-lnw-scan-unscoped (#87)
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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; 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",
|
||||||
|
|||||||
Reference in New Issue
Block a user