Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.2.0] - 2026-08-13

### Added
- `-s` / `--wait-for <selector>` waits for a CSS selector (`page.waitForSelector`) after the page is idle and before the extra `-t` delay. Use this for slow or login-gated dashboards whose main content appears after JavaScript renders.
- `--wait-timeout [s]` is the selector wait in seconds (default 30, clamped to 1–600). Ignored unless `--wait-for` is set.
- `--cookies [file]` loads cookies before navigation. Accepts a JSON array of Puppeteer cookies, Playwright `storageState` JSON, or a Netscape cookie file. Cookies without `url` or `domain` use the screenshot URL.
- Batch lines accept the new flags the same way as the CLI (including quoted selectors).

### Changed
- `-a` / `--auth` help text now says HTTP basic/NTLM (still `username:password` via `page.authenticate`).

## [2.1.0] - 2026-08-13

### Added
Expand Down
51 changes: 31 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,49 @@ Usage: web-screenshot [options]
Take screenshots of web pages

Options:
-V, --version output the version number
-p, --path [path] Chrome executable path.
-d, --debug Enable debug mode. (default: false)
-b, --batch [file] Batch file with URLs to screenshot. Supersedes all
other options.
-u, --url <url> URL (website) to screenshot.
-t, --time [s] Extra seconds to wait after the page is idle. (default:
3)
-x, --x [x] Leftmost pixel. (default: 0)
-y, --y [y] Top pixel. (default: 0)
-w, --width [width] Image width in pixels. 0 takes a full-page screenshot.
(default: 1920)
-h, --height [height] Image height in pixels. 0 takes a full-page screenshot.
(default: 1080)
-o, --out [out] Absolute or relative path to save the screenshot.
-c, --crop Auto crop same-color borders.
-a, --auth [auth] NTLM credentials in username:password format.
--help display help for command
-V, --version output the version number
-p, --path [path] Chrome executable path.
-d, --debug Enable debug mode. (default: false)
-b, --batch [file] Batch file with URLs to screenshot. Supersedes all
other options.
-u, --url <url> URL (website) to screenshot.
-t, --time [s] Extra seconds to wait after the page is idle.
(default: 3)
-x, --x [x] Leftmost pixel. (default: 0)
-y, --y [y] Top pixel. (default: 0)
-w, --width [width] Image width in pixels. 0 takes a full-page
screenshot. (default: 1920)
-h, --height [height] Image height in pixels. 0 takes a full-page
screenshot. (default: 1080)
-o, --out [out] Absolute or relative path to save the screenshot.
-c, --crop Auto crop same-color borders.
-a, --auth [auth] HTTP basic/NTLM credentials in username:password
format.
-s, --wait-for <selector> CSS selector to wait for before taking the
screenshot.
--wait-timeout [s] Seconds to wait for --wait-for. Ignored without
--wait-for. (default: 30)
--cookies <file> Cookies file: JSON array, Playwright storageState,
or Netscape format. Applied before navigation.
--help display help for command

