All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
- backend/: Go API server and runner binaries with PocketBase + MinIO storage - ui-v2/: SvelteKit frontend rewrite - docker-compose-new.yml: compose file for the v2 stack - .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images - scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs - .env.example: document DOCKER_BUILDKIT=1 for Colima users
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
// Package orchestrator coordinates metadata extraction, chapter-list fetching,
|
|
// and parallel chapter scraping for a single book.
|
|
//
|
|
// Design:
|
|
// - RunBook scrapes one book (metadata + chapter list + chapter texts) end-to-end.
|
|
// - N worker goroutines pull chapter refs from a shared queue and call ScrapeChapterText.
|
|
// - The caller (runner poll loop) owns the outer task-claim / finish cycle.
|
|
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/libnovel/backend/internal/bookstore"
|
|
"github.com/libnovel/backend/internal/domain"
|
|
"github.com/libnovel/backend/internal/scraper"
|
|
)
|
|
|
|
// Config holds tunable parameters for the orchestrator.
|
|
type Config struct {
|
|
// Workers is the number of goroutines used to scrape chapters in parallel.
|
|
// Defaults to runtime.NumCPU() when 0.
|
|
Workers int
|
|
}
|
|
|
|
// Orchestrator runs a single-book scrape pipeline.
|
|
type Orchestrator struct {
|
|
novel scraper.NovelScraper
|
|
store bookstore.BookWriter
|
|
log *slog.Logger
|
|
workers int
|
|
}
|
|
|
|
// New returns a new Orchestrator.
|
|
func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log *slog.Logger) *Orchestrator {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
workers := cfg.Workers
|
|
if workers <= 0 {
|
|
workers = runtime.NumCPU()
|
|
}
|
|
return &Orchestrator{novel: novel, store: store, log: log, workers: workers}
|
|
}
|
|
|
|
// RunBook scrapes a single book described by task. It handles:
|
|
// 1. Metadata scrape + write
|
|
// 2. Chapter list scrape + write
|
|
// 3. Parallel chapter text scrape + write (worker pool)
|
|
//
|
|
// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty
|
|
// if the run failed at the metadata or chapter-list level.
|
|
func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult {
|
|
o.log.Info("orchestrator: RunBook starting",
|
|
"task_id", task.ID,
|
|
"kind", task.Kind,
|
|
"url", task.TargetURL,
|
|
"workers", o.workers,
|
|
)
|
|
|
|
var result domain.ScrapeResult
|
|
|
|
if task.TargetURL == "" {
|
|
result.ErrorMessage = "task has no target URL"
|
|
return result
|
|
}
|
|
|
|
// ── Step 1: Metadata ──────────────────────────────────────────────────────
|
|
meta, err := o.novel.ScrapeMetadata(ctx, task.TargetURL)
|
|
if err != nil {
|
|
o.log.Error("metadata scrape failed", "url", task.TargetURL, "err", err)
|
|
result.ErrorMessage = fmt.Sprintf("metadata: %v", err)
|
|
result.Errors++
|
|
return result
|
|
}
|
|
|
|
if err := o.store.WriteMetadata(ctx, meta); err != nil {
|
|
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
|
|
// non-fatal: continue to chapters
|
|
result.Errors++
|
|
} else {
|
|
result.BooksFound = 1
|
|
}
|
|
|
|
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
|
|
|
// ── Step 2: Chapter list ──────────────────────────────────────────────────
|
|
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL)
|
|
if err != nil {
|
|
o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err)
|
|
result.ErrorMessage = fmt.Sprintf("chapter list: %v", err)
|
|
result.Errors++
|
|
return result
|
|
}
|
|
|
|
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
|
|
|
|
// Persist chapter refs (without text) so the index exists early.
|
|
if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil {
|
|
o.log.Warn("chapter refs write failed", "slug", meta.Slug, "err", wErr)
|
|
}
|
|
|
|
// ── Step 3: Chapter texts (worker pool) ───────────────────────────────────
|
|
type chapterJob struct {
|
|
slug string
|
|
ref domain.ChapterRef
|
|
total int // total chapters to scrape (for progress logging)
|
|
}
|
|
work := make(chan chapterJob, o.workers*4)
|
|
|
|
var scraped, skipped, errors atomic.Int64
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < o.workers; i++ {
|
|
wg.Add(1)
|
|
go func(workerID int) {
|
|
defer wg.Done()
|
|
for job := range work {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
if o.store.ChapterExists(ctx, job.slug, job.ref) {
|
|
o.log.Debug("chapter already exists, skipping",
|
|
"slug", job.slug, "chapter", job.ref.Number)
|
|
skipped.Add(1)
|
|
continue
|
|
}
|
|
|
|
ch, err := o.novel.ScrapeChapterText(ctx, job.ref)
|
|
if err != nil {
|
|
o.log.Error("chapter scrape failed",
|
|
"slug", job.slug, "chapter", job.ref.Number, "err", err)
|
|
errors.Add(1)
|
|
continue
|
|
}
|
|
|
|
if err := o.store.WriteChapter(ctx, job.slug, ch); err != nil {
|
|
o.log.Error("chapter write failed",
|
|
"slug", job.slug, "chapter", job.ref.Number, "err", err)
|
|
errors.Add(1)
|
|
continue
|
|
}
|
|
|
|
n := scraped.Add(1)
|
|
// Log a progress summary every 25 chapters scraped.
|
|
if n%25 == 0 {
|
|
o.log.Info("scraping chapters",
|
|
"slug", job.slug, "scraped", n, "total", job.total)
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
// Count how many chapters will actually be enqueued (for progress logging).
|
|
toScrape := 0
|
|
for _, ref := range refs {
|
|
if task.FromChapter > 0 && ref.Number < task.FromChapter {
|
|
continue
|
|
}
|
|
if task.ToChapter > 0 && ref.Number > task.ToChapter {
|
|
continue
|
|
}
|
|
toScrape++
|
|
}
|
|
|
|
// Enqueue chapter jobs respecting the optional range filter from the task.
|
|
for _, ref := range refs {
|
|
if task.FromChapter > 0 && ref.Number < task.FromChapter {
|
|
skipped.Add(1)
|
|
continue
|
|
}
|
|
if task.ToChapter > 0 && ref.Number > task.ToChapter {
|
|
skipped.Add(1)
|
|
continue
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
goto drain
|
|
case work <- chapterJob{slug: meta.Slug, ref: ref, total: toScrape}:
|
|
}
|
|
}
|
|
|
|
drain:
|
|
close(work)
|
|
wg.Wait()
|
|
|
|
result.ChaptersScraped = int(scraped.Load())
|
|
result.ChaptersSkipped = int(skipped.Load())
|
|
result.Errors += int(errors.Load())
|
|
|
|
o.log.Info("book scrape finished",
|
|
"slug", meta.Slug,
|
|
"scraped", result.ChaptersScraped,
|
|
"skipped", result.ChaptersSkipped,
|
|
"errors", result.Errors,
|
|
)
|
|
return result
|
|
}
|