From lxml-html-clean¶
lxml-html-clean is the Cleaner that used to live in
lxml.html.clean, split into its own package when lxml dropped the module. It cleans HTML by blocklist: you
toggle off categories of dangerous content (scripts, javascript, style, comments, embedded,
frames, forms, meta, annoying_tags, …) and everything the toggles do not name survives. A tag the
library has never heard of passes through untouched. Cleaner runs in Python over an lxml element tree parsed by
libxml2, so it inherits lxml’s runtime dependency and its non-WHATWG HTML parser. It is the standard choice for projects
already built on lxml that need to scrub user HTML in place.
turbohtml.clean covers the same ground from the other direction: sanitize() filters against
an allowlist driven by a Policy, parses with the WHATWG algorithm browsers use, and runs
the filtering walk in C.
turbohtml vs lxml-html-clean¶
Dimension |
turbohtml |
lxml-html-clean |
|---|---|---|
Scope |
Allowlist HTML sanitizer, plus linkify and HTML/CSS minify in one module |
Blocklist |
Feature breadth |
Policy presets ( |
~20 category toggles, |
Performance |
C filtering walk; order of magnitude faster (see below) |
Python traversal over a libxml2-parsed lxml tree |
Typing |
Fully type annotated |
No type annotations shipped |
Dependencies |
Self-contained C extension, no runtime dependencies |
Requires lxml (libxml2 / libxslt) |
Maintenance |
Actively developed |
Community-maintained successor to the removed |
Feature overlap¶
The shared surface ports directly:
Strip scripting:
Cleaner(scripts=True, javascript=True)becomes the non-overridable safety baseline that removes script elements, event-handler attributes, andjavascript:URLs on every policy.Restrict the kept tag set:
allow_tags=maps toPolicytags.Drop an element with its whole subtree:
kill_tags=maps toPolicy.remove_with_content.Drop comments:
comments=Truemaps toPolicy.strip_comments(on by default).Constrain attributes:
safe_attrs_only/safe_attrs=map to the per-tagPolicy.attributesallowlist.Force
reltokens on links:add_nofollow=Truemaps toPolicy.add_link_rel(e.g.frozenset({"nofollow"})).Remove forms, frames, and embedded media: leave those tags out of
Policy.tagsinstead of togglingforms,frames,embedded.Restrict embedded media to named hosts:
host_whitelist=maps toPolicy.media_hosts, which drops anaudio/video/source/tracksrcwhose URL host is not on the allowlist.
What turbohtml adds¶
Allowlist model: nothing survives unless the policy names it, so markup the author never anticipated cannot slip through a missing toggle.
WHATWG parsing: the input is parsed by the same algorithm browsers use, so the cleaned tree matches what a browser would build, not what libxml2’s HTML parser produces.
styleattribute scrubbing againstPolicy.css_properties, dropping any declaration whose property is not allowed.Policy.attribute_filter, a per-attribute callback that gets the last word on every surviving value, andPolicy.add_link_relto injectreltokens.Policy.on_disallowed_tag(OnDisallowed) to choose escape, strip, or remove for a tag outside the allowlist.Companion
linkify(),minify(), and value-safeminify_css()in the same module.
What lxml-html-clean has that turbohtml does not¶
In-place cleaning of an existing lxml element or subtree:
Cleanermutates the tree you already hold.sanitize()takes an HTML string and returns an HTML string, so there is no in-tree equivalent.annoying_tags: a named toggle for presentational cruft (blink,marquee). turbohtml has no dedicated flag; exclude those tags fromPolicy.tags(the allowlist already drops them by default).
Performance¶
turbohtml.clean runs the filtering walk in C rather than over an lxml tree, leading the blocklist cleaner by an
order of magnitude:
sanitize |
turbohtml |
|
|---|---|---|
comment |
1.57 µs |
20.4 µs (13.1x) |
post 4 KiB |
42.5 µs |
533 µs (12.6x) |
How to migrate¶
Swap the import and invert the model: instead of switching dangerous categories off, declare the small set you keep.
# lxml-html-clean: enumerate what to strip, keep the rest
from lxml_html_clean import Cleaner
Cleaner(scripts=True, javascript=True, comments=True, style=True, forms=True).clean_html(text)
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from turbohtml.clean import sanitize, Policy
print(
sanitize(
"<p>Hi<script>x()</script> <a href='javascript:1'>l</a></p>",
Policy(tags=frozenset({"p", "a"}), attributes={"a": frozenset({"href"})}),
)
)
<p>Hi<script>x()</script> <a>l</a></p>
The javascript: URL is gone because http/https/mailto are the only schemes the policy admits, and the
<script> is escaped rather than executed.
Gotchas and pitfalls¶
host_whitelistinCleanergatesiframe/embed; turbohtml escapes those framing elements outright (they are never kept), soPolicy.media_hostsinstead gates thesrcof the media elements turbohtml can keep –audio,video,source,track– dropping asrcwhose host is not on the lowercase allowlist.turbohtml scrubs a kept
styleattribute againstPolicy.css_propertiesbut drops<style>elements, whereCleanerscrubs their text and keeps the element.Cleanerrewrites a disallowed scheme to an emptyhref; turbohtml drops the attribute outright.A tag outside the allowlist is escaped by default (
OnDisallowed.ESCAPE), so<script>shows up as text rather than vanishing; passon_disallowed_tag=OnDisallowed.STRIPorREMOVEto matchCleaner’s removal behavior.turbohtml strips comments by default (
strip_comments=True);Cleanerkeeps them unless you setcomments=True.turbohtml parses per WHATWG, so malformed markup yields the tree a browser builds;
Cleanerparses with libxml2 and can produce a different structure for the same broken input.