feat: add Kokoro TTS, ranking page, direct HTTP strategy, and chapter-number fix

- Add Kokoro-FastAPI TTS integration to the chapter reader UI:
  - Browser-side MSE streaming with paragraph-level click-to-start
  - Voice selector, speed slider, auto-next with prefetch of the next chapter
  - New GET /ui/chapter-text endpoint that strips Markdown and serves plain text

- Add ranking page (novelfire /ranking scraper, WriteRanking/ReadRankingItems
  in writer, GET /ranking + POST /ranking/refresh + GET /ranking/view routes)
  with local-library annotation and one-click scrape buttons

- Add StrategyDirect (plain HTTP client) as a new browser strategy; the
  default strategy is now 'direct' for chapter fetching and 'content'
  for chapter-list URL retrieval (split via BROWSERLESS_URL_STRATEGY)

- Fix chapter numbering bug: numbers are now derived from the URL path
  (/chapter-N) rather than list position, correcting newest-first ordering

- Add 'refresh <slug>' CLI sub-command to re-scrape a book from its saved
  source_url without knowing the original URL

- Extend NovelScraper interface with RankingProvider (ScrapeRanking)

- Tune scraper timeouts: wait-for-selector reduced to 5 s, GotoOptions
  timeout set to 60 s, content/scrape client defaults raised to 90 s

- Add cover extraction fix (figure.cover > img rather than bare img.cover)

- Add AGENTS.md and .aiignore for AI tooling context

- Add integration tests for browser client and novelfire scraper (build
  tag: integration) and unit tests for chapterNumberFromURL and pagination
This commit is contained in:
Admin
2026-03-01 12:25:16 +05:00
parent e6e6f7dc4d
commit 7879a51fe3
17 changed files with 2816 additions and 88 deletions

View File

