pig.py/archive.py
Russell Ballestrini 623213f5b6 Self-extracting archives now use bundled neopig/serp.py
- Removed embedded serve.py (872 lines) from archive.py
- bootstrap.c extracts neopig/*.py to /tmp and runs serp.py
- serp.py: tarball mode serves media directly from tar.gz
- OffsetFile wrapper for reading .run files at correct offset
- ArchiveDB: simple SQLite wrapper for archive search (no async deps)
- Archives bundle all neopig source files for self-contained operation
- --upgrade-neopig flag with progress logging
2025-12-30 07:46:59 -05:00

711 lines
28 KiB
Python

#!/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 = '', 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,
):
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
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,
trim_wrapper=self.trim_wrapper,
)
await pig.init()
# Load previously crawled media/screenshots from DB (source of truth for resume)
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_media)} media URIs, {len(crawled_screenshots)} screenshotted pages in DB")
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
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"
html_vault_path = Path(vault_path) / 'html_vault' / domain
media_vault_path = Path(vault_path) / 'media_vault' / domain
linkpeek_vault_path = Path(vault_path) / 'linkpeek_vault' / domain
# 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: Scan HTML files to build sitemap and search index (no copying)
logger.info("Building search index...")
sitemap = []
html_contents = {}
if html_vault_path.exists():
for html_file in html_vault_path.rglob('*.html'):
rel_path = html_file.relative_to(html_vault_path)
try:
content = html_file.read_text(encoding='utf-8', errors='replace')
# Rewrite external URLs to local copies
content = rewrite_urls(content)
title = self._extract_title(content) or str(rel_path)
sitemap.append({'path': f'html/{rel_path}', 'title': title})
html_contents[str(rel_path)] = content
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)
# Create search database
await self._create_search_database_streaming(tmpdir_path, sitemap, domain, html_contents)
# 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_domain = domain.replace('.', '-').replace(':', '-')
state_file = Path("data") / f"crawl-state-{state_domain}.json"
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)
# Step 3: Stream everything to tar.gz in one pass
logger.info("Streaming to archive...")
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)
for rel_path_str, content in html_contents.items():
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))
# Generate markdown on the fly
if self.include_markdown:
md_content = html_to_markdown(content, trim_wrapper=self.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)
# Build hash -> file path mapping from hash vault
hash_vault = Path(vault_path)
added_hashes = set()
for url, md5 in url_to_hash.items():
if md5 in added_hashes:
continue
# Find file in hash vault: vault/xx/hash.ext
bucket = md5[:2]
bucket_dir = hash_vault / bucket
if bucket_dir.exists():
for f in bucket_dir.iterdir():
if f.stem == md5:
try:
arcname = f"{archive_name}/media/{f.name}"
tar.add(f, arcname=arcname)
added_hashes.add(md5)
except Exception as e:
logger.debug(f"Error adding media {f}: {e}")
break
# Stream screenshots (follow symlinks)
if self.include_screenshots and linkpeek_vault_path.exists():
for screenshot_file in linkpeek_vault_path.rglob('*.png'):
try:
rel_path = screenshot_file.relative_to(linkpeek_vault_path)
arcname = f"{archive_name}/screenshots/{rel_path}"
if screenshot_file.is_symlink():
target = screenshot_file.resolve()
if target.exists():
tar.add(target, arcname=arcname)
else:
tar.add(screenshot_file, arcname=arcname)
except Exception as e:
logger.debug(f"Error adding screenshot {screenshot_file}: {e}")
# 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 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")
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
async def _create_search_database_streaming(self, tmpdir: Path, sitemap: list, domain: str, html_contents: dict):
"""Create FTS5 search database from collected html contents."""
import sqlite3
db_path = tmpdir / 'archive.db'
def create_db():
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute('''
CREATE TABLE pages (
id INTEGER PRIMARY KEY,
path TEXT UNIQUE,
title TEXT,
content TEXT
)
''')
c.execute('''
CREATE VIRTUAL TABLE pages_fts USING fts5(
title, content, path,
content='pages',
content_rowid='id'
)
''')
for item in sitemap:
html_path = item['path'].replace('html/', '', 1)
content = html_contents.get(html_path, '')
if content:
try:
soup = BeautifulSoup(content, 'html.parser')
for tag in soup(['script', 'style', 'nav', 'header', 'footer']):
tag.decompose()
text = soup.get_text(separator=' ', strip=True)[:50000]
except Exception:
text = ''
else:
text = ''
try:
c.execute('INSERT INTO pages (path, title, content) VALUES (?, ?, ?)',
(item['path'], item['title'], text))
except Exception:
pass
c.execute('''
INSERT INTO pages_fts(rowid, title, content, path)
SELECT id, title, content, path FROM pages
''')
conn.commit()
conn.close()
await asyncio.to_thread(create_db)
def _write_index_html(self, tmpdir: Path, sitemap: list, domain: str):
"""Write index.html with sitemap."""
html = f'''<!DOCTYPE html>
<html>
<head>
<title>{domain} Archive</title>
<style>
body {{ font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }}
h1 {{ color: #333; }}
ul {{ list-style: none; padding: 0; }}
li {{ padding: 8px 0; border-bottom: 1px solid #eee; }}
a {{ color: #0066cc; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<h1>{domain} Archive</h1>
<p>{len(sitemap)} pages archived</p>
<ul>
'''
for item in sitemap[:1000]: # Limit to 1000 in index
html += f' <li><a href="{item["path"]}">{item["title"]}</a></li>\n'
if len(sitemap) > 1000:
html += f' <li>... and {len(sitemap) - 1000} more pages</li>\n'
html += ''' </ul>
</body>
</html>'''
(tmpdir / 'index.html').write_text(html)
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}")
# NOTE: Embedded serve.py removed - archives now use serp.py from bundled neopig/
# The bootstrap.c extracts neopig/*.py and runs serp.py with the tarball as argument.
# See: make run TARBALL=archive.tar.gz
def upgrade_neopig_in_archive(archive_path: Path) -> Path:
"""Replace neopig/ directory in existing archive with current source."""
import shutil
if not archive_path.exists():
raise FileNotFoundError(f"Archive not found: {archive_path}")
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',
]
# Create new archive with updated neopig
output_path = archive_path.with_suffix('.upgraded.tar.gz')
logger.info(f"Reading archive: {archive_path}")
with tarfile.open(archive_path, 'r:gz') as old_tar:
# Get members list (this reads the whole index)
members = old_tar.getmembers()
total = len(members)
archive_name = members[0].name.split('/')[0]
logger.info(f"Found {total} members in {archive_name}")
with tarfile.open(output_path, 'w:gz') as new_tar:
# Copy all members except neopig/ and requirements.txt
copied = 0
skipped = 0
for i, member in enumerate(members):
parts = member.name.split('/')
if len(parts) > 1 and parts[1] == 'neopig':
skipped += 1
continue # Skip old neopig files
if len(parts) > 1 and parts[1] == 'requirements.txt':
skipped += 1
continue # Skip old requirements
if member.isfile():
f = old_tar.extractfile(member)
if f:
new_tar.addfile(member, f)
copied += 1
else:
new_tar.addfile(member)
copied += 1
if (i + 1) % 1000 == 0:
logger.info(f" Progress: {i+1}/{total} ({copied} copied, {skipped} skipped)")
logger.info(f"Copied {copied} members, skipped {skipped}")
# Add new neopig files
logger.info("Adding neopig source files...")
for pyfile in neopig_files:
src_path = neopig_src / pyfile
if src_path.exists():
new_tar.add(src_path, arcname=f"{archive_name}/neopig/{pyfile}")
logger.info(f" Added: neopig/{pyfile}")
# Add requirements.txt
req_path = neopig_src / 'requirements.txt'
if req_path.exists():
new_tar.add(req_path, arcname=f"{archive_name}/requirements.txt")
logger.info(" Added: requirements.txt")
# Replace original with upgraded
logger.info(f"Replacing original archive...")
shutil.move(output_path, archive_path)
return archive_path
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", nargs='?', 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)")
parser.add_argument("--backfill-markdown", metavar="DOMAIN", help="Re-process stored HTML for DOMAIN to regenerate markdown with absolute URLs")
parser.add_argument("--trim-wrapper", action="store_true", help="With --backfill-markdown: strip nav/header/footer/logo before conversion")
parser.add_argument("--db", default="data/neopig.db", help="Database path (for --backfill-markdown)")
parser.add_argument("--upgrade-neopig", metavar="TARBALL", help="Upgrade neopig inside an existing archive")
args = parser.parse_args()
setup_logging(level=logging.DEBUG if args.verbose else logging.INFO)
# Handle --upgrade-neopig
if args.upgrade_neopig:
archive_path = Path(args.upgrade_neopig)
logger.info(f"Upgrading neopig in {archive_path}...")
upgrade_neopig_in_archive(archive_path)
logger.info(f"Done! Archive updated: {archive_path}")
return
# Handle --backfill-markdown
if args.backfill_markdown:
from neopig import backfill_markdown
await backfill_markdown(args.db, domain_filter=args.backfill_markdown, trim_wrapper=args.trim_wrapper)
return
# URL required for archiving
if not args.url:
parser.error("URL required (use --upgrade-neopig or --backfill-markdown for other operations)")
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,
full_page=True, # Archive captures full page by default
)
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,
trim_wrapper=args.trim_wrapper,
)
# 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())