From ba0048c38e4148dccbb2a1f44ba3c68895a18fad Mon Sep 17 00:00:00 2001 From: Ha1baraA11 <243105435+Ha1baraA11@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:55:51 +0800 Subject: [PATCH 1/2] fix(content filter): preserve inline text during pruning --- crawl4ai/content_filter_strategy.py | 70 ++++++++++++++++++++++++ crawl4ai/content_filter_strategy_lxml.py | 9 ++- tests/async/test_content_filter_prune.py | 32 +++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/crawl4ai/content_filter_strategy.py b/crawl4ai/content_filter_strategy.py index 24634ac32..f6c5405f7 100644 --- a/crawl4ai/content_filter_strategy.py +++ b/crawl4ai/content_filter_strategy.py @@ -31,6 +31,67 @@ from .async_logger import AsyncLogger, LogLevel, LogColor +# Phrasing elements carry inline meaning within a block. Their text density is +# not useful on its own: nested tags can make a short span look like noise even +# when the containing paragraph is retained. +_PRUNING_INLINE_TAGS = frozenset( + { + "a", + "abbr", + "audio", + "b", + "bdi", + "bdo", + "br", + "button", + "canvas", + "cite", + "code", + "data", + "datalist", + "del", + "dfn", + "em", + "embed", + "i", + "iframe", + "img", + "input", + "ins", + "kbd", + "label", + "map", + "mark", + "math", + "meter", + "noscript", + "object", + "output", + "picture", + "progress", + "q", + "ruby", + "s", + "samp", + "script", + "select", + "slot", + "small", + "span", + "strong", + "sub", + "sup", + "svg", + "textarea", + "time", + "u", + "var", + "video", + "wbr", + } +) + + class RelevantContentFilter(ABC): """Abstract base class for content filtering strategies""" @@ -726,6 +787,15 @@ def _prune_tree(self, node): if self._is_preserved(node): return + # Inline nodes inherit the keep/remove decision of their containing + # block. Scoring a short nested span or link independently can remove + # words from a paragraph that otherwise passes the filter. + if node.name in _PRUNING_INLINE_TAGS: + children = [child for child in node.children if hasattr(child, "name")] + for child in children: + self._prune_tree(child) + return + text_len = len(node.get_text(strip=True)) tag_len = len(node.encode_contents().decode("utf-8")) link_text_len = sum( diff --git a/crawl4ai/content_filter_strategy_lxml.py b/crawl4ai/content_filter_strategy_lxml.py index 24671945b..ee7e228b7 100644 --- a/crawl4ai/content_filter_strategy_lxml.py +++ b/crawl4ai/content_filter_strategy_lxml.py @@ -55,7 +55,7 @@ from lxml import etree from lxml import html as lhtml -from .content_filter_strategy import PruningContentFilter +from .content_filter_strategy import PruningContentFilter, _PRUNING_INLINE_TAGS # HTML void elements, taken verbatim from BeautifulSoup's LXMLTreeBuilder # ``empty_element_tags``. These serialize self-closed (``
``) with no end @@ -312,7 +312,12 @@ def _outer_len(self, el, inner_len: int) -> int: # ------------------------------------------------------------------ # # Scoring (top-down pass) # ------------------------------------------------------------------ # - def _should_remove(self, node, m: _NodeMetrics, metrics: Dict[object, _NodeMetrics]) -> bool: + def _should_remove( + self, node, m: _NodeMetrics, metrics: Dict[object, _NodeMetrics] + ) -> bool: + if node.tag in _PRUNING_INLINE_TAGS: + return False + text_len = m.text_len tag_len = m.inner_len diff --git a/tests/async/test_content_filter_prune.py b/tests/async/test_content_filter_prune.py index 1f75a9e1e..303ec5d92 100644 --- a/tests/async/test_content_filter_prune.py +++ b/tests/async/test_content_filter_prune.py @@ -5,6 +5,7 @@ sys.path.append(parent_dir) from crawl4ai.content_filter_strategy import PruningContentFilter +from crawl4ai.content_filter_strategy_lxml import PruningContentFilterLXML @pytest.fixture @@ -63,7 +64,38 @@ def mixed_content_html(): """ +@pytest.fixture +def nested_inline_html(): + return """
+

After the Meiji Restoration, the imperial capital in Kyoto was moved to Edo, and the city was renamed Tokyo + (lit. ' + Eastern Capital'). + The city was greatly damaged by the 1923 earthquake and rebuilt in the following decades.

