mirror of
https://github.com/tiennm99/monkeyd-crawler.git
synced 2026-08-04 05:22:53 +00:00
On the default 90x160mm phone page, 12pt fits about 36 characters per line; 10pt fits about 43 across 26 lines, which reads denser without becoming small on a phone at 100% zoom. -font-size still overrides it.
245 lines
6.4 KiB
Go
245 lines
6.4 KiB
Go
// Package export turns a novel URL into a PDF file. It holds the sequence the
|
|
// CLI and any embedding program both need — resolve the chapter list, fetch the
|
|
// chapters, pick a font, render the PDF — so neither has to reassemble it.
|
|
package export
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tiennm99/monkeyd-crawler/monkeyd"
|
|
"github.com/tiennm99/monkeyd-crawler/pdfout"
|
|
)
|
|
|
|
// Defaults for every tunable field of Request. Exported so a caller's own
|
|
// flags or config can advertise the same values instead of restating them.
|
|
const (
|
|
DefaultPage = "phone"
|
|
DefaultFontSize = 10.0
|
|
DefaultLineSpacing = 1.55
|
|
DefaultMargin = 6.0
|
|
DefaultWorkers = 4
|
|
DefaultDelay = 400 * time.Millisecond
|
|
DefaultRetries = 3
|
|
DefaultCacheDir = ".cache"
|
|
)
|
|
|
|
// Request describes one export. Only NovelURL is required; every zero-valued
|
|
// tunable falls back to its Default above, so a caller that only has a URL can
|
|
// leave the rest alone.
|
|
type Request struct {
|
|
NovelURL string
|
|
|
|
// OutPath is the exact PDF path to write. When empty the file is named
|
|
// after the novel title and placed in OutDir.
|
|
OutPath string
|
|
OutDir string
|
|
|
|
Page string // preset key: phone, a5, a4
|
|
FontFile string // path to a .ttf; empty means discover a system font
|
|
FontSize float64 // points
|
|
LineSpacing float64 // multiple of font size
|
|
Margin float64 // millimetres
|
|
|
|
Workers int
|
|
Retries int
|
|
|
|
// Delay is the minimum spacing between requests. Set NoDelay to remove
|
|
// the spacing rather than setting this to 0, which is read as "unset"
|
|
// and gets the default back.
|
|
Delay time.Duration
|
|
NoDelay bool
|
|
|
|
// Limit caps the export to the first N chapters. 0 means every chapter.
|
|
Limit int
|
|
|
|
// CacheDir stores raw pages so a re-export costs no requests. Set
|
|
// NoCache to opt out rather than clearing this field, which would be
|
|
// read as "unset" and get the default back.
|
|
CacheDir string
|
|
NoCache bool
|
|
|
|
// Log receives progress messages. Optional.
|
|
Log func(format string, args ...any)
|
|
}
|
|
|
|
// Result reports what was produced.
|
|
type Result struct {
|
|
Path string
|
|
Title string
|
|
SourceURL string
|
|
Chapters int
|
|
Words int
|
|
FontName string // font path, or pdfout.BundledFontName
|
|
Page pdfout.PageSize
|
|
}
|
|
|
|
// Summary renders a one-line description of the exported book.
|
|
func (r *Result) Summary() string {
|
|
return fmt.Sprintf("%s — %d chapters, %d words", r.Title, r.Chapters, r.Words)
|
|
}
|
|
|
|
// Export fetches the novel at req.NovelURL and writes it as a PDF, returning
|
|
// where it landed. The context bounds the whole crawl; cancelling it abandons
|
|
// the run without leaving a partial PDF behind.
|
|
func Export(ctx context.Context, req Request) (*Result, error) {
|
|
req.applyDefaults()
|
|
if err := req.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
crawler := &monkeyd.Crawler{
|
|
Client: monkeyd.NewClient(req.Delay, req.Retries),
|
|
CacheDir: req.CacheDir,
|
|
Workers: req.Workers,
|
|
Log: req.Log,
|
|
}
|
|
|
|
novel, err := crawler.Novel(ctx, req.NovelURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.Limit > 0 && req.Limit < len(novel.Chapters) {
|
|
req.logf("limiting to first %d of %d chapters", req.Limit, len(novel.Chapters))
|
|
novel.Chapters = novel.Chapters[:req.Limit]
|
|
}
|
|
|
|
chapters, err := crawler.Chapters(ctx, novel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Falls back to the bundled font, so a host with no fonts installed still
|
|
// renders; only an explicitly requested font can fail here.
|
|
font, err := pdfout.LoadFont(req.FontFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outPath := req.OutPath
|
|
if outPath == "" {
|
|
outPath = filepath.Join(req.OutDir, SafeFileName(novel.Title, novel.Slug)+".pdf")
|
|
}
|
|
|
|
page := pdfout.Presets[req.Page]
|
|
opts := pdfout.Options{
|
|
Page: page,
|
|
Margin: req.Margin,
|
|
Font: font,
|
|
FontSize: req.FontSize,
|
|
LineSpacing: req.LineSpacing,
|
|
Title: novel.Title,
|
|
SourceURL: novel.URL,
|
|
}
|
|
if err := pdfout.Write(outPath, opts, toPDFChapters(chapters)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Result{
|
|
Path: outPath,
|
|
Title: novel.Title,
|
|
SourceURL: novel.URL,
|
|
Chapters: len(chapters),
|
|
Words: monkeyd.TotalWords(chapters),
|
|
FontName: font.Name,
|
|
Page: page,
|
|
}, nil
|
|
}
|
|
|
|
func (r *Request) applyDefaults() {
|
|
if r.Page == "" {
|
|
r.Page = DefaultPage
|
|
}
|
|
if r.FontSize == 0 {
|
|
r.FontSize = DefaultFontSize
|
|
}
|
|
if r.LineSpacing == 0 {
|
|
r.LineSpacing = DefaultLineSpacing
|
|
}
|
|
if r.Margin == 0 {
|
|
r.Margin = DefaultMargin
|
|
}
|
|
if r.Workers == 0 {
|
|
r.Workers = DefaultWorkers
|
|
}
|
|
switch {
|
|
case r.NoDelay:
|
|
r.Delay = 0
|
|
case r.Delay == 0:
|
|
r.Delay = DefaultDelay
|
|
}
|
|
if r.Retries == 0 {
|
|
r.Retries = DefaultRetries
|
|
}
|
|
switch {
|
|
case r.NoCache:
|
|
r.CacheDir = ""
|
|
case r.CacheDir == "":
|
|
r.CacheDir = DefaultCacheDir
|
|
}
|
|
}
|
|
|
|
// validate rejects a Request before any request is made, so a typo costs no
|
|
// fetches. Call applyDefaults first: it checks the effective values.
|
|
func (r *Request) validate() error {
|
|
if r.NovelURL == "" {
|
|
return fmt.Errorf("novel url is required")
|
|
}
|
|
parsed, err := url.Parse(r.NovelURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid novel url: %w", err)
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return fmt.Errorf("invalid novel url: want an http(s) URL, got %q", r.NovelURL)
|
|
}
|
|
if _, ok := pdfout.Presets[r.Page]; !ok {
|
|
return fmt.Errorf("unknown page %q: want one of %s",
|
|
r.Page, strings.Join(pdfout.PresetNames(), ", "))
|
|
}
|
|
if r.FontSize <= 0 {
|
|
return fmt.Errorf("font size must be positive")
|
|
}
|
|
if r.LineSpacing <= 0 {
|
|
return fmt.Errorf("line spacing must be positive")
|
|
}
|
|
if r.Margin < 0 {
|
|
return fmt.Errorf("margin cannot be negative")
|
|
}
|
|
if r.Workers < 1 {
|
|
return fmt.Errorf("workers must be at least 1")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Request) logf(format string, args ...any) {
|
|
if r.Log != nil {
|
|
r.Log(format, args...)
|
|
}
|
|
}
|
|
|
|
func toPDFChapters(chapters []*monkeyd.Chapter) []pdfout.Chapter {
|
|
out := make([]pdfout.Chapter, 0, len(chapters))
|
|
for _, ch := range chapters {
|
|
out = append(out, pdfout.Chapter{Heading: ch.Heading(), Paragraphs: ch.Paragraphs})
|
|
}
|
|
return out
|
|
}
|
|
|
|
var unsafeNameChars = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
|
|
|
// SafeFileName builds a file name from the novel title, falling back to the
|
|
// slug when the title has no usable characters. The result has no extension.
|
|
func SafeFileName(title, fallback string) string {
|
|
name := strings.Trim(unsafeNameChars.ReplaceAllString(title, "-"), "-")
|
|
if name == "" {
|
|
return fallback
|
|
}
|
|
return name
|
|
}
|