Files
Maven/docs/plans/14-web-crawler.md
kami 2c1b0eede0 Read a web page when he names one, and watch a few on a timer (#259)
The network fallback behind the local sources, off unless configured.

internal/crawl is pure: a stdlib robots.txt parser (group specificity,
wildcards, Crawl-delay, cached per host), HTML-to-plaintext extraction, and a
watcher that notes a watched page only when its text changed. It has no store
access and no net/http; cmd/mavend/crawls.go is the impure half.

Every limit is code and tested: the guarded fetcher from #258 enforces the host
allowlist/denylist, refuses private addresses in the dialer Control hook (so DNS
rebinding and each redirect hop are covered), caps size and redirects, times out,
and spaces requests per host. A robots.txt Disallow is refused with no override.

On demand, reading is a query source placed last in the chain, after his memory,
his notes, and the local Kiwix ZIMs once those are wired: no URL in the
utterance means no fetch, and only the URL ever leaves the box. Scheduled
watches write notes and announce nothing.

The vendored tree has no x/net/html, goquery or temoto/robotstxt, so the parsers
are stdlib. No new dependency.
2026-08-01 03:40:21 +04:00

4.5 KiB

Plan: Web Crawler

Goal: Maven can crawl web pages on demand or on a schedule — fetch page content, extract structured data (via LLM or CSS selectors), and store results as facts, notes, or reminders. Used for: price monitoring, documentation updates, recipe extraction, content summarization.

Done when:

  • internal/crawl/ package — HTTP fetcher with polite defaults (rate limiting, robots.txt respect, user-agent)
  • Content extraction: HTML→plaintext (Go stdlib golang.org/x/net/html), or full-page LLM summarization
  • Crawl scheduler in config — crawls: [{name, url, selector, schedule, store_as}]
  • On-demand crawl via voice: "maven, посмотри страницу X и запиши цену"
  • Results are stored as facts/notes through ipc.CoreAPI
  • Crawl history visible on mavweb /tools page

Scope:

  • New internal/crawl/ package — fetcher, parser, scheduler, extractor
  • Config extension: crawls array in config.Config
  • Reuses internal/llm.Client for intelligent extraction (e.g., "extract the price, description, and availability from this page")
  • Reuses internal/ipc.CoreAPI for storing results
  • Reuses internal/router.Embedder for deduplication (don't re-store identical content)
  • Reuses internal/routine.Routine mechanics for scheduled crawls

Steps:

  1. Create internal/crawl/fetcher.goFetch(url) ([]byte, error): HTTP GET with timeout (30s), rate limiting (1 req/sec), robots.txt check via github.com/temoto/robotstxt
  2. Create internal/crawl/extractor.goExtract(html []byte, extraction_type string) (map[string]string, error): for simple extraction use CSS selector (github.com/PuerkitoBio/goquery); for complex extraction use llm.Client with a prompt
  3. Create internal/crawl/scheduler.goScheduler that reads crawls config, runs each on its cron schedule, tracks last-run via facts
  4. Create internal/crawl/dedup.go — compute content hash, skip if identical to last fetched (stored as fact kind=config, key=crawl:hash:<name>)
  5. Wire on-demand crawl into IntentAct — new tool verb crawl that accepts a URL argument
  6. Wire scheduled crawls into cmd/mavend/main.go — separate goroutine manages the crawl scheduler
  7. Add IPC methods MethodTriggerCrawl(name), MethodListCrawls, MethodGetCrawlResult(name)
  8. Add crawls block to config.Config and deploy/mavend.json
  9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly

Shipped 2026-08-01 (#259)

Built as internal/crawl (pure: robots, extraction, watcher) plus cmd/mavend/crawls.go (fetcher, ticker, dedup facts), on top of the guarded internal/webfetch door added with the feed reader (#258). Off unless configured, in two separately-switched halves: crawl.on_demand for a URL he names, crawl.watches for a scheduled re-read.

Limits are code, not documentation (internal/webfetch, tested one test per limit): host allowlist/denylist, no private addresses (loopback, RFC1918 — hence the LAN and the 10.42.0.0/24 wg range —, link-local incl. cloud metadata, CGNAT, v6 ULA) enforced in the dialer's Control hook so DNS rebinding and every redirect hop are covered, response size cap, redirect cap, timeout, one request per host per second. robots.txt is fetched first, cached per host, and a Disallow is refused with no override.

Deliberate deviations from the plan above:

  • No CSS selectors and no LLM structured extraction (steps 2). The output is plaintext handed to the phraser as context for the question he asked. A 1.7B extracting a JSON price table from 4000 runes is a worse bet than reading, and goquery is not vendored.
  • No crawl act verb and no new IPC methods (steps 5, 7). Reading a page is a query source (queryWeb in actions_query.go, last in the chain, behind Kiwix once that is wired), not an action he commands. Nothing needs a new wire method to work.
  • Notes, not facts. A page's text is not a fact about him. Only the dedup hash is a fact (crawl:hash:<name>, kind config, source poll:crawl).
  • Nothing is dispatched. A changed page writes a note; it does not nudge. Not a nag.
  • No /tools crawl history page. The notes and the hash facts are already visible on /dash.

No new dependency. The vendored tree has no x/net/html, no goquery and no temoto/robotstxt, so robots parsing and HTML-to-text are stdlib (regexp, html) — RE2 has no backreferences, hence the pairsRE builder in extract.go.