muckrake
A reusable pipeline for crawling public data sources into
FollowTheMoney (FtM) entities, resolving duplicates
across sources, and publishing immutable releases to a read-only database.
It is partly inspired by zavod and
other tools in the FollowTheMoney ecosystem.
muckrake ships no datasets of its own: a project keeps its crawlers in its own repository and runs muckrake from there. OpenLobbying (UK lobbying and political finance) is the main project built on it.
- Quickstart
- Installation
- How it works
- Guides: writing a crawler, entity extraction, deduplication, releases, Postgres
- Reference: CLI, environment variables, NER, dedupe review, changelog, contributing
Quickstart
Crawl the bundled example dataset, publish it, and search it, using SQLite only
(no Docker, no network access after installation). You need
uv and the ICU and LevelDB libraries (see
Installation; on macOS, brew install icu4c pkg-config leveldb).
git clone https://github.com/openlobbying/muckrake.git
cd muckrake
# macOS only: let the PyICU build find Homebrew's ICU
export PKG_CONFIG_PATH="$(brew --prefix icu4c)/lib/pkgconfig"
uv sync
# Discover the example dataset in examples/hello_dataset/
export MUCKRAKE_DATASET_PATHS=examples
# release-publish writes to a separate published database, and refuses to run without one
export MUCKRAKE_PUBLISHED_DATABASE_URL=sqlite:///data/published.db
uv run muckrake list
uv run muckrake -v crawl hello # writes data/datasets/hello/statements.pack.csv
uv run muckrake -v load hello # loads it into the working DB, data/muckrake.db
uv run muckrake release-build hello # prints "Built release 1"
uv run muckrake -v release-publish 1 # copies release 1 into data/published.db
uv run muckrake search acme --pretty # finds the two example companies
-v turns on progress logging; without it, crawl, load and release-publish
print nothing on success. The example crawler is
examples/hello_dataset/crawler.py: copy it
as the starting point for your own.
Installation
muckrake needs Python 3.13 (uv installs it) and two system libraries that some dependencies compile against:
-
ICU and pkg-config, for PyICU (used for name normalisation). Required.
- macOS:
brew install icu4c pkg-config. Homebrew doesn't linkicu4cinto the default search path, so exportPKG_CONFIG_PATH="$(brew --prefix icu4c)/lib/pkgconfig"beforeuv sync. - Debian/Ubuntu:
sudo apt-get install libicu-dev pkg-config.
- macOS:
-
LevelDB, for plyvel. Required to install: plyvel is a dependency with no binary wheels, so
uv synccompiles it against the LevelDB headers.- macOS:
brew install leveldb. - Debian/Ubuntu:
sudo apt-get install libleveldb-dev.
At runtime only the
xref,dedupe,dedupe-edgesandexportcommands load plyvel. On macOS, plyvel builds against Homebrew's LevelDB but fails on import (symbol not found ... leveldb10ComparatorE), so run those four commands in the container (see Using Postgres); every other command runs natively. Making plyvel an optional dependency is tracked in Make plyvel (LevelDB) an optional extra. - macOS:
Then, in a clone of this repo, uv sync. To use muckrake from another project,
add it as a dependency (uv add muckrake) and run uv run muckrake from that
project's root.
Configuration is through environment variables, read after loading the nearest
.env file from the current directory upward; .env.example is a commented
template, and docs/env.md lists every variable.
How it works
A project has a datasets/ directory with one subdirectory per data source, each
holding a config.yml and a crawler.py. muckrake discovers datasets in
./datasets/ under the current directory and in any MUCKRAKE_DATASET_PATHS.
Data moves through these stages:
- Crawl (
muckrake crawl <name>) runs the dataset'scrawl(dataset)function, which emits FtM entities as statements. Each crawl records a row indataset_runsand writes an immutable artifact underMUCKRAKE_ARTIFACT_PATH(defaultdata/artifacts/); the latest successful orpartialrun is mirrored todata/datasets/<name>/statements.pack.csv. - Extract (
ner-extract,ner-review, optional) splits text fields that name several entities at once into separate entity candidates for review. - Load (
muckrake load) reads the statement packs, applies approved extraction candidates, and writes the statements into the working database. - Deduplicate (
xref,dedupe,dedupe-edges) suggests, reviews and records which entities are the same, using nomenklatura's resolver. - Release (
release-build,release-publish) snapshots the latest dataset runs into an immutable release, and publishes it into the separate published database that applications read.
There are two databases:
- The working database (
MUCKRAKE_DATABASE_URL, default SQLite atdata/muckrake.db) holds everything the pipeline produces and decides: crawl runs, loaded statements, the resolver, extraction candidates, releases, and the HTTP cache thatdataset.fetch_text/json/htmluse (thecachetable). - The published database (
MUCKRAKE_PUBLISHED_DATABASE_URL) holds only the current release's statements and resolver, for applications to read.release-publishrefuses to run unless it is a different database from the working one. Neverloaddirectly into it.
The add, get, search and update commands read and edit single entities in
the working database, for building a graph by hand during an investigation.
Anything that uses FtM must import muckrake first: on import it merges any
schema extensions (from ./ftm_schema_ext/ or MUCKRAKE_FTM_SCHEMA_PATHS) into
the FtM model and points FTM_MODEL_PATH at the result.
Guides
Writing a crawler
At minimum a dataset is a config.yml (FtM dataset metadata; name is what you
pass to muckrake crawl) and a crawler.py defining crawl(dataset). The
crawler creates entities with dataset.make(schema), gives each a stable ID with
dataset.make_id(...) (pass reg_nr= and register= to use an
org-id identifier such as GB-COH-01234567), and writes
them with dataset.emit(entity). See the
FtM schema explorer for schemata
and properties.
Fetch sources with dataset.fetch_text, fetch_json or fetch_html (cached in
the working database's cache table for cache_days=N) or dataset.fetch_resource
(downloaded to data/datasets/<name>/resources/). Requests share a session that
retries with backoff; set MUCKRAKE_HTTP_MIN_INTERVAL to rate-limit each host.
A crawler should crash on data it can't interpret rather than emit a guess.
Units of work and --retry-failed
A crawler can split its work into independently retryable units (one source file,
one page, one record) by wrapping each in dataset.run_unit(key, func, *args). A
unit's statements reach the pack only if func returns. If it raises, the unit is
recorded as failed, nothing from it is emitted, and the crawl carries on. A crawl
that completes with failed units finishes as partial: its output is usable by
load and release-build, and the failures are counted by error type in the run's
stats and the end-of-crawl log. Errors outside a unit still fail the whole crawl.
Each run writes a units.jsonl ledger next to its pack, recording where every
succeeded unit sits in the pack. uv run muckrake crawl <name> --retry-failed
copies the units that succeeded in the dataset's latest crawl into a new run and
re-runs everything else. That covers both retrying failures (e.g. after a parser
fix) and resuming a crawl that crashed or was killed partway. Pack and ledger are
written in place under the run's artifact directory as the crawl goes, so even a
killed crawl leaves its completed units behind.
Completeness warnings
When a crawler knows its output is missing something (a pagination cap was hit,
an API returned fewer items than it reported, a linked file has gone), it records
it with dataset.warn_incomplete(reason, **context) rather than only logging it.
Keep reason a fixed phrase and put the specifics in context. The run finishes
as partial, the warnings are counted by reason in the end-of-crawl log and
listed in the run's stats, and warnings raised inside a unit are carried over by
--retry-failed. release-build logs every partial input and records what it's
missing (failed units and warnings by reason) in the release manifest.
Entity extraction (NER)
Source data often packs several entities into one field, such as a list of
meeting attendees in one cell. ner-extract sends such texts to a splitter (a
deterministic delimiter splitter, or an LLM via OpenRouter or Anthropic) and
stores the result as candidates in the working database; ner-review approves or
rejects them, and only approved candidates are applied by load, xref and
release-build. ner-export and ner-import keep candidates outside the
database, so paid LLM results survive a lost database.
uv run muckrake ner-extract <dataset> --extractor llm --limit 50
uv run muckrake ner-review
--extractor llm needs LLM_MODEL with a provider prefix
(openrouter:<model> or anthropic:<model>) and the matching API key. See the
NER docs.
Deduplication
uv run muckrake xref # suggest pairs of entities that may be the same
uv run muckrake dedupe # review the suggestions in a terminal UI
uv run muckrake dedupe-edges # merge duplicate Representation edges, no review step
Decisions are stored in the resolver table in the working database and applied
when a release is published. xref considers at most 50,000 candidate pairs per
run (--limit). Set MUCKRAKE_PROFILE_URL to show a link to each entity in your
own application in the review UI. These commands, and export, need LevelDB (see
Installation). muckrake also provides the backend for a
multi-user web review UI, which an application can build on; see the
dedupe docs.
Releases and publishing
uv run muckrake release-build # every discovered dataset
uv run muckrake release-build <dataset> <dataset> # or just these
uv run muckrake release-list
uv run muckrake release-publish <release-id>
release-build takes the latest successful (or partial) run of each dataset
and fails if any dataset has none, so name the datasets you want when some have
never been crawled. Set MUCKRAKE_PUBLISHED_DATABASE_URL to a separate database
before publishing.
load is atomic: every dataset in it is cleared and reloaded in a single
transaction, so a load that fails partway leaves the previously loaded statements
in place. A dataset with no statements pack is skipped with a warning, and its
loaded statements are kept. load <dataset> --run-id N loads a specific
immutable crawl run instead of the latest.
Publishing writes the release's statements in a single transaction, so readers
keep seeing the previous release until it commits, and a failure leaves the
previous statements in place. On Postgres, the release is loaded with COPY into
a new statement table, indexed and given its own search view, then swapped in with
renames, so readers only wait for the swap; a release of ~5M statements publishes
in minutes. On SQLite, the published statements are cleared and rewritten row by
row. One caveat: the resolver (deduplication decisions) is copied from the working
database first, in its own transaction, so a publish that fails after that step
leaves the previous statements with the current resolver.
Using Postgres
SQLite is the default and needs no setup. For larger datasets, run Postgres with
the bundled compose file (Postgres 18 on host port 5433; an init script
creates the muckrake and muckrake_published databases):
docker compose up -d db
and set, in .env:
MUCKRAKE_DATABASE_URL=postgresql://muckrake:muckrake@localhost:5433/muckrake
MUCKRAKE_PUBLISHED_DATABASE_URL=postgresql://muckrake:muckrake@localhost:5433/muckrake_published
The compose file also has a CLI runner with LevelDB built in:
docker compose run --rm muckrake <command> is the container equivalent of
uv run muckrake <command>. It bind-mounts this repository at /work (its
working directory, so datasets/, data/ and .env come from the host) and uses
the compose Postgres databases, so run the whole pipeline through it rather than
mixing it with a native SQLite setup.
Metadata
Release files for muckrake 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| muckrake-0.3.1.tar.gz | 73.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| muckrake-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 162.3 kB
Release files / muckrake-0.3.1.tar.gz
| Download URL | muckrake-0.3.1.tar.gz |
|---|---|
| Size | 73.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ccc4e6f13f53afaad53d561de587f88a9b452a3bc16e2deccde3d8e932b08950
|
|
BLAKE2b-256 checksum How to use checksums |
4131aabd6c373917f8147c8d45a813860d0155bfe563575270a36cd0277541cf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / muckrake-0.3.1-py3-none-any.whl
| Download URL | muckrake-0.3.1-py3-none-any.whl |
|---|---|
| Size | 88.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
463900fb3c413a118d57432c1a136c0cc5ee548fff5dc715da56a4134b86f909
|
|
BLAKE2b-256 checksum How to use checksums |
dd9ac600f9342ac1a4c9d1b4d2f37d74840c55dacc16e7f15425a2503d1d1648
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency log