Files
libnovel/scraper/internal/server/handlers_ranking.go
Admin fb6b364382 refactor: audit, split server.go, add unit tests, and fix latent bugs
- Remove dead code: browser cdp/content_scrape strategies, writer package,
  printUsage, downloadAndStoreCoverCLI in main.go
- Fix bugs: defer-in-loop in pocketbase deleteWhere, listAll() pagination
  hard cap removed, splitChapterTitle off-by-one in date extraction
- Split server.go (~1700 lines) into focused handler files:
  handlers_audio, handlers_browse, handlers_progress, handlers_ranking,
  handlers_scrape
- Export htmlutil.AttrVal/TextContent/ResolveURL; add storage/coverutil.go
  to consolidate duplicate helpers
- Flatten deeply nested conditionals: voices() early-return guards,
  ScrapeCatalogue next-link double attr scan, chapterNumberFromKey dead
  strings.Cut line, splitChapterTitle double-nested unit/suffix loop
- Add unit tests: htmlutil (9 funcs), novelfire ScrapeMetadata (3 cases),
  orchestrator Run (5 cases), storage chapterNumberFromKey/splitChapterTitle
  (22 cases); all pass with go build/vet/test clean
2026-03-04 22:14:23 +05:00

87 lines
2.7 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/libnovel/scraper/internal/storage"
)
// handleGetRanking returns all ranking items sorted by rank ascending.
// Cover fields that hold a MinIO object key (e.g. "novelfire.net/assets/book-covers/slug.jpg")
// are rewritten to a /api/cover/{key} proxy URL so the UI can fetch them
// without knowing about the internal MinIO topology.
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
}
if items == nil {
items = []storage.RankingItem{}
}
// Rewrite cover keys to proxy URLs.
// Keys stored by triggerDirectScrape look like:
// "novelfire.net/assets/book-covers/shadow-slave.jpg"
// We expose them as:
// "/api/cover/novelfire.net/shadow-slave"
// (the handler strips the domain and slug from the path, reconstructs the key)
for i := range items {
cover := items[i].Cover
if cover != "" && !strings.HasPrefix(cover, "http") {
// cover is a MinIO key; extract domain + slug for the proxy path.
// Key format: {domain}/assets/book-covers/{slug}.jpg
parts := strings.SplitN(cover, "/assets/book-covers/", 2)
if len(parts) == 2 {
domain := parts[0]
slug := strings.TrimSuffix(parts[1], ".jpg")
items[i].Cover = "/api/cover/" + domain + "/" + slug
}
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(items)
}
// handleGetCover proxies a cover image stored in the MinIO browse bucket.
// Route: GET /api/cover/{domain}/{slug}
// It reconstructs the MinIO key as {domain}/assets/book-covers/{slug}.jpg,
// fetches the object, and streams it to the client.
// Returns 404 if not yet downloaded, allowing the UI to fall back to the
// original source URL.
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
domain := r.PathValue("domain")
slug := r.PathValue("slug")
if domain == "" || slug == "" {
http.Error(w, "missing domain or slug", http.StatusBadRequest)
return
}
key := s.store.BrowseCoverKey(domain, slug)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
data, contentType, ok, err := s.store.GetBrowseAsset(ctx, key)
if err != nil {
s.log.Warn("handleGetCover: GetBrowseAsset error", "key", key, "err", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
if contentType == "" {
contentType = "image/jpeg"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(data)
}