package crawl import ( "regexp" "strings" "sync" "time" ) // robots.txt, parsed the small way: no wildcards beyond the two the standard // actually defines (`*` inside a path and `$` at the end), no sitemaps, no // crawl-delay-per-agent gymnastics. A personal assistant reading a handful of // pages does not need a spec-complete implementation; it needs to not be rude, // and to be auditable in one sitting. // // Two rules worth stating because they are choices, not accidents: // // - a missing or unreadable robots.txt means ALLOW. That is what the standard // says (404 ⇒ unrestricted), and the alternative would make a site that // simply has no robots.txt unreadable; // - an explicit Disallow means REFUSE, and Maven does not offer an override. // There is no "but he asked me to" flag: the page is not read. // Rules is a parsed robots.txt for one user-agent. type Rules struct { allow []string disallow []string // Delay is Crawl-delay in seconds when the group named one, 0 otherwise. // The fetcher's own per-host rate limit is the floor; this can only make // Maven slower, never faster. Enforced in Crawler.waitCrawlDelay — the // fetcher's limiter is flat and cannot express what a site asked for. Delay time.Duration } // ParseRobots reads robots.txt and returns the rules that apply to agent. // // Group selection follows the standard: the most specific matching group wins, // which here means an exact user-agent match beats `*`. Lines that are neither // are ignored rather than guessed at. func ParseRobots(body string, agent string) Rules { agent = strings.ToLower(agent) type group struct { agents []string allow []string disallow []string delay time.Duration } var groups []group var cur *group // startNew tracks whether the next User-agent line opens a new group or // joins the current one: consecutive User-agent lines share their rules. startNew := true for _, raw := range strings.Split(body, "\n") { line := raw if i := strings.IndexByte(line, '#'); i >= 0 { line = line[:i] } line = strings.TrimSpace(line) if line == "" { continue } key, val, ok := strings.Cut(line, ":") if !ok { continue } key = strings.ToLower(strings.TrimSpace(key)) val = strings.TrimSpace(val) switch key { case "user-agent": if startNew || cur == nil { groups = append(groups, group{}) cur = &groups[len(groups)-1] startNew = false } cur.agents = append(cur.agents, strings.ToLower(val)) case "disallow": if cur == nil { continue } startNew = true // "Disallow:" with an empty value allows everything, and is not a // path rule at all. if val != "" { cur.disallow = append(cur.disallow, val) } case "allow": if cur == nil { continue } startNew = true if val != "" { cur.allow = append(cur.allow, val) } case "crawl-delay": if cur == nil { continue } startNew = true if d, err := time.ParseDuration(val + "s"); err == nil && d > 0 { cur.delay = d } } } // Most specific wins, and specificity is the LENGTH of the matching agent // string, not the file order. Two groups naming "mav" and "maven" used to be // resolved by whichever came last in the file. var star, exact *group best := 0 for i := range groups { for _, a := range groups[i].agents { if a == "*" && star == nil { star = &groups[i] } // A robots.txt names "maven", we send "Maven/1.0 (…)": match on // prefix, which is how every crawler reads this field. if a != "*" && a != "" && strings.HasPrefix(agent, a) && len(a) > best { best, exact = len(a), &groups[i] } } } g := exact if g == nil { g = star } if g == nil { return Rules{} } return Rules{allow: g.allow, disallow: g.disallow, Delay: g.delay} } // Allowed reports whether path may be fetched. Longest matching rule wins, and // Allow beats Disallow at equal length — the standard's tie-break, and the one // that makes "Disallow: /" plus "Allow: /public" mean what it looks like. func (r Rules) Allowed(path string) bool { if path == "" { path = "/" } best, allowed := -1, true for _, p := range r.disallow { if n, ok := matchPath(p, path); ok && n > best { best, allowed = n, false } } for _, p := range r.allow { if n, ok := matchPath(p, path); ok && n >= best { best, allowed = n, true } } return allowed } // matchPath applies a robots path pattern and returns the pattern's length as // the specificity score. `*` matches any run of characters, `$` anchors the end. // A pattern is a PREFIX match otherwise, which is what "Disallow: /admin" means. func matchPath(pattern, path string) (int, bool) { score := len(pattern) re, err := robotsRegexp(pattern) if err != nil { return 0, false } return score, re.MatchString(path) } // robotsRegexp turns a robots path pattern into an anchored-at-the-start // regexp. Everything but `*` and a trailing `$` is a literal, so the pattern is // quoted first and the two metacharacters are put back afterwards. func robotsRegexp(pattern string) (*regexp.Regexp, error) { end := "" if strings.HasSuffix(pattern, "$") { pattern = strings.TrimSuffix(pattern, "$") end = "$" } parts := strings.Split(pattern, "*") for i, p := range parts { parts[i] = regexp.QuoteMeta(p) } return regexp.Compile("^" + strings.Join(parts, ".*") + end) } // robotsCache holds parsed rules per host so a crawl of ten pages on one site // reads robots.txt once. TTL because a site may change its mind, and a daemon // that runs for weeks would otherwise never notice. type robotsCache struct { ttl time.Duration mu sync.Mutex m map[string]robotsEntry } type robotsEntry struct { rules Rules at time.Time } func newRobotsCache(ttl time.Duration) *robotsCache { return &robotsCache{ttl: ttl, m: map[string]robotsEntry{}} } func (c *robotsCache) get(host string, now time.Time) (Rules, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.m[host] if !ok || now.Sub(e.at) > c.ttl { return Rules{}, false } return e.rules, true } func (c *robotsCache) put(host string, r Rules, now time.Time) { c.mu.Lock() defer c.mu.Unlock() c.m[host] = robotsEntry{rules: r, at: now} }