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/)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
816
scraper/internal/e2e/e2e_test.go
Normal file
816
scraper/internal/e2e/e2e_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user