# pylint:disable-msg=I1101,W1401 """ Unit tests for the trafilatura library. """ import json import logging import sys import time from copy import copy from functools import partial from os import path from unittest.mock import patch import pytest from lxml import etree, html try: from cchardet import detect except ImportError: from charset_normalizer import detect import trafilatura.htmlprocessing import trafilatura.xpaths as xp from trafilatura import bare_extraction, baseline, core, external, extract, extract_with_metadata, xml from trafilatura import main_extractor as me from trafilatura.baseline import html2txt from trafilatura.deduplication import LRU_TEST, LRUCache from trafilatura.external import _prefer_readability, sanitize_tree, try_justext, try_readability from trafilatura.htmlprocessing import convert_tags, tree_cleaning from trafilatura.main_extractor import ( _span, extract_content, handle_formatting, handle_image, handle_lists, handle_paragraphs, handle_quotes, handle_table, handle_textelem, handle_titles, prune_unwanted_sections, recover_wild_text, ) from trafilatura.meta import reset_caches from trafilatura.metadata import Document from trafilatura.readability_lxml import Document as ReadabilityDocument from trafilatura.readability_lxml import is_probably_readerable from trafilatura.settings import MANUALLY_CLEANED, TAG_CATALOG, use_config from trafilatura.utils import ( LANGID_FLAG, detect_encoding, is_dubious_html, is_image_file, language_classifier, line_processing, load_html, normalize_unicode, repair_faulty_html, return_printables_and_spaces, sanitize, textfilter, trim, ) logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) TEST_DIR = path.abspath(path.dirname(__file__)) RESOURCES_DIR = path.join(TEST_DIR, "resources") SAMPLE_META = Document() ZERO_CONFIG = use_config() # fresh copy: must not mutate the package-global DEFAULT_CONFIG ZERO_CONFIG["DEFAULT"]["MIN_OUTPUT_SIZE"] = "0" ZERO_CONFIG["DEFAULT"]["MIN_EXTRACTED_SIZE"] = "0" NEW_CONFIG = use_config(filename=path.join(RESOURCES_DIR, "newsettings.cfg")) _INTRO = "enough intro text here for extraction" def _extract_doc(body, *, intro=True, **kwargs): "Wrap body in an article doc and extract; config defaults to ZERO_CONFIG (min-sizes 0)." kwargs.setdefault("config", ZERO_CONFIG) inner = f"

{_INTRO}

{body}" if intro else body return extract(f"
{inner}
", **kwargs) def _table_md(table, **kwargs): return _extract_doc(table, output_format="markdown", include_tables=True, **kwargs) def _table_txt(table): return _extract_doc(table, intro=False, output_format="txt", fast=True, include_tables=True) def _md_inline(body, **kwargs): return _extract_doc(body, output_format="markdown", include_formatting=True, **kwargs) or "" def _md(body, include_formatting=True): "Serialize an XML snippet to markdown (or plain text), stripped." return xml.xmltotxt(etree.fromstring(body), include_formatting=include_formatting).strip() def _xpath_hits(xpaths, attrs, tag="div"): "Whether any of the expressions selects a wrapping a paragraph." root = etree.fromstring(f"<{tag} {attrs}>

content

") return any(len(x(root)) > 0 for x in xpaths) def _handle_table(table, recall=False): return handle_table(html.fromstring(table), TAG_CATALOG, core.Extractor(recall=recall)) MOCK_PAGES = { "http://exotic_tags": "exotic_tags.html", } @pytest.fixture def options(): "A fresh Extractor per test (never a shared, mutable module global)." return core.Extractor() def load_mock_page(url, xml_flag=False, langcheck=None, tei_output=False): """load mock page from samples""" try: with open(path.join(TEST_DIR, "resources", MOCK_PAGES[url]), encoding="utf-8") as inputf: htmlstring = inputf.read() # encoding/windows fix for the tests except UnicodeDecodeError: # read as binary with open(path.join(TEST_DIR, "resources", MOCK_PAGES[url]), "rb") as inputf: htmlbinary = inputf.read() guessed_encoding = detect(htmlbinary)["encoding"] if guessed_encoding is not None: try: htmlstring = htmlbinary.decode(guessed_encoding) except UnicodeDecodeError: htmlstring = htmlbinary else: print("Encoding error") if xml_flag: output_format = "xml" elif tei_output: output_format = "xmltei" else: output_format = "txt" return extract(htmlstring, url, record_id="0000", output_format=output_format, target_language=langcheck) def test_trim(): """test string trimming""" assert trim(" Test ") == "Test" assert trim("\t\tTest Test\r\n") == "Test Test" my_elem = etree.Element("body") my_elem.text = "Test Text" assert textfilter(my_elem) is False # my_elem.text = 'Tags: Arbeit, Urlaub' my_elem.text = "Instagram" assert textfilter(my_elem) is True my_elem.text = "\t\t" assert textfilter(my_elem) is True # sanitize logic assert sanitize(None) is None # non-breaking spaces assert sanitize("Test Text") == "Test Text" # clear cache # reset caches: examine_date_elements used above old_values = trim.cache_info() reset_caches() assert trim.cache_info() != old_values def test_reset_caches(): "reset_caches() must empty every trafilatura-owned cache, not just trim." caches = (trim, line_processing, return_printables_and_spaces) for fn in caches: fn("x") LRU_TEST.put("k", 1) assert all(fn.cache_info().currsize for fn in caches) assert LRU_TEST.cache reset_caches() assert not any(fn.cache_info().currsize for fn in caches) assert not LRU_TEST.cache def test_input(options): """test if loaded strings/trees are handled properly""" teststring = "高山云雾出好茶".encode() assert detect_encoding(teststring) == ["utf-8"] teststring = "高山云雾出好茶".encode("gb18030") assert "gb18030" in detect_encoding(teststring) assert "gb18030" in detect_encoding(teststring * 1000) assert is_dubious_html("This is a string.") is True htmlstring = "\n" beginning = htmlstring[:50].lower() assert repair_faulty_html(htmlstring, beginning) == "\n" htmlstring = "\n" beginning = htmlstring[:50].lower() assert repair_faulty_html(htmlstring, beginning) == htmlstring htmlstring = "\n" beginning = htmlstring[:50].lower() assert repair_faulty_html(htmlstring, beginning) == "\n" htmlstring = '\n\n\n\n' beginning = htmlstring[:50].lower() assert repair_faulty_html(htmlstring, beginning) == '\n\n\n\n' htmlstring = "Foo
Bar" beginning = htmlstring[:50].lower() assert repair_faulty_html(htmlstring, beginning) == "Foo
Bar\n" # XML-illegal chars are stripped pre-parse (see utils.INVALID_XML_CHARS) htmlstring = "

a\x00b\x1dc￾￿d

\t

keep\tme

" assert repair_faulty_html(htmlstring, htmlstring[:50].lower()) == "

abcd

\t

keep\tme

" page = ( "
" + "

Long enough article paragraph\x1d for baseline￿ to trigger.

" * 3 + "
" ) assert baseline(page)[2] > 0 # no ValueError, control chars stripped assert extract(page, fast=True) is not None with pytest.raises(TypeError) as err: assert load_html(123) is None assert "incompatible" in str(err.value) assert load_html("ÄÖÜ") is not None assert load_html(b"\x2f\x2e\x9f") is not None assert load_html("\x2f\x2e\x9f".encode("latin-1")) is not None # assert load_html(b'0'*int(10e3)) is None # old: with pytest.raises(TypeError) as err: assert extract(None, "url", "0000", target_language=None) is None # GZip with open(path.join(RESOURCES_DIR, "webpage.html.gz"), "rb") as gzfile: myinput = gzfile.read() assert "Long story short," in extract(myinput) # responses exposing a .data attribute are unwrapped class _RespLike: data = b"

response data

" assert load_html(_RespLike()) is not None # unicode normalization assert normalize_unicode("A\u0308ffin") != "A\u0308ffin" testresult = extract("

A\u0308ffin

", config=ZERO_CONFIG) assert testresult != "A\u0308ffin" assert testresult == "Äffin" options = core.Extractor(source="test\udcc3this") assert options.source == "test?this" # output format assert "ABC" in extract("

ABC

", output_format="xml") with pytest.raises(AttributeError): assert extract("

ABC

", output_format="xyz") is not None assert bare_extraction("

ABC

", output_format="python").text == "ABC" with pytest.raises(AttributeError): assert bare_extraction("

ABC

", output_format="xyz") is not None # text elements elem = etree.Element("p") elem.text = "text" assert handle_textelem(elem, [], options).text == "text" elem = etree.Element("unexpected") elem.text = "text" assert handle_textelem(elem, [], options) is None def test_document_isolation(): "Documents must not share a mutable Element default (thread-safety / correctness)." d1, d2 = Document(), Document() assert d1.body is not d2.body assert d1.commentsbody is not d2.commentsbody assert d1.body.tag == "body" assert len(d1.body) == 0 def test_xmltocsv(): doc = Document() doc.body = etree.fromstring("") doc.commentsbody = etree.fromstring("") assert xml.xmltocsv(doc, False) == "null\tnull\tnull\tnull\tnull\tnull\tnull\tnull\tnull\tnull\tnull\r\n" doc.title = "Test title" doc.url = "https://example.org" doc.hostname = "example.org" doc.id = "1" doc.license = "CC BY-SA" doc.image = "https://example.org/image.jpg" doc.pagetype = "article" text = "Test text" comments = "Test comment" doc.body = etree.fromstring(f"

{text}

") doc.commentsbody = etree.fromstring(f"

{comments}

") target = "https://example.org\t1\tnull\texample.org\tTest title\thttps://example.org/image.jpg\tnull\tTest text\tTest comment\tCC BY-SA\tarticle\r\n" assert xml.xmltocsv(doc, False) == target mystring = "

ÄÄÄÄÄÄÄÄÄÄÄÄÄÄ

" assert extract(mystring, output_format="csv", config=ZERO_CONFIG) is not None assert extract(mystring, output_format="csv", include_comments=False, config=ZERO_CONFIG).endswith("\tnull\r\n") def test_tojson(): # test json mystring = "

ÄÄÄÄÄÄÄÄÄÄÄÄÄÄ

" result = extract(mystring, output_format="json", config=ZERO_CONFIG) assert "Ä" in result assert result.endswith("}") result = extract(mystring, output_format="json", config=ZERO_CONFIG, with_metadata=True) assert result.endswith("}") assert '"fingerprint":' in result assert '"language":' in result assert extract(mystring, output_format="json", include_comments=False, config=ZERO_CONFIG).endswith("}") def test_python_output(): # bare extraction for python mystring = "

ÄÄÄÄÄÄÄÄÄÄÄÄÄÄ

" result = bare_extraction(mystring, config=ZERO_CONFIG) dict_result = result.as_dict() assert isinstance(dict_result, dict) assert len(dict_result) == 21 def test_exotic_tags(): options = core.Extractor(config=ZERO_CONFIG) # cover some edge cases with a specially crafted file result = load_mock_page("http://exotic_tags", xml_flag=False, tei_output=True) assert "Teletype text" in result assert "My new car is silver." in result filepath = path.join(TEST_DIR, "resources", "exotic_tags_tei.html") with open(filepath, encoding="utf-8") as f: content = etree.fromstring(f.read()) res = xml.check_tei(content, "http://dummy") assert etree.tostring(res).startswith( b'\n\n\n
\n\nHello\n

Teletype text

' ) # misformed HTML declaration htmlstring = '

ABC

' # outputs '012"http://www.w3.org/TR/html4/loose.dtd">\nABC' assert "ABC" in extract(htmlstring, config=ZERO_CONFIG) # quotes assert handle_quotes(etree.Element("quote"), options) is None assert handle_table(etree.Element("table"), TAG_CATALOG, options) is None # p within p element, second = etree.Element("p"), etree.Element("p") element.text, second.text = "1st part.", "2nd part." element.append(second) # delete last element.append(etree.Element("lb")) converted = handle_paragraphs(element, ["p"], options) assert etree.tostring(converted) == b"

1st part. 2nd part.

" # naked div with assert "1.\n2.\n3." in extract( "
1.
2.
3.
", fast=True, config=ZERO_CONFIG ) # HTML5:
htmlstring = "
Epcot Center

Epcot is a theme park at Walt Disney World Resort featuring exciting attractions, international pavilions, award-winning fireworks and seasonal special events.

" my_result = extract(htmlstring, fast=True, config=ZERO_CONFIG) assert "Epcot Center" in my_result assert "award-winning fireworks" in my_result my_result = extract(htmlstring, fast=False, config=ZERO_CONFIG) assert "Epcot Center" in my_result assert "award-winning fireworks" in my_result # edge cases htmlstring = """ A weird bug

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Sed et interdum lectus.

Quisque molestie nunc eu arcu condimentum fringilla.

Aliquam eget interdum elit, id posuere ipsum.

Phasellus lectus erat, hendrerit sed tortor ac, dignissim vehicula metus.

""" assert extract(htmlstring, include_formatting=True, include_links=True, include_images=True) is not None htmlstring = """ A weird bug

A header

Very specific bug so odd

Nested header

Some "hyphenated-word quote" followed by a bit more text line.

em improperly wrapping p here

Text here

More articles

