package server import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/libnovel/scraper/internal/storage" "golang.org/x/net/html" "github.com/libnovel/scraper/internal/scraper/htmlutil" ) // ─── Browse API ─────────────────────────────────────────────────────────────── // NovelListing represents a single novel entry from the novelfire browse page. type NovelListing struct { Slug string `json:"slug"` Title string `json:"title"` Cover string `json:"cover"` Rank string `json:"rank"` Rating string `json:"rating"` Chapters string `json:"chapters"` URL string `json:"url"` } const novelFireBase = "https://novelfire.net" const novelFireDomain = "novelfire.net" // handleBrowse handles GET /api/browse. // Query params: // // page (default 1) // genre (default "all") // sort (default "popular") // status (default "all") // type (default "all-novel") // // Returns JSON: {"novels":[...], "page": N, "hasNext": bool} // // Cache strategy: check MinIO browse bucket first (key: {domain}/html/page-N.html); // if a snapshot exists, parse it and return structured JSON. // On a cache miss, fetch live from novelfire.net, return the result, and // trigger a background SingleFile snapshot + ranking population. func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() page := q.Get("page") if page == "" { page = "1" } genre := q.Get("genre") if genre == "" { genre = "all" } sortBy := q.Get("sort") if sortBy == "" { sortBy = "popular" } status := q.Get("status") if status == "" { status = "all" } novelType := q.Get("type") if novelType == "" { novelType = "all-novel" } pageNum, _ := strconv.Atoi(page) if pageNum <= 0 { pageNum = 1 } ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) defer cancel() // ── Cache-first: try MinIO snapshot (new key layout) ───────────────── cacheKey := s.store.BrowseFilteredHTMLKey(novelFireDomain, pageNum, sortBy, genre, status) if html, ok, err := s.store.GetBrowsePage(ctx, cacheKey); err == nil && ok && len(html) > 0 { novels, hasNext := parseBrowsePage(strings.NewReader(html)) s.log.Debug("browse: served from cache", "key", cacheKey) // Still fire background ranking population in case PocketBase ranking // records are missing (e.g. after a schema reset / fresh deploy). targetURLForRanking := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%s", novelFireBase, genre, sortBy, status, novelType, page) s.triggerDirectScrape(cacheKey, targetURLForRanking) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=300") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "novels": novels, "page": pageNum, "hasNext": hasNext, }) return } // ── Live fallback: direct fetch from novelfire.net ─────────────────── // Build URL: /genre-{genre}/sort-{sort}/status-{status}/{type}?page={page} targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%s", novelFireBase, genre, sortBy, status, novelType, page) var novels []NovelListing var hasNext bool var fetchErr error for attempt := 1; attempt <= 3; attempt++ { if attempt > 1 { select { case <-ctx.Done(): http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable) return case <-time.After(time.Duration(attempt) * time.Second): } } var req *http.Request req, fetchErr = http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) if fetchErr != nil { http.Error(w, `{"error":"failed to build request"}`, http.StatusInternalServerError) return } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.Header.Set("Accept-Language", "en-US,en;q=0.9") // Do NOT set Accept-Encoding manually: Go's http.Transport handles // transparent gzip decompression only when it adds the header itself. // If we set it explicitly, Transport disables auto-decompression and // parseBrowsePage receives raw gzip bytes instead of HTML. req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Pragma", "no-cache") resp, err := http.DefaultClient.Do(req) if err != nil { fetchErr = err s.log.Warn("browse fetch failed, retrying", "url", targetURL, "attempt", attempt, "err", err) continue } if resp.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() fetchErr = fmt.Errorf("upstream returned %d", resp.StatusCode) s.log.Warn("browse upstream error, retrying", "url", targetURL, "attempt", attempt, "status", resp.StatusCode) continue } novels, hasNext = parseBrowsePage(resp.Body) resp.Body.Close() fetchErr = nil break } if fetchErr != nil { s.log.Error("browse fetch failed after retries", "url", targetURL, "err", fetchErr) // ── In-memory fallback: use cached result from a prior successful fetch ── s.browseMemCacheMu.RLock() entry, memHit := s.browseMemCache[cacheKey] s.browseMemCacheMu.RUnlock() if memHit { s.log.Warn("browse: upstream unavailable, serving stale in-memory cache", "key", cacheKey, "age", time.Since(entry.cachedAt).Round(time.Second)) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=60") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "novels": entry.novels, "page": pageNum, "hasNext": entry.hasNext, }) return } http.Error(w, fmt.Sprintf(`{"error":"%s"}`, fetchErr.Error()), http.StatusBadGateway) return } // ── Populate in-memory cache with the fresh upstream result ────────── if len(novels) > 0 { s.browseMemCacheMu.Lock() s.browseMemCache[cacheKey] = browseCacheEntry{ novels: novels, hasNext: hasNext, cachedAt: time.Now(), } s.browseMemCacheMu.Unlock() } // ── Background: fetch and cache page directly from novelfire.net ───── // Fire-and-forget: stores raw HTML in MinIO and populates the ranking // collection in PocketBase (no browser/SingleFile needed). s.triggerDirectScrape(cacheKey, targetURL) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=300") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "novels": novels, "page": pageNum, "hasNext": hasNext, }) } // triggerDirectScrape fires a background goroutine that: // 1. Fetches pageURL directly from novelfire.net using Go's HTTP client // (no browser/SingleFile needed — the page is server-rendered HTML). // 2. Stores the raw HTML in MinIO at cacheKey so future requests are served // from cache without hitting the origin. // 3. Parses the HTML to extract novel listings. // 4. For each listing, upserts a ranking record in PocketBase (rank, slug, // title, cover key, source_url). // 5. Fires a separate goroutine per cover image to download and store it at // {domain}/assets/book-covers/{slug}.jpg in MinIO. // // It is a no-op when a refresh for this cache key is already in progress. // The goroutine uses a fresh context so it outlives the HTTP request. func (s *Server) triggerDirectScrape(cacheKey, pageURL string) { s.browseMu.Lock() if _, inflight := s.browseInFlight[cacheKey]; inflight { s.browseMu.Unlock() return } s.browseInFlight[cacheKey] = struct{}{} s.browseMu.Unlock() go func() { defer func() { s.browseMu.Lock() delete(s.browseInFlight, cacheKey) s.browseMu.Unlock() }() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) if err != nil { s.log.Warn("triggerDirectScrape: build request failed", "key", cacheKey, "err", err) return } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.Header.Set("Accept-Language", "en-US,en;q=0.9") resp, err := http.DefaultClient.Do(req) if err != nil { s.log.Warn("triggerDirectScrape: fetch failed", "key", cacheKey, "err", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { s.log.Warn("triggerDirectScrape: non-200 response", "key", cacheKey, "status", resp.StatusCode) return } htmlBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { s.log.Warn("triggerDirectScrape: read body failed", "key", cacheKey, "err", readErr) return } if len(htmlBytes) == 0 { s.log.Warn("triggerDirectScrape: empty response body", "key", cacheKey) return } // Store the HTML in MinIO so subsequent requests are cache-hits. if putErr := s.store.SaveBrowsePage(ctx, cacheKey, string(htmlBytes)); putErr != nil { s.log.Warn("triggerDirectScrape: SaveBrowsePage failed", "key", cacheKey, "err", putErr) // Non-fatal: continue to populate PocketBase/covers even if MinIO write fails. } else { s.log.Info("triggerDirectScrape: cached browse page", "key", cacheKey, "bytes", len(htmlBytes)) } // Parse to extract novel listings. novels, _ := parseBrowsePage(strings.NewReader(string(htmlBytes))) if len(novels) == 0 { s.log.Warn("triggerDirectScrape: no novels parsed", "key", cacheKey) return } // Upsert each novel into PocketBase ranking and kick off cover downloads. for i, novel := range novels { rank := i + 1 coverKey := s.store.BrowseCoverKey(novelFireDomain, novel.Slug) item := storage.RankingItem{ Rank: rank, Slug: novel.Slug, Title: novel.Title, Cover: coverKey, // stored as MinIO key; UI fetches via /api/cover/... SourceURL: novel.URL, } if werr := s.store.WriteRankingItem(ctx, item); werr != nil { s.log.Warn("triggerDirectScrape: WriteRankingItem failed", "slug", novel.Slug, "err", werr) } if novel.Cover != "" { go s.downloadAndStoreCover(coverKey, novel.Cover) } } s.log.Info("triggerDirectScrape: ranking populated", "count", len(novels), "key", cacheKey) }() } // warmBrowseCache checks whether the browse cache for page 1 is populated in // MinIO and, if not, triggers a background direct scrape. This is called // once on server startup so the first user request is likely served from cache. func (s *Server) warmBrowseCache() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cacheKey := s.store.BrowseHTMLKey(novelFireDomain, 1) if _, ok, err := s.store.GetBrowsePage(ctx, cacheKey); err == nil && ok { s.log.Debug("warmBrowseCache: page 1 already cached, skipping") return } targetURL := fmt.Sprintf("%s/genre-all/sort-popular/status-all/all-novel?page=1", novelFireBase) s.log.Info("warmBrowseCache: page 1 not cached, triggering background scrape") s.triggerDirectScrape(cacheKey, targetURL) } // downloadAndStoreCover delegates to storage.DownloadAndStoreCover. func (s *Server) downloadAndStoreCover(key, imageURL string) { storage.DownloadAndStoreCover(s.store, s.log, key, imageURL) } // parseBrowsePage parses the novelfire HTML and extracts novel listings. // Returns novels and whether a "next page" link was found. func parseBrowsePage(r io.Reader) ([]NovelListing, bool) { doc, err := html.Parse(r) if err != nil { return nil, false } var novels []NovelListing hasNext := false var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode { switch n.Data { case "li": if hasClass(n, "novel-item") { if novel, ok := parseNovelItem(n); ok { novels = append(novels, novel) } } // pagination li with class "next" if hasClass(n, "next") { hasNext = true } case "a": // Detect "next" pagination link if hasClass(n, "next") || attrVal(n, "rel") == "next" { hasNext = true } // Also check aria-label="Next" if attrVal(n, "aria-label") == "Next" { hasNext = true } } } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(doc) return novels, hasNext } // parseNovelItem extracts a NovelListing from a