Examples:
$ web-screenshot -u https://example.com
$ web-screenshot -u github.com -w 0 -h 0 -o full.png
$ web-screenshot -u https://google.com -x 700 -y 190 -w 700 -h 180 -o google_logo.png --crop
$ web-screenshot -u https://example.com --wait-for "#dashboard" --wait-timeout 30 -t 2 -o dashboard.png
$ web-screenshot -u https://example.com --cookies cookies.json -o dashboard.png
$ web-screenshot -b jobs.txt
```

Tips
----
* Navigation waits until the network is idle (`networkidle2`), then `-t` extra seconds (default 3; invalid values fall back to 5 seconds, clamped to 1–600).
* Navigation waits until the network is idle (`networkidle2`). If `-s` / `--wait-for` is set, the CLI then waits for that CSS selector (`page.waitForSelector`, up to `--wait-timeout` seconds, default 30; invalid values fall back to 30, clamped to 1–600). After that, `-t` extra seconds (default 3; invalid values fall back to 5 seconds, clamped to 1–600).
* Login-gated dashboards: use `-a` / `--auth user:pass` for HTTP basic or NTLM, and/or `--cookies cookies.json` for session cookies. The cookies file can be a JSON array of Puppeteer cookies, Playwright `storageState` JSON, or a Netscape cookie file. Cookies missing `url` and `domain` use the screenshot URL.
* For batch mode, each line should contain one set of arguments, such as:
* `-u https://google.com -x 700 -y 900 -w 700 -h 180 -o google_logo.png --crop`
* Quoted paths are supported: `-u https://example.com -o "My Screenshots/home.png"`
* `-u https://example.com --wait-for "#dashboard" --wait-timeout 45 -t 2 --cookies session.json -o dashboard.png`
* Quoted paths and selectors are supported: `-u https://example.com --wait-for "#main .dashboard" -o "My Screenshots/home.png"`
* Lines that begin with `#` will be ignored (comments)
* You can call web-screenshot with the URL only, such as `web-screenshot -u github.com`.
* The program will append `http://` to your URL and save the output file as `github.com.png`.
Expand Down
189 changes: 177 additions & 12 deletions dist/screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -1187,7 +1187,7 @@ var require_command = __commonJS({
var EventEmitter = require("node:events").EventEmitter;
var childProcess = require("node:child_process");
var path = require("node:path");
var fs4 = require("node:fs");
var fs5 = require("node:fs");
var process2 = require("node:process");
var { Argument: Argument2, humanReadableArgName } = require_argument();
var { CommanderError: CommanderError2 } = require_error();
Expand Down Expand Up @@ -2182,7 +2182,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
* @param {string} subcommandName
*/
_checkForMissingExecutable(executableFile, executableDir, subcommandName) {
if (fs4.existsSync(executableFile)) return;
if (fs5.existsSync(executableFile)) return;
const executableDirMessage = executableDir ? `searched for local subcommand relative to directory '${executableDir}'` : "no directory for search for local subcommand, use .executableDir() to supply a custom directory";
const executableMissing = `'${executableFile}' does not exist
- if '${subcommandName}' is not meant to be an executable command, remove description parameter from '.command()' and use '.description()' instead
Expand All @@ -2201,10 +2201,10 @@ Expecting one of '${allowedValues.join("', '")}'`);
const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"];
function findFile(baseDir, baseName) {
const localBin = path.resolve(baseDir, baseName);
if (fs4.existsSync(localBin)) return localBin;
if (fs5.existsSync(localBin)) return localBin;
if (sourceExt.includes(path.extname(baseName))) return void 0;
const foundExt = sourceExt.find(
(ext) => fs4.existsSync(`${localBin}${ext}`)
(ext) => fs5.existsSync(`${localBin}${ext}`)
);
if (foundExt) return `${localBin}${foundExt}`;
return void 0;
Expand All @@ -2216,7 +2216,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
if (this._scriptPath) {
let resolvedScriptPath;
try {
resolvedScriptPath = fs4.realpathSync(this._scriptPath);
resolvedScriptPath = fs5.realpathSync(this._scriptPath);
} catch {
resolvedScriptPath = this._scriptPath;
}
Expand Down Expand Up @@ -3452,7 +3452,7 @@ var require_commander = __commonJS({
});

// src/screenshot.ts
var fs3 = __toESM(require("node:fs"));
var fs4 = __toESM(require("node:fs"));

// src/cli.ts
var fs = __toESM(require("node:fs"));
Expand All @@ -3477,7 +3477,7 @@ var {
// package.json
var package_default = {
name: "@helvio/web-screenshot",
version: "2.1.0",
version: "2.2.0",
description: "CLI to take webpage screenshots with Puppeteer and optional Sharp crop",
keywords: [
"screenshot",
Expand Down Expand Up @@ -3599,6 +3599,23 @@ var Sanitizer = {
sanitizeAuth(auth) {
if (!auth) return void 0;
return /^[^:]+:[^:]+$/.test(auth) ? auth : void 0;
},
// CSS selector to wait for. Empty or non-string values are ignored.
sanitizeWaitFor(selector) {
if (typeof selector !== "string") return void 0;
const trimmed = selector.trim();
return trimmed.length > 0 ? trimmed : void 0;
},
// Seconds to wait for --wait-for. Integer 1–600, otherwise 30s (30000 ms).
sanitizeWaitTimeout(timeout) {
const n = parseInteger(timeout);
return n !== void 0 && n >= 1 && n <= 600 ? n * 1e3 : 3e4;
},
// Path to a cookies file. Empty or non-string values are ignored.
sanitizeCookiesFile(file) {
if (typeof file !== "string") return void 0;
const trimmed = file.trim();
return trimmed.length > 0 ? trimmed : void 0;
}
};
var Sanitizer_default = Sanitizer;
Expand Down Expand Up @@ -3652,13 +3669,22 @@ function createProgram() {
new Option("-w, --width [width]", "Image width in pixels. 0 takes a full-page screenshot.").default(1920)
).addOption(
new Option("-h, --height [height]", "Image height in pixels. 0 takes a full-page screenshot.").default(1080)
).addOption(new Option("-o, --out [out]", "Absolute or relative path to save the screenshot.")).addOption(new Option("-c, --crop", "Auto crop same-color borders.")).addOption(new Option("-a, --auth [auth]", "NTLM credentials in username:password format.")).addHelpText(
).addOption(new Option("-o, --out [out]", "Absolute or relative path to save the screenshot.")).addOption(new Option("-c, --crop", "Auto crop same-color borders.")).addOption(new Option("-a, --auth [auth]", "HTTP basic/NTLM credentials in username:password format.")).addOption(new Option("-s, --wait-for <selector>", "CSS selector to wait for before taking the screenshot.")).addOption(
new Option("--wait-timeout [s]", "Seconds to wait for --wait-for. Ignored without --wait-for.").default(30)
).addOption(
new Option(
"--cookies <file>",
"Cookies file: JSON array, Playwright storageState, or Netscape format. Applied before navigation."
)
).addHelpText(
"after",
`
Examples:
$ web-screenshot -u https://example.com
$ web-screenshot -u github.com -w 0 -h 0 -o full.png
$ web-screenshot -u https://google.com -x 700 -y 190 -w 700 -h 180 -o google_logo.png --crop
$ web-screenshot -u https://example.com --wait-for "#dashboard" --wait-timeout 30 -t 2 -o dashboard.png
$ web-screenshot -u https://example.com --cookies cookies.json -o dashboard.png
$ web-screenshot -b jobs.txt
`
);
Expand All @@ -3682,7 +3708,10 @@ function optionsToScreenshot(options2) {
tmp: tmpSanitized.path,
ext: outSanitized.ext,
auth: Sanitizer_default.sanitizeAuth(authValue),
crop: Boolean(options2.crop)
crop: Boolean(options2.crop),
waitFor: Sanitizer_default.sanitizeWaitFor(options2.waitFor),
waitTimeout: Sanitizer_default.sanitizeWaitTimeout(options2.waitTimeout),
cookiesFile: Sanitizer_default.sanitizeCookiesFile(options2.cookies)
};
}
function parseBatchContent(content, debug2 = false) {
Expand Down Expand Up @@ -3727,7 +3756,7 @@ function jobsFromOptions(options2, io = fs, debug2 = false) {
}

// src/runScreenshots.ts
var fs2 = __toESM(require("node:fs"));
var fs3 = __toESM(require("node:fs"));

// src/capture.ts
function isFullPage(width, height) {
Expand All @@ -3738,11 +3767,136 @@ function planCapture(ss) {
const screenshot = isFullPage(ss.width, ss.height) ? { path, fullPage: true } : { path, clip: { x: ss.x, y: ss.y, width: ss.width, height: ss.height } };
return {
goto: { url: ss.url, waitUntil: "networkidle2" },
waitFor: ss.waitFor ? { selector: ss.waitFor, timeout: ss.waitTimeout } : void 0,
extraWaitMs: ss.time,
screenshot
};
}

// src/cookies.ts
var fs2 = __toESM(require("node:fs"));
var SAME_SITE = {
strict: "Strict",
lax: "Lax",
none: "None"
};
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isCookieLike(value) {
return isRecord(value) && typeof value.name === "string" && typeof value.value === "string";
}
function sameSite(value) {
if (typeof value !== "string") return void 0;
return SAME_SITE[value.toLowerCase()];
}
function expires(value) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
return void 0;
}
function ensureUrlOrDomain(cookie, pageUrl) {
if (cookie.url || cookie.domain) return cookie;
return { ...cookie, url: pageUrl };
}
function normalizeCookie(raw, pageUrl) {
const cookie = {
name: String(raw.name),
value: String(raw.value)
};
if (typeof raw.url === "string" && raw.url.length > 0) cookie.url = raw.url;
if (typeof raw.domain === "string" && raw.domain.length > 0) cookie.domain = raw.domain;
if (typeof raw.path === "string" && raw.path.length > 0) cookie.path = raw.path;
const exp = expires(raw.expires) ?? expires(raw.expirationDate);
if (exp !== void 0) cookie.expires = exp;
if (typeof raw.httpOnly === "boolean") cookie.httpOnly = raw.httpOnly;
if (typeof raw.secure === "boolean") cookie.secure = raw.secure;
const site = sameSite(raw.sameSite);
if (site) cookie.sameSite = site;
return ensureUrlOrDomain(cookie, pageUrl);
}
function parseJsonCookies(content, pageUrl) {
let data;
try {
data = JSON.parse(content);
} catch {
throw new Error("Cookies file contains invalid JSON.");
}
let raw;
if (Array.isArray(data)) {
raw = data;
} else if (isRecord(data) && Array.isArray(data.cookies)) {
raw = data.cookies;
} else if (isCookieLike(data)) {
raw = [data];
} else {
throw new Error(
"Cookies file must be a JSON array of cookies or a Playwright storageState object with a cookies array."
);
}
return raw.map((item, index) => {
if (!isCookieLike(item)) {
throw new Error(`Cookies file entry ${index} is missing name or value.`);
}
return normalizeCookie(item, pageUrl);
});
}
function parseNetscapeCookies(content, pageUrl) {
const cookies = [];
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (line === "") continue;
let httpOnly = false;
let fieldsLine = line;
if (line.startsWith("#HttpOnly_")) {
httpOnly = true;
fieldsLine = line.slice("#HttpOnly_".length);
} else if (line.startsWith("#")) {
continue;
}
const fields = fieldsLine.split(" ");
if (fields.length < 7) continue;
const [domain, , path, secure, expiry, name, ...valueParts] = fields;
if (!name) continue;
cookies.push(
ensureUrlOrDomain(
{
name,
value: valueParts.join(" "),
domain: domain || void 0,
path: path || "/",
secure: String(secure).toUpperCase() === "TRUE",
httpOnly,
expires: expires(Number(expiry))
},
pageUrl
)
);
}
return cookies;
}
function parseCookies(content, pageUrl) {
const trimmed = content.trim();
if (trimmed === "") {
throw new Error("Cookies file is empty.");
}
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return parseJsonCookies(trimmed, pageUrl);
}
const cookies = parseNetscapeCookies(trimmed, pageUrl);
if (cookies.length === 0) {
throw new Error(
"Cookies file must be a JSON array of cookies, a Playwright storageState object, or a Netscape cookie file."
);
}
return cookies;
}
function loadCookiesFromFile(filePath, pageUrl, io = fs2) {
if (!io.existsSync(filePath)) {
throw new Error(`Cookies file "${filePath}" does not exist.`);
}
return parseCookies(io.readFileSync(filePath, "utf-8"), pageUrl);
}

// src/runScreenshots.ts
var defaultSleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
async function defaultTrimToFile(input, output) {
Expand All @@ -3751,7 +3905,7 @@ async function defaultTrimToFile(input, output) {
}
async function runScreenshots(jobs2, runtime) {
const sleep = runtime.sleep ?? defaultSleep;
const fileOps = runtime.fs ?? fs2;
const fileOps = runtime.fs ?? fs3;
const trimToFile = runtime.trimToFile ?? defaultTrimToFile;
const launchOptions = {
headless: runtime.debug ? false : "shell",
Expand All @@ -3772,9 +3926,20 @@ async function runScreenshots(jobs2, runtime) {
await page.authenticate({ username, password: password ?? "" });
console.log("Credentials Entered");
}
if (ss.cookiesFile) {
const cookies = loadCookiesFromFile(ss.cookiesFile, ss.url);
if (cookies.length > 0) {
await page.setCookie(...cookies);
console.log(`Cookies loaded from ${ss.cookiesFile}`);
}
}
const plan = planCapture(ss);
await page.goto(plan.goto.url, { waitUntil: plan.goto.waitUntil });
console.log(`Navigated to ${ss.url}`);
if (plan.waitFor) {
console.log(`Waiting for selector ${plan.waitFor.selector}`);
await page.waitForSelector(plan.waitFor.selector, { timeout: plan.waitFor.timeout });
}
console.log(`Waiting for ${ss.time / 1e3} seconds`);
await sleep(plan.extraWaitMs);
console.log("Page Loaded");
Expand Down Expand Up @@ -3807,7 +3972,7 @@ var debug = Boolean(options.debug);
var chromePath = typeof options.path === "string" ? options.path : void 0;
var jobs = [];
try {
jobs = jobsFromOptions(options, fs3, debug);
jobs = jobsFromOptions(options, fs4, debug);
} catch (error) {
console.error(error.message);
process.exit(1);
Expand Down
Loading
Loading