chaoxing-work-cua 1.0.0 → 1.1.0

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.
package/README.md CHANGED
@@ -34,6 +34,12 @@ computer-use 插件)使用的 **Skill(技能指令文件)**。安装后,
34
34
  全部内容来自真实多轮实操(累计完成 30+ 题、跨 5 份作业、2 个账号),每条规则
35
35
  都对应一次真实返工:
36
36
 
37
+ - **三种工作模式**,各有完整踩坑手册:
38
+ - **CDP 直连模式(首选)**:给浏览器开调试端口,用仓库附带的零依赖客户端
39
+ [cdp-client.mjs](cdp-client.mjs) 直接在页面里执行 JS——按文本点击零偏移、
40
+ 后台生效不吃焦点、改 value 绕开粘贴屏蔽、跨域 iframe 可逐个进入
41
+ - **CUA 模式**(外部浏览器:截图+坐标+键盘):CDP 不可用时的兜底
42
+ - **内置浏览器模式**(browser-use/IAB:DOM 直读直写,快一个数量级)
37
43
  - **表单控件不在无障碍树里** → 读题靠截图+视觉,操作靠坐标+键盘
38
44
  - **坐标事件要求前台** → 用户一切窗口焦点就会失效,每次操作前重新前台化
39
45
  - **多标签页标题全部同名** → 必须用 URL 里的 `workId` 校验,否则会答错别人的卷子
@@ -41,7 +47,10 @@ computer-use 插件)使用的 **Skill(技能指令文件)**。安装后,
41
47
  - **富文本编辑器屏蔽粘贴** → 一律模拟键盘逐字输入(支持中文)
42
48
  - **段落以"1."开头会触发自动编号** → 变成"2.2."重复,改用"(1)(2)"
43
49
  - **保存 toast 只有 2 秒** → 错过不代表失败,以最终提交结果页为权威验证
44
- - ……共 **11 条硬规则 + 12 行报错对照表**,详见 [SKILL.md](SKILL.md)
50
+ - **章节测验三层 iframe 嵌套 + 题干字体加密** → 链式 frameLocator 穿透 +
51
+ 同源 JS 直点 + AI讲解获取正确答案
52
+ - **点击-验证循环 + 连错熔断** → 每次点击后核对预期状态变化,连续 2 次偏移立即停手
53
+ - ……共 **14 条硬规则 + 13 起实测事故复盘**,详见 [SKILL.md](SKILL.md)
45
54
 
46
55
  ## 📦 安装
47
56
 
@@ -86,9 +95,10 @@ C:\Users\<你>\.agents\skills\chaoxing-work-cua\SKILL.md
86
95
 
87
96
  ```text
88
97
  chaoxing-work-cua/
89
- ├── README.md # 本文件
90
- ├── SKILL.md # Skill 主文件:完整流程 + 踩坑手册 + 可复用代码片段
91
- └── LICENSE # MIT
98
+ ├── README.md # 本文件
99
+ ├── SKILL.md # Skill 主文件:完整流程 + 踩坑手册 + 可复用代码片段
100
+ ├── cdp-client.mjs # 零依赖 CDP 客户端(CDP 直连模式用,沙箱无 WebSocket 也能跑)
101
+ └── LICENSE # MIT
92
102
  ```
93
103
 
94
104
  ## 🔧 平台适配改造点
package/SKILL.md CHANGED
@@ -37,6 +37,38 @@ description: 用 Computer Use (CUA) 在浏览器中自动作答超星学习通
37
37
  11. **省 token 的帧绑定技巧**:截图接口支持"只绑定帧、不回显图片"的模式(如
38
38
  `emit:false`)。任何"点击/滚动前需要重新绑定帧"的场合都用它,只有真正要读内容时
39
39
  才回显截图。
40
+ 12. **点击-验证循环是硬性纪律**:每次坐标点击后立即截图,确认**预期的那一个**状态变化
41
+ (标签切换了/选项高亮了/页面跳转了)。没变化或变化不对 = 点偏了,**禁止拿原坐标
42
+ 再点一次**——先重新枚举无障碍元素按文本定位,或重新截图重算坐标。同一目标连续
43
+ 2 次偏移就停下向用户报告,不要带错继续(实测教训:点偏后未察觉继续操作,最后靠
44
+ 用户兜底)。
45
+ 13. **标签/按钮优先用无障碍元素点击,不用像素坐标**。凡元素表里能按文本找到的
46
+ (tab、button、"下一节"类按钮),同一执行单元内 `枚举 → 按文本查找 → click(index)`。
47
+ 凭上一帧截图记坐标是实测点空的直接原因——页面每次渲染位置都会微动,元素索引才是
48
+ 稳定锚点。
49
+ 14. **有条件就选 CDP 直连模式(见专属章节)**:它同时根治坐标偏移、焦点抢夺、粘贴屏蔽
50
+ 三大问题,是多轮实测后的首选方案。CUA 坐标方案只作为 CDP 不可用时的兜底。
51
+
52
+ ## 操作权限边界(唯一依据,所有模式适用)
53
+
54
+ <!-- perm-boundary -->
55
+
56
+ 任何模式下执行下表以外的写操作前,必须停下询问用户。**授权不继承**:上一节/上一份
57
+ 作业获准提交,不代表下一节可以提交;授权范围以"用户当前这句话"为准。
58
+
59
+ | 操作 | 默认权限 | 说明 |
60
+ |---|---|---|
61
+ | 读页面内容(截图/DOM 文本/evaluate 查询) | 允许 | 只读无副作用 |
62
+ | 选中选项、填写输入框、暂时保存 | 允许 | 可逆,随时可改 |
63
+ | **提交**(作业提交/测验提交/确认弹窗里的提交) | **禁止,除非用户对当前任务明确说"提交"** | 不可逆;每次提交单独授权 |
64
+ | 取消、清空已答内容 | 禁止 | 破坏已完成的工作 |
65
+ | 关闭标签页/窗口、退出登录、删除 | 禁止 | 不可逆 |
66
+ | 打开新页面/导航到作业无关 URL | 禁止 | 防止迷失目标 |
67
+
68
+ **目标身份校验(连接前强制)**:操作任何页面前,用 `selectTarget`/`connectCDP({url})`
69
+ 按完整 URL(含 workId 等区分参数)唯一确定目标。不匹配(ENOTARGET)、歧义
70
+ (EMULTIPLE)一律停止并报告,**绝不回退到"看起来像"的其他页面**——同名标签页在
71
+ 多账号场景下就是别人的卷子。
40
72
 
41
73
  ## 可直接复制的代码片段
42
74
 
@@ -57,29 +89,43 @@ await setupComputerUseRuntime({ globals: globalThis });
57
89
  const app = await agent.computerUse.getApp({ pid: <浏览器进程pid> }); // 首次用 listApps() 查询
58
90
  ```
59
91
 
60
- ### 确认"前台 + 目标标签页"(所有坐标操作前调用)
92
+ ### 目标身份校验与前台化(两件事,分开做)
93
+
94
+ **身份正确 ≠ 窗口在前台**。身份校验通过时浏览器完全可能仍在后台——宿主每个执行
95
+ 单元都会抢走焦点,此时坐标点击会被系统静默吞掉(页面 no_change、鼠标动了没反应)。
96
+ 所以每个坐标单元必须**依次**完成两步,缺一不可:
61
97
 
62
98
  ```js
63
99
  function workId(els) {
64
100
  const doc = els.find(e => e.kind === "textarea" && /workId=/.test(e.value || ""));
65
101
  return doc ? (doc.value.match(/workId=(\d+)/) || [])[1] : null;
66
102
  }
