release v0.1.0
This commit is contained in:
commit
6649930593
221
MANIFEST.json
Normal file
221
MANIFEST.json
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 1,
|
||||||
|
"name": "cdp-page-control",
|
||||||
|
"summary": "驱动受控浏览器操作任意网站页面(导航、填表、点击、读文本、截图、抓网络)",
|
||||||
|
"tier": 1,
|
||||||
|
"status": "stable",
|
||||||
|
"version": "2d2f88d",
|
||||||
|
"source": {
|
||||||
|
"path": "skills/cdp-page-control",
|
||||||
|
"commit": "2d2f88d",
|
||||||
|
"describe": "2d2f88d",
|
||||||
|
"dirty": false,
|
||||||
|
"packed_at": "2026-10-02T01:26:18.193Z"
|
||||||
|
},
|
||||||
|
"platforms": [
|
||||||
|
"all"
|
||||||
|
],
|
||||||
|
"entry": {
|
||||||
|
"path": "scripts/cdpctl.mjs"
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
"launch",
|
||||||
|
"nav",
|
||||||
|
"snapshot",
|
||||||
|
"wait",
|
||||||
|
"find",
|
||||||
|
"click",
|
||||||
|
"type",
|
||||||
|
"fill",
|
||||||
|
"press",
|
||||||
|
"drag",
|
||||||
|
"select",
|
||||||
|
"hover",
|
||||||
|
"upload",
|
||||||
|
"insert-text",
|
||||||
|
"text",
|
||||||
|
"html",
|
||||||
|
"eval",
|
||||||
|
"net",
|
||||||
|
"console",
|
||||||
|
"shot",
|
||||||
|
"pdf",
|
||||||
|
"cookies",
|
||||||
|
"import-cookies",
|
||||||
|
"persist-cookies",
|
||||||
|
"targets",
|
||||||
|
"open",
|
||||||
|
"close",
|
||||||
|
"status",
|
||||||
|
"stop"
|
||||||
|
],
|
||||||
|
"errors": [
|
||||||
|
"usage",
|
||||||
|
"auth",
|
||||||
|
"notfound",
|
||||||
|
"blocked",
|
||||||
|
"timeout",
|
||||||
|
"external",
|
||||||
|
"internal"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"cdpctl"
|
||||||
|
],
|
||||||
|
"admin": [],
|
||||||
|
"depends_on": [],
|
||||||
|
"requires": [],
|
||||||
|
"config": [],
|
||||||
|
"external_imports": [],
|
||||||
|
"contract": {
|
||||||
|
"version": 1,
|
||||||
|
"skill": "cdp-page-control",
|
||||||
|
"tier": 1,
|
||||||
|
"platforms": [
|
||||||
|
"all"
|
||||||
|
],
|
||||||
|
"commands": {
|
||||||
|
"click": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"close": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"cookies": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"drag": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"eval": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"fill": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"find": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"hover": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"import-cookies": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"insert-text": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"launch": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"net": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"open": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"persist-cookies": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"press": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"shot": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"stop": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait": {
|
||||||
|
"destructive": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": [
|
||||||
|
"auth",
|
||||||
|
"blocked",
|
||||||
|
"external",
|
||||||
|
"internal",
|
||||||
|
"notfound",
|
||||||
|
"timeout",
|
||||||
|
"usage"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"cdpctl"
|
||||||
|
],
|
||||||
|
"adminAliases": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "REFERENCE.md",
|
||||||
|
"bytes": 17940,
|
||||||
|
"sha256": "b60993234ed6ecb7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"bytes": 9387,
|
||||||
|
"sha256": "e438232d7f407ddd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "VERSION",
|
||||||
|
"bytes": 6,
|
||||||
|
"sha256": "e9dd8507f4bf0c6f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "contract.lock.json",
|
||||||
|
"bytes": 1686,
|
||||||
|
"sha256": "7f9826fd24a0edba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "interface.json",
|
||||||
|
"bytes": 3336,
|
||||||
|
"sha256": "01936140a778d16f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/cdp-channel.mjs",
|
||||||
|
"bytes": 37344,
|
||||||
|
"sha256": "87d7b2ff6bdda7c9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/cdpctl.mjs",
|
||||||
|
"bytes": 66823,
|
||||||
|
"sha256": "d1130b829b1fd42b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"leak_scan": {
|
||||||
|
"errors": 0,
|
||||||
|
"warnings": 0,
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
|
}
|
||||||
14
README.md
Normal file
14
README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# cdp-page-control
|
||||||
|
|
||||||
|
驱动受控浏览器操作任意网站页面(导航、填表、点击、读文本、截图、抓网络)
|
||||||
|
|
||||||
|
以 Pi package(技能形态)发布。
|
||||||
|
技能本体在 `skills/cdp-page-control/`,用法见其 `SKILL.md` 与 `REFERENCE.md`。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pi install git:gitea.vhkd.top/geekinney/cdp-page-control.git@v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
装完 `pi-skill list` 能看到 `cdp-page-control`,`pi-skill cdp-page-control check` 会告诉还缺什么。
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "cdp-page-control",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "驱动受控浏览器操作任意网站页面(导航、填表、点击、读文本、截图、抓网络)"
|
||||||
|
}
|
||||||
168
skills/cdp-page-control/REFERENCE.md
Normal file
168
skills/cdp-page-control/REFERENCE.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# cdp-page-control 参考
|
||||||
|
|
||||||
|
## 分层
|
||||||
|
|
||||||
|
| 层 | 文件 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| 通道层 | `scripts/cdp-channel.mjs` | CDP 传输、浏览器生命周期、选择器解析、cookie DB 定位 |
|
||||||
|
| 原语层 | `scripts/cdpctl.mjs` | 每个子命令 = 一个原子操作,参数化,不含站点逻辑 |
|
||||||
|
| 场景层 | `SKILL.md` 配方表 | 多步骤组合只写在文档里,不进代码 |
|
||||||
|
|
||||||
|
新增需求 = 用现成原语组合;某个组合反复出现 = 在 SKILL.md 配方表加一行,**不要**往原语层塞场景分支。
|
||||||
|
|
||||||
|
新加一个原语的四步:① `cdp-channel.mjs` 里加必要传输(多数情况下不需要)→ ② `cdpctl.mjs` 的 `switch` 加一个 case → ③ `CMD_HELP` 加条目(`usage 正文与注册表一致性` 测试会自动校验,漏注册会失败)→ ④ 在 `tests/smoke.test.mjs` 或对应专题测试(如 `tests/inspect.test.mjs`)里加断言。
|
||||||
|
|
||||||
|
## 为什么必须用独立 profile
|
||||||
|
|
||||||
|
Chrome 136 起,**默认 user-data-dir 会直接忽略 `--remote-debugging-port`**(官方为防 Cookie 窃取做的加固)。所以「连上你正在用的那个 Chrome」在不重启、不换 profile 的前提下做不到。本技能的方案是另起一个带独立 profile 的实例,与你日常浏览器完全隔离、可并存。
|
||||||
|
|
||||||
|
必须带的启动参数:`--user-data-dir=<非默认>`、`--remote-debugging-port=<端口>`、`--remote-allow-origins=*`(否则 WebSocket 握手 403)。
|
||||||
|
|
||||||
|
## 登录态持久化:实测结论
|
||||||
|
|
||||||
|
DeepSeek Platform 的实测数据(2026-09,Chrome 153,macOS):
|
||||||
|
|
||||||
|
| 观察 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 站点把会话放哪 | **localStorage 的 `userToken`**;cookie 里没有会话凭证,IndexedDB 也没有 |
|
||||||
|
| `ds_session_id` 的属性 | `expires_utc=0`、`has_expires=0` → **会话 cookie** |
|
||||||
|
| 会话 cookie 跨重启 | **被 Chrome 删除**(实测:重启后 DB 里消失) |
|
||||||
|
| 改成持久 cookie 后 | **连续两次重启都存活**(实测通过) |
|
||||||
|
| 对照实验 | 同一份密文只改元数据:会话版被删,持久版存活 → 差异只来自元数据 |
|
||||||
|
|
||||||
|
`persist-cookies` 就是把 `has_expires=0` 的行改成 `expires_utc=13537929600000000`(2030-01-01,WebKit 1601 纪元微秒)、`has_expires=1`、`is_persistent=1`。**密文不变,不解密、不改值。**
|
||||||
|
|
||||||
|
注意:`session.restore_on_startup=1`(「继续浏览上次打开的网页」)**不可靠** —— Chrome 退出时会把它改回 `None`,且没有可恢复会话数据时照样清会话 cookie。别依赖它。
|
||||||
|
|
||||||
|
局限:这只保证**浏览器侧不丢凭证**。服务端会话是否仍有效由站点决定,站点判过期就得重新登录。
|
||||||
|
|
||||||
|
## 复用日常 Chrome 的登录态:`import-cookies`
|
||||||
|
|
||||||
|
不想为自动化再登录一遍时,不用重启日常 Chrome、不用导出密码,直接把它的 Cookies 库复制过来:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi-skill cdp-page-control import-cookies # 预览(默认 profile)
|
||||||
|
pi-skill cdp-page-control import-cookies --profile "Profile 1" # 指定日常 Chrome 的另一个 profile
|
||||||
|
pi-skill cdp-page-control import-cookies --yes # 执行:关浏览器 → 备份目标库 → 覆盖 → 回读计数
|
||||||
|
pi-skill cdp-page-control launch # 再用浏览器
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么可行:Chrome 136+ 只是不允许给**默认** user-data-dir 开调试端口,并没有换 cookie 加密密钥;同机同 Chrome 的 cookie 由同一份 Keychain「Chrome Safe Storage」派生密钥保护,**文件级复制即可解密**(实测:把日常 profile 的 Cookies 拷进自动化 profile 后,日常已登录的站点直接可用)。
|
||||||
|
|
||||||
|
边界与安全(重要):
|
||||||
|
|
||||||
|
- 复制的是**整个 profile 的全部站点登录态**,不是只挑几个域;等于把一份浏览器身份带进自动化实例。
|
||||||
|
- 自动化实例的调试端口只绑 `127.0.0.1`,但本机任何进程都能连上它读该 profile 的全部凭证;不用时 `stop`。
|
||||||
|
- 目标库原内容会被移到同目录的 `Cookies.bak-<时间戳>`,回滚就是拷回去。
|
||||||
|
- 属于本机私有数据,不要提交/同步(profile 目录在共享仓库之外)。
|
||||||
|
|
||||||
|
## 快照引用(@N)
|
||||||
|
|
||||||
|
`snapshot` 在页面里给「可见的交互 + 结构元素」打 `data-cdpctl-ref`,输出缩进文本树;所有命令的选择器都认 `@N`(`selectorExpr` 转成穿透 shadow root 的查找表达式)。
|
||||||
|
|
||||||
|
- 引用是一次性的:每次 snapshot 先清掉旧引用再重新编号;SPA 重渲染会把节点连同属性一起换掉,旧 `@N` 失效——`click`/`wait` 的报错会提示重跑 `snapshot`。
|
||||||
|
- 为什么用页面属性而不是 Node 侧存映射:无状态、跨进程可用(每条命令都是独立进程)、复用现有全部元素命令(含 `--frame` 内的解析);代价是页面上会留下属性(重拍即清理,无业务副作用)。
|
||||||
|
- shadow DOM:open shadow root 里的元素也编号;查找表达式递归进 shadowRoot(`querySelector` 不穿透)。closed shadow root 仍拿不到。
|
||||||
|
- 挑选规则:`a/button/input/select/textarea/summary/iframe`、各级标题、`main/nav/header/footer/aside/form/table/details`、`contenteditable`、常见交互 role;带可见文本的叶子节点只在 `--all` 时纳入。`--max` 默认 250(0=不限,超出部分只计数不显示)。
|
||||||
|
|
||||||
|
## 对话框:实测行为与策略
|
||||||
|
|
||||||
|
Chrome 153 实测(探针脚本 + `tests/inspect.test.mjs` 守着):
|
||||||
|
|
||||||
|
- 对话框打开期间,**后续 CDP 调用全部挂起**:`Page.enable`、`Runtime.evaluate`、`Input.*` 都不返回;连触发弹窗那次 `mouseReleased` 的响应也要等对话框被处理。
|
||||||
|
- **残留对话框无法从新连接抢救**:新连接收不到 `javascriptDialogOpening`,`Page.handleJavaScriptDialog` 报 "No dialog is showing",`Page.enable` 直接超时。唯一恢复是 `cdpctl stop && cdpctl launch`(浏览器级 `Browser.close` 不受阻塞影响)。
|
||||||
|
- 因此 `connectPage` 默认在**任何动作之前**装上处理器:`Page.enable` + 监听 `Page.javascriptDialogOpening` → 自动 `handleJavaScriptDialog`。策略 `CDPCTL_DIALOG=accept|dismiss|ignore`(默认 accept),prompt 文本 `CDPCTL_DIALOG_TEXT`(缺省用页面 defaultPrompt)。
|
||||||
|
- 兜底超时:`Page.enable` 5s、evaluate/waitForElement 5s、Input 15s;把「永久挂死」变成带 `cdpctl stop && cdpctl launch` 提示的报错。`CDPCTL_DIALOG=ignore` 保留原生行为,风险自负。
|
||||||
|
|
||||||
|
## console:历史回放实测
|
||||||
|
|
||||||
|
- `Runtime.enable` **会回放**当前文档保留的 `console.*` 消息与未捕获异常(`Runtime.consoleAPICalled` / `Runtime.exceptionThrown`),所以不加 `--reload` 也能看到页面之前报过的错。
|
||||||
|
- `Log.enable` 不回放缓存条目;它补的是浏览器日志(网络错误/安全告警等)。两个域都挂上,按时间戳排序输出(Log 的 timestamp 是秒,需 ×1000)。
|
||||||
|
- 对象参数用 `Runtime.callFunctionOn` 在页面里 `JSON.stringify` 后回传,截断 400 字符;避免 `[object Object]`。
|
||||||
|
- `--reload` 先丢弃旧页面的回放,再刷新并收集到 load 后 `--seconds` 秒,专抓加载期一次性报错。
|
||||||
|
|
||||||
|
## 新命令实现要点
|
||||||
|
|
||||||
|
- `fill`:JSON 对象逐字段分派——`<select>` 走原生 setter + input/change;checkbox/radio 用真实点击并回读校验;其余走 `type` 的受控输入路径。失败的字段逐条报告、成功的保留,整体退出码 1。字段级 `waitForElement` 复用全局 `--timeout`。
|
||||||
|
- `drag`:`mousePressed` → 10 步 `mouseMoved`(每步留 10ms)→ `mouseReleased`。分步是必须的:JS 拖拽库和浏览器原生 HTML5 拖放都依赖过程事件。要求两端同屏(拖拽中途无法滚动),暂不支持 `--frame`。
|
||||||
|
- `nav --back/--forward`:`Page.getNavigationHistory` + `Page.navigateToHistoryEntry`;`--reload` 用 `Page.reload`,比 `eval location.reload()` 可靠(不受页面重写/覆盖影响)。
|
||||||
|
- `shot <out.png> [选择器] [--full] [--clip x,y,w,h [--scale N]]`:
|
||||||
|
- 带选择器:`boxExpr` 拿元素边界 + 当前 `scrollX/Y` 作为 `clip`,`captureBeyondViewport: true`;坐标换算已包含 `--frame` 的宿主页面偏移。
|
||||||
|
- `--clip x,y,w,h`:页面坐标(CSS px)矩形,`captureBeyondViewport: false` —— **只 raster 视口内已绘制的区域**,
|
||||||
|
所以视口外的部分是空白;要截比视口大的区域得自己按视口分块 `--clip` 再拼(见下面那条坑)。
|
||||||
|
- 互斥:`选择器 + --full`、`选择器 + --clip`、`--full + --clip` 都直接报错;`--scale` 只能配 `--clip`(默认 1)。
|
||||||
|
- **`pdf <out.pdf> --size WxH [--media screen|print]`**(2026-09-27 加):走 `Page.printToPDF`,纸面尺寸 = `W/96 × H/96` 英寸、四边 margin 0、`printBackground: true`;`--media screen` 会先 `Emulation.setEmulatedMedia{screen}` 以保持屏幕上那套版式。**这是「大画布上怎么一次拿到整页」的正解**:16 栏 7472×786 实测 0.77s 出 PDF,再用 `qlmanage -t -s 2×max(W,H) -o <目录> <pdf>` 矢量重栅格 → 14944×1573 的 PNG(0.48s)。注意 `sips` 不行(按 1pt=1px 出图,等于 0.75× CSS)。
|
||||||
|
- **`captureBeyondViewport: true`(`--full` / 元素截图)在大画布上是死循环**(2026-09-27 实测):
|
||||||
|
宽多栏文本页(6544×896)20s/45s 不返回;纯 `<img>` 拼图页也一样;普通小页(321ms)与纯色页正常。
|
||||||
|
卡住后**同一个标签页后续命令也可能一起变慢/挂死**,重启浏览器才能清干净。
|
||||||
|
「把视口放大到整页再一次截」(`Emulation.setDeviceMetricsOverride` + 普通截图)同样 20s 无响应;
|
||||||
|
要整页图就用上一段的 `pdf`(首选),或退而求其次:把区域切成 ≤ 视口的块,逐块滚动
|
||||||
|
(`behavior:'instant'` + 等两个 rAF)`--clip`,再自己拼(参考 `longtext-web` 的
|
||||||
|
`capture_page` + `lib/png_stitch.mjs`:16 栏 / 7472×786 → 6 块 → 14944×1572,约 5s)。
|
||||||
|
分块前记得在受控副本里藏掉滚动条(`::-webkit-scrollbar{display:none}`)。
|
||||||
|
|
||||||
|
## 容易踩的协议细节
|
||||||
|
|
||||||
|
- **`Network.requestWillBeSent` 常常不带 `Cookie` 头**。真实 Cookie 在 `Network.requestWillBeSentExtraInfo` 事件里。只看前者会误判成「cookie 没发出去」。`net` 命令已正确处理这个映射。
|
||||||
|
- `Runtime.evaluate` 要传 `userGesture: true`,否则部分 API 认为没有用户手势。
|
||||||
|
- 真实鼠标事件要发三个:`mouseMoved` → `mousePressed` → `mouseReleased`。`buttons` 只在 pressed 时置 1。
|
||||||
|
- 键盘:有可见字符的键用 `keyDown` + `text`;功能键用 `rawKeyDown`。收尾都要发 `keyUp`。
|
||||||
|
- `rateLimit`/`--enable-automation` 不要加,会触发「正在被自动化软件控制」提示条。
|
||||||
|
- 文件上传只能走 `DOM.setFileInputFiles`(支持 `nodeId` 或 `objectId`),这也是**页面 JS 做不到**的操作之一。用 `objectId` 时同一套代码能覆盖主文档与同源 iframe。
|
||||||
|
- 同源 iframe:主文档的 `querySelector`/XPath 不穿透 frame,必须拿 `iframe.contentDocument` 当根。坐标要先取 iframe 的 `getBoundingClientRect()`,再加 `clientLeft/clientTop`(边框宽度)才等于内容区原点;否则点击会偏。
|
||||||
|
- `Page.captureScreenshot` 加 `captureBeyondViewport: true` 才是整页截图 —— 但大画布上它会卡死(见「新命令实现要点」里的 `shot`)。
|
||||||
|
|
||||||
|
- **`text=` 的可见性优先级**:同一个文本常有多份副本(折叠菜单、移动端导航、隐藏弹窗),取「第一个最内层」会选中看不见的那个,`click`/`wait` 静默失败。现在的规则:最内层候选中**优先取可见的**(有尺寸且 visibility/display 正常);若全部不可见,退回改动前行为(返回第一个最内层,由后续可见性检查决定成败)。夹具 `tests/fixtures/text-visibility.html` + `tests/selector.test.mjs` 守着这条。
|
||||||
|
- **`--timeout` 的单位是秒**(默认 15)。历史上代码按毫秒用,传 20 会变成 20ms(静默秒退);现在超过 600 秒直接报错并提示除以 1000。派生技能直接传秒即可(`dmp-submit`、`site-session-keeper` 已统一)。
|
||||||
|
- **`launch` 的端口竞态**:`stop` 之后端口可能还没释放,新 Chrome 会「活着但 CDP 不通」,干等超时还留下孤儿进程。现在:超时→杀掉本次启的进程→确认端口释放→重试一次;若是别人占着端口则报错并给出 `lsof` 命令。
|
||||||
|
- **`--target` 精确定位**:编排多个标签页时用 targetId(`status`/`targets`/`open` 都能拿到),比 `--match` 的 URL 片段稳定:重定向不失配、同域名多页不歧义。
|
||||||
|
- **`eval` 的 stdout 是「二次编码的 JSON」**:入口统一 `console.log(JSON.stringify(value))`,所以表达式返回字符串时(典型:自己写了 `JSON.stringify(obj)`,想拿到 JSON 文本)命令行拿到的是「JSON 里再套一层 JSON 字符串」,直接 `JSON.parse` 只会得到字符串。两种正确用法:
|
||||||
|
- 表达式直接返回值对象(不要自己 `JSON.stringify`);或
|
||||||
|
- 调用方解两层:`let v = JSON.parse(raw); if (typeof v === 'string') v = JSON.parse(v);`(`skills/chatgpt-connectors` 的 `evalJson` 就是后者,并带导航竞态重试)。
|
||||||
|
与此相关:页面正在导航时 `Runtime.evaluate` 可能返回 `undefined` 或报上下文销毁,取页面状态要允许重试,别把一次空结果当成「未登录/列表为空」。
|
||||||
|
|
||||||
|
## 排查表
|
||||||
|
|
||||||
|
| 现象 | 原因 / 处理 |
|
||||||
|
|---|---|
|
||||||
|
| `连不上 CDP 127.0.0.1:9222` | 没启动。`launch`。先 `status` 看端口 |
|
||||||
|
| 多个页面无法自动选择 | 用 `--match`;`targets` 或 `status` 列出现有页面 |
|
||||||
|
| `等待超时:找不到可见可点击的元素` | 元素还没渲染 / 选择器不对 / 在跨域 iframe 内。用 `text`、`html`、`find` 现场看;同源 iframe 加 `--frame` |
|
||||||
|
| `无法访问 --frame ... 内部:跨域 iframe` | 该 iframe 与宿主不同源(含 file:// 页面),只能操作其宿主元素 |
|
||||||
|
| 填了输入框但提交是空的 | 用 `eval` 直接赋值了。必须用 `type` |
|
||||||
|
| 点击没反应 | 元素被遮挡或需要先滚动。`find` 看几何;先 `click` 触发展开再点目标 |
|
||||||
|
| `--match` 命中多个 | 片段写长一点,或改用 URL 全路径;能用 id 就改用 `--target <targetId>` |
|
||||||
|
| 启动后 30s 未就绪 | 看 `<profile>/chrome-launch.log`。若日志里没有端口报错、进程却还活着,多半是刚 `stop` 过、端口还没释放(新 Chrome 绑不上调试端口)。现在会自动清理并重试一次;等待上限用 `CDPCTL_LAUNCH_TIMEOUT_MS` 调 |
|
||||||
|
| 报「CDP 端口被别的进程占着」 | 跑 `lsof -nP -iTCP:<端口> -sTCP:LISTEN` 看占用者,或换个端口(`CDPCTL_PORT`) |
|
||||||
|
| 找不到 Chrome | `CDPCTL_CHROME=/绝对/路径` |
|
||||||
|
| 登录态重启就丢 | 跑 `persist-cookies --domain <域名>` |
|
||||||
|
| `@N` 找不到 / 报「引用可能已失效」 | 页面重渲染过。重跑 `snapshot` 拿新引用 |
|
||||||
|
| 页面所有命令都无响应(`Page.enable` 超时) | 多半是残留对话框或页面死循环。`stop && launch` 重启(残留对话框无法从新连接抢救) |
|
||||||
|
| 快照输出太长或元素太多 | `snapshot --max 100`(0=不限);先看 `--max 50`,需要正文再 `--all` |
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- 调试端口只绑 `127.0.0.1`,但**本机任何进程都能连上它并读取该 profile 的全部数据**(cookie 值、localStorage、页面内容)。不用时 `stop` 掉。
|
||||||
|
- 该 profile 会累积登录态凭证,等价于一份浏览器身份。要彻底清理:`stop` 后删除 profile 目录(macOS 默认 `~/Library/Application Support/Chrome-CDP-Automation`)。**这个过程不可逆,删了就要重新登录。**
|
||||||
|
- profile 永不放进共享仓库(有测试 `默认 profile 路径…不在共享仓库内` 守着这条)。
|
||||||
|
- `cookies` 只打印长度不打印值;`net` 只打印 cookie 名。避免凭证进日志/上下文。
|
||||||
|
- `eval` 是在目标页面上下文里执行任意 JS,等同于该页面的权限。跨域、HttpOnly、同源策略照旧生效,绕不过。
|
||||||
|
|
||||||
|
## 验证状态
|
||||||
|
|
||||||
|
已实测(在技能目录下 `node --test "tests/**/*.test.mjs"`):
|
||||||
|
|
||||||
|
> 坑:`node --test tests/`(目录形式)在 Node 21+ 会直接失败——不再扫描目录,而是把目录当模块 `import`。
|
||||||
|
> 必须用显式 glob 或 `node --test` 无参数形式。`tests/entrypoints.test.mjs` 里有测试守着这条。
|
||||||
|
|
||||||
|
- 入口契约:`help` 不需要 Chrome、不创建任何目录、退出码 0;每个已注册命令都有 `help`;usage 正文与注册表不漂移;未知命令/缺参数退出码 1 且不抛裸 JS 异常;未启动时报错含 `cdpctl launch`
|
||||||
|
- 路径策略:`CDPCTL_PROFILE` / `CDPCTL_CHROME` 覆盖生效;默认 profile 落在用户数据目录且不在共享仓库内;找不到 Chrome 时列出已尝试路径
|
||||||
|
- 端到端(真实 Chrome + 临时 profile + headless):`launch` 幂等 → `wait` → `type` 触发受控组件 state 更新 → `click` 触发监听器 → contenteditable 输入 → `eval`/`find` → `press` → `shot` 落盘 → `cookies` → `persist-cookies` → `stop`
|
||||||
|
- 端到端(`tests/inspect.test.mjs`):`snapshot`/`@引用`(含 shadow DOM)→ dialog accept/dismiss/prompt → `fill` 五字段 + 失败逐条报告 → `console` 回放/过滤/`--reload` → `drag`(JS + 原生 HTML5)→ 元素截图尺寸 → `nav --back/--forward/--reload` → 失效引用的报错提示
|
||||||
|
- 测试辨别力:直接 `el.value=` 赋值时夹具镜像**不更新**,`cdpctl type` 时更新 → 证明 `type` 的受控组件路径是被真实验证的,不是空跑
|
||||||
|
|
||||||
|
未验证(不要当成已支持):
|
||||||
|
|
||||||
|
- Windows / Linux 的浏览器探测与启动分支(代码已写,本地无法验证)
|
||||||
|
- headless 模式下的登录类流程(部分站点对 headless 有反自动化策略,登录建议用可见模式)
|
||||||
|
- 非 Chromium 内核浏览器
|
||||||
116
skills/cdp-page-control/SKILL.md
Normal file
116
skills/cdp-page-control/SKILL.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
name: cdp-page-control
|
||||||
|
description: 用 Chrome DevTools Protocol 驱动一个受控浏览器操作任意网站页面:页面快照取 @N 元素引用、导航/回退/刷新、等元素、填表、点击、拖拽、按键、读文本、截图(整页或元素)、读控制台报错、抓网络请求、跨重启保持登录态。用户要求在某个网站上填表/提交/点击/翻页/拖拽/抓数据/截图/排查前端报错,或需要复用已登录的浏览器会话做页面自动化时使用。
|
||||||
|
platforms: all
|
||||||
|
compatibility: 需要 node >=22(用内置 WebSocket,零第三方依赖)。浏览器自动探测 Chrome/Chromium/Edge,可用 CDPCTL_CHROME 覆盖。macOS 已完整验证;Windows/Linux 的浏览器探测与启动分支已实现但未验证。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 浏览器页面控制(CDP 原语)
|
||||||
|
|
||||||
|
失败 → 动作:`error=usage` 读 `pi-skill cdp-page-control help`;`error=deps` 补依赖;`error=auth` 跑本技能的登录/检查命令;
|
||||||
|
`error=notfound` 先 `status`/`targets` 重新定位;`error=conflict` 先 `status`;其它非零按输出里的 `hint=` 执行。
|
||||||
|
|
||||||
|
入口(路径相对本技能目录):`node scripts/cdpctl.mjs <命令> [参数]`
|
||||||
|
|
||||||
|
**不要重新发明轮子**:不要临时写 Puppeteer/Playwright/selenium 脚本,不要用 AppleScript 发 JS,不要手动拼 CDP 协议。先 `cdpctl help <命令>`,再执行。
|
||||||
|
|
||||||
|
## 最小工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi-skill cdp-page-control launch --url "<URL>" # 1. 确保受控浏览器在跑(幂等,可反复调)
|
||||||
|
pi-skill cdp-page-control snapshot # 2. 不认识的页面先快照,直接拿到 @N 引用
|
||||||
|
pi-skill cdp-page-control wait "<选择器>" # 3. 等页面元素出现(别用 sleep)
|
||||||
|
pi-skill cdp-page-control click "<选择器>" # 4. 交互(snapshot 给的 @N 可以直接当选择器)
|
||||||
|
pi-skill cdp-page-control type "<选择器>" "文本" # (受控组件安全输入)
|
||||||
|
pi-skill cdp-page-control text "<选择器>" # 5. 读取结果验证
|
||||||
|
```
|
||||||
|
|
||||||
|
出任何问题,**第一件事跑 `status`**(端口 / profile / 当前页面列表全在里面)。
|
||||||
|
|
||||||
|
## 选择器(所有命令通用)
|
||||||
|
|
||||||
|
| 写法 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `#id` `.class` `[data-x="y"]` | 标准 CSS |
|
||||||
|
| `text=提交` | 文本内容完全相等(元素没有稳定 class 时最好用;多个候选时取最内层里可见的那个) |
|
||||||
|
| `xpath=//div[3]/button` | XPath |
|
||||||
|
| `@3` | `snapshot` 输出的引用编号(穿透 open shadow DOM;页面重渲染后失效,重跑 snapshot) |
|
||||||
|
|
||||||
|
同源 iframe 内部:加 `--frame "<iframe的CSS选择器>"`(所有命令通用),坐标自动换算回宿主页面。跨域 iframe 进不去。
|
||||||
|
|
||||||
|
多标签页时用 `--match "<URL或标题片段>"` 指定目标;只有一个页面时会自动选中。
|
||||||
|
**要精确复用某个标签页用 `--target <targetId>`**(id 从 `status`/`targets`/`open` 拿):
|
||||||
|
重定向会让 `--match` 失配,同域名的其他标签页会让它歧义,编排类脚本应当用 `--target`。
|
||||||
|
|
||||||
|
## 五条稳定性铁律
|
||||||
|
|
||||||
|
0. **不认识的页面先 `snapshot`。** 一次拿到全部可交互/结构元素与 `@N` 引用,直接 `click "@3"`,省掉逐个猜选择器的多轮试探。网页对话框(alert/confirm/prompt/beforeunload)会被自动应答(默认接受),不会卡住后续命令。
|
||||||
|
1. **等元素,不要 sleep。** `wait`、`click`、`type` 内部都会轮询等待可见可点击(默认 15 秒,`--timeout 秒` 改;写毫秒值会被拒绝)。固定 `sleep` 是自动化最大的不稳定来源。
|
||||||
|
2. **填普通输入框只用 `type`,绝不用 `eval` 直接赋值。** React/Vue 受控组件下 `el.value = x` 不会更新框架状态,提交上去还是空值。`type` 走原生 value setter + `input`/`change` 事件,是唯一可靠路径。这条已用夹具页面反证验证过。
|
||||||
|
例外:**自绘编辑器**(Monaco / ProseMirror / CodeMirror / 各类 chat 输入框)不是 input/textarea,`type` 改不动它们;改用 `click "<编辑区>"` 聚焦 → `insert-text "<文本>"`(浏览器原生编辑管线,会发真 beforeinput/input)。清空这类编辑器要用真实按键:`press ctrl+a` 然后 `press backspace`(`execCommand('delete')` 会被它们忽略)。
|
||||||
|
3. **点击用 `click`(真实鼠标事件)**,比 `el.click()` 更接近人操作。`text=` 定位按钮文字通常最稳。
|
||||||
|
4. **操作完立刻验证。** 用 `text` / `eval` 读回状态确认生效,不要假设成功。
|
||||||
|
|
||||||
|
## 配方表
|
||||||
|
|
||||||
|
| 需求 | 命令 |
|
||||||
|
|---|---|
|
||||||
|
| 打开网站 | `launch --url "<URL>"` |
|
||||||
|
| 跳转已有页面 | `nav "<URL>"` |
|
||||||
|
| 刷新 / 回退 / 前进 | `nav --reload` / `nav --back` / `nav --forward` |
|
||||||
|
| 搜索框输入并回车 | `type "<选择器>" "关键词"` 然后 `press enter` |
|
||||||
|
| 逐字段填表 | 对每个字段一次 `type`;选择器优先用 `input[placeholder="..."]` 或 `input[name=...]` |
|
||||||
|
| 往 Monaco/聊天输入框这类自绘编辑器输入 | `click "<编辑区>"` 聚焦 → `insert-text "<文本>"`(长文本用 `insert-text --file 文本.txt`),然后 `click` 发送按钮或 `press enter`;清空用 `press ctrl+a` + `press backspace` |
|
||||||
|
| 点按钮 | `click "text=提交"` |
|
||||||
|
| 勾选/切换 | `click "<选择器>"` |
|
||||||
|
| 看不懂页面 / 找不到元素 | `snapshot`(`--all` 连正文文本一起看;`--max` 限条数) |
|
||||||
|
| 一次填完整张表 | `fill --file fields.json`(`{"选择器": 值}`:字符串输入/选下拉,布尔值填勾选框) |
|
||||||
|
| 页面点不动 / 报错排查 | `console`(含历史回放与未捕获异常);抓加载期报错用 `console --reload` |
|
||||||
|
| 拖拽(JS 与原生 HTML5 拖放) | `drag "<来源选择器>" "<目标选择器>"` |
|
||||||
|
| 抓列表/表格数据 | `eval` 返回 JSON,如 `[...document.querySelectorAll(".row")].map(e=>e.innerText)` |
|
||||||
|
| 滚动加载更多 | `eval "window.scrollTo(0,document.body.scrollHeight)"` 然后 `wait` 新元素 |
|
||||||
|
| 截图存证 | `shot /tmp/x.png`(带上选择器只截那个元素;视口内区域用 `--clip x,y,w,h`);**大页面/整页**用 `pdf /tmp/x.pdf --size WxH --media screen` + `qlmanage -t -s 2*max(W,H)`(`--full` 在大画布上会卡死,见 REFERENCE) |
|
||||||
|
| 上传文件 | `upload "input[type=file]" /绝对/路径` |
|
||||||
|
| 展开被折叠的内容 | `click` 对应按钮/tab,再 `wait` 目标元素 |
|
||||||
|
| **让登录态活过浏览器重启** | `persist-cookies --domain <域名片段>` |
|
||||||
|
| **复用日常 Chrome 已有登录态** | `import-cookies --profile Default`(默认预览;`--yes` 执行;全量复制) |
|
||||||
|
| 查站点到底靠什么维持登录 | `net --grep "/api"`(打印每个请求携带的 cookie 名,不打印值) |
|
||||||
|
| 查元素到底存不存在/在哪 | `find "<选择器>"`(返回几何与可见性) |
|
||||||
|
| 复杂 JS | 写成文件再 `eval --file probe.js`,避免 shell 引号地狱 |
|
||||||
|
| 操作同源 iframe 内的表单 | 任意命令加 `--frame "#inner"`(如 `click "text=提交" --frame "#inner"`) |
|
||||||
|
|
||||||
|
组合多个动作时用 `&&` 串成一条 bash 调用,减少往返。
|
||||||
|
|
||||||
|
## 登录态与身份
|
||||||
|
|
||||||
|
- 受控浏览器用**独立 profile**(默认 `~/Library/Application Support/Chrome-CDP-Automation`),**不会碰你日常用的 Chrome**。profile 持久保留,登录一次可长期复用。
|
||||||
|
- `launch` 幂等:已经在跑就直接复用,所以在任何流程里都可以放心先调一次。
|
||||||
|
- **会话 cookie 默认活不过浏览器重启**(Chrome 会删)。想免二次登录就执行 `persist-cookies --domain <域名>`:它只把 cookie 的过期时间元数据改到 2030 年,密文一字不改。注意它会先关掉浏览器。
|
||||||
|
- 站点把凭证放 localStorage(很常见,例如 `userToken`)时,localStorage 本身跨重启持久,无需额外处理。
|
||||||
|
- **不能保证**登录态永久有效:服务端会话过期由站点决定,浏览器侧只能保证不丢凭证。
|
||||||
|
|
||||||
|
## 能力边界(做不到的事,别浪费轮次)
|
||||||
|
|
||||||
|
- 跨域 iframe 内部:受同源策略限制,进不去(同源 iframe 用 `--frame` 可以)。
|
||||||
|
- closed shadow DOM 内部:能拿到宿主,拿不到内部节点。
|
||||||
|
- 真人验证(验证码/风控挑战)、passkey、支付确认:必须人来做。
|
||||||
|
- HttpOnly cookie 的**值**:`cookies` 只报长度;页面 JS 也读不到。
|
||||||
|
- 服务端逻辑、签名、token 校验:前端绕不过。
|
||||||
|
- 通过 `eval` 直接调用的合成事件 `isTrusted=false`;需要可信事件时用 `click`/`type`/`press`。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
先 `status`,再看 `help <命令>`。故障排查、持久化原理、安全说明、扩展方式见 **REFERENCE.md**。
|
||||||
|
|
||||||
|
环境变量:`CDPCTL_PORT`(默认 9222)、`CDPCTL_PROFILE`、`CDPCTL_CHROME`、`CDPCTL_HEADLESS=1`、
|
||||||
|
`CDPCTL_DIALOG`(对话框策略 accept/dismiss/ignore,默认 accept)、`CDPCTL_DIALOG_TEXT`(prompt 应答文本)。
|
||||||
|
|
||||||
|
改动本技能后必须跑测试:`node --test "tests/**/*.test.mjs"`(入口契约 + 真实端到端冒烟)。
|
||||||
|
注意别写成 `node --test tests/`:Node 21+ 不再把目录参数当目录扫描,而是当模块 `import`,会直接报 `Cannot find module .../tests`。
|
||||||
|
|
||||||
|
**外部真实站点测试**(`tests/realworld.test.mjs`,默认跳过)会跑 saucedemo 的完整下单流程;
|
||||||
|
那个公共演示站连续跑会被限流,所以只有显式开启时才跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CDPCTL_TEST_REALWORLD=1 node --test tests/realworld.test.mjs
|
||||||
|
```
|
||||||
1
skills/cdp-page-control/VERSION
Normal file
1
skills/cdp-page-control/VERSION
Normal file
@ -0,0 +1 @@
|
|||||||
|
0.1.0
|
||||||
107
skills/cdp-page-control/contract.lock.json
Normal file
107
skills/cdp-page-control/contract.lock.json
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skill": "cdp-page-control",
|
||||||
|
"tier": 1,
|
||||||
|
"platforms": [
|
||||||
|
"all"
|
||||||
|
],
|
||||||
|
"commands": {
|
||||||
|
"click": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"close": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"cookies": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"drag": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"eval": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"fill": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"find": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"hover": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"import-cookies": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"insert-text": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"launch": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"net": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"open": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"persist-cookies": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"press": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"shot": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"stop": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"destructive": false
|
||||||
|
},
|
||||||
|
"wait": {
|
||||||
|
"destructive": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": [
|
||||||
|
"auth",
|
||||||
|
"blocked",
|
||||||
|
"external",
|
||||||
|
"internal",
|
||||||
|
"notfound",
|
||||||
|
"timeout",
|
||||||
|
"usage"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"cdpctl"
|
||||||
|
],
|
||||||
|
"adminAliases": []
|
||||||
|
}
|
||||||
146
skills/cdp-page-control/interface.json
Normal file
146
skills/cdp-page-control/interface.json
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
{
|
||||||
|
"summary": "驱动受控浏览器操作任意网站页面(导航、填表、点击、读文本、截图、抓网络)",
|
||||||
|
"useWhen": "要在某个网站的页面上点击/填表/翻页/取文本/截图/抓接口,或用已登录的浏览器会话做页面自动化时",
|
||||||
|
"tier": 1,
|
||||||
|
"entry": {
|
||||||
|
"path": "scripts/cdpctl.mjs"
|
||||||
|
},
|
||||||
|
"aliases": [
|
||||||
|
"cdpctl"
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "launch",
|
||||||
|
"summary": "启动/复用受控浏览器"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nav",
|
||||||
|
"summary": "打开 URL / 前进 / 后退 / 刷新"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "snapshot",
|
||||||
|
"summary": "页面结构快照:可见交互元素 + @N 引用"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wait",
|
||||||
|
"summary": "等元素/条件出现"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "find",
|
||||||
|
"summary": "找元素,返回定位器"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "click",
|
||||||
|
"summary": "点击元素"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"summary": "输入文本"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fill",
|
||||||
|
"summary": "按 JSON 批量填表(输入/下拉/勾选)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "press",
|
||||||
|
"summary": "按键/组合键"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "drag",
|
||||||
|
"summary": "真实鼠标拖拽(含原生 HTML5 拖放)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "select",
|
||||||
|
"summary": "下拉选择"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hover",
|
||||||
|
"summary": "悬停元素"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "upload",
|
||||||
|
"summary": "上传文件"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "insert-text",
|
||||||
|
"summary": "向焦点元素插入文本(自绘编辑器)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "text",
|
||||||
|
"summary": "读元素文本"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "html",
|
||||||
|
"summary": "读元素 HTML"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "eval",
|
||||||
|
"summary": "在页面里执行 JS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "net",
|
||||||
|
"summary": "抓网络请求"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "console",
|
||||||
|
"summary": "读控制台消息(含历史回放与异常)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shot",
|
||||||
|
"summary": "截图(整页或单个元素)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pdf",
|
||||||
|
"summary": "整页导出 PDF(打印管线,一次调用拿到全部内容,不受视口限制):pdf <out.pdf> --size WxH [--media screen|print];要 PNG 再用 qlmanage -t -s <2*max(W,H)> 矢量重栅格"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cookies",
|
||||||
|
"summary": "读写 cookie"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "import-cookies",
|
||||||
|
"summary": "把日常 Chrome 的登录态复制进受控浏览器(默认预览,--yes 执行)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "persist-cookies",
|
||||||
|
"summary": "把会话 cookie 改成持久 cookie(跨重启保登录)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "targets",
|
||||||
|
"summary": "列出标签页"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "open",
|
||||||
|
"summary": "新开标签页"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "close",
|
||||||
|
"summary": "关闭标签页"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"summary": "浏览器状态"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stop",
|
||||||
|
"summary": "关闭受控浏览器"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": [
|
||||||
|
"usage",
|
||||||
|
"auth",
|
||||||
|
"notfound",
|
||||||
|
"blocked",
|
||||||
|
"timeout",
|
||||||
|
"external",
|
||||||
|
"internal"
|
||||||
|
],
|
||||||
|
"helpListsCommands": true,
|
||||||
|
"publish": {
|
||||||
|
"package_repo": "https://gitea.vhkd.top/geekinney/cdp-page-control.git"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"layer": "core",
|
||||||
|
"why": "别的技能建在它上面"
|
||||||
|
}
|
||||||
|
}
|
||||||
815
skills/cdp-page-control/scripts/cdp-channel.mjs
Normal file
815
skills/cdp-page-control/scripts/cdp-channel.mjs
Normal file
@ -0,0 +1,815 @@
|
|||||||
|
// 通道层:CDP 传输 + 浏览器生命周期 + 选择器解析。
|
||||||
|
// 只做「把参数变成协议调用」,不含任何站点业务逻辑。
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { closeSync, existsSync, mkdirSync, openSync, readSync, statSync } from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const PROFILE_NAME = 'Chrome-CDP-Automation';
|
||||||
|
|
||||||
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** 页面无响应时统一的提示:对话框阻塞是最常见原因,且残留后无法从新连接救援。 */
|
||||||
|
export const DIALOG_HINT = '页面可能被 JavaScript 对话框(alert/confirm/prompt/beforeunload)或死循环阻塞;残留的对话框无法从新连接处理,需 cdpctl stop && cdpctl launch 重启浏览器。';
|
||||||
|
|
||||||
|
/** 固定端口,可用 CDPCTL_PORT 覆盖。 */
|
||||||
|
export function port() {
|
||||||
|
return Number(process.env.CDPCTL_PORT || 9222);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机自动化 profile 目录(持久,含登录态)。绝不放共享仓库内。 */
|
||||||
|
export function profileDir() {
|
||||||
|
if (process.env.CDPCTL_PROFILE) return process.env.CDPCTL_PROFILE;
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', PROFILE_NAME);
|
||||||
|
}
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const base = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||||
|
return path.join(base, PROFILE_NAME);
|
||||||
|
}
|
||||||
|
const base = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
||||||
|
return path.join(base, PROFILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function which(bin) {
|
||||||
|
const exts = process.platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
|
||||||
|
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
||||||
|
if (!dir) continue;
|
||||||
|
for (const ext of exts) {
|
||||||
|
const p = path.join(dir, bin + ext);
|
||||||
|
if (existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认的 Chrome 候选路径(按平台)。可单独调用来排查探测问题。 */
|
||||||
|
export function chromeCandidates() {
|
||||||
|
const tried = [];
|
||||||
|
const add = (...p) => tried.push(...p.filter(Boolean));
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
add('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||||
|
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||||
|
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser');
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
for (const root of [process.env['ProgramFiles'], process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA]) {
|
||||||
|
add(root && path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
root && path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const b of ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge']) {
|
||||||
|
add(which(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tried;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome/Chromium/Edge 可执行文件。
|
||||||
|
* CDPCTL_CHROME 优先;否则依次检查 chromeCandidates()。
|
||||||
|
* candidates 参数是可注入接缝,便于独立测试「找不到」的错误分支。
|
||||||
|
*/
|
||||||
|
export function chromePath(candidates = chromeCandidates()) {
|
||||||
|
if (process.env.CDPCTL_CHROME) return process.env.CDPCTL_CHROME;
|
||||||
|
for (const p of candidates) if (existsSync(p)) return p;
|
||||||
|
throw new Error(
|
||||||
|
'找不到 Chrome/Chromium 可执行文件。\n 用 CDPCTL_CHROME=<绝对路径> 指定。\n 已尝试:\n ' + candidates.join('\n ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpJson(route, method = 'GET') {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`http://127.0.0.1:${port()}${route}`, { method });
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`连不上 CDP 127.0.0.1:${port()}(${e.cause?.code || e.message})。\n` +
|
||||||
|
` 先启动: cdpctl launch --url <URL>\n` +
|
||||||
|
` 看状态: cdpctl status`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`CDP 返回 HTTP ${res.status}: ${route}`);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return null;
|
||||||
|
try { return JSON.parse(text); } catch { return text; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浏览器是否在跑;返回 version 对象或 null。 */
|
||||||
|
export async function version(timeoutMs = 1500) {
|
||||||
|
try {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port()}/json/version`, { signal: ctl.signal });
|
||||||
|
clearTimeout(t);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出所有调试目标;未启动时给出可操作的报错。 */
|
||||||
|
export async function listTargets() {
|
||||||
|
return (await httpJson('/json/list')) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选页面目标。
|
||||||
|
* 无 match 时:优先排除 about:blank,唯一命中才自动采用,否则报错并列出候选。
|
||||||
|
*/
|
||||||
|
export function pickPage(targets, match, target) {
|
||||||
|
const pages = targets.filter(t => t.type === 'page');
|
||||||
|
if (!pages.length) throw new Error('没有 page 目标。先运行: cdpctl launch --url <URL>');
|
||||||
|
|
||||||
|
// 按 targetId 精确定位:编排多个标签页时比 --match 可靠
|
||||||
|
// (--match 命中 URL/标题片段,重定向后会失配,同名页面会歧义)。
|
||||||
|
if (target) {
|
||||||
|
const hit = pages.find(t => t.id === target);
|
||||||
|
if (!hit) {
|
||||||
|
throw new Error(`--target "${target}" 不存在(页面可能已关闭或被导航走)。当前页面:\n` +
|
||||||
|
pages.map(t => ` id=${t.id} ${t.title || '(无标题)'} ${t.url}`).join('\n') +
|
||||||
|
'\n用 cdpctl status 或 targets 取最新的 id。');
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const hit = pages.filter(t => (t.url || '').includes(match) || (t.title || '').includes(match));
|
||||||
|
if (!hit.length) {
|
||||||
|
throw new Error(`--match "${match}" 无命中。当前页面:\n` + pages.map(t => ` ${t.title} ${t.url}`).join('\n'));
|
||||||
|
}
|
||||||
|
if (hit.length > 1) {
|
||||||
|
throw new Error(`--match "${match}" 命中 ${hit.length} 个,请写得更精确:\n` + hit.map(t => ` ${t.url}`).join('\n'));
|
||||||
|
}
|
||||||
|
return hit[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const real = pages.filter(t => !/^about:blank$/.test(t.url || ''));
|
||||||
|
const pool = real.length ? real : pages;
|
||||||
|
if (pool.length === 1) return pool[0];
|
||||||
|
throw new Error(
|
||||||
|
`有 ${pages.length} 个页面,无法自动选择。用 --match <URL或标题片段> 指定:\n` +
|
||||||
|
pages.map(t => ` ${t.title || '(无标题)'} ${t.url}`).join('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最小 CDP 客户端(零依赖,用 Node 内置 WebSocket)。 */
|
||||||
|
export class CDP {
|
||||||
|
constructor(wsUrl) {
|
||||||
|
this.wsUrl = wsUrl;
|
||||||
|
this.seq = 0;
|
||||||
|
this.pending = new Map();
|
||||||
|
this.listeners = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
static connect(wsUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const c = new CDP(wsUrl);
|
||||||
|
c.ws = new WebSocket(wsUrl);
|
||||||
|
const to = setTimeout(() => reject(new Error('WebSocket 连接超时: ' + wsUrl)), 10000);
|
||||||
|
c.ws.onopen = () => { clearTimeout(to); resolve(c); };
|
||||||
|
c.ws.onerror = () => { clearTimeout(to); reject(new Error('WebSocket 连接失败: ' + wsUrl)); };
|
||||||
|
c.ws.onmessage = ev => {
|
||||||
|
const m = JSON.parse(ev.data);
|
||||||
|
if (m.id && c.pending.has(m.id)) {
|
||||||
|
const { resolve: res, reject: rej } = c.pending.get(m.id);
|
||||||
|
c.pending.delete(m.id);
|
||||||
|
m.error ? rej(new Error(`${m.error.message} ${JSON.stringify(m.error.data ?? '')}`))
|
||||||
|
: res(m.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.method) for (const fn of c.listeners) fn(m);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
on(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发一条 CDP 命令。timeoutMs>0 时超时会拒绝并带上 timeoutHint——
|
||||||
|
* 页面被对话框/死循环阻塞时 CDP 调用会永久挂起,超时兜底把「挂死」变成可操作的报错。
|
||||||
|
*/
|
||||||
|
send(method, params = {}, { timeoutMs = 0, timeoutHint = '' } = {}) {
|
||||||
|
const id = ++this.seq;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer = null;
|
||||||
|
if (timeoutMs > 0) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (!this.pending.has(id)) return;
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP ${method} ${timeoutMs}ms 无响应${timeoutHint ? '。' + timeoutHint : ''}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve: v => { if (timer) clearTimeout(timer); resolve(v); },
|
||||||
|
reject: e => { if (timer) clearTimeout(timer); reject(e); },
|
||||||
|
});
|
||||||
|
this.ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() { try { this.ws.close(); } catch { /* 已关闭 */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连到浏览器级 endpoint(用于 Browser.close / 新建目标)。 */
|
||||||
|
export async function connectBrowser() {
|
||||||
|
const v = await version();
|
||||||
|
if (!v?.webSocketDebuggerUrl) throw new Error(`CDP 未就绪于 127.0.0.1:${port()}。先运行: cdpctl launch`);
|
||||||
|
return CDP.connect(v.webSocketDebuggerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话框自动处理。实测结论(Chrome 153):
|
||||||
|
* - 对话框打开会阻塞后续 CDP 调用:Page.enable / Runtime.evaluate / Input.* 全部挂起,
|
||||||
|
* 连触发弹窗那次 mouseReleased 的响应都要等对话框被处理后才返回;
|
||||||
|
* - 对话框残留后,新连接既收不到事件也 handle 不掉(Page.enable 先挂死)——
|
||||||
|
* 所以必须在动作之前装好处理器,绝不能让它残留。
|
||||||
|
* mode: accept(默认,自动化合理默认)| dismiss | ignore(不装处理器,保留原生行为)。
|
||||||
|
* prompt 的自动应答文本用 CDPCTL_DIALOG_TEXT 指定,缺省用页面给的 defaultPrompt。
|
||||||
|
*/
|
||||||
|
export function installDialogHandler(cdp, mode = 'accept') {
|
||||||
|
if (!['accept', 'dismiss', 'ignore'].includes(mode)) {
|
||||||
|
throw new Error(`CDPCTL_DIALOG 只支持 accept / dismiss / ignore,收到 "${mode}"`);
|
||||||
|
}
|
||||||
|
if (mode === 'ignore') return { enable: Promise.resolve() };
|
||||||
|
const enable = cdp.send('Page.enable', {}, { timeoutMs: 5000, timeoutHint: DIALOG_HINT });
|
||||||
|
enable.catch(() => {}); // 让 connectPage 的警告提示接管错误
|
||||||
|
cdp.on(m => {
|
||||||
|
if (m.method !== 'Page.javascriptDialogOpening') return;
|
||||||
|
const p = { accept: mode === 'accept' };
|
||||||
|
if (p.accept && m.params.type === 'prompt') {
|
||||||
|
p.promptText = process.env.CDPCTL_DIALOG_TEXT ?? m.params.defaultPrompt ?? '';
|
||||||
|
}
|
||||||
|
cdp.send('Page.handleJavaScriptDialog', p).catch(() => {});
|
||||||
|
});
|
||||||
|
return { enable };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连到某个页面;默认自动装对话框处理器。 */
|
||||||
|
export async function connectPage(match, target, { dialogs = process.env.CDPCTL_DIALOG || 'accept' } = {}) {
|
||||||
|
const t = pickPage(await listTargets(), match, target);
|
||||||
|
const cdp = await CDP.connect(t.webSocketDebuggerUrl);
|
||||||
|
const h = installDialogHandler(cdp, dialogs);
|
||||||
|
await h.enable.catch(e => {
|
||||||
|
console.error('警告: 对话框保护未就绪 — ' + e.message);
|
||||||
|
});
|
||||||
|
return { cdp, target: t };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在页面执行 JS。timeoutMs>0 时超时拒绝(用于页面被阻塞时快速报错)。 */
|
||||||
|
export async function evaluate(cdp, expression, { awaitPromise = true, userGesture = true, timeoutMs = 0 } = {}) {
|
||||||
|
const r = await cdp.send('Runtime.evaluate', {
|
||||||
|
expression, awaitPromise, returnByValue: true, userGesture, includeCommandLineAPI: true,
|
||||||
|
}, timeoutMs ? { timeoutMs, timeoutHint: DIALOG_HINT } : {});
|
||||||
|
if (r.exceptionDetails) {
|
||||||
|
const d = r.exceptionDetails;
|
||||||
|
throw new Error('页面内异常: ' + (d.exception?.description || d.text));
|
||||||
|
}
|
||||||
|
return r.result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等待页面 load 事件。 */
|
||||||
|
export function waitLoad(cdp, timeout = 30000) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let off = () => {};
|
||||||
|
const t = setTimeout(() => { off(); resolve('timeout'); }, timeout);
|
||||||
|
off = cdp.on(m => {
|
||||||
|
if (m.method === 'Page.loadEventFired') { clearTimeout(t); off(); resolve('load'); }
|
||||||
|
});
|
||||||
|
cdp.send('Page.enable').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择器语法 -> 返回元素的 JS 表达式。支持三种:
|
||||||
|
* css querySelector 语法
|
||||||
|
* text=文本 文本内容完全相等(取最内层匹配,避免命中外层容器)
|
||||||
|
* xpath=表达式
|
||||||
|
* root 是解析起点:主文档用 'document',iframe 内用其 contentDocument 变量名。
|
||||||
|
* win 是该 realm 的 window:主文档 'window',iframe 内 'w'(跨 realm 调 getComputedStyle 会抛错)。
|
||||||
|
*/
|
||||||
|
export function selectorExpr(sel, root = 'document', win = 'window') {
|
||||||
|
if (sel.startsWith('@')) {
|
||||||
|
// 快照引用:snapshot 给元素打的 data-cdpctl-ref。
|
||||||
|
// 引用可能落在 open shadow root 里,querySelector 不穿透,所以用 JS 递归查找。
|
||||||
|
const n = sel.slice(1);
|
||||||
|
if (!/^\d+$/.test(n)) {
|
||||||
|
throw new Error(`快照引用格式是 @数字(如 @3),收到 "${sel}"。跑 cdpctl snapshot 获取当前页面的引用。`);
|
||||||
|
}
|
||||||
|
return `(() => {
|
||||||
|
const find = r => {
|
||||||
|
const hit = r.querySelector('[data-cdpctl-ref="${n}"]');
|
||||||
|
if (hit) return hit;
|
||||||
|
for (const e of r.querySelectorAll('*')) if (e.shadowRoot) { const h = find(e.shadowRoot); if (h) return h; }
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return find(${root});
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
if (sel.startsWith('text=')) {
|
||||||
|
return `(() => {
|
||||||
|
const want = ${JSON.stringify(sel.slice(5))};
|
||||||
|
const tt = s => (s || '').trim().replace(/\\s+/g, ' ');
|
||||||
|
const all = [...${root}.querySelectorAll('*')].filter(e => tt(e.textContent) === want);
|
||||||
|
if (!all.length) return null;
|
||||||
|
// 必须取最内层:外层容器(如包着按钮的 div)的 textContent 也相等,
|
||||||
|
// 选中它会让点击落在容器空白处,静默不生效。
|
||||||
|
const inner = all.filter(e => !all.some(o => o !== e && e.contains(o)));
|
||||||
|
// 真页面上常有隐藏的重复项(折叠菜单、移动端导航副本)排在前面(DeepSeek 实测);
|
||||||
|
// 只看「第一个最内层」会选中不可见元素,导致 wait/click 静默失败。优先取可见的。
|
||||||
|
const visible = e => {
|
||||||
|
const r = e.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return false;
|
||||||
|
const cs = ${win}.getComputedStyle(e);
|
||||||
|
return cs.visibility !== 'hidden' && cs.display !== 'none';
|
||||||
|
};
|
||||||
|
return inner.find(visible) || inner[0] || all[0];
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
if (sel.startsWith('xpath=')) {
|
||||||
|
return `${root}.evaluate(${JSON.stringify(sel.slice(6))}, ${root}, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
|
||||||
|
}
|
||||||
|
return `${root}.querySelector(${JSON.stringify(sel)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面结构快照:把可见的「交互 + 结构」元素打上 data-cdpctl-ref,返回缩进文本树。
|
||||||
|
* 所有命令的选择器都支持 @N(见 selectorExpr),一次调用看懂整页。
|
||||||
|
* all=true 额外纳入带可见文本的叶子节点;max 限制输出节点数(0=不限)。
|
||||||
|
* 引用会在页面重渲染后失效,届时重跑 snapshot 即可。
|
||||||
|
*/
|
||||||
|
export function snapshotExpr({ all = false, max = 250 } = {}) {
|
||||||
|
const limit = Number(max) > 0 ? Math.floor(Number(max)) : Number.MAX_SAFE_INTEGER;
|
||||||
|
return `(() => {
|
||||||
|
const REF = 'data-cdpctl-ref';
|
||||||
|
const MAX = ${limit};
|
||||||
|
const ALL = ${all ? 'true' : 'false'};
|
||||||
|
const txt = (s, n) => String(s == null ? '' : s).trim().replace(/\\s+/g, ' ').slice(0, n);
|
||||||
|
const TAG = new Set(['A','BUTTON','INPUT','SELECT','TEXTAREA','SUMMARY','IFRAME']);
|
||||||
|
const STRUCT = new Set(['MAIN','NAV','HEADER','FOOTER','ASIDE','FORM','TABLE','DETAILS']);
|
||||||
|
const ROLES = new Set(['button','link','textbox','searchbox','combobox','listbox','checkbox','radio','switch','slider','spinbutton','menuitem','menuitemcheckbox','menuitemradio','tab','option','treeitem','dialog','alertdialog','alert','status','progressbar']);
|
||||||
|
const isField = el => el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
|
||||||
|
const visible = el => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return false;
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
return cs.display !== 'none' && cs.visibility !== 'hidden';
|
||||||
|
};
|
||||||
|
const nameOf = el => {
|
||||||
|
const aria = el.getAttribute('aria-label');
|
||||||
|
if (aria) return txt(aria, 90);
|
||||||
|
const by = el.getAttribute('aria-labelledby');
|
||||||
|
if (by) {
|
||||||
|
const t = by.split(/\\s+/).map(id => { const x = document.getElementById(id); return x ? (x.innerText || x.textContent) : ''; }).join(' ');
|
||||||
|
if (txt(t, 90)) return txt(t, 90);
|
||||||
|
}
|
||||||
|
if (isField(el)) {
|
||||||
|
const l = el.labels && el.labels[0];
|
||||||
|
if (l) return txt(l.innerText || l.textContent, 90);
|
||||||
|
if (el.placeholder) return 'placeholder: ' + txt(el.placeholder, 70);
|
||||||
|
if (el.title) return txt(el.title, 90);
|
||||||
|
if (['submit','reset','button'].includes((el.type || '').toLowerCase())) return txt(el.value, 90);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (el.tagName === 'IMG') return txt(el.alt, 90);
|
||||||
|
if (STRUCT.has(el.tagName) || el.tagName === 'IFRAME') return txt(el.title, 90);
|
||||||
|
let direct = '';
|
||||||
|
for (const n of el.childNodes) if (n.nodeType === 3) direct += n.textContent;
|
||||||
|
return txt(direct.trim() ? direct : (el.innerText || el.textContent), 90);
|
||||||
|
};
|
||||||
|
const keep = el => {
|
||||||
|
const tag = el.tagName;
|
||||||
|
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') return false;
|
||||||
|
if (tag === 'INPUT' && (el.type || '').toLowerCase() === 'hidden') return false;
|
||||||
|
if (TAG.has(tag) || /^H[1-6]$/.test(tag) || STRUCT.has(tag)) return true;
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
if (tag === 'IMG') return !!el.alt;
|
||||||
|
if (ROLES.has((el.getAttribute('role') || '').toLowerCase())) return true;
|
||||||
|
if (ALL) for (const n of el.childNodes) if (n.nodeType === 3 && n.textContent.trim()) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const describe = el => {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const type = (el.type || '').toLowerCase();
|
||||||
|
const head = tag === 'a' ? 'link' : (tag === 'input' ? 'input[' + (type || 'text') + ']' : tag);
|
||||||
|
const nm = nameOf(el);
|
||||||
|
let s = head + (nm ? ' "' + nm + '"' : '');
|
||||||
|
if (tag === 'a' && el.href) s += ' → ' + txt(el.href, 110);
|
||||||
|
if (tag === 'iframe' && el.src) s += ' → ' + txt(el.src, 110);
|
||||||
|
if (tag === 'input') {
|
||||||
|
if (type === 'checkbox' || type === 'radio') { if (el.checked) s += ' [checked]'; }
|
||||||
|
else if (type === 'password') { if (el.value) s += ' [len=' + el.value.length + ']'; }
|
||||||
|
else if (el.value) s += ' [value=' + JSON.stringify(txt(el.value, 60)) + ']';
|
||||||
|
}
|
||||||
|
if (tag === 'textarea' && el.value) s += ' [len=' + el.value.length + ']';
|
||||||
|
if (tag === 'select' && el.selectedOptions && el.selectedOptions[0]) s += ' [selected=' + JSON.stringify(txt(el.selectedOptions[0].textContent, 50)) + ']';
|
||||||
|
if (el.disabled) s += ' [disabled]';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const clear = root => {
|
||||||
|
for (const e of root.querySelectorAll('[' + REF + ']')) e.removeAttribute(REF);
|
||||||
|
for (const e of root.querySelectorAll('*')) if (e.shadowRoot) clear(e.shadowRoot);
|
||||||
|
};
|
||||||
|
clear(document);
|
||||||
|
const lines = [];
|
||||||
|
let ref = 0, kept = 0, truncated = 0;
|
||||||
|
const walk = (el, depth) => {
|
||||||
|
let d = depth;
|
||||||
|
if (keep(el) && visible(el)) {
|
||||||
|
kept++;
|
||||||
|
if (kept <= MAX) {
|
||||||
|
el.setAttribute(REF, String(++ref));
|
||||||
|
lines.push(' '.repeat(Math.min(depth, 12)) + '@' + ref + ' ' + describe(el));
|
||||||
|
d = depth + 1;
|
||||||
|
} else truncated++;
|
||||||
|
}
|
||||||
|
for (const c of el.children) walk(c, d);
|
||||||
|
if (el.shadowRoot) for (const c of el.shadowRoot.children) walk(c, d);
|
||||||
|
};
|
||||||
|
walk(document.body || document.documentElement, 0);
|
||||||
|
return { url: location.href, title: document.title, kept, truncated, lines };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素几何(页面坐标,左上角)。命中返回 {x,y,w,h,tag};不存在/不可见返回 null。
|
||||||
|
* frameSel 非空时先在 iframe 内解析,再把坐标换算成宿主页面的坐标;iframe 跨域返回 {frameError}。
|
||||||
|
*
|
||||||
|
* 必须是纯同步求值(见 visibleRectExpr 的禁用 rAF 说明),
|
||||||
|
* 求值返回的是本次布局的坐标,所以紧接着派发鼠标事件是安全的。
|
||||||
|
*/
|
||||||
|
export function boxExpr(sel, frameSel) {
|
||||||
|
if (!frameSel) {
|
||||||
|
return `(() => {
|
||||||
|
const el = ${selectorExpr(sel)};
|
||||||
|
if (!el) return null;
|
||||||
|
const measure = () => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, tag: el.tagName }; };
|
||||||
|
const first = measure();
|
||||||
|
if (first.w === 0 || first.h === 0) return null;
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
if (cs.visibility === 'hidden' || cs.display === 'none') return null;
|
||||||
|
// 只在确实看不见时才滚动:已可见的元素保持原布局,
|
||||||
|
// 否则「滚动改变布局 → 坐标过期 → 点空」会在真实站点上静默发生。
|
||||||
|
let box = first;
|
||||||
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||||
|
if (cx < 0 || cy < 0 || cx > innerWidth || cy > innerHeight) {
|
||||||
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
|
box = measure();
|
||||||
|
}
|
||||||
|
const c2x = box.x + box.w / 2, c2y = box.y + box.h / 2;
|
||||||
|
return { ...box, inView: c2x >= 0 && c2y >= 0 && c2x <= innerWidth && c2y <= innerHeight };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
return `(() => {
|
||||||
|
const f = document.querySelector(${JSON.stringify(frameSel)});
|
||||||
|
if (!f) return null;
|
||||||
|
if (!f.contentDocument) return { frameError: 'cross-origin' };
|
||||||
|
const d = f.contentDocument;
|
||||||
|
const w = f.contentWindow;
|
||||||
|
const el = ${selectorExpr(sel, 'd', 'w')};
|
||||||
|
if (!el) return null;
|
||||||
|
// clientLeft/Top = iframe 边框宽度,加上它才是内容区原点
|
||||||
|
const measure = () => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const fr = f.getBoundingClientRect();
|
||||||
|
return { x: fr.left + f.clientLeft + r.x, y: fr.top + f.clientTop + r.y, w: r.width, h: r.height, tag: el.tagName, inFrameY: r.y };
|
||||||
|
};
|
||||||
|
const first = measure();
|
||||||
|
if (first.w === 0 || first.h === 0) return null;
|
||||||
|
const cs = f.contentWindow.getComputedStyle(el);
|
||||||
|
if (cs.visibility === 'hidden' || cs.display === 'none') return null;
|
||||||
|
// 内层先居中:iframe 常常比宿主视口高,元素落在 iframe 中部才好对齐。
|
||||||
|
// 之后迭代收敛:每轮重新量一次坐标再滚一次,因为 iframe 内的 scrollIntoView
|
||||||
|
// 可能连带滚动宿主容器(跨 frame 滚动链),单次计算的 delta 会过期。
|
||||||
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
|
let box = measure();
|
||||||
|
const inView = b => {
|
||||||
|
const x = b.x + b.w / 2, y = b.y + b.h / 2;
|
||||||
|
return x >= 0 && y >= 0 && x <= innerWidth && y <= innerHeight;
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 4 && !inView(box); i++) {
|
||||||
|
const delta = (innerHeight / 2 - (box.inFrameY + box.h / 2)) - f.getBoundingClientRect().top;
|
||||||
|
if (Math.abs(delta) < 1) break;
|
||||||
|
let sc = f.parentElement;
|
||||||
|
while (sc && sc !== document.body && sc !== document.documentElement) {
|
||||||
|
const c2 = getComputedStyle(sc);
|
||||||
|
if (/(auto|scroll)/.test(c2.overflowY) && sc.scrollHeight > sc.clientHeight) break;
|
||||||
|
sc = sc.parentElement;
|
||||||
|
}
|
||||||
|
if (sc && sc !== document.body && sc !== document.documentElement) {
|
||||||
|
const before = sc.scrollTop;
|
||||||
|
sc.scrollTop = before + delta;
|
||||||
|
if (sc.scrollTop === before) break;
|
||||||
|
} else {
|
||||||
|
const before = window.scrollY;
|
||||||
|
window.scrollBy(0, delta);
|
||||||
|
if (window.scrollY === before) break;
|
||||||
|
}
|
||||||
|
box = measure();
|
||||||
|
}
|
||||||
|
return { x: box.x, y: box.y, w: box.w, h: box.h, tag: box.tag, inView: inView(box) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 boxExpr 的跨域标记转成可读异常,其余原样返回。 */
|
||||||
|
export function assertFrameAccess(box, frameSel) {
|
||||||
|
if (box && typeof box === 'object' && box.frameError) {
|
||||||
|
const why = box.frameError === 'cross-origin' ? '跨域 iframe,受同源策略限制' : box.frameError;
|
||||||
|
throw new Error(`无法访问 --frame "${frameSel}" 内部:${why}。跨域 iframe 只能操作其外部宿主元素。`);
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实鼠标点击(isTrusted=true)。
|
||||||
|
* box 来自 visibleRectExpr/waitForElement 的同步求值结果,因此紧接着派发是安全的。
|
||||||
|
*/
|
||||||
|
const INPUT_OPTS = { timeoutMs: 15000, timeoutHint: DIALOG_HINT };
|
||||||
|
|
||||||
|
export async function clickAt(cdp, box) {
|
||||||
|
for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) {
|
||||||
|
await cdp.send('Input.dispatchMouseEvent', {
|
||||||
|
type, x: box.x, y: box.y, button: 'left', clickCount: 1,
|
||||||
|
buttons: type === 'mousePressed' ? 1 : 0,
|
||||||
|
}, INPUT_OPTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实鼠标拖拽:按下 -> 分步移动 -> 松开。
|
||||||
|
* 分步是为了让页面的 mousemove 监听与浏览器原生 HTML5 拖拽控制器都能收到过程事件。
|
||||||
|
* 两端坐标必须已在同一视口内(调用方负责检查)。
|
||||||
|
*/
|
||||||
|
export async function dragBetween(cdp, from, to, steps = 10) {
|
||||||
|
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.x, y: from.y, buttons: 0 }, INPUT_OPTS);
|
||||||
|
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: from.x, y: from.y, button: 'left', clickCount: 1, buttons: 1 }, INPUT_OPTS);
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
await cdp.send('Input.dispatchMouseEvent', {
|
||||||
|
type: 'mouseMoved', button: 'left', buttons: 1,
|
||||||
|
x: from.x + (to.x - from.x) * (i / steps),
|
||||||
|
y: from.y + (to.y - from.y) * (i / steps),
|
||||||
|
}, INPUT_OPTS);
|
||||||
|
await sleep(10); // 给原生拖拽控制器消化过程事件的时间
|
||||||
|
}
|
||||||
|
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: to.x, y: to.y, button: 'left', clickCount: 1, buttons: 0 }, INPUT_OPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把文本插入**当前焦点元素**(CDP Input.insertText,走浏览器原生编辑管线)。
|
||||||
|
* 这是自绘编辑器(Monaco / ProseMirror / CodeMirror / React contenteditable)唯一可靠的输入方式,
|
||||||
|
* 因为那些控件不是 input/textarea,原生 value setter 那套(setInputValue)对它们无效。
|
||||||
|
* 前置条件:页面里已经有焦点元素——先自己 clickAt 聚焦,或让调用方负责。
|
||||||
|
*/
|
||||||
|
export async function insertText(cdp, text) {
|
||||||
|
await cdp.send('Input.insertText', { text }, INPUT_OPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按键表:真实键盘事件用。单字符可直接写,修饰键用 ctrl+a / cmd+enter 形式。 */
|
||||||
|
export const KEYS = {
|
||||||
|
enter: { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
|
||||||
|
tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
|
||||||
|
escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
||||||
|
esc: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
||||||
|
backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
|
||||||
|
delete: { key: 'Delete', code: 'Delete', keyCode: 46 },
|
||||||
|
space: { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
||||||
|
arrowup: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
||||||
|
arrowdown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
||||||
|
arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
||||||
|
arrowright: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
||||||
|
home: { key: 'Home', code: 'Home', keyCode: 36 },
|
||||||
|
end: { key: 'End', code: 'End', keyCode: 35 },
|
||||||
|
pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
||||||
|
pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
||||||
|
};
|
||||||
|
const MOD_BITS = { alt: 1, ctrl: 2, control: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
|
||||||
|
|
||||||
|
/** 'ctrl+a' / 'enter' / 'a' → 协议参数。 */
|
||||||
|
export function parseKey(spec) {
|
||||||
|
const parts = spec.toLowerCase().split('+');
|
||||||
|
const base = parts.pop();
|
||||||
|
let modifiers = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!(p in MOD_BITS)) throw new Error(`未知修饰键 "${p}" (可用 ctrl/cmd/shift/alt)`);
|
||||||
|
modifiers |= MOD_BITS[p];
|
||||||
|
}
|
||||||
|
const k = KEYS[base] || (base.length === 1 ? { key: base, code: `Key${base.toUpperCase()}`, keyCode: base.toUpperCase().charCodeAt(0), text: base } : null);
|
||||||
|
if (!k) throw new Error(`未知按键 "${base}"。可用: ${Object.keys(KEYS).join(', ')},或单字符,或 ctrl+a 形式`);
|
||||||
|
return { ...k, modifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发真实按键(keyDown + keyUp)。这是让目标控件自己的按键处理生效的唯一方式:
|
||||||
|
* 清空自绘编辑器(ctrl+a 后 backspace)、回车提交、Esc 关弹层等。
|
||||||
|
* 注意修饰键组合也带 text(如 ctrl+a 带 text:'a'),浏览器靠 modifiers 判定为快捷键,不会插入字符。
|
||||||
|
*/
|
||||||
|
export async function pressKey(cdp, spec) {
|
||||||
|
const k = parseKey(spec);
|
||||||
|
const base = { modifiers: k.modifiers, key: k.key, code: k.code, windowsVirtualKeyCode: k.keyCode, nativeVirtualKeyCode: k.keyCode };
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { ...base, type: k.text ? 'keyDown' : 'rawKeyDown', text: k.text, unmodifiedText: k.text }, INPUT_OPTS);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' }, INPUT_OPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素是否可见可交互。
|
||||||
|
*
|
||||||
|
* 必须是纯同步求值:不得包含 requestAnimationFrame / await / 定时器。
|
||||||
|
* 实测结论(见 REFERENCE.md「禁用 rAF 探针」):在页面里跑 rAF 会让 Chrome
|
||||||
|
* 永久丢弃此后所有 Input.dispatchMouseEvent 的 mousePressed/mouseReleased,
|
||||||
|
* 只保留 mouseMoved,导致不合成 click——表现为「点击报成功但毫无效果」。
|
||||||
|
*/
|
||||||
|
export function visibleRectExpr(sel, frameSel) {
|
||||||
|
return `(() => {
|
||||||
|
const b = ${boxExpr(sel, frameSel)};
|
||||||
|
if (!b) return null;
|
||||||
|
if (b.frameError) return b;
|
||||||
|
return { x: b.x + b.w / 2, y: b.y + b.h / 2, w: b.w, h: b.h, tag: b.tag, inView: b.inView };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询等待元素可见可交互。
|
||||||
|
* 返回值来自最后一次「同步求值」,因此紧接着派发鼠标事件是安全的。
|
||||||
|
* 等待发生在 Node 侧(setTimeout),不让页面让出渲染帧。
|
||||||
|
*/
|
||||||
|
export async function waitForElement(cdp, sel, timeoutMs = 15000, intervalMs = 150, frameSel) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
// 单次求值 5s 兜底:页面被对话框/死循环阻塞时,这里会报「无响应」而不是永久挂起。
|
||||||
|
const box = assertFrameAccess(await evaluate(cdp, visibleRectExpr(sel, frameSel), { timeoutMs: 5000 }), frameSel);
|
||||||
|
// inView=false 表示元素可见但不在宿主视口内,此时派发鼠标事件会落空。
|
||||||
|
if (box && box.inView !== false) return box;
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
throw new Error(`等待超时(${timeoutMs}ms):找不到可见可点击的元素 ${sel}${frameSel ? `(--frame ${frameSel})` : ''}\n` +
|
||||||
|
(String(sel).startsWith('@') ? `提示:@ 引用可能已失效(页面重渲染后旧引用会被清掉)。跑 cdpctl snapshot 拿新引用。\n` : '') +
|
||||||
|
`提示:用 cdpctl snapshot 看页面结构;用 cdpctl text / html 查看当前页面文本,或 cdpctl eval 检查是否还在 loading。`);
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, intervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开一个新标签页(PUT /json/new?url)。
|
||||||
|
* 只在确实需要新页面时调用;不要把 about:blank 当占位符 ——
|
||||||
|
* 那会在用户的窗口里留下一个空白标签页,每次运行都多一个。
|
||||||
|
*/
|
||||||
|
export async function openTab(url) {
|
||||||
|
return httpJson(`/json/new?${encodeURIComponent(url)}`, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动浏览器(幂等)。已在跑则直接复用,并按需新开页面。
|
||||||
|
*
|
||||||
|
* 关键:不带 url 时**不再**传 '--new-window' / 'about:blank'。
|
||||||
|
* 传了会让 Chrome 在恢复上次会话之外**再**开一个空窗口,用户每次都看到
|
||||||
|
* 多余的 about:blank(实测:launch 后立刻有 2 个 page 目标,其中一个是空的)。
|
||||||
|
* 需要页面时用 url 参数新开一个已有内容的标签页,不留空页。
|
||||||
|
*
|
||||||
|
* 失败处理:刚 stop 过时端口可能还没释放,新 Chrome 会因绑不上调试端口
|
||||||
|
* 而「活着但 CDP 不通」——干等超时还留下孤儿进程(实测遇到过)。
|
||||||
|
* 所以:超时就杀掉本次启的进程、确认端口释放、重试一次,失败给可操作的错误。
|
||||||
|
*/
|
||||||
|
export async function launch({ url, headless = false } = {}) {
|
||||||
|
const existing = await version();
|
||||||
|
if (existing && url) await openTab(url);
|
||||||
|
if (existing) return { started: false, version: existing };
|
||||||
|
|
||||||
|
const exe = chromePath();
|
||||||
|
const dir = profileDir();
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const logPath = path.join(dir, 'chrome-launch.log');
|
||||||
|
const waitMs = Number(process.env.CDPCTL_LAUNCH_TIMEOUT_MS || 30000);
|
||||||
|
|
||||||
|
const attempt = async () => {
|
||||||
|
const log = openSync(logPath, 'a');
|
||||||
|
const args = [
|
||||||
|
`--user-data-dir=${dir}`,
|
||||||
|
`--remote-debugging-port=${port()}`,
|
||||||
|
'--remote-allow-origins=*',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
// 关掉翻译气泡:中英混排页面 + 英文 UI 时 Chrome 会反复弹「要不要翻译」,
|
||||||
|
// 对自动化毫无用处,只会挡住页面。
|
||||||
|
'--disable-features=Translate,TranslateUI',
|
||||||
|
...(headless ? ['--headless=new'] : []),
|
||||||
|
// 只有显式给了 url 才开窗口;否则让 Chrome 自己恢复会话,避免空标签页。
|
||||||
|
...(url ? ['--new-window', url] : []),
|
||||||
|
];
|
||||||
|
const child = spawn(exe, args, { detached: true, stdio: ['ignore', log, log] });
|
||||||
|
child.unref();
|
||||||
|
|
||||||
|
const deadline = Date.now() + waitMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const v = await version();
|
||||||
|
if (v) return { started: true, pid: child.pid, version: v };
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return { failed: 'exited', child };
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
return { failed: 'timeout', child };
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await attempt();
|
||||||
|
if (!first.failed) return first;
|
||||||
|
await killAndWait(first.child);
|
||||||
|
|
||||||
|
// 端口是不是被别的进程占着(而不是我们刚杀掉的那个)
|
||||||
|
let freed = false;
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
if (!(await portInUse(port()))) { freed = true; break; }
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
if (!freed) {
|
||||||
|
throw new Error(
|
||||||
|
`CDP 端口 ${port()} 被别的进程占着,自动化浏览器起不来。\n` +
|
||||||
|
` 处理: 关掉占用者,或用 CDPCTL_PORT 换一个端口。\n` +
|
||||||
|
` 查看占用: lsof -nP -iTCP:${port()} -sTCP:LISTEN`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (first.failed === 'exited') {
|
||||||
|
throw new Error(
|
||||||
|
`Chrome 启动即退出。\n 可执行文件: ${exe}\n profile: ${dir}\n` +
|
||||||
|
` 日志尾部:\n${logTail(logPath)}\n 完整日志: ${logPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端口刚释放,重试一次
|
||||||
|
const second = await attempt();
|
||||||
|
if (!second.failed) return second;
|
||||||
|
await killAndWait(second.child);
|
||||||
|
const tail = logTail(logPath);
|
||||||
|
throw new Error(
|
||||||
|
`启动后 ${Math.round(waitMs / 1000)}s 内 CDP 仍未就绪(已重试 1 次,未就绪的进程已清理)。\n` +
|
||||||
|
` 可执行文件: ${exe}\n profile: ${dir}\n 端口: ${port()}\n` +
|
||||||
|
(tail ? ` 日志尾部:\n${tail}\n` : '') +
|
||||||
|
` 完整日志: ${logPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 端口上有没有人在监听(不判断对方是不是 Chrome)。 */
|
||||||
|
function portInUse(p) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const s = net.connect({ host: '127.0.0.1', port: p });
|
||||||
|
const done = v => { s.destroy(); resolve(v); };
|
||||||
|
s.once('connect', () => done(true));
|
||||||
|
s.once('error', () => done(false));
|
||||||
|
s.setTimeout(1000, () => done(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 优雅结束我们自己启的进程(先 SIGTERM,最多等 5s,再 SIGKILL)。 */
|
||||||
|
async function killAndWait(child, ms = 5000) {
|
||||||
|
if (!child || !child.pid) return;
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
try { process.kill(child.pid, 'SIGTERM'); } catch { return; }
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
try { process.kill(child.pid, 0); } catch { return; }
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
try { process.kill(child.pid, 'SIGKILL'); } catch { /* 已经退了 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日志尾部若干字节,失败时用来解释原因(日志会一直追加,所以只读末尾)。 */
|
||||||
|
function logTail(logPath, bytes = 800) {
|
||||||
|
try {
|
||||||
|
const size = statSync(logPath).size;
|
||||||
|
const start = Math.max(0, size - bytes);
|
||||||
|
const fd = openSync(logPath, 'r');
|
||||||
|
const buf = Buffer.alloc(size - start);
|
||||||
|
readSync(fd, buf, 0, buf.length, start);
|
||||||
|
closeSync(fd);
|
||||||
|
return buf.toString('utf8').trim().split('\n').slice(-6).join('\n');
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优雅关闭自动化浏览器(走 CDP Browser.close,跨平台且无需记录 PID)。
|
||||||
|
* 不会影响用户自己开的 Chrome(不同 user-data-dir / 端口)。
|
||||||
|
*/
|
||||||
|
export async function stop() {
|
||||||
|
const v = await version();
|
||||||
|
if (!v) return { stopped: false, reason: '未在运行' };
|
||||||
|
const b = await connectBrowser();
|
||||||
|
await b.send('Browser.close').catch(() => {});
|
||||||
|
b.close();
|
||||||
|
const deadline = Date.now() + 20000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!(await version())) return { stopped: true };
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
return { stopped: false, reason: '20s 内未退出' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动化 profile 的 Cookies 数据库路径(兼容新旧布局)。 */
|
||||||
|
export function cookiesDbPath() {
|
||||||
|
const base = profileDir();
|
||||||
|
for (const rel of ['Default/Network/Cookies', 'Default/Cookies', 'Cookies']) {
|
||||||
|
const p = path.join(base, rel);
|
||||||
|
if (existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
throw new Error(`找不到 Cookies 数据库。profile=${base}\n提示:先 launch 一次让 Chrome 建立 profile。`);
|
||||||
|
}
|
||||||
1240
skills/cdp-page-control/scripts/cdpctl.mjs
Normal file
1240
skills/cdp-page-control/scripts/cdpctl.mjs
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user