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