# Table of Contents - [trafilatura.baseline — Trafilatura 2.0.0 documentation](#trafilatura-baseline-trafilatura-2-0-0-documentation) - [trafilatura.core — Trafilatura 2.0.0 documentation](#trafilatura-core-trafilatura-2-0-0-documentation) - [trafilatura.external — Trafilatura 2.0.0 documentation](#trafilatura-external-trafilatura-2-0-0-documentation) - [trafilatura.downloads — Trafilatura 2.0.0 documentation](#trafilatura-downloads-trafilatura-2-0-0-documentation) - [trafilatura.feeds — Trafilatura 2.0.0 documentation](#trafilatura-feeds-trafilatura-2-0-0-documentation) - [ Documentation page not found - Read the Docs Community ](#-documentation-page-not-found-read-the-docs-community-) - [trafilatura.main_extractor — Trafilatura 2.0.0 documentation](#trafilatura-main-extractor-trafilatura-2-0-0-documentation) - [trafilatura.sitemaps — Trafilatura 2.0.0 documentation](#trafilatura-sitemaps-trafilatura-2-0-0-documentation) - [trafilatura.metadata — Trafilatura 2.0.0 documentation](#trafilatura-metadata-trafilatura-2-0-0-documentation) - [trafilatura.spider — Trafilatura 2.0.0 documentation](#trafilatura-spider-trafilatura-2-0-0-documentation) - [trafilatura.utils — Trafilatura 2.0.0 documentation](#trafilatura-utils-trafilatura-2-0-0-documentation) - [trafilatura.xml — Trafilatura 2.0.0 documentation](#trafilatura-xml-trafilatura-2-0-0-documentation) --- # trafilatura.baseline — Trafilatura 2.0.0 documentation [Skip to main content](https://trafilatura.readthedocs.io/en/latest/_modules/trafilatura/baseline.html#main-content) Back to top Ctrl+K * [GitHub](https://github.com/adbar/trafilatura "GitHub") Source code for trafilatura.baseline ==================================== """ Module regrouping baseline and basic extraction functions. """ \# pylint:disable-msg=E0611 import json from typing import Any, Tuple from lxml.etree import \_Element, Element, SubElement from lxml.html import HtmlElement from .settings import BASIC\_CLEAN\_XPATH from .utils import load\_html, trim from .xml import delete\_element def basic\_cleaning(tree: HtmlElement) \-> HtmlElement: "Remove a few section types from the document." for elem in BASIC\_CLEAN\_XPATH(tree): delete\_element(elem) return tree [\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.baseline) def baseline(filecontent: Any) \-> Tuple\[\_Element, str, int\]: """Use baseline extraction function targeting text paragraphs and/or JSON metadata. Args: filecontent: HTML code as binary string or string. Returns: A LXML
element containing the extracted paragraphs, the main text as string, and its length as integer. """ tree \= load\_html(filecontent) postbody \= Element('body') if tree is None: return postbody, '', 0 \# scrape from json text temp\_text \= "" for elem in tree.iterfind('.//script\[@type="application/ld+json"\]'): if elem.text and 'articleBody' in elem.text: try: json\_body \= json.loads(elem.text).get("articleBody", "") except Exception: \# JSONDecodeError or 'list' object has no attribute 'get' json\_body \= "" if json\_body: if "" in json\_body:
parsed \= load\_html(json\_body)
text \= trim(parsed.text\_content()) if parsed is not None else ""
else:
text \= trim(json\_body)
SubElement(postbody, 'p').text \= text
temp\_text += " " + text if temp\_text else text
\# return postbody, elem.text, len(elem.text)
if len(temp\_text) \> 100:
return postbody, temp\_text, len(temp\_text)
tree \= basic\_cleaning(tree)
\# scrape from article tag
temp\_text \= ""
for article\_elem in tree.iterfind('.//article'):
text \= trim(article\_elem.text\_content())
if len(text) \> 100:
SubElement(postbody, 'p').text \= text
temp\_text += " " + text if temp\_text else text
if len(postbody) \> 0:
\# temp\_text = trim('\\n'.join(postbody.itertext()))
return postbody, temp\_text, len(temp\_text)
\# scrape from text paragraphs
results \= set()
temp\_text \= ""
\# postbody = Element('body')
for element in tree.iter('blockquote', 'code', 'p', 'pre', 'q', 'quote'):
entry \= trim(element.text\_content())
if entry not in results:
SubElement(postbody, 'p').text \= entry
temp\_text += " " + entry if temp\_text else entry
results.add(entry)
\# temp\_text = trim('\\n'.join(postbody.itertext()))
if len(temp\_text) \> 100:
return postbody, temp\_text, len(temp\_text)
\# default strategy: clean the tree and take everything
postbody \= Element('body')
body\_elem \= tree.find('.//body')
if body\_elem is not None:
p\_elem \= SubElement(postbody, 'p')
\# todo: sanitize?
text\_elems \= \[trim(e) for e in body\_elem.itertext()\]
p\_elem.text \= '\\n'.join(\[e for e in text\_elems if e\])
return postbody, p\_elem.text, len(p\_elem.text)
\# new fallback
text \= html2txt(tree, clean\=False)
SubElement(postbody, 'p').text \= text
return postbody, text, len(text)
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.html2txt)
def html2txt(content: Any, clean: bool \= True) \-> str:
"""Run basic html2txt on a document.
Args:
content: HTML document as string or LXML element.
clean: remove potentially undesirable elements.
Returns:
The extracted text in the form of a string or an empty string.
"""
tree \= load\_html(content)
if tree is None:
return ""
body \= tree.find(".//body")
if body is None:
return ""
if clean:
body \= basic\_cleaning(body)
return " ".join(body.text\_content().split()).strip()
---
# trafilatura.core — Trafilatura 2.0.0 documentation
[Skip to main content](https://trafilatura.readthedocs.io/en/latest/_modules/trafilatura/core.html#main-content)
Back to top Ctrl+K
* [GitHub](https://github.com/adbar/trafilatura "GitHub")
Source code for trafilatura.core
================================
\# pylint:disable-msg=E0611,I1101
"""
Extraction configuration and processing functions.
"""
import logging
import warnings
from copy import copy, deepcopy
from typing import Any, Dict, Optional, Set, Tuple, Union
from lxml.etree import \_Element, Element, XPath, strip\_tags
from lxml.html import HtmlElement
\# own
from .baseline import baseline
from .deduplication import content\_fingerprint, duplicate\_test
from .external import compare\_extraction
from .htmlprocessing import (
build\_html\_output,
convert\_tags,
prune\_unwanted\_nodes,
tree\_cleaning,
)
from .main\_extractor import extract\_comments, extract\_content
from .metadata import Document, extract\_metadata
from .settings import DEFAULT\_CONFIG, Extractor, use\_config
from .utils import (
LANGID\_FLAG,
check\_html\_lang,
language\_filter,
load\_html,
normalize\_unicode,
)
from .xml import build\_json\_output, control\_xml\_output, xmltotxt, xmltocsv
from .xpaths import REMOVE\_COMMENTS\_XPATH
LOGGER \= logging.getLogger(\_\_name\_\_)
TXT\_FORMATS \= {"markdown", "txt"}
def determine\_returnstring(document: Document, options: Extractor) \-> str:
"""Convert XML tree to chosen format, clean the result and output it as a string"""
\# XML (TEI) steps
if "xml" in options.format:
\# last cleaning
for element in document.body.iter("\*"):
if (
element.tag != "graphic"
and len(element) \== 0
and not element.text
and not element.tail
):
parent \= element.getparent()
\# do not remove elements inside elements if nothing found or text too short
\# todo: test precision and recall settings here
if len(result\_body) \== 0 or len(temp\_text) < options.min\_extracted\_size: \# type: ignore\[attr-defined\]
result\_body \= recover\_wild\_text(backup\_tree, result\_body, options, potential\_tags)
temp\_text \= ' '.join(result\_body.itertext()).strip()
\# filter output
strip\_elements(result\_body, 'done')
strip\_tags(result\_body, 'div')
\# return
return result\_body, temp\_text, len(temp\_text)
def process\_comments\_node(elem: \_Element, potential\_tags: Set\[str\], options: Extractor) \-> Optional\[\_Element\]:
'''Process comment node and determine how to deal with its content'''
if elem.tag in potential\_tags:
\# print(elem.tag, elem.text\_content())
processed\_element \= handle\_textnode(elem, options, comments\_fix\=True)
\# test length and remove
if processed\_element is not None: \# and processed\_element.text not in COMMENTS\_BLACKLIST:
processed\_element.attrib.clear()
\# if textfilter(elem) is True: # ^Pingback
\# return None
return processed\_element
return None
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.core.extract_comments)
def extract\_comments(tree: HtmlElement, options: Extractor) \-> Tuple\[\_Element, str, int, HtmlElement\]:
"Try to extract comments out of potential sections in the HTML."
comments\_body \= Element("body")
\# define iteration strategy
potential\_tags \= set(TAG\_CATALOG) \# 'span'
\# potential\_tags.add('div') trouble with to preserve formatting
if parent is not None and parent.tag != "code":
parent.remove(element)
\# build output tree
returnstring \= control\_xml\_output(document, options)
\# CSV
elif options.format \== "csv":
returnstring \= xmltocsv(document, options.formatting)
\# JSON
elif options.format \== "json":
returnstring \= build\_json\_output(document, options.with\_metadata)
\# HTML
elif options.format \== "html":
returnstring \= build\_html\_output(document, options.with\_metadata)
\# Markdown and TXT
else:
if options.with\_metadata:
header \= "---\\n"
for attr in (
"title",
"author",
"url",
"hostname",
"description",
"sitename",
"date",
"categories",
"tags",
"fingerprint",
"id",
"license",
):
if getattr(document, attr):
header += f"{attr}: {str(getattr(document, attr))}\\n"
header += "---\\n"
else:
header \= ""
returnstring \= f"{header}{xmltotxt(document.body, options.formatting)}"
if document.commentsbody is not None:
returnstring \= f"{returnstring}\\n{xmltotxt(document.commentsbody, options.formatting)}".strip()
\# normalize Unicode format (defaults to NFC)
return normalize\_unicode(returnstring)
def trafilatura\_sequence(
cleaned\_tree: HtmlElement,
cleaned\_tree\_backup: HtmlElement,
tree\_backup: HtmlElement,
options: Extractor,
) \-> Tuple\[\_Element, str, int\]:
"Execute the standard cascade of extractors used by Trafilatura."
\# Trafilatura's main extractor
postbody, temp\_text, len\_text \= extract\_content(cleaned\_tree, options)
\# comparison with external extractors
if not options.fast:
postbody, temp\_text, len\_text \= compare\_extraction(
cleaned\_tree\_backup,
deepcopy(tree\_backup),
postbody,
temp\_text,
len\_text,
options,
)
\# rescue: baseline extraction on original/dirty tree
if len\_text < options.min\_extracted\_size and not options.focus \== "precision": \# type: ignore\[attr-defined\]
postbody, temp\_text, len\_text \= baseline(deepcopy(tree\_backup))
LOGGER.debug("non-clean extracted length: %s (extraction)", len\_text)
return postbody, temp\_text, len\_text
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.bare_extraction)
def bare\_extraction(
filecontent: Any,
url: Optional\[str\] \= None,
fast: bool \= False,
no\_fallback: bool \= False,
favor\_precision: bool \= False,
favor\_recall: bool \= False,
include\_comments: bool \= True,
output\_format: str \= "python",
target\_language: Optional\[str\] \= None,
include\_tables: bool \= True,
include\_images: bool \= False,
include\_formatting: bool \= False,
include\_links: bool \= False,
deduplicate: bool \= False,
date\_extraction\_params: Optional\[Dict\[str, Any\]\] \= None,
with\_metadata: bool \= False,
only\_with\_metadata: bool \= False,
max\_tree\_size: Optional\[int\] \= None,
url\_blacklist: Optional\[Set\[str\]\] \= None,
author\_blacklist: Optional\[Set\[str\]\] \= None,
as\_dict: bool \= False,
prune\_xpath: Optional\[Any\] \= None,
config: Any \= DEFAULT\_CONFIG,
options: Optional\[Extractor\] \= None,
) \-> Optional\[Union\[Document, Dict\[str, Any\]\]\]:
"""Internal function for text extraction returning bare Python variables.
Args:
filecontent: HTML code as string.
url: URL of the webpage.
fast: Use faster heuristics and skip backup extraction.
no\_fallback: Will be deprecated, use "fast" instead.
favor\_precision: prefer less text but correct extraction.
favor\_recall: prefer more text even when unsure.
include\_comments: Extract comments along with the main text.
output\_format: Define an output format, Python being the default
and the interest of this internal function.
Other values: "csv", "html", "json", "markdown", "txt", "xml", and "xmltei".
target\_language: Define a language to discard invalid documents (ISO 639-1 format).
include\_tables: Take into account information within the HTML element.
include\_images: Take images into account (experimental).
include\_formatting: Keep structural elements related to formatting
(present in XML format, converted to markdown otherwise).
include\_links: Keep links along with their targets (experimental).
deduplicate: Remove duplicate segments and documents.
date\_extraction\_params: Provide extraction parameters to htmldate as dict().
with\_metadata: Extract metadata fields and add them to the output.
only\_with\_metadata: Only keep documents featuring all essential metadata
(date, title, url).
url\_blacklist: Provide a blacklist of URLs as set() to filter out documents.
author\_blacklist: Provide a blacklist of Author Names as set() to filter out authors.
as\_dict: Will be deprecated, use the .as\_dict() method of the document class.
prune\_xpath: Provide an XPath expression to prune the tree before extraction.
can be str or list of str.
config: Directly provide a configparser configuration.
options: Directly provide a whole extractor configuration.
Returns:
A Python dict() containing all the extracted information or None.
Raises:
ValueError: Extraction problem.
"""
\# deprecations
if no\_fallback:
fast \= no\_fallback
warnings.warn(
'"no\_fallback" will be deprecated in a future version, use "fast" instead',
PendingDeprecationWarning
)
if as\_dict:
warnings.warn(
'"as\_dict" will be deprecated, use the .as\_dict() method on bare\_extraction results',
PendingDeprecationWarning
)
if max\_tree\_size:
raise ValueError("max\_tree\_size is deprecated, use settings.cfg file instead")
\# regroup extraction options
if not options or not isinstance(options, Extractor):
options \= Extractor(
config\=config,
output\_format\=output\_format,
fast\=fast,
precision\=favor\_precision,
recall\=favor\_recall,
comments\=include\_comments,
formatting\=include\_formatting,
links\=include\_links,
images\=include\_images,
tables\=include\_tables,
dedup\=deduplicate,
lang\=target\_language,
url\=url,
with\_metadata\=with\_metadata,
only\_with\_metadata\=only\_with\_metadata,
author\_blacklist\=author\_blacklist,
url\_blacklist\=url\_blacklist,
date\_params\=date\_extraction\_params,
)
try:
\# load the HTML tree
tree \= load\_html(filecontent)
if tree is None:
LOGGER.error("empty HTML tree: %s", url)
raise ValueError
\# quick and dirty HTML lang check
if options.lang and (options.fast or not LANGID\_FLAG):
if check\_html\_lang(tree, options.lang) is False:
LOGGER.error("wrong HTML meta language: %s", options.source)
raise ValueError
\# extract metadata if necessary
if options.with\_metadata:
document \= extract\_metadata(
tree,
options.url,
options.date\_params,
options.fast,
options.author\_blacklist,
)
\# cut short if extracted URL in blacklist
if document.url in options.url\_blacklist:
LOGGER.warning("blacklisted URL: %s", document.url)
raise ValueError
\# cut short if core elements are missing
if options.only\_with\_metadata and not (
document.date and document.title and document.url
):
LOGGER.error("no metadata: %s", options.source)
raise ValueError
else:
document \= Document()
\# prune all xpath expressions that user specified
\# no backup as this is unetre full control of the user
if prune\_xpath is not None:
if isinstance(prune\_xpath, str):
prune\_xpath \= \[prune\_xpath\]
tree \= prune\_unwanted\_nodes(tree, \[XPath(x) for x in prune\_xpath\])
\# clean and backup for further processing
cleaned\_tree \= tree\_cleaning(copy(tree), options)
cleaned\_tree\_backup \= copy(cleaned\_tree)
\# convert tags, the rest does not work without conversion
cleaned\_tree \= convert\_tags(cleaned\_tree, options, options.url or document.url)
\# comments first, then remove
if options.comments:
commentsbody, temp\_comments, len\_comments, cleaned\_tree \= extract\_comments(
cleaned\_tree, options
)
else:
commentsbody, temp\_comments, len\_comments \= Element("body"), "", 0
if options.focus \== "precision":
cleaned\_tree \= prune\_unwanted\_nodes(cleaned\_tree, REMOVE\_COMMENTS\_XPATH)
postbody, temp\_text, len\_text \= trafilatura\_sequence(
cleaned\_tree, cleaned\_tree\_backup, tree, options
)
\# tree size sanity check
if options.max\_tree\_size:
\# strip tags
if len(postbody) \> options.max\_tree\_size:
LOGGER.debug("output tree too long: %s", len(postbody))
strip\_tags(postbody, "hi")
\# still too long, raise an error
if len(postbody) \> options.max\_tree\_size:
LOGGER.debug(
"output tree too long: %s, discarding %s",
len(postbody),
options.source,
)
raise ValueError
\# size checks
if options.comments and len\_comments < options.min\_extracted\_comm\_size: \# type: ignore\[attr-defined\]
LOGGER.debug("not enough comments: %s", options.source)
if (
len\_text < options.min\_output\_size \# type: ignore\[attr-defined\]
and len\_comments < options.min\_output\_comm\_size \# type: ignore\[attr-defined\]
):
LOGGER.debug(
"text and comments not long enough: %s %s %s",
len\_text,
len\_comments,
options.source,
)
raise ValueError
\# check duplicates at body level
if options.dedup and duplicate\_test(postbody, options) is True:
LOGGER.debug("discarding duplicate document: %s", options.source)
raise ValueError
\# sanity check on language
if options.lang:
is\_not\_target\_lang, document \= language\_filter(
temp\_text, temp\_comments, options.lang, document
)
if is\_not\_target\_lang is True:
LOGGER.debug("wrong language: %s", options.source)
raise ValueError
except (TypeError, ValueError):
LOGGER.warning("discarding data: %s", options.source)
return None
\# special case: python variables
if options.format \== "python":
document.text \= xmltotxt(postbody, options.formatting)
if options.comments:
document.comments \= xmltotxt(commentsbody, options.formatting)
document.commentsbody \= commentsbody
document.raw\_text \= document.text
else:
document.raw\_text, document.commentsbody \= temp\_text, commentsbody
document.body \= postbody
return document if not as\_dict else document.as\_dict()
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.extract)
def extract(
filecontent: Any,
url: Optional\[str\] \= None,
record\_id: Optional\[str\] \= None,
fast: bool \= False,
no\_fallback: bool \= False,
favor\_precision: bool \= False,
favor\_recall: bool \= False,
include\_comments: bool \= True,
output\_format: str \= "txt",
tei\_validation: bool \= False,
target\_language: Optional\[str\] \= None,
include\_tables: bool \= True,
include\_images: bool \= False,
include\_formatting: bool \= False,
include\_links: bool \= False,
deduplicate: bool \= False,
date\_extraction\_params: Optional\[Dict\[str, Any\]\] \= None,
with\_metadata: bool \= False,
only\_with\_metadata: bool \= False,
max\_tree\_size: Optional\[int\] \= None,
url\_blacklist: Optional\[Set\[str\]\] \= None,
author\_blacklist: Optional\[Set\[str\]\] \= None,
settingsfile: Optional\[str\] \= None,
prune\_xpath: Optional\[Any\] \= None,
config: Any \= DEFAULT\_CONFIG,
options: Optional\[Extractor\] \= None,
) \-> Optional\[str\]:
"""Main function exposed by the package:
Wrapper for text extraction and conversion to chosen output format.
Args:
filecontent: HTML code as string.
url: URL of the webpage.
record\_id: Add an ID to the metadata.
fast: Use faster heuristics and skip backup extraction.
no\_fallback: Will be deprecated, use "fast" instead.
favor\_precision: prefer less text but correct extraction.
favor\_recall: when unsure, prefer more text.
include\_comments: Extract comments along with the main text.
output\_format: Define an output format:
"csv", "html", "json", "markdown", "txt", "xml", and "xmltei".
tei\_validation: Validate the XML-TEI output with respect to the TEI standard.
target\_language: Define a language to discard invalid documents (ISO 639-1 format).
include\_tables: Take into account information within the HTML
element.
include\_images: Take images into account (experimental).
include\_formatting: Keep structural elements related to formatting
(only valuable if output\_format is set to XML).
include\_links: Keep links along with their targets (experimental).
deduplicate: Remove duplicate segments and documents.
date\_extraction\_params: Provide extraction parameters to htmldate as dict().
with\_metadata: Extract metadata fields and add them to the output.
only\_with\_metadata: Only keep documents featuring all essential metadata
(date, title, url).
url\_blacklist: Provide a blacklist of URLs as set() to filter out documents.
author\_blacklist: Provide a blacklist of Author Names as set() to filter out authors.
settingsfile: Use a configuration file to override the standard settings.
prune\_xpath: Provide an XPath expression to prune the tree before extraction.
can be str or list of str.
config: Directly provide a configparser configuration.
options: Directly provide a whole extractor configuration.
Returns:
A string in the desired format or None.
"""
if no\_fallback:
fast \= no\_fallback
warnings.warn(
'"no\_fallback" will be deprecated in a future version, use "fast" instead',
PendingDeprecationWarning
)
if max\_tree\_size:
raise ValueError("max\_tree\_size is deprecated, use settings.cfg file instead")
\# regroup extraction options
if not options or not isinstance(options, Extractor):
options \= Extractor(
config\=use\_config(settingsfile, config),
output\_format\=output\_format,
fast\=fast,
precision\=favor\_precision,
recall\=favor\_recall,
comments\=include\_comments,
formatting\=include\_formatting,
links\=include\_links,
images\=include\_images,
tables\=include\_tables,
dedup\=deduplicate,
lang\=target\_language,
url\=url,
with\_metadata\=with\_metadata,
only\_with\_metadata\=only\_with\_metadata,
tei\_validation\=tei\_validation,
author\_blacklist\=author\_blacklist,
url\_blacklist\=url\_blacklist,
date\_params\=date\_extraction\_params,
)
\# extraction
document \= bare\_extraction(
filecontent,
options\=options,
as\_dict\=False,
prune\_xpath\=prune\_xpath,
)
\# post-processing
if not document or not isinstance(document, Document):
return None
if options.format not in TXT\_FORMATS:
\# control output
if options.format \== "python":
raise ValueError(
"'python' format only usable in bare\_extraction() function"
)
\# add record ID to metadata
document.id \= record\_id
\# calculate fingerprint
if document.raw\_text is not None:
document.fingerprint \= content\_fingerprint(
str(document.title) + " " + str(document.raw\_text)
)
\# return
return determine\_returnstring(document, options)
---
# trafilatura.external — Trafilatura 2.0.0 documentation
[Skip to main content](https://trafilatura.readthedocs.io/en/latest/_modules/trafilatura/external.html#main-content)
Back to top Ctrl+K
* [GitHub](https://github.com/adbar/trafilatura "GitHub")
Source code for trafilatura.external
====================================
\# pylint:disable-msg=E0611,I1101
"""
Functions grounding on third-party software.
"""
import logging
from typing import Any, Tuple
\# third-party
from justext.core import ParagraphMaker, classify\_paragraphs, revise\_paragraph\_classification \# type: ignore
from justext.utils import get\_stoplist, get\_stoplists \# type: ignore
from lxml.etree import \_Element, Element, strip\_tags, tostring
from lxml.html import HtmlElement
\# own
from .baseline import basic\_cleaning
from .htmlprocessing import convert\_tags, prune\_unwanted\_nodes, tree\_cleaning
from .readability\_lxml import Document as ReadabilityDocument \# fork
from .settings import JUSTEXT\_LANGUAGES
from .utils import fromstring\_bytes, trim
from .xml import TEI\_VALID\_TAGS
from .xpaths import OVERALL\_DISCARD\_XPATH
LOGGER \= logging.getLogger(\_\_name\_\_)
JT\_STOPLIST \= None
SANITIZED\_XPATH \= './/aside|.//audio|.//button|.//fieldset|.//figure|.//footer|.//iframe|.//input|.//label|.//link|.//nav|.//noindex|.//noscript|.//object|.//option|.//select|.//source|.//svg|.//time'
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.external.try_readability)
def try\_readability(htmlinput: HtmlElement) \-> HtmlElement:
'''Safety net: try with the generic algorithm readability'''
\# defaults: min\_text\_length=25, retry\_length=250
try:
doc \= ReadabilityDocument(htmlinput, min\_text\_length\=25, retry\_length\=250)
\# force conversion to utf-8 (see #319)
summary \= fromstring\_bytes(doc.summary())
return summary if summary is not None else HtmlElement()
except Exception as err:
LOGGER.warning('readability\_lxml failed: %s', err)
return HtmlElement()
def compare\_extraction(tree: HtmlElement, backup\_tree: HtmlElement, body: \_Element, text: str, len\_text: int, options: Any) \-> Tuple\[\_Element, str, int\]:
'''Decide whether to choose own or external extraction
based on a series of heuristics'''
\# bypass for recall
if options.focus \== "recall" and len\_text \> options.min\_extracted\_size \* 10:
return body, text, len\_text
use\_readability, jt\_result \= False, False
\# prior cleaning
if options.focus \== "precision":
backup\_tree \= prune\_unwanted\_nodes(backup\_tree, OVERALL\_DISCARD\_XPATH)
\# try with readability
temppost\_algo \= try\_readability(backup\_tree)
\# unicode fix necessary on certain systems (#331)
algo\_text \= trim(tostring(temppost\_algo, method\='text', encoding\='utf-8').decode('utf-8'))
len\_algo \= len(algo\_text)
\# compare
LOGGER.debug('extracted length: %s (algorithm) %s (extraction)', len\_algo, len\_text)
\# conditions to use alternative algorithms
if len\_algo in (0, len\_text):
use\_readability \= False
elif len\_text \== 0 and len\_algo \> 0:
use\_readability \= True
elif len\_text \> 2 \* len\_algo:
use\_readability \= False
\# quick fix for https://github.com/adbar/trafilatura/issues/632
elif len\_algo \> 2 \* len\_text and not algo\_text.startswith("{"):
use\_readability \= True
\# borderline cases
elif not body.xpath('.//p//text()') and len\_algo \> options.min\_extracted\_size \* 2:
use\_readability \= True
elif len(body.findall('.//table')) \> len(body.findall('.//p')) and len\_algo \> options.min\_extracted\_size \* 2:
use\_readability \= True
\# https://github.com/adbar/trafilatura/issues/354
elif options.focus \== "recall" and not body.xpath('.//head') and temppost\_algo.xpath('.//h2|.//h3|.//h4') and len\_algo \> len\_text:
use\_readability \= True
else:
LOGGER.debug('extraction values: %s %s for %s', len\_text, len\_algo, options.source)
use\_readability \= False
\# apply decision
if use\_readability:
body, text, len\_text \= temppost\_algo, algo\_text, len\_algo
LOGGER.debug('using generic algorithm: %s', options.source)
else:
LOGGER.debug('using custom extraction: %s', options.source)
\# override faulty extraction: try with justext
if body.xpath(SANITIZED\_XPATH) or len\_text < options.min\_extracted\_size: \# body.find(...)
LOGGER.debug('unclean document triggering justext examination: %s', options.source)
body2, text2, len\_text2 \= justext\_rescue(tree, options)
jt\_result \= bool(text2)
\# prevent too short documents from replacing the main text
if text2 and not len\_text \> 4\*len\_text2: \# threshold could be adjusted
LOGGER.debug('using justext, length: %s', len\_text2)
body, text, len\_text \= body2, text2, len\_text2
\# post-processing: remove unwanted sections
if use\_readability and not jt\_result:
body, text, len\_text \= sanitize\_tree(body, options) \# type: ignore\[arg-type\]
return body, text, len\_text
def jt\_stoplist\_init() \-> Tuple\[str\]:
'Retrieve and return the content of all JusText stoplists'
global JT\_STOPLIST
stoplist \= set()
for language in get\_stoplists():
stoplist.update(get\_stoplist(language))
JT\_STOPLIST \= tuple(stoplist)
return JT\_STOPLIST
def custom\_justext(tree: HtmlElement, stoplist: Tuple\[str\]) \-> Any:
'Customized version of JusText processing'
paragraphs \= ParagraphMaker.make\_paragraphs(tree)
classify\_paragraphs(paragraphs, stoplist, 50, 150, 0.1, 0.2, 0.25, True)
revise\_paragraph\_classification(paragraphs, 150)
return paragraphs
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.external.try_justext)
def try\_justext(tree: HtmlElement, url: str, target\_language: str) \-> \_Element:
'''Second safety net: try with the generic algorithm justext'''
\# init
result\_body \= Element('body')
\# determine language
if target\_language in JUSTEXT\_LANGUAGES:
justext\_stoplist \= get\_stoplist(JUSTEXT\_LANGUAGES\[target\_language\])
else:
justext\_stoplist \= JT\_STOPLIST or jt\_stoplist\_init()
\# extract
try:
paragraphs \= custom\_justext(tree, justext\_stoplist)
except Exception as err:
LOGGER.error('justext %s %s', err, url)
else:
for paragraph in paragraphs:
if paragraph.is\_boilerplate:
continue
#if duplicate\_test(paragraph) is not True:
elem, elem.text \= Element('p'), paragraph.text
result\_body.append(elem)
return result\_body
def justext\_rescue(tree: HtmlElement, options: Any) \-> Tuple\[\_Element, str, int\]:
'''Try to use justext algorithm as a second fallback'''
\# additional cleaning
tree \= basic\_cleaning(tree)
\# proceed
temppost\_algo \= try\_justext(tree, options.url, options.lang)
temp\_text \= trim(' '.join(temppost\_algo.itertext()))
return temppost\_algo, temp\_text, len(temp\_text)
def sanitize\_tree(tree: HtmlElement, options: Any) \-> Tuple\[HtmlElement, str, int\]:
'''Convert and sanitize the output from the generic algorithm (post-processing)'''
\# 1. clean
cleaned\_tree \= tree\_cleaning(tree, options)
if options.links is False:
strip\_tags(cleaned\_tree, 'a')
strip\_tags(cleaned\_tree, 'span')
\# 2. convert
cleaned\_tree \= convert\_tags(cleaned\_tree, options)
for elem in cleaned\_tree.iter('td', 'th', 'tr'):
\# elem.text, elem.tail = trim(elem.text), trim(elem.tail)
\# finish table conversion
if elem.tag \== 'tr':
elem.tag \= 'row'
elif elem.tag in ('td', 'th'):
if elem.tag \== 'th':
elem.set('role', 'head')
elem.tag \= 'cell'
\# 3. sanitize
sanitization\_list \= \[\
tagname\
for tagname in \[element.tag for element in set(cleaned\_tree.iter('\*'))\]\
if tagname not in TEI\_VALID\_TAGS\
\]
strip\_tags(cleaned\_tree, \*sanitization\_list)
\# 4. return
text \= trim(' '.join(cleaned\_tree.itertext()))
return cleaned\_tree, text, len(text)
---
# trafilatura.downloads — Trafilatura 2.0.0 documentation
[Skip to main content](https://trafilatura.readthedocs.io/en/latest/_modules/trafilatura/downloads.html#main-content)
Back to top Ctrl+K
* [GitHub](https://github.com/adbar/trafilatura "GitHub")
Source code for trafilatura.downloads
=====================================
\# pylint:disable-msg=E0611,I1101
"""
All functions needed to steer and execute downloads of web documents.
"""
import logging
import os
import random
from concurrent.futures import ThreadPoolExecutor, as\_completed
from configparser import ConfigParser
from functools import partial
from importlib.metadata import version
from io import BytesIO
from time import sleep
from typing import (
Any,
Callable,
Dict,
Generator,
List,
Optional,
Set,
Tuple,
Union,
)
import certifi
import urllib3
from courlan import UrlStore
from courlan.network import redirection\_test
from .settings import DEFAULT\_CONFIG, Extractor
from .utils import URL\_BLACKLIST\_REGEX, decode\_file, is\_acceptable\_length, make\_chunks
try:
from urllib3.contrib.socks import SOCKSProxyManager
PROXY\_URL \= os.environ.get("http\_proxy")
except ImportError:
PROXY\_URL \= None
try:
import pycurl \# type: ignore
CURL\_SHARE \= pycurl.CurlShare()
\# available options:
\# https://curl.se/libcurl/c/curl\_share\_setopt.html
CURL\_SHARE.setopt(pycurl.SH\_SHARE, pycurl.LOCK\_DATA\_DNS)
CURL\_SHARE.setopt(pycurl.SH\_SHARE, pycurl.LOCK\_DATA\_SSL\_SESSION)
\# not thread-safe
\# CURL\_SHARE.setopt(pycurl.SH\_SHARE, pycurl.LOCK\_DATA\_CONNECT)
HAS\_PYCURL \= True
except ImportError:
HAS\_PYCURL \= False
LOGGER \= logging.getLogger(\_\_name\_\_)
urllib3.disable\_warnings(urllib3.exceptions.InsecureRequestWarning)
HTTP\_POOL \= None
NO\_CERT\_POOL \= None
RETRY\_STRATEGY \= None
def create\_pool(\*\*args: Any) \-> Union\[urllib3.PoolManager, Any\]:
"Configure urllib3 download pool according to user-defined settings."
manager\_class \= SOCKSProxyManager if PROXY\_URL else urllib3.PoolManager
manager\_args \= {"proxy\_url": PROXY\_URL} if PROXY\_URL else {}
manager\_args\["num\_pools"\] \= 50 \# type: ignore\[assignment\]
return manager\_class(\*\*manager\_args, \*\*args) \# type: ignore\[arg-type\]
DEFAULT\_HEADERS \= urllib3.util.make\_headers(accept\_encoding\=True) \# type: ignore\[no-untyped-call\]
USER\_AGENT \= (
"trafilatura/" + version("trafilatura") + " (+https://github.com/adbar/trafilatura)"
)
DEFAULT\_HEADERS\["User-Agent"\] \= USER\_AGENT
FORCE\_STATUS \= \[\
429,\
499,\
500,\
502,\
503,\
504,\
509,\
520,\
521,\
522,\
523,\
524,\
525,\
526,\
527,\
530,\
598,\
\]
CURL\_SSL\_ERRORS \= {35, 54, 58, 59, 60, 64, 66, 77, 82, 83, 91}
class Response:
"Store information gathered in a HTTP response object."
\_\_slots\_\_ \= \["data", "headers", "html", "status", "url"\]
def \_\_init\_\_(self, data: bytes, status: int, url: str) \-> None:
self.data \= data
self.headers: Optional\[Dict\[str, str\]\] \= None
self.html: Optional\[str\] \= None
self.status \= status
self.url \= url
def \_\_bool\_\_(self) \-> bool:
return self.data is not None
def \_\_repr\_\_(self) \-> str:
return self.html or decode\_file(self.data)
def store\_headers(self, headerdict: Dict\[str, str\]) \-> None:
"Store response headers if required."
\# further control steps here
self.headers \= {k.lower(): v for k, v in headerdict.items()}
def decode\_data(self, decode: bool) \-> None:
"Decode the bytestring in data and store a string in html."
if decode and self.data:
self.html \= decode\_file(self.data)
def as\_dict(self) \-> Dict\[str, str\]:
"Convert the response object to a dictionary."
return {attr: getattr(self, attr) for attr in self.\_\_slots\_\_}
\# caching throws an error
\# @lru\_cache(maxsize=2)
def \_parse\_config(config: ConfigParser) \-> Tuple\[Optional\[List\[str\]\], Optional\[str\]\]:
"Read and extract HTTP header strings from the configuration file."
\# load a series of user-agents
myagents \= config.get("DEFAULT", "USER\_AGENTS", fallback\="").strip()
agent\_list \= myagents.splitlines() if myagents else None
\# https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
\# todo: support for several cookies?
mycookie \= config.get("DEFAULT", "COOKIE") or None
return agent\_list, mycookie
def \_determine\_headers(
config: ConfigParser, headers: Optional\[Dict\[str, str\]\] \= None
) \-> Dict\[str, str\]:
"Internal function to decide on user-agent string."
if config != DEFAULT\_CONFIG:
myagents, mycookie \= \_parse\_config(config)
headers \= {}
if myagents:
headers\["User-Agent"\] \= random.choice(myagents)
if mycookie:
headers\["Cookie"\] \= mycookie
return headers or DEFAULT\_HEADERS
def \_get\_retry\_strategy(config: ConfigParser) \-> urllib3.util.Retry:
"Define a retry strategy according to the config file."
global RETRY\_STRATEGY
if not RETRY\_STRATEGY:
\# or RETRY\_STRATEGY.redirect != config.getint("DEFAULT", "MAX\_REDIRECTS")
RETRY\_STRATEGY \= urllib3.util.Retry(
total\=config.getint("DEFAULT", "MAX\_REDIRECTS"),
redirect\=config.getint(
"DEFAULT", "MAX\_REDIRECTS"
), \# raise\_on\_redirect=False,
connect\=0,
backoff\_factor\=config.getint("DEFAULT", "DOWNLOAD\_TIMEOUT") / 2,
status\_forcelist\=FORCE\_STATUS,
\# unofficial: https://en.wikipedia.org/wiki/List\_of\_HTTP\_status\_codes#Unofficial\_codes
)
return RETRY\_STRATEGY
def \_initiate\_pool(
config: ConfigParser, no\_ssl: bool \= False
) \-> Union\[urllib3.PoolManager, Any\]:
"Create a urllib3 pool manager according to options in the config file and HTTPS setting."
global HTTP\_POOL, NO\_CERT\_POOL
pool \= NO\_CERT\_POOL if no\_ssl else HTTP\_POOL
if not pool:
\# define settings
pool \= create\_pool(
timeout\=config.getint("DEFAULT", "DOWNLOAD\_TIMEOUT"),
ca\_certs\=None if no\_ssl else certifi.where(),
cert\_reqs\="CERT\_NONE" if no\_ssl else "CERT\_REQUIRED",
)
\# update variables
if no\_ssl:
NO\_CERT\_POOL \= pool
else:
HTTP\_POOL \= pool
return pool
def \_send\_urllib\_request(
url: str, no\_ssl: bool, with\_headers: bool, config: ConfigParser
) \-> Optional\[Response\]:
"Internal function to robustly send a request (SSL or not) and return its result."
try:
pool\_manager \= \_initiate\_pool(config, no\_ssl\=no\_ssl)
\# execute request, stop downloading as soon as MAX\_FILE\_SIZE is reached
response \= pool\_manager.request(
"GET",
url,
headers\=\_determine\_headers(config),
retries\=\_get\_retry\_strategy(config),
preload\_content\=False,
)
data \= bytearray()
for chunk in response.stream(2\*\*17):
data.extend(chunk)
if len(data) \> config.getint("DEFAULT", "MAX\_FILE\_SIZE"):
raise ValueError("MAX\_FILE\_SIZE exceeded")
response.release\_conn()
\# necessary for standardization
resp \= Response(bytes(data), response.status, response.geturl())
if with\_headers:
resp.store\_headers(response.headers)
return resp
except urllib3.exceptions.SSLError:
LOGGER.warning("retrying after SSLError: %s", url)
return \_send\_urllib\_request(url, True, with\_headers, config)
except Exception as err:
LOGGER.error("download error: %s %s", url, err) \# sys.exc\_info()\[0\]
return None
def \_is\_suitable\_response(url: str, response: Response, options: Extractor) \-> bool:
"Check if the response conforms to formal criteria."
lentest \= len(response.html or response.data or "")
if response.status != 200:
LOGGER.error("not a 200 response: %s for URL %s", response.status, url)
return False
\# raise error instead?
if not is\_acceptable\_length(lentest, options):
return False
return True
def \_handle\_response(
url: str, response: Response, decode: bool, options: Extractor
) \-> Optional\[Union\[Response, str\]\]: \# todo: only return str
"Internal function to run safety checks on response result."
if \_is\_suitable\_response(url, response, options):
return response.html if decode else response
\# catchall
return None
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.fetch_url)
def fetch\_url(
url: str,
no\_ssl: bool \= False,
config: ConfigParser \= DEFAULT\_CONFIG,
options: Optional\[Extractor\] \= None,
) \-> Optional\[str\]:
"""Downloads a web page and seamlessly decodes the response.
Args:
url: URL of the page to fetch.
no\_ssl: Do not try to establish a secure connection (to prevent SSLError).
config: Pass configuration values for output control.
options: Extraction options (supersedes config).
Returns:
Unicode string or None in case of failed downloads and invalid results.
"""
config \= options.config if options else config
response \= fetch\_response(url, decode\=True, no\_ssl\=no\_ssl, config\=config)
if response and response.data:
if not options:
options \= Extractor(config\=config)
if \_is\_suitable\_response(url, response, options):
return response.html
return None
[\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.fetch_response)
def fetch\_response(
url: str,
\*,
decode: bool \= False,
no\_ssl: bool \= False,
with\_headers: bool \= False,
config: ConfigParser \= DEFAULT\_CONFIG,
) \-> Optional\[Response\]:
"""Downloads a web page and returns a full response object.
Args:
url: URL of the page to fetch.
decode: Use html attribute to decode the data (boolean).
no\_ssl: Don't try to establish a secure connection (to prevent SSLError).
with\_headers: Keep track of the response headers.
config: Pass configuration values for output control.
Returns:
Response object or None in case of failed downloads and invalid results.
"""
dl\_function \= \_send\_urllib\_request if not HAS\_PYCURL else \_send\_pycurl\_request
LOGGER.debug("sending request: %s", url)
response \= dl\_function(url, no\_ssl, with\_headers, config) \# Response
if not response: \# None or ""
LOGGER.debug("request failed: %s", url)
return None
response.decode\_data(decode)
return response
def \_pycurl\_is\_live\_page(url: str) \-> bool:
"Send a basic HTTP HEAD request with pycurl."
page\_exists \= False
\# Initialize pycurl object
curl \= pycurl.Curl()
\# Set the URL and HTTP method (HEAD)
curl.setopt(pycurl.URL, url.encode("utf-8"))
curl.setopt(pycurl.CONNECTTIMEOUT, 10)
\# no SSL verification
curl.setopt(pycurl.SSL\_VERIFYPEER, 0)
curl.setopt(pycurl.SSL\_VERIFYHOST, 0)
\# Set option to avoid getting the response body
curl.setopt(curl.NOBODY, True)
if PROXY\_URL:
curl.setopt(pycurl.PRE\_PROXY, PROXY\_URL)
\# Perform the request
try:
curl.perform()
\# Get the response code
page\_exists \= curl.getinfo(curl.RESPONSE\_CODE) < 400
except pycurl.error as err:
LOGGER.debug("pycurl HEAD error: %s %s", url, err)
page\_exists \= False
\# Clean up
curl.close()
return page\_exists
def \_urllib3\_is\_live\_page(url: str) \-> bool:
"Use courlan redirection test (based on urllib3) to send a HEAD request."
try:
\_ \= redirection\_test(url)
except Exception as err:
LOGGER.debug("urllib3 HEAD error: %s %s", url, err)
return False
return True
def is\_live\_page(url: str) \-> bool:
"Send a HTTP HEAD request without taking anything else into account."
result \= \_pycurl\_is\_live\_page(url) if HAS\_PYCURL else False
\# use urllib3 as backup
return result or \_urllib3\_is\_live\_page(url)
def add\_to\_compressed\_dict(
inputlist: List\[str\],
blacklist: Optional\[Set\[str\]\] \= None,
url\_filter: Optional\[str\] \= None,
url\_store: Optional\[UrlStore\] \= None,
compression: bool \= False,
verbose: bool \= False,
) \-> UrlStore:
"""Filter, convert input URLs and add them to domain-aware processing dictionary"""
if url\_store is None:
url\_store \= UrlStore(compressed\=compression, strict\=False, verbose\=verbose)
inputlist \= list(dict.fromkeys(inputlist))
if blacklist:
inputlist \= \[\
u for u in inputlist if URL\_BLACKLIST\_REGEX.sub("", u) not in blacklist\
\]
if url\_filter:
inputlist \= \[u for u in inputlist if any(f in u for f in url\_filter)\]
url\_store.add\_urls(inputlist)
return url\_store
def load\_download\_buffer(
url\_store: UrlStore, sleep\_time: float \= 5.0
) \-> Tuple\[List\[str\], UrlStore\]:
"""Determine threading strategy and draw URLs respecting domain-based back-off rules."""
while True:
bufferlist \= url\_store.get\_download\_urls(time\_limit\=sleep\_time, max\_urls\=10\*\*5)
if bufferlist or url\_store.done:
break
sleep(sleep\_time)
return bufferlist, url\_store
def \_buffered\_downloads(
bufferlist: List\[str\],
download\_threads: int,
worker: Callable\[\[str\], Any\],
chunksize: int \= 10000,
) \-> Generator\[Tuple\[str, Any\], None, None\]:
"Use a thread pool to perform a series of downloads."
with ThreadPoolExecutor(max\_workers\=download\_threads) as executor:
for chunk in make\_chunks(bufferlist, chunksize):
future\_to\_url \= {executor.submit(worker, url): url for url in chunk}
for future in as\_completed(future\_to\_url):
yield future\_to\_url\[future\], future.result()
def buffered\_downloads(
bufferlist: List\[str\],
download\_threads: int,
options: Optional\[Extractor\] \= None,
) \-> Generator\[Tuple\[str, str\], None, None\]:
"Download queue consumer, single- or multi-threaded."
worker \= partial(fetch\_url, options\=options)
return \_buffered\_downloads(bufferlist, download\_threads, worker)
def buffered\_response\_downloads(
bufferlist: List\[str\],
download\_threads: int,
options: Optional\[Extractor\] \= None,
) \-> Generator\[Tuple\[str, Response\], None, None\]:
"Download queue consumer, returns full Response objects."
config \= options.config if options else DEFAULT\_CONFIG
worker \= partial(fetch\_response, config\=config)
return \_buffered\_downloads(bufferlist, download\_threads, worker)
def \_send\_pycurl\_request(
url: str, no\_ssl: bool, with\_headers: bool, config: ConfigParser
) \-> Optional\[Response\]:
"""Experimental function using libcurl and pycurl to speed up downloads"""
\# https://github.com/pycurl/pycurl/blob/master/examples/retriever-multi.py
\# init
headerlist \= \[\
f"{header}: {content}" for header, content in \_determine\_headers(config).items()\
\]
\# prepare curl request
\# https://curl.haxx.se/libcurl/c/curl\_easy\_setopt.html
curl \= pycurl.Curl()
curl.setopt(pycurl.URL, url.encode("utf-8"))
\# share data
curl.setopt(pycurl.SHARE, CURL\_SHARE)
curl.setopt(pycurl.HTTPHEADER, headerlist)
\# curl.setopt(pycurl.USERAGENT, '')
curl.setopt(pycurl.FOLLOWLOCATION, 1)
curl.setopt(pycurl.MAXREDIRS, config.getint("DEFAULT", "MAX\_REDIRECTS"))
curl.setopt(pycurl.CONNECTTIMEOUT, config.getint("DEFAULT", "DOWNLOAD\_TIMEOUT"))
curl.setopt(pycurl.TIMEOUT, config.getint("DEFAULT", "DOWNLOAD\_TIMEOUT"))
curl.setopt(pycurl.MAXFILESIZE, config.getint("DEFAULT", "MAX\_FILE\_SIZE"))
curl.setopt(pycurl.NOSIGNAL, 1)
if no\_ssl is True:
curl.setopt(pycurl.SSL\_VERIFYPEER, 0)
curl.setopt(pycurl.SSL\_VERIFYHOST, 0)
else:
curl.setopt(pycurl.CAINFO, certifi.where())
if with\_headers:
headerbytes \= BytesIO()
curl.setopt(pycurl.HEADERFUNCTION, headerbytes.write)
if PROXY\_URL:
curl.setopt(pycurl.PRE\_PROXY, PROXY\_URL)
\# TCP\_FASTOPEN
\# curl.setopt(pycurl.FAILONERROR, 1)
\# curl.setopt(pycurl.ACCEPT\_ENCODING, '')
\# send request
try:
bufferbytes \= curl.perform\_rb()
except pycurl.error as err:
LOGGER.error("pycurl error: %s %s", url, err)
\# retry in case of SSL-related error
\# see https://curl.se/libcurl/c/libcurl-errors.html
\# errmsg = curl.errstr\_raw()
\# additional error codes: 80, 90, 96, 98
if no\_ssl is False and err.args\[0\] in CURL\_SSL\_ERRORS:
LOGGER.debug("retrying after SSL error: %s %s", url, err)
return \_send\_pycurl\_request(url, True, with\_headers, config)
\# traceback.print\_exc(file=sys.stderr)
\# sys.stderr.flush()
return None
\# additional info
\# ip\_info = curl.getinfo(curl.PRIMARY\_IP)
resp \= Response(
bufferbytes, curl.getinfo(curl.RESPONSE\_CODE), curl.getinfo(curl.EFFECTIVE\_URL)
)
curl.close()
if with\_headers:
respheaders \= {}
\# https://github.com/pycurl/pycurl/blob/master/examples/quickstart/response\_headers.py
for line in (
headerbytes.getvalue().decode("iso-8859-1", errors\="replace").splitlines()
):
\# re.split(r'\\r?\\n') ?
\# This will botch headers that are split on multiple lines...
if ":" not in line:
continue
\# Break the header line into header name and value.
name, value \= line.split(":", 1)
\# Now we can actually record the header name and value.
respheaders\[name.strip()\] \= value.strip() \# name.strip().lower() ?
resp.store\_headers(respheaders)
return resp
---
# trafilatura.feeds — Trafilatura 2.0.0 documentation
[Skip to main content](https://trafilatura.readthedocs.io/en/latest/_modules/trafilatura/feeds.html#main-content)
Back to top Ctrl+K
* [GitHub](https://github.com/adbar/trafilatura "GitHub")
Source code for trafilatura.feeds
=================================
"""
Examining feeds and extracting links for further processing.
"""
import json
import logging
import re
from itertools import islice
from time import sleep
from typing import List, Optional
from courlan import (
check\_url,
clean\_url,
filter\_urls,
fix\_relative\_urls,
get\_hostinfo,
is\_valid\_url,
)
from .deduplication import is\_similar\_domain
from .downloads import fetch\_url
from .settings import MAX\_LINKS
from .utils import load\_html
LOGGER \= logging.getLogger(\_\_name\_\_)
\# https://www.iana.org/assignments/media-types/media-types.xhtml
\# standard + potential types
FEED\_TYPES \= {
"application/atom", \# not IANA-compatible
"application/atom+xml",
"application/feed+json", \# not IANA-compatible
"application/json",
"application/rdf", \# not IANA-compatible
"application/rdf+xml",
"application/rss", \# not IANA-compatible
"application/rss+xml",
"application/x.atom+xml", \# not IANA-compatible
"application/x-atom+xml", \# not IANA-compatible
"application/xml",
"text/atom", \# not IANA-compatible
"text/atom+xml",
"text/plain",
"text/rdf", \# not IANA-compatible
"text/rdf+xml",
"text/rss", \# not IANA-compatible
"text/rss+xml",
"text/xml",
}
FEED\_OPENING \= re.compile(r"<(feed|rss|\\?xml)")
LINK\_ATTRS \= re.compile(r'(?:\\s\*)(?:)?(?:\\s\*)"
)
BLACKLIST \= re.compile(r"\\bcomments\\b") \# no comment feed
LINK\_VALIDATION\_RE \= re.compile(
r"\\.(?:atom|rdf|rss|xml)$|"
r"\\b(?:atom|rss)\\b|"
r"\\?type=100$|" \# Typo3
r"feeds/posts/default/?$|" \# Blogger
r"\\?feed=(?:atom|rdf|rss|rss2)|"
r"feed$" \# Generic
)
class FeedParameters:
"Store necessary information to proceed a feed."
\_\_slots\_\_ \= \["base", "domain", "ext", "lang", "ref"\]
def \_\_init\_\_(
self,
baseurl: str,
domain: str,
reference: str,
external: bool \= False,
target\_lang: Optional\[str\] \= None,
) \-> None:
self.base: str \= baseurl
self.domain: str \= domain
self.ext: bool \= external
self.lang: Optional\[str\] \= target\_lang
self.ref: str \= reference
def is\_potential\_feed(feed\_string: str) \-> bool:
"Check if the string could be a feed."
if FEED\_OPENING.match(feed\_string):
return True
beginning \= feed\_string\[:100\]
return "
to preserve formatting if parent is not None and element.tag != "graphic" and parent.tag != 'code': parent.remove(element) return tree def strip\_double\_tags(tree: \_Element) \-> \_Element: "Prevent nested tags among a fixed list of tags." for elem in reversed(tree.xpath(".//head | .//code | .//p")): for subelem in elem.iterdescendants("code", "head", "p"): if subelem.tag \== elem.tag and subelem.getparent().tag not in NESTING\_WHITELIST: merge\_with\_parent(subelem) return tree def build\_json\_output(docmeta: Document, with\_metadata: bool \= True) \-> str: '''Build JSON output based on extracted information''' if with\_metadata: outputdict \= {slot: getattr(docmeta, slot, None) for slot in docmeta.\_\_slots\_\_} outputdict.update({ 'source': outputdict.pop('url'), 'source-hostname': outputdict.pop('sitename'), 'excerpt': outputdict.pop('description'), 'categories': ';'.join(outputdict.pop('categories') or \[\]), 'tags': ';'.join(outputdict.pop('tags') or \[\]), 'text': xmltotxt(outputdict.pop('body'), include\_formatting\=False), }) commentsbody \= outputdict.pop('commentsbody') else: outputdict \= {'text': xmltotxt(docmeta.body, include\_formatting\=False)} commentsbody \= docmeta.commentsbody outputdict\['comments'\] \= xmltotxt(commentsbody, include\_formatting\=False) return json\_dumps(outputdict, ensure\_ascii\=False) def clean\_attributes(tree: \_Element) \-> \_Element: '''Remove unnecessary attributes.''' for elem in tree.iter('\*'): if elem.tag not in WITH\_ATTRIBUTES: elem.attrib.clear() return tree def build\_xml\_output(docmeta: Document) \-> \_Element: '''Build XML output tree based on extracted information''' output \= Element('doc') add\_xml\_meta(output, docmeta) docmeta.body.tag \= 'main' \# clean XML tree output.append(clean\_attributes(docmeta.body)) docmeta.commentsbody.tag \= 'comments' output.append(clean\_attributes(docmeta.commentsbody)) return output def control\_xml\_output(document: Document, options: Extractor) \-> str: '''Make sure the XML output is conform and valid if required''' strip\_double\_tags(document.body) remove\_empty\_elements(document.body) func \= build\_xml\_output if options.format \== "xml" else build\_tei\_output output\_tree \= func(document) output\_tree \= sanitize\_tree(output\_tree) \# necessary for cleaning output\_tree \= fromstring(tostring(output\_tree, encoding\='unicode'), CONTROL\_PARSER) \# validate if options.format \== 'xmltei' and options.tei\_validation: LOGGER.debug('TEI validation result: %s %s', validate\_tei(output\_tree), options.source) return tostring(output\_tree, pretty\_print\=True, encoding\='unicode').strip() def add\_xml\_meta(output: \_Element, docmeta: Document) \-> None: '''Add extracted metadata to the XML output tree''' for attribute in META\_ATTRIBUTES: value \= getattr(docmeta, attribute, None) if value: output.set(attribute, value if isinstance(value, str) else ';'.join(value)) def build\_tei\_output(docmeta: Document) \-> \_Element: '''Build TEI-XML output tree based on extracted information''' \# build TEI tree output \= write\_teitree(docmeta) \# filter output (strip unwanted elements), just in case \# check and repair output \= check\_tei(output, docmeta.url) return output def check\_tei(xmldoc: \_Element, url: Optional\[str\]) \-> \_Element: '''Check if the resulting XML file is conform and scrub remaining tags''' \# convert head tags for elem in xmldoc.iter('head'): elem.tag \= 'ab' elem.set('type', 'header') parent \= elem.getparent() if parent is None: continue if len(elem) \> 0: new\_elem \= \_tei\_handle\_complex\_head(elem) parent.replace(elem, new\_elem) elem \= new\_elem if parent.tag \== "p": \_move\_element\_one\_level\_up(elem) \# convert to within when child of
for elem in xmldoc.findall(".//text/body//div/lb"): if elem.tail and elem.tail.strip(): elem.tag, elem.text, elem.tail \= 'p', elem.tail, None \# look for elements that are not valid for elem in xmldoc.findall('.//text/body//\*'): \# check elements if elem.tag not in TEI\_VALID\_TAGS: \# disable warnings for chosen categories \# if element.tag not in ('div', 'span'): LOGGER.warning('not a TEI element, removing: %s %s', elem.tag, url) merge\_with\_parent(elem) continue if elem.tag in TEI\_REMOVE\_TAIL: \_handle\_unwanted\_tails(elem) elif elem.tag \== "div": \_handle\_text\_content\_of\_div\_nodes(elem) \_wrap\_unwanted\_siblings\_of\_div(elem) #if len(elem) == 0: \# elem.getparent().remove(elem) \# check attributes for attribute in \[a for a in elem.attrib if a not in TEI\_VALID\_ATTRS\]: LOGGER.warning('not a valid TEI attribute, removing: %s in %s %s', attribute, elem.tag, url) elem.attrib.pop(attribute) return xmldoc [\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.xml.validate_tei) def validate\_tei(xmldoc: \_Element) \-> bool: '''Check if an XML document is conform to the guidelines of the Text Encoding Initiative''' global TEI\_DTD if TEI\_DTD is None: \# https://tei-c.org/release/xml/tei/custom/schema/dtd/tei\_corpus.dtd TEI\_DTD \= DTD(TEI\_SCHEMA) result \= TEI\_DTD.validate(xmldoc) if result is False: LOGGER.warning('not a valid TEI document: %s', TEI\_DTD.error\_log.last\_error) return result def replace\_element\_text(element: \_Element, include\_formatting: bool) \-> str: "Determine element text based on just the text of the element. One must deal with the tail separately." elem\_text \= element.text or "" \# handle formatting: convert to markdown if include\_formatting and element.text: if element.tag \== "head": try: number \= int(element.get("rend")\[1\]) \# type: ignore\[index\] except (TypeError, ValueError): number \= 2 elem\_text \= f'{"#" \* number} {elem\_text}' elif element.tag \== "del": elem\_text \= f"~~{elem\_text}~~" elif element.tag \== "hi": rend \= element.get("rend") if rend in HI\_FORMATTING: elem\_text \= f"{HI\_FORMATTING\[rend\]}{elem\_text}{HI\_FORMATTING\[rend\]}" elif element.tag \== "code": if "\\n" in element.text: elem\_text \= f"\`\`\`\\n{elem\_text}\\n\`\`\`" else: elem\_text \= f"\`{elem\_text}\`" \# handle links if element.tag \== "ref": if elem\_text: link\_text \= f"\[{elem\_text}\]" target \= element.get("target") if target: elem\_text \= f"{link\_text}({target})" else: LOGGER.warning("missing link attribute: %s %s'", elem\_text, element.attrib) elem\_text \= link\_text else: LOGGER.warning("empty link: %s %s", elem\_text, element.attrib) \# cells if element.tag \== "cell" and elem\_text and len(element) \> 0: if element\[0\].tag \== 'p': elem\_text \= f"{elem\_text} " if element.getprevious() is not None else f"| {elem\_text} " elif element.tag \== 'cell' and elem\_text: \# add | before first cell elem\_text \= f"{elem\_text}" if element.getprevious() is not None else f"| {elem\_text}" \# lists elif element.tag \== "item" and elem\_text: elem\_text \= f"- {elem\_text}\\n" return elem\_text def process\_element(element: \_Element, returnlist: List\[str\], include\_formatting: bool) \-> None: "Recursively convert a LXML element and its children to a flattened string representation." if element.text: \# this is the text that comes before the first child returnlist.append(replace\_element\_text(element, include\_formatting)) for child in element: process\_element(child, returnlist, include\_formatting) if not element.text and not element.tail: if element.tag \== "graphic": \# add source, default to '' text \= f'{element.get("title", "")} {element.get("alt", "")}' returnlist.append(f'!\[{text.strip()}\]({element.get("src", "")})') \# newlines for textless elements elif element.tag in NEWLINE\_ELEMS: \# add line after table head if element.tag \== "row": cell\_count \= len(element.xpath(".//cell")) \# restrict columns to a maximum of 1000 span\_info \= element.get("colspan") or element.get("span") if not span\_info or not span\_info.isdigit(): max\_span \= 1 else: max\_span \= min(int(span\_info), MAX\_TABLE\_WIDTH) \# row ended so draw extra empty cells to match max\_span if cell\_count < max\_span: returnlist.append(f'{"|" \* (max\_span \- cell\_count)}\\n') \# if this is a head row, draw the separator below if element.xpath("./cell\[@role='head'\]"): returnlist.append(f'\\n|{"---|" \* max\_span}\\n') else: returnlist.append("\\n") elif element.tag != "cell": \# cells still need to append vertical bars \# but nothing more to do with other textless elements return \# Process text \# Common elements (Now processes end-tag logic correctly) if element.tag in NEWLINE\_ELEMS and not element.xpath("ancestor::cell"): \# spacing hack returnlist.append("\\n\\u2424\\n" if include\_formatting and element.tag != 'row' else "\\n") elif element.tag \== "cell": returnlist.append(" | ") elif element.tag not in SPECIAL\_FORMATTING: returnlist.append(" ") \# this is text that comes after the closing tag, so it should be after any NEWLINE\_ELEMS if element.tail: returnlist.append(element.tail) [\[docs\]](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#trafilatura.xml.xmltotxt) def xmltotxt(xmloutput: Optional\[\_Element\], include\_formatting: bool) \-> str: "Convert to plain text format and optionally preserve formatting as markdown." if xmloutput is None: return "" returnlist: List\[str\] \= \[\] process\_element(xmloutput, returnlist, include\_formatting) return unescape(sanitize("".join(returnlist)) or "") def xmltocsv(document: Document, include\_formatting: bool, \*, delim: str \= "\\t", null: str \= "null") \-> str: "Convert the internal XML document representation to a CSV string." \# preprocessing posttext \= xmltotxt(document.body, include\_formatting) or null commentstext \= xmltotxt(document.commentsbody, include\_formatting) or null \# output config output \= StringIO() outputwriter \= csv.writer(output, delimiter\=delim, quoting\=csv.QUOTE\_MINIMAL) \# organize fields outputwriter.writerow(\[d if d else null for d in (\ document.url,\ document.id,\ document.fingerprint,\ document.hostname,\ document.title,\ document.image,\ document.date,\ posttext,\ commentstext,\ document.license,\ document.pagetype\ )\]) return output.getvalue() def write\_teitree(docmeta: Document) \-> \_Element: '''Bundle the extracted post and comments into a TEI tree''' teidoc \= Element('TEI', xmlns\='http://www.tei-c.org/ns/1.0') write\_fullheader(teidoc, docmeta) textelem \= SubElement(teidoc, 'text') textbody \= SubElement(textelem, 'body') \# post postbody \= clean\_attributes(docmeta.body) postbody.tag \= 'div' postbody.set('type', 'entry') textbody.append(postbody) \# comments commentsbody \= clean\_attributes(docmeta.commentsbody) commentsbody.tag \= 'div' commentsbody.set('type', 'comments') textbody.append(commentsbody) return teidoc def \_define\_publisher\_string(docmeta: Document) \-> str: '''Construct a publisher string to include in TEI header''' if docmeta.hostname and docmeta.sitename: publisher \= f'{docmeta.sitename.strip()} ({docmeta.hostname})' else: publisher \= docmeta.hostname or docmeta.sitename or 'N/A' if LOGGER.isEnabledFor(logging.WARNING) and publisher \== 'N/A': LOGGER.warning('no publisher for URL %s', docmeta.url) return publisher def write\_fullheader(teidoc: \_Element, docmeta: Document) \-> \_Element: '''Write TEI header based on gathered metadata''' \# todo: add language info header \= SubElement(teidoc, 'teiHeader') filedesc \= SubElement(header, 'fileDesc') bib\_titlestmt \= SubElement(filedesc, 'titleStmt') SubElement(bib\_titlestmt, 'title', type\='main').text \= docmeta.title if docmeta.author: SubElement(bib\_titlestmt, 'author').text \= docmeta.author publicationstmt\_a \= SubElement(filedesc, 'publicationStmt') publisher\_string \= \_define\_publisher\_string(docmeta) \# license, if applicable if docmeta.license: SubElement(publicationstmt\_a, 'publisher').text \= publisher\_string availability \= SubElement(publicationstmt\_a, 'availability') SubElement(availability, 'p').text \= docmeta.license \# insert an empty paragraph for conformity else: SubElement(publicationstmt\_a, 'p') notesstmt \= SubElement(filedesc, 'notesStmt') if docmeta.id: SubElement(notesstmt, 'note', type\='id').text \= docmeta.id SubElement(notesstmt, 'note', type\='fingerprint').text \= docmeta.fingerprint sourcedesc \= SubElement(filedesc, 'sourceDesc') source\_bibl \= SubElement(sourcedesc, 'bibl') sigle \= ', '.join(filter(None, \[docmeta.sitename, docmeta.date\])) if not sigle: LOGGER.warning('no sigle for URL %s', docmeta.url) source\_bibl.text \= ', '.join(filter(None, \[docmeta.title, sigle\])) SubElement(sourcedesc, 'bibl', type\='sigle').text \= sigle biblfull \= SubElement(sourcedesc, 'biblFull') bib\_titlestmt \= SubElement(biblfull, 'titleStmt') SubElement(bib\_titlestmt, 'title', type\='main').text \= docmeta.title if docmeta.author: SubElement(bib\_titlestmt, 'author').text \= docmeta.author publicationstmt \= SubElement(biblfull, 'publicationStmt') SubElement(publicationstmt, 'publisher').text \= publisher\_string if docmeta.url: SubElement(publicationstmt, 'ptr', type\='URL', target\=docmeta.url) SubElement(publicationstmt, 'date').text \= docmeta.date profiledesc \= SubElement(header, 'profileDesc') abstract \= SubElement(profiledesc, 'abstract') SubElement(abstract, 'p').text \= docmeta.description if docmeta.categories or docmeta.tags: textclass \= SubElement(profiledesc, 'textClass') keywords \= SubElement(textclass, 'keywords') if docmeta.categories: SubElement(keywords, 'term', type\='categories').text \= ','.join(docmeta.categories) if docmeta.tags: SubElement(keywords, 'term', type\='tags').text \= ','.join(docmeta.tags) creation \= SubElement(profiledesc, 'creation') SubElement(creation, 'date', type\="download").text \= docmeta.filedate encodingdesc \= SubElement(header, 'encodingDesc') appinfo \= SubElement(encodingdesc, 'appInfo') application \= SubElement(appinfo, 'application', version\=PKG\_VERSION, ident\='Trafilatura') SubElement(application, 'label').text \= 'Trafilatura' SubElement(application, 'ptr', target\='https://github.com/adbar/trafilatura') return header def \_handle\_text\_content\_of\_div\_nodes(element: \_Element) \-> None: "Wrap loose text in
elements for TEI conformity." if element.text and element.text.strip(): if len(element) \> 0 and element\[0\].tag \== "p": element\[0\].text \= f'{element.text} {element\[0\].text or ""}'.strip() else: new\_child \= Element("p") new\_child.text \= element.text element.insert(0, new\_child) element.text \= None if element.tail and element.tail.strip(): if len(element) \> 0 and element\[\-1\].tag \== "p": element\[\-1\].text \= f'{element\[\-1\].text or ""} {element.tail}'.strip() else: new\_child \= Element("p") new\_child.text \= element.tail element.append(new\_child) element.tail \= None def \_handle\_unwanted\_tails(element: \_Element) \-> None: "Handle tail on p and ab elements" element.tail \= element.tail.strip() if element.tail else None if not element.tail: return if element.tag \== "p": element.text \= " ".join(filter(None, \[element.text, element.tail\])) else: new\_sibling \= Element('p') new\_sibling.text \= element.tail parent \= element.getparent() if parent is not None: parent.insert(parent.index(element) + 1 , new\_sibling) element.tail \= None def \_tei\_handle\_complex\_head(element: \_Element) \-> \_Element: "Convert certain child elements to and
." new\_element \= Element('ab', attrib\=element.attrib) new\_element.text \= element.text.strip() if element.text else None for child in element.iterchildren(): if child.tag \== 'p': if len(new\_element) \> 0 or new\_element.text: \# add
if has no children or last tail contains text
if len(new\_element) \== 0 or new\_element\[\-1\].tail:
SubElement(new\_element, 'lb')
new\_element\[\-1\].tail \= child.text
else:
new\_element.text \= child.text
else:
new\_element.append(child)
tail \= element.tail.strip() if element.tail else None
if tail:
new\_element.tail \= tail
return new\_element
def \_wrap\_unwanted\_siblings\_of\_div(div\_element: \_Element) \-> None:
"Wrap unwanted siblings of a div element in a new div element."
new\_sibling \= Element("div")
new\_sibling\_index \= None
parent \= div\_element.getparent()
if parent is None:
return
\# check siblings after target element
for sibling in div\_element.itersiblings():
if sibling.tag \== "div":
break
if sibling.tag in TEI\_DIV\_SIBLINGS:
new\_sibling\_index \= new\_sibling\_index or parent.index(sibling)
new\_sibling.append(sibling)
\# some elements (e.g.
) can appear next to div, but \# order of elements should be kept, thus add and reset new\_sibling else: if new\_sibling\_index and len(new\_sibling) \> 0: parent.insert(new\_sibling\_index, new\_sibling) new\_sibling \= Element("div") new\_sibling\_index \= None if new\_sibling\_index and len(new\_sibling) != 0: parent.insert(new\_sibling\_index, new\_sibling) def \_move\_element\_one\_level\_up(element: \_Element) \-> None: """ Fix TEI compatibility issues by moving certain p-elems up in the XML tree. There is always a n+2 nesting for p-elements with the minimal structure ./TEI/text/body/p """ parent \= element.getparent() grand\_parent \= parent.getparent() if parent is not None else None if parent is None or grand\_parent is None: return new\_elem \= Element("p") new\_elem.extend(list(element.itersiblings())) grand\_parent.insert(grand\_parent.index(parent) + 1, element) tail \= element.tail.strip() if element.tail else None if tail: new\_elem.text \= tail element.tail \= None tail \= parent.tail.strip() if parent.tail else None if tail: new\_elem.tail \= tail parent.tail \= None if len(new\_elem) \> 0 or new\_elem.text or new\_elem.tail: grand\_parent.insert(grand\_parent.index(element) + 1, new\_elem) if len(parent) \== 0 and not parent.text: grand\_parent.remove(parent) ---