pig.py/archive.py
Russell Ballestrini a1a58fbb50 Add backfill-markdown, download button, fix image hydration
- backfill-markdown: regenerate markdown with absolute URLs for a domain
- Download button on /view page with slugified filename from alt/title
- html2text baseurl resolves relative URLs during crawl
- Fallback URL lookup by filename for older crawls
- Video hover-to-play on all video elements
- Avatar CSS float:left for text wrapping
- FileResponse inline disposition (view not download)
- Dynamic screenshot delay based on content length
2025-12-29 15:05:39 -05:00

1432 lines
52 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 = '') -> 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 with LIKE fallback."""
if not DB_PATH or not DB_PATH.exists():
return []
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
results = []
try:
# Try FTS5 with prefix matching (add * for partial matches)
fts_query = ' '.join(f'"{word}"*' for word in query.split())
c.execute("""
SELECT p.path, p.title, snippet(pages_fts, 1, '<mark>', '</mark>', '...', 40)
FROM pages_fts
JOIN pages p ON pages_fts.rowid = p.id
WHERE pages_fts MATCH ?
ORDER BY rank
LIMIT ?
""", (fts_query, limit))
results = [{'path': r[0], 'title': r[1], 'snippet': r[2]} for r in c.fetchall()]
except sqlite3.OperationalError:
pass
# Fallback to LIKE for substring matching if FTS5 found nothing
if not results:
try:
like_q = f'%{query}%'
c.execute("""
SELECT path, title, substr(content, 1, 200) as snippet
FROM pages
WHERE title LIKE ? COLLATE NOCASE
OR content LIKE ? COLLATE NOCASE
OR path LIKE ? COLLATE NOCASE
LIMIT ?
""", (like_q, like_q, like_q, limit))
results = [{'path': r[0], 'title': r[1], 'snippet': r[2] + '...'} for r in c.fetchall()]
except sqlite3.OperationalError:
pass
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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{domain} - neopig Archive</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 20px;
background: #0a0a0a; color: #e0e0e0;
}}
.container {{ max-width: 1400px; margin: 0 auto; }}
h1 {{ color: #ff6b6b; margin-bottom: 5px; }}
.subtitle {{ color: #666; margin-bottom: 20px; }}
nav {{ margin-bottom: 20px; }}
nav a {{ color: #ff6b6b; margin-right: 15px; text-decoration: none; }}
nav a:hover {{ text-decoration: underline; }}
nav a.active {{ font-weight: bold; border-bottom: 2px solid #ff6b6b; }}
.search-box {{
display: flex; gap: 10px; margin-bottom: 20px;
}}
input[type="text"] {{
flex: 1; padding: 12px 16px; font-size: 16px;
border: 2px solid #333; border-radius: 8px;
background: #1a1a1a; color: #fff;
}}
input[type="text"]:focus {{ outline: none; border-color: #ff6b6b; }}
button {{
padding: 12px 24px; font-size: 16px;
background: #ff6b6b; color: #fff;
border: none; border-radius: 8px; cursor: pointer;
}}
button:hover {{ background: #ff5252; }}
.stats {{
display: flex; gap: 20px; flex-wrap: wrap;
padding: 15px; background: #1a1a1a;
border-radius: 8px; margin-bottom: 20px;
}}
.stat {{ text-align: center; }}
.stat-value {{ font-size: 24px; font-weight: bold; color: #ff6b6b; }}
.stat-label {{ font-size: 12px; color: #888; }}
.results {{ margin-top: 20px; }}
.result {{
background: #1a1a1a; border-radius: 8px;
padding: 15px; margin-bottom: 10px;
}}
.result h3 {{ margin: 0 0 8px 0; }}
.result a {{ color: #ff6b6b; text-decoration: none; }}
.result a:hover {{ text-decoration: underline; }}
.result .snippet {{ color: #888; font-size: 14px; }}
.result .snippet mark {{ background: #ff6b6b; color: #fff; padding: 1px 3px; border-radius: 2px; }}
.result .path {{ font-size: 12px; color: #666; margin-top: 5px; }}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}}
.card {{
background: #1a1a1a; border-radius: 8px;
overflow: hidden; transition: transform 0.2s;
}}
.card:hover {{ transform: scale(1.02); }}
.card img {{
width: 100%; height: 150px;
object-fit: cover; background: #222;
}}
.card-info {{ padding: 10px; }}
.card-title {{
font-size: 12px; color: #ccc;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}}
.empty {{ text-align: center; padding: 60px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h1>neopig Archive</h1>
<p class="subtitle">{domain} - archived {created}</p>
<nav>
<a href="/" class="active">Search</a>
<a href="/browse">Browse Pages</a>
<a href="/media">Media</a>
<a href="/screenshots">Screenshots</a>
</nav>
<div class="stats">
<div class="stat"><div class="stat-value">{pages}</div><div class="stat-label">Pages</div></div>
<div class="stat"><div class="stat-value">{media}</div><div class="stat-label">Media</div></div>
<div class="stat"><div class="stat-value">{screenshots}</div><div class="stat-label">Screenshots</div></div>
</div>
<div class="search-box">
<input type="text" id="query" placeholder="Search archived pages..." autofocus>
<button onclick="search()">Search</button>
</div>
<div class="results" id="results"></div>
</div>
<script>
async function search() {{
const q = document.getElementById('query').value;
if (!q) return;
const res = await fetch('/api/search?q=' + encodeURIComponent(q));
const data = await res.json();
const container = document.getElementById('results');
if (data.length === 0) {{
container.innerHTML = '<div class="empty">No results found</div>';
return;
}}
container.innerHTML = data.map(r => `
<div class="result">
<h3><a href="/view/${{r.path.replace('html/', '')}}">${{r.title}}</a></h3>
<p class="snippet">${{r.snippet}}</p>
<p class="path">${{r.path}}</p>
</div>
`).join('');
}}
document.getElementById('query').addEventListener('keypress', e => {{
if (e.key === 'Enter') search();
}});
</script>
</body>
</html>"""
BROWSE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Browse - {domain}</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 20px;
background: #0a0a0a; color: #e0e0e0;
}}
.container {{ max-width: 1400px; margin: 0 auto; }}
h1 {{ color: #ff6b6b; }}
nav {{ margin-bottom: 20px; }}
nav a {{ color: #ff6b6b; margin-right: 15px; text-decoration: none; }}
nav a:hover {{ text-decoration: underline; }}
.list {{ background: #1a1a1a; border-radius: 8px; }}
.list-item {{
padding: 12px 15px; border-bottom: 1px solid #333;
display: flex; align-items: center; gap: 10px;
}}
.list-item:last-child {{ border-bottom: none; }}
.list-item a {{ color: #ff6b6b; text-decoration: none; flex: 1; }}
.list-item a:hover {{ text-decoration: underline; }}
.list-item .path {{ color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<h1>Browse Pages</h1>
<nav>
<a href="/">Search</a>
<a href="/browse">Browse Pages</a>
<a href="/media">Media</a>
<a href="/screenshots">Screenshots</a>
</nav>
<div class="list">
{items}
</div>
</div>
</body>
</html>"""
MEDIA_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media - {domain}</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 20px;
background: #0a0a0a; color: #e0e0e0;
}}
.container {{ max-width: 1600px; margin: 0 auto; }}
h1 {{ color: #ff6b6b; }}
nav {{ margin-bottom: 20px; }}
nav a {{ color: #ff6b6b; margin-right: 15px; text-decoration: none; }}
nav a:hover {{ text-decoration: underline; }}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: min-content;
gap: 12px;
}}
.card {{
background: #1a1a1a; border-radius: 8px;
overflow: hidden; transition: transform 0.2s;
break-inside: avoid;
}}
.card:hover {{ transform: scale(1.02); }}
.card a {{ display: block; }}
.card img, .card video {{
width: 100%;
height: auto;
display: block;
background: #222;
}}
.card-info {{ padding: 8px 10px; }}
.card-title {{
font-size: 11px; color: #888;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}}
.empty {{ text-align: center; padding: 60px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h1>{title}</h1>
<nav>
<a href="/">Search</a>
<a href="/browse">Browse Pages</a>
<a href="/media">Media</a>
<a href="/screenshots">Screenshots</a>
</nav>
<div class="grid">
{items}
</div>
</div>
</body>
</html>"""
MEDIA_DETAIL_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{caption} - neopig Archive</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 20px;
background: #0a0a0a; color: #e0e0e0;
line-height: 1.6;
}}
.container {{ max-width: 1000px; margin: 0 auto; }}
nav {{ margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; }}
nav a {{ color: #ff6b6b; margin-right: 15px; text-decoration: none; }}
nav a:hover {{ text-decoration: underline; }}
.hero {{
background: #111; border-radius: 12px; padding: 20px;
margin-bottom: 25px; text-align: center;
}}
.hero img, .hero video {{
max-width: 100%; max-height: 70vh;
border-radius: 8px; margin-bottom: 15px;
}}
.caption {{
font-size: 1.2em; color: #fff; margin: 15px 0 10px;
}}
.meta {{
font-size: 12px; color: #666; margin: 10px 0;
}}
.meta a {{ color: #6bb3ff; word-break: break-all; }}
.meta-row {{ margin: 5px 0; }}
.meta-label {{ color: #888; }}
.divider {{
border: none; border-top: 1px solid #333;
margin: 25px 0;
}}
.source-heading {{
color: #ff6b6b; font-size: 1.1em; margin-bottom: 15px;
}}
.content {{
background: #111; border-radius: 8px; padding: 25px;
font-size: 15px;
}}
.content h1, .content h2, .content h3 {{ color: #ff6b6b; margin-top: 1.5em; }}
.content h1:first-child, .content h2:first-child {{ margin-top: 0; }}
.content a {{ color: #6bb3ff; }}
.content code {{
background: #1a1a1a; padding: 2px 6px; border-radius: 3px;
font-family: monospace; font-size: 0.9em;
}}
.content pre {{
background: #1a1a1a; padding: 15px; border-radius: 6px;
overflow-x: auto; font-size: 0.85em;
}}
.content pre code {{ background: none; padding: 0; }}
.content blockquote {{
border-left: 3px solid #ff6b6b; margin: 1em 0;
padding-left: 15px; color: #aaa;
}}
.content img {{ max-width: 100%; height: auto; border-radius: 4px; }}
.content ul, .content ol {{ padding-left: 25px; }}
.content li {{ margin: 0.3em 0; }}
</style>
</head>
<body>
<div class="container">
<nav>
<a href="/">Search</a>
<a href="/browse">Browse Pages</a>
<a href="/media">Media</a>
<a href="/screenshots">Screenshots</a>
</nav>
<div class="hero">
{media_element}
<div class="caption">{caption}</div>
<div class="meta">
<div class="meta-row"><span class="meta-label">Image:</span> <a href="{media_uri}" target="_blank">{media_uri}</a></div>
<div class="meta-row"><span class="meta-label">Source:</span> <a href="/view/{page_path}">{page_uri}</a></div>
</div>
</div>
<hr class="divider">
<div class="source-heading">Source Page Content</div>
<div class="content">
{page_content}
</div>
</div>
</body>
</html>"""
PAGE_VIEW_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} - neopig Archive</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 20px;
background: #0a0a0a; color: #e0e0e0;
line-height: 1.6;
}}
.container {{ max-width: 900px; margin: 0 auto; }}
h1 {{ color: #ff6b6b; margin-bottom: 5px; font-size: 1.5em; }}
nav {{ margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; }}
nav a {{ color: #ff6b6b; margin-right: 15px; text-decoration: none; }}
nav a:hover {{ text-decoration: underline; }}
.meta {{ color: #666; font-size: 12px; margin-bottom: 20px; }}
.meta a {{ color: #888; }}
.content {{
background: #111; border-radius: 8px; padding: 25px;
font-size: 15px;
}}
.content h1, .content h2, .content h3 {{ color: #ff6b6b; margin-top: 1.5em; }}
.content h1:first-child, .content h2:first-child {{ margin-top: 0; }}
.content a {{ color: #6bb3ff; }}
.content code {{
background: #1a1a1a; padding: 2px 6px; border-radius: 3px;
font-family: monospace; font-size: 0.9em;
}}
.content pre {{
background: #1a1a1a; padding: 15px; border-radius: 6px;
overflow-x: auto; font-size: 0.85em;
}}
.content pre code {{ background: none; padding: 0; }}
.content blockquote {{
border-left: 3px solid #ff6b6b; margin: 1em 0;
padding-left: 15px; color: #aaa;
}}
.content img {{ max-width: 100%; height: auto; border-radius: 4px; }}
.content ul, .content ol {{ padding-left: 25px; }}
.content li {{ margin: 0.3em 0; }}
.content hr {{ border: none; border-top: 1px solid #333; margin: 2em 0; }}
.screenshot {{ margin-top: 20px; }}
.screenshot img {{ max-width: 100%; border: 1px solid #333; border-radius: 4px; }}
.screenshot-label {{ color: #666; font-size: 12px; margin-bottom: 5px; }}
</style>
</head>
<body>
<div class="container">
<nav>
<a href="/">Search</a>
<a href="/browse">Browse Pages</a>
<a href="/media">Media</a>
<a href="/screenshots">Screenshots</a>
</nav>
<h1>{title}</h1>
<div class="meta">
<a href="/html/{path}">[View Original HTML]</a>
{screenshot_link}
</div>
<div class="content">
{content}
</div>
{screenshot_embed}
</div>
</body>
</html>"""
def simple_markdown_to_html(md: str) -> str:
"""Convert markdown to HTML (simple stdlib-only implementation)."""
import re
lines = md.split('\n')
html_lines = []
in_code_block = False
in_list = False
for line in lines:
# Code blocks
if line.startswith('```'):
if in_code_block:
html_lines.append('</code></pre>')
in_code_block = False
else:
html_lines.append('<pre><code>')
in_code_block = True
continue
if in_code_block:
html_lines.append(html.escape(line))
continue
# Close list if needed
if in_list and not line.strip().startswith(('- ', '* ', '1. ')):
html_lines.append('</ul>')
in_list = False
# Headers
if line.startswith('### '):
html_lines.append(f'<h3>{html.escape(line[4:])}</h3>')
elif line.startswith('## '):
html_lines.append(f'<h2>{html.escape(line[3:])}</h2>')
elif line.startswith('# '):
html_lines.append(f'<h1>{html.escape(line[2:])}</h1>')
# Blockquotes
elif line.startswith('> '):
html_lines.append(f'<blockquote>{html.escape(line[2:])}</blockquote>')
# Horizontal rule
elif line.strip() in ('---', '***', '___'):
html_lines.append('<hr>')
# Lists
elif line.strip().startswith(('- ', '* ')):
if not in_list:
html_lines.append('<ul>')
in_list = True
content = line.strip()[2:]
html_lines.append(f'<li>{html.escape(content)}</li>')
# Empty line
elif not line.strip():
html_lines.append('<br>')
# Regular paragraph
else:
escaped = html.escape(line)
# Inline code
escaped = re.sub(r'`([^`]+)`', r'<code>\1</code>', escaped)
# Bold
escaped = re.sub(r'[*][*]([^*]+)[*][*]', r'<strong>\1</strong>', escaped)
# Italic
escaped = re.sub(r'[*]([^*]+)[*]', r'<em>\1</em>', escaped)
# Links [text](url)
link_re = re.compile(r'\[([^]]+)\]\(([^)]+)\)')
escaped = link_re.sub(r'<a href="\2">\1</a>', escaped)
# Images ![alt](url)
img_re = re.compile(r'!\[([^]]*)\]\(([^)]+)\)')
escaped = img_re.sub(r'<img src="\2" alt="\1">', escaped)
html_lines.append(f'<p>{escaped}</p>')
if in_list:
html_lines.append('</ul>')
if in_code_block:
html_lines.append('</code></pre>')
return '\n'.join(html_lines)
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('<h1>404 Not Found</h1>', 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'<div class="list-item"><a href="/view/{html.escape(p)}">{html.escape(p)}</a></div>'
for p in pages[:500]
])
if not items:
items = '<div class="list-item">No pages found</div>'
content = BROWSE_HTML.format(domain=METADATA.get('domain', ''), items=items)
self.send_html(content)
return
# Page view - render markdown with neopig styling
if path.startswith('/view/'):
page_path = path[6:] # Remove '/view/'
# Try markdown first, fall back to HTML
md_path = 'markdown/' + page_path.replace('.html', '.md')
html_path = 'html/' + page_path
md_content, _ = read_file(md_path)
html_content_raw, _ = read_file(html_path)
if md_content:
# Render markdown
rendered = simple_markdown_to_html(md_content.decode('utf-8', errors='replace'))
elif html_content_raw:
# Extract body from HTML and show as-is
html_str = html_content_raw.decode('utf-8', errors='replace')
# Simple body extraction
import re as re_mod
body_match = re_mod.search(r'<body[^>]*>(.*?)</body>', html_str, re_mod.DOTALL | re_mod.IGNORECASE)
rendered = body_match.group(1) if body_match else html_str
else:
self.send_404()
return
# Extract title
title = page_path.replace('.html', '').replace('/', ' > ')
# Check for screenshot
ss_path = page_path.replace('.html', '.png').replace('/', '_')
screenshot_exists = f'screenshots/{ss_path}' in [f'screenshots/{f}' for f in list_files('screenshots', '*.png')]
screenshot_link = f' | <a href="/screenshots/{html.escape(ss_path)}">[View Screenshot]</a>' if screenshot_exists else ''
screenshot_embed = (
'<div class="screenshot">'
'<div class="screenshot-label">Page Screenshot:</div>'
f'<img src="/screenshots/{html.escape(ss_path)}" alt="Screenshot">'
'</div>'
) if screenshot_exists else ''
content = PAGE_VIEW_HTML.format(
title=html.escape(title),
path=html.escape(page_path),
content=rendered,
screenshot_link=screenshot_link,
screenshot_embed=screenshot_embed,
)
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'<video src="/media/{html.escape(f)}" preload="metadata"></video>'
else:
media_el = f'<img src="/media/{html.escape(f)}" loading="lazy">'
# Link to detail view instead of raw file
items.append(
'<div class="card">'
f'<a href="/media/detail/{html.escape(f)}">{media_el}</a>'
f'<div class="card-info"><div class="card-title">{html.escape(f)}</div></div>'
'</div>'
)
content = MEDIA_HTML.format(
domain=METADATA.get('domain', ''),
title='Media',
items=''.join(items) if items else '<div class="empty">No media found</div>'
)
self.send_html(content)
return
# Media detail view - image at top with source page content below
if path.startswith('/media/detail/'):
media_path = path[14:] # Remove '/media/detail/'
media_file = 'media/' + media_path
# Check media exists
media_content, mime = read_file(media_file)
if not media_content:
self.send_404()
return
# Determine media element type
ext = Path(media_path).suffix.lower()
if ext in ('.mp4', '.webm', '.mov'):
media_el = f'<video src="/media/{html.escape(media_path)}" controls autoplay muted></video>'
else:
media_el = f'<img src="/media/{html.escape(media_path)}">'
# Try to find the source page - media path mirrors URL structure
# e.g., media/images/foo.jpg might come from t/topic-name/123.html
# For now, use filename as caption
caption = Path(media_path).stem.replace('-', ' ').replace('_', ' ')
media_uri = f'/media/{media_path}'
page_uri = METADATA.get('target_url', METADATA.get('domain', 'unknown'))
page_path = 'index.html'
# Try to find associated page content from the database
page_content = '<p class="empty">Source page content not available in archive database.</p>'
if DB_PATH and DB_PATH.exists():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
# Search for pages that might contain this media
media_name = Path(media_path).name
c.execute("""
SELECT path, title, content FROM pages
WHERE content LIKE ? OR path LIKE ?
LIMIT 1
""", (f'%{media_name}%', f'%{media_name}%'))
row = c.fetchone()
if row:
page_path = row[0].replace('html/', '')
caption = row[1] or caption
# Render the content
md_file = 'markdown/' + page_path.replace('.html', '.md')
md_content, _ = read_file(md_file)
if md_content:
page_content = simple_markdown_to_html(md_content.decode('utf-8', errors='replace'))
else:
page_content = f'<p>{html.escape(row[2][:2000] if row[2] else "")}...</p>'
except Exception:
pass
finally:
conn.close()
content = MEDIA_DETAIL_HTML.format(
media_element=media_el,
caption=html.escape(caption),
media_uri=html.escape(media_uri),
page_uri=html.escape(page_uri),
page_path=html.escape(page_path),
page_content=page_content,
)
self.send_html(content)
return
if path == '/screenshots':
files = list_files('screenshots', '*.png')
items = []
for f in files[:200]:
items.append(
'<div class="card">'
f'<a href="/screenshots/{html.escape(f)}" target="_blank">'
f'<img src="/screenshots/{html.escape(f)}" loading="lazy">'
'</a>'
f'<div class="card-info"><div class="card-title">{html.escape(f)}</div></div>'
'</div>'
)
content = MEDIA_HTML.format(
domain=METADATA.get('domain', ''),
title='Screenshots',
items=''.join(items) if items else '<div class="empty">No screenshots found</div>'
)
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)")
parser.add_argument("--backfill-markdown", metavar="DOMAIN", help="Re-process stored HTML for DOMAIN to regenerate markdown with absolute URLs")
parser.add_argument("--db", default="data/neopig.db", help="Database path (for --backfill-markdown)")
args = parser.parse_args()
setup_logging(level=logging.DEBUG if args.verbose else logging.INFO)
# Handle --backfill-markdown
if args.backfill_markdown:
from neopig import backfill_markdown
await backfill_markdown(args.db, domain_filter=args.backfill_markdown)
return
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,
)
# 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())