pig.py/serp.py
Russell Ballestrini bc11c516b6 Add filevault system with async wrapper and convert sync ops to async
New files:
- filevault.py: Hash-based file storage with use_pairs option (v1.1.0)
- async_filevault.py: Async wrapper using asyncio.to_thread()
- domain_vault.py: Triple vault system for web archival (HTML, Media, Linkpeek)
- screenshot.py: Async screenshot capture using uri2png
- tests/unit/: Comprehensive test suite (85 tests)

Sync-to-async conversions:
- storage.py: Wrap Path operations in asyncio.to_thread()
- domain_vault.py: Wrap exists(), mkdir(), rglob(), os.walk() in asyncio.to_thread()
- screenshot.py: Wrap read_bytes(), write_bytes(), unlink() in asyncio.to_thread()

All sync filesystem operations now run in thread pool to avoid blocking async loop.
2025-12-22 20:18:20 -05:00

1394 lines
46 KiB
Python

#!/usr/bin/env python3
"""
neopig SERP - Search Engine Results Page
Fast image/video search across hydrated metadata.
Serves files directly from filevault via Caddy with 1GB memory cache.
Search across:
- keywords (crawl tags)
- alt_text (image alt attributes)
- title (media titles)
- analysis_result (Qwen 3 VL descriptions)
- source_page / source_url (origin)
Usage:
python serp.py --port 8000 --vault ./vault --db neopig.db
"""
import argparse
import asyncio
import json
import logging
import mimetypes
from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import FastAPI, Query, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import aiosqlite
import uvicorn
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="neopig", description="Media crawler + SERP + Screenshot service")
# Try to include uri2png screenshot router (optional dependency)
try:
from uri2png import get_screenshot_router
app.include_router(get_screenshot_router())
logger.info("Screenshot router loaded from uri2png")
except ImportError:
logger.warning("uri2png not installed - screenshot endpoints not available")
# Config - set via startup
DB_PATH = "neopig.db"
VAULT_PATH = Path("vault")
# Active crawl jobs (in-memory tracking)
ACTIVE_CRAWLS: Dict[int, Dict[str, Any]] = {}
async def init_database():
"""Initialize database schema if needed."""
async with aiosqlite.connect(DB_PATH) as db:
# Crawl jobs table
await db.execute("""
CREATE TABLE IF NOT EXISTS crawl_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_uri TEXT NOT NULL,
keywords TEXT,
mode TEXT DEFAULT 'images',
status TEXT DEFAULT 'running',
started_at TEXT NOT NULL,
completed_at TEXT,
stats TEXT
)
""")
# Media records table
await db.execute("""
CREATE TABLE IF NOT EXISTS media (
md5_hash TEXT PRIMARY KEY,
media_type TEXT,
mime_type TEXT,
file_size INTEGER,
keywords TEXT,
alt_text TEXT,
title TEXT,
first_seen_at TEXT NOT NULL,
analysis_status TEXT DEFAULT 'pending',
analysis_result TEXT
)
""")
# Media sources table
await db.execute("""
CREATE TABLE IF NOT EXISTS media_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
md5_hash TEXT NOT NULL,
media_uri TEXT NOT NULL,
page_uri TEXT,
page_title TEXT,
page_description TEXT,
page_keywords TEXT,
alt_text TEXT,
link_text TEXT,
crawl_job_id INTEGER,
discovered_at TEXT NOT NULL,
FOREIGN KEY (md5_hash) REFERENCES media(md5_hash),
FOREIGN KEY (crawl_job_id) REFERENCES crawl_jobs(id),
UNIQUE(md5_hash, media_uri, page_uri)
)
""")
# Indexes
await db.execute("CREATE INDEX IF NOT EXISTS idx_media_type ON media(media_type)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_media_analysis ON media(analysis_status)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_hash ON media_sources(md5_hash)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_job ON media_sources(crawl_job_id)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_media_uri ON media_sources(media_uri)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_page_uri ON media_sources(page_uri)")
await db.commit()
logger.info(f"Database initialized: {DB_PATH}")
@app.on_event("startup")
async def startup_event():
"""Initialize database on startup."""
await init_database()
VAULT_PATH.mkdir(parents=True, exist_ok=True)
logger.info(f"Vault directory ready: {VAULT_PATH}")
class CrawlRequest(BaseModel):
"""Request to start a new crawl."""
targets: List[str] = [] # Multiple target URIs
target_uri: str = "" # Deprecated: single target (for backwards compat)
keywords: List[str] = []
mode: str = "images" # text, images, videos, media, all
depth: int = -1 # -1 = unlimited
max_pages: int = -1 # -1 = unlimited
download_media: bool = True
@app.get("/", response_class=HTMLResponse)
async def index():
"""Simple search UI."""
return SEARCH_HTML
@app.get("/crawl", response_class=HTMLResponse)
async def crawl_page():
"""Crawler command page."""
return CRAWL_HTML
# HTML Templates
SEARCH_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>neopig SERP</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: #0a0a0a;
color: #e0e0e0;
}
h1 { color: #ff6b6b; margin-bottom: 5px; }
.subtitle { color: #666; margin-bottom: 20px; }
.search-box {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
input[type="text"] {
flex: 1;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #333;
border-radius: 8px;
background: #1a1a1a;
color: #fff;
}
input[type="text"]:focus {
outline: none;
border-color: #ff6b6b;
}
select {
padding: 12px 16px;
font-size: 16px;
border: 2px solid #333;
border-radius: 8px;
background: #1a1a1a;
color: #fff;
}
button {
padding: 12px 24px;
font-size: 16px;
background: #ff6b6b;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #ff5252; }
.stats {
padding: 10px 15px;
background: #1a1a1a;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
color: #888;
}
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.result {
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s;
}
.result:hover {
transform: scale(1.02);
}
.result img, .result video {
width: 100%;
height: 200px;
object-fit: contain;
background: #1a1a1a;
}
.result-info {
padding: 10px;
}
.result-hash {
font-family: monospace;
font-size: 11px;
color: #666;
word-break: break-all;
}
.result-meta {
font-size: 12px;
color: #888;
margin-top: 5px;
}
.result-keywords {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.tag {
background: #333;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
color: #aaa;
}
.no-results {
text-align: center;
padding: 60px;
color: #666;
}
.media-link {
display: block;
cursor: pointer;
}
.media-link:hover img, .media-link:hover video {
opacity: 0.8;
}
.result-hash {
color: #ff6b6b;
text-decoration: none;
}
.result-hash:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<h1>🐷 neopig</h1>
<p class="subtitle">Search hydrated media</p>
<div class="search-box">
<input type="text" id="query" placeholder="Search keywords, alt text, titles..." autofocus>
<select id="type">
<option value="">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
<button onclick="search()">Search</button>
</div>
<div class="stats" id="stats">Loading stats...</div>
<div class="results" id="results"></div>
<script>
async function loadStats() {
const res = await fetch('/api/stats');
const stats = await res.json();
document.getElementById('stats').innerHTML =
`<strong>${stats.total_media}</strong> media indexed | ` +
`<strong>${stats.by_type?.image || 0}</strong> images | ` +
`<strong>${stats.by_type?.video || 0}</strong> videos | ` +
`<strong>${stats.total_sources}</strong> source URLs`;
}
async function search() {
const query = document.getElementById('query').value;
const type = document.getElementById('type').value;
let url = `/api/search?q=${encodeURIComponent(query)}&limit=100`;
if (type) url += `&type=${type}`;
const res = await fetch(url);
const results = await res.json();
const container = document.getElementById('results');
if (results.length === 0) {
container.innerHTML = '<div class="no-results">No results found</div>';
return;
}
container.innerHTML = results.map(r => {
const isVideo = r.media_type === 'video';
const mediaEl = isVideo
? `<video src="/media/${r.md5_hash}" controls preload="metadata"></video>`
: `<img src="/media/${r.md5_hash}" alt="${r.alt_text || ''}" loading="lazy">`;
const keywords = JSON.parse(r.keywords || '[]');
const tagsHtml = keywords.map(k => `<span class="tag">${k}</span>`).join('');
return `
<div class="result">
<a href="/media/${r.md5_hash}" target="_blank" class="media-link">
${mediaEl}
</a>
<div class="result-info">
<a href="/view/${r.md5_hash}" class="result-hash">${r.md5_hash}</a>
<div class="result-meta">
${r.media_type} · ${formatBytes(r.file_size)}
${r.alt_text ? ` · ${r.alt_text.substring(0, 50)}` : ''}
</div>
<div class="result-keywords">${tagsHtml}</div>
</div>
</div>
`;
}).join('');
}
function formatBytes(bytes) {
if (!bytes) return '?';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1024/1024).toFixed(1) + ' MB';
}
// Enter key to search
document.getElementById('query').addEventListener('keypress', e => {
if (e.key === 'Enter') search();
});
// Load stats on page load
loadStats();
// Initial search (show all)
search();
</script>
</body>
</html>
"""
CRAWL_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>neopig Crawler</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
background: #0a0a0a;
color: #e0e0e0;
}
h1 { color: #ff6b6b; margin-bottom: 5px; }
.subtitle { color: #666; margin-bottom: 20px; }
nav { margin-bottom: 20px; }
nav a { color: #ff6b6b; margin-right: 15px; }
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #aaa;
font-size: 14px;
}
input[type="text"], input[type="number"], select {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #333;
border-radius: 8px;
background: #1a1a1a;
color: #fff;
}
input:focus, select:focus {
outline: none;
border-color: #ff6b6b;
}
.row {
display: flex;
gap: 15px;
}
.row > div { flex: 1; }
button {
padding: 14px 28px;
font-size: 16px;
background: #ff6b6b;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: 10px;
}
button:hover { background: #ff5252; }
button:disabled {
background: #444;
cursor: not-allowed;
}
button.secondary {
background: #333;
}
button.secondary:hover {
background: #444;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-group input {
width: auto;
}
.jobs-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #333;
}
h2 {
color: #ff6b6b;
font-size: 18px;
margin-bottom: 15px;
}
.job {
background: #1a1a1a;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.job-id {
font-family: monospace;
color: #666;
}
.job-status {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.job-status.running { background: #2d5a27; color: #7bed72; }
.job-status.completed { background: #1a3a4a; color: #6bc5e8; }
.job-status.failed { background: #5a2727; color: #ed7272; }
.job-target {
font-size: 14px;
word-break: break-all;
margin-bottom: 5px;
}
.job-meta {
font-size: 12px;
color: #666;
}
.job-stats {
display: flex;
gap: 15px;
margin-top: 10px;
font-size: 13px;
}
.job-stats span {
background: #252525;
padding: 4px 10px;
border-radius: 4px;
}
.progress-bar {
height: 4px;
background: #333;
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: #ff6b6b;
transition: width 0.3s;
}
.no-jobs {
color: #666;
text-align: center;
padding: 30px;
}
</style>
</head>
<body>
<h1>🐷 neopig crawler</h1>
<p class="subtitle">Hydrate media from the web</p>
<nav>
<a href="/">← Search</a>
<a href="/crawl">Crawler</a>
</nav>
<form id="crawl-form" onsubmit="startCrawl(event)">
<div class="form-group">
<label>Target URIs (space, comma, or newline separated)</label>
<textarea id="target" placeholder="https://example.com https://another.com" rows="2" required style="width:100%;padding:10px;border:1px solid #333;border-radius:4px;background:#1a1a1a;color:#e0e0e0;font-size:14px;resize:vertical;"></textarea>
</div>
<div class="form-group">
<label>Keywords (space or comma separated)</label>
<input type="text" id="keywords" placeholder="rick and morty, adult swim">
</div>
<div class="row">
<div class="form-group">
<label>Mode</label>
<select id="mode">
<option value="images">Images only</option>
<option value="videos">Videos only</option>
<option value="media">All media (images + videos + audio)</option>
<option value="all">Everything (text + media)</option>
<option value="text">Text only</option>
</select>
</div>
<div class="form-group">
<label>Depth (5 recommended, max 15)</label>
<input type="number" id="depth" value="5" min="1" max="15">
</div>
<div class="form-group">
<label>Max Pages (-1 = unlimited)</label>
<input type="number" id="max_pages" value="500" min="-1">
</div>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="download" checked>
<label for="download" style="display:inline; margin:0;">Download media (uncheck to just index URLs)</label>
</div>
<button type="submit" id="start-btn">Start Crawl</button>
</form>
<div class="jobs-section">
<h2>Crawl Jobs</h2>
<div id="jobs">Loading...</div>
</div>
<script>
async function startCrawl(e) {
e.preventDefault();
const btn = document.getElementById('start-btn');
btn.disabled = true;
btn.textContent = 'Starting...';
const keywordsRaw = document.getElementById('keywords').value;
const keywords = keywordsRaw
.split(/[,\\s]+/)
.map(k => k.trim())
.filter(k => k.length > 0);
// Parse multiple target URIs (space, comma, or newline separated)
const targetsRaw = document.getElementById('target').value;
const targets = targetsRaw
.split(/[,\\s\\n]+/)
.map(t => t.trim())
.filter(t => t.length > 0 && t.startsWith('http'));
if (targets.length === 0) {
alert('Please enter at least one valid URI (must start with http)');
btn.disabled = false;
btn.textContent = 'Start Crawl';
return;
}
const payload = {
targets: targets,
keywords: keywords,
mode: document.getElementById('mode').value,
depth: parseInt(document.getElementById('depth').value),
max_pages: parseInt(document.getElementById('max_pages').value),
download_media: document.getElementById('download').checked
};
try {
const res = await fetch('/api/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
alert('Error: ' + (err.detail || 'Failed to start crawl'));
} else {
const result = await res.json();
const jobIds = result.job_ids || [result.job_id];
alert('Crawl started! Job IDs: ' + jobIds.join(', '));
loadJobs();
}
} catch (err) {
alert('Error: ' + err.message);
}
btn.disabled = false;
btn.textContent = 'Start Crawl';
}
async function loadJobs() {
try {
const res = await fetch('/api/crawl/jobs');
const jobs = await res.json();
const container = document.getElementById('jobs');
if (jobs.length === 0) {
container.innerHTML = '<div class="no-jobs">No crawl jobs yet</div>';
return;
}
container.innerHTML = jobs.map(job => {
const stats = job.stats ? JSON.parse(job.stats) : {};
const keywords = job.keywords ? JSON.parse(job.keywords) : [];
return `
<div class="job">
<div class="job-header">
<span class="job-id">Job #${job.id}</span>
<span class="job-status ${job.status}">${job.status}</span>
</div>
<div class="job-target">${job.target_uri}</div>
<div class="job-meta">
Mode: ${job.mode || 'images'} |
Keywords: ${keywords.join(', ') || 'none'} |
Started: ${new Date(job.started_at).toLocaleString()}
</div>
${job.status === 'completed' ? `
<div class="job-stats">
<span>📄 ${stats.pages_crawled || 0} pages</span>
<span>🖼️ ${stats.media_found || 0} found</span>
<span>💾 ${stats.media_downloaded || 0} saved</span>
<span>♻️ ${stats.duplicates_skipped || 0} dupes</span>
</div>
` : ''}
${job.status === 'running' ? `
<div class="progress-bar">
<div class="progress-bar-fill" style="width: 50%"></div>
</div>
` : ''}
</div>
`;
}).join('');
} catch (err) {
document.getElementById('jobs').innerHTML = '<div class="no-jobs">Failed to load jobs</div>';
}
}
// Load jobs on page load
loadJobs();
// Refresh jobs every 5 seconds
setInterval(loadJobs, 5000);
</script>
</body>
</html>
"""
LIVE_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>neopig LIVE - Watch Images Crawl In</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #0a0a0a;
color: #e0e0e0;
}
h1 { color: #ff6b6b; margin-bottom: 5px; }
.subtitle { color: #666; margin-bottom: 10px; }
.stats {
background: #1a1a1a;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 30px;
flex-wrap: wrap;
}
.stat { display: flex; flex-direction: column; }
.stat-value { font-size: 24px; font-weight: bold; color: #ff6b6b; }
.stat-label { font-size: 12px; color: #888; }
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
}
.controls button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.controls button.primary { background: #ff6b6b; color: white; }
.controls button.secondary { background: #333; color: #ddd; }
.controls button:hover { opacity: 0.8; }
.status { color: #4ade80; font-size: 14px; }
.status.paused { color: #fbbf24; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.card {
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card.new {
box-shadow: 0 0 20px rgba(255, 107, 107, 0.5);
}
.card img, .card video {
width: 100%;
height: 180px;
object-fit: contain;
background: #222;
}
.card-info {
padding: 10px;
}
.card-title {
font-size: 12px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-source {
font-size: 10px;
color: #555;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav { margin-bottom: 20px; }
.nav a { color: #ff6b6b; margin-right: 15px; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="nav">
<a href="/">Search</a>
<a href="/crawl">Crawl</a>
<a href="/live">Live Feed</a>
</div>
<h1>Live Feed</h1>
<p class="subtitle">Watch images appear as they're crawled</p>
<div class="stats">
<div class="stat">
<span class="stat-value" id="total-count">0</span>
<span class="stat-label">Total Images</span>
</div>
<div class="stat">
<span class="stat-value" id="new-count">0</span>
<span class="stat-label">New This Session</span>
</div>
<div class="stat">
<span class="stat-value" id="rate">0</span>
<span class="stat-label">Per Minute</span>
</div>
</div>
<div class="controls">
<button class="primary" id="toggle-btn" onclick="toggleFeed()">Pause</button>
<button class="secondary" onclick="clearFeed()">Clear</button>
<span class="status" id="status">Watching for new images...</span>
</div>
<div class="grid" id="grid"></div>
<script>
let running = true;
let lastCheck = new Date().toISOString();
let seenHashes = new Set();
let newCount = 0;
let startTime = Date.now();
function toggleFeed() {
running = !running;
const btn = document.getElementById('toggle-btn');
const status = document.getElementById('status');
if (running) {
btn.textContent = 'Pause';
status.textContent = 'Watching for new images...';
status.className = 'status';
poll();
} else {
btn.textContent = 'Resume';
status.textContent = 'Paused';
status.className = 'status paused';
}
}
function clearFeed() {
document.getElementById('grid').innerHTML = '';
seenHashes.clear();
newCount = 0;
startTime = Date.now();
updateStats(0);
}
function updateStats(total) {
document.getElementById('total-count').textContent = total;
document.getElementById('new-count').textContent = newCount;
const minutes = (Date.now() - startTime) / 60000;
const rate = minutes > 0 ? Math.round(newCount / minutes) : 0;
document.getElementById('rate').textContent = rate;
}
async function poll() {
if (!running) return;
try {
// Get recent media sorted by first_seen_at descending
const res = await fetch('/api/search?q=&limit=50');
const media = await res.json();
// Get stats
const statsRes = await fetch('/api/stats');
const stats = await statsRes.json();
updateStats(stats.total_media || 0);
const grid = document.getElementById('grid');
// Find new items
const newItems = media.filter(m => !seenHashes.has(m.md5_hash));
// Add new items to the top
newItems.reverse().forEach(item => {
seenHashes.add(item.md5_hash);
newCount++;
const card = document.createElement('div');
card.className = 'card new';
const isVideo = item.media_type === 'video';
const mediaEl = isVideo
? `<video src="/media/${item.md5_hash}" muted loop onmouseenter="this.play()" onmouseleave="this.pause()"></video>`
: `<img src="/media/${item.md5_hash}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22180%22><rect fill=%22%23333%22 width=%22200%22 height=%22180%22/><text x=%2250%%22 y=%2250%%22 fill=%22%23666%22 text-anchor=%22middle%22>Error</text></svg>'">`;
card.innerHTML = `
<a href="/view/${item.md5_hash}">
${mediaEl}
</a>
<div class="card-info">
<div class="card-title">${item.alt_text || item.title || item.md5_hash.slice(0,12)}</div>
<div class="card-source">${item.media_type} - ${formatSize(item.file_size)}</div>
</div>
`;
grid.insertBefore(card, grid.firstChild);
// Remove 'new' class after animation
setTimeout(() => card.classList.remove('new'), 2000);
});
// Update stats
updateStats(stats.total_media || 0);
} catch (err) {
console.error('Poll error:', err);
}
// Poll again
setTimeout(poll, 2000);
}
function formatSize(bytes) {
if (!bytes) return '?';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// Start polling
poll();
</script>
</body>
</html>
"""
@app.get("/live", response_class=HTMLResponse)
async def live_page():
"""Live feed page - watch images appear as they're crawled."""
return LIVE_HTML
@app.get("/view/{md5_hash}", response_class=HTMLResponse)
async def view_media_page(md5_hash: str):
"""Detail view page for a single media item."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM media WHERE md5_hash = ?", (md5_hash,))
media = await cursor.fetchone()
if not media:
raise HTTPException(status_code=404, detail="Media not found")
cursor = await db.execute(
"SELECT media_uri, page_uri, discovered_at FROM media_sources WHERE md5_hash = ?",
(md5_hash,)
)
sources = await cursor.fetchall()
media = dict(media)
keywords = json.loads(media.get('keywords') or '[]')
is_video = media['media_type'] == 'video'
is_audio = media['media_type'] == 'audio'
if is_video:
media_html = f'<video src="/media/{md5_hash}" controls style="max-width:100%;max-height:70vh;"></video>'
elif is_audio:
media_html = f'<audio src="/media/{md5_hash}" controls></audio>'
else:
media_html = f'<img src="/media/{md5_hash}" alt="{media.get("alt_text") or ""}" style="max-width:100%;max-height:70vh;">'
# Build sources - show all pages that embed this image
# Prioritize more specific pages (longer paths) over generic ones
sorted_sources = sorted(sources, key=lambda s: len(s["page_uri"] or ""), reverse=True)
sources_html = ''.join([
f'<li><a href="{s["page_uri"]}" target="_blank">{s["page_uri"]}</a></li>'
for s in sorted_sources
])
# Also show the direct media URL(s) separately
media_urls = list(set(s["media_uri"] for s in sources))
media_urls_html = ''.join([
f'<li><a href="{url}" target="_blank">{url}</a></li>'
for url in media_urls
])
keywords_html = ''.join([f'<span class="tag">{k}</span>' for k in keywords])
return f"""
<!DOCTYPE html>
<html>
<head>
<title>{media.get('alt_text') or md5_hash} - neopig</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #0a0a0a;
color: #e0e0e0;
}}
h1 {{ color: #ff6b6b; font-size: 20px; }}
a {{ color: #ff6b6b; }}
.media-container {{ text-align: center; margin: 20px 0; }}
.meta {{ background: #1a1a1a; padding: 15px; border-radius: 8px; margin: 15px 0; }}
.meta-row {{ display: flex; margin: 8px 0; }}
.meta-label {{ width: 120px; color: #888; }}
.meta-value {{ flex: 1; word-break: break-all; }}
.tag {{ background: #333; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-right: 5px; }}
.sources {{ margin-top: 20px; }}
.sources ul {{ padding-left: 20px; }}
.sources li {{ margin: 10px 0; }}
.sources small {{ color: #666; }}
.sources .urls {{ display: block; word-break: break-all; font-size: 11px; margin-top: 4px; }}
nav {{ margin-bottom: 20px; }}
</style>
</head>
<body>
<nav><a href="/">← Back to Search</a></nav>
<h1>{media.get('alt_text') or media.get('title') or 'Untitled'}</h1>
<div class="media-container">
<a href="/media/{md5_hash}" target="_blank">{media_html}</a>
</div>
<div class="meta">
<div class="meta-row"><span class="meta-label">MD5 Hash:</span><span class="meta-value"><code>{md5_hash}</code></span></div>
<div class="meta-row"><span class="meta-label">Type:</span><span class="meta-value">{media['media_type']}</span></div>
<div class="meta-row"><span class="meta-label">MIME:</span><span class="meta-value">{media.get('mime_type') or 'unknown'}</span></div>
<div class="meta-row"><span class="meta-label">Size:</span><span class="meta-value">{media.get('file_size') or 0:,} bytes</span></div>
<div class="meta-row"><span class="meta-label">First seen:</span><span class="meta-value">{media.get('first_seen_at')}</span></div>
<div class="meta-row"><span class="meta-label">Alt text:</span><span class="meta-value">{media.get('alt_text') or '-'}</span></div>
<div class="meta-row"><span class="meta-label">Title:</span><span class="meta-value">{media.get('title') or '-'}</span></div>
<div class="meta-row"><span class="meta-label">Keywords:</span><span class="meta-value">{keywords_html or '-'}</span></div>
<div class="meta-row"><span class="meta-label">Analysis:</span><span class="meta-value">{media.get('analysis_status', 'pending')}</span></div>
</div>
<div class="sources">
<h3>Pages embedding this media ({len(sources)})</h3>
<ul>{sources_html}</ul>
</div>
<div class="sources">
<h3>Direct media URLs ({len(media_urls)})</h3>
<ul>{media_urls_html}</ul>
</div>
</body>
</html>
"""
@app.get("/health")
async def health():
"""Health check endpoint."""
has_screenshot = False
try:
from uri2png import get_available_engines
has_screenshot = True
except ImportError:
pass
return {
"status": "healthy",
"features": {
"search": True,
"crawl": True,
"screenshot": has_screenshot
}
}
@app.get("/api/stats")
async def get_stats():
"""Get database statistics."""
async with aiosqlite.connect(DB_PATH) as db:
stats = {}
cursor = await db.execute("SELECT COUNT(*) FROM media")
stats['total_media'] = (await cursor.fetchone())[0]
cursor = await db.execute(
"SELECT media_type, COUNT(*) FROM media GROUP BY media_type"
)
stats['by_type'] = {row[0]: row[1] for row in await cursor.fetchall()}
cursor = await db.execute(
"SELECT analysis_status, COUNT(*) FROM media GROUP BY analysis_status"
)
stats['by_analysis'] = {row[0]: row[1] for row in await cursor.fetchall()}
cursor = await db.execute("SELECT COUNT(*) FROM media_sources")
stats['total_sources'] = (await cursor.fetchone())[0]
cursor = await db.execute("SELECT COUNT(*) FROM crawl_jobs")
stats['total_jobs'] = (await cursor.fetchone())[0]
return stats
@app.get("/api/search")
async def search(
q: str = Query("", description="Search query"),
type: Optional[str] = Query(None, description="Filter by media type"),
status: Optional[str] = Query(None, description="Filter by analysis status"),
limit: int = Query(100, le=1000),
offset: int = Query(0)
):
"""
Search media by text query.
Searches across: keywords, alt_text, title, source URLs, analysis results.
"""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
# Build query
conditions = []
params = []
if q:
conditions.append("""(
m.keywords LIKE ? OR
m.alt_text LIKE ? OR
m.title LIKE ? OR
m.analysis_result LIKE ? OR
EXISTS (SELECT 1 FROM media_sources ms WHERE ms.md5_hash = m.md5_hash AND (ms.media_uri LIKE ? OR ms.page_uri LIKE ? OR ms.page_title LIKE ? OR ms.page_description LIKE ? OR ms.page_keywords LIKE ?))
)""")
like_q = f"%{q}%"
params.extend([like_q, like_q, like_q, like_q, like_q, like_q, like_q, like_q, like_q])
if type:
conditions.append("m.media_type = ?")
params.append(type)
if status:
conditions.append("m.analysis_status = ?")
params.append(status)
where_clause = " AND ".join(conditions) if conditions else "1=1"
query = f"""
SELECT m.*
FROM media m
WHERE {where_clause}
ORDER BY m.first_seen_at DESC
LIMIT ? OFFSET ?
"""
params.extend([limit, offset])
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.get("/api/media/{md5_hash}")
async def get_media_info(md5_hash: str):
"""Get full media info including all source URLs."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
# Get media record
cursor = await db.execute(
"SELECT * FROM media WHERE md5_hash = ?",
(md5_hash,)
)
media = await cursor.fetchone()
if not media:
raise HTTPException(status_code=404, detail="Media not found")
result = dict(media)
# Get all sources
cursor = await db.execute(
"SELECT media_uri, page_uri, page_title, page_description, page_keywords, alt_text, link_text, discovered_at FROM media_sources WHERE md5_hash = ?",
(md5_hash,)
)
result['sources'] = [dict(row) for row in await cursor.fetchall()]
return result
@app.get("/media/{md5_hash}")
async def serve_media(md5_hash: str):
"""
Serve media file from vault.
Caddy should be configured to cache these responses.
"""
# Find file in vault
subdir = VAULT_PATH / md5_hash[:2]
if not subdir.exists():
raise HTTPException(status_code=404, detail="Media not found")
# Find file with this hash prefix
for f in subdir.iterdir():
if f.name.startswith(md5_hash):
# Guess content type
mime_type, _ = mimetypes.guess_type(f.name)
if not mime_type:
# Try to get from database
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"SELECT mime_type FROM media WHERE md5_hash = ?",
(md5_hash,)
)
row = await cursor.fetchone()
if row and row[0]:
mime_type = row[0]
else:
mime_type = "application/octet-stream"
return FileResponse(
f,
media_type=mime_type,
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Hash": md5_hash,
}
)
raise HTTPException(status_code=404, detail="Media not found")
# ============================================================================
# Crawler API
# ============================================================================
@app.get("/api/crawl/jobs")
async def get_crawl_jobs(limit: int = Query(50, le=200)):
"""Get recent crawl jobs."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"""
SELECT * FROM crawl_jobs
ORDER BY started_at DESC
LIMIT ?
""",
(limit,)
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.get("/api/crawl/jobs/{job_id}")
async def get_crawl_job(job_id: int):
"""Get a specific crawl job."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM crawl_jobs WHERE id = ?",
(job_id,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job not found")
return dict(row)
@app.post("/api/crawl")
async def start_crawl(request: CrawlRequest, background_tasks: BackgroundTasks):
"""
Start new crawl job(s).
Supports multiple targets - creates one job per target.
The crawls run in the background. Poll /api/crawl/jobs/{id} for status.
"""
# Import neopig here to avoid circular imports
from neopig import NeoPig
from async_web_fetcher import CrawlMode
# 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.get(request.mode, CrawlMode.IMAGES)
# Get targets (support both new 'targets' array and old 'target_uri' single value)
targets = request.targets if request.targets else [request.target_uri] if request.target_uri else []
if not targets:
raise HTTPException(status_code=400, detail="No target URIs provided")
job_ids = []
# Create a job for each target
for target_uri in targets:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"""
INSERT INTO crawl_jobs (target_uri, keywords, mode, status, started_at)
VALUES (?, ?, ?, 'running', datetime('now'))
""",
(target_uri, json.dumps(request.keywords), request.mode)
)
await db.commit()
job_id = cursor.lastrowid
job_ids.append(job_id)
# Run crawl in background (closure captures job_id and target_uri)
async def run_crawl(jid=job_id, uri=target_uri):
try:
pig = NeoPig(db_path=DB_PATH, vault_path=str(VAULT_PATH))
await pig.init()
# Load existing seen media for resume capability
crawled_media = await pig.db.get_crawled_media_uris()
if crawled_media:
pig.seen_media = crawled_media
# Cap depth at 15 (convert -1 or values > 15 to 15)
depth = request.depth if 1 <= request.depth <= 15 else 15
stats = await pig.crawl(
target_uri=uri,
keywords=request.keywords,
mode=mode,
depth=depth,
max_pages=request.max_pages,
download_media=request.download_media,
)
# Update job as completed
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""
UPDATE crawl_jobs
SET status = 'completed', completed_at = datetime('now'), stats = ?
WHERE id = ?
""",
(json.dumps(stats), jid)
)
await db.commit()
except Exception as e:
logger.error(f"Crawl job {jid} failed: {e}")
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""
UPDATE crawl_jobs
SET status = 'failed', completed_at = datetime('now'), stats = ?
WHERE id = ?
""",
(json.dumps({"error": str(e)}), jid)
)
await db.commit()
# Schedule background task
background_tasks.add_task(asyncio.create_task, run_crawl())
return {"job_ids": job_ids, "status": "running", "count": len(job_ids)}
def main():
global DB_PATH, VAULT_PATH
parser = argparse.ArgumentParser(description="neopig SERP")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--db", default="neopig.db")
parser.add_argument("--vault", default="vault")
args = parser.parse_args()
DB_PATH = args.db
VAULT_PATH = Path(args.vault)
logger.info(f"Starting neopig SERP on {args.host}:{args.port}")
logger.info(f"Database: {DB_PATH}")
logger.info(f"Vault: {VAULT_PATH}")
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()