fetch.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. // Package fetch downloads a web page and decodes it to UTF-8.
  2. package fetch
  3. import (
  4. "bytes"
  5. "context"
  6. "fmt"
  7. "io"
  8. "mime"
  9. "net/http"
  10. "net/url"
  11. "strconv"
  12. "time"
  13. "golang.org/x/net/html/charset"
  14. )
  15. // DefaultUserAgent looks like a desktop browser, because some sites refuse
  16. // Go's default user agent, and says which tool is asking.
  17. const DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 (compatible; rekopub)"
  18. // Page is a downloaded HTML page.
  19. type Page struct {
  20. URL *url.URL // final URL, after redirects
  21. HTML string // decoded to UTF-8
  22. ContentType string // as sent by the server, or sniffed
  23. }
  24. // StatusError reports a response with a non-2xx status code.
  25. type StatusError struct {
  26. URL string
  27. Code int
  28. }
  29. func (e *StatusError) Error() string {
  30. msg := fmt.Sprintf("%s returned %d %s", e.URL, e.Code, http.StatusText(e.Code))
  31. switch e.Code {
  32. case http.StatusUnauthorized, http.StatusForbidden:
  33. msg += "; the site may need a login or may block automated downloads"
  34. case http.StatusNotFound, http.StatusGone:
  35. msg += "; check the address"
  36. case http.StatusTooManyRequests:
  37. msg += "; the site is limiting requests, so try again later"
  38. }
  39. return msg
  40. }
  41. // Fetcher downloads pages. The zero value is not usable; call New.
  42. type Fetcher struct {
  43. Client *http.Client
  44. UserAgent string
  45. MaxBytes int64 // largest page accepted
  46. Retries int // extra attempts after a 429, 5xx or network error
  47. Backoff time.Duration // wait before the first retry; doubles each time
  48. }
  49. // New returns a Fetcher with the defaults from PLAN.md: a 60 second
  50. // timeout, a 20 MB size limit and two retries. Its client refuses local and
  51. // private addresses unless allowLocal is set; see IsLocal.
  52. func New(allowLocal bool) *Fetcher {
  53. return &Fetcher{
  54. Client: newClient(allowLocal),
  55. UserAgent: DefaultUserAgent,
  56. MaxBytes: 20 << 20,
  57. Retries: 2,
  58. Backoff: time.Second,
  59. }
  60. }
  61. // maxRetryAfter caps how long a server's Retry-After header can make us wait.
  62. const maxRetryAfter = 30 * time.Second
  63. // Fetch downloads u and returns it decoded to UTF-8. It fails for non-2xx
  64. // responses, for content that isn't HTML and for pages over MaxBytes.
  65. func (f *Fetcher) Fetch(ctx context.Context, u *url.URL) (*Page, error) {
  66. wait := f.Backoff
  67. for attempt := 0; ; attempt++ {
  68. page, retryAfter, err := f.fetchOnce(ctx, u)
  69. if err == nil || retryAfter < 0 || attempt >= f.Retries {
  70. return page, err
  71. }
  72. if retryAfter > 0 {
  73. wait = min(retryAfter, maxRetryAfter)
  74. }
  75. select {
  76. case <-ctx.Done():
  77. return nil, ctx.Err()
  78. case <-time.After(wait):
  79. }
  80. wait *= 2
  81. }
  82. }
  83. // fetchOnce makes a single attempt. retryAfter is negative when the error
  84. // is not worth retrying, zero to use the normal backoff, or the delay the
  85. // server asked for.
  86. func (f *Fetcher) fetchOnce(ctx context.Context, u *url.URL) (page *Page, retryAfter time.Duration, err error) {
  87. req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
  88. if err != nil {
  89. return nil, -1, err
  90. }
  91. req.Header.Set("User-Agent", f.UserAgent)
  92. req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5")
  93. resp, err := f.Client.Do(req)
  94. if err != nil {
  95. if ctx.Err() != nil {
  96. return nil, -1, ctx.Err()
  97. }
  98. return nil, 0, Explain(err)
  99. }
  100. defer resp.Body.Close()
  101. if resp.StatusCode < 200 || resp.StatusCode > 299 {
  102. err := &StatusError{URL: u.String(), Code: resp.StatusCode}
  103. if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
  104. return nil, parseRetryAfter(resp.Header.Get("Retry-After")), err
  105. }
  106. return nil, -1, err
  107. }
  108. body, err := io.ReadAll(io.LimitReader(resp.Body, f.MaxBytes+1))
  109. if err != nil {
  110. return nil, 0, fmt.Errorf("reading %s: %w", u, err)
  111. }
  112. if int64(len(body)) > f.MaxBytes {
  113. return nil, -1, fmt.Errorf("%s is larger than %d MB", u, f.MaxBytes>>20)
  114. }
  115. // Sniff the content when the server sends no type or only the generic
  116. // application/octet-stream, as some misconfigured servers do for HTML.
  117. contentType := resp.Header.Get("Content-Type")
  118. if mediaType, _, _ := mime.ParseMediaType(contentType); mediaType == "" || mediaType == "application/octet-stream" {
  119. contentType = http.DetectContentType(body)
  120. }
  121. if !isHTML(contentType) {
  122. return nil, -1, fmt.Errorf("%s is not a web page (content type %q)", u, contentType)
  123. }
  124. // charset.NewReader picks the encoding from the Content-Type header, a
  125. // byte order mark or a <meta charset> tag, then converts to UTF-8.
  126. r, err := charset.NewReader(bytes.NewReader(body), contentType)
  127. if err != nil {
  128. return nil, -1, fmt.Errorf("decoding %s: %w", u, err)
  129. }
  130. decoded, err := io.ReadAll(r)
  131. if err != nil {
  132. return nil, -1, fmt.Errorf("decoding %s: %w", u, err)
  133. }
  134. return &Page{URL: resp.Request.URL, HTML: string(decoded), ContentType: contentType}, 0, nil
  135. }
  136. func isHTML(contentType string) bool {
  137. mediaType, _, err := mime.ParseMediaType(contentType)
  138. return err == nil && (mediaType == "text/html" || mediaType == "application/xhtml+xml")
  139. }
  140. // parseRetryAfter reads a Retry-After header given in seconds. HTTP dates
  141. // and missing values fall back to the normal backoff.
  142. func parseRetryAfter(v string) time.Duration {
  143. secs, err := strconv.Atoi(v)
  144. if err != nil || secs < 0 {
  145. return 0
  146. }
  147. return time.Duration(secs) * time.Second
  148. }