From 97e7a8dc0235ff045776cb98ac09ff71fff2add1 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 5 Mar 2026 14:00:41 +0500 Subject: [PATCH] feat(preview): add on-demand book/chapter preview for unlibrary'd books Adds GET /api/book-preview/{slug} and GET /api/chapter-text-preview/{slug}/{n} Go endpoints that scrape live from novelfire.net without persisting to PocketBase or MinIO. The UI book page falls back to the preview endpoint when a book is not found in PocketBase, showing a 'not in library' badge and the scraped chapter list. Chapter pages handle ?preview=1 to fetch and render chapter text live, skipping MinIO and suppressing the audio player. --- scraper/internal/server/handlers_preview.go | 147 ++++++++++++++++++ ui/src/routes/books/[slug]/+page.server.ts | 107 ++++++++++--- .../books/[slug]/chapters/[n]/+page.server.ts | 78 +++++++++- .../books/[slug]/chapters/[n]/+page.svelte | 9 +- 4 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 scraper/internal/server/handlers_preview.go diff --git a/scraper/internal/server/handlers_preview.go b/scraper/internal/server/handlers_preview.go new file mode 100644 index 0000000..983ff48 --- /dev/null +++ b/scraper/internal/server/handlers_preview.go @@ -0,0 +1,147 @@ +package server + +// handlers_preview.go — on-demand preview endpoints for books not yet in PocketBase. +// +// These endpoints allow the UI to display a book's metadata and chapter list +// (scraped live from novelfire.net) without requiring a full scrape to have +// been run first. They are read-only: nothing is persisted to PocketBase or +// MinIO. +// +// Endpoints: +// +// GET /api/book-preview/{slug} — scrape book metadata + chapter list live +// GET /api/chapter-text-preview/{slug}/{n} — scrape a single chapter text live + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/libnovel/scraper/internal/scraper" +) + +// BookPreviewResponse is the JSON response for /api/book-preview/{slug}. +type BookPreviewResponse struct { + InLib bool `json:"in_lib"` + Meta scraper.BookMeta `json:"meta"` + Chapters []scraper.ChapterRef `json:"chapters"` +} + +// handleBookPreview handles GET /api/book-preview/{slug}. +// +// It scrapes book metadata and the full chapter list live from novelfire.net. +// It also checks whether the book exists in the local store (PocketBase) and +// sets the InLib flag accordingly. Nothing is written to any store. +// +// Query param: source_url (optional) — if provided, uses that URL instead of +// constructing one from the slug. +func (s *Server) handleBookPreview(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) + return + } + + // Determine the book URL: prefer explicit source_url query param. + bookURL := r.URL.Query().Get("source_url") + if bookURL == "" { + bookURL = fmt.Sprintf("%s/book/%s", novelFireBase, slug) + } + + ctx := r.Context() + + // Check whether the book is already in the local library. + _, inLib, err := s.store.ReadMetadata(ctx, slug) + if err != nil { + // Non-fatal: we can still serve the preview. + s.log.Warn("book-preview: ReadMetadata failed", "slug", slug, "err", err) + inLib = false + } + + // Scrape live metadata. + meta, err := s.novel.ScrapeMetadata(ctx, bookURL) + if err != nil { + s.log.Error("book-preview: ScrapeMetadata failed", "slug", slug, "url", bookURL, "err", err) + http.Error(w, fmt.Sprintf(`{"error":"metadata scrape failed: %s"}`, err.Error()), http.StatusBadGateway) + return + } + + // Scrape live chapter list. + chapters, err := s.novel.ScrapeChapterList(ctx, bookURL) + if err != nil { + s.log.Error("book-preview: ScrapeChapterList failed", "slug", slug, "url", bookURL, "err", err) + // Return partial response with metadata only — chapters are non-critical. + chapters = []scraper.ChapterRef{} + } + + resp := BookPreviewResponse{ + InLib: inLib, + Meta: meta, + Chapters: chapters, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// ChapterPreviewResponse is the JSON response for /api/chapter-text-preview/{slug}/{n}. +type ChapterPreviewResponse struct { + Slug string `json:"slug"` + Number int `json:"number"` + Title string `json:"title"` + Text string `json:"text"` // plain text (markdown stripped) + URL string `json:"url"` +} + +// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}. +// +// It scrapes a single chapter from novelfire.net live without storing anything. +// The chapter URL is determined from either: +// - the "chapter_url" query param (preferred — used when the UI knows it from +// a prior book-preview call), or +// - a best-effort construction: {novelFireBase}/book/{slug}/chapter-{n} +// +// Returns plain text (markdown stripped) suitable for TTS or display. +func (s *Server) handleChapterTextPreview(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + nStr := r.PathValue("n") + n, err := strconv.Atoi(nStr) + if err != nil || n < 1 || slug == "" { + http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest) + return + } + + // Chapter URL: prefer explicit query param. + chapterURL := r.URL.Query().Get("chapter_url") + if chapterURL == "" { + chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n) + } + + title := r.URL.Query().Get("title") + + ref := scraper.ChapterRef{ + Number: n, + Title: title, + URL: chapterURL, + } + + chapter, err := s.novel.ScrapeChapterText(r.Context(), ref) + if err != nil { + s.log.Error("chapter-text-preview: ScrapeChapterText failed", + "slug", slug, "n", n, "url", chapterURL, "err", err) + http.Error(w, fmt.Sprintf(`{"error":"chapter scrape failed: %s"}`, err.Error()), http.StatusBadGateway) + return + } + + resp := ChapterPreviewResponse{ + Slug: slug, + Number: n, + Title: chapter.Ref.Title, + Text: stripMarkdown(chapter.Text), + URL: chapterURL, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts index cd814f4..b808661 100644 --- a/ui/src/routes/books/[slug]/+page.server.ts +++ b/ui/src/routes/books/[slug]/+page.server.ts @@ -2,34 +2,99 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +// Minimal chapter shape returned by /api/book-preview +export interface PreviewChapter { + number: number; + title: string; + url: string; +} export const load: PageServerLoad = async ({ params, locals }) => { const { slug } = params; - let book: Awaited>; - let chapters: Awaited>; - let progress: Awaited>; + // Try fetching from PocketBase first + let book = await getBook(slug).catch((e) => { + log.error('books', 'getBook failed', { slug, err: String(e) }); + return null; + }); - try { - [book, chapters, progress] = await Promise.all([ - getBook(slug), - listChapterIdx(slug), - getProgress(locals.sessionId, slug, locals.user?.id) - ]); - } catch (e) { - log.error('books', 'failed to load book page', { slug, err: String(e) }); - throw error(500, 'Failed to load book'); + if (book) { + // Book is in the library — normal path + let chapters, progress; + try { + [chapters, progress] = await Promise.all([ + listChapterIdx(slug), + getProgress(locals.sessionId, slug, locals.user?.id) + ]); + } catch (e) { + log.error('books', 'failed to load book page data', { slug, err: String(e) }); + throw error(500, 'Failed to load book'); + } + + return { + book, + chapters, + previewChapters: null as PreviewChapter[] | null, + inLib: true, + lastChapter: progress?.chapter ?? null, + isAdmin: locals.user?.role === 'admin' + }; } - if (!book) { - log.warn('books', 'book not found', { slug }); + // Book not in PocketBase — try live preview from scraper + try { + const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`); + if (!res.ok) { + log.warn('books', 'book-preview returned error', { slug, status: res.status }); + error(404, `Book "${slug}" not found`); + } + const preview: { + in_lib: boolean; + meta: { + slug: string; + title: string; + author: string; + cover: string; + status: string; + genres: string[]; + summary: string; + total_chapters: number; + source_url: string; + }; + chapters: PreviewChapter[]; + } = await res.json(); + + // Shape the meta into a Book-like object (no PocketBase id fields) + const previewBook = { + id: '', + slug: preview.meta.slug || slug, + title: preview.meta.title, + author: preview.meta.author, + cover: preview.meta.cover, + status: preview.meta.status, + genres: preview.meta.genres ?? [], + summary: preview.meta.summary, + total_chapters: preview.meta.total_chapters, + source_url: preview.meta.source_url, + ranking: 0, + meta_updated: '' + }; + + return { + book: previewBook, + chapters: [], + previewChapters: preview.chapters, + inLib: preview.in_lib, + lastChapter: null, + isAdmin: locals.user?.role === 'admin' + }; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('books', 'book-preview fetch failed', { slug, err: String(e) }); error(404, `Book "${slug}" not found`); } - - return { - book, - chapters, - lastChapter: progress?.chapter ?? null, - isAdmin: locals.user?.role === 'admin' - }; }; diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts index 3956cb0..fcb5d1d 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts @@ -8,12 +8,81 @@ import { env } from '$env/dynamic/private'; const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; -export const load: PageServerLoad = async ({ params, locals }) => { +export const load: PageServerLoad = async ({ params, url, locals }) => { const { slug } = params; const n = parseInt(params.n, 10); if (!n || n < 1) error(400, 'Invalid chapter number'); + const isPreview = url.searchParams.get('preview') === '1'; + const chapterUrl = url.searchParams.get('chapter_url') ?? ''; + const chapterTitle = url.searchParams.get('title') ?? ''; + + if (isPreview) { + // ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ── + const previewParams = new URLSearchParams(); + if (chapterUrl) previewParams.set('chapter_url', chapterUrl); + if (chapterTitle) previewParams.set('title', chapterTitle); + + let chapterData: { slug: string; number: number; title: string; text: string; url: string }; + try { + const res = await fetch( + `${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}` + ); + if (!res.ok) { + log.error('chapter', 'chapter-text-preview returned error', { slug, n, status: res.status }); + error(404, `Chapter ${n} not found`); + } + chapterData = await res.json(); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) }); + error(502, 'Could not fetch chapter preview'); + } + + // Wrap plain text in minimal HTML paragraphs for display + const html = chapterData.text + ? '

