From 1d00fd4e2ed2455ef04afcbd4f6bfcbbce904d6a Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 5 Mar 2026 14:00:59 +0500 Subject: [PATCH] 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. --- scraper/internal/server/handlers_browse.go | 99 ++++++++++++++++++++++ scraper/internal/server/server.go | 6 ++ ui/src/routes/browse/+page.server.ts | 47 +++++++++- ui/src/routes/browse/+page.svelte | 44 ++++++++-- 4 files changed, 189 insertions(+), 7 deletions(-) diff --git a/scraper/internal/server/handlers_browse.go b/scraper/internal/server/handlers_browse.go index 683239d..85b879e 100644 --- a/scraper/internal/server/handlers_browse.go +++ b/scraper/internal/server/handlers_browse.go @@ -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), + }) +} diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go index 148fe38..aa9d8b2 100644 --- a/scraper/internal/server/server.go +++ b/scraper/internal/server/server.go @@ -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) diff --git a/ui/src/routes/browse/+page.server.ts b/ui/src/routes/browse/+page.server.ts index 89a2f01..85e4e19 100644 --- a/ui/src/routes/browse/+page.server.ts +++ b/ui/src/routes/browse/+page.server.ts @@ -25,10 +25,52 @@ export const load: PageServerLoad = async ({ url, locals }) => { const genre = url.searchParams.get('genre') ?? 'all'; const sort = url.searchParams.get('sort') ?? 'popular'; const status = url.searchParams.get('status') ?? 'all'; + const q = url.searchParams.get('q') ?? ''; let novels: NovelListing[] = []; let pageNum = parseInt(page, 10) || 1; let hasNext = false; + let searchQuery = ''; + let searchLocalCount = 0; + let searchRemoteCount = 0; + + // ── Search mode: ?q= overrides browse/ranking ───────────────────────── + if (q.trim().length >= 2) { + searchQuery = q.trim(); + const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(searchQuery)}`; + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('browse', 'search returned error', { status: res.status }); + throw error(502, `Search failed: ${res.status}`); + } + const data: { + results: NovelListing[]; + local_count: number; + remote_count: number; + } = await res.json(); + novels = data.results ?? []; + searchLocalCount = data.local_count ?? 0; + searchRemoteCount = data.remote_count ?? 0; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('browse', 'search network error', { q: searchQuery, err: String(e) }); + throw error(502, 'Could not reach search service'); + } + + return { + novels, + page: 1, + hasNext: false, + genre, + sort, + status, + isAdmin: locals.user?.role === 'admin', + searchQuery, + searchLocalCount, + searchRemoteCount + }; + } if (sort === 'rank') { // Ranking view: fetch from /api/ranking which returns richer metadata. @@ -99,7 +141,10 @@ export const load: PageServerLoad = async ({ url, locals }) => { genre, sort, status, - isAdmin: locals.user?.role === 'admin' + isAdmin: locals.user?.role === 'admin', + searchQuery: '', + searchLocalCount: 0, + searchRemoteCount: 0 }; }; diff --git a/ui/src/routes/browse/+page.svelte b/ui/src/routes/browse/+page.svelte index 51210dc..eab6419 100644 --- a/ui/src/routes/browse/+page.svelte +++ b/ui/src/routes/browse/+page.svelte @@ -40,6 +40,7 @@ // When sort=rank the ranking API is used — pagination + genre/status filters // don't apply to that endpoint. const isRankView = $derived(data.sort === 'rank'); + const isSearchView = $derived(!!data.searchQuery); function buildURL(overrides: Record) { const params = new URLSearchParams({ @@ -96,7 +97,12 @@

Discover

- {#if isRankView} + {#if isSearchView} + {data.novels.length} result{data.novels.length !== 1 ? 's' : ''} for "{data.searchQuery}" + {#if data.searchLocalCount > 0 || data.searchRemoteCount > 0} + ({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire) + {/if} + {:else if isRankView} {#if data.novels.length > 0} {data.novels.length} novels ranked from last catalogue scrape {:else} @@ -105,8 +111,7 @@ {:else} Browse novels from novelfire.net {/if} -

-
+

@@ -181,6 +186,31 @@ {/if} {/if} + +
+ + + {#if data.searchQuery} + + Clear + + {/if} +
+
@@ -232,9 +262,11 @@ {#if data.novels.length === 0}
-

{isRankView ? 'No ranking data.' : 'No novels found.'}

+

{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}

- {#if isRankView} + {#if isSearchView} + Try a different search term. + {:else if isRankView} {#if data.isAdmin} Click Refresh catalogue above to trigger a full catalogue scrape. {:else} @@ -417,7 +449,7 @@ {/if} -{#if !isRankView && data.novels.length > 0} +{#if !isRankView && !isSearchView && data.novels.length > 0}

{#if data.page > 1}