capture-website-cli
Version:
Capture screenshots of websites from the command-line
509 lines (460 loc) • 14.1 kB
JavaScript
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);
}