Command-line tool for querying Common Crawl's URL index. Two backends:
- Athena — SQL against the columnar
ccindex.ccindexParquet table. Right for domain-scale audits (thousands to millions of URLs). Billed per TB scanned; guardrails baked in. - CDX API — the free
index.commoncrawl.orgHTTP service. Right for single-URL and small-URL-set lookups. No AWS, no bill.
Built to support DFRLab's LLM-grooming research (see [[llm-grooming]], [[pravda-network]], [[training-data-poisoning]]). The default query catalog answers "how many URLs from these domains is Common Crawl carrying, per monthly crawl, and where are the raw WARC records?" — the shape of the April 2026 Pravda-in-the-Pipeline audit — plus "which crawls captured this exact URL?" via the CDX backend.
Every Athena command requires --crawl and --subset partition filters (or --all-crawls) so scan volume — and cost — stays predictable. A domain-filtered audit across four monthly crawls typically scans under 100 MB and costs under one cent.
- AWS account with:
- Athena access in us-east-1 (Common Crawl's S3 bucket lives there; cross-region reads are billed).
- An S3 bucket you own for Athena query results.
- IAM permissions:
athena:*,glue:*Database,glue:*Table,s3:GetObjectoncommoncrawl/*, ands3:PutObject/s3:GetObjecton your result bucket.
- Python 3.11+ — via the workspace
narrative-blueprintconda env or your own venv. - AWS credentials discoverable by boto3 (env vars,
~/.aws/credentials, IAM role — anything the default chain finds).
conda activate narrative-blueprint # workspace default env
cd projects/common-crawl
pip install -r requirements.txtcp config.example.toml ~/.common-crawl-cli.toml
$EDITOR ~/.common-crawl-cli.toml # set athena.output_location at minimumConfig precedence: CLI flags > CC_* environment variables > TOML file > built-in defaults. See config.example.toml for the full annotated schema.
python -m common_crawl_cli initThis creates the ccindex database, defines the external table pointing at s3://commoncrawl/cc-index/table/cc-main/warc/, and runs MSCK REPAIR TABLE to register every existing monthly partition (a few minutes, one-time). Rerun init occasionally to pick up new crawls, or use list-crawls to check.
Athena charges ~$5 per TB scanned (verify against current AWS pricing for your region). One monthly warc partition is ~300 GB uncompressed metadata; with Parquet columnar storage + predicate pushdown, a partition-filtered domain query typically scans 10–25 MB per partition. Athena also enforces a 10 MB minimum billable scan per query.
| Query shape (typical) | Est. scan | Est. cost |
|---|---|---|
domain-count, 1 domain, 1 crawl |
~10 MB (min) | ~$0.00005 |
domain-count, 5 domains, 4 crawls |
~100 MB | ~$0.0005 |
domain-timeseries, 1 domain, 24 crawls (2 years) |
~600 MB | ~$0.003 |
warc-pointers, 1 domain, 4 crawls, no LIMIT |
~100 MB | ~$0.0005 |
language-distribution, 1 domain, 12 crawls |
~300 MB | ~$0.0015 |
domain-count, 1 domain, --all-crawls (~126) |
~3.1 GB | ~$0.016 |
domain-timeseries, 1 domain, --all-crawls |
~3.1 GB | ~$0.016 |
lookup-url (CDX backend, any # of crawls) |
0 | $0.00 |
lookup-url --backend athena, 1 URL, 1 crawl |
~10 MB (min) | ~$0.00005 |
lookup-url --backend athena, 1 URL, all crawls |
~1.3 GB | ~$0.007 |
lookup-url --backend athena, 10 URLs, all crawls |
~1.3 GB | ~$0.007 |
find-mirrors, 1 URL, all crawls |
~6.3 GB | ~$0.031 |
find-mirrors, 1 digest, all crawls |
~3.2 GB | ~$0.016 |
Every invocation:
- Prints an estimated cost before submitting.
- Aborts if the estimate exceeds
--max-cost(default $2, configurable in TOML). - Prompts for interactive confirmation above $0.10 unless
--yesis passed. - Reports actual scan bytes + cost from Athena's execution statistics after completion.
Use --dry-run to print the SQL and estimate without submitting.
Global flags (all commands): --config PATH, --max-cost USD, --yes, --dry-run.
Create database + external table + repair partitions. Run once, or after Common Crawl publishes a new crawl you want registered.
List available crawls, most recent first. --source athena (default) queries ccindex$partitions (reflects what your init registered — costs nothing, but capped at what Athena knows about). --source cdx hits the free public collinfo.json (authoritative list of every crawl ever published — 126 as of August 2026 — no AWS needed).
URLs per (crawl, registered_domain). The baseline audit query — cheap, and answers "is this domain being harvested, and at what volume?"
URL count + distinct subdomain count per crawl for one domain. Growth curve — feeds "37 → 40,000 in 12 months" style time-series for grooming operations.
URLs for the domain with mime type / language / fetch time. Omit --limit to export every matching URL — Athena's scan cost is identical whether or not a LIMIT is present (partitions are still scanned in full, LIMIT is applied post-scan), so the only trade-off is result-set size on disk. Set --limit N for a lightweight preview. Optional --language filter matches against content_languages (substring).
Distribution of content_languages for the domain per crawl. Diagnostic for multilingual operations — Pravda-family sites publish in 80+ languages.
Export (url, warc_filename, warc_record_offset, warc_record_length) for every URL of the domain. Downstream, feed these tuples to boto3.S3.get_object(Bucket='commoncrawl', Key=warc_filename, Range=f'bytes={offset}-{offset+length-1}') to fetch just that record's raw bytes — no whole-WARC download.
lookup-url --url URL [--crawl C ...] [--all-crawls] [--since YYYY[-MM[-DD]]] [--until ...] [--backend cdx|athena] [--exact]
Look up which crawls captured a specific URL. --backend cdx (default) is free — hits the public CDX server for each crawl and returns WARC pointers. --backend athena runs the equivalent SQL against ccindex.ccindex (charged; use for consistency when you're already running Athena queries). Use --all-crawls to sweep every crawl without guessing which one holds the URL — CC re-fetches a rotating subset each month, so a given URL usually appears in some crawls and not others.
URL matching. By default lookup-url matches loosely: for Athena it rewrites the query to url_host_registered_domain = HOST AND url_path LIKE PATH_PREFIX% (tolerates scheme, www. prefix, trailing slash, querystring — you'll get one row per crawl-captured variant of the article); for CDX it passes matchType=prefix and lets the server canonicalize. Add --exact if you have the canonical URL from a WARC pointer and want a strict literal match.
Note that CC's stored URL for one page is often https://www.host.tld/path/ even if you typed http://host.tld/path — normalized matching is almost always what you want unless you're joining against a specific known-good record.
Sweeping all crawls: on the audit commands (domain-count, domain-timeseries, sample-urls, language-distribution, warc-pointers) pass --all-crawls instead of an explicit crawl list. The CLI resolves the list from ccindex$partitions at run time and rolls the scan estimate into the cost prompt. For domain-filtered queries this is very cheap (~$0.02 for a full ~126-crawl sweep of one domain).
find-mirrors --url URL | --digest DIGEST [--url-file / --digest-file FILE] [--crawl ... | --all-crawls] [--since ...] [--exact]
Find every URL republishing the same bytes as a source article, via CC's content_digest (SHA-1 of the response payload). Two URLs share it iff their bytes are byte-for-byte identical — the "verbatim republication" case that dominates Pravda-style laundering (same article copied across 150+ mirror sites).
Flow: (1) resolve source URL(s) → distinct content_digest(s); (2) scan crawls for every URL matching those digests. Two Athena queries; skip step 1 by passing --digest DIGEST directly (from a prior lookup-url result). Batchable via --url-file / --digest-file.
Scope limit — exact content only. content_digest catches verbatim republication. It does not catch paragraph-level similarity, translated variants, or lightly-edited copies. For that you need the actual WARC payload:
warc-pointersorlookup-url→ export(warc_filename, warc_record_offset, warc_record_length).- Fetch each record with
boto3.S3.get_object(Bucket='commoncrawl', Key=warc_filename, Range=f'bytes={offset}-{offset+length-1}'). - Parse the WARC record (HTTP response + payload) —
warciohandles this. - Extract text (BeautifulSoup / trafilatura), then do the similarity work locally (shingling / minhash / embeddings).
That fetch-and-search pipeline is a distinct tool — happy to build it as fetch-warc + text-search if useful; not part of the ccindex-only workflow. find-mirrors is the free lunch: it catches the highest-frequency laundering pattern without leaving Athena.
lookup-url and find-mirrors both accept --url-file FILE (one URL per line, blanks and lines starting with # skipped). Combines with -u URL flags. Athena batches all URLs into one scan — same partition cost as a single URL. CDX loops sequentially (one HTTP GET per URL × crawl).
Example:
# urls.txt
# Pravda-network article variants
france.news-pravda.com/politique/2026-02-03-tisza-toll
slovakia.news-pravda.com/svet/2026-02-04-slovak-tisza
python -m common_crawl_cli find-mirrors \
--url-file urls.txt --all-crawls --since 2026-01 \
--output csv --output-file mirrors.csvEvery command that takes a crawl list (both explicit --crawl and --all-crawls) accepts --since and --until bounds. Format: YYYY, YYYY-MM, or YYYY-MM-DD. The CLI parses each crawl id (CC-MAIN-YYYY-WW → ISO week Mon–Sun; legacy formats too) and drops partitions whose fetch window is definitively outside the range before submitting anything.
For a URL you know was first published on 2023-01-25, --since 2023-01 eliminates every crawl from 2008 through end of 2022 as a guaranteed miss — cutting the sweep from ~126 partitions to ~40 without touching data. Same pattern for domain-level queries: if a Pravda-family site launched in 2022, --since 2022 cuts the timeseries sweep to only-relevant crawls.
The filter fails-closed on unparseable crawl ids when a bound is set (rare — only affects the two pre-2013 range archives), so if --since matters for correctness you get a "N dropped" line telling you what was excluded.
bash examples/pravda_audit.shCounts URLs for five Pravda-network domains across the four most recent monthly crawls, writes CSV to pravda_audit.csv. Expected total cost: < $0.01.
Equivalent one-liner:
python -m common_crawl_cli --yes domain-count \
--domain news-pravda.com \
--domain france.news-pravda.com \
--domain slovakia.news-pravda.com \
--crawl CC-MAIN-2026-30 \
--crawl CC-MAIN-2026-26 \
--crawl CC-MAIN-2026-22 \
--output csv --output-file pravda_audit.csvPreview the SQL without spending anything:
python -m common_crawl_cli --dry-run domain-count \
--domain pravda-tv.ru --crawl CC-MAIN-2026-30Find every Common Crawl capture of a specific article, pruning crawls that predate the article's publish date:
python -m common_crawl_cli lookup-url \
--url "http://rt.com/russia/570714-russia-ukraine-pentagon-biolabs/" \
--all-crawls --since 2023-01 \
--output json --output-file rt_captures.jsonThe RT piece was published in January 2023, so --since 2023-01 drops every crawl from CC-MAIN-2008-2009 through CC-MAIN-2022-49 as a guaranteed miss — ~86 partitions eliminated before any HTTP request. Sweep drops from ~1 min to ~20 s wall time. Total cost $0.00 either way (CDX backend).
Omit --since and you sweep all crawls — same result, more waiting.
Full growth curve for a Pravda-family domain across every registered crawl:
python -m common_crawl_cli domain-timeseries \
--domain pravda-tv.ru --all-crawls \
--output csv --output-file pravda-tv-timeseries.csv| Question | Backend | Command |
|---|---|---|
| Which crawls captured this specific URL? | CDX (free) | lookup-url --url URL --all-crawls |
| How many URLs of this domain per crawl? | Athena | domain-count, domain-timeseries |
| Give me a sample of URLs from this domain | Athena | sample-urls |
| Give me WARC pointers for every URL of this domain | Athena | warc-pointers |
| Language breakdown of this domain's corpus | Athena | language-distribution |
| A handful of URL lookups, no AWS set up | CDX (free) | lookup-url per URL |
CDX server caveat. The public CDX server is a shared community resource and returns transient 5xx responses under load. The CLI retries with exponential backoff (1s → 2s → 4s) and skips crawls that stay broken after retries — the sweep continues and logs [warn] CC-MAIN-YYYY-WW: HTTP Error 504 for whatever crawls failed. Re-run the failed crawls individually later, or use the Athena backend (--backend athena) if reliability matters more than cost.
--output {table,csv,json} selects the format; default is table (Markdown-style, stdout). --output-file PATH writes to a file instead. JSON is a list of {column: value} objects; CSV is header + rows.
sample-urls, warc-pointers, lookup-url, and find-mirrors all return the same 16-column canonical projection so downstream code doesn't care which command produced a given CSV:
| Column | Notes |
|---|---|
crawl |
Partition — CC-MAIN-YYYY-WW etc. |
url |
The URL as CC actually fetched it |
url_host_name |
Full hostname (e.g. www.news-pravda.com) |
url_host_registered_domain |
eTLD+1 (e.g. news-pravda.com) |
content_digest |
SHA-1 of the response payload — feed to find-mirrors --digest |
content_mime_type |
HTTP Content-Type header as declared |
content_mime_detected |
Server-side content sniff (often more accurate) |
content_charset |
Encoding declared or detected |
content_languages |
Comma-separated ISO codes CC detected |
content_truncated |
Non-empty if the response was truncated |
fetch_time |
UTC timestamp of the capture |
fetch_status |
HTTP status code (200, 301, 404, …) |
fetch_redirect |
Target URL for redirects, empty otherwise |
warc_filename |
Path in s3://commoncrawl/ |
warc_record_offset |
Byte offset inside the WARC file |
warc_record_length |
Byte length of the record |
The three "counter" commands — domain-count, domain-timeseries, language-distribution — return aggregate shapes appropriate to their question, not this projection.
athena.output_location is required— set it in~/.common-crawl-cli.tomlor exportCC_ATHENA_OUTPUT_LOCATION.AccessDeniedException— your IAM role needss3:GetObjectoncommoncrawl/*(public bucket, but Athena still checks) and read/write on your result bucket.Partition not found— a new crawl was published but hasn't been registered. Reruninit(which callsMSCK REPAIR TABLE) or uselist-crawlsto confirm what's available.- Query stuck in QUEUED — Athena workgroup capacity limit. Check workgroup metrics in the AWS console.
HIVE_S3_THROTTLING/SlowDown/HIVE_CANNOT_OPEN_SPLIT— infrastructure event, not a query bug. Common Crawl's S3 bucket is public and shared; S3 returnsSlowDownwhen concurrent request volume spikes against a hot prefix. The CLI auto-retries these classes of failure with exponential backoff (5s → 10s → 20s → 40s, up to 4 attempts). If it exhausts retries, wait a minute and rerun — the throttle window is usually short. Not billed as data scanned for FAILED queries, though the workgroup still logs the execution.- Estimated cost blocked — pass
--max-cost 5.00for the invocation, or narrow the query. Broadsubset=warcqueries without a domain filter can scan hundreds of GB. lookup-urlreturned zero rows — CC likely stored the URL under a different scheme,www.prefix, or trailing slash than you typed.lookup-urldefaults to normalized matching, which tolerates that; if you passed--exact, drop it. If you still see zero rows, the URL truly isn't captured — Common Crawl decides per-crawl which URLs to fetch based on link-graph reachability and seed lists; publish date doesn't guarantee capture.--output-fileproduced an empty / header-only file — the query succeeded but returned zero rows. The empty file is intentional: it confirms the query ran and returned nothing, so a downstream pipeline can detect the negative case rather than crash on a missing file.
- Query templates live in
common_crawl_cli/queries.py. Every template hard-requirescrawl IN (...)andsubset = ...filters. Do not add a template that omits these — Athena will happily scan every crawl ever if you let it. - Cost estimation (
cost.py) is deliberately conservative. Post-run,Statistics.DataScannedInBytesfrom Athena is the ground truth; the estimator only exists to catch obvious mistakes before submission. - The
ccindexschema is versioned in the commoncrawl/cc-index-table repo. If Common Crawl adds columns, updateCREATE_TABLEinqueries.pyand reruninit. sample-urlsusesLIMITwithoutORDER BY RAND()— Athena'sLIMITon partitioned Parquet is effectively a low-cost prefix sample, not a uniform random sample. For statistically meaningful sampling, pullwarc-pointersand sample downstream.- The
cdx.pyclient usesurllib(stdlib) with exponential-backoff retries and per-crawl error isolation. If we need higher throughput on all-crawls sweeps, aconcurrent.futures.ThreadPoolExecutor(bounded to 4–8 workers to stay polite) would help — deliberately not added yet. boto3andtabulateare lazy-imported so CDX-only workflows (lookup-url --backend cdx,list-crawls --source cdx) run without AWS deps installed. Onlyclickis required at module load;pip install clickalone is enough to use every CDX-backed command.