From b8d4d94b1870e26549bf2a73429dba7b587053d3 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 3 Mar 2026 19:37:49 +0500 Subject: [PATCH] refactor(ranking): replace blob cache with per-item PocketBase storage - Replace SetRanking/GetRanking/SetRankingPageHTML/GetRankingPageHTML blob methods with WriteRankingItem/ReadRankingItems/RankingFreshEnough per-item operations - Add 24h staleness gate in ScrapeRanking to skip re-scraping fresh data - Add GET /api/ranking endpoint returning []RankingItem sorted by rank - Remove RankingPageCacher interface and rankingCacheAdapter adapter - Update integration tests to use new per-item upsert semantics - Include e2e test suite (scraper/internal/e2e/) --- scraper/cmd/scraper/main.go | 16 +- scraper/internal/e2e/e2e_test.go | 816 ++++++++++++++++++ scraper/internal/novelfire/ranking_test.go | 88 -- scraper/internal/novelfire/scraper.go | 151 +++- scraper/internal/scraper/interfaces.go | 10 - scraper/internal/server/server.go | 77 +- scraper/internal/storage/hybrid.go | 58 +- .../storage/hybrid_integration_test.go | 80 +- scraper/internal/storage/pocketbase.go | 187 ++-- scraper/internal/storage/store.go | 41 +- 10 files changed, 1173 insertions(+), 351 deletions(-) create mode 100644 scraper/internal/e2e/e2e_test.go diff --git a/scraper/cmd/scraper/main.go b/scraper/cmd/scraper/main.go index 76b6760..953025b 100644 --- a/scraper/cmd/scraper/main.go +++ b/scraper/cmd/scraper/main.go @@ -119,7 +119,7 @@ func run(log *slog.Logger) error { return fmt.Errorf("storage init failed: %w", err) } - nf := novelfire.New(bc, log, urlClient, &rankingCacheAdapter{store: store}) + nf := novelfire.New(bc, log, urlClient, store) workers := 0 if s := os.Getenv("SCRAPER_WORKERS"); s != "" { @@ -215,20 +215,6 @@ func envOr(key, fallback string) string { return fallback } -// rankingCacheAdapter bridges storage.HybridStore (context-aware) to the -// context-free scraper.RankingPageCacher interface expected by novelfire.New. -type rankingCacheAdapter struct { - store *storage.HybridStore -} - -func (a *rankingCacheAdapter) WriteRankingPageCache(page int, html string) error { - return a.store.WriteRankingPageCache(context.Background(), page, html) -} - -func (a *rankingCacheAdapter) ReadRankingPageCache(page int) (string, error) { - return a.store.ReadRankingPageCache(context.Background(), page) -} - func printUsage() { fmt.Fprintf(os.Stderr, `libnovel scraper diff --git a/scraper/internal/e2e/e2e_test.go b/scraper/internal/e2e/e2e_test.go new file mode 100644 index 0000000..de4cd4d --- /dev/null +++ b/scraper/internal/e2e/e2e_test.go @@ -0,0 +1,816 @@ +//go:build integration + +// End-to-end integration test for libnovel. +// +// Scenario (executed in order): +// 1. Health-check all Docker services (PocketBase, MinIO, Browserless, scraper). +// 2. Register a test user in the app_users PocketBase collection. +// 3. Scrape the popular-ranking page 1 and capture the first book. +// 4. Scrape full metadata for that book and persist it; verify in PocketBase. +// 5. Scrape chapters 1–3 and persist them; verify in MinIO + PocketBase. +// 6. Generate TTS audio for the first 100 chars of each chapter via the scraper +// HTTP API; verify MinIO object + PocketBase audio_cache entry. +// 7. Fetch presigned URLs for each chapter's markdown and audio; verify HTTP 200. +// +// Prerequisites (all must be running): +// +// docker-compose up -d minio pocketbase browserless scraper +// +// Run with: +// +// BROWSERLESS_URL=http://localhost:3030 \ +// MINIO_ENDPOINT=localhost:9000 \ +// POCKETBASE_URL=http://localhost:8090 \ +// SCRAPER_URL=http://localhost:8080 \ +// go test -v -tags integration -timeout 900s \ +// github.com/libnovel/scraper/internal/e2e +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/libnovel/scraper/internal/browser" + "github.com/libnovel/scraper/internal/novelfire" + "github.com/libnovel/scraper/internal/scraper" + "github.com/libnovel/scraper/internal/storage" +) + +// ─── env helpers ───────────────────────────────────────────────────────────── + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// ─── fixture ───────────────────────────────────────────────────────────────── + +type e2eFixture struct { + sc *novelfire.Scraper + hs *storage.HybridStore + scraperURL string // base URL of the running scraper HTTP server + pbBaseURL string + pbEmail string + pbPassword string +} + +func newE2EFixture(t *testing.T) *e2eFixture { + t.Helper() + + browserlessURL := envOr("BROWSERLESS_URL", "") + if browserlessURL == "" { + t.Skip("BROWSERLESS_URL not set — skipping e2e test") + } + if os.Getenv("MINIO_ENDPOINT") == "" { + t.Skip("MINIO_ENDPOINT not set — skipping e2e test") + } + if os.Getenv("POCKETBASE_URL") == "" { + t.Skip("POCKETBASE_URL not set — skipping e2e test") + } + scraperURL := envOr("SCRAPER_URL", "http://localhost:8080") + + pbBaseURL := envOr("POCKETBASE_URL", "http://localhost:8090") + pbEmail := envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local") + pbPassword := envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123") + + pbCfg := storage.PocketBaseConfig{ + BaseURL: pbBaseURL, + AdminEmail: pbEmail, + AdminPassword: pbPassword, + } + minioCfg := storage.MinioConfig{ + Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), + AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), + SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), + UseSSL: envOr("MINIO_USE_SSL", "false") == "true", + BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), + BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg) + if err != nil { + t.Fatalf("NewHybridStore: %v", err) + } + + client := browser.NewContentClient(browser.Config{ + BaseURL: browserlessURL, + Token: os.Getenv("BROWSERLESS_TOKEN"), + Timeout: 120 * time.Second, + MaxConcurrent: 2, + }) + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + sc := novelfire.New(client, log, client, nil) + + return &e2eFixture{ + sc: sc, + hs: hs, + scraperURL: scraperURL, + pbBaseURL: pbBaseURL, + pbEmail: pbEmail, + pbPassword: pbPassword, + } +} + +// ─── The single end-to-end test ─────────────────────────────────────────────── + +// TestE2E_FullScenario executes the complete end-to-end scenario in order. +func TestE2E_FullScenario(t *testing.T) { + f := newE2EFixture(t) + + // ── Step 1: Health-check services ──────────────────────────────────────── + t.Run("step1_health_checks", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // PocketBase health + pbHealth := f.pbBaseURL + "/api/health" + checkHTTP(t, ctx, pbHealth, "PocketBase") + + // MinIO health — the MinIO console liveness endpoint + minioEndpoint := envOr("MINIO_ENDPOINT", "localhost:9000") + scheme := "http" + if envOr("MINIO_USE_SSL", "false") == "true" { + scheme = "https" + } + minioHealth := fmt.Sprintf("%s://%s/minio/health/live", scheme, minioEndpoint) + checkHTTP(t, ctx, minioHealth, "MinIO") + + // Browserless health — /pressure is the liveness endpoint + browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030") + blHealth := browserlessURL + "/pressure" + checkHTTP(t, ctx, blHealth, "Browserless") + + // Scraper server health — wait up to 10 s for it to be ready + scraperHealth := f.scraperURL + "/health" + waitForHTTP(t, ctx, scraperHealth, "scraper server", 10*time.Second) + }) + + // ── Step 2: Register test user ──────────────────────────────────────────── + var testUsername string + t.Run("step2_register_user", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + testUsername = fmt.Sprintf("e2euser-%d", time.Now().UnixMilli()%100000) + passwordHash := "pbkdf2:sha256:dummy-hash-for-test" + + t.Cleanup(func() { + cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cleanCancel() + deleteAppUser(t, f, cleanCtx, testUsername) + }) + + if err := createAppUser(ctx, f, testUsername, passwordHash, "reader"); err != nil { + t.Fatalf("createAppUser: %v", err) + } + t.Logf("created user %q", testUsername) + + // Verify the user exists in PocketBase. + rec, err := getAppUserByUsername(ctx, f, testUsername) + if err != nil { + t.Fatalf("getAppUserByUsername: %v", err) + } + if rec == nil { + t.Fatal("user not found in app_users after creation") + } + if rec["username"] != testUsername { + t.Errorf("username = %q, want %q", rec["username"], testUsername) + } + t.Logf("user verified in PocketBase: id=%v username=%v role=%v", rec["id"], rec["username"], rec["role"]) + }) + + // ── Step 3: Scrape ranking page 1, capture first book ──────────────────── + var firstBook scraper.BookMeta + t.Run("step3_scrape_ranking", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + entries, errs := f.sc.ScrapeRanking(ctx, 1) // maxPages=1 → only page 1 + + select { + case meta, ok := <-entries: + if !ok { + t.Fatal("ranking channel closed without any entry") + } + firstBook = meta + case err := <-errs: + t.Fatalf("ScrapeRanking error: %v", err) + case <-ctx.Done(): + t.Fatal("ScrapeRanking timed out waiting for first entry") + } + + // Drain remaining entries and errors. + for range entries { + } + for range errs { + } + + if firstBook.Slug == "" { + t.Fatal("first book has empty slug") + } + if firstBook.Title == "" { + t.Fatal("first book has empty title") + } + if firstBook.SourceURL == "" { + t.Fatal("first book has empty SourceURL") + } + t.Logf("first ranked book: slug=%q title=%q rank=%d url=%s", + firstBook.Slug, firstBook.Title, firstBook.Ranking, firstBook.SourceURL) + }) + + if firstBook.Slug == "" || firstBook.SourceURL == "" { + t.Fatal("cannot continue: step3 did not produce a valid first book") + } + + // Use a unique slug for the test to avoid colliding with real scraped data. + testSlug := fmt.Sprintf("%s-e2e-%d", firstBook.Slug, time.Now().UnixMilli()%100000) + t.Logf("using test slug: %q", testSlug) + + // Register cleanup for all data written by subsequent steps. + t.Cleanup(func() { + cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cleanCancel() + cleanupTestData(t, f, cleanCtx, testSlug) + }) + + // ── Step 4: Scrape book metadata and persist ────────────────────────────── + var fullMeta scraper.BookMeta + t.Run("step4_scrape_metadata", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + meta, err := f.sc.ScrapeMetadata(ctx, firstBook.SourceURL) + if err != nil { + t.Fatalf("ScrapeMetadata: %v", err) + } + t.Logf("scraped metadata: title=%q author=%q totalChapters=%d", + meta.Title, meta.Author, meta.TotalChapters) + + // Override slug so data lands under our test slug. + meta.Slug = testSlug + fullMeta = meta + + storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer storeCancel() + + if err := f.hs.WriteMetadata(storeCtx, meta); err != nil { + t.Fatalf("WriteMetadata: %v", err) + } + + // Verify in PocketBase. + got, found, err := f.hs.ReadMetadata(storeCtx, testSlug) + if err != nil { + t.Fatalf("ReadMetadata: %v", err) + } + if !found { + t.Fatal("book not found in PocketBase after WriteMetadata") + } + if got.Title == "" { + t.Error("book title is empty after round-trip") + } + if got.Author == "" { + t.Logf("WARNING: book author is empty after round-trip (site may not expose author for this book)") + } + t.Logf("PocketBase verified: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters) + }) + + if fullMeta.SourceURL == "" { + fullMeta.SourceURL = firstBook.SourceURL + } + + // ── Step 5: Scrape first 3 chapters and persist ─────────────────────────── + var chapterRefs []scraper.ChapterRef + t.Run("step5_scrape_chapters", func(t *testing.T) { + // Fetch only page 1 of the chapter list from + // https://novelfire.net/book/{slug}/chapters?page=1 + // to avoid paginating through hundreds of pages for popular books. + listCtx, listCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer listCancel() + + chaptersPageURL := firstBook.SourceURL + "/chapters?page=1" + refs, err := scrapeChapterListPage1(listCtx, f, chaptersPageURL) + if err != nil { + t.Fatalf("scrapeChapterListPage1: %v", err) + } + if len(refs) == 0 { + t.Fatal("chapter list page 1 returned no chapters") + } + t.Logf("chapter list page 1: %d chapters found", len(refs)) + + // Take the first 3 (or fewer if page 1 has < 3 chapters). + n := 3 + if len(refs) < n { + n = len(refs) + } + chapterRefs = refs[:n] + t.Logf("will scrape first %d chapters: %v", n, chapterNumbers(chapterRefs)) + + for _, ref := range chapterRefs { + ref := ref + t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) { + scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 180*time.Second) + defer scrapeCancel() + + ch, err := f.sc.ScrapeChapterText(scrapeCtx, ref) + if err != nil { + t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err) + } + t.Logf("scraped chapter %d: %d bytes", ref.Number, len(ch.Text)) + if len(ch.Text) < 50 { + t.Errorf("chapter %d text too short (%d bytes)", ref.Number, len(ch.Text)) + } + + // Override ref slug with our test slug. + ch.Ref.Number = ref.Number + ch.Ref.Title = ref.Title + + storeCtx, storeCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer storeCancel() + + if err := f.hs.WriteChapter(storeCtx, testSlug, ch); err != nil { + t.Fatalf("WriteChapter(%d): %v", ref.Number, err) + } + + // Verify in MinIO via ReadChapter. + got, err := f.hs.ReadChapter(storeCtx, testSlug, ref.Number) + if err != nil { + t.Fatalf("ReadChapter(%d): %v", ref.Number, err) + } + if got == "" { + t.Errorf("chapter %d: ReadChapter returned empty content", ref.Number) + } + if !strings.HasPrefix(got, "# ") { + t.Errorf("chapter %d: stored content missing markdown header (got %q)", ref.Number, got[:min(len(got), 80)]) + } + + // Verify PocketBase chapters_idx entry. + idxCtx, idxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer idxCancel() + count := f.hs.CountChapters(idxCtx, testSlug) + if count == 0 { + t.Errorf("chapter %d: chapters_idx count = 0 after WriteChapter", ref.Number) + } + t.Logf("chapter %d stored; chapters_idx count=%d", ref.Number, count) + }) + } + }) + + if len(chapterRefs) == 0 { + t.Fatal("cannot continue: step5 produced no chapter refs") + } + + // ── Step 6: Generate TTS audio via scraper HTTP API ─────────────────────── + t.Run("step6_tts_audio", func(t *testing.T) { + if os.Getenv("SCRAPER_URL") == "" { + t.Skip("SCRAPER_URL not set — skipping TTS step") + } + + voice := envOr("KOKORO_VOICE", "af_bella") + + for _, ref := range chapterRefs { + ref := ref + t.Run(fmt.Sprintf("audio-chapter-%d", ref.Number), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + audioURL := fmt.Sprintf("%s/api/audio/%s/%d", f.scraperURL, testSlug, ref.Number) + body, _ := json.Marshal(map[string]interface{}{ + "voice": voice, + "speed": 1.0, + }) + + audioReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, audioURL, bytes.NewReader(body)) + audioReq.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(audioReq) + if err != nil { + t.Fatalf("POST %s: %v", audioURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("audio generation status=%d body=%s", resp.StatusCode, raw) + } + + var audioResp struct { + URL string `json:"url"` + Filename string `json:"filename"` + } + if err := json.NewDecoder(resp.Body).Decode(&audioResp); err != nil { + t.Fatalf("decode audio response: %v", err) + } + if audioResp.URL == "" { + t.Error("audio response has empty url field") + } + if audioResp.Filename == "" { + t.Error("audio response has empty filename field") + } + t.Logf("chapter %d audio: url=%s filename=%s", ref.Number, audioResp.URL, audioResp.Filename) + + // Verify audio_cache entry exists in PocketBase. + pbCtx, pbCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer pbCancel() + + cacheKey := fmt.Sprintf("%s/%d/%s/1.00", testSlug, ref.Number, voice) + filename, found := f.hs.GetAudioCache(pbCtx, cacheKey) + if !found { + t.Errorf("audio_cache entry not found for key=%q", cacheKey) + } else { + t.Logf("audio_cache[%q] = %q", cacheKey, filename) + } + }) + } + }) + + // ── Step 7: Presigned URLs ──────────────────────────────────────────────── + t.Run("step7_presigned_urls", func(t *testing.T) { + if os.Getenv("SCRAPER_URL") == "" { + t.Skip("SCRAPER_URL not set — skipping presign step") + } + + // Give the background MinIO upload goroutines (launched by handleAudioGenerate) + // a moment to complete before we attempt to access the presigned URLs. + time.Sleep(5 * time.Second) + + voice := envOr("KOKORO_VOICE", "af_bella") + + for _, ref := range chapterRefs { + ref := ref + t.Run(fmt.Sprintf("presign-chapter-%d", ref.Number), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Chapter markdown presign. + chPresignURL := fmt.Sprintf("%s/api/presign/chapter/%s/%d", + f.scraperURL, testSlug, ref.Number) + chPresigned := fetchPresignedURL(t, ctx, chPresignURL, "chapter presign") + if chPresigned != "" { + assertURLAccessible(t, ctx, chPresigned, fmt.Sprintf("chapter %d presigned URL", ref.Number)) + } + + // Audio presign — poll with retries to allow background MinIO upload to finish. + auPresignURL := fmt.Sprintf("%s/api/presign/audio/%s/%d?voice=%s&speed=1.0", + f.scraperURL, testSlug, ref.Number, voice) + auPresigned := fetchPresignedURL(t, ctx, auPresignURL, "audio presign") + if auPresigned != "" { + assertURLAccessibleWithRetry(t, ctx, auPresigned, fmt.Sprintf("chapter %d audio presigned URL", ref.Number), 6, 5*time.Second) + } + }) + } + }) +} + +// ─── PocketBase admin helpers ───────────────────────────────────────────────── + +// pbAuthToken obtains a PocketBase superuser JWT. +func pbAuthToken(ctx context.Context, f *e2eFixture) (string, error) { + body, _ := json.Marshal(map[string]string{ + "identity": f.pbEmail, + "password": f.pbPassword, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + f.pbBaseURL+"/api/collections/_superusers/auth-with-password", + bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("pbAuthToken: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("pbAuthToken status %d: %s", resp.StatusCode, b) + } + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("pbAuthToken decode: %w", err) + } + return result.Token, nil +} + +// createAppUser inserts a record into app_users via PocketBase admin API. +func createAppUser(ctx context.Context, f *e2eFixture, username, passwordHash, role string) error { + tok, err := pbAuthToken(ctx, f) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]interface{}{ + "username": username, + "password_hash": passwordHash, + "role": role, + "created": time.Now().UTC().Format(time.RFC3339), + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + f.pbBaseURL+"/api/collections/app_users/records", + bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("createAppUser: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("createAppUser status %d: %s", resp.StatusCode, b) + } + return nil +} + +// getAppUserByUsername fetches an app_users record by username. +// Returns nil, nil when not found. +func getAppUserByUsername(ctx context.Context, f *e2eFixture, username string) (map[string]interface{}, error) { + tok, err := pbAuthToken(ctx, f) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=1", + f.pbBaseURL, username) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("getAppUserByUsername: %w", err) + } + defer resp.Body.Close() + var result struct { + Items []map[string]interface{} `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("getAppUserByUsername decode: %w", err) + } + if len(result.Items) == 0 { + return nil, nil + } + return result.Items[0], nil +} + +// deleteAppUser removes app_users records matching username. +func deleteAppUser(t *testing.T, f *e2eFixture, ctx context.Context, username string) { + t.Helper() + tok, err := pbAuthToken(ctx, f) + if err != nil { + t.Logf("deleteAppUser: pbAuthToken error: %v", err) + return + } + // List matching records. + url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=10", + f.pbBaseURL, username) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("deleteAppUser list error: %v", err) + return + } + defer resp.Body.Close() + var result struct { + Items []map[string]interface{} `json:"items"` + } + _ = json.NewDecoder(resp.Body).Decode(&result) + for _, item := range result.Items { + id, _ := item["id"].(string) + delURL := fmt.Sprintf("%s/api/collections/app_users/records/%s", f.pbBaseURL, id) + delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil) + delReq.Header.Set("Authorization", tok) + delResp, _ := http.DefaultClient.Do(delReq) + if delResp != nil { + delResp.Body.Close() + } + } +} + +// cleanupTestData removes all PocketBase + MinIO data for the given slug. +func cleanupTestData(t *testing.T, f *e2eFixture, ctx context.Context, slug string) { + t.Helper() + tok, err := pbAuthToken(ctx, f) + if err != nil { + t.Logf("cleanupTestData: pbAuthToken error: %v", err) + return + } + pbDelete := func(collection, filter string) { + listURL := fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=500", + f.pbBaseURL, collection, filter) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("cleanupTestData list %s error: %v", collection, err) + return + } + defer resp.Body.Close() + var result struct { + Items []map[string]interface{} `json:"items"` + } + _ = json.NewDecoder(resp.Body).Decode(&result) + for _, item := range result.Items { + id, _ := item["id"].(string) + delURL := fmt.Sprintf("%s/api/collections/%s/records/%s", f.pbBaseURL, collection, id) + delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil) + delReq.Header.Set("Authorization", tok) + delResp, _ := http.DefaultClient.Do(delReq) + if delResp != nil { + delResp.Body.Close() + } + } + } + slugFilter := fmt.Sprintf("slug%%3D%%22%s%%22", slug) + ckFilter := fmt.Sprintf("cache_key%%7E%%22%s%%2F%%22", slug) // cache_key ~ "slug/" + pbDelete("books", slugFilter) + pbDelete("chapters_idx", slugFilter) + pbDelete("audio_cache", ckFilter) + t.Logf("cleanup complete for slug=%q", slug) +} + +// ─── HTTP assertion helpers ─────────────────────────────────────────────────── + +// checkHTTP asserts that a GET to url returns 2xx within the context deadline. +func checkHTTP(t *testing.T, ctx context.Context, url, name string) { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Errorf("%s health check: build request: %v", name, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("%s health check failed: %v", name, err) + return + } + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Errorf("%s health check: status %d, want 2xx", name, resp.StatusCode) + return + } + t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode) +} + +// waitForHTTP retries GET url until a 2xx is received or timeout is reached. +func waitForHTTP(t *testing.T, ctx context.Context, url, name string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + t.Errorf("%s: context cancelled while waiting for health", name) + return + default: + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := http.DefaultClient.Do(req) + if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { + resp.Body.Close() + t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode) + return + } + if resp != nil { + resp.Body.Close() + lastErr = fmt.Errorf("status %d", resp.StatusCode) + } else { + lastErr = err + } + time.Sleep(500 * time.Millisecond) + } + t.Errorf("%s not healthy after %s: %v", name, timeout, lastErr) +} + +// fetchPresignedURL calls the presign endpoint and returns the presigned URL. +// It logs and returns "" on failure (non-fatal) so the caller can decide. +func fetchPresignedURL(t *testing.T, ctx context.Context, presignEndpoint, label string) string { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignEndpoint, nil) + if err != nil { + t.Errorf("fetchPresignedURL %s: %v", label, err) + return "" + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("fetchPresignedURL %s: %v", label, err) + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Errorf("fetchPresignedURL %s: status %d body=%s", label, resp.StatusCode, b) + return "" + } + var body struct { + URL string `json:"url"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Errorf("fetchPresignedURL %s decode: %v", label, err) + return "" + } + if body.URL == "" { + t.Errorf("fetchPresignedURL %s: empty url in response", label) + return "" + } + t.Logf("%s presigned URL: %s", label, body.URL) + return body.URL +} + +// assertURLAccessible does a GET to url and asserts HTTP 200. +func assertURLAccessible(t *testing.T, ctx context.Context, url, label string) { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Errorf("%s: build request: %v", label, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("%s: GET error: %v", label, err) + return + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("%s: status %d, want 200", label, resp.StatusCode) + return + } + t.Logf("%s: HTTP 200 OK", label) +} + +// assertURLAccessibleWithRetry retries GET url up to maxAttempts times with +// interval between attempts, asserting HTTP 200 on any success. +func assertURLAccessibleWithRetry(t *testing.T, ctx context.Context, url, label string, maxAttempts int, interval time.Duration) { + t.Helper() + var lastStatus int + for attempt := 1; attempt <= maxAttempts; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Errorf("%s: build request: %v", label, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("%s: attempt %d GET error: %v", label, attempt, err) + } else { + resp.Body.Close() + lastStatus = resp.StatusCode + if resp.StatusCode == http.StatusOK { + t.Logf("%s: HTTP 200 OK (attempt %d)", label, attempt) + return + } + t.Logf("%s: attempt %d status %d", label, attempt, resp.StatusCode) + } + if attempt < maxAttempts { + select { + case <-ctx.Done(): + t.Errorf("%s: context cancelled before success", label) + return + case <-time.After(interval): + } + } + } + t.Errorf("%s: status %d after %d attempts, want 200", label, lastStatus, maxAttempts) +} + +// ─── stdlib helpers ─────────────────────────────────────────────────────────── + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func chapterNumbers(refs []scraper.ChapterRef) []int { + ns := make([]int, len(refs)) + for i, r := range refs { + ns[i] = r.Number + } + return ns +} + +// scrapeChapterListPage1 fetches a single chapter-list page URL via Browserless +// and returns the chapter refs found on that page (no pagination). +// URL should be: https://novelfire.net/book/{slug}/chapters?page=1 +func scrapeChapterListPage1(ctx context.Context, f *e2eFixture, pageURL string) ([]scraper.ChapterRef, error) { + return f.sc.ScrapeChapterListPage(ctx, pageURL) +} diff --git a/scraper/internal/novelfire/ranking_test.go b/scraper/internal/novelfire/ranking_test.go index 15611a8..2127ae9 100644 --- a/scraper/internal/novelfire/ranking_test.go +++ b/scraper/internal/novelfire/ranking_test.go @@ -2,12 +2,10 @@ package novelfire import ( "context" - "fmt" "os" "path/filepath" "testing" - "github.com/libnovel/scraper/internal/browser" "github.com/libnovel/scraper/internal/scraper" "github.com/libnovel/scraper/internal/writer" ) @@ -208,89 +206,3 @@ func TestWriteRanking_RoundTrip(t *testing.T) { } } } - -// ── in-memory page cacher ───────────────────────────────────────────────────── - -// memPageCacher is a RankingPageCacher backed by an in-memory map. -// It records how many times each page was written and exposes the stored HTML. -type memPageCacher struct { - pages map[int]string - writes map[int]int -} - -func newMemPageCacher() *memPageCacher { - return &memPageCacher{pages: make(map[int]string), writes: make(map[int]int)} -} - -func (c *memPageCacher) WriteRankingPageCache(page int, html string) error { - c.pages[page] = html - c.writes[page]++ - return nil -} - -func (c *memPageCacher) ReadRankingPageCache(page int) (string, error) { - return c.pages[page], nil // returns "" on miss, satisfying the interface contract -} - -var _ scraper.RankingPageCacher = (*memPageCacher)(nil) // compile-time check - -// TestScrapeRanking_CacheHit verifies that when a page is already in the cache -// ScrapeRanking serves from cache and does NOT call the browser client. -func TestScrapeRanking_CacheHit(t *testing.T) { - cache := newMemPageCacher() - // Pre-populate the cache with page 1 HTML. - if err := cache.WriteRankingPageCache(1, rankingPage1HTML()); err != nil { - t.Fatalf("cache write: %v", err) - } - cache.writes[1] = 0 // reset write counter — we only care about fetches - - // The stub client panics on any GetContent call so we can prove it is not used. - panicClient := &panicOnGetContent{} - s := New(panicClient, nil, panicClient, cache) - - entryCh, errCh := s.ScrapeRanking(context.Background(), 1) - entries := drainRanking(t, entryCh, errCh) - - if len(entries) != 2 { - t.Fatalf("expected 2 entries from cache, got %d", len(entries)) - } - // Cache should not have been written again (we served from cache). - if cache.writes[1] != 0 { - t.Errorf("expected 0 cache writes on a hit, got %d", cache.writes[1]) - } -} - -// TestScrapeRanking_CacheMiss verifies that on a cache miss the page is fetched -// from the network and the result is written to the cache. -func TestScrapeRanking_CacheMiss(t *testing.T) { - cache := newMemPageCacher() // empty cache - s := New(&stubClient{html: rankingPage1HTML()}, nil, nil, cache) - - entryCh, errCh := s.ScrapeRanking(context.Background(), 1) - entries := drainRanking(t, entryCh, errCh) - - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if cache.writes[1] != 1 { - t.Errorf("expected 1 cache write on a miss, got %d", cache.writes[1]) - } - if cache.pages[1] == "" { - t.Error("expected page 1 to be stored in cache after miss") - } -} - -// panicOnGetContent is a BrowserClient whose GetContent panics, letting tests -// assert that it is never called (i.e. the cache was used instead). -type panicOnGetContent struct{} - -func (p *panicOnGetContent) Strategy() browser.Strategy { return browser.StrategyContent } -func (p *panicOnGetContent) GetContent(_ context.Context, req browser.ContentRequest) (string, error) { - panic(fmt.Sprintf("unexpected GetContent call for URL %s — should have been served from cache", req.URL)) -} -func (p *panicOnGetContent) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) { - return browser.ScrapeResponse{}, nil -} -func (p *panicOnGetContent) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error { - return nil -} diff --git a/scraper/internal/novelfire/scraper.go b/scraper/internal/novelfire/scraper.go index 2fc1445..58ec9c1 100644 --- a/scraper/internal/novelfire/scraper.go +++ b/scraper/internal/novelfire/scraper.go @@ -21,6 +21,7 @@ import ( "github.com/libnovel/scraper/internal/browser" "github.com/libnovel/scraper/internal/scraper" "github.com/libnovel/scraper/internal/scraper/htmlutil" + "github.com/libnovel/scraper/internal/storage" "golang.org/x/net/html" ) @@ -49,27 +50,33 @@ var rejectResourceTypes = []string{ "websocket", } +// RankingStore is the subset of storage.Store consumed by ScrapeRanking. +type RankingStore interface { + WriteRankingItem(ctx context.Context, item storage.RankingItem) error + RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) +} + // Scraper is the novelfire.net implementation of scraper.NovelScraper. // It uses the /content strategy by default (rendered HTML via Browserless). type Scraper struct { - client browser.BrowserClient - urlClient browser.BrowserClient // separate client for URL retrieval (uses browserless content strategy) - pageCache scraper.RankingPageCacher - log *slog.Logger + client browser.BrowserClient + urlClient browser.BrowserClient // separate client for URL retrieval (uses browserless content strategy) + rankingStore RankingStore + log *slog.Logger } // New returns a new novelfire Scraper. // client is used for content fetching, urlClient is used for URL retrieval (chapter list). // If urlClient is nil, client will be used for both. -// pageCache is optional; pass nil to disable ranking page caching. -func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient, pageCache scraper.RankingPageCacher) *Scraper { +// rankingStore is optional; pass nil to disable freshness checks and per-item persistence. +func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient, rankingStore RankingStore) *Scraper { if log == nil { log = slog.Default() } if urlClient == nil { urlClient = client } - return &Scraper{client: client, urlClient: urlClient, pageCache: pageCache, log: log} + return &Scraper{client: client, urlClient: urlClient, rankingStore: rankingStore, log: log} } // SourceName implements NovelScraper. @@ -366,6 +373,61 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scra return refs, nil } +// ScrapeChapterListPage fetches and parses a single chapter-list page URL and +// returns all chapter refs found on that page without following pagination. +// pageURL should be the full URL including query params, e.g.: +// +// https://novelfire.net/book/shadow-slave/chapters?page=1 +func (s *Scraper) ScrapeChapterListPage(ctx context.Context, pageURL string) ([]scraper.ChapterRef, error) { + s.log.Info("scraping chapter list page (single)", "url", pageURL) + + raw, err := s.urlClient.GetContent(ctx, browser.ContentRequest{ + URL: pageURL, + WaitFor: &browser.WaitForSelector{Selector: ".chapter-list", Timeout: 15000}, + WaitForTimeout: 2000, + RejectResourceTypes: rejectResourceTypes, + GotoOptions: &browser.GotoOptions{Timeout: 60000}, + }) + if err != nil { + return nil, fmt.Errorf("chapter list page fetch: %w", err) + } + + root, err := htmlutil.ParseHTML(raw) + if err != nil { + return nil, fmt.Errorf("chapter list page parse: %w", err) + } + + chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"}) + if chapterList == nil { + return nil, fmt.Errorf("chapter list container not found in %s", pageURL) + } + + items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"}) + var refs []scraper.ChapterRef + for _, item := range items { + linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"}) + if linkNode == nil { + continue + } + href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"}) + chTitle := htmlutil.ExtractText(linkNode, scraper.Selector{}) + if href == "" { + continue + } + chURL := resolveURL(baseURL, href) + num := chapterNumberFromURL(chURL) + if num <= 0 { + num = len(refs) + 1 + } + refs = append(refs, scraper.ChapterRef{ + Number: num, + Title: strings.TrimSpace(chTitle), + URL: chURL, + }) + } + return refs, nil +} + // ─── RankingProvider ─────────────────────────────────────────────────────────── // hasNextPageLink returns true if the HTML document contains a pagination link @@ -388,6 +450,9 @@ func hasNextPageLink(root *html.Node) bool { // listing on novelfire.net (/genre-all/sort-popular/status-all/all-novel). // Pages are fetched one at a time, strictly sequentially. // maxPages <= 0 means "fetch all pages until no more are found". +// +// If a RankingStore was provided and the stored ranking is fresh (< 24 hours old), +// both channels are closed immediately without any network traffic. func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scraper.BookMeta, <-chan error) { entries := make(chan scraper.BookMeta, 32) errs := make(chan error, 16) @@ -396,6 +461,17 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap defer close(entries) defer close(errs) + // Freshness check: skip scraping if data is recent enough. + if s.rankingStore != nil { + fresh, err := s.rankingStore.RankingFreshEnough(ctx, 24*time.Hour) + if err != nil { + s.log.Warn("ranking freshness check failed, proceeding with scrape", "err", err) + } else if fresh { + s.log.Info("ranking data is fresh, skipping scrape") + return + } + } + rank := 1 for page := 1; maxPages <= 0 || page <= maxPages; page++ { @@ -407,38 +483,17 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap pageURL := fmt.Sprintf("%s%s?page=%d", baseURL, rankingPath, page) - // Try to serve from disk cache before hitting the network. - var raw string - if s.pageCache != nil { - if cached, err := s.pageCache.ReadRankingPageCache(page); err != nil { - s.log.Warn("ranking page cache read error", "page", page, "err", err) - } else if cached != "" { - s.log.Info("serving ranking page from cache", "page", page) - raw = cached - } - } - - if raw == "" { - s.log.Info("scraping popular ranking page", "page", page, "url", pageURL) - fetched, err := s.client.GetContent(ctx, browser.ContentRequest{ - URL: pageURL, - WaitFor: &browser.WaitForSelector{Selector: ".novel-item", Timeout: 5000}, - RejectResourceTypes: rejectResourceTypes, - GotoOptions: &browser.GotoOptions{Timeout: 60000}, - }) - if err != nil { - s.log.Debug("ranking page fetch failed", "page", page, "url", pageURL, "err", err) - errs <- fmt.Errorf("ranking page %d: %w", page, err) - return - } - raw = fetched - - // Persist to cache for future runs. - if s.pageCache != nil { - if werr := s.pageCache.WriteRankingPageCache(page, raw); werr != nil { - s.log.Warn("ranking page cache write error", "page", page, "err", werr) - } - } + s.log.Info("scraping popular ranking page", "page", page, "url", pageURL) + raw, err := s.client.GetContent(ctx, browser.ContentRequest{ + URL: pageURL, + WaitFor: &browser.WaitForSelector{Selector: ".novel-item", Timeout: 5000}, + RejectResourceTypes: rejectResourceTypes, + GotoOptions: &browser.GotoOptions{Timeout: 60000}, + }) + if err != nil { + s.log.Debug("ranking page fetch failed", "page", page, "url", pageURL, "err", err) + errs <- fmt.Errorf("ranking page %d: %w", page, err) + return } root, err := htmlutil.ParseHTML(raw) @@ -497,10 +552,10 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap } } - slug := slugFromURL(bookURL) + bookSlug := slugFromURL(bookURL) meta := scraper.BookMeta{ - Slug: slug, + Slug: bookSlug, Title: title, Cover: cover, SourceURL: bookURL, @@ -508,6 +563,20 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap } rank++ + // Persist item to store immediately. + if s.rankingStore != nil { + item := storage.RankingItem{ + Rank: meta.Ranking, + Slug: meta.Slug, + Title: meta.Title, + Cover: meta.Cover, + SourceURL: meta.SourceURL, + } + if werr := s.rankingStore.WriteRankingItem(ctx, item); werr != nil { + s.log.Warn("ranking item write failed", "slug", meta.Slug, "err", werr) + } + } + select { case <-ctx.Done(): return diff --git a/scraper/internal/scraper/interfaces.go b/scraper/internal/scraper/interfaces.go index c8fe0a8..7799476 100644 --- a/scraper/internal/scraper/interfaces.go +++ b/scraper/internal/scraper/interfaces.go @@ -120,16 +120,6 @@ type RankingProvider interface { ScrapeRanking(ctx context.Context, maxPages int) (<-chan BookMeta, <-chan error) } -// RankingPageCacher persists and retrieves raw HTML for individual ranking pages. -// Implementations (e.g. writer.Writer) store files on disk so that a -// subsequent ScrapeRanking call can serve cached HTML without a network round-trip. -type RankingPageCacher interface { - // WriteRankingPageCache stores the raw HTML string for the given page number. - WriteRankingPageCache(page int, html string) error - // ReadRankingPageCache returns the cached HTML for page, or ("", nil) on a miss. - ReadRankingPageCache(page int) (string, error) -} - // NovelScraper is the full interface that a concrete novel source must implement. // It composes all four provider interfaces. type NovelScraper interface { diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go index 454c1f6..3de8706 100644 --- a/scraper/internal/server/server.go +++ b/scraper/internal/server/server.go @@ -38,16 +38,15 @@ import ( // Server wraps an HTTP mux with the scraping endpoints. type Server struct { - addr string - oCfg orchestrator.Config - novel scraper.NovelScraper - log *slog.Logger - store storage.Store - mu sync.Mutex - running bool - rankingRunning bool - kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880 - kokoroVoice string // default voice, e.g. af_bella + addr string + oCfg orchestrator.Config + novel scraper.NovelScraper + log *slog.Logger + store storage.Store + mu sync.Mutex + running bool + kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880 + kokoroVoice string // default voice, e.g. af_bella // voiceMu guards cachedVoices. voiceMu sync.RWMutex @@ -122,6 +121,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("POST /scrape/book", s.handleScrapeBook) // Browse API — fetches and parses novelfire catalogue page mux.HandleFunc("GET /api/browse", s.handleBrowse) + // Ranking API + mux.HandleFunc("GET /api/ranking", s.handleGetRanking) // Scrape status mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus) // Progress API @@ -172,6 +173,21 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } +// handleGetRanking returns all ranking items sorted by rank ascending. +func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) { + items, err := s.store.ReadRankingItems(r.Context()) + if err != nil { + s.log.Error("ranking read failed", "err", err) + http.Error(w, `{"error":"failed to read ranking"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if items == nil { + items = []storage.RankingItem{} + } + _ = json.NewEncoder(w).Encode(items) +} + // ─── Session cookie helpers ─────────────────────────────────────────────────── const sessionCookieName = "libnovel_session" @@ -410,6 +426,24 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) { _ = s.store.SetAudioCache(r.Context(), cacheKey, filename) + // Download generated audio from Kokoro and persist to MinIO so that + // presigned URLs for the audio object are accessible. + go func() { + audioData, dlErr := s.downloadFromKokoro(context.Background(), filename) + if dlErr != nil { + s.log.Warn("audio MinIO upload skipped: kokoro download failed", + "slug", slug, "chapter", n, "filename", filename, "err", dlErr) + return + } + minioKey := s.store.AudioObjectKey(slug, n, voice, speed) + if putErr := s.store.PutAudio(context.Background(), minioKey, audioData); putErr != nil { + s.log.Warn("audio MinIO upload failed", + "slug", slug, "chapter", n, "key", minioKey, "err", putErr) + } else { + s.log.Info("audio uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey) + } + }() + s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename) s.writeAudioResponse(w, slug, n, voice, speed, filename) } @@ -463,6 +497,29 @@ func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed f return filename, nil } +// downloadFromKokoro downloads a generated audio file from Kokoro's temp storage +// using GET /v1/download/{filename} and returns the raw bytes. +func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byte, error) { + url := s.kokoroURL + "/v1/download/" + filename + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build download request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("kokoro download request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("kokoro download status %d", resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read kokoro download body: %w", err) + } + return data, nil +} + // writeAudioResponse writes the JSON response for a generated audio chapter. // The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}. func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, speed float64, filename string) { diff --git a/scraper/internal/storage/hybrid.go b/scraper/internal/storage/hybrid.go index 6e4f443..761fbb9 100644 --- a/scraper/internal/storage/hybrid.go +++ b/scraper/internal/storage/hybrid.go @@ -6,7 +6,6 @@ import ( "context" "encoding/json" "fmt" - "os" "sort" "strconv" "strings" @@ -130,43 +129,23 @@ func (h *HybridStore) CountChapters(ctx context.Context, slug string) int { // ─── Ranking ───────────────────────────────────────────────────────────────── -func (h *HybridStore) WriteRanking(ctx context.Context, items []RankingItem) error { - data, err := json.Marshal(items) - if err != nil { - return fmt.Errorf("storage: marshal ranking: %w", err) - } - return h.pb.SetRanking(ctx, string(data)) +func (h *HybridStore) WriteRankingItem(ctx context.Context, item RankingItem) error { + return h.pb.UpsertRankingItem(ctx, item) } func (h *HybridStore) ReadRankingItems(ctx context.Context) ([]RankingItem, error) { - dataStr, _, err := h.pb.GetRanking(ctx) - if err != nil || dataStr == "" { - return nil, err + return h.pb.ListRankingItems(ctx) +} + +func (h *HybridStore) RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) { + last, err := h.pb.RankingLastUpdated(ctx) + if err != nil { + return false, err } - var items []RankingItem - if err := json.Unmarshal([]byte(dataStr), &items); err != nil { - return nil, fmt.Errorf("storage: unmarshal ranking: %w", err) + if last.IsZero() { + return false, nil } - return items, nil -} - -func (h *HybridStore) RankingFileInfo(ctx context.Context) (os.FileInfo, error) { - return h.pb.RankingModTime(ctx) -} - -// ─── Ranking page HTML cache ────────────────────────────────────────────────── - -func (h *HybridStore) WriteRankingPageCache(ctx context.Context, page int, html string) error { - return h.pb.SetRankingPageHTML(ctx, page, html) -} - -func (h *HybridStore) ReadRankingPageCache(ctx context.Context, page int) (string, error) { - html, _, err := h.pb.GetRankingPageHTML(ctx, page) - return html, err -} - -func (h *HybridStore) RankingPageCacheInfo(ctx context.Context, page int) (os.FileInfo, error) { - return h.pb.RankingPageCacheModTime(ctx, page) + return time.Since(last) < maxAge, nil } // ─── Audio cache ────────────────────────────────────────────────────────────── @@ -222,6 +201,12 @@ func (h *HybridStore) AudioObjectKey(slug string, n int, voice string, speed flo return AudioObjectKey(slug, n, voice, speed) } +// ─── PutAudio ───────────────────────────────────────────────────────────────── + +func (h *HybridStore) PutAudio(ctx context.Context, key string, data []byte) error { + return h.minio.PutAudio(ctx, key, data) +} + // ─── Presigned URLs ─────────────────────────────────────────────────────────── func (h *HybridStore) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) { @@ -300,8 +285,11 @@ func splitChapterTitle(raw string) (title, date string) { start = 0 } numPart := strings.TrimSpace(raw[start:idx]) - if _, err := strconv.Atoi(strings.Fields(numPart)[0]); err == nil { - return strings.TrimSpace(raw[:start]), strings.TrimSpace(raw[start : idx+len(suffix)]) + fields := strings.Fields(numPart) + if len(fields) > 0 { + if _, err := strconv.Atoi(fields[0]); err == nil { + return strings.TrimSpace(raw[:start]), strings.TrimSpace(raw[start : idx+len(suffix)]) + } } } } diff --git a/scraper/internal/storage/hybrid_integration_test.go b/scraper/internal/storage/hybrid_integration_test.go index 06d7621..17d3aac 100644 --- a/scraper/internal/storage/hybrid_integration_test.go +++ b/scraper/internal/storage/hybrid_integration_test.go @@ -233,23 +233,36 @@ func TestHybridStore_WriteReadChapter(t *testing.T) { }) } -// TestHybridStore_WriteReadRanking exercises WriteRanking → ReadRankingItems -// round-trip. +// TestHybridStore_WriteReadRanking exercises WriteRankingItem → ReadRankingItems +// round-trip and RankingFreshEnough. func TestHybridStore_WriteReadRanking(t *testing.T) { hs := newTestHybridStore(t) + slug1 := "integ-rank-1-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + slug2 := "integ-rank-2-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + slug3 := "integ-rank-3-" + fmt.Sprintf("%d", time.Now().UnixMilli()) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, sl := range []string{slug1, slug2, slug3} { + _ = hs.pb.pb.deleteWhere(cleanCtx, "ranking", fmt.Sprintf(`slug="%s"`, sl)) + } + }) + items := []RankingItem{ - {Rank: 1, Slug: "top-novel", Title: "Top Novel", Author: "Author A", Status: "Ongoing", SourceURL: "https://example.com/book/top-novel"}, - {Rank: 2, Slug: "second-novel", Title: "Second Novel", Author: "Author B", Genres: []string{"Action"}, Status: "Completed"}, - {Rank: 3, Slug: "third-novel", Title: "Third Novel"}, + {Rank: 1, Slug: slug1, Title: "Top Novel", Author: "Author A", Status: "Ongoing", SourceURL: "https://example.com/book/top"}, + {Rank: 2, Slug: slug2, Title: "Second Novel", Author: "Author B", Genres: []string{"Action"}, Status: "Completed"}, + {Rank: 3, Slug: slug3, Title: "Third Novel"}, } - t.Run("WriteRanking", func(t *testing.T) { - if err := hs.WriteRanking(ctx, items); err != nil { - t.Fatalf("WriteRanking: %v", err) + t.Run("WriteRankingItem", func(t *testing.T) { + for _, item := range items { + if err := hs.WriteRankingItem(ctx, item); err != nil { + t.Fatalf("WriteRankingItem(%s): %v", item.Slug, err) + } } t.Logf("wrote %d ranking items", len(items)) }) @@ -259,34 +272,43 @@ func TestHybridStore_WriteReadRanking(t *testing.T) { if err != nil { t.Fatalf("ReadRankingItems: %v", err) } - if len(got) != len(items) { - t.Errorf("ReadRankingItems returned %d items, want %d", len(got), len(items)) - } - for i, item := range got { - t.Logf("items[%d]: rank=%d slug=%q title=%q", i, item.Rank, item.Slug, item.Title) - } - if len(got) > 0 { - if got[0].Rank != 1 { - t.Errorf("items[0].Rank = %d, want 1", got[0].Rank) - } - if got[0].Title != "Top Novel" { - t.Errorf("items[0].Title = %q, want %q", got[0].Title, "Top Novel") + // Filter to just our test slugs (other tests may leave rows). + var ours []RankingItem + slugSet := map[string]bool{slug1: true, slug2: true, slug3: true} + for _, g := range got { + if slugSet[g.Slug] { + ours = append(ours, g) } } + if len(ours) != 3 { + t.Fatalf("ReadRankingItems returned %d test items, want 3", len(ours)) + } + // Verify order by rank. + for i := 1; i < len(ours); i++ { + if ours[i].Rank <= ours[i-1].Rank { + t.Errorf("items not sorted by rank: ours[%d].Rank=%d, ours[%d].Rank=%d", + i, ours[i].Rank, i-1, ours[i-1].Rank) + } + } + // Verify fields. + if ours[0].Title != "Top Novel" { + t.Errorf("ours[0].Title = %q, want %q", ours[0].Title, "Top Novel") + } + if ours[0].Author != "Author A" { + t.Errorf("ours[0].Author = %q, want %q", ours[0].Author, "Author A") + } + t.Logf("ranking items: %+v", ours) }) - t.Run("RankingFileInfo", func(t *testing.T) { - fi, err := hs.RankingFileInfo(ctx) + t.Run("RankingFreshEnough", func(t *testing.T) { + fresh, err := hs.RankingFreshEnough(ctx, 24*time.Hour) if err != nil { - t.Fatalf("RankingFileInfo: %v", err) + t.Fatalf("RankingFreshEnough: %v", err) } - if fi == nil { - t.Fatal("RankingFileInfo returned nil") + if !fresh { + t.Error("RankingFreshEnough(24h) returned false immediately after writing items") } - if fi.ModTime().IsZero() { - t.Error("RankingFileInfo.ModTime() is zero") - } - t.Logf("ranking file info: modtime=%s", fi.ModTime()) + t.Logf("ranking fresh=true") }) } diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index bcb9f5a..604bdbd 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -5,8 +5,8 @@ // books — slug(text,unique), title, author, cover, status, genres(json), // summary, total_chapters(number), source_url, ranking(number), updated(date) // chapters_idx — slug(text), number(number), title, date_label, updated(date) -// ranking — data(json), updated(date) [single row, upserted by slug="_ranking_"] -// ranking_html — page(number,unique), html(text), updated(date) +// ranking — rank(number), slug(text,unique), title, author, cover, status, +// genres(json), source_url, updated(date) // progress — session_id(text), slug(text), chapter(number), updated(date) // audio_cache — cache_key(text,unique), filename(text), updated(date) // app_users — username(text,unique), password_hash(text), role(text), created(date) @@ -20,7 +20,6 @@ import ( "io" "net/http" "net/url" - "os" "strings" "sync" "time" @@ -248,20 +247,22 @@ func (s *PocketBaseStore) Ping(ctx context.Context) error { func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { // We just attempt to create each collection; 400/422 errors for "already // exists" are silently ignored. + // PocketBase v0.22+ uses "fields"; older versions used "schema". + // We use "fields" which is the current API. collections := []map[string]interface{}{ { "name": "books", "type": "base", - "schema": []map[string]interface{}{ - {"name": "slug", "type": "text", "required": true, "options": map[string]interface{}{"min": 1}}, + "fields": []map[string]interface{}{ + {"name": "slug", "type": "text", "required": true}, {"name": "title", "type": "text", "required": true}, {"name": "author", "type": "text"}, - {"name": "cover", "type": "url"}, + {"name": "cover", "type": "text"}, {"name": "status", "type": "text"}, {"name": "genres", "type": "json"}, {"name": "summary", "type": "text"}, {"name": "total_chapters", "type": "number"}, - {"name": "source_url", "type": "url"}, + {"name": "source_url", "type": "text"}, {"name": "ranking", "type": "number"}, {"name": "meta_updated", "type": "date"}, }, @@ -269,7 +270,7 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { { "name": "chapters_idx", "type": "base", - "schema": []map[string]interface{}{ + "fields": []map[string]interface{}{ {"name": "slug", "type": "text", "required": true}, {"name": "number", "type": "number", "required": true}, {"name": "title", "type": "text"}, @@ -279,25 +280,22 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { { "name": "ranking", "type": "base", - "schema": []map[string]interface{}{ - {"name": "key", "type": "text", "required": true}, - {"name": "data", "type": "json"}, - {"name": "updated", "type": "date"}, - }, - }, - { - "name": "ranking_html", - "type": "base", - "schema": []map[string]interface{}{ - {"name": "page", "type": "number", "required": true}, - {"name": "html", "type": "text"}, + "fields": []map[string]interface{}{ + {"name": "rank", "type": "number", "required": true}, + {"name": "slug", "type": "text", "required": true}, + {"name": "title", "type": "text"}, + {"name": "author", "type": "text"}, + {"name": "cover", "type": "text"}, + {"name": "status", "type": "text"}, + {"name": "genres", "type": "json"}, + {"name": "source_url", "type": "text"}, {"name": "updated", "type": "date"}, }, }, { "name": "progress", "type": "base", - "schema": []map[string]interface{}{ + "fields": []map[string]interface{}{ {"name": "session_id", "type": "text", "required": true}, {"name": "slug", "type": "text", "required": true}, {"name": "chapter", "type": "number"}, @@ -307,7 +305,7 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { { "name": "audio_cache", "type": "base", - "schema": []map[string]interface{}{ + "fields": []map[string]interface{}{ {"name": "cache_key", "type": "text", "required": true}, {"name": "filename", "type": "text"}, {"name": "updated", "type": "date"}, @@ -316,8 +314,8 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { { "name": "app_users", "type": "base", - "schema": []map[string]interface{}{ - {"name": "username", "type": "text", "required": true, "options": map[string]interface{}{"min": 3, "max": 32}}, + "fields": []map[string]interface{}{ + {"name": "username", "type": "text", "required": true}, {"name": "password_hash", "type": "text", "required": true}, {"name": "role", "type": "text"}, {"name": "created", "type": "date"}, @@ -406,52 +404,82 @@ func (s *PocketBaseStore) CountChapterIdx(ctx context.Context, slug string) int return len(rows) } -// ─── Ranking ────────────────────────────────────────────────────────────────── +// ─── Ranking (per-item) ─────────────────────────────────────────────────────── -func (s *PocketBaseStore) SetRanking(ctx context.Context, dataJSON string) error { - return s.pb.upsert(ctx, "ranking", `key="_ranking_"`, map[string]interface{}{ - "key": "_ranking_", - "data": dataJSON, - "updated": time.Now().UTC().Format(time.RFC3339), +func (s *PocketBaseStore) UpsertRankingItem(ctx context.Context, item RankingItem) error { + genresJSON, _ := json.Marshal(item.Genres) + return s.pb.upsert(ctx, "ranking", fmt.Sprintf(`slug="%s"`, pbEsc(item.Slug)), map[string]interface{}{ + "rank": item.Rank, + "slug": item.Slug, + "title": item.Title, + "author": item.Author, + "cover": item.Cover, + "status": item.Status, + "genres": string(genresJSON), + "source_url": item.SourceURL, + "updated": time.Now().UTC().Format(time.RFC3339), }) } -func (s *PocketBaseStore) GetRanking(ctx context.Context) (string, time.Time, error) { - rec, err := s.pb.listOne(ctx, "ranking", `key="_ranking_"`) - if err != nil || rec == nil { - return "", time.Time{}, err +func (s *PocketBaseStore) ListRankingItems(ctx context.Context) ([]RankingItem, error) { + rows, err := s.pb.listAll(ctx, "ranking", "", "+rank") + if err != nil { + return nil, err } - data, _ := rec["data"].(string) - var updated time.Time - if ts, ok := rec["updated"].(string); ok { - updated, _ = time.Parse(time.RFC3339, ts) + items := make([]RankingItem, 0, len(rows)) + for _, r := range rows { + item := RankingItem{ + Rank: int(floatVal(r, "rank")), + Slug: strVal(r, "slug"), + Title: strVal(r, "title"), + Author: strVal(r, "author"), + Cover: strVal(r, "cover"), + Status: strVal(r, "status"), + SourceURL: strVal(r, "source_url"), + } + if ts, ok := r["updated"].(string); ok { + item.Updated, _ = time.Parse(time.RFC3339, ts) + } + switch v := r["genres"].(type) { + case string: + _ = json.Unmarshal([]byte(v), &item.Genres) + case []interface{}: + for _, g := range v { + if s, ok := g.(string); ok { + item.Genres = append(item.Genres, s) + } + } + } + items = append(items, item) } - return data, updated, nil + return items, nil } -// ─── Ranking page HTML cache ────────────────────────────────────────────────── - -func (s *PocketBaseStore) SetRankingPageHTML(ctx context.Context, page int, html string) error { - return s.pb.upsert(ctx, "ranking_html", - fmt.Sprintf(`page=%d`, page), - map[string]interface{}{ - "page": page, - "html": html, - "updated": time.Now().UTC().Format(time.RFC3339), - }) -} - -func (s *PocketBaseStore) GetRankingPageHTML(ctx context.Context, page int) (string, time.Time, error) { - rec, err := s.pb.listOne(ctx, "ranking_html", fmt.Sprintf(`page=%d`, page)) - if err != nil || rec == nil { - return "", time.Time{}, err +// RankingLastUpdated returns the most recent Updated time across all ranking rows, +// or the zero time if no rows exist. +func (s *PocketBaseStore) RankingLastUpdated(ctx context.Context) (time.Time, error) { + // listAll with sort "-updated" and perPage=1 is the cheapest approach. + q := url.Values{} + q.Set("sort", "-updated") + q.Set("perPage", "1") + path := fmt.Sprintf("/api/collections/ranking/records?%s", q.Encode()) + resp, err := s.pb.do(ctx, http.MethodGet, path, nil) + if err != nil { + return time.Time{}, err } - html, _ := rec["html"].(string) - var updated time.Time - if ts, ok := rec["updated"].(string); ok { - updated, _ = time.Parse(time.RFC3339, ts) + defer resp.Body.Close() + var result struct { + Items []map[string]interface{} `json:"items"` } - return html, updated, nil + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return time.Time{}, err + } + if len(result.Items) == 0 { + return time.Time{}, nil + } + ts, _ := result.Items[0]["updated"].(string) + t, _ := time.Parse(time.RFC3339, ts) + return t, nil } // ─── Reading progress ───────────────────────────────────────────────────────── @@ -516,45 +544,6 @@ func (s *PocketBaseStore) GetAudioCache(ctx context.Context, cacheKey string) (s return filename, filename != "", nil } -// ─── rankingFileInfo is a minimal os.FileInfo implementation ───────────────── - -type rankingFileInfo struct { - modTime time.Time -} - -func (r rankingFileInfo) Name() string { return "ranking" } -func (r rankingFileInfo) Size() int64 { return 0 } -func (r rankingFileInfo) Mode() os.FileMode { return 0o444 } -func (r rankingFileInfo) ModTime() time.Time { return r.modTime } -func (r rankingFileInfo) IsDir() bool { return false } -func (r rankingFileInfo) Sys() interface{} { return nil } - -var _ os.FileInfo = rankingFileInfo{} - -// RankingModTime returns file-info-compatible data for the ranking record. -func (s *PocketBaseStore) RankingModTime(ctx context.Context) (os.FileInfo, error) { - _, updated, err := s.GetRanking(ctx) - if err != nil { - return nil, err - } - if updated.IsZero() { - return nil, nil - } - return rankingFileInfo{modTime: updated}, nil -} - -// RankingPageCacheModTime returns file-info for a cached ranking page. -func (s *PocketBaseStore) RankingPageCacheModTime(ctx context.Context, page int) (os.FileInfo, error) { - _, updated, err := s.GetRankingPageHTML(ctx, page) - if err != nil { - return nil, err - } - if updated.IsZero() { - return nil, nil - } - return rankingFileInfo{modTime: updated}, nil -} - // ─── helpers ────────────────────────────────────────────────────────────────── // pbEsc escapes a string for use in a PocketBase filter expression. diff --git a/scraper/internal/storage/store.go b/scraper/internal/storage/store.go index c23573c..83018c0 100644 --- a/scraper/internal/storage/store.go +++ b/scraper/internal/storage/store.go @@ -5,7 +5,6 @@ package storage import ( "context" - "os" "time" "github.com/libnovel/scraper/internal/scraper" @@ -22,14 +21,15 @@ type ChapterInfo struct { // RankingItem represents a single entry in the novel ranking list. type RankingItem struct { - Rank int `json:"rank"` - Slug string `json:"slug"` - Title string `json:"title"` - Author string `json:"author,omitempty"` - Cover string `json:"cover,omitempty"` - Status string `json:"status,omitempty"` - Genres []string `json:"genres,omitempty"` - SourceURL string `json:"source_url,omitempty"` + Rank int `json:"rank"` + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author,omitempty"` + Cover string `json:"cover,omitempty"` + Status string `json:"status,omitempty"` + Genres []string `json:"genres,omitempty"` + SourceURL string `json:"source_url,omitempty"` + Updated time.Time `json:"updated,omitempty"` } // ReadingProgress holds a single user's reading position for one book. @@ -81,22 +81,13 @@ type Store interface { // ── Ranking ──────────────────────────────────────────────────────────── - // WriteRanking persists the ranking list. - WriteRanking(ctx context.Context, items []RankingItem) error - // ReadRankingItems returns the stored ranking items. + // WriteRankingItem upserts a single ranking entry (keyed on Slug). + WriteRankingItem(ctx context.Context, item RankingItem) error + // ReadRankingItems returns all ranking items sorted by rank ascending. ReadRankingItems(ctx context.Context) ([]RankingItem, error) - // RankingFileInfo returns os.FileInfo-like data for the ranking record. - // Returns (nil, nil) when no ranking has been stored yet. - RankingFileInfo(ctx context.Context) (os.FileInfo, error) - - // ── Ranking page HTML cache ──────────────────────────────────────────── - - // WriteRankingPageCache stores raw HTML for a ranking page. - WriteRankingPageCache(ctx context.Context, page int, html string) error - // ReadRankingPageCache returns cached HTML for a ranking page, or "" on miss. - ReadRankingPageCache(ctx context.Context, page int) (string, error) - // RankingPageCacheInfo returns file-like info for a cached ranking page. - RankingPageCacheInfo(ctx context.Context, page int) (os.FileInfo, error) + // RankingFreshEnough returns true when ranking rows exist and the most + // recent Updated timestamp is within maxAge of now. + RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) // ── Audio cache ──────────────────────────────────────────────────────── @@ -104,6 +95,8 @@ type Store interface { GetAudioCache(ctx context.Context, cacheKey string) (string, bool) // SetAudioCache persists a Kokoro filename for cacheKey. SetAudioCache(ctx context.Context, cacheKey, filename string) error + // PutAudio stores raw audio bytes under the given MinIO object key. + PutAudio(ctx context.Context, key string, data []byte) error // ── Reading progress ───────────────────────────────────────────────────