- make crawl-ingest writes to one central crawl db (CRAWL_DB, default ~/.arborist/crawl/web.db) instead of per-domain shards in the peer-shared main dir: keeps locally-crawled content out of peer sharing by default and a growing domain set under SQLite's 10-attach cap (Makefile, docs/crawler.md). - arborist query auto-includes the local crawl db (query() gains extra_shards; CLI --include-shard / --no-crawl-db, default-on when web.db exists). Fix latent --db single-file query AttributeError (cli.py). Persist used / used_pointer_ids + retrieval_purity into merkle_proof so read-only consumers can see which chunks fed the answer (qa/query.py). - arborist.read: read-only seam for dashboards / verifiers; on a multi-source context root surface the real primary source instead of the opaque corpus://multi-source sentinel (read.py). Backs the arborist-viz Merkle Command Center (#000069). - tests for extra_shards, the CLI crawl-db resolver, and the read seam.
11 KiB
Web crawler — surface ingest from live sites
Arborist's crawler turns a live website into surface-layer documents: it walks a site from a seed URL, fetches each page, and runs the HTML through the same normalize → chunk → Merkle → upsert pipeline as any other source. The output lands in a content-addressed store, which is what makes the crawler's two headline diagnostics — duplicate detection and orphan finding — nearly free.
It pairs with the textbook-ingest workflow (docs/warrant-substrate-cookbook.md)
and the retrieval pipeline (arborist/qa/query.py).
Status: discovery + ingest + recrawl-check ship today. The webmaster report (
crawl-report) described under Diagnostics is designed-not-built — this doc pins the contract.
Default off
The crawler touches the network only when you ask it to, and its heavy
dependencies (aiohttp, beautifulsoup4, lxml, …) are an opt-in
extra:
pip install 'arborist[crawler]' # or: make bootstrap-crawler
The default test suite never imports the crawler module. Nothing in a normal lookup / ingest / Q&A path reaches it.
How discovery works
The crawl driver is arborist/sources/crawler/bridge.py
(_crawl_seed_async). It is a breadth-first walk that stays on one
hostname:
- Start from
--seed-url. Fetch it, extract its links. - Keep links that are same exact hostname (
_same_domain: no subdomain crossover —x.comdoes not follow intodocs.x.com),http(s), and not already seen. - Enqueue the survivors at
depth + 1. Repeat until the queue drains,--depthis hit, or--max-pagesis reached (0= no cap, depth is the only bound).
Discovery fetches each page only far enough to extract its links; the
arborist-side ingest is a separate step (--ingest).
What it refuses to crawl
robots.txtDisallow is always honored — in both polite and fast mode. A blocked URL is skipped, never fetched.- Feeds and sitemaps are skipped at discovery (
_looks_like_feed_url):*.atom,*.rss,*.rdf,/feed/,/feeds/,sitemap.xml,sitemap_index.xml,/sitemaps/*.xml, and the WordPress legacy feed names. They are crawl infrastructure, not knowledge —ingest_crawledwould discard them anyway, and a multi-MBatom.xmlis a slow straggler that gates everything fetched alongside it. A second filter at ingest (URL pattern and responseContent-Type) catches feeds served at unconventional paths.xhtmlis preserved — it is HTML.
The crawler never proactively probes /sitemap.xml or guesses feed
locations on the path we use. (The fetcher carries an unused
sitemap-discovery crawl() strategy method from its upstream lift; the
arborist bridge calls only fetch_webpage.)
Polite vs fast
--fast flips the underlying AsyncWebFetcher into fast_mode. The one
rule that matters: fast mode drops the rate limit, never the access
rules.
| polite (default) | --fast |
|
|---|---|---|
robots Disallow |
honored | honored |
robots crawl-delay |
honored; 2.0s fallback when robots is silent/absent | ignored (0s) |
| concurrency per wave | 1 (sequential) | CPU × 3 |
| connections | shared keepalive session | shared keepalive session |
| per-page requests | one GET |
one GET |
page GET timeout |
15s (robots 5s) | 15s (robots 5s) |
Use --fast only where aggressive fetching is acceptable: your own
sites, or dumps you have been authorized to mirror. On someone else's
infrastructure, the polite default is the courteous neighbour.
Why fast is fast
Each fetch wave runs up to CPU × 3 requests concurrently through one
shared aiohttp session with a keepalive connector, so connections
are reused across the whole crawl instead of paying a fresh TCP+TLS per
page. There is no preflight HEAD — aiohttp exposes response
headers before the body is read, so the binary content-type guard skips
images/video/audio without downloading them, and the second round trip
the old HEAD cost is gone. Link extraction uses lxml (a C parser
that releases the GIL), so parsing genuinely parallelises across a wave
rather than serialising on the interpreter lock.
Measured on a robots-less site (russell.ballestrini.net, 242 URIs),
these together took a full --fast crawl from ~26s to ~5s. The largest
single factor was making fast mode actually ignore the crawl-delay on a
site whose robots.txt 404s — previously it fell back to the 2s default
and every concurrent wave slept.
Ingest and recrawl
With --ingest, each discovered page is fetched with httpx, parsed,
and run through ingest_source, so crawled documents land in the same
tables as Wikipedia or textbook sources. The crawler additionally
records each document's ETag and Last-Modified in
document_http_meta, so arborist crawler recrawl-check can later send
conditional HEAD requests and classify each URL fresh (304) /
stale (200) / gone (404) without re-downloading bodies.
Re-ingest is idempotent: same content → same document_root → no-op.
Same URI with changed content → a new document plus a supersedes edge
(lossless history).
The content-addressed payoff
This is where the crawler differs from an off-the-shelf spider. Every
document's identity is its Merkle root over canonicalized body text
(ingest.py:_compute_artifacts):
text = canonicalize(content) # body only — norm-v1
leaves = [hash_leaf(chunk) for chunk in chunker.split(text)]
document_root = MerkleTree.build(leaves).root
The URI is not part of the root — it is a separate document_uri
column. Two consequences fall straight out of that:
Duplicate detection (free)
Two different URLs whose bodies canonicalize to the same text produce
the same document_root. So exact-and-near-duplicate pages are a
GROUP BY document_root HAVING COUNT(*) > 1 query — no re-crawl, no
heuristics. Because the body is canonicalize()d before hashing,
trivial whitespace/markup differences collapse too, which is the right
behaviour for a "duplicate content" signal.
Leaves are per chunk, so the same data also yields partial
overlap: pages that share chunk-leaf hashes without being full dupes
(boilerplate headers/footers, syndicated sections) cluster via a
chunks join. No other crawler hands you "these N URLs are byte- or
chunk-identical" for nothing — it is a property of the store's design.
Orphan finding
Crawl gives the link-reachable set (what BFS discovered). A sitemap — fetched read-only, once, never ingested — gives the declared set. The difference is the orphans:
orphans = sitemap_urls − bfs_reachable_urls
These are pages a site lists but nothing links to. Surfacing them is a classic webmaster need and costs us a single extra fetch on top of a crawl we already ran.
Diagnostics: the webmaster-tools angle
The store's by-products make a small "webmaster report" cheap. Planned
shape: arborist crawl-report --seed-url … [--sitemap …], off by
default, read-only, never writing providence_cache or audit_events.
| Signal | How it's derived | Extra cost |
|---|---|---|
| Orphan pages | sitemap − BFS-reached |
one sitemap fetch |
| Exact duplicate pages | group by document_root |
free (store query) |
| Partial-overlap clusters | shared chunk-leaf hashes | free (store query) |
| Broken links / 404s | per-URL status during crawl | free |
| Redirect chains | 301/308 hops observed | free |
| Depth / crawl-budget map | BFS depth per URL | free |
| Thin / untitled pages | chunk count + missing <title> |
free |
A likely v1 keeps it to orphans + exact dupes + broken links, with partial-overlap and the rest as follow-ons.
Pros and cons
Pros
- Compliant by default. robots
Disallowalways honored; crawl-delay honored unless you explicitly opt into--fast. - Fast when you own the target. Concurrent waves, keepalive
sessions, no redundant
HEAD, GIL-releasing parse. - Duplicate and partial-overlap detection for free, because identity is content not URL.
- Idempotent, lossless re-ingest with a conditional-
HEADrecrawl-check that avoids re-downloading unchanged pages. - Same store as every other source — crawled pages are queryable, distillable, and Merkle-verifiable like any document.
Cons / trade-offs
- Orphans are invisible to the crawl itself. We follow links and do not consume sitemaps for discovery, so a page reachable only via the sitemap is never ingested. That is a deliberate default (crawl what is linked) — orphan finding is a report you run on top, not a crawl behaviour.
- Single hostname, exact match. No subdomain crossover and no cross-domain following by design; multi-host sites need multiple seeds.
- No JavaScript execution. Links and content rendered client-side (SPA routes) are not seen — this is an HTML fetcher, not a headless browser.
--fastis anti-social off your own turf. Ignoring crawl-delay and fanning outCPU × 3is fine for sites you control and rude elsewhere. The default is polite for a reason.- Diverged from upstream. Arborist's
AsyncWebFetcherno longer matches theagents.ai.unturf.com/coreverbatim lift (shared session, droppedHEAD, fast-mode crawl-delay fix). A candidate to upstream.
CLI and make targets
arborist crawl --seed-url URL [--depth N] [--max-pages N] [--ingest] [--fast] [--author NAME]
arborist crawler recrawl-check [--domain D] [--limit N]
--depth— max BFS depth (default 2).--max-pages— cap discovery at N URLs (0= no cap, depth bounds it).--ingest— ingest discovered pages; without it, the URL list is printed only.--fast— fast mode (see table). Disallow still honored.--author— default author surname appended to titles for warrant resolution (only with--ingest).
Make targets drive both crawl workflows; crawl shards land in
~/.arborist/crawl/ (separate from the main ~/.arborist/shards so
SQLite's 10-attached-DB limit isn't tripped, and so locally crawled
content isn't shared as a peer by default):
make crawl-ingest URL=https://x.com # general web crawl → ONE central
# db (CRAWL_DB, default web.db)
make crawl-textbooks # BFS-crawl every manifest entry
# with a crawl_url (warrant substrate)
make textbook ID=<id> # ingest one textbook by id
make crawl-textbooks-stats # docs-per-shard summary
General web crawls (make crawl-ingest) all flow into a single
central db rather than one-per-domain: content-addressing lets many
domains coexist in one file (idempotent re-ingest, supersedes edges
on change), and a single file always attaches under the 10-DB cap.
Query it standalone with arborist --db ~/.arborist/crawl/web.db query "…", or attach it alongside the main corpus when you want unified
results. The per-host textbook crawls stay separate — they are warrant
substrate, resolved through a different path.
Source map
| File | Role |
|---|---|
arborist/sources/crawler/bridge.py |
BFS driver, feed-skip, ingest, recrawl-check |
arborist/sources/crawler/async_web_fetcher.py |
fetch + robots + crawl-delay + shared session |
arborist/ingest.py |
document_root derivation (content, not URI) |
arborist/merkle.py |
Merkle tree / proof conventions |