Files
libnovel/scraper/internal/server/handlers_preview.go
Admin b3358ac1d2
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 10s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / UI / Build (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 10s
feat: auto-persist metadata and chapter list on first book preview visit
When a book is not in the local DB and the preview endpoint is hit,
metadata and the chapter index are now saved to PocketBase in the
background. Subsequent visits load from the local store instead of
scraping live. Chapter text is not fetched until an explicit scrape job
is triggered.
2026-03-06 17:09:57 +05:00

169 lines
5.6 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 (
"context"
"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{}
}
// If the book was not already in the library, persist the metadata and
// chapter list skeleton to PocketBase now so that subsequent visits load
// from the local store rather than scraping live again. Chapter text is
// NOT fetched here — that still requires an explicit scrape job.
if !inLib {
go func() {
bgCtx := context.Background()
if werr := s.store.WriteMetadata(bgCtx, meta); werr != nil {
s.log.Warn("book-preview: WriteMetadata failed (non-fatal)", "slug", slug, "err", werr)
}
if len(chapters) > 0 {
if werr := s.store.WriteChapterRefs(bgCtx, slug, chapters); werr != nil {
s.log.Warn("book-preview: WriteChapterRefs failed (non-fatal)", "slug", slug, "err", werr)
}
}
s.log.Info("book-preview: metadata+chapter list persisted", "slug", slug, "chapters", len(chapters))
}()
inLib = true // will be true by the time the client navigates back
}
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)
}