ETL-style parallel markdown backfill with real-time progress

- ProcessPoolExecutor: 1 process per CPU core (bypasses GIL)
- ThreadPoolExecutor: 6 threads per process for I/O throughput
- Thread-local SQLite connections with WAL mode
- Manager().Value() for cross-process progress counter
- Real-time tqdm updates polling shared counter every 50ms
- ~19 pages/sec on 4-core system (4887 pages in 2 min)
This commit is contained in:
Russell Ballestrini 2025-12-30 14:22:29 -05:00
parent 293ed64a3b
commit 1346590d39
5 changed files with 840 additions and 864 deletions

View file

@ -8,6 +8,7 @@ Stores:
- Pages for full-text search
"""
import hashlib
import json
import logging
from datetime import datetime, timezone
@ -105,8 +106,11 @@ class Page(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
uri = Column(Text, nullable=False, unique=True)
uri_hash = Column(String(32), unique=True) # MD5 of URI for clean URLs
path = Column(Text)
title = Column(Text)
description = Column(Text) # Meta description
keywords = Column(Text) # JSON array of keywords
content = Column(Text)
markdown = Column(Text)
raw_html = Column(Text)
@ -117,6 +121,7 @@ class Page(Base):
__table_args__ = (
Index('idx_pages_uri', 'uri'),
Index('idx_pages_uri_hash', 'uri_hash'),
)
@ -163,39 +168,75 @@ class Database:
async with self._engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Add new columns to existing databases (ignore if already exist)
for col in ['description', 'keywords']:
try:
await conn.execute(text(f"ALTER TABLE pages ADD COLUMN {col} TEXT"))
except Exception:
pass # Column already exists
# Add uri_hash column for clean URLs
try:
await conn.execute(text("ALTER TABLE pages ADD COLUMN uri_hash TEXT"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_pages_uri_hash ON pages(uri_hash)"))
except Exception:
pass # Column already exists
# Create FTS5 virtual table (SQLAlchemy doesn't handle virtual tables)
# Includes description and keywords for better search scoring
# Check if FTS table needs rebuilding (old schema didn't have description/keywords)
try:
result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE name='pages_fts'"))
row = result.fetchone()
if row and 'description' not in (row[0] or ''):
# Old schema - drop and recreate
await conn.execute(text("DROP TABLE IF EXISTS pages_fts"))
logger.info("Rebuilding FTS5 index with new schema")
except Exception:
pass
await conn.execute(text("""
CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
title, content, uri, path,
title, description, keywords, content, uri, path,
content='pages',
content_rowid='id'
)
"""))
# FTS triggers
# FTS triggers - drop and recreate to ensure they match current schema
await conn.execute(text("DROP TRIGGER IF EXISTS pages_ai"))
await conn.execute(text("DROP TRIGGER IF EXISTS pages_ad"))
await conn.execute(text("DROP TRIGGER IF EXISTS pages_au"))
await conn.execute(text("""
CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
INSERT INTO pages_fts(rowid, title, content, uri, path)
VALUES (new.id, new.title, new.content, new.uri, new.path);
CREATE TRIGGER pages_ai AFTER INSERT ON pages BEGIN
INSERT INTO pages_fts(rowid, title, description, keywords, content, uri, path)
VALUES (new.id, new.title, new.description, new.keywords, new.content, new.uri, new.path);
END
"""))
await conn.execute(text("""
CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
INSERT INTO pages_fts(pages_fts, rowid, title, content, uri, path)
VALUES ('delete', old.id, old.title, old.content, old.uri, old.path);
CREATE TRIGGER pages_ad AFTER DELETE ON pages BEGIN
INSERT INTO pages_fts(pages_fts, rowid, title, description, keywords, content, uri, path)
VALUES ('delete', old.id, old.title, old.description, old.keywords, old.content, old.uri, old.path);
END
"""))
await conn.execute(text("""
CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
INSERT INTO pages_fts(pages_fts, rowid, title, content, uri, path)
VALUES ('delete', old.id, old.title, old.content, old.uri, old.path);
INSERT INTO pages_fts(rowid, title, content, uri, path)
VALUES (new.id, new.title, new.content, new.uri, new.path);
CREATE TRIGGER pages_au AFTER UPDATE ON pages BEGIN
INSERT INTO pages_fts(pages_fts, rowid, title, description, keywords, content, uri, path)
VALUES ('delete', old.id, old.title, old.description, old.keywords, old.content, old.uri, old.path);
INSERT INTO pages_fts(rowid, title, description, keywords, content, uri, path)
VALUES (new.id, new.title, new.description, new.keywords, new.content, new.uri, new.path);
END
"""))
# Rebuild FTS index from existing pages (idempotent - FTS5 handles dupes)
await conn.execute(text("""
INSERT OR IGNORE INTO pages_fts(rowid, title, description, keywords, content, uri, path)
SELECT id, title, description, keywords, content, uri, path FROM pages
"""))
self._initialized = True
logger.info(f"Database initialized: {self.db_path}")
@ -412,10 +453,12 @@ class Database:
crawl_job_id: int = None
) -> None:
"""Store a page for full-text search."""
uri_hash = hashlib.md5(uri.encode()).hexdigest()
async with self.session() as session:
# Use SQLite upsert (INSERT OR REPLACE)
stmt = sqlite_insert(Page).values(
uri=uri,
uri_hash=uri_hash,
path=path,
title=title,
content=content[:100000],
@ -428,6 +471,7 @@ class Database:
stmt = stmt.on_conflict_do_update(
index_elements=['uri'],
set_={
'uri_hash': stmt.excluded.uri_hash,
'path': stmt.excluded.path,
'title': stmt.excluded.title,
'content': stmt.excluded.content,
@ -453,7 +497,7 @@ class Database:
fts_query = ' '.join(f'"{word}"*' for word in query.split())
result = await session.execute(
text("""
SELECT p.uri, p.path, p.title,
SELECT p.uri, p.uri_hash, p.path, p.title,
snippet(pages_fts, 1, '<mark>', '</mark>', '...', 40) as snippet
FROM pages_fts
JOIN pages p ON pages_fts.rowid = p.id
@ -470,7 +514,7 @@ class Database:
if not results:
like_q = f'%{query}%'
stmt = (
select(Page.uri, Page.path, Page.title,
select(Page.uri, Page.uri_hash, Page.path, Page.title,
func.substr(Page.content, 1, 200).label('snippet'))
.where(or_(
Page.title.ilike(like_q),
@ -548,6 +592,27 @@ class Database:
row = result.scalar_one_or_none()
return self._model_to_dict(row) if row else None
async def get_page_by_hash(self, uri_hash: str) -> Optional[Dict[str, Any]]:
"""Get page by URI hash (for clean URLs)."""
async with self.session() as session:
stmt = select(Page).where(Page.uri_hash == uri_hash)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._model_to_dict(row) if row else None
async def backfill_page_hashes(self) -> int:
"""Backfill uri_hash for pages that don't have one. Returns count updated."""
async with self.session() as session:
stmt = select(Page).where(Page.uri_hash == None)
result = await session.execute(stmt)
pages = result.scalars().all()
count = 0
for page in pages:
page.uri_hash = hashlib.md5(page.uri.encode()).hexdigest()
count += 1
await session.commit()
return count
async def get_crawl_jobs(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Get recent crawl jobs."""
async with self.session() as session:

View file

@ -426,8 +426,12 @@ class SmartMarkdownConverter:
src = self._resolve_url(child.get('src', ''))
alt = child.get('alt', '')
if src:
# Images should be block-level, not inline
parts.append(f"\n\n![{alt}]({src})\n\n")
# Emoji images (alt like :smile:) stay inline
if alt.startswith(':') and alt.endswith(':'):
parts.append(f"![{alt}]({src})")
else:
# Regular images are block-level
parts.append(f"\n\n![{alt}]({src})\n\n")
elif tag == 'br':
parts.append('\n')

450
neopig.py
View file

@ -915,24 +915,156 @@ def trim_html_wrapper(html: str) -> str:
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.
"""
import sqlite3
import threading
from concurrent.futures import ThreadPoolExecutor
chunk, db_path, trim_wrapper, threads_per_process, progress_counter = args
# Thread-local storage for DB connections
thread_local = threading.local()
results = {'updated': 0, 'errors': 0}
results_lock = threading.Lock()
def get_conn():
"""Get thread-local DB connection."""
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 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
if error:
with results_lock:
results['errors'] += 1
else:
# Each thread uses its own connection
conn = get_conn()
conn.execute(
"UPDATE pages SET markdown = ?, description = ?, keywords = ? WHERE id = ?",
(markdown, description, keywords_json, page_id)
)
conn.commit()
with results_lock:
results['updated'] += 1
# Update shared progress counter (Manager proxy)
if progress_counter is not None:
progress_counter.value += 1
# Fan out to thread workers within this process
with ThreadPoolExecutor(max_workers=threads_per_process) as thread_executor:
list(thread_executor.map(process_and_write, chunk))
return {'updated': results['updated'], 'errors': results['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)
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 (applied before smart conversion)
trim_wrapper: Strip nav/header/footer/logo before conversion
quiet: Disable progress bar
"""
from html2md import html_to_markdown
import aiosqlite
import multiprocessing
from concurrent.futures import ProcessPoolExecutor
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")
# Ensure WAL mode and columns exist
async with aiosqlite.connect(db_path) as db:
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA journal_mode=WAL")
for col in ['description', 'keywords']:
try:
await db.execute(f"ALTER TABLE pages ADD COLUMN {col} TEXT")
except Exception:
pass
await db.commit()
# Build query with optional domain filter
if domain_filter:
# Match domain in URI
pattern = f"%://{domain_filter}%" if '://' not in domain_filter else f"%{domain_filter}%"
select_sql = "SELECT id, uri, raw_html FROM pages WHERE raw_html IS NOT NULL AND uri LIKE ?"
params = (pattern,)
@ -950,27 +1082,54 @@ async def backfill_markdown(db_path: str, domain_filter: str = None, trim_wrappe
total = len(rows)
logger.info(f"Found {total} pages to process")
updated = 0
errors = 0
for row in tqdm(rows, desc="Markdown", unit="pages", disable=quiet):
if not row['raw_html']:
continue
# Convert to list of tuples
all_pages = [(row[0], row[1], row[2]) for row in rows if row[2]]
try:
raw = row['raw_html']
if trim_wrapper:
raw = trim_html_wrapper(raw)
new_markdown = html_to_markdown(raw, base_url=row['uri'])[:200000]
await db.execute("UPDATE pages SET markdown = ? WHERE id = ?", (new_markdown, row['id']))
updated += 1
if updated % 100 == 0:
await db.commit()
except Exception as e:
errors += 1
logger.debug(f"Error processing {row['uri']}: {e}")
# Divide into exactly num_cpus chunks (last chunk may be slightly larger)
chunks = []
chunk_size = len(all_pages) // num_cpus
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")
await db.commit()
logger.info(f"Backfill complete: {updated} updated, {errors} errors")
# Manager for cross-process shared state
with multiprocessing.Manager() as manager:
# Shared counter for real-time progress across all processes
progress_counter = manager.Value('i', 0)
# Process chunks in parallel with per-page progress
with ProcessPoolExecutor(max_workers=num_cpus) as executor:
chunk_args = [(chunk, db_path, trim_wrapper, threads_per_process, progress_counter) for chunk in chunks]
futures = [executor.submit(_process_chunk, args) for args in chunk_args]
# Poll shared counter for real-time progress
with tqdm(total=len(all_pages), desc="Pages", unit="page", disable=quiet) as pbar:
last_count = 0
while True:
current = progress_counter.value
if current > last_count:
pbar.update(current - last_count)
last_count = current
# Check if all done
if all(f.done() for f in futures):
# Final update
current = progress_counter.value
if current > last_count:
pbar.update(current - last_count)
break
time.sleep(0.05)
# Collect results
total_updated = 0
total_errors = 0
for f in futures:
result = f.result()
total_updated += result['updated']
total_errors += result['errors']
logger.info(f"Backfill complete: {total_updated} updated, {total_errors} errors")
async def backfill_screenshots(
@ -1048,78 +1207,72 @@ async def backfill_screenshots(
total = to_process + skipped_count
logger.info(f"Found {to_process} to process ({skipped_count} already completed)")
captured = 0
failed = 0
bytes_saved = 0
domain_last_fetched = {} # Track last fetch time per domain
crawl_delay = 2.0 # Default crawl delay in seconds
# Concurrent workers in fast mode (based on CPU count)
# Concurrent workers in fast mode (2x CPU count since screenshot is I/O bound)
import multiprocessing
max_workers = multiprocessing.cpu_count() if fast_mode else 1
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)
pbar = tqdm(total=total, initial=skipped_count, desc="Screenshots", unit="pages", disable=quiet, smoothing=0.1)
# Lock for thread-safe updates
state_lock = asyncio.Lock()
# Thread-safe counters
stats_lock = asyncio.Lock()
stats = {'captured': 0, 'failed': 0, 'bytes_saved': 0}
async def process_row(row):
nonlocal completed_uris
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:
page_uri = row.page_uri
old_hash = row.md5_hash
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()
# Log current page (debug level to reduce noise)
logger.debug(f"Capturing: {page_uri}")
try:
# Enforce crawl delay (skip in fast mode)
if not fast_mode:
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()
# Get content length for dynamic delay calculation
from sqlalchemy import func
content_length_result = await session.execute(
select(func.length(Page.raw_html)).where(Page.uri == page_uri)
)
content_length = content_length_result.scalar() or 0
# Capture new screenshot (will be JPEG) with dynamic delay for large pages
result = await capture.capture(page_uri, content_length=content_length)
if not result:
failed += 1
pbar.update(1)
continue
# Normalize to list (oversized images return multiple chunks)
chunks = result if isinstance(result, list) else [result]
# Get old file size for comparison
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 all chunks
# 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')
new_mime = chunk.get('mime_type', 'image/jpeg')
total_new_size += len(new_data)
if not await vault.exists(new_hash):
await vault.store(new_hash, new_data, new_ext)
# Create symlink for each chunk
# Symlink
parsed = Uri(page_uri)
url_path = parsed.path.lstrip('/') or 'index'
suffix = f'_{i}' if len(chunks) > 1 else ''
@ -1130,10 +1283,11 @@ async def backfill_screenshots(
if not new_symlink.exists():
new_symlink.symlink_to(rel_path)
# Delete old PNG file FIRST (before metadata update)
# Delete old
if delete_old and old_path.exists():
old_path.unlink()
bytes_saved += old_size - total_new_size
async with stats_lock:
stats['bytes_saved'] += old_size - total_new_size
# Remove old symlink
parsed = Uri(page_uri)
@ -1142,84 +1296,96 @@ async def backfill_screenshots(
if old_symlink.exists():
old_symlink.unlink()
# Use first chunk for database record
# 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
# Update database using ORM
# md5_hash is PRIMARY KEY, so we need to: insert new -> update refs -> delete old
from sqlalchemy import delete
# Get old record data
old_media_result = await 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:
# Insert new media record
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,
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)
)
session.add(new_media)
await session.flush()
old_media = old_media_result.scalar_one_or_none()
# Update MediaSource references
await session.execute(
update(MediaSource)
.where(MediaSource.md5_hash == old_hash)
.values(md5_hash=new_hash)
)
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()
# Delete old media record
await session.execute(
delete(Media).where(Media.md5_hash == old_hash)
)
elif old_media:
# Same hash, just update metadata
old_media.mime_type = new_mime
old_media.file_size = new_size
old_media.first_seen_at = datetime.now(timezone.utc).isoformat()
captured += 1
# Commit after each capture to preserve progress
await session.commit()
# Save state after each successful capture
if state_path:
async with stats_lock:
stats['captured'] += 1
completed_uris.add(page_uri)
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))
# 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:
# Truncate error message for cleaner logs
err_msg = str(e).split('\n')[0][:60]
logger.warning(f"Failed: {page_uri} - {err_msg}")
failed += 1
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()
await session.commit()
logger.info(f"Backfill complete: {captured} captured, {skipped_count} skipped, {failed} failed")
if bytes_saved > 0:
logger.info(f"Space saved: {bytes_saved / 1024 / 1024:.1f} MB")
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():
@ -1441,6 +1607,34 @@ async def main():
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

View file

@ -262,8 +262,23 @@ class ScreenshotCapture:
)
if not result.success:
# Extract just the first line of error, skip wkhtmltoimage verbosity
error_msg = (result.error or 'unknown error').split('\n')[0][:80]
# Extract meaningful error from wkhtmltoimage output
raw_error = result.error or 'unknown error'
# Filter out wkhtmltoimage progress output (progress bars, loading messages)
lines = raw_error.split('\n')
# Skip lines that are progress bars or loading messages
meaningful = [l.strip() for l in lines if l.strip()
and not l.strip().startswith('[') # Progress bars like [>
and not l.strip().startswith('Loading')
and '%' not in l] # Percentage indicators
# Look for actual error/fail lines first
error_lines = [l for l in meaningful if 'error' in l.lower() or 'fail' in l.lower()]
if error_lines:
error_msg = error_lines[0][:60]
elif meaningful:
error_msg = meaningful[-1][:60]
else:
error_msg = 'capture failed'
logger.warning(f"Screenshot failed: {uri} - {error_msg}")
return None

1132
serp.py

File diff suppressed because it is too large Load diff