v2 #1
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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.
|
// textContent returns the concatenated text content of a node and its descendants.
|
||||||
// Delegates to htmlutil.TextContent.
|
// Delegates to htmlutil.TextContent.
|
||||||
func textContent(n *html.Node) string { return htmlutil.TextContent(n) }
|
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("GET /health", s.handleHealth)
|
||||||
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
||||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||||
|
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
||||||
// Browse API — fetches and parses novelfire catalogue page
|
// Browse API — fetches and parses novelfire catalogue page
|
||||||
mux.HandleFunc("GET /api/browse", s.handleBrowse)
|
mux.HandleFunc("GET /api/browse", s.handleBrowse)
|
||||||
// Ranking API
|
// Ranking API
|
||||||
@@ -150,6 +151,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks)
|
mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks)
|
||||||
// Re-index chapters for a book from MinIO into PocketBase chapters_idx
|
// Re-index chapters for a book from MinIO into PocketBase chapters_idx
|
||||||
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
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
|
// Progress API
|
||||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||||
|
|||||||
@@ -25,10 +25,52 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
const genre = url.searchParams.get('genre') ?? 'all';
|
const genre = url.searchParams.get('genre') ?? 'all';
|
||||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||||
const status = url.searchParams.get('status') ?? 'all';
|
const status = url.searchParams.get('status') ?? 'all';
|
||||||
|
const q = url.searchParams.get('q') ?? '';
|
||||||
|
|
||||||
let novels: NovelListing[] = [];
|
let novels: NovelListing[] = [];
|
||||||
let pageNum = parseInt(page, 10) || 1;
|
let pageNum = parseInt(page, 10) || 1;
|
||||||
let hasNext = false;
|
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') {
|
if (sort === 'rank') {
|
||||||
// Ranking view: fetch from /api/ranking which returns richer metadata.
|
// Ranking view: fetch from /api/ranking which returns richer metadata.
|
||||||
@@ -99,7 +141,10 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
genre,
|
genre,
|
||||||
sort,
|
sort,
|
||||||
status,
|
status,
|
||||||
isAdmin: locals.user?.role === 'admin'
|
isAdmin: locals.user?.role === 'admin',
|
||||||
|
searchQuery: '',
|
||||||
|
searchLocalCount: 0,
|
||||||
|
searchRemoteCount: 0
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
// When sort=rank the ranking API is used — pagination + genre/status filters
|
// When sort=rank the ranking API is used — pagination + genre/status filters
|
||||||
// don't apply to that endpoint.
|
// don't apply to that endpoint.
|
||||||
const isRankView = $derived(data.sort === 'rank');
|
const isRankView = $derived(data.sort === 'rank');
|
||||||
|
const isSearchView = $derived(!!data.searchQuery);
|
||||||
|
|
||||||
function buildURL(overrides: Record<string, string | number>) {
|
function buildURL(overrides: Record<string, string | number>) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -96,7 +97,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
||||||
<p class="text-zinc-400 text-sm mt-1">
|
<p class="text-zinc-400 text-sm mt-1">
|
||||||
{#if isRankView}
|
{#if isSearchView}
|
||||||
|
{data.novels.length} result{data.novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
||||||
|
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||||
|
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||||
|
{/if}
|
||||||
|
{:else if isRankView}
|
||||||
{#if data.novels.length > 0}
|
{#if data.novels.length > 0}
|
||||||
{data.novels.length} novels ranked from last catalogue scrape
|
{data.novels.length} novels ranked from last catalogue scrape
|
||||||
{:else}
|
{:else}
|
||||||
@@ -105,8 +111,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
Browse novels from novelfire.net
|
Browse novels from novelfire.net
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p> </div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<!-- View toggle -->
|
<!-- View toggle -->
|
||||||
@@ -181,6 +186,31 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<form method="GET" action="/browse" class="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value={data.searchQuery}
|
||||||
|
placeholder="Search novels by title or author…"
|
||||||
|
class="flex-1 bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-1.5 focus:outline-none focus:border-amber-400 placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
{#if data.searchQuery}
|
||||||
|
<a
|
||||||
|
href="/browse"
|
||||||
|
class="px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<form method="GET" action="/browse" class="flex flex-wrap gap-3 mb-6">
|
<form method="GET" action="/browse" class="flex flex-wrap gap-3 mb-6">
|
||||||
<input type="hidden" name="page" value="1" />
|
<input type="hidden" name="page" value="1" />
|
||||||
@@ -232,9 +262,11 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
{#if data.novels.length === 0}
|
{#if data.novels.length === 0}
|
||||||
<div class="text-center py-20 text-zinc-500">
|
<div class="text-center py-20 text-zinc-500">
|
||||||
<p class="text-lg">{isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||||
<p class="text-sm mt-2">
|
<p class="text-sm mt-2">
|
||||||
{#if isRankView}
|
{#if isSearchView}
|
||||||
|
Try a different search term.
|
||||||
|
{:else if isRankView}
|
||||||
{#if data.isAdmin}
|
{#if data.isAdmin}
|
||||||
Click <span class="text-amber-400">Refresh catalogue</span> above to trigger a full catalogue scrape.
|
Click <span class="text-amber-400">Refresh catalogue</span> above to trigger a full catalogue scrape.
|
||||||
{:else}
|
{:else}
|
||||||
@@ -417,7 +449,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Pagination (browse mode only) -->
|
<!-- Pagination (browse mode only) -->
|
||||||
{#if !isRankView && data.novels.length > 0}
|
{#if !isRankView && !isSearchView && data.novels.length > 0}
|
||||||
<div class="flex items-center justify-center gap-3 mt-8">
|
<div class="flex items-center justify-center gap-3 mt-8">
|
||||||
{#if data.page > 1}
|
{#if data.page > 1}
|
||||||
<a
|
<a
|
||||||
|
|||||||
Reference in New Issue
Block a user