Some checks failed
CI / Backend (push) Failing after 11s
CI / UI (push) Successful in 48s
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 55s
CI / UI (pull_request) Successful in 37s
Release / Docker / caddy (push) Successful in 47s
CI / Backend (pull_request) Successful in 46s
Release / Docker / runner (push) Successful in 2m37s
Release / Docker / ui (push) Successful in 2m42s
Release / Docker / backend (push) Successful in 3m13s
Release / Gitea Release (push) Failing after 2s
novelfire.net changed its book page structure. Old selectors produced empty status and null genres for every book, causing all Meilisearch filters to return zero results. Old → new: - status: <span class="status"> → <strong class="ongoing|completed|hiatus"> (text lowercased for consistent index values) - genres: <div class="genres"> <a> → <div class="categories"> <a class="property-item"> (text lowercased for consistent index values) Adds TestParseMetadataSelectors to guard against future regressions.
539 lines
16 KiB
Go
539 lines
16 KiB
Go
// 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 <strong class="ongoing">Ongoing</strong> (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 <div class="categories"><ul><li><a class="property-item">Genre</a>
|
|
// Each <a class="property-item"> is one genre tag. Lowercase for index consistency.
|
|
var genres []string
|
|
if categoriesNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "categories"}); categoriesNode != nil {
|
|
for _, v := range htmlutil.ExtractAll(categoriesNode, scraper.Selector{Tag: "a", Class: "property-item", Multiple: true}) {
|
|
if v != "" {
|
|
genres = append(genres, strings.ToLower(strings.TrimSpace(v)))
|
|
}
|
|
}
|
|
}
|
|
|
|
summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"})
|
|
totalStr := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "chapter-count"})
|
|
totalChapters := parseChapterCount(totalStr)
|
|
|
|
slug := slugFromURL(bookURL)
|
|
|
|
meta := domain.BookMeta{
|
|
Slug: slug,
|
|
Title: title,
|
|
Author: author,
|
|
Cover: cover,
|
|
Status: status,
|
|
Genres: genres,
|
|
Summary: summary,
|
|
TotalChapters: totalChapters,
|
|
SourceURL: bookURL,
|
|
}
|
|
s.log.Debug("metadata parsed", "slug", meta.Slug, "title", meta.Title)
|
|
return meta, nil
|
|
}
|
|
|
|
// ── ChapterListProvider ───────────────────────────────────────────────────────
|
|
|
|
// ScrapeChapterList returns chapter references for a book, ordered ascending.
|
|
// upTo > 0 stops pagination as soon as at least upTo chapter numbers have been
|
|
// collected — use this for range scrapes so we don't paginate 100 pages just
|
|
// to discover refs we'll never scrape. upTo == 0 fetches all pages.
|
|
// Each page fetch uses retryGet with 429-aware exponential backoff.
|
|
func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string, upTo int) ([]domain.ChapterRef, error) {
|
|
var refs []domain.ChapterRef
|
|
baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters"
|
|
page := 1
|
|
|
|
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)
|
|
|
|
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second)
|
|
if err != nil {
|
|
return refs, fmt.Errorf("chapter list page %d: %w", page, err)
|
|
}
|
|
|
|
root, err := htmlutil.ParseHTML(raw)
|
|
if err != nil {
|
|
return refs, fmt.Errorf("chapter list page %d parse: %w", page, err)
|
|
}
|
|
|
|
chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"})
|
|
if chapterList == nil {
|
|
s.log.Debug("chapter list container not found, stopping pagination", "page", page)
|
|
break
|
|
}
|
|
|
|
items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"})
|
|
if len(items) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, item := range items {
|
|
linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"})
|
|
if linkNode == nil {
|
|
continue
|
|
}
|
|
href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"})
|
|
chTitle := htmlutil.ExtractText(linkNode, scraper.Selector{})
|
|
if href == "" {
|
|
continue
|
|
}
|
|
chURL := resolveURL(baseURL, href)
|
|
num := chapterNumberFromURL(chURL)
|
|
if num <= 0 {
|
|
num = len(refs) + 1
|
|
s.log.Warn("chapter number not parseable from URL, falling back to position",
|
|
"url", chURL, "position", num)
|
|
}
|
|
refs = append(refs, domain.ChapterRef{
|
|
Number: num,
|
|
Title: strings.TrimSpace(chTitle),
|
|
URL: chURL,
|
|
})
|
|
}
|
|
|
|
// Early-stop: if we have seen at least upTo chapter numbers, we have
|
|
// enough refs to cover the requested range — no need to paginate further.
|
|
if upTo > 0 && len(refs) > 0 && refs[len(refs)-1].Number >= upTo {
|
|
s.log.Debug("chapter list early-stop reached", "upTo", upTo, "collected", len(refs))
|
|
break
|
|
}
|
|
|
|
page++
|
|
}
|
|
|
|
return refs, nil
|
|
}
|
|
|
|
// ── ChapterTextProvider ───────────────────────────────────────────────────────
|
|
|
|
// ScrapeChapterText fetches and parses a single chapter page.
|
|
func (s *Scraper) ScrapeChapterText(ctx context.Context, ref domain.ChapterRef) (domain.Chapter, error) {
|
|
s.log.Debug("chapter text fetch starting", "chapter", ref.Number, "url", ref.URL)
|
|
|
|
raw, err := retryGet(ctx, s.log, s.client, ref.URL, 9, 6*time.Second)
|
|
if err != nil {
|
|
return domain.Chapter{}, fmt.Errorf("chapter %d fetch: %w", ref.Number, err)
|
|
}
|
|
|
|
root, err := htmlutil.ParseHTML(raw)
|
|
if err != nil {
|
|
return domain.Chapter{}, fmt.Errorf("chapter %d parse: %w", ref.Number, err)
|
|
}
|
|
|
|
container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"})
|
|
if container == nil {
|
|
return domain.Chapter{}, fmt.Errorf("chapter %d: #content container not found in %s", ref.Number, ref.URL)
|
|
}
|
|
|
|
text := htmlutil.NodeToMarkdown(container)
|
|
|
|
s.log.Debug("chapter text parsed", "chapter", ref.Number, "text_bytes", len(text))
|
|
|
|
return domain.Chapter{Ref: ref, Text: text}, nil
|
|
}
|
|
|
|
// ── RankingProvider ───────────────────────────────────────────────────────────
|
|
|
|
// ScrapeRanking pages through up to maxPages pages of the popular-novels listing.
|
|
// maxPages <= 0 means all pages. The caller decides whether to persist items.
|
|
func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) {
|
|
entries := make(chan domain.BookMeta, 32)
|
|
errs := make(chan error, 16)
|
|
|
|
go func() {
|
|
defer close(entries)
|
|
defer close(errs)
|
|
|
|
rank := 1
|
|
|
|
for page := 1; maxPages <= 0 || page <= maxPages; page++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
pageURL := fmt.Sprintf("%s%s?page=%d", baseURL, rankingPath, page)
|
|
s.log.Info("scraping popular ranking page", "page", page, "url", pageURL)
|
|
|
|
raw, err := s.client.GetContent(ctx, pageURL)
|
|
if err != nil {
|
|
errs <- fmt.Errorf("ranking page %d: %w", page, err)
|
|
return
|
|
}
|
|
|
|
root, err := htmlutil.ParseHTML(raw)
|
|
if err != nil {
|
|
errs <- fmt.Errorf("ranking page %d parse: %w", page, err)
|
|
return
|
|
}
|
|
|
|
cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true})
|
|
if len(cards) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, card := range cards {
|
|
linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a"})
|
|
if linkNode == nil {
|
|
continue
|
|
}
|
|
href := htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"})
|
|
bookURL := resolveURL(baseURL, href)
|
|
if bookURL == "" {
|
|
continue
|
|
}
|
|
|
|
title := strings.TrimSpace(htmlutil.ExtractFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"}))
|
|
if title == "" {
|
|
title = strings.TrimSpace(htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "title"}))
|
|
}
|
|
if title == "" {
|
|
continue
|
|
}
|
|
|
|
var cover string
|
|
if fig := htmlutil.FindFirst(card, scraper.Selector{Tag: "figure", Class: "novel-cover"}); fig != nil {
|
|
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "data-src"})
|
|
if cover == "" {
|
|
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"})
|
|
}
|
|
if strings.HasPrefix(cover, "data:") {
|
|
cover = ""
|
|
}
|
|
if cover != "" && !strings.HasPrefix(cover, "http") {
|
|
cover = baseURL + cover
|
|
}
|
|
}
|
|
|
|
meta := domain.BookMeta{
|
|
Slug: slugFromURL(bookURL),
|
|
Title: title,
|
|
Cover: cover,
|
|
SourceURL: bookURL,
|
|
Ranking: rank,
|
|
}
|
|
rank++
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case entries <- meta:
|
|
}
|
|
}
|
|
|
|
if !hasNextPageLink(root) {
|
|
break
|
|
}
|
|
}
|
|
}()
|
|
|
|
return entries, errs
|
|
}
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
func resolveURL(base, href string) string { return htmlutil.ResolveURL(base, href) }
|
|
|
|
func hasNextPageLink(root *html.Node) bool {
|
|
links := htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true})
|
|
for _, a := range links {
|
|
for _, attr := range a.Attr {
|
|
if attr.Key == "rel" && attr.Val == "next" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func slugFromURL(bookURL string) string {
|
|
u, err := url.Parse(bookURL)
|
|
if err != nil {
|
|
return bookURL
|
|
}
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if len(parts) >= 2 && parts[0] == "book" {
|
|
return parts[1]
|
|
}
|
|
if len(parts) > 0 {
|
|
return parts[len(parts)-1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseChapterCount(s string) int {
|
|
s = strings.ReplaceAll(s, ",", "")
|
|
fields := strings.Fields(s)
|
|
if len(fields) == 0 {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(fields[0])
|
|
return n
|
|
}
|
|
|
|
func chapterNumberFromURL(chapterURL string) int {
|
|
u, err := url.Parse(chapterURL)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
seg := path.Base(u.Path)
|
|
seg = strings.TrimPrefix(seg, "chapter-")
|
|
seg = strings.TrimPrefix(seg, "chap-")
|
|
seg = strings.TrimPrefix(seg, "ch-")
|
|
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
|
|
}
|
|
|
|
// retryGet calls client.GetContent up to maxAttempts times with exponential backoff.
|
|
// If the server returns 429 (ErrRateLimit), the suggested Retry-After delay is used
|
|
// instead of the geometric backoff delay.
|
|
func retryGet(
|
|
ctx context.Context,
|
|
log *slog.Logger,
|
|
client browser.Client,
|
|
pageURL string,
|
|
maxAttempts int,
|
|
baseDelay time.Duration,
|
|
) (string, error) {
|
|
var lastErr error
|
|
delay := baseDelay
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
raw, err := client.GetContent(ctx, pageURL)
|
|
if err == nil {
|
|
return raw, nil
|
|
}
|
|
lastErr = err
|
|
if ctx.Err() != nil {
|
|
return "", err
|
|
}
|
|
if attempt < maxAttempts {
|
|
// If the server is rate-limiting us, honour its Retry-After delay.
|
|
waitFor := delay
|
|
var rlErr *browser.RateLimitError
|
|
if errors.As(err, &rlErr) {
|
|
waitFor = rlErr.RetryAfter
|
|
if log != nil {
|
|
log.Warn("rate limited, backing off",
|
|
"url", pageURL, "attempt", attempt, "retry_in", waitFor)
|
|
}
|
|
} else {
|
|
if log != nil {
|
|
log.Warn("fetch failed, retrying",
|
|
"url", pageURL, "attempt", attempt, "retry_in", delay, "err", err)
|
|
}
|
|
delay *= 2
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(waitFor):
|
|
}
|
|
}
|
|
}
|
|
return "", lastErr
|
|
}
|