' + chapterData.text.replace(/\n{2,}/g, '

').replace(/\n/g, '
') + '

' + : ''; + + // Fetch voices (non-critical for preview) + let voices: string[] = []; + try { + const vRes = await fetch(`${SCRAPER_URL}/api/voices`); + if (vRes.ok) { + const d = (await vRes.json()) as { voices: string[] }; + voices = d.voices ?? []; + } + } catch { + // Non-critical + } + + // Try to get book title/cover from PocketBase for breadcrumbs; fall back to slug + const pb = await getBook(slug).catch(() => null); + + return { + book: { + slug, + title: pb?.title ?? slug, + cover: pb?.cover ?? '' + }, + chapter: { + id: '', + slug, + number: n, + title: chapterData.title || `Chapter ${n}`, + date_label: '' + }, + html, + voices, + prev: null as number | null, + next: null as number | null, + sessionId: locals.sessionId, + isPreview: true + }; + } + + // ── Normal path: fetch from PocketBase + MinIO ───────────────────────── // Fetch book metadata, chapter index, and voice list in parallel const [book, chapters, voicesRes] = await Promise.all([ getBook(slug), @@ -40,8 +109,8 @@ export const load: PageServerLoad = async ({ params, locals }) => { // Get presigned URL and fetch chapter markdown server-side let html = ''; try { - const url = await presignChapter(slug, n); - const res = await fetch(url); + const presignUrl = await presignChapter(slug, n); + const res = await fetch(presignUrl); if (!res.ok) throw new Error(`MinIO returned ${res.status}`); const markdown = await res.text(); html = await marked(markdown, { async: true }); @@ -60,6 +129,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { voices, prev: prevChapter ? prevChapter.number : null, next: nextChapter ? nextChapter.number : null, - sessionId: locals.sessionId + sessionId: locals.sessionId, + isPreview: false }; }; diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte index f399262..d30d545 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -5,8 +5,9 @@ let { data }: { data: PageData } = $props(); - // Record reading progress when the chapter is opened + // Record reading progress when the chapter is opened (skip for preview chapters) onMount(async () => { + if (data.isPreview) return; try { await fetch('/api/progress', { method: 'POST', @@ -67,6 +68,7 @@ +{#if !data.isPreview} +{:else} +
+ Preview chapter — audio not available for books outside the library. +
+{/if} {#if !data.html}