84 stars on GitHub and counting. Yozh Crawler + Scraper is free, open-source, and built in public — give us a star if it earns its place in your stack.
CyberYozh Data / Yozh Crawler
Software · Yozh Crawler

One seed URL in, the whole site streamed out

Yozh Crawler walks a site from a single URL and streams every discovered page over SSE. It owns the hard part of crawling — frontier, dedup, scope, politeness, retries — while each page fetch goes through Yozh Scraper. No Playwright duplication, no SaaS, runs on :8001.

$curl -N :8001/api/v1/crawl -d '{"seed_url":"https://site.com", "scope":{"mode":"same-domain","max_depth":2,"max_pages":500}}'

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 predicatessame-domain / subdomains / all / regex, with include & exclude patterns and hard max_depth / max_pages caps.
  • 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 429 halves the RPS for a cool-off window.
  • Session health — Crawlee-style scoring: 401/403/429 retire 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 terminal done / cancelled events. Act on finds as they land.
Base URLhttp://localhost:8001
OpenAPI docshttp://localhost:8001/docs
MCP endpointhttp://localhost:8001/mcp
Companion service. The crawler delegates every fetch to Yozh Scraper, so bring both up together — the scraper on :8000 and the crawler on :8001.

Quickstart

The crawler is wired into the root docker-compose.yml and comes up alongside the scraper:

bash
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:

bash
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
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:

