Some checks failed
Release / Test backend (push) Successful in 29s
Release / Check ui (push) Successful in 43s
CI / Test backend (pull_request) Successful in 28s
Release / Docker / caddy (push) Successful in 1m8s
CI / Docker / caddy (pull_request) Failing after 39s
CI / Check ui (pull_request) Successful in 55s
Release / Docker / runner (push) Successful in 2m22s
Release / Docker / ui (push) Successful in 2m20s
CI / Docker / backend (pull_request) Successful in 2m32s
CI / Docker / ui (pull_request) Successful in 1m28s
CI / Docker / runner (pull_request) Successful in 2m25s
Release / Docker / backend (push) Successful in 2m33s
Release / Gitea Release (push) Failing after 2s
- scraper.go: ScrapeCatalogue now uses retryGet (9 attempts, 10s base) + 500–1500ms inter-page jitter instead of bare GetContent. ScrapeMetadata also switched to retryGet so a single 429 on a book page is retried rather than aborting the whole refresh. - catalogue_refresh.go: per-book delay is now configurable (RUNNER_CATALOGUE_REQUEST_DELAY, default 2s) + up to 50% random jitter applied before every metadata fetch. Only metadata is scraped here — chapters are fetched on-demand, not during catalogue refresh. Progress logged every 50 books instead of 100. - config.go / runner.go / main.go: add CatalogueRequestDelay field wired from RUNNER_CATALOGUE_REQUEST_DELAY env var. - release.yaml: comment out upload-sourcemaps job and remove it from the release needs; GlitchTip auth token needs refreshing after DB wipe.
187 lines
6.2 KiB
Go
187 lines
6.2 KiB
Go
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
|
|
}
|