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
+103
View File
@@ -0,0 +1,103 @@
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
# Cancel an in-flight run when a branch is pushed again.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Build and test on Linux and macOS with the race detector on. gofmt and vet
# run here too so a formatting slip fails fast on both platforms. The full
# suite includes Chrome-driven end-to-end tests, so the runners install a
# browser first; tests that find none skip themselves, so the job still passes
# if a future runner image drops Chrome.
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
cache: true
- name: install chromium (linux)
if: matrix.os == 'ubuntu-latest'
uses: browser-actions/setup-chrome@v1
id: chrome
- name: gofmt
run: |
unformatted=$(gofmt -s -l asset browser cli clone cmd robots sanitize urlx)
if [ -n "$unformatted" ]; then
echo "These files need gofmt -s -w:"
echo "$unformatted"
exit 1
fi
- name: go vet
run: go vet ./...
- name: build
run: go build ./...
- name: test
env:
KAGE_CHROME: ${{ steps.chrome.outputs.chrome-path }}
run: go test -race -count=1 -coverprofile=coverage.out ./...
- name: coverage summary
if: matrix.os == 'ubuntu-latest'
run: go tool cover -func=coverage.out | tail -1
# golangci-lint bundles staticcheck, govet, ineffassign, errcheck, unused and
# more, so it is the main quality gate.
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
cache: true
- uses: golangci/golangci-lint-action@v8
with:
version: latest
# Scan the module and its dependencies for known vulnerabilities.
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
cache: true
- name: govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
# Confirm go.mod and go.sum are tidy: a PR that adds an import without running
# go mod tidy fails here instead of breaking a later release.
tidy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
cache: true
- name: go mod tidy is clean
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
+135
View File
@@ -0,0 +1,135 @@
name: Docs
on:
push:
branches: [main]
paths:
- "docs/**"
- ".github/workflows/docs.yml"
- "scripts/ensure_cloudflare_pages.py"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
deployments: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
with:
submodules: true
# Sitemap lastmod comes from the latest content commit.
fetch-depth: 0
- name: Checkout tago
uses: actions/checkout@v6.0.2
with:
repository: tamnd/tago
path: .tago-src
fetch-depth: 1
- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: .tago-src/go.mod
cache: false
- name: Cache tago binary
id: tago-bin-cache
uses: actions/cache@v5.0.5
with:
path: /usr/local/bin/tago
key: tago-bin-${{ runner.os }}-${{ hashFiles('.tago-src/go.sum', '.tago-src/**/*.go') }}
- name: Build tago
if: steps.tago-bin-cache.outputs.cache-hit != 'true'
run: |
cd .tago-src
go build -o /usr/local/bin/tago ./cmd/tago/
# Two builds, one per deploy target. The theme links every page and asset
# with absURL, so each output carries its own base path: the Pages build
# gets the /kage/ sub-path, the Cloudflare build gets the domain root.
- name: Build for GitHub Pages
working-directory: docs
run: tago build --base-url "https://tamnd.github.io/kage/" --output public-pages
# tago absURLs theme links but passes markdown content links through, so
# root-relative links in content need the sub-path prefixed for the Pages
# mirror. The Cloudflare build serves from the root and needs none.
- name: Rewrite content links for the Pages sub-path
run: |
find docs/public-pages -name '*.html' -print0 |
xargs -0 perl -pi -e 's|(href=")/(?!/)|${1}/kage/|g; s|(src=")/(?!/)|${1}/kage/|g'
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5.0.0
with:
path: docs/public-pages
- name: Build for Cloudflare Pages
working-directory: docs
run: tago build --base-url "https://kage.tamnd.com/" --output public-cf
- name: Upload Cloudflare artifact
uses: actions/upload-artifact@v7.0.1
with:
name: public-cf
path: docs/public-cf
retention-days: 1
deploy-pages:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5.0.0
deploy-cloudflare:
runs-on: ubuntu-latest
needs: build
concurrency:
group: cloudflare-pages-kage
cancel-in-progress: true
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 1
sparse-checkout: scripts/
- name: Download Cloudflare artifact
uses: actions/download-artifact@v8.0.1
with:
name: public-cf
path: public-cf
- name: Ensure Cloudflare Pages config
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: python3 scripts/ensure_cloudflare_pages.py --project kage --domain kage.tamnd.com --zone tamnd.com
- name: Deploy to Cloudflare Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx -y wrangler@4 pages deploy public-cf \
--project-name=kage \
--branch=main \
--commit-dirty=true
+87
View File
@@ -0,0 +1,87 @@
name: release
# Pushing a version tag turns the GoReleaser config (.goreleaser.yaml) into a
# GitHub release with the archives, Linux packages (deb, rpm, apk), checksums,
# SBOMs and a cosign signature, and pushes the multi-arch container image to
# GHCR. Pull requests and pushes to main run `goreleaser check` only, so a
# config that would fail a real release is caught long before the tag.
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
# Fast gate on every PR and push: the config parses and is valid for the
# installed GoReleaser version. No artifacts are built here.
check:
if: github.ref_type != 'tag'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check
# The real release, only on a version tag.
release:
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write # create the GitHub release
packages: write # push the image to ghcr.io
id-token: write # keyless cosign signing
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
cache: true
# Build and ship the linux/arm64 image from the amd64 runner.
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Tools GoReleaser shells out to for signing and SBOMs.
- uses: sigstore/cosign-installer@v3
- uses: anchore/sbom-action/download-syft@v0
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
# Creating the release and pushing the GHCR image use the built-in
# token. The package-manager publish steps each read their own secret;
# any that is unset leaves that manager skipped (the artifact is still
# produced), so the release never fails for a tap that is not set up.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }}
+192
View File
@@ -0,0 +1,192 @@
# GoReleaser turns one tag push into everything a user might install from: raw
# archives, Linux packages (deb, rpm, apk), a multi-arch container image, and
# entries for the package managers (Homebrew, Scoop). `git tag vX.Y.Z && git
# push --tags` fans out to all of them through .github/workflows/release.yml.
#
# Publish steps that push to a repository we do not own yet (the Homebrew tap,
# the Scoop bucket) self-disable when their token is absent. A release with no
# extra secrets still produces every downloadable artifact and the container
# image; each manager lights up the moment its repository and token exist.
version: 2
project_name: kage
before:
# Only fetch modules; never `go mod tidy` during a release, the tree is kept
# tidy in CI.
hooks:
- go mod download
builds:
- id: kage
binary: kage
main: ./cmd/kage
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X github.com/tamnd/kage/cli.Version={{ .Version }}
- -X github.com/tamnd/kage/cli.Commit={{ .ShortCommit }}
- -X github.com/tamnd/kage/cli.Date={{ .CommitDate }}
mod_timestamp: "{{ .CommitTimestamp }}"
targets:
- linux_amd64
- linux_arm64
- linux_arm_7
- linux_386
- darwin_amd64
- darwin_arm64
- windows_amd64
- windows_arm64
- freebsd_amd64
- freebsd_arm64
archives:
# tar.gz everywhere except a zip on Windows.
- id: default
name_template: "kage_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
nfpms:
# One nfpm definition emits the deb, rpm, and apk for every Linux build. kage
# is a user command, not a daemon, so there is no unit file and no
# postinstall: the package is the binary and its license. Chrome is a runtime
# dependency the user supplies (or the container image bundles).
- id: linux-packages
package_name: kage
file_name_template: "{{ .ConventionalFileName }}"
vendor: tamnd
homepage: https://github.com/tamnd/kage
maintainer: Duc-Tam Nguyen <tamnd87@gmail.com>
description: Clone any website for offline viewing, with the JavaScript stripped out.
license: MIT
formats:
- deb
- rpm
- apk
bindir: /usr/bin
section: utils
recommends:
- chromium
contents:
- src: ./LICENSE
dst: /usr/share/doc/kage/LICENSE
dockers_v2:
# One multi-platform image built with buildx. GoReleaser stages the prebuilt
# binaries under per-platform directories in the build context and the
# Dockerfile copies the right one through $TARGETPLATFORM.
- images:
- ghcr.io/tamnd/kage
tags:
- "{{ .Version }}"
- latest
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
labels:
org.opencontainers.image.title: "{{ .ProjectName }}"
org.opencontainers.image.description: "Clone any website for offline viewing, with the JavaScript stripped out"
org.opencontainers.image.url: "https://github.com/tamnd/kage"
org.opencontainers.image.source: "https://github.com/tamnd/kage"
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
org.opencontainers.image.licenses: "MIT"
homebrew_casks:
# Homebrew cask pushed to the tap repository. Self-disables until
# HOMEBREW_TAP_GITHUB_TOKEN (a PAT with write to tamnd/homebrew-tap) is set,
# so a tokenless release still writes the cask into dist for inspection.
- name: kage
repository:
owner: tamnd
name: homebrew-tap
token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}'
directory: Casks
homepage: https://github.com/tamnd/kage
description: Clone any website for offline viewing, with the JavaScript stripped out
skip_upload: '{{ if envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}'
commit_author:
name: Duc-Tam Nguyen
email: tamnd87@gmail.com
scoops:
# Scoop manifest for Windows, pushed to the bucket repository.
- repository:
owner: tamnd
name: scoop-bucket
token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}'
homepage: https://github.com/tamnd/kage
description: Clone any website for offline viewing, with the JavaScript stripped out
license: MIT
skip_upload: '{{ if envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}'
commit_author:
name: Duc-Tam Nguyen
email: tamnd87@gmail.com
checksum:
name_template: "checksums.txt"
algorithm: sha256
sboms:
# A CycloneDX SBOM per archive via syft; the release workflow installs it.
- id: archive
artifacts: archive
signs:
# Keyless cosign signature over the checksum file. It runs only on a real
# release in CI, where the workflow grants the OIDC token cosign needs.
- cmd: cosign
certificate: "${artifact}.pem"
args:
- sign-blob
- "--output-certificate=${certificate}"
- "--output-signature=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
docker_signs:
- cmd: cosign
artifacts: manifests
args:
- sign
- "${artifact}@${digest}"
- "--yes"
changelog:
sort: asc
use: github
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"
- Merge pull request
- Merge branch
groups:
- title: Features
regexp: '^.*?feat(\(.+\))??!?:.+$'
order: 0
- title: Fixes
regexp: '^.*?fix(\(.+\))??!?:.+$'
order: 1
- title: Other
order: 999
release:
github:
owner: tamnd
name: kage
draft: false
prerelease: auto
+36
View File
@@ -0,0 +1,36 @@
# Consumed by GoReleaser: it copies the already cross-compiled binary out of the
# build context rather than compiling, so the image build is fast and uses the
# same static binary every other artifact ships.
#
# kage always drives a real headless Chrome, so unlike a plain CLI image this one
# bundles Chromium. KAGE_CHROME points kage at the system binary so it never
# tries to download its own.
#
# GoReleaser builds one multi-platform image with buildx and stages each
# platform's binary under a $TARGETPLATFORM directory (e.g. linux/amd64/) in the
# build context, so the COPY line selects the right one through the automatic
# TARGETPLATFORM build arg.
FROM alpine:3.21
ARG TARGETPLATFORM
# chromium for rendering; ca-certificates for HTTPS; tzdata for sane timestamps;
# the font package so rendered pages have glyphs to lay out.
RUN apk add --no-cache chromium ca-certificates tzdata font-noto \
&& adduser -D -H -u 10001 kage \
&& mkdir -p /out \
&& chown kage:kage /out
COPY $TARGETPLATFORM/kage /usr/bin/kage
USER kage
WORKDIR /out
# Point kage at the bundled Chromium and write mirrors under /out by default:
#
# docker run -v "$PWD/out:/out" ghcr.io/tamnd/kage clone example.com
ENV KAGE_CHROME=/usr/bin/chromium-browser
VOLUME ["/out"]
ENTRYPOINT ["/usr/bin/kage"]
+39
View File
@@ -0,0 +1,39 @@
BIN := kage
PKG := ./cmd/kage
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w \
-X github.com/tamnd/kage/cli.Version=$(VERSION) \
-X github.com/tamnd/kage/cli.Commit=$(COMMIT) \
-X github.com/tamnd/kage/cli.Date=$(DATE)
export CGO_ENABLED := 0
.PHONY: build install test test-short vet tidy clean run
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BIN) $(PKG)
install:
go install -ldflags "$(LDFLAGS)" $(PKG)
# Full suite, including the Chrome-driven end-to-end tests.
test:
go test -race ./...
# Skip the tests that launch a real browser (CI without Chrome, quick loops).
test-short:
go test -short ./...
vet:
go vet ./...
tidy:
go mod tidy
clean:
rm -rf bin
run: build
./bin/$(BIN)
+116
View File
@@ -0,0 +1,116 @@
package asset
import (
"bytes"
"net/url"
"sort"
"strings"
"testing"
"github.com/tamnd/kage/urlx"
"golang.org/x/net/html"
)
// testSink builds a RefSink that records every URL it sees and rewrites it to a
// local relative path from fromDir, mirroring what the cloner does.
func testSink(seedHost, fromDir string, seen *[]string) RefSink {
return func(u *url.URL, kind urlx.Kind) string {
*seen = append(*seen, u.String())
if !urlx.SameSite(&url.URL{Host: seedHost}, u, true) && kind == urlx.Page {
return u.String() // external page links stay absolute
}
local := urlx.LocalPath(seedHost, u, kind, "")
return urlx.Rel(fromDir, local)
}
}
func TestRewriteCSS(t *testing.T) {
base, _ := url.Parse("https://ex.com/css/main.css")
in := `@import "base.css";
@import url('https://cdn.io/reset.css');
body { background: url(../img/bg.png); }
.x { background: url("https://cdn.io/sprite.svg"); }
.y { content: url(data:image/gif;base64,AAAA); }`
var seen []string
// CSS lives at _kage/ex.com/css/main.css → its dir.
fromDir := "_kage/ex.com/css"
out := string(RewriteCSS([]byte(in), base, testSink("ex.com", fromDir, &seen)))
if !strings.Contains(out, `@import "base.css"`) {
t.Errorf("local @import not rewritten relative: %s", out)
}
if !strings.Contains(out, `url("../img/bg.png")`) {
t.Errorf("local url() not rewritten relative: %s", out)
}
// Cross-origin asset goes under _kage/cdn.io/...; from _kage/ex.com/css that
// is ../../cdn.io/...
if !strings.Contains(out, `url("../../cdn.io/reset.css")`) {
t.Errorf("cross-origin @import not localised: %s", out)
}
if !strings.Contains(out, `url("../../cdn.io/sprite.svg")`) {
t.Errorf("cross-origin url() not localised: %s", out)
}
// data: URL must be left exactly as-is.
if !strings.Contains(out, "url(data:image/gif;base64,AAAA)") {
t.Errorf("data: URL was altered: %s", out)
}
sort.Strings(seen)
want := []string{
"https://cdn.io/reset.css",
"https://cdn.io/sprite.svg",
"https://ex.com/css/base.css",
"https://ex.com/img/bg.png",
}
if strings.Join(seen, ",") != strings.Join(want, ",") {
t.Errorf("sink saw %v, want %v", seen, want)
}
}
const htmlDoc = `<!doctype html><html><head>
<link rel="stylesheet" href="/css/main.css">
<link rel="icon" href="/favicon.ico">
<link rel="canonical" href="https://ex.com/canon">
</head><body>
<a href="/docs/intro">internal</a>
<a href="https://other.com/x">external</a>
<a href="/files/report.pdf">a pdf</a>
<img src="/img/logo.png" srcset="/img/logo.png 1x, /img/logo@2x.png 2x">
<p style="background:url(/img/bg.png)">hi</p>
<style>.a{background:url(/img/tile.png)}</style>
</body></html>`
func TestRewriteHTML(t *testing.T) {
base, _ := url.Parse("https://ex.com/")
root, err := html.Parse(strings.NewReader(htmlDoc))
if err != nil {
t.Fatal(err)
}
var seen []string
// The page is the root index.html, so fromDir is "".
RewriteHTML(root, base, testSink("ex.com", "", &seen))
var buf bytes.Buffer
if err := html.Render(&buf, root); err != nil {
t.Fatal(err)
}
out := buf.String()
checks := map[string]bool{
`href="_kage/ex.com/css/main.css"`: true, // stylesheet localised
`href="_kage/ex.com/favicon.ico"`: true, // icon localised
`href="https://ex.com/canon"`: true, // canonical left alone
`href="docs/intro/index.html"`: true, // internal page → local
`href="https://other.com/x"`: true, // external page stays absolute
`href="_kage/ex.com/files/report.pdf"`: true, // pdf link treated as asset
`src="_kage/ex.com/img/logo.png"`: true,
`_kage/ex.com/img/logo@2x.png 2x`: true, // srcset candidate rewritten
`background:url(&#34;_kage/ex.com/img/bg.png&#34;)`: true, // inline style (attr-escaped quotes)
`background:url("_kage/ex.com/img/tile.png")`: true, // <style> text
}
for frag, want := range checks {
if strings.Contains(out, frag) != want {
t.Errorf("fragment %q present=%v, want %v\n--- output ---\n%s", frag, !want, want, out)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package asset
import (
"net/url"
"regexp"
"strings"
"github.com/tamnd/kage/urlx"
)
// RefSink registers a resolved asset/page URL with the cloner and returns the
// string to write back into the markup or CSS — a relative local path for
// things kage saves, or the absolute URL for anything it leaves on the live web.
type RefSink func(u *url.URL, kind urlx.Kind) string
var (
// url( ... ) with optional single/double quotes or bare token.
cssURLRe = regexp.MustCompile(`url\(\s*("[^"]*"|'[^']*'|[^)'"]*)\s*\)`)
// @import "..." / @import '...' (the @import url(...) form is caught by cssURLRe).
cssImportRe = regexp.MustCompile(`@import\s+("[^"]*"|'[^']*')`)
)
// RewriteCSS rewrites every url(...) and @import in a stylesheet so its
// references point at local files. base is the stylesheet's own URL (so relative
// references resolve correctly); sink maps each absolute URL to its local path.
// data: URLs and unparseable references are left untouched.
func RewriteCSS(css []byte, base *url.URL, sink RefSink) []byte {
s := string(css)
s = cssImportRe.ReplaceAllStringFunc(s, func(m string) string {
raw := cssImportRe.FindStringSubmatch(m)[1]
ref := unquote(raw)
if newRef, ok := resolveRef(base, ref, sink); ok {
return `@import "` + newRef + `"`
}
return m
})
s = cssURLRe.ReplaceAllStringFunc(s, func(m string) string {
raw := cssURLRe.FindStringSubmatch(m)[1]
ref := unquote(raw)
if newRef, ok := resolveRef(base, ref, sink); ok {
return `url("` + newRef + `")`
}
return m
})
return []byte(s)
}
// resolveRef normalises ref against base and runs it through the sink. It
// reports ok=false (leave the original text) for empty, data:, or unparseable
// references.
func resolveRef(base *url.URL, ref string, sink RefSink) (string, bool) {
ref = strings.TrimSpace(ref)
if ref == "" || strings.HasPrefix(strings.ToLower(ref), "data:") || strings.HasPrefix(ref, "#") {
return "", false
}
u, err := urlx.Normalize(base, ref)
if err != nil {
return "", false
}
return sink(u, urlx.Asset), true
}
func unquote(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] {
return s[1 : len(s)-1]
}
return s
}
+83
View File
@@ -0,0 +1,83 @@
package asset
import (
"context"
"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
}
// 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,
}
}
// Result is a downloaded asset.
type Result struct {
Body []byte
ContentType string
IsCSS bool
}
// 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).
func (d *Downloader) Get(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, fmt.Errorf("status %d for %s", resp.StatusCode, u)
}
var r io.Reader = resp.Body
if d.MaxBytes > 0 {
r = io.LimitReader(resp.Body, d.MaxBytes)
}
body, err := io.ReadAll(r)
if err != nil {
return nil, err
}
ct := resp.Header.Get("Content-Type")
return &Result{
Body: body,
ContentType: ct,
IsCSS: isCSS(ct, u),
}, nil
}
// 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")
}
+147
View File
@@ -0,0 +1,147 @@
package asset
import (
"net/url"
"strings"
"github.com/tamnd/kage/urlx"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// stylesheetRels are <link rel> values whose href kage downloads as an asset.
var stylesheetRels = map[string]bool{
"stylesheet": true, "icon": true, "shortcut icon": true,
"apple-touch-icon": true, "apple-touch-icon-precomposed": true,
"mask-icon": true, "manifest": true, "preload": true, "prefetch": true,
}
// RewriteHTML walks the parsed document and rewrites every resource and link
// reference through sink, resolving relative URLs against base. It mutates the
// tree in place; the caller renders it afterwards. References kage cannot handle
// (data:, mailto:, fragment-only, …) are left untouched.
func RewriteHTML(root *html.Node, base *url.URL, sink RefSink) {
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
rewriteElement(n, base, sink)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
}
func rewriteElement(n *html.Node, base *url.URL, sink RefSink) {
switch n.DataAtom {
case atom.A, atom.Area:
rewriteAttr(n, "href", base, sink, pageOrAsset)
case atom.Iframe, atom.Frame:
rewriteAttr(n, "src", base, sink, pageOrAsset)
case atom.Link:
rel := strings.ToLower(strings.TrimSpace(attrVal(n, "rel")))
if stylesheetRels[rel] {
rewriteAttr(n, "href", base, sink, alwaysAsset)
}
case atom.Img:
rewriteAttr(n, "src", base, sink, alwaysAsset)
rewriteSrcset(n, base, sink)
case atom.Source:
rewriteAttr(n, "src", base, sink, alwaysAsset)
rewriteSrcset(n, base, sink)
case atom.Video:
rewriteAttr(n, "src", base, sink, alwaysAsset)
rewriteAttr(n, "poster", base, sink, alwaysAsset)
case atom.Audio, atom.Track, atom.Embed:
rewriteAttr(n, "src", base, sink, alwaysAsset)
case atom.Object:
rewriteAttr(n, "data", base, sink, alwaysAsset)
case atom.Style:
rewriteStyleText(n, base, sink)
}
// Any element may carry an inline style="" with url() references.
rewriteInlineStyle(n, base, sink)
}
// kindFunc decides whether a normalized URL is a page or an asset.
type kindFunc func(u *url.URL) urlx.Kind
func alwaysAsset(*url.URL) urlx.Kind { return urlx.Asset }
func pageOrAsset(u *url.URL) urlx.Kind {
if urlx.LikelyPage(u) {
return urlx.Page
}
return urlx.Asset
}
func rewriteAttr(n *html.Node, key string, base *url.URL, sink RefSink, kf kindFunc) {
for i := range n.Attr {
if !strings.EqualFold(n.Attr[i].Key, key) {
continue
}
u, err := urlx.Normalize(base, n.Attr[i].Val)
if err != nil {
return
}
n.Attr[i].Val = sink(u, kf(u))
return
}
}
// rewriteSrcset rewrites each candidate URL in a srcset attribute, keeping the
// width/density descriptors intact.
func rewriteSrcset(n *html.Node, base *url.URL, sink RefSink) {
for i := range n.Attr {
if !strings.EqualFold(n.Attr[i].Key, "srcset") {
continue
}
n.Attr[i].Val = rewriteSrcsetValue(n.Attr[i].Val, base, sink)
return
}
}
func rewriteSrcsetValue(val string, base *url.URL, sink RefSink) string {
parts := strings.Split(val, ",")
for i, p := range parts {
fields := strings.Fields(strings.TrimSpace(p))
if len(fields) == 0 {
continue
}
u, err := urlx.Normalize(base, fields[0])
if err != nil {
continue
}
fields[0] = sink(u, urlx.Asset)
parts[i] = strings.Join(fields, " ")
}
return strings.Join(parts, ", ")
}
func rewriteInlineStyle(n *html.Node, base *url.URL, sink RefSink) {
for i := range n.Attr {
if !strings.EqualFold(n.Attr[i].Key, "style") {
continue
}
n.Attr[i].Val = string(RewriteCSS([]byte(n.Attr[i].Val), base, sink))
return
}
}
func rewriteStyleText(n *html.Node, base *url.URL, sink RefSink) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
c.Data = string(RewriteCSS([]byte(c.Data), base, sink))
}
}
}
func attrVal(n *html.Node, key string) string {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val
}
}
return ""
}
+263
View File
@@ -0,0 +1,263 @@
// Package browser drives a real headless Chrome through the DevTools Protocol so
// JavaScript-built pages are captured as they actually render. kage always goes
// through here: navigate, let the page settle, then serialise the final DOM —
// the same markup a human would have seen — which the rest of the pipeline then
// strips of scripts and localises.
package browser
import (
"context"
"fmt"
"os"
"runtime"
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
"github.com/go-rod/stealth"
)
// Options configure a Pool.
type Options struct {
Headless bool // run Chrome without a window
Workers int // max concurrent pages
Settle time.Duration // network-idle quiet period after load
RenderTimeout time.Duration // hard cap per page render
Scroll bool // auto-scroll to trigger lazy-loaded media
ChromeBin string // explicit binary; empty = autodetect
ControlURL string // attach to an existing Chrome instead of launching
}
// DefaultOptions returns the baseline render settings.
func DefaultOptions() Options {
return Options{
Headless: true,
Workers: 4,
Settle: 1500 * time.Millisecond,
RenderTimeout: 30 * time.Second,
}
}
// Pool owns one Chrome process shared across a run and bounds the number of
// pages open at once.
type Pool struct {
opts Options
sem chan struct{}
mu sync.Mutex
browser *rod.Browser
closed bool
}
// New creates a Pool. Chrome is launched lazily on the first Render.
func New(opts Options) *Pool {
if opts.Workers < 1 {
opts.Workers = 1
}
return &Pool{opts: opts, sem: make(chan struct{}, opts.Workers)}
}
// RenderResult is the outcome of rendering one page.
type RenderResult struct {
HTML string // the serialised final DOM
FinalURL string // URL after any client-side redirects
Title string
}
// Render navigates to rawURL, lets it settle, and returns the final rendered
// HTML. It acquires a page slot from the pool and releases it when done.
func (p *Pool) Render(ctx context.Context, rawURL string) (RenderResult, error) {
select {
case p.sem <- struct{}{}:
defer func() { <-p.sem }()
case <-ctx.Done():
return RenderResult{}, ctx.Err()
}
b, err := p.getBrowser()
if err != nil {
return RenderResult{}, err
}
page, err := stealth.Page(b)
if err != nil {
return RenderResult{}, fmt.Errorf("new page: %w", err)
}
defer func() { _ = page.Close() }()
page = page.Context(ctx).Timeout(p.opts.RenderTimeout)
if err := page.Navigate(rawURL); err != nil {
return RenderResult{}, fmt.Errorf("navigate %s: %w", rawURL, err)
}
if err := page.WaitLoad(); err != nil {
return RenderResult{}, fmt.Errorf("wait load %s: %w", rawURL, err)
}
settle(page, p.opts.Settle)
if p.opts.Scroll {
autoScroll(page)
settle(page, p.opts.Settle)
}
html, err := page.HTML()
if err != nil {
return RenderResult{}, fmt.Errorf("serialise %s: %w", rawURL, err)
}
res := RenderResult{HTML: html, FinalURL: rawURL}
if info, err := page.Info(); err == nil && info != nil {
res.FinalURL = info.URL
res.Title = info.Title
}
return res, nil
}
// getBrowser lazily connects to or launches Chrome.
func (p *Pool) getBrowser() (*rod.Browser, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil, fmt.Errorf("pool is closed")
}
if p.browser != nil {
return p.browser, nil
}
controlURL := p.opts.ControlURL
if controlURL == "" {
l := launcher.New().
Headless(p.opts.Headless).
Set("disable-blink-features", "AutomationControlled").
Set("disable-dev-shm-usage", "").
Set("no-sandbox", "").
Set("disable-gpu", "")
if bin := p.chromeBin(); bin != "" {
l = l.Bin(bin)
}
u, err := l.Launch()
if err != nil {
return nil, fmt.Errorf("launch Chrome: %w", err)
}
controlURL = u
}
b := rod.New().ControlURL(controlURL)
if err := b.Connect(); err != nil {
return nil, fmt.Errorf("connect Chrome: %w", err)
}
p.browser = b
return b, nil
}
// Close shuts down the managed Chrome process.
func (p *Pool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
if p.browser == nil {
return nil
}
err := p.browser.Close()
p.browser = nil
return err
}
// LookChrome reports the path of a usable Chrome/Chromium binary and whether one
// was found, checking KAGE_CHROME, CHROME_BIN, rod's own lookup, and the common
// system install locations. Tests use it to skip when no browser is present.
func LookChrome() (string, bool) {
for _, env := range []string{"KAGE_CHROME", "CHROME_BIN"} {
if v := os.Getenv(env); v != "" {
return v, true
}
}
if bin, ok := launcher.LookPath(); ok {
return bin, true
}
for _, c := range systemChromeCandidates() {
if _, err := os.Stat(c); err == nil {
return c, true
}
}
return "", false
}
// chromeBin returns an explicit Chrome path from options or the environment, or
// "" to let the launcher find/download one.
func (p *Pool) chromeBin() string {
if p.opts.ChromeBin != "" {
return p.opts.ChromeBin
}
for _, env := range []string{"KAGE_CHROME", "CHROME_BIN"} {
if v := os.Getenv(env); v != "" {
return v
}
}
for _, c := range systemChromeCandidates() {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
func systemChromeCandidates() []string {
switch runtime.GOOS {
case "darwin":
return []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
}
case "windows":
return []string{
`C:\Program Files\Google\Chrome\Application\chrome.exe`,
`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
}
default:
return []string{
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
}
}
}
// settle waits for the network to go quiet for d, recovering from any rod
// panic and capping the wait so a chatty page can never hang the worker.
func settle(page *rod.Page, d time.Duration) {
if d <= 0 {
return
}
defer func() { _ = recover() }()
done := make(chan struct{})
go func() {
defer func() { _ = recover(); close(done) }()
wait := page.WaitRequestIdle(d, nil, nil, []proto.NetworkResourceType{})
wait()
}()
select {
case <-done:
case <-time.After(d + 5*time.Second):
}
}
// autoScroll scrolls to the bottom in steps to trigger lazy-loaded images.
func autoScroll(page *rod.Page) {
defer func() { _ = recover() }()
_, _ = page.Eval(`() => new Promise((resolve) => {
let total = 0;
const step = 800;
const timer = setInterval(() => {
window.scrollBy(0, step);
total += step;
if (total >= document.body.scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
})`)
}
+52
View File
@@ -0,0 +1,52 @@
package browser
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestLookChromeReadsEnv(t *testing.T) {
t.Setenv("KAGE_CHROME", "/custom/chrome")
bin, ok := LookChrome()
if !ok || bin != "/custom/chrome" {
t.Fatalf("LookChrome() = %q, %v; want /custom/chrome, true", bin, ok)
}
}
func TestRenderCapturesFinalDOM(t *testing.T) {
if testing.Short() {
t.Skip("render test drives Chrome; skipped under -short")
}
if _, ok := LookChrome(); !ok {
t.Skip("no Chrome/Chromium found; skipping render test")
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// A page whose visible content is built by JavaScript: only a real
// browser render captures the injected node.
_, _ = w.Write([]byte(`<!doctype html><html><body>
<div id="app"></div>
<script>document.getElementById("app").textContent = "rendered-by-js";</script>
</body></html>`))
}))
defer srv.Close()
p := New(Options{Headless: true, Workers: 1, Settle: 300 * time.Millisecond, RenderTimeout: 20 * time.Second})
defer func() { _ = p.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
res, err := p.Render(ctx, srv.URL)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(res.HTML, "rendered-by-js") {
t.Errorf("render did not capture the JS-built DOM:\n%s", res.HTML)
}
}
+190
View File
@@ -0,0 +1,190 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/tamnd/kage/clone"
"github.com/tamnd/kage/urlx"
)
// cloneFlags holds the parsed flag values for one invocation.
type cloneFlags struct {
out string
reserved string
workers int
assetWorkers int
browserPages int
maxPages int
maxDepth int
traversal string
maxAssetMB int64
timeout time.Duration
settle time.Duration
renderTO time.Duration
scroll bool
userAgent string
subdomains bool
scopePrefix string
exclude []string
noRobots bool
noSitemap bool
headful bool
keepNoscript bool
chromeBin string
controlURL string
noResume bool
force bool
quiet bool
}
func newCloneCmd() *cobra.Command {
f := &cloneFlags{}
cmd := &cobra.Command{
Use: "clone <url>",
Short: "Clone a site into a self-contained offline folder",
Long: "clone fetches the seed URL, follows in-scope links, and writes a browsable\n" +
"mirror to <out>/<host>/. Every page is rendered in Chrome and stripped of\n" +
"scripts; CSS, images, and fonts are downloaded and rewritten to local paths.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runClone(cmd.Context(), args[0], f)
},
}
fs := cmd.Flags()
fs.StringVarP(&f.out, "out", "o", "kage-out", "output root; the mirror lands in <out>/<host>/")
fs.StringVar(&f.reserved, "reserved", urlx.DefaultReserved, "reserved dir for assets and state")
fs.IntVar(&f.workers, "workers", 4, "concurrent page render workers")
fs.IntVar(&f.assetWorkers, "asset-workers", 8, "concurrent asset download workers")
fs.IntVar(&f.browserPages, "browser-pages", 4, "Chrome page-pool size")
fs.IntVarP(&f.maxPages, "max-pages", "p", 0, "stop after N pages (0 = unlimited)")
fs.IntVarP(&f.maxDepth, "max-depth", "d", 0, "link-follow depth cap (0 = unlimited)")
fs.StringVar(&f.traversal, "traversal", "bfs", "frontier order: bfs or dfs")
fs.Int64Var(&f.maxAssetMB, "max-asset-mb", 25, "skip assets larger than N MB")
fs.DurationVar(&f.timeout, "timeout", 30*time.Second, "per-request timeout")
fs.DurationVar(&f.settle, "settle", 1500*time.Millisecond, "network-idle quiet period before snapshot")
fs.DurationVar(&f.renderTO, "render-timeout", 30*time.Second, "hard cap per page render")
fs.BoolVar(&f.scroll, "scroll", false, "auto-scroll each page to trigger lazy loading")
fs.StringVar(&f.userAgent, "user-agent", clone.DefaultUserAgent, "User-Agent for asset and robots fetches")
fs.BoolVar(&f.subdomains, "subdomains", false, "treat subdomains of the seed host as in scope")
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.BoolVar(&f.noRobots, "no-robots", false, "ignore robots.txt (be careful and polite)")
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.keepNoscript, "keep-noscript", false, "unwrap <noscript> content instead of dropping it")
fs.StringVar(&f.chromeBin, "chrome", "", "path to the Chrome/Chromium binary")
fs.StringVar(&f.controlURL, "control-url", "", "attach to an existing Chrome DevTools endpoint")
fs.BoolVar(&f.noResume, "no-resume", false, "do not reuse or write resume state")
fs.BoolVarP(&f.force, "force", "f", false, "delete any existing mirror for the host first")
fs.BoolVarP(&f.quiet, "quiet", "q", false, "suppress per-page progress lines")
return cmd
}
func runClone(ctx context.Context, arg string, f *cloneFlags) error {
seed, err := urlx.ParseSeed(arg)
if err != nil {
return fmt.Errorf("invalid url %q: %w", arg, err)
}
cfg := clone.DefaultConfig()
cfg.OutDir = f.out
cfg.Reserved = f.reserved
cfg.Workers = f.workers
cfg.AssetWorkers = f.assetWorkers
cfg.BrowserPages = f.browserPages
cfg.MaxPages = f.maxPages
cfg.MaxDepth = f.maxDepth
cfg.Traversal = f.traversal
cfg.MaxAssetBytes = f.maxAssetMB << 20
cfg.Timeout = f.timeout
cfg.Settle = f.settle
cfg.RenderTimeout = f.renderTO
cfg.Scroll = f.scroll
cfg.UserAgent = f.userAgent
cfg.IncludeSubdomains = f.subdomains
cfg.ScopePrefix = f.scopePrefix
cfg.ExcludePaths = f.exclude
cfg.RespectRobots = !f.noRobots
cfg.FollowSitemap = !f.noSitemap
cfg.Headless = !f.headful
cfg.KeepNoscript = f.keepNoscript
cfg.ChromeBin = f.chromeBin
cfg.ControlURL = f.controlURL
cfg.Resume = !f.noResume
cfg.Force = f.force
logf := func(format string, args ...any) {
if !f.quiet {
fmt.Fprintln(os.Stderr, styleDim.Render(fmt.Sprintf(format, args...)))
}
}
fmt.Fprintln(os.Stderr, styleTitle.Render("kage")+" cloning "+styleAccent.Render(seed.String()))
c := clone.New(seed, cfg, logf)
// Live progress ticker on a second line, refreshed every second.
tickCtx, stopTicker := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
defer close(done)
if f.quiet {
return
}
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-tickCtx.Done():
return
case <-t.C:
p := c.Snapshot()
fmt.Fprintf(os.Stderr, "\r%s", progressLine(p))
}
}
}()
res, runErr := c.Run(ctx)
stopTicker()
<-done
if !f.quiet {
fmt.Fprint(os.Stderr, "\r\033[K")
}
printSummary(res)
if runErr != nil && !errors.Is(runErr, context.Canceled) {
return runErr
}
if errors.Is(runErr, context.Canceled) {
fmt.Fprintln(os.Stderr, styleWarn.Render("interrupted; resume state saved (rerun to continue)"))
}
return nil
}
// progressLine renders the single-line live counter.
func progressLine(p clone.Progress) string {
return styleDim.Render(fmt.Sprintf("pages %d assets %d errors %d skipped %d",
p.Pages, p.Assets, p.PageErrors+p.AssetErrors, p.Skipped))
}
// printSummary prints the final tally and where the mirror landed.
func printSummary(res clone.Result) {
fmt.Fprintln(os.Stderr, styleOK.Render("done")+" "+styleTitle.Render(res.OutDir))
fmt.Fprintf(os.Stderr, " %s %d %s %d\n",
styleAccent.Render("pages"), res.Pages,
styleAccent.Render("assets"), res.Assets)
if res.PageErrors+res.AssetErrors > 0 {
fmt.Fprintf(os.Stderr, " %s %d\n", styleErr.Render("errors"), res.PageErrors+res.AssetErrors)
}
if res.Skipped > 0 {
fmt.Fprintf(os.Stderr, " %s %d\n", styleWarn.Render("skipped"), res.Skipped)
}
fmt.Fprintf(os.Stderr, " open %s\n", styleAccent.Render("kage serve "+res.OutDir))
}
+45
View File
@@ -0,0 +1,45 @@
// Package cli wires kage's command surface: the cobra tree, the global flags,
// and the fang-rendered help and errors. The actual work lives in the clone,
// browser, sanitize, asset, and urlx packages; this layer only parses flags and
// prints progress.
package cli
import (
"context"
"fmt"
"github.com/charmbracelet/fang"
"github.com/spf13/cobra"
)
// Execute builds the root command and runs it through fang. main passes the
// signal-aware context so Ctrl-C cancels the in-flight clone and flushes resume
// state. It returns the process exit code.
func Execute(ctx context.Context) int {
root := newRoot()
opts := []fang.Option{
fang.WithVersion(Version),
}
if err := fang.Execute(ctx, root, opts...); err != nil {
return 1
}
return 0
}
// newRoot assembles the command tree.
func newRoot() *cobra.Command {
root := &cobra.Command{
Use: "kage",
Short: "Clone any website for offline viewing, with the JavaScript stripped out",
Long: "kage (影, \"shadow\") renders each page in headless Chrome, snapshots the\n" +
"final DOM, removes every script and event handler, and localises the CSS,\n" +
"images, and fonts so the saved copy looks like the live site but runs no\n" +
"code. The result is a plain folder you can open straight from disk.",
Version: fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, Date),
SilenceUsage: true,
SilenceErrors: true,
}
root.AddCommand(newCloneCmd())
root.AddCommand(newServeCmd())
return root
}
+54
View File
@@ -0,0 +1,54 @@
package cli
import (
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
func newServeCmd() *cobra.Command {
var addr string
cmd := &cobra.Command{
Use: "serve [dir]",
Short: "Preview a cloned folder in your browser",
Long: "serve runs a local static file server over a cloned folder so you can click\n" +
"through the mirror exactly as a visitor would. With no dir it serves the\n" +
"current directory.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dir := "."
if len(args) == 1 {
dir = args[0]
}
return runServe(dir, addr)
},
}
cmd.Flags().StringVarP(&addr, "addr", "a", "127.0.0.1:8800", "address to listen on")
return cmd
}
func runServe(dir, addr string) error {
info, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("cannot serve %q: %w", dir, err)
}
if !info.IsDir() {
return fmt.Errorf("%q is not a directory", dir)
}
abs, _ := filepath.Abs(dir)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("cannot listen on %s: %w", addr, err)
}
srv := &http.Server{Handler: http.FileServer(http.Dir(abs))}
fmt.Fprintln(os.Stderr, styleTitle.Render("kage serve")+" "+styleDim.Render(abs))
fmt.Fprintln(os.Stderr, " open "+styleAccent.Render("http://"+ln.Addr().String()))
fmt.Fprintln(os.Stderr, styleDim.Render(" press Ctrl-C to stop"))
return srv.Serve(ln)
}
+14
View File
@@ -0,0 +1,14 @@
package cli
import "charm.land/lipgloss/v2"
// Styles for the human-readable progress and summary lines. They degrade to
// plain text when the terminal has no colour profile.
var (
styleTitle = lipgloss.NewStyle().Bold(true)
styleAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
)
+9
View File
@@ -0,0 +1,9 @@
package cli
// Build metadata, stamped via -ldflags at release time. goreleaser targets
// github.com/tamnd/kage/cli.{Version,Commit,Date}.
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)
+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
}
+18
View File
@@ -0,0 +1,18 @@
// Command kage clones a website into a self-contained offline folder: it renders
// every page in headless Chrome, strips all JavaScript, and localises the CSS,
// images, and fonts so the saved copy looks like the live site but runs no code.
package main
import (
"context"
"os"
"os/signal"
"github.com/tamnd/kage/cli"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
os.Exit(cli.Execute(ctx))
}
+41
View File
@@ -0,0 +1,41 @@
---
title: "kage"
description: "kage (影, shadow) clones any website into a self-contained folder you can browse offline, with all the JavaScript stripped out. Render in headless Chrome, remove every script, localise the CSS, images, and fonts, from one pure-Go binary."
heroTitle: "A website, frozen as a shadow"
heroLead: "kage renders every page in headless Chrome, snapshots the final DOM, removes every script and event handler, and downloads and rewrites the CSS, images, and fonts. The result looks like the live site but runs no code: a plain folder of .html files you can open straight from disk."
heroPrimaryURL: "/getting-started/quick-start/"
heroPrimaryText: "Get started"
---
Saving a page with "Save As" gives you a copy that still phones home, still runs
analytics, and often renders blank because the markup is built by JavaScript at
runtime. kage (影, "shadow") takes the opposite approach: it drives a real
browser, captures the page the way a human would have seen it, then makes it
inert.
```bash
kage clone example.com
kage serve kage-out/example.com
```
## What it does
- **Renders first, saves second.** Each page goes through real headless Chrome,
so a page whose content is assembled by JavaScript is captured fully, not as
an empty shell.
- **Strips every script.** Once the DOM is captured, kage removes all `<script>`
tags, every `on*` event handler, and any `javascript:` URL. The saved page
makes no network calls and runs no code.
- **Keeps the layout.** Stylesheets, images, fonts, and media are downloaded and
rewritten to relative local paths, so the offline copy looks like the original.
- **Stays browsable.** In-scope links are rewritten to point at the other saved
pages, so you can click around the mirror exactly as you would the live site.
## Where to go next
- New here? Start with the [introduction](/getting-started/introduction/), then
the [quick start](/getting-started/quick-start/).
- Want to install it? See [installation](/getting-started/installation/).
- Looking for a specific task? The [guides](/guides/) cover scoping a crawl,
serving a mirror, and resuming an interrupted run.
- Need every flag? The [CLI reference](/reference/cli/) is the full surface.
+11
View File
@@ -0,0 +1,11 @@
---
title: "Getting started"
linkTitle: "Getting started"
description: "Install kage and clone your first site into a browsable offline folder in under a minute."
weight: 10
featured: true
---
Three short pages: how kage thinks about cloning a site (render, strip, localise),
how to install the binary and point it at a browser, and a guided first run that
ends with a real offline mirror you can click through.
@@ -0,0 +1,63 @@
---
title: "Installation"
description: "Install kage from Go, Homebrew, a release archive, a Linux package, or the container image, and point it at a browser."
weight: 20
---
kage is a single binary. Pick whichever channel suits you.
## Go
```bash
go install github.com/tamnd/kage/cmd/kage@latest
```
## Homebrew
```bash
brew install tamnd/tap/kage
```
## Release archives and Linux packages
Every [release](https://github.com/tamnd/kage/releases) attaches `tar.gz`
archives (and a `.zip` for Windows) for Linux, macOS, Windows, and FreeBSD, plus
`.deb`, `.rpm`, and `.apk` packages and a `checksums.txt` with a cosign
signature. Download the one for your platform, extract `kage`, and put it on your
`PATH`.
```bash
# Debian/Ubuntu
sudo dpkg -i kage_*_linux_amd64.deb
# Fedora/RHEL
sudo rpm -i kage_*_linux_amd64.rpm
```
## Container
The image bundles Chromium, so it needs nothing else:
```bash
docker run -v "$PWD/out:/out" ghcr.io/tamnd/kage clone example.com
```
The mirror lands in `./out/example.com/` on your host.
## You need a browser
kage drives a real Chrome to render pages. Outside the container image, it needs
Chrome or Chromium available on the machine. It looks for a system install
automatically (Google Chrome on macOS and Windows, `google-chrome`/`chromium` on
Linux). To use a specific binary:
```bash
kage clone example.com --chrome /path/to/chromium
# or
export KAGE_CHROME=/path/to/chromium
```
If no browser is found, kage's launcher can download a private copy of Chromium
on first use.
Next: [the quick start](/getting-started/quick-start/).
@@ -0,0 +1,53 @@
---
title: "Introduction"
description: "Why kage renders before it saves, and what it means to strip the JavaScript out of a clone."
weight: 10
---
A normal website is not a document; it is a program. The HTML the server sends
is often a near-empty shell, and the page you actually see is assembled in your
browser by JavaScript: fetching data, building the DOM, wiring up handlers. That
is why "Save As" so often fails. You get the shell, not the page, and whatever
you do get still runs trackers and phones home when you open it.
kage treats a clone as three steps in order.
## 1. Render
Every page is loaded in a real headless Chrome through the DevTools protocol.
kage navigates to the URL, waits for the network to go quiet, optionally scrolls
to trigger lazy-loaded images, and then serialises the **final** DOM, the markup
that exists after the page's JavaScript has finished building it. This is the
same thing you would see if you opened the page and chose "Inspect".
## 2. Strip
From that captured DOM, kage removes everything executable:
- every `<script>` tag, inline or external;
- every `on*` event handler attribute (`onclick`, `onload`, and the rest);
- every `javascript:` URL;
- `<meta http-equiv="refresh">` redirects and dead resource hints like
`<link rel="preload" as="script">`.
What remains is inert. The saved page makes no network calls, runs no code, and
tracks nothing.
## 3. Localise
A page with no working CSS or images is not much of a clone, so kage keeps the
parts that define how it looks. It downloads every stylesheet, image, font, and
media file, rewrites the references in the HTML and inside the CSS (`url()` and
`@import`) to relative local paths, and rewrites in-scope page links to point at
the other saved pages. The mirror is fully self-contained: you can move the
folder anywhere, open it with no network, and click around.
## The shape of a clone
kage crawls breadth-first from a seed URL, staying within the seed's host (and
optionally its subdomains). It is polite by default: it honours `robots.txt` and
seeds itself from `sitemap.xml`. Output lands in `kage-out/<host>/`, with pages
as `<path>/index.html` and assets under a reserved `_kage/` directory alongside
the crawl state that powers `--resume`.
Next: [install kage](/getting-started/installation/).
@@ -0,0 +1,68 @@
---
title: "Quick start"
description: "From an empty terminal to a self-contained offline mirror you can click through."
weight: 30
---
This walks the core loop: clone a small site, look at what landed on disk, and
serve it back so links and assets resolve the way they would on a real host.
## 1. Clone a site
```bash
kage clone example.com
```
kage launches headless Chrome, renders the home page, strips its scripts, and
follows in-scope links breadth-first. A live counter shows pages, assets, and
errors as it goes; the final summary tells you where the mirror landed.
```
kage cloning https://example.com
done kage-out/example.com
pages 12 assets 38
open kage serve kage-out/example.com
```
## 2. Look at what landed
```bash
ls kage-out/example.com
```
```
index.html # the home page, scripts stripped
about/index.html # /about
_kage/ # localised assets and crawl state
```
Open `index.html` directly in a browser and it renders offline, with no network.
Grep it and you will find no `<script>`, no `onclick`, no `javascript:`.
## 3. Serve it back
Opening files directly works, but some sites use root-relative links. `kage
serve` runs a local static server so everything resolves exactly as it would
live:
```bash
kage serve kage-out/example.com
# open http://127.0.0.1:8800
```
## 4. Scope a bigger crawl
For a large site, bound the crawl so it does not run away:
```bash
# Just the docs section, three levels deep, at most 200 pages
kage clone example.com --scope-prefix /docs --max-depth 3 --max-pages 200
```
If you stop a run with Ctrl-C, kage saves its state. Run the same command again
and it resumes, skipping the pages it already wrote.
## Where to go next
- The [guides](/guides/) cover scoping, serving, and resuming in depth.
- The [CLI reference](/reference/cli/) lists every flag.
+11
View File
@@ -0,0 +1,11 @@
---
title: "Guides"
linkTitle: "Guides"
description: "Task-oriented walkthroughs for the things people actually do with kage: scoping a crawl, serving a mirror, and resuming a run."
weight: 20
featured: true
---
Each guide is built around a job rather than a flag: keeping a crawl inside the
lines, viewing what you cloned, and picking up an interrupted run. They assume
you have worked through the [quick start](/getting-started/quick-start/).
+48
View File
@@ -0,0 +1,48 @@
---
title: "Resuming a run"
description: "Pick up an interrupted clone where it left off, and start fresh when you want to."
weight: 30
---
Cloning a large site can take a while, and runs get interrupted: you press
Ctrl-C, your laptop sleeps, the network drops. kage is built to pick up where it
left off.
## How resume works
As it writes each page, kage records it in a small state file inside the mirror,
at `<host>/_kage/state.json`. When a run ends, for any reason, that file holds
the set of pages already written. Resume is **on by default**: the next time you
run the same clone, kage loads the state and skips every page it already wrote,
re-crawling only what is left.
```bash
kage clone example.com
# ... press Ctrl-C partway through ...
# interrupted; resume state saved (rerun to continue)
kage clone example.com
# resume: 137 pages already done
```
Ctrl-C is a clean stop: kage cancels in-flight renders, flushes the state file,
and exits. You will not lose the pages already written.
## Start fresh
To ignore any previous run and rebuild the mirror from scratch, delete the
existing host folder first with `--force`:
```bash
kage clone example.com --force
```
This removes `kage-out/example.com/` before crawling, so nothing from a prior run
carries over.
To run without reading or writing any resume state at all, for a strictly
one-shot clone, use `--no-resume`:
```bash
kage clone example.com --no-resume
```
+73
View File
@@ -0,0 +1,73 @@
---
title: "Scoping a crawl"
description: "Keep a clone inside the lines with depth, page, prefix, subdomain, and exclude controls."
weight: 10
---
By default kage crawls every in-scope page it can reach from the seed, staying on
the seed's host. On a large site that can be a lot of pages. These flags bound
the crawl.
## Limit by count and depth
```bash
# Stop after 200 pages
kage clone example.com --max-pages 200
# Only follow links three hops from the seed
kage clone example.com --max-depth 3
```
`--max-depth 0` (the default) means unlimited depth; `--max-pages 0` means
unlimited pages. Combine them to put a hard ceiling on a run.
## Limit by path
To clone just one section of a site, restrict the crawl to a path prefix:
```bash
kage clone example.com --scope-prefix /docs
```
Only pages whose path starts with `/docs` are followed. Assets are still fetched
from wherever the page references them, so the section renders correctly.
To skip parts of a site, exclude path prefixes (repeatable):
```bash
kage clone example.com --exclude /archive --exclude /tags
```
## Subdomains
By default a clone stays on the exact seed host. To treat subdomains of the seed
as in scope, add `--subdomains`:
```bash
kage clone example.com --subdomains
```
Now `blog.example.com` and `docs.example.com` are crawled too, each landing
under its own host directory inside the mirror.
## Politeness
kage honours `robots.txt` by default and seeds itself from `sitemap.xml`. If you
are cloning a site you control, or you have a reason to ignore the robots rules,
you can turn them off, but do so responsibly:
```bash
kage clone example.com --no-robots --no-sitemap
```
## Lazy-loaded media
Sites that load images as you scroll will only have their above-the-fold images
captured unless you tell kage to scroll each page:
```bash
kage clone example.com --scroll
```
This makes each render a little slower but captures media that only loads on
view.
+48
View File
@@ -0,0 +1,48 @@
---
title: "Serving a mirror"
description: "View a cloned folder the way it would render on a real host, with kage serve."
weight: 20
---
A clone is a plain folder of files, so the simplest way to view it is to open an
`.html` file in your browser. That works for many sites. But some pages use
root-relative URLs (`/style.css`, `/img/logo.png`), which only resolve correctly
when served from the root of a host. `kage serve` gives you that root.
## Serve a clone
```bash
kage serve kage-out/example.com
```
```
kage serve /…/kage-out/example.com
open http://127.0.0.1:8800
press Ctrl-C to stop
```
Open the printed URL and click around the mirror exactly as you would the live
site. Every in-scope link kage rewrote points at another saved page; every asset
resolves to its localised copy.
## Choose an address
By default kage serves on `127.0.0.1:8800`. Change it with `--addr`:
```bash
# A different port
kage serve kage-out/example.com --addr 127.0.0.1:9000
# Reachable from other machines on your network (be deliberate about this)
kage serve kage-out/example.com --addr 0.0.0.0:8800
```
## Serve the current directory
With no argument, `kage serve` serves the current directory, which is handy from
inside an output folder:
```bash
cd kage-out/example.com
kage serve
```
+11
View File
@@ -0,0 +1,11 @@
---
title: "Reference"
linkTitle: "Reference"
description: "The complete kage surface: every command, every flag, and every environment variable."
weight: 30
featured: true
---
The exhaustive reference. The [CLI](/reference/cli/) page lists every command and
flag; the [configuration](/reference/configuration/) page covers environment
variables and the output layout on disk.
+85
View File
@@ -0,0 +1,85 @@
---
title: "CLI reference"
description: "Every kage command and flag."
weight: 10
---
```
kage [command] [flags]
```
Two commands: `clone` fetches a site into an offline folder, `serve` previews
one. Run `kage <command> --help` for the canonical, up-to-date list.
## kage clone
```
kage clone <url> [flags]
```
Renders each page in headless Chrome, strips all JavaScript, localises CSS,
images, and fonts, and writes a browsable mirror to `<out>/<host>/`.
### Output
| Flag | Default | Meaning |
|------|---------|---------|
| `-o, --out` | `kage-out` | Output root; the mirror lands in `<out>/<host>/` |
| `--reserved` | `_kage` | Reserved directory name for assets and crawl state |
| `-f, --force` | `false` | Delete any existing mirror for the host before crawling |
| `--no-resume` | `false` | Do not read or write resume state |
### Scope
| Flag | Default | Meaning |
|------|---------|---------|
| `-p, --max-pages` | `0` | Stop after N pages (0 = unlimited) |
| `-d, --max-depth` | `0` | Link-follow depth cap (0 = unlimited) |
| `--scope-prefix` | | Only crawl pages whose path starts with this prefix |
| `--subdomains` | `false` | Treat subdomains of the seed host as in scope |
| `--exclude` | | Path prefixes to skip (repeatable) |
| `--traversal` | `bfs` | Frontier order: `bfs` or `dfs` |
### Politeness
| Flag | Default | Meaning |
|------|---------|---------|
| `--no-robots` | `false` | Ignore `robots.txt` |
| `--no-sitemap` | `false` | Do not seed URLs from `sitemap.xml` |
| `--user-agent` | Chrome UA | User-Agent for asset and robots fetches |
### Rendering
| Flag | Default | Meaning |
|------|---------|---------|
| `--scroll` | `false` | Auto-scroll each page to trigger lazy loading |
| `--settle` | `1.5s` | Network-idle quiet period before snapshotting the DOM |
| `--render-timeout` | `30s` | Hard cap per page render |
| `--headful` | `false` | Run Chrome with a visible window (debugging) |
| `--chrome` | | Path to the Chrome/Chromium binary |
| `--control-url` | | Attach to an existing Chrome DevTools endpoint |
| `--keep-noscript` | `false` | Unwrap `<noscript>` content instead of dropping it |
### Concurrency and limits
| Flag | Default | Meaning |
|------|---------|---------|
| `--workers` | `4` | Concurrent page render workers |
| `--asset-workers` | `8` | Concurrent asset download workers |
| `--browser-pages` | `4` | Chrome page-pool size |
| `--max-asset-mb` | `25` | Skip assets larger than N MB |
| `--timeout` | `30s` | Per-request timeout |
| `-q, --quiet` | `false` | Suppress per-page progress lines |
## kage serve
```
kage serve [dir] [flags]
```
Runs a local static file server over a cloned folder. With no `dir`, serves the
current directory.
| Flag | Default | Meaning |
|------|---------|---------|
| `-a, --addr` | `127.0.0.1:8800` | Address to listen on |
+54
View File
@@ -0,0 +1,54 @@
---
title: "Configuration"
description: "Environment variables kage reads, and the layout of a cloned mirror on disk."
weight: 20
---
kage is configured almost entirely through command-line flags (see the
[CLI reference](/reference/cli/)). It reads a couple of environment variables for
locating the browser.
## Environment variables
| Variable | Meaning |
|----------|---------|
| `KAGE_CHROME` | Path to the Chrome/Chromium binary. Takes precedence over autodetection. Equivalent to `--chrome`. |
| `CHROME_BIN` | Fallback Chrome path, read if `KAGE_CHROME` is unset. |
If neither is set and no system Chrome is found in the usual install locations,
kage's launcher can download a private copy of Chromium on first use.
## Output layout
A clone of `example.com` lands under `kage-out/example.com/`:
```
kage-out/example.com/
├── index.html # the home page (/), scripts stripped
├── about/index.html # /about
├── blog/
│ ├── index.html # /blog
│ └── a-post/index.html # /blog/a-post
├── _kage/ # reserved directory
│ ├── example.com/
│ │ ├── site.css # localised stylesheet, url() rewritten
│ │ ├── logo.png
│ │ └── fonts/body.woff2
│ ├── cdn.example.com/ # assets from other hosts, by host
│ └── state.json # visited set, for --resume
└── ...
```
Key points:
- **Pages become directories.** A page at `/about` is written as
`about/index.html`, so a link to `/about` resolves to a real file when served.
- **Assets live under the reserved directory.** Everything kage downloads, CSS,
images, fonts, media, goes under `_kage/<asset-host>/`, mirroring the path it
had on its origin. Cross-origin assets are grouped by their own host.
- **Query strings are folded into the filename.** An asset like
`style.css?v=3` is saved with a short hash suffix so two versions never
collide.
- **State lives in the mirror.** `_kage/state.json` records every page written,
which is what makes `--resume` able to skip completed work. Rename the reserved
directory with `--reserved` if `_kage` would clash with a real path on the site.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="3" fill="#0b6e4f"/><path fill="#f6f8fa" d="M7 2.5a4.5 4.5 0 1 0 2.62 8.15l2.36 2.37a.75.75 0 1 0 1.06-1.06l-2.37-2.36A4.5 4.5 0 0 0 7 2.5Zm-3 4.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z"/></svg>

After

Width:  |  Height:  |  Size: 300 B

+10
View File
@@ -0,0 +1,10 @@
title = "kage"
baseURL = "https://kage.tamnd.com/"
description = "kage (影, shadow) clones any website into a self-contained folder you can browse offline, with all the JavaScript stripped out. It renders every page in headless Chrome, removes every script, and localises the CSS, images, and fonts."
contentDir = "content"
outputDir = "public"
theme = "tago-doks"
syntaxHighlight = "true"
[params]
github = "https://github.com/tamnd/kage"
+44
View File
@@ -0,0 +1,44 @@
module github.com/tamnd/kage
go 1.26.3
require (
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
github.com/charmbracelet/fang v1.0.0
github.com/go-rod/rod v0.116.2
github.com/go-rod/stealth v0.4.9
github.com/spf13/cobra v1.10.2
golang.org/x/net v0.56.0
)
require (
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect
github.com/charmbracelet/x/ansi v0.11.0 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.4.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
+96
View File
@@ -0,0 +1,96 @@
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ=
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0=
github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA=
github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU=
github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+218
View File
@@ -0,0 +1,218 @@
// Package robots is a small, pure robots.txt parser and matcher. kage obeys it
// by default so a clone stays polite; --no-robots bypasses the matcher and puts
// the consequences on the user.
package robots
import (
"strconv"
"strings"
"time"
)
// rule is one Allow or Disallow directive.
type rule struct {
pattern string
allow bool
}
// Matcher answers whether a path may be crawled for one user-agent group, and
// carries the sitemaps and crawl-delay declared in the file.
type Matcher struct {
rules []rule
Sitemaps []string
CrawlDelay time.Duration
}
// AllowAll returns a Matcher that permits every path (used when robots is
// disabled or the file is absent).
func AllowAll() *Matcher { return &Matcher{} }
// group accumulates the rules attached to a set of user-agent lines.
type group struct {
agents []string
rules []rule
delay time.Duration
}
// Parse reads robots.txt content and returns a Matcher for the given agent
// token (e.g. "kage"). The most specific matching group is used, falling back to
// the "*" group. Sitemaps are collected globally.
func Parse(data string, agent string) *Matcher {
agent = strings.ToLower(agent)
var groups []*group
var cur *group
var sitemaps []string
startedRules := false
for line := range strings.SplitSeq(data, "\n") {
line = stripComment(line)
key, val, ok := splitField(line)
if !ok {
continue
}
switch strings.ToLower(key) {
case "user-agent":
// A user-agent line after rules starts a fresh group.
if cur == nil || startedRules {
cur = &group{}
groups = append(groups, cur)
startedRules = false
}
cur.agents = append(cur.agents, strings.ToLower(val))
case "allow":
if cur != nil {
cur.rules = append(cur.rules, rule{pattern: val, allow: true})
startedRules = true
}
case "disallow":
if cur != nil {
cur.rules = append(cur.rules, rule{pattern: val, allow: false})
startedRules = true
}
case "crawl-delay":
if cur != nil {
if d := parseDelay(val); d > 0 {
cur.delay = d
}
startedRules = true
}
case "sitemap":
if val != "" {
sitemaps = append(sitemaps, val)
}
}
}
m := &Matcher{Sitemaps: sitemaps}
if g := selectGroup(groups, agent); g != nil {
m.rules = g.rules
m.CrawlDelay = g.delay
}
return m
}
// selectGroup picks the group whose agent best matches: an exact/substring
// match on the agent token beats the wildcard "*" group.
func selectGroup(groups []*group, agent string) *group {
var star, specific *group
bestLen := -1
for _, g := range groups {
for _, a := range g.agents {
if a == "*" {
star = g
continue
}
if strings.HasPrefix(agent, a) || strings.Contains(agent, a) {
if len(a) > bestLen {
bestLen = len(a)
specific = g
}
}
}
}
if specific != nil {
return specific
}
return star
}
// Allowed reports whether path may be crawled. The longest matching rule wins;
// on a tie, Allow beats Disallow. An empty Disallow means "allow everything".
func (m *Matcher) Allowed(path string) bool {
if path == "" {
path = "/"
}
bestLen := -1
bestAllow := true
for _, r := range m.rules {
if r.pattern == "" {
// Empty Disallow = allow all; empty Allow is a no-op.
continue
}
if matchPattern(r.pattern, path) {
pl := len(r.pattern)
if pl > bestLen || (pl == bestLen && r.allow) {
bestLen = pl
bestAllow = r.allow
}
}
}
if bestLen < 0 {
return true
}
return bestAllow
}
// matchPattern matches a robots path pattern against path, honouring '*'
// (any run) and a trailing '$' (end anchor).
func matchPattern(pattern, path string) bool {
anchored := strings.HasSuffix(pattern, "$")
if anchored {
pattern = pattern[:len(pattern)-1]
}
segs := strings.Split(pattern, "*")
pos := 0
for i, seg := range segs {
if seg == "" {
continue
}
if i == 0 {
if !strings.HasPrefix(path[pos:], seg) {
return false
}
pos += len(seg)
continue
}
idx := strings.Index(path[pos:], seg)
if idx < 0 {
return false
}
pos += idx + len(seg)
}
if anchored {
// The last non-empty segment must land exactly at the end.
last := ""
for i := len(segs) - 1; i >= 0; i-- {
if segs[i] != "" {
last = segs[i]
break
}
}
if last != "" && !strings.HasSuffix(path, last) {
return false
}
// A pattern with no trailing wildcard must consume the whole path.
if !strings.HasSuffix(pattern, "*") && pos != len(path) {
return false
}
}
return true
}
func stripComment(line string) string {
if before, _, found := strings.Cut(line, "#"); found {
return before
}
return line
}
func splitField(line string) (key, val string, ok bool) {
k, v, found := strings.Cut(line, ":")
if !found {
return "", "", false
}
key = strings.TrimSpace(k)
val = strings.TrimSpace(v)
if key == "" {
return "", "", false
}
return key, val, true
}
func parseDelay(val string) time.Duration {
secs, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
if err != nil || secs <= 0 {
return 0
}
return time.Duration(secs * float64(time.Second))
}
+73
View File
@@ -0,0 +1,73 @@
package robots
import "testing"
const sample = `
# example robots
User-agent: *
Disallow: /private/
Allow: /private/public
Disallow: /*.json$
Crawl-delay: 2
User-agent: kage
Disallow: /no-kage/
Sitemap: https://ex.com/sitemap.xml
Sitemap: https://ex.com/news.xml
`
func TestParseAndAllowedSpecificAgent(t *testing.T) {
m := Parse(sample, "kage")
// kage's own group only disallows /no-kage/.
if m.Allowed("/private/secret") == false {
t.Error("kage group should allow /private/secret (only /no-kage/ is blocked)")
}
if m.Allowed("/no-kage/x") {
t.Error("/no-kage/x should be disallowed for kage")
}
}
func TestAllowedWildcardGroup(t *testing.T) {
m := Parse(sample, "somebot")
cases := map[string]bool{
"/": true,
"/private/x": false, // Disallow /private/
"/private/public": true, // longer Allow wins on tie/length
"/data/file.json": false, // Disallow /*.json$
"/data/file.jsonx": true, // $ anchors the extension
"/ok": true,
}
for p, want := range cases {
if got := m.Allowed(p); got != want {
t.Errorf("Allowed(%q) = %v, want %v", p, got, want)
}
}
}
func TestSitemapsAndDelay(t *testing.T) {
m := Parse(sample, "somebot")
if len(m.Sitemaps) != 2 {
t.Fatalf("got %d sitemaps, want 2", len(m.Sitemaps))
}
if m.Sitemaps[0] != "https://ex.com/sitemap.xml" {
t.Errorf("sitemap[0] = %q", m.Sitemaps[0])
}
if m.CrawlDelay.Seconds() != 2 {
t.Errorf("crawl-delay = %v, want 2s", m.CrawlDelay)
}
}
func TestEmptyDisallowAllowsAll(t *testing.T) {
m := Parse("User-agent: *\nDisallow:\n", "x")
if !m.Allowed("/anything") {
t.Error("empty Disallow should allow everything")
}
}
func TestNoRobotsAllowsAll(t *testing.T) {
m := AllowAll()
if !m.Allowed("/whatever") {
t.Error("AllowAll must allow everything")
}
}
+220
View File
@@ -0,0 +1,220 @@
// Package sanitize removes every trace of JavaScript from an HTML document so
// the saved page is inert: a photograph, not a program.
//
// It parses with golang.org/x/net/html, walks the tree, and deletes scripts,
// event handlers, javascript: URLs, and the dead preconnect/preload hints that
// mean nothing offline — while leaving styles, images, fonts, forms, and all
// semantic markup untouched so the layout survives intact.
package sanitize
import (
"bytes"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// Options tune a few edge behaviours; the zero value is the safe default
// (scripts and noscript removed, meta-refresh removed).
type Options struct {
// KeepNoscript unwraps <noscript> content into the document instead of
// deleting it, for sites whose real content hides behind a JS check.
KeepNoscript bool
// KeepMetaRefresh preserves a plain timed <meta http-equiv="refresh">
// (a JS-target refresh is always removed).
KeepMetaRefresh bool
// Banner, when non-empty, is inserted as an HTML comment at the top of the
// document.
Banner string
}
// Report counts what was removed, for the run summary and for tests.
type Report struct {
ScriptsRemoved int
HandlersRemoved int
NoscriptRemoved int
NoscriptUnwrapped int
JSURLsNeutralized int
MetaRefreshRemoved int
DeadLinksRemoved int
}
// jsURLAttrs are attributes whose value may be a javascript: URL.
var jsURLAttrs = map[string]bool{
"href": true, "src": true, "action": true, "formaction": true,
"poster": true, "data": true, "background": true, "xlink:href": true,
}
// Strip parses doc, removes all JavaScript, and returns the rewritten HTML plus
// a Report. A parse error is returned unchanged to the caller.
func Strip(doc []byte, opts Options) ([]byte, Report, error) {
root, err := html.Parse(bytes.NewReader(doc))
if err != nil {
return nil, Report{}, err
}
rep := CleanTree(root, opts)
var buf bytes.Buffer
if err := html.Render(&buf, root); err != nil {
return nil, rep, err
}
return buf.Bytes(), rep, nil
}
// CleanTree removes all JavaScript from an already-parsed document in place and
// returns the Report. The cloner uses this so the HTML is parsed only once and
// shared with the asset rewriter.
func CleanTree(root *html.Node, opts Options) Report {
var rep Report
clean(root, opts, &rep)
if opts.Banner != "" {
insertBanner(root, opts.Banner)
}
return rep
}
// clean walks n's children, removing or scrubbing each before recursing.
func clean(n *html.Node, opts Options, rep *Report) {
var next *html.Node
for c := n.FirstChild; c != nil; c = next {
next = c.NextSibling
if c.Type == html.ElementNode {
switch c.DataAtom {
case atom.Script:
n.RemoveChild(c)
rep.ScriptsRemoved++
continue
case atom.Noscript:
if opts.KeepNoscript {
unwrapNoscript(n, c)
rep.NoscriptUnwrapped++
} else {
n.RemoveChild(c)
rep.NoscriptRemoved++
}
continue
case atom.Meta:
if isMetaRefresh(c) && (!opts.KeepMetaRefresh || isJSRefresh(c)) {
n.RemoveChild(c)
rep.MetaRefreshRemoved++
continue
}
case atom.Link:
if isDeadLink(c) {
n.RemoveChild(c)
rep.DeadLinksRemoved++
continue
}
}
stripHandlers(c, rep)
neutralizeJSURLs(c, rep)
}
clean(c, opts, rep)
}
}
// stripHandlers removes every on* event-handler attribute from n.
func stripHandlers(n *html.Node, rep *Report) {
kept := n.Attr[:0]
for _, a := range n.Attr {
if len(a.Key) > 2 && strings.HasPrefix(strings.ToLower(a.Key), "on") {
rep.HandlersRemoved++
continue
}
kept = append(kept, a)
}
n.Attr = kept
}
// neutralizeJSURLs replaces javascript: URLs: links become "#", other carriers
// lose the attribute entirely.
func neutralizeJSURLs(n *html.Node, rep *Report) {
kept := n.Attr[:0]
for _, a := range n.Attr {
key := strings.ToLower(a.Key)
if jsURLAttrs[key] && strings.HasPrefix(strings.ToLower(strings.TrimSpace(a.Val)), "javascript:") {
rep.JSURLsNeutralized++
if key == "href" {
a.Val = "#"
kept = append(kept, a)
}
// non-href carriers: drop the attribute.
continue
}
kept = append(kept, a)
}
n.Attr = kept
}
// isMetaRefresh reports whether n is a <meta http-equiv="refresh">.
func isMetaRefresh(n *html.Node) bool {
return strings.EqualFold(attr(n, "http-equiv"), "refresh")
}
// isJSRefresh reports whether a meta-refresh target is a javascript: URL.
func isJSRefresh(n *html.Node) bool {
return strings.Contains(strings.ToLower(attr(n, "content")), "javascript:")
}
// isDeadLink reports whether a <link> is a resource hint that is useless or
// script-bound offline: preconnect, dns-prefetch, modulepreload, or a
// preload/prefetch that targets a script.
func isDeadLink(n *html.Node) bool {
for r := range strings.FieldsSeq(strings.ToLower(attr(n, "rel"))) {
switch r {
case "preconnect", "dns-prefetch", "modulepreload":
return true
case "preload", "prefetch":
as := strings.ToLower(attr(n, "as"))
href := strings.ToLower(attr(n, "href"))
if as == "script" || strings.HasSuffix(href, ".js") {
return true
}
}
}
return false
}
// unwrapNoscript replaces a <noscript> with its content. Because x/net/html
// parses noscript content as raw text (scripting enabled), the text is
// re-parsed as a fragment in the parent's context and spliced in before the
// noscript node, which is then removed.
func unwrapNoscript(parent, ns *html.Node) {
var raw strings.Builder
for c := ns.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
raw.WriteString(c.Data)
}
}
frag, err := html.ParseFragment(strings.NewReader(raw.String()), &html.Node{
Type: html.ElementNode,
Data: "body",
DataAtom: atom.Body,
})
if err == nil {
for _, fn := range frag {
parent.InsertBefore(fn, ns)
}
}
parent.RemoveChild(ns)
}
// insertBanner prepends an HTML comment to the document.
func insertBanner(root *html.Node, text string) {
c := &html.Node{Type: html.CommentNode, Data: " " + text + " "}
if root.FirstChild != nil {
root.InsertBefore(c, root.FirstChild)
} else {
root.AppendChild(c)
}
}
// attr returns the value of n's attribute key (case-insensitive), or "".
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val
}
}
return ""
}
+126
View File
@@ -0,0 +1,126 @@
package sanitize
import (
"strings"
"testing"
)
const page = `<!doctype html>
<html><head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="5;url=https://ex.com/next">
<title>Hi</title>
<link rel="stylesheet" href="/css/main.css">
<link rel="preconnect" href="https://cdn.io">
<link rel="modulepreload" href="/app.js">
<link rel="preload" as="script" href="/runtime.js">
<style>.a{color:red}</style>
<script src="/vendor.js"></script>
<script>window.x=1</script>
</head>
<body onload="boot()">
<h1 onclick="go()">Title</h1>
<a href="javascript:open()">js link</a>
<a href="/real">real link</a>
<img src="/logo.png" onerror="fail()">
<form action="/submit"><input name="q"></form>
<noscript><p>need js</p></noscript>
<p style="background:url(/bg.png)">styled</p>
</body></html>`
func TestStripRemovesAllJS(t *testing.T) {
out, rep, err := Strip([]byte(page), Options{Banner: "cloned by kage"})
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "<script") {
t.Error("a <script> survived")
}
if strings.Contains(s, "onload") || strings.Contains(s, "onclick") || strings.Contains(s, "onerror") {
t.Error("an on* handler survived")
}
if strings.Contains(strings.ToLower(s), "javascript:") {
t.Error("a javascript: URL survived")
}
if strings.Contains(s, "modulepreload") || strings.Contains(s, "preconnect") {
t.Error("a dead resource hint survived")
}
if strings.Contains(s, "http-equiv") {
t.Error("a meta refresh survived")
}
// Layout-bearing markup must survive untouched.
for _, want := range []string{
`rel="stylesheet"`, `href="/css/main.css"`,
`<style>`, `color:red`,
`src="/logo.png"`, `<form action="/submit">`,
`background:url(/bg.png)`, `href="/real"`,
"<!-- cloned by kage -->",
} {
if !strings.Contains(s, want) {
t.Errorf("expected %q to survive, output:\n%s", want, s)
}
}
// The js link keeps an anchor but points nowhere dangerous.
if !strings.Contains(s, `href="#"`) {
t.Error("javascript: link should be neutralized to href=#")
}
if rep.ScriptsRemoved != 2 {
t.Errorf("ScriptsRemoved = %d, want 2", rep.ScriptsRemoved)
}
if rep.HandlersRemoved != 3 {
t.Errorf("HandlersRemoved = %d, want 3", rep.HandlersRemoved)
}
if rep.JSURLsNeutralized != 1 {
t.Errorf("JSURLsNeutralized = %d, want 1", rep.JSURLsNeutralized)
}
if rep.MetaRefreshRemoved != 1 {
t.Errorf("MetaRefreshRemoved = %d, want 1", rep.MetaRefreshRemoved)
}
if rep.DeadLinksRemoved != 3 {
t.Errorf("DeadLinksRemoved = %d, want 3", rep.DeadLinksRemoved)
}
if rep.NoscriptRemoved != 1 {
t.Errorf("NoscriptRemoved = %d, want 1", rep.NoscriptRemoved)
}
}
func TestKeepNoscriptUnwraps(t *testing.T) {
in := `<html><body><noscript><p>fallback text</p></noscript></body></html>`
out, rep, err := Strip([]byte(in), Options{KeepNoscript: true})
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "<noscript") {
t.Error("noscript wrapper should be gone")
}
if !strings.Contains(s, "fallback text") {
t.Errorf("unwrapped content missing: %s", s)
}
if rep.NoscriptUnwrapped != 1 {
t.Errorf("NoscriptUnwrapped = %d, want 1", rep.NoscriptUnwrapped)
}
}
func TestKeepMetaRefreshPlain(t *testing.T) {
in := `<html><head><meta http-equiv="refresh" content="5;url=/next"></head><body></body></html>`
out, _, err := Strip([]byte(in), Options{KeepMetaRefresh: true})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "http-equiv") {
t.Error("plain meta refresh should be kept when KeepMetaRefresh is set")
}
// A JS-target refresh is removed even when KeepMetaRefresh is set.
js := `<html><head><meta http-equiv="refresh" content="0;url=javascript:alert(1)"></head><body></body></html>`
out2, _, _ := Strip([]byte(js), Options{KeepMetaRefresh: true})
if strings.Contains(strings.ToLower(string(out2)), "javascript:") {
t.Error("JS-target meta refresh must be removed regardless")
}
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Idempotently configure a Cloudflare Pages project, custom domain, and DNS."""
from __future__ import annotations
import argparse
import json
import os
import urllib.error
import urllib.parse
import urllib.request
API = "https://api.cloudflare.com/client/v4"
def request(method: str, url: str, token: str, payload: dict[str, object] | None = None) -> dict[str, object]:
data = None if payload is None else json.dumps(payload).encode()
req = urllib.request.Request(
url,
data=data,
method=method,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode()
except urllib.error.HTTPError as exc:
body = exc.read().decode()
try:
parsed = json.loads(body) if body else {}
except json.JSONDecodeError:
parsed = {"raw": body}
raise SystemExit(f"{method} {url} failed: HTTP {exc.code} {parsed}") from exc
parsed = json.loads(body) if body else {}
if not parsed.get("success", True):
raise SystemExit(f"{method} {url} failed: {parsed}")
return parsed
def ensure_project(account_id: str, token: str, project: str, branch: str) -> str:
url = f"{API}/accounts/{account_id}/pages/projects/{project}"
try:
result = request("GET", url, token)
print(f"project exists: {project}")
return result["result"]["subdomain"]
except SystemExit as exc:
if "HTTP 404" not in str(exc):
raise
result = request(
"POST",
f"{API}/accounts/{account_id}/pages/projects",
token,
{"name": project, "production_branch": branch},
)
print(f"created project: {project}")
return result["result"]["subdomain"]
def ensure_custom_domain(account_id: str, token: str, project: str, domain: str) -> None:
base = f"{API}/accounts/{account_id}/pages/projects/{project}/domains"
try:
request("GET", f"{base}/{domain}", token)
print(f"domain exists: {domain}")
return
except SystemExit as exc:
if "HTTP 404" not in str(exc):
raise
request("POST", base, token, {"name": domain})
print(f"attached domain: {domain}")
def find_zone(token: str, zone_name: str) -> str:
query = urllib.parse.urlencode({"name": zone_name})
result = request("GET", f"{API}/zones?{query}", token)
zones = result.get("result") or []
if not zones:
raise SystemExit(f"zone not found: {zone_name}")
return zones[0]["id"]
def ensure_dns(token: str, zone_name: str, domain: str, target: str) -> None:
zone_id = find_zone(token, zone_name)
base = f"{API}/zones/{zone_id}/dns_records"
query = urllib.parse.urlencode({"name": domain})
result = request("GET", f"{base}?{query}", token)
payload = {"type": "CNAME", "name": domain, "content": target, "ttl": 1, "proxied": True}
matches = result.get("result") or []
if matches:
record_id = matches[0]["id"]
record = matches[0]
if (
record.get("type") == payload["type"]
and record.get("content") == payload["content"]
and record.get("proxied") is True
):
print(f"DNS exists: {domain} -> {target}")
return
request("PUT", f"{base}/{record_id}", token, payload)
print(f"updated DNS: {domain} -> {target}")
return
request("POST", base, token, payload)
print(f"created DNS: {domain} -> {target}")
def default_zone(domain: str) -> str:
parts = domain.split(".")
if len(parts) < 2:
raise SystemExit(f"cannot infer zone from domain: {domain}")
return ".".join(parts[-2:])
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--project", required=True)
parser.add_argument("--domain")
parser.add_argument("--zone")
parser.add_argument("--branch", default="main")
parser.add_argument("--dns-target")
args = parser.parse_args()
account_id = os.environ["CLOUDFLARE_ACCOUNT_ID"]
token = os.environ["CLOUDFLARE_API_TOKEN"]
# The pages.dev subdomain is not always {project}.pages.dev: when the
# name is taken globally, Cloudflare assigns a suffixed one. A CNAME at
# the guessed name points at a stranger's project and the custom domain
# never validates, so always ask the API for the real subdomain.
subdomain = ensure_project(account_id, token, args.project, args.branch)
if args.domain:
target = args.dns_target or subdomain
zone = args.zone or default_zone(args.domain)
ensure_custom_domain(account_id, token, args.project, args.domain)
ensure_dns(token, zone, args.domain, target)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+318
View File
@@ -0,0 +1,318 @@
// Package urlx is the URL ⇄ filesystem contract at the heart of kage.
//
// Every reference kage meets — a page link, a stylesheet, an image, a font —
// is funnelled through Normalize so that two different-looking URLs that point
// at the same resource collapse to one canonical key. LocalPath then maps that
// canonical URL to a deterministic path on disk, and Rel turns two such paths
// into the relative link that goes back into the rewritten HTML or CSS.
//
// The package is pure: no network, no filesystem, no clock. That is what makes
// the rest of kage easy to reason about — a page worker can rewrite a link to
// an asset long before the asset has been downloaded, because both sides agree
// on where the bytes will live.
package urlx
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"path"
"strings"
)
// Kind distinguishes a crawlable page from a downloadable asset; the two map to
// different places on disk (pages mirror the URL path, assets live under the
// reserved prefix).
type Kind int
const (
// Page is an HTML document kage renders and rewrites.
Page Kind = iota
// Asset is a stylesheet, image, font, or media file kage downloads verbatim.
Asset
)
// DefaultReserved is the directory under the mirror root where every asset and
// kage's own state live. It is deliberately unlikely to collide with a real URL
// path segment.
const DefaultReserved = "_kage"
// binaryExts are extensions that, when seen on an <a href>, mean the link points
// at a file to download rather than a page to render.
var binaryExts = map[string]bool{
".pdf": true, ".zip": true, ".gz": true, ".tar": true, ".tgz": true,
".rar": true, ".7z": true, ".dmg": true, ".exe": true, ".msi": true,
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true,
".svg": true, ".ico": true, ".bmp": true, ".avif": true,
".mp3": true, ".mp4": true, ".webm": true, ".mov": true, ".avi": true,
".wav": true, ".ogg": true, ".flac": true,
".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".eot": true,
".css": true, ".js": true, ".json": true, ".xml": true, ".rss": true,
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true,
".pptx": true, ".csv": true, ".txt": true,
}
// ParseSeed turns a command-line argument like "example.com",
// "https://example.com/docs" or "http://ex.com" into a canonical absolute URL.
// A bare host (no scheme) is assumed to be https.
func ParseSeed(arg string) (*url.URL, error) {
s := strings.TrimSpace(arg)
if s == "" {
return nil, fmt.Errorf("empty seed")
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
u, err := url.Parse(s)
if err != nil {
return nil, fmt.Errorf("parse seed %q: %w", arg, err)
}
if u.Host == "" {
return nil, fmt.Errorf("seed %q has no host", arg)
}
return canonical(u, true), nil
}
// Normalize resolves ref against base and canonicalises the result. It returns
// an error for references kage cannot crawl or download: empty, fragment-only,
// or a non-http(s) scheme (mailto:, tel:, data:, javascript:, blob:, …).
func Normalize(base *url.URL, ref string) (*url.URL, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return nil, fmt.Errorf("empty reference")
}
if strings.HasPrefix(ref, "#") {
return nil, fmt.Errorf("fragment-only reference")
}
low := strings.ToLower(ref)
for _, bad := range []string{"javascript:", "mailto:", "tel:", "data:", "blob:", "about:", "ftp:", "sms:"} {
if strings.HasPrefix(low, bad) {
return nil, fmt.Errorf("non-crawlable scheme: %s", bad)
}
}
r, err := url.Parse(ref)
if err != nil {
return nil, fmt.Errorf("parse ref %q: %w", ref, err)
}
var abs *url.URL
if base != nil {
abs = base.ResolveReference(r)
} else {
abs = r
}
if abs.Scheme != "http" && abs.Scheme != "https" {
return nil, fmt.Errorf("non-http scheme: %q", abs.Scheme)
}
if abs.Host == "" {
return nil, fmt.Errorf("reference has no host")
}
return canonical(abs, false), nil
}
// canonical lower-cases scheme/host, drops the fragment and any default port,
// and cleans the path while preserving a meaningful trailing slash. When root
// is true an empty path becomes "/".
func canonical(u *url.URL, root bool) *url.URL {
c := *u
c.Scheme = strings.ToLower(c.Scheme)
c.Host = strings.ToLower(c.Host)
c.Fragment = ""
// Strip a default port.
if h := c.Hostname(); h != "" {
switch {
case c.Scheme == "http" && c.Port() == "80":
c.Host = h
case c.Scheme == "https" && c.Port() == "443":
c.Host = h
}
}
if c.Path == "" {
if root {
c.Path = "/"
}
} else {
trailing := strings.HasSuffix(c.Path, "/")
cleaned := path.Clean(c.Path)
if cleaned == "." {
cleaned = "/"
}
if trailing && cleaned != "/" {
cleaned += "/"
}
c.Path = cleaned
}
return &c
}
// Key is the canonical string form used to dedup pages and assets.
func Key(u *url.URL) string { return u.String() }
// ScopeConfig controls which page URLs are in scope for crawling.
type ScopeConfig struct {
IncludeSubdomains bool
ScopePrefix string // only crawl paths under this prefix, e.g. "/docs/"
ExcludePaths []string // skip any path containing one of these substrings
}
// SameSite reports whether u belongs to the seed's site: the same host, or a
// subdomain of it when allowSub is set.
func SameSite(seed, u *url.URL, allowSub bool) bool {
sh, uh := seed.Hostname(), u.Hostname()
if sh == uh {
return true
}
if allowSub && strings.HasSuffix(uh, "."+sh) {
return true
}
return false
}
// InScope reports whether u should be crawled as a page given the seed and cfg.
func InScope(seed, u *url.URL, cfg ScopeConfig) bool {
if !SameSite(seed, u, cfg.IncludeSubdomains) {
return false
}
if cfg.ScopePrefix != "" && !strings.HasPrefix(u.Path, cfg.ScopePrefix) {
return false
}
for _, ex := range cfg.ExcludePaths {
if ex != "" && strings.Contains(u.Path, ex) {
return false
}
}
return true
}
// LikelyPage reports whether an <a href> target should be rendered as a page
// rather than downloaded as a file. Links ending in a known binary/document
// extension are treated as assets.
func LikelyPage(u *url.URL) bool {
ext := strings.ToLower(path.Ext(lastSegment(u.Path)))
return !binaryExts[ext]
}
// LocalPath maps a canonical URL to a slash-separated path relative to the
// mirror root (out/<seedHost>/). Pages mirror the URL path as a directory index;
// assets live under reserved/<host>/<path>. See spec §4.3.
func LocalPath(seedHost string, u *url.URL, kind Kind, reserved string) string {
if reserved == "" {
reserved = DefaultReserved
}
host := u.Hostname()
switch kind {
case Asset:
dir, base := splitAsset(u)
base = applyQuery(base, u)
return joinClean(reserved, host, dir, base)
default: // Page
dir := strings.Trim(u.Path, "/")
leaf := applyQuery("index.html", u)
if strings.EqualFold(host, seedHost) {
return joinClean(dir, leaf)
}
// Subdomain pages get a host segment so they never collide with the
// seed host's tree.
return joinClean(host, dir, leaf)
}
}
// splitAsset breaks an asset URL path into a directory and a filename, inventing
// a filename for directory-like or empty paths.
func splitAsset(u *url.URL) (dir, base string) {
clean := strings.Trim(u.Path, "/")
switch {
case clean == "":
return "", "index"
case strings.HasSuffix(u.Path, "/"):
return clean, "index"
default:
if i := strings.LastIndex(clean, "/"); i >= 0 {
return clean[:i], clean[i+1:]
}
return "", clean
}
}
// applyQuery folds a URL's query string into a filename as "__q-<hash>",
// inserting it before the extension so the file keeps a sensible suffix.
func applyQuery(name string, u *url.URL) string {
if u.RawQuery == "" {
return name
}
sum := sha256.Sum256([]byte(u.RawQuery))
suffix := "__q-" + hex.EncodeToString(sum[:])[:6]
if dot := strings.LastIndex(name, "."); dot > 0 {
return name[:dot] + suffix + name[dot:]
}
return name + suffix
}
// Rel returns the relative link from the directory of fromFile to toFile, both
// slash paths relative to the mirror root. The result always uses '/'.
func Rel(fromDir, toFile string) string {
fromDir = path.Clean("/" + strings.TrimPrefix(fromDir, "/"))
toFile = path.Clean("/" + strings.TrimPrefix(toFile, "/"))
rel := relPath(fromDir, toFile)
if rel == "" {
return "."
}
return rel
}
// relPath computes a relative path between two cleaned absolute slash paths,
// treating 'from' as a directory.
func relPath(fromDir, toFile string) string {
from := splitNonEmpty(fromDir)
to := splitNonEmpty(toFile)
// Common prefix.
i := 0
for i < len(from) && i < len(to) && from[i] == to[i] {
i++
}
var out []string
for range from[i:] {
out = append(out, "..")
}
out = append(out, to[i:]...)
return strings.Join(out, "/")
}
func splitNonEmpty(p string) []string {
var out []string
for s := range strings.SplitSeq(p, "/") {
if s != "" {
out = append(out, s)
}
}
return out
}
// Dir returns the directory portion of a slash file path.
func Dir(file string) string {
if i := strings.LastIndex(file, "/"); i >= 0 {
return file[:i]
}
return ""
}
// joinClean joins path elements with '/', dropping empties and cleaning the
// result, and never returns a leading slash.
func joinClean(elems ...string) string {
var parts []string
for _, e := range elems {
e = strings.Trim(e, "/")
if e != "" {
parts = append(parts, e)
}
}
return strings.Join(parts, "/")
}
func lastSegment(p string) string {
p = strings.TrimRight(p, "/")
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[i+1:]
}
return p
}
+247
View File
@@ -0,0 +1,247 @@
package urlx
import (
"net/url"
"strings"
"testing"
)
func mustParse(t *testing.T, s string) *url.URL {
t.Helper()
u, err := url.Parse(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return u
}
func TestParseSeed(t *testing.T) {
cases := []struct {
in string
want string
err bool
}{
{"example.com", "https://example.com/", false},
{"https://example.com", "https://example.com/", false},
{"http://ex.com/docs", "http://ex.com/docs", false},
{"HTTPS://Example.COM/A", "https://example.com/A", false},
{"https://ex.com:443/", "https://ex.com/", false},
{"http://ex.com:80/", "http://ex.com/", false},
{"", "", true},
{"http://", "", true},
}
for _, c := range cases {
got, err := ParseSeed(c.in)
if c.err {
if err == nil {
t.Errorf("ParseSeed(%q) want error, got %v", c.in, got)
}
continue
}
if err != nil {
t.Errorf("ParseSeed(%q) unexpected error: %v", c.in, err)
continue
}
if got.String() != c.want {
t.Errorf("ParseSeed(%q) = %q, want %q", c.in, got.String(), c.want)
}
}
}
func TestNormalize(t *testing.T) {
base := mustParse(t, "https://ex.com/docs/intro")
cases := []struct {
ref string
want string
err bool
}{
{"../guide", "https://ex.com/guide", false},
{"/a/b/../c", "https://ex.com/a/c", false},
{"page#frag", "https://ex.com/docs/page", false},
{"//cdn.io/x.css", "https://cdn.io/x.css", false},
{"HTTPS://Other.COM/Y", "https://other.com/Y", false},
{"sub/", "https://ex.com/docs/sub/", false},
{"#top", "", true},
{"javascript:void(0)", "", true},
{"mailto:a@b.com", "", true},
{"data:image/png;base64,xxx", "", true},
{"tel:+123", "", true},
{"", "", true},
}
for _, c := range cases {
got, err := Normalize(base, c.ref)
if c.err {
if err == nil {
t.Errorf("Normalize(%q) want error, got %v", c.ref, got)
}
continue
}
if err != nil {
t.Errorf("Normalize(%q) unexpected error: %v", c.ref, err)
continue
}
if got.String() != c.want {
t.Errorf("Normalize(%q) = %q, want %q", c.ref, got.String(), c.want)
}
}
}
func TestInScope(t *testing.T) {
seed := mustParse(t, "https://ex.com/")
cases := []struct {
u string
cfg ScopeConfig
want bool
}{
{"https://ex.com/a", ScopeConfig{}, true},
{"https://other.com/a", ScopeConfig{}, false},
{"https://sub.ex.com/a", ScopeConfig{}, false},
{"https://sub.ex.com/a", ScopeConfig{IncludeSubdomains: true}, true},
{"https://ex.com/docs/x", ScopeConfig{ScopePrefix: "/docs/"}, true},
{"https://ex.com/blog/x", ScopeConfig{ScopePrefix: "/docs/"}, false},
{"https://ex.com/a/private/x", ScopeConfig{ExcludePaths: []string{"/private/"}}, false},
{"https://ex.com/a/public/x", ScopeConfig{ExcludePaths: []string{"/private/"}}, true},
}
for _, c := range cases {
got := InScope(seed, mustParse(t, c.u), c.cfg)
if got != c.want {
t.Errorf("InScope(%q, %+v) = %v, want %v", c.u, c.cfg, got, c.want)
}
}
}
func TestLikelyPage(t *testing.T) {
cases := map[string]bool{
"https://ex.com/docs": true,
"https://ex.com/docs/": true,
"https://ex.com/a.html": true,
"https://ex.com/file.pdf": false,
"https://ex.com/img.png": false,
"https://ex.com/style.css": false,
"https://ex.com/app.js": false,
}
for u, want := range cases {
if got := LikelyPage(mustParse(t, u)); got != want {
t.Errorf("LikelyPage(%q) = %v, want %v", u, got, want)
}
}
}
func TestLocalPathPages(t *testing.T) {
seed := "ex.com"
cases := []struct {
u string
want string
}{
{"https://ex.com/", "index.html"},
{"https://ex.com/docs", "docs/index.html"},
{"https://ex.com/docs/", "docs/index.html"},
{"https://ex.com/docs/intro", "docs/intro/index.html"},
{"https://ex.com/a.html", "a.html/index.html"},
{"https://sub.ex.com/x", "sub.ex.com/x/index.html"},
}
for _, c := range cases {
got := LocalPath(seed, mustParse(t, c.u), Page, "")
if got != c.want {
t.Errorf("LocalPath(page %q) = %q, want %q", c.u, got, c.want)
}
}
}
func TestLocalPathPageQuery(t *testing.T) {
got := LocalPath("ex.com", mustParse(t, "https://ex.com/search?q=cats"), Page, "")
if !strings.HasPrefix(got, "search/index__q-") || !strings.HasSuffix(got, ".html") {
t.Errorf("query page mapped to %q", got)
}
// Stable hash.
got2 := LocalPath("ex.com", mustParse(t, "https://ex.com/search?q=cats"), Page, "")
if got != got2 {
t.Errorf("query hash not stable: %q vs %q", got, got2)
}
// Different query → different file.
got3 := LocalPath("ex.com", mustParse(t, "https://ex.com/search?q=dogs"), Page, "")
if got == got3 {
t.Errorf("different queries collided: %q", got)
}
}
func TestLocalPathAssets(t *testing.T) {
seed := "ex.com"
cases := []struct {
u string
want string
}{
{"https://ex.com/css/main.css", "_kage/ex.com/css/main.css"},
{"https://cdn.io/font/x.woff2", "_kage/cdn.io/font/x.woff2"},
{"https://ex.com/avatar", "_kage/ex.com/avatar"},
{"https://ex.com/assets/", "_kage/ex.com/assets/index"},
}
for _, c := range cases {
got := LocalPath(seed, mustParse(t, c.u), Asset, "")
if got != c.want {
t.Errorf("LocalPath(asset %q) = %q, want %q", c.u, got, c.want)
}
}
// Query on an asset folds into the filename before the extension.
got := LocalPath(seed, mustParse(t, "https://ex.com/img/logo.png?v=3"), Asset, "")
if !strings.HasPrefix(got, "_kage/ex.com/img/logo__q-") || !strings.HasSuffix(got, ".png") {
t.Errorf("asset query mapped to %q", got)
}
}
func TestRel(t *testing.T) {
cases := []struct {
from string // page file
to string // target file
want string
}{
{"docs/intro/index.html", "docs/index.html", "../index.html"},
{"docs/intro/index.html", "index.html", "../../index.html"},
{"docs/intro/index.html", "_kage/ex.com/css/main.css", "../../_kage/ex.com/css/main.css"},
{"index.html", "docs/index.html", "docs/index.html"},
{"index.html", "_kage/ex.com/x.png", "_kage/ex.com/x.png"},
{"a/b/c/index.html", "a/b/c/index.html", "index.html"},
}
for _, c := range cases {
got := Rel(Dir(c.from), c.to)
if got != c.want {
t.Errorf("Rel(dir(%q), %q) = %q, want %q", c.from, c.to, got, c.want)
}
}
}
// TestRelResolves checks the invariant that follows: joining a page's directory
// with the relative link Rel produced lands exactly on the target file.
func TestRelResolves(t *testing.T) {
pairs := [][2]string{
{"docs/intro/index.html", "_kage/cdn.io/a/b.css"},
{"index.html", "deep/a/b/c/index.html"},
{"x/y/index.html", "x/index.html"},
}
for _, p := range pairs {
from, to := p[0], p[1]
rel := Rel(Dir(from), to)
joined := joinAndClean(Dir(from), rel)
if joined != to {
t.Errorf("Rel round-trip: from=%q to=%q rel=%q joined=%q", from, to, rel, joined)
}
}
}
func joinAndClean(dir, rel string) string {
full := dir + "/" + rel
parts := splitNonEmpty(full)
var stack []string
for _, p := range parts {
switch p {
case ".":
case "..":
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
default:
stack = append(stack, p)
}
}
return strings.Join(stack, "/")
}