JSON
{ "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.

enable_scraping: false

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
enable_scraping: true

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 ScrapeResponse per page
  • raw_html · screenshot · extracted data
  • Uses scrape_options.proxy_*
  • Pass extract rules to land clean JSON
Proxy selection. 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.

FieldTypeDefaultDescription
modesame-domain · subdomains · all · regexsame-domainWhich links are in-scope. regex matches against include_patterns.
include_patternsstring[][]Regex patterns a URL must match to be queued (used by regex mode / as an allow-list).
exclude_patternsstring[][]Regex patterns that drop a URL even if otherwise in-scope.
max_depthint3Maximum link depth from the seed (seed is depth 0).
max_pagesint500Hard cap on pages visited before the crawl finishes.
per_domain_rpsfloat1.0Token-bucket refill rate per domain (requests/second).
per_domain_concurrencyint1Max in-flight requests to a single domain.
Naive 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
curl -N http://localhost:8001/api/v1/crawl/crawl_abc123/events

The stream emits five event types:

EventWhen
statsPeriodic progress snapshot (visited / queued / failed / dedup_skipped / out_of_scope / retries).
pageOne per visited URL. Carries the full ScrapeResponse in harvest mode, thin metadata in discovery mode.
page_errorA URL that failed after all retries.
doneTerminal — the crawl finished normally.
cancelledTerminal — the crawl was cancelled.

A page event in discovery mode looks like this:

JSON
{
  "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
curl http://localhost:8001/api/v1/crawl/crawl_abc123
curl http://localhost:8001/api/v1/crawl/crawl_abc123/results
JSON
{
  "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 queuedrunningdone (or failed / cancelled).

Cancelling a crawl

Stop a running job with a DELETE. Soft cancel is the default and the safe choice.

cURL
# 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 cancel orphans the scraper. A ?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:

  • health
  • create_crawl
  • get_crawl
  • get_crawl_results
  • cancel_crawl
~/.claude/settings.json
"open-crawler": {
  "type": "http",
  "url": "http://localhost:8001/mcp"
}
The SSE stream_crawl_events endpoint is deliberately excluded from MCP — streaming responses don't translate to a request/response tool.

Configuration reference

Request — CrawlRequest

FieldTypeDefaultDescription
seed_urlstring (URL)requiredThe single URL the crawl starts from.
scopeCrawlScopedefaultsBoundary & politeness — see Crawl scope.
scrape_optionsScrapeOptionsdefaultsForwarded verbatim to the scraper per page (url is injected).
crawl_proxyScrapeOptions · nullnullCheap proxy used in discovery mode. Only proxy_type / proxy_pool_id / proxy_geo are read.
enable_scrapingboolfalseKeep the full ScrapeResponse per page (true) or just discovery metadata (false).

Per-page — ScrapeOptions (selected)

FieldTypeDefaultDescription
proxy_typenone · mobile · res_static · res_rotating · dc_static · …noneProxy pool the page fetch routes through.
devicedesktop · mobiledesktopViewport / UA profile.
renderbooltrueRender with the browser (needed for JS-built links).
stealthbooltrueApply anti-detection hardening.
wait_untildomcontentloaded · networkidledomcontentloadedWhen the page is considered ready.
screenshotboolfalseCapture a screenshot (harvest mode).
extractExtractRule · nullnullCSS/XPath field rules → structured data per page.
session_idstring · nullnullReuse an authenticated scraper session — see Authenticated crawls.

Environment

VariableDefaultNotes
SCRAPER_URLhttp://web-scraper:8000Upstream scraper (use the docker service name inside the compose network).
WORKERS2Number of parallel crawl jobs.
QUEUE_MAXSIZE200Pending-jobs queue depth.
JOB_TIMEOUT_MS3_600_000Wall-clock cap on one crawl job.
SCRAPER_JOB_TIMEOUT_MS120_000Per-page scraper request timeout.
MAX_RETRIES3Retries per request on transient failure.
RETRY_HTTP_CODES[408,429,500,502,503,504]Status codes that trigger a retry.
RETRY_BACKOFF_MAX30.0Upper bound for exponential backoff (seconds).
SESSION_MAX_ERROR_SCORE3.0Session retire threshold.
SESSION_MAX_USAGE50Rotate a session after N uses.
SESSION_BLOCKED_CODES[401,403,429]Codes that retire a session instantly.

API reference

MethodPathPurpose
POST/api/v1/crawlCreate a job. Returns {"job_id":"…"}.
GET/api/v1/crawl/{id}Job record — status, stats, pages so far.
GET/api/v1/crawl/{id}/resultsAlias of the job record.
GET/api/v1/crawl/{id}/eventsSSE stream (stats/page/page_error/done/cancelled).
DELETE/api/v1/crawl/{id}?hard=boolCancel the job (soft or hard).
GET/api/v1/healthHealth + scraper reachability.

Important details

No authentication (v1). No endpoint is authenticated — it's designed for internal / trusted networks. Anyone who can reach it could POST a crawl with an arbitrary seed and turn it into an SSRF proxy. Keep it inside your VPC or behind your own auth gateway.
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.
No persistence. Jobs live in memory and reset on container restart (symmetric with the scraper). For durability, stream the SSE feed into your own store (a file, Postgres, Kafka) as pages arrive. Restart long-lived processes periodically — finished jobs accumulate in the store.
Authenticated crawls. Create a session on the scraper (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.
Live example

Same-domain — the safe default

Stay on the exact seed host, cap depth and pages, stay polite. The discovery mode (enable_scraping: false) keeps only the skeleton of each page.


            

Loved by data teams and AI builders

What people building with Yozh say

5.0 / 5 · 5 reviews
GitHub
Swapped our in-house Playwright cluster for Yozh in an afternoon. The MCP endpoint dropped straight into our Claude agent — zero glue code, crawler and scraper just worked.
Marcus Reinhardt Lead Data Engineer Northwind Analytics
X
The preset system is the killer feature. We pass a source name and get clean JSON back; the self-heal even caught two Amazon layout changes before we noticed.
Priya Nair Founder ScrapeStack
Reddit
Finally an open-source scraper that treats proxies and sessions as first-class. We run it behind login walls for partner portals — sessions persist and results stay consistent across regions.
Daniel Osei Backend Engineer Loopfeed
X
Connected it to Cursor over MCP and my agent pulls live web data mid-task now. The streaming crawl over SSE is exactly what agent workflows were missing.
Elena Kovac AI Engineer Vektor Labs
GitHub
We moved off a paid scraping API to cut costs and braced for a downgrade — got the opposite. Self-hosted, no per-request billing, and the output schema is cleaner than what we used to pay for.
Sofia Almeida Engineering Manager Tabbly
Open Source · MIT License · 84 ★

Point it at a URL. Watch the site stream in

Yozh Crawler ships in the same repo as the scraper — one docker compose up and you have discovery + extraction, MCP-ready, on your own infra. Free. Forever. Star us if it earns its place in your stack.

Yozh Crawler + Scraper is distributed under the MIT license. Use it. Fork it. Build with it.