Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bc9c8bea1 | |||
| 28d08ade88 | |||
| 1e59c10e6f |
@@ -28,7 +28,7 @@ Prefer a prebuilt binary? Grab an archive, a `.deb`/`.rpm`/`.apk`, or a checksum
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Homebrew (macOS)
|
# Homebrew (macOS)
|
||||||
brew install tamnd/tap/kage
|
brew install --cask tamnd/tap/kage
|
||||||
|
|
||||||
# Scoop (Windows)
|
# Scoop (Windows)
|
||||||
scoop bucket add tamnd https://github.com/tamnd/scoop-bucket
|
scoop bucket add tamnd https://github.com/tamnd/scoop-bucket
|
||||||
@@ -122,6 +122,7 @@ The flags you'll actually reach for:
|
|||||||
| `--scroll` | `false` | Auto-scroll each page to trigger lazy loading |
|
| `--scroll` | `false` | Auto-scroll each page to trigger lazy loading |
|
||||||
| `--workers` | `4` | How many pages to render at once |
|
| `--workers` | `4` | How many pages to render at once |
|
||||||
| `--no-robots` | `false` | Ignore `robots.txt` (be nice) |
|
| `--no-robots` | `false` | Ignore `robots.txt` (be nice) |
|
||||||
|
| `--crawl-delay` | `0s` | Override robots.txt `Crawl-delay` between page starts |
|
||||||
| `-f, --force` | `false` | Delete any existing mirror for the host first |
|
| `-f, --force` | `false` | Delete any existing mirror for the host first |
|
||||||
| `--chrome` | | Path to the Chrome/Chromium binary |
|
| `--chrome` | | Path to the Chrome/Chromium binary |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "runtime"
|
||||||
|
|
||||||
|
func launcherLeakless() bool {
|
||||||
|
return runtime.GOOS != "windows"
|
||||||
|
}
|
||||||
+1
-1
@@ -165,7 +165,7 @@ func (p *Pool) getBrowser() (*rod.Browser, error) {
|
|||||||
|
|
||||||
controlURL := p.opts.ControlURL
|
controlURL := p.opts.ControlURL
|
||||||
if controlURL == "" {
|
if controlURL == "" {
|
||||||
l := launcher.New().
|
l := launcher.New().Leakless(launcherLeakless()).
|
||||||
Headless(p.opts.Headless).
|
Headless(p.opts.Headless).
|
||||||
Set("disable-blink-features", "AutomationControlled").
|
Set("disable-blink-features", "AutomationControlled").
|
||||||
Set("disable-gpu", "")
|
Set("disable-gpu", "")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -83,6 +84,14 @@ func TestDisableSandboxContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLauncherLeaklessDisabledOnWindows(t *testing.T) {
|
||||||
|
got := launcherLeakless()
|
||||||
|
want := runtime.GOOS != "windows"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("launcherLeakless() = %v on %s; want %v", got, runtime.GOOS, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderCapturesFinalDOM(t *testing.T) {
|
func TestRenderCapturesFinalDOM(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("render test drives Chrome; skipped under -short")
|
t.Skip("render test drives Chrome; skipped under -short")
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type cloneFlags struct {
|
|||||||
scopePrefix string
|
scopePrefix string
|
||||||
exclude []string
|
exclude []string
|
||||||
noRobots bool
|
noRobots bool
|
||||||
|
crawlDelay time.Duration
|
||||||
noSitemap bool
|
noSitemap bool
|
||||||
headful bool
|
headful bool
|
||||||
keepNoscript bool
|
keepNoscript bool
|
||||||
@@ -84,6 +85,7 @@ func newCloneCmd() *cobra.Command {
|
|||||||
fs.StringVar(&f.scopePrefix, "scope-prefix", "", "only crawl pages whose path starts with this prefix")
|
fs.StringVar(&f.scopePrefix, "scope-prefix", "", "only crawl pages whose path starts with this prefix")
|
||||||
fs.StringSliceVar(&f.exclude, "exclude", nil, "path prefixes to skip (repeatable)")
|
fs.StringSliceVar(&f.exclude, "exclude", nil, "path prefixes to skip (repeatable)")
|
||||||
fs.BoolVar(&f.noRobots, "no-robots", false, "ignore robots.txt (be careful and polite)")
|
fs.BoolVar(&f.noRobots, "no-robots", false, "ignore robots.txt (be careful and polite)")
|
||||||
|
fs.DurationVar(&f.crawlDelay, "crawl-delay", 0, "override robots.txt Crawl-delay between page starts (0 = use robots.txt)")
|
||||||
fs.BoolVar(&f.noSitemap, "no-sitemap", false, "do not seed URLs from sitemap.xml")
|
fs.BoolVar(&f.noSitemap, "no-sitemap", false, "do not seed URLs from sitemap.xml")
|
||||||
fs.BoolVar(&f.headful, "headful", false, "run Chrome with a visible window (debugging)")
|
fs.BoolVar(&f.headful, "headful", false, "run Chrome with a visible window (debugging)")
|
||||||
fs.BoolVar(&f.keepNoscript, "keep-noscript", false, "unwrap <noscript> content instead of dropping it")
|
fs.BoolVar(&f.keepNoscript, "keep-noscript", false, "unwrap <noscript> content instead of dropping it")
|
||||||
@@ -102,6 +104,9 @@ func runClone(ctx context.Context, arg string, f *cloneFlags) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid url %q: %w", arg, err)
|
return fmt.Errorf("invalid url %q: %w", arg, err)
|
||||||
}
|
}
|
||||||
|
if f.crawlDelay < 0 {
|
||||||
|
return fmt.Errorf("--crawl-delay must be >= 0")
|
||||||
|
}
|
||||||
|
|
||||||
cfg := clone.DefaultConfig()
|
cfg := clone.DefaultConfig()
|
||||||
cfg.OutDir = f.out
|
cfg.OutDir = f.out
|
||||||
@@ -139,6 +144,7 @@ func runClone(ctx context.Context, arg string, f *cloneFlags) error {
|
|||||||
cfg.ScopePrefix = f.scopePrefix
|
cfg.ScopePrefix = f.scopePrefix
|
||||||
cfg.ExcludePaths = f.exclude
|
cfg.ExcludePaths = f.exclude
|
||||||
cfg.RespectRobots = !f.noRobots
|
cfg.RespectRobots = !f.noRobots
|
||||||
|
cfg.CrawlDelay = f.crawlDelay
|
||||||
cfg.FollowSitemap = !f.noSitemap
|
cfg.FollowSitemap = !f.noSitemap
|
||||||
cfg.Headless = !f.headful
|
cfg.Headless = !f.headful
|
||||||
cfg.KeepNoscript = f.keepNoscript
|
cfg.KeepNoscript = f.keepNoscript
|
||||||
|
|||||||
+33
-3
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/tamnd/kage/sanitize"
|
"github.com/tamnd/kage/sanitize"
|
||||||
"github.com/tamnd/kage/urlx"
|
"github.com/tamnd/kage/urlx"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logf is an optional sink for human-readable progress lines.
|
// Logf is an optional sink for human-readable progress lines.
|
||||||
@@ -43,9 +44,12 @@ type Cloner struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
seenAssets map[string]bool
|
seenAssets map[string]bool
|
||||||
enqueued int // pages offered to the queue
|
enqueued int // pages offered to the queue
|
||||||
wg sync.WaitGroup
|
|
||||||
pageJobs chan pageItem
|
crawlLimiter *rate.Limiter
|
||||||
assetJobs chan assetItem
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
pageJobs chan pageItem
|
||||||
|
assetJobs chan assetItem
|
||||||
|
|
||||||
muContent sync.Mutex
|
muContent sync.Mutex
|
||||||
seenContent map[string]string // sha-256 of page bytes -> first path written
|
seenContent map[string]string // sha-256 of page bytes -> first path written
|
||||||
@@ -144,6 +148,7 @@ func (c *Cloner) Run(ctx context.Context) (Result, error) {
|
|||||||
defer func() { _ = c.pool.Close() }()
|
defer func() { _ = c.pool.Close() }()
|
||||||
|
|
||||||
c.loadRobots(ctx)
|
c.loadRobots(ctx)
|
||||||
|
c.setupCrawlDelayLimiter()
|
||||||
|
|
||||||
// Start workers.
|
// Start workers.
|
||||||
var workers sync.WaitGroup
|
var workers sync.WaitGroup
|
||||||
@@ -218,6 +223,19 @@ func (c *Cloner) loadRobots(ctx context.Context) {
|
|||||||
c.robots = robots.Parse(string(data), "kage")
|
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
|
// seedSitemaps adds in-scope sitemap URLs (from robots and the default path) to
|
||||||
// the frontier.
|
// the frontier.
|
||||||
func (c *Cloner) seedSitemaps(ctx context.Context) {
|
func (c *Cloner) seedSitemaps(ctx context.Context) {
|
||||||
@@ -252,6 +270,9 @@ func (c *Cloner) processPage(ctx context.Context, j pageItem) {
|
|||||||
c.stats.skipped.Add(1)
|
c.stats.skipped.Add(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !c.waitForCrawlDelay(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
res, err := c.pool.Render(ctx, j.u.String())
|
res, err := c.pool.Render(ctx, j.u.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -324,6 +345,15 @@ func (c *Cloner) processPage(ctx context.Context, j pageItem) {
|
|||||||
c.stats.recordPage(c.pagePathKey(j.u), deduped)
|
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
|
// processAsset downloads one asset, rewriting CSS references on the way, and
|
||||||
// writes it to its deterministic local path.
|
// writes it to its deterministic local path.
|
||||||
func (c *Cloner) processAsset(ctx context.Context, j assetItem) {
|
func (c *Cloner) processAsset(ctx context.Context, j assetItem) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tamnd/kage/browser"
|
"github.com/tamnd/kage/browser"
|
||||||
|
"github.com/tamnd/kage/robots"
|
||||||
"github.com/tamnd/kage/urlx"
|
"github.com/tamnd/kage/urlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,6 +179,56 @@ func TestPageKeyCollapsesDuplicates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrawlDelaySpacesPageStarts(t *testing.T) {
|
||||||
|
seed, _ := urlx.ParseSeed("https://ex.com")
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.RespectRobots = true
|
||||||
|
c := New(seed, cfg, nil)
|
||||||
|
c.robots = &robots.Matcher{CrawlDelay: 20 * time.Millisecond}
|
||||||
|
c.setupCrawlDelayLimiter()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if !c.waitForCrawlDelay(ctx) {
|
||||||
|
t.Fatal("first crawl-delay wait returned false")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if !c.waitForCrawlDelay(ctx) {
|
||||||
|
t.Fatal("second crawl-delay wait returned false")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed < 15*time.Millisecond {
|
||||||
|
t.Fatalf("second crawl-delay wait = %v, want at least 15ms", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrawlDelayFlagOverridesRobots(t *testing.T) {
|
||||||
|
seed, _ := urlx.ParseSeed("https://ex.com")
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.RespectRobots = true
|
||||||
|
cfg.CrawlDelay = 20 * time.Millisecond
|
||||||
|
c := New(seed, cfg, nil)
|
||||||
|
c.robots = &robots.Matcher{CrawlDelay: time.Minute}
|
||||||
|
c.setupCrawlDelayLimiter()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if !c.waitForCrawlDelay(ctx) {
|
||||||
|
t.Fatal("first crawl-delay wait returned false")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if !c.waitForCrawlDelay(ctx) {
|
||||||
|
t.Fatal("second crawl-delay wait returned false")
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if elapsed < 15*time.Millisecond {
|
||||||
|
t.Fatalf("second crawl-delay wait = %v, want at least 15ms", elapsed)
|
||||||
|
}
|
||||||
|
if elapsed > 150*time.Millisecond {
|
||||||
|
t.Fatalf("second crawl-delay wait = %v, override likely ignored", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustURL(t *testing.T, raw string) *url.URL {
|
func mustURL(t *testing.T, raw string) *url.URL {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Config struct {
|
|||||||
ExcludePaths []string
|
ExcludePaths []string
|
||||||
|
|
||||||
RespectRobots bool
|
RespectRobots bool
|
||||||
|
CrawlDelay time.Duration // override robots.txt Crawl-delay when > 0
|
||||||
FollowSitemap bool
|
FollowSitemap bool
|
||||||
Headless bool
|
Headless bool
|
||||||
KeepNoscript bool
|
KeepNoscript bool
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ go install github.com/tamnd/kage/cmd/kage@latest
|
|||||||
## Homebrew (macOS)
|
## Homebrew (macOS)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install tamnd/tap/kage
|
brew install --cask tamnd/tap/kage
|
||||||
```
|
```
|
||||||
|
|
||||||
The cask installs the prebuilt macOS binary. On Linux, use the packages below or
|
The cask installs the prebuilt macOS binary. On Linux, use the packages below or
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ images, and fonts, and writes a browsable mirror to `<out>/<host>/`.
|
|||||||
| Flag | Default | Meaning |
|
| Flag | Default | Meaning |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| `--no-robots` | `false` | Ignore `robots.txt` |
|
| `--no-robots` | `false` | Ignore `robots.txt` |
|
||||||
|
| `--crawl-delay` | `0s` | Override robots.txt `Crawl-delay` between page starts (0 = use robots.txt) |
|
||||||
| `--no-sitemap` | `false` | Do not seed URLs from `sitemap.xml` |
|
| `--no-sitemap` | `false` | Do not seed URLs from `sitemap.xml` |
|
||||||
| `--user-agent` | Chrome UA | User-Agent for asset and robots fetches |
|
| `--user-agent` | Chrome UA | User-Agent for asset and robots fetches |
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||||
golang.org/x/image v0.42.0
|
golang.org/x/image v0.42.0
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.56.0
|
||||||
|
golang.org/x/time v0.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
|||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
|||||||
Reference in New Issue
Block a user