@@ -0,0 +1,344 @@
//go:build integration
// Integration tests for the novelfire.net Scraper against a live Browserless instance.
//
// These tests exercise the full scraping stack — Browserless → raw HTML →
// novelfire HTML parser — for the book:
//
// https://novelfire.net/book/a-dragon-against-the-whole-world
//
// They are gated behind the "integration" build tag so they never run in a
// normal `go test ./...` pass.
//
// Run with:
//
// BROWSERLESS_URL=http://localhost:3000 \
// BROWSERLESS_TOKEN=your-token \ # omit if auth is disabled
// go test -v -tags integration -timeout 600s \
// github.com/libnovel/scraper/internal/novelfire
package novelfire
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/libnovel/scraper/internal/browser"
"github.com/libnovel/scraper/internal/scraper"
)
const (
integrationBookURL = "https://novelfire.net/book/a-dragon-against-the-whole-world"
integrationBookSlug = "a-dragon-against-the-whole-world"
integrationBookTitle = "A Dragon against the Whole World"
)
// newIntegrationScraper reads BROWSERLESS_URL / BROWSERLESS_TOKEN from the
// environment, constructs a real contentClient, and returns a novelfire Scraper
// wired to it. The test is skipped when BROWSERLESS_URL is not set.
func newIntegrationScraper(t *testing.T) *Scraper {
t.Helper()
baseURL := os.Getenv("BROWSERLESS_URL")
if baseURL == "" {
t.Skip("BROWSERLESS_URL not set — skipping integration test")
}
client := browser.NewContentClient(browser.Config{
BaseURL: baseURL,
Token: os.Getenv("BROWSERLESS_TOKEN"),
Timeout: 120 * time.Second,
MaxConcurrent: 1,
})
return New(client, nil)
}
// ── Metadata ──────────────────────────────────────────────────────────────────
// TestIntegration_Novelfire_ScrapeMetadata_ReturnsTitle verifies that
// ScrapeMetadata fetches the book page and correctly parses at minimum
// the slug, title, and source URL.
func TestIntegration_Novelfire_ScrapeMetadata_ReturnsTitle(t *testing.T) {
s := newIntegrationScraper(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
meta, err := s.ScrapeMetadata(ctx, integrationBookURL)
if err != nil {
t.Fatalf("ScrapeMetadata failed: %v", err)
}
t.Logf("slug: %s", meta.Slug)
t.Logf("title: %s", meta.Title)
t.Logf("author: %s", meta.Author)
t.Logf("status: %s", meta.Status)
t.Logf("genres: %v", meta.Genres)
t.Logf("total_chapters: %d", meta.TotalChapters)
t.Logf("source_url: %s", meta.SourceURL)
if meta.Slug != integrationBookSlug {
t.Errorf("slug = %q, want %q", meta.Slug, integrationBookSlug)
}
if meta.Title == "" {
t.Error("title is empty")
}
if !strings.EqualFold(meta.Title, integrationBookTitle) {
// Warn rather than hard-fail — the site may reword the title.
t.Logf("WARN: title = %q, expected something like %q", meta.Title, integrationBookTitle)
}
if meta.SourceURL != integrationBookURL {
t.Errorf("source_url = %q, want %q", meta.SourceURL, integrationBookURL)
}
}
// TestIntegration_Novelfire_ScrapeMetadata_ReturnsFullFields verifies that
// every optional field (author, status, genres, summary, total_chapters) is
// populated. A missing field is a warning, not a hard failure, because the
// site may change its HTML structure.
func TestIntegration_Novelfire_ScrapeMetadata_ReturnsFullFields(t *testing.T) {
s := newIntegrationScraper(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
meta, err := s.ScrapeMetadata(ctx, integrationBookURL)
if err != nil {
t.Fatalf("ScrapeMetadata failed: %v", err)
}
type check struct {
field string
empty bool
}
checks := []check{
{"author", meta.Author == ""},
{"status", meta.Status == ""},
{"summary", meta.Summary == ""},
{"genres", len(meta.Genres) == 0},
{"total_chapters", meta.TotalChapters == 0},
}
for _, c := range checks {
if c.empty {
t.Errorf("field %q is empty — HTML selector may have broken", c.field)
}
}
// total_chapters must be a positive integer.
if meta.TotalChapters < 1 {
t.Errorf("total_chapters = %d, want >= 1", meta.TotalChapters)
}
}
// ── Chapter list ──────────────────────────────────────────────────────────────
// TestIntegration_Novelfire_ScrapeChapterList_ReturnsRefs verifies that
// ScrapeChapterList returns a non-empty slice of chapter references with
// valid URLs and numbers parsed from those URLs (not list position).
func TestIntegration_Novelfire_ScrapeChapterList_ReturnsRefs(t *testing.T) {
s := newIntegrationScraper(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
refs, err := s.ScrapeChapterList(ctx, integrationBookURL)
if err != nil {
t.Fatalf("ScrapeChapterList failed: %v", err)
}
t.Logf("total refs returned: %d", len(refs))
if len(refs) == 0 {
t.Fatal("ScrapeChapterList returned 0 refs")
}
// Every ref must have a non-empty URL pointing at the correct book.
for i, ref := range refs {
if ref.URL == "" {
t.Errorf("refs[%d].URL is empty", i)
}
if !strings.Contains(ref.URL, integrationBookSlug) {
t.Errorf("refs[%d].URL %q does not contain book slug", i, ref.URL)
}
if ref.Number <= 0 {
t.Errorf("refs[%d].Number = %d, want > 0 (URL: %s)", i, ref.Number, ref.URL)
}
if ref.Title == "" {
t.Errorf("refs[%d].Title is empty (URL: %s)", i, ref.URL)
}
}
}
// TestIntegration_Novelfire_ScrapeChapterList_NumbersMatchURLs verifies the
// fix for the newest-first ordering bug: each ref's Number must equal the
// chapter number embedded in its URL, not its position in the list.
func TestIntegration_Novelfire_ScrapeChapterList_NumbersMatchURLs(t *testing.T) {
s := newIntegrationScraper(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
refs, err := s.ScrapeChapterList(ctx, integrationBookURL)
if err != nil {
t.Fatalf("ScrapeChapterList failed: %v", err)
}
if len(refs) == 0 {
t.Fatal("ScrapeChapterList returned 0 refs")
}
mismatches := 0
for i, ref := range refs {
wantNum := chapterNumberFromURL(ref.URL)
if wantNum <= 0 {
// URL has no parseable number — skip this entry.
continue
}
if ref.Number != wantNum {
t.Errorf("refs[%d]: Number=%d but URL %q implies number=%d (position-based bug?)",
i, ref.Number, ref.URL, wantNum)
mismatches++
if mismatches >= 5 {
t.Log("… (further mismatches suppressed)")
break
}
}
}
// Log the first few refs so failures are easy to diagnose.
limit := 5
if len(refs) < limit {
limit = len(refs)
}
for i := 0; i < limit; i++ {
t.Logf("refs[%d]: Number=%d Title=%q URL=%s", i, refs[i].Number, refs[i].Title, refs[i].URL)
}
}
// ── Chapters ──────────────────────────────────────────────────────────────────
// TestIntegration_Novelfire_ScrapeFirst3Chapters scrapes chapters 1, 2, and 3
// via ScrapeChapterText and verifies each returns non-empty markdown text.
// Chapters are run as sub-tests so a single failure does not abort the others.
func TestIntegration_Novelfire_ScrapeFirst3Chapters(t *testing.T) {
s := newIntegrationScraper(t)
chapters := []scraper.ChapterRef{
{
Number: 1,
Title: "Chapter 1",
URL: integrationBookURL + "/chapter-1",
},
{
Number: 2,
Title: "Chapter 2",
URL: integrationBookURL + "/chapter-2",
},
{
Number: 3,
Title: "Chapter 3",
URL: integrationBookURL + "/chapter-3",
},
}
for _, ref := range chapters {
ref := ref // capture
t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) {
// Sequential: each chapter needs its own generous timeout.
ctx, cancel := context.WithTimeout(context.Background(), 110*time.Second)
defer cancel()
ch, err := s.ScrapeChapterText(ctx, ref)
if err != nil {
t.Fatalf("ScrapeChapterText failed: %v", err)
}
t.Logf("chapter %d: %d bytes of markdown", ref.Number, len(ch.Text))
t.Logf("first 300 chars:\n%s", truncateStr(ch.Text, 300))
// Ref fields must be echoed back unchanged.
if ch.Ref.Number != ref.Number {
t.Errorf("Ref.Number = %d, want %d", ch.Ref.Number, ref.Number)
}
if ch.Ref.URL != ref.URL {
t.Errorf("Ref.URL = %q, want %q", ch.Ref.URL, ref.URL)
}
// Text must be non-trivially long.
if len(ch.Text) < 100 {
t.Errorf("Text too short (%d bytes) — likely empty or parsing failed:\n%s",
len(ch.Text), ch.Text)
}
// Text must not contain raw HTML tags — NodeToMarkdown should have
// stripped them.
for _, tag := range []string{"<div", "<span", "<script", "<style"} {
if strings.Contains(ch.Text, tag) {
t.Errorf("Text contains raw HTML tag %q — markdown conversion may be broken", tag)
}
}
})
}
}
// TestIntegration_Novelfire_ScrapeFirst3Chapters_FromList is the end-to-end
// variant: it first calls ScrapeChapterList to get the real refs (with
// URL-derived numbers), then scrapes chapters 13 using those refs.
// This catches any discrepancy between the list and the chapter URLs.
func TestIntegration_Novelfire_ScrapeFirst3Chapters_FromList(t *testing.T) {
s := newIntegrationScraper(t)
// Step 1: fetch the chapter list.
listCtx, listCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer listCancel()
refs, err := s.ScrapeChapterList(listCtx, integrationBookURL)
if err != nil {
t.Fatalf("ScrapeChapterList failed: %v", err)
}
if len(refs) == 0 {
t.Fatal("ScrapeChapterList returned 0 refs")
}
// Build a map number→ref for fast lookup.
byNumber := make(map[int]scraper.ChapterRef, len(refs))
for _, r := range refs {
byNumber[r.Number] = r
}
// Step 2: scrape chapters 1, 2, 3.
for _, wantNum := range []int{1, 2, 3} {
wantNum := wantNum
ref, ok := byNumber[wantNum]
if !ok {
t.Errorf("chapter %d not found in chapter list (list has %d entries)", wantNum, len(refs))
continue
}
t.Run(fmt.Sprintf("chapter-%d", wantNum), func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 110*time.Second)
defer cancel()
ch, err := s.ScrapeChapterText(ctx, ref)
if err != nil {
t.Fatalf("ScrapeChapterText(chapter %d, %s) failed: %v", wantNum, ref.URL, err)
}
t.Logf("chapter %d (%q): %d bytes", wantNum, ref.Title, len(ch.Text))
t.Logf("first 300 chars:\n%s", truncateStr(ch.Text, 300))
if len(ch.Text) < 100 {
t.Errorf("chapter %d text too short (%d bytes)", wantNum, len(ch.Text))
}
})
}
}
// ── helpers ───────────────────────────────────────────────────────────────────
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

