chore: init
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
.npmrc
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
167
CLAUDE.md
Normal file
167
CLAUDE.md
Normal file
@@ -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 <ms>` 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-<timestamp>/`)
|
||||
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)
|
||||
351
README.md
Normal file
351
README.md
Normal file
@@ -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 <your-repo-url>
|
||||
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 <url>` | Target URL to capture | *required* |
|
||||
| `-f, --format <format>` | Export format: `png`, `jpg`, `webp`, `pdf`, `mp4`, `html` | `png` |
|
||||
| `-o, --output <path>` | Output file path | `<hostname>-<timestamp>.<format>` |
|
||||
| `-s, --script <path>` | Custom Puppeteer automation script | - |
|
||||
| `-w, --width <px>` | Viewport width | `1920` |
|
||||
| `-h, --height <px>` | 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 <ms>` | Video duration for MP4 | `5000` |
|
||||
| `--fps <fps>` | Video FPS for MP4 | `30` |
|
||||
| `--wait-until <state>` | Page load state: `load`, `domcontentloaded`, `networkidle0`, `networkidle2` | `networkidle2` |
|
||||
| `--timeout <ms>` | 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**
|
||||
92
examples/README.md
Normal file
92
examples/README.md
Normal file
@@ -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!!!
|
||||
33
examples/animated-sections.js
Normal file
33
examples/animated-sections.js
Normal file
@@ -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');
|
||||
48
examples/dark-mode.js
Normal file
48
examples/dark-mode.js
Normal file
@@ -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));
|
||||
24
examples/hover-effects.js
Normal file
24
examples/hover-effects.js
Normal file
@@ -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));
|
||||
41
examples/interact-form.js
Normal file
41
examples/interact-form.js
Normal file
@@ -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));
|
||||
19
examples/mobile-view.js
Normal file
19
examples/mobile-view.js
Normal file
@@ -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');
|
||||
37
examples/scroll-animation.js
Normal file
37
examples/scroll-animation.js
Normal file
@@ -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));
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- puppeteer
|
||||
804
webshot
Executable file
804
webshot
Executable file
@@ -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 <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);
|
||||
Reference in New Issue
Block a user