|
| 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