+

Tokyo (東京) is the capital of Japan + and one of the most populous cities in the world, with a population of about fourteen million.

+
""" + + class TestPruningContentFilter: + @pytest.mark.filterwarnings("ignore::DeprecationWarning") + def test_pruning_preserves_nested_inline_content_in_kept_paragraphs( + self, nested_inline_html + ): + contents = PruningContentFilter().filter_content(nested_inline_html) + actual = " ".join(contents) + + assert "Eastern Capital" in actual + assert "東京" in actual + + def test_lxml_pruning_preserves_nested_inline_content_in_kept_paragraphs( + self, nested_inline_html + ): + contents = PruningContentFilterLXML().filter_content(nested_inline_html) + actual = " ".join(contents) + + assert "Eastern Capital" in actual + assert "東京" in actual + def test_basic_pruning(self, basic_html): """Test basic content pruning functionality""" filter = PruningContentFilter(min_word_threshold=5) From 40927b95eab50610d1c5776a35f9b1e5304ff091 Mon Sep 17 00:00:00 2001 From: Ha1baraA11 <243105435+Ha1baraA11@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:03:25 +0800 Subject: [PATCH 2/2] fix(content filter): narrow inline pruning tags --- crawl4ai/content_filter_strategy.py | 182 ++++++++++------------- crawl4ai/content_filter_strategy_lxml.py | 8 +- tests/async/test_content_filter_prune.py | 18 +-- 3 files changed, 85 insertions(+), 123 deletions(-) diff --git a/crawl4ai/content_filter_strategy.py b/crawl4ai/content_filter_strategy.py index f6c5405f7..eadb4f430 100644 --- a/crawl4ai/content_filter_strategy.py +++ b/crawl4ai/content_filter_strategy.py @@ -31,67 +31,6 @@ from .async_logger import AsyncLogger, LogLevel, LogColor -# Phrasing elements carry inline meaning within a block. Their text density is -# not useful on its own: nested tags can make a short span look like noise even -# when the containing paragraph is retained. -_PRUNING_INLINE_TAGS = frozenset( - { - "a", - "abbr", - "audio", - "b", - "bdi", - "bdo", - "br", - "button", - "canvas", - "cite", - "code", - "data", - "datalist", - "del", - "dfn", - "em", - "embed", - "i", - "iframe", - "img", - "input", - "ins", - "kbd", - "label", - "map", - "mark", - "math", - "meter", - "noscript", - "object", - "output", - "picture", - "progress", - "q", - "ruby", - "s", - "samp", - "script", - "select", - "slot", - "small", - "span", - "strong", - "sub", - "sup", - "svg", - "textarea", - "time", - "u", - "var", - "video", - "wbr", - } -) - - class RelevantContentFilter(ABC): """Abstract base class for content filtering strategies""" @@ -712,6 +651,40 @@ def __init__( "h6": 0.7, } + # Phrasing elements keep the parent block's decision. Expose this on + # each filter so callers can customize which tags inherit that rule. + self.inline_tags = frozenset( + { + "a", + "abbr", + "b", + "bdi", + "bdo", + "cite", + "code", + "data", + "del", + "dfn", + "em", + "i", + "ins", + "kbd", + "mark", + "q", + "ruby", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "sup", + "time", + "u", + "var", + } + ) + def filter_content(self, html: str, min_word_threshold: int = None) -> List[str]: """ Implements content filtering using pruning algorithm with dynamic threshold. @@ -787,56 +760,51 @@ def _prune_tree(self, node): if self._is_preserved(node): return - # Inline nodes inherit the keep/remove decision of their containing - # block. Scoring a short nested span or link independently can remove - # words from a paragraph that otherwise passes the filter. - if node.name in _PRUNING_INLINE_TAGS: - children = [child for child in node.children if hasattr(child, "name")] - for child in children: - self._prune_tree(child) - return - - text_len = len(node.get_text(strip=True)) - tag_len = len(node.encode_contents().decode("utf-8")) - link_text_len = sum( - len(s.strip()) - for s in (a.string for a in node.find_all("a", recursive=False)) - if s - ) - - metrics = { - "node": node, - "tag_name": node.name, - "text_len": text_len, - "tag_len": tag_len, - "link_text_len": link_text_len, - } - - score = self._compute_composite_score(metrics, text_len, tag_len, link_text_len) + if node.name not in self.inline_tags: + text_len = len(node.get_text(strip=True)) + tag_len = len(node.encode_contents().decode("utf-8")) + link_text_len = sum( + len(s.strip()) + for s in (a.string for a in node.find_all("a", recursive=False)) + if s + ) - if self.threshold_type == "fixed": - should_remove = score < self.threshold - else: # dynamic - tag_importance = self.tag_importance.get(node.name, 0.7) - text_ratio = text_len / tag_len if tag_len > 0 else 0 - link_ratio = link_text_len / text_len if text_len > 0 else 1 + metrics = { + "node": node, + "tag_name": node.name, + "text_len": text_len, + "tag_len": tag_len, + "link_text_len": link_text_len, + } - threshold = self.threshold # base threshold - if tag_importance > 1: - threshold *= 0.8 - if text_ratio > 0.4: - threshold *= 0.9 - if link_ratio > 0.6: - threshold *= 1.2 + score = self._compute_composite_score( + metrics, text_len, tag_len, link_text_len + ) - should_remove = score < threshold + if self.threshold_type == "fixed": + should_remove = score < self.threshold + else: # dynamic + tag_importance = self.tag_importance.get(node.name, 0.7) + text_ratio = text_len / tag_len if tag_len > 0 else 0 + link_ratio = link_text_len / text_len if text_len > 0 else 1 + + threshold = self.threshold # base threshold + if tag_importance > 1: + threshold *= 0.8 + if text_ratio > 0.4: + threshold *= 0.9 + if link_ratio > 0.6: + threshold *= 1.2 + + should_remove = score < threshold + + if should_remove: + node.decompose() + return - if should_remove: - node.decompose() - else: - children = [child for child in node.children if hasattr(child, "name")] - for child in children: - self._prune_tree(child) + children = [child for child in node.children if hasattr(child, "name")] + for child in children: + self._prune_tree(child) def _compute_composite_score(self, metrics, text_len, tag_len, link_text_len): """Computes the composite score""" diff --git a/crawl4ai/content_filter_strategy_lxml.py b/crawl4ai/content_filter_strategy_lxml.py index ee7e228b7..f7a4eba86 100644 --- a/crawl4ai/content_filter_strategy_lxml.py +++ b/crawl4ai/content_filter_strategy_lxml.py @@ -55,7 +55,7 @@ from lxml import etree from lxml import html as lhtml -from .content_filter_strategy import PruningContentFilter, _PRUNING_INLINE_TAGS +from .content_filter_strategy import PruningContentFilter # HTML void elements, taken verbatim from BeautifulSoup's LXMLTreeBuilder # ``empty_element_tags``. These serialize self-closed (``
``) with no end @@ -312,10 +312,8 @@ def _outer_len(self, el, inner_len: int) -> int: # ------------------------------------------------------------------ # # Scoring (top-down pass) # ------------------------------------------------------------------ # - def _should_remove( - self, node, m: _NodeMetrics, metrics: Dict[object, _NodeMetrics] - ) -> bool: - if node.tag in _PRUNING_INLINE_TAGS: + def _should_remove(self, node, m: _NodeMetrics, metrics: Dict[object, _NodeMetrics]) -> bool: + if node.tag in self.inline_tags: return False text_len = m.text_len diff --git a/tests/async/test_content_filter_prune.py b/tests/async/test_content_filter_prune.py index 303ec5d92..f17ffd541 100644 --- a/tests/async/test_content_filter_prune.py +++ b/tests/async/test_content_filter_prune.py @@ -77,20 +77,16 @@ def nested_inline_html(): class TestPruningContentFilter: + @pytest.mark.parametrize( + "filter_class", + [PruningContentFilter, PruningContentFilterLXML], + ids=["bs4", "lxml"], + ) @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_pruning_preserves_nested_inline_content_in_kept_paragraphs( - self, nested_inline_html - ): - contents = PruningContentFilter().filter_content(nested_inline_html) - actual = " ".join(contents) - - assert "Eastern Capital" in actual - assert "東京" in actual - - def test_lxml_pruning_preserves_nested_inline_content_in_kept_paragraphs( - self, nested_inline_html + self, nested_inline_html, filter_class ): - contents = PruningContentFilterLXML().filter_content(nested_inline_html) + contents = filter_class().filter_content(nested_inline_html) actual = " ".join(contents) assert "Eastern Capital" in actual