Skip to main content

RDTextract

PyPI Python License: MIT

HTML→Markdown extractor built for AI / LLM training corpora — every byte should carry signal, not boilerplate.

Language scope: core extraction is language-agnostic and works on any language. The is_low_value_stub() heuristic (paywall / login / skip-link detection) supports French, English, Spanish, German, Italian out of the box. The library was originally built for a French corpus, so FR is the most battle-tested.

  • Zero noise artifacts — no double-bullets, no orphan punctuation, no link dumps, no widget repetition (validated on 672 real .fr pages).
  • Smart fallback chain — JSON-LD structured data (Recipe, Article, FAQ, HowTo, Product) → <title> + meta description, for SPA / React / Next.js pages with empty bodies.
  • Robust against malformed HTML — auto-detects and recovers from unclosed tags, gzip leaks, and CMS quirks (Drupal, WordPress Elementor, React 18 SSR Suspense, etc.).
  • Multi-language stub detection — paywall / login / skip-link markers in FR / EN / ES / DE / IT.
  • is_low_value_stub() filter for paywalls, login walls, skip-link stubs, empty pages.
  • One required dependency: beautifulsoup4. Optional [fast] extra adds lxml for 2-3× speed.

What's new in v0.3.0

Large output-quality release, every change validated on a 205k-page French crawl (10k+ random pages per audit, false-positives verified line-by-line):

  • Recovered lost content — the over-broad overlay junk pattern was silently destroying whole articles on ~3% of pages that use it (news/heritage/HR content, up to +90k chars/page). Validated: 105 pages recovered on 12k, zero regressions.
  • Cleaner Markdown structure — a heading nested in a list item now renders as bold (- **Title**) instead of invalid - ### Title (2155→~0 occurrences); a single-column layout table collapses to paragraphs instead of an empty-header 1-wide table.
  • Un-glued dates18avr.202418 avr. 2024 (day/month/year split across styled <span>s), anchored on FR months so non-dates are untouched.
  • More UI noise removed — invisible/zero-width characters (ZWSP, BOM, soft hyphen; NBSP→space), standalone action labels (Haut de page, Partager, Imprimer), breadcrumb trails (Accueil / X / Y), broadened skip-link variants, generic read-more CTAs (En savoir plus, Lire la suite), repeated per-item CTAs, and consecutive duplicate captions.
  • Cookie-banner stubs flagged by is_low_value_stub.
  • Held the line on false positives — attempted mega-menu (ALL-CAPS), form-wrapped, display:none and lone-digit strips were backed out after large audits showed them eating real content (team rosters, directories, dates).

Test suite 26 → 48. 100% backward-compatible API.

What's new in v0.2.1

Bug-fix + quality release. v0.2.0 fails to import on Python 3.9 (a str | None annotation evaluated at import time) — v0.2.1 restores the advertised 3.9 support.

  • Python 3.9 import fixedfrom __future__ import annotations added; the CI matrix now covers 3.9 → 3.12 so this can't regress.
  • Fail-closed conversion — on any internal error the converter/cleaner now return "" instead of leaking raw HTML into the corpus.
  • Recursion guard — pathological nesting (thousands of unclosed tags) is flattened past a depth cap instead of aborting the whole page.
  • Skip-link stripping — accessibility skip-links ("Aller au contenu", "Skip to content", concatenated skip-nav bars) are removed from the output. They opened 20 % of pages in the 672-page benchmark; now 0 %, with zero false positives on that corpus.
  • Space-before-punctuation fix — the <a>-renderer no longer leaves "lien ."; 1 805 → 41 occurrences across the benchmark, while .com/.fr extensions, decimals, and French typography (space before ; : ! ?) are preserved.
  • Single-sourced version + is_low_value_stub(None) no longer raises.

What's new in v0.2.0

Major release with substantial gains across every metric measured on the Tranco top-1000 .fr benchmark (672 pages):

Metric v0.1.x v0.2.0 Δ
Quality (corrected) 98.3 99.3 +1.0
Avg markdown size 5 538c 6 875c +24%
Speed (with lxml) 152ms 107ms -30%
Empty pages 39 21 -46%
Recoverable empty pages 14 0 -100%
Total artifacts 9 0 -100%

