pig.py/neopig/backfill/markdown.py
Russell Ballestrini 7883ae0eba Refactor: Extract neopig package from neopig.py
Phase 1 of module restructuring as outlined in docs/REFACTOR.md:

New neopig/ package modules:
- live.py: Live media queue (get_live_queue, emit_live_media)
- state.py: AppendOnlyStateLog, state file helpers
- html_utils.py: trim_html_wrapper, extract_meta_from_html
- logging.py: TqdmLoggingHandler, job logging functions
- backfill/: markdown and screenshot backfill operations

Package features:
- Lazy import of NeoPig/main from neopig.py via __getattr__
- Full backwards compatibility with existing imports
- 42 new unit tests for extracted modules

Total: 458 tests passing
2026-01-05 17:30:58 -05:00

300 lines
11 KiB
Python

"""
Markdown backfill operations for neopig.
Re-processes stored HTML to regenerate markdown with smart structure detection.
"""
import json
import logging
import multiprocessing
import sqlite3
import threading
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from datetime import datetime, timezone
from tqdm import tqdm
from ..html_utils import trim_html_wrapper, extract_meta_from_html
logger = logging.getLogger(__name__)
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
page_id, uri, raw_html, trim_wrapper_flag = args
try:
description, keywords = extract_meta_from_html(raw_html)
raw = raw_html
if trim_wrapper_flag:
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).
"""
chunk, db_path, trim_wrapper_flag, 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
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()
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_flag))
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 {
'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,
fresh: 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
fresh: Re-process all pages, even those that already have markdown
"""
import aiosqlite
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
base_condition = "raw_html IS NOT NULL"
if not fresh:
base_condition += " AND (markdown IS NULL OR markdown = '')"
logger.info("Incremental mode: only processing pages without markdown")
else:
logger.info("Fresh mode: re-processing all pages")
if pattern:
cursor = await db.execute(
f"SELECT id, uri, raw_html FROM pages WHERE {base_condition} AND uri LIKE ?",
(pattern,)
)
else:
cursor = await db.execute(
f"SELECT id, uri, raw_html FROM pages WHERE {base_condition}"
)
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, processed_records, error_count, started_at) VALUES (?, ?, 'running', ?, 0, 0, ?)",
('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
with tqdm(total=len(all_pages), desc="Pages", unit="page", disable=quiet, smoothing=0.1) as pbar:
last_count = 0
while True:
conn = sqlite3.connect(db_path, timeout=60.0)
conn.execute("PRAGMA journal_mode=WAL")
cursor = conn.execute(
"SELECT processed_records + error_count FROM backfill_jobs WHERE id = ?",
(job_id,)
)
row = cursor.fetchone()
current = row[0] if row and row[0] is not None else 0
conn.close()
if current > last_count:
pbar.update(current - last_count)
last_count = current
if all(f.done() for f in futures):
conn = sqlite3.connect(db_path, timeout=60.0)
cursor = conn.execute(
"SELECT processed_records + error_count FROM backfill_jobs WHERE id = ?",
(job_id,)
)
row = cursor.fetchone()
current = row[0] if row and row[0] is not None else 0
conn.close()
if current > last_count:
pbar.update(current - last_count)
break
time.sleep(0.1)
# 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")