#!/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) media/ # Images, videos, audio screenshots/ # Page screenshots (optional) archive.db # SQLite search database serve.py # Embedded search server metadata.json # Crawl metadata Usage: python archive.py https://discourse-urho3d.github.io/ python archive.py https://example.com --no-screenshots --no-markdown """ import argparse import asyncio import json import logging import os import re import shutil import signal import sqlite3 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 urllib.parse import urlparse import aiofiles import aiofiles.os from bs4 import BeautifulSoup from tqdm import tqdm from neopig import NeoPig, setup_logging from async_web_fetcher import CrawlMode from screenshot import ScreenshotConfig # 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 = urlparse(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 = '') -> str: """Convert HTML to markdown.""" if not HAS_HTML2TEXT: return 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, ): 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 async def archive( self, target_url: str, depth: int = -1, max_pages: int = -1, db_path: str = None, vault_path: str = None, ) -> Path: """ Archive a site using neopig and package into tar.gz. """ parsed = urlparse(target_url) domain = parsed.netloc.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, ) await pig.init() # Run the crawl 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...") with tempfile.TemporaryDirectory() as tmpdir: archive_root = Path(tmpdir) / archive_name archive_root.mkdir(parents=True) # Create subdirectories html_dir = archive_root / 'html' media_dir = archive_root / 'media' html_dir.mkdir() media_dir.mkdir() if self.include_markdown: md_dir = archive_root / 'markdown' md_dir.mkdir() if self.include_screenshots: screenshots_dir = archive_root / 'screenshots' screenshots_dir.mkdir() # Read pages from neopig's html vault html_vault_path = Path(vault_path) / 'html_vault' / domain sitemap = [] if html_vault_path.exists(): for html_file in html_vault_path.rglob('*.html'): rel_path = html_file.relative_to(html_vault_path) html_content = html_file.read_text(encoding='utf-8', errors='replace') # Write HTML dest_path = html_dir / rel_path await aiofiles.os.makedirs(dest_path.parent, exist_ok=True) async with aiofiles.open(dest_path, 'w', encoding='utf-8') as f: await f.write(html_content) # Write markdown if self.include_markdown: md_path = md_dir / str(rel_path).replace('.html', '.md') await aiofiles.os.makedirs(md_path.parent, exist_ok=True) md_content = html_to_markdown(html_content) async with aiofiles.open(md_path, 'w', encoding='utf-8') as f: await f.write(md_content) # Extract title for sitemap title = self._extract_title(html_content) or str(rel_path) sitemap.append({ 'path': f'html/{rel_path}', 'title': title, }) # Copy media from neopig's media vault (follows symlinks to hash vault) media_vault_path = Path(vault_path) / 'media_vault' / domain if media_vault_path.exists(): for media_file in media_vault_path.rglob('*'): if media_file.is_file() or media_file.is_symlink(): try: # Follow symlinks to get actual content if media_file.is_symlink(): target = media_file.resolve() if not target.exists(): logger.debug(f"Skipping broken symlink: {media_file}") continue content = target.read_bytes() else: content = media_file.read_bytes() rel_path = media_file.relative_to(media_vault_path) dest_path = media_dir / rel_path await aiofiles.os.makedirs(dest_path.parent, exist_ok=True) async with aiofiles.open(dest_path, 'wb') as f: await f.write(content) except Exception as e: logger.debug(f"Error copying media {media_file}: {e}") # Copy screenshots from neopig's linkpeek vault (follows symlinks to hash vault) if self.include_screenshots: linkpeek_vault_path = Path(vault_path) / 'linkpeek_vault' / domain if linkpeek_vault_path.exists(): for screenshot_file in linkpeek_vault_path.rglob('*.png'): try: # Follow symlinks to get actual content if screenshot_file.is_symlink(): target = screenshot_file.resolve() if not target.exists(): logger.debug(f"Skipping broken symlink: {screenshot_file}") continue content = target.read_bytes() else: content = screenshot_file.read_bytes() rel_path = screenshot_file.relative_to(linkpeek_vault_path) dest_path = screenshots_dir / rel_path await aiofiles.os.makedirs(dest_path.parent, exist_ok=True) async with aiofiles.open(dest_path, 'wb') as f: await f.write(content) except Exception as e: logger.debug(f"Error copying screenshot {screenshot_file}: {e}") # Create search database await self._create_search_database(archive_root, sitemap, domain, html_dir) # Write embedded serve.py self._write_serve_py(archive_root) # 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, } async with aiofiles.open(archive_root / 'metadata.json', 'w') as f: await f.write(json.dumps(metadata, indent=2)) # Copy state file into archive for future delta crawls state_domain = domain.replace('.', '-').replace(':', '-') state_file = Path("data") / f"crawl-state-{state_domain}.json" if state_file.exists(): shutil.copy(state_file, archive_root / 'crawl_state.json') logger.info(f"Included crawl state for future delta crawls") # Create tar.gz tar_path = self.output_dir / f"{archive_name}.tar.gz" def create_tarball(): with tarfile.open(tar_path, 'w:gz') as tar: tar.add(archive_root, arcname=archive_name) await asyncio.to_thread(create_tarball) final_size = tar_path.stat().st_size logger.info(f"Archive complete: {tar_path} ({final_size / 1024 / 1024:.1f} MB)") return tar_path def _extract_title(self, html: str) -> Optional[str]: """Extract title from HTML.""" try: soup = BeautifulSoup(html, 'html.parser') if soup.title and soup.title.string: return soup.title.string.strip() h1 = soup.find('h1') if h1: return h1.get_text().strip() except Exception: pass return None def _extract_text(self, html: str) -> str: """Extract readable text from HTML for search indexing.""" try: soup = BeautifulSoup(html, 'html.parser') for tag in soup(['script', 'style', 'nav', 'footer', 'header']): tag.decompose() text = soup.get_text(separator=' ', strip=True) text = re.sub(r'\s+', ' ', text) return text[:50000] except Exception: return '' async def _create_search_database( self, archive_root: Path, sitemap: List[Dict[str, str]], domain: str, html_dir: Path, ): """Create SQLite database with searchable page content.""" db_path = archive_root / 'archive.db' def create_db(): conn = sqlite3.connect(db_path) c = conn.cursor() c.execute(''' CREATE TABLE pages ( id INTEGER PRIMARY KEY, url TEXT NOT NULL, path TEXT NOT NULL, title TEXT, content TEXT ) ''') c.execute(''' CREATE VIRTUAL TABLE pages_fts USING fts5( title, content, url, path, content='pages', content_rowid='id' ) ''') c.execute(''' CREATE TRIGGER pages_ai AFTER INSERT ON pages BEGIN INSERT INTO pages_fts(rowid, title, content, url, path) VALUES (new.id, new.title, new.content, new.url, new.path); END ''') for item in sitemap: path = item['path'] title = item['title'] html_path = archive_root / path if html_path.exists(): html_content = html_path.read_text(encoding='utf-8', errors='replace') text_content = self._extract_text(html_content) else: text_content = '' c.execute( 'INSERT INTO pages (url, path, title, content) VALUES (?, ?, ?, ?)', (path, path, title, text_content) ) conn.commit() conn.close() await asyncio.to_thread(create_db) logger.info(f"Created search database: {db_path}") def _write_serve_py(self, archive_root: Path): """Write embedded search server (stdlib only, no dependencies).""" serve_py = '''#!/usr/bin/env python3 """ neopig Archive Server - Browse and search archived sites. Zero dependencies - uses only Python stdlib. Usage: python serve.py # Serve from extracted archive python serve.py archive.tar.gz # Serve directly from tarball python serve.py -p 8080 # Custom port ./archive.run # Self-extracting archive """ import argparse import html import json import mimetypes import os import re import sqlite3 import sys import tarfile import tempfile from http.server import HTTPServer, BaseHTTPRequestHandler from pathlib import Path from urllib.parse import parse_qs, urlparse, unquote # Globals set at startup ARCHIVE_ROOT = None TAR_FILE = None TAR_MEMBERS = {} DB_PATH = None METADATA = {} def get_archive_source(): """Determine if we are in a tarball, .run, or extracted directory.""" exe_path = Path(sys.argv[0]).resolve() # Check for .run format (has NEOPIG trailer) if exe_path.suffix == '.run' or (len(sys.argv) == 1 and exe_path.stat().st_size > 1000000): try: with open(exe_path, 'rb') as f: f.seek(-22, 2) trailer = f.read(22) if trailer[:6] == b'NEOPIG': offset = int(trailer[6:22].decode(), 16) return ('run', exe_path, offset) except Exception: pass # Check command line for tarball argument for arg in sys.argv[1:]: if not arg.startswith('-'): p = Path(arg) if p.exists() and p.suffix in ('.gz', '.tar', '.tgz'): return ('tarball', p, 0) # Must be extracted directory return ('directory', Path(__file__).parent, 0) def init_archive(): """Initialize archive access.""" global ARCHIVE_ROOT, TAR_FILE, TAR_MEMBERS, DB_PATH, METADATA source_type, source_path, offset = get_archive_source() if source_type == 'run': print(f"Serving from self-extracting archive: {source_path}") f = open(source_path, 'rb') f.seek(offset) TAR_FILE = tarfile.open(fileobj=f, mode='r:gz') elif source_type == 'tarball': print(f"Serving from tarball: {source_path}") TAR_FILE = tarfile.open(source_path, 'r:gz') else: print(f"Serving from directory: {source_path}") ARCHIVE_ROOT = source_path if TAR_FILE: # Build member lookup and find archive root for member in TAR_FILE.getmembers(): TAR_MEMBERS[member.name] = member first = list(TAR_MEMBERS.keys())[0] archive_name = first.split('/')[0] ARCHIVE_ROOT = Path(archive_name) # Extract database to temp for searching db_member = f"{archive_name}/archive.db" if db_member in TAR_MEMBERS: temp_dir = tempfile.mkdtemp() TAR_FILE.extract(TAR_MEMBERS[db_member], temp_dir) DB_PATH = Path(temp_dir) / db_member else: DB_PATH = ARCHIVE_ROOT / 'archive.db' # Load metadata meta_path = ARCHIVE_ROOT / 'metadata.json' if not TAR_FILE else None if meta_path and meta_path.exists(): METADATA = json.loads(meta_path.read_text()) elif TAR_FILE: meta_member = f"{ARCHIVE_ROOT}/metadata.json" if meta_member in TAR_MEMBERS: f = TAR_FILE.extractfile(TAR_MEMBERS[meta_member]) if f: METADATA = json.loads(f.read().decode()) def read_file(path: str) -> tuple: """Read file from archive. Returns (content_bytes, mime_type) or (None, None).""" if TAR_FILE: # Normalize path for tarball tar_path = f"{ARCHIVE_ROOT}/{path}".lstrip('/') if tar_path in TAR_MEMBERS: f = TAR_FILE.extractfile(TAR_MEMBERS[tar_path]) if f: mime, _ = mimetypes.guess_type(path) return f.read(), mime or 'application/octet-stream' return None, None else: file_path = ARCHIVE_ROOT / path if file_path.exists() and file_path.is_file(): # Security: prevent path traversal try: file_path.resolve().relative_to(ARCHIVE_ROOT.resolve()) except ValueError: return None, None mime, _ = mimetypes.guess_type(str(file_path)) return file_path.read_bytes(), mime or 'application/octet-stream' return None, None def list_files(subdir: str, pattern: str = '*') -> list: """List files in a subdirectory.""" files = [] if TAR_FILE: prefix = f"{ARCHIVE_ROOT}/{subdir}/" for name in TAR_MEMBERS: if name.startswith(prefix) and not name.endswith('/'): rel = name[len(prefix):] if pattern == '*' or rel.endswith(pattern.replace('*', '')): files.append(rel) else: dir_path = ARCHIVE_ROOT / subdir if dir_path.exists(): for f in dir_path.rglob(pattern): if f.is_file(): files.append(str(f.relative_to(dir_path))) return sorted(files) def search_pages(query: str, limit: int = 50) -> list: """Search pages using FTS5.""" if not DB_PATH or not DB_PATH.exists(): return [] conn = sqlite3.connect(DB_PATH) c = conn.cursor() try: c.execute(""" SELECT p.path, p.title, snippet(pages_fts, 1, '', '', '...', 40) FROM pages_fts JOIN pages p ON pages_fts.rowid = p.id WHERE pages_fts MATCH ? ORDER BY rank LIMIT ? """, (query, limit)) results = [{'path': r[0], 'title': r[1], 'snippet': r[2]} for r in c.fetchall()] except sqlite3.OperationalError: results = [] finally: conn.close() return results def get_stats() -> dict: """Get archive statistics.""" stats = { 'pages': len(list_files('html', '*.html')), 'media': len(list_files('media')), 'screenshots': len(list_files('screenshots', '*.png')), 'domain': METADATA.get('domain', 'unknown'), 'created': METADATA.get('created', 'unknown'), } if METADATA.get('stats'): stats.update(METADATA['stats']) return stats # HTML Templates INDEX_HTML = """ {domain} - neopig Archive

