main.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. // Command rekopub converts a web article into an EPUB for e-ink readers.
  2. //
  3. // rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
  4. package main
  5. import (
  6. "context"
  7. "errors"
  8. "flag"
  9. "fmt"
  10. "io"
  11. "net/url"
  12. "os"
  13. "os/signal"
  14. "git.monoexp.com/bajdev/rekopub/internal/pipeline"
  15. "git.monoexp.com/bajdev/rekopub/internal/save"
  16. )
  17. // Exit codes, as documented in the README.
  18. const (
  19. exitOK = 0
  20. exitFetch = 1 // page couldn't be fetched
  21. exitUsage = 2 // invalid arguments
  22. exitNoContent = 3 // no readable content found
  23. exitBuild = 4 // book couldn't be built or written
  24. exitExists = 5 // output exists and -FORCE wasn't given
  25. // exitInterrupted is the conventional code for a program stopped
  26. // with Ctrl-C (128 + SIGINT).
  27. exitInterrupted = 130
  28. )
  29. const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
  30. Convert a web article into an EPUB for e-ink readers.
  31. -URL string
  32. Web page to convert (required). Must be http:// or https://.
  33. -SAVE string
  34. Where to write the book (required). Missing folders are created.
  35. - Ends in "/" → a folder: <folder>/<article-title>.epub
  36. - An existing folder → same as above
  37. - Ends in .epub → written to exactly that file
  38. - Anything else → ".epub" is appended
  39. With -KEPUB the extension is ".kepub.epub" instead.
  40. -FORCE
  41. Overwrite the output file if it already exists.
  42. -KEPUB
  43. Write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB.
  44. -NOLOCAL
  45. Never connect to local or private addresses, even when -URL is
  46. one. For servers that run rekopub on behalf of other people.
  47. -VERSION
  48. Print the version and exit.
  49. `
  50. // convertFunc turns a URL into a book. It is pipeline.Run in production and
  51. // a fake in tests.
  52. type convertFunc func(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error)
  53. func main() {
  54. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
  55. code := run(ctx, os.Args[1:], os.Stdout, os.Stderr, pipeline.Run)
  56. stop()
  57. os.Exit(code)
  58. }
  59. func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert convertFunc) int {
  60. fs := flag.NewFlagSet("rekopub", flag.ContinueOnError)
  61. fs.SetOutput(stderr)
  62. fs.Usage = func() { fmt.Fprint(stderr, usage) }
  63. urlArg := fs.String("URL", "", "web page to convert (required)")
  64. saveArg := fs.String("SAVE", "", "file or folder to write the book to (required)")
  65. force := fs.Bool("FORCE", false, "overwrite the output file if it already exists")
  66. kepub := fs.Bool("KEPUB", false, "write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB")
  67. noLocal := fs.Bool("NOLOCAL", false, "never connect to local or private addresses")
  68. showVersion := fs.Bool("VERSION", false, "print the version and exit")
  69. if err := fs.Parse(args); err != nil {
  70. if errors.Is(err, flag.ErrHelp) {
  71. return exitOK
  72. }
  73. return exitUsage // flag has already printed the error and usage
  74. }
  75. fail := func(code int, format string, a ...any) int {
  76. fmt.Fprintf(stderr, "error: "+format+"\n", a...)
  77. return code
  78. }
  79. usageError := func(format string, a ...any) int {
  80. fail(exitUsage, format, a...)
  81. fmt.Fprint(stderr, "\n"+usage)
  82. return exitUsage
  83. }
  84. if *showVersion {
  85. fmt.Fprintln(stdout, "rekopub", versionString())
  86. return exitOK
  87. }
  88. if fs.NArg() > 0 {
  89. return usageError("unexpected argument %q", fs.Arg(0))
  90. }
  91. if *urlArg == "" {
  92. return usageError("-URL is required")
  93. }
  94. if *saveArg == "" {
  95. return usageError("-SAVE is required")
  96. }
  97. pageURL, err := parseURL(*urlArg)
  98. if err != nil {
  99. return usageError("-URL %q: %v", *urlArg, err)
  100. }
  101. target, err := save.Resolve(*saveArg, *kepub)
  102. if err != nil {
  103. return usageError("-SAVE: %v", err)
  104. }
  105. if target.Note != "" {
  106. fmt.Fprintf(stderr, "note: %s\n", target.Note)
  107. }
  108. // Fail before downloading anything when the filename is already known.
  109. if target.Known() && !*force {
  110. if err := save.CheckExists(target.Path("")); err != nil {
  111. return saveFailure(fail, err)
  112. }
  113. }
  114. opts := pipeline.Options{
  115. Warn: func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
  116. KEPUB: *kepub,
  117. NoLocal: *noLocal,
  118. }
  119. if !target.Known() && !*force {
  120. // For folder targets the filename comes from the title; check it
  121. // before downloading images.
  122. opts.BeforeImages = func(title string) error { return save.CheckExists(target.Path(title)) }
  123. }
  124. book, err := convert(ctx, pageURL, opts)
  125. switch {
  126. case errors.Is(err, context.Canceled):
  127. return fail(exitInterrupted, "interrupted")
  128. case errors.Is(err, save.ErrExists):
  129. return saveFailure(fail, err)
  130. case errors.Is(err, pipeline.ErrFetch):
  131. return fail(exitFetch, "%v", err)
  132. case errors.Is(err, pipeline.ErrNoContent):
  133. return fail(exitNoContent, "%v", err)
  134. case err != nil:
  135. return fail(exitBuild, "%v", err)
  136. }
  137. path := target.Path(book.Title)
  138. if !*force {
  139. if err := save.CheckExists(path); err != nil {
  140. return saveFailure(fail, err)
  141. }
  142. }
  143. if err := save.Write(path, book.Content, *force); err != nil {
  144. return saveFailure(fail, err)
  145. }
  146. fmt.Fprintln(stdout, path)
  147. return exitOK
  148. }
  149. // saveFailure reports an error from the save package with its exit code.
  150. func saveFailure(fail func(int, string, ...any) int, err error) int {
  151. if errors.Is(err, save.ErrExists) {
  152. return fail(exitExists, "%v (use -FORCE to overwrite)", err)
  153. }
  154. return fail(exitBuild, "saving: %v", err)
  155. }
  156. // parseURL accepts absolute http and https URLs only.
  157. func parseURL(raw string) (*url.URL, error) {
  158. u, err := url.Parse(raw)
  159. if err != nil {
  160. return nil, errors.New("not a valid URL")
  161. }
  162. if u.Scheme != "http" && u.Scheme != "https" {
  163. return nil, errors.New("must start with http:// or https://")
  164. }
  165. if u.Host == "" {
  166. return nil, errors.New("missing host name")
  167. }
  168. return u, nil
  169. }