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.
171 lines
4.9 KiB
TypeScript
171 lines
4.9 KiB
TypeScript
import { error } from '@sveltejs/kit';
|
|
import type { PageServerLoad, Actions } from './$types';
|
|
import { env } from '$env/dynamic/private';
|
|
import { log } from '$lib/server/logger';
|
|
|
|
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
|
|
|
export interface NovelListing {
|
|
slug: string;
|
|
title: string;
|
|
cover: string;
|
|
rank: string;
|
|
rating: string;
|
|
chapters: string;
|
|
url: string;
|
|
// enriched fields (only set when sort=rank)
|
|
author?: string;
|
|
status?: string;
|
|
genres?: string[];
|
|
source_url?: string;
|
|
}
|
|
|
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
|
const page = url.searchParams.get('page') ?? '1';
|
|
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.
|
|
// Pagination and filters (genre/status) don't apply here — the ranking
|
|
// is a single pre-computed list from the last catalogue scrape.
|
|
const apiURL = `${SCRAPER_URL}/api/ranking`;
|
|
try {
|
|
const res = await fetch(apiURL);
|
|
if (!res.ok) {
|
|
log.error('browse', 'scraper ranking returned error', { status: res.status });
|
|
throw error(502, `Ranking fetch failed: ${res.status}`);
|
|
}
|
|
const items: Array<{
|
|
rank: number;
|
|
slug: string;
|
|
title: string;
|
|
author: string;
|
|
cover: string;
|
|
status: string;
|
|
genres: string[];
|
|
source_url: string;
|
|
}> = await res.json();
|
|
novels = (items ?? []).map((item) => ({
|
|
slug: item.slug,
|
|
title: item.title,
|
|
cover: item.cover,
|
|
rank: item.rank != null ? `#${item.rank}` : '',
|
|
rating: '',
|
|
chapters: '',
|
|
url: item.source_url ?? '',
|
|
author: item.author,
|
|
status: item.status,
|
|
genres: item.genres ?? [],
|
|
source_url: item.source_url
|
|
}));
|
|
pageNum = 1;
|
|
hasNext = false;
|
|
} catch (e) {
|
|
if (e instanceof Error && 'status' in e) throw e;
|
|
log.error('browse', 'scraper ranking network error', { err: String(e) });
|
|
throw error(502, 'Could not load ranking');
|
|
}
|
|
} else {
|
|
// Browse view: paginated catalogue from /api/browse.
|
|
const params = new URLSearchParams({ page, genre, sort, status });
|
|
const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`;
|
|
try {
|
|
const res = await fetch(apiURL);
|
|
if (!res.ok) {
|
|
log.error('browse', 'scraper browse returned error', { status: res.status, url: apiURL });
|
|
throw error(502, `Browse fetch failed: ${res.status}`);
|
|
}
|
|
const data: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
|
|
novels = data.novels ?? [];
|
|
pageNum = data.page ?? 1;
|
|
hasNext = data.hasNext ?? false;
|
|
} catch (e) {
|
|
if (e instanceof Error && 'status' in e) throw e;
|
|
log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) });
|
|
throw error(502, 'Could not load browse page');
|
|
}
|
|
}
|
|
|
|
return {
|
|
novels,
|
|
page: pageNum,
|
|
hasNext,
|
|
genre,
|
|
sort,
|
|
status,
|
|
isAdmin: locals.user?.role === 'admin',
|
|
searchQuery: '',
|
|
searchLocalCount: 0,
|
|
searchRemoteCount: 0
|
|
};
|
|
};
|
|
|
|
// Admin action: trigger a full catalogue scrape (refreshes ranking + library).
|
|
export const actions: Actions = {
|
|
refresh: async ({ locals, fetch }) => {
|
|
if (!locals.user || locals.user.role !== 'admin') {
|
|
throw error(403, 'Forbidden');
|
|
}
|
|
try {
|
|
const res = await fetch('/api/scrape', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
});
|
|
if (res.status === 409) return { status: 'busy' };
|
|
if (!res.ok) return { status: 'error' };
|
|
return { status: 'queued' };
|
|
} catch {
|
|
return { status: 'error' };
|
|
}
|
|
}
|
|
};
|