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