diff --git a/src/crawlee/_utils/html_to_text.py b/src/crawlee/_utils/html_to_text.py index 804b55f464..0558de2fed 100644 --- a/src/crawlee/_utils/html_to_text.py +++ b/src/crawlee/_utils/html_to_text.py @@ -31,6 +31,4 @@ 'option', } -_EMPTY_OR_ENDS_WITH_ANY_WHITE_SPACE = re.compile(r'(^|\s)$') -_EMPTY_OR_ENDS_WITH_NEW_LINE = re.compile(r'(^|\n)$') _ANY_CONSECUTIVE_WHITE_SPACES = re.compile(r'\s+') diff --git a/src/crawlee/crawlers/_beautifulsoup/_utils.py b/src/crawlee/crawlers/_beautifulsoup/_utils.py index 53922f84ab..afaf57200c 100644 --- a/src/crawlee/crawlers/_beautifulsoup/_utils.py +++ b/src/crawlee/crawlers/_beautifulsoup/_utils.py @@ -5,13 +5,7 @@ from bs4 import BeautifulSoup, NavigableString, PageElement, Tag -from crawlee._utils.html_to_text import ( - _ANY_CONSECUTIVE_WHITE_SPACES, - _EMPTY_OR_ENDS_WITH_ANY_WHITE_SPACE, - _EMPTY_OR_ENDS_WITH_NEW_LINE, - BLOCK_TAGS, - SKIP_TAGS, -) +from crawlee._utils.html_to_text import _ANY_CONSECUTIVE_WHITE_SPACES, BLOCK_TAGS, SKIP_TAGS if TYPE_CHECKING: from collections.abc import Iterable @@ -33,7 +27,15 @@ def html_to_text(source: str | Tag) -> str: else: raise TypeError('Source must be either a string or a `BeautifulSoup` object.') - text = '' + parts: list[str] = [] + # Last emitted character, so the whitespace checks never have to read the growing output back. + last = '' + + def push(part: str) -> None: + nonlocal last + if part: + parts.append(part) + last = part[-1] def _page_element_to_text(page_elements: Iterable[PageElement]) -> None: """Extract and process text content from a collection of HTML elements. @@ -41,7 +43,6 @@ def _page_element_to_text(page_elements: Iterable[PageElement]) -> None: Convert page elements into plain text while preserving structure. Handle whitespace compression, skip unwanted elements, and format block elements correctly. """ - nonlocal text for page_element in page_elements: if isinstance(page_element, (Tag, NavigableString)): if isinstance(page_element, NavigableString): @@ -52,26 +53,26 @@ def _page_element_to_text(page_elements: Iterable[PageElement]) -> None: # Compress white spaces outside of pre block compr = re.sub(_ANY_CONSECUTIVE_WHITE_SPACES, ' ', page_element.get_text()) # If text is empty or ends with a whitespace, don't add the leading whitespace or new line - if (compr.startswith((' ', '\n'))) and re.search(_EMPTY_OR_ENDS_WITH_ANY_WHITE_SPACE, text): + if (compr.startswith((' ', '\n'))) and (not last or last.isspace()): compr = compr[1:] - text += compr + push(compr) elif page_element.name.lower() in SKIP_TAGS: # Skip comments and special elements pass elif page_element.name.lower() == 'br': - text += '\n' + push('\n') elif page_element.name.lower() == 'td': _page_element_to_text(page_element.children) - text += '\t' + push('\t') else: # Block elements must be surrounded by newlines(unless beginning of text) is_block_tag = page_element.name.lower() in BLOCK_TAGS - if is_block_tag and not re.search(_EMPTY_OR_ENDS_WITH_NEW_LINE, text): - text += '\n' + if is_block_tag and last and last != '\n': + push('\n') _page_element_to_text(page_element.children) - if is_block_tag and not text.endswith('\n'): - text += '\n' + if is_block_tag and last != '\n': + push('\n') _page_element_to_text(soup.children) - return text.strip() + return ''.join(parts).strip() diff --git a/src/crawlee/crawlers/_parsel/_utils.py b/src/crawlee/crawlers/_parsel/_utils.py index 19706b7564..7ca143d5ff 100644 --- a/src/crawlee/crawlers/_parsel/_utils.py +++ b/src/crawlee/crawlers/_parsel/_utils.py @@ -4,13 +4,7 @@ from parsel import Selector -from crawlee._utils.html_to_text import ( - _ANY_CONSECUTIVE_WHITE_SPACES, - _EMPTY_OR_ENDS_WITH_ANY_WHITE_SPACE, - _EMPTY_OR_ENDS_WITH_NEW_LINE, - BLOCK_TAGS, - SKIP_TAGS, -) +from crawlee._utils.html_to_text import _ANY_CONSECUTIVE_WHITE_SPACES, BLOCK_TAGS, SKIP_TAGS def html_to_text(source: str | Selector) -> str: @@ -29,7 +23,15 @@ def html_to_text(source: str | Selector) -> str: else: raise TypeError('Source must be either a string or a `Selector` object.') - text = '' + parts: list[str] = [] + # Last emitted character, so the whitespace checks never have to read the growing output back. + last = '' + + def push(part: str) -> None: + nonlocal last + if part: + parts.append(part) + last = part[-1] def _extract_text(elements: list[Selector], *, compress: bool = True) -> None: """Extract text content from HTML elements while preserving formatting. @@ -41,7 +43,6 @@ def _extract_text(elements: list[Selector], *, compress: bool = True) -> None: elements: A list of selectors representing the HTML elements. compress: Whether to compress consecutive whitespace outside of `
` blocks.
         """
-        nonlocal text
         for element in elements:
             tag = element.root.tag if hasattr(element.root, 'tag') else None
 
@@ -49,30 +50,30 @@ def _extract_text(elements: list[Selector], *, compress: bool = True) -> None:
                 # Compress white spaces outside of pre block
                 compr = re.sub(_ANY_CONSECUTIVE_WHITE_SPACES, ' ', element.root) if compress else element.root
                 # If text is empty or ends with a whitespace, don't add the leading whitespace or new line
-                if (compr.startswith((' ', '\n'))) and re.search(_EMPTY_OR_ENDS_WITH_ANY_WHITE_SPACE, text):
+                if (compr.startswith((' ', '\n'))) and (not last or last.isspace()):
                     compr = compr[1:]
-                text += compr
+                push(compr)
 
             if tag in SKIP_TAGS or not isinstance(tag, str):
                 continue
 
             if tag == 'br':
-                text += '\n'
+                push('\n')
             elif tag == 'td':
                 _extract_text(element.xpath('./node()'))
-                text += '\t'
+                push('\t')
             else:
                 is_block_tag = tag in BLOCK_TAGS if tag else False
 
-                if is_block_tag and not re.search(_EMPTY_OR_ENDS_WITH_NEW_LINE, text):
-                    text += '\n'
+                if is_block_tag and last and last != '\n':
+                    push('\n')
 
                 _extract_text(element.xpath('./node()'), compress=tag != 'pre')
 
-                if is_block_tag and not text.endswith('\n'):
-                    text += '\n'
+                if is_block_tag and last != '\n':
+                    push('\n')
 
     # Start processing the root elements
     _extract_text(selector.xpath('/*'))
 
-    return text.strip()
+    return ''.join(parts).strip()
diff --git a/tests/unit/_utils/test_html_to_text.py b/tests/unit/_utils/test_html_to_text.py
index f49feb5b9a..ccaf7c2479 100644
--- a/tests/unit/_utils/test_html_to_text.py
+++ b/tests/unit/_utils/test_html_to_text.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import time
 from typing import TYPE_CHECKING
 
 import pytest
@@ -201,3 +202,23 @@ def test_html_to_text_parsel() -> None:
 
 def test_html_to_text_beautifulsoup() -> None:
     assert html_to_text_beautifulsoup(BeautifulSoup(_EXAMPLE_HTML, features='lxml')) == _EXPECTED_TEXT
+
+
+@pytest.mark.parametrize('html_to_text', [html_to_text_parsel, html_to_text_beautifulsoup])
+def test_html_to_text_is_linear_in_node_count(html_to_text: Callable[[str], str]) -> None:
+    """Converting four times as many sibling nodes takes about four times as long, not sixteen times."""
+
+    def convert(repeats: int) -> float:
+        source = '
' + ' a' * repeats + '
' + started = time.perf_counter() + text = html_to_text(source) + elapsed = time.perf_counter() - started + assert text == ' '.join(['a'] * repeats) + return elapsed + + # Warm up the parser so one-off setup costs do not skew the small run. + convert(1000) + small = convert(20_000) + large = convert(80_000) + + assert large / small < 10