Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:05:55 +08:00
commit 89c3c1ab83
18 changed files with 1955 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { platform } from "node:os";
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
delay,
parseArgs,
printJSON,
resolveBrowserConnection,
normalizeNumber,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["force", "json", "quiet"],
string: ["ws", "host"],
number: ["port", "timeout"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 5000,
},
});
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const port = normalizeNumber(args.port, DEFAULT_PORT);
const timeout = normalizeNumber(args.timeout, 5000);
const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws });
const result = {
ok: true,
port,
graceful: false,
forced: false,
closedTabs: 0,
};
logger.info("🔄 Shutting down browser...");
let browser;
if (!args.force) {
try {
browser = await puppeteer.connect({
...connectionOptions,
timeout,
});
const pages = await browser.pages();
result.closedTabs = pages.length;
await browser.close();
result.graceful = true;
logger.info(`✅ Closed browser gracefully (${pages.length} tabs)`);
} catch (error) {
logger.warn(`⚠️ Graceful shutdown failed: ${error.message}`);
} finally {
if (browser) await browser.disconnect();
}
}
if (!result.graceful) {
const forced = await forceCloseProcesses({ logger, port });
result.forced = forced;
if (!forced) {
logger.warn("⚠️ No Chrome processes were terminated");
}
}
await delay(300);
if (jsonOutput) {
printJSON(result);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
logger.info("🎉 Browser shutdown complete");
}
async function forceCloseProcesses({ logger: log, port: debuggingPort }) {
const commands = buildTerminationCommands();
let anyKilled = false;
for (const command of commands) {
const { file, args } = command;
const execution = spawnSync(file, args, { stdio: "ignore" });
if (execution.status === 0) {
anyKilled = true;
log.info(`🔨 Executed ${file} ${args.join(" ")}`);
}
}
// Attempt to free the port via lsof if available (Unix only)
if (platform() !== "win32") {
const killByPort = spawnSync("bash", ["-c", `lsof -ti:${debuggingPort} 2>/dev/null | xargs -r kill -9`], {
stdio: "ignore",
});
if (killByPort.status === 0) {
anyKilled = true;
log.info(`🔨 Cleared processes on port ${debuggingPort}`);
}
}
return anyKilled;
}
function buildTerminationCommands() {
if (platform() === "win32") {
return [
{ file: "taskkill", args: ["/F", "/IM", "chrome.exe", "/T"] },
{ file: "taskkill", args: ["/F", "/IM", "msedge.exe", "/T"] },
];
}
return [
{ file: "pkill", args: ["-f", "Google Chrome"] },
{ file: "pkill", args: ["-f", "chrome"] },
{ file: "pkill", args: ["-f", "chromium"] },
{ file: "pkill", args: ["-f", "msedge"] },
];
}

View File

