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
- {#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}
- Discover