- docker-compose: add dozzle, uptime-kuma, gotify services with Caddy subdomains (logs/uptime/push.libnovel.cc); bind-mount dozzle/users.yml for simple auth - Caddyfile: add site blocks for logs, uptime, push subdomains - admin layout: add Logs, Uptime, Push tabs to external tools pill - CatalogueEntry: add Slug field; populated by novelfire scraper - meili.Client: add BookExists() to check if a book is already indexed - catalogue_refresh: skip books already present in Meilisearch to prevent redundant re-scraping and re-indexing on every runner restart - docker-compose runner: set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
510 lines
15 KiB
Go
510 lines
15 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"
|
|
"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.
|
|
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:
|
|
}
|
|
|
|
s.log.Info("scraping catalogue page", "page", page, "url", pageURL)
|
|
raw, err := s.client.GetContent(ctx, pageURL)
|
|
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.
|
|
func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) {
|
|
s.log.Debug("metadata fetch starting", "url", bookURL)
|
|
|
|
raw, err := s.client.GetContent(ctx, bookURL)
|
|
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 := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"})
|
|
|
|
genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"})
|
|
var genres []string
|
|
if genresNode != nil {
|
|
genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true})
|
|
}
|
|
|
|
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
|
|
}
|