Files
libnovel/scraper/internal/orchestrator/orchestrator.go
2026-02-26 12:56:25 +05:00

214 lines
5.4 KiB
Go

// Package orchestrator coordinates the catalogue walk, metadata extraction,
// chapter-list fetching, and parallel chapter scraping.
//
// Concurrency model
// - One goroutine runs ScrapeCatalogue and feeds book URLs into a channel.
// - For each book, a dedicated goroutine calls ScrapeMetadata (metadata goroutine).
// - ScrapeChapterList is called in the metadata goroutine once metadata is done.
// - N worker goroutines (default: runtime.NumCPU()) each pull ChapterRef values
// from a shared work queue and call ScrapeChapterText.
// - A sync.WaitGroup ensures all chapter workers finish before the orchestrator
// signals completion.
package orchestrator
import (
"context"
"fmt"
"log/slog"
"runtime"
"sync"
"github.com/libnovel/scraper/internal/scraper"
"github.com/libnovel/scraper/internal/writer"
)
// 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
// StaticRoot is the path to the static/books output directory.
StaticRoot string
// SingleBookURL when non-empty causes the orchestrator to scrape only
// that one book instead of walking the full catalogue.
SingleBookURL string
}
// Orchestrator coordinates the full scrape pipeline.
type Orchestrator struct {
cfg Config
novel scraper.NovelScraper
writer *writer.Writer
log *slog.Logger
workers int
}
// New returns a new Orchestrator.
func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger) *Orchestrator {
workers := cfg.Workers
if workers <= 0 {
workers = runtime.NumCPU()
}
return &Orchestrator{
cfg: cfg,
novel: novel,
writer: writer.New(cfg.StaticRoot),
log: log,
workers: workers,
}
}
// Run executes the full scrape pipeline and blocks until it is complete or ctx
// is cancelled.
func (o *Orchestrator) Run(ctx context.Context) error {
o.log.Info("orchestrator starting",
"source", o.novel.SourceName(),
"workers", o.workers,
"static_root", o.cfg.StaticRoot,
)
// chapterWork is the shared queue consumed by chapter worker goroutines.
type chapterJob struct {
slug string
ref scraper.ChapterRef
}
chapterWork := make(chan chapterJob, o.workers*4)
// Start chapter worker pool.
var chapterWG sync.WaitGroup
for i := 0; i < o.workers; i++ {
chapterWG.Add(1)
go func(workerID int) {
defer chapterWG.Done()
for job := range chapterWork {
select {
case <-ctx.Done():
return
default:
}
// Skip if already on disk.
if o.writer.ChapterExists(job.slug, job.ref) {
o.log.Debug("chapter already exists, skipping",
"book", job.slug, "chapter", job.ref.Number)
continue
}
chapter, err := o.novel.ScrapeChapterText(ctx, job.ref)
if err != nil {
o.log.Error("chapter scrape failed",
"book", job.slug,
"chapter", job.ref.Number,
"url", job.ref.URL,
"err", err,
)
continue
}
if err := o.writer.WriteChapter(job.slug, chapter); err != nil {
o.log.Error("chapter write failed",
"book", job.slug,
"chapter", job.ref.Number,
"err", err,
)
continue
}
o.log.Info("chapter saved",
"book", job.slug,
"chapter", job.ref.Number,
"worker", workerID,
)
}
}(i)
}
// processBook scrapes metadata + chapter list for one book, then enqueues
// chapter jobs. It is called inside a goroutine per book.
processBook := func(bookURL string) {
// Metadata goroutine.
meta, err := o.novel.ScrapeMetadata(ctx, bookURL)
if err != nil {
o.log.Error("metadata scrape failed", "url", bookURL, "err", err)
return
}
// Persist / update metadata.yaml.
if err := o.writer.WriteMetadata(meta); err != nil {
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
// Continue — chapters can still be scraped.
}
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
// Fetch chapter list.
refs, err := o.novel.ScrapeChapterList(ctx, bookURL)
if err != nil {
o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err)
return
}
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
// Enqueue chapter jobs.
for _, ref := range refs {
select {
case <-ctx.Done():
return
case chapterWork <- chapterJob{slug: meta.Slug, ref: ref}:
}
}
}
if o.cfg.SingleBookURL != "" {
// Single-book mode: skip catalogue entirely.
o.log.Info("single-book mode", "url", o.cfg.SingleBookURL)
processBook(o.cfg.SingleBookURL)
} else {
// Catalogue mode: stream every book.
entries, catErrs := o.novel.ScrapeCatalogue(ctx)
// Drain catalogue errors in a separate goroutine.
go func() {
for err := range catErrs {
o.log.Error("catalogue error", "err", err)
}
}()
var bookWG sync.WaitGroup
for entry := range entries {
select {
case <-ctx.Done():
break
default:
}
bookWG.Add(1)
bookURL := entry.URL
go func() {
defer bookWG.Done()
processBook(bookURL)
}()
}
// Wait for all book goroutines to enqueue their chapters before
// closing the chapter work queue.
bookWG.Wait()
}
// Signal chapter workers there is no more work.
close(chapterWork)
// Wait for all in-flight chapter scrapes to finish.
chapterWG.Wait()
if ctx.Err() != nil {
return fmt.Errorf("orchestrator: context cancelled: %w", ctx.Err())
}
o.log.Info("orchestrator finished")
return nil
}