import base64 import gzip import json import pathlib import re from dataclasses import dataclass from datetime import datetime from typing import Optional, Tuple, List, Union from urllib.parse import urlparse import pytz from sqlalchemy import asc from sqlalchemy.orm import Session from modules.archive.models import Domain, Archive from wrolpi import dates from wrolpi.common import get_media_directory, logger, extract_domain, escape_file_name, aiohttp_post, \ format_html_string, split_lines_by_length, get_html_soup, get_title_from_html, get_wrolpi_config from wrolpi.dates import now, Seconds from wrolpi.db import get_db_session, get_db_curs, optional_session from wrolpi.errors import UnknownArchive, InvalidOrderBy, InvalidDatetime from wrolpi.tags import tag_append_sub_select_where from wrolpi.vars import PYTEST logger = logger.getChild(__name__) ARCHIVE_SERVICE = 'http://archive:8080' @dataclass class ArchiveFiles: """Every Archive will have some of these files.""" singlefile: pathlib.Path = None readability: pathlib.Path = None readability_txt: pathlib.Path = None readability_json: pathlib.Path = None screenshot: pathlib.Path = None def __repr__(self): singlefile = str(self.singlefile.relative_to(get_archive_directory())) if self.singlefile else None readability = str(self.readability.relative_to(get_archive_directory())) if self.readability else None readability_txt = str( self.readability_txt.relative_to(get_archive_directory())) if self.readability_txt else None readability_json = str( self.readability_json.relative_to(get_archive_directory())) if self.readability_json else None screenshot = str(self.screenshot.relative_to(get_archive_directory())) if self.screenshot else None return f'' def get_archive_directory() -> pathlib.Path: archive_directory = get_media_directory() / get_wrolpi_config().archive_directory return archive_directory def get_domain_directory(url: str) -> pathlib.Path: """Get the archive directory for a particular domain.""" domain = extract_domain(url) directory = get_archive_directory() / domain if directory.is_dir(): return directory elif directory.exists(): raise FileNotFoundError(f'Domain directory {directory} is already a file') directory.mkdir(parents=True, exist_ok=True) return directory # File names include domain and datetime. MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH = 200 def get_new_archive_files(url: str, title: Optional[str]) -> ArchiveFiles: """Create a list of archive files using a shared name schema. Raise an error if any of them exist.""" directory = get_domain_directory(url) # Datetime is valid in Linux and Windows. dt = archive_strftime(now()) title = escape_file_name(title or 'NA') title = title[:MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH] prefix = f'{dt}_{title}' singlefile_path = directory / f'{prefix}.html' readability_path = directory / f'{prefix}.readability.html' readability_txt_path = directory / f'{prefix}.readability.txt' readability_json_path = directory / f'{prefix}.readability.json' screenshot_path = directory / f'{prefix}.png' paths = (singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path) for path in paths: if path.exists(): raise FileExistsError(f'New archive file already exists: {path}') archive_files = ArchiveFiles( singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path, ) return archive_files ARCHIVE_TIMEOUT = Seconds.minute * 10 # Wait at most 10 minutes for response. async def request_archive(url: str) -> Tuple[str, Optional[str], Optional[str]]: """Send a request to the archive service to archive the URL.""" logger.info(f'Sending archive request to archive service: {url}') data = {'url': url} try: contents, status = await aiohttp_post(f'{ARCHIVE_SERVICE}/json', json_=data, timeout=ARCHIVE_TIMEOUT) if contents and (error := contents.get('error')): # Report the error from the archive service. raise Exception(f'Received error from archive service: {error}') readability = contents.get('readability') # Compressed base64 singlefile = contents['singlefile'] screenshot = contents['screenshot'] logger.debug(f'archive request status code {status}') except Exception as e: logger.error('Error when requesting archive', exc_info=e) raise if not (screenshot or singlefile or readability): raise Exception('singlefile response was empty!') if not readability: logger.info(f'Failed to get readability for {url=}') # Decode and decompress. singlefile = base64.b64decode(singlefile) singlefile = gzip.decompress(singlefile) singlefile = singlefile.decode() if not screenshot: logger.info(f'Failed to get screenshot for {url=}') else: # Decode and decompress. screenshot = base64.b64decode(screenshot) screenshot = gzip.decompress(screenshot) return singlefile, readability, screenshot async def model_archive_result(url: str, singlefile: str, readability: dict, screenshot: bytes) -> Archive: """ Convert results from ArchiveDownloader into real files. Create Archive record. """ readability = readability.copy() if readability else readability # First try to get the title from Readability. title = readability.get('title') if readability else None if not title and singlefile: # Try to get the title ourselves from the HTML. title = get_title_from_html(singlefile, url=url) if readability: # Readability could not find title, lets use ours. readability['title'] = title archive_files = get_new_archive_files(url, title) if readability: # Write the readability parts to their own files. Write what is left after pops to the JSON file. with archive_files.readability.open('wt') as fp: content = format_html_string(readability.pop('content')) fp.write(content) with archive_files.readability_txt.open('wt') as fp: readability_txt = readability.pop('textContent') readability_txt = split_lines_by_length(readability_txt) fp.write(readability_txt) else: # No readability was returned, so there are no files. archive_files.readability_txt = archive_files.readability = None # Store the single-file HTML in its own file. singlefile = format_html_string(singlefile) archive_files.singlefile.write_text(singlefile) if screenshot: archive_files.screenshot.write_bytes(screenshot) else: archive_files.screenshot = None # Always write a JSON file that contains at least the URL. readability = readability or {} readability['url'] = url with archive_files.readability_json.open('wt') as fp: json.dump(readability, fp, indent=2, sort_keys=True) # Create any File models that we can, index them all. paths = ( archive_files.singlefile, archive_files.readability, archive_files.readability_json, archive_files.readability_txt, archive_files.screenshot) paths = list(filter(None, paths)) with get_db_session(commit=True) as session: archive = Archive.from_paths(session, *paths) archive.file_group.download_datetime = now() archive.url = url archive.domain = get_or_create_domain(session, url) session.flush() return archive def get_or_create_domain(session: Session, url) -> Domain: """ Get/create the Domain for this archive. """ domain_ = extract_domain(url) domain = session.query(Domain).filter_by(domain=domain_).one_or_none() if not domain: domain = Domain(domain=domain_, directory=str(get_domain_directory(url))) session.add(domain) session.flush() return domain SINGLEFILE_URL_EXTRACTOR = re.compile(r'Page saved with SingleFile \s+url: (http.+?)\n') def get_url_from_singlefile(html: bytes) -> str: """Extract URL from SingleFile contents.""" html = html.decode() if SINGLEFILE_HEADER not in html: raise RuntimeError(f'Not a singlefile!') url, = SINGLEFILE_URL_EXTRACTOR.findall(html) url = url.strip() if not url.startswith('http'): raise RuntimeError('URL did not start with http') # Verify that the URL can be parsed. urlparse(url) return url @dataclass class ArticleMetadata: title: str = None published_datetime: datetime = None modified_datetime: datetime = None description: str = None author: str = None def parse_article_html_metadata(html: Union[bytes, str], assume_utc: bool = True) -> ArticleMetadata: """ Read the data from the tags, extract any data relevant to the article. This function also reads the