1
0

pipeline.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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. // ErrLocal reports a local or private -URL without Options.Local.
  27. ErrLocal = errors.New("local or private address")
  28. )
  29. // Book is a finished book, ready to be saved.
  30. type Book struct {
  31. // Title is the article title, used to name the file for folder targets.
  32. Title string
  33. // Content writes the complete EPUB.
  34. Content io.WriterTo
  35. }
  36. // Options adjust a single conversion.
  37. type Options struct {
  38. // BeforeImages, if set, is called with the article title once it is
  39. // known and before any image is downloaded. An error stops the
  40. // conversion and is returned unchanged. The CLI uses it to stop early
  41. // when the output file already exists.
  42. BeforeImages func(title string) error
  43. // Warn, if set, receives problems that don't stop the conversion, such
  44. // as an image that couldn't be downloaded.
  45. Warn func(msg string)
  46. // KEPUB converts the finished book to a Kobo KEPUB.
  47. KEPUB bool
  48. // Local allows u to be a local or private address, such as a wiki on
  49. // the reader's network; its local images and redirects are allowed too.
  50. // Without it, local and private addresses are always refused, so a
  51. // page can never make rekopub connect to the computer it runs on or
  52. // its network.
  53. Local bool
  54. }
  55. // Run converts the page at u into a book using the default settings.
  56. // Local and private addresses are allowed only with opts.Local and when u
  57. // itself is local; a local u without opts.Local returns ErrLocal.
  58. func Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
  59. local := fetch.IsLocal(ctx, u.Hostname())
  60. if local && !opts.Local {
  61. return nil, fmt.Errorf("%w: %s", ErrLocal, u.Hostname())
  62. }
  63. return New(local).Run(ctx, u, opts)
  64. }
  65. // Pipeline holds the settings for conversions.
  66. type Pipeline struct {
  67. Fetcher *fetch.Fetcher
  68. Images *assets.Processor
  69. }
  70. // New returns a Pipeline with the default settings. Pages and images share
  71. // one HTTP client, which connects to local and private addresses only if
  72. // allowLocal is set.
  73. func New(allowLocal bool) Pipeline {
  74. f := fetch.New(allowLocal)
  75. return Pipeline{Fetcher: f, Images: assets.New(f.Client, f.UserAgent)}
  76. }
  77. // Run converts the page at u into a book.
  78. func (p Pipeline) Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
  79. page, err := p.Fetcher.Fetch(ctx, u)
  80. if err != nil {
  81. return nil, fmt.Errorf("%w: %w", ErrFetch, err)
  82. }
  83. doc, err := html.Parse(strings.NewReader(page.HTML))
  84. if err != nil {
  85. return nil, fmt.Errorf("parsing the page: %w", err)
  86. }
  87. clean.Prepare(doc, page.URL)
  88. article, err := extract.Extract(doc, page.URL)
  89. if err != nil {
  90. return nil, err
  91. }
  92. if opts.BeforeImages != nil {
  93. if err := opts.BeforeImages(article.Title); err != nil {
  94. return nil, err
  95. }
  96. }
  97. body, err := clean.Content(article.Content, page.URL)
  98. if err != nil {
  99. return nil, fmt.Errorf("cleaning the article: %w", err)
  100. }
  101. images := *p.Images
  102. images.Warn = opts.Warn
  103. imgs, err := images.Process(ctx, body, page.URL)
  104. if err != nil {
  105. return nil, fmt.Errorf("processing images: %w", err)
  106. }
  107. photo, err := coverPhoto(ctx, &images, article, page.URL)
  108. if err != nil {
  109. return nil, fmt.Errorf("processing images: %w", err)
  110. }
  111. coverJPEG, err := cover.Render(cover.Details{
  112. Title: article.Title,
  113. SiteName: article.SiteName,
  114. Date: build.DisplayDate(article.Published),
  115. }, photo, images.Profile)
  116. if err != nil {
  117. return nil, fmt.Errorf("drawing the cover: %w", err)
  118. }
  119. data, err := build.Build(article, body, imgs, coverJPEG)
  120. if err != nil {
  121. return nil, fmt.Errorf("building the book: %w", err)
  122. }
  123. if opts.KEPUB {
  124. if data, err = kepub.Convert(ctx, data); err != nil {
  125. return nil, err
  126. }
  127. }
  128. return &Book{Title: article.Title, Content: bytes.NewReader(data)}, nil
  129. }
  130. // minCoverPhoto is the smallest side, in pixels, of a preview image worth
  131. // putting on the cover. Smaller ones are usually site logos, and a plain
  132. // typographic cover looks better than a blown-up logo.
  133. const minCoverPhoto = 300
  134. // coverPhoto downloads the page's preview image for the cover. It returns
  135. // nil, and the cover is typographic, when there is no usable image; only
  136. // cancellation is an error.
  137. func coverPhoto(ctx context.Context, images *assets.Processor, a *extract.Article, page *url.URL) (image.Image, error) {
  138. if a.ImageURL == "" {
  139. return nil, nil
  140. }
  141. img, err := images.Decode(ctx, a.ImageURL, page)
  142. if ctxErr := ctx.Err(); ctxErr != nil {
  143. return nil, ctxErr
  144. }
  145. if err != nil {
  146. if images.Warn != nil {
  147. images.Warn(fmt.Sprintf("cover image %s skipped, using a plain cover: %v", a.ImageURL, err))
  148. }
  149. return nil, nil
  150. }
  151. if b := img.Bounds(); min(b.Dx(), b.Dy()) < minCoverPhoto {
  152. return nil, nil
  153. }
  154. return img, nil
  155. }