diff --git a/.gitignore b/.gitignore
index 2746ffe..fbc8025 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,4 @@ vendor/
# OS
.DS_Store
Thumbs.db
+data/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1b28ecf
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,293 @@
+# CLAUDE.md - neopig Project Reference
+
+## Commit Rules
+
+**NEVER attribute Claude in commit messages.** No co-author tags, no "Generated with Claude" footers, no AI mentions. Keep commits clean and human-authored in appearance.
+
+## Quick Start
+
+```bash
+# Setup
+python3 -m venv .venv && source .venv/bin/activate
+pip install -r requirements.txt
+
+# Run crawler
+python neopig.py https://example.com --mode images
+
+# Run SERP web interface
+python serp.py --host 0.0.0.0 --port 8000
+
+# Run tests
+pytest tests/ -v
+```
+
+## Project Overview
+
+**neopig** (Neo Python Image Grabber) is a full-domain async media crawler with:
+- MD5 content-addressable deduplication
+- SQLite metadata indexing with full-text search
+- Triple git-tracked vault system (HTML, media, screenshots)
+- FastAPI SERP web interface for search/browse
+- Optional page screenshot capture via uri2png
+
+Based on [pig.py](http://russell.ballestrini.net/python-image-grabber-pig-py/) by Russell Ballestrini.
+
+## Architecture
+
+```
+neopig.py # Main CLI crawler entry point (NeoPig class)
+archive.py # Site archiver - creates distributable tar.gz packages
+async_web_fetcher.py # Async HTTP client, CrawlMode enum, robots.txt handling
+database.py # SQLite schema: crawl_jobs, media, media_sources tables
+storage.py # ImageVault - content-addressed storage by MD5 hash
+domain_vault.py # Triple filevault: DomainHtmlVault, DomainMediaVault, DomainLinkpeekVault
+screenshot.py # ScreenshotCapture wrapper for uri2png
+serp.py # FastAPI SERP server with search, live feed, crawl UI
+```
+
+## Key Classes
+
+### NeoPig (neopig.py)
+Main crawler orchestrator. Initializes database, vault, fetcher, and screenshot modules.
+
+```python
+pig = NeoPig(db_path="neopig.db", vault_path="vault")
+await pig.init()
+stats = await pig.crawl(
+ target_uri="https://example.com",
+ keywords=["tag1", "tag2"],
+ mode=CrawlMode.IMAGES,
+ depth=-1, # unlimited
+ max_pages=-1,
+)
+```
+
+### CrawlMode (async_web_fetcher.py)
+```python
+class CrawlMode(Enum):
+ TEXT = "text" # Extract text content
+ IMAGES = "images" # Images only
+ VIDEOS = "videos" # Videos only
+ MEDIA = "media" # All media (images + videos + audio)
+ ALL = "all" # Full domain slurp
+```
+
+### Database Schema (database.py)
+- **crawl_jobs**: id, target_uri, keywords (JSON), mode, status, started_at, completed_at, stats (JSON)
+- **media**: md5_hash (PK), media_type, mime_type, file_size, keywords, alt_text, title, first_seen_at, analysis_status, analysis_result
+- **media_sources**: Tracks all contexts where media was found (page_uri, page_title, page_description, page_content, alt_text, link_text, detail_page_uri, detail_title, detail_content, searchable_text)
+
+### Triple Vault System (domain_vault.py)
+
+Three separate git-tracked vaults per domain with 9-layer deep hash paths:
+
+1. **HTML Vault** (`html_vault/`): Stores page HTML
+ - `{path}/index.html.og` - Original HTML
+ - `{path}/index.html` - Rewritten with neopig media paths
+
+2. **Media Vault** (`media_vault/`): Stores images/videos with git LFS
+ - Files mirror original URL paths
+
+3. **Linkpeek Vault** (`linkpeek_vault/`): Stores page screenshots with git LFS
+ - One PNG per page, named after URL path
+
+Environment: `NEOPIG_VAULT_SALT` - secret salt for domain hashing (privacy)
+
+### ImageVault (storage.py)
+Content-addressed storage. Files stored by MD5 hash in 256-bucket directory structure:
+```
+vault/
+ ab/abcd1234...5678.jpg
+ cd/cdef5678...1234.png
+```
+
+## Site Archiver (archive.py)
+
+Archive sunset sites into distributable tar.gz packages:
+
+```bash
+# Basic usage - archive entire site
+python archive.py https://discourse-urho3d.github.io/
+
+# Custom output directory
+python archive.py https://example.com -o ./archives/
+
+# Limit depth and pages
+python archive.py https://example.com --depth 5 --max-pages 500
+
+# Disable screenshots (faster)
+python archive.py https://example.com --no-screenshots
+
+# Disable markdown conversion
+python archive.py https://example.com --no-markdown
+```
+
+Output structure:
+```
+{domain}-{date}/
+ index.html # Archive index with sitemap
+ html/ # Original HTML pages
+ markdown/ # Converted markdown (optional)
+ media/ # Images, videos, audio
+ screenshots/ # Page screenshots (optional)
+ archive.db # SQLite FTS5 search database
+ serve.py # Embedded Pyramid search server
+ requirements.txt # Server dependencies (pyramid)
+ metadata.json # Crawl metadata and statistics
+```
+
+**Embedded Search Server:**
+```bash
+# Option 1: Extract and run
+tar -xzf example.com-20251229.tar.gz
+cd example.com-20251229
+pip install -r requirements.txt
+python serve.py
+# Open http://localhost:6543
+
+# Option 2: Serve directly from tar.gz (no extraction)
+python serve.py example.com-20251229.tar.gz
+```
+
+**Self-Extracting Executable (.run):**
+```bash
+# Create self-extracting archive (builds bootstrap if needed)
+make run TARBALL=example.com-20251229.tar.gz
+
+# Run it (just needs python3 + pyramid on target)
+./example.com-20251229.run
+# Opens http://localhost:6543
+```
+
+The `.run` file is a single executable containing:
+- C bootstrap (~14KB)
+- Full tar.gz archive
+- NEOPIG trailer with offset
+
+When run, it extracts serve.py to /tmp and launches the search server.
+
+## Screenshot Engines
+
+neopig supports multiple screenshot backends via uri2png. Auto-detects the lightest available:
+
+| Engine | Install | Speed | Notes |
+|--------|---------|-------|-------|
+| wkhtmltoimage | `apt install wkhtmltopdf` | Fast | Native Qt WebKit, no browser download |
+| cutycapt | `apt install cutycapt` | Fast | Native Qt WebKit, no browser download |
+| playwright-webkit | `pip install playwright && playwright install webkit` | Medium | Lighter than Chromium |
+| playwright-chromium | `pip install playwright && playwright install chromium` | Slow | Most compatible, heaviest |
+
+```bash
+# List available engines
+python neopig.py --list-engines
+
+# Use specific engine
+python neopig.py https://example.com --screenshot --screenshot-engine wkhtmltoimage
+python archive.py https://example.com --screenshot-engine cutycapt
+
+# Auto-detect (default) - picks lightest available
+python neopig.py https://example.com --screenshot
+```
+
+**Recommendation:** Install `wkhtmltopdf` for fast, lightweight screenshots without browser downloads.
+
+## CLI Usage (neopig.py)
+
+```bash
+# Single target
+python neopig.py https://example.com --mode images
+
+# Multiple targets concurrently
+python neopig.py https://site1.com https://site2.com --mode media
+
+# With keywords for tagging
+python neopig.py https://example.com -k "tag1" "tag2" --mode images
+
+# Limit depth and pages
+python neopig.py https://example.com --depth 5 --max-pages 500
+
+# Index only (no download)
+python neopig.py https://example.com --no-download
+
+# Enable screenshots (requires uri2png)
+python neopig.py https://example.com --screenshot --screenshot-width 1920 --screenshot-height 1080
+```
+
+## SERP API Endpoints (serp.py)
+
+- `GET /` - Search UI
+- `GET /crawl` - Crawler UI
+- `GET /live` - Live feed (watch images appear)
+- `GET /view/{md5_hash}` - Media detail page
+- `GET /media/{md5_hash}` - Serve media file
+- `GET /api/stats` - Database statistics
+- `GET /api/search?q=&type=&limit=` - Search media
+- `GET /api/media/{md5_hash}` - Media info JSON
+- `POST /api/crawl` - Start crawl job
+- `GET /api/crawl/jobs` - List crawl jobs
+- `GET /api/crawl/jobs/{id}` - Get job status
+- `GET /health` - Health check
+
+## Dependencies
+
+Core:
+- aiohttp, aiofiles - async HTTP/file operations
+- beautifulsoup4, html5lib - HTML parsing
+- aiosqlite - async SQLite
+- miniuri - URI parsing
+
+SERP:
+- fastapi, uvicorn - web server
+- python-multipart - form handling
+
+Optional:
+- filevault - content-addressed storage backend
+- pillow - image processing
+- uri2png - page screenshots (wkhtmltoimage, cutycapt, or playwright backends)
+
+## Makefile Targets
+
+```bash
+make install # Create venv, install deps
+make test # Run pytest
+make serp # Start basic SERP server
+make server # Start SERP + screenshot server
+make crawl ARGS="..." # Run crawler with args
+make vendor-install # Install uri2png with playwright
+make clean # Remove venv and test artifacts
+```
+
+## Testing
+
+```bash
+pytest tests/ -v --tb=short
+```
+
+Tests use pytest-asyncio. Config in `tests/conftest.py` and `pytest.ini`.
+
+## Key Patterns
+
+### Skeleton Key Approach
+Media records track both:
+- **Embedding context**: page_title, page_content from listing page
+- **Detail context**: detail_title, detail_content from detail page (Pinterest-style galleries)
+
+This enables finding images by ANY associated text.
+
+### MediaMetadata Accumulator (async_web_fetcher.py)
+"Never clobber, always append" - collects ALL metadata:
+- img.alt, img.title, a.title, a.text, figcaption, nearby headings, page title
+- Produces combined `searchable_text` for full-text search
+
+### Deduplication
+- Content: MD5 hash of file bytes
+- Context: UNIQUE(md5_hash, media_uri, page_uri) - same content from different pages tracked separately
+
+### Robots.txt Compliance
+AsyncWebFetcher respects robots.txt with configurable crawl delay (default 2s).
+
+## File Extensions
+
+Images: .jpg .jpeg .png .gif .webp .svg .bmp .ico .tiff .avif
+Videos: .mp4 .webm .mov .avi .mkv .m4v .ogv .flv .wmv
+Audio: .mp3 .wav .ogg .m4a .flac .aac .wma
diff --git a/Makefile b/Makefile
index dd6b921..63c01ff 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: venv install test crawl serp clean vendor-uri2png vendor-install
+.PHONY: venv install test crawl serp clean vendor-uri2png vendor-install archive bootstrap
VENV := .venv
PYTHON := $(VENV)/bin/python
@@ -17,12 +17,35 @@ test: install
crawl: install
$(PYTHON) neopig.py $(ARGS)
+archive: install
+ $(PYTHON) archive.py $(ARGS)
+
+# Build bootstrap binary for self-extracting archives
+bootstrap: bootstrap.c
+ gcc -O2 -Wall -o bootstrap bootstrap.c -lz
+ @echo "Built: bootstrap ($$(stat -c%s bootstrap 2>/dev/null || stat -f%z bootstrap) bytes)"
+
+# Create self-extracting .run from a tarball
+# Usage: make run TARBALL=example.tar.gz
+run: bootstrap
+ifndef TARBALL
+ $(error TARBALL not set. Usage: make run TARBALL=path/to/archive.tar.gz)
+endif
+ @OUTNAME=$$(basename "$(TARBALL)" .tar.gz).run; \
+ BOOTSTRAP_SIZE=$$(stat -c%s bootstrap 2>/dev/null || stat -f%z bootstrap); \
+ cat bootstrap "$(TARBALL)" > "$$OUTNAME"; \
+ echo -n "NEOPIG" >> "$$OUTNAME"; \
+ printf '%016x' "$$BOOTSTRAP_SIZE" >> "$$OUTNAME"; \
+ chmod +x "$$OUTNAME"; \
+ echo "Created: $$OUTNAME ($$(stat -c%s $$OUTNAME 2>/dev/null || stat -f%z $$OUTNAME) bytes)"
+
serp: install
$(PYTHON) serp.py --host 0.0.0.0 --port 8000
clean:
rm -rf $(VENV) __pycache__ *.pyc
rm -rf test_vault test_neopig.db
+ rm -f bootstrap *.run
# Vendor dependencies
vendor-uri2png:
@@ -48,6 +71,13 @@ server: vendor-install
# make serp - start basic SERP server
# make server - start combined server (SERP + screenshot)
# make crawl ARGS="https://example.com rick morty --mode images"
+# make archive ARGS="https://discourse-urho3d.github.io/"
+# make bootstrap - build C bootstrap for self-extracting archives
# make clean - remove venv and test artifacts
# make vendor-uri2png - fetch uri2png into vendor/
# make vendor-install - install uri2png Python package with screenshot support
+#
+# Self-extracting archive:
+# make archive ARGS="https://example.com"
+# make run TARBALL=example.com-20251229.tar.gz
+# ./example.com-20251229.run
diff --git a/archive.py b/archive.py
new file mode 100644
index 0000000..64fb0a3
--- /dev/null
+++ b/archive.py
@@ -0,0 +1,1041 @@
+#!/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
+
+
+
+
+
+
+
+"""
+
+BROWSE_HTML = """
+
+
+
+
+ Browse - {domain}
+
+
+
+
+
+"""
+
+MEDIA_HTML = """
+
+
+
+
+ Media - {domain}
+
+
+
+
+
+"""
+
+
+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''
+ 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(
+ ''
+ )
+ 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(
+ ''
+ )
+ 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())
diff --git a/async_web_fetcher.py b/async_web_fetcher.py
index 5740363..8a3e24b 100644
--- a/async_web_fetcher.py
+++ b/async_web_fetcher.py
@@ -802,10 +802,16 @@ class AsyncWebFetcher:
def __init__(
self,
user_agent: str = "uncloseai.com/1.42 (ethical web crawler; +https://uncloseai.com)",
- default_crawl_delay: float = DEFAULT_CRAWL_DELAY
+ default_crawl_delay: float = DEFAULT_CRAWL_DELAY,
+ fast_mode: bool = False
):
self.user_agent = user_agent
self.default_crawl_delay = default_crawl_delay
+ self.fast_mode = fast_mode
+
+ # Timeouts: 5s in fast mode, 60s normally
+ self.media_timeout = 5 if fast_mode else 60
+ self.page_timeout = 5 if fast_mode else 15
# Caches for robots.txt and crawl delays per domain
self.robot_parsers: Dict[str, Optional[RobotFileParser]] = {}
@@ -815,6 +821,11 @@ class AsyncWebFetcher:
# Page cache: {url: (html, links, timestamp)}
self.page_cache: Dict[str, Tuple[str, List, float]] = {}
+ # Domain skip list: domains with too many consecutive timeouts
+ self.skip_domains: Set[str] = set()
+ self.domain_timeout_counts: Dict[str, int] = {}
+ self.MAX_CONSECUTIVE_TIMEOUTS = 5
+
logger.info(f"AsyncWebFetcher initialized with user-agent: {self.user_agent}")
def _get_domain(self, url: str) -> str:
@@ -1173,6 +1184,18 @@ class AsyncWebFetcher:
logger.debug(f"Failed to resolve canonical image from {detail_page_url}: {e}")
return None
+ def _record_timeout(self, domain: str):
+ """Record a timeout for a domain. After MAX_CONSECUTIVE_TIMEOUTS, add to skip list."""
+ self.domain_timeout_counts[domain] = self.domain_timeout_counts.get(domain, 0) + 1
+ if self.domain_timeout_counts[domain] >= self.MAX_CONSECUTIVE_TIMEOUTS:
+ if domain not in self.skip_domains:
+ self.skip_domains.add(domain)
+ logger.warning(f"Skipping domain {domain} after {self.MAX_CONSECUTIVE_TIMEOUTS} consecutive timeouts")
+
+ def _record_success(self, domain: str):
+ """Record a successful fetch, resetting timeout count."""
+ self.domain_timeout_counts[domain] = 0
+
async def fetch_media(
self,
url: str,
@@ -1192,13 +1215,19 @@ class AsyncWebFetcher:
"""
global LAST_FETCH_ERROR
+ # Check if domain is in skip list
+ domain = self._get_domain(url)
+ if domain in self.skip_domains:
+ LAST_FETCH_ERROR = {'type': 'skip_domain', 'details': f'Domain {domain} skipped (too many timeouts)', 'url': url}
+ logger.debug(f"Skipping {url}: domain {domain} in skip list")
+ return None
+
# Check robots.txt
if not await self._can_fetch(url):
LAST_FETCH_ERROR = {'type': 'robots_txt', 'details': 'Blocked by robots.txt', 'url': url}
return None
# Enforce crawl delay
- domain = self._get_domain(url)
await self._enforce_crawl_delay(domain)
try:
@@ -1210,7 +1239,7 @@ class AsyncWebFetcher:
async with session.get(
url,
headers={"User-Agent": self.user_agent},
- timeout=aiohttp.ClientTimeout(total=60), # Longer timeout for media
+ timeout=aiohttp.ClientTimeout(total=self.media_timeout),
allow_redirects=True
) as response:
if response.status != 200:
@@ -1240,6 +1269,9 @@ class AsyncWebFetcher:
# Determine media type
media_type = get_media_type_from_mime(mime_type) or get_media_type_from_extension(url)
+ # Success - reset timeout count
+ self._record_success(domain)
+
logger.info(f"Fetched media {url}: {len(data)} bytes, MD5: {md5_hash}, type: {media_type}")
return {
@@ -1256,8 +1288,9 @@ class AsyncWebFetcher:
await session.close()
except asyncio.TimeoutError:
- LAST_FETCH_ERROR = {'type': 'timeout', 'details': 'Download timeout', 'url': url}
- logger.error(f"Timeout fetching media {url}")
+ self._record_timeout(domain)
+ LAST_FETCH_ERROR = {'type': 'timeout', 'details': f'Download timeout ({self.media_timeout}s)', 'url': url}
+ logger.error(f"Timeout fetching media {url} ({self.media_timeout}s)")
return None
except Exception as e:
LAST_FETCH_ERROR = {'type': 'unknown', 'details': str(e), 'url': url}
@@ -1764,6 +1797,8 @@ class AsyncWebFetcher:
mode: CrawlMode = CrawlMode.TEXT,
media_callback = None, # Callback for discovered media: async fn(media_item: Dict) -> None
page_callback = None, # Callback for page HTML: async fn(url: str, html: str) -> None
+ uris_total_callback = None, # Callback for URI total updates: fn(total: int) -> None
+ initial_visited: Optional[Set[str]] = None, # Pre-visited URLs for resume support
) -> List[Dict[str, str]]:
"""
Intelligent keyword-driven crawl strategy with domain prioritization.
@@ -1816,9 +1851,13 @@ class AsyncWebFetcher:
if unlimited_pages:
max_pages = 999999 # Effectively unlimited
all_pages = []
- visited = set()
+ visited = set(initial_visited) if initial_visited else set()
base_domain = self._get_domain(start_url)
+ # Log resume info
+ if initial_visited:
+ logger.info(f"Resuming with {len(initial_visited)} previously visited pages")
+
# Track all discovered links: {url: {'anchor_texts': [str], 'seen_count': int, 'total_link_score': float, 'domain': str, 'is_same_domain': bool}}
link_registry = {}
@@ -1945,6 +1984,8 @@ class AsyncWebFetcher:
link_registry[link_url]['total_link_score'] += link_score
logger.info(f"Registered {len(link_registry)} unique links from target page (same-domain: {same_domain_count}, cross-domain: {cross_domain_count})")
+ if uris_total_callback:
+ uris_total_callback(len(link_registry) + 1) # +1 for target page
# PHASE 3: Decide whether to crawl deeper
# If depth=0 or we're at max_pages, stop
@@ -2151,6 +2192,10 @@ class AsyncWebFetcher:
link_score = self._score_link(child_url, anchor_text, query_keywords, keyword_variations)
link_registry[child_url]['total_link_score'] += link_score
+ # Update total after processing child links
+ if uris_total_callback:
+ uris_total_callback(len(link_registry) + 1)
+
logger.info(f"Depth {current_depth} complete: crawled {pages_at_this_depth} pages")
return self._finalize_results(all_pages)
diff --git a/neopig.py b/neopig.py
index 18fadf4..d7acb8e 100644
--- a/neopig.py
+++ b/neopig.py
@@ -21,7 +21,9 @@ Usage:
import argparse
import asyncio
import hashlib
+import json
import logging
+import os
import sys
from datetime import datetime, timezone
from pathlib import Path
@@ -41,14 +43,31 @@ from storage import ImageVault
from database import Database
from screenshot import ScreenshotCapture, ScreenshotConfig
from domain_vault import VaultManager, DomainHtmlVault, DomainMediaVault, DomainLinkpeekVault, extract_media_urls
+from tqdm import tqdm
-logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s - %(levelname)s - %(message)s'
-)
logger = logging.getLogger(__name__)
+class TqdmLoggingHandler(logging.Handler):
+ """Logging handler that writes through tqdm to avoid progress bar corruption."""
+
+ def emit(self, record):
+ try:
+ msg = self.format(record)
+ tqdm.write(msg)
+ except Exception:
+ self.handleError(record)
+
+
+def setup_logging(level=logging.INFO):
+ """Setup logging to work with tqdm progress bars."""
+ handler = TqdmLoggingHandler()
+ handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
+ root = logging.getLogger()
+ root.handlers = [handler]
+ root.setLevel(level)
+
+
class NeoPig:
"""
Neo Python Image Grabber - async media crawler with deduplication.
@@ -60,6 +79,7 @@ class NeoPig:
vault_path: str = "vault",
user_agent: str = "neopig/1.0 (ethical image crawler)",
screenshot_config: ScreenshotConfig = None,
+ fast_mode: bool = False,
):
self.db = Database(db_path)
self.vault = ImageVault(vault_path)
@@ -71,7 +91,10 @@ class NeoPig:
media_base_url='/media',
linkpeek_base_url='/linkpeek',
)
- self.fetcher = AsyncWebFetcher(user_agent=user_agent)
+ # Fast mode: no crawl delay, short timeouts (for sites without robots.txt)
+ self.fast_mode = fast_mode
+ crawl_delay = 0.0 if fast_mode else 2.0
+ self.fetcher = AsyncWebFetcher(user_agent=user_agent, default_crawl_delay=crawl_delay, fast_mode=fast_mode)
self.screenshot = ScreenshotCapture(screenshot_config or ScreenshotConfig())
self.screenshot_config = screenshot_config or ScreenshotConfig()
self.vault_path = vault_path
@@ -86,15 +109,98 @@ class NeoPig:
'duplicates_skipped': 0,
'screenshots_taken': 0,
'errors': 0,
+ 'bytes_downloaded': 0, # Total bytes fetched from network
+ 'bytes_stored': 0, # Unique bytes stored in vault
}
# Track seen media URLs to avoid re-processing
self.seen_media: Set[str] = set()
# Track screenshotted pages to avoid duplicates
self.seen_screenshots: Set[str] = set()
+ # Track crawled page URLs for resume support
+ self.seen_pages: Set[str] = set()
# Track per-domain stats for vault commits
self._domain_stats: Dict[str, Dict[str, int]] = {} # domain -> {pages_changed, media_new, screenshots_new}
+ # Progress bar
+ self.pbar: Optional[tqdm] = None
+
+ # Resume support - state files go in data/
+ self._state_dir = Path("data")
+ self._state_dir.mkdir(exist_ok=True)
+ self._state_save_interval = 10
+ self._items_since_save = 0
+
+ def _get_state_file(self, target_url: str) -> Path:
+ """Get path to state file for resume support."""
+ parsed = urlparse(target_url)
+ domain = parsed.netloc.lower().replace('.', '-').replace(':', '-')
+ return self._state_dir / f"crawl-state-{domain}.json"
+
+ def _save_state(self, target_url: str):
+ """Save crawl state for resume."""
+ self._items_since_save += 1
+ if self._items_since_save < self._state_save_interval:
+ return
+
+ self._items_since_save = 0
+ state = {
+ 'target_url': target_url,
+ 'seen_media': list(self.seen_media),
+ 'seen_screenshots': list(self.seen_screenshots),
+ 'seen_pages': list(self.seen_pages),
+ # Note: skip_domains NOT persisted - domains may come back online
+ # It's saved during session for resume, but cleared on fresh runs
+ 'skip_domains': list(self.fetcher.skip_domains),
+ 'stats': self.stats,
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
+ }
+ try:
+ self._state_dir.mkdir(parents=True, exist_ok=True)
+ state_file = self._get_state_file(target_url)
+ with open(state_file, 'w') as f:
+ json.dump(state, f)
+ except Exception as e:
+ logger.debug(f"Failed to save state: {e}")
+
+ def _load_state(self, target_url: str) -> bool:
+ """Load saved crawl state. Returns True if state was loaded."""
+ state_file = self._get_state_file(target_url)
+ if not state_file.exists():
+ return False
+
+ try:
+ with open(state_file, 'r') as f:
+ state = json.load(f)
+ self.seen_media = set(state.get('seen_media', []))
+ self.seen_screenshots = set(state.get('seen_screenshots', []))
+ # In fast mode, skip already-crawled pages for speed
+ # In normal mode, re-fetch pages to detect content changes (git handles versioning)
+ if self.fast_mode:
+ self.seen_pages = set(state.get('seen_pages', []))
+ # Note: skip_domains NOT loaded - domains may have come back online
+ saved_stats = state.get('stats', {})
+ for key in self.stats:
+ if key in saved_stats:
+ self.stats[key] = saved_stats[key]
+ if self.fast_mode:
+ logger.info(f"Fast resume: skipping {len(self.seen_pages)} pages, {len(self.seen_media)} media, {len(self.seen_screenshots)} screenshots")
+ else:
+ logger.info(f"Resuming: {len(self.seen_media)} media, {len(self.seen_screenshots)} screenshots (pages will be re-checked)")
+ return True
+ except Exception as e:
+ logger.warning(f"Could not load state: {e}")
+ return False
+
+ def _clear_state(self, target_url: str):
+ """Clear state file after successful completion."""
+ try:
+ state_file = self._get_state_file(target_url)
+ if state_file.exists():
+ state_file.unlink()
+ except Exception:
+ pass
+
def _get_domain(self, url: str) -> str:
"""Extract domain from URL."""
parsed = urlparse(url)
@@ -116,6 +222,7 @@ class NeoPig:
domain = self._get_domain(url)
html_vault = self.domain_vaults.get_html_vault(domain)
is_changed, _ = await html_vault.archive_page(url, html, media_mappings)
+ self.stats['bytes_downloaded'] += len(html.encode('utf-8'))
if is_changed:
self._track_domain_stat(domain, 'pages_changed')
self.stats['pages_changed'] += 1
@@ -123,28 +230,61 @@ class NeoPig:
async def _archive_media_to_vault(
self,
url: str,
- content: bytes,
+ md5_hash: str,
+ ext: str,
page_url: str = '',
):
- """Archive media to the media vault."""
+ """Create symlink in domain media vault pointing to hash vault."""
domain = self._get_domain(url)
- media_vault = self.domain_vaults.get_media_vault(domain)
- is_new, _, _ = await media_vault.archive_media(url, content, page_url)
- if is_new:
- self._track_domain_stat(domain, 'media_new')
- self.stats['media_new'] += 1
+ # Domain media path: vault/media_vault/{domain}/{url_path}
+ parsed = urlparse(url)
+ url_path = parsed.path.lstrip('/') or 'index'
+ if not url_path.endswith(ext):
+ url_path = f"{url_path}{ext}"
+ domain_media_dir = Path(self.vault_path) / 'media_vault' / domain
+ domain_media_path = domain_media_dir / url_path
+
+ # Hash vault path: vault/{hash[:2]}/{hash}.{ext}
+ hash_vault_path = Path(self.vault_path) / md5_hash[:2] / f"{md5_hash}{ext}"
+
+ # Create symlink if not exists
+ if not domain_media_path.exists():
+ domain_media_path.parent.mkdir(parents=True, exist_ok=True)
+ # Calculate relative path from domain media to hash vault
+ rel_path = os.path.relpath(hash_vault_path, domain_media_path.parent)
+ try:
+ domain_media_path.symlink_to(rel_path)
+ self._track_domain_stat(domain, 'media_new')
+ self.stats['media_new'] += 1
+ except FileExistsError:
+ pass # Already exists
async def _archive_screenshot_to_vault(
self,
url: str,
- screenshot_data: bytes,
+ md5_hash: str,
):
- """Archive screenshot to the linkpeek vault."""
+ """Create symlink in domain linkpeek vault pointing to hash vault."""
domain = self._get_domain(url)
- linkpeek_vault = self.domain_vaults.get_linkpeek_vault(domain)
- is_new, _, _ = await linkpeek_vault.archive_screenshot(url, screenshot_data)
- if is_new:
- self._track_domain_stat(domain, 'screenshots_new')
+ # Domain screenshot path: vault/linkpeek_vault/{domain}/{url_path}.png
+ parsed = urlparse(url)
+ url_path = parsed.path.lstrip('/') or 'index'
+ url_path = url_path.replace('/', '_') + '.png'
+ domain_ss_dir = Path(self.vault_path) / 'linkpeek_vault' / domain
+ domain_ss_path = domain_ss_dir / url_path
+
+ # Hash vault path: vault/{hash[:2]}/{hash}.png
+ hash_vault_path = Path(self.vault_path) / md5_hash[:2] / f"{md5_hash}.png"
+
+ # Create symlink if not exists
+ if not domain_ss_path.exists():
+ domain_ss_path.parent.mkdir(parents=True, exist_ok=True)
+ rel_path = os.path.relpath(hash_vault_path, domain_ss_path.parent)
+ try:
+ domain_ss_path.symlink_to(rel_path)
+ self._track_domain_stat(domain, 'screenshots_new')
+ except FileExistsError:
+ pass
async def _finish_domain_vaults(self, keywords: List[str] = None):
"""Commit changes to all domain vaults that have diffs."""
@@ -208,31 +348,56 @@ class NeoPig:
logger.info(f"Keywords: {keywords}")
logger.info(f"Depth: {'unlimited' if depth == -1 else depth}")
+ # Load saved state if exists (resume support)
+ self._load_state(target_uri)
+
# Track timing for stats
start_time = datetime.now(timezone.utc)
- last_stats_time = start_time
- stats_running = True
- # Background task to emit stats every 15 seconds
- async def stats_reporter():
- nonlocal last_stats_time
- while stats_running:
- await asyncio.sleep(15)
- if not stats_running:
- break
- elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
- rate = self.stats['media_downloaded'] / elapsed * 60 if elapsed > 0 else 0
- logger.info(f"=== CRAWL STATS ({elapsed:.0f}s) ===")
- logger.info(f" Pages: {self.stats['pages_crawled']} | "
- f"Found: {self.stats['media_found']} | "
- f"Downloaded: {self.stats['media_downloaded']} | "
- f"Dupes: {self.stats['duplicates_skipped']} | "
- f"Screenshots: {self.stats['screenshots_taken']} | "
- f"Errors: {self.stats['errors']}")
- logger.info(f" Rate: {rate:.1f}/min | "
- f"Vault size: {len(self.seen_media)}")
+ def format_size(b: int) -> str:
+ if b < 1024:
+ return f"{b}B"
+ elif b < 1024 * 1024:
+ return f"{b/1024:.1f}KB"
+ elif b < 1024 * 1024 * 1024:
+ return f"{b/(1024*1024):.1f}MB"
+ else:
+ return f"{b/(1024*1024*1024):.1f}GB"
- stats_task = asyncio.create_task(stats_reporter())
+ # Track total URIs discovered for progress bar
+ uris_total = [1] # Start with 1 for target page, use list for mutability in closure
+
+ def on_uris_total(total: int):
+ uris_total[0] = total
+ self.pbar.total = total
+ self.pbar.refresh()
+
+ # Create progress bar with actual bar display
+ # Start with previous progress if resuming
+ initial_pages = len(self.seen_pages)
+ self.pbar = tqdm(
+ total=max(1, initial_pages),
+ initial=initial_pages,
+ unit="pages",
+ dynamic_ncols=True,
+ bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}] {postfix}',
+ mininterval=0.1,
+ )
+ self.pbar.set_postfix_str(
+ f"media: {self.stats['media_downloaded']}/{self.stats['media_found']}, "
+ f"ss: {self.stats['screenshots_taken']}, "
+ f"err: {self.stats['errors']}, "
+ f"{format_size(self.stats['bytes_stored'])} stored ({format_size(self.stats['bytes_downloaded'])} fetched)"
+ )
+
+ def update_pbar():
+ self.pbar.set_postfix_str(
+ f"media: {self.stats['media_downloaded']}/{self.stats['media_found']}, "
+ f"ss: {self.stats['screenshots_taken']}, "
+ f"err: {self.stats['errors']}, "
+ f"{format_size(self.stats['bytes_stored'])} stored ({format_size(self.stats['bytes_downloaded'])} fetched)"
+ )
+ self.pbar.refresh()
# Media callback - called for each discovered media item
async def on_media_discovered(item: Dict[str, Any]):
@@ -242,23 +407,27 @@ class NeoPig:
self.seen_media.add(url)
self.stats['media_found'] += 1
+ update_pbar()
if download_media:
await self._process_media_item(item, job_id, keywords)
+ update_pbar()
+ self._save_state(target_uri)
# Note: Screenshots are now captured per-page in on_page_fetched,
# not per-media-item, to honor crawl delay as a unit
# Progress callback
async def on_progress(msg: str):
self.stats['pages_crawled'] += 1
- if self.stats['pages_crawled'] % 10 == 0:
- logger.info(f"Progress: {self.stats['pages_crawled']} pages, "
- f"{self.stats['media_found']} media found, "
- f"{self.stats['media_downloaded']} downloaded")
+ self.pbar.update(1)
+ update_pbar()
# Page callback - archive raw HTML to vault and capture screenshot
# Screenshot happens here (same crawl delay window as page fetch)
async def on_page_fetched(url: str, html: str):
+ # Track this page as crawled for resume support
+ self.seen_pages.add(url)
+
# For now, archive without media URL rewriting (we'd need to download media first)
# TODO: Build media_mappings after media is downloaded
await self._archive_page_to_vault(url, html, media_mappings=None)
@@ -267,6 +436,9 @@ class NeoPig:
if self.screenshot_config.enabled:
await self._capture_page_screenshot(url, job_id, page_title='')
+ # Save state periodically for resume support
+ self._save_state(target_uri)
+
# Run the crawl
pages = await self.fetcher.fetch_with_depth(
start_url=target_uri,
@@ -277,17 +449,14 @@ class NeoPig:
media_callback=on_media_discovered,
progress_callback=on_progress,
page_callback=on_page_fetched,
+ uris_total_callback=on_uris_total,
+ initial_visited=self.seen_pages if self.seen_pages else None,
)
self.stats['pages_crawled'] = len(pages)
- # Stop the stats reporter
- stats_running = False
- stats_task.cancel()
- try:
- await stats_task
- except asyncio.CancelledError:
- pass
+ # Close progress bar
+ self.pbar.close()
# Calculate final stats
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
@@ -308,6 +477,11 @@ class NeoPig:
f"Errors: {self.stats['errors']}")
logger.info(f" Rate: {rate:.1f}/min | Total time: {elapsed:.1f}s")
+ # Keep state file for future delta crawls (don't clear)
+ # Force a final save to ensure latest state is persisted
+ self._items_since_save = self._state_save_interval # Force save
+ self._save_state(target_uri)
+
return self.stats
async def _process_media_item(
@@ -382,6 +556,7 @@ class NeoPig:
return
md5_hash = result['md5_hash']
+ self.stats['bytes_downloaded'] += result.get('size', 0)
# Check if content already in vault
if await self.vault.exists(md5_hash):
@@ -409,9 +584,10 @@ class NeoPig:
# Store in vault (new content)
ext = self._get_extension(media_uri, result.get('mime_type', ''))
await self.vault.store(md5_hash, result['data'], ext)
+ self.stats['bytes_stored'] += len(result['data'])
- # Archive to domain media vault (git-tracked)
- await self._archive_media_to_vault(media_uri, result['data'], page_uri)
+ # Create symlink in domain media vault pointing to hash vault
+ await self._archive_media_to_vault(media_uri, md5_hash, ext, page_uri)
# Record in database with full context and skeleton key
await self.db.create_media_record(
@@ -447,7 +623,12 @@ class NeoPig:
job_id: int,
page_title: str = '',
):
- """Capture and store a screenshot of a page."""
+ """Capture and store a screenshot of a page.
+
+ Respects robots.txt crawl-delay by coordinating with the fetcher's
+ per-domain delay tracking. Screenshots use a headless browser which
+ makes its own HTTP request, so we must enforce delay before capture.
+ """
if not self.screenshot_config.enabled:
return
@@ -457,19 +638,28 @@ class NeoPig:
self.seen_screenshots.add(page_uri)
try:
+ # Enforce crawl delay before screenshot (headless browser makes HTTP request)
+ domain = self._get_domain(page_uri)
+ await self.fetcher._enforce_crawl_delay(domain)
+
result = await self.screenshot.capture(page_uri)
if not result:
return
md5_hash = result['md5_hash']
screenshot_data = result['data']
+ screenshot_size = len(screenshot_data)
+
+ # Screenshots are fetched by headless browser (network traffic)
+ self.stats['bytes_downloaded'] += screenshot_size
# Store in MD5 vault (for deduplication)
if not await self.vault.exists(md5_hash):
await self.vault.store(md5_hash, screenshot_data, 'png')
+ self.stats['bytes_stored'] += screenshot_size
- # Archive to linkpeek vault (git-tracked by URL path)
- await self._archive_screenshot_to_vault(page_uri, screenshot_data)
+ # Create symlink in linkpeek vault pointing to hash vault
+ await self._archive_screenshot_to_vault(page_uri, md5_hash)
# Record in database as screenshot type
await self.db.create_media_record(
@@ -529,7 +719,7 @@ async def main():
parser.add_argument(
"targets",
- nargs="+",
+ nargs="*",
help="Target URI(s) to crawl (e.g., https://example.com https://other.com)"
)
@@ -613,10 +803,50 @@ async def main():
help="Delay after page load in ms (default: 1000)"
)
+ parser.add_argument(
+ "--screenshot-engine",
+ type=str,
+ default=None,
+ help="Screenshot engine: wkhtmltoimage, cutycapt, playwright-webkit, etc. (default: auto-detect lightest)"
+ )
+
+ parser.add_argument(
+ "--list-engines",
+ action="store_true",
+ help="List available screenshot engines and exit"
+ )
+
+ parser.add_argument(
+ "--fresh",
+ action="store_true",
+ help="Start fresh, ignoring any saved resume state"
+ )
+
+ parser.add_argument(
+ "--fast",
+ action="store_true",
+ help="Fast mode: no crawl delay (use for sites without robots.txt)"
+ )
+
args = parser.parse_args()
- if args.verbose:
- logging.getLogger().setLevel(logging.DEBUG)
+ # Setup logging to work with tqdm progress bars
+ setup_logging(level=logging.DEBUG if args.verbose else logging.INFO)
+
+ # Handle --list-engines
+ if args.list_engines:
+ from screenshot import list_available_engines
+ engines = await list_available_engines()
+ print("Available screenshot engines:")
+ for e in engines:
+ print(f" {e['name']}: {e['description']}")
+ print("\nPreference order: wkhtmltoimage > cutycapt > playwright-webkit > playwright")
+ print("Install lightweight: apt install wkhtmltopdf OR apt install cutycapt")
+ return
+
+ # Require targets for crawling
+ if not args.targets:
+ parser.error("targets required (use --list-engines to see available screenshot engines)")
# Map mode string to enum
mode_map = {
@@ -634,25 +864,36 @@ async def main():
width=args.screenshot_width,
height=args.screenshot_height,
delay=args.screenshot_delay,
+ engine=args.screenshot_engine,
)
if args.screenshot:
- logger.info(f"Screenshots enabled: {screenshot_config.width}x{screenshot_config.height}, delay={screenshot_config.delay}ms")
+ engine_info = f", engine={args.screenshot_engine}" if args.screenshot_engine else " (auto-detect)"
+ logger.info(f"Screenshots enabled: {screenshot_config.width}x{screenshot_config.height}, delay={screenshot_config.delay}ms{engine_info}")
# Initialize and run
pig = NeoPig(
db_path=args.db,
vault_path=args.vault,
screenshot_config=screenshot_config,
+ fast_mode=args.fast,
)
await pig.init()
- # Load previously crawled media URIs to enable resume
- crawled_media = await pig.db.get_crawled_media_uris()
+ if args.fast:
+ logger.info("Fast mode: no crawl delay (ignoring robots.txt)")
- if crawled_media:
- logger.info(f"Resuming: {len(crawled_media)} media already crawled")
- pig.seen_media = crawled_media
+ # Handle --fresh: clear state files for all targets
+ if args.fresh:
+ for target in args.targets:
+ pig._clear_state(target)
+ logger.info("Starting fresh (state files cleared)")
+ else:
+ # Load previously crawled media URIs from DB to enable resume
+ crawled_media = await pig.db.get_crawled_media_uris()
+ if crawled_media:
+ logger.info(f"Resuming: {len(crawled_media)} media already in database")
+ pig.seen_media = crawled_media
# Crawl all targets concurrently
async def crawl_target(target: str):
diff --git a/requirements.txt b/requirements.txt
index 8545a8a..1095087 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,5 +16,9 @@ fastapi>=0.104.0
uvicorn>=0.24.0
python-multipart>=0.0.6
+# Progress bar
+tqdm>=4.66.0
+
# Optional
pillow>=10.0.0
+html2text>=2024.2.26
diff --git a/screenshot.py b/screenshot.py
index deb5a9c..540d133 100644
--- a/screenshot.py
+++ b/screenshot.py
@@ -4,19 +4,46 @@ Screenshot capture module for neopig.
Wraps uri2png for async-compatible page screenshots.
Screenshots are stored in vault with MD5 hash like other media.
+
+Supported engines (in order of preference):
+ - wkhtmltoimage: Fast, lightweight, uses Qt WebKit. Install: apt install wkhtmltopdf
+ - cutycapt: Fast, lightweight, uses Qt WebKit. Install: apt install cutycapt
+ - playwright-webkit: WebKit via Playwright (lighter than Chromium)
+ - playwright-firefox: Firefox via Playwright
+ - playwright-chromium: Chromium via Playwright (heaviest, but most compatible)
+ - selenium-*: Various Selenium drivers
+
+The module auto-detects available engines and picks the lightest one,
+or you can specify an engine explicitly.
"""
import asyncio
import hashlib
import logging
-import subprocess
-import tempfile
-from dataclasses import dataclass
+import shutil
+from dataclasses import dataclass, field
from pathlib import Path
-from typing import Optional
+from typing import Optional, List, Dict, Any
+import tempfile
logger = logging.getLogger(__name__)
+# Engine preference order - lightest/fastest first
+ENGINE_PREFERENCE = [
+ 'wkhtmltoimage', # Native Qt WebKit - very fast, no browser download
+ 'cutycapt', # Native Qt WebKit - very fast, no browser download
+ 'playwright-webkit', # WebKit via Playwright - lighter than Chromium
+ 'playwright-firefox',
+ 'playwright-chromium',
+ 'playwright', # Default Playwright (Chromium)
+ 'selenium-chrome',
+ 'selenium-firefox',
+ 'selenium',
+]
+
+# Native tools that don't require browser downloads
+NATIVE_ENGINES = {'wkhtmltoimage', 'cutycapt'}
+
@dataclass
class ScreenshotConfig:
@@ -25,48 +52,134 @@ class ScreenshotConfig:
width: int = 1280
height: int = 1024
delay: int = 1000 # ms after DOM load
+ timeout: int = 30000 # ms total timeout
user_agent: Optional[str] = None
+ engine: Optional[str] = None # None = auto-detect best available
+ full_page: bool = False
class ScreenshotCapture:
"""
Async screenshot capture using uri2png.
- Since uri2png uses GTK main loop, we run it in a subprocess
- to avoid blocking the async event loop.
+ Supports multiple backends with automatic selection of the lightest
+ available engine. Native tools (wkhtmltoimage, cutycapt) are preferred
+ over browser-based solutions.
+
+ Usage:
+ config = ScreenshotConfig(enabled=True, engine='wkhtmltoimage')
+ capture = ScreenshotCapture(config)
+ result = await capture.capture('https://example.com')
"""
def __init__(self, config: ScreenshotConfig = None):
self.config = config or ScreenshotConfig()
- self._uri2png_available = None
+ self._engine = None
+ self._engine_name = None
+ self._available_engines: Optional[List[Dict[str, str]]] = None
+ self._initialized = False
+
+ async def _get_available_engines(self) -> List[Dict[str, str]]:
+ """Get list of available screenshot engines."""
+ if self._available_engines is not None:
+ return self._available_engines
+
+ def _check():
+ try:
+ from uri2png import get_available_engines
+ return get_available_engines()
+ except ImportError:
+ return []
+
+ self._available_engines = await asyncio.to_thread(_check)
+ return self._available_engines
+
+ async def _select_engine(self) -> Optional[str]:
+ """Select the best available engine based on preference order."""
+ available = await self._get_available_engines()
+ available_names = {e['name'] for e in available}
+
+ # If user specified an engine, try to use it
+ if self.config.engine:
+ if self.config.engine in available_names:
+ return self.config.engine
+ else:
+ logger.warning(f"Requested engine '{self.config.engine}' not available")
+ logger.info(f"Available engines: {', '.join(available_names)}")
+
+ # Check native tools first (they're fast and don't need browser downloads)
+ for engine in ENGINE_PREFERENCE:
+ if engine in available_names:
+ # For native engines, verify the binary exists
+ if engine in NATIVE_ENGINES:
+ binary = 'wkhtmltoimage' if engine == 'wkhtmltoimage' else 'cutycapt'
+ if shutil.which(binary):
+ return engine
+ else:
+ logger.debug(f"Engine {engine} listed but binary not found")
+ continue
+ return engine
+
+ return None
+
+ async def initialize(self) -> bool:
+ """Initialize the screenshot engine."""
+ if self._initialized:
+ return self._engine is not None
+
+ engine_name = await self._select_engine()
+ if not engine_name:
+ logger.warning("No screenshot engine available")
+ logger.info("Install one of: wkhtmltopdf, cutycapt, or playwright")
+ self._initialized = True
+ return False
+
+ def _create_engine():
+ try:
+ from uri2png import create_engine
+ # Pass options as kwargs to create_engine
+ return create_engine(
+ engine_name,
+ width=self.config.width,
+ height=self.config.height,
+ delay=self.config.delay,
+ timeout=self.config.timeout,
+ full_page=self.config.full_page,
+ user_agent=self.config.user_agent,
+ )
+ except Exception as e:
+ logger.warning(f"Failed to create engine '{engine_name}': {e}")
+ return None
+
+ self._engine = await asyncio.to_thread(_create_engine)
+ self._engine_name = engine_name
+ self._initialized = True
+
+ if self._engine:
+ logger.info(f"Screenshot engine: {engine_name}")
+ return True
+ return False
async def is_available(self) -> bool:
- """Check if uri2png is installed and available."""
- if self._uri2png_available is not None:
- return self._uri2png_available
+ """Check if screenshots are available."""
+ if not self._initialized:
+ await self.initialize()
+ return self._engine is not None
- try:
- proc = await asyncio.create_subprocess_exec(
- 'python', '-c', 'from uri2png import Uri2Png',
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.DEVNULL,
- )
- await proc.wait()
- self._uri2png_available = proc.returncode == 0
- except Exception:
- self._uri2png_available = False
+ def get_engine_name(self) -> Optional[str]:
+ """Get the name of the active engine."""
+ return self._engine_name
- if not self._uri2png_available:
- logger.warning("uri2png not available - screenshots disabled")
-
- return self._uri2png_available
+ async def list_engines(self) -> List[Dict[str, str]]:
+ """List all available screenshot engines."""
+ return await self._get_available_engines()
async def capture(self, uri: str) -> Optional[dict]:
"""
Capture screenshot of a URI.
Returns:
- Dict with 'data', 'md5_hash', 'mime_type' or None on failure
+ Dict with 'data', 'md5_hash', 'mime_type', 'engine' or None on failure
"""
if not self.config.enabled:
return None
@@ -74,94 +187,55 @@ class ScreenshotCapture:
if not await self.is_available():
return None
- # Create temp file for screenshot
- with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
- tmp_path = tmp.name
-
try:
- # Build uri2png command
- cmd = [
- 'python', '-c',
- f'''
-from uri2png import Uri2Png
-Uri2Png(
- uri="{uri}",
- filepath="{tmp_path}",
- width={self.config.width},
- height={self.config.height},
- delay={self.config.delay},
- user_agent={repr(self.config.user_agent)},
-).capture()
-'''
- ]
-
- # Run with timeout (uri2png can hang on bad URLs)
- proc = await asyncio.create_subprocess_exec(
- *cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
+ # Create temp file for output
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
+ output_path = f.name
try:
- stdout, stderr = await asyncio.wait_for(
- proc.communicate(),
- timeout=30.0 # 30 second timeout
+ # Capture - API is capture(url, output_path)
+ capture_coro = self._engine.capture(uri, output_path)
+
+ # All uri2png engines return coroutines
+ result = await asyncio.wait_for(
+ capture_coro,
+ timeout=self.config.timeout / 1000 + 5
)
- except asyncio.TimeoutError:
- proc.kill()
- await proc.wait()
- logger.warning(f"Screenshot timeout: {uri}")
- return None
- if proc.returncode != 0:
- logger.warning(f"Screenshot failed ({proc.returncode}): {uri}")
- if stderr:
- logger.debug(f"stderr: {stderr.decode()}")
- return None
-
- # Read screenshot data
- path = Path(tmp_path)
-
- def _check_file():
- if not path.exists():
+ if not result.success:
+ logger.warning(f"Screenshot failed: {uri} - {result.error}")
return None
- size = path.stat().st_size
- if size == 0:
+
+ # Read bytes from output file
+ data = Path(output_path).read_bytes()
+ if not data:
+ logger.warning(f"Screenshot empty: {uri}")
return None
- return path.read_bytes()
- data = await asyncio.to_thread(_check_file)
- if data is None:
- logger.warning(f"Screenshot empty: {uri}")
- return None
- md5_hash = hashlib.md5(data).hexdigest()
+ md5_hash = hashlib.md5(data).hexdigest()
+ logger.debug(f"Screenshot captured ({self._engine_name}): {uri} -> {md5_hash}")
- logger.debug(f"Screenshot captured: {uri} -> {md5_hash}")
-
- return {
- 'data': data,
- 'md5_hash': md5_hash,
- 'mime_type': 'image/png',
- 'size': len(data),
- 'source_uri': uri,
- }
-
- except Exception as e:
- logger.warning(f"Screenshot error for {uri}: {e}")
- return None
-
- finally:
- # Cleanup temp file (sync unlink is fine in finally - small operation)
- def _cleanup():
+ return {
+ 'data': data,
+ 'md5_hash': md5_hash,
+ 'mime_type': 'image/png',
+ 'size': len(data),
+ 'source_uri': uri,
+ 'engine': self._engine_name,
+ }
+ finally:
+ # Clean up temp file
try:
- Path(tmp_path).unlink(missing_ok=True)
+ Path(output_path).unlink(missing_ok=True)
except Exception:
pass
- try:
- await asyncio.to_thread(_cleanup)
- except Exception:
- pass
+ except asyncio.TimeoutError:
+ logger.warning(f"Screenshot timeout: {uri}")
+ return None
+ except Exception as e:
+ logger.warning(f"Screenshot error for {uri}: {e}")
+ return None
async def capture_to_file(self, uri: str, output_path: str) -> bool:
"""
@@ -180,3 +254,27 @@ Uri2Png(
except Exception as e:
logger.warning(f"Failed to write screenshot: {e}")
return False
+
+ async def cleanup(self):
+ """Cleanup engine resources."""
+ if self._engine:
+ try:
+ def _cleanup():
+ if hasattr(self._engine, 'cleanup'):
+ self._engine.cleanup()
+ await asyncio.to_thread(_cleanup)
+ except Exception as e:
+ logger.debug(f"Engine cleanup error: {e}")
+
+
+async def list_available_engines() -> List[Dict[str, str]]:
+ """List all available screenshot engines (utility function)."""
+ capture = ScreenshotCapture()
+ return await capture.list_engines()
+
+
+async def get_best_engine() -> Optional[str]:
+ """Get the best available engine name (utility function)."""
+ capture = ScreenshotCapture(ScreenshotConfig(enabled=True))
+ await capture.initialize()
+ return capture.get_engine_name()