| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- // Package fetch downloads a web page and decodes it to UTF-8.
- package fetch
- import (
- "bytes"
- "context"
- "fmt"
- "io"
- "mime"
- "net/http"
- "net/url"
- "strconv"
- "time"
- "golang.org/x/net/html/charset"
- )
- // DefaultUserAgent looks like a desktop browser, because some sites refuse
- // Go's default user agent, and says which tool is asking.
- const DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 (compatible; rekopub)"
- // Page is a downloaded HTML page.
- type Page struct {
- URL *url.URL // final URL, after redirects
- HTML string // decoded to UTF-8
- ContentType string // as sent by the server, or sniffed
- }
- // StatusError reports a response with a non-2xx status code.
- type StatusError struct {
- URL string
- Code int
- }
- func (e *StatusError) Error() string {
- msg := fmt.Sprintf("%s returned %d %s", e.URL, e.Code, http.StatusText(e.Code))
- switch e.Code {
- case http.StatusUnauthorized, http.StatusForbidden:
- msg += "; the site may need a login or may block automated downloads"
- case http.StatusNotFound, http.StatusGone:
- msg += "; check the address"
- case http.StatusTooManyRequests:
- msg += "; the site is limiting requests, so try again later"
- }
- return msg
- }
- // Fetcher downloads pages. The zero value is not usable; call New.
- type Fetcher struct {
- Client *http.Client
- UserAgent string
- MaxBytes int64 // largest page accepted
- Retries int // extra attempts after a 429, 5xx or network error
- Backoff time.Duration // wait before the first retry; doubles each time
- }
- // New returns a Fetcher with the defaults from PLAN.md: a 60 second
- // timeout, a 20 MB size limit and two retries. Its client refuses local and
- // private addresses unless allowLocal is set; see IsLocal.
- func New(allowLocal bool) *Fetcher {
- return &Fetcher{
- Client: newClient(allowLocal),
- UserAgent: DefaultUserAgent,
- MaxBytes: 20 << 20,
- Retries: 2,
- Backoff: time.Second,
- }
- }
- // maxRetryAfter caps how long a server's Retry-After header can make us wait.
- const maxRetryAfter = 30 * time.Second
- // Fetch downloads u and returns it decoded to UTF-8. It fails for non-2xx
- // responses, for content that isn't HTML and for pages over MaxBytes.
- func (f *Fetcher) Fetch(ctx context.Context, u *url.URL) (*Page, error) {
- wait := f.Backoff
- for attempt := 0; ; attempt++ {
- page, retryAfter, err := f.fetchOnce(ctx, u)
- if err == nil || retryAfter < 0 || attempt >= f.Retries {
- return page, err
- }
- if retryAfter > 0 {
- wait = min(retryAfter, maxRetryAfter)
- }
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- case <-time.After(wait):
- }
- wait *= 2
- }
- }
- // fetchOnce makes a single attempt. retryAfter is negative when the error
- // is not worth retrying, zero to use the normal backoff, or the delay the
- // server asked for.
- func (f *Fetcher) fetchOnce(ctx context.Context, u *url.URL) (page *Page, retryAfter time.Duration, err error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
- if err != nil {
- return nil, -1, err
- }
- req.Header.Set("User-Agent", f.UserAgent)
- req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5")
- resp, err := f.Client.Do(req)
- if err != nil {
- if ctx.Err() != nil {
- return nil, -1, ctx.Err()
- }
- return nil, 0, Explain(err)
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode > 299 {
- err := &StatusError{URL: u.String(), Code: resp.StatusCode}
- if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
- return nil, parseRetryAfter(resp.Header.Get("Retry-After")), err
- }
- return nil, -1, err
- }
- body, err := io.ReadAll(io.LimitReader(resp.Body, f.MaxBytes+1))
- if err != nil {
- return nil, 0, fmt.Errorf("reading %s: %w", u, err)
- }
- if int64(len(body)) > f.MaxBytes {
- return nil, -1, fmt.Errorf("%s is larger than %d MB", u, f.MaxBytes>>20)
- }
- // Sniff the content when the server sends no type or only the generic
- // application/octet-stream, as some misconfigured servers do for HTML.
- contentType := resp.Header.Get("Content-Type")
- if mediaType, _, _ := mime.ParseMediaType(contentType); mediaType == "" || mediaType == "application/octet-stream" {
- contentType = http.DetectContentType(body)
- }
- if !isHTML(contentType) {
- return nil, -1, fmt.Errorf("%s is not a web page (content type %q)", u, contentType)
- }
- // charset.NewReader picks the encoding from the Content-Type header, a
- // byte order mark or a <meta charset> tag, then converts to UTF-8.
- r, err := charset.NewReader(bytes.NewReader(body), contentType)
- if err != nil {
- return nil, -1, fmt.Errorf("decoding %s: %w", u, err)
- }
- decoded, err := io.ReadAll(r)
- if err != nil {
- return nil, -1, fmt.Errorf("decoding %s: %w", u, err)
- }
- return &Page{URL: resp.Request.URL, HTML: string(decoded), ContentType: contentType}, 0, nil
- }
- func isHTML(contentType string) bool {
- mediaType, _, err := mime.ParseMediaType(contentType)
- return err == nil && (mediaType == "text/html" || mediaType == "application/xhtml+xml")
- }
- // parseRetryAfter reads a Retry-After header given in seconds. HTTP dates
- // and missing values fall back to the normal backoff.
- func parseRetryAfter(v string) time.Duration {
- secs, err := strconv.Atoi(v)
- if err != nil || secs < 0 {
- return 0
- }
- return time.Duration(secs) * time.Second
- }
|