- 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
87 lines
2.7 KiB
Go
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)
|
|
}
|