neopig Archive

{domain} - archived {created}

{pages}
Pages
{media}
Media
{screenshots}
Screenshots
""" BROWSE_HTML = """ Browse - {domain}

Browse Pages

{items}
""" MEDIA_HTML = """ Media - {domain}

{title}

{items}
""" class ArchiveHandler(BaseHTTPRequestHandler): """HTTP request handler for the archive.""" def log_message(self, format, *args): print(f"[{self.log_date_time_string()}] {args[0]}") def send_html(self, content: str, status: int = 200): self.send_response(status) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', len(content.encode())) self.end_headers() self.wfile.write(content.encode()) def send_json(self, data, status: int = 200): content = json.dumps(data) self.send_response(status) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', len(content.encode())) self.end_headers() self.wfile.write(content.encode()) def send_file(self, content: bytes, mime: str): self.send_response(200) self.send_header('Content-Type', mime) self.send_header('Content-Length', len(content)) self.send_header('Cache-Control', 'public, max-age=86400') self.end_headers() self.wfile.write(content) def send_404(self): self.send_html('

404 Not Found

', 404) def do_GET(self): parsed = urlparse(self.path) path = unquote(parsed.path) query = parse_qs(parsed.query) # API endpoints if path == '/api/search': q = query.get('q', [''])[0] results = search_pages(q) if q else [] self.send_json(results) return if path == '/api/stats': self.send_json(get_stats()) return # Pages if path == '/': stats = get_stats() content = INDEX_HTML.format(**stats) self.send_html(content) return if path == '/browse': pages = list_files('html', '*.html') items = ''.join([ f'
{html.escape(p)}
' for p in pages[:500] ]) if not items: items = '
No pages found
' content = BROWSE_HTML.format(domain=METADATA.get('domain', ''), items=items) self.send_html(content) return if path == '/media': files = list_files('media') items = [] for f in files[:200]: ext = Path(f).suffix.lower() if ext in ('.mp4', '.webm', '.mov'): media_el = f'' else: media_el = f'' items.append( '
' f'{media_el}' f'
{html.escape(f)}
' '
' ) content = MEDIA_HTML.format( domain=METADATA.get('domain', ''), title='Media', items=''.join(items) if items else '
No media found
' ) self.send_html(content) return if path == '/screenshots': files = list_files('screenshots', '*.png') items = [] for f in files[:200]: items.append( '
' f'' f'' '' f'
{html.escape(f)}
' '
' ) content = MEDIA_HTML.format( domain=METADATA.get('domain', ''), title='Screenshots', items=''.join(items) if items else '
No screenshots found
' ) self.send_html(content) return # Serve static files file_path = path.lstrip('/') content, mime = read_file(file_path) if content is not None: self.send_file(content, mime) else: self.send_404() def main(): parser = argparse.ArgumentParser(description='neopig Archive Server') parser.add_argument('archive', nargs='?', help='Path to archive.tar.gz (optional)') parser.add_argument('-p', '--port', type=int, default=8000, help='Port to listen on') parser.add_argument('--host', default='0.0.0.0', help='Host to bind to') args = parser.parse_args() init_archive() server = HTTPServer((args.host, args.port), ArchiveHandler) print(f"Starting neopig archive server at http://{args.host}:{args.port}") print(f"Archive: {METADATA.get('domain', 'unknown')} ({get_stats()['pages']} pages)") print("Press Ctrl+C to stop") try: server.serve_forever() except KeyboardInterrupt: print("\\nShutting down...") server.shutdown() if __name__ == '__main__': main() ''' (archive_root / 'serve.py').write_text(serve_py) async def main(): parser = argparse.ArgumentParser( description="Archive a website for preservation (uses neopig for crawling)", epilog="Example: python archive.py https://discourse-urho3d.github.io/" ) parser.add_argument("url", help="URL of the site to archive") parser.add_argument("-o", "--output", default=".", help="Output directory for tar.gz") parser.add_argument("-d", "--depth", type=int, default=-1, help="Crawl depth (-1 = unlimited)") parser.add_argument("-p", "--max-pages", type=int, default=-1, help="Max pages (-1 = unlimited)") parser.add_argument("--no-screenshots", action="store_true", help="Disable screenshots") parser.add_argument("--no-markdown", action="store_true", help="Disable markdown conversion") parser.add_argument("--screenshot-width", type=int, default=1280, help="Screenshot width") parser.add_argument("--screenshot-height", type=int, default=1024, help="Screenshot height") parser.add_argument("--screenshot-engine", type=str, default=None, help="Screenshot engine") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument("--fresh", action="store_true", help="Start fresh, ignore resume state") parser.add_argument("--fast", action="store_true", help="Fast mode: no crawl delay (for sites without robots.txt)") parser.add_argument("--serve", action="store_true", help="Start SERP server to watch crawl live") parser.add_argument("--port", type=int, default=8000, help="Port for SERP server (default: 8000)") args = parser.parse_args() setup_logging(level=logging.DEBUG if args.verbose else logging.INFO) if args.no_markdown and not HAS_HTML2TEXT: pass elif not HAS_HTML2TEXT: logger.warning("html2text not installed - markdown disabled") screenshot_config = ScreenshotConfig( enabled=not args.no_screenshots, width=args.screenshot_width, height=args.screenshot_height, engine=args.screenshot_engine, ) archiver = SiteArchiver( output_dir=args.output, include_screenshots=not args.no_screenshots, include_markdown=not args.no_markdown, screenshot_config=screenshot_config, fast_mode=args.fast, ) # Start SERP server if requested serp_process = None if args.serve: serp_script = Path(__file__).parent / 'serp.py' if serp_script.exists(): serp_cmd = [ sys.executable, str(serp_script), '--port', str(args.port), '--db', 'data/neopig.db', '--vault', 'data/vault', ] serp_process = subprocess.Popen( serp_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) logger.info(f"SERP server started at http://localhost:{args.port}") logger.info("Watch the crawl live - pages appear as they're indexed!") else: logger.warning("serp.py not found - --serve disabled") try: archive_path = await archiver.archive( target_url=args.url, depth=args.depth, max_pages=args.max_pages, ) print(f"\nArchive created: {archive_path}") print(f"Extract with: tar -xzf {archive_path.name}") finally: # Cleanup SERP server if serp_process: logger.info("Stopping SERP server...") serp_process.terminate() try: serp_process.wait(timeout=5) except subprocess.TimeoutExpired: serp_process.kill() if __name__ == "__main__": asyncio.run(main())