pig.py/templates/crawl.html.j2
Russell Ballestrini 3714c9b6a9 Extract common CSS to 5 external stylesheets (DRY)
- base.css: Core layout, typography, container
- nav.css: Navigation bar styles
- forms.css: Form elements, buttons, inputs, checkboxes
- quests.css: CSS-only easter eggs
- results.css: Media grid and result card styles

Templates now link external CSS via extra_css_links block.
Page-specific styles remain inline (correct pattern).
Fixed OVER_9000 test (it's OVER 9000, so 9001).
2026-01-05 20:44:35 -05:00

499 lines
22 KiB
Django/Jinja

{% extends "base.html.j2" %}
<!-- The Law of Fives is never wrong. -->
{% block title %}{{ t.crawl }} - neopig{% endblock %}
{% block extra_css %}
/* Page layout */
h1 { color: #ff6b6b; margin-bottom: 5px; }
.subtitle { color: #666; margin-bottom: 20px; }
.row { display: flex; gap: 15px; }
.row > div { flex: 1; }
input[type="text"], input[type="number"], select, textarea { width: 100%; }
button { margin-top: 10px; }
button.secondary { background: #333; }
button.secondary:hover { background: #444; }
.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; flex-wrap: wrap; }
.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; }
.progress-bar-fill.running {
width: 100%;
background: linear-gradient(90deg, #ff6b6b 0%, #4ecdc4 50%, #ff6b6b 100%);
background-size: 200% 100%;
animation: progress-wave 1.5s ease-in-out infinite;
}
.job-actions { margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; }
.job-actions button { padding: 4px 12px; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; margin-top: 0; }
.pause-btn { background: #e67e22; }
.pause-btn:hover { background: #f39c12; }
.start-btn { background: #27ae60; }
.start-btn:hover { background: #2ecc71; }
.recrawl-btn { background: #3498db; }
.recrawl-btn:hover { background: #2980b9; }
.delete-btn { background: #7f8c8d; }
.delete-btn:hover { background: #c0392b; }
.replay-btn { background: #9b59b6; }
.replay-btn:hover { background: #8e44ad; }
.job-links { margin-left: 10px; font-size: 12px; }
.job-links a { color: #4ecdc4; margin-right: 8px; }
.job-links a:hover { color: #7bed72; }
@keyframes progress-wave {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.job.running { border-color: #4ecdc4; }
.job-stats.live { color: #4ecdc4; }
.no-jobs { color: #666; text-align: center; padding: 30px; }
.console-btn { background: #2c3e50; }
.console-btn:hover { background: #34495e; }
.console-modal {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8); z-index: 1000; padding: 20px;
}
.console-modal.active { display: flex; flex-direction: column; }
.console-header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 15px; background: #1a1a1a; border-radius: 8px 8px 0 0;
}
.console-header h3 { margin: 0; color: #ff6b6b; }
.console-close { background: #c0392b; border: none; color: white; padding: 5px 15px; border-radius: 4px; cursor: pointer; }
.console-body {
flex: 1; background: #0d0d0d; padding: 15px; overflow: auto;
font-family: monospace; font-size: 12px; white-space: pre-wrap;
word-break: break-all; color: #4ade80; border-radius: 0 0 8px 8px;
}
.console-body .log-line { margin: 0; line-height: 1.4; }
.console-body .log-INFO { color: #4ade80; }
.console-body .log-WARNING { color: #f39c12; }
.console-body .log-ERROR { color: #e74c3c; }
{% endblock %}
{% block content %}
<div class="container">
<h1>{{ t.crawl }}</h1>
<p class="subtitle">{{ t.hydrate_subtitle }}</p>
<form id="crawl-form" onsubmit="startCrawl(event)">
<div class="form-group">
<label>{{ t.url }}</label>
<textarea id="target" placeholder="https://example.com https://another.com" rows="2" required></textarea>
</div>
<div class="form-group">
<label>{{ t.keywords }}</label>
<input type="text" id="keywords" placeholder="e.g. rick and morty, adult swim">
</div>
<div class="row">
<div class="form-group">
<label>{{ t.mode }}</label>
<select id="mode">
<option value="all">{{ t.everything }}</option>
<option value="media" selected>{{ t.all_media }}</option>
<option value="images">{{ t.images_only }}</option>
<option value="videos">{{ t.videos_only }}</option>
<option value="text">{{ t.text_only }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t.depth }}</label>
<input type="number" id="depth" value="9" min="-1" max="15">
</div>
<div class="form-group">
<label>{{ t.max_pages }}</label>
<input type="number" id="max_pages" value="-1" min="-1">
</div>
</div>
<div class="form-group checkbox-group" style="display:flex; flex-wrap:wrap; gap:20px;">
<div>
<input type="checkbox" id="fresh" checked>
<label for="fresh" style="display:inline; margin:0;">{{ t.fresh_start }}</label>
</div>
<div>
<input type="checkbox" id="fast">
<label for="fast" style="display:inline; margin:0;">{{ t.fast_mode }}</label>
</div>
<div>
<input type="checkbox" id="screenshots" checked>
<label for="screenshots" style="display:inline; margin:0;">{{ t.screenshots }}</label>
</div>
<div>
<input type="checkbox" id="hydra">
<label for="hydra" style="display:inline; margin:0;" title="Parse RSS/Atom/Sitemap for fast discovery">{{ t.hydra }}</label>
</div>
</div>
<button type="submit" id="start-btn">{{ t.start_crawl }}</button>
</form>
<div class="jobs-section">
<h2>{{ t.recent_jobs }}</h2>
<div id="jobs">{{ t.loading }}</div>
</div>
<div id="console-modal" class="console-modal" onclick="if(event.target===this)closeConsole()">
<div class="console-header">
<h3 id="console-title">{{ t.console }}</h3>
<button class="console-close" onclick="closeConsole()">{{ t.close }}</button>
</div>
<div id="console-logs" class="console-body"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Translation object for JavaScript
const T = {{ t_json|safe }};
const CRAWL_DISABLED = {{ 'true' if crawl_disabled else 'false' }};
// Disable form when crawling is disabled
if (CRAWL_DISABLED) {
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('crawl-form');
const inputs = form.querySelectorAll('input, select, textarea, button');
inputs.forEach(el => {
el.disabled = true;
el.style.opacity = '0.5';
el.style.cursor = 'not-allowed';
});
const banner = document.createElement('div');
banner.style.cssText = 'background:#442;border:1px solid #664;padding:12px 20px;border-radius:8px;margin-bottom:20px;color:#ffa;';
banner.innerHTML = T.crawl_disabled;
form.parentNode.insertBefore(banner, form);
});
}
async function startCrawl(e) {
e.preventDefault();
if (CRAWL_DISABLED) return;
const btn = document.getElementById('start-btn');
btn.disabled = true;
btn.textContent = T.starting;
const keywordsRaw = document.getElementById('keywords').value;
const keywords = keywordsRaw.split(/[,\s]+/).map(k => k.trim()).filter(k => k.length > 0);
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(T.valid_uri);
btn.disabled = false;
btn.textContent = T.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),
fresh: document.getElementById('fresh').checked,
fast: document.getElementById('fast').checked,
screenshots: document.getElementById('screenshots').checked,
hydra: document.getElementById('hydra').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();
btn.textContent = T.error + ': ' + (err.detail || T.load_error);
btn.style.background = '#c0392b';
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 3000);
} else {
document.getElementById('target').value = '';
document.getElementById('keywords').value = '';
btn.textContent = T.started_ok;
btn.style.background = '#27ae60';
loadJobs();
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 2000);
}
} catch (err) {
btn.textContent = T.error;
btn.style.background = '#c0392b';
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 3000);
}
btn.disabled = false;
}
async function pauseJob(jobId) {
try {
const res = await fetch('/api/crawl/jobs/' + jobId + '/pause', { method: 'POST' });
if (res.ok) loadJobs();
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
} catch (err) { alert(T.error + ': ' + err.message); }
}
async function resumeJob(jobId) {
try {
const res = await fetch('/api/crawl/jobs/' + jobId + '/resume', { method: 'POST' });
if (res.ok) loadJobs();
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
} catch (err) { alert(T.error + ': ' + err.message); }
}
function loadJobSettings(job) {
document.getElementById('target').value = job.target_uri || '';
document.getElementById('keywords').value = (JSON.parse(job.keywords || '[]')).join(', ');
document.getElementById('mode').value = job.mode || 'all';
document.getElementById('depth').value = job.depth || 15;
document.getElementById('max_pages').value = job.max_pages || -1;
document.getElementById('fast').checked = !!job.fast;
document.getElementById('screenshots').checked = job.screenshots !== 0;
document.getElementById('fresh').checked = false;
document.getElementById('target').scrollIntoView({ behavior: 'smooth' });
document.getElementById('target').focus();
}
async function deleteJob(jobId) {
if (!confirm(T.delete + '?')) return;
try {
const res = await fetch('/api/crawl/jobs/' + jobId, { method: 'DELETE' });
if (res.ok) loadJobs();
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
} catch (err) { alert(T.error + ': ' + err.message); }
}
// Track active replays to prevent double-clicking
const activeReplays = new Set();
function replayJob(jobId) {
// Prevent duplicate replays of same job
if (activeReplays.has(jobId)) {
alert(T.replay_already_running + ' #' + jobId);
return;
}
activeReplays.add(jobId);
// Update button to show active state
const btn = document.querySelector(`button[onclick="replayJob(${jobId})"]`);
if (btn) {
btn.textContent = T.replaying;
btn.disabled = true;
}
// Open live feed page with replay parameter
const replayWindow = window.open('/live?replay=' + jobId, '_blank');
// Listen for window close to reset state (works for same-origin)
const checkClosed = setInterval(() => {
if (replayWindow && replayWindow.closed) {
clearInterval(checkClosed);
activeReplays.delete(jobId);
if (btn) {
btn.textContent = T.replay;
btn.disabled = false;
}
}
}, 1000);
// Auto-reset after 5 minutes as fallback
setTimeout(() => {
clearInterval(checkClosed);
activeReplays.delete(jobId);
if (btn) {
btn.textContent = T.replay;
btn.disabled = false;
}
}, 300000);
}
// Recrawl all sources from an import job (async background crawls)
async function recrawlSources(jobId, sourceCount) {
if (!confirm(T.confirm_recrawl + '\n\n' + T.recrawl_async_note)) {
return;
}
const btn = document.querySelector(`button[onclick="recrawlSources(${jobId}, ${sourceCount})"]`);
if (btn) {
btn.textContent = T.starting;
btn.disabled = true;
}
try {
const res = await fetch('/api/crawl/jobs/' + jobId + '/recrawl-sources', { method: 'POST' });
const data = await res.json();
if (res.ok) {
alert(T.started_jobs + '\n\nJob IDs: ' + data.job_ids.join(', '));
loadJobs(); // Refresh to show new jobs
} else {
alert(T.error + ': ' + (data.detail || T.load_error));
}
} catch (err) {
alert(T.error + ': ' + err.message);
} finally {
if (btn) {
btn.textContent = T.recrawl_sources + ' (' + sourceCount + ')';
btn.disabled = false;
}
}
}
function formatBytes(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
return (bytes/1024/1024/1024).toFixed(1) + ' GB';
}
function formatDuration(startedAt, completedAt) {
if (!startedAt || !completedAt) return '';
const sec = Math.round((new Date(completedAt) - new Date(startedAt)) / 1000);
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}
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">' + T.no_jobs + '</div>';
return;
}
const hasRunning = jobs.some(j => j.status === 'running');
container.innerHTML = jobs.map(job => renderJob(job)).join('');
setTimeout(loadJobs, hasRunning ? 3000 : 10000);
} catch (err) {
console.error('loadJobs error:', err);
document.getElementById('jobs').innerHTML = '<div class="no-jobs">' + T.load_jobs_error + ': ' + err.message + '</div>';
setTimeout(loadJobs, 10000);
}
}
function renderJob(job) {
let stats = {}, keywords = [];
try { stats = job.stats ? JSON.parse(job.stats) : {}; } catch(e) {}
try { keywords = job.keywords ? JSON.parse(job.keywords) : []; } catch(e) {}
const isBackfill = job.job_kind === 'backfill';
const kindLabel = isBackfill ? '🔄 ' + job.job_type : '🕷️ ' + (job.mode || 'all');
if (isBackfill) {
const progress = job.total_records > 0 ? Math.round((job.processed_records / job.total_records) * 100) : 0;
return `<div class="job ${job.status}">
<div class="job-header"><span class="job-id">${kindLabel} #${job.id}</span><span class="job-status ${job.status}">${T[job.status] || job.status}</span></div>
<div class="job-target">${job.domain_filter || T.all_domains}</div>
<div class="job-meta">${job.processed_records}/${job.total_records} | ${job.error_count} ${T.error} | ${T.started}: ${new Date(job.started_at).toLocaleString()}</div>
${job.status === 'running' ? '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + progress + '%"></div></div>' : ''}
</div>`;
}
const crawled = stats.pages_crawled || 0, pending = stats.pages_pending || 0;
const crawlProgress = (crawled + pending) > 0 ? Math.round((crawled / (crawled + pending)) * 100) : 0;
const calcEta = () => {
const started = stats.crawl_started || 0;
if (crawled === 0 || started === 0 || pending === 0) return '';
const elapsed = Date.now() / 1000 - started;
if (elapsed < 5) return '';
const etaSec = pending / (crawled / elapsed);
if (etaSec < 60) return '~' + Math.round(etaSec) + 's';
if (etaSec < 3600) return '~' + Math.round(etaSec / 60) + 'm';
return '~' + (etaSec / 3600).toFixed(1) + 'h';
};
const liveStats = job.status === 'running' ? `<div class="job-stats live">
<span>📄 ${crawled}${pending ? '/' + (crawled + pending) : ''} pages${calcEta() ? ' ⏱️' + calcEta() : ''}</span>
<span>🖼️ ${stats.media_found || 0} found</span><span>💾 ${stats.media_downloaded || 0} saved</span>
<span>📸 ${stats.screenshots_taken || 0}</span><span>♻️ ${(stats.duplicates_skipped || 0) + (stats.content_exists || 0)} dupes</span>
${stats.bytes_stored ? '<span>📦 ' + formatBytes(stats.bytes_stored) + '</span>' : ''}
</div>` : '';
let jobLinks = '';
try { const h = new URL(job.target_uri).hostname; jobLinks = `<span class="job-links"><a href="/?q=${encodeURIComponent(h)}">${T.search}</a> <a href="/live?domain=${encodeURIComponent(h)}">${T.live}</a></span>`; } catch(e) {}
return `<div class="job ${job.status}">
<div class="job-header"><span class="job-id">${kindLabel} #${job.id}</span><span class="job-status ${job.status}">${T[job.status] || job.status}</span></div>
<div class="job-target"><a href="${job.target_uri}" target="_blank">${job.target_uri}</a>${jobLinks}</div>
<div class="job-meta">${keywords.length ? T.filter + ': ' + keywords.join(', ') + ' | ' : ''}${T.started}: ${new Date(job.started_at).toLocaleString()}</div>
${job.status === 'completed' ? `<div class="job-stats">
<span>⏱️ ${formatDuration(job.started_at, job.completed_at)}</span><span>📄 ${crawled} pages</span>
<span>🖼️ ${stats.media_found || 0}</span><span>💾 ${stats.media_downloaded || 0}</span>
<span>📸 ${stats.screenshots_taken || 0}</span><span>♻️ ${(stats.duplicates_skipped || 0) + (stats.content_exists || 0)}</span>
${stats.bytes_stored ? '<span>📦 ' + formatBytes(stats.bytes_stored) + '</span>' : ''}
</div>` : ''}
${liveStats}
${job.status === 'running' && job.job_kind === 'crawl' ? '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + crawlProgress + '%"></div></div>' : ''}
<div class="job-actions">
${job.status === 'running' && job.job_kind === 'crawl' ? '<button class="pause-btn" onclick="pauseJob(' + job.id + ')">' + T.pause + '</button>' : ''}
${(job.status === 'paused' || job.status === 'cancelled') && job.job_kind === 'crawl' ? '<button class="start-btn" onclick="resumeJob(' + job.id + ')">' + T.resume + '</button>' : ''}
${(job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') && job.job_kind === 'crawl' && job.mode !== 'import' ? "<button class=\"recrawl-btn\" onclick='loadJobSettings(" + JSON.stringify(job) + ")'>↻</button>" : ''}
${job.status === 'completed' && job.job_kind === 'crawl' ? '<button class="replay-btn" onclick="replayJob(' + job.id + ')">' + T.replay + '</button>' : ''}
${job.status === 'completed' && job.mode === 'import' && stats.imported_sources?.length ? '<button class="recrawl-btn" onclick="recrawlSources(' + job.id + ', ' + stats.imported_sources.length + ')">' + T.recrawl_sources + ' (' + stats.imported_sources.length + ')</button>' : ''}
<button class="console-btn" onclick="openConsole(${job.id})">${T.console}</button>
${job.status !== 'running' ? '<button class="delete-btn" onclick="deleteJob(' + job.id + ')">' + T.delete + '</button>' : ''}
</div>
</div>`;
}
loadJobs();
let consoleJobId = null, consoleInterval = null;
function openConsole(jobId) {
consoleJobId = jobId;
document.getElementById('console-modal').classList.add('active');
document.getElementById('console-title').textContent = T.console + ' #' + jobId;
document.getElementById('console-logs').textContent = T.loading;
fetchLogs();
consoleInterval = setInterval(fetchLogs, 2000);
}
function closeConsole() {
document.getElementById('console-modal').classList.remove('active');
if (consoleInterval) { clearInterval(consoleInterval); consoleInterval = null; }
consoleJobId = null;
}
async function fetchLogs() {
if (!consoleJobId) return;
try {
const res = await fetch('/api/crawl/jobs/' + consoleJobId + '/logs');
const logs = await res.text();
const el = document.getElementById('console-logs');
const wasAtBottom = el.scrollHeight - el.clientHeight <= el.scrollTop + 50;
el.innerHTML = logs ? logs.split('\n').map(line => {
let cls = 'log-line';
if (line.includes(' INFO:')) cls += ' log-INFO';
else if (line.includes(' WARNING:')) cls += ' log-WARNING';
else if (line.includes(' ERROR:')) cls += ' log-ERROR';
return '<div class="' + cls + '">' + line.replace(/</g, '&lt;') + '</div>';
}).join('') : T.no_logs;
if (wasAtBottom) el.scrollTop = el.scrollHeight;
} catch (err) {
document.getElementById('console-logs').textContent = T.error + ': ' + err.message;
}
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConsole(); });
</script>
{% endblock %}