pipeline.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. // Package pipeline turns a web page into a book: fetch, extract, clean,
  2. // process images and build the EPUB.
  3. package pipeline
  4. import (
  5. "bytes"
  6. "context"
  7. "errors"
  8. "fmt"
  9. "image"
  10. "io"
  11. "net/url"
  12. "strings"
  13. "golang.org/x/net/html"
  14. "git.monoexp.com/bajdev/rekopub/internal/assets"
  15. "git.monoexp.com/bajdev/rekopub/internal/build"
  16. "git.monoexp.com/bajdev/rekopub/internal/clean"
  17. "git.monoexp.com/bajdev/rekopub/internal/cover"
  18. "git.monoexp.com/bajdev/rekopub/internal/extract"
  19. "git.monoexp.com/bajdev/rekopub/internal/fetch"
  20. "git.monoexp.com/bajdev/rekopub/internal/kepub"
  21. )
  22. // Errors that Run wraps, so callers can choose an exit code.
  23. var (
  24. ErrFetch = errors.New("couldn't fetch the page")
  25. ErrNoContent = extract.ErrNoContent
  26. )
  27. // Book is a finished book, ready to be saved.
  28. type Book struct {
  29. // Title is the article title, used to name the file for folder targets.
  30. Title string
  31. // Content writes the complete EPUB.
  32. Content io.WriterTo
  33. }
  34. // Options adjust a single conversion.
  35. type Options struct {
  36. // BeforeImages, if set, is called with the article title once it is
  37. // known and before any image is downloaded. An error stops the
  38. // conversion and is returned unchanged. The CLI uses it to stop early
  39. // when the output file already exists.
  40. BeforeImages func(title string) error
  41. // Warn, if set, receives problems that don't stop the conversion, such
  42. // as an image that couldn't be downloaded.
  43. Warn func(msg string)
  44. // KEPUB converts the finished book to a Kobo KEPUB.
  45. KEPUB bool
  46. }
  47. // Run converts the page at u into a book using the default settings.
  48. // Images and redirects may lead to local and private addresses only when
  49. // u itself is local.
  50. func Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
  51. return New(fetch.IsLocal(ctx, u.Hostname())).Run(ctx, u, opts)
  52. }
  53. // Pipeline holds the settings for conversions.
  54. type Pipeline struct {
  55. Fetcher *fetch.Fetcher
  56. Images *assets.Processor
  57. }
  58. // New returns a Pipeline with the default settings. Pages and images share
  59. // one HTTP client, which connects to local and private addresses only if
  60. // allowLocal is set.
  61. func New(allowLocal bool) Pipeline {
  62. f := fetch.New(allowLocal)
  63. return Pipeline{Fetcher: f, Images: assets.New(f.Client, f.UserAgent)}
  64. }
  65. // Run converts the page at u into a book.
  66. func (p Pipeline) Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
  67. page, err := p.Fetcher.Fetch(ctx, u)
  68. if err != nil {
  69. return nil, fmt.Errorf("%w: %w", ErrFetch, err)
  70. }
  71. doc, err := html.Parse(strings.NewReader(page.HTML))
  72. if err != nil {
  73. return nil, fmt.Errorf("parsing the page: %w", err)
  74. }
  75. clean.Prepare(doc, page.URL)
  76. article, err := extract.Extract(doc, page.URL)
  77. if err != nil {
  78. return nil, err
  79. }
  80. if opts.BeforeImages != nil {
  81. if err := opts.BeforeImages(article.Title); err != nil {
  82. return nil, err
  83. }
  84. }
  85. body, err := clean.Content(article.Content, page.URL)
  86. if err != nil {
  87. return nil, fmt.Errorf("cleaning the article: %w", err)
  88. }
  89. images := *p.Images
  90. images.Warn = opts.Warn
  91. imgs, err := images.Process(ctx, body, page.URL)
  92. if err != nil {
  93. return nil, fmt.Errorf("processing images: %w", err)
  94. }
  95. photo, err := coverPhoto(ctx, &images, article, page.URL)
  96. if err != nil {
  97. return nil, fmt.Errorf("processing images: %w", err)
  98. }
  99. coverJPEG, err := cover.Render(cover.Details{
  100. Title: article.Title,
  101. SiteName: article.SiteName,
  102. Date: build.DisplayDate(article.Published),
  103. }, photo, images.Profile)
  104. if err != nil {
  105. return nil, fmt.Errorf("drawing the cover: %w", err)
  106. }
  107. data, err := build.Build(article, body, imgs, coverJPEG)
  108. if err != nil {
  109. return nil, fmt.Errorf("building the book: %w", err)
  110. }
  111. if opts.KEPUB {
  112. if data, err = kepub.Convert(ctx, data); err != nil {
  113. return nil, err
  114. }
  115. }
  116. return &Book{Title: article.Title, Content: bytes.NewReader(data)}, nil
  117. }
  118. // minCoverPhoto is the smallest side, in pixels, of a preview image worth
  119. // putting on the cover. Smaller ones are usually site logos, and a plain
  120. // typographic cover looks better than a blown-up logo.
  121. const minCoverPhoto = 300
  122. // coverPhoto downloads the page's preview image for the cover. It returns
  123. // nil, and the cover is typographic, when there is no usable image; only
  124. // cancellation is an error.
  125. func coverPhoto(ctx context.Context, images *assets.Processor, a *extract.Article, page *url.URL) (image.Image, error) {
  126. if a.ImageURL == "" {
  127. return nil, nil
  128. }
  129. img, err := images.Decode(ctx, a.ImageURL, page)
  130. if ctxErr := ctx.Err(); ctxErr != nil {
  131. return nil, ctxErr
  132. }
  133. if err != nil {
  134. if images.Warn != nil {
  135. images.Warn(fmt.Sprintf("cover image %s skipped, using a plain cover: %v", a.ImageURL, err))
  136. }
  137. return nil, nil
  138. }
  139. if b := img.Bounds(); min(b.Dx(), b.Dy()) < minCoverPhoto {
  140. return nil, nil
  141. }
  142. return img, nil
  143. }