import base64 import gzip import json import os import pathlib import re import shlex import tempfile from dataclasses import dataclass, field from datetime import datetime from json import JSONDecodeError from typing import Optional, Tuple, List, Union from urllib.parse import urlparse import pytz from sqlalchemy import asc, func, BigInteger from sqlalchemy.orm import Session from modules.archive.models import Archive from wrolpi import dates from wrolpi.cmd import READABILITY_BIN, run_command from wrolpi.collections import Collection from wrolpi.common import get_media_directory, get_relative_to_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, html_screenshot, ConfigFile 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.events import Events from wrolpi.files.lib import handle_file_group_search_results from wrolpi.files.models import FileGroup from wrolpi.switches import register_switch_handler, ActivateSwitchMethod from wrolpi.tags import tag_append_sub_select_where from wrolpi.vars import PYTEST, DOCKERIZED logger = logger.getChild(__name__) __all__ = ['DomainsConfig', 'domains_config', 'get_domains_config', 'save_domains_config', 'import_domains_config'] ARCHIVE_SERVICE = 'http://archive:8080' @dataclass class DomainsConfigValidator: """Validator for domains config file.""" version: int = 0 collections: List[dict] = field(default_factory=list) class DomainsConfig(ConfigFile): """ Config file for Domain Collections. This is a specialized config that manages domain collections (Collections with kind='domain'). It maintains a domains.yaml file for backward compatibility and user convenience. Format: collections: - name: "example.com" kind: "domain" description: "Archives from example.com" - name: "wikipedia.org" kind: "domain" Note: Domain collections can have optional directories for tagging support. Items are managed dynamically when Archives are indexed. """ file_name = 'domains.yaml' validator = DomainsConfigValidator default_config = dict( version=0, collections=[], ) # Use wider width to accommodate longer paths width = 120 def __getitem__(self, item): return self._config[item] def __setitem__(self, key, value): self._config[key] = value @property def collections(self) -> List[dict]: """Get list of collection configs.""" return self._config.get('collections', []) def import_config(self, file: pathlib.Path = None, send_events=False): """Import domain collections from config file into database using batch operations.""" file = file or self.get_file() file_str = str(get_relative_to_media_directory(file)) # If config file doesn't exist, mark import as successful (nothing to import) if not file.is_file(): logger.info('No domains config file, skipping import') self.successful_import = True return ConfigFile.import_config(self, file, send_events) collections_data = self._config.get('collections', []) # Empty collections list = never delete DB records if not collections_data: logger.info(f'No domain collections in config, preserving existing DB domain collections') self.successful_import = True return logger.info(f'Importing {len(collections_data)} domain collections from {file_str}') try: with get_db_session(commit=True) as session: # Validate and prepare data first valid_data_list = [] for idx, collection_data in enumerate(collections_data): try: name = collection_data.get('name') if not name: logger.error(f'Domain collection at index {idx} has no name, skipping') continue # Validate domain name format before batching if not Collection.is_valid_domain_name(name): logger.error( f"Domain collection at index {idx} has invalid name '{name}' " f"(must contain at least one '.' and not start/end with '.'), skipping" ) continue # Ensure kind is 'domain' collection_data = collection_data.copy() collection_data['kind'] = 'domain' # Note: Collections can now be tagged even without a directory # Tags enable UI search/filtering for directory-less collections valid_data_list.append(collection_data) except Exception as e: logger.error(f'Failed to validate domain collection at index {idx}', exc_info=e) continue # Batch create/update collections imported_domains = set() if valid_data_list: collections = Collection.batch_from_config(valid_data_list, session=session) imported_domains = {c.name for c in collections} # Delete domain collections that are no longer in config all_domain_collections = session.query(Collection).filter_by(kind='domain').all() for collection in all_domain_collections: if collection.name not in imported_domains: logger.info(f'Deleting domain collection {repr(collection.name)} (no longer in config)') session.delete(collection) logger.info(f'Successfully imported {len(imported_domains)} domain collections from {file_str}') self.successful_import = True except Exception as e: self.successful_import = False message = f'Failed to import {file_str} config!' logger.error(message, exc_info=e) if send_events: Events.send_config_import_failed(message) raise def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False): """Dump all domain collections from database to config file.""" logger.info('Dumping domain collections to config') with get_db_session() as session: # Get only domain collections, ordered by name collections = session.query(Collection).filter_by(kind='domain').order_by(Collection.name).all() # Use to_config to export each collection collections_data = [collection.to_config() for collection in collections] self._config['collections'] = collections_data logger.info(f'Dumping {len(collections_data)} domain collections to config') self.save(file, send_events, overwrite) # Global instance domains_config = DomainsConfig() def get_domains_config() -> DomainsConfig: """Get the global domains config instance.""" return domains_config # Switch handler for saving domains config @register_switch_handler('save_domains_config') def save_domains_config(): """Save the domains config when the switch is activated.""" domains_config.background_dump.activate_switch() # Explicit type for activate_switch helper save_domains_config: ActivateSwitchMethod def import_domains_config(): """Import domain collections from config file into database.""" logger.info('Importing domains config') domains_config.import_config() # Link downloads to domain collections after import with get_db_session() as session: link_domain_and_downloads(session) logger.info('Importing domains config completed') @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_destination = get_wrolpi_config().archive_destination variables = dict(domain='', year='', month='', day='') archive_destination = archive_destination % variables archive_directory = get_media_directory() / archive_destination 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 def get_archive_destination(domain_collection: 'Collection') -> pathlib.Path: """ Get the destination directory for archives in a domain collection. Args: domain_collection: The domain Collection (kind='domain') Returns: Path where archives should be placed. If collection has a directory, returns that directory. Otherwise returns the default archives// path. """ if domain_collection.directory: # Restricted collection - use collection directory return get_media_directory() / domain_collection.directory else: # Unrestricted collection - use default archive// path return get_archive_directory() / domain_collection.name # 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, singlefile: str = None) -> Tuple[str, Optional[dict], Optional[str]]: """Send a request to the archive service to archive the URL.""" logger.info(f'Sending archive request to archive service: {url}') data = dict(url=url, singlefile=singlefile) try: async with aiohttp_post(f'{ARCHIVE_SERVICE}/json', json_=data, timeout=ARCHIVE_TIMEOUT) as response: status = response.status contents = await response.json() 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 request_screenshot(url: str, singlefile_path: pathlib.Path) -> Optional[bytes]: """Send a request to the archive service to generate a screenshot from the singlefile.""" logger.info(f'Sending screenshot request to archive service: {url}') # Read, compress, and encode the singlefile singlefile_contents = singlefile_path.read_bytes() singlefile_compressed = gzip.compress(singlefile_contents) singlefile_b64 = base64.b64encode(singlefile_compressed).decode() data = dict(url=url, singlefile=singlefile_b64) try: async with aiohttp_post(f'{ARCHIVE_SERVICE}/screenshot', json_=data, timeout=ARCHIVE_TIMEOUT) as response: status = response.status contents = await response.json() if contents and (error := contents.get('error')): # Report the error from the archive service. raise Exception(f'Received error from archive service: {error}') # Compressed base64 screenshot = contents.get('screenshot') if not screenshot: logger.warning(f'Failed to get screenshot for {url=}') return None logger.debug(f'screenshot request status code {status}') except Exception as e: logger.error('Error when requesting screenshot', exc_info=e) raise # Decode and decompress. screenshot = base64.b64decode(screenshot) screenshot = gzip.decompress(screenshot) return 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.collection = get_or_create_domain_collection(session, url) archive.flush() return archive def detect_domain_directory(collection: Collection, session: Session) -> Optional[pathlib.Path]: """ Detect if all archives for a domain collection share a common directory. Args: collection: The domain collection to analyze session: Database session Returns: Path (relative to media directory) if all archives share a common directory within archive media directory. None if archives are scattered across different directories or if collection has no archives. """ if collection.kind != 'domain': return None # Query all archives for this domain collection archives = session.query(Archive).filter_by(collection_id=collection.id).all() if not archives: # No archives yet, can't determine directory return None # Get all archive file paths paths = [] for archive in archives: if archive.file_group and archive.file_group.primary_path: paths.append(pathlib.Path(archive.file_group.primary_path)) if not paths: return None # Find common ancestor directory # Start with the first path's parent directory common_dir = paths[0].parent # Check if all other paths are under this directory for path in paths[1:]: try: # Check if path is relative to common_dir path.relative_to(common_dir) except ValueError: # Path is not under common_dir, find the common ancestor # Walk up until we find a common parent while common_dir != common_dir.parent: # Stop at root try: path.relative_to(common_dir) break # Found common ancestor except ValueError: common_dir = common_dir.parent # Check if common directory is within the media directory media_dir = get_media_directory() try: relative_path = common_dir.relative_to(media_dir) except ValueError: # Common directory is outside media directory return None # Check if it's within the archive directory structure archive_base = get_archive_directory() try: archive_base.relative_to(media_dir) # Verify archive_base is under media_dir common_dir.relative_to(archive_base.parent) # Verify common_dir is under archive structure except ValueError: # Not in the archive directory structure return None logger.debug(f'Detected directory for domain {collection.name}: {relative_path}') return relative_path def update_domain_directories(session: Session = None) -> int: """ One-time update to detect and set directories for existing domain collections. This should be run once to fix domain collections that were created without directories but have all their archives in a common location. Args: session: Database session Returns: Number of domain collections updated """ if session is None: with get_db_session(commit=True) as session: return update_domain_directories(session) # Find all domain collections without directories collections = session.query(Collection).filter_by(kind='domain', directory=None).all() updated_count = 0 for collection in collections: detected_dir = detect_domain_directory(collection, session) if detected_dir: collection.directory = detected_dir session.flush([collection]) updated_count += 1 logger.info(f'Updated domain {collection.name} with directory: {detected_dir}') session.commit() logger.info(f'Updated {updated_count} domain collection(s) with auto-detected directories') return updated_count def get_or_create_domain_collection(session: Session, url, directory: pathlib.Path = None) -> Collection: """ Get or create the domain Collection for this archive. Args: session: Database session url: URL of the archive directory: Optional directory to restrict this domain collection. If None, creates unrestricted collection (default). If provided, archives will be placed in this directory and collection can be tagged. Returns: Collection with kind='domain' for the domain extracted from the URL """ domain_name = extract_domain(url) # Try to find existing domain collection collection = session.query(Collection).filter_by( name=domain_name, kind='domain' ).one_or_none() if not collection: # Create new domain collection collection = Collection( name=domain_name, kind='domain', directory=directory, # Can be None (unrestricted) or a Path (restricted) ) session.add(collection) session.flush() # Trigger domain config save for new domain save_domains_config.activate_switch() if directory: logger.info(f'Created domain collection with directory: {domain_name} -> {directory}') else: logger.info(f'Created unrestricted domain collection: {domain_name}') # Auto-detect directory if not explicitly set and collection doesn't have one if not directory and not collection.directory: detected_dir = detect_domain_directory(collection, session) if detected_dir: collection.directory = detected_dir session.flush() # Trigger domain config save when directory is auto-detected save_domains_config.activate_switch() logger.info(f'Auto-detected directory for domain {domain_name}: {detected_dir}') return collection def link_domain_and_downloads(session: Session): """Associate any Download related to a Domain Collection. Downloads are linked to Collections (via collection_id). This function finds Domain Collections and links their Downloads. Matching criteria: - Recurring downloads whose destination is within the domain collection's directory (including subdirectories) - RSS downloads with sub_downloader='archive' (matched by URL domain) """ # Local import to avoid circular import: downloader -> archive -> downloader from wrolpi.downloader import Download # Only Downloads with a frequency can be a Collection Download. downloads = list(session.query(Download).filter(Download.frequency.isnot(None)).all()) # Get domain collections that have a directory domain_collections = session.query(Collection).filter( Collection.kind == 'domain', Collection.directory.isnot(None) ).all() need_commit = False # Match downloads by destination directory (including subdirectories) downloads_with_destination = [d for d in downloads if (d.settings or {}).get('destination')] for collection in domain_collections: # Ensure directory ends with / for proper prefix matching directory = str(collection.directory) directory_prefix = directory if directory.endswith('/') else directory + '/' for download in downloads_with_destination: dest = download.settings['destination'] # Match if destination equals directory or is a subdirectory if not download.collection_id and (dest == directory or dest.startswith(directory_prefix)): download.collection_id = collection.id need_commit = True # Match RSS downloads with archive sub_downloader by URL domain rss_archive_downloads = [ d for d in downloads if d.downloader == 'rss' and d.sub_downloader == 'archive' and not d.collection_id ] for download in rss_archive_downloads: # Extract domain from the RSS URL and find matching domain collection domain_name = extract_domain(download.url) collection = session.query(Collection).filter_by( name=domain_name, kind='domain' ).one_or_none() if collection: download.collection_id = collection.id need_commit = True if need_commit: session.commit() SINGLEFILE_URL_EXTRACTOR = re.compile(r'Page saved with SingleFile \s+url: (http.+?)\n') def get_url_from_singlefile(html: bytes | str) -> str: """Extract URL from SingleFile contents.""" html = html.decode() if isinstance(html, bytes) else html 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