From 7568e2a786ac8f06e79e7145d05fe2a3872abc96 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 11 Sep 2026 23:09:08 +0200 Subject: [PATCH] Upstream adbar/trafilatura state of the touched files before 49484caaab --- tests/unit_tests.py | 4387 +++++++++++++++++++++++++++++++++ trafilatura/main_extractor.py | 792 ++++++ trafilatura/xpaths.py | 482 ++++ 3 files changed, 5661 insertions(+) create mode 100644 tests/unit_tests.py create mode 100644 trafilatura/main_extractor.py create mode 100644 trafilatura/xpaths.py diff --git a/tests/unit_tests.py b/tests/unit_tests.py new file mode 100644 index 0000000..977d4f5 --- /dev/null +++ b/tests/unit_tests.py @@ -0,0 +1,4387 @@ +# 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( + """ +

    + + + + + + + +
    + text
    +

    more_text

    +
    linktext
    +
    + """ + ) + 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 = """
    +

    Enough intro text to satisfy trafilatura's minimum extraction length requirements for this test.

    + + + + + +
    KeyValue
    Coord:48 N
    State:BW
    Region:Stuttgart
    +
    """ + 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 TenseI buyyou buyhe/she/it buyswe buyyou buythey 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 = ( + "
    " + '

    Intro with a link and bold word.

    ' + "" + '' + '
    h1h2
    bold linkpic
    ' + "
    " +) +_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 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