text
diff --git a/scraper/cmd/scraper/main.go b/scraper/cmd/scraper/main.go
index 60faab3..923a88d 100644
--- a/scraper/cmd/scraper/main.go
+++ b/scraper/cmd/scraper/main.go
@@ -10,10 +10,6 @@
//
// Environment variables:
//
-// BROWSERLESS_URL Browserless base URL (default: http://localhost:3030)
-// BROWSERLESS_TOKEN Browserless API token (default: "")
-// BROWSERLESS_STRATEGY content | scrape | cdp | direct (default: direct; chapter list+text always use direct HTTP)
-// BROWSERLESS_MAX_CONCURRENT Max simultaneous browser sessions (default: 5)
// SCRAPER_WORKERS Chapter goroutine count (default: NumCPU)
// SCRAPER_HTTP_ADDR HTTP listen address (default: :8080)
// KOKORO_URL Kokoro-FastAPI base URL (default: "")
@@ -33,9 +29,7 @@ package main
import (
"context"
"fmt"
- "io"
"log/slog"
- "net/http"
"os"
"os/exec"
"os/signal"
@@ -48,6 +42,7 @@ import (
"github.com/libnovel/scraper/internal/browser"
"github.com/libnovel/scraper/internal/novelfire"
"github.com/libnovel/scraper/internal/orchestrator"
+ "github.com/libnovel/scraper/internal/scraper/htmlutil"
"github.com/libnovel/scraper/internal/server"
"github.com/libnovel/scraper/internal/storage"
)
@@ -343,7 +338,7 @@ func runSaveBrowse(ctx context.Context, args []string, store storage.Store, log
// Download cover image in the background (best-effort).
if novel.coverURL != "" {
- go downloadAndStoreCoverCLI(store, log, coverKey, novel.coverURL)
+ go storage.DownloadAndStoreCover(store, log, coverKey, novel.coverURL)
}
}
if len(novels) > 0 {
@@ -420,9 +415,9 @@ func parseSaveBrowseListings(htmlBytes []byte, novelFireBase string) []novelList
// Extract cover URL from data-src or src on img tags.
if cur.coverURL == "" && strings.Contains(trimmed, " pages to capture (default: 5)
Environment variables:
- BROWSERLESS_URL Browserless base URL (default: http://localhost:3030)
- BROWSERLESS_TOKEN API token (default: "")
- BROWSERLESS_STRATEGY content|scrape|cdp|direct (default: direct; chapter list+text always use direct HTTP)
- BROWSERLESS_MAX_CONCURRENT Max simultaneous sessions (default: 5)
- BROWSERLESS_TIMEOUT HTTP request timeout sec (default: 90)
SCRAPER_WORKERS Chapter goroutines (default: NumCPU = %d)
- SCRAPER_STATIC_ROOT Output directory (default: ./static/books)
SCRAPER_HTTP_ADDR HTTP listen address (default: :8080)
+ SCRAPER_TIMEOUT HTTP request timeout sec (default: 90)
KOKORO_URL Kokoro-FastAPI base URL (default: "", TTS disabled)
KOKORO_VOICE Default TTS voice (default: af_bella)
+ POCKETBASE_URL PocketBase base URL (default: http://localhost:8090)
+ POCKETBASE_ADMIN_EMAIL PocketBase admin email (default: admin@libnovel.local)
+ POCKETBASE_ADMIN_PASSWORD PocketBase admin password (default: changeme123)
+ MINIO_ENDPOINT MinIO endpoint host:port (default: localhost:9000)
+ MINIO_ACCESS_KEY MinIO access key (default: admin)
+ MINIO_SECRET_KEY MinIO secret key (default: changeme123)
+ MINIO_USE_SSL MinIO TLS (default: false)
+ MINIO_BUCKET_CHAPTERS Chapter objects bucket (default: libnovel-chapters)
+ MINIO_BUCKET_AUDIO Audio objects bucket (default: libnovel-audio)
MINIO_BUCKET_BROWSE Browse snapshots bucket (default: libnovel-browse)
+ BROWSERLESS_URL Browserless WS endpoint (default: http://localhost:3030)
SINGLEFILE_PATH Path to single-file CLI (default: single-file)
LOG_LEVEL debug|info|warn|error (default: info)
`, runtime.NumCPU())
diff --git a/scraper/go.mod b/scraper/go.mod
index 61b5e73..7828b2b 100644
--- a/scraper/go.mod
+++ b/scraper/go.mod
@@ -3,10 +3,8 @@ module github.com/libnovel/scraper
go 1.25.0
require (
- github.com/gorilla/websocket v1.5.3
github.com/minio/minio-go/v7 v7.0.98
golang.org/x/net v0.51.0
- gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -27,4 +25,5 @@ require (
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/scraper/go.sum b/scraper/go.sum
index 43ef929..f4750f9 100644
--- a/scraper/go.sum
+++ b/scraper/go.sum
@@ -6,8 +6,6 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
diff --git a/scraper/internal/browser/cdp.go b/scraper/internal/browser/cdp.go
deleted file mode 100644
index bdbe0cb..0000000
--- a/scraper/internal/browser/cdp.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package browser
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "sync/atomic"
- "time"
-
- "github.com/gorilla/websocket"
-)
-
-// cdpClient implements BrowserClient using the CDP WebSocket endpoint.
-type cdpClient struct {
- cfg Config
- sem chan struct{}
-}
-
-// NewCDPClient returns a BrowserClient that uses CDP WebSocket sessions.
-func NewCDPClient(cfg Config) BrowserClient {
- if cfg.Timeout == 0 {
- cfg.Timeout = 60 * time.Second
- }
- return &cdpClient{cfg: cfg, sem: makeSem(cfg.MaxConcurrent)}
-}
-
-func (c *cdpClient) Strategy() Strategy { return StrategyCDP }
-
-func (c *cdpClient) GetContent(_ context.Context, _ ContentRequest) (string, error) {
- return "", fmt.Errorf("CDP client does not support /content; use NewContentClient")
-}
-
-func (c *cdpClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeResponse, error) {
- return ScrapeResponse{}, fmt.Errorf("CDP client does not support /scrape; use NewScrapeClient")
-}
-
-// CDPSession opens a WebSocket to the Browserless /devtools/browser endpoint,
-// navigates to pageURL, and invokes fn with a live CDPConn.
-func (c *cdpClient) CDPSession(ctx context.Context, pageURL string, fn CDPSessionFunc) error {
- if err := acquire(ctx, c.sem); err != nil {
- return fmt.Errorf("cdp: semaphore: %w", err)
- }
- defer release(c.sem)
-
- // Build WebSocket URL: ws://host:port/devtools/browser?token=...&url=...
- wsURL := strings.Replace(c.cfg.BaseURL, "http://", "ws://", 1)
- wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
- wsURL += "/devtools/browser"
- sep := "?"
- if c.cfg.Token != "" {
- wsURL += sep + "token=" + c.cfg.Token
- sep = "&"
- }
- wsURL += sep + "url=" + pageURL
-
- dialer := websocket.Dialer{
- HandshakeTimeout: 15 * time.Second,
- Proxy: http.ProxyFromEnvironment,
- }
-
- conn, _, err := dialer.DialContext(ctx, wsURL, nil)
- if err != nil {
- return fmt.Errorf("cdp: dial %s: %w", wsURL, err)
- }
-
- cdp := &cdpConn{ws: conn}
- defer cdp.Close()
-
- return fn(ctx, cdp)
-}
-
-// ─── cdpConn ─────────────────────────────────────────────────────────────────
-
-type cdpConn struct {
- ws *websocket.Conn
- counter atomic.Int64
-}
-
-type cdpRequest struct {
- ID int64 `json:"id"`
- Method string `json:"method"`
- Params map[string]any `json:"params,omitempty"`
-}
-
-type cdpResponse struct {
- ID int64 `json:"id"`
- Result map[string]any `json:"result,omitempty"`
- Error *struct {
- Code int `json:"code"`
- Message string `json:"message"`
- } `json:"error,omitempty"`
-}
-
-func (c *cdpConn) Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
- id := c.counter.Add(1)
-
- req := cdpRequest{ID: id, Method: method, Params: params}
- data, err := json.Marshal(req)
- if err != nil {
- return nil, fmt.Errorf("cdp send: marshal: %w", err)
- }
-
- if dl, ok := ctx.Deadline(); ok {
- _ = c.ws.SetWriteDeadline(dl)
- }
- if err := c.ws.WriteMessage(websocket.TextMessage, data); err != nil {
- return nil, fmt.Errorf("cdp send: write: %w", err)
- }
-
- // Read messages until we find the response matching our id.
- for {
- if dl, ok := ctx.Deadline(); ok {
- _ = c.ws.SetReadDeadline(dl)
- }
- _, msg, err := c.ws.ReadMessage()
- if err != nil {
- return nil, fmt.Errorf("cdp send: read: %w", err)
- }
- var resp cdpResponse
- if err := json.Unmarshal(msg, &resp); err != nil {
- continue // skip non-JSON frames (events etc.)
- }
- if resp.ID != id {
- continue // event or different command reply
- }
- if resp.Error != nil {
- return nil, fmt.Errorf("cdp error %d: %s", resp.Error.Code, resp.Error.Message)
- }
- return resp.Result, nil
- }
-}
-
-func (c *cdpConn) Close() error {
- return c.ws.Close()
-}
diff --git a/scraper/internal/browser/content_scrape.go b/scraper/internal/browser/common.go
similarity index 60%
rename from scraper/internal/browser/content_scrape.go
rename to scraper/internal/browser/common.go
index a0e7a85..a84adf3 100644
--- a/scraper/internal/browser/content_scrape.go
+++ b/scraper/internal/browser/common.go
@@ -55,6 +55,8 @@ func release(sem chan struct{}) {
}
}
+// ─── /content client ──────────────────────────────────────────────────────────
+
// contentClient implements BrowserClient using the /content endpoint.
type contentClient struct {
cfg Config
@@ -121,75 +123,5 @@ func (c *contentClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeRe
}
func (c *contentClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error {
- return fmt.Errorf("content client does not support CDP; use NewCDPClient")
-}
-
-// ─── /scrape client ───────────────────────────────────────────────────────────
-
-type scrapeClient struct {
- cfg Config
- http *http.Client
- sem chan struct{}
-}
-
-// NewScrapeClient returns a BrowserClient that uses POST /scrape.
-func NewScrapeClient(cfg Config) BrowserClient {
- if cfg.Timeout == 0 {
- cfg.Timeout = 90 * time.Second
- }
- return &scrapeClient{
- cfg: cfg,
- http: &http.Client{Timeout: cfg.Timeout},
- sem: makeSem(cfg.MaxConcurrent),
- }
-}
-
-func (c *scrapeClient) Strategy() Strategy { return StrategyScrape }
-
-func (c *scrapeClient) GetContent(_ context.Context, _ ContentRequest) (string, error) {
- return "", fmt.Errorf("scrape client does not support /content; use NewContentClient")
-}
-
-func (c *scrapeClient) ScrapePage(ctx context.Context, req ScrapeRequest) (ScrapeResponse, error) {
- if err := acquire(ctx, c.sem); err != nil {
- return ScrapeResponse{}, fmt.Errorf("scrape: semaphore: %w", err)
- }
- defer release(c.sem)
-
- body, err := json.Marshal(req)
- if err != nil {
- return ScrapeResponse{}, fmt.Errorf("scrape: marshal request: %w", err)
- }
-
- url := c.cfg.BaseURL + "/scrape"
- if c.cfg.Token != "" {
- url += "?token=" + c.cfg.Token
- }
-
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
- if err != nil {
- return ScrapeResponse{}, fmt.Errorf("scrape: build request: %w", err)
- }
- httpReq.Header.Set("Content-Type", "application/json")
-
- resp, err := c.http.Do(httpReq)
- if err != nil {
- return ScrapeResponse{}, fmt.Errorf("scrape: do request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- b, _ := io.ReadAll(resp.Body)
- return ScrapeResponse{}, fmt.Errorf("scrape: unexpected status %d: %s", resp.StatusCode, b)
- }
-
- var result ScrapeResponse
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return ScrapeResponse{}, fmt.Errorf("scrape: decode response: %w", err)
- }
- return result, nil
-}
-
-func (c *scrapeClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error {
- return fmt.Errorf("scrape client does not support CDP; use NewCDPClient")
+ return fmt.Errorf("content client does not support CDP")
}
diff --git a/scraper/internal/e2e/e2e_test.go b/scraper/internal/e2e/e2e_test.go
index 1210ed9..278845f 100644
--- a/scraper/internal/e2e/e2e_test.go
+++ b/scraper/internal/e2e/e2e_test.go
@@ -802,13 +802,6 @@ func assertURLAccessibleWithRetry(t *testing.T, ctx context.Context, url, label
// ─── stdlib helpers ───────────────────────────────────────────────────────────
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
-
func chapterNumbers(refs []scraper.ChapterRef) []int {
ns := make([]int, len(refs))
for i, r := range refs {
diff --git a/scraper/internal/novelfire/ranking_test.go b/scraper/internal/novelfire/ranking_test.go
index 1f6c13b..0299b19 100644
--- a/scraper/internal/novelfire/ranking_test.go
+++ b/scraper/internal/novelfire/ranking_test.go
@@ -2,12 +2,9 @@ package novelfire
import (
"context"
- "os"
- "path/filepath"
"testing"
"github.com/libnovel/scraper/internal/scraper"
- "github.com/libnovel/scraper/internal/writer"
)
// rankingPage1HTML is a realistic mock of the popular genre listing page
@@ -149,60 +146,3 @@ func TestScrapeRanking_EmptyPage(t *testing.T) {
t.Errorf("expected 0 entries for empty page, got %d", len(entries))
}
}
-
-// TestWriteRanking_RoundTrip verifies WriteRanking → ReadRankingItems
-// faithfully reconstructs the original slice.
-func TestWriteRanking_RoundTrip(t *testing.T) {
- dir := t.TempDir()
- w := writer.New(dir)
-
- items := []writer.RankingItem{
- {Rank: 1, Slug: "the-iron-throne", Title: "The Iron Throne", Status: "Ongoing",
- Genres: []string{"Fantasy", "Action"}, SourceURL: "https://novelfire.net/book/the-iron-throne"},
- {Rank: 2, Slug: "shadow-mage", Title: "Shadow Mage", Status: "Completed",
- Genres: []string{"Magic"}, SourceURL: "https://novelfire.net/book/shadow-mage"},
- }
-
- if err := w.WriteRanking(items); err != nil {
- t.Fatalf("WriteRanking failed: %v", err)
- }
-
- rankingFile := filepath.Join(dir, "ranking.json")
- if _, err := os.Stat(rankingFile); err != nil {
- t.Fatalf("ranking.json not created: %v", err)
- }
-
- got, err := w.ReadRankingItems()
- if err != nil {
- t.Fatalf("ReadRankingItems failed: %v", err)
- }
- if len(got) != len(items) {
- t.Fatalf("expected %d items, got %d", len(items), len(got))
- }
- for i, want := range items {
- if got[i].Rank != want.Rank {
- t.Errorf("item[%d].Rank = %d, want %d", i, got[i].Rank, want.Rank)
- }
- if got[i].Slug != want.Slug {
- t.Errorf("item[%d].Slug = %q, want %q", i, got[i].Slug, want.Slug)
- }
- if got[i].Title != want.Title {
- t.Errorf("item[%d].Title = %q, want %q", i, got[i].Title, want.Title)
- }
- if got[i].Status != want.Status {
- t.Errorf("item[%d].Status = %q, want %q", i, got[i].Status, want.Status)
- }
- if len(got[i].Genres) != len(want.Genres) {
- t.Errorf("item[%d].Genres len = %d, want %d", i, len(got[i].Genres), len(want.Genres))
- } else {
- for j, g := range want.Genres {
- if got[i].Genres[j] != g {
- t.Errorf("item[%d].Genres[%d] = %q, want %q", i, j, got[i].Genres[j], g)
- }
- }
- }
- if got[i].SourceURL != want.SourceURL {
- t.Errorf("item[%d].SourceURL = %q, want %q", i, got[i].SourceURL, want.SourceURL)
- }
- }
-}
diff --git a/scraper/internal/novelfire/scraper.go b/scraper/internal/novelfire/scraper.go
index 5be55b0..c03e86d 100644
--- a/scraper/internal/novelfire/scraper.go
+++ b/scraper/internal/novelfire/scraper.go
@@ -155,18 +155,8 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue
}
nextHref := ""
for _, a := range htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) {
- isNext := false
- for _, attr := range a.Attr {
- if attr.Key == "rel" && attr.Val == "next" {
- isNext = true
- }
- }
- if isNext {
- for _, attr := range a.Attr {
- if attr.Key == "href" {
- nextHref = attr.Val
- }
- }
+ if htmlutil.AttrVal(a, "rel") == "next" {
+ nextHref = htmlutil.AttrVal(a, "href")
break
}
}
@@ -683,20 +673,8 @@ func (s *Scraper) ScrapeChapterText(ctx context.Context, ref scraper.ChapterRef)
// ─── helpers ─────────────────────────────────────────────────────────────────
-func resolveURL(base, href string) string {
- if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
- return href
- }
- b, err := url.Parse(base)
- if err != nil {
- return base + href
- }
- ref, err := url.Parse(href)
- if err != nil {
- return base + href
- }
- return b.ResolveReference(ref).String()
-}
+// resolveURL is a thin alias over htmlutil.ResolveURL kept for readability.
+func resolveURL(base, href string) string { return htmlutil.ResolveURL(base, href) }
func slugFromURL(bookURL string) string {
u, err := url.Parse(bookURL)
diff --git a/scraper/internal/novelfire/scraper_test.go b/scraper/internal/novelfire/scraper_test.go
index e629d38..1f46f36 100644
--- a/scraper/internal/novelfire/scraper_test.go
+++ b/scraper/internal/novelfire/scraper_test.go
@@ -141,6 +141,84 @@ func TestChapterNumberFromURL(t *testing.T) {
}
}
+// ── ScrapeMetadata ────────────────────────────────────────────────────────────
+
+func TestScrapeMetadata_ParsesFields(t *testing.T) {
+ html := `

A sweeping epic set in a magical world.

Hello world
`) + if err != nil { + t.Fatal(err) + } + p := FindFirst(root, scraper.Selector{Tag: "p"}) + if p == nil { + t.Fatal("expected to find") + } + if got := TextContent(p); got != "Hello world" { + t.Errorf("TextContent = %q, want %q", got, "Hello world") + } +} + +// ── FindFirst / FindAll ─────────────────────────────────────────────────────── + +func TestFindFirst_ByTag(t *testing.T) { + root, _ := ParseHTML(`
text
nothing
`) + n := FindFirst(root, scraper.Selector{Tag: "h1"}) + if n != nil { + t.Errorf("expected nil for missing tag, got %v", n) + } +} + +func TestFindAll_Multiple(t *testing.T) { + root, _ := ParseHTML(` +
`)
+ got := ExtractFirst(root, scraper.Selector{Tag: "img", Attr: "src"})
+ if got != "/covers/slug.jpg" {
+ t.Errorf("ExtractFirst img src = %q, want %q", got, "/covers/slug.jpg")
+ }
+}
+
+func TestExtractFirst_Missing(t *testing.T) {
+ root, _ := ParseHTML(``)
+ got := ExtractFirst(root, scraper.Selector{Tag: "h1"})
+ if got != "" {
+ t.Errorf("ExtractFirst missing = %q, want empty", got)
+ }
+}
+
+func TestExtractAll_Genres(t *testing.T) {
+ root, _ := ParseHTML(`
+
+ `)
+ genresNode := FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"})
+ if genresNode == nil {
+ t.Fatal("expected genres div")
+ }
+ genres := ExtractAll(genresNode, scraper.Selector{Tag: "a"})
+ if len(genres) != 2 {
+ t.Fatalf("genres = %v, want 2", genres)
+ }
+ if genres[0] != "Action" || genres[1] != "Fantasy" {
+ t.Errorf("genres = %v, want [Action Fantasy]", genres)
+ }
+}
+
+// ── NodeToMarkdown ────────────────────────────────────────────────────────────
+
+func TestNodeToMarkdown_Paragraphs(t *testing.T) {
+ root, _ := ParseHTML(`First paragraph.
+Second paragraph.
+He was very strong.
Good