pig.py/neopig.py
Russell Ballestrini ee9c3cfc74 ETL backfill with jobs table, background flusher, smooth progress
- backfill_jobs table tracks ETL progress with atomic increments
- Background flusher thread syncs progress every 50ms (workers never block)
- Thread-local DB connections for page writes (WAL mode)
- tqdm smoothing=0.1 for stable rate display (30-40 pages/sec)
- 4 processes x 6 threads = 24 workers burning all CPUs
- Zero lost counts - final flush verified against return values
2025-12-30 14:43:25 -05:00

1853 lines
70 KiB
Python

#!/usr/bin/env python3
"""
neopig - Neo Python Image Grabber
Full domain crawler that:
1. Crawls entire domain (no depth limit)
2. Extracts all images/videos via multiple methods
3. Uses HEAD checks for extensionless URLs
4. Stores in vault (MD5 dedupe)
5. Queues for Qwen 3 VL analysis
6. Indexes metadata in SQLite
Based on pig.py by Russell Ballestrini
https://russell.ballestrini.net/python-image-grabber-pig-py/
Usage:
python neopig.py https://example.com "rick and morty" --mode images
python neopig.py https://example.com --mode all --depth -1 # Full domain slurp
"""
import argparse
import asyncio
import hashlib
import json
import logging
import os
import sys
from datetime import datetime, timezone, timezone
from pathlib import Path
from typing import List, Dict, Any, Optional, Set
from miniuri import Uri
from async_web_fetcher import (
AsyncWebFetcher,
CrawlMode,
MediaItem,
extract_media_from_html,
get_media_type_from_extension,
get_media_type_from_mime,
)
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
logger = logging.getLogger(__name__)
def get_state_file_path(domain: str) -> Path:
"""Get unified state file path for a domain.
Args:
domain: Domain name (e.g., 'example.com')
Returns:
Path like data/{domain}.state
"""
safe_domain = domain.replace('://', '-').replace('/', '-').replace('.', '-')
return Path(f"data/{safe_domain}.state")
def rotate_state_file(state_path: Path, preserve_keys: List[str] = None) -> Optional[Path]:
"""Rotate state file with optional selective preservation.
Args:
state_path: Path to state file
preserve_keys: If provided, rotate then copy back these keys from rotated file.
If None, just rotate (full fresh start).
Returns:
Path to rotated file, or None if no rotation needed.
"""
if not state_path.exists():
return None
# Find next available rotation number
i = 1
while Path(f"{state_path}.{i}").exists():
i += 1
rotated = Path(f"{state_path}.{i}")
state_path.rename(rotated)
logger.info(f"Rotated state file to {rotated}")
# If preserve_keys specified, copy back those keys from rotated file
if preserve_keys:
try:
import json
old_state = json.loads(rotated.read_text())
new_state = {k: v for k, v in old_state.items() if k in preserve_keys}
if new_state:
state_path.write_text(json.dumps(new_state, indent=2))
logger.info(f"Preserved keys: {list(new_state.keys())}")
except Exception as e:
logger.warning(f"Could not preserve state keys: {e}")
return rotated
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.
"""
def __init__(
self,
db_path: str = "neopig.db",
vault_path: str = "vault",
user_agent: str = "neopig/1.0 (ethical image crawler)",
screenshot_config: ScreenshotConfig = None,
fast_mode: bool = False,
trim_wrapper: bool = False,
):
self.db = Database(db_path)
self.vault = ImageVault(vault_path)
self.trim_wrapper = trim_wrapper
# Triple filevault system: html_vault/, media_vault/, and linkpeek_vault/
self.domain_vaults = VaultManager(
html_vault_base=f"{vault_path}/html_vault",
media_vault_base=f"{vault_path}/media_vault",
linkpeek_vault_base=f"{vault_path}/linkpeek_vault",
media_base_url='/media',
linkpeek_base_url='/linkpeek',
)
# 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
# Track stats
self.stats = {
'pages_crawled': 0,
'pages_changed': 0, # Pages with content changes (for git commit)
'media_found': 0,
'media_downloaded': 0,
'media_new': 0, # New media (for git commit)
'duplicates_skipped': 0,
'content_exists': 0, # Same content (MD5) already in vault
'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 unified state file path for domain."""
parsed = Uri(target_url)
domain = parsed.hostname.lower()
return get_state_file_path(domain)
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_file = self._get_state_file(target_url)
# Load existing state to preserve other keys (e.g., backfill state)
try:
full_state = json.loads(state_file.read_text()) if state_file.exists() else {}
except Exception:
full_state = {}
# Update crawl section
full_state['crawl'] = {
'target_url': target_url,
'seen_media': list(self.seen_media),
'seen_screenshots': list(self.seen_screenshots),
'seen_pages': list(self.seen_pages),
'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)
with open(state_file, 'w') as f:
json.dump(full_state, f, indent=2)
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.
Note: seen_media and seen_screenshots are NOT loaded from state file -
they come from the database (source of truth for successful downloads).
Only seen_pages (for fast mode) and stats are loaded from state.
"""
state_file = self._get_state_file(target_url)
if not state_file.exists():
return False
try:
full_state = json.loads(state_file.read_text())
state = full_state.get('crawl', {})
if not state:
return False
# In fast mode, skip already-crawled pages for speed
if self.fast_mode:
self.seen_pages = set(state.get('seen_pages', []))
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 (media/screenshots from DB)")
else:
logger.info(f"Resume: loading stats from state (media/screenshots from DB)")
return True
except Exception as e:
logger.warning(f"Could not load state: {e}")
return False
def _clear_state(self, target_url: str, preserve_keys: List[str] = None):
"""Rotate state file for fresh start.
Args:
preserve_keys: Keys to preserve (e.g., ['crawl'] for backfill-only fresh)
"""
try:
state_file = self._get_state_file(target_url)
rotate_state_file(state_file, preserve_keys=preserve_keys)
except Exception:
pass
def _get_domain(self, url: str) -> str:
"""Extract domain from URL."""
parsed = Uri(url)
return parsed.hostname.lower()
def _track_domain_stat(self, domain: str, stat: str, increment: int = 1):
"""Track per-domain stats for vault commits."""
if domain not in self._domain_stats:
self._domain_stats[domain] = {'pages_changed': 0, 'media_new': 0, 'screenshots_new': 0}
self._domain_stats[domain][stat] += increment
def _extract_text_from_html(self, html: str, base_url: str = None) -> tuple:
"""Extract title, text content, and markdown from HTML.
Args:
html: Raw HTML content
base_url: Base URL for resolving relative links/images
Returns: (title, text_content, markdown)
"""
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
# Extract title
title = ''
title_tag = soup.find('title')
if title_tag:
title = title_tag.get_text(strip=True)
# Convert to markdown using smart converter
markdown = ''
try:
from html2md import html_to_markdown
markdown = html_to_markdown(html, base_url=base_url)[:200000]
except Exception:
# Fallback to html2text
try:
import html2text
h = html2text.HTML2Text()
h.ignore_links = False
h.ignore_images = False
h.body_width = 0
if base_url:
h.baseurl = base_url
markdown = h.handle(html)[:200000]
except ImportError:
pass
# Remove script and style elements for plain text
for tag in soup(['script', 'style', 'nav', 'header', 'footer']):
tag.decompose()
# Get text content
text = soup.get_text(separator=' ', strip=True)
# Clean up whitespace
import re
text = re.sub(r'\s+', ' ', text)
return title, text[:100000], markdown
except Exception:
return '', '', ''
async def _archive_page_to_vault(
self,
uri: str,
html: str,
media_mappings: Dict[str, str] = None,
crawl_job_id: int = None,
):
"""Archive a page to the HTML vault and store for search."""
domain = self._get_domain(uri)
html_vault = self.domain_vaults.get_html_vault(domain)
is_changed, _ = await html_vault.archive_page(uri, 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
# Store page content for full-text search and phantom site recreation
title, content, markdown = self._extract_text_from_html(html, base_url=uri)
parsed = Uri(uri)
path = parsed.path or '/'
await self.db.store_page(
uri=uri,
title=title,
content=content,
path=path,
markdown=markdown,
raw_html=html,
crawl_job_id=crawl_job_id,
)
async def _archive_media_to_vault(
self,
url: str,
md5_hash: str,
ext: str,
page_url: str = '',
):
"""Create symlink in domain media vault pointing to hash vault."""
domain = self._get_domain(url)
# Domain media path: vault/media_vault/{domain}/{url_path}
parsed = Uri(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,
md5_hash: str,
ext: str = 'jpg',
suffix: str = '',
):
"""Create symlink in domain linkpeek vault pointing to hash vault."""
domain = self._get_domain(url)
# Domain screenshot path: vault/linkpeek_vault/{domain}/{url_path}{suffix}.{ext}
parsed = Uri(url)
url_path = parsed.path.lstrip('/') or 'index'
url_path = url_path.replace('/', '_') + f'{suffix}.{ext}'
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}.{ext}
hash_vault_path = Path(self.vault_path) / md5_hash[:2] / f"{md5_hash}.{ext}"
# 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."""
for domain, stats in self._domain_stats.items():
if stats['pages_changed'] > 0 or stats['media_new'] > 0 or stats['screenshots_new'] > 0:
html_vault = self.domain_vaults.get_html_vault(domain)
media_vault = self.domain_vaults.get_media_vault(domain)
linkpeek_vault = self.domain_vaults.get_linkpeek_vault(domain)
html_commit = await html_vault.finish_crawl(stats)
media_commit = await media_vault.finish_crawl(stats)
linkpeek_commit = await linkpeek_vault.finish_crawl(stats)
if html_commit:
logger.info(f"Committed HTML vault for {domain}: {html_commit[:8]}")
if media_commit:
logger.info(f"Committed media vault for {domain}: {media_commit[:8]}")
if linkpeek_commit:
logger.info(f"Committed linkpeek vault for {domain}: {linkpeek_commit[:8]}")
async def init(self):
"""Initialize database and vault."""
await self.db.init()
await self.vault.init()
async def crawl(
self,
target_uri: str,
keywords: List[str] = None,
mode: CrawlMode = CrawlMode.IMAGES,
depth: int = -1, # -1 = unlimited
max_pages: int = -1, # -1 = unlimited
download_media: bool = True,
) -> Dict[str, Any]:
"""
Crawl a domain for media.
Args:
target_uri: Starting URL
keywords: Keywords to tag media with (e.g., ["rick and morty"])
mode: CrawlMode - what to collect (IMAGES, VIDEOS, MEDIA, ALL)
depth: Crawl depth (-1 = unlimited)
max_pages: Max pages to crawl (-1 = unlimited)
download_media: Whether to download media or just index URLs
Returns:
Crawl statistics
"""
keywords = keywords or []
# Create crawl job
job_id = await self.db.create_crawl_job(
target_uri=target_uri,
keywords=keywords,
mode=mode.value,
)
logger.info(f"Starting neopig crawl job {job_id}")
logger.info(f"Target: {target_uri}")
logger.info(f"Mode: {mode.value}")
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)
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"
# Track total URIs discovered for progress bar
uris_total = [0] # Use list for mutability in closure
def on_uris_total(total: int):
uris_total[0] = total
if self.pbar and total > 0:
self.pbar.total = total
self.pbar.refresh()
# Create progress bar with dynamic total
initial_pages = len(self.seen_pages)
self.pbar = tqdm(
total=1, # Start with 1, will be updated as links are discovered
initial=initial_pages,
unit="pages",
dynamic_ncols=True,
bar_format='{n_fmt}/{total_fmt} pages [{elapsed}] {postfix}',
mininterval=0.1,
)
self.pbar.set_postfix_str(
f"new: {self.stats['media_downloaded']}, "
f"skip: {self.stats['duplicates_skipped']}, "
f"dup: {self.stats['content_exists']}, "
f"found: {self.stats['media_found']}, "
f"ss: {self.stats['screenshots_taken']}, "
f"err: {self.stats['errors']}, "
f"{format_size(self.stats['bytes_stored'])} stored"
)
def update_pbar():
self.pbar.set_postfix_str(
f"new: {self.stats['media_downloaded']}, "
f"skip: {self.stats['duplicates_skipped']}, "
f"dup: {self.stats['content_exists']}, "
f"found: {self.stats['media_found']}, "
f"ss: {self.stats['screenshots_taken']}, "
f"err: {self.stats['errors']}, "
f"{format_size(self.stats['bytes_stored'])} stored"
)
self.pbar.refresh()
# Media callback - called for each discovered media item
async def on_media_discovered(item: Dict[str, Any]):
url = item.get('url')
if not url:
return
if url in self.seen_media:
# Debug: log when skipping
if self.stats['media_found'] < 5: # Only first few
logger.debug(f"SKIP (in seen_media): {url[:80]}")
return
self.stats['media_found'] += 1
update_pbar()
if download_media:
success = await self._process_media_item(item, job_id, keywords)
if success:
self.seen_media.add(url) # Only mark seen after success
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
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(uri: str, html: str):
# Track this page as crawled for resume support
self.seen_pages.add(uri)
# Archive to vault and store for search (with job_id for tracking)
await self._archive_page_to_vault(uri, html, media_mappings=None, crawl_job_id=job_id)
# Capture screenshot for every page (honors crawl delay as a UNIT with page fetch)
if self.screenshot_config.enabled:
await self._capture_page_screenshot(uri, job_id, page_title='', content_length=len(html))
# 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,
depth=depth,
max_pages=max_pages,
query_keywords=keywords,
mode=mode,
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)
# Close progress bar
self.pbar.close()
# Calculate final stats
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
rate = self.stats['media_downloaded'] / elapsed * 60 if elapsed > 0 else 0
# Update job status
await self.db.complete_crawl_job(job_id, self.stats)
# Commit domain vaults if there were changes
await self._finish_domain_vaults(keywords)
logger.info(f"=== CRAWL COMPLETE ({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 | 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(
self,
item: Dict[str, Any],
job_id: int,
keywords: List[str]
) -> bool:
"""Download and store a media item with page context. Returns True on success.
Implements the skeleton key approach:
- If detail_page_url is set, resolves canonical image URL
- Collects both embedding_title (page_title) and detail_title
- Prefers canonical URL for download, falls back to original
"""
media_uri = item['url']
page_uri = item.get('source_page', '')
media_type = item.get('media_type', 'unknown')
detail_page_url = item.get('detail_page_url')
# Extract page context for searchability (embedding context)
page_title = item.get('page_title', '')
page_description = item.get('page_description', '')
page_keywords = item.get('page_keywords', '')
page_content = item.get('page_content', '') # Blog post text for full-text search
alt_text = item.get('alt_text', '')
link_text = item.get('link_text', '')
searchable_text = item.get('searchable_text', '') # Combined metadata from accumulator
# Skeleton key: detail context (from detail page if Pinterest-style gallery)
detail_page_uri = ''
detail_title = ''
detail_content = ''
try:
# Crystal algorithm: resolve canonical image if this looks like a gallery thumbnail
if detail_page_url:
canonical_result = await self.fetcher.resolve_canonical_image(
detail_page_url=detail_page_url,
thumbnail_url=media_uri,
embedding_title=page_title, # Pass listing page title
)
if canonical_result:
detail_page_uri = detail_page_url
detail_title = canonical_result.get('detail_title', '')
detail_content = canonical_result.get('detail_content', '')
# Use canonical URL if found, otherwise keep thumbnail
if canonical_result.get('canonical_url'):
logger.debug(f"Canonical resolution: {media_uri} -> {canonical_result['canonical_url']}")
media_uri = canonical_result['canonical_url']
# Enrich metadata from detail page
if not alt_text and canonical_result.get('description'):
alt_text = canonical_result['description']
# Check if this exact media+page combo was already crawled
existing_hash = await self.db.check_media_uri_exists(media_uri, page_uri)
if existing_hash:
self.stats['duplicates_skipped'] += 1
logger.debug(f"Already crawled: {media_uri} from {page_uri}")
return True # Already have it, consider success
# Check if content already in vault (same MD5 = same content)
# We still need to add the new page context even if content exists
if media_uri in self.seen_media:
# Already processed this media_uri, just add context
# We need to fetch to get MD5, but we can skip if we track it
pass
# Fetch the media
result = await self.fetcher.fetch_media(media_uri)
if not result:
self.stats['errors'] += 1
return False
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):
# Content exists, but add this new page context with skeleton key
await self.db.add_media_source(
md5_hash=md5_hash,
media_uri=media_uri,
page_uri=page_uri,
page_title=page_title,
page_description=page_description,
page_keywords=page_keywords,
page_content=page_content,
alt_text=alt_text,
link_text=link_text,
detail_page_uri=detail_page_uri,
detail_title=detail_title,
detail_content=detail_content,
searchable_text=searchable_text,
crawl_job_id=job_id,
)
self.stats['content_exists'] += 1
logger.debug(f"Content exists, added context: {md5_hash} from {page_uri}")
return True
# 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'])
# 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(
md5_hash=md5_hash,
media_uri=media_uri,
page_uri=page_uri,
crawl_job_id=job_id,
media_type=media_type,
mime_type=result.get('mime_type', ''),
file_size=result.get('size', 0),
page_title=page_title,
page_description=page_description,
page_keywords=page_keywords,
page_content=page_content,
alt_text=alt_text,
link_text=link_text,
detail_page_uri=detail_page_uri,
detail_title=detail_title,
detail_content=detail_content,
searchable_text=searchable_text,
)
self.stats['media_downloaded'] += 1
logger.debug(f"Stored: {md5_hash} ({media_uri})")
return True
except Exception as e:
logger.warning(f"Failed to process {media_uri}: {e}")
self.stats['errors'] += 1
return False
async def _capture_page_screenshot(
self,
page_uri: str,
job_id: int,
page_title: str = '',
content_length: int = 0,
):
"""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.
Args:
page_uri: URL of the page to screenshot
job_id: Crawl job ID for database tracking
page_title: Optional page title for metadata
content_length: Raw HTML content length for dynamic delay calculation
"""
if not self.screenshot_config.enabled:
logger.debug(f"Screenshots disabled, skipping {page_uri}")
return
if page_uri in self.seen_screenshots:
logger.debug(f"Screenshot already exists for {page_uri}")
return
logger.info(f"Taking screenshot: {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)
# Pass content_length for dynamic delay calculation on long pages
result = await self.screenshot.capture(page_uri, content_length=content_length)
if not result:
return
# Normalize to list (oversized images return multiple chunks)
chunks = result if isinstance(result, list) else [result]
total_size = 0
for i, chunk in enumerate(chunks):
md5_hash = chunk['md5_hash']
screenshot_data = chunk['data']
screenshot_size = len(screenshot_data)
screenshot_ext = chunk.get('format', 'png')
screenshot_mime = chunk.get('mime_type', 'image/png')
total_size += screenshot_size
# Store in MD5 vault (for deduplication)
if not await self.vault.exists(md5_hash):
await self.vault.store(md5_hash, screenshot_data, screenshot_ext)
self.stats['bytes_stored'] += screenshot_size
# Create symlink in linkpeek vault (with suffix for chunks)
suffix = f'_{i}' if len(chunks) > 1 else ''
await self._archive_screenshot_to_vault(page_uri, md5_hash, screenshot_ext, suffix=suffix)
# Record in database (all chunks, with chunk index in alt_text)
chunk_label = f" (part {i+1}/{len(chunks)})" if len(chunks) > 1 else ""
await self.db.create_media_record(
md5_hash=md5_hash,
media_uri=f"screenshot:{page_uri}{suffix}",
page_uri=page_uri,
crawl_job_id=job_id,
media_type='screenshot',
mime_type=screenshot_mime,
file_size=screenshot_size,
page_title=page_title,
page_description='',
page_keywords='',
alt_text=f"Screenshot of {page_uri}{chunk_label}",
link_text='',
)
# Screenshots are fetched by headless browser (network traffic)
self.stats['bytes_downloaded'] += total_size
self.stats['screenshots_taken'] += 1
self.seen_screenshots.add(page_uri) # Only mark seen after success
logger.debug(f"Screenshot captured: {page_uri} -> {chunks[0]['md5_hash']} ({len(chunks)} chunk(s))")
except Exception as e:
logger.warning(f"Screenshot failed for {page_uri}: {e}")
def _get_extension(self, url: str, mime_type: str) -> str:
"""Determine file extension from URL or MIME type."""
# Try from URL path
path = Uri(url).path.lower()
for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp',
'.mp4', '.webm', '.mov', '.avi', '.mkv']:
if path.endswith(ext):
return ext.lstrip('.')
# Try from MIME type
mime_map = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'video/mp4': 'mp4',
'video/webm': 'webm',
}
for mt, ext in mime_map.items():
if mt in mime_type:
return ext
return 'bin'
def trim_html_wrapper(html: str) -> str:
"""Strip nav, header, footer, sidebar, and logo elements from HTML.
Useful for cleaning up Discourse and similar sites before markdown conversion.
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
# Remove common wrapper elements
selectors_to_remove = [
'nav', 'header', 'footer', 'aside',
'.sidebar', '.nav', '.navigation', '.menu',
'.header', '.footer', '.logo', '.site-logo',
'#header', '#footer', '#nav', '#sidebar',
'.d-header', '.d-footer', # Discourse specific
'.header-wrapper', '.footer-wrapper',
'[role="banner"]', '[role="navigation"]', '[role="contentinfo"]',
]
for selector in selectors_to_remove:
for tag in soup.select(selector):
tag.decompose()
# Remove site logo images (be specific to avoid removing content images)
for img in soup.find_all('img'):
src = img.get('src', '').lower()
alt = img.get('alt', '').lower()
cls = ' '.join(img.get('class', [])).lower()
# Only remove if it's clearly a site logo, not general icons
is_logo = 'logo' in cls or 'brand' in cls or 'site-logo' in src
is_logo = is_logo or (alt and ('logo' in alt or 'brand' in alt))
if is_logo:
img.decompose()
return str(soup)
def extract_meta_from_html(html: str) -> tuple[str, list[str]]:
"""Extract meta description and keywords from HTML.
Returns:
Tuple of (description, keywords_list)
"""
from bs4 import BeautifulSoup
import re
soup = BeautifulSoup(html, 'html.parser')
description = ""
keywords = []
# Extract meta description
meta_desc = soup.find('meta', attrs={'name': re.compile(r'^description$', re.I)})
if meta_desc and meta_desc.get('content'):
description = meta_desc['content'].strip()[:500]
# Extract meta keywords
meta_kw = soup.find('meta', attrs={'name': re.compile(r'^keywords$', re.I)})
if meta_kw and meta_kw.get('content'):
raw_kw = meta_kw['content']
keywords = [k.strip().lower() for k in raw_kw.split(',') if k.strip()]
# Also check og:description as fallback
if not description:
og_desc = soup.find('meta', attrs={'property': 'og:description'})
if og_desc and og_desc.get('content'):
description = og_desc['content'].strip()[:500]
# Extract from article:tag meta tags (common in blogs)
for tag_meta in soup.find_all('meta', attrs={'property': 'article:tag'}):
if tag_meta.get('content'):
keywords.append(tag_meta['content'].strip().lower())
# Dedupe keywords
keywords = list(dict.fromkeys(keywords))[:20]
return description, keywords
def _process_single_page(args: tuple) -> tuple:
"""Process a single page for markdown conversion (runs in thread within process)."""
from html2md import html_to_markdown
import json
page_id, uri, raw_html, trim_wrapper = args
try:
description, keywords = extract_meta_from_html(raw_html)
raw = raw_html
if trim_wrapper:
raw = trim_html_wrapper(raw)
new_markdown = html_to_markdown(raw, base_url=uri)[:200000]
return (page_id, new_markdown, description, json.dumps(keywords) if keywords else None, None)
except Exception as e:
return (page_id, None, None, None, str(e))
def _process_chunk(args: tuple) -> dict:
"""Process a chunk of pages in a subprocess with thread workers.
Each process gets a chunk and spawns threads to burn through it.
Each thread has its own DB connection - WAL mode allows concurrent writes.
Background flusher thread syncs progress every 100ms (workers never block).
"""
import sqlite3
import threading
import time
from concurrent.futures import ThreadPoolExecutor
chunk, db_path, trim_wrapper, threads_per_process, job_id = args
# Thread-local storage for DB connections
thread_local = threading.local()
progress = {'updated': 0, 'errors': 0}
progress_lock = threading.Lock()
stop_flusher = threading.Event()
def get_conn():
"""Get thread-local DB connection for page writes."""
if not hasattr(thread_local, 'conn'):
thread_local.conn = sqlite3.connect(db_path, timeout=60.0)
thread_local.conn.execute("PRAGMA journal_mode=WAL")
thread_local.conn.execute("PRAGMA synchronous=NORMAL")
thread_local.conn.execute("PRAGMA busy_timeout=60000")
return thread_local.conn
def flusher_thread():
"""Background thread that syncs progress to DB every 100ms."""
conn = sqlite3.connect(db_path, timeout=60.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=60000")
flushed_updated, flushed_errors = 0, 0
while not stop_flusher.is_set():
time.sleep(0.05)
with progress_lock:
curr_updated, curr_errors = progress['updated'], progress['errors']
delta_updated = curr_updated - flushed_updated
delta_errors = curr_errors - flushed_errors
if delta_updated > 0 or delta_errors > 0:
conn.execute(
"UPDATE backfill_jobs SET processed_records = processed_records + ?, error_count = error_count + ? WHERE id = ?",
(delta_updated, delta_errors, job_id)
)
conn.commit()
flushed_updated, flushed_errors = curr_updated, curr_errors
# Final flush - get final counts and flush any remaining delta
with progress_lock:
final_updated, final_errors = progress['updated'], progress['errors']
delta_updated = final_updated - flushed_updated
delta_errors = final_errors - flushed_errors
if delta_updated > 0 or delta_errors > 0:
conn.execute(
"UPDATE backfill_jobs SET processed_records = processed_records + ?, error_count = error_count + ? WHERE id = ?",
(delta_updated, delta_errors, job_id)
)
conn.commit()
# Store final counts for verification
progress['_flushed_updated'] = final_updated
progress['_flushed_errors'] = final_errors
conn.close()
def process_and_write(page_data):
"""Process one page and write immediately with thread-local connection."""
page_id, uri, raw_html = page_data
result = _process_single_page((page_id, uri, raw_html, trim_wrapper))
page_id, markdown, description, keywords_json, error = result
conn = get_conn()
if error:
with progress_lock:
progress['errors'] += 1
else:
conn.execute(
"UPDATE pages SET markdown = ?, description = ?, keywords = ? WHERE id = ?",
(markdown, description, keywords_json, page_id)
)
conn.commit()
with progress_lock:
progress['updated'] += 1
# Start background flusher
flusher = threading.Thread(target=flusher_thread, daemon=True)
flusher.start()
# Fan out to thread workers
with ThreadPoolExecutor(max_workers=threads_per_process) as thread_executor:
list(thread_executor.map(process_and_write, chunk))
# Stop flusher and wait for final flush
stop_flusher.set()
flusher.join(timeout=5.0)
# Return verified counts (what was actually flushed to DB)
return {
'updated': progress.get('_flushed_updated', progress['updated']),
'errors': progress.get('_flushed_errors', progress['errors']),
'total': len(chunk)
}
async def backfill_markdown(db_path: str, domain_filter: str = None, trim_wrapper: bool = False, quiet: bool = False):
"""Re-process stored HTML to regenerate markdown with smart structure detection.
ETL-style parallel processing:
- Divides work into chunks (1 per CPU core)
- Each process spawns 6 threads to burn through its chunk
- Immediate DB writes with WAL mode (thread-safe)
- Progress tracked via backfill_jobs table (atomic increments)
Args:
db_path: Path to SQLite database
domain_filter: Only process pages matching this domain (e.g., 'example.com')
trim_wrapper: Strip nav/header/footer/logo before conversion
quiet: Disable progress bar
"""
import aiosqlite
import sqlite3
import multiprocessing
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timezone
import time
num_cpus = multiprocessing.cpu_count()
threads_per_process = 6
logger.info(f"ETL mode: {num_cpus} processes x {threads_per_process} threads = {num_cpus * threads_per_process} workers")
if domain_filter:
pattern = f"%://{domain_filter}%" if '://' not in domain_filter else f"%{domain_filter}%"
logger.info(f"Backfilling markdown for domain: {domain_filter}")
else:
pattern = None
logger.info("Backfilling markdown for ALL pages")
if trim_wrapper:
logger.info("Trim wrapper enabled")
# Setup: ensure tables/columns exist, create job record, get pages
async with aiosqlite.connect(db_path) as db:
await db.execute("PRAGMA journal_mode=WAL")
# Ensure backfill_jobs table exists
await db.execute("""
CREATE TABLE IF NOT EXISTS backfill_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_type TEXT NOT NULL,
domain_filter TEXT,
status TEXT DEFAULT 'running',
total_records INTEGER DEFAULT 0,
processed_records INTEGER DEFAULT 0,
error_count INTEGER DEFAULT 0,
started_at TEXT NOT NULL,
completed_at TEXT
)
""")
# Ensure page columns exist
for col in ['description', 'keywords']:
try:
await db.execute(f"ALTER TABLE pages ADD COLUMN {col} TEXT")
except Exception:
pass
# Get pages to process
if pattern:
cursor = await db.execute(
"SELECT id, uri, raw_html FROM pages WHERE raw_html IS NOT NULL AND uri LIKE ?",
(pattern,)
)
else:
cursor = await db.execute(
"SELECT id, uri, raw_html FROM pages WHERE raw_html IS NOT NULL"
)
rows = await cursor.fetchall()
total = len(rows)
logger.info(f"Found {total} pages to process")
# Create job record
now = datetime.now(timezone.utc).isoformat()
cursor = await db.execute(
"INSERT INTO backfill_jobs (job_type, domain_filter, status, total_records, started_at) VALUES (?, ?, 'running', ?, ?)",
('markdown', domain_filter, total, now)
)
job_id = cursor.lastrowid
await db.commit()
logger.info(f"Created backfill job {job_id}")
# Convert to list of tuples
all_pages = [(row[0], row[1], row[2]) for row in rows if row[2]]
# Divide into exactly num_cpus chunks
chunks = []
chunk_size = len(all_pages) // num_cpus if num_cpus > 0 else len(all_pages)
for i in range(num_cpus):
start = i * chunk_size
end = (i + 1) * chunk_size if i < num_cpus - 1 else len(all_pages)
chunks.append(all_pages[start:end])
logger.info(f"Split into {len(chunks)} chunks of ~{chunk_size} pages each")
# Process chunks in parallel, poll job record for progress
with ProcessPoolExecutor(max_workers=num_cpus) as executor:
chunk_args = [(chunk, db_path, trim_wrapper, threads_per_process, job_id) for chunk in chunks]
futures = [executor.submit(_process_chunk, args) for args in chunk_args]
# Poll backfill_jobs table for progress
conn = sqlite3.connect(db_path, timeout=60.0)
conn.execute("PRAGMA journal_mode=WAL")
with tqdm(total=len(all_pages), desc="Pages", unit="page", disable=quiet, smoothing=0.1) as pbar:
last_count = 0
while True:
cursor = conn.execute(
"SELECT processed_records + error_count FROM backfill_jobs WHERE id = ?",
(job_id,)
)
current = cursor.fetchone()[0]
if current > last_count:
pbar.update(current - last_count)
last_count = current
if all(f.done() for f in futures):
# Final count
cursor = conn.execute(
"SELECT processed_records + error_count FROM backfill_jobs WHERE id = ?",
(job_id,)
)
current = cursor.fetchone()[0]
if current > last_count:
pbar.update(current - last_count)
break
time.sleep(0.05)
conn.close()
# Collect results
total_updated = 0
total_errors = 0
for f in futures:
result = f.result()
total_updated += result['updated']
total_errors += result['errors']
# Mark job completed
async with aiosqlite.connect(db_path) as db:
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"UPDATE backfill_jobs SET status = 'completed', completed_at = ? WHERE id = ?",
(now, job_id)
)
await db.commit()
logger.info(f"Backfill complete: {total_updated} updated, {total_errors} errors")
async def backfill_screenshots(
db_path: str,
vault_path: str = "vault",
domain_filter: str = None,
delete_old: bool = True,
fast_mode: bool = False,
quiet: bool = False,
):
"""Re-capture screenshots as JPEG to replace old PNGs.
Args:
db_path: Path to SQLite database
vault_path: Path to vault directory
domain_filter: Only process pages matching this domain
delete_old: Delete old PNG files after successful JPEG capture
fast_mode: Skip delay between captures (for sites without robots.txt)
quiet: Disable progress bar
"""
from screenshot import ScreenshotCapture, ScreenshotConfig
from storage import ImageVault
from database import Database, Media, MediaSource, Page
from sqlalchemy import select, update
from datetime import datetime, timezone
import time
# Load completed URIs from unified state file
completed_uris = set()
state_path = get_state_file_path(domain_filter) if domain_filter else None
if state_path and state_path.exists():
try:
full_state = json.loads(state_path.read_text())
completed_uris = set(full_state.get('backfill_screenshots', []))
if completed_uris:
logger.info(f"Resuming: {len(completed_uris)} already backfilled")
except Exception as e:
logger.warning(f"Could not load state: {e}")
config = ScreenshotConfig(enabled=True, full_page=True)
capture = ScreenshotCapture(config)
vault = ImageVault(vault_path)
await vault.init()
db = Database(db_path)
await db.init()
async with db.session() as session:
# Find all screenshot records using ORM, excluding already-completed URIs
if domain_filter:
pattern = f"%://{domain_filter}%" if '://' not in domain_filter else f"%{domain_filter}%"
stmt = (
select(MediaSource.page_uri, Media.md5_hash)
.join(Media, MediaSource.md5_hash == Media.md5_hash)
.where(Media.media_type == 'screenshot')
.where(MediaSource.page_uri.like(pattern))
.where(MediaSource.page_uri.notin_(completed_uris) if completed_uris else True)
.distinct()
)
logger.info(f"Backfilling screenshots for domain: {domain_filter}")
else:
stmt = (
select(MediaSource.page_uri, Media.md5_hash)
.join(Media, MediaSource.md5_hash == Media.md5_hash)
.where(Media.media_type == 'screenshot')
.where(MediaSource.page_uri.notin_(completed_uris) if completed_uris else True)
.distinct()
)
logger.info("Backfilling screenshots for ALL pages")
result = await session.execute(stmt)
rows = result.fetchall()
to_process = len(rows)
skipped_count = len(completed_uris)
total = to_process + skipped_count
logger.info(f"Found {to_process} to process ({skipped_count} already completed)")
domain_last_fetched = {} # Track last fetch time per domain
crawl_delay = 2.0 # Default crawl delay in seconds
# Concurrent workers in fast mode (2x CPU count since screenshot is I/O bound)
import multiprocessing
max_workers = multiprocessing.cpu_count() * 2 if fast_mode else 1
semaphore = asyncio.Semaphore(max_workers)
if fast_mode and max_workers > 1:
logger.info(f"Fast mode: {max_workers} concurrent workers")
pbar = tqdm(total=total, initial=skipped_count, desc="Screenshots", unit="pages", disable=quiet, smoothing=0.1)
# Thread-safe counters
stats_lock = asyncio.Lock()
stats = {'captured': 0, 'failed': 0, 'bytes_saved': 0}
async def capture_one(page_uri: str, old_hash: str):
"""Capture screenshot for one page. Returns (success, result_data) or (False, None)."""
async with semaphore:
try:
# Get content length for dynamic delay
async with db.session() as local_session:
from sqlalchemy import func as sqlfunc
result = await local_session.execute(
select(sqlfunc.length(Page.raw_html)).where(Page.uri == page_uri)
)
content_length = result.scalar() or 0
# Capture screenshot
result = await capture.capture(page_uri, content_length=content_length)
if not result:
return False, None
return True, (page_uri, old_hash, result)
except Exception as e:
logger.debug(f"Capture error {page_uri}: {e}")
return False, None
async def process_result(success, data):
"""Process a captured screenshot - store and update DB."""
nonlocal completed_uris
if not success or not data:
async with stats_lock:
stats['failed'] += 1
pbar.update(1)
return
page_uri, old_hash, result = data
domain = Uri(page_uri).hostname.lower()
try:
chunks = result if isinstance(result, list) else [result]
old_path = Path(vault_path) / old_hash[:2] / f"{old_hash}.png"
old_size = old_path.stat().st_size if old_path.exists() else 0
# Store chunks
total_new_size = 0
for i, chunk in enumerate(chunks):
new_hash = chunk['md5_hash']
new_data = chunk['data']
new_ext = chunk.get('format', 'jpg')
total_new_size += len(new_data)
if not await vault.exists(new_hash):
await vault.store(new_hash, new_data, new_ext)
# Symlink
parsed = Uri(page_uri)
url_path = parsed.path.lstrip('/') or 'index'
suffix = f'_{i}' if len(chunks) > 1 else ''
new_symlink = Path(vault_path) / 'linkpeek_vault' / domain / (url_path.replace('/', '_') + f'{suffix}.{new_ext}')
new_symlink.parent.mkdir(parents=True, exist_ok=True)
hash_vault_path = Path(vault_path) / new_hash[:2] / f"{new_hash}.{new_ext}"
rel_path = os.path.relpath(hash_vault_path, new_symlink.parent)
if not new_symlink.exists():
new_symlink.symlink_to(rel_path)
# Delete old
if delete_old and old_path.exists():
old_path.unlink()
async with stats_lock:
stats['bytes_saved'] += old_size - total_new_size
# Remove old symlink
parsed = Uri(page_uri)
url_path = parsed.path.lstrip('/') or 'index'
old_symlink = Path(vault_path) / 'linkpeek_vault' / domain / (url_path.replace('/', '_') + '.png')
if old_symlink.exists():
old_symlink.unlink()
# Update database
new_hash = chunks[0]['md5_hash']
new_ext = chunks[0].get('format', 'jpg')
new_mime = chunks[0].get('mime_type', 'image/jpeg')
new_size = total_new_size
async with db.session() as local_session:
from sqlalchemy import delete as sqldelete
old_media_result = await local_session.execute(
select(Media).where(Media.md5_hash == old_hash)
)
old_media = old_media_result.scalar_one_or_none()
if old_media and old_hash != new_hash:
new_media = Media(
md5_hash=new_hash,
media_type='screenshot',
mime_type=new_mime,
file_size=new_size,
keywords=old_media.keywords,
alt_text=old_media.alt_text,
title=old_media.title,
first_seen_at=datetime.now(timezone.utc).isoformat(),
analysis_status=old_media.analysis_status,
analysis_result=old_media.analysis_result,
)
local_session.add(new_media)
await local_session.flush()
await local_session.execute(
update(MediaSource).where(MediaSource.md5_hash == old_hash).values(md5_hash=new_hash)
)
await local_session.execute(sqldelete(Media).where(Media.md5_hash == old_hash))
elif old_media:
old_media.mime_type = new_mime
old_media.file_size = new_size
old_media.first_seen_at = datetime.now(timezone.utc).isoformat()
await local_session.commit()
async with stats_lock:
stats['captured'] += 1
completed_uris.add(page_uri)
# Save state
if state_path:
async with stats_lock:
try:
full_state = json.loads(state_path.read_text()) if state_path.exists() else {}
except Exception:
full_state = {}
full_state['backfill_screenshots'] = list(completed_uris)
state_path.write_text(json.dumps(full_state, indent=2))
pbar.update(1)
except Exception as e:
err_msg = str(e).split('\n')[0][:60]
logger.warning(f"Failed: {page_uri} - {err_msg}")
async with stats_lock:
stats['failed'] += 1
pbar.update(1)
# Process - each worker flushes immediately when done
if fast_mode and max_workers > 1:
async def capture_and_process(row):
"""Capture and immediately process/flush to DB."""
success, data = await capture_one(row.page_uri, row.md5_hash)
await process_result(success, data)
# Launch all tasks, they'll complete and flush independently
tasks = [asyncio.create_task(capture_and_process(row)) for row in rows]
# Wait for all to complete
await asyncio.gather(*tasks)
else:
# Sequential mode with crawl delay
for row in rows:
if not fast_mode:
domain = Uri(row.page_uri).hostname.lower()
last_fetched = domain_last_fetched.get(domain, 0)
elapsed = time.time() - last_fetched
if elapsed < crawl_delay:
await asyncio.sleep(crawl_delay - elapsed)
domain_last_fetched[domain] = time.time()
success, data = await capture_one(row.page_uri, row.md5_hash)
await process_result(success, data)
pbar.close()
logger.info(f"Backfill complete: {stats['captured']} captured, {skipped_count} skipped, {stats['failed']} failed")
if stats['bytes_saved'] > 0:
logger.info(f"Space saved: {stats['bytes_saved'] / 1024 / 1024:.1f} MB")
async def main():
parser = argparse.ArgumentParser(
description="neopig - Neo Python Image Grabber",
epilog="Based on pig.py by Russell Ballestrini"
)
parser.add_argument(
"targets",
nargs="*",
help="Target URI(s) to crawl (e.g., https://example.com https://other.com)"
)
parser.add_argument(
"-k", "--keywords",
nargs="*",
default=[],
help="Keywords to tag media with (e.g., -k 'rick' 'morty')"
)
parser.add_argument(
"-m", "--mode",
choices=["text", "images", "videos", "media", "all"],
default="images",
help="Crawl mode (default: images)"
)
parser.add_argument(
"-d", "--depth",
type=int,
default=-1,
help="Crawl depth (-1 = unlimited, default: -1)"
)
parser.add_argument(
"-p", "--max-pages",
type=int,
default=-1,
help="Maximum pages to crawl (-1 = unlimited, default: -1)"
)
parser.add_argument(
"--db",
default="data/neopig.db",
help="Database path (default: data/neopig.db)"
)
parser.add_argument(
"--vault",
default="data/vault",
help="Vault storage path (default: data/vault)"
)
parser.add_argument(
"--no-download",
action="store_true",
help="Don't download media, just index URLs"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Verbose output"
)
# Screenshot options (all off by default)
parser.add_argument(
"--screenshot",
action="store_true",
help="Enable page screenshots (requires uri2png)"
)
parser.add_argument(
"--screenshot-width",
type=int,
default=1280,
help="Screenshot viewport width in pixels (default: 1280)"
)
parser.add_argument(
"--screenshot-height",
type=int,
default=1024,
help="Screenshot viewport height in pixels (default: 1024)"
)
parser.add_argument(
"--screenshot-delay",
type=int,
default=1000,
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 (rotates state file; backfills preserve crawl state)"
)
parser.add_argument(
"--fast",
action="store_true",
help="Fast mode: no crawl delay (use for sites without robots.txt)"
)
parser.add_argument(
"--backfill-markdown",
metavar="DOMAIN",
help="Re-process stored HTML for DOMAIN to regenerate markdown with absolute URLs (e.g., example.com)"
)
parser.add_argument(
"--trim-wrapper",
action="store_true",
help="With --backfill-markdown: strip nav/header/footer/logo before conversion"
)
parser.add_argument(
"-q", "--quiet",
action="store_true",
help="Disable progress bars"
)
parser.add_argument(
"--backfill-screenshots",
metavar="DOMAIN",
help="Re-capture screenshots as JPEG for DOMAIN (e.g., example.com)"
)
parser.add_argument(
"--keep-old-screenshots",
action="store_true",
help="With --backfill-screenshots: keep old PNG files instead of deleting them"
)
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()
# Create data directory if needed
from pathlib import Path
db_dir = Path(args.db).parent
if db_dir and str(db_dir) != '.':
db_dir.mkdir(parents=True, exist_ok=True)
# 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
# Handle --backfill-markdown
if args.backfill_markdown:
await backfill_markdown(args.db, domain_filter=args.backfill_markdown, trim_wrapper=args.trim_wrapper, quiet=args.quiet)
return
# Handle --backfill-screenshots
if args.backfill_screenshots:
# Handle --fresh: rotate state but preserve crawl data
if args.fresh:
state_path = get_state_file_path(args.backfill_screenshots)
rotate_state_file(state_path, preserve_keys=['crawl'])
serp_process = None
if args.serve:
import subprocess, sys
serp_script = Path(__file__).parent / 'serp.py'
if serp_script.exists():
serp_cmd = [
sys.executable, str(serp_script),
'--port', str(args.port),
'--db', args.db,
'--vault', args.vault,
]
serp_process = subprocess.Popen(serp_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
logger.info(f"SERP server started at http://localhost:{args.port}")
try:
await backfill_screenshots(
args.db,
vault_path=args.vault,
domain_filter=args.backfill_screenshots,
delete_old=not args.keep_old_screenshots,
fast_mode=args.fast,
quiet=args.quiet,
)
finally:
if serp_process:
serp_process.terminate()
try:
serp_process.wait(timeout=5)
except subprocess.TimeoutExpired:
serp_process.kill()
return
# Handle --serve alone (just run the server)
if args.serve and not args.targets:
import subprocess
import sys
import signal
serp_script = Path(__file__).parent / 'serp.py'
if serp_script.exists():
serp_cmd = [
sys.executable, str(serp_script),
'--port', str(args.port),
'--db', args.db,
'--vault', args.vault,
]
logger.info(f"Starting SERP server at http://localhost:{args.port}")
try:
proc = subprocess.Popen(serp_cmd)
proc.wait() # Block until server exits
except KeyboardInterrupt:
proc.terminate()
proc.wait(timeout=5)
else:
logger.error("serp.py not found")
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 = {
"text": CrawlMode.TEXT,
"images": CrawlMode.IMAGES,
"videos": CrawlMode.VIDEOS,
"media": CrawlMode.MEDIA,
"all": CrawlMode.ALL,
}
mode = mode_map[args.mode]
# Create screenshot config from args
screenshot_config = ScreenshotConfig(
enabled=args.screenshot,
width=args.screenshot_width,
height=args.screenshot_height,
delay=args.screenshot_delay,
engine=args.screenshot_engine,
)
if args.screenshot:
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,
trim_wrapper=args.trim_wrapper,
)
await pig.init()
if args.fast:
logger.info("Fast mode: no crawl delay (ignoring robots.txt)")
# 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/screenshots from DB (source of truth)
crawled_media = await pig.db.get_crawled_media_uris()
crawled_screenshots = await pig.db.get_crawled_screenshot_uris()
if crawled_media or crawled_screenshots:
logger.info(f"Resuming: {len(crawled_media)} media, {len(crawled_screenshots)} screenshots in database")
pig.seen_media = crawled_media
pig.seen_screenshots = crawled_screenshots
# Start SERP server if requested
serp_process = None
if args.serve:
import subprocess
import sys
serp_script = Path(__file__).parent / 'serp.py'
if serp_script.exists():
serp_cmd = [
sys.executable, str(serp_script),
'--port', str(args.port),
'--db', args.db,
'--vault', args.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:
# Crawl all targets concurrently
async def crawl_target(target: str):
logger.info(f"=== Starting crawl: {target} ===")
return await pig.crawl(
target_uri=target,
keywords=args.keywords,
mode=mode,
depth=args.depth,
max_pages=args.max_pages,
download_media=not args.no_download,
)
await asyncio.gather(*[crawl_target(t) for t in args.targets])
# Keep SERP server running after crawl
if serp_process:
logger.info(f"Crawl complete. SERP server still running at http://localhost:{args.port}")
logger.info("Press Ctrl+C to stop...")
try:
serp_process.wait()
except KeyboardInterrupt:
pass
finally:
# Cleanup SERP server
if serp_process and serp_process.poll() is None:
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())