Crawl
Yozh Crawler walks a site from a single seed URL and streams every discovered page over SSE. It owns discovery — frontier, dedup, scope, link extraction, retries, politeness — while every page fetch is delegated to Yozh Scraper over HTTP. There's exactly one Playwright stack to operate, and every scraper feature (proxy, stealth, sessions, extract rules) applies to crawled pages too.
- Scope predicates —
same-domain/subdomains/all/regex, with include & exclude patterns and hardmax_depth/max_pagescaps. - Fingerprint dedup — URLs are canonicalized then hashed (SHA1) so reordered queries and trailing-slash variants collapse to one visit.
- Per-domain round-robin frontier — one slow or huge host can't starve the others of workers.
- Adaptive rate limiter — a global per-domain token bucket (with jitter) shared across jobs; a
429halves the RPS for a cool-off window. - Session health — Crawlee-style scoring:
401/403/429retire a session, errors raise its score, success lowers it; rotate at score ≥ 3 or 50 uses. - SSE streaming — every job emits
stats,page,page_error, and terminaldone/cancelledevents. Act on finds as they land.
:8000 and the crawler on :8001.Quickstart
The crawler is wired into the root docker-compose.yml and comes up alongside the scraper:
docker compose up --build # scraper → http://localhost:8000 # crawler → http://localhost:8001
Or run it locally without Docker — point SCRAPER_URL at a running scraper:
cd yozh-crawler pip install -r requirements.txt cp .env.example .env # edit SCRAPER_URL if needed python -m uvicorn src.main:app --reload --port 8001
Basic usage
Submit a crawl with a seed_url and a scope. The call returns a job_id immediately; the crawl then runs in the background across worker tasks.
curl -X POST http://localhost:8001/api/v1/crawl \
-H "Content-Type: application/json" \
-d '{
"seed_url": "https://example.com",
"scope": {"mode": "same-domain", "max_depth": 2, "max_pages": 50,
"per_domain_rps": 1.0, "per_domain_concurrency": 1},
"scrape_options": {"proxy_type": "none"},
"enable_scraping": false
}'
The response is just the job handle:
{ "job_id": "crawl_abc123" }
From here you either stream live events or poll the job for the full record.
Discovery vs harvest — the enable_scraping toggle
The same crawl engine runs both ways. One boolean chooses between a cheap discovery map and a full-payload harvest — and each mode uses its own proxy config, so there's no cross-pollination.
Discovery map
A lightweight pass. The scraper still renders each page (so JS-built links are found), but the crawler keeps only the skeleton — ideal for sitemaps, link audits, and delta detection.
- Keeps
url·parent_url·depth·status·took_ms - Drops raw HTML, screenshots & extracted data
- Uses the cheaper
crawl_proxy - Smallest payloads, fastest crawls
Full harvest
Every visited page is kept with its complete ScrapeResponse — raw HTML, optional screenshot, and any fields from your extract rules. Crawl and structure in one pass.
- Keeps the full
ScrapeResponseper page -
raw_html·screenshot· extracteddata - Uses
scrape_options.proxy_* - Pass
extractrules to land clean JSON
crawl_proxy is used only when enable_scraping=false; scrape_options.proxy_* is used when true. If crawl_proxy is null, the scrape_options proxy is used regardless of mode.Crawl scope
Scope bounds the crawl: where it's allowed to walk, and how hard it's allowed to push each host. It's the scope object on the request.
| Field | Type | Default | Description |
|---|---|---|---|
mode | same-domain · subdomains · all · regex | same-domain | Which links are in-scope. regex matches against include_patterns. |
include_patterns | string[] | [] | Regex patterns a URL must match to be queued (used by regex mode / as an allow-list). |
exclude_patterns | string[] | [] | Regex patterns that drop a URL even if otherwise in-scope. |
max_depth | int | 3 | Maximum link depth from the seed (seed is depth 0). |
max_pages | int | 500 | Hard cap on pages visited before the crawl finishes. |
per_domain_rps | float | 1.0 | Token-bucket refill rate per domain (requests/second). |
per_domain_concurrency | int | 1 | Max in-flight requests to a single domain. |
subdomains scope. It uses the last two labels as the registrable domain, so it over-matches on Public Suffix List hosts like github.io or co.uk. Prefer same-domain or regex for those.Streaming results (SSE)
Every job exposes a Server-Sent Events stream. Read it to act on pages the moment they're discovered, instead of waiting for the crawl to finish.
curl -N http://localhost:8001/api/v1/crawl/crawl_abc123/events
The stream emits five event types:
| Event | When |
|---|---|
stats | Periodic progress snapshot (visited / queued / failed / dedup_skipped / out_of_scope / retries). |
page | One per visited URL. Carries the full ScrapeResponse in harvest mode, thin metadata in discovery mode. |
page_error | A URL that failed after all retries. |
done | Terminal — the crawl finished normally. |
cancelled | Terminal — the crawl was cancelled. |
A page event in discovery mode looks like this:
{
"event": "page",
"url": "https://example.com/docs/intro",
"parent_url": "https://example.com/docs",
"depth": 2,
"status_code": 200,
"took_ms": 812,
"scrape_response": null
}
Status & results
Prefer polling? The job record is queryable any time — including mid-crawl, with the pages discovered so far. /results is an alias of the job endpoint, kept for symmetry with the scraper.
curl http://localhost:8001/api/v1/crawl/crawl_abc123 curl http://localhost:8001/api/v1/crawl/crawl_abc123/results
{
"job_id": "crawl_abc123",
"status": "running",
"stats": {
"visited": 47, "queued": 12, "failed": 0,
"dedup_skipped": 9, "out_of_scope": 31, "retries_total": 2
},
"pages": [
{ "url": "https://example.com/", "parent_url": null,
"depth": 0, "status_code": 200, "took_ms": 640 }
]
}
status moves through queued → running → done (or failed / cancelled).Cancelling a crawl
Stop a running job with a DELETE. Soft cancel is the default and the safe choice.
# soft — stop scheduling, let in-flight requests drain curl -X DELETE "http://localhost:8001/api/v1/crawl/crawl_abc123?hard=false" # hard — abort in-flight asyncio tasks immediately curl -X DELETE "http://localhost:8001/api/v1/crawl/crawl_abc123?hard=true"
?hard=true drops the crawler's in-flight request, but the scraper has no way to learn that — its page render finishes on its side. Prefer soft cancel unless you must stop right now.MCP
fastapi-mcp is mounted at /mcp as a Streamable HTTP endpoint. Point Claude or Cursor at it and the tools appear automatically:
healthcreate_crawlget_crawlget_crawl_resultscancel_crawl
"open-crawler": {
"type": "http",
"url": "http://localhost:8001/mcp"
}
stream_crawl_events endpoint is deliberately excluded from MCP — streaming responses don't translate to a request/response tool.Configuration reference
Request — CrawlRequest
| Field | Type | Default | Description |
|---|---|---|---|
seed_url | string (URL) | required | The single URL the crawl starts from. |
scope | CrawlScope | defaults | Boundary & politeness — see Crawl scope. |
scrape_options | ScrapeOptions | defaults | Forwarded verbatim to the scraper per page (url is injected). |
crawl_proxy | ScrapeOptions · null | null | Cheap proxy used in discovery mode. Only proxy_type / proxy_pool_id / proxy_geo are read. |
enable_scraping | bool | false | Keep the full ScrapeResponse per page (true) or just discovery metadata (false). |
Per-page — ScrapeOptions (selected)
| Field | Type | Default | Description |
|---|---|---|---|
proxy_type | none · mobile · res_static · res_rotating · dc_static · … | none | Proxy pool the page fetch routes through. |
device | desktop · mobile | desktop | Viewport / UA profile. |
render | bool | true | Render with the browser (needed for JS-built links). |
stealth | bool | true | Apply anti-detection hardening. |
wait_until | domcontentloaded · networkidle | domcontentloaded | When the page is considered ready. |
screenshot | bool | false | Capture a screenshot (harvest mode). |
extract | ExtractRule · null | null | CSS/XPath field rules → structured data per page. |
session_id | string · null | null | Reuse an authenticated scraper session — see Authenticated crawls. |
Environment
| Variable | Default | Notes |
|---|---|---|
SCRAPER_URL | http://web-scraper:8000 | Upstream scraper (use the docker service name inside the compose network). |
WORKERS | 2 | Number of parallel crawl jobs. |
QUEUE_MAXSIZE | 200 | Pending-jobs queue depth. |
JOB_TIMEOUT_MS | 3_600_000 | Wall-clock cap on one crawl job. |
SCRAPER_JOB_TIMEOUT_MS | 120_000 | Per-page scraper request timeout. |
MAX_RETRIES | 3 | Retries per request on transient failure. |
RETRY_HTTP_CODES | [408,429,500,502,503,504] | Status codes that trigger a retry. |
RETRY_BACKOFF_MAX | 30.0 | Upper bound for exponential backoff (seconds). |
SESSION_MAX_ERROR_SCORE | 3.0 | Session retire threshold. |
SESSION_MAX_USAGE | 50 | Rotate a session after N uses. |
SESSION_BLOCKED_CODES | [401,403,429] | Codes that retire a session instantly. |
API reference
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/crawl | Create a job. Returns {"job_id":"…"}. |
| GET | /api/v1/crawl/{id} | Job record — status, stats, pages so far. |
| GET | /api/v1/crawl/{id}/results | Alias of the job record. |
| GET | /api/v1/crawl/{id}/events | SSE stream (stats/page/page_error/done/cancelled). |
| DELETE | /api/v1/crawl/{id}?hard=bool | Cancel the job (soft or hard). |
| GET | /api/v1/health | Health + scraper reachability. |
Important details
robots.txt is not consulted. The crawler walks every in-scope URL regardless of /robots.txt. You're responsible for honoring robots rules and a site's terms on targets you don't own — use exclude_patterns and rate limits to stay polite.POST /sessions + POST /sessions/{id}/login), then pass scrape_options.session_id. The scraper round-trips cookies + storage state per request, so every crawled page sees the authenticated state.