""" common = {"include_formatting": True, "include_links": True, "include_images": True} params = [common, {**common, "favor_precision": True}, {**common, "favor_recall": True}] for p in params: result = extract(htmlstring, **p) assert "em improperly wrapping p here" in result assert result.endswith("Text here") # comments assert extract( '

text

comment

', include_comments=True, fast=True, config=ZERO_CONFIG, ).endswith("\ncomment") def test_formatting(options): """Test HTML formatting conversion and extraction""" # trailing my_document = html.fromstring("

This here is the text.

") my_result = extract(my_document, output_format="xml", config=ZERO_CONFIG) assert "lb" not in my_result # simple formatting my_document = html.fromstring("

This here is in bold font.

") my_result = extract(my_document, output_format="xml", include_formatting=True, config=ZERO_CONFIG) assert 'This here is in bold font.' in my_result # titles as markdown my_string = ( "

Title

This here is in bold font.Non-bold here

" ) my_document = html.fromstring(my_string) my_result = extract(my_document, output_format="txt", include_formatting=True, config=ZERO_CONFIG) assert my_result == "### Title\n\n**This here is in bold font.**Non-bold here" assert extract(my_string, output_format="markdown", config=ZERO_CONFIG) == my_result assert '' in etree.tostring( bare_extraction(my_string, output_format="markdown", config=ZERO_CONFIG).body, encoding="unicode" ) meta_string = "Test

ABC.

" meta_result = extract(meta_string, output_format="markdown", config=ZERO_CONFIG, with_metadata=True) assert meta_result.startswith("---\ntitle: Test\nfingerprint: ") assert meta_result.endswith("---\nABC.") # the record ID and fingerprint are in the header, as in the other formats json_result = json.loads( extract(meta_string, output_format="json", config=ZERO_CONFIG, with_metadata=True, record_id="doc-1") ) for fmt in ("markdown", "txt"): meta_result = extract(meta_string, output_format=fmt, config=ZERO_CONFIG, with_metadata=True, record_id="doc-1") assert f"\nfingerprint: {json_result['fingerprint']}\nid: doc-1\n" in meta_result # space between paragraphs my_document = html.fromstring( "

Title

Paragraph 1

Paragraph 2

" ) my_result = extract(my_document, output_format="txt", include_formatting=True, config=ZERO_CONFIG) assert my_result.endswith("Paragraph 1\n\nParagraph 2") # code sections my_document = html.fromstring( "

Title

Here is a code sample:

import trafilatura

" ) my_result = extract(my_document, output_format="txt", include_formatting=True, config=ZERO_CONFIG) assert ( my_result == """### Title Here is a code sample: `import trafilatura`""" ) my_document = html.fromstring( '

Title

Here is a code sample:

import something
something.run("somewhere")

Sometimes code is wrapped using pre and code:

import trafilatura\ntrafilatura.extract("")

Less often code is wrapped using just pre:

\ntrafilatura.extract("")
' ) my_result = extract(my_document, output_format="txt", include_formatting=True, config=ZERO_CONFIG) assert ( my_result == """### Title Here is a code sample: ``` import something something.run("somewhere") ``` Sometimes code is wrapped using `pre` and `code`: ``` import trafilatura trafilatura.extract("") ``` Less often code is wrapped using just `pre`: ``` trafilatura.extract("") ```""" ) # nested my_document = html.fromstring("

This here is in bold and italic font.

") my_result = extract(my_document, output_format="xml", include_formatting=True, config=ZERO_CONFIG) assert 'This here is in bold and italic font.' in my_result # empty my_document = html.fromstring("

") my_result = extract(my_document, output_format="xml", include_formatting=True, config=ZERO_CONFIG) assert "
" in my_result # wild div my_document = html.fromstring("
Wild text
") my_result = extract(my_document, output_format="xml", include_formatting=True, config=ZERO_CONFIG) assert "

" in my_result assert 'Wild text' in my_result my_document = html.fromstring("

Wild text
") my_result = extract(my_document, config=ZERO_CONFIG) assert my_result == "Wild text" # links doc = html.fromstring('

Link text

') my_result = extract(doc, config=ZERO_CONFIG) assert my_result == "Link text" # line-breaks doc = html.fromstring("


") my_result = extract(doc, config=ZERO_CONFIG) assert my_result == "" doc = html.fromstring("


Here is the text.

") my_result = extract(doc, config=ZERO_CONFIG) assert my_result == "Here is the text." # handle formatting tails element = etree.Element("hi") element.text = "Here is the text." element.tail = "And a tail." options = core.Extractor(config=ZERO_CONFIG) converted = handle_formatting(element, options) assert etree.tostring(converted) == b"

Here is the text.And a tail.

" # no wrapper inside a protected parent element = etree.SubElement(etree.Element("item"), "hi") element.text = "Here is the text." assert etree.tostring(handle_formatting(element, options)) == b"Here is the text." # empty elements my_document = html.fromstring("
\t\n
There is text here.
") my_result = extract(my_document, output_format="xml", config=ZERO_CONFIG) assert "
\n

There is text here.

\n
" in my_result # lists with links my_document = html.fromstring( '
  • Number 1
  • Number 2
  • Number 3
  • Test

' ) my_result = extract(my_document, output_format="xml", include_links=True, config=ZERO_CONFIG) assert 'Number 2' in my_result my_document = html.fromstring("""
  • Number 0
  • Number 1
  • Number 2 n2
  • Number 3
  • Number 4

    n4
Test
""") my_result = extract(my_document, output_format="markdown", include_links=True, config=ZERO_CONFIG) assert my_result == "- Number 0\n- Number [1](test.html)\n- [Number 2](test.html) n2\n- Number 3\n- Number 4 n4\n\nTest" # XML and Markdown formatting within

-tag my_document = html.fromstring( '

bold, italics, tt, deleted, underlined, link and additional text to bypass detection.

' ) plain = "bold, italics, tt, deleted, underlined, link and additional text to bypass detection." marked = "**bold**, *italics*, `tt`, ~~deleted~~, underlined, link and additional text to bypass detection." xml_p = ( '

bold, italics, tt, deleted, ' 'underlined, {} and additional text to bypass detection.

' ) for kwargs, expected in ( ({"include_formatting": False}, plain), ({"include_formatting": True}, marked), ({"include_formatting": True, "output_format": "txt"}, marked), ({"include_formatting": True, "include_links": True}, marked.replace(" link ", " [link](test.html) ")), ): assert extract(copy(my_document), fast=True, config=ZERO_CONFIG, **kwargs) == expected for links, link in ((False, "link"), (True, 'link')): result = extract( copy(my_document), output_format="xml", fast=True, include_formatting=True, include_links=links, config=ZERO_CONFIG ) assert xml_p.format(link) in result # double

-elems # could be solved by keeping the elements instead of reconstructing them my_document = html.fromstring("

AAA,

BBB

, CCC.

") my_result = extract( my_document, output_format="xml", include_formatting=True, include_links=True, fast=True, config=ZERO_CONFIG ) assert "AAA" in my_result assert "BBB" in my_result assert "CCC" in my_result # line-break following formatting my_document = html.fromstring( "

Staff Review of the Financial Situation
Domestic financial conditions remained accommodative over the intermeeting period.

" ) my_result = extract(my_document, output_format="txt", fast=True, config=ZERO_CONFIG) assert ( my_result == "Staff Review of the Financial Situation\nDomestic financial conditions remained accommodative over the intermeeting period." ) # title with formatting my_document = html.fromstring( '

1) The in Operator

The easiest way to check if a Python string contains a substring is to use the in operator. The in operator is used to check data structures for membership in Python. It returns a Boolean (either True or False) and can be used as follows:

' ) my_result = extract(my_document, output_format="xml", fast=True, include_formatting=True, config=ZERO_CONFIG) assert '1) The in Operator' in my_result assert ( "

The easiest way to check if a Python string contains a substring is to use the in operator. The in operator is used to check data structures for membership in Python. It returns a Boolean (either True or False) and can be used as follows:

" in my_result ) my_document = html.fromstring("""
python code below:

def test:
    print('hello')
    print('world')
    