67
- async function ensureReady(app, targetWorkId) {
103
+
104
+ // 第 1 步:身份 —— 确认当前标签就是目标作业;不是则枚举同名标签逐个切换(≤3 次)
105
+ async function ensureTargetIdentity(app, targetWorkId) {
68
106
  let els = await app.elements();
69
- if (workId(els) !== targetWorkId) {
107
+ for (let i = 0; i < 3 && workId(els) !== targetWorkId; i++) {
70
108
  const tabs = els.filter(e => e.kind === "tab" && /作业作答/.test(e.title || ""));
71
- for (let i = 0; i < tabs.length; i++) {
72
- await app.click(tabs[i].index); // 点击标签页同时起到激活窗口的作用
73
- els = await app.elements();
74
- if (workId(els) === targetWorkId) break;
75
- }
109
+ if (!tabs.length) return false;
110
+ await app.click(tabs[0].index);
111
+ els = await app.elements();
76
112
  }
77
- return workId(els) === targetWorkId;
113
+ return workId(els) === targetWorkId; // false → 停止并报告(切错账号/卷子风险)
114
+ }
115
+
116
+ // 第 2 步:前台 —— 点一次浏览器标签页元素激活窗口(身份已正确时仍然要做)
117
+ async function ensureForeground(app) {
118
+ const els = await app.elements(); // 取完立刻用,防索引失效
119
+ const tab = els.find(e => e.kind === "tab" && /学生学习页面|作业作答/.test(e.title || ""));
120
+ if (!tab) return false;
121
+ await app.click(tab.index); // 无障碍点击 = 激活窗口
122
+ return true; // false → 停止并报告(坐标操作会全部无效)
78
123
  }
79
124
  ```
80
125
 
81
- 调用方式:`await ensureReady(app, "<目标作业的workId>")`,之后再做任何坐标操作。
82
- 它同时解决**前台化**(点击标签会激活窗口)和**切错标签**(workId 校验)两个问题。
126
+ 顺序固定:`ensureTargetIdentity` 失败 → 停止报告;成功后**仍然**要执行
127
+ `ensureForeground`——跳过这一步是"点击全部无效"最高频的根因(见 TL;DR #3)。
128
+ 前台化失败的判断标准:连续两次截图 `snapshot_mode: no_change` 且页面毫无反应。
83
129
 
84
130
  ### 无障碍跳题(读题阶段用,后台可用、不抢焦点)
85
131
 
@@ -178,7 +224,7 @@ scroll_amount = round((元素当前y - 期望y) / pxPerNotch)
178
224
  每个作答执行单元的固定骨架:
179
225
 
180
226
  ```
181
- 初始化 → ensureReady(校验业务主键+前台化) → [跳到目标题] → 新截图 + 路标锚定算坐标
227
+ 初始化 → ensureTargetIdentity(校验业务主键) → ensureForeground(前台化) → [跳到目标题] → 新截图 + 路标锚定算坐标
182
228
  → 坐标操作 → 输入/点击 → 截图验证
183
229
  ```
184
230
 
@@ -231,7 +277,7 @@ scroll_amount = round((元素当前y - 期望y) / pxPerNotch)
231
277
  | 报错/现象 | 原因 | 处置 |
232
278
  |---|---|---|
233
279
  | `agent is not defined` | 新执行单元未初始化 | 单元开头加初始化代码 |
234
- | `raw foreground event ... refused ... frontmost_pid_mismatch` | 目标窗口不在前台 | ensureReady()(点标签页激活)后重试 |
280
+ | `raw foreground event ... refused ... frontmost_pid_mismatch` | 目标窗口不在前台 | ensureForeground()(点标签页激活)后重试 |
235
281
  | `element index N no longer refers to...tree has shifted` | 无障碍索引跨调用失效 | 同单元内重取元素表再点,重试≤3 次 |
236
282
  | `frame_id ... is expired frame` | 帧绑定过期(切换标签页、间隔太久) | 先截一帧(不回显)重新绑定,再提交坐标 |
237
283
  | `scroll: expected an integer` | 滚动量传了小数 | 取整 |
@@ -259,6 +305,336 @@ scroll_amount = round((元素当前y - 期望y) / pxPerNotch)
259
305
  3. 每份作业独立验收完毕再切下一份;全部完成后统一汇报作答内容,**等用户明确说
260
306
  "提交"再提交**。
261
307
 
308
+ ## 内置浏览器高效模式(browser-use / IAB,推荐)
309
+
310
+ > 安全基线:停止条件/状态核验/恢复执行「三模式统一安全基线」;提交权限执行
311
+ > 「操作权限边界」。本章只写 IAB 特有的操作技巧。
312
+
313
+ 配合 ZCode 内置浏览器(control-browser skill)时,可以**直接读写 DOM**:domSnapshot 读题、
314
+ locator 点击/填写,彻底绕开 CUA 模式的坐标校准、截图超时、字体加密猜测等难题,
315
+ 速度快一个数量级。本章是实测后的完整打法。
316
+
317
+ ### 接管用户已登录的页面
318
+
319
+ - 用户自己登录并打开课程页后:先 `browser.tabs.list()`;为空或不匹配时用
320
+ `browser.user.openTabs()` 找到目标页 → `browser.user.claimTab(info)` 接管。
321
+ - **接管后立即 `await tab.markHandoff()`**:未标记的 claimed 标签会在对话轮次结束时被
322
+ 释放回用户侧,下一轮调用会报 `Browser tab ... is unavailable`,需重新 claim。
323
+ - 报 unavailable 的标准处理:重新 `tabs.list()` + `user.openTabs()` 枚举,再 claim。
324
+
325
+ ### 学习通课程页结构(mooc2-ans/mycourse/stu)
326
+
327
+ - 顶部导航在主文档:AI助教 / 任务 / 章节 / 讨论 / 作业 / 考试…,全是 javascript: 链接,
328
+ 点击后由 iframe 加载对应内容。
329
+ - **iframe 的 id 随顶部标签切换**:章节=`frame_content-zj`,作业=`frame_content`,
330
+ 任务=`frame_content-hd`。每切一次标签,frameLocator 的 id 也要跟着换。
331
+ - 课程的测验任务都在章节里(作业标签可能为空)。章节菜单里带"N个待完成任务点"
332
+ 标记的小节就是待答题任务;全部做完后菜单内该标记清零,任务点进度 +1
333
+ (如 39/94 → 40/94)。
334
+
335
+ ### 打开指定小节(菜单点击点不动的可靠替代)
336
+
337
+ 章节菜单的可见行被上层 `<a>` 覆盖,Playwright 点击会报
338
+ `covered by <a>`;强制点击/回车/JS 直点隐藏模板均无效。可靠路径:
339
+
340
+ 1. 题号项的父级 `chapter_item` 元素带 onclick:`toOld('courseid','knowledgeid','clazzid',0)`。
341
+ `getAttribute("onclick")` 查询通道可正常读取 → 提取 knowledgeid。
342
+ 2. 先导航顶层标签到
343
+ `studentcourse?courseid=..&clazzid=..&knowledgeid=<id>&cpi=..&ut=s` 让主文档与
344
+ 课程框架**同源化**(此后 evaluate/点击都可用)。
345
+ 3. 同源后直接点击 `#cur<knowledgeid>`(chapter_item),页面跳转到 studentstudy
346
+ 知识卡片页(URL 形如 `mycourse/studentstudy?chapterId=<knowledgeid>&enc=..`)。
347
+ 4. `goto()` 遇到学习通多重重定向会超时报错——**超时 ≠ 失败**,读 url()/title() 确认即可。
348
+
349
+ ### 知识卡片页(studentstudy)作答
350
+
351
+ - 任务点以 `option` 角色分组(视频 / 章节测验),点击"章节测验"option 切换到测验。
352
+ - **测验嵌在三层 iframe 里**:主文档 → `iframe(knowledge/cards)` →
353
+ `iframe[src*="ananas/modules/work"]` → `iframe#frame_content(doHomeWorkNew)`。
354
+ 用链式 frameLocator 穿透:
355
+
356
+ ```js
357
+ const quiz = tab.playwright
358
+ .frameLocator('iframe#iframe')
359
+ .frameLocator('iframe[src*="ananas/modules/work"]')
360
+ .frameLocator('iframe#frame_content');
361
+ ```
362
+
363
+ - **题干有字体加密**(读出来是乱码),但选项是明文;结合章节名可推断题目——**推断有
364
+ 风险:多选题全对才给分,且提交后不能重做**(实测猜 ABCD 错了记 0 分)。提交前用
365
+ "选项明文 + 章节名"联网检索题库核实。
366
+ - 选项元素是 `<li>`(无 input),被覆盖 `<a>` 挡住 → locator click 报 covered。
367
+ - **同源 JS 直点方案**:当主文档与整条 iframe 链同源(都在 mooc1.chaoxing.com)时,
368
+ 用主文档 evaluate 穿透 window.frames 直接触发选项的 DOM click:
369
+
370
+ ```js
371
+ // frames 索引 ≠ DOM 顺序,先递归扫描 window.frames 树按 href 找到测验层
372
+ const qdoc = window.frames[4].frames[0].frames[0].document; // 实测路径
373
+ qdoc.querySelectorAll('a').forEach(a => {
374
+ const t = (a.innerText || '').replace(/\s+/g, '');
375
+ if (/选项关键词/.test(t)) a.click();
376
+ });
377
+ ```
378
+
379
+ - 提交(**需先取得用户对当前测验的一次性授权**,见「操作权限边界」):测验框内点
380
+ "提交"后,确认弹窗在**主文档** `.popBottom`(含 提交/取消)→ 在主文档对该弹窗内
381
+ 的"提交"按钮执行 JS click。
382
+ - 验证:测验框文本出现「已完成 / 第N次作答 / 本次成绩X分 / 我的答案」即提交成功。
383
+
384
+ ### 判分、错题与正确答案
385
+
386
+ - 多选题全对才给分;提交后**不能重做**——拿不准就先核实再提交。
387
+ - 提交后点「AI讲解」打开 AI 助教侧边栏(`iframe#cozeIframe`,跨域),用
388
+ `frameLocator("#cozeIframe").locator("body").innerText()` 可读完整解析与**正确答案**
389
+ (供记录,无法改分)。
390
+ - 批改标记读不到 class 时,AI讲解是最可靠的答案来源。
391
+
392
+ ### IAB 已知限制
393
+
394
+ - 重页面(带视频/大量动画)截图会持续报
395
+ `screenshot surface preparation timed out after 3000ms` → 改用 innerText /
396
+ getAttribute 查询通道做验证,不要反复重试截图。
397
+ - **跨域 iframe 的 evaluate 完全不可靠**:执行可能不生效、返回值被吞(一律返回 `{}`)。
398
+ 跨域框架内只依赖查询类方法(innerText / count / getAttribute);
399
+ 需要执行 JS 时先按上述方法把主文档同源化。
400
+ - `dom_cua.get_visible_dom()` 不穿透 iframe(只返回主文档可见节点)。
401
+
402
+ ## 外部浏览器 CUA 模式实战补充(第二轮实测)
403
+
404
+ > 安全基线:停止条件/状态核验/恢复执行「三模式统一安全基线」;提交权限执行
405
+ > 「操作权限边界」。本章只写 CUA 坐标模式特有的坑与对策。
406
+
407
+ 以下为第二轮(逐章测验)实测新增的坑与对策,与前面章节配合阅读。
408
+
409
+ ### 焦点漂移(最高频失败原因)
410
+
411
+ - 自动化宿主每个执行单元都会把焦点抢回自己 → **每个单元操作浏览器前必须先前台化**:
412
+ 无障碍点击浏览器标签页元素(elements() 里 kind=tab 且标题匹配的),否则原始坐标
413
+ 点击全部无效(页面截图 no_change、鼠标动了但页面没反应)。
414
+ - 判断方法:连续两次截图无变化且页面毫无反应 = 点击没生效,先前台化再重试。
415
+
416
+ ### 54px 布局位移(点击落到相邻行)
417
+
418
+ - **截图与点击之间,页面内容会整体位移约一行(54px)**(懒加载/选中态重渲染导致),
419
+ 坐标点击会落到相邻选项(想点 A 选中了 B,想点 C 选中了 D)。
420
+ - 对策:①每次点击后立即截图核对选中态;②发现错行时按观测偏移量(±54px)修正重点;
421
+ ③点完所有选项后**再整体截图核对一遍**才提交。
422
+
423
+ ### 答案必须检索核实(硬性要求)
424
+
425
+ - 题干字体加密时,靠乱码解码+章节名推断**不可靠**(实测多选题猜全选错了 0 分)。
426
+ - 正确姿势:用可读的**选项文本+章节名**上搜索引擎核实标准答案,再作答;
427
+ 检索不到的题目优先做保守选择并向用户说明。
428
+ - **"全选 ABCD"是高危答案**:多选题通常全对才给分,选多/选少都可能是 0 分。除非
429
+ 检索到直接证据(题库原题+答案),禁止默认全选(实测连续两道多选全猜 ABCD 全错,
430
+ 被用户点名"正确率低")。
431
+ - 每题检索失败时的处理顺序:换关键词(选项中最独特的短语)→ 搜"章节名+题型" →
432
+ 仍找不到就**标出来告诉用户**,宁留白不乱猜。
433
+
434
+ ### 点击自检纪律(针对"点偏了自己没发现")
435
+
436
+ 每次坐标点击必须走完这个闭环,缺一步就是返工:
437
+
438
+ 1. **点前**:定位来源必须是本帧——无障碍元素表按文本找 index(首选),或刚回显的
439
+ 截图实测坐标(次选)。**禁止**沿用记忆坐标/上一帧坐标。
440
+ 2. **点后**:立即截图,核对预期的**具体**状态变化(tab 高亮切换、选项选中、弹窗
441
+ 出现)。`snapshot_mode: no_change` ≠ 点击生效,可能只是没绑到新帧。
442
+ 3. **点偏**:不重放原坐标。重新枚举元素定位 → 换元素 index 点击;元素树里找不到才
443
+ 退回"新截图 + 路标推算"。修正方向参考布局位移规律(见下)。
444
+ 4. **熔断**:执行「三模式统一安全基线」的停止条件(同一目标连续 2 次点击未达预期
445
+ → 停止操作,截图说明现状请用户确认,避免在错误状态下继续点)。
446
+
447
+ ### 知识卡片链接(knowledge/cards)的局限
448
+
449
+ - 直接打开 `knowledge/cards?knowledgeid=..` 页面对部分小节**只显示视频任务**,
450
+ 章节测验可能不渲染——**不能以此断定该节没有测验**。
451
+ - 可靠做法:在 studentstudy 学习页里点「章节测验」标签查看;或用「下一节」按钮
452
+ 逐节推进,每节进知识卡片后点测验标签确认。
453
+
454
+ ### 批阅页是明文答案来源
455
+
456
+ - 提交后的批阅页(URL 含 selectWorkQuestionYiPiYue)里题干、选项、正确答案全部明文。
457
+ - 每提交一节,立即读批阅页,把「题目 → 正确答案」记入对照表;同课程题库在不同
458
+ 账号间重复,对照表可直接复用命中。
459
+
460
+ ### 杂散小弹窗窗口
461
+
462
+ - 页面可能弹出 150×38 的小工具窗口并**抢走坐标点击的窗口绑定**(坐标点击报
463
+ `outside 150x38`)。处理:对当前绑定的窗口按 Alt+F4 关掉它,然后按窗口名称
464
+ 重新绑定主窗口;注意登录页可能被一起关掉,重新导航即可(登录态在 cookie 里)。
465
+
466
+ ### 提交按钮的坐标漂移
467
+
468
+ - 提交按钮位置随页面滚动/内容高度变化(实测同一按钮在 y=1227/1194/1144/886 之间
469
+ 变化)。每次提交前必须基于当前帧截图重新定位,按 54px 偏移规律修正。
470
+
471
+ ## 终版推荐流程:纯 JS 驱动逐节走查(第三轮实测定稿)
472
+
473
+ **坐标点击方案的全部失败(前台漂移、54px 位移、遮挡、点错行)都可以用
474
+ 「主文档 JS 直操作」绕开**——前提是主文档与 iframe 链同源(都在 mooc1.chaoxing.com)。
475
+ 同源化技巧:顶层标签直接导航
476
+ `studentcourse?courseid=..&clazzid=..&knowledgeid=<id>&cpi=..&ut=s`,
477
+ 主文档即变为同源的课程页。
478
+
479
+ ### 每小节两个执行单元
480
+
481
+ **单元 A(跳节 + 读题,纯文本输出省 token)**:
482
+
483
+ ```js
484
+ // 1. 点「下一节」button(按文本找,不依赖坐标)
485
+ const btn = [...document.querySelectorAll('button')].find(b => (b.innerText||'').trim()==='下一节');
486
+ btn && btn.click();
487
+ // 2. 等 3.5s 后点「章节测验」任务点标签(role=option 按文本找)
488
+ // 3. 等 3s 后递归扫描 window.frames 树,找 doHomeWorkNew/api/work 框架
489
+ // → 返回题目文本(题干乱码无妨,选项明文可读)
490
+ ```
491
+
492
+ 注意:**window.frames 索引 ≠ DOM iframe 顺序**,必须按 href 特征递归扫描;
493
+ 扫描函数要 try/catch 包裹跨域访问。
494
+
495
+ **单元 B(核实 + 作答 + 提交 + 记录)**:
496
+
497
+ ```js
498
+ // 0. 前置:用户已对"本节测验提交"明确授权,否则到此为止只保存不提交
499
+ // 1. 每道题先检索核实(选项明文 + 章节名搜索题库)
500
+ // 2. 同一 evaluate 内:按选项明文文本 .click() 对应 <a>
501
+ // 3. 点「提交」→ 主文档 .popBottom 弹窗内点「提交」
502
+ // 4. 读批阅页(明文)→ 记录「题干 → 正确答案」入对照表
503
+ ```
504
+
505
+ ### 对照表的价值(跨账号复用)
506
+
507
+ 同一课程题库在不同账号间重复。提交后的批阅页是明文——把每题「题干 → 正确答案」
508
+ 记入**本地**对照表(不入公开仓库),下一个账号遇到同题直接命中。历史教训与
509
+ 记录方法见 [INCIDENTS.md](INCIDENTS.md) 的「对照表」一节。
510
+
511
+ ### 历史事故复盘
512
+
513
+ 13 起实操事故 + 三轮审计修复记录已移至 **[INCIDENTS.md](INCIDENTS.md)**(核心规则
514
+ 与历史档案分离)。想知道某条规则的来历,去那里查。
515
+
516
+ ## 三模式统一安全基线(CDP / IAB / CUA 通用,唯一依据)
517
+
518
+ <!-- safety-baseline -->
519
+
520
+ **停止条件(满足任一立即停手,报告用户,不得自行绕过)**:
521
+
522
+ 1. 目标身份不唯一或不确定(ENOTARGET / EMULTIPLE / workId 校验失败);
523
+ 2. 同一操作连续 2 次未达预期(点击无效果、选项没选上、页面没切换);
524
+ 3. 连接异常(ECLOSED / ETIMEOUT / EWS)且重连一次后仍异常;
525
+ 4. 待执行操作超出「操作权限边界」且用户未授权;
526
+ 5. 页面内容与预期作业不符(题目数、课程名对不上——可能切错了账号/卷子)。
527
+
528
+ **状态核验(证据必须来自当前这一刻,不凭记忆推断)**:
529
+
530
+ - CDP:`evaluate` 返回值 / 元素 `getBoundingClientRect` / 选中态 class;
531
+ - IAB:`innerText` / `count` / `getAttribute` 查询通道;
532
+ - CUA:刚回显的截图(`snapshot_mode: no_change` 不算点击生效的证据)。
533
+ - 提交前:整页内容级核对(题数、每题选中态、无未答项)。
534
+
535
+ **恢复指导(出错后怎么回来,不重放错误操作)**:
536
+
537
+ 1. 重新枚举目标(`listTargets` / 标签页枚举)→ 严格身份校验 → 重新连接;
538
+ 2. 从页面**当前实际状态**反推进度(读已答内容),不假设"上次操作已生效";
539
+ 3. 被中断的写入(填空/简答)先读回内容再决定补输还是重输,禁止盲目覆盖;
540
+ 4. 恢复失败 → 停止并报告,等待用户处置。
541
+
542
+ ## CDP 直连模式(首选方案,已全流程实测通过)
543
+
544
+ 用 Chrome DevTools Protocol(CDP)直连用户侧的 Chrome/Edge,**在页面里执行 JS 完成
545
+ 一切操作**。对比 CUA 坐标点击的全面优势:
546
+
547
+ | CUA 坐标方案的痛点 | CDP 模式下 |
548
+ |---|---|
549
+ | 点击偏移、点到相邻选项 | 按 selector/文本点击,没有坐标概念 |
550
+ | 宿主抢焦点导致点击全部无效 | CDP 在浏览器**后台**也生效 |
551
+ | 平台屏蔽粘贴 | 直接改输入框 value + dispatchEvent |
552
+ | 布局位移让旧坐标作废 | 每次实时 `getBoundingClientRect` |
553
+ | 跨域 iframe 读不到 | OOPIF iframe 是独立调试目标,可逐个连接 |
554
+
555
+ ### 一次性启动带调试端口的浏览器(细节多,照抄)
556
+
557
+ ```bash
558
+ # 必须用 cmd //c start 脱离进程树——自动化工具的 shell 每次调用结束会回收子进程,
559
+ # 直接后台启动的浏览器会随调用结束被杀(实测)。启动后用 netstat 确认端口 LISTENING。
560
+ cmd //c start "" "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" \
561
+ --user-data-dir="C:\Users\<你>\.zcode\edge-cua-profile" \
562
+ --remote-debugging-port=9222 --no-first-run --no-default-browser-check \
563
+ --window-size=1280,900 "about:blank"
564
+ ```
565
+
566
+ - **必须配独立 `--user-data-dir`**:Chromium 136+ 对默认用户目录禁用
567
+ remote-debugging(静默失效)。独立 profile 首次要登录目标平台一次,之后 cookie
568
+ 常驻,且与用户日常浏览器隔离。
569
+ - 验证:GET `http://127.0.0.1:9222/json/version`。
570
+
571
+ ### 零依赖客户端库(仓库已带,见 cdp-client.mjs)
572
+
573
+ 很多 Agent 的 JS 沙箱会**剥离全局 WebSocket/fetch**,所以本仓库附带的
574
+ [cdp-client.mjs](cdp-client.mjs) 用 node:net 手写 WebSocket 帧协议,仅依赖 Node
575
+ 内置模块,放到任何执行环境都能跑。
576
+
577
+ ```js
578
+ const { pathToFileURL } = await import("node:url");
579
+ const { connectCDP, listTargets } = await import(
580
+ pathToFileURL("<仓库路径>/cdp-client.mjs").href);
581
+ // url 是严格身份:origin+路径+指定的 query 参数(如 workId)必须全部匹配,
582
+ // 且恰好只有一个目标匹配。不匹配抛 ENOTARGET、歧义抛 EMULTIPLE、
583
+ // wsUrl 指向非环回主机抛 EHOST —— 三种情况都必须停止并报告,不得回退。
584
+ const cdp = await connectCDP({
585
+ port: 9222,
586
+ url: "https://mooc1.chaoxing.com/mooc-ans/mooc2/work/dowork?workId=12345",
587
+ });
588
+ await cdp.send("Page.enable");
589
+ const r = await cdp.send("Runtime.evaluate", { expression: "document.title", returnByValue: true });
590
+ ```
591
+
592
+ ### 常用操作片段(全部经 Runtime.evaluate)
593
+
594
+ ```js
595
+ // 按文本点击(选项/按钮/标签通用)
596
+ `[...document.querySelectorAll('a,li,button')].find(e=>/目标文本/.test(e.innerText))?.click()`
597
+
598
+ // 读题干/验证状态
599
+ `document.querySelector('<题目容器选择器>')?.innerText`
600
+
601
+ // 设值(普通输入框,绕开粘贴屏蔽;富文本编辑器需另行处理)
602
+ `(()=>{const i=document.querySelector('input');const s=Object.getOwnPropertyDescriptor(
603
+ window.HTMLInputElement.prototype,'value').set;s.call(i,'答案');i.dispatchEvent(new Event('input',{bubbles:true}));})()`
604
+
605
+ // 精确元素位置(无需截图猜坐标)
606
+ `(()=>{const r=document.querySelector('#id').getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height}})()`
607
+
608
+ // iframe:先 listTargets(),再对目标列表跑 selectTarget(targets, {url, type:"iframe"})
609
+ // 唯一确定后,用**选中目标**的 webSocketDebuggerUrl 走 connectWsUrl() 直连。
610
+ // 禁止跳过 selectTarget 直接拿列表里第一个 wsUrl 连——那是身份校验旁路。
611
+ ```
612
+
613
+ - 需要看图验证时用 `Page.captureScreenshot`(可带 clip 截元素级区域),返回 base64,
614
+ 用 node:fs 写临时 png 后交给视觉模型。
615
+ - 验证与熔断执行「三模式统一安全基线」:CDP 消灭了坐标偏移,但消灭不了选错答案,
616
+ 每次点击后仍要 evaluate 核对预期状态。提交权限执行「操作权限边界」——默认只
617
+ 保存,提交需用户对当前任务一次性明确授权。
618
+
619
+ ### 本模式已踩坑(写库时已修复,改动需重验)
620
+
621
+ | 坑 | 原因 | 处置 |
622
+ |---|---|---|
623
+ | shell 里启动的浏览器随调用结束消失 | 工具回收子进程树 | `cmd //c start` 脱离启动 |
624
+ | `Connection: close` 也等不到 socket close | DevTools HTTP 服务保持连接 | 按 Content-Length 判定响应完整(库已处理) |
625
+ | 调试端口开了但连不上 | 默认 profile 被 Chromium 136+ 禁用 | 必须独立 `--user-data-dir` |
626
+ | 沙箱报 `WebSocket is not defined` | 执行环境剥离全局 | 用本仓库 cdp-client.mjs(手写 WS 帧) |
627
+
628
+ ### 备选方案(调研结论,未默认启用)
629
+
630
+ - 官方 `@playwright/mcp --cdp-endpoint http://localhost:9222`:让 Playwright MCP 的
631
+ browser_* 工具直连本机浏览器(保留登录态);缺点是调试端口没开时整个 MCP 可能
632
+ 失效,影响其他任务,故默认不改。
633
+ - `chrome-devtools-mcp --autoConnect`:Chrome 官方 MCP,能力类似。
634
+ - 平台字体反爬的解密思路:预置标准字体 glyf 哈希映射表做字形比对还原(开源实现可
635
+ 搜"超星 字体反爬 解码"),或装油猴脚本在浏览器端直接解密显示——后者一并解除
636
+ 富文本编辑器的粘贴限制。
637
+
262
638
  ## 提交前验收清单
263
639
 
264
640
  - [ ] 全部题目清单齐全(题量与页面标注一致)
package/bin/install.js CHANGED
@@ -1,23 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * chaoxing-work-cua 安装器
4
- * 把 SKILL.md 安装到 Agent 的技能目录(零依赖)。
3
+ * chaoxing-work-cua 安装器(零依赖)
4
+ * 把 SKILL.md + cdp-client.mjs 安装到 Agent 的技能目录。
5
5
  *
6
- * 用法:
7
- * npx chaoxing-work-cua # 自动检测已存在的技能目录并安装
8
- * npx chaoxing-work-cua --target <dir> # 安装到指定目录
6
+ * 安全机制:
7
+ * - 必需文件(SKILL.md / cdp-client.mjs)缺失 → 立即失败,且失败发生在写入任何
8
+ * 文件之前(先全量校验,后写入)。
9
+ * - 安装采用「决策备份 → 暂存写入 → 原子换入」三段式;任一段失败整体回滚,
10
+ * 目标目录保持安装前状态(不出现新旧混合的半成品)。
11
+ * - 覆盖已存在的任何文件前一律备份为 <原名>.bak-<时间戳>。
12
+ * - 若目标 SKILL.md 不是本 skill(frontmatter 的 name 不是 chaoxing-work-cua)
13
+ * → 视为外来文件,默认跳过覆盖并提示;--force 才覆盖(覆盖前同样备份)。
14
+ *
15
+ * 用法:不带参数自动检测技能目录;--target <目录> 指定安装位置;--force 允许覆盖外来文件。
9
16
  */
10
17
  const fs = require("fs");
11
18
  const path = require("path");
12
19
  const os = require("os");
13
20
 
14
21
  const SKILL_NAME = "chaoxing-work-cua";
22
+ const REQUIRED_FILES = ["SKILL.md", "cdp-client.mjs"]; // 必需文件,缺一即失败
23
+
15
24
  const args = process.argv.slice(2);
16
25
 
17
- // --target <dir>
18
26
  let customTarget = null;
19
27
  const tIdx = args.indexOf("--target");
20
- if (tIdx > -1 && args[tIdx + 1]) customTarget = path.resolve(args[tIdx + 1]);
28
+ if (tIdx > -1) {
29
+ const val = args[tIdx + 1];
30
+ if (!val || val.startsWith("--")) {
31
+ console.error("✗ --target 需要一个目录参数(如 --target \"D:\\my-agent\\skills\")");
32
+ process.exit(1);
33
+ }
34
+ customTarget = path.resolve(val);
35
+ }
36
+ const FLAG_FORCE = "--force";
37
+ const force = args.includes(FLAG_FORCE);
21
38
 
22
39
  // 候选技能目录(存在哪个装哪个;都没有则装到默认目录)
23
40
  const candidates = [".agents/skills", ".zcode/skills", ".claude/skills"];
@@ -31,25 +48,99 @@ if (customTarget) {
31
48
  targets = [path.join(os.homedir(), ".agents", "skills")];
32
49
  }
33
50
 
34
- // 源文件:包根目录下的 SKILL.md
35
- const src = path.join(__dirname, "..", "SKILL.md");
36
- if (!fs.existsSync(src)) {
37
- console.error("✗ 安装包损坏:找不到 SKILL.md,请重新 npx 或 npm i -g。");
51
+ // ---- 第 1 步:全量校验(任何失败都发生在写入之前)----
52
+ const pkgRoot = path.join(__dirname, "..");
53
+ const srcFiles = REQUIRED_FILES.map((f) => ({ name: f, path: path.join(pkgRoot, f) }));
54
+ const missing = srcFiles.filter((s) => !fs.existsSync(s.path)).map((s) => s.name);
55
+ if (missing.length) {
56
+ console.error(`✗ 安装包损坏:缺少必需文件 ${missing.join("、")}。请重新 npx 或 npm i -g 后重试。`);
38
57
  process.exit(1);
39
58
  }
40
59
 
60
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
61
+ const backup = (filePath) => {
62
+ const bak = `${filePath}.bak-${timestamp}`;
63
+ fs.copyFileSync(filePath, bak);
64
+ return bak;
65
+ };
66
+
67
+ /** 仅识别 frontmatter 里 name 恰为本 skill 的文件(整行精确匹配,防超串/嵌入伪造) */
68
+ const isOurs = (filePath) => {
69
+ try {
70
+ const text = fs.readFileSync(filePath, "utf8");
71
+ const lines = text.split(/\r?\n/);
72
+ if (lines[0].trim() !== "---") return false; // 无 frontmatter 一律视为外来文件
73
+ const end = lines.indexOf("---", 1);
74
+ if (end < 0) return false;
75
+ const nameLine = `name: ${SKILL_NAME}`;
76
+ return lines.slice(1, end).some((l) => l.trim() === nameLine);
77
+ } catch {
78
+ return false;
79
+ }
80
+ };
81
+
82
+ // ---- 第 2 步:逐目录安装(决策备份 → 暂存 → 换入;任一步失败整体回滚)----
41
83
  console.log(`安装 ${SKILL_NAME} skill ...`);
84
+ let hadError = false;
85
+ let skippedSkillCount = 0;
42
86
  for (const t of targets) {
43
87
  const dir = path.join(t, SKILL_NAME);
88
+ const plan = srcFiles.map((s) => ({
89
+ ...s,
90
+ dest: path.join(dir, s.name),
91
+ staged: path.join(dir, `${s.name}.staged-${timestamp}`),
92
+ }));
93
+ let skipSkill = false;
94
+ const backups = []; // 换入前已存在的文件 → {name, dest, bak}
95
+ const swapped = []; // 已换入的 dest
44
96
  try {
45
97
  fs.mkdirSync(dir, { recursive: true });
46
- fs.copyFileSync(src, path.join(dir, "SKILL.md"));
47
- console.log(` ✓ ${dir}`);
98
+
99
+ // 决策 + 备份(此阶段不写任何目标文件)
100
+ if (fs.existsSync(plan[0].dest) && !isOurs(plan[0].dest) && !force) {
101
+ skipSkill = true;
102
+ skippedSkillCount++;
103
+ console.warn(` ! 跳过 SKILL.md(外来文件保护): ${plan[0].dest} 确认要覆盖请使用 force 开关。`);
104
+ }
105
+ for (const p of plan) {
106
+ if (p.name === "SKILL.md" && skipSkill) continue;
107
+ if (fs.existsSync(p.dest)) {
108
+ const bak = backup(p.dest);
109
+ backups.push({ name: p.name, dest: p.dest, bak });
110
+ console.log(` ⧗ 已备份原 ${p.name} → ${path.basename(bak)}`);
111
+ }
112
+ }
113
+
114
+ // 暂存写入(失败在此阶段发生时目标文件尚未被动过)
115
+ for (const p of plan) {
116
+ if (p.name === "SKILL.md" && skipSkill) continue;
117
+ fs.copyFileSync(p.path, p.staged);
118
+ }
119
+ // 换入(rename 原子替换)
120
+ for (const p of plan) {
121
+ if (p.name === "SKILL.md" && skipSkill) continue;
122
+ fs.renameSync(p.staged, p.dest);
123
+ swapped.push(p.dest);
124
+ }
125
+ console.log(skipSkill ? ` ◐ ${dir}(SKILL.md 因外来文件保护未覆盖,其余文件已安装)` : ` ✓ ${dir}`);
48
126
  } catch (e) {
49
- console.error(` ✗ ${dir} : ${e.message}`);
50
- process.exitCode = 1;
127
+ // 整体回滚:有备份的恢复备份;无备份但已换入的删除;清理暂存
128
+ for (const b of backups) { try { fs.copyFileSync(b.bak, b.dest); } catch {} }
129
+ for (const dest of swapped) {
130
+ if (!backups.some((b) => b.dest === dest)) { try { fs.rmSync(dest, { force: true }); } catch {} }
131
+ }
132
+ for (const p of plan) { try { fs.rmSync(p.staged, { force: true }); } catch {} }
133
+ console.error(` ✗ ${dir} : ${e.message}(已回滚,目标目录保持安装前状态)`);
134
+ hadError = true;
51
135
  }
52
136
  }
137
+ if (hadError) process.exitCode = 1;
138
+
139
+ // 唯一目标也被外来保护跳过 → 明确失败(调用方必须看到"没装上",而不是"完成!")
140
+ if (!hadError && targets.length === 1 && skippedSkillCount === 1) {
141
+ console.error("✗ SKILL.md 未安装(外来文件保护生效)。确认覆盖请使用 force 开关。");
142
+ process.exitCode = 1;
143
+ }
53
144
 
54
145
  console.log("");
55
146
  console.log("完成!重启你的 Agent 后,skill 即被识别。");
package/cdp-client.mjs ADDED
@@ -0,0 +1,333 @@
1
+ // 零依赖 CDP(Chrome DevTools Protocol)客户端:仅用 node:net/node:crypto。
2
+ // 适用于 WebSocket/fetch 全局被剥离的 Agent JS 沙箱(手写 WebSocket 帧协议)。
3
+ //
4
+ // 安全模型(R1 审计后确立):
5
+ // 1. 目标选择是严格的:url 不匹配 / 多目标歧义 → 抛错停止,绝不回退到其他页面。
6
+ // url 匹配规则 = origin 相同 + pathname 相同 + pattern 里给出的每个 query 参数值相同。
7
+ // 2. /json/list 属于不可信输入:其中 wsUrl 的主机必须是环回地址,否则拒绝(EHOST)。
8
+ // 3. 所有发送的 JS 表达式必须由 Agent 自己构造,页面内容只能作为数据读取。
9
+ //
10
+ // 用法(把 <repo> 换成仓库实际路径):
11
+ // const { connectCDP, listTargets } = await import(pathToFileURL("<repo>/cdp-client.mjs").href);
12
+ // const cdp = await connectCDP({
13
+ // port: 9222,
14
+ // url: "https://mooc1.chaoxing.com/mooc-ans/mooc2/work/dowork?workId=12345", // 严格身份
15
+ // });
16
+ // await cdp.send("Page.enable");
17
+ // const r = await cdp.send("Runtime.evaluate", { expression: "document.title", returnByValue: true });
18
+ import net from "node:net";
19
+ import crypto from "node:crypto";
20
+
21
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
22
+ const DEFAULT_TIMEOUT_MS = 15000; // 单个 CDP 命令的默认超时
23
+ const MAX_EVENT_QUEUE = 500; // 事件队列上限(防事件洪峰占内存)
24
+ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024; // 单条消息上限(防恶意服务撑爆内存)
25
+
26
+ function assertLoopbackHost(hostname) {
27
+ const h = String(hostname || "").toLowerCase();
28
+ if (LOOPBACK_HOSTS.has(h)) return h;
29
+ throw new Error(`EHOST: CDP 端点必须是环回地址(localhost/127.0.0.1),收到 ${hostname}。` +
30
+ `调试端口只应监听本机;如确需连接远程主机请人工评估后修改 LOOPBACK_HOSTS。`);
31
+ }
32
+
33
+ // ---------- HTTP(/json/*) ----------
34
+
35
+ function httpGetRaw(url) {
36
+ return new Promise((resolve, reject) => {
37
+ const u = new URL(url);
38
+ assertLoopbackHost(u.hostname);
39
+ const req = net.connect(+u.port, u.hostname, () => {
40
+ req.write(`GET ${u.pathname} HTTP/1.1\r\nHost: ${u.hostname}:${u.port}\r\nConnection: close\r\n\r\n`);
41
+ });
42
+ let buf = Buffer.alloc(0), headerDone = false, need = -1, settled = false;
43
+ const finish = (val) => { if (settled) return; settled = true; try { req.destroy(); } catch {} resolve(val); };
44
+ const fail = (e) => { if (settled) return; settled = true; try { req.destroy(); } catch {} reject(e); };
45
+ req.setTimeout(8000, () => fail(new Error("ETIMEOUT-HTTP: /json 请求超时")));
46
+ req.on("data", (c) => {
47
+ buf = Buffer.concat([buf, c]);
48
+ if (!headerDone) {
49
+ const i = buf.indexOf("\r\n\r\n");
50
+ if (i < 0) return;
51
+ headerDone = true;
52
+ const m = /content-length:\s*(\d+)/i.exec(buf.slice(0, i).toString());
53
+ need = m ? +m[1] : -1;
54
+ if (need === 0) return finish("");
55
+ }
56
+ if (need >= 0) {
57
+ const body = buf.slice(buf.indexOf("\r\n\r\n") + 4);
58
+ if (body.length >= need) finish(body.slice(0, need).toString());
59
+ }
60
+ });
61
+ req.on("error", (e) => fail(e));
62
+ // 无响应头即关闭(干净 FIN、无 error):必须拒绝,否则 Promise 永久 pending
63
+ req.on("close", () => {
64
+ if (headerDone) return finish(buf.slice(buf.indexOf("\r\n\r\n") + 4).toString());
65
+ fail(new Error("ECLOSED-HTTP: 连接在响应头到达前关闭"));
66
+ });
67
+ });
68
+ }
69
+
70
+ export async function listTargets(port = 9222) {
71
+ const body = await httpGetRaw(`http://127.0.0.1:${port}/json/list`);
72
+ const arr = JSON.parse(body);
73
+ if (!Array.isArray(arr)) throw new Error("EBADTARGETS: /json/list 返回非数组");
74
+ return arr;
75
+ }
76
+
77
+ // ---------- 目标身份:严格选择(R1 核心) ----------
78
+
79
+ function parseUrlStrict(url) {
80
+ try { return new URL(url); }
81
+ catch { throw new Error(`EINVALIDURL: 无法解析目标 url "${url}";必须给完整 URL(含 origin),` +
82
+ `例如 https://mooc1.chaoxing.com/mooc-ans/mooc2/work/dowork?workId=123`); }
83
+ }
84
+
85
+ /** pattern 的 origin+pathname 与 target 相同,且 pattern 指明的每个 query 参数值相等 */
86
+ export function urlMatches(targetUrl, patternUrl) {
87
+ const t = parseUrlStrict(targetUrl), p = parseUrlStrict(patternUrl);
88
+ if (t.origin !== p.origin) return false;
89
+ if (t.pathname !== p.pathname) return false;
90
+ for (const [k, v] of p.searchParams) {
91
+ if (t.searchParams.get(k) !== v) return false;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * 从 /json/list 结果中唯一确定目标。任何不唯一/不匹配都抛错,绝不静默回退。
98
+ * @param {Array} targets /json/list 原始数组(不可信输入)
99
+ * @param {{url?: string, type?: string}} opts
100
+ */
101
+ export function selectTarget(targets, { url, type = "page" } = {}) {
102
+ const pool = targets.filter(t => t && t.type === type && typeof t.webSocketDebuggerUrl === "string");
103
+ const list = () => pool.map(t => ` - ${t.url} ws=${t.webSocketDebuggerUrl}`).join("\n");
104
+ if (!pool.length) throw new Error(`ENOTARGET: 没有 type=${type} 且带 webSocketDebuggerUrl 的目标`);
105
+ if (!url) {
106
+ if (pool.length === 1) return pool[0];
107
+ throw new Error(`EMULTIPLE: 存在 ${pool.length} 个 ${type} 目标,必须用 url 指明操作对象:\n${list()}`);
108
+ }
109
+ parseUrlStrict(url); // 尽早校验格式
110
+ const matches = pool.filter(t => urlMatches(t.url, url));
111
+ if (!matches.length) throw new Error(`ENOTARGET: 没有 ${type} 目标匹配 ${url}。当前目标:\n${list()}`);
112
+ if (matches.length > 1) throw new Error(`EMULTIPLE: ${matches.length} 个目标同时匹配 ${url}(歧义即停止):\n${list()}`);
113
+ assertLoopbackHost(new URL(matches[0].webSocketDebuggerUrl).hostname); // 连接前校验,EHOST
114
+ return matches[0];
115
+ }
116
+
117
+ // ---------- WebSocket(手写帧协议) ----------
118
+
119
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
120
+
121
+ function wsConnect(wsUrl) {
122
+ return new Promise((resolve, reject) => {
123
+ const u = new URL(wsUrl);
124
+ try { assertLoopbackHost(u.hostname); } catch (e) { return reject(e); }
125
+ const key = crypto.randomBytes(16).toString("base64");
126
+ const sock = net.connect(+u.port, u.hostname, () => {
127
+ sock.write(
128
+ `GET ${u.pathname}${u.search} HTTP/1.1\r\n` +
129
+ `Host: ${u.hostname}:${u.port}\r\n` +
130
+ `Upgrade: websocket\r\nConnection: Upgrade\r\n` +
131
+ `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
132
+ });
133
+ sock.setTimeout(10000, () => failInit(new Error("ETIMEOUT-WS: WebSocket 握手超时")));
134
+ let buf = Buffer.alloc(0), handshaken = false, closed = false;
135
+ let fragments = null; // 分片重组缓冲 {chunks, bytes}
136
+ const handlers = { message: [], close: [] };
137
+ const earlyMessages = []; // 订阅前到达的消息(握手同包竞态),首次订阅时补投
138
+ const failInit = (e) => { if (!handshaken) { try { sock.destroy(); } catch {} reject(e); } };
139
+ const failConn = (e) => { // 握手后的致命错误:通知 close 监听者(会拒绝所有 pending)
140
+ if (!handshaken) return failInit(e);
141
+ try { sock.destroy(); } catch {}
142
+ closed = true;
143
+ const err = e instanceof Error ? e : new Error("ECLOSED: CDP 连接已断开");
144
+ handlers.close.forEach((f) => { try { f(err); } catch {} });
145
+ };
146
+ sock.on("error", (e) => failConn(new Error(`ECONN: ${e.message}`)));
147
+ // 握手完成前连接被关闭(干净 FIN):必须拒绝,否则 Promise 永久 pending
148
+ sock.on("close", () => {
149
+ if (!handshaken) return failInit(new Error("ECLOSED: 握手完成前连接被关闭"));
150
+ if (!closed) { closed = true; handlers.close.forEach((f) => { try { f(); } catch {} }); }
151
+ });
152
+ sock.on("data", (c) => {
153
+ buf = Buffer.concat([buf, c]);
154
+ if (buf.length > MAX_MESSAGE_BYTES) return failConn(new Error("EWS: 缓冲区超过消息上限"));
155
+ if (!handshaken) {
156
+ const i = buf.indexOf("\r\n\r\n");
157
+ if (i < 0) return;
158
+ const head = buf.slice(0, i).toString();
159
+ if (!/ 101 /.test(head.split("\r\n")[0])) return failInit(new Error("EHANDSHAKE: 握手失败 " + head.split("\r\n")[0]));
160
+ // 校验 Sec-WebSocket-Accept(防中间人/伪服务)
161
+ const expected = crypto.createHash("sha1").update(key + WS_GUID).digest("base64");
162
+ const got = /sec-websocket-accept:\s*(.+)/i.exec(head);
163
+ if (!got || got[1].trim() !== expected) return failInit(new Error("EHANDSHAKE: Sec-WebSocket-Accept 不匹配"));
164
+ buf = buf.slice(i + 4); handshaken = true;
165
+ sock.setTimeout(0); // 握手完成,交由上层 send 超时管理
166
+ resolve({
167
+ send: (obj) => sendFrame(JSON.stringify(obj)),
168
+ // 订阅晚于事件(同一批数据里既有握手又有后续帧的竞态):close 立即补发,message 先缓存再补投
169
+ on: (ev, fn) => {
170
+ if (ev === "close") {
171
+ if (closed) queueMicrotask(() => { try { fn(); } catch {} });
172
+ else handlers.close.push(fn);
173
+ return;
174
+ }
175
+ if (ev === "message") {
176
+ handlers.message.push(fn);
177
+ if (earlyMessages.length) {
178
+ const backlog = earlyMessages.splice(0);
179
+ queueMicrotask(() => backlog.forEach((t) => { try { fn(t); } catch {} }));
180
+ }
181
+ }
182
+ },
183
+ close: () => { closed = true; try { sock.end(); } catch {} },
184
+ });
185
+ }
186
+ pump();
187
+ });
188
+ function pump() {
189
+ for (;;) {
190
+ if (buf.length < 2) return;
191
+ const b0 = buf[0], b1 = buf[1];
192
+ const fin = !!(b0 & 0x80), opcode = b0 & 0x0f;
193
+ if (b0 & 0x70) return failConn(new Error("EWS: RSV 位被置位,非法帧")); // rsv1-3
194
+ let len = b1 & 0x7f, off = 2, masked = b1 & 0x80;
195
+ if (masked) return failConn(new Error("EWS: 服务端帧不允许带掩码(RFC 6455)"));
196
+ if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; }
197
+ else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; }
198
+ if (len > MAX_MESSAGE_BYTES) return failConn(new Error("EWS: 帧长度超过上限"));
199
+ if (buf.length < off + (masked ? 4 : 0) + len) return;
200
+ const maskKey = masked ? buf.slice(off, off + 4) : null; off += masked ? 4 : 0;
201
+ const payload = Buffer.from(buf.slice(off, off + len)); buf = buf.slice(off + len);
202
+ if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i & 3];
203
+
204
+ if (opcode === 0) { // continuation
205
+ if (!fragments) return failConn(new Error("EWS: 意外的 continuation 帧"));
206
+ fragments.chunks.push(payload); fragments.bytes += payload.length;
207
+ if (fragments.bytes > MAX_MESSAGE_BYTES) return failConn(new Error("EWS: 分片消息超过上限"));
208
+ if (fin) { const full = Buffer.concat(fragments.chunks); fragments = null; deliver(full); }
209
+ continue;
210
+ }
211
+ if (opcode === 1 || opcode === 2) { // 文本/二进制
212
+ if (fragments) return failConn(new Error("EWS: 数据帧打断未完成的分片消息"));
213
+ if (!fin) { fragments = { chunks: [payload], bytes: payload.length }; continue; }
214
+ deliver(payload);
215
+ continue;
216
+ }
217
+ if (opcode === 9) { sendFrameRaw(0x8a, payload); continue; } // ping -> pong
218
+ if (opcode === 0xa) continue; // 未请求的 pong:忽略(RFC 6455)
219
+ if (opcode === 8) { // close
220
+ closed = true; try { sock.end(); } catch {}
221
+ handlers.close.forEach((f) => { try { f(); } catch {} });
222
+ return;
223
+ }
224
+ return failConn(new Error(`EWS: 未处理的 opcode ${opcode}`)); // 0x3-0x7 / 0xB-0xF
225
+ }
226
+ }
227
+ function deliver(payload) {
228
+ const txt = payload.toString();
229
+ if (!handlers.message.length) { // 尚无订阅者:缓存待补投(上限防洪)
230
+ earlyMessages.push(txt);
231
+ if (earlyMessages.length > MAX_EVENT_QUEUE) earlyMessages.shift();
232
+ return;
233
+ }
234
+ handlers.message.forEach((f) => { try { f(txt); } catch {} });
235
+ }
236
+ function sendFrame(str) { sendFrameRaw(0x81, Buffer.from(str)); }
237
+ function sendFrameRaw(opcode, payload) {
238
+ const mask = crypto.randomBytes(4);
239
+ let head;
240
+ if (payload.length < 126) head = Buffer.from([opcode, 0x80 | payload.length]);
241
+ else if (payload.length < 65536) { head = Buffer.alloc(4); head[0] = opcode; head[1] = 0x80 | 126; head.writeUInt16BE(payload.length, 2); }
242
+ else { head = Buffer.alloc(10); head[0] = opcode; head[1] = 0x80 | 127; head.writeBigUInt64BE(BigInt(payload.length), 2); }
243
+ const masked = Buffer.from(payload);
244
+ for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i & 3];
245
+ sock.write(Buffer.concat([head, mask, masked]));
246
+ }
247
+ });
248
+ }
249
+
250
+ // ---------- 连接封装 ----------
251
+
252
+ /**
253
+ * 严格模式连接 CDP。
254
+ * @param {{port?: number, url?: string, type?: string, timeoutMs?: number}} opts
255
+ * url=目标页面完整 URL(含区分参数如 workId);timeoutMs=单命令超时(默认 15s)
256
+ */
257
+ export async function connectCDP(opts = {}) {
258
+ const port = opts.port ?? 9222;
259
+ const targets = await listTargets(port);
260
+ const target = selectTarget(targets, { url: opts.url, type: opts.type });
261
+ return openConnection(target, opts);
262
+ }
263
+
264
+ /** 直接用 wsUrl 建连(用于 iframe 等 /json/list 里列出的非 page 目标);同样校验环回主机 */
265
+ export async function connectWsUrl(wsUrl, opts = {}) {
266
+ const u = new URL(wsUrl);
267
+ assertLoopbackHost(u.hostname);
268
+ return openConnection({ webSocketDebuggerUrl: wsUrl, url: wsUrl }, opts);
269
+ }
270
+
271
+ async function openConnection(target, opts = {}) {
272
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
273
+ const ws = await wsConnect(target.webSocketDebuggerUrl);
274
+ let seq = 0; const pending = new Map(); const events = [];
275
+ let closed = false;
276
+ const failAll = (err) => {
277
+ closed = true;
278
+ for (const [, p] of pending) p.reject(err);
279
+ pending.clear();
280
+ };
281
+ ws.on("message", (txt) => {
282
+ let m;
283
+ try { m = JSON.parse(txt); } catch { return; } // 非 JSON 消息丢弃(无法对应请求)
284
+ if (m.id && pending.has(m.id)) {
285
+ const { resolve, reject } = pending.get(m.id); pending.delete(m.id);
286
+ m.error ? reject(new Error(`ECDP: ${m.error.message}`)) : resolve(m.result);
287
+ } else if (m.method) {
288
+ events.push(m);
289
+ if (events.length > MAX_EVENT_QUEUE) events.splice(0, events.length - MAX_EVENT_QUEUE);
290
+ }
291
+ });
292
+ ws.on("close", (err) => failAll(err instanceof Error ? err : new Error("ECLOSED: CDP 连接已断开")));
293
+ return {
294
+ target,
295
+ send(method, params = {}) {
296
+ if (closed) return Promise.reject(new Error("ECLOSED: 连接已关闭,禁止再发送"));
297
+ const id = ++seq;
298
+ return new Promise((resolve, reject) => {
299
+ const timer = setTimeout(() => {
300
+ pending.delete(id);
301
+ reject(new Error(`ETIMEOUT: ${method} 在 ${timeoutMs}ms 内未收到响应`));
302
+ }, timeoutMs);
303
+ pending.set(id, {
304
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
305
+ reject: (e) => { clearTimeout(timer); reject(e); },
306
+ });
307
+ try { ws.send({ id, method, params }); }
308
+ catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
309
+ });
310
+ },
311
+ /**
312
+ * Runtime.evaluate 封装:页面抛出的异常透出为 EPAGEERR,正常时直接返回 value。
313
+ * 默认 returnByValue + awaitPromise(promise 表达式会等到解析值,非 promise 不受影响)。
314
+ */
315
+ async evaluate(expression, evalOpts = {}) {
316
+ const r = await this.send("Runtime.evaluate", {
317
+ expression, returnByValue: true, awaitPromise: true, ...evalOpts,
318
+ });
319
+ if (r && r.exceptionDetails) {
320
+ const desc = r.exceptionDetails.exception?.description
321
+ ?? r.exceptionDetails.text ?? "unknown page error";
322
+ throw new Error(`EPAGEERR: ${desc}`);
323
+ }
324
+ return r && r.result ? r.result.value : undefined;
325
+ },
326
+ drain: () => events.splice(0),
327
+ close: () => {
328
+ if (!closed) { closed = true; }
329
+ ws.close();
330
+ failAll(new Error("ECLOSED: 连接已关闭"));
331
+ },
332
+ };
333
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaoxing-work-cua",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "基于 Computer Use 的在线作业自动作答 Skill 模板(超星学习通示例)。npx 一键安装到 Agent 技能目录,含完整踩坑手册。",
5
5
  "bin": {
6
6
  "chaoxing-work-cua": "bin/install.js"
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "bin",
10
10
  "SKILL.md",
11
+ "cdp-client.mjs",
11
12
  "README.md",
12
13
  "LICENSE"
13
14
  ],
@@ -22,9 +23,12 @@
22
23
  "license": "MIT",
23
24
  "repository": {
24
25
  "type": "git",
25
- "url": "git+ssh://git@github.com/0xAlchemist-cs/chaoxing-work-cua.git"
26
+ "url": "git+ssh://git@github.com:0xAlchemist-cs/chaoxing-work-cua.git"
26
27
  },
27
28
  "engines": {
28
- "node": ">=16"
29
+ "node": ">=20.11"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test"
29
33
  }
30
34
  }