NanoScraper · collection pipeline
How a scrape becomes a record you can trust
From the URL someone submits to the row they export. Every layer assumes the one below it is untrusted — that idea explains most of the design.
Where the boundary sits
Ours
The API, job records, the queue, the worker, the connector interface and the guarded fetch.
untrusted
The engines
Third-party containers on a network of their own. They supply bytes and decide nothing else.
The store
Immutable raw observations, then canonical records carrying field-level provenance.
The dashed rules are where trust stops. An engine supplies bytes and decides nothing else.
Stage A–F
The whole path, at once
Six stages, four groups, three trust zones. Tenancy, lifecycle, persistence, deduplication and provenance stay ours — which is why swapping an engine touches one module and nothing downstream of it.
Only the engine container is isolated — the connector and its guarded fetch are ours, inside the worker. Three connectors open the socket through fetch.py; one hands it to a container, and nothing downstream can tell which.
Stage A · B
Creating and dispatching a job
Two refusals land before any work starts, in order: readiness, then config. Validating the config of a connector that cannot run would imply a correct config leads somewhere.
Quota admission comes before the run row exists — admitting afterwards leaves an orphaned pending run behind every refusal, and pending runs count toward the next check.
Authorization happens twice, on purpose. The task carries a workspace id as a scalar, never a trusted object, and the worker proves membership again — the queue is not a trust boundary.
Stage C
Four connectors, two ways of reaching the web
The connectors differ in one thing that matters: who opens the socket, and therefore where the SSRF check sits.
Scrapling is a parser only. Letting it fetch would open a second way out that knows nothing about our blocked ranges. Parsing alone avoids about 1.5 GB of Chromium and still buys JSON-LD — a business stating its own phone number in machine-readable form.
Every address a person names is fetched by our own guarded egress. The one engine left is handed search terms, not addresses, so there is no target of anyone’s choosing to validate on its behalf.
Stage C·1
Inside the guarded fetch
The threat is rarely http://127.0.0.1. It is five subtler shapes, and one URL is checked for every one of them.
- Private answerA public hostname whose DNS record resolves somewhere private.
- Second lookupA name that answers differently the second time it is asked.
- RedirectA hop that lands in the metadata service.
- Mapped spellingAn IPv4-mapped IPv6 form of a blocked range.
- No endA body that never stops arriving.
Pinning the address is what closes DNS rebinding. The Host header and TLS SNI still carry the original name, so virtual hosting and certificates keep working.
Stage C·2
What an engine can reach if it is compromised
Application validation is not the last line. Three things it cannot cover:
- Its own redirect
- A hop the engine follows itself, after our check has passed.
- A changed answer
- DNS that moves between our check and its connect.
- A defect
- A flaw in the engine itself.
The engines network has no route to PostgreSQL or Redis. A connection to PostgreSQL was tried from inside the engine and timed out, and a topology check asserts both against the declared configuration and the live network.
Network isolation holds then, and both controls are required. It bounds what a compromised engine can reach; which workspace's data a legitimate request may touch is a different boundary, on /docs/security.
Stage D
Storing what was observed, and what happens when it fails
A connector failure is a normal operating condition, not a crash. The run stores a classified reason — transient for a timeout or an upstream 5xx, invalid_config for a bad configuration — and stays inspectable rather than escaping as a server error.
Classification does not reopen the run. A failed run is terminal — the lifecycle table declares no transition out of it — and re-running opens a new attempt, so the failure stays in the history.
Uniqueness is scoped to a single run, so a redelivered task cannot store the same observation twice. Re-running opens a new attempt with its own copies, and the count recorded is what was persisted.
Stage E
Raw becomes canonical, and stays traceable
Normalization is a separate task, so a normalization bug cannot roll back the collection that produced the evidence, and re-normalizing after a rule change is a repair path rather than a gamble.
Raw observations are never mutated. Saving one twice raises.
Provenance records both halves, because they are not the same claim. Where the source said nothing the observed value stays blank — inventing it would put fabricated evidence in the table whose job is to be checkable.
Stage F
The catalogue, and the two that never collect
Whether a connector can run right now is answered by the application, not this page. /api/v1/connectors/ re-checks the operator's switches on every request and asks each engine-backed connector whether its engine answers.
Readiness travels with the catalogue, so the interface can disable a control rather than offer an action the API would refuse anyway. The table states only what each connector declares in code.
| Connector | Declared in code | Who fetches | Notes |
|---|---|---|---|
| web_extract | on by default | us, guarded | parses pages we fetched ourselves |
| scrapling | on by default | us, guarded | parser only — never its fetchers; adds JSON-LD |
| website_emails | on by default | us, guarded | reads a business's own site for an email address |
| google_maps | on by default | the engine | keywords, not URLs — no SSRF surface from input |
| reference | diagnostic, off by default | nobody | its records are invented |
Five connectors, registered by apps/connectors/apps.py.
- reference
- Fabricates records. Once stored they are indistinguishable from collected ones, so it is labelled diagnostic and stays off.
- extraction
- The sixth, registered by apps/extraction/apps.py. It runs saved extraction definitions through the same boundary and appears in the same catalogue response.
Where these pictures come from
Every claim here comes from the code and from runs executed against production. The Mermaid sources are committed alongside the SVGs they produce, and the renderer between them is a container pinned by digest.
The security boundariesWhat has actually been verifiedSign in