import base64 import gzip import io import json import os import pathlib import re import secrets import shlex import tempfile import zipfile from dataclasses import dataclass, field from datetime import datetime from json import JSONDecodeError from typing import Optional, Tuple, List, Union, Dict, Set from urllib.parse import urlparse import pytz from sqlalchemy import asc, func, BigInteger from sqlalchemy.orm import Session from modules.archive.models import Archive, MATCH_DATE, parse_singlefile_saved_date, read_singlefile_header from wrolpi import dates from wrolpi import fts 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, html_file_screenshot, ConfigFile, trim_file_name, chunks from wrolpi.collections.models import validate_collection_directory from wrolpi.config_import import ConfigSection, Unreadable, media_relative from wrolpi.dates import now, Seconds from wrolpi.db import get_db_session, get_db_curs, session_curs from wrolpi.errors import UnknownArchive, InvalidOrderBy, InvalidDatetime from wrolpi.events import Events from wrolpi.files.lib import handle_file_group_search_results, cached_search_total, \ search_filter_cache_key, count_file_groups, get_kv_value, set_kv_value from wrolpi.files.models import FileGroup from wrolpi.switches import register_switch_handler, ActivateSwitchMethod from wrolpi.tags import Tag, tag_append_sub_select_where, unknown_tag_names 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' def _domain_row(description, directory, tag_name, file_format) -> dict: return dict( description=description or None, directory=media_relative(directory) if directory else None, tag_name=tag_name or None, file_format=file_format or None, ) class DomainsSection(ConfigSection): """domains.yaml's domain Collections, keyed by name. (Their Downloads are linked after the import, see `link_domain_and_downloads`.)""" name = 'domains' def file_rows(self, config: dict) -> Tuple[Dict[str, dict], Set[str]]: rows, invalid = dict(), set() for position, data in enumerate(config.get('collections') or []): name = data.get('name') if isinstance(data, dict) else None if not name or not isinstance(name, str): logger.error(f'Domain collection at index {position} has no name, skipping: {data!r}') # Counted as skipped (see `Unreadable`). invalid.add(Unreadable(position)) continue try: if not Collection.is_valid_domain_name(name): raise ValueError(f"Invalid domain name {name!r} (must contain at least one '.' and not start/end" f" with '.')") if name in rows: raise ValueError(f'Another entry has the same domain: {name!r}') directory = validate_collection_directory(data['directory']) if data.get('directory') else None rows[name] = _domain_row(data.get('description'), directory, data.get('tag_name'), data.get('file_format')) except Exception as e: logger.error(f'Skipping invalid domain collection at index {position}', exc_info=e) invalid.add(name) return rows, invalid def db_rows(self, session: Session) -> Dict[str, dict]: query = session.query(Collection.name, Collection.description, Collection.directory, Tag.name, Collection.file_format) \ .outerjoin(Tag, Tag.id == Collection.tag_id) \ .filter(Collection.kind == 'domain') return {name: _domain_row(*values) for name, *values in query} def upsert(self, session: Session, rows: Dict[str, dict]): existing = {i.name: i for i in session.query(Collection).filter(Collection.kind == 'domain', Collection.name.in_(list(rows)))} tags = {i.name: i for i in session.query(Tag).filter(Tag.name.in_(list({i['tag_name'] for i in rows.values()} - {None})))} for name, row in rows.items(): if not (collection := existing.get(name)): logger.info(f'Creating domain collection {name!r}') collection = Collection(name=name, kind='domain') session.add(collection) collection.description = row['description'] collection.directory = get_media_directory() / row['directory'] if row['directory'] else None collection.tag = tags.get(row['tag_name']) collection.file_format = row['file_format'] session.flush() def delete(self, session: Session, keys: Set[str]): """A domain with Archives is reset to its default settings (a refresh of its Archives would re-create it, and deleting it would delete them); any other is deleted.""" with_archives = {i for i, in session.query(Archive.collection_id).distinct()} for collection in session.query(Collection).filter(Collection.kind == 'domain', Collection.name.in_(list(keys))): if collection.id in with_archives: logger.warning(f'Resetting domain collection {collection.name!r} which is not in the config, because' f' it has archives') collection.description = collection.directory = collection.tag = collection.file_format = None else: logger.info(f'Deleting domain collection {collection.name!r} (no longer in config)') session.delete(collection) @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' sections = (DomainsSection(),) 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 prepare(self, diffs: dict): """A Tag that no longer exists (it was removed) is dropped, and the config is saved without it. A removed domain with Archives which is already reset (see `DomainsSection.delete`) is left as it is; one which is not is reset, and the config is saved with it.""" diff = diffs['domains'] if diff.removed: with get_db_session() as session: with_archives = {name for name, in session.query(Collection.name).join( Archive, Archive.collection_id == Collection.id).filter( Collection.kind == 'domain', Collection.name.in_(list(diff.removed))).distinct()} reset = _domain_row(None, None, None, None) for name in with_archives: if self.import_db_rows['domains'][name] == reset: diff.removed.discard(name) else: self._config_outdated = True rows = [*diff.added.values(), *diff.changed.values()] unknown = unknown_tag_names({i['tag_name'] for i in rows if i['tag_name']}) for row in rows: if row['tag_name'] in unknown: logger.warning(f'Removing unknown Tag {row["tag_name"]!r} from a domain') row['tag_name'] = None self._config_outdated = True def import_config(self, file: pathlib.Path = None, send_events=False): self._config_outdated = False super().import_config(file, send_events) def after_import(self, diffs: dict): if self._config_outdated: self.background_dump.activate_switch() 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: version = self.db_version_for_dump(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] if not collections_data and self._config.get('collections'): logger.warning('Refusing to save empty domains config because domains exist in the config file!') return self._config['collections'] = collections_data self._config['version'] = version logger.info(f'Dumping {len(collections_data)} domain collections to config') self.save(file, send_events, overwrite) def preview_backup_import(self, backup_date: str, mode: str) -> dict: backup_file = self._get_backup_file(backup_date) backup_data = self.read_config_file(backup_file) current_data = self.read_config_file() if self.get_file().is_file() else dict(collections=[], version=0) backup_collections = backup_data.get('collections', []) current_collections = current_data.get('collections', []) # Identity key for domains is the directory (if present) or name def get_key(c): return c.get('directory') or c.get('name', '') current_keys = {get_key(c) for c in current_collections} backup_keys = {get_key(c) for c in backup_collections} add = [] remove = [] unchanged = 0 for c in backup_collections: key = get_key(c) if key not in current_keys: add.append(dict(type='domain', name=c.get('name', ''), directory=c.get('directory', ''))) else: unchanged += 1 if mode == 'overwrite': current_key_to_coll = {get_key(c): c for c in current_collections} for key in sorted(current_keys - backup_keys): c = current_key_to_coll[key] remove.append(dict(type='domain', name=c.get('name', ''), directory=c.get('directory', ''))) return dict(mode=mode, add=add, remove=remove, unchanged=unchanged) def import_backup(self, backup_date: str, mode: str, send_events: bool = False): self._preserve_current_config() import shutil backup_file = self._get_backup_file(backup_date) config_file = self.get_file() if mode == 'overwrite': config_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(backup_file, config_file) elif mode == 'merge': backup_data = self.read_config_file(backup_file) current_data = self.read_config_file() if config_file.is_file() else dict(collections=[], version=0) def get_key(c): return c.get('directory') or c.get('name', '') current_keys = {get_key(c) for c in current_data.get('collections', [])} merged_collections = list(current_data.get('collections', [])) for c in backup_data.get('collections', []): if get_key(c) not in current_keys: merged_collections.append(c) merged_data = dict( collections=merged_collections, version=current_data.get('version', 0) + 1, ) config_file.parent.mkdir(parents=True, exist_ok=True) self.write_config_data(merged_data, config_file) # Importing a backup is the user's choice: apply it whatever the versions. self.import_forced(send_events=send_events) # 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, then link their Downloads (a short write, only when there is something to link).""" logger.info('Importing domains config') domains_config.import_config() if domains_config.import_skipped: # Linking changes domains.yaml, which cannot be saved while it has skipped entries: the database would be # newer than the file, and the next startup would save over them. logger.warning('Not linking Downloads to domains while domains.yaml has skipped entries') return with get_db_session() as session: links = domain_download_links(session) if links: with get_db_session(commit=True) as session: _link_downloads(session, links) save_domains_config.activate_switch() logger.info('Importing domains config completed') @dataclass class ArchiveDownloaderConfigValidator: """Validator for archives_downloader.yaml config.""" file_name_format: str version: int = 0 # Browser settings (only used on native deployments, not Docker) browser_executable: str = None # None means auto-detect, or absolute path browser_args: str = '["--no-sandbox"]' # JSON array of browser arguments user_agent: str = None # None means use system default def __post_init__(self): # Validate file_name_format ends with .%(ext)s (like video format) if not self.file_name_format.endswith('.%(ext)s'): raise ValueError('file_name_format must end with .%(ext)s') # Validate browser_args is valid JSON array if self.browser_args: import json try: args = json.loads(self.browser_args) if not isinstance(args, list): raise ValueError('browser_args must be a JSON array') except json.JSONDecodeError as e: raise ValueError(f'browser_args must be valid JSON: {e}') class ArchiveDownloaderConfig(ConfigFile): """ Config file for Archive Downloader settings. This config mirrors videos_downloader.yaml and holds settings for how archives are downloaded and named. Format: version: 0 file_name_format: '%(download_date)s_%(title)s.%(ext)s' browser_executable: null # null for auto-detect, or absolute path browser_args: '["--no-sandbox"]' # JSON array of browser arguments user_agent: null # null for system default Variables available in file_name_format: - %(title)s - Page title (extracted from HTML) - %(download_datetime)s - Full download datetime (YYYY-MM-DD-HH-MM-SS) - %(download_date)s - Download date only (YYYY-MM-DD) - %(download_year)s - Download year - %(download_month)s - Download month (zero-padded) - %(download_day)s - Download day (zero-padded) - %(domain)s - Domain name - %(ext)s - File extension (required, must be at end) Subdirectories can be included in the format, e.g.: '%(download_year)s/%(download_datetime)s_%(title)s.%(ext)s' Browser settings (only used on native deployments, not Docker): - browser_executable: Path to browser or null for auto-detect - browser_args: JSON array of arguments passed to browser - user_agent: Custom user agent string or null for default """ file_name = 'archives_downloader.yaml' validator = ArchiveDownloaderConfigValidator default_config = dict( version=0, file_name_format='%(download_datetime)s_%(title)s.%(ext)s', browser_executable=None, browser_args='["--no-sandbox"]', user_agent=None, ) @property def file_name_format(self) -> str: return self._config['file_name_format'] @property def browser_executable(self) -> Optional[str]: """Get the configured browser executable path, or None for auto-detect.""" return self._config.get('browser_executable') @property def browser_args(self) -> str: """Get the browser arguments as a JSON string.""" return self._config.get('browser_args', '["--no-sandbox"]') @property def user_agent(self) -> Optional[str]: """Get the custom user agent, or None for system default.""" return self._config.get('user_agent') # Global instance for archive downloader config ARCHIVE_DOWNLOADER_CONFIG: ArchiveDownloaderConfig = ArchiveDownloaderConfig() def get_archive_downloader_config() -> ArchiveDownloaderConfig: """Get the global archive downloader config instance.""" return ARCHIVE_DOWNLOADER_CONFIG def format_archive_filename( title: str, domain: str = None, download_date: datetime = None, ) -> str: """Format archive filename using config template. This function formats the filename (and optional subdirectory path) for an archive using the file_name_format from archives_downloader.yaml. Args: title: Page title (extracted from HTML) domain: Domain name of the archived URL download_date: Date of download (defaults to now) Returns: Formatted filename/path (with .html extension included) Example: format_archive_filename("My Article", "example.com") # With default format: "2025-12-22_My Article.html" # With "%(download_year)s/%(title)s.%(ext)s": "2025/My Article.html" """ config = get_archive_downloader_config() template = config.file_name_format download_date = download_date or now() variables = dict( title=escape_file_name(title) if title else 'untitled', domain=domain or '', download_datetime=archive_strftime(download_date), download_date=download_date.strftime('%Y-%m-%d'), download_year=str(download_date.year), download_month=f'{download_date.month:02d}', download_day=f'{download_date.day:02d}', ext='html', ) try: result = template % variables # Trim the filename portion if it's too long (may include subdirectory paths) if '/' in result: # Has subdirectories - only trim the filename part parts = result.rsplit('/', 1) subdir = parts[0] filename = trim_file_name(parts[1]) return f'{subdir}/{filename}' else: return trim_file_name(result) except KeyError as e: logger.error(f'Invalid variable in archive file_name_format: {e}') # Fallback to default format return trim_file_name( f'{archive_strftime(download_date)}_{escape_file_name(title) if title else "untitled"}.html') def preview_archive_filename(file_name_format: str) -> str: """Preview the archive filename using sample data. Args: file_name_format: The format template to preview Returns: A preview string showing what the filename would look like Raises: RuntimeError: If the format is invalid or missing required variables """ if not file_name_format.endswith('.%(ext)s'): raise RuntimeError('file_name_format must end with .%(ext)s') sample_date = now() variables = dict( title='Example Page Title', domain='example.com', download_datetime=archive_strftime(sample_date), download_date=sample_date.strftime('%Y-%m-%d'), download_year=str(sample_date.year), download_month=f'{sample_date.month:02d}', download_day=f'{sample_date.day:02d}', ext='html', ) try: return file_name_format % variables except KeyError as e: raise RuntimeError(f'Invalid variable: {e}') except ValueError as e: raise RuntimeError(f'Invalid format: {e}') def format_archive_filename_from_archive( archive: Archive, file_name_format: str = None, ) -> str: """Single source of truth for archive filenames when reorganizing. Formats an archive filename using the configured file_name_format template. Used by collection reorganization to compute new file paths. Args: archive: Archive model with metadata file_name_format: Optional override, defaults to config value Returns: Formatted filename (may include subdirectory path like "2025/My Article.html") """ config = get_archive_downloader_config() template = file_name_format or config.file_name_format file_group = archive.file_group # Get download date from file_group download_date = file_group.download_datetime or file_group.published_datetime or now() # Get title title = file_group.title or 'untitled' # Get domain from URL domain = '' if file_group.url: try: domain = extract_domain(file_group.url) except Exception: pass variables = dict( title=escape_file_name(title) if title else 'untitled', domain=domain, download_datetime=archive_strftime(download_date), download_date=download_date.strftime('%Y-%m-%d'), download_year=str(download_date.year), download_month=f'{download_date.month:02d}', download_day=f'{download_date.day:02d}', ext='html', ) try: result = template % variables # Trim the filename portion if it's too long (may include subdirectory paths) if '/' in result: # Has subdirectories - only trim the filename part parts = result.rsplit('/', 1) subdir = parts[0] filename = trim_file_name(parts[1]) return f'{subdir}/{filename}' else: return trim_file_name(result) except KeyError as e: logger.error(f'Invalid variable in archive file_name_format: {e}') # Fallback to default format return trim_file_name( f'{archive_strftime(download_date)}_{escape_file_name(title) if title else "untitled"}.html') @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='', domain_tag='', year='', month='', day='') archive_destination = archive_destination % variables archive_directory = get_media_directory() / archive_destination return archive_directory # File names include domain and datetime. MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH = 200 def get_new_archive_files(url: str, title: Optional[str], destination: pathlib.Path = None) -> ArchiveFiles: """Create a list of archive files using a shared name schema. Raise an error if any of them exist. Args: url: The URL being archived (used to derive domain if no destination) title: Title for the archive files destination: Optional directory to save files to. If provided, files go here instead of archive/ """ domain = extract_domain(url) if destination: directory = destination else: # Fallback to archive// when no destination provided directory = get_archive_directory() / domain directory.mkdir(parents=True, exist_ok=True) # Use the configured file format template (returns full filename with .html extension) title = title or 'NA' title = title[:MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH] formatted_filename = format_archive_filename(title, domain=domain) # Handle subdirectories in the format (e.g., "%(download_year)s/%(title)s.%(ext)s") if '/' in formatted_filename: # Filename includes subdirectories, create them relative to base directory full_path = directory / formatted_filename full_path.parent.mkdir(parents=True, exist_ok=True) singlefile_path = full_path stem = full_path.stem parent_dir = full_path.parent readability_path = trim_file_name(parent_dir / f'{stem}.readability.html') readability_txt_path = trim_file_name(parent_dir / f'{stem}.readability.txt') readability_json_path = trim_file_name(parent_dir / f'{stem}.readability.json') screenshot_path = trim_file_name(parent_dir / f'{stem}.png') else: singlefile_path = directory / formatted_filename stem = singlefile_path.stem readability_path = trim_file_name(directory / f'{stem}.readability.html') readability_txt_path = trim_file_name(directory / f'{stem}.readability.txt') readability_json_path = trim_file_name(directory / f'{stem}.readability.json') screenshot_path = trim_file_name(directory / f'{stem}.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, compress: bool = False) \ -> Tuple[str | bytes, 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, compress=compress) 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) # A compressed (SingleFileZ) singlefile is binary and must stay bytes; only decode regular singlefiles. if not is_compressed_singlefile(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 @dataclass class WrittenArchiveFiles: """The on-disk artifacts produced by write_archive_files.""" url: str paths: List[pathlib.Path] def write_archive_files(url: str, singlefile: str | bytes, readability: Optional[dict], screenshot: Optional[bytes], destination: Optional[pathlib.Path] = None) -> WrittenArchiveFiles: """Pure I/O: write the singlefile/readability/screenshot/json files to disk. Returns the URL and the list of paths to register, leaving DB work to register_archive. """ compressed = isinstance(singlefile, bytes) and is_compressed_singlefile(singlefile) 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. A compressed singlefile contains the # uncompressed page as index.html. title = get_title_from_html(get_compressed_singlefile_index(singlefile) if compressed else singlefile, url=url) if readability: # Readability could not find title, lets use ours. readability['title'] = title archive_files = get_new_archive_files(url, title, destination=destination) 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 = readability.pop('content') if compressed: # Readability extracted from the ZIP's index.html references images by relative # ZIP paths; inline them so the readability file is self-contained. content = inline_compressed_singlefile_resources(content, singlefile, url) content = format_html_string(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. if compressed: # A compressed (SingleFileZ) singlefile is a binary ZIP; prettifying would corrupt it. archive_files.singlefile.write_bytes(singlefile) elif isinstance(singlefile, bytes) and b' Archive: """Pure DB: create the Archive record from already-written files. Caller commits.""" archive = Archive.from_paths(session, *written.paths) archive.file_group.download_datetime = now() archive.url = written.url archive.collection = get_or_create_domain_collection(session, written.url) # Update collection's file_format to track the format used for this file config = get_archive_downloader_config() if archive.collection and config.file_name_format: if not archive.collection.file_format: # Set initial file_format on collection archive.collection.file_format = config.file_name_format save_domains_config.activate_switch() archive.flush() return archive async def model_archive_result(url: str, singlefile: str, readability: dict, screenshot: bytes, destination: pathlib.Path = None) -> Archive: """ Convert results from ArchiveDownloader into real files. Create Archive record. Thin wrapper around write_archive_files + register_archive that opens its own transaction. New code (notably the phase-split ArchiveDownloader) should call the helpers directly so the caller controls the session. Args: url: The URL that was archived singlefile: The HTML content from SingleFile readability: The readability extraction results screenshot: The screenshot bytes destination: Optional directory to save files to. If provided, files go here instead of archive/ """ written = write_archive_files(url, singlefile, readability, screenshot, destination=destination) with get_db_session(commit=True) as session: archive = register_archive(session, written) return archive def detect_domain_directory(collection: Collection) -> Optional[pathlib.Path]: """ Detect directory for domain collection using format_destination template. Computes the expected domain directory using the collection's format_destination() method and checks if that directory exists on the filesystem. Args: collection: The domain collection to analyze Returns: Path (relative to media directory) if the expected directory exists. None if collection is not a domain or directory doesn't exist. """ if collection.kind != 'domain': return None # Get the tag name if collection has a tag tag_name = collection.tag.name if collection.tag else None # Compute expected directory using archive_destination template expected_directory = collection.format_destination(tag_name) # Simply check if the directory exists if expected_directory.is_dir(): relative_path = get_relative_to_media_directory(expected_directory) logger.debug(f'Detected directory for domain {collection.name}: {relative_path}') return relative_path return None 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) 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) 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 domain_download_links(session: Session) -> Dict[int, int]: """The Downloads to link to a Domain Collection: {Download id: Collection id}. Matching criteria (only recurring Downloads without a Collection): - a destination 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 domains = session.query(Collection.id, Collection.name, Collection.directory) \ .filter(Collection.kind == 'domain').all() by_name = {name: id_ for id_, name, _ in domains} # Ensure each directory ends with / for proper prefix matching. directories = [(id_, str(directory), str(directory).rstrip('/') + '/') for id_, _, directory in domains if directory] links = dict() downloads = session.query(Download.id, Download.url, Download.downloader, Download.sub_downloader, Download.destination, Download.settings) \ .filter(Download.frequency.isnot(None), Download.collection_id.is_(None)) for id_, url, downloader, sub_downloader, destination, settings in downloads: # An old Download kept its destination in its settings. destination = str(destination) if destination else (settings or dict()).get('destination') if destination: for collection_id, directory, prefix in directories: if destination == directory or destination.startswith(prefix): links[id_] = collection_id break if id_ not in links and downloader == 'rss' and sub_downloader == 'archive': if collection_id := by_name.get(extract_domain(url)): links[id_] = collection_id return links def _link_downloads(session: Session, links: Dict[int, int]): # Local import to avoid circular import: downloader -> archive -> downloader from wrolpi.downloader import Download for ids in chunks(list(links), 500): for download in session.query(Download).filter(Download.id.in_(ids)): download.collection_id = links[download.id] session.flush() def link_domain_and_downloads(session: Session): """Associate any Download related to a Domain Collection (see `domain_download_links`).""" if links := domain_download_links(session): _link_downloads(session, links) session.commit() SINGLEFILE_URL_EXTRACTOR = re.compile(r'Page saved with SingleFile \s+url: (http.+?)\n') COMPRESSED_SINGLEFILE_INDEX = 'index.html' def is_compressed_singlefile(singlefile: bytes) -> bool: """A compressed (SingleFileZ) singlefile is a self-extracting HTML file which is also a valid ZIP containing the uncompressed page as index.html.""" buf = io.BytesIO(singlefile) if not zipfile.is_zipfile(buf): return False try: with zipfile.ZipFile(buf) as zip_: return COMPRESSED_SINGLEFILE_INDEX in zip_.namelist() except zipfile.BadZipFile: return False def get_compressed_singlefile_index(singlefile: bytes) -> bytes: """Extract the uncompressed page (index.html) from a compressed (SingleFileZ) singlefile.""" with zipfile.ZipFile(io.BytesIO(singlefile)) as zip_: return zip_.read(COMPRESSED_SINGLEFILE_INDEX) def read_singlefile_html(path: pathlib.Path) -> bytes: """Read the page HTML of the singlefile at `path`; extract the uncompressed page when the singlefile is compressed (SingleFileZ).""" contents = path.read_bytes() if is_compressed_singlefile(contents): return get_compressed_singlefile_index(contents) return contents def inline_compressed_singlefile_resources(html: str, singlefile: bytes, url: str) -> str: """Inline the images of a compressed (SingleFileZ) singlefile into the provided HTML. The index.html inside a compressed singlefile references its resources by relative ZIP paths (e.g. images/0.jpg); HTML extracted from it (readability) dangles those references — either still relative, or absolutized against the page URL by readability-extractor (https://example.com/images/0.jpg). Replace them with data URIs read from the ZIP, like an uncompressed singlefile would contain.""" import mimetypes from urllib.parse import urljoin, unquote # readability-extractor resolves relative srcs against the page URL. url_base = urljoin(url, '.') soup = get_html_soup(html) with zipfile.ZipFile(io.BytesIO(singlefile)) as zip_: names = set(zip_.namelist()) changed = False for img in soup.find_all('img'): src = img.get('src') or '' name = src.removeprefix('./') if src.startswith(url_base): name = unquote(src[len(url_base):]) if not name or name not in names: continue mimetype = mimetypes.guess_type(name)[0] or 'application/octet-stream' img['src'] = f'data:{mimetype};base64,{base64.b64encode(zip_.read(name)).decode()}' # The ZIP-path srcset entries are dangling too; the inlined src replaces them. if img.get('srcset'): del img['srcset'] changed = True return str(soup) if changed else html def get_url_from_singlefile(html: bytes | str) -> str: """Extract URL from SingleFile contents.""" # Ignore decode errors; a compressed singlefile has a plain-text prelude but a binary ZIP tail. html = html.decode(errors='ignore') 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