feat(search): add browse text search across local library and novelfire.net
Adds GET /api/search Go endpoint that queries PocketBase books by title/author substring and fetches novelfire.net /search results via parseBrowsePage(), merging results with local-first de-duplication. The browse page gains a search input that navigates to ?q=; results show local vs remote counts and hide pagination/filter controls while in search mode.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -474,3 +475,101 @@ func attrVal(n *html.Node, key string) string { return htmlutil.AttrVal(n, key)
|
||||
// textContent returns the concatenated text content of a node and its descendants.
|
||||
// Delegates to htmlutil.TextContent.
|
||||
func textContent(n *html.Node) string { return htmlutil.TextContent(n) }
|
||||
|
||||
// ─── Search API ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleSearch handles GET /api/search.
|
||||
//
|
||||
// Query params:
|
||||
//
|
||||
// q — search query string (required, min 2 chars)
|
||||
// source — "local" | "remote" | "all" (default: "all")
|
||||
//
|
||||
// When source includes "local", it searches books already in the local store
|
||||
// by title substring match. When source includes "remote", it fetches the
|
||||
// novelfire.net search page and parses results. Results from both sources
|
||||
// are merged with local results first (de-duplicated by slug).
|
||||
//
|
||||
// Returns JSON: {"results": [...NovelListing], "local_count": N, "remote_count": N}
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if len([]rune(q)) < 2 {
|
||||
http.Error(w, `{"error":"query must be at least 2 characters"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "all"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var localResults []NovelListing
|
||||
var remoteResults []NovelListing
|
||||
|
||||
// ── Local search (PocketBase books) ──────────────────────────────────
|
||||
if source == "local" || source == "all" {
|
||||
books, err := s.store.ListBooks(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("search: ListBooks failed", "err", err)
|
||||
} else {
|
||||
qLower := strings.ToLower(q)
|
||||
for _, b := range books {
|
||||
if strings.Contains(strings.ToLower(b.Title), qLower) ||
|
||||
strings.Contains(strings.ToLower(b.Author), qLower) {
|
||||
listing := NovelListing{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Cover: b.Cover,
|
||||
URL: b.SourceURL,
|
||||
}
|
||||
localResults = append(localResults, listing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote search (novelfire.net /search?keyword=...) ─────────────────
|
||||
if source == "remote" || source == "all" {
|
||||
searchURL := novelFireBase + "/search?keyword=" + url.QueryEscape(q)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
|
||||
if err == nil {
|
||||
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")
|
||||
if resp, fetchErr := http.DefaultClient.Do(req); fetchErr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
parsed, _ := parseBrowsePage(resp.Body)
|
||||
remoteResults = parsed
|
||||
} else {
|
||||
s.log.Warn("search: remote returned non-200", "status", resp.StatusCode, "url", searchURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge: de-duplicate remote results already in local ───────────────
|
||||
localSlugs := make(map[string]bool, len(localResults))
|
||||
for _, item := range localResults {
|
||||
localSlugs[item.Slug] = true
|
||||
}
|
||||
|
||||
combined := make([]NovelListing, 0, len(localResults)+len(remoteResults))
|
||||
combined = append(combined, localResults...)
|
||||
for _, item := range remoteResults {
|
||||
if !localSlugs[item.Slug] {
|
||||
combined = append(combined, item)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"results": combined,
|
||||
"local_count": len(localResults),
|
||||
"remote_count": len(remoteResults),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
||||
// Browse API — fetches and parses novelfire catalogue page
|
||||
mux.HandleFunc("GET /api/browse", s.handleBrowse)
|
||||
// Ranking API
|
||||
@@ -150,6 +151,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks)
|
||||
// Re-index chapters for a book from MinIO into PocketBase chapters_idx
|
||||
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
||||
// On-demand preview (no store writes) — for books not yet in the library
|
||||
mux.HandleFunc("GET /api/book-preview/{slug}", s.handleBookPreview)
|
||||
mux.HandleFunc("GET /api/chapter-text-preview/{slug}/{n}", s.handleChapterTextPreview)
|
||||
// Search: local PocketBase + remote novelfire.net
|
||||
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||
// Progress API
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
|
||||
Reference in New Issue
Block a user