commit 66499305936ea57cf0a439006094e6319f8c6187 Author: Kinneyzhang Date: Fri Oct 2 09:26:18 2026 +0800 release v0.1.0 diff --git a/MANIFEST.json b/MANIFEST.json new file mode 100644 index 0000000..ca9fcac --- /dev/null +++ b/MANIFEST.json @@ -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": [] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..04bcc06 --- /dev/null +++ b/README.md @@ -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` 会告诉还缺什么。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ca7c01 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "cdp-page-control", + "version": "0.1.0", + "description": "驱动受控浏览器操作任意网站页面(导航、填表、点击、读文本、截图、抓网络)" +} diff --git a/skills/cdp-page-control/REFERENCE.md b/skills/cdp-page-control/REFERENCE.md new file mode 100644 index 0000000..9e24316 --- /dev/null +++ b/skills/cdp-page-control/REFERENCE.md @@ -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 对象逐字段分派——` 的选项(先 value 后显示文本)。返回 {ok, value, text, reason}。 */ +async function selectOption(cdp, sel, want, frameSel) { + const core = (root, win) => `(() => { + const el = ${selectorExpr(sel, root, win)}; + if (!el) return { ok: false, reason: '元素不存在' }; + if (el.tagName !== 'SELECT') return { ok: false, reason: '不是 的选项(按 value 或显示文本) + click <选择器> [--timeout 秒] 真实鼠标点击(isTrusted=true) + type <选择器> <文本> [--clear] 受控组件安全输入(原生 setter + input/change 事件) + insert-text <文本> [--file F] 把文本插入当前焦点元素(CDP Input.insertText) + 自绘编辑器(Monaco/ProseMirror/CodeMirror)唯一可靠输入方式 + 用法:先 click 聚焦目标编辑区,再 insert-text + press <按键...> 真实按键: enter tab escape esc backspace arrowup/down/left/right + home end pageup pagedown 单字符,或 ctrl+a / cmd+enter + fill --file fields.json 按 { "选择器": 值 } 批量填表(输入框/下拉/勾选框);--file - 读 stdin + drag <来源选择器> <目标选择器> [--steps N] + 真实鼠标拖拽(分步移动,兼容 JS 与原生 HTML5 拖放) + upload <选择器> <文件路径> 设置 file input 的文件(@引用 也可用) + +会话凭证: + cookies 列出 cookie 元数据(值只报长度,不打印) + persist-cookies --domain D 把 D 域下的会话 cookie 改写成持久 cookie(跨重启保登录) + 注意:会先关闭浏览器再改写,然后需自行 launch + import-cookies [--from 目录] [--profile 名] [--yes] + 把日常 Chrome 的登录态(Cookies 库)复制进自动化 profile + 默认只预览;--yes 才执行(会先关闭受控浏览器并备份原库) + +诊断: + net [--seconds 秒] [--grep 正则] [--save 文件] [--bodies] [--sanitize] + 抓请求并标出每个请求携带的 cookie 名(排查站点如何鉴权) + --save 落盘完整抓包(含凭证 → 只允许写共享仓库外,权限 0600) + +目标定位(涉及页面元素的命令通用): + --match <片段> 按 URL 或标题片段选页面;只有一个页面时自动选中。 + 重定向后片段可能失配,命中多个时报错。 + --target 按 cdpctl status / targets 给出的 targetId 精确定位。 + 编排或复用多个标签页时用它(不受重定向与同名页面影响)。 + +选择器语法(所有 <选择器> 通用): + css 写法 #id .class [data-x="y"] + text=文本 文本内容完全相等(取最内层;多个候选时优先可见的那个) + xpath=表达式 XPath + @N snapshot 输出的引用编号(页面重渲染后会失效,重跑 snapshot) + +iframe(同源)内操作: + 涉及元素定位的命令都支持 --frame