package clone import ( "context" "crypto/sha256" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "github.com/tamnd/kage/asset" "github.com/tamnd/kage/browser" "github.com/tamnd/kage/robots" "github.com/tamnd/kage/sanitize" "github.com/tamnd/kage/urlx" "golang.org/x/net/html" "golang.org/x/time/rate" ) // Logf is an optional sink for human-readable progress lines. type Logf func(format string, args ...any) // Cloner runs one clone. Build it with New, then call Run. type Cloner struct { cfg Config seed *url.URL seedHost string outRoot string statePth string pool *browser.Pool dl *asset.Downloader httpC *http.Client robots *robots.Matcher front *frontier stats stats logf Logf mu sync.Mutex seenAssets map[string]bool enqueued int // pages offered to the queue crawlLimiter *rate.Limiter wg sync.WaitGroup pageJobs chan pageItem assetJobs chan assetItem muContent sync.Mutex seenContent map[string]string // sha-256 of page bytes -> first path written } type pageItem struct { u *url.URL depth int } type assetItem struct { u *url.URL referer string } // New builds a Cloner for seed under cfg. It does not touch the network until // Run is called. func New(seed *url.URL, cfg Config, logf Logf) *Cloner { if logf == nil { logf = func(string, ...any) {} } host := seed.Hostname() outRoot := cfg.HostDir(host) return &Cloner{ cfg: cfg, seed: seed, seedHost: host, outRoot: outRoot, statePth: filepath.Join(outRoot, cfg.Reserved, "state.json"), dl: asset.NewDownloader(cfg.UserAgent, cfg.Timeout, cfg.MaxAssetBytes), httpC: &http.Client{Timeout: cfg.Timeout}, robots: robots.AllowAll(), front: newFrontier(), logf: logf, seenAssets: map[string]bool{}, seenContent: map[string]string{}, pageJobs: make(chan pageItem), assetJobs: make(chan assetItem), } } // Snapshot returns the current progress, for a CLI ticker. func (c *Cloner) Snapshot() Progress { return c.stats.snapshot() } // pageKey is the dedup identity for a page: its output file. Two URLs that would // write to the same file (http vs https, "/" vs "/index.html", a trailing // slash) are the same page and must be crawled only once. func (c *Cloner) pageKey(u *url.URL) string { return urlx.LocalPath(c.seedHost, u, urlx.Page, c.cfg.Reserved) } // assetKey is the dedup identity for an asset: its output file, so the same // bytes referenced over http and https download once. func (c *Cloner) assetKey(u *url.URL) string { return urlx.LocalPath(c.seedHost, u, urlx.Asset, c.cfg.Reserved) } // pagePathKey is the identity of a page ignoring its query string, used to tell // a real page apart from its ?q=…/?page=… variants for the progress display. // Each variant writes its own file (so the crawl stays complete), but they all // fold to one path here. func (c *Cloner) pagePathKey(u *url.URL) string { if u.RawQuery == "" { return c.pageKey(u) } cp := *u cp.RawQuery = "" return c.pageKey(&cp) } // Run executes the clone until the frontier drains, MaxPages is hit, or ctx is // cancelled (which flushes the resume state). It returns the final Result. func (c *Cloner) Run(ctx context.Context) (Result, error) { if c.cfg.Force { _ = os.RemoveAll(c.outRoot) } // Refresh re-renders every page in place, so it deliberately skips loading // the prior visited set; everything else resumes from where it left off. if c.cfg.Resume && !c.cfg.Refresh { if err := c.front.load(c.statePth); err != nil { c.logf("resume: could not load state: %v", err) } else if n := c.front.visitedCount(); n > 0 { c.logf("resume: %d pages already done", n) } } c.pool = browser.New(browser.Options{ Headless: c.cfg.Headless, Workers: c.cfg.BrowserPages, Settle: c.cfg.Settle, RenderTimeout: c.cfg.RenderTimeout, Scroll: c.cfg.Scroll, ChromeBin: c.cfg.ChromeBin, ControlURL: c.cfg.ControlURL, }) defer func() { _ = c.pool.Close() }() c.loadRobots(ctx) c.setupCrawlDelayLimiter() // Start workers. var workers sync.WaitGroup for range max1(c.cfg.Workers) { workers.Go(func() { for j := range c.pageJobs { c.processPage(ctx, j) c.wg.Done() } }) } for range max1(c.cfg.AssetWorkers) { workers.Go(func() { for j := range c.assetJobs { c.processAsset(ctx, j) c.wg.Done() } }) } // Seed. c.enqueuePage(ctx, c.seed, 0) if c.cfg.FollowSitemap { c.seedSitemaps(ctx) } // Close the job channels once every outstanding item is processed. go func() { c.wg.Wait() close(c.pageJobs) close(c.assetJobs) }() workers.Wait() if c.cfg.Persist { if err := c.front.save(c.statePth); err != nil { c.logf("could not save resume state: %v", err) } } res := Result{Progress: c.stats.snapshot(), OutDir: c.outRoot, Failures: c.stats.recordedFailures()} if ctx.Err() != nil { return res, ctx.Err() } return res, nil } // loadRobots fetches and parses robots.txt (unless disabled) and seeds the // sitemap list it advertises. func (c *Cloner) loadRobots(ctx context.Context) { if !c.cfg.RespectRobots { return } robotsURL := c.seed.Scheme + "://" + c.seed.Host + "/robots.txt" req, err := http.NewRequestWithContext(ctx, http.MethodGet, robotsURL, nil) if err != nil { return } req.Header.Set("User-Agent", c.cfg.UserAgent) resp, err := c.httpC.Do(req) if err != nil { return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return } data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return } c.robots = robots.Parse(string(data), "kage") } func (c *Cloner) setupCrawlDelayLimiter() { delay := c.cfg.CrawlDelay if delay <= 0 && c.cfg.RespectRobots && c.robots != nil { delay = c.robots.CrawlDelay } if delay <= 0 { c.crawlLimiter = nil return } c.crawlLimiter = rate.NewLimiter(rate.Every(delay), 1) } // seedSitemaps adds in-scope sitemap URLs (from robots and the default path) to // the frontier. func (c *Cloner) seedSitemaps(ctx context.Context) { seeds := append([]string{}, c.robots.Sitemaps...) seeds = append(seeds, c.seed.Scheme+"://"+c.seed.Host+"/sitemap.xml") locs := collectSitemaps(ctx, c.httpC, c.cfg.UserAgent, seeds) added := 0 for _, loc := range locs { u, err := urlx.Normalize(c.seed, loc) if err != nil { continue } if urlx.InScope(c.seed, u, c.cfg.scope()) && urlx.LikelyPage(u) { if c.enqueuePage(ctx, u, 1) { added++ } } } if added > 0 { c.logf("sitemap: seeded %d URLs", added) } } // processPage renders one page, rewrites its references to local paths, strips // every script, and writes the result. func (c *Cloner) processPage(ctx context.Context, j pageItem) { if ctx.Err() != nil { return } key := c.pageKey(j.u) if c.cfg.RespectRobots && !c.robots.Allowed(j.u.Path) { c.stats.skipped.Add(1) return } if !c.waitForCrawlDelay(ctx) { return } res, err := c.pool.Render(ctx, j.u.String()) if err != nil { var notHTML *browser.ErrNotHTML if errors.As(err, ¬HTML) { // The URL is not a page but a file (a zip, a CSV, a bare image) that // reached the page worker through an extensionless link. Hand it to the // asset downloader, where the size and media policy decides whether to // localise it or leave it remote, rather than saving a broken page or // letting Chrome download it to the user's Downloads folder (issue #32). c.front.markVisited(key) if c.wantAsset(j.u) { c.enqueueAsset(ctx, j.u, "") c.logf("not a page, fetching as asset (%s): %s", notHTML.ContentType, j.u.String()) } else { c.logf("not a page, left on the live web (%s): %s", notHTML.ContentType, j.u.String()) } return } c.failPage(j.u.String(), fmt.Errorf("render: %w", err)) return } root, err := html.Parse(strings.NewReader(res.HTML)) if err != nil { c.failPage(j.u.String(), fmt.Errorf("parse: %w", err)) return } localFile := urlx.LocalPath(c.seedHost, j.u, urlx.Page, c.cfg.Reserved) fileDir := urlx.Dir(localFile) sink := func(u *url.URL, kind urlx.Kind) string { switch kind { case urlx.Page: if urlx.InScope(c.seed, u, c.cfg.scope()) { c.enqueuePage(ctx, u, j.depth+1) local := urlx.LocalPath(c.seedHost, u, urlx.Page, c.cfg.Reserved) return urlx.Rel(fileDir, local) } return u.String() // external page link stays on the live web default: // Asset if !c.wantAsset(u) { return u.String() // off-domain or bulk media: leave it on the live web } c.enqueueAsset(ctx, u, j.u.String()) local := urlx.LocalPath(c.seedHost, u, urlx.Asset, c.cfg.Reserved) return urlx.Rel(fileDir, local) } } asset.RewriteHTML(root, j.u, sink) sanitize.CleanTree(root, sanitize.Options{ KeepNoscript: c.cfg.KeepNoscript, MobileReadable: c.cfg.MobileReadable, Banner: "cloned by kage from " + j.u.String(), }) var buf strings.Builder if err := html.Render(&buf, root); err != nil { c.failPage(j.u.String(), fmt.Errorf("render html: %w", err)) return } deduped, err := c.writePage(localFile, []byte(buf.String())) if err != nil { c.failPage(j.u.String(), fmt.Errorf("write %s: %w", localFile, err)) return } c.front.markVisited(key) c.stats.recordPage(c.pagePathKey(j.u), deduped) } // waitForCrawlDelay spaces page render starts. func (c *Cloner) waitForCrawlDelay(ctx context.Context) bool { if c.crawlLimiter == nil { return true } return c.crawlLimiter.Wait(ctx) == nil } // processAsset downloads one asset, rewriting CSS references on the way, and // writes it to its deterministic local path. func (c *Cloner) processAsset(ctx context.Context, j assetItem) { if ctx.Err() != nil { return } res, err := c.dl.Get(ctx, j.u, j.referer) if err != nil { if errors.Is(err, asset.ErrTooLarge) { // Over the size cap: leave it out rather than save a truncated // fragment. Count it as skipped, not failed, so the run is clean. c.stats.assetSkipped.Add(1) c.logf("asset skipped (over %d MB): %s", c.cfg.MaxAssetBytes>>20, j.u.String()) return } c.failAsset(j.u.String(), j.referer, err) return } localFile := urlx.LocalPath(c.seedHost, j.u, urlx.Asset, c.cfg.Reserved) body := res.Body if res.IsCSS { fileDir := urlx.Dir(localFile) cssSink := func(u *url.URL, _ urlx.Kind) string { if !c.wantAsset(u) { return u.String() // off-domain or bulk media: leave it on the live web } c.enqueueAsset(ctx, u, j.u.String()) local := urlx.LocalPath(c.seedHost, u, urlx.Asset, c.cfg.Reserved) return urlx.Rel(fileDir, local) } body = asset.RewriteCSS(body, j.u, cssSink) } if err := c.writeFile(localFile, body); err != nil { c.failAsset(j.u.String(), j.referer, fmt.Errorf("write %s: %w", localFile, err)) return } c.stats.assets.Add(1) } // failAsset records and logs a failed asset, naming the page that referenced it // so a 403 or 404 is traceable back to where it came from. The reason is // classified (HTTP status, timeout, or other) for a readable line. func (c *Cloner) failAsset(u, referer string, err error) { c.stats.assetErrors.Add(1) reason := classifyError(err) c.stats.recordFailure(Failure{Kind: "asset", URL: u, Referer: referer, Reason: reason}) if referer != "" { c.logf("asset error: %s\n %s\n referenced by %s", reason, u, referer) } else { c.logf("asset error: %s\n %s", reason, u) } } // failPage records and logs a failed page. func (c *Cloner) failPage(u string, err error) { c.stats.pageErrors.Add(1) reason := classifyError(err) c.stats.recordFailure(Failure{Kind: "page", URL: u, Reason: reason}) c.logf("page error: %s\n %s", reason, u) } // classifyError turns an error into a short, human-readable reason for the log // and the final report: an HTTP status with its name, a timeout, a cancellation, // or the underlying message otherwise. func classifyError(err error) string { if err == nil { return "" } var se *asset.StatusError if errors.As(err, &se) { return se.Error() } switch { case errors.Is(err, context.DeadlineExceeded): return "timed out" case errors.Is(err, context.Canceled): return "cancelled" } return err.Error() } // enqueuePage offers a page URL to the frontier, honouring the visited set, the // depth cap, and the page budget. It reports whether the page was newly queued. func (c *Cloner) enqueuePage(ctx context.Context, u *url.URL, depth int) bool { if c.cfg.MaxDepth > 0 && depth > c.cfg.MaxDepth { return false } key := c.pageKey(u) if c.front.isVisited(key) { return false } if !c.front.offer(key) { return false } c.mu.Lock() if c.cfg.MaxPages > 0 && c.enqueued >= c.cfg.MaxPages { c.mu.Unlock() return false } c.enqueued++ c.mu.Unlock() c.wg.Add(1) go func() { select { case c.pageJobs <- pageItem{u: u, depth: depth}: case <-ctx.Done(): c.wg.Done() } }() return true } // wantAsset reports whether an asset should be downloaded and localized, or left // pointing at its live URL. kage skips two classes by default: assets on a host // outside the seed's registrable domain (a third-party tracker, an unrelated // CDN), and bulk media, installers, and archives whose extension is in the skip // set. Both are decisions a page worker can make from the URL alone, before any // download, so the rewritten HTML simply keeps the remote link. func (c *Cloner) wantAsset(u *url.URL) bool { if c.cfg.AssetSameDomain && !urlx.SameRegistrableDomain(c.seed, u) { return false } if c.cfg.SkipAssetExts[urlx.Ext(u)] { return false } return true } // enqueueAsset offers an asset URL for download, deduping by canonical URL. func (c *Cloner) enqueueAsset(ctx context.Context, u *url.URL, referer string) { key := c.assetKey(u) c.mu.Lock() if c.seenAssets[key] { c.mu.Unlock() return } c.seenAssets[key] = true c.mu.Unlock() c.wg.Add(1) go func() { select { case c.assetJobs <- assetItem{u: u, referer: referer}: case <-ctx.Done(): c.wg.Done() } }() } // writePage writes a rendered page, deduping by content. The first page with a // given byte content is written normally; a later page with identical bytes is // stored as a hard link to that first file, so the same content never occupies // disk twice (a faceted site whose ?q=… variants all render the same page is the // motivating case). It reports whether this write was deduped. If hard links are // unsupported, it falls back to writing the bytes, so correctness never depends // on the link succeeding. func (c *Cloner) writePage(relSlash string, data []byte) (bool, error) { sum := sha256.Sum256(data) h := string(sum[:]) c.muContent.Lock() canon, seen := c.seenContent[h] if !seen { c.seenContent[h] = relSlash } c.muContent.Unlock() if seen && canon != relSlash { if err := c.linkFile(canon, relSlash); err == nil { return true, nil } // The canonical file may not be on disk yet (a concurrent first write) // or the filesystem may not support links; write the bytes instead. } return false, c.writeFile(relSlash, data) } // linkFile hard-links the already-written canonical file to target, replacing // any file already at target. Both paths are confined to the mirror root. func (c *Cloner) linkFile(canonSlash, targetSlash string) error { canonFull := filepath.Join(c.outRoot, filepath.FromSlash(canonSlash)) targetFull := filepath.Join(c.outRoot, filepath.FromSlash(targetSlash)) if !strings.HasPrefix(targetFull, filepath.Clean(c.outRoot)+string(os.PathSeparator)) { return fmt.Errorf("refusing to link outside mirror root: %s", targetSlash) } if err := os.MkdirAll(filepath.Dir(targetFull), 0o755); err != nil { return err } _ = os.Remove(targetFull) return os.Link(canonFull, targetFull) } // writeFile writes data to a slash path relative to the mirror root, creating // parent directories. The path is cleaned so it can never escape the root. func (c *Cloner) writeFile(relSlash string, data []byte) error { rel := filepath.FromSlash(relSlash) full := filepath.Join(c.outRoot, rel) if !strings.HasPrefix(full, filepath.Clean(c.outRoot)+string(os.PathSeparator)) && full != c.outRoot { return fmt.Errorf("refusing to write outside mirror root: %s", relSlash) } if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return err } return os.WriteFile(full, data, 0o644) } func max1(n int) int { if n < 1 { return 1 } return n }