ai-engineering-init 1.14.2 → 1.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# /release - 自动化发版
|
|
2
|
+
|
|
3
|
+
作为发版助手,自动读取项目发版规范(RELEASE.md),按标准流程执行版本发布。
|
|
4
|
+
|
|
5
|
+
## 使用方式
|
|
6
|
+
|
|
7
|
+
- `/release` — 自动检测变更,推荐版本号,执行完整发版流程
|
|
8
|
+
- `/release patch` — 指定 patch 版本升级
|
|
9
|
+
- `/release minor` — 指定 minor 版本升级
|
|
10
|
+
- `/release major` — 指定 major 版本升级
|
|
11
|
+
|
|
12
|
+
## 执行流程
|
|
13
|
+
|
|
14
|
+
### 第一步:环境检查
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 1. 读取发版规范
|
|
18
|
+
cat RELEASE.md
|
|
19
|
+
|
|
20
|
+
# 2. 查看当前版本和最新 tag
|
|
21
|
+
node -p "require('./package.json').version"
|
|
22
|
+
git tag --sort=-version:refname | head -5
|
|
23
|
+
|
|
24
|
+
# 3. 检查工作区状态(必须干净或只有待发版的改动)
|
|
25
|
+
git status -s
|
|
26
|
+
|
|
27
|
+
# 4. 查看自上次 tag 以来的所有 commit
|
|
28
|
+
git log $(git tag --sort=-version:refname | head -1)..HEAD --oneline
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 第二步:确定版本号
|
|
32
|
+
|
|
33
|
+
根据 commit 内容自动推荐版本类型:
|
|
34
|
+
- 含 `feat` → minor
|
|
35
|
+
- 含 `fix`/`chore`/`remove` → patch
|
|
36
|
+
- 含 `BREAKING CHANGE` → major
|
|
37
|
+
|
|
38
|
+
如果用户指定了版本类型(patch/minor/major),直接使用。
|
|
39
|
+
否则展示推荐并等用户确认。
|
|
40
|
+
|
|
41
|
+
### 第三步:更新 CHANGELOG.md
|
|
42
|
+
|
|
43
|
+
基于 `git log` 自动生成 CHANGELOG 条目:
|
|
44
|
+
|
|
45
|
+
```markdown
|
|
46
|
+
## [vX.Y.Z] - YYYY-MM-DD
|
|
47
|
+
|
|
48
|
+
### 新增
|
|
49
|
+
- ...
|
|
50
|
+
|
|
51
|
+
### 修复
|
|
52
|
+
- ...
|
|
53
|
+
|
|
54
|
+
### 移除
|
|
55
|
+
- ...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
分类规则:
|
|
59
|
+
- `feat` 开头 → 新增
|
|
60
|
+
- `fix` 开头 → 修复
|
|
61
|
+
- `remove`/`删除` → 移除
|
|
62
|
+
- `chore`/其他 → 改进
|
|
63
|
+
|
|
64
|
+
生成后展示给用户确认,可修改。
|
|
65
|
+
|
|
66
|
+
### 第四步:更新 package.json
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# 使用 npm version 更新(不自动打 tag)
|
|
70
|
+
npm version <patch|minor|major> --no-git-tag-version
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 第五步:提交
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git add package.json CHANGELOG.md
|
|
77
|
+
git commit -m "chore: 发布 vX.Y.Z"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 第六步:推送代码
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git push origin main
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 第七步:打 tag 并推送
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git tag vX.Y.Z
|
|
90
|
+
git push origin vX.Y.Z
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 第八步:验证
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
echo "package.json: $(node -p \"require('./package.json').version\")"
|
|
97
|
+
echo "latest tag: $(git tag --sort=-version:refname | head -1)"
|
|
98
|
+
echo "HEAD commit: $(git log --oneline -1)"
|
|
99
|
+
echo "tag commit: $(git rev-parse --short vX.Y.Z)"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
确认四个值一致后,输出发版成功信息。
|
|
103
|
+
|
|
104
|
+
## 安全规则
|
|
105
|
+
|
|
106
|
+
- 如果 `git status` 有未提交的改动,先询问用户是否需要一起发版
|
|
107
|
+
- 如果 package.json version 已经大于最新 tag,说明版本号已手动更新过,跳过 npm version 步骤
|
|
108
|
+
- 推送前必须确认 remote 是正确的仓库
|
|
109
|
+
- 每一步失败都立即停止并报告,不要继续后续步骤
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chrome-cdp
|
|
3
|
+
description: 通过 Chrome DevTools Protocol 与本地 Chrome 浏览器交互(截图、导航、点击、执行JS等)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Chrome CDP
|
|
7
|
+
|
|
8
|
+
轻量级 Chrome DevTools Protocol CLI 工具。通过 WebSocket 直接连接 Chrome 浏览器 —— 无需 Puppeteer,支持 100+ 标签页,即时连接。
|
|
9
|
+
|
|
10
|
+
## 前置条件
|
|
11
|
+
|
|
12
|
+
- Chrome 已启用远程调试:打开 `chrome://inspect/#remote-debugging` 并开启开关
|
|
13
|
+
- Node.js 22+(使用内置 WebSocket)—— 通过 nvm 自动切换
|
|
14
|
+
|
|
15
|
+
## 脚本路径
|
|
16
|
+
|
|
17
|
+
所有命令通过包装脚本执行(自动切换 Node.js 23):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
CDP="/Users/xujiajun/Developer/ai-engineering-init/.claude/skills/chrome-cdp/scripts/run-cdp.sh"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 命令速查
|
|
24
|
+
|
|
25
|
+
### 列出打开的页面
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bash $CDP list
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 截图
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bash $CDP shot <target> [file] # 默认: /tmp/screenshot.png
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
截取**视口**。需要滚动后内容时先用 `eval` 滚动。输出包含 DPR 和坐标转换提示。
|
|
38
|
+
|
|
39
|
+
### 可访问性树快照
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bash $CDP snap <target>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 执行 JavaScript
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bash $CDP eval <target> <expr>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> **注意:** 避免跨多次 `eval` 调用使用基于索引的选择器(`querySelectorAll(...)[i]`),因为 DOM 可能在调用间变化。应在一次 `eval` 中收集所有数据或使用稳定选择器。
|
|
52
|
+
|
|
53
|
+
### 其他命令
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
bash $CDP html <target> [selector] # 完整页面或元素 HTML
|
|
57
|
+
bash $CDP nav <target> <url> # 导航并等待加载
|
|
58
|
+
bash $CDP net <target> # 资源加载计时
|
|
59
|
+
bash $CDP click <target> <selector> # 通过 CSS 选择器点击元素
|
|
60
|
+
bash $CDP clickxy <target> <x> <y> # 在 CSS 像素坐标处点击
|
|
61
|
+
bash $CDP type <target> <text> # 在焦点元素处输入文本
|
|
62
|
+
bash $CDP loadall <target> <selector> [ms] # 点击"加载更多"直到消失
|
|
63
|
+
bash $CDP evalraw <target> <method> [json] # 原始 CDP 命令透传
|
|
64
|
+
bash $CDP stop [target] # 停止守护进程
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 坐标系统
|
|
68
|
+
|
|
69
|
+
`shot` 以原生分辨率保存图片:图片像素 = CSS 像素 x DPR。CDP 输入事件(`clickxy` 等)使用 **CSS 像素**。
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
CSS px = 截图图片 px / DPR
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`shot` 会输出当前页面的 DPR。典型 Retina (DPR=2):截图坐标除以 2。
|
|
76
|
+
|
|
77
|
+
## 使用技巧
|
|
78
|
+
|
|
79
|
+
- 优先使用 `snap --compact` 而非 `html` 来查看页面结构
|
|
80
|
+
- 使用 `type`(而非 eval)在跨域 iframe 中输入文本 —— 先用 `click`/`clickxy` 聚焦,再用 `type`
|
|
81
|
+
- Chrome 在首次访问标签页时显示"允许调试"弹窗。后台守护进程会保持会话,后续命令无需再次确认。守护进程在 20 分钟无活动后自动退出
|
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cdp - lightweight Chrome DevTools Protocol CLI
|
|
3
|
+
// Uses raw CDP over WebSocket, no Puppeteer dependency.
|
|
4
|
+
// Requires Node 22+ (built-in WebSocket).
|
|
5
|
+
//
|
|
6
|
+
// Per-tab persistent daemon: page commands go through a daemon that holds
|
|
7
|
+
// the CDP session open. Chrome's "Allow debugging" modal fires once per
|
|
8
|
+
// daemon (= once per tab). Daemons auto-exit after 20min idle.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { resolve } from 'path';
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import net from 'net';
|
|
15
|
+
|
|
16
|
+
const TIMEOUT = 15000;
|
|
17
|
+
const NAVIGATION_TIMEOUT = 30000;
|
|
18
|
+
const IDLE_TIMEOUT = 20 * 60 * 1000;
|
|
19
|
+
const DAEMON_CONNECT_RETRIES = 20;
|
|
20
|
+
const DAEMON_CONNECT_DELAY = 300;
|
|
21
|
+
const MIN_TARGET_PREFIX_LEN = 8;
|
|
22
|
+
const SOCK_PREFIX = '/tmp/cdp-';
|
|
23
|
+
const PAGES_CACHE = '/tmp/cdp-pages.json';
|
|
24
|
+
|
|
25
|
+
function sockPath(targetId) { return `${SOCK_PREFIX}${targetId}.sock`; }
|
|
26
|
+
|
|
27
|
+
function getWsUrl() {
|
|
28
|
+
const candidates = [
|
|
29
|
+
resolve(homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort'),
|
|
30
|
+
resolve(homedir(), '.config/google-chrome/DevToolsActivePort'),
|
|
31
|
+
];
|
|
32
|
+
const portFile = candidates.find(path => existsSync(path));
|
|
33
|
+
if (!portFile) throw new Error(`Could not find DevToolsActivePort file in: ${candidates.join(', ')}`);
|
|
34
|
+
const lines = readFileSync(portFile, 'utf8').trim().split('\n');
|
|
35
|
+
return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
39
|
+
|
|
40
|
+
function listDaemonSockets() {
|
|
41
|
+
return readdirSync('/tmp')
|
|
42
|
+
.filter(f => f.startsWith('cdp-') && f.endsWith('.sock'))
|
|
43
|
+
.map(f => ({
|
|
44
|
+
targetId: f.slice(4, -5),
|
|
45
|
+
socketPath: `/tmp/${f}`,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolvePrefix(prefix, candidates, noun = 'target', missingHint = '') {
|
|
50
|
+
const upper = prefix.toUpperCase();
|
|
51
|
+
const matches = candidates.filter(candidate => candidate.toUpperCase().startsWith(upper));
|
|
52
|
+
if (matches.length === 0) {
|
|
53
|
+
const hint = missingHint ? ` ${missingHint}` : '';
|
|
54
|
+
throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`);
|
|
55
|
+
}
|
|
56
|
+
if (matches.length > 1) {
|
|
57
|
+
throw new Error(`Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`);
|
|
58
|
+
}
|
|
59
|
+
return matches[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getDisplayPrefixLength(targetIds) {
|
|
63
|
+
if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN;
|
|
64
|
+
const maxLen = Math.max(...targetIds.map(id => id.length));
|
|
65
|
+
for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) {
|
|
66
|
+
const prefixes = new Set(targetIds.map(id => id.slice(0, len).toUpperCase()));
|
|
67
|
+
if (prefixes.size === targetIds.length) return len;
|
|
68
|
+
}
|
|
69
|
+
return maxLen;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// CDP WebSocket client
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
class CDP {
|
|
77
|
+
#ws; #id = 0; #pending = new Map(); #eventHandlers = new Map(); #closeHandlers = [];
|
|
78
|
+
|
|
79
|
+
async connect(wsUrl) {
|
|
80
|
+
return new Promise((res, rej) => {
|
|
81
|
+
this.#ws = new WebSocket(wsUrl);
|
|
82
|
+
this.#ws.onopen = () => res();
|
|
83
|
+
this.#ws.onerror = (e) => rej(new Error('WebSocket error: ' + (e.message || e.type)));
|
|
84
|
+
this.#ws.onclose = () => this.#closeHandlers.forEach(h => h());
|
|
85
|
+
this.#ws.onmessage = (ev) => {
|
|
86
|
+
const msg = JSON.parse(ev.data);
|
|
87
|
+
if (msg.id && this.#pending.has(msg.id)) {
|
|
88
|
+
const { resolve, reject } = this.#pending.get(msg.id);
|
|
89
|
+
this.#pending.delete(msg.id);
|
|
90
|
+
if (msg.error) reject(new Error(msg.error.message));
|
|
91
|
+
else resolve(msg.result);
|
|
92
|
+
} else if (msg.method && this.#eventHandlers.has(msg.method)) {
|
|
93
|
+
for (const handler of [...this.#eventHandlers.get(msg.method)]) {
|
|
94
|
+
handler(msg.params || {}, msg);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
send(method, params = {}, sessionId) {
|
|
102
|
+
const id = ++this.#id;
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
this.#pending.set(id, { resolve, reject });
|
|
105
|
+
const msg = { id, method, params };
|
|
106
|
+
if (sessionId) msg.sessionId = sessionId;
|
|
107
|
+
this.#ws.send(JSON.stringify(msg));
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
if (this.#pending.has(id)) {
|
|
110
|
+
this.#pending.delete(id);
|
|
111
|
+
reject(new Error(`Timeout: ${method}`));
|
|
112
|
+
}
|
|
113
|
+
}, TIMEOUT);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
onEvent(method, handler) {
|
|
118
|
+
if (!this.#eventHandlers.has(method)) this.#eventHandlers.set(method, new Set());
|
|
119
|
+
const handlers = this.#eventHandlers.get(method);
|
|
120
|
+
handlers.add(handler);
|
|
121
|
+
return () => {
|
|
122
|
+
handlers.delete(handler);
|
|
123
|
+
if (handlers.size === 0) this.#eventHandlers.delete(method);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
waitForEvent(method, timeout = TIMEOUT) {
|
|
128
|
+
let settled = false;
|
|
129
|
+
let off;
|
|
130
|
+
let timer;
|
|
131
|
+
const promise = new Promise((resolve, reject) => {
|
|
132
|
+
off = this.onEvent(method, (params) => {
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
off();
|
|
137
|
+
resolve(params);
|
|
138
|
+
});
|
|
139
|
+
timer = setTimeout(() => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
off();
|
|
143
|
+
reject(new Error(`Timeout waiting for event: ${method}`));
|
|
144
|
+
}, timeout);
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
promise,
|
|
148
|
+
cancel() {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
off?.();
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
onClose(handler) { this.#closeHandlers.push(handler); }
|
|
158
|
+
close() { this.#ws.close(); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Command implementations — return strings, take (cdp, sessionId)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
async function getPages(cdp) {
|
|
166
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
167
|
+
return targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatPageList(pages) {
|
|
171
|
+
const prefixLen = getDisplayPrefixLength(pages.map(p => p.targetId));
|
|
172
|
+
return pages.map(p => {
|
|
173
|
+
const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);
|
|
174
|
+
const title = p.title.substring(0, 54).padEnd(54);
|
|
175
|
+
return `${id} ${title} ${p.url}`;
|
|
176
|
+
}).join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function shouldShowAxNode(node, compact = false) {
|
|
180
|
+
const role = node.role?.value || '';
|
|
181
|
+
const name = node.name?.value ?? '';
|
|
182
|
+
const value = node.value?.value;
|
|
183
|
+
if (compact && role === 'InlineTextBox') return false;
|
|
184
|
+
return role !== 'none' && role !== 'generic' && !(name === '' && (value === '' || value == null));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatAxNode(node, depth) {
|
|
188
|
+
const role = node.role?.value || '';
|
|
189
|
+
const name = node.name?.value ?? '';
|
|
190
|
+
const value = node.value?.value;
|
|
191
|
+
const indent = ' '.repeat(Math.min(depth, 10));
|
|
192
|
+
let line = `${indent}[${role}]`;
|
|
193
|
+
if (name !== '') line += ` ${name}`;
|
|
194
|
+
if (!(value === '' || value == null)) line += ` = ${JSON.stringify(value)}`;
|
|
195
|
+
return line;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function orderedAxChildren(node, nodesById, childrenByParent) {
|
|
199
|
+
const children = [];
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
for (const childId of node.childIds || []) {
|
|
202
|
+
const child = nodesById.get(childId);
|
|
203
|
+
if (child && !seen.has(child.nodeId)) {
|
|
204
|
+
seen.add(child.nodeId);
|
|
205
|
+
children.push(child);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const child of childrenByParent.get(node.nodeId) || []) {
|
|
209
|
+
if (!seen.has(child.nodeId)) {
|
|
210
|
+
seen.add(child.nodeId);
|
|
211
|
+
children.push(child);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return children;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function snapshotStr(cdp, sid, compact = false) {
|
|
218
|
+
const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);
|
|
219
|
+
const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
|
|
220
|
+
const childrenByParent = new Map();
|
|
221
|
+
for (const node of nodes) {
|
|
222
|
+
if (!node.parentId) continue;
|
|
223
|
+
if (!childrenByParent.has(node.parentId)) childrenByParent.set(node.parentId, []);
|
|
224
|
+
childrenByParent.get(node.parentId).push(node);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const lines = [];
|
|
228
|
+
const visited = new Set();
|
|
229
|
+
function visit(node, depth) {
|
|
230
|
+
if (!node || visited.has(node.nodeId)) return;
|
|
231
|
+
visited.add(node.nodeId);
|
|
232
|
+
if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth));
|
|
233
|
+
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
|
|
234
|
+
visit(child, depth + 1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
|
|
239
|
+
for (const root of roots) visit(root, 0);
|
|
240
|
+
for (const node of nodes) visit(node, 0);
|
|
241
|
+
|
|
242
|
+
return lines.join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function evalStr(cdp, sid, expression) {
|
|
246
|
+
await cdp.send('Runtime.enable', {}, sid);
|
|
247
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
248
|
+
expression, returnByValue: true, awaitPromise: true,
|
|
249
|
+
}, sid);
|
|
250
|
+
if (result.exceptionDetails) {
|
|
251
|
+
throw new Error(result.exceptionDetails.text || result.exceptionDetails.exception?.description);
|
|
252
|
+
}
|
|
253
|
+
const val = result.result.value;
|
|
254
|
+
return typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val ?? '');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function shotStr(cdp, sid, filePath) {
|
|
258
|
+
// Get device scale factor so we can report coordinate mapping
|
|
259
|
+
let dpr = 1;
|
|
260
|
+
try {
|
|
261
|
+
const metrics = await cdp.send('Page.getLayoutMetrics', {}, sid);
|
|
262
|
+
dpr = metrics.visualViewport?.clientWidth
|
|
263
|
+
? metrics.cssVisualViewport?.clientWidth
|
|
264
|
+
? Math.round((metrics.visualViewport.clientWidth / metrics.cssVisualViewport.clientWidth) * 100) / 100
|
|
265
|
+
: 1
|
|
266
|
+
: 1;
|
|
267
|
+
// Simpler: deviceScaleFactor is on the root Page metrics
|
|
268
|
+
const { deviceScaleFactor } = await cdp.send('Emulation.getDeviceMetricsOverride', {}, sid).catch(() => ({}));
|
|
269
|
+
if (deviceScaleFactor) dpr = deviceScaleFactor;
|
|
270
|
+
} catch {}
|
|
271
|
+
// Fallback: try to get DPR from JS
|
|
272
|
+
if (dpr === 1) {
|
|
273
|
+
try {
|
|
274
|
+
const raw = await evalStr(cdp, sid, 'window.devicePixelRatio');
|
|
275
|
+
const parsed = parseFloat(raw);
|
|
276
|
+
if (parsed > 0) dpr = parsed;
|
|
277
|
+
} catch {}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }, sid);
|
|
281
|
+
const out = filePath || '/tmp/screenshot.png';
|
|
282
|
+
writeFileSync(out, Buffer.from(data, 'base64'));
|
|
283
|
+
|
|
284
|
+
const lines = [out];
|
|
285
|
+
lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`);
|
|
286
|
+
lines.push(`Coordinate mapping:`);
|
|
287
|
+
lines.push(` Screenshot pixels → CSS pixels (for CDP Input events): divide by ${dpr}`);
|
|
288
|
+
lines.push(` e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy <target> 100 200`);
|
|
289
|
+
if (dpr !== 1) {
|
|
290
|
+
lines.push(` On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100/dpr)/100}`);
|
|
291
|
+
}
|
|
292
|
+
return lines.join('\n');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function htmlStr(cdp, sid, selector) {
|
|
296
|
+
const expr = selector
|
|
297
|
+
? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'`
|
|
298
|
+
: `document.documentElement.outerHTML`;
|
|
299
|
+
return evalStr(cdp, sid, expr);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) {
|
|
303
|
+
const deadline = Date.now() + timeoutMs;
|
|
304
|
+
let lastState = '';
|
|
305
|
+
let lastError;
|
|
306
|
+
while (Date.now() < deadline) {
|
|
307
|
+
try {
|
|
308
|
+
const state = await evalStr(cdp, sid, 'document.readyState');
|
|
309
|
+
lastState = state;
|
|
310
|
+
if (state === 'complete') return;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
lastError = e;
|
|
313
|
+
}
|
|
314
|
+
await sleep(200);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (lastState) {
|
|
318
|
+
throw new Error(`Timed out waiting for navigation to finish (last readyState: ${lastState})`);
|
|
319
|
+
}
|
|
320
|
+
if (lastError) {
|
|
321
|
+
throw new Error(`Timed out waiting for navigation to finish (${lastError.message})`);
|
|
322
|
+
}
|
|
323
|
+
throw new Error('Timed out waiting for navigation to finish');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function navStr(cdp, sid, url) {
|
|
327
|
+
await cdp.send('Page.enable', {}, sid);
|
|
328
|
+
const loadEvent = cdp.waitForEvent('Page.loadEventFired', NAVIGATION_TIMEOUT);
|
|
329
|
+
const result = await cdp.send('Page.navigate', { url }, sid);
|
|
330
|
+
if (result.errorText) {
|
|
331
|
+
loadEvent.cancel();
|
|
332
|
+
throw new Error(result.errorText);
|
|
333
|
+
}
|
|
334
|
+
if (result.loaderId) {
|
|
335
|
+
await loadEvent.promise;
|
|
336
|
+
} else {
|
|
337
|
+
loadEvent.cancel();
|
|
338
|
+
}
|
|
339
|
+
await waitForDocumentReady(cdp, sid, 5000);
|
|
340
|
+
return `Navigated to ${url}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function netStr(cdp, sid) {
|
|
344
|
+
const raw = await evalStr(cdp, sid, `JSON.stringify(performance.getEntriesByType('resource').map(e => ({
|
|
345
|
+
name: e.name.substring(0, 120), type: e.initiatorType,
|
|
346
|
+
duration: Math.round(e.duration), size: e.transferSize
|
|
347
|
+
})))`);
|
|
348
|
+
return JSON.parse(raw).map(e =>
|
|
349
|
+
`${String(e.duration).padStart(5)}ms ${String(e.size || '?').padStart(8)}B ${e.type.padEnd(8)} ${e.name}`
|
|
350
|
+
).join('\n');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Click element by CSS selector
|
|
354
|
+
async function clickStr(cdp, sid, selector) {
|
|
355
|
+
if (!selector) throw new Error('CSS selector required');
|
|
356
|
+
const expr = `
|
|
357
|
+
(function() {
|
|
358
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
359
|
+
if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };
|
|
360
|
+
el.scrollIntoView({ block: 'center' });
|
|
361
|
+
el.click();
|
|
362
|
+
return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };
|
|
363
|
+
})()
|
|
364
|
+
`;
|
|
365
|
+
const result = await evalStr(cdp, sid, expr);
|
|
366
|
+
const r = JSON.parse(result);
|
|
367
|
+
if (!r.ok) throw new Error(r.error);
|
|
368
|
+
return `Clicked <${r.tag}> "${r.text}"`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Click at CSS pixel coordinates using Input.dispatchMouseEvent
|
|
372
|
+
async function clickXyStr(cdp, sid, x, y) {
|
|
373
|
+
const cx = parseFloat(x);
|
|
374
|
+
const cy = parseFloat(y);
|
|
375
|
+
if (isNaN(cx) || isNaN(cy)) throw new Error('x and y must be numbers (CSS pixels)');
|
|
376
|
+
const base = { x: cx, y: cy, button: 'left', clickCount: 1, modifiers: 0 };
|
|
377
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseMoved' }, sid);
|
|
378
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed' }, sid);
|
|
379
|
+
await sleep(50);
|
|
380
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' }, sid);
|
|
381
|
+
return `Clicked at CSS (${cx}, ${cy})`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Type text using Input.insertText (works in cross-origin iframes, unlike eval)
|
|
385
|
+
async function typeStr(cdp, sid, text) {
|
|
386
|
+
if (text == null || text === '') throw new Error('text required');
|
|
387
|
+
await cdp.send('Input.insertText', { text }, sid);
|
|
388
|
+
return `Typed ${text.length} characters`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Load-more: repeatedly click a button/selector until it disappears
|
|
392
|
+
async function loadAllStr(cdp, sid, selector, intervalMs = 1500) {
|
|
393
|
+
if (!selector) throw new Error('CSS selector required');
|
|
394
|
+
let clicks = 0;
|
|
395
|
+
const deadline = Date.now() + 5 * 60 * 1000; // 5-minute hard cap
|
|
396
|
+
while (Date.now() < deadline) {
|
|
397
|
+
const exists = await evalStr(cdp, sid,
|
|
398
|
+
`!!document.querySelector(${JSON.stringify(selector)})`
|
|
399
|
+
);
|
|
400
|
+
if (exists !== 'true') break;
|
|
401
|
+
const clickExpr = `
|
|
402
|
+
(function() {
|
|
403
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
404
|
+
if (!el) return false;
|
|
405
|
+
el.scrollIntoView({ block: 'center' });
|
|
406
|
+
el.click();
|
|
407
|
+
return true;
|
|
408
|
+
})()
|
|
409
|
+
`;
|
|
410
|
+
const clicked = await evalStr(cdp, sid, clickExpr);
|
|
411
|
+
if (clicked !== 'true') break;
|
|
412
|
+
clicks++;
|
|
413
|
+
await sleep(intervalMs);
|
|
414
|
+
}
|
|
415
|
+
return `Clicked "${selector}" ${clicks} time(s) until it disappeared`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Send a raw CDP command and return the result as JSON
|
|
419
|
+
async function evalRawStr(cdp, sid, method, paramsJson) {
|
|
420
|
+
if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")');
|
|
421
|
+
let params = {};
|
|
422
|
+
if (paramsJson) {
|
|
423
|
+
try { params = JSON.parse(paramsJson); }
|
|
424
|
+
catch { throw new Error(`Invalid JSON params: ${paramsJson}`); }
|
|
425
|
+
}
|
|
426
|
+
const result = await cdp.send(method, params, sid);
|
|
427
|
+
return JSON.stringify(result, null, 2);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// Per-tab daemon
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
async function runDaemon(targetId) {
|
|
435
|
+
const sp = sockPath(targetId);
|
|
436
|
+
|
|
437
|
+
const cdp = new CDP();
|
|
438
|
+
try {
|
|
439
|
+
await cdp.connect(getWsUrl());
|
|
440
|
+
} catch (e) {
|
|
441
|
+
process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let sessionId;
|
|
446
|
+
try {
|
|
447
|
+
const res = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
448
|
+
sessionId = res.sessionId;
|
|
449
|
+
} catch (e) {
|
|
450
|
+
process.stderr.write(`Daemon: attach failed: ${e.message}\n`);
|
|
451
|
+
cdp.close();
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Shutdown helpers
|
|
456
|
+
let alive = true;
|
|
457
|
+
function shutdown() {
|
|
458
|
+
if (!alive) return;
|
|
459
|
+
alive = false;
|
|
460
|
+
server.close();
|
|
461
|
+
try { unlinkSync(sp); } catch {}
|
|
462
|
+
cdp.close();
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Exit if target goes away or Chrome disconnects
|
|
467
|
+
cdp.onEvent('Target.targetDestroyed', (params) => {
|
|
468
|
+
if (params.targetId === targetId) shutdown();
|
|
469
|
+
});
|
|
470
|
+
cdp.onEvent('Target.detachedFromTarget', (params) => {
|
|
471
|
+
if (params.sessionId === sessionId) shutdown();
|
|
472
|
+
});
|
|
473
|
+
cdp.onClose(() => shutdown());
|
|
474
|
+
process.on('SIGTERM', shutdown);
|
|
475
|
+
process.on('SIGINT', shutdown);
|
|
476
|
+
|
|
477
|
+
// Idle timer
|
|
478
|
+
let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
|
|
479
|
+
function resetIdle() {
|
|
480
|
+
clearTimeout(idleTimer);
|
|
481
|
+
idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Handle a command
|
|
485
|
+
async function handleCommand({ cmd, args }) {
|
|
486
|
+
resetIdle();
|
|
487
|
+
try {
|
|
488
|
+
let result;
|
|
489
|
+
switch (cmd) {
|
|
490
|
+
case 'list': {
|
|
491
|
+
const pages = await getPages(cdp);
|
|
492
|
+
result = formatPageList(pages);
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'list_raw': {
|
|
496
|
+
const pages = await getPages(cdp);
|
|
497
|
+
result = JSON.stringify(pages);
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
case 'snap': case 'snapshot': result = await snapshotStr(cdp, sessionId, true); break;
|
|
501
|
+
case 'eval': result = await evalStr(cdp, sessionId, args[0]); break;
|
|
502
|
+
case 'shot': case 'screenshot': result = await shotStr(cdp, sessionId, args[0]); break;
|
|
503
|
+
case 'html': result = await htmlStr(cdp, sessionId, args[0]); break;
|
|
504
|
+
case 'nav': case 'navigate': result = await navStr(cdp, sessionId, args[0]); break;
|
|
505
|
+
case 'net': case 'network': result = await netStr(cdp, sessionId); break;
|
|
506
|
+
case 'click': result = await clickStr(cdp, sessionId, args[0]); break;
|
|
507
|
+
case 'clickxy': result = await clickXyStr(cdp, sessionId, args[0], args[1]); break;
|
|
508
|
+
case 'type': result = await typeStr(cdp, sessionId, args[0]); break;
|
|
509
|
+
case 'loadall': result = await loadAllStr(cdp, sessionId, args[0], args[1] ? parseInt(args[1]) : 1500); break;
|
|
510
|
+
case 'evalraw': result = await evalRawStr(cdp, sessionId, args[0], args[1]); break;
|
|
511
|
+
case 'stop': return { ok: true, result: '', stopAfter: true };
|
|
512
|
+
default: return { ok: false, error: `Unknown command: ${cmd}` };
|
|
513
|
+
}
|
|
514
|
+
return { ok: true, result: result ?? '' };
|
|
515
|
+
} catch (e) {
|
|
516
|
+
return { ok: false, error: e.message };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Unix socket server — NDJSON protocol
|
|
521
|
+
// Wire format: each message is one JSON object followed by \n (newline-delimited JSON).
|
|
522
|
+
// Request: { "id": <number>, "cmd": "<command>", "args": ["arg1", "arg2", ...] }
|
|
523
|
+
// Response: { "id": <number>, "ok": <boolean>, "result": "<string>" }
|
|
524
|
+
// or { "id": <number>, "ok": false, "error": "<message>" }
|
|
525
|
+
const server = net.createServer((conn) => {
|
|
526
|
+
let buf = '';
|
|
527
|
+
conn.on('data', (chunk) => {
|
|
528
|
+
buf += chunk.toString();
|
|
529
|
+
const lines = buf.split('\n');
|
|
530
|
+
buf = lines.pop(); // keep incomplete last line
|
|
531
|
+
for (const line of lines) {
|
|
532
|
+
if (!line.trim()) continue;
|
|
533
|
+
let req;
|
|
534
|
+
try {
|
|
535
|
+
req = JSON.parse(line);
|
|
536
|
+
} catch {
|
|
537
|
+
conn.write(JSON.stringify({ ok: false, error: 'Invalid JSON request', id: null }) + '\n');
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
handleCommand(req).then((res) => {
|
|
541
|
+
const payload = JSON.stringify({ ...res, id: req.id }) + '\n';
|
|
542
|
+
if (res.stopAfter) conn.end(payload, shutdown);
|
|
543
|
+
else conn.write(payload);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
try { unlinkSync(sp); } catch {}
|
|
550
|
+
server.listen(sp);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// CLI ↔ daemon communication
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
function connectToSocket(sp) {
|
|
558
|
+
return new Promise((resolve, reject) => {
|
|
559
|
+
const conn = net.connect(sp);
|
|
560
|
+
conn.on('connect', () => resolve(conn));
|
|
561
|
+
conn.on('error', reject);
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function getOrStartTabDaemon(targetId) {
|
|
566
|
+
const sp = sockPath(targetId);
|
|
567
|
+
// Try existing daemon
|
|
568
|
+
try { return await connectToSocket(sp); } catch {}
|
|
569
|
+
|
|
570
|
+
// Clean stale socket
|
|
571
|
+
try { unlinkSync(sp); } catch {}
|
|
572
|
+
|
|
573
|
+
// Spawn daemon
|
|
574
|
+
const child = spawn(process.execPath, [process.argv[1], '_daemon', targetId], {
|
|
575
|
+
detached: true,
|
|
576
|
+
stdio: 'ignore',
|
|
577
|
+
});
|
|
578
|
+
child.unref();
|
|
579
|
+
|
|
580
|
+
// Wait for socket (includes time for user to click Allow)
|
|
581
|
+
for (let i = 0; i < DAEMON_CONNECT_RETRIES; i++) {
|
|
582
|
+
await sleep(DAEMON_CONNECT_DELAY);
|
|
583
|
+
try { return await connectToSocket(sp); } catch {}
|
|
584
|
+
}
|
|
585
|
+
throw new Error('Daemon failed to start — did you click Allow in Chrome?');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function sendCommand(conn, req) {
|
|
589
|
+
return new Promise((resolve, reject) => {
|
|
590
|
+
let buf = '';
|
|
591
|
+
let settled = false;
|
|
592
|
+
|
|
593
|
+
const cleanup = () => {
|
|
594
|
+
conn.off('data', onData);
|
|
595
|
+
conn.off('error', onError);
|
|
596
|
+
conn.off('end', onEnd);
|
|
597
|
+
conn.off('close', onClose);
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const onData = (chunk) => {
|
|
601
|
+
buf += chunk.toString();
|
|
602
|
+
const idx = buf.indexOf('\n');
|
|
603
|
+
if (idx === -1) return;
|
|
604
|
+
settled = true;
|
|
605
|
+
cleanup();
|
|
606
|
+
resolve(JSON.parse(buf.slice(0, idx)));
|
|
607
|
+
conn.end();
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const onError = (error) => {
|
|
611
|
+
if (settled) return;
|
|
612
|
+
settled = true;
|
|
613
|
+
cleanup();
|
|
614
|
+
reject(error);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const onEnd = () => {
|
|
618
|
+
if (settled) return;
|
|
619
|
+
settled = true;
|
|
620
|
+
cleanup();
|
|
621
|
+
reject(new Error('Connection closed before response'));
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const onClose = () => {
|
|
625
|
+
if (settled) return;
|
|
626
|
+
settled = true;
|
|
627
|
+
cleanup();
|
|
628
|
+
reject(new Error('Connection closed before response'));
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
conn.on('data', onData);
|
|
632
|
+
conn.on('error', onError);
|
|
633
|
+
conn.on('end', onEnd);
|
|
634
|
+
conn.on('close', onClose);
|
|
635
|
+
req.id = 1;
|
|
636
|
+
conn.write(JSON.stringify(req) + '\n');
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Find any running daemon socket to reuse for list
|
|
641
|
+
function findAnyDaemonSocket() {
|
|
642
|
+
return listDaemonSockets()[0]?.socketPath || null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
// Stop daemons
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
async function stopDaemons(targetPrefix) {
|
|
650
|
+
const daemons = listDaemonSockets();
|
|
651
|
+
|
|
652
|
+
if (targetPrefix) {
|
|
653
|
+
const targetId = resolvePrefix(targetPrefix, daemons.map(d => d.targetId), 'daemon');
|
|
654
|
+
const daemon = daemons.find(d => d.targetId === targetId);
|
|
655
|
+
try {
|
|
656
|
+
const conn = await connectToSocket(daemon.socketPath);
|
|
657
|
+
await sendCommand(conn, { cmd: 'stop' });
|
|
658
|
+
} catch {
|
|
659
|
+
try { unlinkSync(daemon.socketPath); } catch {}
|
|
660
|
+
}
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
for (const daemon of daemons) {
|
|
665
|
+
try {
|
|
666
|
+
const conn = await connectToSocket(daemon.socketPath);
|
|
667
|
+
await sendCommand(conn, { cmd: 'stop' });
|
|
668
|
+
} catch {
|
|
669
|
+
try { unlinkSync(daemon.socketPath); } catch {}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// Main
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)
|
|
679
|
+
|
|
680
|
+
Usage: cdp <command> [args]
|
|
681
|
+
|
|
682
|
+
list List open pages (shows unique target prefixes)
|
|
683
|
+
snap <target> Accessibility tree snapshot
|
|
684
|
+
eval <target> <expr> Evaluate JS expression
|
|
685
|
+
shot <target> [file] Screenshot (default: /tmp/screenshot.png); prints coordinate mapping
|
|
686
|
+
html <target> [selector] Get HTML (full page or CSS selector)
|
|
687
|
+
nav <target> <url> Navigate to URL and wait for load completion
|
|
688
|
+
net <target> Network performance entries
|
|
689
|
+
click <target> <selector> Click an element by CSS selector
|
|
690
|
+
clickxy <target> <x> <y> Click at CSS pixel coordinates (see coordinate note below)
|
|
691
|
+
type <target> <text> Type text at current focus via Input.insertText
|
|
692
|
+
Works in cross-origin iframes unlike eval-based approaches
|
|
693
|
+
loadall <target> <selector> [ms] Repeatedly click a "load more" button until it disappears
|
|
694
|
+
Optional interval in ms between clicks (default 1500)
|
|
695
|
+
evalraw <target> <method> [json] Send a raw CDP command; returns JSON result
|
|
696
|
+
e.g. evalraw <t> "DOM.getDocument" '{}'
|
|
697
|
+
stop [target] Stop daemon(s)
|
|
698
|
+
|
|
699
|
+
<target> is a unique targetId prefix from "cdp list". If a prefix is ambiguous,
|
|
700
|
+
use more characters.
|
|
701
|
+
|
|
702
|
+
COORDINATE SYSTEM
|
|
703
|
+
shot captures the viewport at the device's native resolution.
|
|
704
|
+
The screenshot image size = CSS pixels × DPR (device pixel ratio).
|
|
705
|
+
For CDP Input events (clickxy, etc.) you need CSS pixels, not image pixels.
|
|
706
|
+
|
|
707
|
+
CSS pixels = screenshot image pixels / DPR
|
|
708
|
+
|
|
709
|
+
shot prints the DPR and an example conversion for the current page.
|
|
710
|
+
Typical Retina (DPR=2): CSS px ≈ screenshot px × 0.5
|
|
711
|
+
If your viewer rescales the image further, account for that scaling too.
|
|
712
|
+
|
|
713
|
+
EVAL SAFETY NOTE
|
|
714
|
+
Avoid index-based DOM selection (querySelectorAll(...)[i]) across multiple
|
|
715
|
+
eval calls when the list can change between calls (e.g. after clicking
|
|
716
|
+
"Ignore" buttons on a feed — indices shift). Prefer stable selectors or
|
|
717
|
+
collect all data in a single eval.
|
|
718
|
+
|
|
719
|
+
DAEMON IPC (for advanced use / scripting)
|
|
720
|
+
Each tab runs a persistent daemon at Unix socket: /tmp/cdp-<fullTargetId>.sock
|
|
721
|
+
Protocol: newline-delimited JSON (one JSON object per line, UTF-8).
|
|
722
|
+
Request: {"id":<number>, "cmd":"<command>", "args":["arg1","arg2",...]}
|
|
723
|
+
Response: {"id":<number>, "ok":true, "result":"<string>"}
|
|
724
|
+
or {"id":<number>, "ok":false, "error":"<message>"}
|
|
725
|
+
Commands mirror the CLI: snap, eval, shot, html, nav, net, click, clickxy,
|
|
726
|
+
type, loadall, evalraw, stop. Use evalraw to send arbitrary CDP methods.
|
|
727
|
+
The socket disappears after 20 min of inactivity or when the tab closes.
|
|
728
|
+
`;
|
|
729
|
+
|
|
730
|
+
const NEEDS_TARGET = new Set([
|
|
731
|
+
'snap','snapshot','eval','shot','screenshot','html','nav','navigate',
|
|
732
|
+
'net','network','click','clickxy','type','loadall','evalraw',
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
async function main() {
|
|
736
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
737
|
+
|
|
738
|
+
// Daemon mode (internal)
|
|
739
|
+
if (cmd === '_daemon') { await runDaemon(args[0]); return; }
|
|
740
|
+
|
|
741
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
742
|
+
console.log(USAGE); process.exit(0);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// List — use existing daemon if available, otherwise direct
|
|
746
|
+
if (cmd === 'list' || cmd === 'ls') {
|
|
747
|
+
let pages;
|
|
748
|
+
const existingSock = findAnyDaemonSocket();
|
|
749
|
+
if (existingSock) {
|
|
750
|
+
try {
|
|
751
|
+
const conn = await connectToSocket(existingSock);
|
|
752
|
+
const resp = await sendCommand(conn, { cmd: 'list_raw' });
|
|
753
|
+
if (resp.ok) pages = JSON.parse(resp.result);
|
|
754
|
+
} catch {}
|
|
755
|
+
}
|
|
756
|
+
if (!pages) {
|
|
757
|
+
// No daemon running — connect directly (will trigger one Allow)
|
|
758
|
+
const cdp = new CDP();
|
|
759
|
+
await cdp.connect(getWsUrl());
|
|
760
|
+
pages = await getPages(cdp);
|
|
761
|
+
cdp.close();
|
|
762
|
+
}
|
|
763
|
+
writeFileSync(PAGES_CACHE, JSON.stringify(pages));
|
|
764
|
+
console.log(formatPageList(pages));
|
|
765
|
+
setTimeout(() => process.exit(0), 100);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Stop
|
|
770
|
+
if (cmd === 'stop') {
|
|
771
|
+
await stopDaemons(args[0]);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Page commands — need target prefix
|
|
776
|
+
if (!NEEDS_TARGET.has(cmd)) {
|
|
777
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
778
|
+
console.log(USAGE);
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const targetPrefix = args[0];
|
|
783
|
+
if (!targetPrefix) {
|
|
784
|
+
console.error('Error: target ID required. Run "cdp list" first.');
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Resolve prefix → full targetId from cache or running daemon
|
|
789
|
+
let targetId;
|
|
790
|
+
const daemonTargetIds = listDaemonSockets().map(d => d.targetId);
|
|
791
|
+
const daemonMatches = daemonTargetIds.filter(id => id.toUpperCase().startsWith(targetPrefix.toUpperCase()));
|
|
792
|
+
|
|
793
|
+
if (daemonMatches.length > 0) {
|
|
794
|
+
targetId = resolvePrefix(targetPrefix, daemonTargetIds, 'daemon');
|
|
795
|
+
} else {
|
|
796
|
+
if (!existsSync(PAGES_CACHE)) {
|
|
797
|
+
console.error('No page list cached. Run "cdp list" first.');
|
|
798
|
+
process.exit(1);
|
|
799
|
+
}
|
|
800
|
+
const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));
|
|
801
|
+
targetId = resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target', 'Run "cdp list".');
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const conn = await getOrStartTabDaemon(targetId);
|
|
805
|
+
|
|
806
|
+
const cmdArgs = args.slice(1);
|
|
807
|
+
|
|
808
|
+
if (cmd === 'eval') {
|
|
809
|
+
const expr = cmdArgs.join(' ');
|
|
810
|
+
if (!expr) { console.error('Error: expression required'); process.exit(1); }
|
|
811
|
+
cmdArgs[0] = expr;
|
|
812
|
+
} else if (cmd === 'type') {
|
|
813
|
+
// Join all remaining args as text (allows spaces)
|
|
814
|
+
const text = cmdArgs.join(' ');
|
|
815
|
+
if (!text) { console.error('Error: text required'); process.exit(1); }
|
|
816
|
+
cmdArgs[0] = text;
|
|
817
|
+
} else if (cmd === 'evalraw') {
|
|
818
|
+
// args: [method, ...jsonParts] — join json parts in case of spaces
|
|
819
|
+
if (!cmdArgs[0]) { console.error('Error: CDP method required'); process.exit(1); }
|
|
820
|
+
if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(' ');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if ((cmd === 'nav' || cmd === 'navigate') && !cmdArgs[0]) {
|
|
824
|
+
console.error('Error: URL required');
|
|
825
|
+
process.exit(1);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const response = await sendCommand(conn, { cmd, args: cmdArgs });
|
|
829
|
+
|
|
830
|
+
if (response.ok) {
|
|
831
|
+
if (response.result) console.log(response.result);
|
|
832
|
+
} else {
|
|
833
|
+
console.error('Error:', response.error);
|
|
834
|
+
process.exitCode = 1;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
main().catch(e => { console.error(e.message); process.exit(1); });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Wrapper script to run cdp.mjs with Node.js 23+ (required for built-in WebSocket)
|
|
3
|
+
export NVM_DIR="$HOME/.nvm"
|
|
4
|
+
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
|
5
|
+
nvm use 23 --silent 2>/dev/null
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
node "$SCRIPT_DIR/cdp.mjs" "$@"
|