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.
148 lines
4.7 KiB
Go
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)
|
|
}
|