141 lines
4.5 KiB
Go
141 lines
4.5 KiB
Go
// Package writer handles persistence of scraped chapters and metadata.
|
|
//
|
|
// Directory layout:
|
|
//
|
|
// static/books/
|
|
// ├── {book-slug}/
|
|
// │ ├── metadata.yaml
|
|
// │ ├── vol-0/ (no volume grouping)
|
|
// │ │ ├── 1-50/
|
|
// │ │ │ ├── chapter-1.md
|
|
// │ │ │ └── …
|
|
// │ │ └── 51-100/
|
|
// │ │ └── …
|
|
// │ └── vol-1/
|
|
// │ └── …
|
|
package writer
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/libnovel/scraper/internal/scraper"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const chaptersPerFolder = 50
|
|
|
|
// Writer persists scraped content under a configurable root directory.
|
|
type Writer struct {
|
|
root string // e.g. "./static/books"
|
|
}
|
|
|
|
// New creates a Writer that stores files under root.
|
|
func New(root string) *Writer {
|
|
return &Writer{root: root}
|
|
}
|
|
|
|
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
|
|
|
// WriteMetadata serialises meta to static/books/{slug}/metadata.yaml.
|
|
// It creates the directory if it does not exist and overwrites any existing file.
|
|
func (w *Writer) WriteMetadata(meta scraper.BookMeta) error {
|
|
dir := w.bookDir(meta.Slug)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("writer: mkdir %s: %w", dir, err)
|
|
}
|
|
|
|
path := filepath.Join(dir, "metadata.yaml")
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return fmt.Errorf("writer: create metadata %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
enc := yaml.NewEncoder(f)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(meta); err != nil {
|
|
return fmt.Errorf("writer: encode metadata: %w", err)
|
|
}
|
|
return enc.Close()
|
|
}
|
|
|
|
// ReadMetadata reads the metadata.yaml for slug if it exists.
|
|
// Returns (zero-value, false, nil) when the file does not exist.
|
|
func (w *Writer) ReadMetadata(slug string) (scraper.BookMeta, bool, error) {
|
|
path := filepath.Join(w.bookDir(slug), "metadata.yaml")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return scraper.BookMeta{}, false, nil
|
|
}
|
|
return scraper.BookMeta{}, false, fmt.Errorf("writer: read metadata %s: %w", path, err)
|
|
}
|
|
|
|
var meta scraper.BookMeta
|
|
if err := yaml.Unmarshal(data, &meta); err != nil {
|
|
return scraper.BookMeta{}, true, fmt.Errorf("writer: unmarshal metadata %s: %w", path, err)
|
|
}
|
|
return meta, true, nil
|
|
}
|
|
|
|
// ─── Chapters ─────────────────────────────────────────────────────────────────
|
|
|
|
// ChapterExists returns true if the markdown file for ref already exists on disk.
|
|
func (w *Writer) ChapterExists(slug string, ref scraper.ChapterRef) bool {
|
|
_, err := os.Stat(w.chapterPath(slug, ref))
|
|
return err == nil
|
|
}
|
|
|
|
// WriteChapter writes chapter.Text to the appropriate markdown file.
|
|
// The parent directories are created on demand.
|
|
func (w *Writer) WriteChapter(slug string, chapter scraper.Chapter) error {
|
|
path := w.chapterPath(slug, chapter.Ref)
|
|
dir := filepath.Dir(path)
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("writer: mkdir %s: %w", dir, err)
|
|
}
|
|
|
|
// Build the markdown document.
|
|
var sb strings.Builder
|
|
sb.WriteString("# ")
|
|
sb.WriteString(chapter.Ref.Title)
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString(chapter.Text)
|
|
sb.WriteString("\n")
|
|
|
|
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
|
return fmt.Errorf("writer: write chapter %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
|
|
// bookDir returns the root directory for a book slug.
|
|
func (w *Writer) bookDir(slug string) string {
|
|
return filepath.Join(w.root, slug)
|
|
}
|
|
|
|
// chapterPath computes the full file path for a chapter.
|
|
//
|
|
// vol-{volume}/{folderRange}/chapter-{number}.md
|
|
//
|
|
// Example: vol-0/1-50/chapter-1.md, vol-0/51-100/chapter-51.md
|
|
func (w *Writer) chapterPath(slug string, ref scraper.ChapterRef) string {
|
|
vol := ref.Volume // 0 == no volume grouping
|
|
volDir := fmt.Sprintf("vol-%d", vol)
|
|
|
|
// Folder group: chapters 1-50 → "1-50", 51-100 → "51-100", …
|
|
lo := ((ref.Number-1)/chaptersPerFolder)*chaptersPerFolder + 1
|
|
hi := lo + chaptersPerFolder - 1
|
|
rangeDir := fmt.Sprintf("%d-%d", lo, hi)
|
|
|
|
filename := fmt.Sprintf("chapter-%d.md", ref.Number)
|
|
|
|
return filepath.Join(w.bookDir(slug), volDir, rangeDir, filename)
|
|
}
|