Crawllr

Documentation

How the crawler works.

Three ways to find pages, six ways to get them back out. Everything below is available over HTTP with no account and no API key, from the command line, or as an MCP server inside Claude.

Crawl modes

The mode decides how pages are found, not how they are extracted — extraction is the same either way. If you are not sure which to use, probe the site first and take the recommendation.

normal --depth N

Any site. The fallback when there is nothing better.

Starts at the URL you give it and walks internal links breadth-first, one depth level at a time. Only reaches pages something links to — orphan pages stay invisible.

sitemap --sitemap

Static sites, and anywhere with orphan pages.

Reads robots.txt, then every common sitemap location. Follows sitemap indexes and handles gzipped .xml.gz sitemaps. Usually the most complete option for a non-WordPress site.

wordpress --wordpress

WordPress sites with an open REST API.

Enumerates published content straight through /wp-json/wp/v2/. Several times faster, because there is no HTML to parse. Falls back to fetching the live page when a page builder leaves content.rendered empty.

All three fetch concurrently and crawl breadth-first, one depth level at a time.

How a page becomes text

Every fetched page goes through the same pipeline, whatever mode found it.

  1. 1 Trafilatura extracts the main content as markdown, keeping links, tables and formatting, and dropping navigation, headers and boilerplate.
  2. 2 If that comes back empty or suspiciously short next to the page's visible text, a DOM fallback runs instead and the larger result wins. The page records which one was used as extraction_method.
  3. 3 Metadata, JSON-LD and microdata are pulled out separately, so structured data survives even when the prose does not.
  4. 4 Links are collected and split into internal and external, which is what feeds the next depth level in normal mode.

URL handling

Two URLs that render the same page are normalised to the same key so the same content is never crawled twice: the scheme and host are lowercased, fragments and tracking parameters are dropped, remaining parameters are sorted, and index files and trailing slashes are stripped.

That normalised form is used for deduplication only. The URL actually requested is the one the site advertises — sitemap entries and links are fetched exactly as written. This matters on sites that canonicalise to a trailing slash, where requesting the stripped form would earn a redirect that misses the CDN and doubles the number of requests.

HTTP API

Four endpoints, no authentication. POST bodies are JSON. Every response carries a success boolean, and failures carry an error string.

POST /api/probe.php Call this first.

Inspects a site before you commit to crawling it: does it publish a sitemap, is the WordPress REST API open, and how many pages would each strategy reach. Returns a recommendedMode you can pass straight to crawl.php.

Parameter Type Default Description
url req string The site to inspect. Must be http or https.
POST /api/crawl.php Starts a job, returns immediately.

Launches a background crawl and hands back a job_id. The crawl keeps running server-side whether or not you keep polling.

Parameter Type Default Description
url req string The site to crawl.
crawl_mode string "normal" One of "normal", "sitemap", "wordpress". Anything else silently falls back to "normal".
max_pages integer 150 Clamped to 1–1000. Pass 0 for the 1000-page maximum.
depth integer 2 Link levels to follow, clamped to 1–5. Ignored in wordpress mode.
workers integer 5 Concurrent fetches, clamped to 1–10.
wp_types string "posts,pages" WordPress mode only. Comma-separated REST bases, or "all".
include_paths string "" Restrict the crawl to these path prefixes, e.g. "/blog,/guides".
exclude_paths string "" Skip these paths, e.g. "/tag,/author".
include_subdomains boolean false Follow links onto subdomains of the same site.
respect_robots boolean false Obey robots.txt disallow rules.
GET /api/status.php Poll until it is done.

Returns live progress while the crawl runs, then the finished result plus a saved_as filename. The result is delivered once — poll again after that and the job reads as expired (404).

Parameter Type Default Description
job_id req string The job_id returned by crawl.php.
GET /api/export.php Re-render without re-crawling.

Renders a completed crawl in any output format. Completed crawls are saved server-side, so switching format costs nothing — the site is not fetched again.

Parameter Type Default Description
file req string A filename previously returned as saved_as. Anything else is rejected.
format string "markdown" Any of the six output formats.
inline integer Pass 1 to render in the browser instead of downloading.

A crawl, end to end

Probe, start, poll, export. The crawl runs detached — a dropped poll does not stop it, and you can pick the job back up with the same job_id until it is delivered.

Probe first
curl -X POST https://crawller.dev/api/probe.php \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'
Then crawl
# 1. start the job
curl -X POST https://crawller.dev/api/crawl.php \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com","crawl_mode":"sitemap","max_pages":200}'

# → {"success":true,"job_id":"crawl_6a94...","mode":"sitemap","crawls_remaining":11}

# 2. poll it
curl "https://crawller.dev/api/status.php?job_id=crawl_6a94..."

