Files
webshot/webshot
2025-11-04 18:39:56 +01:00

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);