@@ -0,0 +1,217 @@
import { existsSync } from "node:fs";
import { access, constants } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve as resolvePath, join } from "node:path";
export const DEFAULT_PORT = 9222;
const identity = (value) => value;
export function parseArgs(argv, options = {}) {
const {
boolean = [],
string = [],
number = [],
alias = {},
defaults = {},
} = options;
const boolSet = new Set(boolean.map(normalizeKey));
const stringSet = new Set(string.map(normalizeKey));
const numberSet = new Set(number.map(normalizeKey));
const aliases = Object.fromEntries(
Object.entries(alias).map(([key, target]) => [normalizeKey(key), normalizeKey(target)]),
);
const result = { _: [] };
const assignValue = (key, value = true) => {
const normalized = normalizeKey(key);
const target = aliases[normalized] ?? normalized;
result[target] = value;
};
for (let index = 0; index < argv.length; index++) {
const token = argv[index];
if (token === "--") {
result._.push(...argv.slice(index + 1));
break;
}
if (!token.startsWith("-")) {
result._.push(token);
continue;
}
if (token.startsWith("--no-")) {
const key = token.slice(5);
assignValue(key, false);
continue;
}
const [rawKey, inlineValue] = token.split("=", 2);
const key = rawKey.replace(/^--?/, "");
if (boolSet.has(key)) {
assignValue(key, inlineValue === undefined ? true : coerceBoolean(inlineValue));
continue;
}
const expectsString = stringSet.has(key);
const expectsNumber = numberSet.has(key);
if (inlineValue !== undefined) {
assignValue(key, expectsNumber ? Number(inlineValue) : inlineValue);
continue;
}
const nextToken = argv[index + 1];
const hasNext = nextToken !== undefined && !nextToken.startsWith("--");
if ((expectsString || expectsNumber) && hasNext) {
index += 1;
assignValue(key, expectsNumber ? Number(nextToken) : nextToken);
} else if (expectsString || expectsNumber) {
assignValue(key, expectsNumber ? NaN : "");
} else {
assignValue(key, true);
}
}
for (const [key, value] of Object.entries(defaults)) {
if (!(key in result)) {
result[key] = typeof value === "function" ? value() : value;
}
}
return result;
}
export function createLogger({ quiet = false, json = false } = {}) {
const format = (args) =>
args
.map((part) => {
if (part instanceof Error) {
return part.stack ?? part.message;
}
return typeof part === "object" ? JSON.stringify(part) : String(part);
})
.join(" ");
const write = (stream, args) => {
stream.write(format(args));
stream.write("\n");
};
return {
info: (...args) => {
if (!quiet && !json) write(process.stderr, args);
},
warn: (...args) => {
if (!quiet && !json) write(process.stderr, args);
},
error: (...args) => {
write(process.stderr, args);
},
};
}
export function printJSON(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
export function fail(message, { code = 1, json = false } = {}) {
if (json) {
printJSON({ ok: false, error: message });
} else {
process.stderr.write(`${message}\n`);
}
process.exit(code);
}
export function resolveBrowserConnection(flags = {}) {
if (flags.ws || process.env.BROWSER_WS_URL) {
const endpoint = flags.ws ?? process.env.BROWSER_WS_URL;
return {
browserWSEndpoint: endpoint,
defaultViewport: null,
};
}
const host = flags.host ?? process.env.BROWSER_HOST ?? "localhost";
const port = normalizeNumber(flags.port ?? process.env.BROWSER_PORT ?? DEFAULT_PORT, DEFAULT_PORT);
return {
browserURL: `http://${host}:${port}`,
defaultViewport: null,
};
}
export async function waitFor(fn, { timeout = 10000, interval = 250 } = {}) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeout) {
try {
const result = await fn();
if (result) return result;
} catch (error) {
lastError = error;
}
await delay(interval);
}
if (lastError) throw lastError;
return null;
}
export function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function getActivePage(browser, { index = -1 } = {}) {
const pages = await browser.pages();
if (pages.length === 0) return null;
return index === -1 ? pages.at(-1) : pages[index] ?? null;
}
export function expandPath(value) {
if (!value) return value;
if (value.startsWith("~")) {
return resolvePath(join(homedir(), value.slice(1)));
}
return resolvePath(value);
}
export async function pathExists(path) {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
export function normalizeNumber(value, fallback) {
const number = typeof value === "string" ? Number(value) : value;
return Number.isFinite(number) ? number : fallback;
}
export function findExistingPath(paths) {
for (const candidate of paths) {
if (!candidate) continue;
if (existsSync(candidate)) return candidate;
}
return null;
}
function normalizeKey(key) {
return key.replace(/^--?/, "");
}
function coerceBoolean(value) {
if (typeof value === "boolean") return value;
const normalized = String(value).toLowerCase();
if (["false", "0", "no", "off"].includes(normalized)) return false;
return true;
}

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
fail,
getActivePage,
parseArgs,
printJSON,
resolveBrowserConnection,
normalizeNumber,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["clear", "json", "quiet"],
string: ["export", "import", "domain", "ws", "host"],
number: ["port", "timeout"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 15000,
},
});
const mode = resolveMode(args);
if (!mode) {
fail("Specify one command: --export [file] | --import <file> | --clear", { json: Boolean(args.json) });
}
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const timeout = normalizeNumber(args.timeout, 15000);
const port = normalizeNumber(args.port, DEFAULT_PORT);
const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws });
let browser;
try {
browser = await puppeteer.connect({
...connectionOptions,
timeout,
});
} catch (error) {
fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput });
}
try {
const page = await getActivePage(browser, { index: 0 });
if (!page) {
fail("No active page found. Start a session first.", { json: jsonOutput });
}
const client = await page.createCDPSession();
await client.send("Network.enable");
if (mode === "export") {
await handleExport({ page, client, args, logger, jsonOutput });
} else if (mode === "import") {
await handleImport({ page, client, args, logger, jsonOutput, timeout });
} else {
await handleClear({ client, args, logger, jsonOutput });
}
} catch (error) {
fail(`Cookie operation failed: ${error.message}`, { json: jsonOutput });
} finally {
if (browser) await browser.disconnect();
}
function resolveMode(parsed) {
if (typeof parsed.export === "string") return "export";
if (typeof parsed.import === "string") return "import";
if (parsed.clear) return "clear";
return null;
}
async function handleExport({ page, client, args: parsed, logger: log, jsonOutput }) {
const domainFilter = parsed.domain ?? null;
const outputPath = parsed.export?.trim() ? parsed.export : null;
const { cookies } = await client.send("Network.getAllCookies");
const filtered = (domainFilter
? cookies.filter((cookie) => cookie.domain.includes(domainFilter))
: cookies
).sort((a, b) => {
if (a.domain !== b.domain) return a.domain.localeCompare(b.domain);
return a.name.localeCompare(b.name);
});
const payload = {
ok: true,
exportedAt: new Date().toISOString(),
pageUrl: page.url(),
domain: domainFilter,
total: filtered.length,
cookies: filtered.map((cookie) => ({
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires: cookie.expires,
httpOnly: cookie.httpOnly,
secure: cookie.secure,
sameSite: cookie.sameSite,
priority: cookie.priority,
})),
};
if (outputPath) {
await writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
log.info(`🍪 Exported ${filtered.length} cookies to ${outputPath}`);
if (jsonOutput) {
printJSON({ ok: true, path: outputPath, total: filtered.length });
}
} else if (jsonOutput) {
printJSON(payload);
} else {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
}
async function handleImport({ page, client, args: parsed, logger: log, jsonOutput, timeout }) {
const sourcePath = parsed.import?.trim();
if (!sourcePath) {
fail("--import requires a file path", { json: jsonOutput });
}
const content = await readFile(sourcePath, "utf8");
let payload;
try {
payload = JSON.parse(content);
} catch (error) {
fail(`Invalid JSON in ${sourcePath}: ${error.message}`, { json: jsonOutput });
}
if (!payload.cookies || !Array.isArray(payload.cookies)) {
fail("Cookie file missing 'cookies' array", { json: jsonOutput });
}
const cookiesToSet = payload.cookies.map((cookie) => normalizeCookie(cookie));
await client.send("Network.setCookies", { cookies: cookiesToSet });
log.info(`✅ Imported ${cookiesToSet.length} cookies`);
try {
await page.reload({ waitUntil: "networkidle0", timeout });
log.info("🔄 Page reloaded to apply cookies");
} catch (error) {
log.warn(`⚠️ Page reload failed: ${error.message}`);
}
const result = { ok: true, imported: cookiesToSet.length };
if (jsonOutput) {
printJSON(result);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
}
}
async function handleClear({ client, args: parsed, logger: log, jsonOutput }) {
const domainFilter = parsed.domain ?? null;
if (!domainFilter) {
await client.send("Network.clearBrowserCookies");
const result = { ok: true, cleared: "all" };
if (jsonOutput) {
printJSON(result);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
}
log.info("🧹 Cleared all cookies");
return;
}
const { cookies } = await client.send("Network.getAllCookies");
const filtered = cookies.filter((cookie) => cookie.domain.includes(domainFilter));
for (const cookie of filtered) {
await client.send("Network.deleteCookies", {
name: cookie.name,
domain: cookie.domain,
path: cookie.path,
});
}
const result = { ok: true, cleared: filtered.length, domain: domainFilter };
if (jsonOutput) {
printJSON(result);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
}
log.info(`🧹 Cleared ${filtered.length} cookies for ${domainFilter}`);
}
function normalizeCookie(cookie) {
const sameSite = normalizeSameSite(cookie.sameSite);
const param = {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path ?? "/",
};
if (cookie.expires && Number.isFinite(cookie.expires)) {
param.expires = cookie.expires;
}
if (cookie.httpOnly !== undefined) param.httpOnly = Boolean(cookie.httpOnly);
if (cookie.secure !== undefined) param.secure = Boolean(cookie.secure);
if (sameSite) param.sameSite = sameSite;
return param;
}
function normalizeSameSite(value) {
if (!value) return undefined;
const normalized = String(value).toLowerCase();
if (normalized === "lax") return "Lax";
if (normalized === "strict") return "Strict";
if (normalized === "none") return "None";
return undefined;
}

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env node
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
fail,
getActivePage,
parseArgs,
printJSON,
resolveBrowserConnection,
normalizeNumber,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["click", "scroll", "json", "quiet"],
string: ["text", "ws", "host"],
number: ["port", "timeout"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 15000,
},
});
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const selector = args._[0] ?? null;
const textSearch = args.text ?? null;
const timeout = normalizeNumber(args.timeout, 15000);
const port = normalizeNumber(args.port, DEFAULT_PORT);
const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws });
let browser;
try {
browser = await puppeteer.connect({
...connectionOptions,
timeout,
});
} catch (error) {
fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput });
}
try {
const page = await getActivePage(browser, { index: -1 });
if (!page) {
fail("No active page found. Navigate first.", { json: jsonOutput });
}
const elementResult = await resolveElement(page, { selector, textSearch, timeout });
if (!elementResult || !elementResult.handle) {
const message = selector
? `Selector not found: ${selector}`
: textSearch
? `No element found containing text: ${textSearch}`
: "No element selected.";
fail(message, { json: jsonOutput });
}
const { handle, info } = elementResult;
if (args.scroll) {
await handle.evaluate((el) => {
el.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
});
logger.info("↕️ Element scrolled into view");
}
if (args.click) {
try {
await handle.click({ delay: 20 });
logger.info("🖱️ Element clicked");
} catch (error) {
fail(`Click failed: ${error.message}`, { json: jsonOutput });
}
}
const output = {
ok: true,
selector: info.selector,
tag: info.tag,
id: info.id,
classes: info.classes,
text: info.text,
attributes: info.attributes,
rect: info.rect,
visible: info.visible,
children: info.children,
};
if (jsonOutput) {
printJSON(output);
} else {
logger.info(`✅ Element ${info.selector}`);
process.stdout.write(formatHumanOutput(output));
}
await handle.dispose();
} catch (error) {
fail(`Element lookup failed: ${error.message}`, { json: jsonOutput });
} finally {
if (browser) await browser.disconnect();
}
async function resolveElement(page, { selector: rawSelector, textSearch: rawText, timeout: timeoutMs }) {
if (rawSelector) {
const handle = await page.$(rawSelector);
if (!handle) return null;
const info = await collectElementInfo(page, handle, rawSelector);
return { handle, info };
}
if (rawText) {
const handle = await findByText(page, rawText);
if (!handle) return null;
const info = await collectElementInfo(page, handle);
return { handle, info };
}
return await pickElement(page, timeoutMs);
}
async function collectElementInfo(page, handle, selectorOverride) {
return await page.evaluate((el, selectorHint) => {
const escapeIdent = (value) => {
if (window.CSS && typeof window.CSS.escape === "function") {
return window.CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_\-]/g, (char) => `\\${char}`);
};
const toSelector = (element) => {
const parts = [];
let current = element;
while (current && current.nodeType === 1) {
let part = current.nodeName.toLowerCase();
if (current.id) {
part = `#${escapeIdent(current.id)}`;
parts.unshift(part);
break;
}
if (current.classList.length > 0) {
part += `.${Array.from(current.classList, (cls) => escapeIdent(cls)).join('.')}`;
}
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.nodeName === current.nodeName);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
part += `:nth-of-type(${index})`;
}
}
parts.unshift(part);
current = parent;
}
return parts.join(' > ');
};
const rect = el.getBoundingClientRect();
const attributes = Object.fromEntries(
el.getAttributeNames().map((name) => [name, el.getAttribute(name)]),
);
const textContent = el.innerText ?? el.textContent ?? "";
return {
selector: selectorHint ?? toSelector(el),
tag: el.tagName.toLowerCase(),
id: el.id || null,
classes: el.classList.length ? Array.from(el.classList) : [],
text: textContent.trim().slice(0, 160),
attributes,
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
visible: Boolean(el.offsetParent),
children: el.children.length,
};
}, handle, selectorOverride ?? null);
}
async function findByText(page, text) {
const escaped = text.replace(/"/g, '\\"');
const handles = await page.$x(`//*[contains(normalize-space(text()), "${escaped}")]`);
if (!handles || handles.length === 0) return null;
const [first, ...rest] = handles;
await Promise.all(rest.map((handle) => handle.dispose()));
return first;
}
async function pickElement(page, timeoutMs) {
const result = await page.evaluate(async (timeout) => {
const originalCursor = document.body?.style?.cursor ?? "";
const state = {
highlight: null,
previousOutline: null,
resolved: false,
};
let timerId = null;
const escapeIdent = (value) => {
if (window.CSS && typeof window.CSS.escape === "function") {
return window.CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_\-]/g, (char) => `\\${char}`);
};
const cleanup = () => {
if (state.highlight) {
state.highlight.style.outline = state.previousOutline ?? "";
}
document.body.style.cursor = originalCursor;
document.removeEventListener("mouseover", onHover, true);
document.removeEventListener("click", onClick, true);
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
};
const highlight = (element) => {
if (state.highlight === element) return;
if (state.highlight) {
state.highlight.style.outline = state.previousOutline ?? "";
}
state.highlight = element;
state.previousOutline = element.style.outline;
element.style.outline = "3px solid #ff4444";
};
const buildInfo = (element) => {
const rect = element.getBoundingClientRect();
return {
tag: element.tagName.toLowerCase(),
text: (element.innerText ?? element.textContent ?? "").trim().slice(0, 160),
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
};
};
const toSelector = (element) => {
const parts = [];
let current = element;
while (current && current.nodeType === 1) {
let part = current.nodeName.toLowerCase();
if (current.id) {
part = `#${escapeIdent(current.id)}`;
parts.unshift(part);
break;
}
if (current.classList.length > 0) {
part += `.${Array.from(current.classList, (cls) => escapeIdent(cls)).join('.')}`;
}
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.nodeName === current.nodeName);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
part += `:nth-of-type(${index})`;
}
}
parts.unshift(part);
current = parent;
}
return parts.join(' > ');
};
const resolveSelection = (element) => {
if (!element) return null;
const info = buildInfo(element);
const selector = toSelector(element);
window.__BT_PICKED_ELEMENT = element;
return { ...info, selector };
};
const onHover = (event) => {
if (state.resolved) return;
const target = event.target;
if (!(target instanceof HTMLElement)) return;
highlight(target);
};
const onClick = (event) => {
event.preventDefault();
event.stopPropagation();
if (state.resolved) return;
state.resolved = true;
cleanup();
resolve(resolveSelection(event.target));
};
document.body.style.cursor = "crosshair";
document.addEventListener("mouseover", onHover, true);
document.addEventListener("click", onClick, true);
return await new Promise((resolve) => {
timerId = timeout
? setTimeout(() => {
if (state.resolved) return;
state.resolved = true;
cleanup();
resolve(null);
}, timeout)
: null;
window.addEventListener(
"blur",
() => {
if (state.resolved) return;
state.resolved = true;
cleanup();
resolve(null);
},
{ once: true },
);
window.__BT_PICKER_CANCEL = () => {
if (state.resolved) return;
state.resolved = true;
cleanup();
resolve(null);
};
});
}, timeoutMs || 60000);
if (!result) return null;
const handle = await page.evaluateHandle(() => {
const element = window.__BT_PICKED_ELEMENT ?? null;
delete window.__BT_PICKED_ELEMENT;
delete window.__BT_PICKER_CANCEL;
return element;
});
const element = handle.asElement();
if (!element) {
await handle.dispose();
return null;
}
const info = await collectElementInfo(page, element, result.selector);
return { handle: element, info };
}
function formatHumanOutput(info) {
const lines = [];
lines.push(`selector: ${info.selector}`);
if (info.tag) lines.push(`tag: ${info.tag}`);
if (info.id) lines.push(`id: ${info.id}`);
if (info.classes?.length) lines.push(`classes: ${info.classes.join(" ")}`);
if (info.text) lines.push(`text: ${info.text}`);
lines.push(`visible: ${info.visible}`);
lines.push(`children: ${info.children}`);
if (info.rect) {
lines.push(`position: (${info.rect.x}, ${info.rect.y})`);
lines.push(`size: ${info.rect.width}x${info.rect.height}`);
}
return `${lines.join("\n")}\n`;
}

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
fail,
getActivePage,
parseArgs,
printJSON,
resolveBrowserConnection,
normalizeNumber,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["json", "quiet"],
string: ["file", "ws", "host"],
number: ["port", "timeout", "truncate"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 30000,
truncate: 8000,
},
});
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const expression = await resolveExpression(args);
if (!expression) {
fail("No JavaScript provided. Pass an expression or use --file/STDIN.", { json: jsonOutput });
}
const timeout = normalizeNumber(args.timeout, 30000);
const port = normalizeNumber(args.port, DEFAULT_PORT);
const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws });
let browser;
try {
browser = await puppeteer.connect({
...connectionOptions,
timeout,
});
} catch (error) {
fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput });
}
try {
const page = await getActivePage(browser, { index: -1 });
if (!page) {
fail("No active page found. Navigate to a page first.", { json: jsonOutput });
}
const result = await page.evaluate(async (code) => {
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(`return (${code})`);
return await fn();
}, expression);
const truncated = truncateResult(result, normalizeNumber(args.truncate, 8000));
if (jsonOutput) {
printJSON({ ok: true, result });
} else {
process.stdout.write(`${truncated}\n`);
logger.info(truncated);
}
} catch (error) {
fail(`Evaluation failed: ${error.message}`, { json: jsonOutput });
} finally {
if (browser) await browser.disconnect();
}
async function resolveExpression(parsed) {
if (parsed.file) {
const content = await readFile(parsed.file, "utf8");
return content.trim();
}
if (parsed._.length > 0) {
return parsed._.join(" ");
}
if (!process.stdin.isTTY) {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8").trim();
}
return null;
}
function truncateResult(value, maxLength) {
if (value === undefined) return "undefined";
if (value === null) return "null";
if (typeof value === "string") {
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
const serialized = JSON.stringify(value, null, 2);
if (!serialized) return String(value);
return serialized.length > maxLength ? `${serialized.slice(0, maxLength)}` : serialized;
}

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
fail,
getActivePage,
parseArgs,
printJSON,
resolveBrowserConnection,
normalizeNumber,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["new", "json", "quiet"],
string: ["wait", "ws", "host"],
number: ["port", "timeout"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 30000,
wait: "domcontentloaded",
},
});
const url = args._[0];
if (!url) {
fail("Usage: navigate.js <url> [--new] [--wait=domcontentloaded|networkidle0|load|none]", {
json: Boolean(args.json),
});
}
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const waitStrategy = normalizeWait(args.wait);
if (!waitStrategy) {
fail("Invalid --wait value. Use domcontentloaded, networkidle0, load, or none.", { json: jsonOutput });
}
const timeout = normalizeNumber(args.timeout, 30000);
const port = normalizeNumber(args.port, DEFAULT_PORT);
logger.info(`🌐 Navigating to ${url}${args.new ? " (new tab)" : ""}...`);
const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws });
let browser;
try {
browser = await puppeteer.connect({
...connectionOptions,
timeout,
});
} catch (error) {
fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput });
}
try {
const page = args.new ? await browser.newPage() : await getExistingPage(browser);
if (!page) {
fail("No active page found. Start Chrome with start.js or use --new to open a tab.", { json: jsonOutput });
}
const gotoOptions = { timeout };
if (waitStrategy !== "none") {
gotoOptions.waitUntil = waitStrategy;
}
await page.goto(url, gotoOptions);
const currentURL = page.url();
if (jsonOutput) {
printJSON({ ok: true, url: currentURL, newPage: Boolean(args.new) });
} else {
process.stdout.write(`${JSON.stringify({ ok: true, url: currentURL, newPage: Boolean(args.new) })}\n`);
logger.info(`✅ Navigated to ${currentURL}`);
}
} catch (error) {
fail(`Navigation failed: ${error.message}`, { json: jsonOutput });
} finally {
if (browser) await browser.disconnect();
}
function normalizeWait(value) {
if (!value) return "domcontentloaded";
const normalized = String(value).toLowerCase();
if (["domcontentloaded", "networkidle0", "load", "none"].includes(normalized)) {
return normalized;
}
return null;
}
async function getExistingPage(browserInstance) {
const page = await getActivePage(browserInstance, { index: -1 });
return page;
}

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
fail,
getActivePage,
parseArgs,
printJSON,
resolveBrowserConnection,
normalizeNumber,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["json", "quiet"],
string: ["element", "format", "ws", "host", "out"],
number: ["port", "timeout", "quality"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 30000,
format: "png",
},
});
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const timeout = normalizeNumber(args.timeout, 30000);
const port = normalizeNumber(args.port, DEFAULT_PORT);
const format = normalizeFormat(args.format);
if (!format) {
fail("Invalid --format. Use png or jpeg.", { json: jsonOutput });
}
const quality = determineQuality(args.quality, format);
const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws });
let browser;
try {
browser = await puppeteer.connect({
...connectionOptions,
timeout,
});
} catch (error) {
fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput });
}
const outputFile = args.out ? args.out : await allocateTempFile(format);
try {
const page = await getActivePage(browser, { index: -1 });
if (!page) {
fail("No active page found. Navigate first.", { json: jsonOutput });
}
let dimensions;
let buffer;
if (args.element) {
const handle = await page.$(args.element);
if (!handle) {
fail(`Element not found: ${args.element}`, { json: jsonOutput });
}
const box = await handle.boundingBox();
if (!box) {
fail(`Element not visible: ${args.element}`, { json: jsonOutput });
}
buffer = await handle.screenshot({
path: outputFile,
type: format,
quality,
});
dimensions = {
width: Math.round(box.width),
height: Math.round(box.height),
};
} else {
const metrics = await page.evaluate(() => {
const width = Math.max(
document.documentElement.scrollWidth,
document.body?.scrollWidth ?? 0,
window.innerWidth,
);
const height = Math.max(
document.documentElement.scrollHeight,
document.body?.scrollHeight ?? 0,
window.innerHeight,
);
return {
width: Math.round(width),
height: Math.round(height),
};
});
buffer = await page.screenshot({
path: outputFile,
type: format,
quality,
fullPage: true,
});
dimensions = metrics;
}
if (!buffer) {
fail("Screenshot failed.", { json: jsonOutput });
}
const result = {
ok: true,
path: outputFile,
format,
width: dimensions?.width ?? null,
height: dimensions?.height ?? null,
element: Boolean(args.element),
};
if (jsonOutput) {
printJSON(result);
} else {
logger.info(`📸 Screenshot saved (${result.width ?? "?"}×${result.height ?? "?"})`);
process.stdout.write(`${outputFile}\n`);
}
} catch (error) {
fail(`Screenshot failed: ${error.message}`, { json: jsonOutput });
} finally {
if (browser) await browser.disconnect();
}
function normalizeFormat(value) {
if (!value) return "png";
const normalized = String(value).toLowerCase();
if (["png", "jpeg"].includes(normalized)) return normalized;
return null;
}
function determineQuality(value, currentFormat) {
if (currentFormat !== "jpeg") return undefined;
if (value === undefined) return 80;
const numeric = normalizeNumber(value, 80);
return Math.min(100, Math.max(1, numeric));
}
async function allocateTempFile(currentFormat) {
const directory = await mkdtemp(join(tmpdir(), "browser-tools-"));
return join(directory, `screenshot-${randomUUID()}.${currentFormat}`);
}

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, rm, cp } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import net from "node:net";
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
delay,
expandPath,
fail,
findExistingPath,
parseArgs,
printJSON,
resolveBrowserConnection,
waitFor,
normalizeNumber,
pathExists,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["json", "quiet"],
string: ["profile", "profile-path", "chrome-path", "chromePath", "host", "user-data-dir", "ws"],
number: ["port", "timeout"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 15000,
},
});
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const port = normalizeNumber(args.port, DEFAULT_PORT);
const timeout = normalizeNumber(args.timeout, 15000);
const host = args.host ?? "127.0.0.1";
const userDataDir = expandPath(
args["user-data-dir"] ?? join(homedir(), ".cache", "browser-tools", `profile-${port}`),
);
await ensurePortAvailable({ port, host });
const chromeExecutable = await resolveChromeExecutable(args);
if (!chromeExecutable) {
fail(
"Unable to find Chrome/Chromium executable. Provide --chrome-path or set CHROME_PATH/PUPPETEER_EXECUTABLE_PATH.",
{ json: jsonOutput },
);
}
let profileSource = await resolveProfileSource(args);
let copiedProfile = false;
try {
await rm(userDataDir, { recursive: true, force: true });
if (profileSource) {
logger.info(`⏳ Copying profile from ${profileSource}...`);
await cp(profileSource, userDataDir, { recursive: true });
copiedProfile = true;
} else {
await mkdir(userDataDir, { recursive: true });
}
} catch (error) {
logger.error(`Failed to prepare user data dir: ${error.message}`);
fail("Failed to prepare profile directory", { json: jsonOutput });
}
const chromeArgs = buildChromeArguments({ port, userDataDir });
logger.info(`🚀 Launching Chrome at ${chromeExecutable} on port ${port}...`);
const chromeProcess = spawn(chromeExecutable, chromeArgs, {
detached: true,
stdio: "ignore",
});
chromeProcess.unref();
logger.info("⏳ Waiting for DevTools endpoint...");
const connectionOptions = resolveBrowserConnection({ port, host, ws: args.ws });
const connected = await waitFor(async () => {
try {
const browser = await puppeteer.connect({
...connectionOptions,
timeout: 2000,
});
await browser.disconnect();
return true;
} catch {
return false;
}
}, { timeout, interval: 500 });
if (!connected) {
fail(
`Failed to connect to Chrome on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`,
{ json: jsonOutput },
);
}
logger.info(`✅ Chrome listening on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`);
const result = {
ok: true,
port,
userDataDir,
chromePath: chromeExecutable,
profile: copiedProfile ? profileSource : null,
};
if (jsonOutput) {
printJSON(result);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
logger.info(
`✓ Chrome started on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`,
);
}
async function ensurePortAvailable({ port: portToCheck, host: hostToCheck }) {
await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", (error) => {
if (error.code === "EADDRINUSE") {
reject(
new Error(
`Port ${portToCheck} is already in use. If Chrome is already running, reconnect with other tools or close it first.`,
),
);
} else {
reject(error);
}
});
server.once("listening", () => {
server.close(() => resolve());
});
server.listen(portToCheck, hostToCheck);
}).catch((error) => {
fail(error.message, { json: jsonOutput });
});
// small delay to allow OS to release the port after closing the test server
await delay(50);
}
function buildChromeArguments({ port: portValue, userDataDir: dir }) {
return [
`--remote-debugging-port=${portValue}`,
`--user-data-dir=${dir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-sync",
"--metrics-recording-only",
"--enable-automation",
];
}
async function resolveChromeExecutable(parsed) {
const providedPath = parsed["chrome-path"] ?? parsed.chromePath ?? process.env.CHROME_PATH;
const puppeteerPath = process.env.PUPPETEER_EXECUTABLE_PATH;
const platformCandidates = getDefaultChromeCandidates();
return findExistingPath([
providedPath && expandPath(providedPath),
puppeteerPath && expandPath(puppeteerPath),
...platformCandidates,
]);
}
async function resolveProfileSource(parsed) {
const raw = parsed.profile ?? parsed["profile-path"];
if (raw === undefined || raw === false) return null;
const normalized = typeof raw === "string" ? raw.trim() : "";
if (normalized && normalized.toLowerCase() !== "default") {
const expanded = expandPath(normalized);
if (await pathExists(expanded)) return expanded;
logger.warn(`⚠️ Profile path not found: ${expanded}`);
return null;
}
const defaultPath = await resolveDefaultProfileRoot();
if (!defaultPath) {
logger.warn("⚠️ No default Chrome profile detected; starting with a fresh profile.");
return null;
}
return defaultPath;
}
async function resolveDefaultProfileRoot() {
const candidates = getDefaultProfileCandidates();
for (const candidate of candidates) {
if (!candidate) continue;
const expanded = expandPath(candidate);
if (await pathExists(expanded)) {
return expanded;
}
}
return null;
}
function getDefaultChromeCandidates() {
const platform = process.platform;
const home = homedir();
if (platform === "darwin") {
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
];
}
if (platform === "linux") {
return [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
join(home, "snap", "chromium", "current", "usr", "lib", "chromium-browser", "chromium-browser"),
];
}
if (platform === "win32") {
const localAppData = process.env.LOCALAPPDATA;
const programFiles = process.env["PROGRAMFILES(X86)"] ?? process.env.PROGRAMFILES;
return [
localAppData && join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
programFiles && join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
];
}
return [];
}
function getDefaultProfileCandidates() {
const platform = process.platform;
if (platform === "darwin") {
return [
"~/Library/Application Support/Google/Chrome",
"~/Library/Application Support/Chromium",
];
}
if (platform === "linux") {
return [
"~/.config/google-chrome",
"~/.config/chromium",
];
}
if (platform === "win32") {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) return [];
return [join(localAppData, "Google", "Chrome", "User Data")];
}
return [];
}