View File

@@ -13,6 +13,7 @@ import (
"fmt"
"log/slog"
"net/url"
"path"
"strconv"
"strings"
"time"
@@ -25,6 +26,7 @@ import (
const (
baseURL = "https://novelfire.net"
cataloguePath = "/genre-all/sort-new/status-all/all-novel"
rankingPath = "/ranking"
)
// rejectResourceTypes lists Browserless resource types to block on every request.
@@ -40,8 +42,6 @@ var rejectResourceTypes = []string{
"media",
"other",
"ping",
"prefetch",
"preflight",
"signedexchange",
"stylesheet",
"texttrack",
@@ -51,16 +51,22 @@ var rejectResourceTypes = []string{
// Scraper is the novelfire.net implementation of scraper.NovelScraper.
// It uses the /content strategy by default (rendered HTML via Browserless).
type Scraper struct {
client browser.BrowserClient
log *slog.Logger
client browser.BrowserClient
urlClient browser.BrowserClient // separate client for URL retrieval (uses browserless content strategy)
log *slog.Logger
}
// New returns a new novelfire Scraper.
func New(client browser.BrowserClient, log *slog.Logger) *Scraper {
// client is used for content fetching, urlClient is used for URL retrieval (chapter list).
// If urlClient is nil, client will be used for both.
func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient) *Scraper {
if log == nil {
log = slog.Default()
}
return &Scraper{client: client, log: log}
if urlClient == nil {
urlClient = client
}
return &Scraper{client: client, urlClient: urlClient, log: log}
}
// SourceName implements NovelScraper.
@@ -92,15 +98,14 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue
"page", page,
"payload_url", pageURL,
"payload_wait_selector", ".novel-item",
"payload_wait_selector_timeout_ms", 10000,
"payload_wait_for_timeout_ms", 10000,
"payload_wait_selector_timeout_ms", 5000,
)
html, err := s.client.GetContent(ctx, browser.ContentRequest{
URL: pageURL,
WaitFor: &browser.WaitForSelector{Selector: ".novel-item", Timeout: 10000},
WaitForTimeout: 10000,
WaitFor: &browser.WaitForSelector{Selector: ".novel-item", Timeout: 5000},
RejectResourceTypes: rejectResourceTypes,
GotoOptions: &browser.GotoOptions{Timeout: 60000},
})
if err != nil {
s.log.Debug("catalogue page fetch failed",
@@ -173,15 +178,14 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (scraper.B
s.log.Debug("metadata fetch starting",
"payload_url", bookURL,
"payload_wait_selector", ".novel-title",
"payload_wait_selector_timeout_ms", 10000,
"payload_wait_for_timeout_ms", 10000,
"payload_wait_selector_timeout_ms", 5000,
)
raw, err := s.client.GetContent(ctx, browser.ContentRequest{
URL: bookURL,
WaitFor: &browser.WaitForSelector{Selector: ".novel-title", Timeout: 10000},
WaitForTimeout: 10000,
WaitFor: &browser.WaitForSelector{Selector: ".novel-title", Timeout: 5000},
RejectResourceTypes: rejectResourceTypes,
GotoOptions: &browser.GotoOptions{Timeout: 60000},
})
if err != nil {
s.log.Debug("metadata fetch failed", "url", bookURL, "err", err)
@@ -198,8 +202,11 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (scraper.B
title := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"})
// <span class="author"><a>Author Name</a></span>
author := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "author"})
// <img class="cover" src="...">
cover := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "img", Class: "cover", Attr: "src"})
// <figure class="cover"><img src="..."></figure>
var cover string
if figureCover := htmlutil.FindFirst(root, scraper.Selector{Tag: "figure", Class: "cover"}); figureCover != nil {
cover = htmlutil.ExtractFirst(figureCover, scraper.Selector{Tag: "img", Attr: "src"})
}
// <span class="status">Ongoing</span>
status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"})
@@ -245,32 +252,41 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (scraper.B
func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scraper.ChapterRef, error) {
var refs []scraper.ChapterRef
// Chapter list URL: {bookURL}/chapters
pageURL := strings.TrimRight(bookURL, "/") + "/chapters"
// Chapter list URL: {bookURL}/chapters?page=N
baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters"
page := 1
for pageURL != "" {
for {
select {
case <-ctx.Done():
return refs, ctx.Err()
default:
}
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
s.log.Debug("chapter list fetch starting",
"page", page,
"payload_url", pageURL,
"payload_wait_selector", ".chapter-list",
"payload_wait_selector_timeout_ms", 10000,
"payload_wait_for_timeout_ms", 10000,
"payload_wait_selector_timeout_ms", 15000,
"payload_wait_timeout_ms", 2000,
"strategy", s.urlClient.Strategy(),
)
raw, err := s.client.GetContent(ctx, browser.ContentRequest{
URL: pageURL,
WaitFor: &browser.WaitForSelector{Selector: ".chapter-list", Timeout: 10000},
WaitForTimeout: 10000,
raw, err := s.urlClient.GetContent(ctx, browser.ContentRequest{
URL: pageURL,
// Wait up to 15 s for the chapter list container to appear in the DOM.
WaitFor: &browser.WaitForSelector{Selector: ".chapter-list", Timeout: 15000},
// After the selector is found, wait an additional 2 s for any
// deferred JS rendering (lazy-loaded links, infinite-scroll hydration).
WaitForTimeout: 2000,
RejectResourceTypes: rejectResourceTypes,
GotoOptions: &browser.GotoOptions{Timeout: 60000},
// Do NOT use BestAttempt — we want a complete page or a clear error,
// not silently partial HTML that looks like "no more chapters".
BestAttempt: false,
})
if err != nil {
s.log.Debug("chapter list fetch failed",
@@ -293,10 +309,27 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scra
chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"})
if chapterList == nil {
// No chapter list container on this page — we've gone past the last page.
s.log.Debug("chapter list container not found, stopping pagination", "page", page)
break
}
// Each chapter row: <li class="chapter-item"><a href="...">Title</a></li>
items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"})
s.log.Debug("chapter list page parsed",
"page", page,
"url", pageURL,
"chapters_on_page", len(items),
"total_refs_so_far", len(refs),
)
// Zero items on this page means we've gone past the last page.
if len(items) == 0 {
s.log.Debug("no chapters on page, stopping pagination", "page", page)
break
}
for _, item := range items {
linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"})
if linkNode == nil {
@@ -308,7 +341,15 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scra
continue
}
chURL := resolveURL(baseURL, href)
num := len(refs) + 1
num := chapterNumberFromURL(chURL)
if num <= 0 {
// Fall back to position if the URL has no parseable number.
num = len(refs) + 1
s.log.Warn("chapter number not parseable from URL, falling back to position",
"url", chURL,
"position", num,
)
}
refs = append(refs, scraper.ChapterRef{
Number: num,
Title: strings.TrimSpace(chTitle),
@@ -316,30 +357,134 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scra
})
}
s.log.Debug("chapter list page parsed",
"page", page,
"url", pageURL,
"chapters_on_page", len(items),
"total_refs_so_far", len(refs),
)
// Next page: <a class="next" href="...">
nextHref := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "a", Class: "next", Attr: "href"})
if nextHref == "" {
break
}
pageURL = resolveURL(baseURL, nextHref)
page++
}
return refs, nil
}
// ─── RankingProvider ───────────────────────────────────────────────────────────
func (s *Scraper) ScrapeRanking(ctx context.Context) (<-chan scraper.BookMeta, <-chan error) {
entries := make(chan scraper.BookMeta, 64)
errs := make(chan error, 16)
go func() {
defer close(entries)
defer close(errs)
pageURL := baseURL + rankingPath
rank := 1
for pageURL != "" {
select {
case <-ctx.Done():
return
default:
}
s.log.Info("scraping ranking page", "url", pageURL)
// Use WaitFor only for browser-based strategies
var raw string
var err error
if s.client.Strategy() == browser.StrategyDirect {
raw, err = s.client.GetContent(ctx, browser.ContentRequest{
URL: pageURL,
RejectResourceTypes: rejectResourceTypes,
})
} else {
raw, err = s.client.GetContent(ctx, browser.ContentRequest{
URL: pageURL,
WaitFor: &browser.WaitForSelector{Selector: ".rank-novels", Timeout: 30000},
RejectResourceTypes: rejectResourceTypes,
GotoOptions: &browser.GotoOptions{Timeout: 60000},
BestAttempt: true,
})
}
if err != nil {
s.log.Debug("ranking page fetch failed", "url", pageURL, "err", err)
errs <- fmt.Errorf("ranking page: %w", err)
return
}
root, err := htmlutil.ParseHTML(raw)
if err != nil {
errs <- fmt.Errorf("ranking page parse: %w", err)
return
}
rankList := htmlutil.FindFirst(root, scraper.Selector{Class: "rank-novels"})
if rankList == nil {
break
}
items := htmlutil.FindAll(rankList, scraper.Selector{Tag: "li", Class: "novel-item"})
for _, item := range items {
// Cover: <figure class="cover"><a href="/book/slug"><img data-src="..."></a></figure>
var cover string
if fig := htmlutil.FindFirst(item, scraper.Selector{Tag: "figure", Class: "cover"}); fig != nil {
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "data-src"})
if cover != "" {
cover = baseURL + cover
}
}
// Title and URL: <h2 class="title"><a href="/book/slug">Title</a></h2>
titleNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "h2", Class: "title"})
var title, bookURL string
if titleNode != nil {
linkNode := htmlutil.FindFirst(titleNode, scraper.Selector{Tag: "a"})
if linkNode != nil {
title = htmlutil.ExtractText(linkNode, scraper.Selector{})
href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"})
bookURL = resolveURL(baseURL, href)
}
}
// Status: <span class="status"> Ongoing/Completed </span>
status := htmlutil.ExtractFirst(item, scraper.Selector{Tag: "span", Class: "status"})
// Genres: <div class="categories"><div class="scroll"><span>Genre1</span><span>Genre2</span>...</div></div>
var genres []string
categoriesNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "div", Class: "categories"})
if categoriesNode != nil {
genres = htmlutil.ExtractAll(categoriesNode, scraper.Selector{Tag: "span", Multiple: true})
}
slug := slugFromURL(bookURL)
meta := scraper.BookMeta{
Slug: slug,
Title: title,
Cover: cover,
Status: strings.TrimSpace(status),
Genres: genres,
SourceURL: bookURL,
Ranking: rank,
}
rank++
select {
case <-ctx.Done():
return
case entries <- meta:
}
}
// Next page - ranking pages use different pagination, just get first page for now
break
}
}()
return entries, errs
}
// ─── ChapterTextProvider ─────────────────────────────────────────────────────
// retryGetContent calls client.GetContent up to maxAttempts times, backing off
// exponentially between retries. Only errors that look like transient Browserless
// 5xx responses (navigation timeouts, etc.) are retried; context cancellation and
// failures (timeouts, 5xx responses) are retried; context cancellation and
// permanent errors are returned immediately.
func retryGetContent(
ctx context.Context,
@@ -363,11 +508,6 @@ func retryGetContent(
return "", err
}
// Only retry on Browserless 5xx responses.
if !strings.Contains(err.Error(), "unexpected status 5") {
return "", err
}
if attempt < maxAttempts {
log.Warn("chapter fetch failed, retrying",
"url", req.URL,
@@ -393,15 +533,15 @@ func (s *Scraper) ScrapeChapterText(ctx context.Context, ref scraper.ChapterRef)
"title", ref.Title,
"payload_url", ref.URL,
"payload_wait_selector", "#content",
"payload_wait_selector_timeout_ms", 75000,
"payload_wait_for_timeout_ms", 75000,
"payload_wait_selector_timeout_ms", 5000,
)
raw, err := retryGetContent(ctx, s.log, s.client, browser.ContentRequest{
URL: ref.URL,
WaitFor: &browser.WaitForSelector{Selector: "#content", Timeout: 75000},
WaitForTimeout: 75000,
WaitFor: &browser.WaitForSelector{Selector: "#content", Timeout: 5000},
RejectResourceTypes: rejectResourceTypes,
GotoOptions: &browser.GotoOptions{Timeout: 60000},
BestAttempt: true,
}, 9, 6*time.Second)
if err != nil {
s.log.Debug("chapter text fetch failed",
@@ -411,6 +551,18 @@ func (s *Scraper) ScrapeChapterText(ctx context.Context, ref scraper.ChapterRef)
)
return scraper.Chapter{}, fmt.Errorf("chapter %d fetch: %w", ref.Number, err)
}
if len(raw) > 0 {
preview := raw
if len(preview) > 500 {
preview = preview[:500]
}
s.log.Debug("chapter text fetch partial content",
"chapter", ref.Number,
"url", ref.URL,
"response_bytes", len(raw),
"preview", preview,
)
}
s.log.Debug("chapter text fetch completed",
"chapter", ref.Number,
"url", ref.URL,
@@ -484,3 +636,30 @@ func parseChapterCount(s string) int {
n, _ := strconv.Atoi(fields[0])
return n
}
// chapterNumberFromURL extracts the chapter number from a novelfire chapter URL.
//
// URL pattern: https://novelfire.net/book/{book-slug}/chapter-{N}
// The last path segment is expected to be "chapter-{N}" or "{N}".
// Returns 0 if no number can be parsed.
func chapterNumberFromURL(chapterURL string) int {
u, err := url.Parse(chapterURL)
if err != nil {
return 0
}
seg := path.Base(u.Path) // e.g. "chapter-42" or "42"
// Strip a "chapter-" prefix if present.
seg = strings.TrimPrefix(seg, "chapter-")
// Also handle "chap-", "ch-" variants used by some sites.
seg = strings.TrimPrefix(seg, "chap-")
seg = strings.TrimPrefix(seg, "ch-")
// Take only the leading digits (handles slugs like "42-title-text").
digits := strings.FieldsFunc(seg, func(r rune) bool {
return r < '0' || r > '9'
})
if len(digits) == 0 {
return 0
}
n, _ := strconv.Atoi(digits[0])
return n
}

View File

@@ -0,0 +1,217 @@
package novelfire
import (
"context"
"strings"
"testing"
"github.com/libnovel/scraper/internal/browser"
"github.com/libnovel/scraper/internal/scraper"
)
// ── stub browser client ───────────────────────────────────────────────────────
// stubClient is a BrowserClient that returns a fixed HTML string for every
// GetContent call. ScrapePage and CDPSession are not used by these tests.
type stubClient struct {
html string
}
func (s *stubClient) Strategy() browser.Strategy { return browser.StrategyContent }
func (s *stubClient) GetContent(_ context.Context, _ browser.ContentRequest) (string, error) {
return s.html, nil
}
func (s *stubClient) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) {
return browser.ScrapeResponse{}, nil
}
func (s *stubClient) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error {
return nil
}
// pagedStubClient returns a different HTML response for each successive call.
// Once all pages are exhausted it returns an empty page (no chapter-list),
// simulating the paginated chapter-list endpoint terminating correctly.
type pagedStubClient struct {
pages []string
call int
}
func (c *pagedStubClient) Strategy() browser.Strategy { return browser.StrategyContent }
func (c *pagedStubClient) GetContent(_ context.Context, _ browser.ContentRequest) (string, error) {
if c.call < len(c.pages) {
html := c.pages[c.call]
c.call++
return html, nil
}
// Past the last page — return a page with no chapter-list to stop pagination.
return `<!DOCTYPE html><html><body><div class="no-content"></div></body></html>`, nil
}
func (c *pagedStubClient) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) {
return browser.ScrapeResponse{}, nil
}
func (c *pagedStubClient) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error {
return nil
}
// ── helpers ───────────────────────────────────────────────────────────────────
func newScraper(html string) *Scraper {
return New(&stubClient{html: html}, nil, &stubClient{html: html})
}
func newPagedScraper(pages ...string) *Scraper {
urlClient := &pagedStubClient{pages: pages}
return New(&stubClient{}, nil, urlClient)
}
// ── ScrapeChapterText ─────────────────────────────────────────────────────────
func TestScrapeChapterText_ExtractsInnerText(t *testing.T) {
html := `<!DOCTYPE html><html><body>
<div id="content">
<p>It was a dark and stormy night.</p>
<p>The hero stepped forward.</p>
</div>
</body></html>`
s := newScraper(html)
ref := scraper.ChapterRef{Number: 1, Title: "Chapter 1", URL: "https://novelfire.net/book/test-novel/chapter-1"}
ch, err := s.ScrapeChapterText(context.Background(), ref)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.Ref.Number != 1 {
t.Errorf("expected chapter number 1, got %d", ch.Ref.Number)
}
if !strings.Contains(ch.Text, "dark and stormy") {
t.Errorf("expected chapter text to contain 'dark and stormy', got: %q", ch.Text)
}
if !strings.Contains(ch.Text, "hero stepped forward") {
t.Errorf("expected chapter text to contain 'hero stepped forward', got: %q", ch.Text)
}
}
func TestScrapeChapterText_MissingContainer(t *testing.T) {
html := `<!DOCTYPE html><html><body><div class="other">nothing here</div></body></html>`
s := newScraper(html)
ref := scraper.ChapterRef{Number: 2, Title: "Chapter 2", URL: "https://novelfire.net/book/test-novel/chapter-2"}
_, err := s.ScrapeChapterText(context.Background(), ref)
if err == nil {
t.Fatal("expected an error when #content container is missing, got nil")
}
}
// ── chapterNumberFromURL ──────────────────────────────────────────────────────
func TestChapterNumberFromURL(t *testing.T) {
cases := []struct {
url string
want int
}{
// Standard novelfire pattern.
{"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-1", 1},
{"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-26", 26},
{"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-58", 58},
// Large chapter numbers.
{"https://novelfire.net/book/some-novel/chapter-1000", 1000},
// Path segment with trailing slash.
{"https://novelfire.net/book/some-novel/chapter-5/", 5},
// Slug with title appended after the number (hypothetical future format).
{"https://novelfire.net/book/some-novel/chapter-42-the-battle", 42},
// Unparseable — should return 0 so the caller can fall back.
{"https://novelfire.net/book/some-novel/prologue", 0},
{"https://novelfire.net/book/some-novel/", 0},
{"not-a-url", 0},
}
for _, tc := range cases {
got := chapterNumberFromURL(tc.url)
if got != tc.want {
t.Errorf("chapterNumberFromURL(%q) = %d, want %d", tc.url, got, tc.want)
}
}
}
// ── ScrapeChapterList (position vs URL numbering) ─────────────────────────────
// TestScrapeChapterList_NumbersFromURL verifies that when the chapter list HTML
// is served newest-first (as novelfire.net does), chapter numbers are still
// assigned from the URL — not from list position — so that a re-run correctly
// identifies which chapters are already on disk.
func TestScrapeChapterList_NumbersFromURL(t *testing.T) {
// Simulate a newest-first chapter list with 5 chapters on a single page.
// Positions 1..5 correspond to chapters 5,4,3,2,1 in the site HTML.
page1 := `<!DOCTYPE html><html><body>
<ul class="chapter-list">
<li class="chapter-item"><a href="/book/test/chapter-5">Chapter 5</a></li>
<li class="chapter-item"><a href="/book/test/chapter-4">Chapter 4</a></li>
<li class="chapter-item"><a href="/book/test/chapter-3">Chapter 3</a></li>
<li class="chapter-item"><a href="/book/test/chapter-2">Chapter 2</a></li>
<li class="chapter-item"><a href="/book/test/chapter-1">Chapter 1</a></li>
</ul>
</body></html>`
s := newPagedScraper(page1)
refs, err := s.ScrapeChapterList(context.Background(), "https://novelfire.net/book/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(refs) != 5 {
t.Fatalf("expected 5 refs, got %d", len(refs))
}
// With position-based numbering (the old bug), refs[0].Number would be 1
// even though its URL is /chapter-5. With URL-based numbering it must be 5.
wantNumbers := []int{5, 4, 3, 2, 1}
for i, ref := range refs {
if ref.Number != wantNumbers[i] {
t.Errorf("refs[%d].Number = %d, want %d (URL: %s)", i, ref.Number, wantNumbers[i], ref.URL)
}
}
}
// TestScrapeChapterList_Pagination verifies that the scraper correctly follows
// ?page=N pagination and stops when a page returns no chapter items.
func TestScrapeChapterList_Pagination(t *testing.T) {
page1 := `<!DOCTYPE html><html><body>
<ul class="chapter-list">
<li class="chapter-item"><a href="/book/test/chapter-3">Chapter 3</a></li>
<li class="chapter-item"><a href="/book/test/chapter-2">Chapter 2</a></li>
<li class="chapter-item"><a href="/book/test/chapter-1">Chapter 1</a></li>
</ul>
</body></html>`
page2 := `<!DOCTYPE html><html><body>
<ul class="chapter-list">
<li class="chapter-item"><a href="/book/test/chapter-6">Chapter 6</a></li>
<li class="chapter-item"><a href="/book/test/chapter-5">Chapter 5</a></li>
<li class="chapter-item"><a href="/book/test/chapter-4">Chapter 4</a></li>
</ul>
</body></html>`
// page3 is omitted — pagedStubClient will return empty page to stop pagination.
s := newPagedScraper(page1, page2)
refs, err := s.ScrapeChapterList(context.Background(), "https://novelfire.net/book/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(refs) != 6 {
t.Fatalf("expected 6 refs (3 per page × 2 pages), got %d", len(refs))
}
wantNumbers := []int{3, 2, 1, 6, 5, 4}
for i, ref := range refs {
if ref.Number != wantNumbers[i] {
t.Errorf("refs[%d].Number = %d, want %d (URL: %s)", i, ref.Number, wantNumbers[i], ref.URL)
}
}
}