scripts / cdp.mjs
#!/usr/bin/env node
import fs from 'node:fs/promises';
const argv = process.argv.slice(2);
const command = argv.shift();
const options = {};
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i];
if (!key?.startsWith('--') || argv[i + 1] === undefined) usage(`Invalid argument: ${key ?? ''}`);
options[key.slice(2)] = argv[i + 1];
}
const endpoint = (options.endpoint || 'http://127.0.0.1:9222').replace(/\/$/, '');
function usage(error) {
if (error) console.error(error);
console.error(`Usage:
cdp.mjs list [--endpoint URL]
cdp.mjs body --target-url SUBSTRING [--limit 20000]
cdp.mjs eval --target-url SUBSTRING --expression JS
cdp.mjs navigate --target-url SUBSTRING --url URL
cdp.mjs new --url URL
cdp.mjs click-text --target-url SUBSTRING --text TEXT
cdp.mjs type --target-url SUBSTRING --selector CSS --text TEXT
cdp.mjs screenshot --target-url SUBSTRING --output ABSOLUTE_PATH
cdp.mjs clear-cookies --domains DOMAIN[,DOMAIN...]
Optional for all commands: --endpoint http://127.0.0.1:9222`);
process.exit(2);
}
async function jsonFetch(url, init) {
const response = await fetch(url, init);
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.json();
}
async function targets() {
return jsonFetch(`${endpoint}/json/list`);
}
async function targetByUrl(fragment) {
if (!fragment) usage('--target-url is required');
const matches = (await targets()).filter(t => t.type === 'page' && t.url?.includes(fragment));
if (matches.length !== 1) {
const detail = matches.map(t => `${t.id}\t${t.title}\t${t.url}`).join('\n');
throw new Error(`Expected one page matching ${JSON.stringify(fragment)}, found ${matches.length}${detail ? `:\n${detail}` : ''}`);
}
return matches[0];
}
function connect(wsUrl) {
const socket = new WebSocket(wsUrl);
let nextId = 0;
const pending = new Map();
socket.onmessage = event => {
const message = JSON.parse(event.data);
if (!message.id || !pending.has(message.id)) return;
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(new Error(JSON.stringify(message.error)));
else resolve(message.result);
};
const ready = new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = () => reject(new Error(`Could not connect to ${wsUrl}`));
});
return {
ready,
send(method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++nextId;
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
},
close() { socket.close(); },
};
}
async function withTarget(fragment, fn) {
const target = await targetByUrl(fragment);
const cdp = connect(target.webSocketDebuggerUrl);
await cdp.ready;
try { return await fn(cdp, target); }
finally { cdp.close(); }
}
async function evaluate(cdp, expression) {
const result = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
});
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Evaluation failed');
return result.result?.value;
}
async function elementCenter(cdp, expression) {
const center = await evaluate(cdp, `(() => {
const element = (${expression});
if (!element) return null;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
})()`);
if (!center) throw new Error('No visible matching element found');
return center;
}
async function clickAt(cdp, { x, y }) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
}
function quote(value) { return JSON.stringify(value); }
switch (command) {
case 'list': {
const rows = (await targets())
.filter(t => t.type === 'page')
.map(t => ({ id: t.id, title: t.title, url: t.url }));
console.log(JSON.stringify(rows, null, 2));
break;
}
case 'body': {
const limit = Number(options.limit || 20000);
if (!Number.isInteger(limit) || limit < 1 || limit > 100000) usage('--limit must be between 1 and 100000');
const value = await withTarget(options['target-url'], cdp => evaluate(cdp,
`({ title: document.title, url: location.href, text: document.body?.innerText.slice(0, ${limit}) || '' })`));
console.log(JSON.stringify(value, null, 2));
break;
}
case 'eval': {
if (!options.expression) usage('--expression is required');
const value = await withTarget(options['target-url'], cdp => evaluate(cdp, options.expression));
console.log(JSON.stringify(value, null, 2));
break;
}
case 'navigate': {
if (!options.url) usage('--url is required');
const value = await withTarget(options['target-url'], cdp => cdp.send('Page.navigate', { url: options.url }));
console.log(JSON.stringify(value, null, 2));
break;
}
case 'new': {
if (!options.url) usage('--url is required');
const value = await jsonFetch(`${endpoint}/json/new?${encodeURIComponent(options.url)}`, { method: 'PUT' });
console.log(JSON.stringify({ id: value.id, title: value.title, url: value.url }, null, 2));
break;
}
case 'click-text': {
if (!options.text) usage('--text is required');
await withTarget(options['target-url'], async cdp => {
const text = quote(options.text);
const center = await elementCenter(cdp,
`[...document.querySelectorAll('button,a,[role="button"],[role="link"],input[type="submit"]')]
.filter(e => (e.innerText || e.value || '').trim() === ${text})
.sort((a,b) => a.getBoundingClientRect().width*a.getBoundingClientRect().height - b.getBoundingClientRect().width*b.getBoundingClientRect().height)[0]`);
await clickAt(cdp, center);
});
console.log(JSON.stringify({ clicked: options.text }));
break;
}
case 'type': {
if (!options.selector || options.text === undefined) usage('--selector and --text are required');
await withTarget(options['target-url'], async cdp => {
const selector = quote(options.selector);
const focused = await evaluate(cdp, `(() => {
const element = document.querySelector(${selector});
if (!element) return false;
element.focus();
if (typeof element.select === 'function') element.select();
return true;
})()`);
if (!focused) throw new Error(`Selector not found: ${options.selector}`);
await cdp.send('Input.insertText', { text: options.text });
});
console.log(JSON.stringify({ typed: true, selector: options.selector }));
break;
}
case 'screenshot': {
if (!options.output) usage('--output is required');
await withTarget(options['target-url'], async cdp => {
await cdp.send('Page.enable');
const result = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
await fs.writeFile(options.output, Buffer.from(result.data, 'base64'));
});
console.log(JSON.stringify({ output: options.output }));
break;
}
case 'clear-cookies': {
if (!options.domains) usage('--domains is required');
const domains = options.domains.split(',').map(d => d.trim().replace(/^\./, '').toLowerCase()).filter(Boolean);
const pages = (await targets()).filter(t => t.type === 'page');
if (!pages.length) throw new Error('No page target available');
const cdp = connect(pages[0].webSocketDebuggerUrl);
await cdp.ready;
try {
const matches = domain => {
const normalized = (domain || '').replace(/^\./, '').toLowerCase();
return domains.some(base => normalized === base || normalized.endsWith(`.${base}`));
};
const before = (await cdp.send('Network.getAllCookies')).cookies.filter(c => matches(c.domain));
for (const cookie of before) {
const params = { name: cookie.name, domain: cookie.domain, path: cookie.path };
if (cookie.partitionKey) params.partitionKey = cookie.partitionKey;
await cdp.send('Network.deleteCookies', params);
}
const remaining = (await cdp.send('Network.getAllCookies')).cookies.filter(c => matches(c.domain));
console.log(JSON.stringify({ deleted: before.length, remaining: remaining.length, domains }, null, 2));
} finally { cdp.close(); }
break;
}
default:
usage(command ? `Unknown command: ${command}` : undefined);
}
scripts / cdp.mjs
#!/usr/bin/env node
import fs from 'node:fs/promises';
const argv = process.argv.slice(2);
const command = argv.shift();
const options = {};
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i];
if (!key?.startsWith('--') || argv[i + 1] === undefined) usage(`Invalid argument: ${key ?? ''}`);
options[key.slice(2)] = argv[i + 1];
}
const endpoint = (options.endpoint || 'http://127.0.0.1:9222').replace(/\/$/, '');
function usage(error) {
if (error) console.error(error);
console.error(`Usage:
cdp.mjs list [--endpoint URL]
cdp.mjs body --target-url SUBSTRING [--limit 20000]
cdp.mjs eval --target-url SUBSTRING --expression JS
cdp.mjs navigate --target-url SUBSTRING --url URL
cdp.mjs new --url URL
cdp.mjs click-text --target-url SUBSTRING --text TEXT
cdp.mjs type --target-url SUBSTRING --selector CSS --text TEXT
cdp.mjs screenshot --target-url SUBSTRING --output ABSOLUTE_PATH
cdp.mjs clear-cookies --domains DOMAIN[,DOMAIN...]
Optional for all commands: --endpoint http://127.0.0.1:9222`);
process.exit(2);
}
async function jsonFetch(url, init) {
const response = await fetch(url, init);
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.json();
}
async function targets() {
return jsonFetch(`${endpoint}/json/list`);
}
async function targetByUrl(fragment) {
if (!fragment) usage('--target-url is required');
const matches = (await targets()).filter(t => t.type === 'page' && t.url?.includes(fragment));
if (matches.length !== 1) {
const detail = matches.map(t => `${t.id}\t${t.title}\t${t.url}`).join('\n');
throw new Error(`Expected one page matching ${JSON.stringify(fragment)}, found ${matches.length}${detail ? `:\n${detail}` : ''}`);
}
return matches[0];
}
function connect(wsUrl) {
const socket = new WebSocket(wsUrl);
let nextId = 0;
const pending = new Map();
socket.onmessage = event => {
const message = JSON.parse(event.data);
if (!message.id || !pending.has(message.id)) return;
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(new Error(JSON.stringify(message.error)));
else resolve(message.result);
};
const ready = new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = () => reject(new Error(`Could not connect to ${wsUrl}`));
});
return {
ready,
send(method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++nextId;
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
},
close() { socket.close(); },
};
}
async function withTarget(fragment, fn) {
const target = await targetByUrl(fragment);
const cdp = connect(target.webSocketDebuggerUrl);
await cdp.ready;
try { return await fn(cdp, target); }
finally { cdp.close(); }
}
async function evaluate(cdp, expression) {
const result = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
});
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Evaluation failed');
return result.result?.value;
}
async function elementCenter(cdp, expression) {
const center = await evaluate(cdp, `(() => {
const element = (${expression});
if (!element) return null;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
})()`);
if (!center) throw new Error('No visible matching element found');
return center;
}
async function clickAt(cdp, { x, y }) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
}
function quote(value) { return JSON.stringify(value); }
switch (command) {
case 'list': {
const rows = (await targets())
.filter(t => t.type === 'page')
.map(t => ({ id: t.id, title: t.title, url: t.url }));
console.log(JSON.stringify(rows, null, 2));
break;
}
case 'body': {
const limit = Number(options.limit || 20000);
if (!Number.isInteger(limit) || limit < 1 || limit > 100000) usage('--limit must be between 1 and 100000');
const value = await withTarget(options['target-url'], cdp => evaluate(cdp,
`({ title: document.title, url: location.href, text: document.body?.innerText.slice(0, ${limit}) || '' })`));
console.log(JSON.stringify(value, null, 2));
break;
}
case 'eval': {
if (!options.expression) usage('--expression is required');
const value = await withTarget(options['target-url'], cdp => evaluate(cdp, options.expression));
console.log(JSON.stringify(value, null, 2));
break;
}
case 'navigate': {
if (!options.url) usage('--url is required');
const value = await withTarget(options['target-url'], cdp => cdp.send('Page.navigate', { url: options.url }));
console.log(JSON.stringify(value, null, 2));
break;
}
case 'new': {
if (!options.url) usage('--url is required');
const value = await jsonFetch(`${endpoint}/json/new?${encodeURIComponent(options.url)}`, { method: 'PUT' });
console.log(JSON.stringify({ id: value.id, title: value.title, url: value.url }, null, 2));
break;
}
case 'click-text': {
if (!options.text) usage('--text is required');
await withTarget(options['target-url'], async cdp => {
const text = quote(options.text);
const center = await elementCenter(cdp,
`[...document.querySelectorAll('button,a,[role="button"],[role="link"],input[type="submit"]')]
.filter(e => (e.innerText || e.value || '').trim() === ${text})
.sort((a,b) => a.getBoundingClientRect().width*a.getBoundingClientRect().height - b.getBoundingClientRect().width*b.getBoundingClientRect().height)[0]`);
await clickAt(cdp, center);
});
console.log(JSON.stringify({ clicked: options.text }));
break;
}
case 'type': {
if (!options.selector || options.text === undefined) usage('--selector and --text are required');
await withTarget(options['target-url'], async cdp => {
const selector = quote(options.selector);
const focused = await evaluate(cdp, `(() => {
const element = document.querySelector(${selector});
if (!element) return false;
element.focus();
if (typeof element.select === 'function') element.select();
return true;
})()`);
if (!focused) throw new Error(`Selector not found: ${options.selector}`);
await cdp.send('Input.insertText', { text: options.text });
});
console.log(JSON.stringify({ typed: true, selector: options.selector }));
break;
}
case 'screenshot': {
if (!options.output) usage('--output is required');
await withTarget(options['target-url'], async cdp => {
await cdp.send('Page.enable');
const result = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
await fs.writeFile(options.output, Buffer.from(result.data, 'base64'));
});
console.log(JSON.stringify({ output: options.output }));
break;
}
case 'clear-cookies': {
if (!options.domains) usage('--domains is required');
const domains = options.domains.split(',').map(d => d.trim().replace(/^\./, '').toLowerCase()).filter(Boolean);
const pages = (await targets()).filter(t => t.type === 'page');
if (!pages.length) throw new Error('No page target available');
const cdp = connect(pages[0].webSocketDebuggerUrl);
await cdp.ready;
try {
const matches = domain => {
const normalized = (domain || '').replace(/^\./, '').toLowerCase();
return domains.some(base => normalized === base || normalized.endsWith(`.${base}`));
};
const before = (await cdp.send('Network.getAllCookies')).cookies.filter(c => matches(c.domain));
for (const cookie of before) {
const params = { name: cookie.name, domain: cookie.domain, path: cookie.path };
if (cookie.partitionKey) params.partitionKey = cookie.partitionKey;
await cdp.send('Network.deleteCookies', params);
}
const remaining = (await cdp.send('Network.getAllCookies')).cookies.filter(c => matches(c.domain));
console.log(JSON.stringify({ deleted: before.length, remaining: remaining.length, domains }, null, 2));
} finally { cdp.close(); }
break;
}
default:
usage(command ? `Unknown command: ${command}` : undefined);
}