package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/libnovel/scraper/internal/orchestrator"
"github.com/libnovel/scraper/internal/scraper"
"github.com/libnovel/scraper/internal/writer"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
goldhtml "github.com/yuin/goldmark/renderer/html"
)
// md is the shared goldmark instance used for all markdown→HTML conversions.
var md = goldmark.New(
goldmark.WithExtensions(extension.Typographer, extension.Table),
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
)
// kokoroVoices is the full list of voices shipped with Kokoro-FastAPI,
// grouped loosely by language prefix:
//
// af_ / am_ American English female / male
// bf_ / bm_ British English female / male
// ef_ / em_ Spanish female / male
// ff_ French female
// hf_ / hm_ Hindi female / male
// if_ / im_ Italian female / male
// jf_ / jm_ Japanese female / male
// pf_ / pm_ Portuguese female / male
// zf_ / zm_ Chinese female / male
var kokoroVoices = []string{
// American English
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
"af_sarah", "af_sky",
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
"am_michael", "am_onyx", "am_puck",
// British English
"bf_alice", "bf_emma", "bf_lily",
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
// Spanish
"ef_dora", "em_alex",
// French
"ff_siwis",
// Hindi
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
// Italian
"if_sara", "im_nicola",
// Japanese
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
// Portuguese
"pf_dora", "pm_alex",
// Chinese
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
}
// voiceInfo holds the parsed display metadata for a single Kokoro voice.
type voiceInfo struct {
ID string // raw voice ID, e.g. "af_bella"
Name string // display name, e.g. "Bella"
Lang string // language label, e.g. "EN-US"
Gender string // "F" or "M"
}
// langLabel maps the two-letter prefix to a human-readable language tag.
var langLabel = map[string]string{
"a": "EN-US",
"b": "EN-GB",
"e": "ES",
"f": "FR",
"h": "HI",
"i": "IT",
"j": "JA",
"p": "PT",
"z": "ZH",
}
// parseVoice decodes a Kokoro voice ID into display metadata.
// IDs follow the pattern {lang}{gender}_{name} e.g. "af_bella".
func parseVoice(id string) voiceInfo {
v := voiceInfo{ID: id, Name: id, Lang: "?", Gender: "?"}
if len(id) < 3 || id[2] != '_' {
return v
}
lc := string(id[0])
gc := string(id[1])
name := id[3:]
if l, ok := langLabel[lc]; ok {
v.Lang = l
}
switch gc {
case "f":
v.Gender = "F"
case "m":
v.Gender = "M"
}
// Capitalise name, replace underscores with spaces.
if len(name) > 0 {
runes := []rune(name)
runes[0] -= 'a' - 'A'
v.Name = strings.ReplaceAll(string(runes), "_", " ")
}
return v
}
// parseVoices converts a slice of raw voice IDs to voiceInfo structs.
func parseVoices(ids []string) []voiceInfo {
out := make([]voiceInfo, len(ids))
for i, id := range ids {
out[i] = parseVoice(id)
}
return out
}
// ─── shared layout ────────────────────────────────────────────────────────────
const layoutHead = `
{{.Title}} — libnovel
`
const layoutFoot = ``
func renderPage(w http.ResponseWriter, title, body string) {
t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = t.Execute(w, struct{ Title string }{Title: title})
}
func renderFragment(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, body)
}
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// respond writes either a full page or an HTMX fragment depending on the request.
func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) {
if isHTMX(r) {
renderFragment(w, fragment)
return
}
renderPage(w, title,
``+fragment+``)
}
// ─── GET / — book catalogue ───────────────────────────────────────────────────
const homeTmpl = `
No ranking data. Use the page buttons above to fetch from novelfire.net.
{{end}}
`
// rankingViewItem enriches a RankingItem with whether it is present in the
// local book library, so the template can highlight it differently.
type rankingViewItem struct {
writer.RankingItem
Local bool
}
// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
out := make([]rankingViewItem, len(items))
for i, it := range items {
out[i] = rankingViewItem{
RankingItem: it,
Local: localSlugs[it.Slug],
}
}
return out
}
// pageNum is one entry in the ranking pagination bar.
// Num == 0 is a sentinel that renders as an ellipsis gap.
type pageNum struct {
Num int
}
// rankingPageNums builds a pagination list with smart ellipsis.
// It always shows: first 2, last 2, and a ±2 window around current.
// Gaps between non-consecutive runs are filled with a sentinel (Num==0) for "…".
// Pass current=0 when there is no concept of a current page (e.g. fetch bar).
func rankingPageNums(total, current int) []pageNum {
if total <= 0 {
return nil
}
show := make(map[int]bool)
// First 2 and last 2.
for i := 1; i <= 2 && i <= total; i++ {
show[i] = true
}
for i := total - 1; i <= total; i++ {
if i >= 1 {
show[i] = true
}
}
// ±2 window around current page.
if current > 0 {
for i := current - 2; i <= current+2; i++ {
if i >= 1 && i <= total {
show[i] = true
}
}
}
// Collect and sort.
pages := make([]int, 0, len(show))
for p := range show {
pages = append(pages, p)
}
for i := 0; i < len(pages); i++ {
for j := i + 1; j < len(pages); j++ {
if pages[j] < pages[i] {
pages[i], pages[j] = pages[j], pages[i]
}
}
}
// Build output with ellipsis sentinels between non-consecutive pages.
out := make([]pageNum, 0, len(pages)*2)
for i, p := range pages {
if i > 0 && p > pages[i-1]+1 {
out = append(out, pageNum{0})
}
out = append(out, pageNum{p})
}
return out
}
const rankingPageSize = 20
// handleRanking serves the ranking page from the cached ranking.json file.
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
// Supports ?page=N for browsing through cached items (20 per page).
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
rankingItems, err := s.writer.ReadRankingItems()
if err != nil {
s.log.Error("failed to read cached ranking", "err", err)
}
cachedAt := ""
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
}
// Parse requested display page (1-indexed).
currentPage := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err2 := strconv.Atoi(p); err2 == nil && n > 0 {
currentPage = n
}
}
totalItems := len(rankingItems)
totalPages := 1
if totalItems > 0 {
totalPages = (totalItems + rankingPageSize - 1) / rankingPageSize
}
if currentPage > totalPages {
currentPage = totalPages
}
// Slice items for the current display page.
start := (currentPage - 1) * rankingPageSize
end := start + rankingPageSize
if end > totalItems {
end = totalItems
}
pageItems := rankingItems
if totalItems > 0 {
pageItems = rankingItems[start:end]
}
t := template.Must(template.New("ranking").Parse(rankingTmpl))
var buf bytes.Buffer
// Collect distinct genres and statuses across ALL items for facet filters.
genreSet := map[string]bool{}
statusSet := map[string]bool{}
for _, it := range rankingItems {
if it.Status != "" {
statusSet[it.Status] = true
}
for _, g := range it.Genres {
genreSet[g] = true
}
}
allGenres := sortedKeys(genreSet)
allStatuses := sortedKeys(statusSet)
// Encode full dataset + local slugs for client-side cross-page filtering.
localSlugs := s.writer.LocalSlugs()
type rankingJSONItem struct {
Rank int `json:"rank"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author,omitempty"`
Cover string `json:"cover,omitempty"`
Status string `json:"status,omitempty"`
Genres []string `json:"genres,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Local bool `json:"local"`
}
allItemsForJS := make([]rankingJSONItem, len(rankingItems))
for i, it := range rankingItems {
allItemsForJS[i] = rankingJSONItem{
Rank: it.Rank,
Slug: it.Slug,
Title: it.Title,
Author: it.Author,
Cover: it.Cover,
Status: it.Status,
Genres: it.Genres,
SourceURL: it.SourceURL,
Local: localSlugs[it.Slug],
}
}
allItemsJSON, _ := json.Marshal(allItemsForJS)
_ = t.Execute(&buf, struct {
Books interface{}
CachedAt string
FetchNums []pageNum
DisplayNums []pageNum
CurrentPage int
TotalPages int
TotalItems int
AllGenres []string
AllStatuses []string
AllItemsJSON template.JS
}{
Books: toRankingViewItems(pageItems, localSlugs),
CachedAt: cachedAt,
FetchNums: rankingPageNums(100, 0),
DisplayNums: rankingPageNums(totalPages, currentPage),
CurrentPage: currentPage,
TotalPages: totalPages,
TotalItems: totalItems,
AllGenres: allGenres,
AllStatuses: allStatuses,
AllItemsJSON: template.JS(allItemsJSON),
})
s.respond(w, r, "Rankings", buf.String())
}
// handleRankingRefresh starts an async scrape of novelfire.net/ranking and
// immediately returns a polling badge. The browser polls /ui/ranking/status
// until the job finishes, then follows an HX-Redirect back to /ranking.
//
// Accepts an optional form field "pages" (integer ≥ 1). 0 or absent means
// fetch all pages; otherwise at most that many pages are scraped.
func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
maxPages := 0
if p := strings.TrimSpace(r.FormValue("pages")); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
maxPages = n
}
}
s.mu.Lock()
if s.rankingRunning {
s.mu.Unlock()
renderFragment(w, rankingStatusHTML("running", "Ranking refresh already in progress…"))
return
}
s.rankingRunning = true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.rankingRunning = false
s.mu.Unlock()
}()
// Allow ~90 s per page; minimum 120 s for a single page.
timeout := 120 * time.Second
if maxPages > 1 {
timeout = time.Duration(maxPages) * 90 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
rankingCh, errCh := s.novel.ScrapeRanking(ctx, maxPages)
var rankingItems []writer.RankingItem
for rankingCh != nil || errCh != nil {
select {
case meta, ok := <-rankingCh:
if !ok {
rankingCh = nil
} else {
rankingItems = append(rankingItems, writer.RankingItem{
Rank: meta.Ranking,
Slug: meta.Slug,
Title: meta.Title,
Author: meta.Author,
Cover: meta.Cover,
Status: meta.Status,
Genres: meta.Genres,
SourceURL: meta.SourceURL,
})
}
case err, ok := <-errCh:
if !ok {
errCh = nil
} else if err != nil {
s.log.Error("ranking scrape error", "err", err)
}
}
}
if len(rankingItems) > 0 {
if err := s.writer.WriteRanking(rankingItems); err != nil {
s.log.Error("failed to save ranking", "err", err)
}
}
}()
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
}
// handleRankingStatus is the HTMX polling endpoint for ranking refresh jobs.
// While running it returns a self-replacing badge; when done it issues an
// HX-Redirect so the browser navigates to /ranking.
func (s *Server) handleRankingStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.rankingRunning
s.mu.Unlock()
if running {
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
return
}
// Job done — redirect the HTMX request to the ranking page.
w.Header().Set("HX-Redirect", "/ranking")
w.WriteHeader(http.StatusOK)
}
// rankingStatusHTML returns a self-replacing polling badge for the ranking
// refresh job. state is "running" or "done".
func rankingStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = ``
poll = `hx-get="/ui/ranking/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
default:
colour = "text-green-300 bg-green-950 border-green-800"
dot = ``
}
return fmt.Sprintf(
`
`
func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
currentPage := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
currentPage = n
}
}
chapters, err := s.writer.ListChapters(slug)
if err != nil {
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
return
}
total := len(chapters)
totalPages := (total + chapterPageSize - 1) / chapterPageSize
if totalPages < 1 {
totalPages = 1
}
start := (currentPage - 1) * chapterPageSize
if start >= total {
w.WriteHeader(http.StatusNoContent)
return
}
end := start + chapterPageSize
if end > total {
end = total
}
funcMap := template.FuncMap{
"pages": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i + 1
}
return out
},
"prev": func(n int) int { return n - 1 },
"next": func(n int) int { return n + 1 },
}
t := template.Must(template.New("chapterPage").Funcs(funcMap).Parse(chapterPageTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
Chapters interface{}
TotalPages int
CurrentPage int
}{
Slug: slug,
Chapters: chapters[start:end],
TotalPages: totalPages,
CurrentPage: currentPage,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
const chapterTmpl = `
{{.Title}}
{{.HTML}}
`
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
raw, err := s.writer.ReadChapter(slug, n)
if err != nil {
http.NotFound(w, r)
return
}
// Strip the first heading line so it isn't rendered as a duplicate
// inside the article (the template already renders an explicit
).
rawForHTML := stripFirstHeadingLine(raw)
var htmlBuf bytes.Buffer
if err := md.Convert([]byte(rawForHTML), &htmlBuf); err != nil {
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
return
}
chapters, _ := s.writer.ListChapters(slug)
prevN, nextN := adjacentChapters(chapters, n)
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
chapterTitle, chapterDate := writer.SplitChapterTitle(title)
// Load cover URL for Media Session artwork (best-effort; ignore errors).
var coverURL string
if meta, ok, err := s.writer.ReadMetadata(slug); err == nil && ok {
coverURL = meta.Cover
}
t := template.Must(template.New("chapter").Parse(chapterTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
HTML template.HTML
PrevN int
NextN int
ChapterN int
Title string
ChapterDate string
AllChapters interface{}
Voices []voiceInfo
DefaultVoice string
Cover string
}{
Slug: slug,
HTML: template.HTML(htmlBuf.String()),
PrevN: prevN,
NextN: nextN,
ChapterN: n,
Title: chapterTitle,
ChapterDate: chapterDate,
AllChapters: chapters,
Voices: parseVoices(s.voices()),
DefaultVoice: s.kokoroVoice,
Cover: coverURL,
})
s.respond(w, r, chapterTitle, buf.String())
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// sortedKeys returns the keys of a string-bool map in sorted order.
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// Simple insertion sort — sets are small (< 100 items).
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// stripMarkdown removes Markdown syntax and returns clean plain text.
func stripMarkdown(src string) string {
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}
// adjacentChapters returns the chapter numbers immediately before and after n
// in the sorted chapters list. 0 means "does not exist".
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
for i, ch := range chapters {
if ch.Number == n {
if i > 0 {
prev = chapters[i-1].Number
}
if i < len(chapters)-1 {
next = chapters[i+1].Number
}
return
}
}
return
}
// stripFirstHeadingLine removes the first non-empty line if it is a markdown
// heading (starts with one or more "#"). This prevents the heading from being
// rendered as a duplicate
inside the article when the template already
// renders an explicit title above the article.
func stripFirstHeadingLine(src string) string {
lines := strings.SplitN(src, "\n", -1)
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
// Remove this line and return the rest.
rest := strings.Join(append(lines[:i], lines[i+1:]...), "\n")
return strings.TrimLeft(rest, "\n")
}
// First non-empty line is not a heading — nothing to strip.
break
}
return src
}
// firstHeading returns the text of the first non-empty line, stripping a
// leading "# " markdown heading marker. Falls back to fallback.
func firstHeading(md, fallback string) string {
for _, line := range strings.SplitN(md, "\n", 20) {
line = strings.TrimSpace(line)
if line == "" {
continue
}
return strings.TrimPrefix(line, "# ")
}
return fallback
}
// ─── POST /ui/scrape/book — form submission ───────────────────────────────────
func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
bookURL := strings.TrimSpace(r.FormValue("url"))
if bookURL == "" {
renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
return
}
s.mu.Lock()
already := s.running
if !already {
s.running = true
}
s.mu.Unlock()
if already {
renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
return
}
cfg := s.oCfg
cfg.SingleBookURL = bookURL
go func() {
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
defer cancel()
o := orchestrator.New(cfg, s.novel, s.log)
if err := o.Run(ctx); err != nil {
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
}
}()
// Return a status badge that polls until the job finishes.
renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
}
// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────
func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.running
s.mu.Unlock()
if running {
// Keep polling every 3 s while the job is in progress.
renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
return
}
// Job finished — show a done badge and stop polling.
renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
}
// scrapeStatusHTML returns a self-contained status badge fragment.
// state is one of: "running" | "done" | "busy" | "error".
func scrapeStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = ``
poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
case "done":
colour = "text-green-300 bg-green-950 border-green-800"
dot = ``
case "busy":
colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
dot = ``
default: // error
colour = "text-red-300 bg-red-950 border-red-800"
dot = ``
}
return fmt.Sprintf(
`