# 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