package runner // catalogue_refresh.go — independent loop that walks the full novelfire.net // catalogue, scrapes per-book metadata, downloads cover images to MinIO, and // indexes every book in Meilisearch. // // Design: // - Runs on its own ticker (CatalogueRefreshInterval, default 24h) inside Run(). // - Also fires once on startup (unless SkipInitialCatalogueRefresh is set). // - ScrapeCatalogue streams CatalogueEntry values over a channel — already has // its own inter-page jitter + retryGet (see scraper.go). // - Per-book: only metadata is scraped here (not chapters). Chapters are scraped // on-demand when a user opens a book or via an explicit scrape task. // - Between each metadata request a configurable base delay plus up to 50% // random jitter is applied (CatalogueRequestDelay, default 2s). This keeps // the request rate well below novelfire.net's rate limit even for ~15k books. // - ScrapeMetadata itself uses retryGet with 429-aware exponential backoff // (up to 9 attempts), so transient rate limits are handled gracefully. // - Cover images are fetched and stored in MinIO on first sight; subsequent // refreshes skip covers that already exist (CoverExists check). // - Books already present in Meilisearch are skipped entirely (fast path). // - Errors for individual books are logged and skipped; the loop never aborts. import ( "context" "fmt" "io" "math/rand" "net/http" "time" ) // runCatalogueRefresh performs one full catalogue walk: scrapes metadata for // every book on novelfire.net, downloads covers to MinIO, and upserts to // Meilisearch. Individual book failures are logged and skipped. func (r *Runner) runCatalogueRefresh(ctx context.Context) { if r.deps.Novel == nil { r.deps.Log.Warn("runner: catalogue refresh skipped — Novel scraper not configured") return } if r.deps.BookWriter == nil { r.deps.Log.Warn("runner: catalogue refresh skipped — BookWriter not configured") return } delay := r.cfg.CatalogueRequestDelay log := r.deps.Log.With("op", "catalogue_refresh") log.Info("runner: catalogue refresh starting", "request_delay", delay) entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx) ok, skipped, errCount := 0, 0, 0 for entry := range entries { if ctx.Err() != nil { break } // Fast path: skip books already indexed in Meilisearch. if r.deps.SearchIndex.BookExists(ctx, entry.Slug) { skipped++ continue } // Polite delay between metadata requests: base + up to 50% jitter. // This applies before every fetch so we never fire bursts. jitter := time.Duration(rand.Int63n(int64(delay / 2))) select { case <-ctx.Done(): break case <-time.After(delay + jitter): } // ScrapeMetadata internally retries on 429 with exponential back-off. meta, err := r.deps.Novel.ScrapeMetadata(ctx, entry.URL) if err != nil { log.Warn("runner: catalogue refresh: metadata scrape failed — skipping book", "slug", entry.Slug, "url", entry.URL, "err", err) errCount++ continue } // Rewrite cover URL to backend proxy path so UI never hits CDN directly. originalCover := meta.Cover meta.Cover = fmt.Sprintf("/api/cover/novelfire.net/%s", meta.Slug) // Persist to PocketBase. if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil { log.Warn("runner: catalogue refresh: WriteMetadata failed — skipping book", "slug", meta.Slug, "err", err) errCount++ continue } // Index in Meilisearch (non-fatal). if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil { log.Warn("runner: catalogue refresh: UpsertBook failed", "slug", meta.Slug, "err", err) } // Download cover to MinIO if not already cached (non-fatal). if r.deps.CoverStore != nil && originalCover != "" { if !r.deps.CoverStore.CoverExists(ctx, meta.Slug) { if err := r.downloadCover(ctx, meta.Slug, originalCover); err != nil { log.Warn("runner: catalogue refresh: cover download failed", "slug", meta.Slug, "url", originalCover, "err", err) } } } ok++ if ok%50 == 0 { log.Info("runner: catalogue refresh progress", "scraped", ok, "skipped", skipped, "errors", errCount) } } if err := <-errCh; err != nil { log.Warn("runner: catalogue refresh: catalogue stream error", "err", err) } log.Info("runner: catalogue refresh finished", "ok", ok, "skipped", skipped, "errors", errCount) } // downloadCover fetches the cover image from coverURL and stores it in MinIO // under covers/{slug}.jpg. It retries up to 3 times with exponential backoff // on transient errors (5xx, network failures). func (r *Runner) downloadCover(ctx context.Context, slug, coverURL string) error { const maxRetries = 3 delay := 2 * time.Second var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { if ctx.Err() != nil { return ctx.Err() } if attempt > 0 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): } delay *= 2 } data, err := fetchCoverBytes(ctx, coverURL) if err != nil { lastErr = err continue } if err := r.deps.CoverStore.PutCover(ctx, slug, data, ""); err != nil { return fmt.Errorf("put cover: %w", err) } return nil } return fmt.Errorf("download cover after %d retries: %w", maxRetries, lastErr) } // fetchCoverBytes performs a single HTTP GET for coverURL and returns the body. func fetchCoverBytes(ctx context.Context, coverURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, coverURL, nil) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)") req.Header.Set("Referer", "https://novelfire.net/") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("http get: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 500 { _, _ = io.Copy(io.Discard, resp.Body) return nil, fmt.Errorf("upstream %d for %s", resp.StatusCode, coverURL) } if resp.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, resp.Body) return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, coverURL) } return io.ReadAll(io.LimitReader(resp.Body, 5<<20)) // 5 MiB cap }