CLI
Every command and subcommand, with the flags that matter.
ccrawl <command> [subcommand] [flags]
Run ccrawl <command> --help for the full flag list on any command.
Commands
| Command | What it does |
|---|---|
crawls |
List, resolve, and inspect the monthly crawls |
search |
Query the URL index (CDX) for captures of a URL or pattern |
get |
Fetch what Common Crawl captured for a URL |
fetch |
Retrieve WARC records by explicit location, or from stdin |
export |
Write matching captures into WARC files with provenance |
download |
Download whole archive files for a crawl |
paths |
List the archive file paths for a crawl |
parse |
Decode a local WARC/WAT/WET file into records |
extract |
Pull text, links, title, or Markdown from a captured page |
content |
Live-fetch content signals: text, outlinks, quality |
news |
Work with the continuous CC-NEWS dataset |
columnar |
Query the columnar Parquet index |
markdown |
Build Markdown Parquet datasets from CC WARCs and publish them |
rank |
Look up host and domain ranks from the web graph |
host |
Enumerate and enrich hosts from the CC web graph |
urls |
Mirror the Common Crawl URL index to a HuggingFace dataset |
domains |
Mirror the Common Crawl domain ranks to a HuggingFace dataset |
publish |
Maintenance for the published Common Crawl datasets |
crawl |
Recrawl engine: seed, fetch, and write WARC output |
recrawl |
Recrawl a published dataset, streaming the work list rather than queueing it |
sched |
Recrawl scheduling: tier assignment and differential CDX analysis |
index |
Build and query a local BM25 full-text search index |
api |
Start the v2 REST API server |
db |
Build and query a local DuckDB database |
convert |
Convert WARC/WAT/WET archives to Parquet or JSONL |
library |
Inspect, verify, and collect the dataset library |
dedup |
Report exact and near duplicate documents in a Markdown Parquet dataset |
stats |
Show the shape of a crawl: file counts per archive kind |
serve |
Serve the operations over HTTP as NDJSON |
mcp |
Run as an MCP server over stdio |
config |
Show resolved configuration and data paths |
cache |
Inspect and clear the on-disk cache |
version |
Print the version and exit |
crawls
| Subcommand | Does |
|---|---|
crawls list |
List the monthly crawls, newest first |
crawls latest |
Print the newest crawl ID |
crawls resolve <ref> |
Resolve a year or latest to a crawl ID |
crawls info [id] |
File counts per archive kind for a crawl |
crawls info and stats are the same command under two names, so they count the same kinds and answer with the same rows: crawl, kind, files.
Both take --kinds to narrow the list, and both honour -o, so crawls info -o csv writes CSV the way every other read command does.
The one difference is how you name the crawl: crawls info takes it as a positional argument, stats reads -c, and either one falls back to the configured crawl when you leave it out.
A kind whose manifest could not be fetched comes back with files of -1 rather than being dropped, so a row is never silently missing.
search
ccrawl search <url|pattern> [flags]
A trailing /* matches everything under a path.
Filters: --mime, --status, --from, --to, --filter.
URL filters: --url-contains, --url-not-contains.
Pick the capture closest to a date with --at (for example --at 2023-06).
Order with --sort newest|oldest.
Estimate the size of a result instead of listing it with --estimate.
Shaping: --fields, --template, -o, -n.
Alias: cdx.
Where the filtering happens
--status, --mime, --lang, --filter, --url-contains and --url-not-contains are all sent to the index server, which drops the rows before they leave it.
The two URL substrings become regex filters on the url field, and they are checked again on the way past here, which costs nothing and means a server that filtered differently cannot widen the result.
Everything that has to compare one record against another stays on this machine: --at, --latest-only and --dedup need the whole result set, and the index has no way to express them.
--explain prints the split, along with the request the index server actually answers and the bytes the run read from it:
$ ccrawl search '*.senate.gov' --url-contains /budget --explain -o url
search: 1 crawl: CC-MAIN-2026-30
search: the index server answers https://index.commoncrawl.org/CC-MAIN-2026-30-index?filter=~url%3A.%2A%2Fbudget.%2A&matchType=domain&output=json&url=senate.gov
search: pushed to the server: --url-contains /budget
search: applied here: --url-contains /budget (again, on what the server sent)
search: read 49.4 KB from the index (50576 bytes)
Paste that URL into curl and the rows that come back are the rows the command reads.
Run the same query twice, once with --no-push-filters, and the two byte counts say what the push saved.
That query is 10 index pages over CC-MAIN-2026-30 and 77 of its rows hold /budget.
Pushed, it reads 50,576 bytes.
With --no-push-filters it reads about 75 MB to find the same rows, which is roughly 1,500 times as much for the same answer.
--no-push-filters keeps the URL substrings off the wire and filters here instead.
It is an escape hatch, not a tuning knob: the only reason to reach for it is an index server whose filtering disagrees with ours, and the cost of it is every unwanted row downloaded before it is dropped.
The export command takes the same flag, for the two URL substring filters of its own.
The cost is worse than the bytes suggest, and it is worth knowing before reaching for the flag.
The index truncates a large page under load and closes the connection as if it had finished, so a run that moves 75 MB to answer a query loses a few rows to that and cannot tell you which.
The *.senate.gov query above was run twice with --no-push-filters on 2026-08-18: it read 70.3 MB and returned 75 rows, then read 77.3 MB and returned a different 75, each time missing two the pushed run found.
Pushed, the same query returned all 77 every time.
Filtering on the server is the difference between a complete answer and a nearly complete one, not only a faster one.
Memory over a wide query
--at, --latest-only and --dedup each have to remember something about every URL the query touches, and a wildcard over a large domain across every crawl touches hundreds of millions of them.
The three of them share one budget, --max-buffer, which is how many records they hold in memory before the run starts writing to a temporary file in TMPDIR instead.
The default is 5,000,000 records, which is a few hundred megabytes, and the temporary files are removed when the command exits however it exits.
| Flag | Description |
|---|---|
--max-buffer |
Records --at, --latest-only and --dedup hold in memory before spilling to disk (default 5000000) |
A CDX response is sorted by urlkey and every capture of a URL sits in one urlkey group, so --at and --latest-only stay exact past the budget: they reduce each group as it goes by and merge the per crawl runs afterwards.
--dedup is the exception, because payload digests arrive in no order at all.
Past the budget it forgets the digests it has not seen for the longest and says so on stderr, which costs you a duplicate that gets through rather than a unique record that gets dropped.
One thing does change past the budget. --at normally sorts its result newest first; when the result itself will not fit in the buffer it comes out in index order instead, and the command says so on stderr.
That sort gets the same budget again, so --at can hold twice --max-buffer records at the moment it hands the result over.
A page the index will not serve
A wide query is thousands of index pages, and the index truncates or refuses one often enough that a long run used to end on it and throw away everything that had already arrived.
A page whose body stops early is read again, up to --retries times, so a truncated response costs a second request rather than the records it was carrying.
A page that fails every attempt is named on stderr with its crawl and page number, and the run carries on with the next page:
search: CC-MAIN-2026-30: CDX page 252: HTTP 503, skipping the page
search: the result is incomplete, 1 index page could not be read; run it again or pass --strict to fail instead
The summary line is printed once at the end of the run, so a partial result never passes for a whole one.
Pass --strict to get the old behaviour, where the first page that cannot be read ends the command.
| Flag | Description |
|---|---|
--strict |
Fail the run if an index page cannot be read, rather than skipping it |
export takes --strict and reports the same way, for the same reason.
get
ccrawl get <url> [flags]
Content flags (pick one): --text, --markdown, --links, --headers.
With none, prints the raw HTTP response body.
fetch
ccrawl fetch [-] [flags]
Locate a record with --file, --offset, --length, or stream JSONL locations on stdin with -.
Content flags: --body (default), --text, --markdown, --links, --headers, --meta.
Write one file per record with --dir and --out-dir.
Batch mode
One record per HTTP request is fine for a few thousand locations and hopeless for a few million.
--batch sorts the locations by file and offset, coalesces the ones that sit close together, and reads each run of them in a single ranged GET.
Records that share a request are sliced back apart by their own offset and length and parsed individually, so the output is byte for byte what the one at a time path produces.
| Flag | Description |
|---|---|
--batch |
Coalesce nearby records in the same WARC file into shared ranged GETs |
--gap |
Coalesce records at most this many bytes apart (default 1 MiB) |
--max-span |
Never read more than this in one GET (default 16 MiB) |
--order |
input or file: emit in the order given or the order on disk (default file) |
--ledger |
File of finished locations, to skip on a resume |
--lookahead |
Ranged GETs allowed to run ahead of the writer (default 64) |
ccrawl columnar locations --tld vn -o jsonl | ccrawl fetch - --batch --ledger fetched.txt --dir
Choosing a gap
--gap is the price you are willing to pay in wasted bytes to save one request.
Every merge drops one round trip and reads the hole between the two records, so the flag is that trade written down as a number.
Add --dry-run and nothing is fetched: the grouping is pure arithmetic on the locations, so it reports both halves of the trade and you can try a few values for free.
$ ccrawl fetch - --batch --dry-run --gap 65536 < locations.jsonl
1000000 locations in 612078 requests, 1.6x fewer than one at a time; 10.1 GB read for 1.6 GB of records, 6.3x amplification
$ ccrawl fetch - --batch --dry-run < locations.jsonl
1000000 locations in 101238 requests, 9.9x fewer than one at a time; 116.0 GB read for 1.6 GB of records, 71.5x amplification
Those are real numbers from a million robots.txt locations, which is close to the worst case: tiny records scattered ten to a WARC file.
The 71x amplification looks alarming and is still the right call, because a round trip to data.commoncrawl.org costs far more than a megabyte of transfer does.
On 486 real records packed into 20 files the default gap turned 486 requests into 20 and finished in 6.6 seconds against 97.4 seconds for the one at a time path, a 14.7x speedup while reading 34x the bytes.
Lower --gap when bandwidth is what you are paying for, raise it when latency is.
--max-span is the separate ceiling that stops one dense file from becoming a single enormous read.
--order file streams: groups are written out in the order they sit on disk and no more than --lookahead of them are ever in flight.
--order input has to put back an ordering the grouping destroyed, so it holds finished records in memory until their turn comes, and a single slow group early in the input will hold everything behind it.
Use it when something downstream is lining the output up against the input, and leave it alone otherwise.
Pass --ledger and every finished location is appended to that file, flushed after each group.
Rerunning the same command with the same ledger skips what is already in it, so a killed run picks up where it stopped rather than starting over.
The ledger is one filename@offset per line, so it is greppable and safe to trim by hand.
export
ccrawl export <url-or-pattern|-> [flags]
Run a query, pull each matching capture, and write them into one or more .warc.gz files.
Each file opens with a warcinfo record carrying provenance (the tool and version, the prefix, and the exact command line), so the output is self-describing.
Pass a URL or wildcard pattern to run a query, or - to read location records (filename, offset, length) as JSONL on stdin, exactly what search --locations and columnar locations produce.
Naming: --prefix, --subprefix. Rotation: --size (bytes, default 1 GB). Destination: --out-dir.
Provenance: --creator, --operator.
Query filters mirror search: --match, --from, --to, --status, --mime, --lang, --filter.
URL filters: --url-fgrep, --url-fgrepv, which go to the index server as regex filters the same way search sends its own, and --no-push-filters to keep them here instead.
ccrawl export example.com/* --prefix example
ccrawl search example.com --locations | ccrawl export - --prefix example
download
ccrawl download <kind|-> [flags]
Kinds: warc, wat, wet, robotstxt, non200responses, cc-index, cc-index-table.
Use - to read paths on stdin.
--out sets the directory, --flat drops the source tree, -j/--workers sets concurrency.
paths
ccrawl paths <kind> [flags]
Kinds: warc, wat, wet, robotstxt, non200responses, cc-index, cc-index-table, segment.
--kinds lists them.
-o url prints full URLs.
parse
ccrawl parse <file|-> [flags]
Force the format with --format (warc|wat|wet).
Filters: --type, --status, --mime, --lang, --url.
Content flags: --links, --text, --markdown, --meta.
extract
| Subcommand | Does |
|---|---|
extract title <url> |
The page title |
extract text <url> |
Readable plain text |
extract markdown <url> |
HTML converted to Markdown |
extract links <url> |
Outbound links |
content
Live-fetch a URL and compute content signals.
Unlike extract, these commands use the v2 crawler config (10 MB body limit, brotli support, redirect following).
| Subcommand | Does |
|---|---|
content extract <url|-> |
Clean text, title, description, canonical URL, language, word count |
content outlinks <url|-> |
Outbound links as (source, url, host) rows, without anchor text |
content quality <url|-> |
Quality signals: word count, title length, main-content flag, spam score, parked detection |
content lang <url|-> |
The language the markdown pipelines would detect, with the confidence and the text it judged |
ccrawl content extract https://golang.org/
ccrawl content quality https://example.com/ -o json
ccrawl content outlinks https://news.ycombinator.com/ -n 20
ccrawl content lang https://vnexpress.net/ -o json
All four take - in place of the URL and read a list from stdin, one URL per line or JSONL with a url field, which is what search, columnar and crawl fetch produce:
ccrawl content quality - -o jsonl < seeds.txt
ccrawl search 'example.com/*' -n 100 -o jsonl | ccrawl content quality - -o jsonl
Over a stream a URL that cannot be fetched is named on stderr and the rest of the list carries on. A run that scored nothing because every URL failed exits 1, and empty stdin exits 3. Content signals has the field tables and the pipelines.
content lang
content lang runs the same identifier markdown export --lang applies, on one URL at a time, so a document that was kept or dropped can be asked about directly.
$ ccrawl content lang https://vnexpress.net/ -o json
{
"url": "https://vnexpress.net/",
"language": "vie",
"confidence": 1,
"cc_language": "vi",
"chars": 24196,
"sample": "Báo tiếng Việt nhiều người xem nhất Thứ hai, 3/8/2026 ..."
}
language is detected in the extracted Markdown, not in the raw HTML and not read off the page's own lang attribute, because the Markdown is what the pipelines keep and filter on. cc_language is what the page declares, printed next to it so a disagreement is visible instead of silent. sample is the text the identifier actually saw, truncated; when an answer looks wrong it is almost always the input that is wrong.
news
CC-NEWS is the one Common Crawl dataset with no index of any kind: no CDX, no columnar table, nothing but the WARC files and a list of their names.
So finding one publisher's articles in a month means reading the month, which is around 350 files of roughly a gigabyte each.
news publish builds the missing index and puts it on HuggingFace, and news search reads that index when it exists.
| Subcommand | Does |
|---|---|
news list |
List CC-NEWS files for --year/--month |
news download |
Download CC-NEWS files |
news search <host> |
Find a publisher's articles in a month, from the index or by scanning |
news publish |
Build the missing CC-NEWS index and mirror it to a HuggingFace dataset |
news search
ccrawl news search bbc.co.uk --year 2026 --month 7
ccrawl news search bbc.co.uk --year 2026 --month 7 -o jsonl | ccrawl fetch - --text
ccrawl news search bbc.co.uk --year 2026 --month 7 --no-index # force the full scan
It answers from the published index when that month is indexed, and falls back to streaming the archives when it is not, saying on stderr which one it did. A month that is indexed but still building is searched from the part that is published, and the shortfall is reported on stderr rather than passed off as the whole month.
The rows are the CDX columns, so the output reads like index output and -o jsonl pipes straight into fetch -.
| Flag | Meaning |
|---|---|
--year |
CC-NEWS year |
--month |
CC-NEWS month |
--repo |
HuggingFace dataset repo to read the index from (default: open-index/ccrawl-news, or CCRAWL_NEWS_REPO) |
--no-index |
Skip the index and scan the archives, which is the old behaviour |
--limit |
Stop after this many matches |
--workers |
Concurrent readers (0 picks a default from CPU count) |
news publish
ccrawl news publish --months 2026/07
ccrawl news publish --months 2026/07,2026/06 --commit-every 16
ccrawl news publish --months 2026/07 --files 4 --no-push # index a slice, upload nothing
It streams every WARC file of the month, records the byte span of each stored response, and writes one Parquet shard per source file. The archives are never written to disk: they are decompressed, indexed, and dropped as they stream, so a run holds one output shard per worker and nothing else. A stream that dies partway resumes at the last complete record rather than restarting the file.
Reading a month is the cost of this dataset, a few hundred gigabytes of transfer, once.
HF_TOKEN (or HUGGINGFACE_TOKEN) must be set to push.
| Flag | Meaning |
|---|---|
--repo |
HuggingFace dataset repo (default: open-index/ccrawl-news, or CCRAWL_NEWS_REPO) |
--months |
Comma list of months to index, as YYYY/MM |
--files |
Index only the first N WARC files of each month (0 indexes the month) |
--commit-every |
Shards per HuggingFace commit |
--workers |
Stream-and-index workers (0 picks a default from CPU count) |
--private |
Create the dataset repo private |
--keep |
Keep local shards after commit instead of deleting them |
--min-free-gb |
Pause new work when free disk is under this many GB |
--max-stall |
Restart the run (exit 75) after this long with no progress |
--no-push |
Index and stage but skip the upload |
The news index reference has the schema and the queries.
columnar
Aliases: table, athena.
| Subcommand | Does |
|---|---|
columnar urls |
Matching URLs |
columnar locations |
Record locations, ready for fetch |
columnar count |
Count of matching captures |
columnar langs |
Breakdown by content language |
columnar mimes |
Breakdown by MIME type |
columnar sql |
Build the SQL from the filter flags and print it |
columnar query <sql> |
Run raw SQL (ccindex is the source) |
columnar schema |
The columns of the index |
Filters: --domain, --host, --tld, --mime, --status, --lang, --path-prefix, --subset.
Negated filters: --not-tld, --not-mime, --not-lang, --not-status. A row where the column is missing counts as a match, so --not-lang vie returns the captures Common Crawl never labelled as well as the ones it labelled something else.
Set filters: --hosts-file and --domains-file read one value per line, skipping blank lines and # comments, and turn the whole list into a single query. The list prunes the same way a single --host or --domain does, so it costs one pass over the index rather than one pass per value.
Engine: --engine (auto|duckdb|native|print). See the columnar engines for what each one can answer and how fast.
ccrawl columnar count --tld vn --not-lang vie
ccrawl columnar urls --hosts-file hosts.txt --not-tld vn -o url
markdown
Build Markdown Parquet datasets from Common Crawl WARC files and commit them to a HuggingFace dataset repo.
| Subcommand | Does |
|---|---|
markdown export |
Convert the HTML Common Crawl captured to Markdown Parquet |
markdown refetch |
Re-fetch every URL in a shard live, then convert to Markdown Parquet |
ccrawl markdown export --shards 0 --push=false --out ./md
ccrawl markdown export --shards all --parallel 4 --commit-batch 10 --repo org/name
ccrawl markdown refetch --shards 0-9 --fetch-workers 400 --repo org/name
Both take --shards (N, N-M, N,M, or all), resume from a ledger at <out>/.committed, and need HF_TOKEN unless --push=false.
The markdown reference has both schemas, the HuggingFace path layout, and the tuning flags.
Choosing an extractor
--extractor picks the engine that turns a captured page into the text in the row.
| Engine | Reads | Does |
|---|---|---|
h2m (default) |
WARC | go-trafilatura tuned for recall, rendered as GitHub-flavored Markdown |
readability |
WARC | go-readability extraction plus mdconv, the engine open-markdown-v2 shipped |
raw |
WARC | the whole document as Markdown, no boilerplate removal at all |
wet |
WET | the plain text Common Crawl already extracted, passed through unchanged |
ccrawl markdown export --shards 0 --extractor readability --push=false --out ./md
ccrawl markdown export --shards 0 --extractor raw --push=false --out ./md
ccrawl markdown export --shards 0 --source-kind wet --extractor wet --push=false --out ./md
Which engine you use is a corpus quality decision, so it is a flag and not a build time constant. The same shard through two engines is two different corpora, and the only way to find out which one suits a downstream task is to build both and compare. Every row records the engine that produced it in the extractor column, as name@version, because extraction changes between releases and a name alone cannot explain why two shards built months apart disagree about the same page.
raw keeps the nav bars and the footer, which sounds useless and is not. Every extractor is a lossy judgement about what a page was for, and on the pages where that judgement goes wrong the output alone does not say so. Raw is the control you measure the others against.
--source-kind picks the manifest a run reads: warc takes warc.paths.gz and extracts the HTML itself, wet takes wet.paths.gz and uses the text Common Crawl already extracted. It defaults to whatever the extractor needs, so it rarely has to be given. The two are not interchangeable, since a WET file holds no HTML and a WARC holds no pre-extracted text, and asking for a pairing that cannot work is a usage error rather than a silently reinterpreted run:
$ ccrawl markdown export --shards 0 --source-kind wet --extractor h2m
ERROR Extractor h2m reads warc shards, so it cannot be used with --source-kind wet.
markdown refetch takes --extractor too, but only the WARC engines: a live fetch returns HTML, and there is no Common Crawl text to pass through.
WET is much cheaper than any of the extractors, since the text is already there and the files are a fraction of the size of the WARCs. It is also somebody else's extraction decision, boilerplate and all, and it is plain text rather than Markdown, so headings and links are gone.
Language filtering
Both subcommands label every row with a detected language and can keep only one of them.
| Flag | Default | Does |
|---|---|---|
--lang |
(off) | Keep only documents detected as this ISO 639-3 language, for example vie |
--min-lang-confidence |
0.8 |
Confidence a document has to clear for --lang |
ccrawl markdown export --shards 0 --lang vie --push=false --out ./md
ccrawl markdown export --shards 0 --lang vie --min-lang-confidence 0.9 --push=false --out ./md
The language is detected in the extracted Markdown, so it describes the text in the row rather than whatever the page declared. Without --lang nothing is dropped and every row still carries language and language_confidence, which is what makes an unfiltered shard filterable later without extracting it again.
A document with too little text to identify is dropped by --lang rather than kept: a filtered export asks for documents known to be in one language, and "we could not tell" is not that. The run prints both the drop rate and the detected mix, so a filter that threw away more than expected says so:
language: --lang vie kept 21482 of 43110 documents, dropped 21628 (50.2%)
language: detected vie=21482 eng=17903 unknown=2011 zho=884 ...
--lang cannot be combined with markdown refetch --fetch-only, since fetch-only never produces the Markdown the identifier reads.
This is a coarse pre-filter. A trigram identifier tells Vietnamese from Malay well enough to cut a corpus down to something worth looking at, and it is not a substitute for a language specific classifier.
Deduplication
| Flag | Default | Does |
|---|---|---|
--dedup-digest |
false |
Skip records whose payload digest was already seen in this shard |
ccrawl markdown export --shards 0 --dedup-digest --push=false --out ./md
The check runs before extraction, so a duplicate costs a hash lookup rather than an HTML parse, and the scope is one shard rather than the whole run. On one shard of CC-MAIN-2026-30 it dropped 83 duplicate payloads, which an independent scan of the same WARC confirmed exactly.
Every row also carries a simhash fingerprint whether or not the flag is set. Near duplicates are reported by ccrawl dedup rather than dropped during the run, because deciding which of two near identical copies is the good one is not a decision a converter should make on its own. See Deduplication.
Converting a location set
markdown export --locations reads exactly the records a stream of index locations points at, instead of downloading whole shards.
| Flag | Default | Does |
|---|---|---|
--locations |
(off) | Convert the records in this JSONL location stream, - for stdin |
--part-size |
50000 |
Locations per Parquet part |
--gap |
1 MiB | Coalesce records closer together than this many bytes into one ranged read |
--max-span |
16 MiB | Cap on the size of one coalesced ranged read |
ccrawl columnar locations --crawl CC-MAIN-2026-30 --lang vie -o jsonl \
| ccrawl markdown export --locations - --lang vie --dedup-digest --push=false --out ./md
ccrawl markdown export --locations missed.jsonl --part-size 10000 --push=false --out ./md
This is the recovery pass: a columnar query picks out the pages you are missing, and the export turns those pages and nothing else into Parquet, with the same extractor, language filter, dedup, and schema a full export uses. Reading whole shards to reach a few thousand scattered pages would move something like a thousand times the bytes those pages are worth, so the records are fetched with ranged GETs and the neighbours coalesced.
A part is to a location run what a shard is to a full export: the unit that gets one Parquet file, one ledger entry, and one digest dedup set. The stream is cut in order, so an interrupted run resumes where the ledger says it stopped. Locations that will not fetch are skipped rather than failing the part, because a recovery pass runs against an index that can disagree with the archive.
--locations bypasses --shards, --source-kind, and the manifest fetch entirely, and it cannot be combined with the wet extractor, since WET files have no record offsets to point at. See Converting a location set instead of whole shards.
rank
| Subcommand | Does |
|---|---|
rank domain <domain> |
Rank of a registered domain |
rank host <host> |
Rank of a host |
rank top |
Top-ranked hosts |
rank all |
Stream every host in a rank table, most central first |
All four read the newest web-graph release without being told where it is. --graph <release-id> pins a release, the way the host commands take it, and --table <url> points at a table directly and skips the lookup.
rank domain reads the domain ranks and the other three read the host ranks, which are separate tables with separate positions in them: wikipedia.org is domain 14 and host 864 in the same release.
The newest release for a domain lookup is the newest one whose domain table is published, which is not always the newest release, because a release is listed as soon as its host tables land.
rank top and rank all take --tld to filter by TLD.
The rank table is sorted by harmonic centrality, so rank all -n 1000 is the top 1000 hosts without any sorting on your side.
ccrawl rank host wikipedia.org
ccrawl rank all --tld com -n 1000
ccrawl rank all --graph cc-main-2026-mar-apr-may -o jsonl > hosts.jsonl
host
Enumerate and enrich hosts from the CC web graph.
All subcommands accept --graph <release-id> to pin a specific web-graph release (default: latest).
| Subcommand | Does |
|---|---|
host top |
Top hosts by harmonic centrality, streamed from the rank table |
host get <hostname> |
Enriched profile for one host |
host vertices |
Stream the vertex ID to hostname mapping |
host degrees |
Compute in-degree and out-degree from edge files (~7.7 GB) |
host cdx |
Aggregate CDX statistics per host via DuckDB |
host enrich |
Full enrichment pipeline: rank + degrees + CDX |
A host record only carries what was measured
Every host subcommand emits the same record, and each one fills a different part of it.
host top and host get read the rank table, so they answer with the rank signals.
The degree counts come from host degrees or host enrich, and the CDX counts from host cdx, host enrich, or host get --cdx.
A count nothing measured is left out rather than written as zero, because zero is an answer and this is the absence of one:
$ ccrawl host top -n 1 -o jsonl
{"host":"www.facebook.com","host_rev":"com.facebook.www","tld":"com","registered_domain":"facebook.com","harmonic_pos":1,"harmonic_val":34375268,"pagerank_pos":3,"pagerank_val":0.0055143537769673755}
In -o csv and -o table the column is still there and the cell is blank, so the shape of the output does not change with what a run happened to measure.
A count that was taken and came out zero prints as 0, which is the whole point of the distinction.
host top
ccrawl host top -n 20 -o table
ccrawl host top --graph cc-main-2026-mar-apr-may -n 1000 -o jsonl > top1k.jsonl
host get
ccrawl host get golang.org -o json
host vertices
ccrawl host vertices --graph cc-main-2026-mar-apr-may -n 5
host degrees
Streams all edge files to compute per-host in/out-degree. Requires ~7.7 GB of edge data.
ccrawl host degrees --graph cc-main-2026-mar-apr-may -n 100 -o jsonl
host cdx
Runs a DuckDB GROUP BY url_host_name over the columnar Parquet index.
Without --filter this scans ~184 GB of Parquet.
ccrawl host cdx --filter example.com -o json
ccrawl host cdx -n 100 -o jsonl
| Flag | Meaning |
|---|---|
--filter |
Restrict to one host (url_host_name) |
host enrich
Runs all enrichment phases in sequence. Phases 3 and 4 are opt-in because they require large data transfers.
ccrawl host enrich -n 20
ccrawl host enrich --graph cc-main-2026-mar-apr-may -n 100
ccrawl host enrich --degrees --cdx -o jsonl > enriched.jsonl
| Flag | Meaning |
|---|---|
--graph |
Web-graph release ID (default: latest) |
--degrees |
Phase 3: compute in/out-degree from edge files (~7.7 GB) |
--cdx |
Phase 4: aggregate CDX statistics via DuckDB (~184 GB) |
urls
Mirror the Common Crawl columnar URL index to a HuggingFace dataset, one output Parquet shard per original source part. Nothing is aggregated, deduplicated, or filtered: the rows and their order match the source, projected down to the URL-level columns. The run is idempotent from remote truth, so shards already on the hub are skipped and each local shard is deleted right after it commits.
| Subcommand | Does |
|---|---|
urls publish |
Mirror the URL index to a HuggingFace dataset, shard for shard |
urls recount |
Repair drifted URL and byte totals in stats.csv from the hub |
urls publish
ccrawl urls publish -c CC-MAIN-2026-25
ccrawl urls publish -c 2 --commit-every 32
ccrawl urls publish -c CC-MAIN-2026-25 --no-push # scan and report, upload nothing
HF_TOKEN (or HUGGINGFACE_TOKEN) must be set to push.
| Flag | Meaning |
|---|---|
--repo |
HuggingFace dataset repo (default: open-index/ccrawl-urls, or CCRAWL_URLS_REPO) |
--commit-every |
Shards per HuggingFace commit (default 16) |
--workers |
Download-and-convert workers (0 picks a default from CPU count) |
--whole |
Download each part whole before reading (fallback for range-hostile mirrors) |
--private |
Create the dataset repo private |
--keep |
Keep local shards after commit instead of deleting them |
--min-free-gb |
Pause new downloads when free disk is under this many GB |
--max-stall |
Restart the run (exit 75) after this long with no progress |
--no-push |
Scan and stage but skip the upload |
domains
Stream the web-graph domain ranks top to bottom and republish them as rank-ordered Parquet shards on a HuggingFace dataset.
The one edit to the data is un-reversing the source host key (com.example becomes example.com); rows stay in rank order, so part-000 holds the highest-centrality domains.
| Subcommand | Does |
|---|---|
domains publish |
Mirror the domain ranks to a HuggingFace dataset, in rank order |
domains recount |
Repair drifted release totals in stats.csv from the hub |
domains diff |
Count domains added, removed, and shared between two published releases |
domains publish
ccrawl domains publish
ccrawl domains publish --no-push # scan and report, upload nothing
| Flag | Meaning |
|---|---|
--repo |
HuggingFace dataset repo (default: open-index/ccrawl-domains, or CCRAWL_DOMAINS_REPO) |
--commit-every |
Shards per HuggingFace commit |
--private |
Create the dataset repo private |
--keep |
Keep local shards after commit instead of deleting them |
--min-free-gb |
Pause new work when free disk is under this many GB |
--max-stall |
Restart the run (exit 75) after this long with no progress |
--no-push |
Scan and stage but skip the upload |
domains diff
Compare two web-graph domain releases already published to the dataset and report how many domains are new in the later release, how many dropped out of the earlier one, and how many the two share. It reads only the domain column of each shard straight from the hub, so it never downloads the rank fields. With no ids it diffs the two most recent complete releases in the dataset, older against newer.
ccrawl domains diff
ccrawl domains diff --from cc-main-2026-mar-apr-may --to cc-main-2026-apr-may-jun
ccrawl domains diff --added-out new-domains.txt
| Flag | Meaning |
|---|---|
--repo |
HuggingFace dataset repo (default: open-index/ccrawl-domains, or CCRAWL_DOMAINS_REPO) |
--from |
Older web-graph release id (default: second-newest published) |
--to |
Newer web-graph release id (default: newest published) |
--added-out |
Write the domains new in the later release to this file, one per line |
--workers |
Concurrent shard readers (0 picks a default from CPU count) |
publish
Maintenance for the published Common Crawl datasets.
| Subcommand | Does |
|---|---|
publish verify |
Check that the published shards are readable, complete, and the schema they claim |
publish delete-obsolete |
Delete the superseded first-generation dataset repos |
publish verify
Publishing only ever asks whether a shard's path exists, so an upload that was cut off part way through leaves an object the resume path skips forever, and nothing notices until somebody reads the dataset and gets an error out of a Parquet library.
publish verify reads each shard's footer over ranged requests and asks what publishing never asks: does the file parse, is it the schema the dataset promises, do the row groups add up to the row count the footer claims, and does every column chunk sit inside the bytes the hub is holding.
The totals are then reconciled against the stats.csv ledger the dataset card is built from, and a disagreement is reported even when every shard passes, because it means the numbers the dataset advertises are not the numbers it holds.
ccrawl publish verify -c CC-MAIN-2026-25
ccrawl publish verify -c CC-MAIN-2026-25 --sample 64
ccrawl publish verify -c CC-MAIN-2026-25 --repair
ccrawl publish verify --graph cc-main-2026-mar-apr-may --json
Verifying the 300 shard CC-MAIN-2026-25 crawl reads 269 MB against a dataset holding 150.5 GB, which is 0.175 percent of it, so a full check costs about as much as a listing.
| Flag | Meaning |
|---|---|
--repo |
HuggingFace dataset repo (default: the dataset the unit belongs to) |
--graph |
Verify a web-graph release of the domains dataset instead of URL crawls |
--sample |
Rows to decode from each shard's last row group (0 reads the footer alone) |
--workers |
Shards checked at once (0 picks a default from CPU count) |
--repair |
Rebuild and re-upload the shards that fail |
--no-push |
With --repair, rebuild locally but skip the upload |
--json |
Print the report as JSON |
Each shard comes back ok, or missing, unreadable, truncated, schema, empty, corrupt, or no-access.
The last one is not a verdict on the data: the shards are read with plain ranged GETs because the published datasets are public, so a repo that will not serve them reports no-access rather than being called corrupt.
The exit status is non-zero when a shard fails and --repair was not passed.
--sample decodes rows out of each shard as well as reading its footer, which is the only way to catch a page whose bytes are wrong rather than missing.
It reads a page of every column instead of a footer, so it costs a great deal more than the default check and is worth running on a crawl you have a specific reason to distrust.
--repair works on the URL dataset, where a shard is the projection of exactly one source part and can be rebuilt on its own from the part that made it.
A domain shard is a cut of one sequential rank stream, so rebuilding one means reading the source up to it: publish verify --graph reports the bad shards and leaves the rebuild to ccrawl domains publish.
publish delete-obsolete
Delete the obsolete dataset repos that the ccrawl-urls and ccrawl-domains datasets replaced.
It removes open-index/cc-host-dataset and open-index/commoncrawl-urls, and asks for confirmation unless --yes is passed.
ccrawl publish delete-obsolete # prompt before deleting
ccrawl publish delete-obsolete --yes # delete without prompting
crawl
Recrawl engine commands for seeding and fetching live URLs.
| Subcommand | Does |
|---|---|
crawl seed |
Generate seed URLs from the web-graph rank table |
crawl fetch <url> |
Crawl a single URL with robots.txt checking and content digest |
crawl run |
Run a crawl from a seed file, with a resumable frontier and WARC output |
crawl status |
Show daily crawl budget allocation across the five recrawl tiers |
crawl seed
Streams the rank table and emits one seed URL per host.
Use --max-tier to restrict to high-priority hosts: tier 2 is roughly the top million by harmonic rank, tier 5 is everything.
Tier 1 wants a change rate above 0.8 and a seed carries no measured change rate, so --max-tier 1 is refused rather than answered with nothing.
ccrawl crawl seed -n 100 -o table
ccrawl crawl seed --max-tier 2 -n 1000000 -o jsonl > seeds.jsonl
ccrawl crawl seed --graph cc-main-2026-mar-apr-may --max-tier 3 -n 5000000
| Flag | Meaning |
|---|---|
--graph |
Web-graph release ID (default: latest) |
--max-seeds |
Maximum hosts to emit (default 10 000 000) |
--max-tier |
Skip hosts with tier higher than this (2-5, default 5 = all; 1 is unreachable from a seed) |
crawl fetch
Fetches one URL with the v2 crawler config: polite user-agent, brotli support, redirect following (up to 5 hops), 10 MB body limit, SHA-1 digest.
ccrawl crawl fetch https://golang.org/ -o json
ccrawl crawl fetch https://example.com/ --robots -o json
ccrawl crawl fetch https://example.com/ --warc-dir warc/
| Flag | Meaning |
|---|---|
--robots |
Check robots.txt before fetching |
--warc-dir |
Write the fetch to a WARC file in this directory |
With --warc-dir the fetch is archived as an ISO 28500 WARC/1.0 file: a warcinfo record, then a request and response pair linked with WARC-Concurrent-To, with WARC-Block-Digest and WARC-Payload-Digest as sha1: base32, WARC-IP-Address, and WARC-Truncated: length when the body cap trips.
The stored headers describe the stored body, so a decoded or dechunked response gets a rewritten Content-Length and loses the encoding headers that no longer apply.
The path written is reported in the record as warc_file.
crawl run
Drives a whole crawl from a seed file: the frontier hands out URLs in priority order, one host at a time, robots.txt is fetched once per host and enforced, every fetch is written to WARC, and outlinks go back in the queue up to --max-depth.
ccrawl crawl seed -n 100000 -o jsonl > seeds.jsonl
ccrawl crawl run --seeds seeds.jsonl --out warc/ --state crawl.db --max-pages 100000 -j 64
ccrawl crawl run --seeds - --out warc/ --state crawl.db --max-depth 2 --same-host
| Flag | Meaning |
|---|---|
--seeds |
Seed file: crawl seed JSONL or one URL per line, - for stdin |
--out |
Directory to write WARC files into (empty fetches without archiving) |
--state |
Frontier state file, so a run resumes after a restart |
--delay |
Minimum spacing between two requests to the same host, default 1s, 0 for none |
--max-depth |
How far from a seed to follow links (default 0, the seeds only) |
--max-pages |
Stop after this many fetches (0 = no limit) |
--same-host |
Stay on the hosts the seeds named |
--no-robots |
Do not check robots.txt |
--robots |
Check robots.txt, which is already the default |
--warc-size |
Rotate to a new WARC file past this many bytes |
--prefix |
File name prefix for the WARC output |
--shard |
Which partition of the seed list this process takes, 0-based |
--shards |
How many machines are splitting the seed list, default 1 |
The frontier in --state is the whole resume story: a run that is killed leaves its queue, its politeness clocks and its seen set on disk, and the next run over the same file picks up where the last one stopped rather than refetching what is done.
Politeness is per host and it is the longer of --delay and the host's own Crawl-delay, so raising --workers adds hosts in flight and never adds requests to one host.
--delay 0 means no spacing, which is for a benchmark against a server you own and not for the open web.
That per host delay is also what sets the rate, and it is worth knowing which number to reach for. Throughput is roughly the number of distinct hosts in flight divided by the delay, so a crawl over 200 hosts at the default second runs at about 200 pages per second no matter how many workers it has, and adding workers to a crawl of one host does nothing at all. Measured against a local server: 200 hosts at the default settings gave 140 pages per second, and the same binary with the delay off sustained 8234 pages per second at 32 workers. Past a few dozen workers the frontier's lock starts costing more than it returns, 8234 per second at 32 workers falling to 4333 at 128, so more workers than hosts is not a way to go faster.
crawl run uses a client of its own rather than the one --rate and --global-rate configure.
Those two exist to be polite to data.commoncrawl.org, which is one host serving bulk files to everybody, and a crawl of unrelated sites has no business drawing on that budget.
It also could not: the Common Crawl delay is per process rather than per host, so robots.txt would be fetched five hosts a second however many workers were running.
--shard and --shards split one seed list across several machines that never talk to each other.
Give all three servers the same file and pass --shard 0, --shard 1 and --shard 2 with --shards 3, and each one keeps a third of the list and skips the rest.
The filter runs as the seeds are read, so a URL a machine does not own never reaches its frontier and three servers keep a third of the state each rather than three full copies of it.
The partition key is the registered domain, taken from the public suffix list, and that is the part worth understanding before you use it.
A crawler keeps one politeness clock per host, and that clock only means anything if the host belongs to one process.
Hash the URL instead and a busy site's pages scatter across all three machines, each of which waits its own second while the site sees three requests a second and every machine believes it is behaving.
Keying on the registered domain keeps a site on one machine, and it keeps a.example.co.uk with b.example.co.uk, which usually are one server behind one budget.
The split is stable across machines and across runs, so a restart on server 2 picks up the same third it had before, and it is even enough to be worth nothing further.
Measured over all 121 091 933 domains in the open-index/ccrawl-domains release, three shards land within 0.005 percent of even, seven within 0.030 percent and thirty two within 0.159 percent.
Three shards is 40 365 223, 40 361 867 and 40 364 843 domains, a spread of 3356 across forty million.
ccrawl crawl run --seeds domains.txt --state crawl.db --shard 0 --shards 3 # server1
ccrawl crawl run --seeds domains.txt --state crawl.db --shard 1 --shards 3 # server2
ccrawl crawl run --seeds domains.txt --state crawl.db --shard 2 --shards 3 # server3
Shards are numbered 0 to --shards minus 1, and a run that names a partition outside that range stops with a usage error rather than quietly crawling nothing.
crawl status
Prints the daily page budget across the five recrawl tiers assuming 10 000 pages/s sustained throughput.
ccrawl crawl status -o table
recrawl
Recrawl a work list that is already published, streamed out of Parquet rather than loaded into a frontier.
| Subcommand | Does |
|---|---|
recrawl run |
Walk a published dataset and fetch every URL in it, resumable from a two number checkpoint |
recrawl publish |
Commit closed capture shards to a HuggingFace dataset as they land, with the ledger and card |
recrawl run
Reads a published dataset a part at a time, fetches every URL in it, and keeps its place in a checkpoint of a part number and a row offset.
robots.txt is fetched once per host and enforced, each host gets one request per --delay, and every fetch is written out as a capture row or a WARC record.
ccrawl recrawl run --from domains --out captures/ --state recrawl.json
ccrawl recrawl run --from urls --dir data/CC-MAIN-2026-25 --out captures/ --state recrawl.json --shard 0 --shards 3
ccrawl recrawl run --from open-index/ccrawl-domains --column domain --format warc --max-pages 1000
| Flag | Meaning |
|---|---|
--from |
Published dataset to recrawl: domains, urls, or a dataset repo ID |
--dir |
Directory of parts inside the dataset, default the newest release published |
--column |
String column holding the work, domain or url |
--out |
Directory to write output files into (empty fetches without archiving) |
--format |
Output format: parquet rows with the body inline, or warc, default parquet |
--state |
Checkpoint file, so a killed run resumes where it stopped |
--delay |
Minimum spacing between two requests to the same host, default 1s, 0 for none |
--max-pages |
Stop after this many fetches (0 = no limit) |
--robots |
Check robots.txt before fetching, off by default on a recrawl |
--no-extract |
Store the body without rendering it to text and Markdown |
--extractor |
Engine that renders a page to Markdown: h2m, readability, or raw, default h2m |
--extractors |
How wide the extract pool is, separately from --workers, default the core count |
--shard-size |
Rotate to a new output shard past this much payload |
--prefix |
File name prefix for the output files |
--writers |
Output files open at once, each with its own encoder, default 1 |
--batch |
Work items fetched between checkpoints, default 2000 |
--queue-mb |
Megabytes of finished work that may wait for the sink, default 128 |
--shard |
Which partition of the work list this process takes, 0-based |
--shards |
How many machines are splitting the work list, default 1 |
--dns-lookups |
How many DNS lookups may be in flight at once, default an eighth of --workers, floored at 16 and capped at 128 |
--robots-timeout |
Budget for one host's robots.txt fetch, retries included, default 10s |
--profile-dir |
Write a CPU and a heap profile into this directory, off by default |
--profile-name |
Name the profile files, so two runs can be told apart and diffed |
robots.txt is not checked on a recrawl unless --robots is given, and this is the one default in the tool that is worth reading twice.
On a link crawl the frontier keeps coming back to hosts it already knows, so the check is paid once per host and amortised over every page that host gives up.
On a recrawl of a domain corpus every row is a different host, so it is a second request for every single page, and measured on the live domain list at 256 workers it was 45 percent of the worker time.
Worse than the cost is what it was buying: about a third of those hosts never answered at all, so the run sat out the whole ten second budget and then threw the row away, and RFC 9309 reads a host that cannot be asked as a host that said no.
Turning it on with --robots gets the full RFC 9309 behaviour back, unchanged, and a run with it off says so on its own summary line rather than leaving a reader to guess from a command line somebody typed a week ago.
--from domains and --from urls are shorthands for open-index/ccrawl-domains and open-index/ccrawl-urls with the right column already chosen.
A full repo ID works too, and then --column has to name the column, because there is no guessing what an arbitrary dataset calls its URLs.
Leave --dir off and the newest release in the repo is used, which is worked out by asking the dataset what it holds rather than by keeping a list in the binary.
A domain is turned into its homepage, since a domain is not a URL and the homepage is what a domain level recrawl means.
--format parquet is the default because the output of a recrawl is meant to be published, and a dataset is rows.
Each fetch becomes one row with the body, the request head and the response head inline, in ami's captures schema column for column, so a query written against an ami capture file reads one of these and the other way round.
The columns are compressed with zstd columnar rather than one page at a time, and on a real sample of 398 homepages, 139 MB of bodies and heads, that is 22.8 MB against the 25.5 MB the same bytes take gzipped a record at a time the way a WARC stores them.
Eleven percent is not the reason to do it and it is worth saying so plainly: the reason is that a reader can count status codes or pull one column without touching the bodies at all, and the compression is a small bonus on top.
A 304 is stored as a row with unchanged set and no body, which over a corpus where most pages do not move between crawls is the difference between a dataset and a second copy of one.
Every HTML page is also rendered to Markdown and to plain text as it is fetched, into markdown, text, title, word_count, language, language_confidence, simhash and extractor.
Those columns are appended after ami's, so a reader written against the older shape reads a newer file unchanged, and the names match open-markdown-v3 where they overlap so a query written for that dataset runs against a capture shard.
Rendering happens in the worker that fetched the page, while the body is still in memory and before the write lock is taken.
It is free in the only sense that matters here, which is that the run is waiting on the network and the machine is not: measured against the live domain list at 256 workers, extraction held 0 percent of the pool while fetching held 18 and robots held 39.
The alternative is a second pass over the published corpus, and at 363 million domain homepages and 6.3 billion indexed URLs that is a few hundred terabytes read back to look at bodies that were in memory a moment earlier.
A page that is not HTML is left alone rather than fed to an HTML extractor, and extractor being empty is how a reader tells a page nobody tried to render from a page that rendered to nothing.
--no-extract turns it off, and a WARC run never pays for it at all, since a WARC record has nowhere to put a Markdown column.
--format warc writes ISO 28500 the way it always did, and it is still the right answer when the output is going into an archive somebody will read with archive tools.
--shard-size is how much uncompressed payload goes into one output file before the writer starts the next one, and it defaults to half a gigabyte.
Every shard is finished with its own footer, so it is readable on its own and can be uploaded and deleted while the run is still going, which is what lets a hundred day run publish from day one instead of at the end.
Shards are opened by the first row that goes into them, so a run that stops cleanly leaves no empty file behind for the publish step to find and upload.
A run that is killed leaves the shard that was open unsealed, and an unsealed shard has no footer and does not open, which is exactly why the checkpoint did not advance past it and why the next run refetches it.
A shard is sealed between batches rather than in the middle of one, so it overshoots the size by up to a batch of pages, and that is deliberate: it is what makes the end of a shard and the end of a batch the same place, which is the only place a checkpoint can sit.
Setting it very small is a false economy, since columnar compression is paid for by having many similar pages in the same file and a shard holding a handful of rows gets almost none of it.
--writers is how many shards are open at once, each with its own encoder, its own buffer and its own goroutine.
The default of one is right for any run where the network is the slowest thing, which is most of them, and the timing line at the end of a run is what says otherwise: it prints how much of the wall clock the writer was busy for, and a figure in the nineties means the pool is waiting on the sink and more workers will not help.
That is what a wide pool on a fast link ends up looking like once the fetches are cheap, and it is the case this flag is for.
Rows go round the writers as they arrive, so the parts fill at the same rate, and when one of them reaches --shard-size all of them seal together.
Sealing together is not tidiness, it is what keeps the checkpoint moving: a checkpoint may only advance when everything behind it is readable, so parts that rotated on their own would have to be caught empty at the same instant for that to ever be true, and at fleet speed it never would be.
The cost is more files of slightly uneven size and more memory held in encoder buffers, about two gigabytes of resident memory going from one writer to four on a run at 256 workers, so this is a knob to raise one step at a time against the timing line rather than a number to set high and forget.
Measured on the live domain list on a loaded box, where the writer was busy 88 percent of the run, going from one writer to four took it from 10.6 pages a second to 29.0 and dropped the share of the pool queueing to write from 36 percent to 13.
Measured on the same box two hours later, where the writer was busy 65 percent, the same change was worth six percent, which is noise.
That is the flag working correctly in both cases: it buys back time the writer was spending waiting, and a writer that was not waiting has none to give back.
--extractors sets the width of the extract pool, which is a separate pool from the fetch workers and answers to a different limit.
Fetching waits on the far end, so it wants to be hundreds wide and costs almost nothing while it waits, and rendering waits on nothing at all.
Rendering used to happen in the fetch worker, which was right while the pool was narrow and stopped being right as it widened: measured on server2 at 96 workers, a run reported fetching at 44 percent of the pool and extracting at 27, which is 27 percent of ninety six workers doing a job six cores can do.
The default is four times the core count, clamped to --workers.
The core count is the obvious answer and it was measured and it is wrong: on server2 the same run went 32.5 pages a second at 6 extractors, 35.2 at 12 and 40.3 at 24, because these are shared machines and our runnable goroutines are what win a share of the cores against the other tenants.
The clamp to --workers is there because a pool that can render faster than the run can fetch is spare capacity by definition.
The end of a run prints what the pool did with itself, as a share of its own width times the wall clock rather than of the fetch pool's, and the three shares in it are the three answers: rendering near the whole pool wants more of the machine, waiting for the sink wants --writers, and idle means the fetch line is the one to read.
A run writing WARC or given --no-extract has no extract pool at all, and prints no line about one.
--queue-mb bounds how much finished work may be waiting for the sink, counted as the bodies and the rendered columns it is holding.
It is in megabytes because that is the unit the machine's free memory is read in, and it is what the run is promising about its own memory between the pool and the disk.
The bound used to be a count of items and a count cannot do the job, since a body on this corpus runs from a kilobyte to the 10 MB cap and the same 256 items is 76 MB on an average stretch and 2.5 GB on a bad one.
A run at 288 workers and 6 writers reached 3.6 GB resident on a box with 2 GB free and was killed for it, having been told nothing about a limit it was nowhere near in items.
The end of a run prints the bound next to the most that was ever held against it, and the pair is what makes either number readable: a peak well under the bound means the queue never made a worker wait, and a peak sitting on the bound means the sink is the ceiling, which --writers is for and a larger queue is not.
A run that renders has two queues rather than one, since there are two stages between the fetch and the disk, and the budget is split between them: what the flag promises is what the run holds in total, and giving each stage the whole figure would cost twice what it said.
--profile-dir writes a CPU profile and a heap profile for the run, and --profile-name decides what they are called.
The timing line answers which phase of the pool held the time, which is the right first question and stops being enough as soon as the answer is a phase rather than a knob.
A pool spending 55 percent of itself extracting says extraction is the cost and does not say which of the passes inside it is, and a run holding 4.5 GB resident says nothing at all about where, because the timing line does not measure memory.
The name matters because go tool pprof -base diffs two profiles and a diff is what a change is worth judging on, so two runs being compared have to be told apart on disk rather than overwriting each other.
Profiling costs a few percent and writes tens of megabytes, so it is off unless asked for and no fleet unit sets it.
The heap profile is taken as soon as the run stops and before anything is closed, since the encoders hold their buffers until a shard is sealed and a profile written after that has lost the largest thing in the run.
The reason this is a separate command rather than crawl run with a different seed file is the frontier.
A frontier earns its keep on a discovery crawl: the queue is not known ahead of time, outlinks arrive as the crawl goes, and a crash has to leave something resumable behind.
A recrawl has none of those properties, because the list is already published, deduplicated and sorted, and writing it into SQLite in order to read it back is copying a list in order to walk it.
That copy costs about 135 bytes a URL, so one server's third of a threefold recrawl of a monthly crawl is roughly 283 GB of database before a single page is fetched, on machines with 2.8 GB and 6.6 GB free.
It does not fit, it does not nearly fit, and the frontier's admit rate already falls fourfold between two million and five million rows on the way to not fitting.
So recrawl run holds one part open and a fixed read buffer, and its memory is the same on the first row and the billionth.
The parts are listed once from the dataset and read in order, so nobody has to keep a part count in step with the next release and no part name has to be guessed at.
That listing is not a nicety.
The published corpora do not agree on how wide a part number is written, part-000.parquet in the domain ranks and part-00000.parquet in the URL index, and a run that built the name from a counter asked for a file that was not there and read the 404 as the end of the dataset.
A part that is listed and then cannot be fetched is an error now, and so is a release with no parts, because both used to look exactly like a finished work list.
Only the one column the work list needs is read, which is what makes streaming a part cheap: parquet fetches that column's chunks and leaves the rest of the file alone.
The checkpoint in --state is a part number, a row offset and the identity of what they point into, and it is a few hundred bytes whether the work list has a thousand rows or six billion.
It is written only after the bytes it accounts for are readable again, and it is written to a temporary file and renamed over the old one, so a kill at any moment leaves either the old checkpoint or the new one and never half of either.
What readable again means depends on the format, and the difference is worth stating.
A WARC file is durable wherever it is fsynced, so a WARC run checkpoints at every batch.
A kill costs the batch that was being worked through plus whatever the pool had in the air behind it: the buffer between the reader and the workers, the item each worker is holding, and the rows already fetched and queued for the writer.
At --batch 2000 --workers 256 that is around 2800 rows, and replaying them costs duplicate rows rather than missing ones, which is the direction the checkpoint is built to fail in.
A Parquet file is not readable at all until its footer is written, so the only durable position in one is the end of a shard, and a Parquet run checkpoints at shard boundaries and a kill costs at most the shard that was open.
That is the price of the publishing format and --shard-size is the flag that bounds it: at 250 pages a second and half a gigabyte of payload a shard closes about every forty seconds.
Either way it replays rather than skips, which is the property that matters: a batch cut short is not checkpointed at all, because a checkpoint past rows that were never fetched would skip them silently and nobody would ever find out.
A run that stops between batches is the other case, and there the shard is sealed and the checkpoint written before the run exits, whether it stopped at the end of the work list, at --max-pages, or on a signal that landed on the boundary.
A stop is the last chance to make the open shard readable, so holding it back for a fuller shard means holding it back forever.
A checkpoint written by a different dataset or a different shard is refused rather than resumed, since its row offset points at nothing here.
--shard and --shards split the work list across machines that never talk to each other, on the same registered domain key crawl run uses and for the same reason.
Rows a machine does not own are counted and dropped as they stream by, so the row offset means the same thing on every machine in the fleet and the checkpoints are comparable across them.
Like crawl run, the pages and robots.txt go through a client of their own rather than the one --rate and --global-rate configure.
Those two are a budget for data.commoncrawl.org, one host serving bulk files to everybody, and applying a per process delay meant for one host to a million unrelated sites is not politeness, it is a queue five hosts a second long.
The work list itself is read from HuggingFace, which is a bulk host, and that side does use the ordinary client and its budget.
recrawl publish
Watches the directory recrawl run writes into and commits each shard to a dataset repo as it closes, refreshing the ledger and the dataset card in the same commit, then deleting the local file.
It is meant to run alongside the crawl, not after it.
ccrawl recrawl publish --dir captures/ --kind domains --server server1 --shard 0 --shards 3 --watch 30s
ccrawl recrawl publish --dir captures/ --kind urls --server server2 --shard 1 --shards 3 --state recrawl.json --watch 30s
ccrawl recrawl publish --dir captures/ --no-push
| Flag | Meaning |
|---|---|
--dir |
Capture directory to publish from, the same one recrawl run writes into |
--repo |
Dataset repo to publish to, default the open-index repo for --kind |
--kind |
Which recrawl this is, domains or urls, default domains |
--server |
Name of this machine, default the hostname |
--shard |
Which partition of the work list this machine took, 0-based |
--shards |
How many machines are splitting the work list, default 1 |
--state |
The crawl's checkpoint file, read so the card can report progress |
--commit-every |
Shards per HuggingFace commit, default 4 |
--watch |
Keep watching the directory and publish shards as they close, 0 makes one pass |
--keep |
Keep local shards after commit instead of deleting them |
--private |
Create the dataset repo private |
--no-push |
Stage and report but skip the upload |
--kind domains and --kind urls publish to open-index/ccrawl-recrawl-domains and open-index/ccrawl-recrawl-urls.
They are two repos rather than one because a domain recrawl and a URL recrawl finish on completely different schedules, and a card that averaged the two would describe neither.
The reason this is a second process rather than a stage inside recrawl run is that a run measured in months has to survive the publisher being restarted, reconfigured or pointed somewhere else without stopping the fetch.
The two share a directory and one rule: a .parquet in it is a whole shard and nothing else is.
A shard being written is named .parquet.tmp and is renamed only once its footer is on the platter, so the publisher can never pick up a file whose footer is missing, and a crash leaves a .parquet.tmp that the checkpoint never advanced past.
Each machine writes exactly one ledger file, ledger/<server>-shard<i>of<n>.csv, and never touches another machine's.
That is not a tidiness preference.
The hub has no compare-and-set on a path, so if the fleet shared one stats.csv, read it, added its rows and wrote it back, whoever committed second would write back a copy without the first one's numbers in it, nothing would error, and the counts would quietly go backwards.
One writer per file makes two machines committing at the same instant touch different files, so there is nothing to lose.
The card is generated from the union of whatever ledger files are on the hub at the time, which makes it derived rather than authoritative: a card written from a snapshot that missed a row someone else had just committed is corrected by the next commit from any machine, and until then it undercounts rather than overcounts.
Shards are named data/<server>-shard<i>of<n>-<hash>.parquet, where the hash is the first twelve hex digits of the file's sha256.
Naming by content rather than by a counter is what makes republishing idempotent.
The crawl's own file numbering restarts at zero on every run and the files are deleted once they are committed, so a counter would either collide with a shard already on the hub or publish the same rows twice under two names.
With a content hash the existence check catches a duplicate before any of its bytes are uploaded, which is what a publisher killed between the commit landing and the local delete needs.
Resume asks the hub what is published rather than trusting local state, the same way the url and news publishers do. On start the publisher downloads its own ledger row and carries on from it, so a wiped staging directory or a move to a new disk does not restart the file count or the card's totals at zero halfway through a crawl.
With --watch, the publisher stops on its own once the crawl's checkpoint says the work list is walked out and the directory it was draining is empty.
Without that the fleet would need a second signal to shut the publisher down, and a forgotten publisher polling an empty directory for a week is exactly the kind of thing nobody notices.
HF_TOKEN (or HUGGINGFACE_TOKEN) has to be set to push.
--no-push runs everything except the upload, including the ledger and card generation, so the part most likely to be wrong is the part being rehearsed.
sched
Recrawl scheduling commands.
sched diff requires DuckDB on PATH.
| Subcommand | Does |
|---|---|
sched assign |
Assign crawl tiers to hosts by harmonic rank and change rate |
sched diff |
Compare two crawls and compute per-host content change rates |
sched assign
ccrawl sched assign -n 20 -o table
ccrawl sched assign --graph cc-main-2026-mar-apr-may --change-rate 0.5 -o jsonl
| Flag | Meaning |
|---|---|
--graph |
Web-graph release ID (default: latest) |
--change-rate |
Assumed change rate for all hosts (0-1, default 0.5) |
Tier assignment:
| Tier | Recrawl interval | Criteria |
|---|---|---|
| 1 | 24 h | harmonic rank <= 100 K and change rate > 0.8 |
| 2 | 3 days | rank <= 1 M and change rate >= 0.5 |
| 3 | 7 days | rank <= 5 M and change rate >= 0.2 |
| 4 | 30 days | rank <= 10 M |
| 5 | on-demand | everything else |
sched diff
Joins two CDX Parquet indexes on URL and compares content_digest to compute per-host change rates.
Requires DuckDB on PATH.
Scans ~368 GB of Parquet (184 GB per crawl).
ccrawl sched diff --crawl-a CC-MAIN-2026-17 --crawl-b CC-MAIN-2026-21 -n 20
ccrawl sched diff --crawl-a CC-MAIN-2026-12 --crawl-b CC-MAIN-2026-17 -o jsonl > changes.jsonl
| Flag | Meaning |
|---|---|
--crawl-a |
Older crawl ID |
--crawl-b |
Newer crawl ID |
index
Build and query a local BM25 full-text search index over a JSONL page corpus. This is a reference implementation with a corpus ceiling of a few hundred thousand documents, not a production search engine; the search index guide has the measured numbers.
| Subcommand | Does |
|---|---|
index build |
Build a BM25 inverted index from JSONL documents or a list of URLs |
index search <query> |
Query the index; results ranked by BM25 score |
index build
Reads JSONL documents from --input, or fetches and extracts the pages named by --urls, tokenizes them, and writes a BM25 inverted index with per-document length normalization.
Each --input line is a JSON object with a url and the text to index, and optionally a title and a language, which is the shape ccrawl parse writes for a WET file.
The language key can be language, which is what a file written by hand tends to say, or content_language, which is what ccrawl parse wet -o jsonl writes; a file written by a ccrawl older than v0.10.1 says ContentLanguage and is still read.
The index directory contains terms.dat, postings.dat, forward.jsonl, and stats.dat, and a rebuild replaces all four.
One of --input or --urls is required; without either the command exits 2 rather than writing an empty index.
ccrawl index build --dir /data/idx --input docs.jsonl
ccrawl parse file.warc.wet.gz --lang eng -o jsonl | ccrawl index build --dir /data/idx --input -
ccrawl index build --dir /data/idx --urls https://golang.org/,https://pkg.go.dev/ -o json
| Flag | Meaning |
|---|---|
--dir |
Directory to write the index into (default: ~/data/ccrawl/index) |
--input |
JSONL file of documents to index, or - for stdin |
--urls |
Comma-separated URLs to fetch and index |
-j, --workers |
Fetch concurrency for --urls (default 8) |
index search
Queries the local index using BM25 scoring with per-document length normalization. Query terms are ORed: a document matches if it holds any of them, and the ones holding more score higher. A query that matches nothing exits 3.
ccrawl index search "golang web server"
ccrawl index search "machine learning" --dir /data/idx -n 20 -o json
| Flag | Meaning |
|---|---|
--dir |
Index directory to search (default: ~/data/ccrawl/index) |
-n, --limit |
Documents to return (default 100) |
api
Start the v2 HTTP REST API server.
This is a local exploration tool with no authentication, no rate limiting, no request log and no pagination, so it binds loopback by default and warns if pointed anywhere else; see the API server guide.
The host store is loaded from the web-graph rank table on startup (top 1 M hosts) and a load that fails is fatal.
Full-text search is available when --index-dir points to a built index, and answers 503 without it.
GET /v2/host/{host} host profile from the rank table
GET /v2/hosts?tld=&n= top N hosts, optional TLD filter
GET /v2/search?q=&k= BM25 full-text search (requires --index-dir)
GET /v2/health liveness, and which stores are loaded
ccrawl api
ccrawl api --addr 127.0.0.1:9090 --index-dir /data/idx
| Flag | Meaning |
|---|---|
--addr |
Listen address (default 127.0.0.1:8080) |
--index-dir |
Path to a built inverted index directory |
db
| Subcommand | Does |
|---|---|
db load |
Load matching index records into local DuckDB |
db sql <query> |
Run SQL against the local database |
db shell |
Open an interactive DuckDB shell |
db path |
Print the database file path |
db load takes the same filter flags as table.
convert
ccrawl convert <file|dir> [flags]
--to parquet|jsonl (default parquet).
-O/--out sets the output file or directory.
--markdown converts HTML bodies on the way.
library
ccrawl library list
ccrawl library du
ccrawl library verify
ccrawl library gc --older-than 90d
ccrawl library scan
The dataset library is the tree --library downloads into and processes from, ~/notes/ccrawl by default and moved with --library-dir or CCRAWL_LIBRARY.
It is separate from the data dir so scratch state and the files you keep never mix, and library is how you find out what is in it.
library.json at the root of the tree records every artifact: its path, crawl, kind, format, size, sha256, when it was written, and which version of ccrawl wrote it.
Every command that materialises into the library updates it, and the checksum for a download is computed as the bytes stream past rather than by reading the file back.
Concurrent runs take a lock on library.lock for the read-change-write, so two ccrawl processes filling one library do not lose each other's records.
| Subcommand | Does |
|---|---|
library list |
List the artifacts the manifest records |
library du |
Report library size, per crawl |
library verify |
Rehash every artifact and report what does not match |
library gc |
Delete artifacts older than a cutoff |
library scan |
Record what is on disk into the manifest |
library list
| Flag | Default | Does |
|---|---|---|
--kind |
all | Only this kind: warc, wet, wat, and so on |
--format |
all | Only this format: raw, parquet, jsonl |
-c narrows to one crawl and -n caps the rows.
library du
--by crawl|kind|format picks the grouping, crawl by default.
A total row follows the groups when there is more than one, as a row rather than a footer so it survives -o json and a pipe.
library verify
Reads every artifact and compares it against the manifest.
Four kinds of trouble are reported: missing for a file that is gone, resized for one whose size changed, corrupt for one whose bytes changed, and untracked for a file on disk the manifest has never heard of.
The first three exit 1, so library verify can gate a publish run; an untracked file is reported but is not a failure, since library scan is the fix.
| Flag | Default | Does |
|---|---|---|
--quick |
false |
Check existence and size only, no rehash |
--kind |
all | Only this kind |
library gc
Deletes artifacts and drops them from the manifest.
It needs something to select on: --older-than, -c, or --kind.
--older-than takes 30d, 8w, or any Go duration such as 12h.
It is a dry run unless you pass --yes, and the dry run prints exactly the list the real run deletes.
Directories the collection emptied are removed with it.
library scan
Walks the tree, hashes what it finds, and writes the manifest.
This is how a library built by a ccrawl that predated the manifest, or one you copied files into, comes under management.
It reads every byte the first time and is a no-op after that: an artifact already recorded at the same size is left alone unless you pass --rehash.
Only files that fit the library layout are recorded, <crawl>/<kind>/<file> for a raw archive and <crawl>/<format>/<kind>/<file> for processed output, so a README you left in the tree is not mistaken for a corrupt artifact.
dedup
ccrawl dedup <parquet-file|dir>... [flags]
Reports duplicate documents in a dataset written by markdown export or markdown refetch. It reads and prints, it never rewrites the input, so it is safe to point at a published dataset.
| Flag | Default | Does |
|---|---|---|
--distance |
3 |
Hamming distance between fingerprints that still counts as a near duplicate, 0 to 64 |
--top |
10 |
How many of the largest clusters to list, 0 for none |
--json |
false |
Emit the report as JSON instead of a table |
ccrawl dedup ./md
ccrawl dedup ./md --distance 6 --top 20
ccrawl dedup ./md/part-000.parquet ./md/part-001.parquet --json
20,861 rows in 1 files
exact duplicates 165 in 113 clusters, 139.7 kB
near duplicates 24 in 23 clusters, 171.0 kB (distance <= 3)
redundant 189 (0.9% of rows)
no fingerprint 2 (too short to hash, left alone)
Exact clusters are documents with identical Markdown. Near clusters are grouped by the simhash column, and a file written before that column existed is fingerprinted on the fly, so an older dataset still works.
Only three columns are read, which is why a 20k row shard takes well under a second. Raising --distance widens the net and chains clusters together, so 6 finds more real template duplicates and also more junk. Documents under 512 bytes are left out of the near pass because a 64 bit fingerprint over that little text is decided by noise. See Deduplication for what the numbers mean and how they were measured.
serve
Serve the record-stream operations over HTTP as NDJSON. Every kit operation gets an endpoint, so anything the CLI can list, the server can stream.
ccrawl serve --addr :8080
ccrawl serve --addr :8080 --allow-writes
| Flag | Meaning |
|---|---|
--addr |
Listen address (default :8080) |
--allow-writes |
Expose the write operations, which are hidden by default |
serve is the generic operation server; api is the purpose-built v2 REST API with the host store and search.
mcp
Run as an MCP server over stdio, exposing the same operations as tools to an MCP client.
ccrawl mcp
There are no command-specific flags.
The global flags apply, so -c, --data-dir, and --no-cache all set the server's defaults.
version
ccrawl version # the one-line form
ccrawl version --short # just the version number
ccrawl version -o json # the same six fields as data
The default line is six facts glued into a sentence: ccrawl 0.10.1 (commit 664360e, built 2026-08-13, darwin/arm64, go1.26.5). Pass -o and you get them separately, which is what a CI job wants when it needs one of them and would otherwise write a regular expression:
ccrawl version -o json | jq -r .commit
-o auto, which is the default, keeps the sentence whether the output is a terminal or a pipe. Every other command switches to JSONL when piped; this one does not, because that line is what ccrawl version has always printed and asking for a format is how you say you want data instead.
config
ccrawl config show
config show prints every setting a run resolved, the value it ended up with, and where that value came from. The source column is the reason to run it: a run that behaves oddly is nearly always a setting arriving from somewhere you did not look, and workers 7 config [bulk] ends that search in one line.
key value source
crawl CC-MAIN-2026-30 flag --crawl
workers 7 config [bulk]
retries 2 config [default]
user_agent my-crawler/1 env CCRAWL_USER_AGENT
timeout 2m0s default
profile bulk flag --profile
config_file ~/.config/ccrawl/config.toml derived from config_dir
The config file
ccrawl reads ~/.config/ccrawl/config.toml if it is there. CCRAWL_CONFIG_DIR names the directory outright, and XDG_CONFIG_HOME moves it the usual way. There is no file by default and nothing needs one.
The [default] table applies to every run. Any other table is a profile, and --profile <name> layers it on top of [default] for that run.
[default]
workers = 8
global_rate = "500ms"
[bulk]
workers = 64
global_rate = "50ms"
library_dir = "/data/ccrawl"
[polite]
global_rate = "5s"
retries = 8
ccrawl --profile bulk markdown build -c 2026-30
ccrawl --profile polite search '*.gov' --limit 1000
CCRAWL_PROFILE selects a profile from the environment, for a shell or a systemd unit that should run everything one way.
Precedence is flag, then environment, then profile, then [default], then the built-in default. A profile cannot undo an export in the shell that started the run, and a flag on the command line always wins.
The settings, with the environment variable that beats each one:
| Setting | Env | What it sets |
|---|---|---|
crawl |
CCRAWL_CRAWL |
Default crawl, same values as -c |
source |
CCRAWL_SOURCE |
https or s3 |
data_dir |
CCRAWL_DATA_DIR |
Root data directory |
cache_dir |
CCRAWL_CACHE_DIR |
Cache directory, follows data_dir when unset |
library_dir |
CCRAWL_LIBRARY |
Dataset library root |
db_path |
CCRAWL_DB_PATH |
Local DuckDB file |
workers |
CCRAWL_WORKERS |
Concurrency |
rate |
CCRAWL_RATE |
Per-process delay between requests |
global_rate |
CCRAWL_GLOBAL_RATE |
Host-wide gap between Common Crawl requests |
timeout |
CCRAWL_TIMEOUT |
Per-request timeout |
retries |
CCRAWL_RETRIES |
Retry attempts |
backoff |
CCRAWL_BACKOFF |
Base wait before the first retry |
backoff_max |
CCRAWL_BACKOFF_MAX |
Cap on a single retry wait |
user_agent |
CCRAWL_USER_AGENT |
User agent sent to Common Crawl |
urls_repo |
CCRAWL_URLS_REPO |
HuggingFace dataset for urls publish |
domains_repo |
CCRAWL_DOMAINS_REPO |
HuggingFace dataset for domains publish |
news_repo |
CCRAWL_NEWS_REPO |
HuggingFace dataset for news publish and news search |
collinfo_endpoint |
CCRAWL_COLLINFO_ENDPOINT |
Where the crawl list comes from |
data_endpoint |
CCRAWL_DATA_ENDPOINT |
Where manifests, WARC files and the columnar index come from |
cdx_endpoint |
CCRAWL_CDX_ENDPOINT |
Where the URL index comes from |
Durations are written the way the flags are written, "500ms" or "5s", and have to be quoted. The endpoints are there for a mirror or a local proxy; the defaults are Common Crawl's own hosts.
A setting ccrawl does not read stops the run and names the line, since the alternative is a config file that looks like it is doing something and is not. So does --profile naming a table the file does not declare, and the error lists the ones it does. A value of the right key and the wrong type, workers = "lots", is reported on stderr and the run continues on the default: that is read while the flags are being registered, where there is nowhere to return an error.
Global flags
These apply to every command.
| Flag | Short | Meaning | Default |
|---|---|---|---|
--crawl |
-c |
Crawl ID, year (all crawls of that year), latest, all, an integer for the newest N, or a comma list |
latest |
--output |
-o |
Output format: auto, table, json, jsonl, csv, tsv, url, raw, parquet |
auto |
--limit |
-n |
Maximum records (0 = unlimited) | 0 |
--workers |
-j |
Concurrency for downloads and scans | 8 |
--source |
Bulk data source: https or s3 |
https |
|
--rate |
Minimum delay between requests, for this process alone | 0s |
|
--global-rate |
Minimum gap between Common Crawl requests across every ccrawl process on this host (0 disables) | 200ms |
|
--timeout |
Per-request timeout | 0s |
|
--no-cache |
Bypass the on-disk cache | false | |
--fields |
Comma-separated columns to show | ||
--template |
Go template applied per record | ||
--library |
Read and write under the dataset library | false | |
--library-dir |
Library root | ~/notes/ccrawl |
|
--data-dir |
Root data directory | ||
--dry-run |
Print actions, do not perform them | false | |
--quiet |
-q |
Suppress progress output | false |
--verbose |
-v |
Increase verbosity (repeatable) | |
--color |
Color output: auto, always, never |
auto |
|
--no-header |
Omit the header row in table output | false | |
--db |
Tee every record into a store (e.g. out.db, postgres://...) |
||
--profile |
Named profile to load | ||
--progress |
Progress reporting for long runs: text, json, none |
text on a terminal, json otherwise | |
--journal |
Append run events as JSON Lines to this file | run.jsonl beside the ledger |
|
--metrics-addr |
Serve Prometheus metrics for the run on this address, e.g. :9090 |
off |
See run journal for the event schema, the metric names, and the queries worth keeping.
The crawl list outlives the index server
Turning latest into a crawl ID needs collinfo.json from index.commoncrawl.org. It is cached for six hours, and when the fetch fails the cached copy is used at any age, with a line on stderr saying how old it is:
crawls: the index server is unreachable, using the crawl list cached 19h0m0s ago; pass -c to name a crawl instead of resolving it
Common Crawl publishes about six crawls a year, so a day-old list is almost always the list a fresh fetch would return, and the index server has gone away for three days at a stretch. Most of what ccrawl does reads data.commoncrawl.org, which stays up through those outages, and only touches the index server to resolve that one word. Without the fallback paths, columnar and download all failed on a crawl ID sitting in the cache.
Naming a crawl with -c CC-MAIN-2026-30 skips the lookup entirely, and is the right move for a scheduled job that should not depend on it. With no cached copy and the server unreachable the run still fails with exit 8: guessing a crawl list is worse than saying nothing.
--no-cache turns the whole cache off, the fallback with it.
The shared request budget
--rate spaces the requests one ccrawl process makes. That is not the number Common Crawl sees. Running the URL publish, the domain publish, and a Markdown export at once means three processes each pacing themselves politely and three times the traffic arriving at a nonprofit that serves this for free.
--global-rate is the gap between requests summed over every ccrawl process on the host. The processes coordinate through a small lock file at <data-dir>/ratelimit.lock: taking a slot means locking the file, reading the time the next slot comes free, pushing it forward by one interval, and sleeping until the slot you were handed. The lock is held for sixteen bytes of read and write, so processes queue on the timestamps rather than on the lock.
It covers index.commoncrawl.org, data.commoncrawl.org, commoncrawl.org, and the commoncrawl S3 bucket. Requests a recrawl makes to arbitrary sites are not Common Crawl's bandwidth, so they pay --rate and nothing else. Columnar scans are also exempt, for the reason --rate does not apply to them either: a scan is thousands of few-kilobyte footer reads, and pushing those through a five per second budget turns a thirty second query into an hour.
The default is 200ms, five requests per second, which is what a single process used to take on its own. So one process behaves exactly as it did before, and three processes now split that budget instead of tripling it. Raise the gap to be gentler, or pass --global-rate 0 to switch the shared limiter off and go back to a per process delay. CCRAWL_GLOBAL_RATE sets it from the environment.
Processes sharing a budget must share a data dir, since that is where the lock file lives. When the file cannot be created or locked, which is what a read-only or exotic filesystem looks like, ccrawl prints one warning and falls back to the per process delay rather than failing the run. Pass -v to have any run print the rate it is actually working under.
Measured on one host, three concurrent pipelines walking twenty crawls each and fetching real path manifests: --global-rate 2s served 60 requests at 0.499 per second combined against a configured 0.500, and the same 60 requests with --global-rate 0 went out at 2.316 per second.
Exit codes
0 success, 1 error, 2 usage error, 3 the query matched nothing, 4 a credential is needed and is not set, 8 transport failure so Common Crawl could not be reached at all, 75 temporary failure so run it again. See exit codes for what to branch on and how to supervise a publish run.