pig.py/serp.py
Russell Ballestrini 623213f5b6 Self-extracting archives now use bundled neopig/serp.py
- Removed embedded serve.py (872 lines) from archive.py
- bootstrap.c extracts neopig/*.py to /tmp and runs serp.py
- serp.py: tarball mode serves media directly from tar.gz
- OffsetFile wrapper for reading .run files at correct offset
- ArchiveDB: simple SQLite wrapper for archive search (no async deps)
- Archives bundle all neopig source files for self-contained operation
- --upgrade-neopig flag with progress logging
2025-12-30 07:46:59 -05:00

2964 lines
106 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, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sqlalchemy import text
import uvicorn
from database import Database
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 = "data/neopig.db"
VAULT_PATH = Path("data/vault")
# Global database instance
db: Database = None
# Active crawl jobs (in-memory tracking)
ACTIVE_CRAWLS: Dict[int, Dict[str, Any]] = {}
# Tarball mode - serve directly from tar.gz archive
import tarfile
import tempfile
TAR_FILE: tarfile.TarFile = None
TAR_MEMBERS: Dict[str, tarfile.TarInfo] = {}
ARCHIVE_ROOT: str = None # e.g., "example.com-20251230"
TEMP_DB_PATH: str = None # Extracted database (SQLite needs real file)
def read_from_tarball(path: str) -> bytes:
"""Read a file from the tarball. Path is relative to archive root."""
if not TAR_FILE or not ARCHIVE_ROOT:
return None
full_path = f"{ARCHIVE_ROOT}/{path}"
if full_path in TAR_MEMBERS:
member = TAR_MEMBERS[full_path]
f = TAR_FILE.extractfile(member)
if f:
return f.read()
return None
def find_media_in_tarball(md5_hash: str) -> tuple:
"""Find media file in tarball by hash. Returns (data, extension) or (None, None)."""
if not TAR_FILE or not ARCHIVE_ROOT:
return None, None
prefix = f"{ARCHIVE_ROOT}/media/{md5_hash}"
for name, member in TAR_MEMBERS.items():
if name.startswith(prefix):
f = TAR_FILE.extractfile(member)
if f:
ext = Path(name).suffix
return f.read(), ext
return None, None
class ArchiveDB:
"""Simple sync SQLite wrapper for archive.db (FTS5 search only)."""
def __init__(self, db_path):
import sqlite3
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
async def search_pages(self, query, limit=50):
cursor = self.conn.execute(
"SELECT uri, title, snippet(pages_fts, 2, '<b>', '</b>', '...', 32) as snippet "
"FROM pages_fts WHERE pages_fts MATCH ? LIMIT ?",
(query, limit)
)
return [dict(row) for row in cursor.fetchall()]
async def get_stats(self):
cursor = self.conn.execute("SELECT COUNT(*) FROM pages")
return {"pages": cursor.fetchone()[0], "media": 0, "screenshots": 0}
@app.on_event("startup")
async def startup_event():
"""Initialize database on startup."""
global db
if TAR_FILE:
# Tarball mode: use simple archive DB
db = ArchiveDB(DB_PATH)
logger.info(f"Using archive database: {DB_PATH}")
else:
# Normal mode: use full async database
db = Database(DB_PATH)
await db.init() # Handles schema + WAL mode
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;
margin: 0;
padding: 0;
background: #0a0a0a;
color: #e0e0e0;
}
.nav {
background: #1a1a1a;
padding: 10px 20px;
display: flex;
gap: 20px;
align-items: center;
border-bottom: 1px solid #333;
}
.nav a { color: #ff6b6b; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.nav .brand { font-weight: bold; font-size: 18px; }
.container { padding: 20px; }
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;
}
.section-title {
color: #ff6b6b;
font-size: 18px;
margin: 25px 0 15px 0;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
.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;
}
/* Page results */
.page-results {
display: flex;
flex-direction: column;
gap: 10px;
}
.page-result {
background: #1a1a1a;
border-radius: 8px;
padding: 15px;
transition: background 0.2s;
}
.page-result:hover {
background: #252525;
}
.page-result a {
color: #ff6b6b;
text-decoration: none;
font-size: 16px;
font-weight: 500;
}
.page-result a:hover {
text-decoration: underline;
}
.page-path {
font-size: 12px;
color: #4ade80;
margin-top: 4px;
font-family: monospace;
}
.page-snippet {
font-size: 13px;
color: #999;
margin-top: 8px;
line-height: 1.5;
}
.page-snippet mark {
background: #ff6b6b33;
color: #ff9999;
padding: 1px 3px;
border-radius: 2px;
}
/* Two-column layout: pages left, media right */
.search-columns {
display: grid;
grid-template-columns: 1fr;
gap: 30px;
align-items: start;
}
.search-columns.has-pages {
grid-template-columns: 1fr 2fr;
}
.search-columns .page-column {
display: none;
}
.search-columns.has-pages .page-column {
display: block;
position: sticky;
top: 20px;
max-height: 85vh;
overflow-y: auto;
}
@media (max-width: 1000px) {
.search-columns.has-pages {
grid-template-columns: 1fr;
}
.search-columns.has-pages .page-column {
position: static;
max-height: none;
}
}
</style>
</head>
<body>
<div class="nav">
<a href="/" class="brand">🐷 neopig</a>
<a href="/">Search</a>
<a href="/live">Live</a>
<a href="/random">Random</a>
<a href="/crawl">Crawl</a>
<a href="/phantom">Phantom</a>
</div>
<div class="container">
<div class="search-box">
<input type="text" id="query" placeholder="Search keywords, alt text, page content..." 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="search-columns">
<div class="page-column" id="page-section">
<h2 class="section-title">Pages</h2>
<div class="page-results" id="page-results"></div>
</div>
<div class="media-column" id="media-section">
<h2 class="section-title">Media</h2>
<div class="results" id="results"></div>
</div>
</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 | ` +
`<strong>${stats.by_type?.image || 0}</strong> images | ` +
`<strong>${stats.by_type?.video || 0}</strong> videos | ` +
`<strong>${stats.total_sources}</strong> sources | ` +
`<strong>${stats.total_pages || 0}</strong> pages`;
}
async function search() {
const query = document.getElementById('query').value;
const type = document.getElementById('type').value;
// Search media
let mediaUrl = `/api/search?q=${encodeURIComponent(query)}&limit=100`;
if (type) mediaUrl += `&type=${type}`;
const mediaRes = await fetch(mediaUrl);
const mediaResults = await mediaRes.json();
const mediaContainer = document.getElementById('results');
const mediaSection = document.getElementById('media-section');
if (mediaResults.length === 0) {
mediaContainer.innerHTML = '<div class="no-results">No media found</div>';
} else {
mediaContainer.innerHTML = mediaResults.map(r => {
const isVideo = r.media_type === 'video';
const mediaEl = isVideo
? `<video src="/media/${r.md5_hash}" muted loop preload="metadata" onmouseenter="this.play()" onmouseleave="this.pause()"></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="/view/${r.md5_hash}" 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('');
}
// Search pages
const pageContainer = document.getElementById('page-results');
const searchColumns = document.querySelector('.search-columns');
if (query.trim()) {
const pageRes = await fetch(`/api/search/pages?q=${encodeURIComponent(query)}&limit=30`);
const pageResults = await pageRes.json();
if (pageResults.length > 0) {
searchColumns.classList.add('has-pages');
pageContainer.innerHTML = pageResults.map(p => `
<div class="page-result">
<a href="/page/view?uri=${encodeURIComponent(p.uri)}">${p.title || p.uri}</a>
<div class="page-path">${p.path || p.uri}</div>
<div class="page-snippet">${p.snippet || ''}</div>
</div>
`).join('');
} else {
searchColumns.classList.remove('has-pages');
pageContainer.innerHTML = '';
}
} else {
searchColumns.classList.remove('has-pages');
pageContainer.innerHTML = '';
}
}
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();
// Check for query param from nav search
const urlParams = new URLSearchParams(window.location.search);
const q = urlParams.get('q');
if (q) {
document.getElementById('query').value = q;
}
// Initial search (show all media or query)
search();
</script>
</div>
</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;
margin: 0;
padding: 0;
background: #0a0a0a;
color: #e0e0e0;
}
.nav {
background: #1a1a1a;
padding: 10px 20px;
display: flex;
gap: 20px;
align-items: center;
border-bottom: 1px solid #333;
}
.nav a { color: #ff6b6b; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.nav .brand { font-weight: bold; font-size: 18px; }
.container { padding: 20px; }
h1 { color: #ff6b6b; margin-bottom: 5px; }
.subtitle { color: #666; margin-bottom: 20px; }
.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>
<div class="nav">
<a href="/" class="brand">🐷 neopig</a>
<a href="/">Search</a>
<a href="/live">Live</a>
<a href="/random">Random</a>
<a href="/crawl">Crawl</a>
<a href="/phantom">Phantom</a>
</div>
<div class="container">
<form class="search-box" action="/" method="get" style="display:flex;gap:10px;margin-bottom:20px;">
<input type="text" name="q" placeholder="Search keywords, alt text, page content..." style="flex:1;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<select name="type" style="flex:0 0 auto;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<option value="">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
<button type="submit" style="flex:0 0 auto;padding:12px 24px;font-size:16px;background:#ff6b6b;color:#fff;border:none;border-radius:8px;cursor:pointer;">Search</button>
</form>
<h1>Crawler</h1>
<p class="subtitle">Hydrate media from the web</p>
<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>
</div>
</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: 0;
background: #0a0a0a;
color: #e0e0e0;
}
.nav {
background: #1a1a1a;
padding: 10px 20px;
display: flex;
gap: 20px;
align-items: center;
border-bottom: 1px solid #333;
}
.nav a { color: #ff6b6b; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.nav .brand { font-weight: bold; font-size: 18px; }
.container { padding: 20px; }
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; }
.live-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.live-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); }
}
.live-card.new {
box-shadow: 0 0 20px rgba(255, 107, 107, 0.5);
}
.live-card img, .live-card video {
width: 100%;
height: 180px;
object-fit: contain;
background: #222;
}
.live-card-info {
padding: 10px;
}
.live-card-title {
font-size: 12px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-card-source {
font-size: 10px;
color: #555;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="nav">
<a href="/" class="brand">🐷 neopig</a>
<a href="/">Search</a>
<a href="/live">Live</a>
<a href="/random">Random</a>
<a href="/crawl">Crawl</a>
<a href="/phantom">Phantom</a>
</div>
<div class="container">
<form class="search-box" action="/" method="get" style="display:flex;gap:10px;margin-bottom:20px;">
<input type="text" name="q" placeholder="Search keywords, alt text, page content..." style="flex:1;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<select name="type" style="flex:0 0 auto;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<option value="">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
<button type="submit" style="flex:0 0 auto;padding:12px 24px;font-size:16px;background:#ff6b6b;color:#fff;border:none;border-radius:8px;cursor:pointer;">Search</button>
</form>
<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="live-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 (on first poll, show all; after that only new ones)
const isFirstPoll = seenHashes.size === 0;
const newItems = media.filter(m => !seenHashes.has(m.md5_hash));
// Add new items to the top (or all items on first load)
newItems.reverse().forEach(item => {
seenHashes.add(item.md5_hash);
newCount++;
const card = document.createElement('div');
card.className = 'live-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="live-card-info">
<div class="live-card-title">${item.alt_text || item.title || item.md5_hash.slice(0,12)}</div>
<div class="live-card-source">${item.media_type} - ${formatSize(item.file_size)}</div>
</div>
`;
grid.insertBefore(card, grid.firstChild);
// Remove 'new' highlight 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>
</div>
</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 db.session() as session:
result = await session.execute(
text("SELECT * FROM media WHERE md5_hash = :hash"),
{'hash': md5_hash}
)
media = result.fetchone()
if not media:
raise HTTPException(status_code=404, detail="Media not found")
result = await session.execute(
text("""SELECT media_uri, page_uri, page_title, page_content,
detail_page_uri, detail_title, detail_content, discovered_at
FROM media_sources WHERE md5_hash = :hash"""),
{'hash': md5_hash}
)
sources = result.fetchall()
media = dict(media._mapping)
sources = [dict(s._mapping) for s in sources]
keywords = json.loads(media.get('keywords') or '[]')
# Generate download filename
# Priority: alt_text -> title -> (page_title + consistent index)
name_source = media.get('alt_text') or media.get('title')
if not name_source and sources:
page_title = sources[0].get('page_title', '')
# Use first 4 hex chars of md5 as consistent index (0-65535)
media_idx = int(md5_hash[:4], 16)
name_source = f"{page_title}-{media_idx}" if page_title else f"media-{media_idx}"
download_name = slugify(name_source or f"media-{md5_hash[:8]}")
# Get extension from mime type
ext_map = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif',
'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/bmp': '.bmp',
'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov',
'audio/mpeg': '.mp3', 'audio/wav': '.wav', 'audio/ogg': '.ogg',
}
ext = ext_map.get(media.get('mime_type', ''), '.bin')
download_filename = f"{download_name}{ext}"
# Display title: alt_text -> title -> page_title -> hash
display_title = media.get('alt_text') or media.get('title')
if not display_title and sources:
display_title = sources[0].get('page_title')
if not display_title:
display_title = f"Media {md5_hash[:12]}"
is_video = media['media_type'] == 'video'
is_audio = media['media_type'] == 'audio'
if is_video:
media_html = f'<video src="/media/{md5_hash}" controls muted loop style="max-width:100%;max-height:70vh;" onmouseenter="this.play()" onmouseleave="this.pause()"></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])
# Get page URI for content lookup
page_uri = sorted_sources[0]["page_uri"] if sorted_sources else None
# Get page content - prefer markdown, fallback to raw HTML or text
page_content_html = ""
if page_uri:
page_row = await db.get_page_by_uri(page_uri)
if page_row:
import html as html_module
import re
page_title = page_row.get("title") or ""
# Render markdown to HTML with our stylesheet
if page_row.get("markdown"):
# Render markdown to HTML
try:
import markdown
md_converter = markdown.Markdown(extensions=['fenced_code', 'tables', 'nl2br'])
rendered = md_converter.convert(page_row["markdown"][:100000])
# Hydrate: rewrite image URLs to use our vault
img_pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
img_urls = img_pattern.findall(rendered)
if img_urls:
from urllib.parse import urljoin
resolved_urls = {}
for url in img_urls:
if url.startswith(('http://', 'https://', '//')):
resolved_urls[url] = url
else:
resolved_urls[url] = urljoin(page_uri, url)
# Look up md5_hash for resolved URLs
all_urls = list(set(resolved_urls.values()))
resolved_to_hash = await db.lookup_media_by_uris(all_urls)
# Fallback: for URLs not found, try matching by filename
missing_urls = [u for u in all_urls if u not in resolved_to_hash]
if missing_urls:
from pathlib import Path as P
for murl in missing_urls:
fname = P(murl).stem
if len(fname) >= 20:
result = await db.lookup_media_by_filename(fname)
if result:
resolved_to_hash[murl] = result[1]
# Replace original URLs with vault paths
for orig_url, resolved_url in resolved_urls.items():
if resolved_url in resolved_to_hash:
md5 = resolved_to_hash[resolved_url]
rendered = rendered.replace(f'src="{orig_url}"', f'src="/media/{md5}"')
rendered = rendered.replace(f"src='{orig_url}'", f'src="/media/{md5}"')
# Look for screenshot of this page
screenshot_html = ""
screenshot_hash = await db.get_page_screenshot(page_uri, exclude_hash=md5_hash)
if screenshot_hash:
screenshot_html = f'''
<div class="page-screenshot">
<a href="/media/{screenshot_hash}" target="_blank">
<img src="/media/{screenshot_hash}" alt="Page screenshot" loading="lazy">
</a>
</div>'''
if screenshot_html:
page_content_html = f'''
<div class="page-content">
<h3>Page Context{(' - ' + html_module.escape(page_title)) if page_title else ''}</h3>
<div class="page-context-grid">
<div class="content-rendered">{rendered}</div>
{screenshot_html}
</div>
</div>
'''
else:
page_content_html = f'''
<div class="page-content">
<h3>Page Context{(' - ' + html_module.escape(page_title)) if page_title else ''}</h3>
<div class="content-rendered">{rendered}</div>
</div>
'''
except ImportError:
escaped = html_module.escape(page_row["markdown"][:50000])
page_content_html = f'''
<div class="page-content">
<h3>Page Context{(' - ' + html_module.escape(page_title)) if page_title else ''}</h3>
<div class="content-text"><pre style="white-space:pre-wrap;">{escaped}</pre></div>
</div>
'''
elif page_row.get("content"):
content = page_row["content"][:50000]
escaped = html_module.escape(content)
paragraphs = escaped.split('\n\n')
formatted = ''.join(f'<p>{p.replace(chr(10), "<br>")}</p>' for p in paragraphs if p.strip())
page_content_html = f'''
<div class="page-content">
<h3>Page Context{(' - ' + html_module.escape(page_title)) if page_title else ''}</h3>
<div class="content-text">{formatted}</div>
</div>
'''
return f"""
<!DOCTYPE html>
<html>
<head>
<title>{display_title} - neopig</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
background: #0a0a0a;
color: #e0e0e0;
}}
.nav {{
background: #1a1a1a;
padding: 10px 20px;
display: flex;
gap: 20px;
align-items: center;
border-bottom: 1px solid #333;
}}
.nav a {{ color: #ff6b6b; text-decoration: none; }}
.nav a:hover {{ text-decoration: underline; }}
.nav .brand {{ font-weight: bold; font-size: 18px; }}
.container {{ padding: 20px; }}
h1 {{ color: #ff6b6b; font-size: 20px; }}
h3 {{ color: #ff6b6b; font-size: 16px; margin-top: 30px; border-bottom: 1px solid #333; padding-bottom: 8px; }}
a {{ color: #ff6b6b; }}
.media-container {{ text-align: center; margin-bottom: 20px; }}
.media-container img, .media-container video {{ max-width: 100%; max-height: 80vh; }}
.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; }}
.page-content {{ margin-top: 30px; }}
.content-text {{
background: #1a1a1a;
padding: 20px;
border-radius: 8px;
line-height: 1.7;
font-size: 14px;
color: #ccc;
max-height: 600px;
overflow-y: auto;
}}
.content-text p {{ margin: 0 0 15px 0; }}
.content-rendered {{
background: #1a1a1a;
padding: 20px;
border-radius: 8px;
line-height: 1.7;
font-size: 14px;
color: #ccc;
}}
.content-rendered img {{ max-width: 100%; height: auto; }}
.content-rendered img.avatar {{
display: inline-block;
vertical-align: middle;
width: 40px;
height: 40px;
border-radius: 50%;
margin: 0 10px 0 0;
object-fit: cover;
}}
/* Forum-style post layout */
.content-rendered .post-block {{
display: grid;
grid-template-columns: 48px 1fr;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid #252530;
}}
.content-rendered .post-block:last-child {{
border-bottom: none;
}}
.content-rendered .post-avatar-col {{
display: flex;
flex-direction: column;
align-items: center;
}}
.content-rendered .post-avatar {{
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}}
.content-rendered .post-avatar-placeholder {{
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
}}
.content-rendered .post-content {{
min-width: 0;
}}
.content-rendered .post-content p:first-child {{
margin-top: 0;
}}
.content-rendered a {{ color: #ff6b6b; }}
.content-rendered pre, .content-rendered code {{
background: #252525;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
overflow-x: auto;
}}
.content-rendered pre {{
padding: 15px;
display: block;
white-space: pre-wrap;
position: relative;
}}
.content-rendered pre .code-actions {{
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 5px;
opacity: 0;
transition: opacity 0.2s;
}}
.content-rendered pre:hover .code-actions {{ opacity: 1; }}
.content-rendered pre .code-actions button {{
background: #444;
border: none;
color: #ccc;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
}}
.content-rendered pre .code-actions button:hover {{ background: #555; }}
.content-rendered pre .code-actions button.copied {{ background: #4ade80; color: #000; }}
.content-rendered blockquote {{
background: #151520;
border-left: 3px solid #4a9eff;
padding: 12px 16px;
margin: 16px 0;
border-radius: 0 6px 6px 0;
color: #aaa;
font-style: italic;
}}
.content-rendered blockquote p {{
margin: 0 0 8px 0;
}}
.content-rendered blockquote p:last-child {{
margin-bottom: 0;
}}
/* Nested quotes */
.content-rendered blockquote blockquote {{
background: #1a1a25;
border-left-color: #666;
margin: 12px 0;
}}
/* Post/comment spacing - add visual separation */
.content-rendered hr {{
border: none;
border-top: 1px solid #333;
margin: 24px 0;
}}
.content-rendered p {{
margin: 0 0 16px 0;
line-height: 1.7;
}}
.content-rendered > p + p {{
margin-top: 16px;
}}
.content-rendered h1, .content-rendered h2, .content-rendered h3 {{
color: #ff6b6b;
margin-top: 28px;
margin-bottom: 12px;
padding-top: 16px;
border-top: 1px solid #252530;
}}
.content-rendered h1:first-child, .content-rendered h2:first-child, .content-rendered h3:first-child {{
margin-top: 0;
padding-top: 0;
border-top: none;
}}
.page-context-grid {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 15px;
}}
.page-screenshot {{
position: sticky;
top: 20px;
}}
.page-screenshot img {{
width: 100%;
border-radius: 8px;
border: 1px solid #333;
}}
@media (max-width: 900px) {{
.page-context-grid {{
grid-template-columns: 1fr;
}}
.page-screenshot {{
position: static;
}}
.hero-grid {{
grid-template-columns: 1fr !important;
}}
}}
</style>
</head>
<body>
<div class="nav">
<a href="/" class="brand">🐷 neopig</a>
<a href="/">Search</a>
<a href="/live">Live</a>
<a href="/random">Random</a>
<a href="/crawl">Crawl</a>
<a href="/phantom">Phantom</a>
</div>
<div class="container">
<form class="search-box" action="/" method="get" style="display:flex;gap:10px;margin-bottom:20px;">
<input type="text" name="q" placeholder="Search keywords, alt text, page content..." style="flex:1;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<select name="type" style="flex:0 0 auto;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<option value="">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
<button type="submit" style="flex:0 0 auto;padding:12px 24px;font-size:16px;background:#ff6b6b;color:#fff;border:none;border-radius:8px;cursor:pointer;">Search</button>
</form>
<div class="hero-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:30px;">
<div class="hero-left">
<div class="media-container" style="background:#111;border-radius:12px;padding:20px;text-align:center;">
<a href="/media/{md5_hash}" target="_blank">{media_html}</a>
</div>
<h1 style="margin-top:15px;font-size:1.3em;">{display_title}</h1>
</div>
<div class="hero-right" style="background:#151515;border-radius:12px;padding:20px;">
<div class="meta" style="margin-bottom:20px;">
<div class="meta-row"><span class="meta-label">MD5 Hash:</span><span class="meta-value"><code style="font-size:11px;">{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>
<a href="/media/{md5_hash}?download=1" class="download-btn" style="display:block;padding:12px 20px;background:#4a9eff;color:#fff;text-decoration:none;border-radius:6px;text-align:center;font-weight:500;">
⬇ Download ({download_filename})
</a>
<div class="sources" style="margin-top:20px;">
<h4 style="color:#888;font-size:12px;margin-bottom:8px;">Source pages ({len(sources)})</h4>
<ul style="font-size:12px;padding-left:15px;margin:0;">{sources_html}</ul>
</div>
</div>
</div>
{page_content_html}
</div>
<script>
// Syntax highlighting
hljs.highlightAll();
// Auto-detect small images as emojis/avatars
document.querySelectorAll('.content-rendered img').forEach(img => {{
const checkSize = () => {{
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
if (w > 0 && h > 0) {{
if (w <= 24 && h <= 24) {{
img.classList.add('emoji');
}} else if (w <= 60 && h <= 60) {{
img.classList.add('avatar');
}}
}}
}};
if (img.complete) checkSize();
else img.onload = checkSize;
}});
// Add copy/download buttons to all code blocks
const langExtMap = {{
'python': 'py', 'py': 'py', 'javascript': 'js', 'js': 'js', 'typescript': 'ts', 'ts': 'ts',
'cpp': 'cpp', 'c++': 'cpp', 'c': 'c', 'java': 'java', 'rust': 'rs', 'go': 'go',
'ruby': 'rb', 'php': 'php', 'swift': 'swift', 'kotlin': 'kt', 'scala': 'scala',
'html': 'html', 'css': 'css', 'scss': 'scss', 'json': 'json', 'yaml': 'yaml', 'yml': 'yml',
'xml': 'xml', 'sql': 'sql', 'bash': 'sh', 'sh': 'sh', 'shell': 'sh', 'powershell': 'ps1',
'markdown': 'md', 'md': 'md', 'lua': 'lua', 'perl': 'pl', 'r': 'r'
}};
document.querySelectorAll('.content-rendered pre').forEach((pre, idx) => {{
const code = pre.querySelector('code') || pre;
const text = code.textContent;
// Detect language from class
let ext = 'txt';
const classes = (code.className || '').split(/\\s+/);
for (const cls of classes) {{
const match = cls.match(/^(?:language-|lang-)?(.+)$/);
if (match && langExtMap[match[1].toLowerCase()]) {{
ext = langExtMap[match[1].toLowerCase()];
break;
}}
}}
const actions = document.createElement('div');
actions.className = 'code-actions';
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy';
copyBtn.onclick = async () => {{
await navigator.clipboard.writeText(text);
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('copied');
setTimeout(() => {{ copyBtn.textContent = 'Copy'; copyBtn.classList.remove('copied'); }}, 2000);
}};
const dlBtn = document.createElement('button');
dlBtn.textContent = 'Download';
dlBtn.onclick = () => {{
const blob = new Blob([text], {{ type: 'text/plain' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `code-${{idx + 1}}.${{ext}}`;
a.click();
URL.revokeObjectURL(url);
}};
actions.appendChild(copyBtn);
actions.appendChild(dlBtn);
pre.appendChild(actions);
}});
// Restructure forum posts into two-column layout
// Looks for pattern: <p>![avatar](url) <strong>username</strong></p> followed by content until <hr>
function restructureForumPosts() {{
const container = document.querySelector('.content-rendered');
if (!container) return;
// Find paragraphs that start with an avatar image followed by bold username
const postStarts = [];
container.querySelectorAll('p').forEach(p => {{
const img = p.querySelector('img[alt="avatar"]');
const strong = p.querySelector('strong');
if (img && strong) {{
postStarts.push({{ p, img, username: strong.textContent }});
}}
}});
if (postStarts.length === 0) return;
// For each post header, create a post-block
postStarts.forEach(({{ p, img, username }}, idx) => {{
if (p.closest('.post-block')) return;
const postBlock = document.createElement('div');
postBlock.className = 'post-block';
// Avatar column - use placeholder if no src
const avatarCol = document.createElement('div');
avatarCol.className = 'post-avatar-col';
const hasAvatar = img.src && !img.src.endsWith('#') && img.getAttribute('src') !== '#';
if (hasAvatar) {{
const avatarImg = img.cloneNode(true);
avatarImg.classList.add('post-avatar');
avatarCol.appendChild(avatarImg);
}} else {{
// Placeholder for posts without avatar
const placeholder = document.createElement('div');
placeholder.className = 'post-avatar-placeholder';
placeholder.textContent = username.charAt(0).toUpperCase();
avatarCol.appendChild(placeholder);
}}
// Content column
const contentCol = document.createElement('div');
contentCol.className = 'post-content';
// Add username header
const header = document.createElement('div');
header.className = 'post-header';
header.innerHTML = `<strong>${{username}}</strong>`;
contentCol.appendChild(header);
// Collect siblings until next post or HR
let sibling = p.nextElementSibling;
const nextP = idx < postStarts.length - 1 ? postStarts[idx + 1].p : null;
while (sibling && sibling !== nextP && sibling.tagName !== 'HR') {{
contentCol.appendChild(sibling.cloneNode(true));
const toRemove = sibling;
sibling = sibling.nextElementSibling;
toRemove.remove();
}}
// Remove the HR separator if present
if (sibling && sibling.tagName === 'HR') {{
sibling.remove();
}}
postBlock.appendChild(avatarCol);
postBlock.appendChild(contentCol);
p.parentNode.insertBefore(postBlock, p);
p.remove();
}});
}}
// Run after DOM ready (avatars may be empty placeholders)
restructureForumPosts();
</script>
<script src="https://uncloseai.com/uncloseai.js" type="module"></script>
</body>
</html>
"""
@app.get("/page/view", response_class=HTMLResponse)
async def view_page(
uri: str = Query(..., description="Page URI to view"),
noai: bool = Query(False, description="Disable AI assistant")
):
"""View an archived page with markdown and screenshot."""
import html as html_module
import re
page = await db.get_page_by_uri(uri)
if not page:
raise HTTPException(status_code=404, detail="Page not found")
page_title = page.get("title") or uri
# Render markdown
content_html = ""
if page.get("markdown"):
try:
import markdown
md_converter = markdown.Markdown(extensions=['fenced_code', 'tables', 'nl2br'])
content_html = md_converter.convert(page["markdown"][:100000])
# Hydrate images from vault
img_pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
img_urls = img_pattern.findall(content_html)
if img_urls:
from urllib.parse import urljoin
resolved_urls = {}
for url in img_urls:
if url.startswith(('http://', 'https://', '//')):
resolved_urls[url] = url
else:
resolved_urls[url] = urljoin(uri, url)
# Look up md5_hash for resolved URLs
all_urls = list(set(resolved_urls.values()))
resolved_to_hash = await db.lookup_media_by_uris(all_urls)
# Fallback: for URLs not found, try matching by filename
missing_urls = [u for u in all_urls if u not in resolved_to_hash]
if missing_urls:
from pathlib import Path as P
for murl in missing_urls:
fname = P(murl).stem
if len(fname) >= 20:
result = await db.lookup_media_by_filename(fname)
if result:
resolved_to_hash[murl] = result[1]
# Replace original URLs with vault paths
for orig_url, resolved_url in resolved_urls.items():
if resolved_url in resolved_to_hash:
md5 = resolved_to_hash[resolved_url]
content_html = content_html.replace(f'src="{orig_url}"', f'src="/media/{md5}"')
content_html = content_html.replace(f"src='{orig_url}'", f'src="/media/{md5}"')
except ImportError:
content_html = f"<pre>{html_module.escape(page.get('markdown', '')[:50000])}</pre>"
elif page.get("content"):
escaped = html_module.escape(page["content"][:50000])
content_html = f"<pre style='white-space:pre-wrap;'>{escaped}</pre>"
# Find screenshot
screenshot_html = ""
screenshot_hash = await db.get_page_screenshot(uri)
if screenshot_hash:
screenshot_html = f'''<a href="/media/{screenshot_hash}" target="_blank">
<img src="/media/{screenshot_hash}" alt="Page screenshot" style="max-width:100%;max-height:50vh;border-radius:8px;">
</a>'''
# Find media from this page
media_items = await db.get_page_media(uri)
media_grid = ""
if media_items:
media_cards = []
for m in media_items:
is_video = m.get("media_type") == "video"
if is_video:
el = f'<video src="/media/{m["md5_hash"]}" muted loop preload="metadata" onmouseenter="this.play()" onmouseleave="this.pause()"></video>'
else:
el = f'<img src="/media/{m["md5_hash"]}" loading="lazy">'
media_cards.append(f'''
<a href="/view/{m["md5_hash"]}" class="media-card">
{el}
</a>''')
media_grid = f'''
<div class="page-media">
<h3>Media from this page ({len(media_items)})</h3>
<div class="media-grid">{''.join(media_cards)}</div>
</div>'''
return f"""
<!DOCTYPE html>
<html>
<head>
<title>{html_module.escape(page_title)} - neopig</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
background: #0a0a0a;
color: #e0e0e0;
}}
.nav {{
background: #1a1a1a;
padding: 10px 20px;
display: flex;
gap: 20px;
align-items: center;
border-bottom: 1px solid #333;
}}
.nav a {{ color: #ff6b6b; text-decoration: none; }}
.nav a:hover {{ text-decoration: underline; }}
.nav .brand {{ font-weight: bold; font-size: 18px; }}
.container {{ padding: 20px; }}
h1 {{ color: #ff6b6b; font-size: 22px; margin-bottom: 5px; }}
.page-uri {{ color: #4ade80; font-size: 12px; font-family: monospace; margin-bottom: 20px; }}
.page-uri a {{ color: #4ade80; }}
.hero-grid {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}}
.hero-left {{
background: #111;
border-radius: 12px;
padding: 20px;
}}
.hero-right {{
background: #151515;
border-radius: 12px;
padding: 20px;
}}
.meta {{
background: #1a1a1a;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
}}
.meta-row {{
display: flex;
margin: 8px 0;
}}
.meta-label {{
width: 100px;
color: #888;
font-size: 13px;
}}
.meta-value {{
flex: 1;
word-break: break-all;
font-size: 13px;
}}
.meta-value a {{
color: #4ade80;
}}
.content-rendered {{
background: #1a1a1a;
padding: 20px;
border-radius: 8px;
line-height: 1.7;
font-size: 14px;
color: #ccc;
}}
.content-rendered img {{ max-width: 100%; height: auto; margin: 10px 0; }}
.content-rendered img.avatar, .content-rendered img.small-img {{
display: inline-block;
vertical-align: middle;
width: 32px;
height: 32px;
border-radius: 50%;
margin: 0 8px 0 0;
object-fit: cover;
}}
.content-rendered img.emoji {{
display: inline;
width: 20px;
height: 20px;
margin: 0 2px;
vertical-align: text-bottom;
}}
/* Forum-style post layout */
.content-rendered .post-block {{
display: grid;
grid-template-columns: 48px 1fr;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid #252530;
}}
.content-rendered .post-block:last-child {{
border-bottom: none;
}}
.content-rendered .post-avatar-col {{
display: flex;
flex-direction: column;
align-items: center;
}}
.content-rendered .post-avatar {{
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}}
.content-rendered .post-avatar-placeholder {{
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
}}
.content-rendered .post-content {{
min-width: 0;
}}
.content-rendered .post-content p:first-child {{
margin-top: 0;
}}
.content-rendered a {{ color: #ff6b6b; }}
.content-rendered pre, .content-rendered code {{
background: #252525;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}}
.content-rendered pre {{
padding: 15px;
display: block;
white-space: pre-wrap;
overflow-x: auto;
position: relative;
}}
.content-rendered pre .code-actions {{
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
opacity: 0;
transition: opacity 0.2s;
}}
.content-rendered pre:hover .code-actions {{ opacity: 1; }}
.content-rendered pre .code-actions button {{
background: #444;
border: none;
color: #ccc;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
}}
.content-rendered pre .code-actions button:hover {{ background: #555; }}
.content-rendered pre .code-actions button.copied {{ background: #4ade80; color: #000; }}
.content-rendered blockquote {{
background: #151520;
border-left: 3px solid #4a9eff;
padding: 12px 16px;
margin: 16px 0;
border-radius: 0 6px 6px 0;
color: #aaa;
font-style: italic;
}}
.content-rendered blockquote p {{
margin: 0 0 8px 0;
}}
.content-rendered blockquote p:last-child {{
margin-bottom: 0;
}}
/* Nested quotes */
.content-rendered blockquote blockquote {{
background: #1a1a25;
border-left-color: #666;
margin: 12px 0;
}}
/* Post/comment spacing */
.content-rendered hr {{
border: none;
border-top: 1px solid #333;
margin: 24px 0;
}}
.content-rendered p {{
margin: 0 0 16px 0;
line-height: 1.7;
}}
.content-rendered h1, .content-rendered h2, .content-rendered h3 {{
color: #ff6b6b;
margin-top: 28px;
margin-bottom: 12px;
padding-top: 16px;
border-top: 1px solid #252530;
}}
.content-rendered h1:first-child, .content-rendered h2:first-child, .content-rendered h3:first-child {{
margin-top: 0;
padding-top: 0;
border-top: none;
}}
.page-screenshot {{
position: sticky;
top: 20px;
}}
.page-screenshot img {{
width: 100%;
border-radius: 8px;
border: 1px solid #333;
}}
.page-media {{ margin-top: 30px; }}
.page-media h3 {{ color: #ff6b6b; font-size: 16px; border-bottom: 1px solid #333; padding-bottom: 8px; margin: 0 0 15px 0; }}
.media-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}}
.media-card {{
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
display: block;
transition: transform 0.2s;
}}
.media-card:hover {{
transform: scale(1.02);
}}
.media-card img, .media-card video {{
width: 100%;
height: 120px;
object-fit: contain;
background: #0a0a0a;
}}
@media (max-width: 900px) {{
.hero-grid {{
grid-template-columns: 1fr;
}}
}}
</style>
</head>
<body>
<div class="nav">
<a href="/" class="brand">🐷 neopig</a>
<a href="/">Search</a>
<a href="/live">Live</a>
<a href="/random">Random</a>
<a href="/crawl">Crawl</a>
<a href="/phantom">Phantom</a>
</div>
<div class="container">
<form class="search-box" action="/" method="get" style="display:flex;gap:10px;margin-bottom:20px;">
<input type="text" name="q" placeholder="Search keywords, alt text, page content..." style="flex:1;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<select name="type" style="flex:0 0 auto;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<option value="">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
<button type="submit" style="flex:0 0 auto;padding:12px 24px;font-size:16px;background:#ff6b6b;color:#fff;border:none;border-radius:8px;cursor:pointer;">Search</button>
</form>
<div class="hero-grid">
<div class="hero-left">
{screenshot_html or '<div style="color:#666;text-align:center;padding:40px;">No screenshot available</div>'}
<h1 style="margin-top:15px;font-size:1.3em;">{html_module.escape(page_title)}</h1>
</div>
<div class="hero-right">
<div class="meta">
<div class="meta-row"><span class="meta-label">URL:</span><span class="meta-value"><a href="{uri}" target="_blank">{uri}</a></span></div>
<div class="meta-row"><span class="meta-label">Media:</span><span class="meta-value">{len(media_items)} items</span></div>
</div>
<div class="content-rendered" style="max-height:400px;overflow-y:auto;">{content_html or '<p>No content available</p>'}</div>
</div>
</div>
{media_grid}
</div>
<script>
// Syntax highlighting
hljs.highlightAll();
// Auto-detect small images as emojis/avatars
document.querySelectorAll('.content-rendered img').forEach(img => {{
const checkSize = () => {{
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
if (w > 0 && h > 0) {{
if (w <= 24 && h <= 24) {{
img.classList.add('emoji');
}} else if (w <= 60 && h <= 60) {{
img.classList.add('avatar');
}}
}}
}};
if (img.complete) checkSize();
else img.onload = checkSize;
}});
// Add copy/download buttons to all code blocks
const langExtMap = {{
'python': 'py', 'py': 'py', 'javascript': 'js', 'js': 'js', 'typescript': 'ts', 'ts': 'ts',
'cpp': 'cpp', 'c++': 'cpp', 'c': 'c', 'java': 'java', 'rust': 'rs', 'go': 'go',
'ruby': 'rb', 'php': 'php', 'swift': 'swift', 'kotlin': 'kt', 'scala': 'scala',
'html': 'html', 'css': 'css', 'scss': 'scss', 'json': 'json', 'yaml': 'yaml', 'yml': 'yml',
'xml': 'xml', 'sql': 'sql', 'bash': 'sh', 'sh': 'sh', 'shell': 'sh', 'powershell': 'ps1',
'markdown': 'md', 'md': 'md', 'lua': 'lua', 'perl': 'pl', 'r': 'r'
}};
document.querySelectorAll('.content-rendered pre').forEach((pre, idx) => {{
const code = pre.querySelector('code') || pre;
const text = code.textContent;
// Detect language from class
let ext = 'txt';
const classes = (code.className || '').split(/\\s+/);
for (const cls of classes) {{
const match = cls.match(/^(?:language-|lang-)?(.+)$/);
if (match && langExtMap[match[1].toLowerCase()]) {{
ext = langExtMap[match[1].toLowerCase()];
break;
}}
}}
const actions = document.createElement('div');
actions.className = 'code-actions';
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy';
copyBtn.onclick = async () => {{
await navigator.clipboard.writeText(text);
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('copied');
setTimeout(() => {{ copyBtn.textContent = 'Copy'; copyBtn.classList.remove('copied'); }}, 2000);
}};
const dlBtn = document.createElement('button');
dlBtn.textContent = 'Download';
dlBtn.onclick = () => {{
const blob = new Blob([text], {{ type: 'text/plain' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `code-${{idx + 1}}.${{ext}}`;
a.click();
URL.revokeObjectURL(url);
}};
actions.appendChild(copyBtn);
actions.appendChild(dlBtn);
pre.appendChild(actions);
}});
// Restructure forum posts into two-column layout
// Looks for pattern: <p>![avatar](url) <strong>username</strong></p> followed by content until <hr>
function restructureForumPosts() {{
const container = document.querySelector('.content-rendered');
if (!container) return;
// Find paragraphs that start with an avatar image followed by bold username
const postStarts = [];
container.querySelectorAll('p').forEach(p => {{
const img = p.querySelector('img[alt="avatar"]');
const strong = p.querySelector('strong');
if (img && strong) {{
postStarts.push({{ p, img, username: strong.textContent }});
}}
}});
if (postStarts.length === 0) return;
// For each post header, create a post-block
postStarts.forEach(({{ p, img, username }}, idx) => {{
if (p.closest('.post-block')) return;
const postBlock = document.createElement('div');
postBlock.className = 'post-block';
// Avatar column - use placeholder if no src
const avatarCol = document.createElement('div');
avatarCol.className = 'post-avatar-col';
const hasAvatar = img.src && !img.src.endsWith('#') && img.getAttribute('src') !== '#';
if (hasAvatar) {{
const avatarImg = img.cloneNode(true);
avatarImg.classList.add('post-avatar');
avatarCol.appendChild(avatarImg);
}} else {{
// Placeholder for posts without avatar
const placeholder = document.createElement('div');
placeholder.className = 'post-avatar-placeholder';
placeholder.textContent = username.charAt(0).toUpperCase();
avatarCol.appendChild(placeholder);
}}
// Content column
const contentCol = document.createElement('div');
contentCol.className = 'post-content';
// Add username header
const header = document.createElement('div');
header.className = 'post-header';
header.innerHTML = `<strong>${{username}}</strong>`;
contentCol.appendChild(header);
// Collect siblings until next post or HR
let sibling = p.nextElementSibling;
const nextP = idx < postStarts.length - 1 ? postStarts[idx + 1].p : null;
while (sibling && sibling !== nextP && sibling.tagName !== 'HR') {{
contentCol.appendChild(sibling.cloneNode(true));
const toRemove = sibling;
sibling = sibling.nextElementSibling;
toRemove.remove();
}}
// Remove the HR separator if present
if (sibling && sibling.tagName === 'HR') {{
sibling.remove();
}}
postBlock.appendChild(avatarCol);
postBlock.appendChild(contentCol);
p.parentNode.insertBefore(postBlock, p);
p.remove();
}});
}}
// Run after DOM ready (avatars may be empty placeholders)
restructureForumPosts();
</script>
{'' if noai else '<script src="https://uncloseai.com/uncloseai.js" type="module"></script>'}
</body>
</html>
"""
@app.get("/phantom/export")
async def phantom_export(domain: str = Query(None, description="Filter by domain")):
"""
Export a phantom HTML site - original HTML with media URLs rewritten to vault.
Creates a downloadable zip of the phantom site ready for static hosting.
"""
import io
import re
import zipfile
from urllib.parse import urlparse, urljoin
from fastapi.responses import StreamingResponse
# Get all pages with raw_html
pages = await db.get_pages_by_domain(domain)
if not pages:
raise HTTPException(status_code=404, detail="No pages with raw HTML found")
# Get all media URL to hash mappings
url_to_hash = await db.get_all_media_uri_mappings()
# Create zip in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
pages_written = 0
media_hashes = set()
for page in pages:
uri = page['uri']
raw_html = page.get('raw_html')
if not raw_html:
continue
# Parse URI to get path
parsed = urlparse(uri)
site_domain = parsed.netloc
path = parsed.path.strip('/') or 'index'
if not path.endswith('.html') and '.' not in path.split('/')[-1]:
path = f"{path}/index.html" if path else "index.html"
# Rewrite media URLs to local paths
html = raw_html
# Find all src and href attributes pointing to media
patterns = [
(r'src=["\']([^"\']+)["\']', 'src'),
(r'href=["\']([^"\']+\.(jpg|jpeg|png|gif|webp|mp4|webm|svg|ico))["\']', 'href'),
]
for pattern, attr in patterns:
matches = re.findall(pattern, html, re.IGNORECASE)
for match in matches:
url = match[0] if isinstance(match, tuple) else match
# Resolve relative URLs
full_url = urljoin(uri, url)
# Check if we have this media
if full_url in url_to_hash:
md5 = url_to_hash[full_url]
media_hashes.add(md5)
# Replace with local path
ext = Path(url).suffix or '.bin'
local_path = f"media/{md5}{ext}"
html = html.replace(f'{attr}="{url}"', f'{attr}="{local_path}"')
html = html.replace(f"{attr}='{url}'", f'{attr}="{local_path}"')
elif url in url_to_hash:
md5 = url_to_hash[url]
media_hashes.add(md5)
ext = Path(url).suffix or '.bin'
local_path = f"media/{md5}{ext}"
html = html.replace(f'{attr}="{url}"', f'{attr}="{local_path}"')
html = html.replace(f"{attr}='{url}'", f'{attr}="{local_path}"')
# Write HTML file
zf.writestr(f"site/{path}", html.encode('utf-8'))
pages_written += 1
# Copy media files from vault
media_copied = 0
for md5 in media_hashes:
subdir = VAULT_PATH / md5[:2]
if subdir.exists():
for f in subdir.iterdir():
if f.name.startswith(md5):
ext = f.suffix or '.bin'
zf.write(f, f"site/media/{md5}{ext}")
media_copied += 1
break
# Write index
index_html = f"""<!DOCTYPE html>
<html>
<head>
<title>Phantom Site - {site_domain}</title>
<style>
body {{ font-family: sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }}
h1 {{ color: #333; }}
ul {{ line-height: 2; }}
a {{ color: #0066cc; }}
</style>
</head>
<body>
<h1>Phantom Site Archive</h1>
<p>Domain: {site_domain}</p>
<p>Pages: {pages_written}</p>
<p>Media: {media_copied}</p>
<h2>Pages</h2>
<ul>
"""
for page in pages[:100]:
parsed = urlparse(page['uri'])
path = parsed.path.strip('/') or 'index'
if not path.endswith('.html') and '.' not in path.split('/')[-1]:
path = f"{path}/index.html" if path else "index.html"
title = page.get('title') or path
index_html += f' <li><a href="{path}">{title}</a></li>\n'
index_html += """ </ul>
</body>
</html>"""
zf.writestr("site/phantom_index.html", index_html.encode('utf-8'))
# Return zip
zip_buffer.seek(0)
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=phantom_{site_domain or 'site'}.zip"}
)
@app.get("/phantom", response_class=HTMLResponse)
async def phantom_page():
"""Phantom site export UI."""
domains = await db.get_domains_with_pages()
domain_options = ''.join([
f'<option value="{d[0]}">{d[0]} ({d[1]} pages)</option>'
for d in domains if d[0]
])
return f"""
<!DOCTYPE html>
<html>
<head>
<title>Phantom Site Export - neopig</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
background: #0a0a0a;
color: #e0e0e0;
}}
.nav {{
background: #1a1a1a;
padding: 10px 20px;
display: flex;
gap: 20px;
align-items: center;
border-bottom: 1px solid #333;
}}
.nav a {{ color: #ff6b6b; text-decoration: none; }}
.nav a:hover {{ text-decoration: underline; }}
.nav .brand {{ font-weight: bold; font-size: 18px; }}
.container {{ padding: 20px; }}
h1 {{ color: #ff6b6b; margin-bottom: 10px; }}
.subtitle {{ color: #888; margin-bottom: 30px; }}
.form-group {{ margin-bottom: 20px; }}
label {{ display: block; margin-bottom: 8px; color: #aaa; }}
select {{
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #333;
border-radius: 8px;
background: #1a1a1a;
color: #fff;
}}
button {{
padding: 14px 28px;
font-size: 16px;
background: #ff6b6b;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}}
button:hover {{ background: #ff5252; }}
.info {{
background: #1a1a1a;
padding: 20px;
border-radius: 8px;
margin-top: 30px;
}}
.info h3 {{ color: #ff6b6b; margin-top: 0; }}
.info ul {{ color: #aaa; line-height: 1.8; }}
</style>
</head>
<body>
<div class="nav">
<a href="/" class="brand">🐷 neopig</a>
<a href="/">Search</a>
<a href="/live">Live</a>
<a href="/random">Random</a>
<a href="/crawl">Crawl</a>
<a href="/phantom">Phantom</a>
</div>
<div class="container">
<form class="search-box" action="/" method="get" style="display:flex;gap:10px;margin-bottom:20px;">
<input type="text" name="q" placeholder="Search keywords, alt text, page content..." style="flex:1;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<select name="type" style="flex:0 0 auto;padding:12px 16px;font-size:16px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#fff;width:auto;">
<option value="">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
<button type="submit" style="flex:0 0 auto;padding:12px 24px;font-size:16px;background:#ff6b6b;color:#fff;border:none;border-radius:8px;cursor:pointer;">Search</button>
</form>
<h1>Phantom Site Export</h1>
<p class="subtitle">Export archived pages as a static site with local media</p>
<form action="/phantom/export" method="get">
<div class="form-group">
<label>Select Domain</label>
<select name="domain">
<option value="">All domains</option>
{domain_options}
</select>
</div>
<button type="submit">Download Phantom Site (.zip)</button>
</form>
<div class="info">
<h3>What is a Phantom Site?</h3>
<ul>
<li>Original HTML preserved exactly as crawled</li>
<li>All media URLs rewritten to local paths</li>
<li>Ready to host statically (nginx, Caddy, S3, etc.)</li>
<li>Works offline - all assets included</li>
<li>Perfect for archival and preservation</li>
</ul>
</div>
</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_endpoint():
"""Get database statistics."""
stats = await db.get_stats()
# Add page count (handled separately since table may not exist)
try:
from sqlalchemy import select, func
from database import Page
async with db.session() as session:
result = await session.execute(select(func.count()).select_from(Page))
stats['total_pages'] = result.scalar() or 0
except Exception:
stats['total_pages'] = 0
return stats
@app.get("/random")
async def random_media():
"""Redirect to a random media item."""
from sqlalchemy import select, func
from database import Media
import random
async with db.session() as session:
# Get a random media item (excluding screenshots)
stmt = (
select(Media.md5_hash)
.where(Media.media_type != 'screenshot')
.order_by(func.random())
.limit(1)
)
result = await session.execute(stmt)
row = result.fetchone()
if row:
return RedirectResponse(url=f"/view/{row[0]}", status_code=302)
else:
return RedirectResponse(url="/", status_code=302)
@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.
"""
results = await db.search_media_advanced(
q=q if q else None,
media_type=type,
limit=limit,
offset=offset
)
return results
@app.get("/api/search/pages")
async def search_pages_endpoint(
q: str = Query("", description="Search query"),
limit: int = Query(50, le=500),
):
"""
Search pages by text query using FTS5.
"""
if not q:
return []
return await db.search_pages(q, limit)
@app.get("/api/media/{md5_hash}")
async def get_media_info(md5_hash: str):
"""Get full media info including all source URLs."""
media = await db.get_media_by_hash(md5_hash)
if not media:
raise HTTPException(status_code=404, detail="Media not found")
media['sources'] = await db.get_media_sources(md5_hash)
return media
def slugify(text: str, max_len: int = 60) -> str:
"""Convert text to a safe filename slug."""
import re
import unicodedata
# Normalize unicode
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii')
# Lowercase and replace spaces/special chars with hyphens
text = re.sub(r'[^\w\s-]', '', text.lower())
text = re.sub(r'[-\s]+', '-', text).strip('-')
return text[:max_len] if text else ""
@app.get("/media/{md5_hash}")
async def serve_media(md5_hash: str, download: bool = False):
"""
Serve media file from vault or tarball.
Use ?download=1 for attachment mode with smart filename.
Caddy should be configured to cache these responses.
"""
# Tarball mode: serve from tar.gz
if TAR_FILE:
data, ext = find_media_in_tarball(md5_hash)
if data:
mime_type, _ = mimetypes.guess_type(f"file{ext}")
if not mime_type:
mime_type = "application/octet-stream"
filename = f"{md5_hash[:12]}{ext}"
headers = {
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Hash": md5_hash,
}
if download:
headers["Content-Disposition"] = f'attachment; filename="{filename}"'
return Response(content=data, media_type=mime_type, headers=headers)
raise HTTPException(status_code=404, detail="Media not found in archive")
# Filesystem mode: 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 and get extension
mime_type, _ = mimetypes.guess_type(f.name)
ext = f.suffix or ""
# Get metadata for filename generation
filename = None
media_record = await db.get_media_by_hash(md5_hash)
if media_record:
if not mime_type and media_record.get("mime_type"):
mime_type = media_record["mime_type"]
# Generate filename from alt_text or title
name_source = media_record.get("alt_text") or media_record.get("title")
if name_source:
slug = slugify(name_source)
if slug:
filename = f"{slug}{ext}"
# Fallback: try to get page_title from media_sources
if not filename:
sources = await db.get_media_sources(md5_hash)
if sources:
row2 = sources[0]
# Try page_title + hash index
if row2.get("page_title"):
media_idx = int(md5_hash[:4], 16)
slug = slugify(f"{row2['page_title']}-{media_idx}")
if slug:
filename = f"{slug}{ext}"
# Fallback: original filename from URL
if not filename and row2.get("media_uri"):
from urllib.parse import urlparse, unquote
parsed = urlparse(row2["media_uri"])
orig_name = Path(unquote(parsed.path)).name
if orig_name and '.' in orig_name:
filename = orig_name
if not mime_type:
mime_type = "application/octet-stream"
# Default filename if nothing else
if not filename:
filename = f"{md5_hash[:12]}{ext}"
# Download mode: attachment with smart filename
# Inline mode: no filename header, browser shows inline
if download:
return FileResponse(
f,
media_type=mime_type,
filename=filename,
content_disposition_type="attachment",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Hash": md5_hash,
}
)
else:
return FileResponse(
f,
media_type=mime_type,
content_disposition_type="inline",
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_endpoint(limit: int = Query(50, le=200)):
"""Get recent crawl jobs."""
return await db.get_crawl_jobs(limit)
@app.get("/api/crawl/jobs/{job_id}")
async def get_crawl_job_endpoint(job_id: int):
"""Get a specific crawl job."""
job = await db.get_crawl_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@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:
job_id = await db.create_crawl_job(target_uri, request.keywords, request.mode)
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
await db.complete_crawl_job(jid, stats)
except Exception as e:
logger.error(f"Crawl job {jid} failed: {e}")
await db.complete_crawl_job(jid, {"error": str(e), "status": "failed"})
# Schedule background task
background_tasks.add_task(asyncio.create_task, run_crawl())
return {"job_ids": job_ids, "status": "running", "count": len(job_ids)}
def init_tarball_mode(tarball_path: str):
"""Initialize serving from a tar.gz archive."""
global TAR_FILE, TAR_MEMBERS, ARCHIVE_ROOT, DB_PATH, TEMP_DB_PATH
logger.info(f"Opening archive: {tarball_path}")
tarball = Path(tarball_path)
# Handle .run files with NEOPIG trailer
offset = 0
if tarball.suffix == '.run' or tarball.stat().st_size > 100000:
try:
with open(tarball, 'rb') as f:
f.seek(-22, 2)
trailer = f.read(22)
if trailer[:6] == b'NEOPIG':
offset = int(trailer[6:22].decode(), 16)
logger.info(f"Detected .run format, tarball offset: {offset}")
except Exception:
pass
# Open tarball
if offset > 0:
# Create a wrapper that presents just the tarball portion
class OffsetFile:
"""File wrapper that starts reading from an offset."""
def __init__(self, path, offset):
self._f = open(path, 'rb')
self._offset = offset
self._f.seek(offset)
def read(self, size=-1):
return self._f.read(size)
def seek(self, pos, whence=0):
if whence == 0: # SEEK_SET
return self._f.seek(self._offset + pos)
elif whence == 1: # SEEK_CUR
return self._f.seek(pos, 1)
else: # SEEK_END
return self._f.seek(pos, 2)
def tell(self):
return self._f.tell() - self._offset
def close(self):
self._f.close()
TAR_FILE = tarfile.open(fileobj=OffsetFile(tarball, offset), mode='r:gz')
else:
TAR_FILE = tarfile.open(tarball_path, 'r:gz')
# Build member lookup
for member in TAR_FILE.getmembers():
TAR_MEMBERS[member.name] = member
# Get archive root from first member
first = list(TAR_MEMBERS.keys())[0]
ARCHIVE_ROOT = first.split('/')[0]
logger.info(f"Archive root: {ARCHIVE_ROOT}")
# Extract database to temp (SQLite needs real file)
db_member = f"{ARCHIVE_ROOT}/archive.db"
if db_member in TAR_MEMBERS:
temp_dir = tempfile.mkdtemp(prefix="neopig_")
TEMP_DB_PATH = f"{temp_dir}/archive.db"
member = TAR_MEMBERS[db_member]
f = TAR_FILE.extractfile(member)
if f:
with open(TEMP_DB_PATH, 'wb') as out:
out.write(f.read())
DB_PATH = TEMP_DB_PATH
logger.info(f"Extracted database to: {TEMP_DB_PATH}")
else:
logger.warning("No archive.db found in tarball")
def main():
global DB_PATH, VAULT_PATH
parser = argparse.ArgumentParser(description="neopig SERP")
parser.add_argument("tarball", nargs='?', help="Path to archive.tar.gz or .run file")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--db", default="data/neopig.db")
parser.add_argument("--vault", default="data/vault")
args = parser.parse_args()
# Tarball mode
if args.tarball:
init_tarball_mode(args.tarball)
logger.info(f"Starting neopig SERP (archive mode) on {args.host}:{args.port}")
else:
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()