#!/usr/bin/env python3 """ archive.py - Site Archiver for Sunset Sites Thin wrapper around neopig that packages crawl results into a distributable tar.gz archive. Uses neopig for the actual crawling. Output structure: {domain}-{date}/ html/ # Original HTML pages markdown/ # Converted markdown (optional) vault/ # Media vault (9-layer hash paths, neopig-compatible) neopig.db # Full neopig database neopig/ # Embedded neopig server metadata.json # Crawl metadata Usage: python archive.py https://discourse-urho3d.github.io/ python archive.py https://example.com --no-screenshot """ import argparse import asyncio import json import logging import os import re import shutil import signal import subprocess import sys import tarfile import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Dict, Any, List, Optional from miniuri import Uri import aiofiles import aiofiles.os from bs4 import BeautifulSoup from tqdm import tqdm from neopig import NeoPig, setup_logging, rotate_state_file, get_state_file_path from async_web_fetcher import CrawlMode from screenshot import ScreenshotConfig from filevault import hash_to_path # Optional markdown conversion try: import html2text HAS_HTML2TEXT = True except ImportError: HAS_HTML2TEXT = False logger = logging.getLogger(__name__) def sanitize_filename(name: str) -> str: """Sanitize a string for use as filename. Uses - as separator.""" name = re.sub(r'[<>:"/\\|?*.]', '-', name) name = re.sub(r'-+', '-', name) # collapse multiple dashes name = name.strip('- ') return name[:200] if name else 'unnamed' def url_to_path(url: str) -> str: """Convert URL to filesystem path.""" parsed = Uri(url) path = parsed.path.strip('/') if not path: return 'index.html' if path.endswith('.html') or path.endswith('.htm'): return path if '.' in path.split('/')[-1]: return path return f"{path}/index.html" def html_to_markdown(html: str, base_url: str = '', trim_wrapper: bool = False) -> str: """Convert HTML to markdown.""" if not HAS_HTML2TEXT: return html # Optionally strip nav/header/footer/logo before conversion if trim_wrapper: from neopig import trim_html_wrapper html = trim_html_wrapper(html) h = html2text.HTML2Text() h.ignore_links = False h.ignore_images = False h.body_width = 0 h.unicode_snob = True if base_url: h.baseurl = base_url return h.handle(html) class SiteArchiver: """ Packages neopig crawl results into a distributable tar.gz archive. Uses neopig for crawling, then reads from its vaults to build the archive. """ def __init__( self, output_dir: str = '.', include_screenshots: bool = True, include_markdown: bool = True, screenshot_config: ScreenshotConfig = None, fast_mode: bool = False, trim_wrapper: bool = False, show_progress: bool = True, fresh_start: bool = False, ): self.output_dir = Path(output_dir) self.include_screenshots = include_screenshots self.include_markdown = include_markdown and HAS_HTML2TEXT self.screenshot_config = screenshot_config or ScreenshotConfig(enabled=include_screenshots) self.fast_mode = fast_mode self.trim_wrapper = trim_wrapper self.show_progress = show_progress self.fresh_start = fresh_start async def archive( self, target_url: str, depth: int = -1, max_pages: int = -1, db_path: str = None, vault_path: str = None, package_only: bool = False, ) -> Path: """ Archive a site using neopig and package into tar.gz. """ parsed = Uri(target_url) domain = parsed.hostname.lower() date_str = datetime.now().strftime('%Y%m%d') archive_name = f"{sanitize_filename(domain)}-{date_str}" # Use standard neopig data paths - one vault, multiple domains if not db_path: db_path = "data/neopig.db" if not vault_path: vault_path = "data/vault" # Ensure data directory exists Path("data").mkdir(exist_ok=True) logger.info(f"Starting archive of {target_url}") logger.info(f"Archive name: {archive_name}") # Create neopig instance and crawl pig = NeoPig( db_path=db_path, vault_path=vault_path, screenshot_config=self.screenshot_config, fast_mode=self.fast_mode, trim_wrapper=self.trim_wrapper, ) await pig.init() # Handle fresh start: rotate state files if self.fresh_start: pig._clear_state(target_url) logger.info("Fresh start: state file rotated") else: # Load previously crawled pages/media/screenshots from DB (source of truth for resume) crawled_pages = await pig.db.get_crawled_page_uris() crawled_media = await pig.db.get_crawled_media_uris() crawled_screenshots = await pig.db.get_crawled_screenshot_uris() logger.info(f"Resume state: {len(crawled_pages)} pages, {len(crawled_media)} media URIs, {len(crawled_screenshots)} screenshotted pages in DB") if crawled_pages: pig.seen_pages = crawled_pages if crawled_media: pig.seen_media = crawled_media if crawled_screenshots: pig.seen_screenshots = crawled_screenshots logger.info(f"Screenshots enabled: {pig.screenshot_config.enabled}") # Run the crawl (unless package_only) stats = {} if package_only: logger.info("Package-only mode: skipping crawl, packaging existing data") else: stats = await pig.crawl( target_uri=target_url, mode=CrawlMode.ALL, depth=depth, max_pages=max_pages, download_media=True, ) # Now package the results logger.info("Packaging archive (streaming mode)...") tar_path = self.output_dir / f"{archive_name}.tar.gz" # Get URL-to-hash mapping for rewriting external URLs to local copies logger.info("Loading media URL mappings...") url_to_hash = await pig.db.get_all_media_uri_mappings() logger.info(f"Loaded {len(url_to_hash)} URL mappings for rewriting") def rewrite_urls(html_content: str) -> str: """Rewrite external image/media URLs to local archive paths.""" import re def replace_url(match): url = match.group(1) if url in url_to_hash: md5 = url_to_hash[url] # Get extension from original URL ext = Path(url.split('?')[0]).suffix or '.bin' return match.group(0).replace(url, f'../media/{md5}{ext}') return match.group(0) # Replace src="url" and href="url" patterns html_content = re.sub(r'src=["\']([^"\']+)["\']', replace_url, html_content) html_content = re.sub(r'href=["\']([^"\']+\.(jpg|jpeg|png|gif|webp|mp4|webm|svg))["\']', replace_url, html_content, flags=re.IGNORECASE) return html_content # Step 1: Load pages from database (has both raw_html and markdown) logger.info("Loading pages from database...") sitemap = [] html_contents = {} markdown_contents = {} pages = await pig.db.get_pages_by_domain(domain) for page in pages: if not page.get('raw_html'): continue # Convert URI to path uri_path = page.get('path') or '' if not uri_path or uri_path == '/': rel_path = 'index.html' elif uri_path.endswith('.html'): rel_path = uri_path.lstrip('/') else: rel_path = uri_path.strip('/') + '/index.html' try: content = page['raw_html'] # Rewrite external URLs to local copies content = rewrite_urls(content) title = page.get('title') or self._extract_title(content) or rel_path sitemap.append({'path': f'html/{rel_path}', 'title': title}) html_contents[rel_path] = content # Store markdown if available if page.get('markdown'): markdown_contents[rel_path] = page['markdown'] except Exception: pass # Step 2: Create generated files in small temp dir local_tmpdir = self.output_dir / '.tmp' local_tmpdir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=local_tmpdir) as tmpdir: tmpdir_path = Path(tmpdir) # Write metadata metadata = { 'domain': domain, 'target_url': target_url, 'created': datetime.now(timezone.utc).isoformat(), 'stats': stats, 'include_screenshots': self.include_screenshots, 'include_markdown': self.include_markdown, } (tmpdir_path / 'metadata.json').write_text(json.dumps(metadata, indent=2)) # Copy state file state_file = get_state_file_path(domain) if state_file.exists(): shutil.copy(state_file, tmpdir_path / 'crawl_state.json') logger.info(f"Included crawl state for future delta crawls") # Write index.html self._write_index_html(tmpdir_path, sitemap, domain) # Close database BEFORE copying to ensure WAL is checkpointed # (SQLite WAL mode keeps data in -wal file until close) await pig.db.close() logger.info("Database closed, WAL checkpointed") # Step 3: Stream everything to tar.gz in one pass logger.info("Streaming to archive...") # Capture for closure show_progress = self.show_progress include_markdown = self.include_markdown trim_wrapper = self.trim_wrapper include_screenshots = self.include_screenshots def stream_to_tar(): with tarfile.open(tar_path, 'w:gz') as tar: # Add generated files first (from temp) for f in tmpdir_path.iterdir(): tar.add(f, arcname=f"{archive_name}/{f.name}") # Stream HTML files (with rewritten URLs) html_iter = tqdm(html_contents.items(), desc="HTML", unit="pages", disable=not show_progress) for rel_path_str, content in html_iter: try: html_bytes = content.encode('utf-8') arcname = f"{archive_name}/html/{rel_path_str}" info = tarfile.TarInfo(name=arcname) info.size = len(html_bytes) tar.addfile(info, io.BytesIO(html_bytes)) # Use stored markdown (or generate if not available) if include_markdown: md_content = markdown_contents.get(rel_path_str) if not md_content: md_content = html_to_markdown(content, trim_wrapper=trim_wrapper) md_bytes = md_content.encode('utf-8') md_rel = rel_path_str.replace('.html', '.md') md_arcname = f"{archive_name}/markdown/{md_rel}" md_info = tarfile.TarInfo(name=md_arcname) md_info.size = len(md_bytes) tar.addfile(md_info, io.BytesIO(md_bytes)) except Exception as e: logger.debug(f"Error adding HTML {rel_path_str}: {e}") # Stream media files by hash (matching rewritten URLs) hash_vault = Path(vault_path) added_hashes = set() media_iter = tqdm(url_to_hash.items(), desc="Media", unit="files", disable=not show_progress) for url, md5 in media_iter: if md5 in added_hashes: continue # Skip screenshots if not included if not include_screenshots and url.startswith('screenshot:'): continue # Find file in hash vault using 9-layer deep path: vault/ab/cd/.../hash.ext hash_path = hash_vault / hash_to_path(md5) hash_dir = hash_path.parent if hash_dir.exists(): for f in hash_dir.iterdir(): if f.stem == md5: try: # Preserve vault structure for neopig compatibility rel_path = f.relative_to(hash_vault) arcname = f"{archive_name}/vault/{rel_path}" tar.add(f, arcname=arcname) added_hashes.add(md5) except Exception as e: logger.debug(f"Error adding media {f}: {e}") break # Bundle neopig source files for self-contained crawling neopig_src = Path(__file__).parent neopig_files = [ 'neopig.py', 'database.py', 'async_web_fetcher.py', 'storage.py', 'domain_vault.py', 'screenshot.py', 'html2md.py', 'serp.py', 'filevault.py', 'async_filevault.py', ] for pyfile in neopig_files: src_path = neopig_src / pyfile if src_path.exists(): tar.add(src_path, arcname=f"{archive_name}/neopig/{pyfile}") # Add static vendor assets (highlight.js for markdown code blocks) vendor_dir = neopig_src / 'static' / 'vendor' if vendor_dir.exists(): for asset in vendor_dir.iterdir(): if asset.is_file(): tar.add(asset, arcname=f"{archive_name}/neopig/static/vendor/{asset.name}") # Add requirements.txt for neopig dependencies req_path = neopig_src / 'requirements.txt' if req_path.exists(): tar.add(req_path, arcname=f"{archive_name}/requirements.txt") # Add full neopig database for self-containment db_file = Path(db_path) if db_file.exists(): tar.add(db_file, arcname=f"{archive_name}/neopig.db") await asyncio.to_thread(stream_to_tar) # Clear html_contents to free memory html_contents.clear() final_size = tar_path.stat().st_size logger.info(f"Archive complete: {tar_path} ({final_size / 1024 / 1024:.1f} MB)") return tar_path def _write_index_html(self, tmpdir: Path, sitemap: list, domain: str): """Write index.html with sitemap.""" html = f'''
{len(sitemap)} pages archived