Files
Maven/internal/crawl/crawl.go
T
claude 04584fb2da webfetch checks the status before it reads the body (V-581)
A non-2xx reply was read in full first and only then rejected. Two costs
followed. A 500 with a large error page pulled up to MaxBytes off the wire for
nothing. An error page over the cap returned ErrTooLarge, which names the size
and hides the status the server actually sent.

The status is a typed error now. webfetch.StatusError carries the code and
unwraps to ErrStatus, so errors.Is keeps working and errors.As reads the number.
crawl.StatusError is the same shape on the other side of the seam, and
cmd/mavend/crawls.go carries the code across.

That removes the string grep in crawl.isServerError, which decided whether a
failed robots.txt blocks a crawl by looking for " 50" in an error message it did
not own. A reworded error would have turned a 503 robots.txt into permission to
crawl. It reads the code now.

Two comments corrected. webfetch.HostMatches said the crawler calls it and
nothing outside the package does. rss.PlainText said the crawler's extractor
goes through it and crawl/extract.go has its own pass.

The rss poller parses the feed straight off the byte slice instead of copying a
document that can run to a megabyte through a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:12:59 +04:00

264 lines
9.4 KiB
Go

// Package crawl reads a web page: fetch, robots check, HTML to text.
//
// It is the last LOCAL-FIRST step, and the ordering is the whole design.
// "Never phones home" is deprecated, but what replaced it puts local sources
// first. Where this actually sits in querySources (cmd/mavend/actions_query.go):
// after his memory and after his notes, and BEFORE the model answers from what
// it remembers. Not after the model, which is what this comment used to claim.
//
// That position is deliberate. A fetch only happens where he named a URL out
// loud, and a named URL is an instruction, not a guess; letting a 1.7B answer
// about a page it cannot read is how a small model invents contents. Kiwix
// (internal/kiwix) is not in the chain yet, so nothing here describes it.
//
// A page's text is written by whoever owns the page. It reaches PhraseQuery as
// context beside his question, and for a watch it becomes a note. It cannot
// reach a tool or an act — the query path executes nothing — but it can steer
// what she says, which is the same trust level as a mail body and lower than
// anything he said himself.
//
// What never leaves the box: his notes, his facts, the persona block, the
// conversation history. Only the URL is requested and, for the on-demand path,
// only because he said it out loud. Nothing here reads the store.
//
// The limits are not in this package — they are in internal/webfetch, which is
// the only way anything here touches a socket: http(s) only, host allow/deny,
// private-address refusal, size cap, redirect cap, per-host rate limit. What
// this package adds is politeness (robots.txt) and dedup.
package crawl
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
)
// Errors callers distinguish.
var (
ErrRobots = errors.New("crawl: robots.txt disallows this path")
ErrNotHTML = errors.New("crawl: response is not html or text")
// ErrFetchRefused — the fetcher would not go: a denied host, a private
// address, a scheme that is not http(s). The adapter that owns both
// packages (cmd/mavend/crawls.go) maps webfetch's sentinels onto this one,
// so this package tells "off limits" from "no robots.txt here" without
// importing webfetch and without matching on message text.
ErrFetchRefused = errors.New("crawl: the fetcher refused this url")
// ErrFetchStatus — the server answered, badly (5xx, and anything else
// non-2xx). Separate from ErrFetchRefused because robots treats them
// differently: a broken server is not permission to crawl.
ErrFetchStatus = errors.New("crawl: the server answered with an error status")
)
// StatusError is ErrFetchStatus with the code the server actually sent. The
// adapter builds it; isServerError reads Code rather than the message, so a
// reworded error can no longer turn a 503 robots.txt into permission to crawl.
type StatusError struct{ Code int }
func (e *StatusError) Error() string {
return fmt.Sprintf("crawl: the server answered with status %d", e.Code)
}
func (e *StatusError) Unwrap() error { return ErrFetchStatus }
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
// interface so this package constructs no http.Client of its own and can be
// tested without a network.
type Fetcher interface {
Get(ctx context.Context, url string) (*Response, error)
}
// Response is the minimum a crawl needs from a fetch.
type Response struct {
URL string
ContentType string
Body []byte
}
// Config — crawler knobs.
type Config struct {
// UserAgent is the name matched against robots.txt groups. It must be the
// same string the fetcher sends, or Maven would be claiming one identity
// and obeying the rules for another.
UserAgent string
// MaxRunes caps extracted text. 0 ⇒ DefaultMaxRunes.
MaxRunes int
// RobotsTTL — how long a parsed robots.txt is trusted. 0 ⇒ 1h.
RobotsTTL time.Duration
// Now is injectable for tests. nil ⇒ time.Now.
Now func() time.Time
}
// Crawler fetches and extracts pages. Safe for concurrent use.
type Crawler struct {
fetch Fetcher
cfg Config
robots *robotsCache
// mu guards last, the per-host time of the previous fetch. It is what makes
// Crawl-delay real: the fetcher's own limiter is a flat one request per
// host per second and knows nothing about what a site asked for.
mu sync.Mutex
last map[string]time.Time
}
// robotsTimeout — the robots fetch gets its own, shorter deadline. It shares the
// caller's budget with the page fetch (30s for the on-demand path, against a 20s
// default fetch timeout each), so a slow robots.txt used to eat the page's half
// and he heard "не получилось прочитать страницу" about a site that was fine.
const robotsTimeout = 8 * time.Second
// New builds a crawler. Returns nil when there is no fetcher, which is how the
// daemon expresses "crawling is off unless configured".
func New(fetch Fetcher, cfg Config) *Crawler {
if fetch == nil {
return nil
}
if cfg.UserAgent == "" {
cfg.UserAgent = "Maven"
}
if cfg.MaxRunes <= 0 {
cfg.MaxRunes = DefaultMaxRunes
}
if cfg.RobotsTTL <= 0 {
cfg.RobotsTTL = time.Hour
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &Crawler{fetch: fetch, cfg: cfg, robots: newRobotsCache(cfg.RobotsTTL), last: map[string]time.Time{}}
}
// Page fetches rawURL and returns its text. It checks robots.txt first and
// refuses a disallowed path with ErrRobots — there is no override.
func (c *Crawler) Page(ctx context.Context, rawURL string) (Page, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return Page{}, fmt.Errorf("crawl: bad url %q: %w", rawURL, err)
}
rules, err := c.rulesFor(ctx, u)
if err != nil {
return Page{}, err
}
path := u.EscapedPath()
if u.RawQuery != "" {
path += "?" + u.RawQuery
}
if !rules.Allowed(path) {
return Page{}, fmt.Errorf("%w: %s", ErrRobots, u.Path)
}
if err := c.waitCrawlDelay(ctx, u.Host, rules.Delay); err != nil {
return Page{}, err
}
resp, err := c.fetch.Get(ctx, u.String())
c.markFetched(u.Host)
if err != nil {
return Page{}, err
}
// A PDF or an image is bytes Maven cannot read; saying so beats storing
// binary garbage as a "note".
ct := strings.ToLower(resp.ContentType)
if ct != "" && !strings.Contains(ct, "html") && !strings.Contains(ct, "text/") &&
!strings.Contains(ct, "xml") && !strings.Contains(ct, "json") {
return Page{}, fmt.Errorf("%w: %s", ErrNotHTML, resp.ContentType)
}
return Extract(resp.URL, resp.Body, c.cfg.MaxRunes), nil
}
// rulesFor consults robots.txt for u's host, reading it at most once per TTL.
//
// A robots.txt that is not there (404) means allow, per the standard. What does
// NOT mean allow: a server that answered with an error. The standard asks for
// the opposite there, and "the site is broken, so crawl it" is the wrong way to
// resolve an unknown.
func (c *Crawler) rulesFor(ctx context.Context, u *url.URL) (Rules, error) {
host := u.Host
now := c.cfg.Now()
rules, ok := c.robots.get(host, now)
if ok {
return rules, nil
}
robotsURL := u.Scheme + "://" + host + "/robots.txt"
rctx, cancel := context.WithTimeout(ctx, robotsTimeout)
defer cancel()
resp, err := c.fetch.Get(rctx, robotsURL)
c.markFetched(host)
switch {
case err == nil:
rules = ParseRobots(string(resp.Body), c.cfg.UserAgent)
case errors.Is(err, ErrFetchRefused):
// Not swallowed: if the fetcher says this host is denied or private,
// the page fetch would fail the same way, and the real reason beats a
// robots verdict we never got.
return Rules{}, err
case errors.Is(err, ErrFetchStatus) && isServerError(err):
return Rules{}, fmt.Errorf("%w: robots.txt at %s could not be read", ErrFetchStatus, host)
default:
rules = Rules{}
}
c.robots.put(host, rules, now)
return rules, nil
}
// waitCrawlDelay honours a Crawl-delay the site asked for. The fetcher's flat
// one-per-host-per-second is the floor and cannot express "30 seconds"; without
// this, deploy/README's claim that Crawl-delay is honoured was false.
//
// It waits on ctx, so a delay longer than the caller's budget fails the read
// rather than blocking a turn. That is the honest outcome: a site that wants a
// minute between requests is not a site to answer a voice question from.
func (c *Crawler) waitCrawlDelay(ctx context.Context, host string, delay time.Duration) error {
if delay <= 0 {
return nil
}
c.mu.Lock()
last, ok := c.last[host]
c.mu.Unlock()
if !ok {
return nil
}
wait := delay - c.cfg.Now().Sub(last)
if wait <= 0 {
return nil
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ctx.Done():
return fmt.Errorf("crawl: %s asks for %s between requests, longer than this read has: %w", host, delay, ctx.Err())
case <-t.C:
return nil
}
}
func (c *Crawler) markFetched(host string) {
c.mu.Lock()
c.last[host] = c.cfg.Now()
c.mu.Unlock()
}
// isServerError — a 5xx rather than any other non-2xx. A status the adapter
// could not recover reads as 0 and is not a server error, which keeps the
// standard's "404 means allow" as the default for an unknown.
func isServerError(err error) bool {
var se *StatusError
if !errors.As(err, &se) {
return false
}
return se.Code >= 500 && se.Code <= 599
}
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
// hex, first 16 chars. Text and not raw HTML, because a page whose only change
// is a rotating ad slot or a CSRF token has not changed.
func Hash(text string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(text)))
return hex.EncodeToString(sum[:])[:16]
}