feat: add exponential backoff, some UI elements to see the resut of a scrape

This commit is contained in:
Admin
2026-02-26 18:51:32 +05:00
parent d68ea71239
commit e6e6f7dc4d
12 changed files with 462 additions and 153 deletions

View File

@@ -19,6 +19,8 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/libnovel/scraper/internal/scraper"
@@ -113,7 +115,101 @@ func (w *Writer) WriteChapter(slug string, chapter scraper.Chapter) error {
return nil
}
// ─── Path helpers ─────────────────────────────────────────────────────────────
// ─── Catalogue helpers ────────────────────────────────────────────────────────
// ListBooks returns metadata for every book that has a metadata.yaml under root.
// Books with unreadable metadata files are silently skipped.
func (w *Writer) ListBooks() ([]scraper.BookMeta, error) {
entries, err := os.ReadDir(w.root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("writer: list books: %w", err)
}
var books []scraper.BookMeta
for _, e := range entries {
if !e.IsDir() {
continue
}
meta, ok, _ := w.ReadMetadata(e.Name())
if !ok {
continue
}
books = append(books, meta)
}
sort.Slice(books, func(i, j int) bool {
return books[i].Title < books[j].Title
})
return books, nil
}
// ChapterInfo is a lightweight chapter descriptor derived from on-disk files.
type ChapterInfo struct {
Number int
Title string // first line of the markdown file (without the leading "# ")
}
// ListChapters returns all chapters on disk for slug, sorted by number.
func (w *Writer) ListChapters(slug string) ([]ChapterInfo, error) {
bookDir := w.bookDir(slug)
var chapters []ChapterInfo
// Walk vol-*/range-*/ directories.
volDirs, err := filepath.Glob(filepath.Join(bookDir, "vol-*"))
if err != nil {
return nil, fmt.Errorf("writer: list chapters glob: %w", err)
}
for _, vd := range volDirs {
rangeDirs, _ := filepath.Glob(filepath.Join(vd, "*-*"))
for _, rd := range rangeDirs {
files, _ := filepath.Glob(filepath.Join(rd, "chapter-*.md"))
for _, f := range files {
base := filepath.Base(f) // chapter-N.md
numStr := strings.TrimSuffix(strings.TrimPrefix(base, "chapter-"), ".md")
n, err := strconv.Atoi(numStr)
if err != nil {
continue
}
title := chapterTitle(f, n)
chapters = append(chapters, ChapterInfo{Number: n, Title: title})
}
}
}
sort.Slice(chapters, func(i, j int) bool {
return chapters[i].Number < chapters[j].Number
})
return chapters, nil
}
// chapterTitle reads the first non-empty line of a markdown file and strips
// the leading "# " heading marker. Falls back to "Chapter N".
func chapterTitle(path string, n int) string {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("Chapter %d", n)
}
for _, line := range strings.SplitN(string(data), "\n", 10) {
line = strings.TrimSpace(line)
if line == "" {
continue
}
return strings.TrimPrefix(line, "# ")
}
return fmt.Sprintf("Chapter %d", n)
}
// ReadChapter returns the raw markdown content for chapter number n of slug.
func (w *Writer) ReadChapter(slug string, n int) (string, error) {
// Reconstruct path using the same bucketing formula as chapterPath.
ref := scraper.ChapterRef{Number: n, Volume: 0}
path := w.chapterPath(slug, ref)
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("writer: read chapter %d: %w", n, err)
}
return string(data), nil
}
// bookDir returns the root directory for a book slug.
func (w *Writer) bookDir(slug string) string {