All source files from Capture Server v1 are listed below as formatted code blocks. This page is designed for another AI agent to read — share the URL and it can review every file, check for issues, and suggest improvements.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Capture — Full Page Screenshot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0a0a0f;--bg2:#12121a;--bg3:#1a1a2e;--surface:#1e1e32;--border:#2a2a44;--accent:#818cf8;--accent2:#6366f1;--accent3:#4f46e5;--green:#34d399;--amber:#fbbf24;--pink:#f472b6;--text:#e2e8f0;--muted:#8892a4;--radius:12px;--radius-sm:8px}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;overflow-x:hidden;min-height:100vh}
/* Noise */
.noise{position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:.02;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
/* Animations */
@keyframes fadeUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes scaleIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
@keyframes blob{0%,100%{border-radius:60% 40% 30% 70%/60% 30% 70% 40%}50%{border-radius:30% 60% 70% 40%/50% 60% 30% 60%}}
@keyframes cursor{0%,100%{opacity:1}50%{opacity:0}}
@keyframes shimmer{to{background-position:200% 0}}
@keyframes spin{to{transform:rotate(360deg)}}
.anim-fade{animation:fadeUp .7s ease-out both}
.anim-fade-d1{animation-delay:.1s}
.anim-fade-d2{animation-delay:.2s}
.anim-fade-d3{animation-delay:.3s}
.anim-scale{animation:scaleIn .6s ease-out both}
/* Hero */
.hero{position:relative;padding:80px 24px 60px;text-align:center;overflow:hidden}
.hero .bg-blobs{position:absolute;inset:0;overflow:hidden}
.hero .bg-blobs .blob{position:absolute;width:500px;height:500px;border-radius:50%;filter:blur(100px);opacity:.1}
.hero .bg-blobs .b1{top:-200px;left:-200px;background:var(--accent);animation:blob 8s ease-in-out infinite}
.hero .bg-blobs .b2{bottom:-300px;right:-200px;background:var(--accent3);animation:blob 10s ease-in-out infinite reverse}
.hero .bg-blobs .b3{top:50%;left:50%;transform:translate(-50%,-50%);width:700px;height:700px;background:var(--accent2);opacity:.05;animation:blob 12s ease-in-out infinite}
.hero h1{font-size:clamp(32px,5vw,56px);font-weight:800;letter-spacing:-1.5px;position:relative;z-index:1;line-height:1.08}
.hero h1 .highlight{background:linear-gradient(135deg,var(--accent),var(--accent2),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.hero p{font-size:16px;color:var(--muted);margin-top:12px;position:relative;z-index:1;max-width:500px;margin-left:auto;margin-right:auto}
/* Input area */
.capture-section{position:relative;z-index:1;max-width:800px;margin:0 auto;padding:0 24px 60px}
.input-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:32px;box-shadow:0 4px 24px rgba(0,0,0,.2)}
.input-label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:6px}
.input-row{display:flex;gap:10px;align-items:stretch}
.input-row input[type="text"]{flex:1;padding:12px 16px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;font-family:'Inter',system-ui,sans-serif;outline:none;transition:all .2s}
.input-row input[type="text"]::placeholder{color:rgba(255,255,255,.2)}
.input-row input[type="text"]:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(129,140,248,.1)}
.input-row input[type="text"].error{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
.capture-btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all .25s;white-space:nowrap;font-family:'Inter',system-ui,sans-serif}
.capture-btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(99,102,241,.35)}
.capture-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.capture-btn .spinner{display:none;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite}
.capture-btn.loading .spinner{display:inline-block}
.capture-btn.loading .btn-text{display:none}
/* Options row */
.options-row{display:flex;gap:20px;margin-top:14px;flex-wrap:wrap}
.opt-group{display:flex;align-items:center;gap:8px}
.opt-group label{font-size:12px;color:var(--muted)}
.opt-group select,.opt-group input{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px 8px;color:var(--text);font-size:12px;font-family:'Inter',system-ui,sans-serif;outline:none}
.opt-group select:focus,.opt-group input:focus{border-color:var(--accent)}
.opt-group input[type="number"]{width:64px}
/* Status bar */
.status-bar{display:none;align-items:center;gap:10px;margin-top:12px;padding:10px 14px;border-radius:var(--radius-sm);font-size:12px}
.status-bar.show{display:flex}
.status-bar.info{background:rgba(129,140,248,.08);border:1px solid rgba(129,140,248,.15);color:var(--accent)}
.status-bar.success{background:rgba(52,211,153,.08);border:1px solid rgba(52,211,153,.15);color:var(--green)}
.status-bar.error{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.15);color:#f87171}
.status-bar .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.status-bar.info .status-dot{background:var(--accent);animation:pulse 1.5s ease-in-out infinite}
.status-bar.success .status-dot{background:var(--green)}
.status-bar.error .status-dot{background:#ef4444}
.status-bar .status-close{margin-left:auto;cursor:pointer;opacity:.5;font-size:14px;background:none;border:none;color:inherit;font-family:inherit}
.status-bar .status-close:hover{opacity:1}
/* Result area */
.result-area{display:none;margin-top:24px;animation:fadeUp .5s ease-out}
.result-area.show{display:block}
.result-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.result-meta{display:flex;gap:16px;padding:14px 20px;border-bottom:1px solid var(--border);flex-wrap:wrap;font-size:12px;color:var(--muted);align-items:center}
.result-meta .meta-item{display:flex;align-items:center;gap:4px}
.result-meta .meta-item strong{color:var(--text);font-weight:600}
.result-meta .spacer{flex:1}
.result-meta .download-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(129,140,248,.1);color:var(--accent);font-size:11px;font-weight:500;text-decoration:none;border:1px solid rgba(129,140,248,.2);transition:all .15s}
.result-meta .download-btn:hover{background:rgba(129,140,248,.18)}
.result-meta .copy-url-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(255,255,255,.04);color:var(--muted);font-size:11px;font-weight:500;border:1px solid var(--border);cursor:pointer;font-family:'Inter',system-ui,sans-serif;transition:all .15s}
.result-meta .copy-url-btn:hover{color:var(--text);border-color:var(--accent);background:rgba(129,140,248,.08)}
.result-image-wrap{position:relative;overflow:auto;max-height:80vh;background:repeating-conic-gradient(rgba(255,255,255,.03) 0% 25%,transparent 0% 50%) 0 0/20px 20px}
.result-image-wrap img{display:block;width:100%;max-width:none}
.result-image-wrap .loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg2);font-size:13px;color:var(--muted);gap:8px;z-index:2}
.result-image-wrap .loading-overlay .spinner-s{width:14px;height:14px;border:2px solid rgba(255,255,255,.1);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}
/* Divider */
.divider{display:flex;align-items:center;gap:12px;padding:20px 0;color:var(--border);font-size:10px;text-transform:uppercase;letter-spacing:1px}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
/* Footer */
.footer{text-align:center;padding:40px 24px 60px;font-size:12px;color:var(--muted)}
.footer a{color:var(--accent);text-decoration:none}
.footer a:hover{text-decoration:underline}
/* Responsive */
@media(max-width:640px){
.input-row{flex-direction:column}
.capture-btn{justify-content:center}
.options-row{flex-direction:column;gap:10px}
}
</style>
</head>
<body>
<div class="noise"></div>
<div class="hero anim-fade">
<div class="bg-blobs">
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="blob b3"></div>
</div>
<h1><span class="highlight">Capture</span></h1>
<p>Full-page screenshots of any URL — scroll, stitch, done.</p>
</div>
<div class="capture-section anim-fade anim-fade-d1">
<div class="input-card">
<div class="input-label">🔗 URL to capture</div>
<div class="input-row">
<input type="text" id="urlInput" placeholder="https://example.com" onkeydown="if(event.key==='Enter') startCapture()">
<button class="capture-btn" id="captureBtn" onclick="startCapture()">
<span class="spinner"></span>
<span class="btn-text">📸 Capture</span>
</button>
</div>
<div class="options-row">
<div class="opt-group">
<label>Width</label>
<select id="widthSelect">
<option value="1440">1440px (Desktop)</option>
<option value="1280">1280px</option>
<option value="1024">1024px (Tablet)</option>
<option value="768">768px</option>
<option value="414">414px (Mobile)</option>
</select>
</div>
<div class="opt-group">
<label>Wait</label>
<select id="waitSelect">
<option value="500">500ms</option>
<option value="1500" selected>1.5s</option>
<option value="3000">3s</option>
<option value="5000">5s</option>
</select>
</div>
</div>
<div class="status-bar" id="statusBar">
<span class="status-dot"></span>
<span class="status-text" id="statusText">Ready</span>
<button class="status-close" onclick="hideStatus()">✕</button>
</div>
</div>
<div class="result-area" id="resultArea">
<div class="result-card">
<div class="result-meta" id="resultMeta"></div>
<div class="result-image-wrap" id="resultImageWrap">
<div class="loading-overlay" id="imgLoading">
<span class="spinner-s"></span>
Loading image...
</div>
<img id="resultImage" src="" alt="Screenshot" onload="document.getElementById('imgLoading').style.display='none'">
</div>
</div>
</div>
</div>
<div class="divider anim-fade anim-fade-d2">powered by Playwright</div>
<div class="footer anim-fade anim-fade-d2">
<a href="https://preview.aivibehosting.com">Preview Dashboard</a> ·
<a href="https://pcm.aivibehosting.com">PCM</a>
</div>
<script>
function showStatus(msg, type) {
const bar = document.getElementById('statusBar');
const text = document.getElementById('statusText');
bar.className = 'status-bar show ' + type;
text.textContent = msg;
}
function hideStatus() {
document.getElementById('statusBar').classList.remove('show');
}
function copyUrl(filename) {
const url = window.location.origin + '/screenshots/' + filename;
navigator.clipboard.writeText(url).then(() => {
showStatus('🔗 URL copied to clipboard!', 'success');
}).catch(() => {
// Fallback
const ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showStatus('🔗 URL copied!', 'success');
});
}
async function startCapture() {
const url = document.getElementById('urlInput').value.trim();
const btn = document.getElementById('captureBtn');
const width = parseInt(document.getElementById('widthSelect').value);
const waitMs = parseInt(document.getElementById('waitSelect').value);
const input = document.getElementById('urlInput');
if (!url) {
input.classList.add('error');
showStatus('Please enter a URL', 'error');
setTimeout(() => input.classList.remove('error'), 2000);
return;
}
// Basic URL validation
if (!url.startsWith('http://') && !url.startsWith('https://')) {
input.classList.add('error');
showStatus('URL must start with http:// or https://', 'error');
setTimeout(() => input.classList.remove('error'), 2000);
return;
}
btn.classList.add('loading');
btn.disabled = true;
showStatus('Capturing ' + url + ' ...', 'info');
try {
const res = await fetch('/api/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, width, wait_ms: waitMs }),
});
const data = await res.json();
if (!data.success) {
showStatus(data.error || 'Capture failed', 'error');
btn.classList.remove('loading');
btn.disabled = false;
return;
}
// Show result
const resultArea = document.getElementById('resultArea');
const resultMeta = document.getElementById('resultMeta');
const resultImage = document.getElementById('resultImage');
const imgLoading = document.getElementById('imgLoading');
resultMeta.innerHTML = `
<span class="meta-item">📄 <strong>\${data.title}</strong></span>
<span class="meta-item">📐 \${data.scroll_height}px × \${data.viewport_width}px</span>
<span class="meta-item">🧩 \${data.stitched} panels</span>
<span class="meta-item">💾 \${data.file_size_kb}KB</span>
<span class="spacer"></span>
<button class="copy-url-btn" onclick="copyUrl('\${data.filename}')">🔗 Copy URL</button>
<a class="download-btn" href="/screenshots/\${data.filename}" download="\${data.filename}">⬇ Download PNG</a>
\
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const { chromium } = require('playwright');
const PORT = 3005;
const SCREENSHOT_DIR = '/var/www/capture/screenshots';
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
// Auto-clean screenshots older than 24 hours to prevent disk exhaustion
setInterval(() => {
fs.readdir(SCREENSHOT_DIR, (err, files) => {
if (err) return;
const now = Date.now();
for (const file of files) {
const fp = path.join(SCREENSHOT_DIR, file);
try {
const stat = fs.statSync(fp);
if (stat.isFile() && now - stat.mtimeMs > 24 * 60 * 60 * 1000) {
fs.unlinkSync(fp);
}
} catch {}
}
});
}, 60 * 60 * 1000); // Run once per hour
// ── MIME types ────────────────────────────────────────────────────────────
const MIME = {
'.html': 'text/html; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
};
function sendFile(res, filePath) {
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
const stream = fs.createReadStream(filePath);
stream.on('open', () => {
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': 'no-cache, no-store, must-revalidate',
});
stream.pipe(res);
});
stream.on('error', () => {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
});
}
function sendJSON(res, data, status = 200) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(data));
}
function sendHTML(res, html, status = 200) {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
}
// ── Capture via Playwright ────────────────────────────────────────────────
// Safe evaluate wrapper: retries if navigation destroys the execution context
async function safeEvaluate(page, fn, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await page.evaluate(fn);
} catch (err) {
const msg = (err && err.message) || '';
if (msg.includes('Execution context was destroyed') || msg.includes('Target closed')) {
if (attempt < maxRetries - 1) {
// Page navigated — wait for new page to stabilize, then retry
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(500);
continue;
}
}
throw err; // Non-navigation error or exhausted retries — let caller decide
}
}
}
async function capturePage(targetUrl, width = 1440, waitMs = 1500) {
const safeName = `capture_${Date.now()}`;
const filename = `${safeName}.png`;
const outputPath = path.join(SCREENSHOT_DIR, filename);
// Flexible Chrome path: fallback to Playwright default binary if missing
const customChromePath = '/usr/bin/google-chrome-stable';
const launchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
};
if (fs.existsSync(customChromePath)) {
launchOptions.executablePath = customChromePath;
}
const browser = await chromium.launch(launchOptions);
try {
const context = await browser.newContext({
viewport: { width, height: 900 },
deviceScaleFactor: 1,
userAgent:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
});
const page = await context.newPage();
// ── Step 1: Navigate and wait for FULL load ──
await page.goto(targetUrl, { waitUntil: 'load', timeout: 60000 });
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 15000 }).catch(() => {});
// ── Step 2: Wait for initial images to finish loading ──
await page.waitForFunction(() => {
const imgs = Array.from(document.images);
return imgs.every(img => img.complete);
}, { timeout: 15000 }).catch(() => {});
// ── Step 3: Force all lazy images to load before scrolling ──
// WordPress uses native loading="lazy" which doesn't trigger reliably
// in headless Chrome during fast scroll passes.
await page.evaluate(() => {
document.querySelectorAll('img[loading="lazy"], img[data-lazy], iframe[loading="lazy"]').forEach(el => {
el.setAttribute('loading', 'eager');
// Force loading if the image hasn't started yet
if (el.tagName === 'IMG' && !el.complete) {
const src = el.getAttribute('src');
if (src) {
el.src = ''; // Clear to force re-request
el.src = src; // Re-set to trigger immediate load
}
}
});
});
// Wait for images to actually start loading after removing lazy
await page.waitForFunction(() => {
const imgs = Array.from(document.images);
return imgs.length > 0 && imgs.some(img => !img.complete) === false;
}, { timeout: 30000 }).catch(() => {});
// ── Step 4: Force page builder animations to reveal hidden content ──
// Elementor and other page builders hide content with CSS classes
// (elementor-invisible, animated-slow, etc.) and reveal it via
// scroll-triggered JavaScript. In headless Chrome these don't fire
// reliably, so we force all hidden content visible.
await page.evaluate(() => {
// Remove Elementor invisibility
document.querySelectorAll('.elementor-invisible').forEach(el => {
el.classList.remove('elementor-invisible');
el.style.visibility = 'visible';
el.style.opacity = '1';
});
// Force all animated elements to final state
document.querySelectorAll('[class*="animated"], [data-settings*="animation"]').forEach(el => {
el.classList.remove('animated-slow');
el.style.animation = 'none';
el.style.opacity = '1';
el.style.visibility = 'visible';
});
// Force WPBakery / other builders
document.querySelectorAll('.vc_hidden, .wpb_animate_when_almost_visible').forEach(el => {
el.classList.remove('vc_hidden', 'wpb_animate_when_almost_visible');
el.style.visibility = 'visible';
el.style.opacity = '1';
});
// Unhide any hidden parent containers that might trap content
document.querySelectorAll('[style*="display: none"], [style*="display:none"]').forEach(el => {
el.style.display = '';
});
// Force zero-height containers (common in Elementor footer animations)
// to auto-height so their content renders
// First, force a reflow so measurements are accurate
document.body.offsetHeight;
// Directly target footer and its children
const footer = document.querySelector('footer');
if (footer) {
footer.style.height = 'auto';
footer.style.minHeight = 'auto';
footer.style.overflow = 'visible';
}
// Then sweep all elements for any remaining zero-height containers
document.querySelectorAll('*').forEach(el => {
if (el.offsetHeight === 0 && el.scrollHeight > 0) {
el.style.height = 'auto';
el.style.minHeight = 'auto';
el.style.overflow = 'visible';
}
});
// Force another reflow
document.body.offsetHeight;
});
await page.waitForTimeout(500);
// ── Step 5: Force re-layout after revealing hidden content ──
await page.evaluate(() => {
// Trigger a layout recalculation
document.body.offsetHeight;
// Dispatch scroll event to trigger any remaining IntersectionObservers
window.dispatchEvent(new Event('scroll'));
});
await page.waitForTimeout(300);
// ── Step 6: Slow scroll — pause at every viewport until stable ──
// Instead of fast-scrolling, we pause at each scroll position and wait
// for DOM mutations to stop. This lets Elementor animations, lazy images,
// and JavaScript widgets fully render at each section before moving on.
await page.evaluate(() => {
return new Promise((resolve) => {
const viewportH = window.innerHeight;
const MAX_STEPS = 20;
let step = 0;
// Wait-for-stable helper: resolves after `ms` of no DOM changes
const waitForStable = (ms = 3000) => {
return new Promise((stableResolve) => {
let lastChange = Date.now();
const observer = new MutationObserver(() => { lastChange = Date.now(); });
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'src', 'loading'] });
const check = setInterval(() => {
if (Date.now() - lastChange >= ms) {
observer.disconnect();
clearInterval(check);
stableResolve();
}
}, 250);
// Safety: always proceed after 10s at this position
setTimeout(() => { observer.disconnect(); clearInterval(check); stableResolve(); }, 10000);
});
};
const doStep = async () => {
if (step >= MAX_STEPS) return resolve();
const targetY = step * viewportH;
const totalHeight = Math.max(
document.body ? document.body.scrollHeight : 0,
document.documentElement ? document.documentElement.scrollHeight : 0,
);
// Stop if we've scrolled past total content
if (targetY > totalHeight) {
window.scrollTo(0, totalHeight);
await waitForStable(3000);
return resolve();
}
window.scrollTo(0, targetY);
await waitForStable(3000);
step++;
setTimeout(() => doStep(), 100);
};
window.scrollTo(0, 0);
setTimeout(() => doStep(), 100);
});
});
// Final settle at the very bottom
await page.evaluate(() => {
const totalHeight = Math.max(
document.body ? document.body.scrollHeight : 0,
document.documentElement ? document.documentElement.scrollHeight : 0,
);
window.scrollTo(0, totalHeight);
});
await page.waitForTimeout(500);
// ── Step 7: Measure final dimensions (after everything rendered) ──
const pageInfo = await safeEvaluate(page, () => ({
title: document.title,
scrollHeight: Math.max(
document.body ? document.body.scrollHeight : 0,
document.documentElement ? document.documentElement.scrollHeight : 0,
(document.scrollingElement || document.documentElement || {}).scrollHeight || 0,
),
viewportHeight: window.innerHeight,
})).catch(() => ({ title: targetUrl, scrollHeight: 1080, viewportHeight: 900 }));
await page.screenshot({ path: outputPath, fullPage: true });
const fileSizeKb = Math.round(fs.statSync(outputPath).size / 1024);
// ── Quality check: compare HTML content density vs screenshot ──
const pageSummary = await safeEvaluate(page, () => {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6').length;
const paragraphs = document.querySelectorAll('p').length;
const images = document.querySelectorAll('img').length;
const links = document.querySelectorAll('a').length;
const textLen = (document.body ? document.body.innerText || '' : '').length;
return { headings, paragraphs, images, links, textLen };
}).catch(() => ({ headings: 0, paragraphs: 0, images: 0, links: 0, textLen: 0 }));
// Calculate density: KB per 1000px of scroll height
const densityKbPer1000px = pageInfo.scrollHeight > 0
? (fileSizeKb / pageInfo.scrollHeight) * 1000
: 0;
const quality = { densityKbPer1000px: Math.round(densityKbPer1000px * 10) / 10 };
// Flag suspicious captures: content-rich HTML but blank/small screenshot
const hasRichHtml = pageSummary.textLen > 500 || pageSummary.headings > 3 || pageSummary.images > 5;
const seemsBlank = densityKbPer1000px < 50 && pageInfo.scrollHeight > 1500;
if (hasRichHtml && seemsBlank) {
quality.warning = 'HIGH';
quality.reason = `Page has ${pageSummary.headings} headings, ${pageSummary.images} images, ${(pageSummary.textLen / 1000).toFixed(1)}K chars but screenshot is only ${fileSizeKb}KB for ${pageInfo.scrollHeight}px — lazy content may not have rendered. Try increasing wait time.`;
} else if (seemsBlank) {
quality.warning = 'MEDIUM';
quality.reason = `Screenshot is ${fileSizeKb}KB for ${pageInfo.scrollHeight}px (${densityKbPer1000px.toFixed(1)} KB/1000px) — page may be sparse or capture may have missed content.`;
} else {
quality.warning = 'NONE';
}
return {
filename,
outputPath,
fileSizeKb,
title: pageInfo.title,
scrollHeight: pageInfo.scrollHeight,
viewportHeight: pageInfo.viewportHeight,
stitched: Math.ceil(pageInfo.scrollHeight / pageInfo.viewportHeight),
quality,
};
} finally {
// Guaranteed cleanup — prevents zombie Chrome processes
await browser.close();
}
}
// ── HTML Page ─────────────────────────────────────────────────────────────
const HTML_PAGE = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Capture — Full Page Screenshot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0a0a0f;--bg2:#12121a;--bg3:#1a1a2e;--surface:#1e1e32;--border:#2a2a44;--accent:#818cf8;--accent2:#6366f1;--accent3:#4f46e5;--green:#34d399;--amber:#fbbf24;--pink:#f472b6;--text:#e2e8f0;--muted:#8892a4;--radius:12px;--radius-sm:8px}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;overflow-x:hidden;min-height:100vh}
/* Noise */
.noise{position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:.02;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
/* Animations */
@keyframes fadeUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes scaleIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
@keyframes blob{0%,100%{border-radius:60% 40% 30% 70%/60% 30% 70% 40%}50%{border-radius:30% 60% 70% 40%/50% 60% 30% 60%}}
@keyframes cursor{0%,100%{opacity:1}50%{opacity:0}}
@keyframes shimmer{to{background-position:200% 0}}
@keyframes spin{to{transform:rotate(360deg)}}
.anim-fade{animation:fadeUp .7s ease-out both}
.anim-fade-d1{animation-delay:.1s}
.anim-fade-d2{animation-delay:.2s}
.anim-fade-d3{animation-delay:.3s}
.anim-scale{animation:scaleIn .6s ease-out both}
/* Hero */
.hero{position:relative;padding:80px 24px 60px;text-align:center;overflow:hidden}
.hero .bg-blobs{position:absolute;inset:0;overflow:hidden}
.hero .bg-blobs .blob{position:absolute;width:500px;height:500px;border-radius:50%;filter:blur(100px);opacity:.1}
.hero .bg-blobs .b1{top:-200px;left:-200px;background:var(--accent);animation:blob 8s ease-in-out infinite}
.hero .bg-blobs .b2{bottom:-300px;right:-200px;background:var(--accent3);animation:blob 10s ease-in-out infinite reverse}
.hero .bg-blobs .b3{top:50%;left:50%;transform:translate(-50%,-50%);width:700px;height:700px;background:var(--accent2);opacity:.05;animation:blob 12s ease-in-out infinite}
.hero h1{font-size:clamp(32px,5vw,56px);font-weight:800;letter-spacing:-1.5px;position:relative;z-index:1;line-height:1.08}
.hero h1 .highlight{background:linear-gradient(135deg,var(--accent),var(--accent2),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.hero p{font-size:16px;color:var(--muted);margin-top:12px;position:relative;z-index:1;max-width:500px;margin-left:auto;margin-right:auto}
/* Input area */
.capture-section{position:relative;z-index:1;max-width:800px;margin:0 auto;padding:0 24px 60px}
.input-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:32px;box-shadow:0 4px 24px rgba(0,0,0,.2)}
.input-label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:6px}
.input-row{display:flex;gap:10px;align-items:stretch}
.input-row input[type="text"]{flex:1;padding:12px 16px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;font-family:'Inter',system-ui,sans-serif;outline:none;transition:all .2s}
.input-row input[type="text"]::placeholder{color:rgba(255,255,255,.2)}
.input-row input[type="text"]:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(129,140,248,.1)}
.input-row input[type="text"].error{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
.capture-btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all .25s;white-space:nowrap;font-family:'Inter',system-ui,sans-serif}
.capture-btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(99,102,241,.35)}
.capture-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.capture-btn .spinner{display:none;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite}
.capture-btn.loading .spinner{display:inline-block}
.capture-btn.loading .btn-text{display:none}
/* Options row */
.options-row{display:flex;gap:20px;margin-top:14px;flex-wrap:wrap}
.opt-group{display:flex;align-items:center;gap:8px}
.opt-group label{font-size:12px;color:var(--muted)}
.opt-group select,.opt-group input{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px 8px;color:var(--text);font-size:12px;font-family:'Inter',system-ui,sans-serif;outline:none}
.opt-group select:focus,.opt-group input:focus{border-color:var(--accent)}
.opt-group input[type="number"]{width:64px}
/* Status bar */
.status-bar{display:none;align-items:center;gap:10px;margin-top:12px;padding:10px 14px;border-radius:var(--radius-sm);font-size:12px}
.status-bar.show{display:flex}
.status-bar.info{background:rgba(129,140,248,.08);border:1px solid rgba(129,140,248,.15);color:var(--accent)}
.status-bar.success{background:rgba(52,211,153,.08);border:1px solid rgba(52,211,153,.15);color:var(--green)}
.status-bar.error{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.15);color:#f87171}
.status-bar .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.status-bar.info .status-dot{background:var(--accent);animation:pulse 1.5s ease-in-out infinite}
.status-bar.success .status-dot{background:var(--green)}
.status-bar.error .status-dot{background:#ef4444}
.status-bar .status-close{margin-left:auto;cursor:pointer;opacity:.5;font-size:14px;background:none;border:none;color:inherit;font-family:inherit}
.status-bar .status-close:hover{opacity:1}
/* Result area */
.result-area{display:none;margin-top:24px;animation:fadeUp .5s ease-out}
.result-area.show{display:block}
.result-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.result-meta{display:flex;gap:16px;padding:14px 20px;border-bottom:1px solid var(--border);flex-wrap:wrap;font-size:12px;color:var(--muted);align-items:center}
.result-meta .meta-item{display:flex;align-items:center;gap:4px}
.result-meta .meta-item strong{color:var(--text);font-weight:600}
.result-meta .spacer{flex:1}
.result-meta .download-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(129,140,248,.1);color:var(--accent);font-size:11px;font-weight:500;text-decoration:none;border:1px solid rgba(129,140,248,.2);transition:all .15s}
.result-meta .download-btn:hover{background:rgba(129,140,248,.18)}
.result-meta .copy-url-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(255,255,255,.04);color:var(--muted);font-size:11px;font-weight:500;border:1px solid var(--border);cursor:pointer;font-family:'Inter',system-ui,sans-serif;transition:all .15s}
.result-meta .copy-url-btn:hover{color:var(--text);border-color:var(--accent);background:rgba(129,140,248,.08)}
.result-image-wrap{position:relative;overflow:auto;max-height:80vh;background:repeating-conic-gradient(rgba(255,255,255,.03) 0% 25%,transparent 0% 50%) 0 0/20px 20px}
.result-image-wrap img{display:block;width:100%;max-width:none}
.result-image-wrap .loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg2);font-size:13px;color:var(--muted);gap:8px;z-index:2}
.result-image-wrap .loading-overlay .spinner-s{width:14px;height:14px;border:2px solid rgba(255,255,255,.1);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}
/* Divider */
.divider{display:flex;align-items:center;gap:12px;padding:20px 0;color:var(--border);font-size:10px;text-transform:uppercase;letter-spacing:1px}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
/* Footer */
.footer{text-align:center;padding:40px 24px 60px;font-size:12px;color:var(--muted)}
.footer a{color:var(--accent);text-decoration:none}
.footer a:hover{text-decoration:underline}
/* Responsive */
@media(max-width:640px){
.input-row{flex-direction:column}
.capture-btn{justify-content:center}
.options-row{flex-direction:column;gap:10px}
}
</style>
</head>
<body>
<div class="noise"></div>
<div class="hero anim-fade">
<div class="bg-blobs">
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="blob b3"></div>
</div>
<h1><span class="highlight">Capture</span></h1>
<p>Full-page screenshots of any URL — scroll, stitch, done.</p>
</div>
<div class="capture-section anim-fade anim-fade-d1">
<div class="input-card">
<div class="input-label">🔗 URL to capture</div>
<div class="input-row">
<input type="text" id="urlInput" placeholder="https://example.com" onkeydown="if(event.key==='Enter') startCapture()">
<button class="capture-btn" id="captureBtn" onclick="startCapture()">
<span class="spinner"></span>
<span class="btn-text">📸 Capture</span>
</button>
</div>
<div class="options-row">
<div class="opt-group">
<label>Width</label>
<select id="widthSelect">
<option value="1440">1440px (Desktop)</option>
<option value="1280">1280px</option>
<option value="1024">1024px (Tablet)</option>
<option value="768">768px</option>
<option value="414">414px (Mobile)</option>
</select>
</div>
<div class="opt-group">
<label>Wait</label>
<select id="waitSelect">
<option value="500">500ms</option>
<option value="1500" selected>1.5s</option>
<option value="3000">3s</option>
<option value="5000">5s</option>
</select>
</div>
</div>
<div class="status-bar" id="statusBar">
<span class="status-dot"></span>
<span class="status-text" id="statusText">Ready</span>
<button class="status-close" onclick="hideStatus()">✕</button>
</div>
</div>
<div class="result-area" id="resultArea">
<div class="result-card">
<div class="result-meta" id="resultMeta"></div>
<div class="result-image-wrap" id="resultImageWrap">
<div class="loading-overlay" id="imgLoading">
<span class="spinner-s"></span>
Loading image...
</div>
<img id="resultImage" src="" alt="Screenshot" onload="document.getElementById('imgLoading').style.display='none'">
</div>
</div>
</div>
</div>
<div class="divider anim-fade anim-fade-d2">powered by Playwright</div>
<div class="footer anim-fade anim-fade-d2">
<a href="https://preview.aivibehosting.com">Preview Dashboard</a> ·
<a href="https://pcm.aivibehosting.com">PCM</a>
</div>
<script>
function showStatus(msg, type) {
const bar = document.getElementById('statusBar');
const text = document.getElementById('statusText');
bar.className = 'status-bar show ' + type;
text.textContent = msg;
}
function hideStatus() {
document.getElementById('statusBar').classList.remove('show');
}
function copyUrl(filename) {
const url = window.location.origin + '/screenshots/' + filename;
navigator.clipboard.writeText(url).then(() => {
showStatus('🔗 URL copied to clipboard!', 'success');
}).catch(() => {
// Fallback
const ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showStatus('🔗 URL copied!', 'success');
});
}
async function startCapture() {
const url = document.getElementById('urlInput').value.trim();
const btn = document.getElementById('captureBtn');
const width = parseInt(document.getElementById('widthSelect').value);
const waitMs = parseInt(document.getElementById('waitSelect').value);
const input = document.getElementById('urlInput');
if (!url) {
input.classList.add('error');
showStatus('Please enter a URL', 'error');
setTimeout(() => input.classList.remove('error'), 2000);
return;
}
// Basic URL validation
if (!url.startsWith('http://') && !url.startsWith('https://')) {
input.classList.add('error');
showStatus('URL must start with http:// or https://', 'error');
setTimeout(() => input.classList.remove('error'), 2000);
return;
}
btn.classList.add('loading');
btn.disabled = true;
showStatus('Capturing ' + url + ' ...', 'info');
try {
const res = await fetch('/api/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, width, wait_ms: waitMs }),
});
const data = await res.json();
if (!data.success) {
showStatus(data.error || 'Capture failed', 'error');
btn.classList.remove('loading');
btn.disabled = false;
return;
}
// Show result
const resultArea = document.getElementById('resultArea');
const resultMeta = document.getElementById('resultMeta');
const resultImage = document.getElementById('resultImage');
const imgLoading = document.getElementById('imgLoading');
resultMeta.innerHTML = \`
<span class="meta-item">📄 <strong>\${data.title}</strong></span>
<span class="meta-item">📐 \${data.scroll_height}px × \${data.viewport_width}px</span>
<span class="meta-item">🧩 \${data.stitched} panels</span>
<span class="meta-item">💾 \${data.file_size_kb}KB</span>
<span class="spacer"></span>
<button class="copy-url-btn" onclick="copyUrl('\${data.filename}')">🔗 Copy URL</button>
<a class="download-btn" href="/screenshots/\${data.filename}" download="\${data.filename}">⬇ Download PNG</a>
\`;
imgLoading.style.display = 'flex';
resultImage.src = '/screenshots/' + data.filename + '?t=' + Date.now();
resultArea.classList.add('show');
showStatus('✅ Captured! Scroll height: ' + data.scroll_height + 'px', 'success');
} catch (err) {
showStatus('Error: ' + err.message, 'error');
}
btn.classList.remove('loading');
btn.disabled = false;
}
</script>
</body>
</html>`;
// ── HTTP Server ───────────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
// ── POST /api/capture ──────────────────────────────────────
if (pathname === '/api/capture' && req.method === 'POST') {
let body = '';
req.on('data', c => (body += c));
req.on('end', async () => {
try {
const data = JSON.parse(body);
if (!data.url) {
return sendJSON(res, { success: false, error: 'Missing url' }, 400);
}
// SSRF guard: block internal/private network targets
const lowerUrl = data.url.toLowerCase();
const blockedPatterns = ['localhost', '127.0.0.1', '169.254.169.254', '0.0.0.0', '10.', '172.16.', '192.168.'];
if (blockedPatterns.some(p => lowerUrl.includes(p))) {
return sendJSON(res, { success: false, error: 'Internal URL not allowed' }, 403);
}
const result = await capturePage(
data.url,
data.width || 1440,
data.wait_ms || 1500
);
sendJSON(res, {
success: true,
filename: result.filename,
title: result.title,
scroll_height: result.scrollHeight,
viewport_width: data.width || 1440,
stitched: result.stitched,
file_size_kb: result.fileSizeKb,
image_url: '/screenshots/' + result.filename,
quality: result.quality,
});
} catch (e) {
sendJSON(res, { success: false, error: e.message }, 500);
}
});
return;
}
// ── Serve screenshots ──────────────────────────────────────
if (pathname.startsWith('/screenshots/')) {
const filePath = path.join(SCREENSHOT_DIR, pathname.replace('/screenshots/', ''));
if (filePath.startsWith(SCREENSHOT_DIR) && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
return sendFile(res, filePath);
}
return sendJSON(res, { error: 'Not found' }, 404);
}
// ── Serve main page ────────────────────────────────────────
sendHTML(res, HTML_PAGE);
});
server.listen(PORT, () => {
console.error(`Capture server running on port ${PORT}`);
});