Skip to content

fix(scraping): a nested <noscript> discards the entire page body - #2114

Open
Aitosoft wants to merge 1 commit into
unclecode:developfrom
Aitosoft:fix/noscript-swallows-body
Open

fix(scraping): a nested <noscript> discards the entire page body#2114
Aitosoft wants to merge 1 commit into
unclecode:developfrom
Aitosoft:fix/noscript-swallows-body

Conversation

@Aitosoft

Copy link
Copy Markdown

Summary

<noscript> may not nest. With scripting enabled its content is raw text, so a
parser never sees an inner <noscript> as an element and the outer element
is left unclosed — libxml2 then swallows every following node into it.
LXMLWebScrapingStrategy._scrap hands the raw serialised DOM straight to
lhtml.document_fromstring, so the damage is done before there is a tree that
could be repaired downstream.

A single WordPress lazy-load plugin that wraps the Google Tag Manager block in a
second <noscript> is enough to lose an entire page:

312,628 B rendered HTML  ->  97 B cleaned_html  ->  1 B markdown

with status_code: 200 and success: True, so nothing downstream can tell that
anything went wrong. cleaned_html is literally
<html><head><title>…</title></head></html> — the whole <body> is gone. The
markdown generator is innocent; it faithfully renders nothing.

I hit this across 70 hosts in one corpus, and it reproduced byte-identically
on the same URLs 3.5 months apart, because the plugin markup is stable.

Reproduction (6 lines, unmodified develop)

from crawl4ai.content_scraping_strategy import LXMLWebScrapingStrategy
MIN = '''<html><head><title>T</title></head><body>
<noscript><iframe src="about:blank"></iframe><noscript><iframe src="about:blank"></iframe></noscript>
<h1>Contact</h1><p>Phone 010 123 4567</p>
<div><p>A second paragraph.</p></div>
</body></html>'''
LXMLWebScrapingStrategy().scrap('https://x/', MIN, word_count_threshold=1).cleaned_html
# nested:            42 B  -> '<html><head><title>T</title></head></html>'
# single <noscript>: 187 B  (a well-formed noscript is harmless)
# no <noscript>:     188 B

Confirmed by excision on the real captured DOM:

Input cleaned_html
baseline 97 B
cut only the nested-noscript region 47,310 B
strip all <noscript> tags 51,795 B
strip <script> only 66 B (no help)

Fix

strip_noscript(), applied at the top of _scrap() immediately before parsing.

Removal rather than unwrapping, because Crawl4AI renders with JavaScript
enabled: by definition <noscript> content is not what the page displayed,
and its scripted equivalent is already in the DOM. Dropping it loses nothing and
removes the whole malformed-nesting failure class.

Two passes, so this handles the tag rather than the specific nesting:

  1. Well-formed elements — a non-greedy match ends at the first </noscript>,
    which is exactly where the browser's own parser ends the outer element too,
    so the nested shape collapses correctly.
  2. Any unpaired tag left over by truncated or malformed markup, so an unclosed
    <noscript> cannot swallow a body either.

Pages without the substring return the same object unparsed, so the cost on the
overwhelming majority of pages is one in check.

grep noscript crawl4ai/content_scraping_strategy.py crawl4ai/utils.py returns
nothing on develop — there is no existing <noscript> handling to conflict
with, which is also why this went unnoticed.

Tests

tests/general/test_noscript_body_loss.py — 11 tests. Covers nested,
well-formed, unclosed, uppercase and attributed shapes; asserts that nested and
well-formed become indistinguishable (the point of the fix, as opposed to
special-casing the nested shape); and pins that a page whose only content is
inside <noscript> correctly stays empty, since JavaScript was enabled.

pytest tests/general/test_noscript_body_loss.py -q        # 11 passed
pytest tests/test_pr_1435_redirected_status_code.py -q    #  7 passed

Known limitation

A <noscript> literal inside a <script> string could in principle start a
match that ends at a later real </noscript>. Any pre-parse repair carries
this, and the alternative — a spec-compliant HTML5 parse of every document — is
a much larger change than this bug warrants. Not observed in the corpus above.

Happy to adjust the approach if you would rather see this handled elsewhere in
the pipeline.

`<noscript>` may not nest. With scripting enabled its content is raw text, so
the parser never sees an inner `<noscript>` as an element and the OUTER element
is left unclosed — libxml2 then swallows every following node into it. Since
`LXMLWebScrapingStrategy._scrap` hands the raw serialised DOM straight to
`lhtml.document_fromstring`, the damage is done before there is a tree to fix.

A single WordPress lazy-load plugin that wraps the Google Tag Manager block in
a second `<noscript>` is enough to lose a whole site:

    312,628 B rendered HTML  ->  97 B cleaned_html  ->  1 B markdown

with `status_code: 200` and `success: True`, so nothing downstream can tell.
`cleaned_html` is literally `<html><head><title>…</title></head></html>` — the
`<body>` is gone. I found this across 70 hosts in one corpus; it reproduced
byte-identically on the same URLs 3.5 months apart, because the plugin markup is
stable.

Six-line reproduction on unmodified v0.9.2:

    from crawl4ai.content_scraping_strategy import LXMLWebScrapingStrategy
    MIN = '''<html><head><title>T</title></head><body>
    <noscript><iframe src="about:blank"></iframe><noscript><iframe src="about:blank"></iframe></noscript>
    <h1>Contact</h1><p>Phone 010 123 4567</p>
    <div><p>A second paragraph.</p></div>
    </body></html>'''
    LXMLWebScrapingStrategy().scrap('https://x/', MIN, word_count_threshold=1).cleaned_html
    # nested:            42 B  -> '<html><head><title>T</title></head></html>'
    # single <noscript>: 187 B  (well-formed noscript is harmless)
    # no <noscript>:     188 B

Fix: `strip_noscript()`, applied at the top of `_scrap()` immediately before
parsing. Removal rather than unwrapping, because Crawl4AI renders with
JavaScript enabled — by definition `<noscript>` content is not what the page
displayed, and its scripted equivalent is already in the DOM. Two passes:
well-formed elements first (a non-greedy match ends at the first `</noscript>`,
which is where the browser's parser ends the outer element too), then any
unpaired tag left by truncated markup, so an unclosed `<noscript>` cannot
swallow a body either. Pages without the substring return the same object
unparsed, so the cost is one `in` check.

`grep noscript crawl4ai/content_scraping_strategy.py crawl4ai/utils.py` returns
nothing on develop — there is no existing `<noscript>` handling to conflict with.

Tests: tests/general/test_noscript_body_loss.py, 11 passed. Covers nested,
well-formed, unclosed, uppercase and attributed shapes, asserts nested and
well-formed become indistinguishable, and pins that a page whose only content
is inside `<noscript>` correctly stays empty.

    pytest tests/general/test_noscript_body_loss.py -q          # 11 passed
    pytest tests/test_pr_1435_redirected_status_code.py -q      #  7 passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant