249761f29f
A developer.apple.com crawl came back at 19 GB, 18 of it assets, most of that the site's own videos, .dmg/.pkg installers, and PDF manuals pulled from a couple dozen hosts including unrelated third parties. None of it helps read the docs offline. This changes what kage localizes by default. Bulk media, installers, archives, and PDFs are left pointing at their live URL instead of downloaded. The decision is made from the URL alone, so the rewritten HTML simply keeps the remote link. --keep-media restores the old behavior and --skip-ext adds more extensions to leave remote. Assets are localized only from the seed's registrable domain by default, so www.apple.com and images.apple.com still come along but a separate brand domain or an off-topic third party does not. --all-asset-hosts goes back to downloading from any host. The size cap was also truncating instead of skipping: it wrapped the body in a LimitReader, so an over-cap file was saved as exactly the first N MB of itself, a corrupt fragment that would never play or run. On the apple crawl that was around a gigabyte of half-downloaded WWDC videos. kage now checks the response size and leaves an over-cap asset out of the mirror.
186 lines
5.5 KiB
Go
186 lines
5.5 KiB
Go
package asset
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Downloader fetches asset bytes over plain HTTP. It is separate from the Chrome
|
|
// pool: assets are public bytes that rarely need a real browser, so a fast HTTP
|
|
// client keeps the crawl cheap. Failures are returned to the caller, which logs
|
|
// them and moves on — a missing asset degrades a page, it never aborts a clone.
|
|
type Downloader struct {
|
|
Client *http.Client
|
|
UserAgent string
|
|
MaxBytes int64 // per-asset cap; 0 = unlimited
|
|
Retries int // extra attempts for a transient failure (0 = try once)
|
|
}
|
|
|
|
// NewDownloader builds a Downloader with a sane client and the given timeout.
|
|
func NewDownloader(userAgent string, timeout time.Duration, maxBytes int64) *Downloader {
|
|
return &Downloader{
|
|
Client: &http.Client{Timeout: timeout},
|
|
UserAgent: userAgent,
|
|
MaxBytes: maxBytes,
|
|
// A few sites (and the bot-protection in front of them) reject the first
|
|
// request of a burst with a 403 or 429 but serve a retry fine, so give
|
|
// transient failures a couple of extra tries before giving up.
|
|
Retries: 3,
|
|
}
|
|
}
|
|
|
|
// Result is a downloaded asset.
|
|
type Result struct {
|
|
Body []byte
|
|
ContentType string
|
|
IsCSS bool
|
|
}
|
|
|
|
// ErrTooLarge reports that an asset exceeds the size cap and was skipped without
|
|
// being saved. It is deliberately a skip, not a download failure: the caller
|
|
// leaves the asset out of the mirror rather than writing a truncated fragment of
|
|
// it, so a 500 MB installer or video never bloats the archive with a corrupt
|
|
// quarter of itself.
|
|
var ErrTooLarge = errors.New("asset over size cap")
|
|
|
|
// StatusError reports a non-2xx HTTP response. It carries the code so callers
|
|
// can render a clear message ("HTTP 403 Forbidden") and decide whether a retry
|
|
// is worthwhile, without the URL baked in (the caller already has it).
|
|
type StatusError struct {
|
|
Code int
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
if t := http.StatusText(e.Code); t != "" {
|
|
return fmt.Sprintf("HTTP %d %s", e.Code, t)
|
|
}
|
|
return fmt.Sprintf("HTTP %d", e.Code)
|
|
}
|
|
|
|
// Get fetches u, sending referer as the Referer header. It reads at most
|
|
// MaxBytes and reports whether the body is CSS (so the caller can rewrite it).
|
|
// A transient failure (a 403/429/5xx or a network blip) is retried with a short
|
|
// backoff up to Retries times.
|
|
func (d *Downloader) Get(ctx context.Context, u *url.URL, referer string) (*Result, error) {
|
|
attempts := d.Retries + 1
|
|
if attempts < 1 {
|
|
attempts = 1
|
|
}
|
|
var lastErr error
|
|
for i := 0; i < attempts; i++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if i > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff(i)):
|
|
}
|
|
}
|
|
res, err := d.try(ctx, u, referer)
|
|
if err == nil {
|
|
return res, nil
|
|
}
|
|
lastErr = err
|
|
if !transient(err) {
|
|
break
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
// try performs a single fetch attempt.
|
|
func (d *Downloader) try(ctx context.Context, u *url.URL, referer string) (*Result, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.UserAgent != "" {
|
|
req.Header.Set("User-Agent", d.UserAgent)
|
|
}
|
|
if referer != "" {
|
|
req.Header.Set("Referer", referer)
|
|
}
|
|
resp, err := d.Client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, &StatusError{Code: resp.StatusCode}
|
|
}
|
|
// Skip an over-cap asset instead of truncating it. A Content-Length lets us
|
|
// bail before reading a byte; otherwise we read one byte past the cap and, if
|
|
// the body really is larger, discard what we have. Either way nothing partial
|
|
// reaches disk.
|
|
if d.MaxBytes > 0 && resp.ContentLength > d.MaxBytes {
|
|
return nil, ErrTooLarge
|
|
}
|
|
var r io.Reader = resp.Body
|
|
if d.MaxBytes > 0 {
|
|
// Read at most one byte past the cap so a body with no (or a lying)
|
|
// Content-Length cannot stream gigabytes into memory before we notice.
|
|
r = io.LimitReader(resp.Body, d.MaxBytes+1)
|
|
}
|
|
body, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.MaxBytes > 0 && int64(len(body)) > d.MaxBytes {
|
|
return nil, ErrTooLarge
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
return &Result{
|
|
Body: body,
|
|
ContentType: ct,
|
|
IsCSS: isCSS(ct, u),
|
|
}, nil
|
|
}
|
|
|
|
// backoff returns the pause before retry attempt i (1-based): 500ms, 1s, 2s, …
|
|
func backoff(i int) time.Duration {
|
|
d := 500 * time.Millisecond << (i - 1)
|
|
if max := 5 * time.Second; d > max {
|
|
d = max
|
|
}
|
|
return d
|
|
}
|
|
|
|
// transient reports whether an error is worth retrying. Bot-protection statuses
|
|
// (403/429), request-timeout and too-early (408/425), and 5xx server errors are
|
|
// transient; other 4xx (404, 401, 410, …) are permanent. A network error is
|
|
// retried, but a cancelled or expired context is not.
|
|
func transient(err error) bool {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return false
|
|
}
|
|
if errors.Is(err, ErrTooLarge) {
|
|
return false
|
|
}
|
|
var se *StatusError
|
|
if errors.As(err, &se) {
|
|
switch se.Code {
|
|
case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests:
|
|
return true
|
|
}
|
|
return se.Code >= 500
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isCSS reports whether a response is a stylesheet, by content-type or by a
|
|
// .css path when the server sends no useful type.
|
|
func isCSS(contentType string, u *url.URL) bool {
|
|
if strings.Contains(strings.ToLower(contentType), "text/css") {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(strings.ToLower(u.Path), ".css")
|
|
}
|