# 3. re-render the finished crawl in any format
curl "https://crawller.dev/api/export.php?file=example.com-2026-08-30_120000-a1b2c3.json&format=llms-full&inline=1"

While it runs

status.php
{
  "success": true,
  "state": "running",
  "progress": {
    "state":      "running",
    "crawled":    84,
    "discovered": 397,
    "max_pages":  200,
    "current":    "https://example.com/pricing/"
  }
}

When it finishes

status.php
{
  "success": true,
  "state": "complete",
  "saved_as": "example.com-2026-08-30_120000-a1b2c3.json",
  "data": {
    "summary": {
      "startUrl":       "https://example.com/",
      "baseDomain":     "example.com",
      "totalPages":     120,
      "successPages":   120,
      "errorPages":     0,
      "emptyPages":     0,
      "discoveredUrls": 397,
      "maxDepth":       1,
      "crawlMode":      "sitemap",
      "workers":        5,
      "crawlTime":      "18.6s",
      "timestamp":      "2026-08-30T12:00:00.000000",
      "usedSitemap":    true,
      "truncated":      true
    },
    "pages": [
      {
        "url":         "https://example.com/pricing/",
        "title":       "Pricing",
        "description": "Simple per-seat pricing.",
        "content":     "## Pricing\n\nSimple per-seat pricing...",
        "wordCount":   612,
        "extraction_method": "trafilatura",
        "metadata":    { "canonical": "...", "openGraph": {} },
        "schema":      { "jsonLd": [], "microdata": [] },
        "links":       { "internal": [], "external": [] },
        "error":       null
      }
    ]
  }
}

Output formats

Set on the crawler with --format, or applied after the fact by export.php. Re-rendering a saved crawl never re-fetches the site.

Format What you get
json The full crawl object — every page with metadata, schema, and extracted links. The default.
clean-json The same crawl with the noise dropped: URL, title, description, and content only.
markdown One markdown document for the whole site, with a header block and each page as a section.
llms An llms.txt index — one titled, described link per page. Small enough to paste anywhere.
llms-full llms.txt with the full page text inlined. The one to use when the result goes straight into a prompt.
text Plain text, no markup at all.

Limits

The public API is unauthenticated, so it is rate limited per IP. Values a request exceeds are clamped rather than rejected.

12
Crawls per hour, per IP
60
Probes per hour, per IP
1000
Max pages per crawl
5
Max crawl depth
10
Max concurrent workers
1 hour
Job files kept for

crawl.php returns a crawls_remaining count with every job so you can see where you stand. Private and reserved IP addresses are refused.

Command line

The crawler runs standalone with no PHP and no web server — pyScripts/site_crawler.py. It needs trafilatura, beautifulsoup4, requests and lxml_html_clean.

Terminal
# what will reach the most pages?
python pyScripts/site_crawler.py https://example.com --probe -p

# whole site from the sitemap, unlimited pages, 8 workers
python pyScripts/site_crawler.py https://example.com --sitemap --max-pages 0 --workers 8

# WordPress, every public post type
python pyScripts/site_crawler.py https://example.com --wordpress --wp-types all

# straight to llms-full on disk
python pyScripts/site_crawler.py https://example.com --sitemap --format llms-full -o site.txt

# one page, markdown only
python pyScripts/site_crawler.py https://example.com/about --single --markdown

# re-render an existing crawl without touching the network
python pyScripts/site_crawler.py --render-file site.json --format markdown
Flag Default Description
--depth, -d 2 Max link depth in normal mode.
--max-pages, -m 200 Page cap. 0 or negative means unlimited.
--workers, -w 5 Concurrent fetches.
--delay 0.0 Per-request pause, in seconds. Applied per worker, so pair it with --workers 1 to actually slow the crawl down.
--timeout 25 Per-request timeout in seconds.
--retries, -r 3 Attempts per URL before the page is given up on.
--sitemap off Seed from robots.txt and sitemap.xml instead of following links.
--wordpress off Pull content through the WordPress REST API.
--wp-types posts,pages WordPress REST bases to fetch, or "all".
--single, -s off One page, no link following.
--probe off Report which mode reaches the most pages, then exit.
--format, -f json Output format. Any of the six below.
--render-file Re-render an existing crawl JSON instead of crawling.
--include, -i Restrict to a path prefix. Repeatable.
--exclude, -e Skip matching URLs. Repeatable.
--include-subdomains off Follow links onto subdomains of the same site.
--respect-robots off Obey robots.txt disallow rules.
--user-agent CrawllBot/2.0 Override the User-Agent.
--output, -o stdout Write to a file instead of stdout.
--pretty, -p off Pretty-print JSON output.

Want this inside Claude instead? Set up the MCP server, or try a crawl in the browser.