pipeline.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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. // NoLocal refuses local and private addresses even when u itself is
  47. // one. A server that converts pages for other people sets it, so a
  48. // visitor can't make it connect to services on the server's network.
  49. NoLocal bool
  50. }
  51. // Run converts the page at u into a book using the default settings.
  52. // Images and redirects may lead to local and private addresses only when
  53. // u itself is local and opts.NoLocal isn't set.
  54. func Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
  55. return New(!opts.NoLocal && fetch.IsLocal(ctx, u.Hostname())).Run(ctx, u, opts)
  56. }
  57. // Pipeline holds the settings for conversions.
  58. type Pipeline struct {
  59. Fetcher *fetch.Fetcher
  60. Images *assets.Processor
  61. }
  62. // New returns a Pipeline with the default settings. Pages and images share
  63. // one HTTP client, which connects to local and private addresses only if
  64. // allowLocal is set.
  65. func New(allowLocal bool) Pipeline {
  66. f := fetch.New(allowLocal)
  67. return Pipeline{Fetcher: f, Images: assets.New(f.Client, f.UserAgent)}
  68. }
  69. // Run converts the page at u into a book.
  70. func (p Pipeline) Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
  71. page, err := p.Fetcher.Fetch(ctx, u)
  72. if err != nil {
  73. return nil, fmt.Errorf("%w: %w", ErrFetch, err)
  74. }
  75. doc, err := html.Parse(strings.NewReader(page.HTML))
  76. if err != nil {
  77. return nil, fmt.Errorf("parsing the page: %w", err)
  78. }
  79. clean.Prepare(doc, page.URL)
  80. article, err := extract.Extract(doc, page.URL)
  81. if err != nil {
  82. return nil, err
  83. }
  84. if opts.BeforeImages != nil {
  85. if err := opts.BeforeImages(article.Title); err != nil {
  86. return nil, err
  87. }
  88. }
  89. body, err := clean.Content(article.Content, page.URL)
  90. if err != nil {
  91. return nil, fmt.Errorf("cleaning the article: %w", err)
  92. }
  93. images := *p.Images
  94. images.Warn = opts.Warn
  95. imgs, err := images.Process(ctx, body, page.URL)
  96. if err != nil {
  97. return nil, fmt.Errorf("processing images: %w", err)
  98. }
  99. photo, err := coverPhoto(ctx, &images, article, page.URL)
  100. if err != nil {
  101. return nil, fmt.Errorf("processing images: %w", err)
  102. }
  103. coverJPEG, err := cover.Render(cover.Details{
  104. Title: article.Title,
  105. SiteName: article.SiteName,
  106. Date: build.DisplayDate(article.Published),
  107. }, photo, images.Profile)
  108. if err != nil {
  109. return nil, fmt.Errorf("drawing the cover: %w", err)
  110. }
  111. data, err := build.Build(article, body, imgs, coverJPEG)
  112. if err != nil {
  113. return nil, fmt.Errorf("building the book: %w", err)
  114. }
  115. if opts.KEPUB {
  116. if data, err = kepub.Convert(ctx, data); err != nil {
  117. return nil, err
  118. }
  119. }
  120. return &Book{Title: article.Title, Content: bytes.NewReader(data)}, nil
  121. }
  122. // minCoverPhoto is the smallest side, in pixels, of a preview image worth
  123. // putting on the cover. Smaller ones are usually site logos, and a plain
  124. // typographic cover looks better than a blown-up logo.
  125. const minCoverPhoto = 300
  126. // coverPhoto downloads the page's preview image for the cover. It returns
  127. // nil, and the cover is typographic, when there is no usable image; only
  128. // cancellation is an error.
  129. func coverPhoto(ctx context.Context, images *assets.Processor, a *extract.Article, page *url.URL) (image.Image, error) {
  130. if a.ImageURL == "" {
  131. return nil, nil
  132. }
  133. img, err := images.Decode(ctx, a.ImageURL, page)
  134. if ctxErr := ctx.Err(); ctxErr != nil {
  135. return nil, ctxErr
  136. }
  137. if err != nil {
  138. if images.Warn != nil {
  139. images.Warn(fmt.Sprintf("cover image %s skipped, using a plain cover: %v", a.ImageURL, err))
  140. }
  141. return nil, nil
  142. }
  143. if b := img.Bounds(); min(b.Dx(), b.Dy()) < minCoverPhoto {
  144. return nil, nil
  145. }
  146. return img, nil
  147. }