New features:

  • [fast] extra — opt-in lxml backend for 2-3× faster parsing, with automatic fallback to html.parser on malformed HTML.
  • JSON-LD fallback — Schema.org structured data (Article, NewsArticle, BlogPosting, Recipe, FAQPage, HowTo, Product, with @graph wrapper) extracted when the body is empty.
  • Multi-language stub detectionis_low_value_stub(md, language="en") supports fr, en, es, de, it (paywall, login, skip-link markers).
  • PARSER constant — module-level value exposing the active parser ('lxml' or 'html.parser').

Robustness fixes (no regression, all gains):

  • React 18 SSR Suspense boundaries (<div hidden> with content) preserved.
  • WordPress Elementor pages no longer destroyed by widget pattern over-match.
  • Drupal pages with malformed <header> wrappers now extract the real content.
  • Layout-tables (image + caption WordPress) collapsed cleanly to paragraphs.
  • Gzipped HTML leaks (corrupted fetcher) detected → empty output instead of mojibake.

Tooling:

  • New benchmark/find_outliers.py — classifies suspicious extractions (EMPTY_LARGE / TINY_RATIO / HUGE_RATIO / ARTIFACTS / SPA_NO_FALLBACK) for human review.
  • New benchmark/compare_outputs.py — generates side-by-side .md outputs of the 4 extractors for any sample of pages.
  • Test suite expanded from 6 → 48 tests (JSON-LD types, multi-lang, layout-tables, parser detection, robustness, skip-link/punctuation/overlay/date/heading/table regressions).

API compatibility: 100% backward-compatible with v0.1.x. Existing code keeps working; new features are opt-in.

Install

pip install RDTextract           # minimal install (html.parser only)
pip install RDTextract[fast]     # recommended: adds lxml for faster parsing

Quick start

import rdtextract

html = open("page.html", encoding="utf-8", errors="replace").read()

# One-shot
markdown = rdtextract.extract(html)

# Or two-step (re-use cleaned HTML for caching, debugging, etc.)
cleaned = rdtextract.clean_html(html)
markdown = rdtextract.to_markdown(cleaned)

# Filter low-value pages before writing to your corpus
if not rdtextract.is_low_value_stub(markdown):
    with open("page.md", "w", encoding="utf-8") as f:
        f.write(markdown)
    print(f"Saved {len(markdown)} chars to page.md")
else:
    print("Page is low-value (paywall/login/empty), skipped.")

# Multi-language: target one specific language for stub detection
if not rdtextract.is_low_value_stub(markdown, language="en"):
    save(markdown)

Note : PyPI distribution is RDTextract, Python module is rdtextract (PEP 8 lowercase).

Why another HTML→Markdown lib?

Existing tools target human readability (newsletters, archive). RDTextract targets LLM training data: every byte should carry signal.

Benchmark on Tranco top-1000 .fr homepages (672 pages successfully fetched and processed), measured against a corpus quality scorer (lower artifact counts = cleaner output):

Extractor Quality (corrected) double-bullet orphan punct http dump para dup Total artifacts
RDTextract 99.3 0 0 0 0 0
html2text 96.2 1 148 70 2 373 2 592
trafilatura 96.1 18 780 10 309 1 117
markdownify 85.5 0 246 107 5 042 5 395

RDTextract wins on quality AND on all 4 artifact metrics — zero artifacts on every counter. Reproducible: see benchmark/ — domain list is committed (Tranco, deterministic), HTML cache is .gitignored.

Validated by human review

On 4 randomly-sampled pages (Université Paris Cité, Sport 2000, Élysée.fr, Polytechnique), reading the markdown outputs side-by-side confirms:

  • trafilatura misses entire structured sections (cards, lists) and duplicates paragraphs ("Découvrir le campus" ×2, legal disclaimers ×6)
  • html2text captures everything but the first 1000-2000 chars are systematically the navigation mega-menu
  • markdownify dumps everything raw (often >100 KB per page, unusable for LLM training)
  • RDTextract captures the structured cards trafilatura misses, drops the menu nav html2text keeps, and dedupes what trafilatura repeats

Use python benchmark/compare_outputs.py to generate side-by-side comparisons on your own pages.

Performance

Measured on the full 672-page benchmark (single pass; figures are machine-dependent):

  • ~115 ms/page with the [fast] extra (lxml backend)
  • ~135 ms/page without lxml (pure-Python html.parser) — only ~1.2× slower, because most of the time is the Markdown walker, not the parser
  • Roughly 3× slower than html2text (~40 ms/page): we filter + walk + dedup far more aggressively, but produce zero artifacts vs html2text's 2 592.

API

extract(html: str) -> str

Convenience: to_markdown(clean_html(html)). The main entry point.

