Concepts#
forage is a crawler for URLs you do not control, whose pages become evidence that a person reviews and cites. It has five building blocks: the crawl frontier, guarded egress, single-page fetch, snapshots with their stores, and provenance references. This page explains each one and how they fit together. The design notes explain why they are built this way.
flowchart LR seed[Seed + CrawlPolicy] --> crawl["crawl()"] crawl -->|each frontier URL| fetch["fetchSource()"] fetch -->|every request| egress["guarded fetch"] fetch -->|on-shell or always| render["pinned Chromium"] fetch --> snap[Snapshot] snap -->|live mode| store[(SnapshotStore)] store -->|replay mode| crawl snap --> ref["sourceRef"] ref -->|"resolveSnapshotSourceRef()"| store
The crawl frontier#
crawl(seed, policy) is the package's main entry point. It runs a bounded,
breadth-first frontier, one URL at a time:
- It validates the seed and policy. A bad value throws
InvalidCrawlConfigError, the only errorcrawl()throws. - With
discovery: "sitemap"or"both", it reads the seed origin'srobots.txtSitemap:directives (or/sitemap.xmlwhen there are none) and queues each same-host page URL at depth 1. - It takes the next URL from the queue and acquires it with
fetchSource()(live) or from the store (replay). - If the page is HTML, its depth is below
maxDepth, and link discovery is on ("links"or"both"), it extracts<a href>links and queues each new URL at the next depth. WithsameHost(the default), only links on the seed's origin are queued. - It stops when the queue is empty or after
maxPagesacquisition attempts. Failed attempts count towardmaxPages.
The result is a CrawlManifest: the pages, a truncated flag that is true
when URLs were still queued at the cap, crawl-level warnings, and, when
discovery is "sitemap" or "both", sitemap.documentsRead and
sitemap.urlsDiscovered.
Never-throws#
Operational failures never abort a crawl. A denied address, a robots refusal,
a timeout, an HTTP error, a failed store.put, or a parse problem becomes a
warning prefixed with the page's depth and URL, and the crawl moves on. A
crawl whose seed is refused returns an empty pages list and a warning that
says why, unless sitemap discovery found other pages to fetch.
Deferred seams#
Two policy fields are accepted but not yet acted on. shouldFollow (a frontier
scorer) and politeness.concurrency other than 1 each add a warning, and the
crawl uses its deterministic host and depth rules with a sequential frontier.
forage ships no AI; shouldFollow is where a consumer could plug scoring in.
Guarded egress#
By default, every request forage makes goes through a guarded fetch.
egress: { guarded: false }, or a caller-supplied fetch in
FetchSourceOptions, replaces it for plain HTTP requests; browser rendering
is always guarded. For each
request, and again for each redirect hop, it:
- accepts only
http:andhttps:URLs without credentials, on the default port (80 or 443); - refuses
localhost,*.localhost, and cloud metadata hostnames such asmetadata.google.internal; - resolves the hostname once and requires every DNS answer to be a public address, refusing private, loopback, link-local, carrier-grade NAT, multicast, documentation, and reserved ranges, and IPv6 forms that embed a refused IPv4 address;
- connects to the one validated IP while keeping the hostname for the
Hostheader and TLS SNI, so a second DNS answer cannot rebind the connection.
When the guard follows redirects itself, it refuses a redirect to another
origin, or from https: to http:. A refusal is an EgressUrlPolicyError
with a code such as DENIED_ADDRESS or INVALID_PORT. Inside crawl() and
fetchSource() it is reported as an egress-denied error.
The same guard is exported on its own from @kontourai/forage/egress, so a
consumer can protect any fetch-shaped call. See
the egress reference.
Single-page fetch#
fetchSource(config, options) acquires one URL. crawl() calls it for every
frontier page, and it is exported from @kontourai/forage/fetch for callers
that need one page without a frontier. For each request it:
- checks
robots.txtfor the origin (cached per origin), unlessrespectRobotsisfalse; - waits so requests to one origin are at least
minDelayMsapart (1,000 ms by default); - retries timeouts, network errors, HTTP 429, and HTTP 5xx with jittered backoff (2 retries by default, at most 5);
- follows up to 5 redirects itself, sending each hop through the guard again
and re-checking robots.txt (except when rendering). A redirect to another
host, or from
https:tohttp:, fails with anetworkerror; - sends
If-None-MatchandIf-Modified-Sincefrom the source's latest stored snapshot, when a store is supplied,revalidateis notfalse, and that snapshot's URL is the one being requested. A304 Not Modifiedreturns that stored snapshot withnotModified: true.
It returns { snapshot?, error?, warnings? }. It never throws for an
operational failure, and it never writes to the store.
Adaptive rendering#
render: "on-shell" renders a page in a browser only when the plain HTML
response looks like a JavaScript shell: very little visible text, plus either
a very low text-to-markup ratio or an app bootstrap marker such as a
<script> tag or <div id="root">. render: "always" (or Seed.render: true
for the seed page) renders every matching page.
The browser is pinned too. forage resolves the hostname through the guard
once, launches a dedicated headless Chromium whose host resolver maps that
hostname to the validated IP, blocks service workers, and aborts every request
to any other hostname. Rendering needs the optional playwright peer. If it is
missing or rendering fails, the plain snapshot is kept and a warning says why.
A rendered snapshot has rendered: true, and its body is the serialized DOM.
Snapshots#
A Snapshot is the durable record of one acquisition: sourceId, final
url, status, fetchedAt, headers, any same-host redirects, the
body, and bodyHash.
- Binary bodies keep the raw bytes in
body. - Text bodies keep the exact bytes received in
bytesand the lower-casedcharsetfromContent-TypeindeclaredCharset.bodyis the text decoded from those bytes bydecodeTextBody(), using UTF-8 when no charset is declared. - Rendered bodies have no wire bytes;
bodyis the HTML string.
bodyHash is a SHA-256 hex digest. snapshotHashBasis(snapshot) reports what
it covers: "bytes" for binary bodies and new text captures, or
"decoded-utf8" for rendered snapshots and text snapshots written by releases
before 1.0.0.
Snapshot stores#
A SnapshotStore persists and replays snapshots per sourceId, with
put, latest, get, and list, and optionally findExact and prune. In
live mode with a store, crawl() puts every acquired page. In replay mode it
serves each page from store.latest() and makes no network request. With
sitemap discovery, the robots.txt and sitemap documents are stored and
replayed the same way, under their own source ids.
forage ships two implementations:
createInMemorySnapshotStore(), for tests and short-lived processes.createFilesystemSnapshotStore({ root, maxHistoryFiles? }), which keeps immutable JSON records per source. It is bounded (at most 10,000 records per source, lowered withmaxHistoryFiles, and 96 MiB per record), fail-closed on corrupt records, safe for several writer processes through a per-source lock, and never deletes anything unless you callprune(). It can also produce a verified source-head witness.
Provenance references#
Every crawled page has a sourceRef, a string of the form
forage-snapshot:<sourceId>?url=…&sha256=…&fetchedAt=…&snapshotSha256=…. It
commits to the body hash and to a digest of the canonical replay metadata
(status, headers, redirects, render state, and body encoding), so a review
tool can cite it and later prove which bytes it meant.
resolveSnapshotSourceRef(store, ref) parses a canonical reference, looks it
up with the store's findExact, and checks the stored snapshot against every
committed field. It returns { ok: true, snapshot, integrity } or
{ ok: false, error }, where error.kind distinguishes an invalid reference,
a missing snapshot, a mismatch, a corrupt store, and a store that could not be
read. It never fetches and never accepts a hash prefix.
Where forage sits#
forage knows nothing about extraction. In the Kontour stack it is the bottom layer:
- traverse (
@kontourai/traverse) does schema-directed extraction and composes forage's crawl and guarded egress in its/fetchsubpath.@kontourai/forage/fetchkeeps the same import names as@kontourai/traverse/fetch, so a caller can switch between them. - lookout is the consumer the
@kontourai/forage/fetchsubpath was exported for (its drift checks), and it uses the guarded single-URL egress in production. - campfit is the application on top. Its move from its own egress and render code onto forage is still pending.