commit e69016e4e30e440e4b07b47adb1562a5180d6bbc Author: Sebastian Krüger Date: Tue Nov 4 18:39:56 2025 +0100 chore: init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..318a451 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.log +.DS_Store +.npmrc +pnpm-lock.yaml +package-lock.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..46a466b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,167 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +WebShot is a CLI tool built with Node.js and Puppeteer for capturing screenshots, videos, and full HTML snapshots of websites. It features an interactive "shoot mode" with pastel-themed terminal UI, custom automation scripts, and support for multiple export formats (PNG, JPG, WEBP, PDF, MP4, HTML). + +## Architecture + +### Core Components + +**Main executable** (`webshot`): +- Single-file CLI application with shebang (`#!/usr/bin/env node`) +- Self-contained with all functionality in ~800 lines +- Uses Puppeteer for browser automation +- Built-in debug mode with Node.js inspector support (restarts process with `--inspect`) + +**Key architectural patterns**: +- **Spinner class** (webshot:79-114): Animated terminal progress indicator with rotating frames and pastel colors +- **Export functions** (webshot:202-470): Modular export handlers for each format (PNG, JPG, WEBP, PDF, MP4, HTML) +- **MP4 recording** (webshot:243-400): Fast screenshot-loop method that captures JPEG frames and uses ffmpeg to create video (supports both MP4 with H.264 and WebM with VP9) +- **HTML export** (webshot:403-470): Downloads complete page with all assets (images, CSS, JS) into `assets/` subdirectory +- **Interactive shoot mode** (webshot:149-200): Race between spacebar press and countdown timer + +### Custom Script System + +WebShot supports custom Puppeteer automation scripts via `-s/--script` flag. Scripts are evaluated with access to the `page` object and can manipulate the page before capture. + +**Example scripts** (`examples/`): +- `scroll-animation.js` - Smoothly scrolls to bottom and back to top +- `dark-mode.js` - Attempts to enable dark mode using multiple detection methods +- `interact-form.js` - Fills out forms with demo data +- `hover-effects.js` - Simulates hover states on interactive elements +- `mobile-view.js` - Switches to mobile viewport (375x667, iPhone SE) +- `animated-sections.js` - Scrolls through sections to trigger scroll-based animations + +Scripts are executed via `eval()` in an async context with access to `page`, `console`, and other webshot internals (webshot:722-727). + +## Commands + +### Basic Usage + +```bash +# Install dependencies +pnpm install + +# Make executable (if needed) +chmod +x webshot + +# Run from anywhere (if in PATH) +webshot -u https://github.com --help +``` + +### Common Workflows + +```bash +# Screenshot with interactive shoot mode +webshot -u https://example.com + +# Screenshot without delay +webshot -u https://example.com --no-delay + +# High quality WEBP +webshot -u https://example.com -f webp --quality 95 + +# Record 10 second video (MP4) +webshot -u https://bruno-simon.com -f mp4 --duration 10000 --fps 30 + +# Record as WebM instead +webshot -u https://bruno-simon.com -f mp4 -o output.webm --duration 10000 + +# Export as PDF +webshot -u https://github.com/features -f pdf + +# Download complete HTML with assets +webshot -u https://stripe.com -f html -o stripe/index.html + +# Use custom automation script +webshot -u https://example.com -s examples/dark-mode.js + +# Custom viewport +webshot -u https://example.com -w 1280 -h 720 + +# Custom shoot delay (5 seconds) +webshot -u https://example.com 5000 +``` + +### Debug Mode + +```bash +# Enable debug logging and Node.js inspector +webshot -u https://example.com --debug + +# Connect debugger on custom port +DEBUG_PORT=9230 webshot -u https://example.com --debug + +# Debug with specific wait strategy (for slow sites) +webshot -u https://example.com --debug --wait-until load --timeout 60000 +``` + +Debug mode: +- Prints detailed logs prefixed with `[DEBUG]` in magenta (webshot:36-40) +- Enables Puppeteer dumpio for browser console output (webshot:669) +- Listens for page console messages, errors, and failed requests (webshot:677-682) +- Restarts process with `--inspect` if not already in debug mode (webshot:7-19) + +### Wait Strategies + +The `--wait-until` flag controls when navigation is considered complete: +- `load` - Wait for window.load event (fastest, may miss dynamic content) +- `domcontentloaded` - Wait for DOM to be ready +- `networkidle2` (default) - Wait until 2 or fewer network connections remain for 500ms +- `networkidle0` - Wait until 0 network connections remain (slowest, most complete) + +Use `--timeout ` to increase navigation timeout for slow sites (default: 30000ms). + +## Format-Specific Details + +### MP4/WebM Video Recording + +Video recording uses a fast screenshot-loop method: +1. Captures JPEG screenshots as fast as possible (quality: 60 for speed) +2. Saves frames to temporary directory (`.webshot-frames-/`) +3. Uses ffmpeg to convert frames to video +4. Automatically detects output format based on file extension (`.mp4` → H.264, `.webm` → VP9) +5. Duplicates frames to reach target FPS using ffmpeg `-r` flag +6. Cleans up temporary frames after conversion + +Requires `ffmpeg` to be installed and available in PATH. + +### HTML Export + +HTML export downloads a complete snapshot: +1. Extracts all resource URLs (images, CSS, JS, favicon) +2. Downloads each resource using Puppeteer's page.goto() +3. Saves resources to `assets/` subdirectory relative to output file +4. Rewrites HTML to use local asset paths +5. Returns to original page after downloading + +## Package Structure + +- **Package manager**: pnpm (uses pnpm-workspace) +- **Main entry**: `webshot` (executable script, also set as bin in package.json) +- **Only dependency**: `puppeteer` (uses bundled Chromium) +- **Keywords**: pupeteer (sic), cli, record, screeencast (sic), shoot + +## Color Palette + +Terminal UI uses pastel ANSI colors defined in `colors` object (webshot:43-63): +- Pastels: pink (217), lavender (183), mint (158), peach (223), sky (153), coral (210), sage (151) +- Standard: green, yellow, red, cyan, gray +- Modifiers: bright, dim, reset + +ASCII banner uses mint → sky → lavender gradient (webshot:66-76). + +## Important Notes + +- **Single file architecture**: All code lives in the `webshot` executable - no separate modules +- **No README**: Project lacks a README.md file, all documentation is in `--help` output +- **No tests**: No test directory or test files present +- **Script evaluation security**: Custom scripts are evaluated with `eval()` and have full access to Node.js APIs - only use trusted scripts +- **ffmpeg dependency**: MP4/WebM recording requires ffmpeg to be installed separately +- **Viewport defaults**: 1920x1080 at 2x device scale factor (webshot:684-688) +- **Interactive mode**: Uses raw terminal input with keypress events (webshot:152-154) +- **File overwrite**: Always prompts before overwriting existing files (webshot:129-146) +- **Fonts ready**: Waits for document.fonts.ready before capturing (webshot:716) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9127580 --- /dev/null +++ b/README.md @@ -0,0 +1,351 @@ +# 📸 WebShot + +> Shoot your favorite Websites!!! + +A beautiful CLI tool for capturing screenshots, videos, and complete HTML snapshots of websites using Puppeteer. Features an interactive "shoot mode" with a pastel-themed terminal UI. + +``` +╦ ╦╔═╗╔╗ ╔═╗╦ ╦╔═╗╔╦╗ +║║║║╣ ╠╩╗╚═╗╠═╣║ ║ ║ +╚╩╝╚═╝╚═╝╚═╝╩ ╩╚═╝ ╩ + +Shoot your favorite Websites!!! +──────────────────────────────── +``` + +## ✨ Features + +- 📷 **Multiple export formats**: PNG, JPG, WEBP, PDF, MP4/WebM video, HTML with assets +- 🎯 **Interactive shoot mode**: Press spacebar when ready or wait for countdown +- 🎨 **Beautiful terminal UI**: Pastel-colored spinners and progress indicators +- 📜 **Custom automation scripts**: Execute Puppeteer code before capture +- 🐛 **Debug mode**: Built-in Node.js inspector support +- ⚡ **Fast video recording**: Efficient screenshot-loop method with ffmpeg +- 🌐 **Complete HTML snapshots**: Download websites with all assets +- 📱 **Flexible viewports**: Custom dimensions or mobile presets + +## 🚀 Installation + +```bash +# Clone or download the repository +git clone +cd webshot + +# Install dependencies +pnpm install + +# Make executable (if needed) +chmod +x webshot + +# Optional: Link globally to use from anywhere +npm link +# or add to your PATH +``` + +### Requirements + +- **Node.js** (v14 or higher recommended) +- **ffmpeg** (required for MP4/WebM video recording) + ```bash + # Debian/Ubuntu + sudo apt install ffmpeg + + # macOS + brew install ffmpeg + ``` + +## 📖 Usage + +### Basic Commands + +```bash +# Interactive screenshot with shoot mode +webshot -u https://github.com + +# Quick screenshot without delay +webshot -u https://example.com --no-delay + +# Specify output path +webshot -u https://example.com -o screenshots/github.png +``` + +### Export Formats + +#### PNG (default) +```bash +webshot -u https://example.com -f png +``` + +#### JPEG with quality control +```bash +webshot -u https://example.com -f jpg --quality 95 +``` + +#### WEBP for smaller file sizes +```bash +webshot -u https://example.com -f webp --quality 90 +``` + +#### PDF documents +```bash +webshot -u https://github.com/features -f pdf +``` + +#### MP4 video recording +```bash +# 10 second video at 30 FPS +webshot -u https://bruno-simon.com -f mp4 --duration 10000 --fps 30 + +# WebM format (VP9 codec) +webshot -u https://bruno-simon.com -f mp4 -o output.webm --duration 5000 +``` + +#### Complete HTML with assets +```bash +# Downloads all images, CSS, JS to assets/ subdirectory +webshot -u https://stripe.com -f html -o stripe/index.html +``` + +### Custom Automation Scripts + +Execute custom Puppeteer code before capturing: + +```bash +# Use a built-in example +webshot -u https://example.com -s examples/dark-mode.js + +# Chain multiple behaviors +webshot -u https://example.com -s examples/scroll-animation.js -f mp4 --duration 8000 +``` + +**Available example scripts:** +- `scroll-animation.js` - Scroll to bottom and back to top +- `dark-mode.js` - Attempt to enable dark mode +- `interact-form.js` - Fill out forms with demo data +- `hover-effects.js` - Simulate hover states +- `mobile-view.js` - Switch to mobile viewport +- `animated-sections.js` - Trigger scroll-based animations + +**Create your own script:** +```javascript +// my-script.js +console.log('🎬 Running custom automation...'); + +// You have access to the 'page' object +await page.evaluate(() => { + document.body.style.backgroundColor = '#ff69b4'; +}); + +await new Promise(resolve => setTimeout(resolve, 1000)); +console.log('✓ Done!'); +``` + +### Viewport Configuration + +```bash +# Custom dimensions +webshot -u https://example.com -w 1280 -h 720 + +# Mobile viewport (use with script) +webshot -u https://example.com -s examples/mobile-view.js +``` + +### Custom Shoot Delay + +```bash +# 5 second delay +webshot -u https://example.com 5000 + +# 10 second delay +webshot -u https://example.com 10000 +``` + +### Debug Mode + +```bash +# Enable debug logging and Node.js inspector +webshot -u https://example.com --debug + +# Connect to chrome://inspect in Chrome DevTools +# or use: node inspect localhost:9229 + +# Custom debug port +DEBUG_PORT=9230 webshot -u https://example.com --debug + +# Debug slow or complex websites +webshot -u https://heavy-site.com --debug --wait-until load --timeout 60000 +``` + +## 🎯 Wait Strategies + +Control when navigation is considered complete: + +```bash +# Wait for window.load event (fastest) +webshot -u https://example.com --wait-until load + +# Wait for DOM ready +webshot -u https://example.com --wait-until domcontentloaded + +# Wait for network to be mostly idle (default, best for most sites) +webshot -u https://example.com --wait-until networkidle2 + +# Wait for complete network silence (slowest, most complete) +webshot -u https://example.com --wait-until networkidle0 + +# Increase timeout for slow sites +webshot -u https://slow-site.com --timeout 60000 +``` + +## 📋 Command Reference + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `-u, --url ` | Target URL to capture | *required* | +| `-f, --format ` | Export format: `png`, `jpg`, `webp`, `pdf`, `mp4`, `html` | `png` | +| `-o, --output ` | Output file path | `-.` | +| `-s, --script ` | Custom Puppeteer automation script | - | +| `-w, --width ` | Viewport width | `1920` | +| `-h, --height ` | Viewport height | `1080` | +| `--no-delay` | Skip interactive shoot delay | `false` | +| `--full-page` | Capture full page (scrollable content) | `true` | +| `--quality <1-100>` | Image quality for JPG/WEBP | `90` | +| `--duration ` | Video duration for MP4 | `5000` | +| `--fps ` | Video FPS for MP4 | `30` | +| `--wait-until ` | Page load state: `load`, `domcontentloaded`, `networkidle0`, `networkidle2` | `networkidle2` | +| `--timeout ` | Navigation timeout | `30000` | +| `--debug` | Enable debug mode with Node.js inspector | `false` | +| `--help` | Show help message | - | + +### Arguments + +| Argument | Description | Default | +|----------|-------------|---------| +| `[delay]` | Shoot delay in milliseconds | `3000` | + +## 🌐 Cool Websites to Try + +```bash +# 3D interactive portfolio +webshot -u https://bruno-simon.com -f mp4 --duration 10000 + +# WebGL demos and examples +webshot -u https://threejs.org/examples -f webp + +# Beautiful modern design +webshot -u https://stripe.com -f pdf + +# Modern UI with animations +webshot -u https://linear.app -f png +``` + +## 🎨 Terminal UI + +WebShot features a beautiful pastel-themed terminal interface: + +- **Animated spinners** with rotating frames (◐ ◓ ◑ ◒) +- **Progress bars** for video rendering +- **Color-coded output** using pastel colors (mint, sky, lavender, peach, coral) +- **Interactive countdown** with spacebar to shoot +- **File overwrite prompts** to prevent accidents + +## 🔧 How It Works + +### Screenshot Capture +1. Launches headless Chromium via Puppeteer +2. Sets viewport and navigates to URL +3. Waits for fonts and network activity +4. Executes custom script (if provided) +5. Shows interactive shoot mode countdown +6. Captures screenshot using Puppeteer's `page.screenshot()` + +### Video Recording +1. Creates temporary frames directory +2. Captures JPEG screenshots in a fast loop +3. Writes frames to disk with sequential naming +4. Uses ffmpeg to convert frames to video: + - **MP4**: H.264 codec with `-preset fast -crf 23` + - **WebM**: VP9 codec with `-crf 30` +5. Duplicates frames to reach target FPS +6. Cleans up temporary frames + +### HTML Export +1. Extracts all resource URLs (images, CSS, JS, favicon) +2. Downloads each resource using Puppeteer +3. Saves to `assets/` subdirectory +4. Rewrites HTML to use local paths +5. Returns to original page + +## 📦 Package Details + +- **Name**: `@valknarness/webshot` +- **Version**: 1.0.0 +- **Main**: Single-file executable (`webshot`) +- **Dependencies**: `puppeteer@^24.26.0` +- **License**: MIT +- **Author**: valknar@pivoine.art + +## 🤝 Contributing + +This is a personal project, but suggestions and improvements are welcome! + +## 📄 License + +MIT License - feel free to use and modify as needed. + +## ⚠️ Security Notes + +- Custom scripts are executed with `eval()` and have full access to Node.js APIs +- Only use trusted scripts from reliable sources +- Be cautious when capturing websites with sensitive information +- Video recording creates temporary files that are cleaned up automatically + +## 🐛 Troubleshooting + +### ffmpeg not found +```bash +# Install ffmpeg +sudo apt install ffmpeg # Debian/Ubuntu +brew install ffmpeg # macOS +``` + +### Puppeteer installation issues +```bash +# Clear cache and reinstall +rm -rf node_modules pnpm-lock.yaml +pnpm install +``` + +### Timeout errors on slow sites +```bash +# Increase timeout and use faster wait strategy +webshot -u https://slow-site.com --timeout 60000 --wait-until load +``` + +### Video recording too slow +```bash +# Reduce FPS or duration +webshot -u https://example.com -f mp4 --fps 24 --duration 3000 +``` + +### Debug complex issues +```bash +# Enable debug mode to see detailed logs +webshot -u https://example.com --debug +``` + +## 💡 Tips + +- Use `--no-delay` in scripts and automation +- WebP format offers the best quality-to-size ratio for screenshots +- For video recording, 30 FPS is smooth for most content +- Use `networkidle2` (default) for most sites, `load` for fast captures +- Mobile viewport scripts work best with responsive websites +- HTML export is great for offline archiving or analysis +- Debug mode shows all network requests and page console output + +--- + +**Made with 💜 by valknar** diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..cbc3c53 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,92 @@ +# WebShot Example Scripts + +Collection of example puppeteer automation scripts for WebShot. + +## 📜 Available Scripts + +### `scroll-animation.js` +Smoothly scrolls through the entire page before capturing. Great for long-form content and revealing lazy-loaded images. + +**Usage:** +```bash +webshot -u https://example.com -s ~/webshot-examples/scroll-animation.js +``` + +### `dark-mode.js` +Attempts to enable dark mode on the page using various common methods. Perfect for capturing dark theme versions of websites. + +**Usage:** +```bash +webshot -u https://github.com -s ~/webshot-examples/dark-mode.js +``` + +### `interact-form.js` +Fills out and interacts with forms on the page. Useful for capturing form states with data. + +**Usage:** +```bash +webshot -u https://example.com/contact -s ~/webshot-examples/interact-form.js +``` + +### `hover-effects.js` +Simulates hovering over interactive elements to capture hover states. + +**Usage:** +```bash +webshot -u https://example.com -s ~/webshot-examples/hover-effects.js +``` + +### `mobile-view.js` +Sets viewport to mobile dimensions (iPhone SE). Capture mobile-responsive designs. + +**Usage:** +```bash +webshot -u https://example.com -s ~/webshot-examples/mobile-view.js +``` + +### `animated-sections.js` +Scrolls through page sections to trigger scroll-based animations. Perfect for modern websites with scroll-triggered effects. + +**Usage:** +```bash +webshot -u https://bruno-simon.com -s ~/webshot-examples/animated-sections.js +``` + +## 🎯 Cool Websites to Try + +- **https://bruno-simon.com** - 3D portfolio with scroll interactions +- **https://threejs.org/examples** - WebGL animations +- **https://stripe.com** - Beautiful modern design +- **https://linear.app** - Sleek UI with animations +- **https://github.com** - Try with dark-mode.js + +## ✨ Creating Your Own Scripts + +Scripts have access to the `page` object from Puppeteer. Here's a template: + +```javascript +// Your custom automation script + +console.log('🚀 Starting custom automation...'); + +// Do something with the page +await page.evaluate(() => { + // Manipulate DOM + document.body.style.background = '#000'; +}); + +// Wait for animations +await new Promise(resolve => setTimeout(resolve, 1000)); + +console.log('✓ Automation complete'); +``` + +## 📚 Puppeteer API Reference + +- [Page Methods](https://pptr.dev/api/puppeteer.page) +- [Browser Context](https://pptr.dev/api/puppeteer.browsercontext) +- [Element Handles](https://pptr.dev/api/puppeteer.elementhandle) + +--- + +**WebShot** - Shoot your favorite Websites!!! diff --git a/examples/animated-sections.js b/examples/animated-sections.js new file mode 100644 index 0000000..73feffc --- /dev/null +++ b/examples/animated-sections.js @@ -0,0 +1,33 @@ +// Animated Sections Example +// Scrolls through page to trigger scroll-based animations + +console.log('✨ Triggering scroll animations...'); + +// Get all sections +const sections = await page.evaluate(() => { + const elements = document.querySelectorAll('section, .section, [class*="section"]'); + return Array.from(elements).map(el => el.offsetTop); +}); + +console.log(`Found ${sections.length} sections to animate`); + +// Scroll to each section +for (let i = 0; i < sections.length; i++) { + await page.evaluate((top) => { + window.scrollTo({ top, behavior: 'smooth' }); + }, sections[i]); + + console.log(`✓ Scrolled to section ${i + 1}/${sections.length}`); + + // Wait for animations to trigger + await new Promise(resolve => setTimeout(resolve, 800)); +} + +// Scroll back to top +await page.evaluate(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); +}); + +await new Promise(resolve => setTimeout(resolve, 1000)); + +console.log('✓ All animations triggered'); diff --git a/examples/dark-mode.js b/examples/dark-mode.js new file mode 100644 index 0000000..e65205e --- /dev/null +++ b/examples/dark-mode.js @@ -0,0 +1,48 @@ +// Dark Mode Toggle Example +// Attempts to enable dark mode on the page + +console.log('🌙 Attempting to enable dark mode...'); + +// Try common dark mode implementations +await page.evaluate(() => { + // Method 1: Toggle dark class on html + if (document.documentElement.classList.contains('light')) { + document.documentElement.classList.remove('light'); + document.documentElement.classList.add('dark'); + } else if (!document.documentElement.classList.contains('dark')) { + document.documentElement.classList.add('dark'); + } + + // Method 2: Toggle dark class on body + if (document.body.classList.contains('light')) { + document.body.classList.remove('light'); + document.body.classList.add('dark'); + } else if (!document.body.classList.contains('dark')) { + document.body.classList.add('dark'); + } + + // Method 3: Set data-theme attribute + document.documentElement.setAttribute('data-theme', 'dark'); + + // Method 4: Try to click dark mode toggle button + const darkModeSelectors = [ + '[aria-label*="dark" i]', + '[title*="dark" i]', + 'button[class*="dark" i]', + 'button[class*="theme" i]', + '[data-theme-toggle]' + ]; + + for (const selector of darkModeSelectors) { + const element = document.querySelector(selector); + if (element && !element.classList.contains('active')) { + element.click(); + break; + } + } +}); + +console.log('✓ Dark mode enabled'); + +// Wait for transitions +await new Promise(resolve => setTimeout(resolve, 500)); diff --git a/examples/hover-effects.js b/examples/hover-effects.js new file mode 100644 index 0000000..ac38533 --- /dev/null +++ b/examples/hover-effects.js @@ -0,0 +1,24 @@ +// Hover Effects Example +// Simulates hovering over interactive elements + +console.log('👆 Simulating hover effects...'); + +// Hover over buttons and links +await page.evaluate(() => { + const hoverTargets = document.querySelectorAll('button, a, .card, [class*="hover"]'); + + hoverTargets.forEach((element, index) => { + if (index < 5) { // Hover first 5 elements + element.dispatchEvent(new MouseEvent('mouseenter', { + view: window, + bubbles: true, + cancelable: true + })); + } + }); +}); + +console.log('✓ Hover effects activated'); + +// Let effects settle +await new Promise(resolve => setTimeout(resolve, 500)); diff --git a/examples/interact-form.js b/examples/interact-form.js new file mode 100644 index 0000000..d3b54ed --- /dev/null +++ b/examples/interact-form.js @@ -0,0 +1,41 @@ +// Form Interaction Example +// Fills out and interacts with forms on the page + +console.log('📝 Interacting with forms...'); + +// Find and fill input fields +await page.evaluate(() => { + // Fill text inputs + const textInputs = document.querySelectorAll('input[type="text"], input[type="email"]'); + textInputs.forEach((input, index) => { + if (input.placeholder.toLowerCase().includes('email')) { + input.value = 'demo@example.com'; + } else if (input.placeholder.toLowerCase().includes('name')) { + input.value = 'John Doe'; + } else { + input.value = `Demo Text ${index + 1}`; + } + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + + // Select first option in dropdowns + const selects = document.querySelectorAll('select'); + selects.forEach(select => { + if (select.options.length > 1) { + select.selectedIndex = 1; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + + // Check checkboxes + const checkboxes = document.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + }); +}); + +console.log('✓ Forms filled'); + +// Wait for any animations +await new Promise(resolve => setTimeout(resolve, 500)); diff --git a/examples/mobile-view.js b/examples/mobile-view.js new file mode 100644 index 0000000..19c45b6 --- /dev/null +++ b/examples/mobile-view.js @@ -0,0 +1,19 @@ +// Mobile View Example +// Sets viewport to mobile dimensions + +console.log('📱 Switching to mobile view...'); + +await page.setViewport({ + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true +}); + +console.log('✓ Mobile viewport set (iPhone SE)'); + +// Reload page for mobile view +await page.reload({ waitUntil: 'networkidle0' }); + +console.log('✓ Page reloaded in mobile view'); diff --git a/examples/scroll-animation.js b/examples/scroll-animation.js new file mode 100644 index 0000000..0202acd --- /dev/null +++ b/examples/scroll-animation.js @@ -0,0 +1,37 @@ +// Scroll Animation Example +// Smoothly scrolls through the entire page before capturing + +console.log('🎬 Starting scroll animation...'); + +// Scroll to bottom smoothly +await page.evaluate(async () => { + await new Promise((resolve) => { + let totalHeight = 0; + const distance = 100; + const timer = setInterval(() => { + const scrollHeight = document.body.scrollHeight; + window.scrollBy(0, distance); + totalHeight += distance; + + if (totalHeight >= scrollHeight) { + clearInterval(timer); + resolve(); + } + }, 100); + }); +}); + +console.log('✓ Scrolled to bottom'); + +// Wait a bit at bottom +await new Promise(resolve => setTimeout(resolve, 1000)); + +// Scroll back to top +await page.evaluate(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); +}); + +console.log('✓ Scrolled back to top'); + +// Wait for scroll to finish +await new Promise(resolve => setTimeout(resolve, 1000)); diff --git a/package.json b/package.json new file mode 100644 index 0000000..cc910b1 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "@valknarness/webshot", + "version": "1.0.0", + "description": "Shoot your favorite Websites", + "main": "webshot", + "bin": { + "webshot": "./webshot" + }, + "keywords": [ + "pupeteer", + "cli", + "record", + "screeencast", + "shoot" + ], + "author": "valknar@pivoine.art", + "license": "MIT", + "dependencies": { + "puppeteer": "^24.26.0" + } +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6b0d7e3 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - puppeteer diff --git a/webshot b/webshot new file mode 100755 index 0000000..95a737e --- /dev/null +++ b/webshot @@ -0,0 +1,804 @@ +#!/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 [options] [delay] + +${colors.bright}OPTIONS${colors.reset} + ${colors.mint}-u, --url ${colors.reset} Target URL to capture + ${colors.mint}-f, --format ${colors.reset} Export format: png, jpg, webp, pdf, mp4, html + ${colors.gray}(default: png)${colors.reset} + ${colors.mint}-o, --output ${colors.reset} Output file path + ${colors.mint}-s, --script ${colors.reset} Custom puppeteer automation script + ${colors.mint}-w, --width ${colors.reset} Viewport width ${colors.gray}(default: 1920)${colors.reset} + ${colors.mint}-h, --height ${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 ${colors.reset} Video duration for mp4 ${colors.gray}(default: 5000)${colors.reset} + ${colors.mint}--fps ${colors.reset} Video FPS for mp4 ${colors.gray}(default: 30)${colors.reset} + ${colors.mint}--wait-until ${colors.reset} Page load state: load, domcontentloaded, networkidle0, networkidle2 + ${colors.gray}(default: networkidle2)${colors.reset} + ${colors.mint}--timeout ${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 ${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);