Files
kage/robots/robots.go
Duc-Tam Nguyen e6afa91e09 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.
2026-06-14 18:22:25 +07:00

219 lines
5.0 KiB
Go

// 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))
}