""" Module bundling all functions needed to scrape metadata from webpages. """ import json import logging import re from copy import deepcopy from html import unescape from typing import Any, Dict, List, Optional, Set, Tuple, Union from courlan import ( extract_domain, get_base_url, is_valid_url, normalize_url, validate_url, ) from htmldate import find_date from lxml.etree import XPath from lxml.html import HtmlElement, tostring from .htmlprocessing import prune_unwanted_nodes from .json_metadata import ( extract_json, extract_json_parse_error, normalize_authors, normalize_json, ) from .settings import Document, set_date_params from .utils import HTML_STRIP_TAGS, line_processing, load_html, trim from .xpaths import ( AUTHOR_DISCARD_XPATHS, AUTHOR_XPATHS, CATEGORIES_XPATHS, TAGS_XPATHS, TITLE_XPATHS, ) __all__ = ["Document"] LOGGER = logging.getLogger(__name__) logging.getLogger("htmldate").setLevel(logging.WARNING) META_URL = re.compile(r"https?://(?:www\.|w[0-9]+\.)?([^/]+)") JSON_MINIFY = re.compile(r'("(?:\\"|[^"])*")|\s') HTMLTITLE_REGEX = re.compile( r"^(.+)?\s+[–•·—|⁄*⋆~‹«<›»>:-]\s+(.+)$" ) # part without dots? CLEAN_META_TAGS = re.compile(r'["\']') LICENSE_REGEX = re.compile( r"/(by-nc-nd|by-nc-sa|by-nc|by-nd|by-sa|by|zero)/([1-9]\.[0-9])" ) TEXT_LICENSE_REGEX = re.compile( r"(cc|creative commons) (by-nc-nd|by-nc-sa|by-nc|by-nd|by-sa|by|zero) ?([1-9]\.[0-9])?", re.I, ) METANAME_AUTHOR = { "article:author", "atc-metaauthor", "author", "authors", "byl", "citation_author", "creator", "dc.creator", "dc.creator.aut", "dc:creator", "dcterms.creator", "dcterms.creator.aut", "dcsext.author", "parsely-author", "rbauthors", "sailthru.author", "shareaholic:article_author_name", } # questionable: twitter:creator METANAME_DESCRIPTION = { "dc.description", "dc:description", "dcterms.abstract", "dcterms.description", "description", "sailthru.description", "twitter:description", } METANAME_PUBLISHER = { "article:publisher", "citation_journal_title", "copyright", "dc.publisher", "dc:publisher", "dcterms.publisher", "publisher", "sailthru.publisher", "rbpubname", "twitter:site", } # questionable: citation_publisher METANAME_TAG = { "citation_keywords", "dcterms.subject", "keywords", "parsely-tags", "shareaholic:keywords", "tags", } METANAME_TITLE = { "citation_title", "dc.title", "dcterms.title", "fb_title", "headline", "parsely-title", "sailthru.title", "shareaholic:title", "rbtitle", "title", "twitter:title", } METANAME_URL = {"rbmainurl", "twitter:url"} METANAME_IMAGE = { "image", "og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src", } PROPERTY_AUTHOR = {"author", "article:author"} TWITTER_ATTRS = {"twitter:site", "application-name"} # also interesting: article:section EXTRA_META = {"charset", "http-equiv", "property"} OG_PROPERTIES = { "og:title": "title", "og:description": "description", "og:site_name": "sitename", "og:image": "image", "og:image:url": "image", "og:image:secure_url": "image", "og:type": "pagetype", } OG_AUTHOR = {"og:author", "og:article:author"} URL_SELECTORS = [ './/head//link[@rel="canonical"]', './/head//base', './/head//link[@rel="alternate"][@hreflang="x-default"]' ] def normalize_tags(tags: str) -> str: """Remove special characters of tags""" trimmed = trim(unescape(tags)) if not trimmed: return "" tags = CLEAN_META_TAGS.sub(r"", trimmed) return ", ".join(filter(None, tags.split(", "))) def check_authors(authors: str, author_blacklist: Set[str]) -> Optional[str]: "Check if the authors string correspond to expected values." author_blacklist = {a.lower() for a in author_blacklist} new_authors = [ author.strip() for author in authors.split(";") if author.strip().lower() not in author_blacklist ] if new_authors: return "; ".join(new_authors).strip("; ") return None def extract_meta_json(tree: HtmlElement, metadata: Document) -> Document: """Parse and extract metadata from JSON-LD data""" for elem in tree.xpath( './/script[@type="application/ld+json" or @type="application/settings+json"]' ): if not elem.text: continue element_text = normalize_json(JSON_MINIFY.sub(r"\1", elem.text)) try: schema = json.loads(element_text) metadata = extract_json(schema, metadata) except json.JSONDecodeError: metadata = extract_json_parse_error(element_text, metadata) return metadata def extract_opengraph(tree: HtmlElement) -> Dict[str, Optional[str]]: """Search meta tags following the OpenGraph guidelines (https://ogp.me/)""" result = dict.fromkeys( ("title", "author", "url", "description", "sitename", "image", "pagetype") ) # detect OpenGraph schema for elem in tree.xpath('.//head/meta[starts-with(@property, "og:")]'): property_name, content = elem.get("property"), elem.get("content") # safeguard if content and not content.isspace(): if property_name in OG_PROPERTIES: result[OG_PROPERTIES[property_name]] = content elif property_name == "og:url" and is_valid_url(content): result["url"] = content elif property_name in OG_AUTHOR: result["author"] = normalize_authors(None, content) # og:locale # elif elem.get('property') == 'og:locale': # pagelocale = elem.get('content') return result def examine_meta(tree: HtmlElement) -> Document: """Search meta tags for relevant information""" # bootstrap from potential OpenGraph tags metadata = Document().from_dict(extract_opengraph(tree)) # test if all values not assigned in the following have already been assigned if all( ( metadata.title, metadata.author, metadata.url, metadata.description, metadata.sitename, metadata.image, ) ): # tags return metadata tags, backup_sitename = [], None # iterate through meta tags for elem in tree.iterfind(".//head/meta[@content]"): # content content_attr = HTML_STRIP_TAGS.sub("", elem.get("content", "")).strip() if not content_attr: continue # todo: image info # ... # property if "property" in elem.attrib: property_attr = elem.get("property", "").lower() # no opengraph a second time if property_attr.startswith("og:"): continue if property_attr == "article:tag": tags.append(normalize_tags(content_attr)) elif property_attr in PROPERTY_AUTHOR: metadata.author = normalize_authors(metadata.author, content_attr) elif property_attr == "article:publisher": metadata.sitename = metadata.sitename or content_attr elif property_attr in METANAME_IMAGE: metadata.image = metadata.image or content_attr # name attribute elif "name" in elem.attrib: name_attr = elem.get("name", "").lower() # author if name_attr in METANAME_AUTHOR: metadata.author = normalize_authors(metadata.author, content_attr) # title elif name_attr in METANAME_TITLE: metadata.title = metadata.title or content_attr # description elif name_attr in METANAME_DESCRIPTION: metadata.description = metadata.description or content_attr # site name elif name_attr in METANAME_PUBLISHER: metadata.sitename = metadata.sitename or content_attr # image elif name_attr in METANAME_IMAGE: metadata.image = metadata.image or content_attr # twitter elif name_attr in TWITTER_ATTRS or "twitter:app:name" in name_attr: backup_sitename = content_attr # url elif ( name_attr == "twitter:url" and not metadata.url and is_valid_url(content_attr) ): metadata.url = content_attr # keywords elif name_attr in METANAME_TAG: # 'page-topic' tags.append(normalize_tags(content_attr)) elif "itemprop" in elem.attrib: itemprop_attr = elem.get("itemprop", "").lower() if itemprop_attr == "author": metadata.author = normalize_authors(metadata.author, content_attr) elif itemprop_attr == "description": metadata.description = metadata.description or content_attr elif itemprop_attr == "headline": metadata.title = metadata.title or content_attr # to verify: # elif itemprop_attr == 'name': # if title is None: # title = elem.get('content') # other types elif all(key not in elem.attrib for key in EXTRA_META): LOGGER.debug( "unknown attribute: %s", tostring(elem, pretty_print=False, encoding="unicode").strip(), ) # backups metadata.sitename = metadata.sitename or backup_sitename # copy metadata.tags = tags # metadata.set_attributes(tags=tags) return metadata def extract_metainfo( tree: HtmlElement, expressions: List[XPath], len_limit: int = 200 ) -> Optional[str]: """Extract meta information""" # try all XPath expressions for expression in expressions: # examine all results results = expression(tree) for elem in results: content = trim(" ".join(elem.itertext())) if content and 2 < len(content) < len_limit: return content if len(results) > 1: LOGGER.debug( "more than one invalid result: %s %s", expression, len(results) ) return None def examine_title_element( tree: HtmlElement, ) -> Tuple[str, Optional[str], Optional[str]]: """Extract text segments out of main