clean_html(html: str) -> str

Strip nav/footer/scripts/ads/hidden elements. Drops responsive duplicates (mobile/desktop variants), icon font ligatures, role-based junk (role=navigation, role=banner, …). Preserves <script type="application/ld+json"> for structured data fallback.

Smart guards built in:

  • Auto-falls back from lxml to html.parser when malformed HTML over-nests content into a single <header>/<nav>.
  • Size-guard preserves large <header>/<nav>/<div> elements that match junk patterns but contain real content (e.g. WordPress with-nav modifier, Drupal region-header wrappers, React 18 <div hidden> Suspense boundaries).

to_markdown(cleaned_html: str) -> str

Walk the cleaned tree and emit Markdown. Includes:

  • Heading levels, lists (with sublist flattening), tables (colspan, nested, layout-table fix), code blocks, blockquotes, definition lists.
  • Post-processing: collapse whitespace, restore breadcrumb separators, dedup consecutive blocks, dedup global blocks (UI widgets repeated ≥3×), strip standalone URL lines, strip orphan punctuation.
  • Fallback chain for empty body:
    1. JSON-LD structured data (Article, NewsArticle, BlogPosting, Recipe, FAQPage, HowTo, Product, with @graph wrapper support)
    2. Meta fallback (<title> + meta description, with smart length thresholds)

is_low_value_stub(markdown: str, language: str | None = None) -> bool

True if the markdown is a paywall, login stub, skip-link, or empty page (no LLM training value). Combined length + marker check (avoids false positives on real articles that happen to mention subscription words in passing).

Supported languages for marker detection: fr, en, es, de, it. Pass language=None (default) to check all supported languages.

rdtextract.is_low_value_stub(md)                    # check all languages
rdtextract.is_low_value_stub(md, language="en")     # only EN markers

PARSER

Module-level constant exposing the parser actually in use: 'lxml' if installed, otherwise 'html.parser'.

import rdtextract
print(rdtextract.PARSER)   # → 'lxml' or 'html.parser'

Architecture

Two stages, ~700 lines of pure Python total:

HTML raw
   ↓
HTMLCleaner.clean_html()   ── strip 90% of DOM (nav, footer, ads, scripts, hidden)
   ↓
Cleaned HTML
   ↓
MarkdownConverter.to_markdown()   ── custom walker emitting clean Markdown
   ↓
Markdown
   ↓
[optional] is_low_value_stub()   ── filter paywalls / login / empty
   ↓
LLM training corpus

Custom walker (no markdownify dependency) avoids ~50% of buggy edge cases that wrap-libraries inherit.

Testing & benchmarking

pip install -e ".[dev,benchmark]"
pytest -q                          # 48 tests
python benchmark/fetch.py --top 1000   # download HTML cache
python benchmark/run.py            # 4-extractor comparison
python benchmark/find_outliers.py  # find pages where extraction is suspect
python benchmark/compare_outputs.py --samples 10   # side-by-side review

The benchmark/ directory is reproducible: the Tranco domain list is committed, the HTML cache is .gitignored (re-fetched on first run, ~30 min for top-1000).

License

MIT — see LICENSE.

Author

Théo CHARLET — extracted from the RDTvlokip Search crawler stack.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rdtextract-0.3.0.tar.gz (31.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rdtextract-0.3.0-py3-none-any.whl (26.2 kB view details)

Uploaded Python 3

File details

Details for the file rdtextract-0.3.0.tar.gz.

File metadata

  • Download URL: rdtextract-0.3.0.tar.gz
  • Upload date:
  • Size: 31.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.4

File hashes

Hashes for rdtextract-0.3.0.tar.gz
Algorithm Hash digest
SHA256 435c9c0a208fa9a61f1455e31ca02f3ebcdd14277d72af46e046ac7a5a1dab7b
MD5 03fe60fb82640aa99c5a428e5ffc27b1
BLAKE2b-256 6451161e48575de75e603aa3d422d934a31af00c769a2211c94cee063db6a273

See more details on using hashes here.

File details

Details for the file rdtextract-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: rdtextract-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 26.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.4

File hashes

Hashes for rdtextract-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 00016f835709c7e5507a70c5d647864e6c9d7ad2f25640905225617ba2509445
MD5 3afc96477e79c14a7e865257e0714f3c
BLAKE2b-256 b922c0c0529fa5848850d7b96cea122fd368623324fd02bd2c9627a0f3ef5bb1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page