# pylint:disable-msg=C0301,E0611,I1101
"""
Functions to process nodes in HTML code.
"""
import logging
from copy import deepcopy
from typing import List, Optional, Tuple
from courlan.urlutils import fix_relative_urls, get_base_url
from lxml.etree import _Element, Element, SubElement, XPath, strip_tags, tostring
from lxml.html import HtmlElement
from .deduplication import duplicate_test
from .settings import (
Document,
Extractor,
CUT_EMPTY_ELEMS,
MANUALLY_CLEANED,
MANUALLY_STRIPPED,
)
from .utils import textfilter, trim, is_image_element
from .xml import META_ATTRIBUTES, delete_element
LOGGER = logging.getLogger(__name__)
REND_TAG_MAPPING = {
"em": "#i",
"i": "#i",
"b": "#b",
"strong": "#b",
"u": "#u",
"kbd": "#t",
"samp": "#t",
"tt": "#t",
"var": "#t",
"sub": "#sub",
"sup": "#sup",
}
HTML_TAG_MAPPING = {v: k for k, v in REND_TAG_MAPPING.items()}
PRESERVE_IMG_CLEANING = {"figure", "picture", "source"}
CODE_INDICATORS = ["{", "(\"", "('", "\n "]
def tree_cleaning(tree: HtmlElement, options: Extractor) -> HtmlElement:
"Prune the tree by discarding unwanted elements."
# determine cleaning strategy, use lists to keep it deterministic
cleaning_list, stripping_list = MANUALLY_CLEANED.copy(), MANUALLY_STRIPPED.copy()
if not options.tables:
cleaning_list.extend(["table", "td", "th", "tr"])
else:
# prevent this issue: https://github.com/adbar/trafilatura/issues/301
for elem in tree.xpath(".//figure[descendant::table]"):
elem.tag = "div"
if options.images:
# Many websites have inside or or tag
cleaning_list = [e for e in cleaning_list if e not in PRESERVE_IMG_CLEANING]
stripping_list.remove("img")
# strip targeted elements
strip_tags(tree, stripping_list)
# prevent removal of paragraphs
if options.focus == "recall" and tree.find(".//p") is not None:
tcopy = deepcopy(tree)
for expression in cleaning_list:
for element in tree.iter(expression):
delete_element(element)
if tree.find(".//p") is None:
tree = tcopy
# delete targeted elements
else:
for expression in cleaning_list:
for element in tree.iter(expression):
delete_element(element)
return prune_html(tree, options.focus)
def prune_html(tree: HtmlElement, focus: str = "balanced") -> HtmlElement:
"Delete selected empty elements to save space and processing time."
tails = focus != "precision"
# .//comment() needed for date extraction
for element in tree.xpath(".//processing-instruction()|.//*[not(node())]"):
if element.tag in CUT_EMPTY_ELEMS:
delete_element(element, keep_tail=tails)
return tree
def prune_unwanted_nodes(
tree: HtmlElement, nodelist: List[XPath], with_backup: bool = False
) -> HtmlElement:
"Prune the HTML tree by removing unwanted sections."
if with_backup:
old_len = len(tree.text_content()) # ' '.join(tree.itertext())
backup = deepcopy(tree)
for expression in nodelist:
for subtree in expression(tree):
# preserve tail text from deletion
# tail is by default preserved by delete_element()
# remove the node
delete_element(subtree)
if with_backup:
new_len = len(tree.text_content())
# todo: adjust for recall and precision settings
return tree if new_len > old_len / 7 else backup
return tree
def collect_link_info(
links_xpath: List[HtmlElement],
) -> Tuple[int, int, int, List[str]]:
"Collect heuristics on link text"
mylist = [e for e in (trim(elem.text_content()) for elem in links_xpath) if e]
lengths = list(map(len, mylist))
# longer strings impact recall in favor of precision
shortelems = sum(1 for l in lengths if l < 10)
return sum(lengths), len(mylist), shortelems, mylist
def link_density_test(
element: HtmlElement, text: str, favor_precision: bool = False
) -> Tuple[bool, List[str]]:
"Remove sections which are rich in links (probably boilerplate)"
links_xpath = element.findall(".//ref")
if not links_xpath:
return False, []
mylist: List[str] = []
# shortcut
if len(links_xpath) == 1:
len_threshold = 10 if favor_precision else 100
link_text = trim(links_xpath[0].text_content())
if len(link_text) > len_threshold and len(link_text) > len(text) * 0.9:
return True, []
if element.tag == "p":
limitlen = 60 if element.getnext() is None else 30
else:
if element.getnext() is None:
limitlen = 300
# elif re.search(r'[.?!:]', element.text_content()):
# limitlen, threshold = 150, 0.66
else:
limitlen = 100
elemlen = len(text)
if elemlen < limitlen:
linklen, elemnum, shortelems, mylist = collect_link_info(links_xpath)
if elemnum == 0:
return True, mylist
LOGGER.debug(
"list link text/total: %s/%s – short elems/total: %s/%s",
linklen,
elemlen,
shortelems,
elemnum,
)
if linklen > elemlen * 0.8 or (elemnum > 1 and shortelems / elemnum > 0.8):
return True, mylist
return False, mylist
def link_density_test_tables(element: HtmlElement) -> bool:
"Remove tables which are rich in links (probably boilerplate)."
links_xpath = element.findall(".//ref")
if not links_xpath:
return False
elemlen = len(trim(element.text_content()))
if elemlen < 200:
return False
linklen, elemnum, _, _ = collect_link_info(links_xpath)
if elemnum == 0:
return True
LOGGER.debug("table link text: %s / total: %s", linklen, elemlen)
return linklen > 0.8 * elemlen if elemlen < 1000 else linklen > 0.5 * elemlen
def delete_by_link_density(
subtree: HtmlElement,
tagname: str,
backtracking: bool = False,
favor_precision: bool = False,
) -> HtmlElement:
"""Determine the link density of elements with respect to their length,
and remove the elements identified as boilerplate."""
deletions = []
len_threshold = 200 if favor_precision else 100
depth_threshold = 1 if favor_precision else 3
for elem in subtree.iter(tagname):
elemtext = trim(elem.text_content())
result, templist = link_density_test(elem, elemtext, favor_precision)
if result or (
backtracking
and templist
and 0 < len(elemtext) < len_threshold
and len(elem) >= depth_threshold
):
deletions.append(elem)
# else: # and not re.search(r'[?!.]', text):
# print(elem.tag, templist)
for elem in dict.fromkeys(deletions):
delete_element(elem)
return subtree
def handle_textnode(
elem: _Element,
options: Extractor,
comments_fix: bool = True,
preserve_spaces: bool = False,
) -> Optional[_Element]:
"Convert, format, and probe potential text elements."
if elem.tag == "graphic" and is_image_element(elem):
return elem
if elem.tag == "done" or (len(elem) == 0 and not elem.text and not elem.tail):
return None
# lb bypass
if not comments_fix and elem.tag == "lb":
if not preserve_spaces:
elem.tail = trim(elem.tail) or None
# if textfilter(elem) is True:
# return None
# duplicate_test(subelement)?
return elem
if not elem.text and len(elem) == 0:
# try the tail
# LOGGER.debug('using tail for element %s', elem.tag)
elem.text, elem.tail = elem.tail, ""
# handle differently for br/lb
if comments_fix and elem.tag == "lb":
elem.tag = "p"
# trim
if not preserve_spaces:
elem.text = trim(elem.text) or None
if elem.tail:
elem.tail = trim(elem.tail) or None
# filter content
# or not re.search(r'\w', element.text): # text_content()?
if (
not elem.text
and textfilter(elem)
or (options.dedup and duplicate_test(elem, options))
):
return None
return elem
def process_node(elem: _Element, options: Extractor) -> Optional[_Element]:
"Convert, format, and probe potential text elements (light format)."
if elem.tag == "done" or (len(elem) == 0 and not elem.text and not elem.tail):
return None
# trim
elem.text, elem.tail = trim(elem.text) or None, trim(elem.tail) or None
# adapt content string
if elem.tag != "lb" and not elem.text and elem.tail:
elem.text, elem.tail = elem.tail, None
# content checks
if elem.text or elem.tail:
if textfilter(elem) or (options.dedup and duplicate_test(elem, options)):
return None
return elem
def convert_lists(elem: _Element) -> None:
"Convert
and to and underlying
elements to ."
elem.set("rend", elem.tag)
elem.tag = "list"
i = 1
for subelem in elem.iter("dd", "dt", "li"):
# keep track of dd/dt items
if subelem.tag in ("dd", "dt"):
subelem.set("rend", f"{str(subelem.tag)}-{i}")
# increment counter after
in description list
if subelem.tag == "dd":
i += 1
# convert elem tag (needs to happen after the rest)
subelem.tag = "item"
def convert_quotes(elem: _Element) -> None:
"Convert quoted elements while accounting for nested structures."
code_flag = False
if elem.tag == "pre":
# detect if there could be code inside
# pre with a single span is more likely to be code
if len(elem) == 1 and elem[0].tag == "span":
code_flag = True
# find hljs elements to detect if it's code
code_elems = elem.xpath(".//span[starts-with(@class,'hljs')]")
if code_elems:
code_flag = True
for subelem in code_elems:
subelem.attrib.clear()
if _is_code_block(elem.text):
code_flag = True
elem.tag = "code" if code_flag else "quote"
def _is_code_block(text: Optional[str]) -> bool:
"Check if the element text is part of a code block."
if not text:
return False
for indicator in CODE_INDICATORS:
if indicator in text:
return True
return False
def convert_headings(elem: _Element) -> None:
"Add head tags and delete attributes."
elem.attrib.clear()
elem.set("rend", elem.tag)
elem.tag = "head"
def convert_line_breaks(elem: _Element) -> None:
"Convert and to "
elem.tag = "lb"
def convert_deletions(elem: _Element) -> None:
'Convert , , to '
elem.tag = "del"
elem.set("rend", "overstrike")
def convert_details(elem: _Element) -> None:
"Handle details and summary."
elem.tag = "div"
for subelem in elem.iter("summary"):
subelem.tag = "head"
CONVERSIONS = {
"dl": convert_lists,
"ol": convert_lists,
"ul": convert_lists,
"h1": convert_headings,
"h2": convert_headings,
"h3": convert_headings,
"h4": convert_headings,
"h5": convert_headings,
"h6": convert_headings,
"br": convert_line_breaks,
"hr": convert_line_breaks,
"blockquote": convert_quotes,
"pre": convert_quotes,
"q": convert_quotes,
"del": convert_deletions,
"s": convert_deletions,
"strike": convert_deletions,
"details": convert_details,
# wbr
}
def convert_link(elem: HtmlElement, base_url: Optional[str]) -> None:
"Replace link tags and href attributes, delete the rest."
elem.tag = "ref"
target = elem.get("href") # defaults to None
elem.attrib.clear()
if target:
# convert relative URLs
if base_url:
target = fix_relative_urls(base_url, target)
elem.set("target", target)
def convert_tags(
tree: HtmlElement, options: Extractor, url: Optional[str] = None
) -> HtmlElement:
"Simplify markup and convert relevant HTML tags to an XML standard."
# delete links for faster processing
if not options.links:
xpath_expr = ".//*[self::div or self::li or self::p]//a"
if options.tables:
xpath_expr += "|.//table//a"
# necessary for further detection
for elem in tree.xpath(xpath_expr):
elem.tag = "ref"
# strip the rest
strip_tags(tree, "a")
else:
# get base URL for converting relative URLs
base_url = url and get_base_url(url)
for elem in tree.iter("a", "ref"):
convert_link(elem, base_url)
if options.formatting:
for elem in tree.iter(REND_TAG_MAPPING.keys()):
elem.attrib.clear()
elem.set("rend", REND_TAG_MAPPING[elem.tag]) # type: ignore[index]
elem.tag = "hi"
else:
strip_tags(tree, *REND_TAG_MAPPING.keys())
# iterate over all concerned elements
for elem in tree.iter(CONVERSIONS.keys()):
CONVERSIONS[elem.tag](elem) # type: ignore[index]
# images
if options.images:
for elem in tree.iter("img"):
elem.tag = "graphic"
return tree
HTML_CONVERSIONS = {
"list": "ul",
"item": "li",
"code": "pre",
"quote": "blockquote",
"head": lambda elem: f"h{int(elem.get('rend', 'h3')[1:])}",
"lb": "br",
"img": "graphic",
"ref": "a",
"hi": lambda elem: HTML_TAG_MAPPING[elem.get("rend", "#i")],
}
def convert_to_html(tree: _Element) -> _Element:
"Convert XML to simplified HTML."
for elem in tree.iter(HTML_CONVERSIONS.keys()):
conversion = HTML_CONVERSIONS[str(elem.tag)]
# apply function or straight conversion
if callable(conversion):
elem.tag = conversion(elem)
else:
elem.tag = conversion # type: ignore[assignment]
# handle attributes
if elem.tag == "a":
elem.set("href", elem.attrib.pop("target", ""))
else:
elem.attrib.clear()
tree.tag = "body"
root = Element("html")
root.append(tree)
return root
def build_html_output(document: Document, with_metadata: bool = False) -> str:
"Convert the document to HTML and return a string."
html_tree = convert_to_html(document.body)
if with_metadata:
head = Element("head")
for item in META_ATTRIBUTES:
if value := getattr(document, item):
SubElement(head, "meta", name=item, content=value)
html_tree.insert(0, head)
return tostring(html_tree, pretty_print=True, encoding="unicode").strip()