805 lines
25 KiB
JavaScript
Executable File
805 lines
25 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
// Check for debug mode before anything else
|
|
const DEBUG = process.argv.includes('--debug');
|
|
const DEBUG_PORT = process.env.DEBUG_PORT || 9229;
|
|
|
|
if (DEBUG && !process.execArgv.includes('--inspect')) {
|
|
// Restart with inspector
|
|
const { spawn } = require('child_process');
|
|
const args = ['--inspect=' + DEBUG_PORT, __filename, ...process.argv.slice(2).filter(arg => arg !== '--debug')];
|
|
|
|
console.log(`\x1b[36m🐛 Starting debugger on port ${DEBUG_PORT}\x1b[0m`);
|
|
console.log(`\x1b[90mChrome DevTools: chrome://inspect\x1b[0m`);
|
|
console.log(`\x1b[90mOr connect with: node inspect localhost:${DEBUG_PORT}\x1b[0m\n`);
|
|
|
|
const child = spawn(process.execPath, args, { stdio: 'inherit' });
|
|
child.on('exit', (code) => process.exit(code));
|
|
return;
|
|
}
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const readline = require('readline');
|
|
const { spawn } = require('child_process');
|
|
|
|
// Try to load puppeteer from the specified path
|
|
let puppeteer;
|
|
try {
|
|
puppeteer = require('puppeteer');
|
|
} catch (err) {
|
|
console.error('❌ Puppeteer not found. Please install it first.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Debug logging
|
|
function debug(...args) {
|
|
if (DEBUG) {
|
|
console.log(`\x1b[35m[DEBUG]\x1b[0m`, ...args);
|
|
}
|
|
}
|
|
|
|
// ANSI colors - pastel palette
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
bright: '\x1b[1m',
|
|
dim: '\x1b[2m',
|
|
|
|
// Pastel colors
|
|
pink: '\x1b[38;5;217m',
|
|
lavender: '\x1b[38;5;183m',
|
|
mint: '\x1b[38;5;158m',
|
|
peach: '\x1b[38;5;223m',
|
|
sky: '\x1b[38;5;153m',
|
|
coral: '\x1b[38;5;210m',
|
|
sage: '\x1b[38;5;151m',
|
|
|
|
// Standard colors
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
red: '\x1b[31m',
|
|
cyan: '\x1b[36m',
|
|
gray: '\x1b[90m'
|
|
};
|
|
|
|
// ASCII Banner
|
|
function showBanner() {
|
|
const banner = `
|
|
${colors.mint}╦ ╦╔═╗╔╗ ╔═╗╦ ╦╔═╗╔╦╗${colors.reset}
|
|
${colors.sky}║║║║╣ ╠╩╗╚═╗╠═╣║ ║ ║ ${colors.reset}
|
|
${colors.lavender}╚╩╝╚═╝╚═╝╚═╝╩ ╩╚═╝ ╩ ${colors.reset}
|
|
|
|
${colors.peach}${colors.bright}Shoot your favorite Websites!!!${colors.reset}
|
|
${colors.gray}────────────────────────────────${colors.reset}
|
|
`;
|
|
console.log(banner);
|
|
}
|
|
|
|
// Progress spinner
|
|
class Spinner {
|
|
constructor(text) {
|
|
this.frames = ['◐', '◓', '◑', '◒'];
|
|
this.colorFrames = [colors.mint, colors.sky, colors.lavender, colors.peach];
|
|
this.text = text;
|
|
this.frame = 0;
|
|
this.timer = null;
|
|
}
|
|
|
|
start() {
|
|
this.timer = setInterval(() => {
|
|
const color = this.colorFrames[this.frame % this.colorFrames.length];
|
|
const frame = this.frames[this.frame % this.frames.length];
|
|
readline.clearLine(process.stdout, 0);
|
|
readline.cursorTo(process.stdout, 0);
|
|
process.stdout.write(`${color}${frame}${colors.reset} ${this.text}`);
|
|
this.frame++;
|
|
}, 100);
|
|
}
|
|
|
|
update(text) {
|
|
this.text = text;
|
|
}
|
|
|
|
stop(finalText) {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
readline.clearLine(process.stdout, 0);
|
|
readline.cursorTo(process.stdout, 0);
|
|
if (finalText) {
|
|
console.log(finalText);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Progress bar
|
|
function progressBar(current, total, width = 30) {
|
|
const percentage = Math.round((current / total) * 100);
|
|
const filled = Math.round((width * current) / total);
|
|
const empty = width - filled;
|
|
|
|
const filledBar = '█'.repeat(filled);
|
|
const emptyBar = '░'.repeat(empty);
|
|
|
|
return `${colors.mint}${filledBar}${colors.gray}${emptyBar}${colors.reset} ${colors.bright}${percentage}%${colors.reset}`;
|
|
}
|
|
|
|
// Confirm overwrite
|
|
async function confirmOverwrite(filepath) {
|
|
if (!fs.existsSync(filepath)) return true;
|
|
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
rl.question(
|
|
`${colors.yellow}⚠${colors.reset} File ${colors.cyan}${path.basename(filepath)}${colors.reset} already exists. Overwrite? ${colors.gray}(y/N)${colors.reset} `,
|
|
(answer) => {
|
|
rl.close();
|
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// Wait for spacebar
|
|
async function waitForSpacebar() {
|
|
return new Promise((resolve) => {
|
|
readline.emitKeypressEvents(process.stdin);
|
|
if (process.stdin.isTTY) {
|
|
process.stdin.setRawMode(true);
|
|
}
|
|
|
|
const onKeypress = (str, key) => {
|
|
if (key && key.name === 'space') {
|
|
if (process.stdin.isTTY) {
|
|
process.stdin.setRawMode(false);
|
|
}
|
|
process.stdin.removeListener('keypress', onKeypress);
|
|
resolve();
|
|
} else if (key && key.ctrl && key.name === 'c') {
|
|
process.exit();
|
|
}
|
|
};
|
|
|
|
process.stdin.on('keypress', onKeypress);
|
|
});
|
|
}
|
|
|
|
// Countdown timer
|
|
async function showCountdown(delay) {
|
|
const startTime = Date.now();
|
|
const endTime = startTime + delay;
|
|
|
|
return new Promise((resolve) => {
|
|
const interval = setInterval(() => {
|
|
const now = Date.now();
|
|
const remaining = endTime - now;
|
|
const elapsed = now - startTime;
|
|
|
|
if (remaining <= 0) {
|
|
clearInterval(interval);
|
|
readline.clearLine(process.stdout, 0);
|
|
readline.cursorTo(process.stdout, 0);
|
|
console.log(`${colors.green}✓${colors.reset} Ready to shoot!`);
|
|
resolve();
|
|
} else {
|
|
readline.clearLine(process.stdout, 0);
|
|
readline.cursorTo(process.stdout, 0);
|
|
process.stdout.write(
|
|
`${colors.mint}⏱${colors.reset} Elapsed: ${colors.bright}${elapsed}ms${colors.reset} | ` +
|
|
`Remaining: ${colors.bright}${remaining}ms${colors.reset} | ` +
|
|
`${colors.peach}Press ${colors.bright}SPACE${colors.reset}${colors.peach} to shoot!${colors.reset}`
|
|
);
|
|
}
|
|
}, 10);
|
|
});
|
|
}
|
|
|
|
// Export as PNG
|
|
async function exportPNG(page, outputPath, options = {}) {
|
|
await page.screenshot({
|
|
path: outputPath,
|
|
fullPage: options.fullPage !== false
|
|
});
|
|
}
|
|
|
|
// Export as JPG
|
|
async function exportJPG(page, outputPath, options = {}) {
|
|
await page.screenshot({
|
|
path: outputPath,
|
|
fullPage: options.fullPage !== false,
|
|
type: 'jpeg',
|
|
quality: options.quality || 90,
|
|
...options
|
|
});
|
|
}
|
|
|
|
// Export as WEBP
|
|
async function exportWEBP(page, outputPath, options = {}) {
|
|
await page.screenshot({
|
|
path: outputPath,
|
|
fullPage: options.fullPage !== false,
|
|
type: 'webp',
|
|
quality: options.quality || 90,
|
|
...options
|
|
});
|
|
}
|
|
|
|
// Export as PDF
|
|
async function exportPDF(page, outputPath, options = {}) {
|
|
await page.pdf({
|
|
path: outputPath,
|
|
format: options.format || 'A4',
|
|
printBackground: true,
|
|
...options
|
|
});
|
|
}
|
|
|
|
// Export as MP4 (screen recording)
|
|
async function exportMP4(page, outputPath, options = {}) {
|
|
const duration = options.duration || 5000;
|
|
const targetFPS = options.fps || 30;
|
|
|
|
debug('Starting MP4 recording using fast screenshot-loop...');
|
|
debug('Duration:', duration, 'ms');
|
|
debug('Target FPS:', targetFPS);
|
|
|
|
// Create temporary frames directory
|
|
const tempDir = path.join(path.dirname(outputPath), `.webshot-frames-${Date.now()}`);
|
|
if (!fs.existsSync(tempDir)) {
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
}
|
|
|
|
debug('Temp frames dir:', tempDir);
|
|
|
|
const frameInterval = 1000 / targetFPS;
|
|
const targetFrames = Math.ceil((duration / 1000) * targetFPS);
|
|
|
|
debug('Frame interval:', frameInterval.toFixed(2), 'ms');
|
|
debug('Target frames:', targetFrames);
|
|
|
|
let frameCount = 0;
|
|
const startTime = Date.now();
|
|
let lastUpdateTime = startTime;
|
|
|
|
const spinner = new Spinner(`Recording video (${duration}ms, 0 frames)...`);
|
|
spinner.start();
|
|
|
|
try {
|
|
// Capture frames as fast as possible
|
|
while (Date.now() - startTime < duration) {
|
|
const captureStart = Date.now();
|
|
|
|
// Capture screenshot (JPEG for speed, lower quality)
|
|
const screenshot = await page.screenshot({
|
|
type: 'jpeg',
|
|
quality: 60, // Lower quality = faster captures
|
|
encoding: 'binary'
|
|
});
|
|
|
|
// Write frame to disk
|
|
frameCount++;
|
|
const framePath = path.join(tempDir, `frame-${String(frameCount).padStart(4, '0')}.jpg`);
|
|
fs.writeFileSync(framePath, screenshot);
|
|
|
|
const captureTime = Date.now() - captureStart;
|
|
debug(`Frame ${frameCount}: capture took ${captureTime}ms`);
|
|
|
|
// Update spinner every 500ms
|
|
if (Date.now() - lastUpdateTime > 500) {
|
|
spinner.update(`Recording video (${duration}ms, ${frameCount}/${targetFrames} frames)...`);
|
|
lastUpdateTime = Date.now();
|
|
}
|
|
|
|
// Try to maintain target frame rate (but don't slow down if already too slow)
|
|
const sleepTime = Math.max(0, frameInterval - captureTime);
|
|
if (sleepTime > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, sleepTime));
|
|
}
|
|
}
|
|
|
|
spinner.stop();
|
|
|
|
const totalTime = Date.now() - startTime;
|
|
const actualFPS = (frameCount / (totalTime / 1000)).toFixed(2);
|
|
|
|
debug(`Captured ${frameCount}/${targetFrames} frames in ${totalTime}ms`);
|
|
debug(`Actual capture FPS: ${actualFPS}`);
|
|
|
|
// Detect output format
|
|
const isWebM = outputPath.toLowerCase().endsWith('.webm');
|
|
const formatName = isWebM ? 'WebM' : 'MP4';
|
|
|
|
console.log(`${colors.gray}Converting ${frameCount} frames to ${formatName}...${colors.reset}`);
|
|
|
|
const conversionSpinner = new Spinner(`Converting to ${formatName}...`);
|
|
conversionSpinner.start();
|
|
|
|
// Build ffmpeg arguments based on output format
|
|
const ffmpegArgs = [
|
|
'-y',
|
|
'-framerate', actualFPS,
|
|
'-i', path.join(tempDir, 'frame-%04d.jpg'),
|
|
'-r', targetFPS, // Duplicate frames to reach target FPS (fast)
|
|
];
|
|
|
|
if (isWebM) {
|
|
// WebM with VP9 codec
|
|
ffmpegArgs.push(
|
|
'-c:v', 'libvpx-vp9',
|
|
'-crf', '30',
|
|
'-b:v', '0'
|
|
);
|
|
} else {
|
|
// MP4 with H.264 codec
|
|
ffmpegArgs.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'fast',
|
|
'-crf', '23',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-movflags', '+faststart'
|
|
);
|
|
}
|
|
|
|
ffmpegArgs.push(outputPath);
|
|
|
|
debug('ffmpeg args:', ffmpegArgs.join(' '));
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const ffmpeg = spawn('ffmpeg', ffmpegArgs, {
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stderr = '';
|
|
|
|
ffmpeg.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
debug('ffmpeg stderr:', data.toString().trim());
|
|
});
|
|
|
|
ffmpeg.on('close', (code) => {
|
|
debug('ffmpeg exit code:', code);
|
|
if (code === 0) {
|
|
// Clean up temp frames directory
|
|
debug('Cleaning up temp frames:', tempDir);
|
|
try {
|
|
fs.rmSync(tempDir, { recursive: true });
|
|
} catch (e) {
|
|
debug('Cleanup error:', e);
|
|
}
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`ffmpeg failed with exit code ${code}\nStderr: ${stderr.slice(-500)}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
conversionSpinner.stop();
|
|
debug('Conversion complete');
|
|
|
|
} catch (error) {
|
|
debug('Recording error:', error);
|
|
spinner.stop();
|
|
|
|
// Clean up temp frames directory if it exists
|
|
if (fs.existsSync(tempDir)) {
|
|
debug('Cleaning up temp frames after error:', tempDir);
|
|
try {
|
|
fs.rmSync(tempDir, { recursive: true });
|
|
} catch (e) {
|
|
debug('Cleanup error:', e);
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Export as HTML with assets
|
|
async function exportHTML(page, outputPath, options = {}) {
|
|
const url = page.url();
|
|
const baseDir = path.dirname(outputPath);
|
|
const assetsDir = path.join(baseDir, 'assets');
|
|
|
|
if (!fs.existsSync(assetsDir)) {
|
|
fs.mkdirSync(assetsDir, { recursive: true });
|
|
}
|
|
|
|
// Get all resources
|
|
const html = await page.content();
|
|
|
|
// Download all assets
|
|
const resources = await page.evaluate(() => {
|
|
const resources = [];
|
|
|
|
// Images
|
|
document.querySelectorAll('img[src]').forEach(img => {
|
|
resources.push({ type: 'image', url: img.src, selector: `img[src="${img.src}"]` });
|
|
});
|
|
|
|
// Stylesheets
|
|
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
|
|
resources.push({ type: 'css', url: link.href, selector: `link[href="${link.href}"]` });
|
|
});
|
|
|
|
// Scripts
|
|
document.querySelectorAll('script[src]').forEach(script => {
|
|
resources.push({ type: 'js', url: script.src, selector: `script[src="${script.src}"]` });
|
|
});
|
|
|
|
// Favicon
|
|
const favicon = document.querySelector('link[rel*="icon"]');
|
|
if (favicon) {
|
|
resources.push({ type: 'icon', url: favicon.href, selector: `link[href="${favicon.href}"]` });
|
|
}
|
|
|
|
return resources;
|
|
});
|
|
|
|
let processedHtml = html;
|
|
|
|
// Download resources
|
|
for (let i = 0; i < resources.length; i++) {
|
|
const resource = resources[i];
|
|
|
|
try {
|
|
const response = await page.goto(resource.url, { waitUntil: 'networkidle0', timeout: 10000 });
|
|
const buffer = await response.buffer();
|
|
|
|
const filename = path.basename(new URL(resource.url).pathname) || `asset-${i}`;
|
|
const filepath = path.join(assetsDir, filename);
|
|
|
|
fs.writeFileSync(filepath, buffer);
|
|
|
|
// Update HTML references
|
|
processedHtml = processedHtml.replace(resource.url, `assets/${filename}`);
|
|
} catch (err) {
|
|
console.log(`${colors.yellow}⚠${colors.reset} Failed to download: ${resource.url}`);
|
|
}
|
|
}
|
|
|
|
// Save HTML
|
|
fs.writeFileSync(outputPath, processedHtml);
|
|
|
|
// Go back to original page
|
|
await page.goto(url, { waitUntil: 'networkidle0' });
|
|
}
|
|
|
|
// Show help
|
|
function showHelp() {
|
|
showBanner();
|
|
console.log(`
|
|
${colors.bright}USAGE${colors.reset}
|
|
webshot -u <url> [options] [delay]
|
|
|
|
${colors.bright}OPTIONS${colors.reset}
|
|
${colors.mint}-u, --url <url>${colors.reset} Target URL to capture
|
|
${colors.mint}-f, --format <format>${colors.reset} Export format: png, jpg, webp, pdf, mp4, html
|
|
${colors.gray}(default: png)${colors.reset}
|
|
${colors.mint}-o, --output <path>${colors.reset} Output file path
|
|
${colors.mint}-s, --script <path>${colors.reset} Custom puppeteer automation script
|
|
${colors.mint}-w, --width <px>${colors.reset} Viewport width ${colors.gray}(default: 1920)${colors.reset}
|
|
${colors.mint}-h, --height <px>${colors.reset} Viewport height ${colors.gray}(default: 1080)${colors.reset}
|
|
${colors.mint}--no-delay${colors.reset} Skip interactive shoot delay
|
|
${colors.mint}--full-page${colors.reset} Capture full page ${colors.gray}(default: true)${colors.reset}
|
|
${colors.mint}--quality <1-100>${colors.reset} Image quality for jpg/webp ${colors.gray}(default: 90)${colors.reset}
|
|
${colors.mint}--duration <ms>${colors.reset} Video duration for mp4 ${colors.gray}(default: 5000)${colors.reset}
|
|
${colors.mint}--fps <fps>${colors.reset} Video FPS for mp4 ${colors.gray}(default: 30)${colors.reset}
|
|
${colors.mint}--wait-until <state>${colors.reset} Page load state: load, domcontentloaded, networkidle0, networkidle2
|
|
${colors.gray}(default: networkidle2)${colors.reset}
|
|
${colors.mint}--timeout <ms>${colors.reset} Navigation timeout ${colors.gray}(default: 30000)${colors.reset}
|
|
${colors.mint}--debug${colors.reset} Enable debug mode with Node.js inspector
|
|
${colors.mint}--help${colors.reset} Show this help message
|
|
|
|
${colors.bright}ARGUMENTS${colors.reset}
|
|
${colors.mint}[delay]${colors.reset} Shoot delay in milliseconds ${colors.gray}(default: 3000)${colors.reset}
|
|
|
|
${colors.bright}EXAMPLES${colors.reset}
|
|
${colors.gray}# Capture GitHub homepage with interactive delay${colors.reset}
|
|
webshot -u https://github.com
|
|
|
|
${colors.gray}# Export as PDF without delay${colors.reset}
|
|
webshot -u https://github.com/features -f pdf --no-delay
|
|
|
|
${colors.gray}# Record 10 second video of a website${colors.reset}
|
|
webshot -u https://bruno-simon.com -f mp4 --duration 10000
|
|
|
|
${colors.gray}# Download complete HTML with all assets${colors.reset}
|
|
webshot -u https://stripe.com -f html
|
|
|
|
${colors.gray}# High quality WEBP with custom delay${colors.reset}
|
|
webshot -u https://threejs.org/examples -f webp --quality 95 5000
|
|
|
|
${colors.gray}# Use custom puppeteer script${colors.reset}
|
|
webshot -u https://example.com -s ~/webshot-examples/scroll-animation.js
|
|
|
|
${colors.gray}# Debug mode for complex websites${colors.reset}
|
|
webshot -u https://bruno-simon.com -f mp4 --duration 10000 --wait-until load --debug
|
|
|
|
${colors.gray}# Increased timeout for slow sites${colors.reset}
|
|
webshot -u https://example.com --timeout 60000 --wait-until domcontentloaded
|
|
|
|
${colors.bright}COOL WEBSITES TO TRY${colors.reset}
|
|
${colors.sky}https://bruno-simon.com${colors.reset} ${colors.gray}# 3D portfolio${colors.reset}
|
|
${colors.lavender}https://threejs.org/examples${colors.reset} ${colors.gray}# WebGL demos${colors.reset}
|
|
${colors.peach}https://stripe.com${colors.reset} ${colors.gray}# Beautiful design${colors.reset}
|
|
${colors.mint}https://linear.app${colors.reset} ${colors.gray}# Modern UI${colors.reset}
|
|
${colors.coral}https://github.com${colors.reset} ${colors.gray}# GitHub${colors.reset}
|
|
|
|
${colors.bright}SCRIPT EXAMPLES${colors.reset}
|
|
See example scripts in: ${colors.cyan}~/webshot-examples/${colors.reset}
|
|
|
|
${colors.gray}────────────────────────────────────────────────────────────${colors.reset}
|
|
`);
|
|
}
|
|
|
|
// Parse arguments
|
|
function parseArgs(args) {
|
|
const options = {
|
|
url: null,
|
|
format: 'png',
|
|
output: null,
|
|
script: null,
|
|
width: 1920,
|
|
height: 1080,
|
|
delay: 3000,
|
|
noDelay: false,
|
|
fullPage: true,
|
|
quality: 90,
|
|
duration: 5000,
|
|
fps: 30,
|
|
waitUntil: 'networkidle2',
|
|
timeout: 30000,
|
|
debug: DEBUG
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (arg === '--help') {
|
|
showHelp();
|
|
process.exit(0);
|
|
} else if (arg === '-u' || arg === '--url') {
|
|
options.url = args[++i];
|
|
} else if (arg === '-f' || arg === '--format') {
|
|
options.format = args[++i];
|
|
} else if (arg === '-o' || arg === '--output') {
|
|
options.output = args[++i];
|
|
} else if (arg === '-s' || arg === '--script') {
|
|
options.script = args[++i];
|
|
} else if (arg === '-w' || arg === '--width') {
|
|
options.width = parseInt(args[++i]);
|
|
} else if (arg === '-h' || arg === '--height') {
|
|
options.height = parseInt(args[++i]);
|
|
} else if (arg === '--no-delay') {
|
|
options.noDelay = true;
|
|
} else if (arg === '--full-page') {
|
|
options.fullPage = true;
|
|
} else if (arg === '--quality') {
|
|
options.quality = parseInt(args[++i]);
|
|
} else if (arg === '--duration') {
|
|
options.duration = parseInt(args[++i]);
|
|
} else if (arg === '--fps') {
|
|
options.fps = parseInt(args[++i]);
|
|
} else if (arg === '--wait-until') {
|
|
options.waitUntil = args[++i];
|
|
} else if (arg === '--timeout') {
|
|
options.timeout = parseInt(args[++i]);
|
|
} else if (arg === '--debug') {
|
|
options.debug = true;
|
|
} else if (!arg.startsWith('-') && !isNaN(parseInt(arg))) {
|
|
options.delay = parseInt(arg);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
// Main function
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0 || args.includes('--help')) {
|
|
showHelp();
|
|
return;
|
|
}
|
|
|
|
const options = parseArgs(args);
|
|
|
|
if (!options.url) {
|
|
console.error(`${colors.red}✗${colors.reset} URL is required. Use ${colors.cyan}-u <url>${colors.reset}`);
|
|
console.log(`Run ${colors.cyan}webshot --help${colors.reset} for more information.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
showBanner();
|
|
|
|
// Generate output filename if not provided
|
|
if (!options.output) {
|
|
const urlObj = new URL(options.url);
|
|
const hostname = urlObj.hostname.replace(/\./g, '-');
|
|
const timestamp = Date.now();
|
|
options.output = `${hostname}-${timestamp}.${options.format}`;
|
|
}
|
|
|
|
// Resolve output path (handle ~ expansion)
|
|
if (options.output.startsWith('~/')) {
|
|
options.output = path.join(process.env.HOME, options.output.slice(2));
|
|
}
|
|
|
|
// Create output directory if it doesn't exist
|
|
const outputDir = path.dirname(options.output);
|
|
if (!fs.existsSync(outputDir)) {
|
|
debug('Creating output directory:', outputDir);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
// Check if output file exists
|
|
const shouldProceed = await confirmOverwrite(options.output);
|
|
if (!shouldProceed) {
|
|
console.log(`${colors.yellow}⚠${colors.reset} Operation cancelled.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${colors.mint}🌐${colors.reset} Target: ${colors.bright}${options.url}${colors.reset}`);
|
|
console.log(`${colors.sky}📁${colors.reset} Output: ${colors.bright}${options.output}${colors.reset}`);
|
|
console.log(`${colors.lavender}📊${colors.reset} Format: ${colors.bright}${options.format.toUpperCase()}${colors.reset}`);
|
|
if (options.debug) {
|
|
console.log(`${colors.coral}🐛${colors.reset} Debug: ${colors.bright}ENABLED${colors.reset}`);
|
|
console.log(`${colors.gray} Wait Until: ${options.waitUntil}${colors.reset}`);
|
|
console.log(`${colors.gray} Timeout: ${options.timeout}ms${colors.reset}`);
|
|
}
|
|
console.log();
|
|
|
|
debug('Options:', options);
|
|
|
|
// Launch browser
|
|
const spinner = new Spinner('Launching browser...');
|
|
spinner.start();
|
|
|
|
debug('Launching puppeteer...');
|
|
|
|
const browser = await puppeteer.launch({
|
|
headless: 'new',
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
dumpio: options.debug
|
|
});
|
|
|
|
debug('Browser launched');
|
|
|
|
const page = await browser.newPage();
|
|
|
|
// Enable verbose logging in debug mode
|
|
if (options.debug) {
|
|
page.on('console', msg => debug('PAGE LOG:', msg.text()));
|
|
page.on('pageerror', error => debug('PAGE ERROR:', error.message));
|
|
page.on('request', request => debug('REQUEST:', request.url()));
|
|
page.on('requestfailed', request => debug('REQUEST FAILED:', request.url(), request.failure().errorText));
|
|
}
|
|
|
|
await page.setViewport({
|
|
width: options.width,
|
|
height: options.height,
|
|
deviceScaleFactor: 2
|
|
});
|
|
|
|
debug('Viewport set:', options.width, 'x', options.height);
|
|
|
|
spinner.update(`Loading page (${options.waitUntil})...`);
|
|
|
|
// Navigate to URL with configurable waitUntil
|
|
debug('Navigating to:', options.url);
|
|
debug('Wait until:', options.waitUntil);
|
|
debug('Timeout:', options.timeout);
|
|
|
|
try {
|
|
await page.goto(options.url, {
|
|
waitUntil: options.waitUntil,
|
|
timeout: options.timeout
|
|
});
|
|
debug('Page loaded successfully');
|
|
} catch (error) {
|
|
if (error.name === 'TimeoutError') {
|
|
debug('Navigation timeout, but continuing anyway...');
|
|
spinner.update('Navigation timeout - continuing...');
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
spinner.update('Waiting for fonts...');
|
|
await page.evaluateHandle('document.fonts.ready');
|
|
debug('Fonts ready');
|
|
|
|
spinner.stop(`${colors.green}✓${colors.reset} Page loaded successfully!`);
|
|
|
|
// Execute custom script if provided
|
|
if (options.script) {
|
|
console.log(`${colors.peach}📜${colors.reset} Executing custom script...`);
|
|
const scriptContent = fs.readFileSync(options.script, 'utf8');
|
|
await eval(`(async () => { ${scriptContent} })()`);
|
|
console.log(`${colors.green}✓${colors.reset} Script executed!`);
|
|
}
|
|
|
|
// Interactive shoot delay
|
|
if (!options.noDelay) {
|
|
console.log();
|
|
console.log(`${colors.peach}${colors.bright}🎯 SHOOT MODE ACTIVATED${colors.reset}`);
|
|
console.log(`${colors.gray}Press SPACE when ready or wait ${options.delay}ms${colors.reset}`);
|
|
console.log();
|
|
|
|
await Promise.race([
|
|
waitForSpacebar(),
|
|
showCountdown(options.delay)
|
|
]);
|
|
|
|
// Clear the timer line
|
|
readline.clearLine(process.stdout, 0);
|
|
readline.cursorTo(process.stdout, 0);
|
|
}
|
|
|
|
// Export based on format
|
|
console.log();
|
|
const exportSpinner = new Spinner(`Capturing ${options.format.toUpperCase()}...`);
|
|
exportSpinner.start();
|
|
|
|
try {
|
|
switch (options.format.toLowerCase()) {
|
|
case 'png':
|
|
await exportPNG(page, options.output, options);
|
|
break;
|
|
case 'jpg':
|
|
case 'jpeg':
|
|
await exportJPG(page, options.output, options);
|
|
break;
|
|
case 'webp':
|
|
await exportWEBP(page, options.output, options);
|
|
break;
|
|
case 'pdf':
|
|
await exportPDF(page, options.output, options);
|
|
break;
|
|
case 'mp4':
|
|
exportSpinner.stop();
|
|
await exportMP4(page, options.output, options);
|
|
break;
|
|
case 'html':
|
|
exportSpinner.stop();
|
|
await exportHTML(page, options.output, options);
|
|
break;
|
|
default:
|
|
throw new Error(`Unsupported format: ${options.format}`);
|
|
}
|
|
|
|
if (options.format !== 'mp4' && options.format !== 'html') {
|
|
exportSpinner.stop();
|
|
}
|
|
|
|
console.log(`${colors.green}${colors.bright}✓ Shot captured successfully!${colors.reset}`);
|
|
console.log(`${colors.gray}Saved to:${colors.reset} ${colors.cyan}${options.output}${colors.reset}`);
|
|
|
|
// Show file size
|
|
const stats = fs.statSync(options.output);
|
|
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
|
|
console.log(`${colors.gray}File size:${colors.reset} ${colors.bright}${sizeMB} MB${colors.reset}`);
|
|
|
|
} catch (error) {
|
|
exportSpinner.stop();
|
|
console.error(`${colors.red}✗${colors.reset} Error: ${error.message}`);
|
|
await browser.close();
|
|
process.exit(1);
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
console.log();
|
|
console.log(`${colors.mint}${colors.bright}Thanks for using WebShot! 📸${colors.reset}`);
|
|
}
|
|
|
|
// Run
|
|
main().catch(console.error);
|