Files
libnovel/scraper/internal/server/handlers_preview.go
Admin 97e7a8dc02 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.
2026-03-05 14:00:41 +05:00

148 lines
4.7 KiB
Go

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)
}