Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows - Flatten v3/ contents to repo root - Add Doppler secrets management (project=libnovel, config=prd) - Add justfile with doppler run wrappers for all docker compose commands - Strip hardcoded env fallbacks from docker-compose.yml - Add minimal README.md - Clean up .gitignore
223 lines
6.6 KiB
Go
223 lines
6.6 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.
|
|
// - An optional PostMetadata hook (set in Config) is called after WriteMetadata
|
|
// succeeds. The runner uses this to upsert books into Meilisearch.
|
|
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
|
|
// PostMetadata is an optional hook called with the scraped BookMeta after
|
|
// WriteMetadata succeeds. Errors from the hook are logged but not fatal.
|
|
// Used by the runner to index books in Meilisearch.
|
|
PostMetadata func(ctx context.Context, meta domain.BookMeta)
|
|
}
|
|
|
|
// Orchestrator runs a single-book scrape pipeline.
|
|
type Orchestrator struct {
|
|
novel scraper.NovelScraper
|
|
store bookstore.BookWriter
|
|
log *slog.Logger
|
|
workers int
|
|
postMetadata func(ctx context.Context, meta domain.BookMeta)
|
|
}
|
|
|
|
// 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,
|
|
postMetadata: cfg.PostMetadata,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// Fire optional post-metadata hook (e.g. Meilisearch indexing).
|
|
if o.postMetadata != nil {
|
|
o.postMetadata(ctx, meta)
|
|
}
|
|
}
|
|
|
|
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
|
|
|
// ── Step 2: Chapter list ──────────────────────────────────────────────────
|
|
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter)
|
|
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
|
|
}
|