# pylint:disable-msg=E0611 """ Listing a series of settings that are applied module-wide. """ from configparser import ConfigParser from datetime import datetime from html import unescape from typing import Any, Dict, List, Optional, Set try: from os import sched_getaffinity CPU_COUNT = len(sched_getaffinity(0)) except ImportError: from os import cpu_count CPU_COUNT = cpu_count() or 1 from pathlib import Path from lxml.etree import _Element, Element, XPath from .utils import line_processing SUPPORTED_FMT_CLI = ["csv", "json", "html", "markdown", "txt", "xml", "xmltei"] SUPPORTED_FORMATS = set(SUPPORTED_FMT_CLI) | {"python"} # for bare_extraction() only def use_config( filename: Optional[str] = None, config: Optional[ConfigParser] = None ) -> ConfigParser: """ Use configuration object or read and parse a settings file. """ if config is not None: return config if filename is None: filename = str(Path(__file__).parent / "settings.cfg") elif not Path(filename).is_file(): raise FileNotFoundError("The given config file does not exist") config = ConfigParser() config.read(filename) return config DEFAULT_CONFIG = use_config() CONFIG_MAPPING = { "min_extracted_size": "MIN_EXTRACTED_SIZE", "min_output_size": "MIN_OUTPUT_SIZE", "min_output_comm_size": "MIN_OUTPUT_COMM_SIZE", "min_extracted_comm_size": "MIN_EXTRACTED_COMM_SIZE", "min_duplcheck_size": "MIN_DUPLCHECK_SIZE", "max_repetitions": "MAX_REPETITIONS", "max_file_size": "MAX_FILE_SIZE", "min_file_size": "MIN_FILE_SIZE", } # todo Python >= 3.10: use dataclass with slots=True class Extractor: "Defines a class to store all extraction options." __slots__ = [ "config", # general "format", "fast", "focus", "comments", "formatting", "links", "images", "tables", "dedup", "lang", # extraction size "min_extracted_size", "min_output_size", "min_output_comm_size", "min_extracted_comm_size", # deduplication "min_duplcheck_size", "max_repetitions", # rest "max_file_size", "min_file_size", "max_tree_size", # meta "source", "url", "with_metadata", "only_with_metadata", "tei_validation", "date_params", "author_blacklist", "url_blacklist", ] def __init__( self, *, config: ConfigParser = DEFAULT_CONFIG, output_format: str = "txt", fast: bool = False, precision: bool = False, recall: bool = False, comments: bool = True, formatting: bool = False, links: bool = False, images: bool = False, tables: bool = True, dedup: bool = False, lang: Optional[str] = None, url: Optional[str] = None, source: Optional[str] = None, with_metadata: bool = False, only_with_metadata: bool = False, tei_validation: bool = False, author_blacklist: Optional[Set[str]] = None, url_blacklist: Optional[Set[str]] = None, date_params: Optional[Dict[str, str]] = None, ): self._set_source(url, source) self._set_format(output_format) self._add_config(config) self.fast: bool = fast self.focus: str = ( "recall" if recall else "precision" if precision else "balanced" ) self.comments: bool = comments self.formatting: bool = formatting or self.format == "markdown" self.links: bool = links self.images: bool = images self.tables: bool = tables self.dedup: bool = dedup self.lang: Optional[str] = lang self.url: Optional[str] = url self.only_with_metadata: bool = only_with_metadata self.tei_validation: bool = tei_validation self.author_blacklist: Set[str] = author_blacklist or set() self.url_blacklist: Set[str] = url_blacklist or set() self.with_metadata: bool = ( with_metadata or only_with_metadata or bool(url_blacklist) or output_format == "xmltei" ) self.date_params: Dict[str, Any] = date_params or set_date_params( self.config.getboolean("DEFAULT", "EXTENSIVE_DATE_SEARCH") ) self.max_tree_size = None def _set_source(self, url: Optional[str], source: Optional[str]) -> None: "Set the source attribute in a robust way." source = url or source self.source = source and source.encode("utf-8", "replace").decode("utf-8") def _set_format(self, chosen_format: str) -> None: "Store the format if supported and raise an error otherwise." if chosen_format not in SUPPORTED_FORMATS: raise AttributeError( f"Cannot set format, must be one of: {', '.join(sorted(SUPPORTED_FORMATS))}" ) self.format = chosen_format def _add_config(self, config: ConfigParser) -> None: "Store options loaded from config file." for key, value in CONFIG_MAPPING.items(): setattr(self, key, config.getint("DEFAULT", value)) self.config = config def args_to_extractor(args: Any, url: Optional[str] = None) -> Extractor: "Derive extractor configuration from CLI args." options = Extractor( config=use_config(filename=args.config_file), output_format=args.output_format, formatting=args.formatting, precision=args.precision, recall=args.recall, comments=args.no_comments, tables=args.no_tables, dedup=args.deduplicate, lang=args.target_language, url=url, with_metadata=args.with_metadata, only_with_metadata=args.only_with_metadata, tei_validation=args.validate_tei, ) for attr in ("fast", "images", "links"): setattr(options, attr, getattr(args, attr)) return options def set_date_params(extensive: bool = True) -> Dict[str, Any]: "Provide default parameters for date extraction." return { "original_date": True, "extensive_search": extensive, "max_date": datetime.now().strftime("%Y-%m-%d"), } # todo Python >= 3.10: use dataclass with slots=True class Document: "Defines a class to store all necessary data and metadata fields for extracted information." __slots__ = [ "title", "author", "url", "hostname", "description", "sitename", "date", "categories", "tags", "fingerprint", "id", "license", "body", "comments", "commentsbody", "raw_text", "text", "language", "image", "pagetype", "filedate", # 'locale'? ] def __init__( self, *, title: Optional[str] = None, author: Optional[str] = None, url: Optional[str] = None, hostname: Optional[str] = None, description: Optional[str] = None, sitename: Optional[str] = None, date: Optional[str] = None, categories: Optional[List[str]] = None, tags: Optional[List[str]] = None, fingerprint: Optional[str] = None, idval: Optional[str] = None, license_val: Optional[str] = None, body: _Element = Element("body"), comments: Optional[str] = None, commentsbody: _Element = Element("body"), raw_text: Optional[str] = None, text: Optional[str] = None, language: Optional[str] = None, image: Optional[str] = None, pagetype: Optional[str] = None, filedate: Optional[str] = None, ): self.title: Optional[str] = title self.author: Optional[str] = author self.url: Optional[str] = url self.hostname: Optional[str] = hostname self.description: Optional[str] = description self.sitename: Optional[str] = sitename self.date: Optional[str] = date self.categories: Optional[List[str]] = categories self.tags: Optional[List[str]] = tags self.fingerprint: Optional[str] = fingerprint self.id: Optional[str] = idval self.license: Optional[str] = license_val self.body: _Element = body self.comments: Optional[str] = comments self.commentsbody: _Element = commentsbody self.raw_text: Optional[str] = raw_text self.text: Optional[str] = text self.language: Optional[str] = language self.image: Optional[str] = image self.pagetype: Optional[str] = pagetype self.filedate: Optional[str] = filedate @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Document': "Set a series of attributes using a dictionary." doc = cls() for key, value in data.items(): setattr(doc, key, value) return doc def clean_and_trim(self) -> None: "Limit text length and trim the attributes." for slot in self.__slots__: value = getattr(self, slot) if isinstance(value, str): # length if len(value) > 10000: value = value[:9999] + "…" # HTML entities, remove spaces and control characters value = line_processing(unescape(value)) setattr(self, slot, value) def as_dict(self) -> Dict[str, Optional[str]]: "Convert the document to a dictionary." return {attr: getattr(self, attr, None) for attr in self.__slots__} # Safety checks PARALLEL_CORES = min(CPU_COUNT, 16) # 16 processes at most LRU_SIZE = 4096 # Files MAX_FILES_PER_DIRECTORY = 1000 FILENAME_LEN = 8 # Network MAX_LINKS = 10**6 MAX_SITEMAPS_SEEN = 10**4 # filters CUT_EMPTY_ELEMS = { "article", "b", "blockquote", "dd", "div", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "i", "li", "main", "p", "pre", "q", "section", "span", "strong", } # 'meta', 'td', 'a', 'caption', 'dl', 'header', # 'colgroup', 'col', # CUT_EMPTY_ELEMS = {'div', 'span'} # order could matter, using lists to keep extraction deterministic MANUALLY_CLEANED = [ # important "aside", "embed", "footer", "form", "head", "iframe", "menu", "object", "script", # other content "applet", "audio", "canvas", "figure", "map", "picture", "svg", "video", # secondary "area", "blink", "button", "datalist", "dialog", "frame", "frameset", "fieldset", "link", "input", "ins", "label", "legend", "marquee", "math", "menuitem", "nav", "noindex", "noscript", "optgroup", "option", "output", "param", "progress", "rp", "rt", "rtc", "select", "source", "style", "track", "textarea", "time", "use", ] # 'meta', 'hr', 'img', 'data', 'details', 'summary' MANUALLY_STRIPPED = [ "abbr", "acronym", "address", "bdi", "bdo", "big", "cite", "data", "dfn", "font", "hgroup", "img", "ins", "mark", "meta", "ruby", "small", "tbody", "template", "tfoot", "thead", ] # 'center', 'rb', 'wbr' BASIC_CLEAN_XPATH = XPath( ".//aside|.//div[contains(@class|@id, 'footer')]|.//footer|.//script|.//style" ) TAG_CATALOG = frozenset( ["blockquote", "code", "del", "head", "hi", "lb", "list", "p", "pre", "quote"] ) # + list(CUT_EMPTY_ELEMS) JUSTEXT_LANGUAGES = { "ar": "Arabic", "bg": "Bulgarian", "cz": "Czech", "da": "Danish", "de": "German", "en": "English", "el": "Greek", "es": "Spanish", "fa": "Persian", "fi": "Finnish", "fr": "French", "hr": "Croatian", "hu": "Hungarian", # 'ja': '', "ko": "Korean", "id": "Indonesian", "it": "Italian", "no": "Norwegian_Nynorsk", "nl": "Dutch", "pl": "Polish", "pt": "Portuguese", "ro": "Romanian", "ru": "Russian", "sk": "Slovak", "sl": "Slovenian", "sr": "Serbian", "sv": "Swedish", "tr": "Turkish", "uk": "Ukrainian", "ur": "Urdu", "vi": "Vietnamese", # 'zh': '', }