import base64 import gzip import io import json import os import pathlib import re 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 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 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 from wrolpi.dates import now, Seconds from wrolpi.db import get_db_session, get_db_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 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') self.import_skipped += 1 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" ) self.import_skipped += 1 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) self.import_skipped += 1 continue # Batch create/update collections imported_domains = set() if valid_data_list: collections = Collection.batch_from_config(session, valid_data_list) 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] 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 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) self.import_config(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.""" 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 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') def import_config(self, file: pathlib.Path = None, send_events=False): super().import_config(file, send_events) self.successful_import = True # 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 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') 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