mirror of
https://github.com/tiennm99/hako-crawler.git
synced 2026-08-30 16:20:25 +00:00
Add a pdfout package that renders the fetched chapters to a single PDF and an export package holding the whole pipeline, so the CLI and an embedding program share one code path and reject the same inputs. The default page is 90x160mm rather than A4 with large type: a viewer scales a whole page to fit the screen, so on a phone the page shape decides legibility and a wide page just gets shrunk. Embed DejaVu Sans in the binary. Vietnamese needs the Latin Extended Additional block, and a headless host often has no fonts installed at all, so rendering must not depend on finding one. An explicitly named font is never silently substituted. Font data is passed as bytes because fpdf resolves a font path against its own font directory. Keep the original per-chapter text output available behind -txt.
278 lines
7.3 KiB
Go
278 lines
7.3 KiB
Go
// Package export turns a novel URL into a PDF file. It holds the sequence the
|
|
// CLI and any embedding program both need — read the chapter list, fetch the
|
|
// chapters, pick a font, render — so neither has to reassemble it.
|
|
package export
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tiennm99/hako-crawler/hako"
|
|
"github.com/tiennm99/hako-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 = 500 * 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 and placed in OutDir.
|
|
OutPath string
|
|
OutDir string
|
|
|
|
// TextDir, when set, also writes one plain-text file per chapter — the
|
|
// output this project produced before it rendered PDFs.
|
|
TextDir 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
|
|
TextDir 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 := &hako.Crawler{
|
|
Client: hako.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
|
|
}
|
|
|
|
if req.TextDir != "" {
|
|
if err := writeText(req.TextDir, chapters); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &Result{
|
|
Path: outPath,
|
|
TextDir: req.TextDir,
|
|
Title: novel.Title,
|
|
SourceURL: novel.URL,
|
|
Chapters: len(chapters),
|
|
Words: hako.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 []*hako.Chapter) []pdfout.Chapter {
|
|
out := make([]pdfout.Chapter, 0, len(chapters))
|
|
for _, ch := range chapters {
|
|
out = append(out, pdfout.Chapter{
|
|
Heading: ch.Heading(),
|
|
Volume: ch.Volume,
|
|
Paragraphs: ch.Paragraphs,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeText saves one file per chapter, numbered so a directory listing stays
|
|
// in reading order rather than in the site's own chapter naming.
|
|
func writeText(dir string, chapters []*hako.Chapter) error {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create text dir: %w", err)
|
|
}
|
|
for i, ch := range chapters {
|
|
name := fmt.Sprintf("%04d-%s.txt", i+1, SafeFileName(ch.Heading(), "chuong"))
|
|
body := ch.Heading() + "\n\n" + strings.Join(ch.Paragraphs, "\n\n") + "\n"
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
|
return fmt.Errorf("write %s: %w", name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var unsafeNameChars = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
|
|
|
// SafeFileName builds a file name from a title, falling back 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
|
|
}
|