""") my_result = extract(my_document, output_format="markdown", include_formatting=True, config=ZERO_CONFIG) assert "python code below:\n```\ndef test:\n print('hello')\n print('world')\n \n```" == my_result my_result = extract(my_document, output_format="markdown", include_formatting=True, config=ZERO_CONFIG) assert ( """python code below: ``` def test: print('hello') print('world') ```""" == my_result ) my_document = html.fromstring("

Sjätte AP-fonden

") my_result = extract(my_document, output_format="xml", include_tables=True, config=ZERO_CONFIG) assert "AP-fonden" in my_result def test_subelement_text_kept(): "Text of nested and unexpected subelements stays in place." # a container's tail follows its children assert "**B** C tail" in _md_inline("
  • A B C
    tail
") # unexpected tags in a paragraph keep their text and tail assert "Hello bar world" in _md_inline("

Hello bar world

") # every line break in a flattened hi keeps its space assert "A **b c d** e" in _md_inline("

A b
c
d
e

") assert "bar world" in _md_inline("

Hello bar world

") # a list in a cell keeps its tail, dropped or not for recall in (False, True): table = "
a
  • x
after
b
" assert "after" in _table_md(table, favor_recall=recall) # a whitespace-only item is dropped, numbering stays continuous assert "1. a\n2. b" in _md_inline("
  1. a
  2. b

after

") def test_subelement_tails_unit(options): "Whitespace-only tails are not moved, consumed children are not merged back." item = handle_lists(etree.fromstring("
A B
\n\t
"), options) assert item.find(".//hi").tail is None paragraph = handle_paragraphs(etree.fromstring("

a old b

"), TAG_CATALOG, options) assert "old" not in "".join(paragraph.itertext()) # a teaser heading whose lead was consumed earlier is not emitted again (lanacion) assert handle_titles(etree.fromstring("Lead. Teaser"), options) is None def test_markdown_metadata_yaml_safe(): "Markdown metadata header must stay valid YAML for special values (GH #814)." # scalar rendering: plain when safe, double-quoted (json) otherwise assert core._yaml_scalar("Indu K Murthy") == "Indu K Murthy" assert core._yaml_scalar("https://example.com/a:b") == "https://example.com/a:b" assert core._yaml_scalar("élan vital") == "élan vital" assert core._yaml_scalar("COP30: a guide") == '"COP30: a guide"' assert core._yaml_scalar("#1 ranking") == '"#1 ranking"' assert core._yaml_scalar("&launch") == '"&launch"' assert core._yaml_scalar("true") == '"true"' assert core._yaml_scalar("2024") == '"2024"' assert core._yaml_scalar("[draft]") == '"[draft]"' assert core._yaml_scalar('say "hi": now') == '"say \\"hi\\": now"' # the rendered scalars must round-trip exactly through a real YAML parser yaml = pytest.importorskip("yaml") for value in ( "COP30: a beginner’s guide", "#1 ranking", "&launch", "true", "[draft] note", 'a "quoted" word', "Indu K Murthy", ): loaded = yaml.safe_load("title: " + core._yaml_scalar(value)) assert loaded["title"] == value # end-to-end: a title with ": " used to emit invalid YAML, now it is quoted my_html = ( "COP30: a beginner’s guide" '' "

Some body text with enough words to be extracted.

" "
" ) result = extract(my_html, output_format="markdown", config=ZERO_CONFIG, with_metadata=True) header = result.split("---\n", 2)[1] meta = yaml.safe_load(header) assert meta["title"] == "COP30: a beginner’s guide" assert meta["author"] == "Indu K Murthy" assert 'title: "COP30: a beginner’s guide"' in result def test_yoast_faq_block(): "Yoast FAQ block questions are bold but act as headers and must be kept (issue #471)." # a long lead paragraph keeps
out of the potential tags, as on the reported page lead = ( "The wrap dress is a dress with a front closure formed by wrapping one side across the other " "and knotting the attached ties that wrap around the back at the waist or fastening buttons. " "It was popularised in the seventies and has remained a wardrobe staple ever since, flattering " "many different body shapes thanks to its adjustable and forgiving cut. " * 2 ) htmlstring = ( "

Wrap dress

" + lead + "

" '
' '
' 'Who invented the wrap dress? ' '

It was popularised by Diane von Furstenberg in 1974.

' "
" ) # default (no formatting): the question must not be dropped assert "Who invented the wrap dress?" in extract(htmlstring, config=ZERO_CONFIG) # with formatting: it is rendered as a header assert "### Who invented the wrap dress?" in extract( htmlstring, output_format="txt", include_formatting=True, config=ZERO_CONFIG ) assert 'Who invented the wrap dress?' in extract( htmlstring, output_format="xml", config=ZERO_CONFIG ) def test_include_formatting_markdown(): "markdown defaults to formatted, but an explicit include_formatting=False is honored (was ignored for markdown)." doc = "

plain and bold text here.

" assert extract(doc, output_format="markdown", config=ZERO_CONFIG) == "plain and **bold** text here." assert extract(doc, output_format="markdown", include_formatting=False, config=ZERO_CONFIG) == "plain and bold text here." assert extract(doc, output_format="txt", include_formatting=True, config=ZERO_CONFIG) == "plain and **bold** text here." @pytest.mark.parametrize( "body,expected", [ ("one", "- one"), ("\n one", "- one"), ("one

two

", "- one\ntwo"), ("abc", "- a\nb\nc"), ("\n x y", "- **x** y"), ("s: who use b", "- [s](s): who use [b](b)"), ("use bots, to resell.", "- use **bots**, to resell."), ("

xy

z
", "| x y z |"), ("abz
", "| a b z |"), ("use bots, x
", "| use **bots**, x |"), ("A B, x
", "| **A B**, x |"), ("a b
", "| a b |"), ("x

y

z mid

w

", "| x y z mid w |"), ("a

b

c

d

", "- a b c d"), ("b", "- b"), ('

abc tail

', "**abc** tail"), ], ids=[ "item-leading-lb", "item-leading-lb-after-whitespace", "item-nested-lb-after-text", "item-lb-no-stray-space", "item-whitespace-before-child", "item-tail-between-links", "item-no-space-before-comma", "cell-tail-after-children", "cell-tail-after-list", "cell-no-space-before-comma", "cell-whitespace-between-inline", "cell-textless-child-tail", "cell-block-after-text", "item-block-after-text", "item-empty-child-no-marker", "adjacent-hi-chain-merged", ], ) def test_serializer_tail_spacing(body, expected): "Item and cell tails keep source separators, in order, without stacked spaces." assert xml.xmltotxt(etree.fromstring(f"{body}"), include_formatting=True).strip() == expected plain = xml.xmltotxt(etree.fromstring(f"{body}"), include_formatting=False).strip() assert plain == expected.replace("**", "") def test_serializer_nested_lb_in_item_keeps_break(): "A nested line break after item content keeps its newline." tree = etree.fromstring("

para

second

") assert "\nsecond" in xml.xmltotxt(tree, include_formatting=True) def test_serializer_nested_list_tail_stays_in_parent_item(): "Text after a nested list belongs to the parent item, not to the last sub-item." tree = etree.fromstring( "L1L2L3 t2 t1n" ) assert xml.xmltotxt(tree, include_formatting=True) == "- L1\n - L2\n - L3\n\n t2\n\n t1\n- n\n" assert xml.xmltotxt(tree, include_formatting=False) == "- L1\n - L2\n - L3\nt2\nt1\n- n" @pytest.mark.parametrize( "body,kwargs,expected", [ ("
  • \n
    tail after break
  • x
", {"output_format": "markdown"}, "- tail after break\n- x"), ( "
A
\nB, x
", {"output_format": "markdown", "include_tables": True}, "| **A** **B**, x |", ), ("

A
B

", {"output_format": "markdown"}, "**A**\n\n**B**"), # underline has no markdown syntax (__ is bold), adjacent runs still merge ("

ab c

", {"output_format": "markdown"}, "ab c"), # the parser nests the rest of the paragraph inside ("

longword and more

", {"output_format": "txt"}, "longword and more"), ], ids=["item-leading-br", "cell-br-between-bold", "p-br-between-bold", "underline-as-html", "wbr-keeps-text"], ) def test_extract_snippet(body, kwargs, expected): "Line breaks before inline content, underline and on intro-less snippets." assert _extract_doc(body, intro=False, **kwargs) == expected @pytest.mark.parametrize("fmt", ["xml", "xmltei"]) def test_linebreak_before_inline_kept_in_xml(fmt): "XML/TEI keep a tail-less before inline content." out = _extract_doc( "

Rutgers University
\nLinda

", intro=False, output_format=fmt, include_formatting=True ) assert '

Rutgers UniversityLinda

' in out def test_markdown_sup_sub_keep_boundary(): """sup/sub must keep a boundary, else 1002 reads as 1002 (issue #889)""" sup = "

The layer has 1002=10000 nodes.

" sub = "

Written 201115ya in winter.

" assert extract(sup, output_format="markdown", config=ZERO_CONFIG) == "The layer has 1002=10000 nodes." assert extract(sub, output_format="markdown", config=ZERO_CONFIG) == "Written 201115ya in winter." # flanking whitespace stays outside the tags, exactly as for the symmetric markers spaced = "

x 2 y

" bold = "

x 2 y

" assert extract(spaced, output_format="markdown", config=ZERO_CONFIG) == "x 2 y" assert extract(bold, output_format="markdown", config=ZERO_CONFIG) == "x **2** y" @pytest.mark.parametrize( "body,expected", [ ( "
lead
  • one
after
", 'lead one after', ), ( "
  • First
    • sub
    tail text
  • second
", 'Firstsubtail text', ), ("
  • para x

    trailing
", '

para x

trailing
'), ("

para x

trailing
", '

para x

trailing
'), ("
  • x caption
", " caption"), ("
x caption
", " caption"), # quote children go through handle_textnode: br becomes lb, empty paragraphs vanish ("
text
Author, Site
", 'textAuthor, Site'), ("

embed caption
", "embed caption"), ], ids=[ "list-in-quote", "nested-list-tail", "p-in-item", "p-in-quote", "srcless-img-in-item", "srcless-img-in-quote", "br-in-quote", "empty-p-in-quote", ], ) def test_nested_element_tail_kept(body, expected): "Nested lists, formatted paragraphs and dropped src-less images inside an item or a quote keep their structure and tail." assert expected in _extract_doc(body, output_format="xml", include_formatting=True, include_images=True) def test_srcless_graphic_dropped_text_kept(): "A src-less image is dropped from a quote, the text around it in a paragraph is kept." quote = handle_quotes(etree.fromstring("a"), core.Extractor()) assert etree.tostring(quote) == b"a" para = handle_paragraphs(etree.fromstring("

ab

"), {*TAG_CATALOG, "graphic"}, core.Extractor()) assert "".join(para.itertext()) == "ab" @pytest.mark.parametrize( "block,expected", [ ("a b", "a b"), ("a

b
", "a b"), ("a ", "a"), ("ab", "a b"), ("a b", "a b"), ("ab", "ab"), ("
x b
", "x b
"), ("
x
", "x
"), ( "
xi b
", "xi b
", ), ], ids=[ "dropped-list-in-quote", "dropped-p-in-quote", "blank-tail-in-quote", "unspaced-tail-in-quote", "spaced-text-in-quote", "srcless-img-in-quote-no-space", "dropped-list-in-cell", "blank-tail-in-cell", "kept-list-in-cell", ], ) def test_nested_block_tail_kept(block, expected): "The tail of a nested list or paragraph is kept, whether the block itself is kept or dropped." element = etree.fromstring(block) if element.tag == "quote": result = handle_quotes(element, core.Extractor()) else: result = handle_table(element, {*TAG_CATALOG, "table"}, core.Extractor(recall=True)) assert etree.tostring(result).decode() == expected @pytest.mark.parametrize( "cell,links,expected", [ ("
Block in bold
", False, "| Block in bold | y |"), ('

Block in link

', True, "| Block in link | y |"), ('bold

after

', True, "| [**bold**](/x) after | y |"), ], ids=["bold", "link", "link-with-inline"], ) def test_block_in_inline_wrapper_in_cell(cell, links, expected): "A block wrapped in a link or bold inside a cell is kept." output = _table_md(f"
{cell}y
", include_links=links) assert output.splitlines()[-1] == expected def test_markdown_empty_sup_sub_are_dropped(): """An empty sup/sub must be removed without taking the text after it (issue #889)""" # process_node() hands a textless element its tail as text, so without the removal in # convert_tags() the new marker would wrap the *following* words: ab for tag in ("sup", "sub"): doc = f"

a<{tag}>b

" assert extract(doc, output_format="markdown", config=ZERO_CONFIG) == "ab" # a footnote marker whose only child is dropped must still not swallow the sentence footnote = '

Fact follows here.

' assert extract(footnote, output_format="markdown", config=ZERO_CONFIG) == "Fact follows here." # the tail survives in precision mode too, where CUT_EMPTY_ELEMS pruning would drop it tailed = "

alphabeta gamma.

" assert extract(tailed, output_format="markdown", config=ZERO_CONFIG) == "alphabeta gamma." assert extract(tailed, output_format="markdown", favor_precision=True, config=ZERO_CONFIG) == "alphabeta gamma." def test_extract_with_metadata(): """Test extract_with_metadata method""" url = "http://aa.bb/cc.html" my_document = html.fromstring("""

AAA,

BBB

, CCC.

""") parsed_doc = extract_with_metadata(my_document, output_format="txt", include_formatting=True, fast=True, url=url) content = parsed_doc.text assert "AAA" in content assert "BBB" in content assert "CCC" in content assert url == parsed_doc.url assert parsed_doc.date is None assert parsed_doc.title is None my_document = html.fromstring(""" title
May 24, 2021

AAA,

BBB

, CCC.

""") parsed_doc = extract_with_metadata(my_document, output_format="txt", include_formatting=True, fast=True, url=url) content = parsed_doc.text assert "AAA" in content assert "BBB" in content assert "CCC" in content assert url == parsed_doc.url assert "2021-05-24" == parsed_doc.date assert "title" == parsed_doc.title parsed_doc = extract_with_metadata(my_document, output_format="xml", config=ZERO_CONFIG) assert "AAA, BBB , CCC." == parsed_doc.raw_text assert "ee7d2fb6fcf2837d" == parsed_doc.fingerprint content = parsed_doc.text assert "AAA" in content assert "BBB" in content assert "CCC" in content my_document = html.fromstring("""

AAA,

BBB

, CCC.

""") parsed_doc = extract_with_metadata(my_document, target_language="en", fast=True) assert parsed_doc is None with pytest.raises(ValueError): extract_with_metadata(my_document, output_format="python") def test_sanitize_tree_normalizes_images(options): "Fallback images get handle_image: src from data-src, stray attributes dropped, src-less images removed." options.images = True mydoc = html.fromstring( '

aArm, 8byc

' ) mytree, _ = sanitize_tree(mydoc, options) graphics = mytree.findall(".//graphic") assert [dict(g.attrib) for g in graphics] == [{"src": "x.jpg", "alt": "Arm, 8"}] assert "".join(mytree.itertext()) == "abc" def test_external(options): """Test external components""" options.tables = True # remove unwanted elements mydoc = html.fromstring("
Test text
") _, text = sanitize_tree(mydoc, options) mylen = len(text) assert mylen == 0 mydoc = html.fromstring("
Test text
Test
") _, text = sanitize_tree(mydoc, options) mylen = len(text) assert mylen > 0 # strip fancy tags while including links and images mydoc = html.fromstring( '

Text here Test textwith a link.

' ) mytree, _ = sanitize_tree(mydoc, options) assert len(mytree) == 1 mydoc = html.fromstring( '

Text here Test textwith a link.

' ) options.links, options.images = True, True mytree, _ = sanitize_tree(mydoc, options) myelems = {element.tag for element in set(mytree.iter())} assert "graphic" in myelems assert "ref" in myelems # test langid if LANGID_FLAG is True: doc = html.fromstring("" + "

Non è inglese.

" * 20 + "") assert extract(doc, fast=False, target_language="en", deduplicate=False) is None # no tables with open(path.join(RESOURCES_DIR, "apache.html"), encoding="utf-8") as f: teststring = f.read() assert "localhost:80" in extract(teststring, fast=False, include_tables=True) assert "localhost:80" not in extract(teststring, fast=False, include_tables=False) with open(path.join(RESOURCES_DIR, "scam.html"), encoding="utf-8") as f: teststring = f.read() assert extract(teststring, fast=True, include_tables=False, config=ZERO_CONFIG) == "" assert extract(teststring, fast=False, include_tables=False, config=ZERO_CONFIG) == "" # invalid XML attributes: namespace colon in attribute key (issue #375). Those attributes should be stripped bad_xml = '

Testing

    Features:
  • Saves the cost of two dedicated phone lines.
  • al station using Internet or cellular technology.
  • Requires no change to the existing Fire Alarm Control Panel configuration. The IPGSM-4G connects directly to the primary and secondary telephone ports.
  • ' res = extract(bad_xml, output_format="xml") assert "Features" in res @pytest.mark.skipif(not LANGID_FLAG, reason="py3langid not installed") def test_wrong_language_discarded(): "content whose detected language differs from target_language is discarded (the filter raises, caught -> None)." doc = "" + "

    Questo testo non è affatto in lingua inglese.

    " * 20 + "" assert extract(doc, target_language="en", config=ZERO_CONFIG) is None def test_sanitize_tree_absolutizes_links(): "Regression: sanitize_tree must absolutize relative links when url is set (convert_tags fix)." doc = html.fromstring('

    link ' + "padding " * 10 + "

    ") options = core.Extractor(url="https://www.example.org", links=True) tree, _ = sanitize_tree(doc, options) targets = [elem.get("target") for elem in tree.iter("ref")] assert "https://www.example.org/path/page" in targets def test_images(options): """Test image extraction function""" # file type assert is_image_file(None) is False assert is_image_file("") is False assert is_image_file("test.jpg") is True # Extensions are case-insensitive by convention (e.g. camera output IMG_1234.JPG). assert is_image_file("test.JPG") is True assert is_image_file("PIC.PNG") is True assert is_image_file("photo.JPEG") is True assert is_image_file("test.txt") is False assert is_image_file("test.TXT") is False assert is_image_file("test.jpg" * 2000) is False # length threshold # tag with attributes assert handle_image(html.fromstring('')) is not None assert handle_image(html.fromstring('text')) is not None assert handle_image(html.fromstring('')) is None # HTML conversion assert handle_textelem(etree.Element("graphic"), [], options) is None with open(path.join(RESOURCES_DIR, "http_sample.html"), encoding="utf-8") as f: teststring = f.read() assert "![Example image](test.jpg)" not in extract(teststring) assert "![Example image](test.jpg)" in extract(teststring, include_images=True, fast=True) assert '' in extract( teststring, include_images=True, fast=True, output_format="xml", config=ZERO_CONFIG ) def img(body, **kw): return _extract_doc(body, intro=False, include_images=True, fast=True, **kw) assert img('text') == "![a title text](test.jpg)" assert img('

    text

    ') == "![a title text](test.jpg)" assert img('

    text

    ') == "" assert img('

    text

    ') == "![a title text](test.jpg)" assert img('

    text

    ') == "![a title text](test.jpg)" assert ( img('

    text

    ') == "![a title text](https://a.b/test.jpg)" ) url = "http://a.b/c/d.html" assert ( img('

    text

    ', url=url) == "![a title text](http://a.b/test.jpg)" ) assert ( img('

    text

    ', url=url) == "![a title text](http://a.b/a.b/test.jpg)" ) assert ( img('

    text

    ', url=url) == "![a title text](http://a.b/c/a.b/test.jpg)" ) assert ( img('

    text

    ', url=url) == "![a title text](http://a.b/a.b/test.jpg)" ) assert ( handle_image( html.fromstring( 'text' ) ) is None ) # CNN example mydoc = html.fromstring( 'Harry and Meghan last March, in their final royal engagement.' ) myimage = handle_image(mydoc) assert myimage is not None assert "alt" in myimage.attrib assert "src" in myimage.attrib # modified CNN example mydoc = html.fromstring( 'Harry and Meghan last March, in their final royal engagement.' ) myimage = handle_image(mydoc) assert myimage is not None assert "alt" in myimage.attrib assert "src" in myimage.attrib assert myimage.get("src").startswith("http") def test_links(): """Test link extraction function""" options = core.Extractor(config=ZERO_CONFIG) assert handle_textelem(etree.Element("ref"), [], options) is None assert handle_formatting(html.fromstring('Test link text.'), options) is not None # empty link mydoc = html.fromstring("

    Some text.

    ") assert extract(mydoc) is not None # link with target mydoc = html.fromstring( '

    Test link text. This part of the text has to be long enough.

    ' ) assert "testlink.html" not in extract(copy(mydoc)) assert "[Test link text.](testlink.html) This part of the text has to be long enough." in extract( copy(mydoc), include_links=True, fast=True, config=ZERO_CONFIG ) # relative link conversion assert "[Test link text.](https://www.example.com/testlink.html) This part of the text has to be long enough." in extract( copy(mydoc), url="https://www.example.com/", include_links=True, fast=True, config=ZERO_CONFIG ) # malformed target or page URL, target kept as is mydoc = html.fromstring( '

    Test link text. This part of the text has to be long enough.

    ' ) for url in ("https://www.example.com/", "https://ex]ample.org/"): assert "[Test link text.](http://[::1) This part of the text has to be long enough." in extract( copy(mydoc), url=url, include_links=True, fast=True, config=ZERO_CONFIG ) # link without target mydoc = html.fromstring( "

    Test link text. This part of the text has to be long enough.

    " ) assert "[Test link text.] This part of the text has to be long enough." in extract( copy(mydoc), include_links=True, fast=True, config=ZERO_CONFIG ) mydoc = html.fromstring( "" ) result = extract(copy(mydoc), output_format="xml", include_links=True, fast=True, config=ZERO_CONFIG) assert "1" in result assert "2" in result assert "3" in result with open(path.join(RESOURCES_DIR, "http_sample.html"), encoding="utf-8") as f: teststring = f.read() assert "testlink.html" not in extract(teststring, config=ZERO_CONFIG) assert "[link](testlink.html)" in extract(teststring, include_links=True, fast=True, config=ZERO_CONFIG) assert 'link' in extract( teststring, include_links=True, fast=True, output_format="xml", config=ZERO_CONFIG ) # test license link mydoc = html.fromstring('

    Test text under CC BY-SA license.

    ') assert 'license="CC BY-SA license"' in extract( mydoc, include_links=True, fast=True, output_format="xml", config=ZERO_CONFIG, with_metadata=True ) # link in p, length threshold mydoc = html.fromstring(f"") assert "abc" in extract(copy(mydoc), fast=True, config=ZERO_CONFIG, favor_precision=False) assert extract(mydoc, fast=True, config=ZERO_CONFIG, favor_precision=True) == "" def test_tei(): """test TEI-related functions""" # open local resources to avoid redownloading at each run with open(path.join(RESOURCES_DIR, "httpbin_sample.html"), encoding="utf-8") as f: teststring = f.read() # download, parse and validate simple html file result1 = extract(teststring, "mocked", fast=True, output_format="xmltei", tei_validation=False) result2 = extract(teststring, "mocked", fast=True, output_format="xmltei", tei_validation=True) assert result1 is not None assert result1 == result2 assert xml.validate_tei(etree.fromstring(result1)) is True assert xml.validate_tei(etree.fromstring(teststring)) is False # test with another file with open(path.join(RESOURCES_DIR, "http_sample.html"), encoding="utf-8") as f: teststring = f.read() # download, parse and validate simple html file result = extract(teststring, "mocked", fast=True, include_comments=True, output_format="xmltei", tei_validation=False) assert result is not None # and '

    license

    ' in result assert xml.validate_tei(etree.fromstring(result)) is True result = extract(teststring, "mocked", fast=True, include_comments=False, output_format="xmltei", tei_validation=False) assert result is not None # and '

    license

    ' in result assert xml.validate_tei(etree.fromstring(result)) is True # include ID in metadata result = extract(teststring, "mocked", fast=True, output_format="xmltei", tei_validation=False, record_id="0001") assert result is not None assert xml.validate_tei(etree.fromstring(result)) is True # test header + metadata tei = etree.Element("TEI", xmlns="http://www.tei-c.org/ns/1.0") header = etree.SubElement(tei, "teiHeader") docmeta = Document() docmeta.categories, docmeta.tags = [], [] docmeta.title = "Title" assert xml.write_fullheader(header, docmeta) is not None docmeta.sitename = "Site Name" docmeta.date = "2021-01-01" assert xml.write_fullheader(header, docmeta) is not None docmeta.date = None assert xml.write_fullheader(header, docmeta) is not None docmeta.hostname = "hostname" assert xml.write_fullheader(header, docmeta) is not None docmeta.sitename = None docmeta.license = "CC BY-SA" docmeta.url = "https://test.org/" docmeta.categories = ["cat1", "cat2"] assert xml.write_fullheader(header, docmeta) is not None docmeta.date = "2021-01-01" assert xml.write_fullheader(header, docmeta) is not None docmeta.title, docmeta.sitename = None, None assert xml.write_fullheader(header, docmeta) is not None htmlstring = html.fromstring("

    text

    ") extracted = extract(htmlstring, url="mocked", fast=True, output_format="xmltei", config=ZERO_CONFIG) assert xml.validate_tei(etree.fromstring(extracted)) is True htmlstring = html.fromstring("

    title

    subtitle

    text

    ") extracted = extract(htmlstring, url="mocked", fast=True, output_format="xmltei", config=ZERO_CONFIG) assert 'title' in extracted assert 'subtitle' in extracted htmlstring = html.fromstring( """

    content

    • text1
    • text2

    Following paragraph.

    """ ) extracted = extract(htmlstring, url="mocked", fast=True, output_format="xmltei", config=ZERO_CONFIG) assert 'contenttext1' in extracted.replace("\n", "") # merge double elements tree = html.fromstring( """

    content

    """ ) tree = xml.remove_empty_elements(xml.strip_double_tags(tree)) result = sanitize(etree.tostring(tree, encoding="unicode")).replace("\n", "") assert result == "

    content

    " tree = html.fromstring( """

    text

    """ ) xml.strip_double_tags(tree) assert tree.find(".//div/div") is not None assert tree.find(".//p/p") is None tree = etree.XML( """

    text1text2

    text3

    text4

    text5

    text6

    """ ) xml.strip_double_tags(tree) assert tree.find(".//p/p") is None tree = etree.XML( """

    text1text2

    text3

    text4

    text5

    text6

    text7

    """ ) xml.strip_double_tags(tree) assert tree.find(".//p/p") is None assert "text7" in etree.tostring(tree, encoding="unicode") # nested elements with same tag not merged tree = html.fromstring( """

    text

    text1

    text2

    text3

    text4

    """ ) xml.strip_double_tags(tree) for parent_tag in ["item", "cell", "quote", "note", "figure"]: assert tree.find(f".//{parent_tag}/p") is not None @pytest.mark.parametrize( "div,expected", [ ("text", "

    text

    "), ("
    text1

    text2

    ", "

    text1 text2

    "), ("
    text1text2
    ", '

    text1

    text2
    '), ("
    text1

    text2

    has to be there", "

    text1 text2 has to be there

    "), ( "
    text1text2
    has to be there", "

    text1

    text2

    has to be there

    ", ), ("
    text1

    text2

    has to be there
    ", "

    text1 text2 has to be there

    "), ], ) def test_check_tei_loose_text(div, expected): "check_tei wraps loose text and merges it into the neighbouring paragraph." cleaned = xml.check_tei(etree.fromstring(f"
    {div}
    "), "fake_url") assert etree.tostring(cleaned.find(".//body/div"), encoding="unicode") == f"
    {expected}
    " def test_htmlprocessing(options): """test html-related functions""" assert xml.xmltotxt(None, include_formatting=False) == "" options.tables = True assert tree_cleaning(etree.Element("html"), options) is not None assert trafilatura.htmlprocessing.prune_html(etree.Element("unwanted")) is not None mydoc = html.fromstring( 'Link
    UnderlinedTrue TypeTextText' ) options.formatting, options.images, options.links = True, True, True myconverted = convert_tags(mydoc, options) assert myconverted.xpath(".//ref") assert myconverted.xpath(".//graphic") assert myconverted.xpath('.//hi[@rend="#t"]') assert myconverted.xpath(".//table") # multiple images inside a link must keep their original order after being # lifted out of the (addnext reverses order if iterated forward) multi_img = html.fromstring( '' ) options.images, options.links = True, True multi_converted = convert_tags(multi_img, options) srcs = [g.get("src") for g in multi_converted.iter("graphic")] assert srcs == ["a.jpg", "b.jpg", "c.jpg"] options.images, options.tables = True, False myconverted = tree_cleaning(mydoc, options) assert myconverted.xpath(".//graphic") assert not myconverted.xpath(".//table") mydoc = html.fromstring("

    Test headline

    Test

    ") assert 'Test headline' in extract(copy(mydoc), output_format="xml", config=ZERO_CONFIG, fast=True) assert 'Test headline' in extract( copy(mydoc), output_format="xmltei", config=ZERO_CONFIG, fast=True ) # merge with parent function element = etree.Element("test") xml.delete_element(element) assert etree.tostring(element) == b"" element = etree.Element("test") xml.merge_with_parent(element) assert etree.tostring(element) == b"" mydoc = html.fromstring("

    ABC

    ") for element in mydoc.iter("span"): xml.merge_with_parent(element) assert b"

    A B C

    " in etree.tostring(mydoc) mydoc = html.fromstring("

    AB tailC

    ") for element in mydoc.iter("span"): xml.merge_with_parent(element) assert b"

    A B tail C

    " in etree.tostring(mydoc) # paywalls my_html = '

    1

    2

    3

    ' assert extract(my_html, config=ZERO_CONFIG, fast=True) == "1\n3" assert extract(my_html, config=ZERO_CONFIG, fast=False) == "1\n3" # fencedframe fenced_html = "

    real

    ad

    more

    " assert "ad" not in extract(fenced_html, config=ZERO_CONFIG, fast=True) assert "ad" not in extract(fenced_html, config=ZERO_CONFIG, fast=False) fenced_outer = "

    ad

    buy now buy now buy now buy now

    " assert "ad" not in extract(fenced_outer, config=ZERO_CONFIG, fast=False) assert extract(fenced_outer) is None # test tail of node deleted if set as text node = etree.fromstring("

    tail
    ")[0] trafilatura.htmlprocessing.process_node(node, options) assert node.text == "tail" assert node.tail is None node = etree.fromstring("text in tail")[0] trafilatura.htmlprocessing.process_node(node, options) assert node.text == "text in tail" assert node.tail is None line_break = etree.fromstring("

    tail

    ")[0] trafilatura.htmlprocessing.process_node(line_break, options) assert line_break.text is None assert line_break.tail == "tail" node = etree.fromstring("

    some text

    tail
    ")[0] trafilatura.htmlprocessing.process_node(node, options) assert node.text == "some text" assert node.tail == "tail" node = etree.fromstring("

    boldinnerouter

    ")[0] processed = trafilatura.htmlprocessing.handle_textnode(node, options) assert processed.tail == "outer" node = etree.fromstring("

    texttail

    ")[0] processed = trafilatura.htmlprocessing.handle_textnode(node, options) assert processed.tail == "tail" assert processed.text == "text" node = etree.fromstring("

    tail

    ")[0] processed = trafilatura.htmlprocessing.handle_textnode(node, options) assert processed.tail == "" assert processed.text == "tail" node = etree.fromstring("

    textboldtail

    ")[0] processed = trafilatura.htmlprocessing.handle_textnode(node, options) assert processed.tail == "tail" assert processed.text == "text" # fix for bug 807 node = html.fragment_fromstring("

    span span tail

    p tail
    ") assert node.text_content() == "span span tail p tail " prune = etree.XPath(".//span") processed = trafilatura.htmlprocessing.prune_unwanted_nodes(node, [prune]) assert node.text_content() == " span tail p tail " # link_density_test_tables: a text-rich table whose only links carry no text # (e.g. icon/flag links wrapping images) has zero link text -> not boilerplate, kept linkless_table = html.fromstring("" + "word " * 50 + '
    ') assert trafilatura.htmlprocessing.link_density_test_tables(linkless_table) is False # short table (elemlen < 200) is never removed regardless of link ratio assert ( trafilatura.htmlprocessing.link_density_test_tables( html.fromstring("short " + 'link ' * 5 + "
    ") ) is False ) # replace_element_text: an empty (no text) yields an empty string assert xml.replace_element_text(etree.Element("ref"), include_formatting=False) == "" # regression #797: a code block with a tailless must not emit a literal "None" code = etree.fromstring("print(1)") code_md = xml.replace_element_text(code, include_formatting=True) assert "None" not in code_md assert "print(1)" in code_md # handle_paragraphs: a trailing with no tail is stripped from the output para = etree.fromstring("

    textx

    ") processed = handle_paragraphs(para, {"p", "lb"}, options) assert processed is not None assert not processed.findall(".//lb") # handle_paragraphs: non-INLINE_CARRIED children of are stripped with a leading space fmt_opts = core.Extractor(formatting=True) para = etree.fromstring('

    premidend

    ') hi = handle_paragraphs(para, set(TAG_CATALOG), fmt_opts).find("hi") assert hi is not None assert "pre" in hi.text assert " mid" in hi.text para = etree.fromstring('

    starttail

    ') hi = handle_paragraphs(para, set(TAG_CATALOG) | {"lb"}, fmt_opts).find("hi") assert hi is not None assert " tail" in hi.text @pytest.mark.parametrize("widget_words,pruned", [(300, False), (3, True)]) def test_prune_unwanted_nodes_guard(widget_words, pruned): "An over-pruning trip leaves the same subtree in place, recovery matches elements by identity." doc = html.fromstring( "

    real text

    " + "x " * widget_words + "
    " ) node = doc.find(".//div") before = doc.text_content() result = trafilatura.htmlprocessing.prune_unwanted_nodes(node, [etree.XPath(".//div[@class='widget']")], with_backup=True) assert result is node assert result.getparent() is doc.find(".//body") assert (doc.text_content() != before) is pruned def test_extract_restored_subtree_not_extracted_twice(): "A later BODY_XPATH pass sees the restored subtree with its consumed paragraph marked done." hidden = "
    " + "Hidden block sentence. " * 30 + "
    " page = ( "

    Lead paragraph of the story.

    " f"{hidden}

    Outside paragraph one.

    " ) result = extract(page, fast=True, config=ZERO_CONFIG) assert result.count("Lead paragraph of the story.") == 1 assert "Outside paragraph one." in result @pytest.mark.parametrize( "link", ['three plots', 'three plots'], ) def test_readability_div_keeps_inline_link(link): "An inline anchor must not split a div's sentence into separate paragraphs (#585)." sentence = f"The garden team measured {link} before planting the seeds." tree = html.fromstring(f"
    {sentence}
    ") ReadabilityDocument(tree).transform_misused_divs_into_paragraphs() paragraph = tree.find(".//body/p") assert paragraph is not None assert paragraph.text_content() == "The garden team measured three plots before planting the seeds." assert paragraph.find("a").get("href") == "https://example.org/plots" assert paragraph.find("p") is None @pytest.mark.parametrize("block", ["div", "p", "blockquote", "article", "aside", "address"]) def test_readability_div_keeps_linked_blocks(block): "A link wrapping a block still prevents conversion of its containing div to a paragraph." tree = html.fromstring( f'' ) ReadabilityDocument(tree).transform_misused_divs_into_paragraphs() assert tree.find(".//body/div/a") is not None @pytest.mark.parametrize( "content", [ 'Plot measurements', 'Garden planPlot measurements', " caption", " caption", ], ids=["link-only", "link-in-span", "nested-img", "nested-picture"], ) def test_readability_div_keeps_link_wrappers(content): "Containers without loose text, or with a block below an inline child, retain their role in Readability's scoring." tree = html.fromstring(f"
    {content}
    ") ReadabilityDocument(tree).transform_misused_divs_into_paragraphs() assert tree.find(".//body/div") is not None def test_readability_div_tails_keep_order(): "Tails wrapped into paragraphs stay in document order." tree = html.fromstring("

    one

    two

    three

    four
    ") ReadabilityDocument(tree).transform_misused_divs_into_paragraphs() assert [p.text for p in tree.find(".//body/div")] == ["one", "two", "three", "four"] def test_readability_sanitize_drops_all_matches(): "Dropping an element does not stop the iteration over the remaining ones." tree = html.fromstring( '

    a

    b

    ' "
    c
    e
    text
    " ) assert ReadabilityDocument(tree).sanitize(tree, {}) == "
    text
    " def test_readability_unlikely_candidates(): "Unlikely classes are removed case-insensitively, content containers are kept." tree = html.fromstring( '' '' ) ReadabilityDocument(tree).remove_unlikely_candidates() assert [div.text for div in tree.iter("div")] == ["c"] def test_readability_scoring_edge_cases(): "Parentless paragraphs are not scored, list-heavy blocks are removed." paragraph = html.Element("p") paragraph.text = "Some long enough paragraph text. " * 3 assert not ReadabilityDocument(paragraph).score_paragraphs() items = "".join(f"
  • item number {i} with some words
  • " for i in range(101)) tree = html.fromstring(f"
      {items}
    ") assert ReadabilityDocument(tree).sanitize(tree, {}) == "
    " @pytest.mark.parametrize("words,kept", [(150, True), (10, False)]) def test_readability_sanitize_textless_block_between_long_neighbours(words, kept): "A textless block stays only if its nearest non-empty siblings are long." text = "word " * words tree = html.fromstring(f'

    {text}

    {text}

    ') assert ("a.png" in ReadabilityDocument(tree).sanitize(tree, {})) is kept def test_extract_div_keeps_inline_link(): "Exercise the Readability fallback with default size thresholds and self-created HTML." following = ( " before planting the seeds. They recorded the width of each plot, checked the soil, " "and marked every corner with a wooden stake. The measurements will help the volunteers " "leave enough space between rows when they return to plant beans next week." ) document = ( '
    The garden team measured three plots' f"{following}
    " ) assert extract(document, output_format="markdown", include_links=True) == ( f"The garden team measured [three plots](https://example.org/plots){following}" ) result = etree.fromstring(extract(document, output_format="xml", include_links=True)) link = result.find(".//ref") assert link is not None assert link.getparent().tag == "p" assert link.getparent().text == "The garden team measured " assert link.tail == following def test_extraction_options(): """Test the different parameters available in extract() and bare_extraction()""" my_html = '

    Text.

    ' with pytest.raises(ValueError): extract(my_html, output_format="python") assert extract(my_html, config=NEW_CONFIG) is None assert extract(my_html, config=ZERO_CONFIG) is not None assert extract(my_html, only_with_metadata=False, output_format="xml", config=ZERO_CONFIG) is not None assert extract(my_html, only_with_metadata=True, output_format="xml", config=ZERO_CONFIG) is None assert extract(my_html, target_language="de", config=ZERO_CONFIG) is None assert extract(my_html, target_language="de", fast=True, config=ZERO_CONFIG) is None # justext hardening assert etree.tostring(try_justext(html.fromstring(my_html), None, "de")) == b"" assert etree.tostring(try_justext(None, None, "de")) == b"" # assert extract(my_html) is None # readability my_html = "

    " + "Text. " * 10 + "

    " result = etree.tostring(try_readability(html.fromstring(my_html))) assert len(result) > 10 assert b"Text" in result my_html = ( "

    " + "Text. " * 10 + "

    " "

    A caption that is long enough to count.

    Test
    " ) result = etree.tostring(try_readability(html.fromstring(my_html))) assert b"Text" in result assert b"Test" not in result my_html = "" + "

    ABC def ghi jkl.

    " * 1000 + "

    Posted on 1st Dec 2019<.

    " assert bare_extraction(my_html, config=ZERO_CONFIG, with_metadata=True).date is not None assert bare_extraction(my_html, config=NEW_CONFIG, with_metadata=True).date is None assert bare_extraction(my_html, config=NEW_CONFIG, with_metadata=False).date is None @pytest.mark.parametrize( "case", [ { "favor_precision": True, "config": ZERO_CONFIG, "only_with_metadata": True, "url_blacklist": {"https://example.org/blocked"}, "author_blacklist": {"Jane Doe"}, }, # no blacklists here: they would mask a dropped with_metadata {"favor_recall": True, "settingsfile": path.join(RESOURCES_DIR, "newsettings.cfg"), "with_metadata": True}, ], ids=["blacklists", "settingsfile"], ) def test_extractor_parity(monkeypatch, case): "extract(), extract_with_metadata() and bare_extraction() must translate the same arguments into the same Extractor." built = [] class SpyExtractor(core.Extractor): def __init__(self, **kwargs): super().__init__(**kwargs) built.append(self) monkeypatch.setattr(core, "Extractor", SpyExtractor) # non-default values everywhere, so a dropped argument shows up as a mismatch kwargs = { "url": "https://example.org/page", "fast": True, "include_comments": False, "output_format": "xml", "target_language": "en", "include_tables": False, "include_images": True, "include_formatting": True, "include_links": True, "deduplicate": LRUCache(maxsize=2), "date_extraction_params": {"extensive_search": False}, **case, } bare_kwargs = dict(kwargs) if "settingsfile" in bare_kwargs: bare_kwargs["config"] = use_config(bare_kwargs.pop("settingsfile")) my_html = "

    Some text.

    " extract(my_html, **kwargs) bare_extraction(my_html, **bare_kwargs) if "only_with_metadata" not in kwargs: extract_with_metadata(my_html, **{k: v for k, v in kwargs.items() if k != "with_metadata"}) assert len(built) == (2 if "only_with_metadata" in kwargs else 3) # config objects differ with settingsfile, the values read from them are slots for other in built[1:]: for slot in core.Extractor.__slots__: if slot != "config": assert getattr(built[0], slot) == getattr(other, slot), slot def test_precision_recall(): """test precision- and recall-oriented settings""" # the test cases could be better my_document = html.fromstring("

    This here is the text.

    ") assert extract(copy(my_document), favor_precision=True, config=ZERO_CONFIG, fast=True) is not None assert extract(copy(my_document), favor_recall=True, config=ZERO_CONFIG, fast=True) is not None my_document = html.fromstring( '

    This here is a teaser text.

    This here is the text.

    ' ) assert "teaser text" in extract(copy(my_document), favor_recall=True, config=ZERO_CONFIG, fast=True) assert "teaser text" not in extract(copy(my_document), config=ZERO_CONFIG, fast=True) assert "teaser text" not in extract(copy(my_document), favor_precision=True, config=ZERO_CONFIG, fast=True) my_document = html.fromstring( '' ) result = extract(copy(my_document), favor_recall=True, config=ZERO_CONFIG, fast=True) assert "1" not in result result = extract(copy(my_document), favor_precision=True, config=ZERO_CONFIG, fast=True) assert "1" not in result my_document = html.fromstring( '

    content

    ' ) result = extract(copy(my_document), favor_precision=False, config=ZERO_CONFIG, fast=True) assert "content" in result assert "Test" in result result = extract(copy(my_document), favor_precision=True, config=ZERO_CONFIG, fast=True) assert "content" in result assert "Test" not in result my_document = html.fromstring("
    ") result = extract(copy(my_document), favor_recall=False, config=ZERO_CONFIG, fast=True) assert result != "Here is the text." result = extract(copy(my_document), favor_recall=True, config=ZERO_CONFIG, fast=True) assert result == "Here is the text." my_document = html.fromstring("

    Title

    Text.
    ") result = extract(copy(my_document), favor_recall=True, config=ZERO_CONFIG, fast=False) assert len(result) > 0 my_document = html.fromstring("
    Text.
    ") assert extract(copy(my_document), favor_precision=True, fast=True, config=ZERO_CONFIG) == "" assert extract(copy(my_document), favor_recall=True, fast=True, config=ZERO_CONFIG) == "Text." def test_url_blacklist(): "A document whose canonical URL is blacklisted is rejected." doc = '

    Some real article body text here.

    ' assert extract(doc, config=ZERO_CONFIG) is not None assert extract(doc, url_blacklist={"https://example.org/page"}, config=ZERO_CONFIG) is None def test_recover_wild_text_default_tags(): "recover_wild_text with default tags in recall mode must not crash (frozenset .update())" options = core.Extractor(recall=True) tree = html.fromstring("
    some wild text outside the main frame, long enough to be recovered
    ") result = recover_wild_text(tree, etree.Element("body"), options) assert result is not None _MED_REF = '' + "x" * 250 + "" _BIG_REF = '' + "x" * 600 + "" # 60% link density at ~600 chars: kept by the 0.8 (medium) threshold, removed by the 0.5 (large) threshold _STRADDLE_REF = '' + "x" * 360 + "" @pytest.mark.parametrize( "html_str,expected", [ # medium table (<1000 chars): 80% link threshold (f"{'y' * 50}{_MED_REF}
    ", True), # 83% links → removed (f"{'y' * 200}{_MED_REF}
    ", False), # 56% links → kept # straddle table (500-999 chars): 60% links — kept by 0.8 threshold, removed by 0.5 (guards the 1000-char boundary) (f"{'y' * 240}{_STRADDLE_REF}
    ", False), # 60% links, ~600 chars → kept # large table (>=1000 chars): 50% link threshold (f"{'y' * 400}{_BIG_REF}
    ", True), # 60% links → removed (f"{'y' * 600}{_BIG_REF}
    ", False), # 40% links → kept ], ) def test_link_density_tables_threshold(html_str: str, expected: bool) -> None: assert trafilatura.htmlprocessing.link_density_test_tables(html.fromstring(html_str)) is expected def test_link_density_tables_textless_links_kept() -> None: "A text-rich table whose only own-depth ref is a textless icon link is not boilerplate (#lineups)." # 250 chars of real text + one image-wrapping link with no text -> elemnum 0, must be kept icon_table = html.fromstring( "" + "data " * 50 + '
    ' ) assert trafilatura.htmlprocessing.link_density_test_tables(icon_table) is False _HEADLINES = "".join(f'Latest news headline number {i} about some topic today ' for i in range(20)) _LISTING = "".join(f'

    Instruktionsbok MC 258 part {i}

    ' for i in range(14)) _CARD = "Align: a widget that aligns its child within itself and optionally sizes itself based on the child's given size" @pytest.mark.parametrize( "items,short,expected", [ # 100 to 150 chars of recommendation links (mixed.de): the length limit stays at 100 pytest.param( "".join(f'Recommended product number {i}: a nice gadget ' for i in range(3)), True, False, id="short-link-list-kept", ), # a big "latest news" sidebar, above limitlen pytest.param(_HEADLINES, False, True, id="584-large-link-farm-pruned"), # one long link per catalog card (avg link text >= 100 chars) is content pytest.param( "".join(f'{_CARD} ' for i in range(8)), False, False, id="584-whole-card-links-kept" ), # a document listing, one short link per paragraph pytest.param(_LISTING, False, False, id="900-paragraph-listing-kept"), # a farm wrapped in a single paragraph buys no exemption pytest.param(f"

    {_HEADLINES}

    ", False, True, id="900-links-sharing-a-paragraph-pruned"), # the exemption is all-or-nothing: one paragraph with two links makes it a farm again pytest.param( _LISTING + '

    Also see this page and this other one

    ', False, True, id="900-listing-with-one-shared-paragraph-pruned", ), ], ) def test_link_density_test(items, short, expected): "Link-density verdict on a div followed by a content sibling (#584, #900)." element = html.fromstring(f"
    {items}

    real article sibling here

    ")[0] length = len(trim(element.text_content())) assert 100 < length < 150 if short else length > 300 assert trafilatura.htmlprocessing.link_density_test(element)[0] is expected def test_delete_by_link_density_backtracking(): "a short div with 3+ children and some link text is not dense, but backtracking still drops it" htmlstring = "

    Some intro text

    More words

    A link text
    " delete = trafilatura.htmlprocessing.delete_by_link_density assert delete(html.fromstring(htmlstring), "div").find(".//div") is not None assert delete(html.fromstring(htmlstring), "div", backtracking=True).find(".//div") is None def test_overall_discard_legacy_tokens(): "regression on the legacy single-PR discard tokens, each decided by a full-WMB single-token A/B \ (see xpaths.py audit note): 'yin' STAYS (net-positive despite English '-ying'/'y+Info' \ collisions -- a prior removal was reverted after a sign-error), 'xg1' was REMOVED (net-negative \ -- it discarded the real content of a Chinese forum page to nothing)." for cls in ("yin", "zlylin", "mol-factbox"): assert _xpath_hits(xp.OVERALL_DISCARD_XPATH, f'class="{cls}"'), cls assert not _xpath_hits(xp.OVERALL_DISCARD_XPATH, 'class="xg1"') # removed 2026-07-10 def test_overall_discard_matches_both_attributes(): "regression: discard tokens must match whichever of id/class carries them, regardless of \ attribute order in the source (re:test(@id|@class,...) alone only tests the source-FIRST one). \ Exception: 'cookie' stays first-attribute-only -- pages about cookies carry it on real content." discarded = partial(_xpath_hits, xp.OVERALL_DISCARD_XPATH) assert discarded('class="x" id="author-box"') # token in @id, class written first assert discarded('id="x" class="sidebar"') # token in @class, id written first assert not discarded('class="hidden-x" id="cookieBanner"') # cookie exception: class-first protects def test_xpath_alt_rejects_empty_group(): "regression: the concept-token composer _alt() must refuse an empty group -- '' would make \ re:test(@id|@class, '') match every element (a sole-token removal would silently over-discard). \ Uses ValueError not assert so the guard survives python -O." assert xp._alt(("a", "b")) == "a|b" with pytest.raises(ValueError): xp._alt(()) def test_precision_discard_link_token_only(): "regression: PRECISION_DISCARD_XPATH matches 'link' as a whole class TOKEN, not a bare \ substring (2026-07-11). class='link' is still dropped (intended, cf. test_precision_recall), \ but compound classes containing 'link' (permalink/headline-link/featured-link) must NOT be \ -- the bare substring discarded real content in precision mode. 'bottom'/'header' unchanged." discarded = partial(_xpath_hits, xp.PRECISION_DISCARD_XPATH) assert discarded('class="link"') # standalone token still dropped (intended) assert discarded('class="nav link"') # token among others assert not discarded('class="article-permalink"') # compound: no longer dropped assert not discarded('class="headline-link"') assert not discarded('class="featured-link--wrap"') assert discarded('class="article-bottom"') # 'bottom' still matched at a token boundary assert discarded('class="site-header"', "header") # .//header still discarded def test_precision_discard_bottom_token_boundary(): "regression: PRECISION_DISCARD_XPATH must match 'bottom' only where it starts or ends a \ class token. As a bare substring it also matched CSS spacing utilities -- alsumaria.tv wraps \ the article column in 'Padding-bottom-lg-30', so precision mode discarded the whole article." discarded = partial(_xpath_hits, xp.PRECISION_DISCARD_XPATH) # page-bottom chrome: still discarded, whichever end of the token 'bottom' sits at assert discarded('class="bottom"') assert discarded('class="article-bottom"') assert discarded('class="bottom-bar"') assert discarded('id="bottom"') assert discarded('class="wrapper bottom"') # spacing/border utilities on real content: 'bottom' mid-token, must NOT be discarded assert not discarded('class="Padding-bottom-lg-30"') assert not discarded('class="col-8 Padding-bottom-lg-30 floatL"') assert not discarded('class="border-bottom-0"') assert not discarded('class="mb-bottom-3"') def test_wrapper_form_kept(): "regression: ASP.NET WebForms and similar wrap the entire page in one
    . \ tree_cleaning() deletes every form, so on those sites (alsumaria.tv) the whole document was \ discarded and extraction fell back to unrelated sidebar chrome. A form holding most of the \ text is a layout wrapper and must survive; genuine widget forms must still be removed." article = "

    " + "This is the actual article body with enough text to be extracted. " * 4 + "

    " options = core.Extractor(config=ZERO_CONFIG) # the wrapper form survives cleaning, so the content is still there to be selected -- # without this the whole document was gone and only the recovery path could save anything wrapped = f'
    {article}
    ' cleaned = tree_cleaning(load_html(wrapped), options) assert "the actual article body" in cleaned.text_content() assert cleaned.find(".//form") is None # demoted, not left as a form assert "the actual article body" in extract(wrapped, config=ZERO_CONFIG) # a search/newsletter widget alongside real content is still removed outright with_widget = f'
    {article}

    Search this site

    ' cleaned = tree_cleaning(load_html(with_widget), options) assert "the actual article body" in cleaned.text_content() assert "Search this site" not in cleaned.text_content() # MANUALLY_CLEANED is mutated in place by users (cf. issue #746 and docs/settings.rst): # taking "form" out must keep widget forms instead of raising on the internal removal MANUALLY_CLEANED.remove("form") try: cleaned = tree_cleaning(load_html(with_widget), options) assert "Search this site" in cleaned.text_content() finally: MANUALLY_CLEANED.insert(5, "form") assert "form" in MANUALLY_CLEANED def test_no_duplicate_paragraph_from_lb_tail(): "A text-bearing div moved into result_body is not revisited (alsumaria.tv), whitespace-only tails are dropped." html = ( "
    " "First paragraph of the article, long enough to be kept by the extractor.
    " '
    ' "Second paragraph of the article, also long enough to be kept here." "
    " ) result = extract(html, config=ZERO_CONFIG) assert result.count("Second paragraph of the article") == 1 assert "First paragraph of the article" in result # no blank, space-filled lines from the pruned block's indentation assert not any(line and line.isspace() for line in result.split("\n")) def test_lb_only_subtree_stays_one_paragraph(): "A body subtree holding only is emitted as one paragraph, not one

    per line." html_str = ( '

    ' "
    First line of text long enough to be kept by the extractor here." "
    Second line of text long enough to be kept by the extractor here." "
    " ) result = extract(html_str, config=ZERO_CONFIG, output_format="xml") assert result.count("

    ") == 1 assert result.count("") == 2 def test_extract_deep_caller_supplied_tree(): "Caller-supplied trees have no depth cap, so the content walk must not recurse." tree = html.fromstring("

    root text

    ") node = tree.find("body") for _ in range(3000): node = etree.SubElement(node, "div") node.text = "Deep text long enough to be kept by the extractor, appearing at the very bottom." result = extract(tree, config=ZERO_CONFIG) assert "Deep text long enough" in result def test_extract_leaves_caller_tree_intact(): "A caller-supplied tree must come back unchanged, prune_xpath included." tree = html.fromstring("

    First paragraph.

    Second paragraph.

    ") before = html.tostring(tree) result = extract(tree, prune_xpath="//p[1]", config=ZERO_CONFIG) assert result is not None assert "First paragraph" not in result assert html.tostring(tree) == before @pytest.mark.parametrize( "block", [ "
    • Improve the tram network
    • Expand the bicycle lanes
    ", "
    Budget40
    Staff12
    ", "

    We will deliver the plan on time and on budget.

    ", "
    total = sum(values)
    ", ], ) def test_text_after_block_is_kept(block): "regression: text directly after a list, table, quote or code block (its tail) was dropped: \ _extract() rebuilds those blocks as new elements and left the source tail behind. It is now \ kept as a paragraph of its own, exactly once." html = ( "

    Headline

    " f"

    {'The committee reviewed the proposal over several sessions. ' * 4}

    " f"{block}These totals include every department that submitted figures in time." f"

    {'The findings were published together with the underlying data. ' * 4}

    " "
    " ) result = extract(html, config=ZERO_CONFIG) assert result.count("These totals include every department") == 1 def test_text_after_inline_code_not_duplicated(): "GH#951: inline code in a div is copied whole, tail included, so the tail must not also be emitted as a paragraph of its own." html = ( "
    " f"

    {'The committee reviewed the proposal over several sessions. ' * 4}

    " "
    Tool events such as PreToolUse and PostToolUse fire the same hooks.
    " f"

    {'The findings were published together with the underlying data. ' * 4}

    " "
    " ) result = extract(html, config=ZERO_CONFIG) assert "PreToolUse and PostToolUse fire the same hooks." in result assert result.count("fire the same hooks") == 1 def test_body_xpath_fulltext_class(): "GH#780: BODY_XPATH's fulltext-class rule (re:test(@class,'fulltext','i'), replacing an \ obscure translate()-based case-fold hack) must still match every capitalization of a \ genuine 'fulltext' content class. #780's actual complaint -- the bare substring ALSO \ matching a wrapper div ('FulltextWrapper') that isn't the content itself -- is NOT fixed \ here: word-boundary anchoring can't separate that from a legitimate 'FullText' class, since \ both are equally well-bounded tokens (documented limitation, not a regression)." for cls in ("fulltext", "FullText", "fullText", "FULLTEXT", "article-fulltext"): assert _xpath_hits(xp.BODY_XPATH, f'class="{cls}"'), cls # #780's reported false positive: still matches (documented, not fixed by this change) assert _xpath_hits(xp.BODY_XPATH, 'class="FulltextWrapper"') def test_basic_cleaning_cookie_banner_scope(): "regression: BASIC_CLEAN_XPATH's cookie/consent tokens are ANCHORED banner/CMP compounds, not \ bare substrings -- the substrings matched WP Cookie Notice BODY classes (cookies-not-set) and \ topical content classes, deleting up to 97% of a page from baseline()/html2txt() and zeroing \ the escalation-gate denominator. Banner containers (incl. CMP vendors) must still be pruned." content = "

    " + "Real article text about a subject. " * 5 + "

    " banners = ( "" "" ) doc = html.fromstring( "" f"{banners}" ) _, text, _ = trafilatura.baseline(doc) # body class + topical content class survive (the old substrings deleted both) assert "Real article text" in text assert "cookies" not in text # banner containers pruned (substring lives only in banner prose) page_measure = html2txt(doc) assert "Real article text" in page_measure assert "cookies" not in page_measure def test_cell_context_follows_real_ancestry(): "Cell handling must follow real ancestry, not 'a cell exists somewhere' (#767)." tree = etree.fromstring("

    inside

    outside

    ") assert xml.xmltotxt(tree, include_formatting=True) == "| inside | \n\noutside\n" assert xml.xmltotxt(tree, include_formatting=False) == "| inside | \noutside" _COLSPAN_CONTENT_BASE = "abc" _COLSPAN_CONTENT_ROW = "a

    b

    c

    " @pytest.mark.parametrize( "rows,expected", [ (1, "| a | b | c | \n| a | b c | |"), (2, "| a | b | c | \n| a | b c | | \n| a | b c | |"), ], ids=["one-colspan-row", "two-colspan-rows"], ) def test_table_colspan_content(rows, expected): "A colspan=2 cell with block children is flattened; the placeholder fills to max_cols." html_str = ( f"
    {_COLSPAN_CONTENT_BASE}{_COLSPAN_CONTENT_ROW * rows}
    " ) assert extract(html_str, fast=True, output_format="txt", config=ZERO_CONFIG, include_tables=True) == expected @pytest.mark.parametrize( "table,recall,expected", [ # comments/PIs in a have a read-only .tag ("
    cell text
    ", False, "cell text"), ("
    visible
    ", False, "visible"), ( "
    cell1cell2
    cell3cell4
    ", False, "cell1cell2cell3cell4", ), ( "

    text

    more text

    ", False, "

    text

    more text

    ", ), ("
    text

    more text

    ", False, "text

    more text

    "), # the ref stays in the cell, a

    wrapper detached it (#794) ( "
    link
    ", False, 'link', ), ( "
    MonthDays
    January31
    ", False, 'MonthDaysJanuary31', ), ( "
    NamePhone
    Jp1p2
    ", False, 'NamePhoneJp1p2', ), ( "
    highlighted text
    ", False, "highlighted text", ), # only the outer table is processed, the extraction walk re-extracts the inner one (T7) ("
    \n
    1
    \n
    ", False, "\n"), ( "
    \n
    1
    \n
    text1
    text2
    ", False, "\ntext1text2", ), ( "
    A
    inner
    AFTER
    ", False, "AAFTER", ), # the tail after a nested table is kept, after the cell text or its last child ( "
    before
    i
    after
    ", False, "before after", ), ( "
    struck
    i
    after
    ", False, "struck after", ), # lists in cells are only kept in recall mode ( "

    a list

    one
    ", False, "

    a list

    ", ), ( "

    a list

    one
    ", True, "

    a list

    one
    ", ), # broken tables: orphan cells, a

    in a , no at all, stray nested cells ( "
    cell1
    cell2
    ", False, "cell1cell2", ), ( "
    a
    b
    c
    ", False, "abc", ), ("

    text

    cell
    ", False, "cell"), ("
    ab
    ", False, "ab"), ("
    inner
    ", False, "inner"), # colgroup/col are layout hints, a whitespace-only caption is skipped ( "
    ab
    ", False, "ab", ), ("
    xy
    ", False, "xy"), ( "
    orphan
    normal
    ", False, "orphannormal", ), ("
    x
    ", False, "x"), ], ) def test_handle_table(table, recall, expected): "handle_table output structure on small and broken tables." assert etree.tostring(_handle_table(table, recall), encoding="unicode") == f"{expected}
    " def test_table_processing(): # complex table that hasn't been cleaned yet htmlstring = html.fromstring( """

    """ ) processed = extract(htmlstring, fast=True, output_format="xml", config=ZERO_CONFIG, include_links=True) result = processed.replace("\n", "").replace(" ", "") assert ( """textmore_textlinktext
    """ in result ) # multi-row table with in every cell must keep all rows and link text # (regression: handle_formatting detached the ref mid-iteration, collapsing # subsequent rows into wild text, see issue #794) multi_row_links = """""" md = extract(multi_row_links, output_format="markdown", include_tables=True, include_links=True, config=ZERO_CONFIG) for key, href, value in [("Coord", "/k1", "48 N"), ("State", "/k2", "BW"), ("Region", "/k3", "Stuttgart")]: assert f"[{key}]({href})" in md assert value in md # tables with nested elements htmlstring = """
    Present Tense I buy you buy he/she/it buys we buy you buy they buy
    """ my_result = extract(htmlstring, fast=True, output_format="xml", include_formatting=True, config=ZERO_CONFIG) assert ( """ Present Tense I buy you buy he/she/it buys we buy you buy they buy """ in my_result ) assert extract(htmlstring, fast=True, output_format="txt", config=ZERO_CONFIG).startswith( "| Present Tense | I buy | you buy |" ) # table with links table = '
    ' + "ABCD" * 100 + "
    " result = _extract_doc(table, intro=False, fast=True, output_format="xml", include_tables=True, include_links=True) assert "ABCD" not in result # malformed source: and inner are siblings, not parent/child; # pipeline extracts them as two separate flat tables, not one nested structure table = "
    1
    2
    " result = _extract_doc(table, intro=False, fast=True, output_format="xml", include_tables=True) assert '1' in result assert "2" in result # table nested in figure https://github.com/adbar/trafilatura/issues/301 table = "
    1
    2
    " result = _extract_doc(table, intro=False, fast=True, output_format="xml", include_tables=True) assert "1" in result assert "2" in result # table headers in non-XML formats table = "
    head 1head 2
    12
    " assert "|---|---|" in _table_txt(table) # regression: a header row's separator must match the column count even with no body row (was "|---|") single_header = "
    abc
    " assert "|---|---|---|" in _table_txt(single_header) # remove new lines in table cells in text format table = "
    cell
    1
    cell

    2

    " assert "| cell 1 | cell 2 |" in _table_txt(table) # only one header row is allowed in text format table = "
    ab
    cd
    " assert _table_txt(table).count("---|") == 2 # colspan/span handling lives in test_table_colspan_* (parametrized) # links: this gets through (for now) table = '
    a
    ' assert _table_txt(table) == "| a |" # link: this is filtered out assert _table_txt(f'
    {"abc" * 100}
    ') == "" assert _table_txt(f'
    {" " * 100}
    ') == "" # image markdown tests live in test_table_image_in_cell (parametrized) _IMG_URL = "http://aa.bb/c.jpg" _IMG_TABLE_HEADER = "abc" _IMG_TABLE_TAIL = "

    b

    c

    d" @pytest.mark.parametrize( "cell1,expected_cell1", [ ( f'aimga', f"a ![img]({_IMG_URL}) a", ), ( f'
    imga', f"![img]({_IMG_URL}) a", ), ( f'imga', f"![img]({_IMG_URL}) a", ), ( f'img1aimg2', f"![img1]({_IMG_URL}) a ![img2]({_IMG_URL})", ), ], ids=["text-img-span", "linked-img-span", "img-span", "two-imgs"], ) def test_table_image_in_cell(cell1, expected_cell1): "Images in table cells render as GFM inline images; link wrapping is preserved." html_str = ( f"
    {_IMG_TABLE_HEADER}{cell1}{_IMG_TABLE_TAIL}
    " ) result = extract( html_str, fast=True, output_format="markdown", config=ZERO_CONFIG, include_images=True, include_tables=True ) assert result == f"| a | b | c | \n| {expected_cell1} | b c | d |" # link + bold paragraph, plus a table cell with a link/bold and an image cell — shared by the combo tests _COMBO_DOC = ( "" ) _COMBO_ALL_ON = {"include_links": True, "include_formatting": True, "include_images": True, "include_tables": True} def test_combined_links_formatting_images_tables(): "All four flags on: cells keep formatting + images, and a link wrapping formatting keeps its target." result = extract(_COMBO_DOC, output_format="markdown", config=ZERO_CONFIG, **_COMBO_ALL_ON) assert result == ( "Intro with a [link](http://x.io/p) and **bold** word.\n\n" "| h1 | h2 | \n| [**bold link**](http://x.io/c) | ![pic](http://x.io/i.jpg) |" ) @pytest.mark.parametrize( "off,expected", [ ( "include_links", "Intro with a link and **bold** word.\n\n| h1 | h2 | \n| **bold link** | ![pic](http://x.io/i.jpg) |", ), ( "include_formatting", "Intro with a [link](http://x.io/p) and bold word.\n| h1 | h2 | \n| [bold link](http://x.io/c) | ![pic](http://x.io/i.jpg) |", ), ( "include_images", "Intro with a [link](http://x.io/p) and **bold** word.\n\n| h1 | h2 | \n| [**bold link**](http://x.io/c) | |", ), ("include_tables", "Intro with a [link](http://x.io/p) and **bold** word."), ], ) def test_combined_flags_toggle_off(off, expected): "Pin current behavior: disabling one flag changes only its feature (combined doc)." result = extract(_COMBO_DOC, output_format="markdown", config=ZERO_CONFIG, **{**_COMBO_ALL_ON, off: False}) assert result == expected # first-row tail cell + a 3-cell second row that sets the table to 3 columns; # the first row fills to 3 via a colspan placeholder or a trailing pad depending on the span _COLSPAN_ROWS = "bcde" def test_table_colspan_padding(): "A colspan=2 cell materializes an inline placeholder; the row fills to the table's column count." assert "| a | | b |" in _table_txt(f"{_COLSPAN_ROWS}") @pytest.mark.parametrize("bad_span", ['span="2"', 'span="2.1"', 'span="-1"', 'span="abc"']) def test_table_bad_span_attr_treated_as_colspan1(bad_span): "Non-colspan span attributes are ignored (colspan=1); trailing pad fills to max_cols." assert "| a | b | |" in _table_txt(f"
    a
    {_COLSPAN_ROWS}") @pytest.mark.parametrize( "first_cell", ['', '', ''] ) def test_table_huge_or_bad_colspan_no_crash(first_cell): "Huge or non-numeric colspan must not crash or discard the document (#657)." assert _table_txt(f"
    aaaa
    {first_cell}{_COLSPAN_ROWS}") is not None @pytest.mark.parametrize( "colspan,expected", [("1", 1), ("3", 3), ("12", 12), ("²", 1), ("1²", 1), ("2x", 1), ("", 1), ("-2", 1)], ) def test_colspan_zero_trust(colspan, expected): "_span must default to 1 for non-decimal values (isdigit() admits superscripts that int() rejects)." assert _span(html.fromstring(f''), "colspan") == expected @pytest.mark.parametrize( "table,suffix", [ # a rowspan leaves a placeholder in the continuation row ("", "| x | a | \n| | b |"), # rowspan + colspan registers every spanned column ("", "| | | x |"), # padding a short row consumes the rowspan, no stale placeholder below ( "", "| d | e | f |", ), # an empty still consumes one row of a pending rowspan ("", "| a | b | \n| | c |"), ("", "| a | b | \n| c | d |"), ], ids=["aligned", "colspan-combined", "decrement-on-padding", "through-empty-row", "ends-on-empty-row"], ) def test_table_rowspan(table, suffix): "Rowspan placeholders keep the columns aligned." assert _table_md(f"
    x
    xa
    b
    bigc
    x
    abc
    x
    def
    ab
    c
    ab
    cd
    {table}
    ").endswith(suffix) @pytest.mark.parametrize( "html,suffix", [ ("
    b
    ", "| | b |"), ("
    a
    ", "| a | |"), ("
    ab
    ", "| a | b |"), ( "
    ac
    de
    ", "| a | c | \n| d | e |", ), ( "
    ac
    de
    ", "| a | c | \n| d | e |", ), ], ids=["leading-empty", "trailing-empty", "all-empty-row-dropped", "empty-row-middle", "empty-tr"], ) def test_table_empty_cells_and_rows(html, suffix): "Empty cells are kept blank to keep columns aligned; rows that are entirely empty are dropped." assert _table_md(html).endswith(suffix) # cases from PR #930 (issue #633) @pytest.mark.parametrize("output_format", ["xml", "xmltei"]) @pytest.mark.parametrize( "first_row,expected", [ ("bc", [None, "b", "c"]), ("ac", ["a", None, "c"]), ("ab", ["a", "b", None]), ("ac", ["a", None, "c"]), ("a

    c", ["a", None, "c"]), ], ids=["leading-empty", "middle-empty", "trailing-empty", "colspan-placeholder", "emptied-cell"], ) def test_xml_table_preserves_empty_cells(output_format, first_row, expected): "XML cleanup keeps blank cells so the following values stay in their columns." result = _extract_doc( f"{first_row}
    def
    ", output_format=output_format, include_tables=True, ) rows = etree.fromstring(result).xpath(".//*[local-name()='table']/*[local-name()='row']") assert [[cell.text for cell in row] for row in rows] == [expected, ["d", "e", "f"]] def test_table_cell_list_no_row_break(): "A
      in a cell (no recall mode) must not inject a row-breaking newline." row = _table_md("
      • i1
      • i2
      b
      ").split("\n\n")[-1] assert "\n" not in row assert row.endswith("| b |") @pytest.mark.parametrize( "html,suffix,kwargs", [ ("

      Title

      b
      ", "| Title | b |", {}), ("

      para

      b
      ", "| para | b |", {}), ("

      Title

      txt
      b
      ", "| Title txt | b |", {}), ( "
      • i1
      • i2
      b
      ", "| i1 i2 | b |", {"favor_recall": True}, ), ], ids=["heading", "paragraph", "heading-plus-tail", "list-recall"], ) def test_table_cell_block_elements_flattened(html, suffix, kwargs): "Block elements in a cell are flattened to inline: no ## marker, space-separated, no mashing." assert _table_md(html, **kwargs).endswith(suffix) @pytest.mark.parametrize( "expected,cell,kwargs", [ ("**bold**", "

      bold

      ", {"include_formatting": True}), ("![a](/i.jpg)", "

      a

      ", {"include_images": True}), ("pre **mid** post", "

      pre mid post

      ", {"include_formatting": True}), ("x ~~gone~~ y", "

      x gone y

      ", {"include_formatting": True}), ("x `c` y", "

      x c y

      ", {"include_formatting": True}), ], ) def test_table_cell_keeps_nested_formatting(expected, cell, kwargs): "regression #829/#396: formatting/images wrapped in a block inside a cell must survive." assert expected in _table_md(f"
      {cell}x
      ", **kwargs) @pytest.mark.parametrize("fmt", ["xml", "xmltei"]) def test_table_cell_escaping_stays_out_of_xml(fmt): "GFM cell escaping is markdown-only: a nested element in a cell keeps its literal pipe." doc = ( "
      " f"

      {'A paragraph long enough to clear the minimum extracted size without any trouble. ' * 3}

      " "
      x | yz | w
      " "
      " ) result = extract(doc, output_format=fmt, include_tables=True, include_formatting=True, fast=True) or "" assert "x | y z | w" in result assert "\\|" not in result def test_include_images_does_not_truncate(): "regression #194/#842: a lead image plus one paragraph must not let _extract break early and drop later content." # real config + a >250-char lead so wild-text recovery does not fire and mask the early break lead = "This single lead paragraph is deliberately long enough to exceed the minimum extracted size. " * 4 doc = ( "" f"
      lead

      {lead}

      " "
      " + "".join(f"

      Continuation paragraph {i} that must also survive extraction in full here.

      " for i in range(1, 5)) + "
      " ) result = extract(doc, output_format="txt", include_images=True) or "" assert "/lead.jpg" in result assert all(f"Continuation paragraph {i}" in result for i in range(1, 5)) def test_short_document_fallback_preserves_image(): """A text-only fallback must not erase a requested image when it recovers no text.""" first = ( "Some reasonably long paragraph of body text that trafilatura will keep because it " "is clearly the main content of this document and not navigation furniture." ) second = "A second paragraph that follows the figure and continues the argument at length." doc = ( f"

      {first}

      " 'a chart' f"

      {second}

      " ) result = extract(doc, output_format="xml", include_images=True, include_links=True) or "" assert '' in result # wild-text recovery must not append an image paragraph the main pass already kept img = 'a chart' doc = doc.replace(img, f"

      {img}

      ") for fast in (True, False): result = extract(doc, output_format="xml", include_images=True, fast=fast) or "" assert result.count("
      Intro text
      " 'cell' "

      Short page text.

      " ) for fast in (True, False): result = extract(doc, output_format="xml", include_images=True, fast=fast, favor_recall=True) or "" assert result.count('

      First paragraph with enough words here.

      Some words " 'code more words.
      ' ) result = extract(doc, output_format="xml", include_images=True, include_formatting=True) or "" assert result.count('
      ' '

      A short visible article paragraph below the extraction threshold.

      chart' "
      " ) result = extract(doc, output_format="xml", include_images=True) or "" assert "Recovered canonical article body" in result assert "/
      body (below min_extracted_size) triggers wild-text recovery, # which must not re-append the paragraphs the main pass already collected (interleaved dupes) dup879 = ( "

      The Example Chronicle

      " "

      First synthetic paragraph of adequate length for extraction to engage properly.

      " "

      Second synthetic paragraph, also long enough to matter for the extractor.

      " "
      footer chrome
      " ) for doc in (dup879, dup879.replace("article>", "main>")): out = extract(doc, output_format="txt") or "" assert out.count("First synthetic paragraph") == 1 assert out.count("Second synthetic paragraph") == 1 def _listing_page(entries): "Blog-index markup: sibling
      teasers, each a link-only heading plus its lead paragraph." # each teaser clears min_extracted_size on its own, so only the page-share # test flags the layout as under-extracted and escalates to recall articles = "".join( f'" for i, (title, text) in enumerate(entries) ) return f'
      {articles}
      ' def test_listing_page_keeps_every_entry_heading(): "regression #774: an index page must not collapse to its first entry, losing every other heading." entries = [ ( f"Entry number {i} about a self contained subject", f"Teaser {i} carries enough prose to pass the extractor thresholds on its own, running " f"over several clauses and a couple of sentences. It reads as a lead paragraph would, " f"so nothing about this entry looks like boilerplate to the extractor, and the index " f"page as a whole reads as a real body of text rather than a stub.", ) for i in range(8) ] result = extract(_listing_page(entries), output_format="markdown", config=use_config()) or "" for title, _ in entries: assert f"## {title}" in result def test_article_page_ignores_related_posts_strip(): "the other side of #774: the same shape is a related-posts strip, which must not displace the body." body = " ".join( f"Sentence {i} of the actual article body, long enough that the extractor never escalates." for i in range(8) ) related = "".join(f"" for i in range(3)) doc = ( f'

      The Real Headline

      {body}

      ' f'
      ' ) result = extract(doc, output_format="txt", config=use_config()) or "" assert "Sentence 3 of the actual article body" in result assert "Related teaser 1" not in result def test_short_document_keeps_structure(): "regression #896: the baseline rescue must not flatten a valid short extraction it cannot improve on." doc = ( "

      Notice Title

      " "

      This municipal notice concerns the closure of the main road for repairs.

      " "
      • Detour signposted east
      • Works end on Friday
      " "
      " ) # real config: the doc is below min_extracted_size, so the rescue engages (ZERO_CONFIG hides it) result = extract(doc, output_format="markdown", include_formatting=True) or "" assert "# Notice Title" in result assert "- Detour signposted east" in result def test_short_document_baseline_rescue_accepted(): "the other side of the #896 gate: when the rescue does add text, it must still win." # distinct sentences: a repeated one trips the body-level duplicate_test instead. # The empty keeps the main extractor at zero, so the rescue is what supplies the text. body = " ".join(f"Sentence {i} of a body that arrives only as structured data." for i in range(6)) doc = f'' assert "Sentence 3 of a body" in (extract(doc, output_format="txt") or "") def test_no_duplicate_content_list_item(): "regression T6: a

      already folded into a item by the main pass must not be re-added by \ recover_wild_text — backup_tree is a snapshot taken before the main pass marks consumed tags 'done'." options = core.Extractor() para = "This is a moderately long description paragraph exceeding the fifty character dedup threshold here." htmlstring = f"

      Term

      {para}

      " cleaned = convert_tags(tree_cleaning(html.fromstring(htmlstring), options), options) _, text = extract_content(cleaned, options) assert text.count(para) == 1 def test_no_duplicate_content_nonadjacent(): "regression T6 (non-adjacent case): the duplicate is not the element immediately preceding it in \ result_body once genuine wild text is recovered in between — an adjacent-only dedup check would miss it." dup = "X" * 30 + " short duplicate description text for the list item here right now please." wild = ( "Y" * 30 + " this is genuinely separate wild text living outside the article container elsewhere" " in the page body content over here, quite far removed from it." ) htmlstring = f"

      {wild}

      Term

      {dup}

      " result = extract(htmlstring, output_format="txt", fast=True) or "" assert result.count(dup) == 1 assert result.count(wild) == 1 assert "Term" in result def test_no_duplicate_content_short_elements(): "regression #634: on short pages, recovery must not re-add elements the main pass already \ extracted, even when their text is below the substring-dedup length gate." body = ( '

      this is sample intro

      intro 2

      table below

      ' '
      ' "
      ab
      fss
      gb
      " "

      header table below

      " '
      ' "" "
      bsh
      ab
      fss
      gb
      " "

      list below

      • this is 1
      • this is 2
      • this is 3
      " "

      numbered list below

      1. this is 1
      2. this is 2
      3. this is 3
      " ) doc = f'
      {body}
      ' result = extract(doc, output_format="txt", fast=True) or "" assert result.count("this is sample intro") == 1 def test_recover_wild_text_inline_formatting_dedup(): "regression: the recovery dedup must render accumulated text per element with plain \ concatenation, exactly like the candidates -- ' '.join across all text nodes invented \ spaces at inline-tag boundaries (Hyperlinked -> 'Hyper link ed'), so a paragraph \ folded into a list item escaped the substring check and was emitted twice." para = ( "This paragraph has Hyperlinked formatting inside and needs to be comfortably " "longer than the fifty character dedup gate to be caught by the substring check." ) doc = f"
      Term one

      {para}

      " result = extract(doc, output_format="txt", include_formatting=True, fast=True) or "" assert result.count("formatting inside") == 1 def test_recover_wild_text_dedup_scan_cap(monkeypatch): "regression: DEDUPE_SCAN_CAP gates the O(n) substring scan against `existing` -- a \ >MIN_DUPLICATE_LENGTH-char substring duplicate is caught while `existing` is under the \ cap, and kept (scan skipped, exact-match set is the only check left) once past it." options = core.Extractor(recall=True) container = "The quick brown fox jumps over the lazy dog while the sun was setting slowly over the meadow today." substring_dup = "quick brown fox jumps over the lazy dog while the sun was setting slowly" # substring of container assert substring_dup in container assert len(substring_dup) > me.MIN_DUPLICATE_LENGTH filler = "Zebra quokka platypus wallaby echidna kookaburra numbat bilby quoll dingo marsupial. " * 4 def run(cap): monkeypatch.setattr(me, "DEDUPE_SCAN_CAP", cap) htmlstring = f"
      {filler}
      {container}
      {substring_dup}
      " tree = html.fromstring(htmlstring) result_body = etree.Element("body") me.recover_wild_text(tree, result_body, options) return [trim("".join(el.itertext())) for el in result_body] # cap comfortably covers filler+container: substring scan runs, duplicate dropped assert substring_dup not in run(cap=len(filler) + len(container) + 100) # cap smaller than filler alone: scan is skipped once `existing` passes it, duplicate kept assert substring_dup in run(cap=len(filler) // 4) def test_prune_boilerplate_table_after_nested(): "regression: prune_unwanted_sections must not skip a boilerplate table that follows a deleted \ table containing a nested one (deleting mid tree.iter() made the iterator lose the following sibling)." options = core.Extractor(tables=True) link = 'link text that is reasonably long over here' rows = "".join(f"{link}" for _ in range(6)) nested = "x
      " real = "Real article content paragraph that should always survive the pruning pass intact here now." htmlstring = f"{rows}{nested}
      {rows}

      {real}

      " pruned = prune_unwanted_sections(html.fromstring(htmlstring), {"table", "p"}, options) assert pruned.findall(".//table") == [] assert pruned.find(".//p") is not None def test_prune_strips_span_and_ref(): "spans are always flattened, refs only when links are not kept" options = core.Extractor() htmlstring = '

      One two three four.

      ' pruned = prune_unwanted_sections(html.fromstring(htmlstring), {"p"}, options) assert pruned.find(".//span") is None assert pruned.find(".//ref") is None assert trim(pruned.find(".//p").text_content()) == "One two three four." pruned = prune_unwanted_sections(html.fromstring(htmlstring), {"p", "ref"}, options) assert pruned.find(".//span") is None assert pruned.find(".//ref") is not None def test_prune_keep_teasers(): "regression: keep_teasers spares teaser-class blocks (some sites wrap real article text in them); \ the recovery path uses it in fast mode so content isn't lost after the main extractor already failed." options = core.Extractor(tables=True) htmlstring = ( '

      Real article body text that is long enough ' "to count as genuine content here now.

      " ) assert prune_unwanted_sections(html.fromstring(htmlstring), {"p"}, options).find(".//p") is None assert prune_unwanted_sections(html.fromstring(htmlstring), {"p"}, options, keep_teasers=True).find(".//p") is not None def test_recall_escalation(): "a short balanced extraction covering little of the page is retried in recall mode: \ wild paragraphs block the baseline rescue while the bulk of the content sits in bare divs." ps = "".join( f"

      Wild paragraph number {i} directly under body, with enough words to pass the paragraph checks in place.

      " for i in range(3) ) divs = "".join( f"
      Main content block {i} living in a bare div element with plenty of meaningful words to matter here today.
      " for i in range(30) ) doc = f"{ps}{divs}" result = extract(doc, output_format="txt", fast=True) or "" assert result.count("Wild paragraph") == 3 assert result.count("Main content block") == 30 # shared by the recall-escalation tests: long enough to clear the minimum extraction size _ESCALATION_INTRO = ( "

      " + ( "Introductory paragraph with regular prose content that is moderately long and clearly " "meaningful, providing enough text to exceed the minimum extraction size threshold used " "by the extractor. " * 2 ) + "

      " ) def test_recall_escalation_justext(): "the recall escalation also tries justext, which reaches content the rule-based retry \ cannot: 'sidebar'-classed divs are discarded by both the main extractor/recall retry \ (OVERALL_DISCARD_XPATH) and readability (unlikelyCandidatesRe), but justext classifies \ them on paragraph density alone and keeps them." messages = "".join( f"" for i in range(8) ) doc = f"{_ESCALATION_INTRO}{messages}" result = extract(doc, output_format="txt") or "" assert "Introductory paragraph" in result assert result.count("Message number") == 8 _DFP_JSONLD = ( '' ) def _thread_doc(jsonld: str, reply_tag: str = "div", intro: str = _ESCALATION_INTRO, wrap: bool = True) -> str: "Thread page: article intro plus 8 replies in a comments-classed container." replies = "".join( f"<{reply_tag} class='comment-body'>Reply number {i} contains substantial discussion " "content with plenty of genuine words and enough length to be recognized as real text " f"by a paragraph density classifier, not boilerplate at all today, said the commenter." for i in range(8) ) # unwrapped: no article-region marker at all, so body selection stays broad article = f"
      {intro}
      " if wrap else f"
      {intro}
      " return f"{jsonld}{article}
      {replies}
      " @pytest.mark.parametrize( "jsonld,expect_replies,fast", [ ("", False, False), (_DFP_JSONLD, True, False), ("", False, True), ], ids=["blog_excludes_comments", "forum_keeps_posts", "blog_excludes_comments_fast"], ) def test_recall_escalation_justext_comment_scoping(jsonld, expect_replies, fast): "regression: justext has no article-region scoping of its own and will absorb a reader- \ comments thread as if it were content (AEB regression: a short article inviting comments, \ justext grabbed the replies too) -- comment containers must be stripped before it runs, \ UNLESS this is a thread-forum (schema.org DiscussionForumPosting), where those same \ containers hold the actual posts, not boilerplate replies (WCXB regression fixed by \ _forum_thread_page). The fast=True case is the rule-based retry alone (justext is skipped \ in fast mode): it must apply the same comment-scoping, or a reader-comments thread leaks \ into the body precisely when the caller asked to exclude comments." doc = _thread_doc(jsonld) result = extract(doc, output_format="txt", include_comments=False, fast=fast) or "" assert "Introductory paragraph" in result assert result.count("Reply number") == (8 if expect_replies else 0) @pytest.mark.parametrize("reply_tag", ["p", "div"], ids=["captured_as_comments", "capture_missed"]) def test_recall_escalation_no_comment_doubling(reply_tag): "regression: with include_comments=True on a thread-forum page, posts must not appear in \ both text and comments (the escalation stage sees the raw tree, whose comment containers \ extract_comments already consumed). Policy: comment capture on a DFP page is re-routed \ into the body (the 'comments' are the posts); partial captures must not lose the rest \ of the thread either (WCXB regression: pruning all containers because a fragment was \ captured tanked 3 forum pages).

      replies are capturable,

      replies are not \ (extract_comments rejects divs) -- either way: exactly one occurrence per post." doc = _thread_doc(_DFP_JSONLD, reply_tag) # txt output concatenates text and comments: exactly one occurrence per post overall result = extract(doc, output_format="txt", include_comments=True) or "" assert "Introductory paragraph" in result assert result.count("Reply number") == 8 _LONG_OP = "".join( f"

      Opening post paragraph {i} lays out the question in full detail, with context, " "history and several worked examples that make the thread starter alone longer than " "the escalation length gate, so no rescue pass can be relied upon for the replies.

      " for i in range(16) ) def test_dfp_long_opening_post_keeps_replies(): "regression (review round 3): the DFP re-route emptied the captured comments betting on \ the stage-4 escalation to restore the posts into the body, but the escalation is gated \ on len_text < ESCALATION_MAX_LENGTH -- a thread whose opening post alone exceeds the \ gate lost every reply from both channels. Captured posts must survive regardless of gates." doc = _thread_doc(_DFP_JSONLD, "p", intro=_LONG_OP) result = extract(doc, output_format="txt", include_comments=True) or "" assert "Opening post paragraph" in result assert result.count("Reply number") == 8 def test_dfp_precision_keeps_posts(): "regression (review round 3): precision mode pruned REMOVE_COMMENTS_XPATH from the very \ tree the forum re-route had just restored the posts into, and precision closes the \ stage-3/4 rescues, so a DFP page returned no posts at all." doc = _thread_doc(_DFP_JSONLD, "p") result = extract(doc, output_format="txt", include_comments=True, favor_precision=True) or "" assert result.count("Reply number") == 8 @pytest.mark.parametrize("fast", [False, True], ids=["full", "fast"]) def test_recall_escalation_blog_comment_leak(fast): "regression (review round 3): on a wrapper-less blog layout (broad body selection) with \ include_comments=True and an uncapturable div-based reply thread, the recall retry kept \ the comment containers (its prune gate checked options.comments, the justext branch \ checked len_comments) and emitted the reader comments as main body text." doc = _thread_doc("", wrap=False) result = extract(doc, output_format="txt", include_comments=True, fast=fast) or "" assert "Introductory paragraph" in result assert result.count("Reply number") == 0 @pytest.mark.parametrize("fast", [False, True], ids=["full", "fast"]) def test_recall_escalation_captured_comment_list(fast): "regression (#881): a captured ol/ul comment list must not come back in the body through the recall escalation." intro = "".join( f"

      Article paragraph {i} stays above the rescue floor but under the escalation gate.

      " for i in range(5) ) replies = "".join( f"
    • Reply number {i} contains substantial discussion content with plenty of genuine words " "and enough length to be recognized as real text, said the commenter.

    • " for i in range(20) ) doc = f"
      {intro}
        {replies}
      " result = extract(doc, output_format="xml", include_comments=True, fast=fast) or "" body, comments = result.split("Point number {i} of the short visible article summary text here." for i in range(4)) cont = "".join( f"

      Continuation paragraph {i} with substantial article prose that only the " "recall retry can recover from inside the form wrapper element.

      " for i in range(12) ) replies = "".join( f"

      Reply {i} ZQXJKVREPLY says something reasonably long about the topic at hand " "in this discussion thread below the article body.

      " for i in range(8) ) doc = ( f"

      Title of the page

        {intro}
      " f"
      {cont}
      {replies}
      " ) result = extract(doc, output_format="txt", include_comments=True, fast=True) or "" assert "Continuation paragraph 5" in result assert "ZQXJKVREPLY" in result def test_main_pass_excludes_comments_when_disabled(): "regression: with include_comments=False, comment containers must be pruned before the MAIN \ pass, not only in precision mode -- balanced/recall let the div-ladder pull a short article's \ comment thread into the body (a bare-div thread the comment extractor cannot capture)." intro = "

      Short intro under the escalation and rescue thresholds here.

      " replies = "".join( f"
      Reader comment number {i} that must never appear when comments are excluded, long enough to matter.
      " for i in range(8) ) doc = f"
      {intro}
      {replies}
      " # WordPress comment lists: still ol/ul on the raw tree items = replies.replace("
      ", "
    • ").replace("
    • ", "") doc_lists = [ f"
      {intro}
      <{tag} class='{cls}'>{items}" for tag, cls in (("ol", "commentlist"), ("ul", "comment-list")) ] for page in (doc, *doc_lists): for fast in (False, True): result = extract(page, output_format="txt", include_comments=False, fast=fast) or "" assert "Short intro" in result assert result.count("Reader comment number") == 0 def test_main_pass_excludes_details_wrapped_comments(): # 850 "regression (#850): comment containers wrapped in
      must also be pruned when \ include_comments=False -- REMOVE_COMMENTS_XPATH matched only div/list/section, so a \
      thread leaked into the body outside precision mode. A
      \ without a comment id/class must be kept (no over-pruning)." body = "
      " + "

      Real article paragraph with enough content to be extracted normally here.

      " * 3 + "
      " comments = ( "
      Comments" + "".join(f"

      Reader comment number {i} that must never leak into the body text.

      " for i in range(6)) + "
      " ) doc = f"{body}{comments}" for fast in (False, True): result = extract(doc, output_format="txt", include_comments=False, fast=fast) or "" assert "Real article paragraph" in result assert result.count("Reader comment number") == 0 # a non-comment
      is legitimate content and must survive faq = "
      More

      Kept expandable content paragraph that is genuine.

      " doc_faq = f"{body}{faq}" assert "Kept expandable content" in (extract(doc_faq, output_format="txt") or "") # "commentary" is content, not reader comments for attr in ("id", "class"): commentary = f"

      Expert commentary paragraph that is part of the article.

      " doc_commentary = f"{body}{commentary}" assert "Expert commentary" in (extract(doc_commentary, output_format="txt", include_comments=False) or "") def test_infinite_scroll_container_with_h1_kept(): "regression: the #911 prune deleted the real article when the scroll container holds the h1 (BGR)." real = "

      Real article paragraph with enough content to be extracted normally here.

      " * 4 appended = "

      Appended follow-up story text that belongs to another article entirely.

      " * 4 for fast in (False, True): doc = ( "
      " f"

      Real title

      {real}
      " ) assert "Real article paragraph" in (extract(doc, output_format="txt", fast=fast) or "") # still pruned: an infinite-scroll container without an h1, an mvp container even with one for cls, head in (("infinite-scroll", "h2"), ("mvp-post-add-box", "h1")): doc = ( f"

      Real title

      {real}
      " f"<{head}>Next story{appended}
      " ) result = extract(doc, output_format="txt", fast=fast) or "" assert "Real article paragraph" in result assert "Appended follow-up" not in result def test_compare_extraction_justext_ratio(monkeypatch): "the justext override must not replace text more than JUSTEXT_OVERRIDE_RATIO times longer \ than its own output (calibrated 3x: at the previous 4x it replaced longer, closer-to-target \ extractions with shorter, comment-contaminated justext output)." jt_text = "j" * 100 jt_body = etree.Element("body") etree.SubElement(jt_body, "p").text = jt_text monkeypatch.setattr(external, "justext_rescue", lambda tree, options: (jt_body, jt_text)) options = core.Extractor() # empty backup keeps readability out; the