diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index 8e5425c..78d14e0 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -1,6 +1,7 @@ package latest import ( + "net/url" "regexp" "strconv" "strings" @@ -34,6 +35,17 @@ var comixSlugRe = regexp.MustCompile(`/title/([^/?#]+)`) // there are no anchors to scan. var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`) +// novelfullSlugRe pulls the series slug out of a stored series_url. novelfull +// series pages are "/.html"; their chapter anchors are +// "//chapter-[-].html". Verified live 2026-08-05. +var novelfullSlugRe = regexp.MustCompile(`^/([^/?#]+)\.html$`) + +// lnwSlugRe does the same for lightnovelworld, whose series pages live under +// /novel// while its chapter URLs are flat at the site root: +// "/-chapter-/", absolute in the page's own anchors. Verified live +// 2026-08-05. +var lnwSlugRe = regexp.MustCompile(`^/novel/([^/?#]+)/?$`) + // latestChapterFrom returns the highest chapter number body advertises for this // series. ok is false when the body yields nothing usable — an unknown site, an // empty body, a Cloudflare challenge page, and a site redesign all land here, @@ -86,6 +98,29 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) { re = regexp.MustCompile(`"latestChapterUrl":"/title/` + regexp.QuoteMeta(id) + `-[^"]*-chapter-([0-9.]+)"`) case "kagane": re = kaganeChapterRe + case "novelfull": + u, err := url.Parse(seriesURL) + if err != nil { + return latestChapter{}, false + } + m := novelfullSlugRe.FindStringSubmatch(u.Path) + if m == nil { + return latestChapter{}, false + } + // Scoped to this series' slug for the same reason asura is: page 1 + // carries a "latest chapters" widget and a "you may also like" strip, + // 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 { + 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.]+)/`) default: return latestChapter{}, false } diff --git a/backend/internal/latest/sites_test.go b/backend/internal/latest/sites_test.go index a2bb82d..49423ad 100644 --- a/backend/internal/latest/sites_test.go +++ b/backend/internal/latest/sites_test.go @@ -52,6 +52,33 @@ const kaganeAPIFixture = ` {"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]} ` +// Trimmed from https://novelfull.com/reverend-insanity.html fetched 2026-08-05. +// The page carries a newest-first "latest chapters" widget above an +// oldest-first paginated list, so the newest anchor is deliberately NOT last — +// only a maximum finds it. The final anchor belongs to another series and must +// be excluded by slug scoping. +const novelfullSeriesFixture = ` + + +Chapter 9999 +` + +// Trimmed from https://lightnovelworld.net/novel/a-will-eternal/ fetched +// 2026-08-05. Its chapter anchors are absolute and flat — /-chapter-/ +// at the site root, not under /novel/. The last anchor is another series'. +const lnwSeriesFixture = ` +Chapter 1 +Chapter 1317 +Chapter 1298 +Chapter 9999 +` + func TestLatestChapterFrom(t *testing.T) { const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer" @@ -146,6 +173,48 @@ func TestLatestChapterFrom(t *testing.T) { body: challengeFixture, wantOK: false, }, + { + name: "novelfull takes the max and ignores another series", + site: "novelfull", + seriesURL: "https://novelfull.com/reverend-insanity.html", + body: novelfullSeriesFixture, + wantOK: true, wantNum: 2334, wantLabel: "Chapter 2334", + }, + { + name: "novelfull yields nothing on a challenge page", + site: "novelfull", + seriesURL: "https://novelfull.com/reverend-insanity.html", + body: challengeFixture, + wantOK: false, + }, + { + name: "novelfull with an unparseable series url", + site: "novelfull", + seriesURL: "https://novelfull.com/genre/Fantasy", + body: novelfullSeriesFixture, + wantOK: false, + }, + { + name: "lightnovelworld takes the max and ignores another series", + site: "lightnovelworld", + seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/", + body: lnwSeriesFixture, + wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317", + }, + { + name: "lightnovelworld tolerates a series url with no trailing slash", + site: "lightnovelworld", + seriesURL: "https://lightnovelworld.net/novel/a-will-eternal", + body: lnwSeriesFixture, + wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317", + }, + { + name: "lightnovelworld yields nothing on a challenge page", + site: "lightnovelworld", + seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/", + body: challengeFixture, + wantOK: false, + }, } for _, tt := range tests {