// Package novelfire provides a NovelScraper implementation for novelfire.net. // // Site structure (as of 2025): // // Catalogue : https://novelfire.net/genre-all/sort-new/status-all/all-novel?page=N // Book page : https://novelfire.net/book/{slug} // Chapters : https://novelfire.net/book/{slug}/chapters?page=N // Chapter : https://novelfire.net/book/{slug}/{chapter-slug} package novelfire import ( "context" "errors" "fmt" "log/slog" "math/rand" "net/url" "path" "strconv" "strings" "time" "github.com/libnovel/backend/internal/browser" "github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/novelfire/htmlutil" "github.com/libnovel/backend/internal/scraper" "golang.org/x/net/html" ) const ( baseURL = "https://novelfire.net" cataloguePath = "/genre-all/sort-new/status-all/all-novel" rankingPath = "/genre-all/sort-popular/status-all/all-novel" ) // Scraper is the novelfire.net implementation of scraper.NovelScraper. type Scraper struct { client browser.Client log *slog.Logger } // Compile-time interface check. var _ scraper.NovelScraper = (*Scraper)(nil) // New returns a new novelfire Scraper backed by client. func New(client browser.Client, log *slog.Logger) *Scraper { if log == nil { log = slog.Default() } return &Scraper{client: client, log: log} } // SourceName implements NovelScraper. func (s *Scraper) SourceName() string { return "novelfire.net" } // ── CatalogueProvider ───────────────────────────────────────────────────────── // ScrapeCatalogue streams all CatalogueEntry values across all catalogue pages. // Each page fetch uses retryGet with 429-aware exponential backoff. // A small inter-page delay (cataloguePageDelay) is inserted between requests to // avoid hammering the server when paging through hundreds of catalogue pages. func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) { entries := make(chan domain.CatalogueEntry, 64) errs := make(chan error, 16) go func() { defer close(entries) defer close(errs) pageURL := baseURL + cataloguePath page := 1 for pageURL != "" { select { case <-ctx.Done(): return default: } // Polite inter-page delay — skipped on the very first page. if page > 1 { jitter := time.Duration(500+rand.Intn(1000)) * time.Millisecond select { case <-ctx.Done(): return case <-time.After(jitter): } } s.log.Info("scraping catalogue page", "page", page, "url", pageURL) raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 10*time.Second) if err != nil { errs <- fmt.Errorf("catalogue page %d: %w", page, err) return } root, err := htmlutil.ParseHTML(raw) if err != nil { errs <- fmt.Errorf("catalogue page %d parse: %w", page, err) return } cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true}) if len(cards) == 0 { s.log.Warn("no novel cards found, stopping pagination", "page", page) return } for _, card := range cards { linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a", Attr: "href"}) titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"}) var title, href string if linkNode != nil { href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"}) } if titleNode != nil { title = strings.TrimSpace(htmlutil.ExtractText(titleNode, scraper.Selector{})) } if href == "" || title == "" { continue } bookURL := resolveURL(baseURL, href) select { case <-ctx.Done(): return case entries <- domain.CatalogueEntry{Slug: slugFromURL(bookURL), Title: title, URL: bookURL}: } } if !hasNextPageLink(root) { break } nextHref := "" for _, a := range htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) { if htmlutil.AttrVal(a, "rel") == "next" { nextHref = htmlutil.AttrVal(a, "href") break } } if nextHref == "" { break } pageURL = resolveURL(baseURL, nextHref) page++ } }() return entries, errs } // ── MetadataProvider ────────────────────────────────────────────────────────── // ScrapeMetadata fetches and parses book metadata from the book's landing page. // Uses retryGet with 429-aware exponential backoff (up to 9 attempts). func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) { s.log.Debug("metadata fetch starting", "url", bookURL) raw, err := retryGet(ctx, s.log, s.client, bookURL, 9, 10*time.Second) if err != nil { return domain.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err) } root, err := htmlutil.ParseHTML(raw) if err != nil { return domain.BookMeta{}, fmt.Errorf("metadata parse %s: %w", bookURL, err) } title := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"}) author := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "author"}) var cover string if fig := htmlutil.FindFirst(root, scraper.Selector{Tag: "figure", Class: "cover"}); fig != nil { cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"}) if cover != "" && !strings.HasPrefix(cover, "http") { cover = baseURL + cover } } // Status: novelfire renders Ongoing (or // "completed", "hiatus") inside the .header-stats block. We take the text // content and lowercase it so the index value is always canonical lowercase. var status string for _, cls := range []string{"ongoing", "completed", "hiatus"} { if v := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "strong", Class: cls}); v != "" { status = strings.ToLower(strings.TrimSpace(v)) break } } // Genres: novelfire renders