UNPKG

capture-website-cli

Version:

Capture screenshots of websites from the command-line

509 lines (460 loc) 14.1 kB
#!/usr/bin/env node import process from 'node:process'; import {mkdir} from 'node:fs/promises'; import path from 'node:path'; import meow from 'meow'; import captureWebsite from 'capture-website'; import splitOnFirst from 'split-on-first'; import getStdin from 'get-stdin'; import {unusedFilename} from 'unused-filename'; import filenamifyUrl from 'filenamify-url'; const cli = meow(` Usage $ capture-website <url|file> $ echo "<h1>Unicorn</h1>" | capture-website Options --output Image file path (writes it to stdout if omitted) --auto-output Automatically generate output filename from URL/input --width Page width [default: 1280] --height Page height [default: 800] --type Image type: png|jpeg|webp [default: png] --quality Image quality: 0...1 (Only for JPEG and WebP) [default: 1] --scale-factor Scale the webpage \`n\` times [default: 2] --list-devices Output a list of supported devices to emulate --emulate-device Capture as if it were captured on the given device --full-page Capture the full scrollable page, not just the viewport --no-default-background Make the default background transparent --timeout Seconds before giving up trying to load the page. Specify \`0\` to disable. [default: 60] --delay Seconds to wait after the page finished loading before capturing the screenshot [default: 0] --wait-for-element Wait for a DOM element matching the CSS selector to appear in the page and to be visible before capturing the screenshot --element Capture the DOM element matching the CSS selector. It will wait for the element to appear in the page and to be visible. --hide-elements Hide DOM elements matching the CSS selector (Can be set multiple times) --remove-elements Remove DOM elements matching the CSS selector (Can be set multiple times) --click-element Click the DOM element matching the CSS selector --scroll-to-element Scroll to the DOM element matching the CSS selector --disable-animations Disable CSS animations and transitions [default: false] --no-javascript Disable JavaScript execution (does not affect --module/--script) --module Inject a JavaScript module into the page. Can be inline code, absolute URL, and local file path with \`.js\` extension. (Can be set multiple times) --script Same as \`--module\`, but instead injects the code as a classic script --style Inject CSS styles into the page. Can be inline code, absolute URL, and local file path with \`.css\` extension. (Can be set multiple times) --header Set a custom HTTP header (Can be set multiple times) --user-agent Set the user agent --cookie Set a cookie (Can be set multiple times) --authentication Credentials for HTTP authentication --debug Show the browser window to see what it's doing --dark-mode Emulate preference of dark color scheme --local-storage Set localStorage items before the page loads (Can be set multiple times) --launch-options Puppeteer launch options as JSON --overwrite Overwrite the destination file if it exists --inset Inset the screenshot relative to the viewport or \`--element\`. Accepts a number or four comma-separated numbers for top, right, bottom, and left. --clip Position and size in the website (clipping region). Accepts comma-separated numbers for x, y, width, and height. --no-block-ads Disable ad blocking --allow-cors Allow cross-origin requests (useful for local HTML files) --wait-for-network-idle Wait for network connections to finish --insecure Accept self-signed and invalid SSL certificates --preload-lazy-content Scroll through entire page to trigger lazy-loaded content before capture --referrer Custom referrer header for navigation --throw-on-http-error Throw error on non-2xx HTTP status codes --log-console Redirect page console output to terminal --pdf-format Paper format: letter|legal|tabloid|ledger|a0|a1|a2|a3|a4|a5|a6 [default: letter] --pdf-landscape Use landscape orientation for PDF --pdf-margin Page margins. Accepts a number/string or four comma-separated values for top, right, bottom, and left. --pdf-background Include background graphics in PDF Examples $ capture-website https://sindresorhus.com --output=screenshot.png $ capture-website https://sindresorhus.com --auto-output $ capture-website index.html --output=screenshot.png $ echo "<h1>Unicorn</h1>" | capture-website --output=screenshot.png $ capture-website https://sindresorhus.com | open -f -a Preview Flag examples --output=screenshot.png --width=1000 --height=600 --type=jpeg --quality=0.5 --scale-factor=3 --emulate-device="iPhone X" --timeout=80 --delay=10 --wait-for-element="#header" --element=".main-content" --hide-elements=".sidebar" --remove-elements="img.ad" --click-element="button" --scroll-to-element="#map" --disable-animations --no-javascript --module=https://sindresorhus.com/remote-file.js --module=local-file.js --module="document.body.style.backgroundColor = 'red'" --header="x-powered-by: capture-website-cli" --user-agent="I love unicorns" --cookie="id=unicorn; Expires=Wed, 21 Oct 2018 07:28:00 GMT;" --authentication="username:password" --launch-options='{"headless": false}' --dark-mode --local-storage="theme=dark" --inset=10,15,-10,15 --inset=30 --clip=10,30,300,1024 --allow-cors --wait-for-network-idle --insecure --preload-lazy-content --referrer="https://google.com" --throw-on-http-error --log-console --pdf-format=a4 --pdf-landscape --pdf-margin=1in --pdf-margin=1in,0.5in,1in,0.5in --pdf-background `, { importMeta: import.meta, flags: { output: { type: 'string', }, autoOutput: { type: 'boolean', }, width: { type: 'number', }, height: { type: 'number', }, type: { type: 'string', }, quality: { type: 'number', }, scaleFactor: { type: 'number', }, listDevices: { type: 'boolean', }, emulateDevice: { type: 'string', }, fullPage: { type: 'boolean', }, defaultBackground: { type: 'boolean', }, timeout: { type: 'number', }, delay: { type: 'number', }, waitForElement: { type: 'string', }, clip: { type: 'string', }, element: { type: 'string', }, hideElements: { type: 'string', isMultiple: true, }, removeElements: { type: 'string', isMultiple: true, }, clickElement: { type: 'string', }, scrollToElement: { type: 'string', }, disableAnimations: { type: 'boolean', }, javascript: { type: 'boolean', default: true, }, module: { type: 'string', isMultiple: true, }, script: { type: 'string', isMultiple: true, }, style: { type: 'string', isMultiple: true, }, header: { type: 'string', isMultiple: true, }, userAgent: { type: 'string', }, cookie: { type: 'string', isMultiple: true, }, authentication: { type: 'string', }, debug: { type: 'boolean', }, darkMode: { type: 'boolean', }, launchOptions: { type: 'string', }, localStorage: { type: 'string', isMultiple: true, }, overwrite: { type: 'boolean', }, inset: { type: 'string', }, blockAds: { type: 'boolean', default: true, }, allowCors: { type: 'boolean', }, waitForNetworkIdle: { type: 'boolean', }, insecure: { type: 'boolean', }, preloadLazyContent: { type: 'boolean', }, referrer: { type: 'string', }, throwOnHttpError: { type: 'boolean', }, logConsole: { type: 'boolean', }, pdfFormat: { type: 'string', }, pdfLandscape: { type: 'boolean', }, pdfMargin: { type: 'string', }, pdfBackground: { type: 'boolean', }, }, }); let [input] = cli.input; const options = cli.flags; // TODO: `meow` needs a way to handle this. options.modules = options.module; options.scripts = options.script; options.styles = options.style; options.cookies = options.cookie; delete options.module; delete options.script; delete options.style; delete options.cookie; function parseKeyValuePairs(items, separator, itemName) { const result = {}; for (const item of items) { const [key, value] = splitOnFirst(item, separator); if (!key || value === undefined) { console.error(`Invalid ${itemName} format: "${item}". Use key${separator}value format.`); process.exit(1); } result[key.trim()] = value.trim(); } return result; } options.launchOptions &&= JSON.parse(options.launchOptions); if (options.insecure) { options.launchOptions = { acceptInsecureCerts: true, ...options.launchOptions, }; } options.localStorage &&= parseKeyValuePairs(options.localStorage, '=', 'localStorage'); if (options.clip) { const [x, y, width, height] = options.clip.split(',').map(chunk => Number.parseInt(chunk, 10)); options.clip = { x, y, width, height, }; } if (options.header) { options.headers = parseKeyValuePairs(options.header, ':', 'header'); delete options.header; } else { options.headers = {}; } if (options.authentication) { const [username, password] = splitOnFirst(options.authentication, ':'); options.authentication = {username, password}; } if (options.inset) { const values = options.inset.split(',').map(chunk => Number.parseInt(chunk, 10)); const containsNaN = values.some(number => Number.isNaN(number)); if (containsNaN || ![1, 4].includes(values.length)) { console.error('Invalid `--inset` value'); process.exit(1); } if (values.length === 1) { options.inset = values[0]; } else { const insetOption = {}; for (const [index, key] of ['top', 'right', 'bottom', 'left'].entries()) { insetOption[key] = values[index]; } options.inset = insetOption; } } options.isJavaScriptEnabled = options.javascript; // Process PDF options if (options.pdfFormat || options.pdfLandscape || options.pdfMargin !== undefined || options.pdfBackground) { options.pdf = {}; if (options.pdfFormat) { options.pdf.format = options.pdfFormat; } if (options.pdfLandscape) { options.pdf.landscape = options.pdfLandscape; } if (options.pdfBackground) { options.pdf.background = options.pdfBackground; } if (options.pdfMargin !== undefined) { const values = options.pdfMargin.split(',').map(chunk => chunk.trim()); // Validate that we have 1 or 4 values if (![1, 4].includes(values.length)) { console.error('Invalid `--pdf-margin` value: must be a single value or four comma-separated values'); process.exit(1); } // Validate that no values are empty if (values.includes('')) { console.error('Invalid `--pdf-margin` value: empty values not allowed'); process.exit(1); } // Helper to parse a single margin value const parseMarginValue = value => { // Check if it's a pure number (only digits and optional decimal/negative) const isPureNumber = /^-?\d+(\.\d+)?$/.test(value); if (isPureNumber) { return Number.parseFloat(value); } // Keep as string to preserve units (e.g., "1in", "2.5cm") return value; }; if (values.length === 1) { // Single value for all sides options.pdf.margin = parseMarginValue(values[0]); } else { // Four values for top, right, bottom, left const marginOption = {}; const keys = ['top', 'right', 'bottom', 'left']; for (const [index, key] of keys.entries()) { marginOption[key] = parseMarginValue(values[index]); } options.pdf.margin = marginOption; } } delete options.pdfFormat; delete options.pdfLandscape; delete options.pdfMargin; delete options.pdfBackground; } // Add console logging callback if (options.logConsole) { options.onConsole = message => { try { const type = message.type(); const text = message.text(); console.error(`[console.${type}] ${text}`); } catch { // Silently ignore console logging errors to not break screenshot capture } }; delete options.logConsole; } async function generateAutoFilename(input, type, inputType) { let filename = 'screenshot'; if (inputType !== 'html') { try { // Try to parse as URL const url = new URL(input); if (url.protocol === 'http:' || url.protocol === 'https:') { filename = filenamifyUrl(input); } } catch { // It's a file path const basename = path.basename(input, path.extname(input)); if (basename) { filename = basename; } } } // Get unused filename (handles increments if file exists) return unusedFilename(`${filename}.${type}`); } async function main() { const { internalPrintFlags, listDevices, output, autoOutput, } = options; if (internalPrintFlags) { console.log(JSON.stringify(options)); return; } if (listDevices) { console.log(captureWebsite.devices.join('\n')); return; } if (!input) { input = await getStdin(); options.inputType = 'html'; } if (!input) { console.error('Please specify a URL, file path or HTML'); process.exit(1); } // Handle auto-output flag let finalOutput = output; if (autoOutput && !output) { finalOutput = await generateAutoFilename(input, options.type || 'png', options.inputType); } if (finalOutput) { // Ensure the directory exists when using --overwrite const outputDirectory = path.dirname(finalOutput); await mkdir(outputDirectory, {recursive: true}); await captureWebsite.file(input, finalOutput, options); } else { process.stdout.write(await captureWebsite.buffer(input, options)); } } try { await main(); } catch (error) { console.error(error.message); process.exit(1); }