pig.py/database.py
Russell Ballestrini 59e7ad5669 Add job purge: delete all traces of a crawl job
Order of operations (critical for data integrity):
1. Get job info and collect hashes/URIs to delete
2. Delete MediaSource records (breaks references)
3. Delete Page records
4. Delete orphan Media records (not referenced by other jobs)
5. Delete orphan media files from vault
6. Delete screenshot files for deleted pages
7. Delete CrawlJob record
8. Delete state files

CLI: python neopig.py --purge-job JOB_ID
API: DELETE /api/crawl/jobs/{job_id}?purge=true (default)
UI: Delete button now purges by default
2026-01-01 16:47:59 -05:00

1139 lines
46 KiB
Python

#!/usr/bin/env python3
"""
SQLAlchemy async database for neopig metadata.
Stores:
- Crawl jobs (target, keywords, timestamps, stats)
- Media records (md5_hash, source URLs, metadata)
- Pages for full-text search
"""
import hashlib
import json
import logging
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Set
from sqlalchemy import (
Column, Integer, String, Text, ForeignKey, Index, UniqueConstraint,
create_engine, event, text, select, update, func, or_, and_
)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
logger = logging.getLogger(__name__)
Base = declarative_base()
# =============================================================================
# Models
# =============================================================================
class CrawlJob(Base):
__tablename__ = 'crawl_jobs'
id = Column(Integer, primary_key=True, autoincrement=True)
target_uri = Column(Text, nullable=False)
keywords = Column(Text) # JSON array
mode = Column(String(50), default='images')
status = Column(String(50), default='running')
started_at = Column(Text, nullable=False)
completed_at = Column(Text)
stats = Column(Text) # JSON object
# Settings for resume
depth = Column(Integer, default=15)
max_pages = Column(Integer, default=-1)
fast = Column(Integer, default=0) # SQLite boolean
screenshots = Column(Integer, default=1) # SQLite boolean
media_sources = relationship('MediaSource', back_populates='crawl_job')
pages = relationship('Page', back_populates='crawl_job')
# Media scoring constants (higher = better quality)
SCORE_SCREENSHOT = 1 # Page screenshot capture
SCORE_OG_IMAGE = 3 # og:image / meta image
SCORE_THUMBNAIL = 5 # Thumbnail / preview image
SCORE_FULL_RES = 10 # Full resolution / canonical image
# Search limit - IT'S OVER 9000!
OVER_9000 = 9000
class Media(Base):
__tablename__ = 'media'
md5_hash = Column(String(32), primary_key=True)
media_type = Column(String(20)) # 'image', 'video', 'audio'
mime_type = Column(String(100))
file_size = Column(Integer)
keywords = Column(Text) # JSON array
alt_text = Column(Text)
title = Column(Text)
score = Column(Integer, default=SCORE_THUMBNAIL) # Quality score
first_seen_at = Column(Text, nullable=False)
last_seen_at = Column(Text) # Updated each time media is encountered
upgraded_at = Column(Text) # When score was upgraded (for live feed)
analysis_status = Column(String(20), default='pending')
analysis_result = Column(Text) # JSON
sources = relationship('MediaSource', back_populates='media')
__table_args__ = (
Index('idx_media_type', 'media_type'),
Index('idx_media_analysis', 'analysis_status'),
Index('idx_media_score', 'score'),
Index('idx_media_upgraded', 'upgraded_at'),
Index('idx_media_last_seen', 'last_seen_at'),
)
class MediaSource(Base):
__tablename__ = 'media_sources'
id = Column(Integer, primary_key=True, autoincrement=True)
md5_hash = Column(String(32), ForeignKey('media.md5_hash'), nullable=False)
media_uri = Column(Text, nullable=False)
page_uri = Column(Text)
page_title = Column(Text)
page_description = Column(Text)
page_keywords = Column(Text)
page_content = Column(Text)
alt_text = Column(Text)
link_text = Column(Text)
detail_page_uri = Column(Text)
detail_title = Column(Text)
detail_content = Column(Text)
searchable_text = Column(Text)
crawl_job_id = Column(Integer, ForeignKey('crawl_jobs.id'))
discovered_at = Column(Text, nullable=False)
media = relationship('Media', back_populates='sources')
crawl_job = relationship('CrawlJob', back_populates='media_sources')
__table_args__ = (
UniqueConstraint('md5_hash', 'media_uri', 'page_uri', name='uq_media_source'),
Index('idx_sources_hash', 'md5_hash'),
Index('idx_sources_job', 'crawl_job_id'),
Index('idx_sources_media_uri', 'media_uri'),
Index('idx_sources_page_uri', 'page_uri'),
)
class Page(Base):
__tablename__ = 'pages'
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)
crawl_job_id = Column(Integer, ForeignKey('crawl_jobs.id'))
crawled_at = Column(Text, nullable=False)
crawl_job = relationship('CrawlJob', back_populates='pages')
__table_args__ = (
Index('idx_pages_uri', 'uri'),
Index('idx_pages_uri_hash', 'uri_hash'),
)
class BackfillJob(Base):
"""Track ETL backfill jobs with real-time metrics."""
__tablename__ = 'backfill_jobs'
id = Column(Integer, primary_key=True, autoincrement=True)
job_type = Column(String(50), nullable=False) # 'markdown', 'screenshots', etc.
domain_filter = Column(Text) # Optional domain filter
status = Column(String(20), default='running') # running, completed, failed
total_records = Column(Integer, default=0)
processed_records = Column(Integer, default=0)
error_count = Column(Integer, default=0)
started_at = Column(Text, nullable=False)
completed_at = Column(Text)
__table_args__ = (
Index('idx_backfill_status', 'status'),
Index('idx_backfill_type', 'job_type'),
)
# =============================================================================
# Database Class
# =============================================================================
class Database:
"""Async SQLAlchemy database for neopig metadata."""
def __init__(self, db_path: str = "neopig.db"):
self.db_path = db_path
self._engine = None
self._session_factory = None
self._initialized = False
async def init(self) -> None:
"""Initialize database schema."""
if self._initialized:
return
# Create async engine with WAL mode for concurrent access
self._engine = create_async_engine(
f"sqlite+aiosqlite:///{self.db_path}",
echo=False,
)
# Enable WAL mode for concurrent reads/writes
@event.listens_for(self._engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=30000") # 30 second timeout
cursor.close()
# Create session factory
self._session_factory = async_sessionmaker(
self._engine,
class_=AsyncSession,
expire_on_commit=False
)
# Create tables
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
# Add score and upgraded_at columns to media table
for col, col_type in [('score', 'INTEGER DEFAULT 5'), ('upgraded_at', 'TEXT')]:
try:
await conn.execute(text(f"ALTER TABLE media ADD COLUMN {col} {col_type}"))
except Exception:
pass # Column already exists
# Create indexes for score columns
try:
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_media_score ON media(score)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_media_upgraded ON media(upgraded_at)"))
except Exception:
pass
# Add last_seen_at column to media table
try:
await conn.execute(text("ALTER TABLE media ADD COLUMN last_seen_at TEXT"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen_at)"))
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, description, keywords, content, uri, path,
content='pages',
content_rowid='id'
)
"""))
# 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 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 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 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}")
def session(self) -> AsyncSession:
"""Get a new async session."""
return self._session_factory()
async def close(self):
"""Close database connections."""
if self._engine:
await self._engine.dispose()
async def create_crawl_job(
self,
target_uri: str,
keywords: List[str] = None,
mode: str = "images",
depth: int = 15,
max_pages: int = -1,
fast: bool = False,
screenshots: bool = True
) -> int:
"""Create a new crawl job and return its ID."""
async with self.session() as session:
job = CrawlJob(
target_uri=target_uri,
keywords=json.dumps(keywords or []),
mode=mode,
started_at=datetime.now(timezone.utc).isoformat(),
depth=depth,
max_pages=max_pages,
fast=1 if fast else 0,
screenshots=1 if screenshots else 0
)
session.add(job)
await session.commit()
return job.id
async def update_crawl_job_stats(self, job_id: int, stats: Dict[str, Any]) -> None:
"""Update stats for a running crawl job (for progress tracking)."""
async with self.session() as session:
stmt = (
update(CrawlJob)
.where(CrawlJob.id == job_id)
.values(stats=json.dumps(stats))
)
await session.execute(stmt)
await session.commit()
async def complete_crawl_job(self, job_id: int, stats: Dict[str, Any]) -> None:
"""Mark a crawl job as complete."""
async with self.session() as session:
stmt = (
update(CrawlJob)
.where(CrawlJob.id == job_id)
.values(
status='completed',
completed_at=datetime.now(timezone.utc).isoformat(),
stats=json.dumps(stats)
)
)
await session.execute(stmt)
await session.commit()
async def pause_crawl_job(self, job_id: int, stats: Dict[str, Any] = None) -> None:
"""Mark a crawl job as paused (can be resumed)."""
async with self.session() as session:
values = {'status': 'paused'}
if stats:
values['stats'] = json.dumps(stats)
stmt = update(CrawlJob).where(CrawlJob.id == job_id).values(**values)
await session.execute(stmt)
await session.commit()
async def delete_crawl_job(self, job_id: int, purge_data: bool = False) -> Dict[str, Any]:
"""Delete a crawl job and optionally all associated data.
Args:
job_id: The job ID to delete
purge_data: If True, also delete associated MediaSource, Pages, and orphan Media
Returns:
Dict with deletion info:
- deleted: True if job was deleted
- target_uri: The job's target URI (for state file deletion)
- orphan_media: List of md5_hashes that are now orphaned (for file deletion)
- page_uris: List of page URIs deleted (for screenshot deletion)
"""
result = {
'deleted': False,
'target_uri': None,
'orphan_media': [],
'page_uris': [],
}
async with self.session() as session:
# Get job info first
job_result = await session.execute(
select(CrawlJob).where(CrawlJob.id == job_id)
)
job = job_result.scalar_one_or_none()
if not job:
return result
result['target_uri'] = job.target_uri
if purge_data:
# Step 1: Get all md5_hashes from MediaSource for this job
hash_result = await session.execute(
select(MediaSource.md5_hash).where(MediaSource.crawl_job_id == job_id).distinct()
)
job_hashes = {row[0] for row in hash_result.fetchall()}
# Step 2: Get all page URIs for this job
page_result = await session.execute(
select(Page.uri).where(Page.crawl_job_id == job_id)
)
result['page_uris'] = [row[0] for row in page_result.fetchall()]
# Step 3: Delete MediaSource records for this job
await session.execute(
MediaSource.__table__.delete().where(MediaSource.crawl_job_id == job_id)
)
# Step 4: Delete Page records for this job
await session.execute(
Page.__table__.delete().where(Page.crawl_job_id == job_id)
)
# Step 5: Find orphan media (no longer referenced by any MediaSource)
for md5_hash in job_hashes:
ref_result = await session.execute(
select(func.count()).select_from(MediaSource).where(MediaSource.md5_hash == md5_hash)
)
ref_count = ref_result.scalar()
if ref_count == 0:
result['orphan_media'].append(md5_hash)
# Delete from Media table
await session.execute(
Media.__table__.delete().where(Media.md5_hash == md5_hash)
)
# Step 6: Delete the job itself
await session.delete(job)
await session.commit()
result['deleted'] = True
return result
async def create_media_record(
self,
md5_hash: str,
media_uri: str,
page_uri: str,
crawl_job_id: int,
media_type: str = "image",
mime_type: str = "",
file_size: int = 0,
page_title: str = "",
page_description: str = "",
page_keywords: str = "",
page_content: str = "",
alt_text: str = "",
link_text: str = "",
detail_page_uri: str = "",
detail_title: str = "",
detail_content: str = "",
searchable_text: str = "",
score: int = SCORE_THUMBNAIL,
) -> None:
"""Create a new media record and add source context."""
now = datetime.now(timezone.utc).isoformat()
async with self.session() as session:
# Insert or update media record - always update last_seen_at
media_stmt = sqlite_insert(Media).values(
md5_hash=md5_hash,
media_type=media_type,
mime_type=mime_type,
file_size=file_size,
score=score,
first_seen_at=now,
last_seen_at=now
).on_conflict_do_update(
index_elements=['md5_hash'],
set_={'last_seen_at': now}
)
await session.execute(media_stmt)
# Insert or ignore source context
source_stmt = sqlite_insert(MediaSource).values(
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=crawl_job_id,
discovered_at=now
).on_conflict_do_nothing()
await session.execute(source_stmt)
await session.commit()
async def upgrade_media_score(
self,
md5_hash: str,
new_score: int
) -> bool:
"""Upgrade a media's score if the new score is higher. Returns True if upgraded."""
now = datetime.now(timezone.utc).isoformat()
async with self.session() as session:
# Only update if new score is higher
stmt = (
update(Media)
.where(Media.md5_hash == md5_hash)
.where(Media.score < new_score)
.values(score=new_score, upgraded_at=now)
)
result = await session.execute(stmt)
await session.commit()
return result.rowcount > 0
async def add_media_source(
self,
md5_hash: str,
media_uri: str,
page_uri: str,
page_title: str = "",
page_description: str = "",
page_keywords: str = "",
page_content: str = "",
alt_text: str = "",
link_text: str = "",
detail_page_uri: str = "",
detail_title: str = "",
detail_content: str = "",
searchable_text: str = "",
crawl_job_id: int = None
) -> None:
"""Add another source context for an existing media hash."""
async with self.session() as session:
stmt = sqlite_insert(MediaSource).values(
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=crawl_job_id,
discovered_at=datetime.now(timezone.utc).isoformat()
).on_conflict_do_nothing()
await session.execute(stmt)
await session.commit()
async def get_pending_analysis(self, limit: int = 100) -> List[Dict[str, Any]]:
"""Get media items pending analysis."""
async with self.session() as session:
stmt = (
select(Media.md5_hash, Media.media_type, Media.mime_type,
Media.keywords, Media.alt_text, Media.title)
.where(Media.analysis_status == 'pending')
.limit(limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def update_analysis(
self,
md5_hash: str,
status: str,
result: Dict[str, Any] = None
) -> None:
"""Update analysis status and result for a media item."""
async with self.session() as session:
stmt = (
update(Media)
.where(Media.md5_hash == md5_hash)
.values(
analysis_status=status,
analysis_result=json.dumps(result) if result else None
)
)
await session.execute(stmt)
await session.commit()
async def get_media_by_keyword(self, keyword: str, limit: int = 100) -> List[Dict[str, Any]]:
"""Search media by keyword."""
async with self.session() as session:
like_pattern = f'%{keyword}%'
stmt = (
select(Media)
.where(or_(
Media.keywords.like(like_pattern),
Media.alt_text.like(like_pattern),
Media.title.like(like_pattern)
))
.limit(limit)
)
result = await session.execute(stmt)
return [
{**dict(row._mapping), 'source_urls': None}
for row in result.fetchall()
]
async def check_media_uri_exists(self, media_uri: str, page_uri: str) -> Optional[str]:
"""Check if a media URI from a specific page has already been crawled."""
async with self.session() as session:
stmt = (
select(MediaSource.md5_hash)
.where(and_(
MediaSource.media_uri == media_uri,
MediaSource.page_uri == page_uri
))
)
result = await session.execute(stmt)
row = result.fetchone()
return row[0] if row else None
async def get_crawled_media_uris(self) -> Dict[str, str]:
"""Get all media URIs that have been crawled, mapped to their md5_hash."""
async with self.session() as session:
stmt = select(MediaSource.media_uri, MediaSource.md5_hash).distinct()
result = await session.execute(stmt)
return {row[0]: row[1] for row in result.fetchall()}
async def store_page(
self,
uri: str,
title: str,
content: str,
path: str = "",
markdown: str = "",
raw_html: str = "",
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],
markdown=markdown,
raw_html=raw_html,
crawl_job_id=crawl_job_id,
crawled_at=datetime.now(timezone.utc).isoformat()
)
# On conflict with uri, update all fields
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,
'markdown': stmt.excluded.markdown,
'raw_html': stmt.excluded.raw_html,
'crawl_job_id': stmt.excluded.crawl_job_id,
'crawled_at': stmt.excluded.crawled_at,
}
)
await session.execute(stmt)
await session.commit()
async def search_pages(self, query: str, limit: int = 50) -> List[Dict[str, Any]]:
"""Search pages using FTS5 with LIKE fallback.
Note: FTS5 virtual table requires raw SQL (allowed per CLAUDE.md).
"""
async with self.session() as session:
results = []
# Try FTS5 (raw SQL required for virtual table)
try:
fts_query = ' '.join(f'"{word}"*' for word in query.split())
result = await session.execute(
text("""
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
WHERE pages_fts MATCH :query
ORDER BY rank LIMIT :limit
"""),
{'query': fts_query, 'limit': limit}
)
results = [dict(row._mapping) for row in result.fetchall()]
except Exception:
pass
# Fallback to ORM LIKE
if not results:
like_q = f'%{query}%'
stmt = (
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),
Page.content.ilike(like_q)
))
.limit(limit)
)
result = await session.execute(stmt)
results = [dict(row._mapping) for row in result.fetchall()]
return results
async def get_stats(self) -> Dict[str, Any]:
"""Get database statistics."""
async with self.session() as session:
stats = {}
result = await session.execute(select(func.count()).select_from(Media))
stats['total_media'] = result.scalar()
result = await session.execute(
select(Media.media_type, func.count())
.group_by(Media.media_type)
)
stats['by_type'] = {row[0]: row[1] for row in result.fetchall()}
result = await session.execute(
select(Media.analysis_status, func.count())
.group_by(Media.analysis_status)
)
stats['by_analysis'] = {row[0]: row[1] for row in result.fetchall()}
result = await session.execute(select(func.count()).select_from(MediaSource))
stats['total_sources'] = result.scalar()
result = await session.execute(select(func.count()).select_from(CrawlJob))
stats['total_jobs'] = result.scalar()
return stats
# =============================================================================
# Query methods for SERP
# =============================================================================
def _model_to_dict(self, obj) -> Dict[str, Any]:
"""Convert a SQLAlchemy model instance to dict."""
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
async def get_media_by_hash(self, md5_hash: str) -> Optional[Dict[str, Any]]:
"""Get media record by MD5 hash."""
async with self.session() as session:
stmt = select(Media).where(Media.md5_hash == md5_hash)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._model_to_dict(row) if row else None
async def get_media_sources(self, md5_hash: str) -> List[Dict[str, Any]]:
"""Get all sources for a media item."""
async with self.session() as session:
stmt = (
select(MediaSource.media_uri, MediaSource.page_uri, MediaSource.page_title,
MediaSource.page_description, MediaSource.page_content, MediaSource.alt_text,
MediaSource.link_text, MediaSource.detail_page_uri, MediaSource.detail_title,
MediaSource.detail_content, MediaSource.discovered_at)
.where(MediaSource.md5_hash == md5_hash)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def get_page_by_uri(self, uri: str) -> Optional[Dict[str, Any]]:
"""Get page by URI."""
async with self.session() as session:
stmt = select(Page).where(Page.uri == uri)
result = await session.execute(stmt)
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 lookup_pages_by_uris(self, uris: List[str]) -> Dict[str, str]:
"""Batch lookup page uri_hashes by URIs. Returns {uri: uri_hash}."""
if not uris:
return {}
async with self.session() as session:
stmt = select(Page.uri, Page.uri_hash).where(Page.uri.in_(uris))
result = await session.execute(stmt)
return {row[0]: row[1] for row in result.fetchall() if row[1]}
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:
stmt = (
select(CrawlJob)
.order_by(CrawlJob.started_at.desc())
.limit(limit)
)
result = await session.execute(stmt)
return [self._model_to_dict(row) for row in result.scalars()]
async def get_crawl_job(self, job_id: int) -> Optional[Dict[str, Any]]:
"""Get a specific crawl job."""
async with self.session() as session:
stmt = select(CrawlJob).where(CrawlJob.id == job_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._model_to_dict(row) if row else None
async def get_backfill_jobs(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Get recent backfill jobs."""
async with self.session() as session:
stmt = (
select(BackfillJob)
.order_by(BackfillJob.started_at.desc())
.limit(limit)
)
result = await session.execute(stmt)
return [self._model_to_dict(row) for row in result.scalars()]
async def search_media_advanced(
self,
q: str = None,
media_type: str = None,
limit: int = OVER_9000,
offset: int = 0
) -> List[Dict[str, Any]]:
"""Search media with filters.
Searches across:
- Media alt_text, title
- MediaSource searchable_text (contains page context at crawl time)
- MediaSource page_title
- Page content/title ONLY for non-hub pages (excludes homepages/archives)
Hub pages (/, /index, pages with many media) are excluded from
page-content matching to avoid pulling ALL media from a homepage
that happens to mention the search term.
"""
async with self.session() as session:
# Subquery to count media per page (detect hub pages)
media_count_subq = (
select(MediaSource.page_uri, func.count(MediaSource.md5_hash).label('media_count'))
.group_by(MediaSource.page_uri)
.subquery()
)
# First, find matching md5_hashes (deduplicated)
hash_conditions = []
if q:
q_like = f'%{q}%'
# Direct media matches (always included)
direct_match = or_(
Media.alt_text.ilike(q_like),
Media.title.ilike(q_like),
MediaSource.searchable_text.ilike(q_like),
MediaSource.page_title.ilike(q_like),
)
# Page content match - only for non-hub pages
# Hub = homepage (path / or empty) or page with >20 media items
is_hub_page = or_(
Page.path == '/',
Page.path == '',
Page.path.is_(None),
Page.path.like('/index%'),
Page.path.like('/page/%'),
Page.path.like('/tag/%'),
Page.path.like('/category/%'),
Page.path.like('/archive%'),
media_count_subq.c.media_count > 20
)
page_content_match = and_(
or_(Page.title.ilike(q_like), Page.content.ilike(q_like)),
~is_hub_page # NOT a hub page
)
hash_conditions.append(or_(direct_match, page_content_match))
if media_type:
hash_conditions.append(Media.media_type == media_type)
# Subquery to get unique matching hashes
matching_hashes = (
select(Media.md5_hash)
.outerjoin(MediaSource, Media.md5_hash == MediaSource.md5_hash)
.outerjoin(Page, MediaSource.page_uri == Page.uri)
.outerjoin(media_count_subq, MediaSource.page_uri == media_count_subq.c.page_uri)
)
if hash_conditions:
matching_hashes = matching_hashes.where(and_(*hash_conditions))
matching_hashes = matching_hashes.distinct().subquery()
# Main query - join Media with first source for display
first_source = (
select(
MediaSource.md5_hash,
func.min(MediaSource.page_uri).label('page_uri'),
func.min(MediaSource.page_title).label('page_title')
)
.group_by(MediaSource.md5_hash)
.subquery()
)
stmt = (
select(Media.md5_hash, Media.media_type, Media.mime_type, Media.file_size,
Media.alt_text, Media.title, Media.first_seen_at,
Media.score, Media.upgraded_at, Media.keywords,
first_source.c.page_uri, first_source.c.page_title)
.join(matching_hashes, Media.md5_hash == matching_hashes.c.md5_hash)
.outerjoin(first_source, Media.md5_hash == first_source.c.md5_hash)
.order_by(
Media.score.desc().nulls_last(),
Media.first_seen_at.desc()
)
.limit(limit).offset(offset)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def get_recently_upgraded(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Get media that was recently upgraded (higher score version found)."""
async with self.session() as session:
stmt = (
select(Media.md5_hash, Media.media_type, Media.mime_type, Media.file_size,
Media.alt_text, Media.title, Media.first_seen_at,
Media.score, Media.upgraded_at,
MediaSource.page_uri, MediaSource.page_title)
.outerjoin(MediaSource, Media.md5_hash == MediaSource.md5_hash)
.where(Media.upgraded_at.isnot(None))
.distinct()
.order_by(Media.upgraded_at.desc())
.limit(limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def get_domains_with_pages(self) -> List[tuple]:
"""Get domains that have pages with raw_html.
Note: Uses raw SQL for SQLite-specific substr/instr functions.
"""
async with self.session() as session:
# Raw SQL needed for SQLite string functions
result = await session.execute(
text("""SELECT DISTINCT
substr(uri, instr(uri, '://') + 3,
instr(substr(uri, instr(uri, '://') + 3), '/') - 1) as domain,
COUNT(*) as cnt
FROM pages WHERE raw_html IS NOT NULL
GROUP BY domain""")
)
return [(row[0], row[1]) for row in result.fetchall()]
async def lookup_media_by_uris(self, uris: List[str]) -> Dict[str, str]:
"""Look up media hashes by URIs. Returns {uri: md5_hash}."""
if not uris:
return {}
async with self.session() as session:
stmt = (
select(MediaSource.media_uri, MediaSource.md5_hash)
.where(MediaSource.media_uri.in_(uris))
)
result = await session.execute(stmt)
return {row[0]: row[1] for row in result.fetchall()}
async def lookup_media_by_filename(self, filename: str) -> Optional[tuple]:
"""Look up media by filename pattern. Returns (media_uri, md5_hash) or None."""
async with self.session() as session:
stmt = (
select(MediaSource.media_uri, MediaSource.md5_hash)
.where(MediaSource.media_uri.like(f'%{filename}%'))
.limit(1)
)
result = await session.execute(stmt)
row = result.fetchone()
return (row[0], row[1]) if row else None
async def get_page_screenshot(self, page_uri: str, exclude_hash: str = None) -> Optional[str]:
"""Get screenshot hash for a page. Returns md5_hash or None."""
async with self.session() as session:
stmt = (
select(Media.md5_hash)
.join(MediaSource, Media.md5_hash == MediaSource.md5_hash)
.where(and_(
MediaSource.page_uri == page_uri,
Media.media_type == 'screenshot' # Must be a screenshot, not just any PNG
))
.order_by(Media.file_size.desc())
.limit(1)
)
if exclude_hash:
stmt = stmt.where(Media.md5_hash != exclude_hash)
result = await session.execute(stmt)
row = result.fetchone()
return row[0] if row else None
async def get_page_screenshots(self, page_uri: str) -> List[str]:
"""Get all screenshot hashes for a page (for chunked screenshots). Returns list of md5_hash ordered by file size desc."""
async with self.session() as session:
stmt = (
select(Media.md5_hash)
.join(MediaSource, Media.md5_hash == MediaSource.md5_hash)
.where(and_(
MediaSource.page_uri == page_uri,
Media.media_type == 'screenshot'
))
.order_by(Media.file_size.desc())
)
result = await session.execute(stmt)
return [row[0] for row in result.fetchall()]
async def get_recent_media(self, limit: int = 50, media_type: str = None) -> List[Dict[str, Any]]:
"""Get recently discovered media."""
async with self.session() as session:
stmt = (
select(Media.md5_hash, Media.media_type, Media.mime_type, Media.file_size,
Media.alt_text, Media.title, Media.first_seen_at)
.order_by(Media.first_seen_at.desc())
.limit(limit)
)
if media_type:
stmt = stmt.where(Media.media_type == media_type)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def get_pages_by_domain(self, domain: str = None) -> List[Dict[str, Any]]:
"""Get pages, optionally filtered by domain."""
async with self.session() as session:
stmt = select(Page.uri, Page.path, Page.title, Page.raw_html, Page.markdown)
if domain:
stmt = stmt.where(Page.uri.like(f'%{domain}%'))
else:
stmt = stmt.where(Page.raw_html.isnot(None))
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def get_page_media(self, page_uri: str, limit: int = 9999) -> List[Dict[str, Any]]:
"""Get media items from a specific page (excluding screenshots)."""
async with self.session() as session:
stmt = (
select(Media.md5_hash, Media.media_type, Media.alt_text, Media.file_size)
.join(MediaSource, Media.md5_hash == MediaSource.md5_hash)
.where(and_(
MediaSource.page_uri == page_uri,
Media.media_type != 'screenshot'
))
.distinct()
.limit(limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def get_all_media_uri_mappings(self) -> Dict[str, str]:
"""Get all media URI to hash mappings."""
async with self.session() as session:
stmt = select(MediaSource.media_uri, MediaSource.md5_hash)
result = await session.execute(stmt)
return {row[0]: row[1] for row in result.fetchall()}
async def get_crawled_screenshot_uris(self) -> Set[str]:
"""Get page URIs that have been screenshotted (for resume support)."""
async with self.session() as session:
# Screenshots are stored with media_uri = 'screenshot:{page_uri}'
stmt = (
select(MediaSource.page_uri)
.join(Media, MediaSource.md5_hash == Media.md5_hash)
.where(Media.media_type == 'screenshot')
.distinct()
)
result = await session.execute(stmt)
return {row[0] for row in result.fetchall()}
async def get_crawled_page_uris(self) -> Set[str]:
"""Get all page URIs that have been crawled (for resume support)."""
async with self.session() as session:
stmt = select(Page.uri)
result = await session.execute(stmt)
return {row[0] for row in result.fetchall()}
async def get_pages_without_screenshots(self, domain: str = None) -> List[str]:
"""Get page URIs that don't have screenshots yet.
Args:
domain: Optional domain filter (e.g., 'example.com')
Returns:
List of page URIs needing screenshots
"""
async with self.session() as session:
# Subquery: pages that DO have screenshots
screenshotted = (
select(MediaSource.page_uri)
.join(Media, MediaSource.md5_hash == Media.md5_hash)
.where(Media.media_type == 'screenshot')
.distinct()
.scalar_subquery()
)
# Pages that exist but aren't in the screenshotted set
stmt = select(Page.uri).where(Page.uri.notin_(screenshotted))
if domain:
pattern = f"%://{domain}%" if '://' not in domain else f"%{domain}%"
stmt = stmt.where(Page.uri.like(pattern))
result = await session.execute(stmt)
return [row[0] for row in result.fetchall()]