Add the clone engine, CLI, tests, CI, and docs

kage renders every page in headless Chrome, snapshots the final
DOM, strips all JavaScript, and localises CSS, images, and fonts
so a site can be browsed offline as a plain folder of files.

The engine is split into small packages:

  urlx      deterministic URL to local-path mapping and scope rules
  sanitize  remove scripts, on* handlers, and javascript: URLs
  asset     rewrite HTML and CSS references, download assets
  browser   headless Chrome pool over the DevTools protocol
  robots    robots.txt matcher
  clone     the orchestrator: a polite resumable breadth-first crawl

The cli package wires a cobra and fang command surface with two
commands, clone and serve. Every pure package has table tests; the
browser and clone packages add Chrome-driven end-to-end tests that
skip when no browser is present or under -short.

CI runs gofmt, vet, build, race tests, golangci-lint, govulncheck,
and a tidy check on Linux and macOS. A goreleaser config fans one
tag out to archives, deb/rpm/apk, a Chromium-bundled GHCR image,
and the package managers. A tago docs site builds to Pages and
Cloudflare.
This commit is contained in:
Duc-Tam Nguyen
2026-06-14 18:22:25 +07:00
parent f7c5a9a1c6
commit e6afa91e09
48 changed files with 4697 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
package clone
import (
"context"
"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"
)
// 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
wg sync.WaitGroup
pageJobs chan pageItem
assetJobs chan assetItem
}
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{},
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() }
// 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)
}
if c.cfg.Resume {
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)
// 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 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}
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")
}
// 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 := urlx.Key(j.u)
if c.cfg.RespectRobots && !c.robots.Allowed(j.u.Path) {
c.stats.skipped.Add(1)
return
}
res, err := c.pool.Render(ctx, j.u.String())
if err != nil {
c.stats.pageErrors.Add(1)
c.logf("page error %s: %v", j.u, err)
return
}
root, err := html.Parse(strings.NewReader(res.HTML))
if err != nil {
c.stats.pageErrors.Add(1)
c.logf("parse error %s: %v", j.u, 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
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,
Banner: "cloned by kage from " + j.u.String(),
})
var buf strings.Builder
if err := html.Render(&buf, root); err != nil {
c.stats.pageErrors.Add(1)
return
}
if err := c.writeFile(localFile, []byte(buf.String())); err != nil {
c.stats.pageErrors.Add(1)
c.logf("write error %s: %v", localFile, err)
return
}
c.front.markVisited(key)
c.stats.pages.Add(1)
}
// 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 {
c.stats.assetErrors.Add(1)
c.logf("asset error %s: %v", j.u, 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 {
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.stats.assetErrors.Add(1)
c.logf("write error %s: %v", localFile, err)
return
}
c.stats.assets.Add(1)
}
// 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 := urlx.Key(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
}
// enqueueAsset offers an asset URL for download, deduping by canonical URL.
func (c *Cloner) enqueueAsset(ctx context.Context, u *url.URL, referer string) {
key := urlx.Key(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()
}
}()
}
// 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
}
+226
View File
@@ -0,0 +1,226 @@
package clone
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/tamnd/kage/browser"
"github.com/tamnd/kage/urlx"
)
// testSite is a tiny two-page site with a stylesheet, an image, an inline
// script, an onclick handler, and a javascript: link, so a full clone exercises
// rendering, asset localisation, and JavaScript stripping at once.
func testSite(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><html><head>
<link rel="stylesheet" href="/site.css">
<script src="/app.js"></script>
</head><body>
<h1>Home</h1>
<img src="/logo.png" alt="logo">
<a href="/about">About</a>
<a href="javascript:void(0)" onclick="boom()">Danger</a>
<script>console.log("inline")</script>
</body></html>`))
})
mux.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><html><head>
<link rel="stylesheet" href="/site.css">
</head><body><h1>About</h1><a href="/">Home</a></body></html>`))
})
mux.HandleFunc("/site.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
_, _ = w.Write([]byte(`body{background:url("/bg.png")} h1{color:red}`))
})
mux.HandleFunc("/app.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write([]byte(`document.body.dataset.ran = "1";`))
})
mux.HandleFunc("/logo.png", servePNG)
mux.HandleFunc("/bg.png", servePNG)
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("User-agent: *\nAllow: /\n"))
})
return httptest.NewServer(mux)
}
// a 1x1 transparent PNG.
var pngBytes = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
func servePNG(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(pngBytes)
}
func TestCloneEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("clone end-to-end drives Chrome; skipped under -short")
}
if _, ok := browser.LookChrome(); !ok {
t.Skip("no Chrome/Chromium found; skipping clone end-to-end")
}
srv := testSite(t)
defer srv.Close()
seed, err := urlx.ParseSeed(srv.URL)
if err != nil {
t.Fatalf("parse seed: %v", err)
}
out := t.TempDir()
cfg := DefaultConfig()
cfg.OutDir = out
cfg.Settle = 300 * time.Millisecond
cfg.RenderTimeout = 20 * time.Second
cfg.Timeout = 10 * time.Second
c := New(seed, cfg, t.Logf)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
res, err := c.Run(ctx)
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Pages < 2 {
t.Fatalf("expected at least 2 pages, got %d", res.Pages)
}
root := res.OutDir
indexPath := filepath.Join(root, "index.html")
body := readFile(t, indexPath)
// JavaScript is gone: no <script>, no onclick, no javascript: URL.
if strings.Contains(strings.ToLower(body), "<script") {
t.Error("index.html still contains a <script> tag")
}
if strings.Contains(strings.ToLower(body), "onclick") {
t.Error("index.html still contains an onclick handler")
}
if strings.Contains(strings.ToLower(body), "javascript:") {
t.Error("index.html still contains a javascript: URL")
}
// Layout is preserved: the stylesheet link survives and points local.
if !strings.Contains(body, "stylesheet") {
t.Error("index.html lost its stylesheet link")
}
if strings.Contains(body, srv.URL+"/site.css") {
t.Error("stylesheet still points at the live origin")
}
// The about page and the localised assets exist on disk.
if !fileExists(filepath.Join(root, "about", "index.html")) {
t.Error("about page was not written")
}
assetDir := filepath.Join(root, cfg.Reserved)
if !anyFileUnder(t, assetDir, "site.css") {
t.Error("site.css was not downloaded")
}
if !anyFileUnder(t, assetDir, "logo.png") {
t.Error("logo.png was not downloaded")
}
// The localised CSS had its url() rewritten away from the origin.
css := readAnyFile(t, assetDir, "site.css")
if strings.Contains(css, srv.URL) {
t.Error("site.css still references the live origin in url()")
}
}
func TestCloneResumeSkipsVisited(t *testing.T) {
if testing.Short() {
t.Skip("resume test drives Chrome; skipped under -short")
}
if _, ok := browser.LookChrome(); !ok {
t.Skip("no Chrome/Chromium found; skipping resume test")
}
srv := testSite(t)
defer srv.Close()
seed, _ := urlx.ParseSeed(srv.URL)
out := t.TempDir()
cfg := DefaultConfig()
cfg.OutDir = out
cfg.Settle = 300 * time.Millisecond
c1 := New(seed, cfg, t.Logf)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if _, err := c1.Run(ctx); err != nil {
t.Fatalf("first run: %v", err)
}
// Second run with resume on should find the state and re-render nothing new.
c2 := New(seed, cfg, t.Logf)
res2, err := c2.Run(ctx)
if err != nil {
t.Fatalf("second run: %v", err)
}
if res2.Pages != 0 {
t.Fatalf("resume should skip all visited pages, but rendered %d", res2.Pages)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func anyFileUnder(t *testing.T, dir, name string) bool {
t.Helper()
found := false
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.HasSuffix(p, name) {
found = true
}
return nil
})
return found
}
func readAnyFile(t *testing.T, dir, name string) string {
t.Helper()
var out string
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.HasSuffix(p, name) {
b, _ := os.ReadFile(p)
out = string(b)
}
return nil
})
return out
}
+86
View File
@@ -0,0 +1,86 @@
// Package clone is kage's engine: it ties the Chrome pool, the JavaScript
// stripper, the asset localiser, and the URL↔path mapper into one resumable,
// polite crawl that turns a live site into a browsable offline folder.
package clone
import (
"path/filepath"
"time"
"github.com/tamnd/kage/urlx"
)
// Config is the full set of knobs for a clone run. DefaultConfig fills the
// baseline; the CLI overlays flags on top.
type Config struct {
OutDir string // output root; the mirror lands in <OutDir>/<host>/
Reserved string // reserved dir name for assets and state (default "_kage")
Workers int // page render workers
AssetWorkers int // HTTP asset download workers
BrowserPages int // Chrome page-pool size
MaxPages int // stop after N pages (0 = unlimited)
MaxDepth int // BFS/DFS depth cap (0 = unlimited)
Traversal string
MaxAssetBytes int64
Timeout time.Duration // per HTTP request
Settle time.Duration // network-idle quiet period
RenderTimeout time.Duration // hard cap per page render
Scroll bool
UserAgent string
IncludeSubdomains bool
ScopePrefix string
ExcludePaths []string
RespectRobots bool
FollowSitemap bool
Headless bool
KeepNoscript bool
ChromeBin string
ControlURL string
Resume bool
Force bool
}
// DefaultUserAgent is a current desktop Chrome UA, used by the asset fetcher and
// the robots fetch so a site treats kage like the browser it drives.
const DefaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
// DefaultConfig returns the baseline configuration.
func DefaultConfig() Config {
return Config{
OutDir: "kage-out",
Reserved: urlx.DefaultReserved,
Workers: 4,
AssetWorkers: 8,
BrowserPages: 4,
MaxAssetBytes: 25 << 20,
Traversal: "bfs",
Timeout: 30 * time.Second,
Settle: 1500 * time.Millisecond,
RenderTimeout: 30 * time.Second,
UserAgent: DefaultUserAgent,
RespectRobots: true,
FollowSitemap: true,
Headless: true,
Resume: true,
}
}
// HostDir returns the mirror root for a seed host: <OutDir>/<host>.
func (c Config) HostDir(host string) string {
return filepath.Join(c.OutDir, host)
}
// scope builds the urlx scope config from the run config.
func (c Config) scope() urlx.ScopeConfig {
return urlx.ScopeConfig{
IncludeSubdomains: c.IncludeSubdomains,
ScopePrefix: c.ScopePrefix,
ExcludePaths: c.ExcludePaths,
}
}
+106
View File
@@ -0,0 +1,106 @@
package clone
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
)
// frontier is the deduped set of page URLs kage has already seen. It is small,
// concurrency-safe, and persists to disk so --resume can skip work already done.
// The actual queueing is handled by the cloner's channels; the frontier only
// answers "is this URL new?" and remembers the answer.
type frontier struct {
mu sync.Mutex
seen map[string]bool // queued or visited
visited map[string]bool // fully written
}
func newFrontier() *frontier {
return &frontier{seen: map[string]bool{}, visited: map[string]bool{}}
}
// offer reports whether key is new (and records it as seen). A repeated key
// returns false so it is enqueued only once.
func (f *frontier) offer(key string) bool {
f.mu.Lock()
defer f.mu.Unlock()
if f.seen[key] {
return false
}
f.seen[key] = true
return true
}
// markVisited records that a page was written.
func (f *frontier) markVisited(key string) {
f.mu.Lock()
f.visited[key] = true
f.mu.Unlock()
}
// isVisited reports whether a page was already written in a previous run.
func (f *frontier) isVisited(key string) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.visited[key]
}
func (f *frontier) visitedCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.visited)
}
// state is the JSON shape persisted for resume.
type state struct {
Visited []string `json:"visited"`
}
// load reads a previously saved visited set; a missing file is not an error.
func (f *frontier) load(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var s state
if err := json.Unmarshal(data, &s); err != nil {
return err
}
f.mu.Lock()
defer f.mu.Unlock()
for _, v := range s.Visited {
f.visited[v] = true
f.seen[v] = true
}
return nil
}
// save writes the visited set atomically (write temp, rename).
func (f *frontier) save(path string) error {
f.mu.Lock()
visited := make([]string, 0, len(f.visited))
for v := range f.visited {
visited = append(visited, v)
}
f.mu.Unlock()
sort.Strings(visited)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(state{Visited: visited}, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
+67
View File
@@ -0,0 +1,67 @@
package clone
import (
"path/filepath"
"testing"
)
func TestFrontierOfferDedups(t *testing.T) {
f := newFrontier()
if !f.offer("a") {
t.Fatal("first offer of a should be new")
}
if f.offer("a") {
t.Fatal("second offer of a should be a duplicate")
}
if !f.offer("b") {
t.Fatal("first offer of b should be new")
}
}
func TestFrontierVisited(t *testing.T) {
f := newFrontier()
if f.isVisited("x") {
t.Fatal("x should not be visited yet")
}
f.markVisited("x")
if !f.isVisited("x") {
t.Fatal("x should be visited after markVisited")
}
if f.visitedCount() != 1 {
t.Fatalf("visitedCount = %d, want 1", f.visitedCount())
}
}
func TestFrontierSaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sub", "state.json")
f := newFrontier()
f.markVisited("https://example.com/")
f.markVisited("https://example.com/about")
if err := f.save(path); err != nil {
t.Fatalf("save: %v", err)
}
g := newFrontier()
if err := g.load(path); err != nil {
t.Fatalf("load: %v", err)
}
if g.visitedCount() != 2 {
t.Fatalf("loaded visitedCount = %d, want 2", g.visitedCount())
}
if !g.isVisited("https://example.com/about") {
t.Fatal("about should be visited after load")
}
// A loaded visited URL is also seen, so it is not re-offered.
if g.offer("https://example.com/about") {
t.Fatal("a loaded URL should not be offered again")
}
}
func TestFrontierLoadMissingIsNotError(t *testing.T) {
f := newFrontier()
if err := f.load(filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatalf("loading a missing file should be a no-op, got %v", err)
}
}
+83
View File
@@ -0,0 +1,83 @@
package clone
import (
"context"
"encoding/xml"
"net/http"
"strings"
"time"
)
// sitemapDoc covers both a urlset and a sitemapindex: both carry <loc> elements,
// so one shape extracts the URLs from either.
type sitemapDoc struct {
URLs []sitemapEntry `xml:"url"`
Sitemaps []sitemapEntry `xml:"sitemap"`
}
type sitemapEntry struct {
Loc string `xml:"loc"`
}
// fetchSitemap downloads and parses one sitemap, returning page locations and,
// for a sitemap index, the child sitemap URLs.
func fetchSitemap(ctx context.Context, client *http.Client, ua, sitemapURL string) (locs, children []string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sitemapURL, nil)
if err != nil {
return nil, nil
}
if ua != "" {
req.Header.Set("User-Agent", ua)
}
resp, err := client.Do(req)
if err != nil {
return nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, nil
}
var doc sitemapDoc
dec := xml.NewDecoder(resp.Body)
if err := dec.Decode(&doc); err != nil {
return nil, nil
}
for _, u := range doc.URLs {
if s := strings.TrimSpace(u.Loc); s != "" {
locs = append(locs, s)
}
}
for _, sm := range doc.Sitemaps {
if s := strings.TrimSpace(sm.Loc); s != "" {
children = append(children, s)
}
}
return locs, children
}
// collectSitemaps walks a set of seed sitemap URLs (following index files one
// level deep) and returns all discovered page locations, bounded by a deadline.
func collectSitemaps(ctx context.Context, client *http.Client, ua string, seeds []string) []string {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
var out []string
seen := map[string]bool{}
queue := append([]string{}, seeds...)
for len(queue) > 0 && ctx.Err() == nil {
sm := queue[0]
queue = queue[1:]
if seen[sm] {
continue
}
seen[sm] = true
locs, children := fetchSitemap(ctx, client, ua, sm)
out = append(out, locs...)
for _, c := range children {
if !seen[c] {
queue = append(queue, c)
}
}
}
return out
}
+37
View File
@@ -0,0 +1,37 @@
package clone
import "sync/atomic"
// stats are the live counters of a run, read by the CLI's progress ticker.
type stats struct {
pages atomic.Int64
assets atomic.Int64
pageErrors atomic.Int64
assetErrors atomic.Int64
skipped atomic.Int64 // robots-disallowed or out of budget
}
// Progress is a snapshot of a run for display.
type Progress struct {
Pages int64
Assets int64
PageErrors int64
AssetErrors int64
Skipped int64
}
func (s *stats) snapshot() Progress {
return Progress{
Pages: s.pages.Load(),
Assets: s.assets.Load(),
PageErrors: s.pageErrors.Load(),
AssetErrors: s.assetErrors.Load(),
Skipped: s.skipped.Load(),
}
}
// Result is the final outcome returned by Run.
type Result struct {
Progress
OutDir string
}