From courlan¶
courlan is the URL cleaner and filter underneath trafilatura. It scrubs the
transport damage out of scraped URLs (clean_url/scrub_url), canonicalizes them for a crawl (normalize_url:
query sorting, tracker removal, a strict content-parameter allowlist, a path-based language filter), decides whether a
URL is worth fetching (check_url, is_navigation_page, spam and content-type heuristics), pulls the followable
links out of a page by regex (extract_links/filter_links), and manages a crawl frontier in memory
(UrlStore). It is used wherever trafilatura scrapes: web-corpus building, focused crawling, deduplicating a link
set before fetching.
turbohtml covers the URL-hygiene half of that surface in turbohtml.extract. Three functions – clean_url, normalize_url, extract_links – share one frozen UrlCleaning config, produce the
canonical form the WHATWG URL standard defines, and read links from the real parsed
DOM rather than a markup regex. The crawl-policy half (frontier management, spam and navigation heuristics, HTTP
probing) stays out of scope by design.
turbohtml vs courlan¶
Dimension |
turbohtml |
courlan |
|---|---|---|
Scope |
WHATWG HTML parser with a URL-hygiene module ( |
URL cleaning, filtering, and crawl-frontier management for |
Feature breadth |
clean, normalize, and link-extract over the parsed DOM, one frozen config |
clean/scrub/normalize/check, regex link extraction, |
Performance |
~2x per URL on cleaning, 1.4x-2.4x on link extraction including the parse (see below) |
baseline |
Typing |
fully typed, ships |
ships type hints |
Dependencies |
native C extension, no Python runtime deps |
pure Python, depends on |
Maintenance |
actively developed |
actively maintained under the |
Feature overlap¶
The URL-hygiene surface ports 1:1:
courlan.clean_url(url)->turbohtml.extract.clean_url(), withscrub_url’s markup-damage recovery folded in (whitespace and control-character stripping,<![CDATA[]]>unwrap,&un-escape, truncation at a stray</>/").courlan.normalize_url(url, strict=..., language=..., trailing_slash=...)->normalize_url()with aUrlCleaningconfig: query sorting, tracker removal, the strict content-parameter allowlist, trailing-slash folding.courlan.extract_links(page, url=..., external_bool=True, language=...)->extract_links()withexternal_only=True.courlan.is_external(url, reference)->external_only=onextract_links().The keyword-argument spread across courlan’s signatures collapses onto one immutable
UrlCleaning;UrlCleaning.w3libreproducesw3lib.url.canonicalize_url’s mode (drop the fragment, keep every non-tracker parameter).
What turbohtml adds¶
Link extraction reads anchors from the real WHATWG DOM, not a regex over the markup: links inside comments or scripts never leak in, a
<base href>is honored per HTML spec 4.2.3, and repeated navigation targets are cleaned once and deduplicated across theirhttp/httpsand trailing-slash twins.Output follows the WHATWG URL serializer, the form a browser’s address bar or an
hrefgetter returns (root slash on special URLs, host punycoded to ASCII, empty path segments preserved).query_allowandquery_denyonUrlCleaninggive a per-call keep-list and drop-list (w3lib.url.url_query_cleaner’s keep andremove=Truemodes), which courlan does not expose.external_onlysplits links on the registrable domain (eTLD+1), the same public-suffix boundary courlan reaches for through thetldpackage:spam.example.co.ukandexample.co.ukcount as one site,a.co.ukandb.co.ukas two. The suffix comes from the shipped IANA and Public Suffix List tables in C, so there is notlddependency and no Python fallback.One frozen config drives all three functions, no per-call keyword drift, and no Python runtime dependency.
What courlan has that turbohtml does not¶
Crawl-frontier management.
UrlStore(deduplicating, host-bucketed, visit-tracked in-memory URL storage) has no equivalent; turbohtml cleans URLs but does not manage a crawl. Keepcourlan.UrlStorefor that layer.Crawl-policy filters.
check_url’s content-type, spam/adult, and site-structure filters,is_navigation_page,is_not_crawlable,filter_links, and thewith_redirectsHTTP probe are out of scope. They are fetch policy, not URL hygiene; keep courlan where you need them.Content-based language scoring. courlan’s language filter can fall back to locale scoring of the content; turbohtml consults only URL-based markers (a leading path segment, a
lang/languagequery parameter, an anchor’shreflang, and in strict mode a language subdomain). No equivalent for the content-scoring path.Punycode-to-Unicode and slash collapsing. courlan decodes punycode hosts to Unicode and collapses repeated slashes; turbohtml keeps the WHATWG forms (ASCII host, empty segments preserved). No flag toggles this.
A URL-cleaning command line. courlan ships a CLI for its URL operations. turbohtml now ships
python -m turbohtml(minify, convert, sanitize, detect), but none of its subcommands clean URLs; run the extract API from a short script for that job.
Performance¶
clean_url() and normalize_url() produce the WHATWG canonical form (the
serialization a browser’s address bar would show) and layer courlan’s crawl canonicalization (query sorting, tracker
removal, the strict allowlist, the language filter) on top, about 2x faster per URL.
extract_links() reads anchors from the real WHATWG DOM instead of regex-scanning the markup, so
links inside comments or scripts never leak in, a <base href> is honored, and pages whose navigation repeats the
same targets clean each spelling once, 1.4x-2.4x ahead over real saved pages even with the parse in the loop:
clean and normalize 100 URLs |
turbohtml |
|
|---|---|---|
clean and normalize 100 URLs — clean 100 URLs |
262 µs |
526 µs (2.1x) |
clean and normalize 100 URLs — normalize 100 URLs |
216 µs |
404 µs (1.9x) |
extract filtered page links — daring fireball (10 kB) |
117 µs |
364 µs (3.2x) |
extract filtered page links — ars technica (56 kB) |
280 µs |
747 µs (2.7x) |
extract filtered page links — mozilla blog (95 kB) |
471 µs |
989 µs (2.1x) |
extract filtered page links — whatwg spec (235 kB) |
786 µs |
1.48 ms (1.9x) |
Over the 290 URLs in courlan’s own test suite the two libraries return identical output for 66% of inputs and agree up to the WHATWG root-slash serialization for 90%; every remaining divergence is deliberate and listed under Gotchas and pitfalls.
How to migrate¶
Swap the import: courlan’s free functions move to turbohtml.extract, and the keyword arguments courlan
spreads across signatures collapse onto UrlCleaning.
turbohtml |
|
|---|---|
|
|
|
folded into |
|
|
|
|
|
|
|
|
The keyword arguments courlan spreads across its functions live on one immutable config:
from turbohtml.extract import UrlCleaning, clean_url, normalize_url
print(clean_url(" https://www.Example.ORG:443/dir/../page?utm_source=rss&id=7&lang=en "))
print(normalize_url("http://test.net/foo?post=abc&page=2&session=x", UrlCleaning(strict=True)))
print(clean_url("https://example.org/de/beitrag", UrlCleaning(language="en")))
https://www.example.org/page?id=7&lang=en
http://test.net/foo?page=2&post=abc
None
Link extraction takes the page markup plus the URL it was fetched from, and returns a set of cleaned absolute URLs;
external_only=True keeps only the links that leave the site:
from turbohtml.extract import extract_links
page = '<a href="/about">about</a> <a href="https://other.example/x?fbclid=1">out</a>'
print(sorted(extract_links(page, "https://site.example/dir/")))
print(sorted(extract_links(page, "https://site.example/dir/", external_only=True)))
['https://other.example/x', 'https://site.example/about']
['https://other.example/x']
Gotchas and pitfalls¶
turbohtml follows the WHATWG URL standard where courlan makes its own choices. Each of these shows up in the 10% of courlan’s suite where outputs differ beyond the root slash:
Root slash. A host-only URL serializes as
http://test.org/(the URL standard’s serializer always emits the path of a special URL); courlan strips it tohttp://test.org.Punycode direction. A Unicode host converts to ASCII (
münchen.debecomesxn--mnchen-3ya.de, what a browser’shrefgetter returns); courlan decodes punycode to Unicode.Repeated slashes.
//404.htmlstays//404.html; the standard’s path parser preserves empty segments, and servers may distinguish them. courlan collapses them.Junk stays rejected.
http://1234orhttp://abreturnNonefromclean_url(); courlan’sclean_urlpasses them through and onlycheck_urlrejects.
Behavioral differences to watch when porting call sites:
extract_linksreturns every surviving link by default;external_only=Truerestricts to other sites. courlan’sexternal_boolflag instead splits the set:Falsemeans internal links only. Filter the result by host when you need the internal half.The site boundary for
external_onlyis the registrable domain (eTLD+1), the same public-suffix rule courlan applies through thetldpackage, sospam.example.co.ukandexample.co.ukare one site while sibling subdomains such asa.co.ukandb.co.ukare two.UrlCleaning(language=...)rejects inclean_url()andextract_links()but never innormalize_url(), which is total; courlan’snormalize_urlraisesValueErroron a language mismatch.turbohtml keeps a non-tracker fragment by default (
#page2, text fragments) like courlan, butUrlCleaning(strict=True)orstrip_fragment=Truedrops it; courlan couples fragment removal tostrictonly.
The content-type, spam/adult, navigation-page, and site-structure filters of check_url/filter_links, the
with_redirects HTTP probe, the domain blocklist, and the embedded-URL salvage of scrub_url (recovering a target
from a twitter.com/share?url=... wrapper) are out of scope: they are crawl policy, not URL hygiene. The language
filter is the URL-based subset (path segment, lang parameter, and in strict mode a language subdomain); courlan’s
fuller language filter, which can score the linked content, is not consulted.