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.
This commit is contained in:
147
scraper/internal/server/handlers_preview.go
Normal file
147
scraper/internal/server/handlers_preview.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,34 +2,99 @@ import { error } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
|
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
|
||||||
import { log } from '$lib/server/logger';
|
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 }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
|
|
||||||
let book: Awaited<ReturnType<typeof getBook>>;
|
// Try fetching from PocketBase first
|
||||||
let chapters: Awaited<ReturnType<typeof listChapterIdx>>;
|
let book = await getBook(slug).catch((e) => {
|
||||||
let progress: Awaited<ReturnType<typeof getProgress>>;
|
log.error('books', 'getBook failed', { slug, err: String(e) });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (book) {
|
||||||
[book, chapters, progress] = await Promise.all([
|
// Book is in the library — normal path
|
||||||
getBook(slug),
|
let chapters, progress;
|
||||||
listChapterIdx(slug),
|
try {
|
||||||
getProgress(locals.sessionId, slug, locals.user?.id)
|
[chapters, progress] = await Promise.all([
|
||||||
]);
|
listChapterIdx(slug),
|
||||||
} catch (e) {
|
getProgress(locals.sessionId, slug, locals.user?.id)
|
||||||
log.error('books', 'failed to load book page', { slug, err: String(e) });
|
]);
|
||||||
throw error(500, 'Failed to load book');
|
} 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) {
|
// Book not in PocketBase — try live preview from scraper
|
||||||
log.warn('books', 'book not found', { slug });
|
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`);
|
error(404, `Book "${slug}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
book,
|
|
||||||
chapters,
|
|
||||||
lastChapter: progress?.chapter ?? null,
|
|
||||||
isAdmin: locals.user?.role === 'admin'
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,12 +8,81 @@ import { env } from '$env/dynamic/private';
|
|||||||
|
|
||||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
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 { slug } = params;
|
||||||
const n = parseInt(params.n, 10);
|
const n = parseInt(params.n, 10);
|
||||||
|
|
||||||
if (!n || n < 1) error(400, 'Invalid chapter number');
|
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
|
||||||
|
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// 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
|
// Fetch book metadata, chapter index, and voice list in parallel
|
||||||
const [book, chapters, voicesRes] = await Promise.all([
|
const [book, chapters, voicesRes] = await Promise.all([
|
||||||
getBook(slug),
|
getBook(slug),
|
||||||
@@ -40,8 +109,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
// Get presigned URL and fetch chapter markdown server-side
|
// Get presigned URL and fetch chapter markdown server-side
|
||||||
let html = '';
|
let html = '';
|
||||||
try {
|
try {
|
||||||
const url = await presignChapter(slug, n);
|
const presignUrl = await presignChapter(slug, n);
|
||||||
const res = await fetch(url);
|
const res = await fetch(presignUrl);
|
||||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||||
const markdown = await res.text();
|
const markdown = await res.text();
|
||||||
html = await marked(markdown, { async: true });
|
html = await marked(markdown, { async: true });
|
||||||
@@ -60,6 +129,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
voices,
|
voices,
|
||||||
prev: prevChapter ? prevChapter.number : null,
|
prev: prevChapter ? prevChapter.number : null,
|
||||||
next: nextChapter ? nextChapter.number : null,
|
next: nextChapter ? nextChapter.number : null,
|
||||||
sessionId: locals.sessionId
|
sessionId: locals.sessionId,
|
||||||
|
isPreview: false
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
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 () => {
|
onMount(async () => {
|
||||||
|
if (data.isPreview) return;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/progress', {
|
await fetch('/api/progress', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Audio player -->
|
<!-- Audio player -->
|
||||||
|
{#if !data.isPreview}
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
slug={data.book.slug}
|
slug={data.book.slug}
|
||||||
chapter={data.chapter.number}
|
chapter={data.chapter.number}
|
||||||
@@ -76,6 +78,11 @@
|
|||||||
nextChapter={data.next}
|
nextChapter={data.next}
|
||||||
voices={data.voices}
|
voices={data.voices}
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="mb-6 px-4 py-3 rounded bg-zinc-800/60 border border-zinc-700 text-zinc-500 text-sm">
|
||||||
|
Preview chapter — audio not available for books outside the library.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Chapter content -->
|
<!-- Chapter content -->
|
||||||
{#if !data.html}
|
{#if !data.html}
|
||||||
|
|||||||
Reference in New Issue
Block a user