Skip to content

Commit 6be0889

Browse files
committed
fix: preserve tail text when remove_empty_elements_fast removes an element
In lxml, `el.tail` stores the text that follows an element in the parent's serialised output. Calling `parent.remove(el)` silently discards that text. Before this fix, `<div><span></span>trailing text</div>` would produce `<div></div>` after the span was removed — the trailing text was lost. The fix rescues `el.tail` before removal: - if a previous sibling exists, the tail is appended to `prev.tail` - otherwise it is prepended to `parent.text` This matches the pattern already used in the same file when removing `<script>` and `<style>` elements (see lines 734–747). Fixes #1938
1 parent 67376d3 commit 6be0889

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

crawl4ai/content_scraping_strategy.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,18 @@ def remove_empty_elements_fast(self, root, word_count_threshold=5):
562562
):
563563
parent = el.getparent()
564564
if parent is not None:
565+
# Preserve the element's tail text before removing it.
566+
# In lxml, .tail holds the text that follows the element
567+
# in the parent's serialisation. Calling parent.remove(el)
568+
# without rescuing the tail silently drops that text.
569+
# See https://github.com/unclecode/crawl4ai/issues/1938
570+
tail = el.tail
571+
if tail:
572+
prev = el.getprevious()
573+
if prev is not None:
574+
prev.tail = (prev.tail or "") + tail
575+
else:
576+
parent.text = (parent.text or "") + tail
565577
parent.remove(el)
566578

567579
return root
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Regression tests for https://github.com/unclecode/crawl4ai/issues/1938
2+
3+
remove_empty_elements_fast() must preserve the .tail text of removed elements.
4+
5+
In lxml, when an element is removed via parent.remove(el), any trailing text
6+
stored in el.tail is silently discarded. The fix rescues that text by
7+
appending it to the previous sibling's tail (or the parent's text).
8+
"""
9+
10+
from lxml import html as lhtml
11+
12+
13+
def _get_remove_empty_elements_fast():
14+
"""Extract remove_empty_elements_fast without importing the full crawl4ai package."""
15+
import sys
16+
import types
17+
18+
# Stub the crawl4ai package and its transitive sub-modules so we can load
19+
# content_scraping_strategy.py without needing playwright, OpenSSL, etc.
20+
stubs = [
21+
"crawl4ai",
22+
"crawl4ai.async_logger",
23+
"crawl4ai.config",
24+
"crawl4ai.models",
25+
"crawl4ai.utils",
26+
"crawl4ai.content_filter_strategy",
27+
"crawl4ai.extraction_strategy",
28+
"crawl4ai.le",
29+
"crawl4ai.le.legacy",
30+
"crawl4ai.le.legacy.model_loader",
31+
]
32+
for mod_name in stubs:
33+
if mod_name not in sys.modules:
34+
m = types.ModuleType(mod_name)
35+
if "." in mod_name:
36+
m.__path__ = [] # type: ignore[attr-defined]
37+
sys.modules[mod_name] = m
38+
39+
# -- crawl4ai.config symbols --
40+
config_mod = sys.modules["crawl4ai.config"]
41+
config_mod.MIN_WORD_THRESHOLD = 5 # type: ignore[attr-defined]
42+
config_mod.IMAGE_DESCRIPTION_MIN_WORD_THRESHOLD = 5 # type: ignore[attr-defined]
43+
config_mod.IMAGE_SCORE_THRESHOLD = 0.5 # type: ignore[attr-defined]
44+
config_mod.ONLY_TEXT_ELIGIBLE_TAGS = set() # type: ignore[attr-defined]
45+
config_mod.IMPORTANT_ATTRS = [] # type: ignore[attr-defined]
46+
config_mod.SOCIAL_MEDIA_DOMAINS = set() # type: ignore[attr-defined]
47+
48+
# -- crawl4ai.utils symbols --
49+
utils_mod = sys.modules["crawl4ai.utils"]
50+
for attr in [
51+
"extract_metadata",
52+
"normalize_url",
53+
"is_external_url",
54+
"get_base_domain",
55+
"extract_metadata_using_lxml",
56+
"extract_page_context",
57+
"calculate_link_intrinsic_score",
58+
]:
59+
setattr(utils_mod, attr, lambda *a, **kw: None) # type: ignore[attr-defined]
60+
61+
# -- crawl4ai.models symbols --
62+
models_mod = sys.modules["crawl4ai.models"]
63+
for attr in ["ScrapingResult", "MediaItem", "Link", "Media", "Links"]:
64+
setattr(models_mod, attr, object) # type: ignore[attr-defined]
65+
66+
import importlib.util
67+
import pathlib
68+
69+
# Remove any stale cached module from a previous test run.
70+
sys.modules.pop("crawl4ai.content_scraping_strategy", None)
71+
72+
path = pathlib.Path(__file__).parent.parent.parent / "crawl4ai" / "content_scraping_strategy.py"
73+
spec = importlib.util.spec_from_file_location("crawl4ai.content_scraping_strategy", path)
74+
assert spec and spec.loader
75+
mod = importlib.util.module_from_spec(spec)
76+
mod.__package__ = "crawl4ai"
77+
spec.loader.exec_module(mod) # type: ignore[union-attr]
78+
79+
assert hasattr(mod, "LXMLWebScrapingStrategy"), (
80+
"LXMLWebScrapingStrategy not found — check stubs above"
81+
)
82+
return mod.LXMLWebScrapingStrategy.remove_empty_elements_fast
83+
84+
85+
_fn = _get_remove_empty_elements_fast()
86+
87+
88+
def _call(root, word_count_threshold=1):
89+
"""Invoke the unbound method with a placeholder self=None."""
90+
_fn(None, root, word_count_threshold=word_count_threshold)
91+
92+
93+
def _parse(html_fragment: str) -> lhtml.HtmlElement:
94+
"""Parse an HTML fragment and return the root element."""
95+
return lhtml.fragment_fromstring(html_fragment)
96+
97+
98+
def test_tail_after_removed_element_is_preserved():
99+
"""Text following a removed empty element must not be dropped.
100+
101+
HTML: <div><span></span>trailing text</div>
102+
The <span> has no text content — it will be removed.
103+
"trailing text" lives in span.tail and must survive.
104+
"""
105+
root = _parse("<div><span></span>trailing text</div>")
106+
_call(root)
107+
result = lhtml.tostring(root, encoding="unicode")
108+
assert "trailing text" in result, (
109+
f"remove_empty_elements_fast() dropped the tail text. HTML: {result!r}"
110+
)
111+
112+
113+
def test_tail_appended_to_previous_sibling():
114+
"""When a non-first empty element is removed, its tail joins the previous sibling."""
115+
# <div><b>kept</b><span></span> and this text</div>
116+
root = _parse("<div><b>kept</b><span></span> and this text</div>")
117+
_call(root)
118+
result = lhtml.tostring(root, encoding="unicode")
119+
assert "and this text" in result, (
120+
f"Tail text after removed element was dropped. HTML: {result!r}"
121+
)
122+
assert "<span>" not in result, "Empty <span> should have been removed"
123+
124+
125+
def test_tail_merged_into_parent_text_when_first_child():
126+
"""When the first child is removed, its tail merges into the parent's text."""
127+
# <div><span></span>after first child</div> (no previous sibling)
128+
root = _parse("<div><span></span>after first child</div>")
129+
_call(root)
130+
result = lhtml.tostring(root, encoding="unicode")
131+
assert "after first child" in result, (
132+
f"Tail of first child was dropped after removal. HTML: {result!r}"
133+
)

0 commit comments

Comments
 (0)