pyroid.trafilatura: drop-in Trafilatura¶
pyroid.trafilatura is a Rust port of the text extraction of
Trafilatura by Adrien Barbaresi (Apache-2.0), releases 2.3.1
(the default) and 1.11.0 (the one datatrove[processing] 0.10.1 installs, below).
Trafilatura turns a web page into its main text, without the navigation, footers, ads and other
boilerplate; datatrove's Trafilatura step, and so FineWeb, use it. The whole path is ported,
including the parts Trafilatura delegates: libxml2 2.14.6's HTML parser as lxml 6.1.3 drives it,
the readability fallback Trafilatura vendors, and jusText 3.0.2. The text comes out the same, byte
for byte, as Trafilatura with lxml 6.1.3 and jusText 3.0.2 (parity), 7 to 11 times
faster on one thread, and in parallel without the GIL.
pip install pyroid
pyroid has no dependencies. trafilatura is only needed for the delegated options.
Change one import¶
-import trafilatura
+import pyroid.trafilatura as trafilatura
html = "<html><body><article><p>" + "Some words. " * 30 + "</p></article></body></html>"
trafilatura.extract(html) # 'Some words. Some words. ...'
trafilatura.extract(html, favor_precision=True, include_comments=False, deduplicate=True)
extract, bare_extraction and extract_with_metadata have trafilatura 2.3.1's parameters, in
the same order, with the same defaults. The one addition is the keyword-only compat
(versions). Code you cannot edit can use pyroid.patch("trafilatura").
What runs in Rust¶
- Input: a
str(lone surrogates included), orbytesthat are valid UTF-8 and not compressed. output_format="txt"forextract, and"python"forbare_extraction.fast(and the deprecatedno_fallback, with trafilatura'sDeprecationWarning),favor_precision,favor_recall,include_comments,include_tables.deduplicate:True, or apyroid.trafilatura.LRUCache.url,record_id,tei_validationanddate_extraction_params, which do not change the text output;max_tree_sizeraises trafilatura'sValueError.configandsettingsfile: the size settings (MIN_EXTRACTED_SIZE,MIN_OUTPUT_SIZE,MIN_OUTPUT_COMM_SIZE,MIN_DUPLCHECK_SIZE,MAX_REPETITIONS,MAX_TREE_SIZE) are read as trafilatura reads them, with the same errors for bad values.- The interpreter's Unicode version (CPython 3.11 to 3.15), as trafilatura's
strmethods and NFC normalization see it.
Other input types give None, as in trafilatura. Pages trafilatura rejects (too short, not
HTML, duplicates) give None too, and a page with a &# reference of more than 4,300 digits
raises CPython's ValueError, as trafilatura does.
Delegated options¶
These go to the installed trafilatura, which must be the mode's release (2.3.1 by default, 1.11.0
with compat="1.11.0", where author_blacklist and extract_with_metadata are not on this
list). The first call per function and reason emits a
pyroid.FallbackWarning; without trafilatura, or with another
version, the call raises pyroid.PyroidError and
says which:
- the other output formats (
xml,xmltei,json,csv,html,markdown); include_formatting=True,include_links=True,include_images=True;- metadata:
with_metadata,only_with_metadata, a non-emptyurl_blacklistorauthor_blacklist, andextract_with_metadata(always delegated); target_languageandprune_xpath;options=atrafilatura.settings.Extractor, anddeduplicate=atrafilatura.deduplication.LRUCache;- an lxml tree or an HTTP response as input, and bytes that are compressed or not UTF-8;
- a negative size setting in
config, and asys.set_int_max_str_digits()limit other than 4300.
trafilatura.extract(html, output_format="xml") # trafilatura's XML, and:
# FallbackWarning: pyroid.trafilatura.extract: output_format='xml' is not supported by the Rust
# port; delegating to trafilatura 2.3.1 (slow path). This warning is shown once.
A pyroid LRUCache (or the module cache, for deduplicate=True) is lent to a delegated call, so
a run that mixes native and delegated calls deduplicates as one trafilatura run would.
Deduplication¶
deduplicate=True reads and updates one module-level cache, pyroid.trafilatura.LRU_TEST, of
LRU_SIZE (4,096) entries, as trafilatura's trafilatura.deduplication.LRU_TEST.
reset_caches() empties it, as trafilatura.meta.reset_caches() does. Pass a
pyroid.trafilatura.LRUCache instead to keep a run's state apart:
import pyroid.trafilatura as trafilatura
cache = trafilatura.LRUCache(maxsize=4096)
texts = [trafilatura.extract(page, deduplicate=cache) for page in pages]
cache.maxsize, len(cache.cache) # cache.cache is a read-only copy of the entries
LRUCache has trafilatura's methods (get, put, increment, clear) and is always true. It
is safe to share between threads: an extraction holds it for the whole page, so extractions
through one cache run one at a time. It pickles and copies with its entries. Keys are str without
lone surrogates.
Many pages: extract_batch¶
extract_batch is pyroid's addition. It runs extract over a list or tuple of pages, or an
Arrow string column (a null gives None), on pyroid's thread pool, without the GIL, and returns
the results in order, as a list (for a single str, extract's result):
texts = trafilatura.extract_batch(pages, favor_precision=True, include_comments=False)
texts = trafilatura.extract_batch(pages, threads=4) # at most 4 tasks at once
extract_batch(pages, **kwargs) always equals [extract(page, **kwargs) for page in pages].
Without deduplicate, the pages are extracted in parallel. With deduplicate=True or an
LRUCache, they are parsed in parallel, but extracted one after the other in page order through
the cache, so the results, and the cache afterwards, are the loop's; such a batch gains less from
threads (speed). A batch with an item that is not a str, or with a delegated option
or no_fallback, runs that loop. An error raises for the whole batch, with a note naming the
page.
bare_extraction¶
bare_extraction returns a pyroid.trafilatura.Document with trafilatura's Document fields:
text, raw_text (the same string), comments (with include_comments), body and
commentsbody, and None in the metadata fields, as trafilatura without metadata extraction.
When trafilatura 2.3.1 is installed, the class is a subclass of trafilatura.settings.Document
(trafilatura is imported the first time it is used; on free-threaded CPython that import
re-enables the GIL, as lxml does, while extract and extract_batch never import trafilatura or
lxml). body and commentsbody are built as lxml
elements from their XML when first read; without lxml they are the XML strings
(etree.tostring(element, encoding="unicode")). as_dict() and the deprecated as_dict=True
work as in trafilatura, and a Document pickles (its trees as XML).
Versions¶
pyroid reproduces one combination of releases per mode, selected with compat=:
compat |
trafilatura | lxml | libxml2 | jusText | Also as resolved (no effect on the text) |
|---|---|---|---|---|---|
"2.3.1" (the default) |
2.3.1 | 6.1.3 | 2.14.6 (bundled in lxml's Linux and macOS wheels) | 3.0.2 | |
"1.11.0" |
1.11.0 | 6.1.3 | 2.14.6 | 3.0.2 | lxml-html-clean 0.4.6, charset-normalizer 3.5.2, courlan 1.4.0, htmldate 1.11.0 |
The second row is what a fresh pip install 'datatrove[processing]==0.10.1' resolves.
pyroid.trafilatura.TRAFILATURA_VERSIONS lists the modes, REPRODUCED_VERSIONS the versions
behind each, and DEFAULT_CONFIGS each release's settings.cfg. Another version of trafilatura or
lxml can extract different text from the same page.
lxml 6.1.3's Windows wheels bundle libxml2 2.11.9, whose HTML parser builds different trees for
some pages (it drops �, for one), so Trafilatura itself extracts some pages differently on
Windows. pyroid parses as libxml2 2.14.6 on every platform, and the tests that compare it with
Trafilatura run only where lxml has 2.14.6.
Choosing a mode¶
| Use | compat |
|---|---|
| Direct calls, and code written for trafilatura 2.3.1 | "2.3.1", the default |
Output that must match a stock datatrove[processing]==0.10.1 install, such as a FineWeb-style pipeline checked against stock datatrove |
"1.11.0" |
The datatrove step and pyroid.patch("trafilatura") |
leave it at None: they follow the installed trafilatura, so a stock datatrove install gets 1.11.0 |
trafilatura 1.11.0¶
compat="1.11.0" runs trafilatura 1.11.0's extraction:
import pyroid.trafilatura as trafilatura
trafilatura.extract(
html, favor_precision=True, include_comments=False, deduplicate=True, compat="1.11.0"
) # what datatrove 0.10.1's Trafilatura step gets
trafilatura.extract_batch(pages, no_fallback=True, compat="1.11.0")
The same options run natively as for 2.3.1, with 1.11.0's behaviour, and the delegated ones go to an installed trafilatura 1.11.0. Where 1.11.0 differs from 2.3.1:
- It has no
fast. Itsextracttakes**kwargsand ignores unknown keywords, sofast=Truedoes nothing there, and neither does it here;bare_extractionraises 1.11.0'sTypeError. The switch isno_fallback, without aDeprecationWarning. max_tree_sizeis a limit on the output tree, not an error.author_blacklistdoes not need metadata in 1.11.0, so it runs natively and has no effect on the text.deduplicateis a flag: any true value, apyroid.trafilatura.LRUCacheincluded, uses the module cacheLRU_TEST, as 1.11.0 always uses its own.configwins oversettingsfile, which is only read withconfig=None, and then on its own. The defaultconfigstands for 1.11.0's settings.cfg.bare_extractionraises 1.11.0'sAttributeError('NoneType' object has no attribute 'source') for a page that does not load, as 1.11.0 does when it is called directly, and itscommentsbodyisNonewithoutinclude_comments. ItsDocumentis 1.11.0's (a subclass of it when 1.11.0 is installed). 1.11.0 keeps control characters, which no lxml element built from Python can hold: a tree with one stays its XML string.- There is no
extract_with_metadatain 1.11.0;compat="1.11.0"raisesValueErrorfor it.
bare_extraction returns a dict unless as_dict=False, as 1.11.0's does, and extract ignores
keywords it does not know (the old *_output names raise 1.11.0's NameError). The functions
keep 2.3.1's parameter order, so pass 1.11.0's options by keyword.
pyroid.patch("trafilatura") with trafilatura 1.11.0 installed gives
functions with 1.11.0's exact signatures.
The datatrove step¶
pyroid.integrations.datatrove.Trafilatura replaces datatrove 0.10.1's Trafilatura extractor
(pip install 'pyroid[datatrove]'):
-from datatrove.pipeline.extractors import Trafilatura
+from pyroid.integrations.datatrove import Trafilatura
pipeline = [
WarcReader(...),
URLFilter(),
Trafilatura(favour_precision=True),
...
]
It takes datatrove's arguments with their defaults (favour_precision=True,
include_images=False, timeout=1, deduplicate=True, and **kwargs passed to extract),
plus three keyword-only ones:
| Argument | Default | Meaning |
|---|---|---|
compat |
None |
the trafilatura release to reproduce. None: the installed trafilatura's version if pyroid reproduces it (2.3.1 or 1.11.0); 2.3.1 if trafilatura is not installed; for any other installed version, datatrove's own step runs the documents (with one FallbackWarning) |
batch_size |
128 |
documents per pyroid call |
threads |
1 |
pyroid's threads= (Threads) |
A stock datatrove[processing] install has trafilatura 1.11.0, so with compat=None the step
runs pyroid's 1.11.0 mode, and a FineWeb pipeline gives the same documents as with datatrove's own
step. With trafilatura 2.3.1 installed it runs the 2.3.1 mode. trafilatura is not needed at all;
without it the step runs 2.3.1 unless compat="1.11.0" says otherwise.
The documents out, their text, and the statistics (total, extracted, forwarded, dropped,
doc_len) are datatrove's. The step runs in the task's process, so it differs from datatrove's
in these ways:
- There is no child process and no timeout. A page datatrove gives up on after
timeoutseconds (counted astimeout, and followed by a fresh child with an empty dedup cache) gets its text here. - With
deduplicate=Truethe dedup cache is a freshLRUCacheperrun(), that is per task, as datatrove's per-task child process has a freshLRU_TEST. - Errors are counted as datatrove counts them. A page whose extraction raises kills datatrove's
child, so it is
broken_process, and the rest of the task continues with an empty dedup cache, as datatrove's next child does. Arguments that makeextract("")raise (datatrove's warm-up call, for example a keywordextractdoes not take) make every pagetimeout. - Time is tracked per batch, not per document.
pyroid.patch("trafilatura")¶
import pyroid
pyroid.patch("trafilatura")
from trafilatura import extract # pyroid's, with trafilatura's exact signature
The patch puts shims in sys.modules for trafilatura and trafilatura.core (extract,
bare_extraction, and with 2.3.1 extract_with_metadata), trafilatura.deduplication
(LRUCache, LRU_TEST) and trafilatura.meta (reset_caches, which empties pyroid's cache
too). Everything else comes from the installed trafilatura. The mode follows the installed
release: with 1.11.0 installed the shim's functions are 1.11.0's (its extract takes
**kwargs and has no fast, its bare_extraction returns a dict by default), with 2.3.1 they
are 2.3.1's. Either way they have exactly the installed trafilatura's signatures, without
compat, and config defaults to its DEFAULT_CONFIG. With another release installed, a
PatchWarning says that the patched functions give trafilatura 2.3.1's results. datatrove's own Trafilatura step
does from trafilatura import extract in its child process, so a patch made before the pipeline
starts (or pyroid run -p trafilatura) reaches it. See pyroid.patch().
Differences from trafilatura¶
From the module's documentation (pyroid/trafilatura.pyi):
- Trafilatura's log messages (
logging.getLogger("trafilatura")) are not emitted. configdefaults topyroid.trafilatura.DEFAULT_CONFIG, nottrafilatura.settings.DEFAULT_CONFIG: changing trafilatura's object changes nothing here (underpyroid.patchthe shim's functions do default to trafilatura's object).LRUCachekeys are str without lone surrogates, and itscacheis a read-only copy.bare_extraction'sbodyandcommentsbodybecome lxml elements when first read, and are the XML strings without lxml.
And by design, the delegated options need the trafilatura release of the mode installed.
Parity¶
On the 4,677 Common Crawl pages of the development sample (CC-MAIN-2026-39, as datatrove's
WarcReader and URLFilter give them), every page gives trafilatura 2.3.1's text in 4 option
sets (datatrove's call; without dedup; fast; fast with dedup), both page by page and through
extract_batch: 0 differences in 37,416 comparisons, with dedup runs in page order. The test
suite (python/tests/test_trafilatura*.py) also compares pyroid with trafilatura on pages from a
Hypothesis tag grammar and on Common Crawl pages cut, spliced and with junk inserted, in 7 option
sets, bare_extraction field by field, the dedup cache's contents after each sequence, every
delegated option (one FallbackWarning, trafilatura's result), and the datatrove step against
datatrove's own, statistics included. The parity suite (parity/, run on 2026-11-09 on CPython
3.13) found 0 differences on its 3 pinned corpora of 7,192 Common Crawl pages in 7 option sets
and 2 bare_extraction sets, and on Trafilatura's own test suite replayed as 2,669 native and
1,884 delegated calls; details on the Compatibility page.
For compat="1.11.0", the same 4,677 pages give trafilatura 1.11.0's text in 5 option sets
(datatrove's call, without dedup, no_fallback, the defaults, the defaults with dedup; 7 on the
1,000 development pages), page by page and through extract_batch: 0 differences in 50,770
comparisons. The test suite runs the same checks against trafilatura 1.11.0 (with lxml 6.1.3 and
jusText 3.0.2) when it is installed, and the datatrove step against datatrove's own step on
trafilatura 1.11.0, as a stock datatrove[processing] install has it.
Speed¶
Measured on 1,000 Common Crawl pages (157,000 characters each on average) on a Docker/OrbStack VM on Apple Silicon (12 vCPUs, load 2 to 4), CPython 3.13, indicative. Each mode against its own trafilatura release:
| Call | compat="2.3.1" vs trafilatura 2.3.1 |
compat="1.11.0" vs trafilatura 1.11.0 |
|---|---|---|
extract(page), one page at a time |
4.4 vs 31.6 ms/page (7.2×) | 2.7 vs 23.4 ms/page (8.6×) |
extract_batch, 1 / 4 / 8 threads |
4.1 / 0.99 / 0.50 ms/page | 2.7 / 0.68 / 0.33 ms/page |
datatrove's call (favor_precision, deduplicate=True), one at a time |
3.5 vs 27.3 ms/page (7.8×) | 3.0 vs 32.4 ms/page (10.8×) |
the same with extract_batch, 1 / 4 / 8 threads |
3.4 / 2.0 / 2.0 ms/page | 2.9 / 2.1 / 2.1 ms/page |
datatrove's Trafilatura step, one task, vs datatrove's own step |
3.0 (threads=1), 2.1 (threads=8) vs 26.3 ms/page (8.7×) |
2.9 (threads=1), 2.2 (threads=8) vs 32.9 ms/page (11×) |
With deduplication, extraction runs in page order, so threads only parse ahead and the gain stops at about 1.5×. A datatrove pipeline gets its parallelism from running one task per core.
datatrove's FineWeb pipeline from WARC files¶
bench/fineweb_warc.py runs datatrove 0.10.1's examples/fineweb.py (WARC reader, URL filter,
Trafilatura, language ID, the quality filters, MinHash dedup, PII) on 8 WARC files of
CC-MAIN-2026-39 (171,595 HTML records), as stock datatrove with trafilatura 1.11.0, with
pyroid's drop-in steps in datatrove's executor, and as a native pyroid chain. AWS EC2
c7i.4xlarge (Intel Xeon Platinum 8488C), CPython 3.13.18, 8 workers on 8 cores or 1 worker:
| Run | 8 workers | 1 worker |
|---|---|---|
| stock datatrove | 871 s | 6,976 s |
| datatrove with pyroid's steps | 104 s (8.4×) | 776 s (9.0×) |
| native pyroid chain | 110 s | 739 s (9.4×) |
The Trafilatura step took 5,543 s of stock datatrove's 6,759 CPU seconds at 8 workers, and 514 of 792 with pyroid's steps. Of the steps still in Python, the largest is datatrove's WARC reader (warcio): 154 of those 792 CPU seconds.
Stock datatrove gives up on a page after a 1 s timeout, so which pages it drops varies between
runs: 69 at 8 workers and 80 at 1 worker, and the two runs kept 23,503 and 23,501 documents.
pyroid has no timeout and kept 23,505 in every run, with the same final documents from its steps
and from the native chain. With the pages stock datatrove dropped left out the same way, the
native chain's final documents are byte-identical to stock datatrove's, for both stock runs. Both
also drop the 5 pages on which trafilatura 1.11.0 itself raises. The results are in
bench/results/aws-c7i.4xlarge/2026-11-09-warc/.