Compare commits

...

8 Commits

Author SHA1 Message Date
46bc573777 Release v1.0.66: table default_sort_json for data management
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-08-02 12:17:55 +08:00
0002e86729 docs: columnMapping for replaces create/update lookup reuse
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-31 15:14:44 +08:00
caf0191366 feat: document optional replaces for CRUD mutation binding
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-31 14:43:15 +08:00
ba284922e5 docs: data-management column lookup via options_json
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-31 13:37:51 +08:00
7cf139dfd2 docs: Skill Action interaction direct/confirm/form
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 11:15:51 +08:00
6c1ffc73d3 Release v1.0.61: desktop RPA Name/Value and activate fixes
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-25 17:02:07 +08:00
8fa7ec2743 Release v1.0.60: harden business logging policies (LOGGING-006/007/008)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-25 11:23:32 +08:00
79d21c063d feat: add three-surface RPA locator standards and examples
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-25 10:20:45 +08:00
52 changed files with 5888 additions and 122 deletions

View File

@@ -9,6 +9,51 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.66
- 数据管理支持表级默认排序:`_jiangchang_tables.default_sort_json`(多字段 JSON);宿主优先采用,未配置则仍按 updated_at → created_at → 主键
- 更新 SCHEMA.md 与 `upsert_table_metadata`;旧库幂等补列
## 1.0.65
- `replaces` create/update 可复用表列 lookup:字段名对齐列名,或使用 `bind.columnMapping` / 字段 `column`
- 更新 ACTIONS.md 与 skill-actions.schema.json
## 1.0.64
- Skill Action 新增可选 `replaces: create|update|delete`:接管数据管理默认新增/编辑/删除,无配置时宿主仍直写 SQLite
- 更新 ACTIONS.md 与 skill-actions.schema.json;`replaces` 须配合 `bind.tables`,可与空 `placements` 并用
## 1.0.63
- 数据管理支持字段级 lookup:`options_json` 可声明同库关联表(`ref_table` / `value_column` / `label_column`),宿主新建/编辑时渲染下拉
- 更新 SCHEMA.md 与元数据校验,兼容静态枚举数组与 lookup 对象两种形态
## 1.0.62
- Skill Action 新增 `interaction` / `interactionByPlacement`:声明数据管理点击是直通、轻确认还是参数表单(与 `executionProfile` 正交)
- 明确 `confirmation` 只负责风险文案;cron 触发与 agent 忽略 `interaction`
- 补充 ACTIONS.md 示例(打开文件位置 = direct,打开浏览器 = confirm)
## 1.0.61
- 桌面 RPA 标准反哺:明确 Name≠Value、主窗最大化与禁止误 `SW_RESTORE`、同名叶子须绑父容器
- 更新 `real_desktop_rpa` example:activate/Value 定位、element-map 补全「新的客户/添加」、README 与契约测试
## 1.0.60
- 强化日志规范:验收铁律、固定事件词表、失败须先写文件日志再 print
- 新增 hard 门禁 POLICY-LOGGING-006/007/008(域模块有日志、RPA 事件词、ERROR 与 logger 共现)
- 模板金样 `task_service` 示范 `entitlement_ok`、`rpa_start`/`stage=`、`task_failed error_code=`
- 增加 `tests/samples/test_logging_contract.py.sample` 与交活 Checklist 日志 P0 项
## 1.0.59
- 新增浏览器、Windows 桌面、Android/iOS 三端元素拾取与定位标准,要求先真实拾取、填写 element map、验证唯一性和业务后置状态
- 从已跑通的企业微信添加客户技能提炼真实 Windows UIA example,提供窗口唯一性、作用域定位、probe 和 dry-run/assisted 范式
- 手机 RPA 增加设备、Appium Inspector、Native/WebView、控件树和失败留证标准;在成功案例补齐前明确标为规格骨架
- 需求模板和交活 Checklist 增加禁止 AI 猜 selector、禁止 first/index 掩盖歧义、结构变化停止留证等门禁
## 1.0.58
- 新增 account-manager 接入标准,说明一账号一 Profile、多账号矩阵、登录状态持久化与完整运行生命周期

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.58
version: 1.0.66
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -27,7 +27,7 @@ allowed-tools:
- **Agent 是入口与编排者**:理解用户意图、补齐参数、判断风险、选择 Action,并用业务语言解释结果。
- **RPA 是确定性执行器**:只按已验证步骤操作授权范围内的浏览器、桌面软件或手机;不得破解、逆向、调用未授权私有协议或绕过验证码、安全验证和平台限制。
- 遇到验证码、人脸、U盾、频控、账号风险或权限不足时,转人工或安全停止,禁止自动硬闯、换号或无限重试。
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`;浏览器账号、Profile、多账号和租约再读 `development/ACCOUNT_MANAGER.md`;三端执行细则读 `development/RPA.md`。
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`;三端界面代码必须先读 `development/RPA_ELEMENT_LOCATING.md` 并完成真实拾取与 element map,再按浏览器/桌面/手机选读 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`;浏览器账号、Profile、多账号和租约另读 `development/ACCOUNT_MANAGER.md`。
### 架构铁律(先读)

View File

@@ -28,6 +28,11 @@
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。placements 与 executionProfile 正交,不做条件限制。"
},
"mutationKind": {
"type": "string",
"enum": ["create", "update", "delete"],
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。"
},
"scalarArg": {
"type": ["string", "number", "boolean"]
},
@@ -52,6 +57,12 @@
"readOnly": {
"type": "boolean",
"description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等"
},
"column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "可选。绑定业务表物理列名;replaces create/update 时宿主可复用该列的中文名与 lookup 下拉"
}
}
},
@@ -120,9 +131,18 @@
"type": "object",
"additionalProperties": { "type": "string", "minLength": 1 },
"description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。"
},
"columnMapping": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$"
},
"description": "可选。将 inputSchema 属性名映射到业务表物理列名,供 replaces create/update 抽屉复用列 displayName / lookup options。"
}
},
"description": "表级绑定;toolbar 必填。可选 inputMapping 供行内/批量预填。concurrency/locks 勿写入。"
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping。concurrency/locks 勿写入。"
},
"action": {
"type": "object",
@@ -144,10 +164,11 @@
},
"placements": {
"type": "array",
"minItems": 1,
"minItems": 0,
"items": { "$ref": "#/$defs/placement" },
"description": "决定 Action 出现在哪里;与 executionProfile 正交"
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。"
},
"replaces": { "$ref": "#/$defs/mutationKind" },
"entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" },
@@ -156,6 +177,27 @@
"enum": ["sync", "async"],
"description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。"
},
"interaction": {
"type": "string",
"enum": ["auto", "direct", "confirm", "form"],
"description": "数据管理等可点击入口的交互方式(与 executionProfile 正交)。direct=一点即跑;confirm=轻量确认(文案用 confirmation.message);form=参数表单;auto/省略=宿主按 inputSchema+confirmation 推断。cron 触发与 agent 忽略本字段。"
},
"interactionByPlacement": {
"type": "object",
"additionalProperties": false,
"properties": {
"toolbar": { "type": "string", "enum": ["direct", "confirm", "form"] },
"row": { "type": "string", "enum": ["direct", "confirm", "form"] },
"batch": { "type": "string", "enum": ["direct", "confirm", "form"] },
"cron": { "type": "string", "enum": ["direct", "confirm", "form"] },
"agent": { "type": "string", "enum": ["direct", "confirm", "form"] },
"skill-detail": { "type": "string", "enum": ["direct", "confirm", "form"] },
"create": { "type": "string", "enum": ["direct", "confirm", "form"] },
"update": { "type": "string", "enum": ["direct", "confirm", "form"] },
"delete": { "type": "string", "enum": ["direct", "confirm", "form"] }
},
"description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。"
},
"bind": { "$ref": "#/$defs/bind" }
},
"if": {
@@ -169,7 +211,17 @@
"then": {
"required": ["bind"]
},
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 时必须声明 bind.tables。"
"allOf": [
{
"if": {
"required": ["replaces"]
},
"then": {
"required": ["bind"]
}
}
],
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。"
}
}
}

View File

@@ -129,9 +129,11 @@ checklist_version: 1.0
## 7. 可观测与安全观感(所有技能)
- [ ] A7-1 **P0** 失败提示或任务结论不泄露密码、完整 Cookie、明文 token 等敏感信息
- [ ] A7-2 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束)
- [ ] A7-3 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL**
- [ ] A7-4 **P2** 用户可见文案偏业务语言,而非大段开发黑话
- [ ] A7-2 **P0** 统一文件日志(运行日志 / `jiangchang.log`)能定位失败阶段:可见 `task_start` 与 `task_failed|task_success`;失败含 `error_code=`;**不只** `cli_start`
- [ ] A7-3 **P0** 故意失败一条时,文件日志先有 `task_failed`/`warning`,再有 `ERROR:CODE` 或任务中心失败态
- [ ] A7-4 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束;RPA 宜有多个 `stage=`)
- [ ] A7-5 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL**
- [ ] A7-6 **P2** 用户可见文案偏业务语言,而非大段开发黑话
---
@@ -147,6 +149,12 @@ checklist_version: 1.0
- [ ] A8-8 **P1** 只对明确可重试错误做有限重试与递增退避;风险、封禁、权限和结果不明确时不盲目重试
- [ ] A8-9 **P1** 流程结束先核验业务结果并完成日志 / 截图 / 可选视频,再保留合理结束缓冲后关闭会话
- [ ] A8-10 **P1** Agent 能区分成功、部分成功、失败、等待人工、等待确认和主动停止,并用业务语言解释结果
- [ ] A8-11 **P0** 关键元素均有真实拾取证据和 element map;没有真实 DOM/UIA/XML 时未由 AI 猜 selector
- [ ] A8-12 **P0** 不可逆动作定位器在目标作用域内唯一;没有用 `.first` / `hwnds[0]` / `instance=0` 掩盖歧义
- [ ] A8-13 **P1** 页面、窗口、弹窗、iframe、Native/WebView 等作用域明确;列表先定位业务行再定位动作元素
- [ ] A8-14 **P1** 重启浏览器/软件/App 后关键定位复测通过;动态列表顺序变化不会误点
- [ ] A8-15 **P1** 定位失败保存截图和 DOM/UIA/XML、候选数量、作用域及目标版本;结构变化时停止而非在线猜新定位器
- [ ] A8-16 **P1** 所有 fallback 均经过人工验证;图像/OCR/坐标仅在控件树确认不可用后使用
---

View File

@@ -134,6 +134,8 @@ scripts/
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读总纲与四份标准**:`AI_RPA.md`(职责、合规、风控与完整闭环)、`ACCOUNT_MANAGER.md`(账号 / Profile / 租约 / 多账号 / 查看排障)、`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。
- 写任何界面操作代码前再读 `RPA_ELEMENT_LOCATING.md`,先完成真实拾取、element map、唯一性和后置状态。
- 浏览器读 `RPA_BROWSER.md`;Windows 桌面读 `RPA_DESKTOP.md`;Android/iOS 读 `RPA_MOBILE.md`。
2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map):
- 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`:
- `examples/real_browser_rpa/scripts/service/browser_session.py`
@@ -156,7 +158,8 @@ scripts/
```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。
5. **桌面/手机**:本期标准见 RPA.md 第 2/3 节,复用 `jiangchang_desktop_sdk` / `screencast`,**不要在新 skill 里重复造包**(尚待实战验证)。
5. **Windows 桌面**:技术路线已由 `add-friend-wecom` 全链路验证。先读 `examples/real_desktop_rpa/README.md` 与 `development/RPA_DESKTOP.md`(含 Name/Value、最大化激活、同名叶子父容器),复用窗口唯一性、UIA 作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。
6. **手机**:先按 `RPA_MOBILE.md` 完成设备、context、element map 和验收设计。当前 `examples/mobile_rpa/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。
**技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。
@@ -882,7 +885,9 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag)
- [ ] `.github/workflows/release_skill.yaml` 存在
- [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`)
- [ ] 主任务入口(如 `cmd_run`)有关键日志:`task_start`、`task_log_saved`;失败路径使用 `logger.exception`
- [ ] 主任务入口有关键日志事件词:`task_start`、`task_log_saved`;失败路径 `task_failed error_code=` + `logger.exception`/`warning`(见 [`LOGGING.md`](LOGGING.md) §0、§3)
- [ ] RPA / 域模块(`scripts/service/<domain>/` 等)非零文件日志;`print(ERROR:)` 旁有 logger(`POLICY-LOGGING-006`~`008`)
- [ ] 本地跑一次主任务后,统一日志能 grep 到 §3.4 最低可验收串(不只 `cli_start`)
- [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md))
- [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行)

View File

@@ -1,6 +1,26 @@
# 日志规范
本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文,并在 code review 时对照「必打日志节点清单」人工检查。
本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文。
自动 hard policy(`POLICY-LOGGING-001`~`008`)只保证底线;阶段是否够细仍须对照本文事件词表做 review。复制技能后可用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample) 做契约断言。
---
## 0. 验收铁律(先读)
| 通道 | 给谁看 | 能否单独当排障依据 |
|------|--------|-------------------|
| 统一文件日志 `jiangchang.log` | 运维 / 离线打包 | **必须能单独定位到失败阶段与 error_code** |
| Activity / 任务中心 `emit` | 用户进度 | **不能**替代文件日志 |
| `print` / stdout | CLI 一眼结果 | **禁止**作为唯一失败记录 |
| `task_logs` | 终态审计 | 摘要层;**不能**替代逐步 trace |
硬规定:
1. **没有关键节点文件日志 = 客户现场不可排障。**
2. **`emit` ≠ 文件日志**:任务中心有进度,而 `jiangchang.log` 只有 `cli_start`,视为交付不合格。
3. **失败出口必须先写文件日志,再 `print` / `finish`**:凡 `print(...ERROR:...)`、`finish(status=failed|partial)`、队列 `mark_failed`,同一出口须有 `log.warning` / `log.exception`(含 `error_code=` 或 `task_failed`)。
4. **域模块也要打点**:`scripts/service/<domain>/`、`*_flow.py`、`*_driver.py` 等业务文件不得零日志(见 `POLICY-LOGGING-006`)。
---
@@ -24,8 +44,6 @@ OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览
| 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 |
| 存证路径 | 截图、录屏、`video_log`、失败 artifacts |
**没有关键节点日志 = 客户现场不可排障。** 不要把 `print` 当作日志;stdout 面向用户与 Agent,文件日志与 Run Journal 面向运维。
---
## 2. 日志分层
@@ -130,36 +148,73 @@ Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`
---
## 3. 必打日志节点清单
## 3. 固定事件词表(生产代码须出现)
以下节点应在**生产代码**中有对应 `log.info` / `log.warning` / `log.exception`(或 Activity / RPA step)。复制模板后按业务增删,但**不可整体省略**。
日志消息推荐 **key=value**。下列**事件名字符串**须出现在 `log.info` / `log.warning` / `log.exception` 的消息中(可用 f-string / `%s`,但事件名本身须可 grep)。
| 阶段 | 建议日志 / emit |
|------|-----------------|
| CLI 入口启动 | `cli_start`(`cli/app.py`,已由模板提供) |
| 参数解析完成 | `run_args target_id=... input_id=...` |
| 鉴权开始 | `entitlement_check_start` |
| 鉴权成功 | `entitlement_ok` |
| 鉴权失败 | `entitlement_failed`(`warning`) |
| 任务开始 | `task_start task_type=... target_id=... input_id=... batch_id=...` + `emit("开始处理任务")` |
| adapter / test target 选择 | `adapter_selected adapter=... system=...` |
| 账号 / profile / lease 获取 | `account_acquired account_id=...` |
| 账号 / lease 释放 | `account_released account_id=...` |
| 外部 API 开始 | `external_call_start system=... operation=...` |
| 外部 API 结束 | `external_call_done system=... operation=... elapsed_ms=... status=...` |
| 兄弟技能调用开始 / 结束 | 同上,或 `sibling_call_start/done skill_slug=...` |
| RPA 操作开始 / 结束 | `rpa_start` / `rpa_done` + `video.add_step` 中文步骤 |
| 打开页面 | step:「打开 xxx 页面」 |
| 检查登录 | step + log |
| 定位输入框 / 点击提交 | step + log |
| 等待结果 | step + log(含 timeout 预期) |
| 批量循环 | `batch_progress current=... total=...` |
| 重试 | `retry_attempt n=... reason=...`(`warning`) |
| 跳过 / 部分失败 | `skipped` / `partial_failure`(`warning`) |
| 人工介入 | `human_intervention type=captcha|login|ukey|otp`(`warning`) |
| 失败截图 / 视频 | `artifact_saved artifact_path=...` / `video_path=...` |
| 任务成功 | `task_success ...` + `task_log_saved status=success` |
| 任务失败 | `task_failed ...`(`exception` 保留 traceback)+ `task_log_saved status=failed` |
### 3.1 长任务通用最低集(所有业务 skill)
| 事件名 | 级别 | 说明 |
|--------|------|------|
| `cli_start` | info | CLI 入口(模板已提供) |
| `task_start` | info | 主任务开始(含 `task_type=`) |
| `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 |
| `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 |
| `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` |
| `task_log_saved` | info | 写入 `task_logs` 后 |
### 3.2 RPA / 桌面 / 浏览器额外最低集
当 `scripts/service/` 使用 `RpaVideoSession` / `uiautomation` / `playwright` / `@rpa_step` 时,额外要求:
| 事件名 | 说明 |
|--------|------|
| `rpa_start` / `rpa_done` 或 `rpa_failed` | RPA 段起止(可与 `task_*` 同条合并,但事件名须可 grep) |
| `stage=` | 至少 3 个不同阶段里程碑(如 `open_window` / `locate_control` / `submit` / `wait_result`) |
| `risk_blocked` | 若有配额/工作时段/熔断等前置拦截 |
**不要**要求每个 Click 一行日志;要求的是**阶段里程碑 + 全部失败出口**。
### 3.3 其它常见节点(按业务选用)
| 阶段 | 建议事件 |
|------|----------|
| adapter 选择 | `adapter_selected adapter=...` |
| 账号 / lease | `account_acquired` / `account_released` |
| 外部 API | `external_call_start` / `external_call_done`(含 `elapsed_ms=` / `status=`) |
| 兄弟技能 | `sibling_call_start` / `sibling_call_done` |
| 重试 / 跳过 / 人工 | `retry_attempt` / `skipped` / `human_intervention` |
| 存证 | `artifact_saved` / `video_path=` |
### 3.4 最低可验收日志串(示例)
mock 或真实跑完一条主路径后,`jiangchang.log`(或宿主「运行日志」)应能 grep 到类似:
```text
cli_start argv=...
task_start task_type=add-one target_id=...
entitlement_ok task_type=add-one
rpa_start stage=open_window
rpa_start stage=locate_control
rpa_start stage=submit
task_failed error_code=ELEMENT_NOT_FOUND ...
task_log_saved status=failed
```
成功路径则出现 `task_success` 而非 `task_failed`。若只有 `cli_start` → **不合格**。
---
## 3.5 必打节点与 Activity 对照
以下节点除文件日志外,长任务仍应 `emit` / `video.add_step`(用户可见),但**不能只 emit 不打文件日志**。
| 阶段 | 文件日志 | Activity |
|------|----------|----------|
| 任务开始 | `task_start` | `emit("开始…")` |
| RPA 步骤 | `stage=...` | `add_step` / `@rpa_step` |
| 批量进度 | `item_start` / `batch_progress` | `emit(..., progress=)` |
| 失败 | `task_failed error_code=` | `finish(failed\|partial)` |
---

View File

@@ -30,6 +30,9 @@
| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception`,并写入 `save_task_log` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` |
| POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.md;development/RPA.md | hard | 扫描 `scripts/service/task_service.py` 含 `emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` |
| POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` |
| POLICY-LOGGING-006 | `scripts/service/**/*.py` 业务域文件须有 `log/logger/_logger.(info|warning|error|exception)`;豁免薄工具:`__init__.py`、`task_run_support.py`、`entitlement_service.py`、`sibling_bridge.py` | development/LOGGING.md §0、§3 | hard | 扫描 service 树 | `tests/test_development_policy_guard.py::TestPolicyLogging006` |
| POLICY-LOGGING-007 | 若 service 层含 RPA 标记(`RpaVideoSession` / `uiautomation` / `playwright` / `rpa_step`),则须出现事件词 `task_start`、`task_failed` 或 `rpa_failed`、以及 `stage=` | development/LOGGING.md §3.1–§3.2 | hard | 合并扫描 `scripts/service/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyLogging007` |
| POLICY-LOGGING-008 | `scripts/service/**/*.py` 中若 `print` 含 `ERROR:`,同一函数体内须有 `log/logger/_logger.(warning|error|exception)` | development/LOGGING.md §0 铁律 3 | hard | 按 `def` 切分共现检查 | `tests/test_development_policy_guard.py::TestPolicyLogging008` |
| POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5;development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` |
| POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(` 与 `finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` |
| POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`(RPA 等待须 `interruptible_sleep`) | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` |
@@ -59,6 +62,7 @@
| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 |
| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` |
| RPA 真实轨迹、选择器纪律、状态等待、HITL 超时与有限重试是否正确实现 | development/AI_RPA.md;development/RPA.md §0–§1 | 文档和默认节奏已守护,真实行为与 DOM 质量仍需人工验收 |
| RPA 阶段日志是否「够细」(每个控件) | development/LOGGING.md §3.2 | hard 已要求里程碑事件词与域模块有日志;逐步粒度靠 sample 契约与验收 |
| adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 |
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
| platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |

View File

@@ -9,7 +9,7 @@
| 步骤 | 做什么 | 详见 |
|------|--------|------|
| 1 | **拿到仓库**:① 项目经理在 [自建 Gitea](https://git.jc2009.com/)(`git.jc2009.com`,非 Gitea 官网)开仓并授权后 `git clone`;或 ② 本地已有 `skill-template` 时用 scaffold / 复制出新目录(遵守 Git 红线) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §1、§4 |
| 2 | **先读 AI + RPA 总纲**;浏览器技能再读账号 / Profile / 租约标准,理解真实多账号场景 | [`AI_RPA.md`](AI_RPA.md)、[`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) |
| 2 | **先读 AI + RPA 总纲**;界面技能先完成元素拾取/element map,再按浏览器、桌面或手机读分端手册 | [`AI_RPA.md`](AI_RPA.md)、[`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) |
| 3 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) |
| 4 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 |
| 5 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 |
@@ -34,12 +34,14 @@
7. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
8. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
9. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
10. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
11. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
12. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
13. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
14. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
14. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/)
10. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时的会话、风控和工程总纲
11. [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) — **所有界面技能必读**:真实拾取、element map、唯一性、降级和 AI 编程约束
12. 按执行端选读:[`RPA_BROWSER.md`](RPA_BROWSER.md) / [`RPA_DESKTOP.md`](RPA_DESKTOP.md) / [`RPA_MOBILE.md`](RPA_MOBILE.md)
13. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
14. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
15. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
16. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
17. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/)
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md)。
用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md),不要写进本目录。

View File

@@ -54,6 +54,10 @@
### RPA 风控设计
- 执行端:浏览器 / 桌面 / Android / iOS:
- 真实界面版本、拾取工具和拾取日期:
- element map 文件路径;关键元素是否全部登记:
- 每个关键元素的作用域、主定位器、匹配数量和已验证 fallback:
- 页面 / iframe / 窗口 / 弹窗 / Native / WebView 如何选择:
- 会话、Profile、窗口或设备如何准备与互斥:
- 单批上限、单位时间操作量和最长运行时间:
- 普通动作节奏(真实环境默认 `1~3` 秒):
@@ -63,6 +67,7 @@
- 哪些步骤进入人工处理(验证码、人脸、指纹、短信、U盾等):
- 如何防止重复提交并支持必要的断点续跑:
- 需要保存哪些日志、截图、视频和业务结果:
- 定位失败时如何保存 DOM/UIA/XML、候选数量和环境版本:
## 1. 文档目标
@@ -212,12 +217,15 @@
- 安装后按本技能 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或其他规避平台限制的实现
- RPA 真实主路径已验证状态等待、`1~3` 秒正常操作节奏、有限重试、账号 / 设备串行、人工介入、结果核验和结束存证
- RPA 关键元素已在真实界面拾取并登记 element map;关键动作唯一定位,重启会话后复测通过
- 页面/窗口/设备结构变化时停止并保存截图与 DOM/UIA/XML,不运行时猜 selector
## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目
- 正常界面或获得授权的官方 API 无法完成闭环时,停止并上报;不得擅自改走破解、逆向或绕过安全机制的路线
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
- 涉及界面时再按端阅读 `RPA_ELEMENT_LOCATING.md` 和 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`)
- 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)

View File

@@ -8,6 +8,12 @@
我们开发的各类 skill,本质上都是在替人操作三类界面:**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:可控会话(Profile)、有头运行、拟人操作、失败存证、人工兜底。
**开始写界面操作代码前,必须先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md),完成真实拾取、元素清单、唯一性和后置状态设计。** 分端实操:
- 浏览器:[`RPA_BROWSER.md`](RPA_BROWSER.md)
- Windows 桌面:[`RPA_DESKTOP.md`](RPA_DESKTOP.md)
- Android / iOS:[`RPA_MOBILE.md`](RPA_MOBILE.md)
---
## 0. 三端通用范式(先看这个)
@@ -22,7 +28,7 @@
| **步骤间随机等待** | 每两个正常业务动作之间 `random_delay(min,max)`,真实环境默认 **1~3 秒**;状态等待另算,不得用固定睡眠代替 |
| **人工兜底(HITL)** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` |
| **选择器纪律** | 语义选择器优先(id/name/text/aria);**F12 确认后再写,严禁凭记忆猜 DOM** |
| **选择器纪律** | 先真实拾取并填写 element map;语义/可访问性属性优先;关键动作唯一匹配;**严禁凭记忆猜 selector 或用 first/index 掩盖歧义** |
| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 |
| **步骤可控制** | 每条 RPA 指令 = 一个 `@rpa_step` 或 `video.add_step`;等待用 `interruptible_sleep`;宿主通过 control.json 暂停/继续/停止(SRCP,见 [`LOGGING.md`](LOGGING.md) §2.5) |
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
@@ -127,6 +133,8 @@ async def open_login(page):
**选型:Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts`、`receive-order` 已落地)。
元素拾取、定位优先级、iframe/弹窗/Shadow DOM、动态列表和唯一性标准见 [`RPA_BROWSER.md`](RPA_BROWSER.md)。本节继续定义会话、启动、HITL 和工程边界。
### 1.0 生产路径(优先)
| 项 | 标准 |
@@ -275,13 +283,14 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
## 2. 桌面软件(Windows 原生程序)
**选型:pywinauto(UIA backend)为主 + 图像识别兜底。**
**选型:Windows UI Automation(UIA)为主 + 图像识别兜底。** 当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。完整拾取、定位和降级标准见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md)。
桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。
| 技术 | 优先级 | 适用 / 说明 |
|------|--------|------|
| **pywinauto(`backend="uia"`)** | ✅ 首选 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python |
| **uiautomation** | ✅ 已验证首选 | `add-friend-wecom` 真实成功案例使用;直接读取 UIA Window/Control 树 |
| **pywinauto(`backend="uia"`)** | ✅ 兼容 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python |
| **FlaUI**(经 pythonnet 调 .NET) | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 |
| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 |
| **PyAutoGUI / SikuliX(图像识别)** | ⚠️ 兜底 | 控件树完全拿不到时(Flash/远程桌面/纯自绘 UI);**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 |
@@ -295,11 +304,12 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
- **禁止侵入**:不注入或 Hook 第三方进程,不修改客户端文件,不反编译或调用未授权私有协议。
```bash
pip install pywinauto # UIA 自动化
pip install uiautomation # 当前真实成功案例
# 或:pip install pywinauto # UIA backend
# 图像兜底:pip install pyautogui opencv-python
```
> 状态:桌面端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
> 状态:UIA 技术路线已经 `add-friend-wecom` 真实项目验证。脱敏、收紧后的参考 example 见 `examples/real_desktop_rpa/`;目标软件 selector 仍必须重新拾取。
---
@@ -307,6 +317,8 @@ pip install pywinauto # UIA 自动化
**选型:Android 用 uiautomator2(或 Appium);iOS 用 Appium + WebDriverAgent(需 Mac)。** 底层都是经 USB 的 ADB / WDA。
设备准备、Appium Inspector/UIAutoDev 拾取、Native/WebView、定位优先级和失败 XML 留证见 [`RPA_MOBILE.md`](RPA_MOBILE.md)。
| 平台 | 技术 | 优先级 | 说明 |
|------|------|--------|------|
| **Android** | **uiautomator2**(python 原生) | ✅ 首选 | ADB over USB,直接拿控件树点击/输入,比 Appium 轻快,纯 Python |
@@ -330,7 +342,7 @@ python -m uiautomator2 init # 初始化设备端 agent
# 或统一走 Appium:pip install Appium-Python-Client(另需 Appium Server)
```
> 状态:手机端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
> 状态:手机端标准已定义,但**尚无团队真实成功案例**。`examples/mobile_rpa/` 仅是 example 完成规格,不能作为已跑通代码照抄;收到成功案例后再升级状态。
---
@@ -404,6 +416,10 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
## 相关文档
- `RPA_ELEMENT_LOCATING.md` — 三端共用的拾取、element map、唯一性、降级和 AI 编程约束
- `RPA_BROWSER.md` — 浏览器 Pick Locator、DOM 作用域和定位质量
- `RPA_DESKTOP.md` — Windows UIA 拾取与已验证桌面路线
- `RPA_MOBILE.md` — Android/iOS 拾取、context、设备与 XML 留证
- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
- `CONFIG.md` — `.env` 配置规范与首次落盘机制
- `RUNTIME.md` — 运行时目录与环境变量约定

123
development/RPA_BROWSER.md Normal file
View File

@@ -0,0 +1,123 @@
# 浏览器 RPA 元素拾取与定位标准
> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。本文只定义浏览器端的拾取、定位、验证和常见陷阱;账号、Profile、HITL 和录屏仍以 [`RPA.md`](RPA.md) 为准。
## 目录
1. 拾取工具
2. 拾取步骤
3. 定位优先级
4. 特殊上下文
5. 验证标准
6. 禁止模式
7. example 路由
## 1. 拾取工具
优先使用 Playwright Inspector / Codegen 的 **Pick Locator**,辅以 Chrome/Edge DevTools:
- Pick Locator:点选页面元素,获取 role/label/text/testid 等候选定位器。
- DevTools Elements:确认真实 DOM、属性、父容器、iframe 和 Shadow DOM。
- Playwright trace/screenshot:复核动作发生时的页面状态。
检查器只负责给出候选。复制到生产代码前必须人工确认作用域、唯一性和稳定性。
## 2. 拾取步骤
1. 使用真实 `profile_dir` 和有头浏览器打开目标页面。
2. 手工进入发生动作的准确页面、标签页和弹窗状态。
3. 用 Pick Locator 点选元素,先记录建议 locator。
4. 在 DevTools 中确认:
- accessible role/name
- label、placeholder、可见文本
- 稳定 id/name
- `data-testid`(仅目标系统已提供或自有系统可维护时)
- 所在 iframe、dialog、表格行或卡片
5. 先定位业务容器,再在容器内定位动作元素。
6. 运行 `count()`;关键动作期望 `1`。
7. 验证 `visible`、`enabled`、必要时 `editable/checked`。
8. 执行动作并断言 URL、业务容器、成功提示、单号或状态变化。
9. 刷新页面、关闭重开浏览器后复测。
## 3. 定位优先级
从高到低:
1. `get_by_role(role, name=...)`
2. `get_by_label(...)`
3. 自有系统的稳定 `get_by_test_id(...)`
4. `get_by_placeholder(...)`、`get_by_alt_text(...)`、`get_by_title(...)`
5. 稳定业务文本,优先用于非交互元素或父容器
6. 稳定 `id` / `name` / 短 CSS
7. 容器 + `filter(has_text=...)` / `has=...`
8. 短 XPath
9. 已验证的顺序定位
示例:
```python
dialog = page.get_by_role("dialog", name="订单确认")
submit = dialog.get_by_role("button", name="确认提交")
if await submit.count() != 1:
raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS submit_button")
await submit.wait_for(state="visible")
await submit.click()
await page.get_by_text("提交成功", exact=True).wait_for(state="visible")
```
### 列表与重复按钮
不要直接选择全页第一个“添加”:
```python
row = page.get_by_role("row").filter(has_text="客户A")
button = row.get_by_role("button", name="添加")
```
如果业务键不唯一,先停止并要求补充输入,不要用 `.first` 猜。
## 4. 特殊上下文
### iframe
先确认元素属于哪个 iframe,再使用 `frame_locator`。不得因为主页面找不到就改用坐标。
### 新标签页和弹窗
点击前注册 popup/new page 等待;取得新 Page 后再定位。关闭弹窗不等于业务成功。
### Shadow DOM
Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。
### 动态列表
等待列表完成加载后再读取;`locator.all()` 不会等待动态列表稳定。滚动或刷新后重新获取 locator。
### Canvas
先确认是否存在可访问性节点或外层业务控件。完全无 DOM 时才进入局部图像方案,并记录分辨率、缩放和识别区域。
## 5. 验证标准
- locator 在真实目标页面匹配数量符合元素清单。
- 不因语言、窗口宽度、列表顺序或一次刷新就指向不同业务对象。
- 动作前检查可见、可用;动作后等待真实业务状态。
- 登录态至少结合两个证据:例如稳定用户菜单 + 登录按钮不存在,不能只看单个哈希 class。
- 真实和仿真站明确区分:仿真可以使用 `fill()` 加速测试;真实生产输入遵守项目拟人节奏。
## 6. 禁止模式
- 用全页所有 `input` 的尺寸和位置猜搜索框。
- 用 `.first` 绕过严格模式。
- 从 DevTools 复制深层 `nth-child` 或绝对 XPath。
- 只等 URL 含 `search` 就判定结果成功。
- 页面结构变化后动态生成 selector 并继续提交。
- 为方便测试给第三方网站注入 testid。
## 7. example 路由
- 真实网站、登录/HITL/滚动:[`../examples/real_browser_rpa/README.md`](../examples/real_browser_rpa/README.md)
- 自有 sandbox、表单和 adapter:[`../examples/simulator_browser_rpa/README.md`](../examples/simulator_browser_rpa/README.md)
复制 example 前先完成目标站元素清单;example 中的任何 URL、selector 和业务字段都不得跨平台照抄。

189
development/RPA_DESKTOP.md Normal file
View File

@@ -0,0 +1,189 @@
# Windows 桌面 RPA 元素拾取与定位标准
> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Windows 桌面首选 **UI Automation(UIA)**。当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。标准约束的是 UIA 方法,不强制混用两个库。
## 目录
1. 已验证基线
2. 拾取工具和步骤
3. 定位优先级
4. 窗口与控件规则
5. 输入、点击与等待
6. UIA 不可用时的降级
7. 诊断和留证
8. example 路由
## 1. 已验证基线
真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`(含通讯录 → 新的客户 → 添加 → 搜索 → 发送全链路)。
该案例验证了:
- 通过进程/窗口类名/标题找到企业微信顶层窗口。
- 主窗激活后**最大化**,避免侧栏折叠导致控件不可见或坐标漂移。
- 从 HWND 获取 UIA 根控件;在**子业务 pane**(如「企业微信-通讯录」)内缩小作用域。
- 区分 UIA `Name` 与 `Value`:可见中文常在 Value / 子 Label,不在 Name。
- 通用叶子名(`item` / `status_layout` / `wbtn_container`)必须绑业务父容器,并用文案校验。
- 真实点击后校验后置状态(标题、专用容器、子窗口)。
- 处理独立弹窗与嵌套弹窗。
- 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。
example 只提炼通用模式;企业微信名称、窗口类、控件名、列表顺序和业务结果不得复制到其他软件。
## 2. 拾取工具和步骤
可使用影刀元素拾取器、Microsoft Inspect/Accessibility Insights、FlaUInspect 或库自带树打印能力。
操作步骤:
1. 启动目标软件,登录并手工进入准确页面;需要完整侧栏时先**最大化**。
2. 记录目标进程名、PID、主窗口标题和 Window Class。
3. 用检查器点选目标元素,记录:
- AutomationId
- **Name**(内部 id,可能是 `org_op_new` / `status_layout`)
- **Value**(可见中文文案常在这里,如「通讯录」「新的客户」)
- ControlType / ClassName / FrameworkId
- IsEnabled / IsOffscreen / BoundingRectangle
- 父容器链(含业务子窗/pane 标题)
4. 保存截图和原始 UIA 树/拾取器导出文件。
5. 从长路径中找出稳定窗口、业务父容器和叶子属性。
6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据,且优先用文案定位。
7. 写入 element map(Name 与 Value 分开登记)。
8. 运行 `probe-ui`(建议先激活并最大化),验证关键窗口和元素。
9. 关闭重开软件、切换一次页面后再次验证。
### Name / Value(易错)
- 影刀/检查器里的**选择器显示名**(如「新的客户」)≠ 控件 UIA `Name`。
- 许多国产客户端:`Name` 是内部 id,**界面中文在 `Value` 或子控件 Value**。
- 拾取时两者都记;定位时勿把业务中文当成 `Name=` 去搜。
### 从拾取结果提炼定位器
影刀等工具可能导出很长的 UIA 路径。不要整条照抄:
```text
原始:主窗口 → 通讯录 pane → wlist_view → item[2] → status_layout
→ … → main_chattext(Value=新的客户)
提炼:通讯录 pane → wlist_view → 行内 main_chattext.Value=新的客户 → 点击该行 status_layout
禁止:全树第一个 Name=status_layout(常为「群聊」等其它行)
```
长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。
## 3. 定位优先级
从高到低:
1. `Process/PID + WindowClass + WindowTitle`(及稳定业务子 pane)
2. 目标窗口内 `AutomationId + ControlType`
3. 稳定内部 `Name + ControlType`(如 `org_op_new`、`searchedit`)
4. 业务父容器 + 稳定子元素属性
5. **可见业务文案(优先读 Value / 子 Label)+ 相对关系**
6. `ClassName/FrameworkId` 辅助组合
7. 有业务依据的 index(仅兜底;须写清父容器与序号含义,禁止「过滤后的列表 [2]」与「父节点 child index=2」混用)
8. UIA 元素实时 BoundingRectangle 派生坐标
9. 局部截图模板/OCR
10. 写死绝对坐标:禁止作为生产方案
顶层窗口或关键按钮出现多个候选时,返回 `ERROR:ELEMENT_AMBIGUOUS`,不得默认使用第一个。
同名叶子(如多处 `wbtn_container`)必须限制在业务父容器内(如 `search_external`),并可用子文案(如 Value=`添加`)校验;禁止点搜索旁通用「+」。
## 4. 窗口与控件规则
### 窗口
- 校验窗口属于预期进程和当前用户会话。
- 主窗口、**应用内业务子 pane**、搜索窗、确认窗分别建立作用域。
- 进入依赖完整侧栏/工具栏的页面时:**激活后最大化**;`activate` **禁止**对已最大化窗口无条件调用 `SW_RESTORE`(会还原成普通窗,导致折叠与坐标漂移)。仅最小化时才 Restore。
- 应用可能重建窗口;页面切换后重新取 HWND 和 UIA root。
- 自动化进程与目标进程权限级别必须兼容;不要为了绕过权限注入目标进程。
- 多开场景必须由账号、PID或明确窗口信息选择,歧义时停止。
### 控件
- 在最小业务父容器中搜索。
- 默认要求唯一匹配。
- 检查可见、启用、未离屏和边界矩形有效。
- 虚拟列表只包含当前实例化元素;滚动后重新枚举。
- 不长期保存 Control 对象。
## 5. 输入、点击与等待
### 点击
1. 激活目标窗口(主窗按需最大化;子对话框不要误 Maximize)。
2. 重新定位元素。
3. 检查边界矩形。
4. 使用 UIA Click/Invoke 或正常鼠标点击。
5. 等待后置窗口/控件/业务状态(如标题文案、专用容器、子窗口 Class)。
关键导航建议日志:`locate`(策略/文案)→ `click` → `verify`(后置)。这与 [`LOGGING.md`](LOGGING.md)「不必每个 Click 一行」不冲突:要求的是**关键跳转可观测**,不是像素级刷屏。
UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。
### 输入
- 先点击聚焦。
- 清空并逐字输入,敏感值不写日志。
- 输入后读取 Value/Text 或通过后续业务状态验证。
- 剪贴板可能泄露敏感信息,非必要不使用。
### 等待
优先等待:
- 目标窗口出现/消失。
- 控件出现、启用或状态变化。
- 列表产生业务结果。
- 成功/失败提示出现。
固定 sleep 只作短缓冲,并使用可中断等待;不能作为成功条件。
## 6. UIA 不可用时的降级
先检查:
1. 权限级别和 Windows 会话是否一致。
2. 是否选错进程或子窗口;主窗是否未最大化导致侧栏未实例化。
3. 控件是否离屏或尚未实例化。
4. 应用是否为 Electron(可评估 Playwright)。
5. 是否为自绘、游戏引擎、远程桌面或安全控件。
6. 空树/树抖动时:最大化主窗;用 Inspect/影刀附着一次后再 probe(远程桌面工具可能干扰无障碍树)。
只有控件树确认不可用时才使用图像:
- 固定或记录 DPI、缩放、分辨率和主题。
- 限定目标窗口和搜索区域。
- 记录模板来源、置信度阈值和多匹配处理。
- 匹配不唯一时停止。
- 点击后仍验证业务状态。
U盾、人脸、动态口令和安全键盘交给人工,不用 OCR、Hook 或坐标绕过。
## 7. 诊断和留证
桌面 `health` / `probe-ui` 至少输出:
- UIA 依赖是否可用。
- 目标进程和主窗口是否存在;是否已最大化(若业务需要)。
- 关键窗口/控件是否各唯一匹配。
- 当前窗口标题、Class、PID。
- DPI/缩放诊断。
example 里的最小 `probe`(只探主窗+个别入口)**不等于**生产 probe 完整度;业务技能应对关键跳转节点分别探测或做深探。
手开 PowerShell 跑 `stats` / `health` / dump 时,必须显式对齐宿主的 `CLAW_USER_ID` / 数据根,否则会读到默认用户空库,误判熔断/冷却。
定位失败保存:
- 目标窗口和全屏截图。
- UIA 树。
- 顶层窗口枚举。
- element map 逻辑名、候选数量和属性。
- 应用版本、Windows版本、DPI和会话类型。
## 8. example 路由
真实成功参考见 [`../examples/real_desktop_rpa/README.md`](../examples/real_desktop_rpa/README.md)。该 example 默认只演示 `probe`、`dry-run` 和 `assisted`;不可逆动作必须由业务技能显式实现确认策略。

View File

@@ -0,0 +1,176 @@
# RPA 元素定位与拾取总标准
> 本文定义浏览器、Windows 桌面和手机三端共用的“元素工程”标准。开始写任何界面操作代码前必须先完成元素拾取、元素清单和定位验证。分端实操分别见 [`RPA_BROWSER.md`](RPA_BROWSER.md)、[`RPA_DESKTOP.md`](RPA_DESKTOP.md)、[`RPA_MOBILE.md`](RPA_MOBILE.md)。
## 目录
1. 核心术语
2. 强制工作流
3. 元素清单
4. 定位器质量规则
5. 动作闭环
6. 降级决策树
7. 失败留证
8. AI 编程约束
9. 交付门禁
## 1. 核心术语
| 术语 | 定义 |
|---|---|
| **拾取** | 使用检查器在真实界面上点选元素,读取 DOM、UIA 或移动控件树属性 |
| **定位器** | 根据稳定属性在运行时重新找到元素的规则 |
| **作用域** | 页面、iframe、窗口、弹窗、父容器、列表行或 WebView 等查找边界 |
| **锚点** | 与目标元素存在稳定业务关系的父级、标题、标签或同一行元素 |
| **后置状态** | 动作完成后必须出现或消失的真实业务状态 |
| **降级** | 主定位器不可用时,改用已验证的备用定位器或图像方案 |
| **界面快照** | 失败现场的截图和 DOM/UIA/XML 控件树 |
## 2. 强制工作流
按以下顺序开发,不得跳过拾取直接猜 selector:
1. 在真实目标版本上手工走通业务流程,记录每一步的前置状态和成功表现。
2. 使用对应端检查器拾取元素,保存截图和原始 DOM/UIA/XML。
3. 识别正确作用域:页面/iframe、进程/窗口、Native/WebView、弹窗或列表行。
4. 为每个关键元素填写元素清单。
5. 按分端优先级生成候选定位器。
6. 验证候选定位器在目标作用域中匹配数量符合预期,关键动作默认必须恰好为 `1`。
7. 验证元素可见、启用、未离屏且边界矩形有效。
8. 执行动作,并等待真实后置状态。
9. 重启目标程序或会话后再次验证;动态列表还要更换数据验证。
10. 记录已验证的软件版本、页面状态、系统缩放/设备信息和日期。
未完成第 2~6 步时,代码只能保留明确的 `TODO: NEED_REAL_UI_INSPECTION`,不得写“看起来可能正确”的 selector。
## 3. 元素清单
每个关键输入、按钮、列表、弹窗、成功状态和错误状态都必须登记。推荐 YAML:
```yaml
logical_name: submit_button
purpose: 提交当前业务单据
surface: browser # browser | windows | android | ios
screen: 订单确认
scope:
kind: dialog
locator: role=dialog,name=订单确认
primary_locator:
strategy: role
value: button,name=确认提交
fallback_locators: []
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 出现订单号且状态为已提交
failure_evidence:
- screenshot
- ui_tree
verified_target_version: "待填写"
verified_at: "YYYY-MM-DD"
```
要求:
- `logical_name` 使用业务语义,不使用 `button1`、`element2`。
- `scope` 不得省略;全页面、全桌面或全设备盲搜不作为默认生产方案。
- `fallback_locators` 只能填写人工验证过的定位器。
- 不可逆动作必须同时登记确认前状态和提交后状态。
- selector 变化时更新 `verified_target_version` 和验证日期。
## 4. 定位器质量规则
### 4.1 必须满足
- 关键动作定位器在目标作用域中唯一。
- 定位器依赖业务语义、可访问性属性或稳定接口标识。
- 每次页面/窗口/列表刷新后重新定位,不长期缓存元素句柄。
- 同一元素的主定位器和 fallback 使用不同的稳定证据,不是同一脆弱路径的轻微改写。
- 多结果是合法业务场景时,先定位业务容器/行,再在容器内定位按钮。
### 4.2 默认禁止
- 用 `.first`、`.nth(0)`、`hwnds[0]`、`instance=0` 消除本应解决的歧义。
- 长绝对 XPath、深层 DOM/UIA 路径和大量 index。
- 哈希 class、随机生成 id、易变化坐标作为唯一依据。
- 扫描所有输入框后按宽度、位置“猜”目标。
- 捕获所有异常后继续尝试随机 selector 或随机坐标。
- UI结构变化后自动生成新 selector 并继续执行不可逆动作。
确有稳定业务顺序时可以使用 index,但必须在元素清单中写明锚点、顺序依据、匹配总数和版本验证结果。
## 5. 动作闭环
每一个动作实现为一个小状态机:
```text
确认前置状态
→ 获取正确作用域
→ 重新定位元素
→ 校验唯一/可见/可用
→ 激活并执行真实输入
→ 等待后置状态
→ 记录成功证据
```
- 缓冲等待只用于改善稳定性,不能替代后置状态。
- 点击后状态不明确时先查询业务状态,禁止再次点击。
- 付款、发送、删除、审批、发布等动作如果无法确认结果,返回 `ERROR:RESULT_UNCONFIRMED`。
- 列表滚动、页面切换、窗口重建和移动端 Activity 切换后重新获取元素。
## 6. 降级决策树
找不到元素时依次检查:
1. 目标应用、页面或设备是否正确启动并处于预期账号。
2. 是否选错页面、iframe、进程、窗口、弹窗、Native/WebView 上下文。
3. 元素是否尚未加载、被遮挡、禁用、离屏或在虚拟列表未实例化区域。
4. 是否因管理员权限、远程会话、设备授权或驱动版本无法读取控件树。
5. 主定位器属性是否变化;对比保存的界面快照。
6. 是否可以用已验证的父容器、业务文案或相对锚点定位。
7. 控件是否为 Canvas、自绘、远程桌面或未暴露可访问性树。
8. 最后才评估局部图像/OCR/坐标方案。
任何一级确认“界面结构变化”时,截图、导出控件树并停止;不得在生产任务中让 AI 临时猜新定位器。
## 7. 失败留证
定位失败至少保存:
- 当前屏幕/窗口截图。
- 当前 DOM、UIA 或移动端 XML。
- 页面 URL、进程/PID/窗口标题或设备 serial、包名、Activity、context。
- 逻辑元素名、作用域、主定位器和已尝试 fallback。
- 每个候选匹配数量、可见/启用/离屏状态和边界矩形。
- 目标应用版本、浏览器版本或设备系统版本。
- 失败阶段、错误码、时间和 batch_id。
敏感输入、Cookie、token、手机号和客户数据必须脱敏。
## 8. AI 编程约束
向 AI 编程工具提供本模板时,必须要求它:
1. 先阅读本总标准和对应分端手册。
2. 先输出业务步骤表和元素清单,再生成代码。
3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。桌面端须同时记录 **Name 与 Value**(可见中文常在 Value;见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md) §2)。
4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。
5. 为每个动作实现前置状态、定位、动作和后置状态。
6. 把诊断探针、界面树导出和失败截图作为实现的一部分。
7. 不把 mock 通过、命令未报错或弹窗关闭当成真实业务成功。
8. 不擅自切换到 Hook、注入、私有协议、验证码破解或其他禁止路线。
## 9. 交付门禁
- [ ] 关键元素均有元素清单和真实拾取证据
- [ ] 每个不可逆动作默认唯一定位
- [ ] 重启会话后定位仍然有效
- [ ] 动态数据或列表顺序变化后不会误点
- [ ] 每个动作有真实后置状态
- [ ] UI结构变化会停止并留证
- [ ] fallback 均已人工验证
- [ ] 截图和控件树不泄露敏感数据
- [ ] 真实主路径至少完整跑通一次
- [ ] example 状态明确区分“已验证成功”和“规范骨架”

141
development/RPA_MOBILE.md Normal file
View File

@@ -0,0 +1,141 @@
# 手机 RPA 元素拾取与定位标准
> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Android 首选 uiautomator2 或 Appium UiAutomator2;iOS 使用 Appium + XCUITest/WebDriverAgent。当前模板尚无团队真实成功案例,因此本文是强制设计标准,example 仅为规格骨架,不得宣称已生产验证。
## 目录
1. 状态与边界
2. 设备准备
3. 拾取工具和步骤
4. Android 定位优先级
5. iOS 定位优先级
6. Native/WebView 与动态界面
7. 手势、输入和等待
8. 降级与留证
9. example 路由
## 1. 状态与边界
- Android 标准可用于设计和实现,但交付前必须在授权真机和官方 App 上验证。
- iOS 必须有 Mac、签名可用的 WebDriverAgent 和授权设备。
- 不 Root、不越狱、不 Hook、不修改 APK/IPA、不伪造设备身份。
- 短信、人脸、指纹、系统安全确认交给人工。
## 2. 设备准备
每次运行前检查:
- 唯一设备 serial/UDID;多个设备时不得默认取第一个。
- USB/网络连接和调试授权。
- Android 包名/Activity,或 iOS bundle id。
- 屏幕是否解锁、方向、分辨率和系统弹窗状态。
- 自动化 driver/service 是否可用。
- 设备租约/互斥;同一设备默认串行。
`doctor` 应输出上述信息,但不得输出设备隐私数据。
## 3. 拾取工具和步骤
Android 可使用 Appium Inspector、uiautomator2 的 UIAutoDev/层级导出;iOS 使用 Appium Inspector。
1. 在真机上手工进入准确页面。
2. 刷新 Inspector screenshot 和 page source。
3. 在截图或 XML 树中点选元素。
4. 记录:
- Android:resource-id、content-desc、text、class、package、clickable、enabled、bounds
- iOS:name/accessibility id、label、value、type、enabled、visible
5. 确认当前 context 是 Native 还是正确 WebView。
6. 记录稳定父容器、列表行和业务锚点。
7. 在 Inspector 搜索定位器,确认匹配数量。
8. 写入 element map,保存 screenshot + XML。
9. 重启 App、重新进入页面并复测。
截图上看到的元素不一定存在于控件树;必须以 page source/可访问性树验证,不能直接凭坐标写代码。
## 4. Android 定位优先级
从高到低:
1. 稳定且唯一的 `resourceId`
2. `description/content-desc`(accessibility)
3. 稳定 `text + className`
4. 业务父容器内的子元素组合
5. UiSelector / 相对关系
6. 短 XPath
7. 已验证的 instance/index
8. 图像/坐标
示例:
```python
search = d(resourceId="com.example:id/search_input")
if search.count != 1:
raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS search_input")
search.click()
search.set_text(keyword)
if not d(resourceId="com.example:id/result_list").wait(timeout=10):
raise RuntimeError("ERROR:RESULT_TIMEOUT")
```
第三方 App 没有 resource-id 时,使用可访问性描述、稳定文案和父容器组合;不得修改第三方 APK 添加 id。
## 5. iOS 定位优先级
从高到低:
1. accessibility id / name
2. `type + name/label/value`
3. iOS predicate string
4. iOS class chain
5. XPath
6. 图像/坐标
XPath 依赖完整 XML 且通常较慢,只作后备。多个同名按钮时先定位 navigation bar、cell、alert 或其他业务容器。
## 6. Native/WebView 与动态界面
### Native/WebView
- 先枚举 contexts。
- Native 元素使用移动端控件树。
- WebView 元素切换到正确 web context 后使用 Web 定位器。
- context 数量或名称变化时按包名、URL或页面特征验证,不默认取第一个 WebView。
### 动态列表
- 虚拟列表只暴露屏幕附近元素。
- 每次滚动后重新定位。
- 用业务键/行文案选择条目,不依赖当前 instance。
- 到达末尾必须有明确状态或滚动前后内容对比。
### 系统弹窗
权限、通知、文件选择等可能属于系统包或 SpringBoard;为其建立独立作用域和显式处理策略,不把它们当业务 App 元素。
## 7. 手势、输入和等待
- 点击前确认元素可见、启用且 bounds 有效。
- 输入后读取文本或等待结果状态。
- 滑动以元素/屏幕相对区域描述,不跨设备写死绝对像素。
- 页面切换等待 Activity/view/context 或业务元素变化。
- 后台切前台、横竖屏变化后重新获取元素。
- 人工验证期间可用 scrcpy/Appium Inspector 辅助观察,程序轮询真实状态并有超时。
## 8. 降级与留证
找不到元素先检查 context、Activity、系统弹窗、滚动位置和页面 source。确认控件树未暴露后才评估局部图像/OCR。
失败保存:
- 设备 screenshot。
- page source XML。
- serial 脱敏值、包名、Activity、context 列表。
- 屏幕分辨率、方向和系统版本。
- element map 逻辑名、定位器和匹配数量。
- App 版本、driver 版本和失败阶段。
图像方案必须限定设备矩阵和搜索区域;匹配不唯一时停止。
## 9. example 路由
手机 example 规格见 [`../examples/mobile_rpa/README.md`](../examples/mobile_rpa/README.md)。在团队提供真实成功案例前,该目录只能作为实施清单和验收契约,不能作为“已跑通代码”宣传或直接复制。

View File

@@ -201,8 +201,11 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`、写入 `save_task_log`(`POLICY-LOGGING-002` / `003`)
- 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`)
- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`)
- **域模块**(`scripts/service/**`,豁免薄工具)是否有文件日志调用(`POLICY-LOGGING-006`)
- RPA 技能是否具备事件词 `task_start` / `task_failed|rpa_failed` / `stage=` / 鉴权事件(`POLICY-LOGGING-007`)
- `print(...ERROR:)` 是否与同函数内 `log.warning|error|exception` 共现(`POLICY-LOGGING-008`)
**局限**:自动检测只覆盖可静态判断的**底线**,**不**判断业务日志是否足够完整。复制新 skill 后,仍须人工对照 [`LOGGING.md`](LOGGING.md) §必打日志节点清单做 code review。
**局限**:hard 保证事件词与「失败先落盘」底线,**不**判断每个控件一步一日志。复制新 skill 后仍建议对照 [`LOGGING.md`](LOGGING.md) §3 事件词表,并按需启用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample)。
新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。

View File

@@ -8,14 +8,16 @@
根 `README.md`、`SKILL.md`、`references/`、`development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。
## 四类外部交互方式
## 外部交互示例矩阵
一级目录固定为四种模式(不可删除、不可移动):
adapter 仍是 `真实/仿真 × API/RPA` 四档;RPA 再按浏览器、Windows 桌面和手机三种界面拆分。原四类目录不可删除、不可移动,桌面和手机目录用于补足界面维度。
| 目录 | 类型 | 状态 | 适用场景 |
|---|---|---|---|
| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 |
| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 |
| `real_desktop_rpa` | 真实 Windows 桌面 RPA | **已有成功来源并已提炼** | UIA 拾取、窗口/控件、probe、dry-run/assisted |
| `mobile_rpa` | Android/iOS RPA | **仅规格,待成功案例** | 设备、控件树、Native/WebView、example 完成契约 |
| `real_api` | 真实 API | **占位** | 真实系统接口、token、权限、限流(尚未沉淀可复制实现) |
| `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器(尚未沉淀可复制实现) |
@@ -44,6 +46,8 @@
|---|---|
| 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** |
| 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** |
| Windows 原生软件 + UIA | **real_desktop_rpa** |
| Android/iOS App | **mobile_rpa**(当前仅规格,不可作为成功实现参考) |
| 真实系统 API | **real_api**(当前占位,不可作为实现参考) |
| mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) |
@@ -60,7 +64,8 @@ examples/<mode>/
tests/
```
- 仅 `simulator_browser_rpa` 可额外包含 `sandbox/`
- `simulator_browser_rpa` 可额外包含 `sandbox/`
- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture
- 仅未来确实需要样例数据时可加 `fixtures/`
- **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件
@@ -76,6 +81,8 @@ examples/<mode>/
- 真实浏览器 RPA → 参考 `examples/real_browser_rpa/`
- 仿真浏览器 RPA → 参考 `examples/simulator_browser_rpa/`(adapter 分层权威参考:`scripts/service/adapter/`)
- Windows 桌面 RPA → 参考 `examples/real_desktop_rpa/`(真实成功来源:`add-friend-wecom`)
- 手机 RPA → 先按 `examples/mobile_rpa/` 定义完成标准;真实成功案例补齐前不得照骨架宣称跑通
- `real_api/`、`simulator_api/` 当前为规划占位,**不得**作为实现参考
> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。

View File

@@ -0,0 +1,94 @@
# Mobile RPA Example Specification
本目录定义 Android/iOS example 必须具备的结构和验收契约。**当前没有团队真实成功案例,因此本目录不是可复制实现,也不得宣称已跑通。**
先读:
1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md)
2. [`../../development/RPA_MOBILE.md`](../../development/RPA_MOBILE.md)
## 为什么现在不放伪代码实现
移动端 selector、driver、Activity/context、权限弹窗和设备兼容性必须在真实授权设备上验证。没有成功案例时放一套看似完整的代码,会诱导开发者和 AI 猜 `resource-id`、默认取第一个设备或用坐标硬点,违背模板目标。
## 未来成功 example 的固定结构
```text
mobile_rpa/
README.md
android/
README.md
assets/element-map.yaml
scripts/service/device_session.py
scripts/service/mobile_locator.py
scripts/service/example_flow.py
tests/test_locator_contract.py
tests/integration/test_real_device.py.sample
ios/
README.md
assets/element-map.yaml
scripts/service/device_session.py
scripts/service/mobile_locator.py
scripts/service/example_flow.py
tests/test_locator_contract.py
tests/integration/test_real_device.py.sample
```
只有对应端出现真实成功案例后才创建代码文件。
## Android example 完成标准
- 唯一设备 serial 和设备互斥
- doctor:ADB授权、包名、Activity、屏幕状态、driver
- Appium Inspector/UIAutoDev 拾取步骤和原始 XML
- element map:resource-id/content-desc/text/class/父容器
- Native/WebView context 选择
- 点击、输入、滑动和真实后置状态
- 权限弹窗、系统弹窗和人工验证
- screenshot + page source 失败留证
- 虚拟列表滚动后重新定位
- 至少一台授权真机完整跑通
## iOS example 完成标准
- Mac、WebDriverAgent签名和设备 UDID 准备
- doctor:WDA、bundle id、系统版本、屏幕状态
- Appium Inspector 拾取和原始 page source
- accessibility id/name、predicate/class chain 定位
- Native/WebView context 选择
- 系统 alert 和人工验证
- screenshot + page source 失败留证
- 至少一台授权真机完整跑通
## element map 最小字段
```yaml
logical_name: login_button
surface: android
screen: 登录页
scope:
package: com.example.app
activity: .LoginActivity
primary_locator:
strategy: resource_id
value: com.example.app:id/login
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 首页业务标记出现
verified_device: 待填写
verified_app_version: 待填写
```
## 状态升级规则
满足以下全部条件后,才可把本目录状态改为“已有成功案例”:
1. 提供用户确认的成功项目来源。
2. 在真实授权设备上跑通完整业务闭环。
3. 保存脱敏 element map 和失败证据样例。
4. 有默认离线单测和显式真实集成测试。
5. 经过设备重连、App重启和页面变化验证。
6. 用户人工验收通过。

View File

@@ -33,6 +33,8 @@ real_browser_rpa/
│ └── util/
│ ├── constants.py # 平台常量、selector 占位
│ └── logging.py # 结构化日志与脱敏
├── assets/
│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写
└── tests/
├── test_human_verification.py # 验证码误判防护
└── test_stop_reason.py # 停止原因文案
@@ -55,12 +57,13 @@ real_browser_rpa/
## 开发新 skill 时如何复制
1. **先阅读**本 README 及下方「Copy map」「复制边界」章节
2. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
3. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
4. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
5. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
6. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
7. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
2. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map
3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
7. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
## Copy map(复制到新技能 `scripts/`)
@@ -73,6 +76,7 @@ real_browser_rpa/
| `examples/real_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 参考并合并编排层 |
| `examples/real_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 平台常量与 selector(须替换) |
| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 |
| `examples/real_browser_rpa/assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实 Pick Locator/F12 拾取后填写 |
| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 |
| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
@@ -121,6 +125,8 @@ real_browser_rpa/
- 不要照抄平台名、slug、logger 名
- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖
- 不要把示例里的 result selector 当成真实站点 selector
- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器
- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性
- 不要把示例代码原样发布为业务 skill
- 不要把真实账号、手机号、cookie、token 写入代码或 README

View File

@@ -0,0 +1,45 @@
surface: browser
status: placeholder_requires_real_site_inspection
elements:
- logical_name: search_input
purpose: 输入搜索关键词
scope:
kind: main
business_container: 待真实拾取
primary_locator:
strategy: role_or_label
value: 待真实拾取
fallback_locators: []
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 搜索结果容器出现
verified_target_version: 待填写
- logical_name: search_button
purpose: 提交搜索
scope:
logical_parent: search_form
primary_locator:
strategy: role
value: button,name=搜索
fallback_locators: []
expected_count: 1
postcondition:
description: 结果页或业务错误状态出现
verified_target_version: 待填写
- logical_name: result_container
purpose: 承载搜索结果
scope:
kind: main
primary_locator:
strategy: stable_business_container
value: 待真实拾取
fallback_locators: []
expected_count: 1
postcondition:
description: 列表项、空结果或结束标记可区分
verified_target_version: 待填写

View File

@@ -188,42 +188,22 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
async def _find_search_input(page) -> Tuple[Optional[Any], str]:
for sel in SEARCH_INPUT_SELECTORS:
try:
loc = page.locator(sel).first
if await _visible(loc):
loc = page.locator(sel)
count = await loc.count()
if count == 1 and await _visible(loc):
return loc, sel
if count > 1:
logger.warning("search_input_ambiguous selector=%s count=%s", sel, count)
except Exception:
continue
candidates: List[Tuple[int, Any, str]] = []
try:
inputs = await page.locator("input").all()
except Exception:
inputs = []
for inp in inputs:
try:
if not await inp.is_visible():
continue
box = await inp.bounding_box()
if not box or box.get("width", 0) < 120:
continue
placeholder = await inp.get_attribute("placeholder") or ""
score = box.get("width", 0)
if "搜索" in placeholder:
score += 500
if box.get("y", 9999) < 200:
score += 200
candidates.append((score, inp, f"input placeholder={placeholder!r}"))
except Exception:
continue
if candidates:
candidates.sort(key=lambda x: x[0], reverse=True)
_, inp, sel = candidates[0]
return inp, sel
try:
ph = page.get_by_placeholder(re.compile("搜索"))
if await ph.count() > 0 and await ph.first.is_visible():
return ph.first, "placeholder=搜索"
count = await ph.count()
if count == 1 and await ph.is_visible():
return ph, "placeholder=搜索"
if count > 1:
logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count)
except Exception:
pass
return None, ""
@@ -232,14 +212,24 @@ async def _find_search_input(page) -> Tuple[Optional[Any], str]:
async def _find_search_button(page):
try:
btn = page.get_by_role("button", name="搜索")
if await btn.count() > 0 and await btn.first.is_visible():
return btn.first
count = await btn.count()
if count == 1 and await btn.is_visible():
return btn
if count > 1:
logger.warning("search_button_ambiguous selector=role_button_search count=%s", count)
except Exception:
pass
try:
btn = page.locator(SEARCH_BUTTON_SELECTOR).first
if await btn.count() > 0 and await btn.is_visible():
btn = page.locator(SEARCH_BUTTON_SELECTOR)
count = await btn.count()
if count == 1 and await btn.is_visible():
return btn
if count > 1:
logger.warning(
"search_button_ambiguous selector=%s count=%s",
SEARCH_BUTTON_SELECTOR,
count,
)
except Exception:
pass
return None

View File

@@ -0,0 +1,152 @@
# Real Windows Desktop RPA Example
这是从已跑通业务技能 `D:\OpenClaw\client-marketing\add-friend-wecom` 提炼的 Windows UIA 参考案例。它用于教技术人员和 AI 编程工具如何从真实拾取结果得到稳定定位器,并完成窗口、控件、动作、等待和诊断闭环。
## 状态
- **技术路线已真实验证**:Windows UIA + Python `uiautomation`
- **来源业务已真实跑通**:企业微信添加客户(通讯录 → 新的客户 → 添加 → 搜索 → 发送)
- **本目录默认安全模式**:只提供 probe、dry-run、assisted 范式,不默认发送真实邀请
- **不是完整 skill 仓库**:按 Copy map 选择性复制
## 先读
1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md)
2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md)(含 Name/Value、最大化激活、同名叶子)
3. 本 README 的“原始拾取到生产定位器”
## 成功案例验证了什么
```text
枚举目标顶层窗口
→ 激活并最大化主窗(勿无条件 SW_RESTORE)
→ 从 HWND 获取 UIA root / 业务子 pane
→ 在父容器中定位(Name 与 Value 分开看)
→ 真实点击并校验后置状态
→ 等待子窗口或业务结果
→ 处理独立/嵌套弹窗
→ probe-ui 诊断
```
来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。
本目录 `probe()` 是**最小探针**(主窗 + 通讯录入口,可选尝试新的客户行),**不等于**生产技能完整 probe。
## 目录结构
```text
real_desktop_rpa/
README.md
assets/
element-map.sample.yaml
selector-capture.sample.xml
scripts/
service/
desktop_session.py
uia_locator.py
wecom_reference_flow.py
util/
selectors.py
tests/
test_selector_contract.py
```
## 原始拾取到生产定位器
### 搜索框(添加客户窗)
```text
原始:Window(SearchExternalsWnd) → 多层 Layout/index → search_layout → searchedit
提炼:窗口 → search_layout → Name=searchedit
```
### 新的客户(易错)
```text
原始:通讯录 pane → wlist_view → item[2] → status_layout → main_chattext
提炼:wlist_view 内找 子控件 main_chattext.Value=新的客户 的那一行,再点该行 status_layout
禁止:全树第一个 Name=status_layout(常为「群聊」)
禁止:把选择器标题「新的客户」当成 UIA Name 去搜
```
### 添加按钮
```text
提炼:search_external → wbtn_container(子文案 Value=添加)
禁止:搜索旁 plus_button 下的同名 wbtn_container
```
正确提炼方式:
1. 保留稳定顶层窗口:进程、Window Class、标题;需要时加上业务子 pane。
2. 保留最小业务父容器。
3. 叶子用稳定 Name **和/或** Value / 子控件 Value。
4. 删除只反映当前布局的深层 index;index 仅作已验证兜底并写清含义。
5. 验证父容器内候选数量为 `1`。
6. 重启应用后再次验证。
不要把完整 XML 路径或未校验的 `item[0]` 原样变成生产定位器。
## Element map
本目录的 [`assets/element-map.sample.yaml`](assets/element-map.sample.yaml) 只展示来源案例的元素登记方法。复制后必须重新拾取目标软件并全部替换。
关键规则:
- 窗口不唯一时失败,不取 `hwnds[0]`。
- 控件不唯一时失败,不取第一个匹配。
- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器,并用文案校验。
- 行顺序不是身份;用业务文案(Value)选择结果行。
- 主窗依赖完整侧栏时先 maximize;`activate(maximize=False)` 不得冲掉已最大化状态。
- 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。
## 三种实施模式
| 模式 | 行为 | 用途 |
|---|---|---|
| `probe` | 只检查依赖、窗口和关键控件 | 安装、自检、版本变更排障 |
| `dry-run` | 导航并填写,提交前停止 | 开发和验收默认 |
| `assisted` | 填写后等待用户人工确认 | 不可逆动作或现场联调 |
业务技能若增加 `auto`,必须实现明确确认策略、频率限制、业务后置状态和 `RESULT_UNCONFIRMED`。
## Copy map
| 来源 | 目标 | 说明 |
|---|---|---|
| `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活/最大化 |
| `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位(含 Value)、点击、输入 |
| `scripts/util/selectors.py` | `scripts/util/<platform>_selectors.py` | element spec;必须全部替换 |
| `scripts/service/wecom_reference_flow.py` | `scripts/service/<business>_flow.py` | 只复制流程分层,不复制企微 selector |
| `assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实拾取后填写 |
| `tests/test_selector_contract.py` | `tests/test_selector_contract.py` | 定位器契约测试思路 |
## 必须补齐的业务能力
- 目标应用安装/版本 doctor
- 真实业务元素清单
- 失败截图、UIA树和顶层窗口快照
- Activity/RpaVideoSession 步骤
- 不可逆动作确认
- 业务成功/失败/结果不明确状态
- 频率限制、幂等和断点续跑
- 真实集成测试(默认不进 CI)
## 禁止照抄
- `WXWork`、`WeWorkWindow`、`SearchExternalsWnd`
- `org_op_new`、`searchedit`、`wbtn_container`、`main_chattext`
- 企业微信列表第 0/1/2 行含义或固定 index
- 来源案例的本机路径、账号和业务数据
- 任何未经目标软件真实拾取验证的 fallback
## 验收
- `probe` 能明确报告依赖、窗口和关键元素(生产技能覆盖关键跳转节点)
- 每个关键元素在目标作用域内唯一
- 关闭重开软件后仍能定位
- 多窗口时不会误选
- UI变化时停止并保存截图/UIA树
- dry-run 不执行不可逆提交
- assisted 清楚提示用户确认
- 提交后仅在业务后置状态成立时报告成功

View File

@@ -0,0 +1,117 @@
source_case: add-friend-wecom
status: verified_source_redacted_example
surface: windows
driver: uiautomation
notes:
- 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label
- 禁止全树第一个 status_layout / wbtn_container
- 主窗流程先 maximize;activate 勿无条件 SW_RESTORE
elements:
- logical_name: main_window
purpose: 企业微信主窗口
scope:
process_name: WXWork
primary_locator:
window_class: WeWorkWindow
title: 企业微信
expected_count: 1
postcondition: UIA root 可读取;业务需要时已最大化
replace_in_new_skill: true
- logical_name: contacts_entry
purpose: 打开通讯录
scope:
logical_parent: main_window
primary_locator:
name: org_op_new
control_type: TreeItem
value_hint: 通讯录
expected_count: 1
postcondition: 出现企业微信-通讯录业务 pane
replace_in_new_skill: true
- logical_name: contacts_pane
purpose: 通讯录业务子层作用域
scope:
logical_parent: main_window
primary_locator:
name_contains: 通讯录
expected_count: 1
postcondition: 可枚举 wlist_view
replace_in_new_skill: true
- logical_name: new_customer_row
purpose: 侧栏进入「新的客户」
scope:
logical_parent: contacts_pane
ancestor_name: wlist_view
primary_locator:
row_leaf_name: status_layout
child_name: main_chattext
child_value: 新的客户
expected_count: 1
postcondition: panel_title_label Value=新的客户 或出现 search_external
failure_note: 禁止点全树第一个 status_layout(常为群聊)
replace_in_new_skill: true
- logical_name: panel_title
purpose: 校验已进入新的客户页
scope:
logical_parent: contacts_pane
primary_locator:
name: panel_title_label
value: 新的客户
expected_count: 1
replace_in_new_skill: true
- logical_name: add_customer_button
purpose: 打开添加客户窗口
scope:
logical_parent: contacts_pane
ancestor_name: search_external
primary_locator:
name: wbtn_container
child_name: ww_btn_text
child_value: 添加
expected_count: 1
postcondition: 出现 SearchExternalsWnd
failure_note: 禁止点搜索旁 plus_button 下的 wbtn_container
replace_in_new_skill: true
- logical_name: add_customer_window
purpose: 添加客户窗口
scope:
process_name: WXWork
primary_locator:
window_class: SearchExternalsWnd
title: 添加客户
expected_count: 1
postcondition: search_input 可定位
replace_in_new_skill: true
- logical_name: search_input
purpose: 输入待搜索账号
scope:
logical_parent: add_customer_window
ancestor_name: search_layout
primary_locator:
name: searchedit
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition: 结果列表或用户不存在弹窗出现
replace_in_new_skill: true
- logical_name: verify_message_input
purpose: 填写添加验证语
scope:
window_class: InputReasonWnd
title_any:
- 输入认证信息
- 发送添加邀请
primary_locator:
name: name_edit
expected_count: 1
postcondition: 输入值可读取或发送按钮可用
replace_in_new_skill: true

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
脱敏教学摘录:展示“拾取器长路径”而非可直接复制的生产 selector。
生产代码应保留稳定窗口、业务父容器和叶子属性,删除无业务含义的深层 index。
-->
<selector name="search_input" source="inspector-export">
<window process="WXWork" class="SearchExternalsWnd" title="添加客户" />
<uia name="content_container" index="1" />
<uia name="search_layout" index="0" />
<uia name="inner_search_layout" index="1" />
<uia name="searchedit" index="1" />
</selector>
<!--
新的客户:长路径示意 → 生产应改为
wlist_view 内 child main_chattext.Value=新的客户 的行,再点该行 status_layout。
禁止全树第一个 status_layout。
-->
<selector name="new_customer_row" source="inspector-export">
<window process="WXWork" class="WeWorkWindow" title="企业微信" />
<pane name="企业微信-通讯录" />
<uia name="wlist_view" />
<uia name="item" index="2" />
<uia name="status_layout" />
<uia name="main_chattext" value="新的客户" />
</selector>

View File

@@ -0,0 +1,129 @@
"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。"""
from __future__ import annotations
import ctypes
import ntpath
import time
from ctypes import wintypes
from dataclasses import dataclass
from typing import Iterable
from util.selectors import WindowSpec
class DesktopRpaError(RuntimeError):
def __init__(self, code: str, message: str) -> None:
super().__init__(f"ERROR:{code} {message}")
self.code = code
self.message = message
@dataclass(frozen=True)
class WindowCandidate:
hwnd: int
pid: int
process_name: str
class_name: str
title: str
visible: bool
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
SW_RESTORE = 9
SW_MAXIMIZE = 3
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
def _process_name(pid: int) -> str:
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return ""
try:
size = wintypes.DWORD(32768)
buffer = ctypes.create_unicode_buffer(size.value)
if not kernel32.QueryFullProcessImageNameW(
handle,
0,
buffer,
ctypes.byref(size),
):
return ""
return ntpath.splitext(ntpath.basename(buffer.value))[0]
finally:
kernel32.CloseHandle(handle)
def enumerate_top_windows() -> list[WindowCandidate]:
found: list[WindowCandidate] = []
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
def callback(hwnd, _lparam):
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
cls = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, cls, 256)
length = user32.GetWindowTextLengthW(hwnd)
title = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, title, length + 1)
found.append(
WindowCandidate(
hwnd=int(hwnd),
pid=int(pid.value),
process_name=_process_name(int(pid.value)),
class_name=cls.value,
title=title.value,
visible=bool(user32.IsWindowVisible(hwnd)),
)
)
return True
user32.EnumWindows(callback, 0)
return found
def resolve_unique_window(
spec: WindowSpec,
*,
candidates: Iterable[WindowCandidate] | None = None,
) -> WindowCandidate:
pool = list(candidates) if candidates is not None else enumerate_top_windows()
matched = [
item
for item in pool
if item.visible
and item.process_name.casefold() == spec.process_name.casefold()
and item.class_name == spec.class_name
and (spec.title is None or item.title == spec.title)
]
if not matched:
raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != 1:
raise DesktopRpaError(
"ELEMENT_AMBIGUOUS",
f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域",
)
return matched[0]
def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None:
"""激活窗口。
maximize=True:主窗进入依赖完整侧栏的流程时使用。
maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。
"""
hwnd = window.hwnd
iconic = bool(user32.IsIconic(hwnd))
zoomed = bool(user32.IsZoomed(hwnd))
if maximize:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not zoomed:
user32.ShowWindow(hwnd, SW_MAXIMIZE)
else:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not user32.SetForegroundWindow(hwnd):
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}")
time.sleep(0.35 if maximize else 0.2)

View File

@@ -0,0 +1,127 @@
"""作用域内 UIA 唯一定位、真实点击和逐字输入。"""
from __future__ import annotations
import random
from typing import Any, Iterator
from service.desktop_session import DesktopRpaError, WindowCandidate, activate_window
from util.selectors import ElementSpec
def require_uiautomation():
try:
import uiautomation as auto
except ImportError as exc:
raise DesktopRpaError("UIA_UNAVAILABLE", "缺少 uiautomation 依赖") from exc
return auto
def root_from_window(window: WindowCandidate):
auto = require_uiautomation()
return auto.ControlFromHandle(window.hwnd)
def control_value(control: Any) -> str:
try:
auto = require_uiautomation()
raw = control.GetPropertyValue(auto.PropertyId.ValueValueProperty)
return str(raw or "").strip()
except Exception:
return ""
def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
if depth > max_depth:
return
yield root
if depth == max_depth:
return
try:
children = root.GetChildren()
except Exception:
children = []
for child in children:
yield from _walk(child, depth=depth + 1, max_depth=max_depth)
def _child_has_value(control: Any, child_name: str, expected_value: str, *, max_depth: int = 10) -> bool:
for node in _walk(control, max_depth=max_depth):
if (getattr(node, "Name", None) or "") != child_name:
continue
if control_value(node) == expected_value:
return True
return False
def _matches(control: Any, spec: ElementSpec) -> bool:
if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name:
return False
if spec.value is not None and not spec.child_value_name:
if control_value(control) != spec.value:
return False
if spec.automation_id is not None and (
getattr(control, "AutomationId", None) or ""
) != spec.automation_id:
return False
if spec.control_type is not None and (
(getattr(control, "ControlTypeName", None) or "").removesuffix("Control")
!= spec.control_type.removesuffix("Control")
):
return False
if spec.class_name is not None and (
getattr(control, "ClassName", None) or ""
) != spec.class_name:
return False
if spec.child_value_name and spec.value:
if not _child_has_value(control, spec.child_value_name, spec.value):
return False
return True
def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
search_root = root
if spec.ancestor_name:
ancestor_spec = ElementSpec(
logical_name=f"{spec.logical_name}.ancestor",
name=spec.ancestor_name,
)
search_root = locate_unique(root, ancestor_spec, max_depth=max_depth)
matched = [
control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec)
]
if not matched:
raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != spec.expected_count:
raise DesktopRpaError(
"ELEMENT_AMBIGUOUS",
f"{spec.logical_name} 期望 {spec.expected_count} 个,实际 {len(matched)} 个",
)
return matched[0]
def click_control(
control: Any,
*,
window: WindowCandidate,
maximize: bool = False,
) -> None:
activate_window(window, maximize=maximize)
rect = control.BoundingRectangle
if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000:
raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效")
control.Click(
x=random.randint(-2, 2),
y=random.randint(-2, 2),
simulateMove=True,
)
def type_text(control: Any, text: str, *, window: WindowCandidate) -> None:
auto = require_uiautomation()
click_control(control, window=window, maximize=False)
auto.SendKeys("{Ctrl}a", waitTime=0.05)
auto.SendKeys("{Delete}", waitTime=0.05)
for char in text:
auto.SendKeys(char, waitTime=random.uniform(0.04, 0.12))

View File

@@ -0,0 +1,58 @@
"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。"""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from service.desktop_session import activate_window, resolve_unique_window
from service.uia_locator import locate_unique, root_from_window, type_text
from util.selectors import (
ADD_CUSTOMER_WINDOW,
CONTACTS_ENTRY,
MAIN_WINDOW,
NEW_CUSTOMER_ROW,
SEARCH_INPUT,
)
def probe() -> dict[str, Any]:
"""最小 probe:主窗 + 通讯录入口。
生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。
调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。
"""
main = resolve_unique_window(MAIN_WINDOW)
activate_window(main, maximize=True)
root = root_from_window(main)
contacts = locate_unique(root, CONTACTS_ENTRY)
payload: dict[str, Any] = {
"success": True,
"minimized_probe": True,
"main_window": asdict(main),
"contacts_entry": {
"name": getattr(contacts, "Name", ""),
"control_type": getattr(contacts, "ControlTypeName", ""),
},
}
# 可选:若已在通讯录页,尝试按 Value 定位「新的客户」行(失败不视为 probe 失败)
try:
row = locate_unique(root, NEW_CUSTOMER_ROW)
payload["new_customer_row"] = {"name": getattr(row, "Name", "")}
except Exception:
payload["new_customer_row"] = None
return payload
def fill_search_dry_run(target: str) -> dict[str, Any]:
"""要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。"""
search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW)
activate_window(search_window, maximize=False)
root = root_from_window(search_window)
search_input = locate_unique(root, SEARCH_INPUT)
type_text(search_input, target, window=search_window)
return {
"success": True,
"mode": "dry-run",
"message": "已填写搜索条件,未发送邀请",
}

View File

@@ -0,0 +1,63 @@
"""脱敏 UIA element specs;复制到新技能后必须用真实拾取结果替换。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class WindowSpec:
logical_name: str
process_name: str
class_name: str
title: Optional[str] = None
@dataclass(frozen=True)
class ElementSpec:
logical_name: str
name: Optional[str] = None
"""UIA Name(内部 id,未必是界面中文)。"""
value: Optional[str] = None
"""UIA Value(可见中文文案常在此)。"""
automation_id: Optional[str] = None
control_type: Optional[str] = None
class_name: Optional[str] = None
ancestor_name: Optional[str] = None
"""先定位到该 Name 的祖先,再在其下搜索。"""
child_value_name: Optional[str] = None
"""在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。"""
expected_count: int = 1
MAIN_WINDOW = WindowSpec("main_window", "WXWork", "WeWorkWindow", "企业微信")
ADD_CUSTOMER_WINDOW = WindowSpec(
"add_customer_window",
"WXWork",
"SearchExternalsWnd",
"添加客户",
)
CONTACTS_ENTRY = ElementSpec(
"contacts_entry",
name="org_op_new",
control_type="TreeItem",
)
# 示意:用子控件 main_chattext 的 Value 定位「新的客户」行(禁止全树第一个 status_layout)
NEW_CUSTOMER_ROW = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
ADD_BUTTON = ElementSpec(
"add_customer_button",
name="wbtn_container",
ancestor_name="search_external",
)
SEARCH_INPUT = ElementSpec(
"search_input",
name="searchedit",
ancestor_name="search_layout",
)

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from service.desktop_session import ( # noqa: E402
DesktopRpaError,
WindowCandidate,
activate_window,
resolve_unique_window,
)
from service import uia_locator # noqa: E402
from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402
class SelectorContractTest(unittest.TestCase):
def test_unique_window_is_required(self) -> None:
candidate = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True)
self.assertEqual(
resolve_unique_window(MAIN_WINDOW, candidates=[candidate]),
candidate,
)
def test_duplicate_windows_fail_instead_of_taking_first(self) -> None:
candidates = [
WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True),
WindowCandidate(2, 11, "WXWork", "WeWorkWindow", "企业微信", True),
]
with self.assertRaises(DesktopRpaError) as ctx:
resolve_unique_window(MAIN_WINDOW, candidates=candidates)
self.assertEqual(ctx.exception.code, "ELEMENT_AMBIGUOUS")
def test_hidden_window_is_not_a_candidate(self) -> None:
hidden = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", False)
with self.assertRaises(DesktopRpaError) as ctx:
resolve_unique_window(MAIN_WINDOW, candidates=[hidden])
self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND")
def test_activate_without_maximize_skips_restore_when_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = True
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=False)
user32.ShowWindow.assert_not_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_activate_maximize_when_not_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = False
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=True)
user32.ShowWindow.assert_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_locate_unique_matches_child_value(self) -> None:
row = MagicMock()
row.Name = "status_layout"
row.AutomationId = ""
row.ControlTypeName = "TreeControl"
row.ClassName = ""
label = MagicMock()
label.Name = "main_chattext"
label.GetChildren.return_value = []
row.GetChildren.return_value = [label]
root = MagicMock()
root.Name = "wlist_view"
root.AutomationId = ""
root.ControlTypeName = "TreeControl"
root.ClassName = ""
root.GetChildren.return_value = [row]
with patch.object(uia_locator, "control_value", side_effect=lambda c: "新的客户" if c is label else ""):
spec = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
# ancestor is root itself when searching from root with name wlist_view
found = uia_locator.locate_unique(root, ElementSpec("wlist", name="wlist_view"))
self.assertIs(found, root)
found_row = uia_locator.locate_unique(root, spec)
self.assertIs(found_row, row)
if __name__ == "__main__":
unittest.main()

View File

@@ -18,6 +18,7 @@
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态
## 目录结构

View File

@@ -80,9 +80,11 @@ Action 是**可选能力**:
| 字段 / 层 | 只决定什么 | 不决定什么 |
|-----------|------------|------------|
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async |
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async,不决定点了是否弹窗 |
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除」 | 不强制;无配置时宿主仍直写 SQLite |
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
@@ -93,13 +95,16 @@ Action 是**可选能力**:
| 层级 | 稳定字段 / 能力 |
|------|----------------|
| 顶层 | `schemaVersion`、`skill`、`actions` |
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`bind`** |
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`** |
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron` |
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` — 接管默认新增/编辑/删除(须 `bind.tables`) |
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` |
| confirmation | `{ "message": "..." }` |
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
## `bind.tables`(表级绑定)
@@ -122,6 +127,125 @@ Action 是**可选能力**:
- **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。
- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。
## `replaces`(可选:接管默认新增 / 编辑 / 删除)
有领域副作用的表(建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明:
```json
{
"id": "account-add-web",
"label": "新增账号",
"description": "登记网页账号并初始化 profile 目录。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": { "tables": ["accounts"] },
"inputSchema": {
"type": "object",
"required": ["platform_key", "account_label"],
"properties": {
"platform_key": { "type": "string", "title": "平台" },
"account_label": { "type": "string", "title": "账号名称" }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["add-web", "--platform", "{{platform_key}}", "--label", "{{account_label}}"]
}
}
```
规则:
- **全可选**:无 `replaces` 时,宿主默认按钮仍直写库,行为与以前一致。
- 取值:`create`(新增)/ `update`(编辑)/ `delete`(删除)。
- **必须**非空 `bind.tables`;同一技能同一表同一 `replaces` 至多一个 Action。
- 与 `placements` 正交:可只接管默认按钮(`placements: []`),不必再挂 toolbar「第二个新增」。
- 点默认按钮时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。
- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。
- 宿主导入仍直写:有 `replaces:create` 的表慎用宿主导入,或另做 import Action。
- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。
- **列元数据复用(lookup)**:create/update 抽屉可将 `inputSchema` 字段对齐业务列,复用中文名与 lookup 下拉:
- 优先让属性名 = 物理列名(如 `platform_key`);
- 或 `bind.columnMapping`:`{ "platform": "platform_key" }`;
- 或字段上写 `"column": "platform_key"`。
- **不要**在 actions.json 里写死会过期的全量平台 `enum`。
## `interaction`(点击交互方式)
与 `executionProfile` **正交**:一个 Action 可以是 `async` + `direct`(一点就进任务中心),也可以是 `sync` + `form`(填参后当场返回)。
| 值 | 数据管理体验 | 典型场景 |
|----|--------------|----------|
| `direct` | **一点即跑**,无弹窗 | 打开文件位置、无参只读、行内主键已映射 |
| `confirm` | 轻量确认弹窗(无参数区;文案优先 `confirmation.message`) | 打开浏览器、有副作用但无可编辑入参 |
| `form` | 完整参数表单(现状默认路径) | 导入、查询条件、需用户改写的字段 |
| `auto` / 省略 | 宿主推断:有**可编辑**参数 → `form`;仅有 `confirmation` → `confirm`;否则 → `direct` | 旧技能兼容;新技能建议显式写出 |
```json
{
"id": "reveal-profile",
"label": "打开文件位置",
"description": "在资源管理器中打开 Profile 目录。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": { "type": "number", "title": "账号 ID", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["reveal-profile", "{{accountId}}"]
}
}
```
```json
{
"id": "open-browser",
"label": "打开浏览器",
"placements": ["row"],
"executionProfile": "async",
"interaction": "confirm",
"confirmation": { "message": "将用该账号 Profile 打开浏览器,仅供查看。" },
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": { "type": "number", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["open", "{{accountId}}"]
}
}
```
可选 `interactionByPlacement`:同一 Action 在不同入口用不同交互(例如 `row: "direct"`、`toolbar: "form"`)。值只能是 `direct`/`confirm`/`form`,不能写 `auto`。
**边界:**
- **cron 配置**:仍展示可编辑参数;`confirmation.message` 可作为配置页风险提示(由宿主决定)。
- **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。
- 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。
默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例:
```json

View File

@@ -78,6 +78,8 @@ metadata:
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
若创建/更新/删除带有领域副作用(自动建目录、加密、级联清理),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete` 接管默认按钮,详见 [`ACTIONS.md`](ACTIONS.md)。
## 标准数据同步语义(外源 → 本地库)
适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。
@@ -131,6 +133,38 @@ metadata:
| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 |
#### 表级默认排序 `default_sort_json`(宿主支持)
技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退:
`updated_at DESC` → `created_at DESC` → 主键 DESC。
JSON 数组(可多字段,按顺序):
```json
[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]
```
规则:
- `column` 必须是该表真实列名;敏感列会被宿主忽略
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
示例(消息时间线):
```python
upsert_table_metadata(
cursor,
table_name="messages",
display_name="消息",
readonly=0,
default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]',
)
```
禁止:
@@ -354,7 +388,11 @@ CREATE TABLE demo_items (
## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`:
`options_json` 支持两种形态:
### 1)静态枚举(数组)
每项包含 `value` 与 `label`:
```json
[
@@ -366,6 +404,31 @@ CREATE TABLE demo_items (
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
### 2)表关联 lookup(对象,推荐用于外键式字段)
当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组):
```json
{
"ref_table": "platforms",
"value_column": "platform_key",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1
}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `ref_table` | 是 | 同库被引用表名(snake_case 标识符) |
| `value_column` | 是 | 写入当前字段的值列 |
| `label_column` | 是 | 下拉展示名列 |
| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) |
宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 下拉选项;**物理列仍存标量**(如 `platform_key` 文本、`account_id` 整数),不做嵌套子表。
示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表。
## 幂等初始化标准
- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化

View File

@@ -24,7 +24,8 @@ CREATE TABLE IF NOT EXISTS _jiangchang_tables (
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0
readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT
)
"""
@@ -82,23 +83,50 @@ TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
)
def _ensure_jiangchang_tables_columns(cursor) -> None:
"""旧库补列:CREATE IF NOT EXISTS 不会给已有表加新字段。"""
cursor.execute("PRAGMA table_info('_jiangchang_tables')")
cols = {str(row[1]) for row in cursor.fetchall()}
if "default_sort_json" not in cols:
cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN default_sort_json TEXT")
def init_display_metadata_tables(cursor) -> None:
cursor.execute(_JIANGCHANG_TABLES_DDL)
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
_ensure_jiangchang_tables_columns(cursor)
def upsert_table_metadata(cursor, *, table_name: str, display_name: str, description: str | None = None, visible: int = 1, readonly: int = 0) -> None:
def upsert_table_metadata(
cursor,
*,
table_name: str,
display_name: str,
description: str | None = None,
visible: int = 1,
readonly: int = 0,
default_sort_json: str | None = None,
) -> None:
"""写入表级元数据。
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
为空则宿主回退到 updated_at → created_at → 主键。
"""
cursor.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, description, visible, readonly)
VALUES (?, ?, ?, ?, ?)
INSERT INTO _jiangchang_tables (
table_name, display_name, description, visible, readonly, default_sort_json
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
visible = excluded.visible,
readonly = excluded.readonly
readonly = excluded.readonly,
default_sort_json = excluded.default_sort_json
""",
(table_name, display_name, description, visible, readonly),
(table_name, display_name, description, visible, readonly, default_sort_json),
)

View File

@@ -196,7 +196,7 @@ def _default_uses_unixepoch(default_value: object) -> bool:
def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]:
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。"""
"""校验 options_json:静态枚举数组,或 lookup 对象。"""
errors: list[str] = []
warnings: list[str] = []
if options_json is None or str(options_json).strip() == "":
@@ -206,8 +206,24 @@ def validate_options_json(options_json: str | None) -> tuple[list[str], list[str
except json.JSONDecodeError as exc:
errors.append(f"options_json 不是合法 JSON:{exc}")
return errors, warnings
if isinstance(parsed, dict):
for key in ("ref_table", "value_column", "label_column"):
if not str(parsed.get(key) or "").strip():
errors.append(f"options_json lookup 必须包含非空 {key}")
ident = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
for key in ("ref_table", "value_column", "label_column", "filter_column"):
raw = parsed.get(key)
if raw is None or raw == "":
continue
if not ident.match(str(raw)):
errors.append(f"options_json.{key} 必须是安全标识符,当前为 {raw!r}")
if parsed.get("filter_column") not in (None, "") and "filter_value" not in parsed:
warnings.append("options_json lookup 声明了 filter_column 但未提供 filter_value")
return errors, warnings
if not isinstance(parsed, list):
errors.append("options_json 必须是 JSON 数组")
errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)")
return errors, warnings
for index, item in enumerate(parsed):
if not isinstance(item, dict):

View File

@@ -70,7 +70,7 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s target_id=%s input_id=%s",
"task_start task_type=%s target_id=%s input_id=%s stage=run",
task_type,
target,
input_id,
@@ -79,7 +79,10 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason)
log.warning(
"entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED",
task_type,
)
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
tlr.save_task_log(
task_type=task_type,
@@ -103,12 +106,19 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
error_code="ENTITLEMENT_DENIED",
stage="auth",
)
print(f"❌ {reason}")
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
log.info("task_run_demo_start target_id=%s input_id=%s", target, input_id)
log.info("entitlement_ok task_type=%s stage=auth", task_type)
log.info("rpa_start stage=demo_prepare task_type=%s", task_type)
log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id)
rc, video_info = asyncio.run(_run_template_demo(target, input_id))
log.info("task_run_demo_done target_id=%s input_id=%s status=failed", target, input_id)
log.info("rpa_done stage=demo_prepare status=ok")
log.info(
"task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed",
target,
input_id,
)
_print_video_summary(video_info)
summary_payload = merge_video_into_result_summary(
@@ -135,6 +145,10 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
input_id,
)
log.warning(
"task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB",
task_type,
)
finish(
status="failed",
message="模板仓库未实现真实业务",
@@ -142,8 +156,9 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
template_demo=True,
target=target,
input_id=input_id,
error_code="TEMPLATE_STUB",
)
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
print("ERROR:TEMPLATE_STUB 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
return rc
except JobStopped:
raise

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.58"
SKILL_VERSION = "1.0.66"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.2"

View File

@@ -42,6 +42,10 @@
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。
### 5. `test_logging_contract.py.sample`
**适合:** 长任务 / RPA 技能,需断言统一文件日志含 `task_start`、`task_failed|task_success`、`error_code=` 等事件词(见 [`development/LOGGING.md`](../../development/LOGGING.md) §3)。复制后用 `IsolatedDataRoot` + mock 域 RPA,**不要**依赖真实企微/浏览器。
---
## 如何启用 service contract 样例

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""日志契约样例:复制为 tests/test_logging_contract.py 后按业务改入口。
验证主任务 mock/离线路径写入统一日志事件词(见 development/LOGGING.md §3)。
默认套件不收集本文件(.sample)。
"""
from __future__ import annotations
import io
import logging
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import patch
# 复制到 tests/ 根目录后取消下一行注释,并按技能改 import
# from _support import IsolatedDataRoot
class TestLoggingContractSample(unittest.TestCase):
"""示范:捕获 skill logger,断言关键事件词出现。"""
def test_main_path_emits_task_start_and_terminal_event(self) -> None:
self.skipTest(
"复制本文件为 tests/test_logging_contract.py 后:"
"用 IsolatedDataRoot + patch 域 RPA,调用 cmd_run/cmd_add_one,"
"对 logging.Handler 断言消息含 task_start 与 task_success|task_failed"
)
# 示例骨架(复制后实现):
# with IsolatedDataRoot():
# log = logging.getLogger("openclaw.skill.your_skill_slug")
# records: list[str] = []
# class _H(logging.Handler):
# def emit(self, record):
# records.append(record.getMessage())
# h = _H()
# log.addHandler(h)
# try:
# with patch(...), redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
# rc = cmd_add_one(...) # 或 cmd_run
# finally:
# log.removeHandler(h)
# blob = "\n".join(records)
# self.assertIn("task_start", blob)
# self.assertTrue("task_failed" in blob or "task_success" in blob)
# if "task_failed" in blob:
# self.assertIn("error_code=", blob)
def test_failure_path_logs_error_code_before_print(self) -> None:
self.skipTest(
"复制后:注入业务错误(如 ELEMENT_NOT_FOUND),断言 logger 含 "
"task_failed error_code=...,且 stdout 含 ERROR:CODE"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -190,7 +190,21 @@ class TestActionsManifest(unittest.TestCase):
placements = action.get("placements")
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
replaces = action.get("replaces")
if replaces is not None:
self.assertIn(
replaces,
{"create", "update", "delete"},
msg=f"{action_id} invalid replaces {replaces!r}",
)
bind = action.get("bind") or {}
tables = bind.get("tables") or []
self.assertTrue(
isinstance(tables, list) and len(tables) > 0,
msg=f"{action_id} replaces requires non-empty bind.tables",
)
else:
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
for placement in placements:
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
@@ -216,6 +230,31 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(bind_def.get("required"), ["tables"])
self.assertIs(bind_def.get("additionalProperties"), False)
def test_schema_defines_interaction_fields(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("interaction", props)
self.assertEqual(
set(props["interaction"].get("enum") or []),
{"auto", "direct", "confirm", "form"},
)
self.assertIn("interactionByPlacement", props)
by_placement = props["interactionByPlacement"]
self.assertIs(by_placement.get("additionalProperties"), False)
for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete"):
self.assertIn(key, by_placement.get("properties") or {})
self.assertEqual(
set((by_placement["properties"][key].get("enum") or [])),
{"direct", "confirm", "form"},
)
self.assertIn("replaces", props)
self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind")
self.assertEqual(
set(schema["$defs"]["mutationKind"].get("enum") or []),
{"create", "update", "delete"},
)
def test_manifest_validates_against_json_schema(self) -> None:
try:
import jsonschema
@@ -469,6 +508,8 @@ class TestActionsManifest(unittest.TestCase):
"additionalProperties",
"placements",
"executionProfile",
"interaction",
"confirmation",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
for forbidden in (

View File

@@ -46,6 +46,9 @@ POLICY_IDS = (
"POLICY-LOGGING-003",
"POLICY-LOGGING-004",
"POLICY-LOGGING-005",
"POLICY-LOGGING-006",
"POLICY-LOGGING-007",
"POLICY-LOGGING-008",
"POLICY-CONTROL-001",
"POLICY-CONTROL-002",
"POLICY-CONTROL-003",
@@ -201,6 +204,26 @@ LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
LOGGING_003_SOURCE = "development/LOGGING.md"
LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md"
LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.md"
LOGGING_006_SOURCE = "development/LOGGING.md §0、§3"
LOGGING_007_SOURCE = "development/LOGGING.md §3.1–§3.2"
LOGGING_008_SOURCE = "development/LOGGING.md §0 铁律 3"
LOGGING_006_EXEMPT_BASENAMES = frozenset(
{
"__init__.py",
"task_run_support.py",
"entitlement_service.py",
"sibling_bridge.py",
}
)
LOGGING_RPA_MARKERS = (
"RpaVideoSession",
"uiautomation",
"playwright",
"@rpa_step",
"rpa_step(",
)
LOGGING_CALL_PATTERN = re.compile(
r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(",
@@ -911,6 +934,121 @@ class TestPolicyLogging005(unittest.TestCase):
)
def _iter_service_py_files(skill_root: str) -> list[str]:
service_dir = os.path.join(skill_root, "scripts", "service")
if not os.path.isdir(service_dir):
return []
return list(_walk_files(skill_root, os.path.join("scripts", "service"), suffix=".py"))
def _service_layer_combined_text(skill_root: str) -> str:
parts: list[str] = []
for path in _iter_service_py_files(skill_root):
parts.append(_read_text(path))
return "\n".join(parts)
_LOGGING_DOMAIN_CALL_RE = re.compile(
r"\b(log|logger|_logger)\s*\.\s*(info|warning|error|exception)\s*\(",
re.IGNORECASE,
)
_PRINT_ERROR_RE = re.compile(r"\bprint\s*\(.*ERROR:", re.IGNORECASE | re.DOTALL)
_FUNC_DEF_RE = re.compile(r"(?m)^(async\s+)?def\s+\w+\s*\(")
def _scan_print_error_without_logger(skill_root: str, path: str, text: str) -> list[str]:
"""同一函数体内 print(...ERROR:) 须伴随 warning/error/exception 日志。"""
offenders: list[str] = []
matches = list(_FUNC_DEF_RE.finditer(text))
if not matches:
return offenders
for i, m in enumerate(matches):
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end]
if not _PRINT_ERROR_RE.search(body):
continue
if re.search(
r"\b(log|logger|_logger)\s*\.\s*(warning|error|exception)\s*\(",
body,
re.IGNORECASE,
):
continue
lineno = text[:start].count("\n") + 1
offenders.append(
f"{_rel(skill_root, path)}:{lineno}: print(ERROR:) without log.warning|error|exception in same function"
)
return offenders
class TestPolicyLogging006(unittest.TestCase):
def test_service_domain_modules_have_file_logging(self) -> None:
skill_root = get_skill_root()
offenders: list[str] = []
for path in _iter_service_py_files(skill_root):
base = os.path.basename(path)
if base in LOGGING_006_EXEMPT_BASENAMES:
continue
text = _read_text(path)
if not _LOGGING_DOMAIN_CALL_RE.search(text):
offenders.append(_rel(skill_root, path))
self.assertEqual(
offenders,
[],
msg=_policy_msg(
"POLICY-LOGGING-006",
LOGGING_006_SOURCE,
"service domain modules missing log/logger/_logger.(info|warning|error|exception):\n"
+ "\n".join(offenders),
),
)
class TestPolicyLogging007(unittest.TestCase):
def test_rpa_service_layer_has_event_tokens(self) -> None:
skill_root = get_skill_root()
combined = _service_layer_combined_text(skill_root)
if not any(marker in combined for marker in LOGGING_RPA_MARKERS):
self.skipTest("no RPA markers in scripts/service; LOGGING-007 N/A")
missing: list[str] = []
if "task_start" not in combined:
missing.append("task_start")
if "task_failed" not in combined and "rpa_failed" not in combined:
missing.append("task_failed|rpa_failed")
if "stage=" not in combined:
missing.append("stage=")
# entitlement tokens strongly recommended; require at least one
if "entitlement_ok" not in combined and "entitlement_failed" not in combined:
missing.append("entitlement_ok|entitlement_failed")
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-LOGGING-007",
LOGGING_007_SOURCE,
"RPA service layer missing event tokens: " + ", ".join(missing),
),
)
class TestPolicyLogging008(unittest.TestCase):
def test_print_error_paired_with_logger(self) -> None:
skill_root = get_skill_root()
offenders: list[str] = []
for path in _iter_service_py_files(skill_root):
text = _read_text(path)
offenders.extend(_scan_print_error_without_logger(skill_root, path, text))
self.assertEqual(
offenders,
[],
msg=_policy_msg(
"POLICY-LOGGING-008",
LOGGING_008_SOURCE,
"\n".join(offenders) or "print(ERROR:) must share function with log.warning|error|exception",
),
)
class TestPolicyControl001(unittest.TestCase):
def test_no_util_progress_module(self) -> None:
skill_root = get_skill_root()

View File

@@ -498,12 +498,60 @@ class TestDocsStandards(unittest.TestCase):
def test_examples_top_level_modes_present(self) -> None:
examples_dir = os.path.join(get_skill_root(), "examples")
for mode in ("real_" + "api", "real_browser_rpa", "simulator_api", "simulator_browser_rpa"):
for mode in (
"real_" + "api",
"real_browser_rpa",
"simulator_api",
"simulator_browser_rpa",
"real_desktop_rpa",
"mobile_rpa",
):
self.assertTrue(
os.path.isdir(os.path.join(examples_dir, mode)),
msg=f"examples/{mode}/ must exist",
)
def test_three_surface_locator_standards_are_present(self) -> None:
required = {
"development/RPA_ELEMENT_LOCATING.md": (
"元素清单",
"expected_count",
"ERROR:RESULT_UNCONFIRMED",
"AI 编程约束",
),
"development/RPA_BROWSER.md": (
"Pick Locator",
"iframe",
"Shadow DOM",
"禁止模式",
),
"development/RPA_DESKTOP.md": (
"uiautomation",
"add-friend-wecom",
"AutomationId",
"probe-ui",
),
"development/RPA_MOBILE.md": (
"Appium Inspector",
"resourceId",
"Native/WebView",
"尚无团队真实成功案例",
),
}
for rel, markers in required.items():
text = self._read(rel)
for marker in markers:
self.assertIn(marker, text, msg=f"{rel} missing {marker!r}")
def test_desktop_and_mobile_examples_state_validation_level(self) -> None:
desktop = self._read("examples/real_desktop_rpa/README.md")
self.assertIn("技术路线已真实验证", desktop)
self.assertIn("add-friend-wecom", desktop)
self.assertIn("dry-run", desktop)
mobile = self._read("examples/mobile_rpa/README.md")
self.assertIn("不是可复制实现", mobile)
self.assertIn("不得宣称已跑通", mobile)
def test_examples_are_not_full_skill_repos(self) -> None:
examples_dir = os.path.join(get_skill_root(), "examples")
for mode in os.listdir(examples_dir):

View File

@@ -1,5 +1,14 @@
# skill-template 工具
## 桌面 UIA 拾取(开发期)
```powershell
cd D:\OpenClaw\client-commons\skill-template
python tools/ui_inspector/run.py --scene demo
```
说明见 [`ui_inspector/README.md`](ui_inspector/README.md)。企微暖树依赖本机 `jiangchang-platform-kit>=1.2.5`(与技能同调 `ensure_ui_ready`)。
## 业务仓也可以直接 clone(来源 A)
若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接:

5
tools/ui_inspector/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# 仅本工具目录使用;不修改 skill-template 根 .gitignore
_captures/
__pycache__/
*.pyc
.pytest_cache/

View File

@@ -0,0 +1,49 @@
# OpenClaw UI Inspector(桌面试验版 v0.8.1)
> **有焦点的窗口 = 采集目标**(与大小无关)。
> 企微暖树:**只调用** `jiangchang_desktop_wake.ensure_ui_ready`(与技能同路径),不复制注入逻辑。
> 主窗 → profile `wecom`;搜索浮层 `SearchResultWindow2` → profile `wecom_search`(需 kit≥1.2.6)。
## 启动
```powershell
cd D:\OpenClaw\client-commons\skill-template
# 建议使用已安装 jiangchang-platform-kit>=1.2.5 的 Python(与宿主共享 venv 一致)
python tools/ui_inspector/run.py --scene demo
```
依赖:
```powershell
pip install -r tools/ui_inspector/requirements.txt
# 企微自动暖树(强烈建议)
pip install "jiangchang-platform-kit>=1.2.5" # 或指向你们 Gitea/本地 index
```
## 规则
1. 焦点在谁就采谁(排除 Inspector 自身 hwnd)
2. **自动拾取默认勾选**;点「开始流程」后先 **5 秒倒数**,请在此期间点击目标窗
3. Inspector **保持置顶**便于看进度;置顶 ≠ 焦点,不因此改采本面板
4. **不强制最大化**目标窗,保持操作者当时的大小/位置(仅最小化时会还原)
5. 不会因为窗口小 / FindAll 少而改采别的软件
6. **企微自动暖树(无感)**:识别到 `WeWorkWindow` 时,在准备/拾取前静默调用
`ensure_ui_ready("wecom")`;已暖则快速返回。结果写入每步 `meta.json` → `dump_stats.kit_wake`
## 空壳说明
- **已装 kit**:冷企微应被 n2-wake 暖到 `findall` 较大、`provider_cold=false`。若仍冷,看状态栏 `kit_wake: FAILED …`。
- **未装 kit**:行为与旧版相同,可能 `findall≈2`、`provider_cold=true`;状态栏会提示未安装 platform-kit。
## 验收建议(企微)
1. 打开并登录桌面企业微信
2. 启动 Inspector →「开始流程」→ 倒数内点到企微主窗
3. 看详情区 / 第一步 `meta.json`:`kit_wake.warm=true`,`findall` 通常 ≫ 80
4. 再搜联系人、开会话,确认 `groupbuddyname` 的 Value 能采到
## 冒烟(不弹 GUI)
```powershell
python tools/ui_inspector/smoke_check.py
```

View File

@@ -0,0 +1,3 @@
"""Desktop UI Inspector — development-only UIA capture tool (additive, disposable)."""
__version__ = "0.8.1"

View File

@@ -0,0 +1,12 @@
"""python -m tools.ui_inspector (from skill-template root, if tools is a package)
Prefer: python tools/ui_inspector/run.py
"""
from __future__ import annotations
import runpy
from pathlib import Path
if __name__ == "__main__":
runpy.run_path(str(Path(__file__).resolve().parent / "app.py"), run_name="__main__")

784
tools/ui_inspector/app.py Normal file
View File

@@ -0,0 +1,784 @@
"""Tk panel v0.8.1: 开始流程 → 手动拾取 / 自动串行循环 → 结束流程。
企微主窗 / 搜索浮层在识别/拾取前静默调用 kit ensure(wecom / wecom_search)。
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import threading
from pathlib import Path
import tkinter as tk
from tkinter import messagebox, simpledialog, ttk
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
import artifacts as art # noqa: E402
import desktop_uia as uia # noqa: E402
try:
from . import __version__
except Exception:
try:
import importlib.util
_spec = importlib.util.spec_from_file_location(
"ui_inspector_pkg", _HERE / "__init__.py"
)
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
assert _spec and _spec.loader
_spec.loader.exec_module(_mod)
__version__ = getattr(_mod, "__version__", "0.8.1")
except Exception:
__version__ = "0.8.1"
# Auto loop: after each dump finishes, wait this long, then dump again.
_AUTO_GAP_MS = 2000
# After frontmost target is resolved, short delay before first dump.
_AUTO_START_DELAY_MS = 400
# After「开始流程」with auto-pick: countdown so user can focus the target app.
_START_COUNTDOWN_SEC = 5
_FRONTMOST_WAIT_SEC = 15.0
class InspectorApp:
def __init__(
self,
*,
captures_root: Path,
scene: str = "desktop",
max_depth: int = 16,
max_nodes: int = 8000,
) -> None:
self.captures_root = captures_root
self.scene = scene
self.max_depth = max_depth
self.max_nodes = max_nodes
self.flow_dir: Path | None = None
self.flow: dict | None = None
self.last_window: dict | None = None
self.sticky_pid: int | None = None
self._warmed_pids: set[int] = set()
self._busy = False
self._auto_loop_active = False
self._pending_end = False
self._auto_after_id: str | None = None
self._countdown_after_id: str | None = None
self._countdown_left = 0
self.root = tk.Tk()
self.root.title(f"OpenClaw UI Inspector v{__version__} (desktop)")
self.root.geometry("840x660")
self.root.minsize(720, 540)
self.root.attributes("-topmost", True)
self.flow_name_var = tk.StringVar(value=self.scene)
self.auto_pick_var = tk.BooleanVar(value=True)
self.flow_var = tk.StringVar(value="流程:未开始(请先点「开始流程」)")
self.target_var = tk.StringVar(value="尚未拾取")
self.status_var = tk.StringVar(
value="用法:开始流程 →(默认自动拾取)5 秒倒数 → 串行 dump。"
"企微将自动 ensure_ui_ready(需 platform-kit≥1.2.5);选窗看焦点,不强制放大。"
)
self._build_ui()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _build_ui(self) -> None:
pad = {"padx": 8, "pady": 4}
top = ttk.Frame(self.root)
top.pack(fill=tk.X, **pad)
ttk.Label(top, text="流程名").pack(side=tk.LEFT)
ttk.Entry(top, textvariable=self.flow_name_var, width=28).pack(side=tk.LEFT, padx=4)
ttk.Button(top, text="打开输出目录", command=self.on_open_out).pack(side=tk.LEFT, padx=8)
btns = ttk.Frame(self.root)
btns.pack(fill=tk.X, **pad)
self.start_btn = ttk.Button(btns, text="1. 开始流程", command=self.on_start_flow)
self.start_btn.pack(side=tk.LEFT, padx=2)
self.pick_btn = ttk.Button(btns, text="2. 拾取当前页", command=self.on_pick_page)
self.pick_btn.pack(side=tk.LEFT, padx=2)
self.end_btn = ttk.Button(btns, text="3. 结束流程", command=self.on_end_flow)
self.end_btn.pack(side=tk.LEFT, padx=2)
self.auto_chk = ttk.Checkbutton(
btns,
text="自动拾取(串行·步间2秒)",
variable=self.auto_pick_var,
command=self.on_auto_toggle,
)
self.auto_chk.pack(side=tk.LEFT, padx=12)
info = ttk.LabelFrame(self.root, text="状态")
info.pack(fill=tk.X, **pad)
ttk.Label(info, textvariable=self.flow_var, wraplength=800, justify=tk.LEFT).pack(
anchor=tk.W, padx=6, pady=2
)
ttk.Label(info, textvariable=self.target_var, wraplength=800, justify=tk.LEFT).pack(
anchor=tk.W, padx=6, pady=2
)
ttk.Label(info, textvariable=self.status_var, wraplength=800, justify=tk.LEFT).pack(
anchor=tk.W, padx=6, pady=2
)
tree_box = ttk.LabelFrame(self.root, text="本步拾取结果")
tree_box.pack(fill=tk.BOTH, expand=True, **pad)
scroll = ttk.Scrollbar(tree_box)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.detail = tk.Text(
tree_box,
height=24,
wrap=tk.WORD,
yscrollcommand=scroll.set,
)
self.detail.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
scroll.config(command=self.detail.yview)
self.detail.insert(
tk.END,
"1) 填写流程名,点「开始流程」——新建一份流程文档\n"
"2) 默认已勾选「自动拾取」:先 5 秒倒数(请点目标窗到最前端),\n"
" 然后串行:dump → 保存 → 等 2 秒 → 再 dump … 直到「结束流程」\n"
" 本窗口保持置顶方便看进度;选目标看焦点并排除自身\n"
" 不强制最大化目标窗(保持你当时的大小/位置)\n"
"3) 也可取消自动,改为手动点「拾取当前页」\n"
"4) 点「结束流程」——退出循环并关闭文档\n\n"
"完整内容在 _captures/<流程>/flow.json、SUMMARY.md、steps/\n",
)
self.detail.configure(state=tk.DISABLED)
tip = (
"一个流程 = 一份文档;多次拾取 = 多个 steps。"
"自动拾取严格串行:上一拍完全结束才会开始下一拍。"
"给 AI 时优先丢 SUMMARY.md + 各步 locatable.json。"
)
ttk.Label(self.root, text=tip, wraplength=800, foreground="#444").pack(
fill=tk.X, padx=8, pady=6
)
self._refresh_button_states()
def _flow_is_open(self) -> bool:
return bool(self.flow_dir and self.flow and self.flow.get("status") == "open")
def _in_start_countdown(self) -> bool:
return self._countdown_after_id is not None or self._countdown_left > 0
def _refresh_button_states(self) -> None:
open_flow = self._flow_is_open()
counting = self._in_start_countdown()
# Manual pick only when open, idle, and not in auto loop / countdown
can_manual = (
open_flow
and not self._busy
and not self._auto_loop_active
and not counting
)
self.pick_btn.configure(state=tk.NORMAL if can_manual else tk.DISABLED)
# End must stay clickable during a long dump so the loop can be stopped
self.end_btn.configure(state=tk.NORMAL if open_flow else tk.DISABLED)
self.start_btn.configure(
state=tk.DISABLED if (self._busy or counting) else tk.NORMAL
)
# Don't flip checkbox mid-dump in a confusing way; allow toggle to stop loop
self.auto_chk.configure(state=tk.NORMAL)
def _set_status(self, text: str) -> None:
self.status_var.set(text)
def _set_detail(self, text: str) -> None:
self.detail.configure(state=tk.NORMAL)
self.detail.delete("1.0", tk.END)
self.detail.insert(tk.END, text)
self.detail.see("1.0")
self.detail.configure(state=tk.DISABLED)
def _show_picking_placeholder(self, *, auto: bool) -> None:
n = int((self.flow or {}).get("step_count") or 0) + 1
mode = "自动" if auto else "手动"
self.target_var.set(f"{mode}拾取中…(即将写入第 {n} 步)")
self._set_status(f"正在{mode}拾取:已清空本框上次结果,请稍候…")
self._set_detail(
"(已清空上次本步结果)\n\n"
f"正在读取控件树,完成后写入当前流程第 {n} 步…\n"
)
def _exclude_hwnds(self) -> set[int]:
try:
return {int(self.root.winfo_id())}
except Exception:
return set()
def _update_flow_label(self) -> None:
if not self.flow_dir or not self.flow:
self.flow_var.set("流程:未开始(请先点「开始流程」)")
return
if self._auto_loop_active:
auto = "LOOP"
elif self.auto_pick_var.get() and self._flow_is_open():
auto = "armed"
else:
auto = "off"
self.flow_var.set(
f"流程:{self.flow.get('flow_name')} | status={self.flow.get('status')} | "
f"steps={self.flow.get('step_count')} | auto={auto} | {self.flow_dir}"
)
def _cancel_auto_timer(self) -> None:
if self._auto_after_id is not None:
try:
self.root.after_cancel(self._auto_after_id)
except Exception:
pass
self._auto_after_id = None
def _cancel_countdown(self) -> None:
if self._countdown_after_id is not None:
try:
self.root.after_cancel(self._countdown_after_id)
except Exception:
pass
self._countdown_after_id = None
self._countdown_left = 0
def _stop_auto_loop(self) -> None:
self._auto_loop_active = False
self._cancel_auto_timer()
self._cancel_countdown()
def _start_auto_loop(self, *, first_delay_ms: int = _AUTO_START_DELAY_MS) -> None:
self._auto_loop_active = True
self._pending_end = False
self._update_flow_label()
self._refresh_button_states()
self._schedule_auto_next(first_delay_ms)
def _set_inspector_topmost(self, enabled: bool) -> None:
try:
self.root.attributes("-topmost", bool(enabled))
except Exception:
pass
def _ensure_inspector_visible(self) -> None:
"""Keep panel on-screen for progress; do not force-focus (avoids stealing FG)."""
try:
if str(self.root.state()) == "iconic":
self.root.deiconify()
except Exception:
pass
def _arm_auto_with_countdown(self, *, seconds: int = _START_COUNTDOWN_SEC) -> None:
"""Visible countdown, then resolve frontmost and enter auto loop."""
if not self._flow_is_open():
return
# Stop prior loop/timers without clearing the countdown we are about to arm.
self._auto_loop_active = False
self._cancel_auto_timer()
self._cancel_countdown()
self._ensure_inspector_visible()
# Stay topmost for progress; capture still uses focus + exclude self.
self._set_inspector_topmost(True)
self._countdown_left = max(1, int(seconds))
self._update_flow_label()
self._set_status(
f"倒计时 {self._countdown_left} 秒:请把要采集的窗口点到最前端"
"(本面板置顶看进度;不强制放大目标窗)。"
)
self._set_detail(
f"流程:{self.flow_dir}\n\n"
f"- {self._countdown_left} 秒内请点击目标窗口(给它焦点)\n"
f"- 本 Inspector 保持置顶,方便看进度;选窗排除自身\n"
f"- 倒计时结束后:识别最前端 → dump → 等 2 秒 → 再 dump …\n"
f"- 目标窗保持你当前的大小/位置(不强制最大化)\n"
f"- 可点「结束流程」或取消「自动拾取」中止\n"
)
self._refresh_button_states()
self._countdown_after_id = self.root.after(1000, self._countdown_tick)
def _countdown_tick(self) -> None:
self._countdown_after_id = None
if not self._flow_is_open():
self._countdown_left = 0
self._refresh_button_states()
return
if not self.auto_pick_var.get():
self._countdown_left = 0
self._refresh_button_states()
self._set_status("已取消自动拾取倒计时;可手动点「拾取当前页」。")
return
self._countdown_left -= 1
if self._countdown_left > 0:
self._set_status(
f"倒计时 {self._countdown_left} 秒:请把要采集的窗口点到最前端…"
)
self._refresh_button_states()
self._countdown_after_id = self.root.after(1000, self._countdown_tick)
return
self._countdown_left = 0
self._refresh_button_states()
self._set_status("倒计时结束,正在识别最前端窗口…")
self._begin_auto_with_probe()
def _begin_auto_with_probe(self) -> None:
"""Resolve frontmost target (not Inspector), warmup best-effort, then loop."""
if self._busy:
return
self._busy = True
self._auto_loop_active = False
self._refresh_button_states()
# Keep topmost for progress; target = focused window (Inspector hwnd excluded).
self._set_inspector_topmost(True)
self._ensure_inspector_visible()
self._set_status(
"自动拾取:正在识别最前端窗口(焦点窗=采集目标,已排除本面板)…"
)
self._set_detail(
"识别最前端窗口中…\n\n"
"- 规则:谁有焦点就采谁(与窗口大小无关)\n"
"- 本面板可置顶;选目标时排除 Inspector 自身\n"
"- 不强制最大化目标窗\n"
"- 若是企微:自动调用 kit ensure_ui_ready(与技能同路径)\n"
"- FindAll 多少只作提示,不会因此改采别的软件\n"
)
def work() -> None:
try:
uia.ensure_com_sta()
report = uia.prepare_frontmost_for_capture(
exclude_hwnds=self._exclude_hwnds(),
timeout_sec=_FRONTMOST_WAIT_SEC,
)
self.root.after(0, lambda r=report: self._on_frontmost_ready(r))
except Exception as exc:
err = exc
self.root.after(0, lambda e=err: self._on_frontmost_fail(e))
threading.Thread(target=work, daemon=True).start()
def _on_frontmost_ready(self, report: dict) -> None:
self._busy = False
self._refresh_button_states()
win = report.get("window") or {}
if win.get("pid"):
self.sticky_pid = int(win["pid"])
self._warmed_pids.add(int(win["pid"]))
import kit_wake
kit = report.get("kit_wake") or {}
kit_line = kit_wake.format_kit_wake_line(kit)
sparse_note = ""
if report.get("provider_cold") or report.get("uia_sparse"):
if kit.get("reason") == "kit_not_installed":
sparse_note = (
f"(仍偏稀 FindAll={report.get('findall')}:未安装 platform-kit,"
"企微无法走 n2-wake)"
)
elif kit.get("applicable") and not kit.get("ok"):
sparse_note = (
f"(kit 暖树未成功 FindAll={report.get('findall')} "
f"code={kit.get('error_code')};仍按此窗采集)"
)
else:
sparse_note = (
f"(UIA 仍偏稀 FindAll={report.get('findall')};仍按此前端窗采集)"
)
self._set_status(
f"已锁定最前端:{win.get('title')!r} / {win.get('class_name')!r} "
f"findall={report.get('findall')} — 开始自动串行拾取。{sparse_note}"
)
self._set_detail(
f"采集目标(最前端):\n"
f" title={win.get('title')!r}\n"
f" class={win.get('class_name')!r}\n"
f" hwnd={win.get('hwnd')} pid={win.get('pid')}\n"
f" findall={report.get('findall')} sparse={report.get('uia_sparse')} "
f"cold={report.get('provider_cold')}\n"
f" {kit_line}\n\n"
"即将进入自动循环(步间 2 秒)…\n"
)
self._set_inspector_topmost(True)
self._ensure_inspector_visible()
self._start_auto_loop(first_delay_ms=_AUTO_START_DELAY_MS)
def _on_frontmost_fail(self, exc: BaseException) -> None:
self._busy = False
self._auto_loop_active = False
# Keep auto checked (default on); user can retry or end flow.
self._set_inspector_topmost(True)
self._ensure_inspector_visible()
self._refresh_button_states()
self._set_status(f"未能锁定最前端目标: {exc}")
messagebox.showerror("UI Inspector", f"未能锁定最前端目标:\n{exc}")
def _schedule_auto_next(self, delay_ms: int) -> None:
self._cancel_auto_timer()
if not self._auto_loop_active or not self._flow_is_open():
return
self._auto_after_id = self.root.after(delay_ms, self._auto_loop_tick)
def _auto_loop_tick(self) -> None:
self._auto_after_id = None
if not self._auto_loop_active or not self._flow_is_open():
return
if self._busy:
# Should not happen in serial design; re-check soon.
self._schedule_auto_next(200)
return
self.on_pick_page(auto=True)
def _continue_auto_loop_after_pick(self) -> None:
if self._pending_end:
self._pending_end = False
self._stop_auto_loop()
self._finish_end_flow()
return
if self._auto_loop_active and self._flow_is_open():
self._set_status(
f"自动循环:第 {self.flow.get('step_count') if self.flow else '?'} 步已处理;"
f"{_AUTO_GAP_MS // 1000} 秒后开始下一拍。点「结束流程」退出循环。"
)
self._schedule_auto_next(_AUTO_GAP_MS)
def on_auto_toggle(self) -> None:
if self.auto_pick_var.get():
if (
self._flow_is_open()
and not self._auto_loop_active
and not self._busy
and not self._in_start_countdown()
):
self._arm_auto_with_countdown()
else:
self._update_flow_label()
else:
was = self._auto_loop_active or self._in_start_countdown()
self._stop_auto_loop()
self._update_flow_label()
self._refresh_button_states()
if was and self._flow_is_open():
self._set_status("已停止自动循环/倒计时;仍可手动点「拾取当前页」。")
elif self._flow_is_open():
self._set_status("自动拾取已关闭;可手动点「拾取当前页」。")
def on_open_out(self) -> None:
path = self.flow_dir if self.flow_dir else self.captures_root
path.mkdir(parents=True, exist_ok=True)
try:
os.startfile(str(path)) # type: ignore[attr-defined]
except Exception:
subprocess.Popen(["explorer", str(path)])
def on_start_flow(self) -> None:
if self._busy:
return
if self.flow and self.flow.get("status") == "open":
if not messagebox.askyesno(
"UI Inspector",
"当前已有进行中的流程。\n是否结束旧流程并开始新流程?",
):
return
self._stop_auto_loop()
self._pending_end = False
try:
art.end_flow(self.flow_dir, self.flow) # type: ignore[arg-type]
except Exception:
pass
name = (self.flow_name_var.get() or "").strip()
if not name:
name = simpledialog.askstring("流程名", "请输入流程名称:", initialvalue="desktop-flow")
if not name:
return
self.flow_name_var.set(name)
try:
self.flow_dir = art.create_flow(self.captures_root, flow_name=name)
self.flow = art.load_flow(self.flow_dir)
self._pending_end = False
self._warmed_pids.clear()
self._update_flow_label()
self._refresh_button_states()
if self.auto_pick_var.get():
# No modal / no minimize: keep panel visible for countdown + progress.
self._arm_auto_with_countdown(seconds=_START_COUNTDOWN_SEC)
else:
self._stop_auto_loop()
self._ensure_inspector_visible()
self._set_status(
f"已开始流程:{self.flow_dir.name}。请翻到目标页后点「拾取当前页」。"
)
self._set_detail(
f"流程已创建:{self.flow_dir}\n\n"
f"- flow.json / SUMMARY.md / steps/\n"
f"- 手动点「拾取当前页」追加一步;或勾选「自动拾取」进入串行循环。\n"
)
except Exception as exc:
messagebox.showerror("UI Inspector", str(exc))
def _finish_end_flow(self) -> None:
if not self.flow_dir or not self.flow:
return
if self.flow.get("status") != "open":
self._refresh_button_states()
return
try:
self.flow = art.end_flow(self.flow_dir, self.flow)
self._update_flow_label()
self._refresh_button_states()
n = self.flow.get("step_count") or 0
self._set_status(f"流程已结束,共 {n} 步。可再点「开始流程」开新文档。")
self._set_detail(
f"流程已结束:{self.flow_dir}\n\n"
f"- 共 {n} 步\n"
f"- 请打开 SUMMARY.md 查看总览\n"
)
messagebox.showinfo("UI Inspector", f"流程已结束\n共 {n} 步\n{self.flow_dir}")
except Exception as exc:
messagebox.showerror("UI Inspector", str(exc))
def on_end_flow(self) -> None:
if not self.flow_dir or not self.flow:
messagebox.showwarning("UI Inspector", "没有进行中的流程。")
return
if self.flow.get("status") != "open":
messagebox.showinfo("UI Inspector", "流程已经结束。")
return
self._stop_auto_loop()
# Keep「自动拾取」勾选状态(默认开),方便下一轮直接开始。
if self._busy:
self._pending_end = True
self._set_status("已请求结束:等待当前这一拍 dump/保存完成…")
self._refresh_button_states()
return
self._pending_end = False
self._finish_end_flow()
def on_pick_page(self, *, auto: bool = False) -> None:
if self._busy:
return
if not self._flow_is_open():
if not auto:
messagebox.showwarning("UI Inspector", "请先点「开始流程」。")
return
if auto and not self._auto_loop_active:
return
self._busy = True
self._refresh_button_states()
self._show_picking_placeholder(auto=auto)
self.root.update_idletasks()
def work() -> None:
try:
uia.ensure_com_sta()
# Keep panel topmost for progress; selection excludes Inspector hwnd.
self.root.after(0, lambda: self._set_inspector_topmost(True))
flow = art.load_flow(self.flow_dir) # type: ignore[arg-type]
if flow.get("status") != "open":
raise RuntimeError("流程已结束,取消本步拾取。")
info, cands = uia.find_capture_target(
exclude_hwnds=self._exclude_hwnds(),
prefer_pid=None if auto else self.sticky_pid,
)
# Prefer light after PID already warmed; dump_window_tree escalates
# to full poke if locatable still sparse (still no force maximize).
warmup_mode = (
"light" if int(info.pid) in self._warmed_pids else "full"
)
tree, index, stats, locatable = uia.dump_window_tree(
info.hwnd,
max_depth=self.max_depth,
max_nodes=self.max_nodes,
only_visible=False,
warmup_mode=warmup_mode,
merge_same_pid_if_sparse=True,
)
self._warmed_pids.add(int(info.pid))
window = info.to_dict()
label = art.resolve_step_label(
flow,
window_title=info.title or "",
window_class=info.class_name or "",
locatable=locatable,
)
step_dir, step_idx, step_name = art.add_step_dir(
self.flow_dir, # type: ignore[arg-type]
flow,
window_title=info.title or "",
window_class=info.class_name or "",
locatable=locatable,
label=label,
)
meta = art.build_meta(
window=window,
scene=flow.get("flow_name") or "flow",
extra={
"flow_dir": str(self.flow_dir),
"step_index": step_idx,
"step_name": step_name,
"step_label": label,
"auto_pick": auto,
"dump_stats": stats,
"selection": {
"strategy": "z-order+last-active-popup",
"sticky_pid": self.sticky_pid,
"candidate_count": len(cands),
},
},
)
art.write_json(step_dir / "meta.json", meta)
art.write_json(step_dir / "tree.json", tree)
art.write_jsonl(step_dir / "index.jsonl", index)
art.write_json(step_dir / "locatable.json", locatable)
png_ok = art.capture_window_png(info.hwnd, step_dir / "screen.png")
noteworthy = art.noteworthy_from_index(index)
art.write_step_summary(
step_dir, meta=meta, stats=stats, noteworthy=noteworthy
)
art.register_step(
self.flow_dir, # type: ignore[arg-type]
flow,
step_index=step_idx,
step_name=step_name,
window=window,
stats=stats,
label=label,
)
flow = art.load_flow(self.flow_dir) # type: ignore[arg-type]
loc_text = uia.format_locatable_text(locatable)
tree_text = uia.format_tree_text(tree)
import kit_wake
warm = stats.get("warmup") or {}
kit = stats.get("kit_wake") or {}
kit_line = kit_wake.format_kit_wake_line(kit)
sparse_note = ""
if stats.get("uia_sparse"):
sparse_note = "\n⚠ uia_sparse=true:可定位候选偏少,请结合 screen.png。\n"
mode = "自动" if auto else "手动"
header = (
f"流程步骤: #{step_idx} `{step_name}`({mode})\n"
f"窗口: title={info.title!r} class={info.class_name!r} "
f"pid={info.pid} hwnd={info.hwnd}\n"
f"kit: {kit_line}\n"
f"软poke: findall {warm.get('findall_before')} → {warm.get('findall_after')} "
f"woke={warm.get('woke')}\n"
f"统计: kept={stats.get('nodes_kept')} findall={stats.get('findall_count')} "
f"locatable={stats.get('locatable_count')} png={png_ok} "
f"sparse={stats.get('uia_sparse')} cold={stats.get('provider_cold')}\n"
f"已写入: {step_dir}\n"
f"流程文档: {self.flow_dir}\n"
f"{sparse_note}"
f"{'=' * 60}\n"
)
body = header + "\n" + loc_text + "\n\n" + ("=" * 60) + "\n控件树:\n" + tree_text
self.root.after(
0,
lambda i=info, f=flow, t=body, sn=step_name, a=auto: self._on_pick_ok(
i, f, t, sn, auto=a
),
)
except Exception as exc:
err = exc
self.root.after(0, lambda e=err, a=auto: self._on_fail(e, auto=a))
threading.Thread(target=work, daemon=True).start()
def _on_pick_ok(
self,
info: uia.WindowInfo,
flow: dict,
text: str,
step_name: str,
*,
auto: bool = False,
) -> None:
self._busy = False
self.flow = flow
self.last_window = info.to_dict()
self.sticky_pid = int(info.pid)
self._update_flow_label()
self._refresh_button_states()
self.target_var.set(
f"本步目标: title={info.title!r} class={info.class_name!r} | step=`{step_name}`"
)
self._set_detail(text)
if not auto:
self._set_status(
f"第 {flow.get('step_count')} 步已写入。可换页继续拾取,或点「结束流程」。"
)
try:
self.root.lift()
self.root.attributes("-topmost", True)
except Exception:
pass
if self._pending_end:
self._pending_end = False
self._finish_end_flow()
return
# auto: either pending end, or continue loop after gap
self._continue_auto_loop_after_pick()
def _on_fail(self, exc: BaseException, *, auto: bool = False) -> None:
self._busy = False
self._refresh_button_states()
self._set_status(f"失败: {exc}")
if not auto:
messagebox.showerror("UI Inspector", str(exc))
if self._pending_end:
self._pending_end = False
self._finish_end_flow()
return
# Auto: do not pop modal; keep looping after gap (unless ending)
self._continue_auto_loop_after_pick()
def _on_close(self) -> None:
self._stop_auto_loop()
self._pending_end = False
self.root.destroy()
def run(self) -> None:
self.captures_root.mkdir(parents=True, exist_ok=True)
self.root.mainloop()
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="OpenClaw desktop UI Inspector v0.6(流程文档:开始/拾取/结束/自动串行)"
)
p.add_argument("--out", type=Path, default=None, help="输出根目录")
p.add_argument("--scene", default="desktop", help="默认流程名")
p.add_argument("--max-depth", type=int, default=16)
p.add_argument("--max-nodes", type=int, default=8000)
return p.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
out = args.out.resolve() if args.out else art.default_captures_root(_HERE)
try:
uia.ensure_uia()
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
app = InspectorApp(
captures_root=out,
scene=args.scene,
max_depth=args.max_depth,
max_nodes=args.max_nodes,
)
app.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,694 @@
"""Flow-oriented capture artifacts: one flow document, many steps."""
from __future__ import annotations
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
try:
from . import __version__
except ImportError:
try:
import importlib.util
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"ui_inspector_pkg", Path(__file__).resolve().parent / "__init__.py"
)
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
assert _spec and _spec.loader
_spec.loader.exec_module(_mod)
__version__ = getattr(_mod, "__version__", "0.8.0")
except Exception:
__version__ = "0.8.0"
def _local_ts() -> str:
now = datetime.now().astimezone()
return now.strftime("%Y%m%dT%H%M%S%z")
def safe_slug(text: str, *, fallback: str = "session") -> str:
text = (text or "").strip()
if not text:
return fallback
text = re.sub(r"\s+", "-", text)
text = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", text, flags=re.UNICODE)
text = text.strip("-_") or fallback
return text[:80]
def _clean_ui_text(text: str) -> str:
"""Strip WeCom-style rich markup and collapse whitespace."""
s = (text or "").strip()
if not s:
return ""
s = re.sub(r"<[^>]+>", "", s)
s = re.sub(r"\s+", " ", s).strip()
return s
# Name/AutomationId keys that usually mark "what page am I on"
_PAGE_HINT_KEYS: dict[str, int] = {
"panel_title_label": 100,
"title": 95,
"ww_btn_text": 70,
"main_chattext": 45,
}
_LAYOUT_NOISE = {
"bg",
"navigation_bar",
"outer_layout",
"content_container",
"title_layout",
"closebtn",
"VerticalLayoutUI",
"HorizontalLayoutUI",
"wbtn_container",
"search_layout",
"search_layout_border",
"inner_search_layout",
"ControlUI",
"LabelUI",
"item",
"externals",
"status_layout",
"search_button_region",
"search_icon",
"ww_btn_icon",
"wbadge_label",
"lasttime",
"groupbuddydescription",
"bottom_text",
"chattext",
"pane",
"Pane",
}
_CHROME_TEXT = {
"关闭",
"最小化",
"最大化",
"还原",
"取消",
"确定",
"关闭窗口",
}
# Left-rail / chrome values that appear on every WeCom page — not page identity
_GLOBAL_NAV_TEXT = {
"消息",
"邮件",
"文档",
"日程",
"待办",
"会议",
"智能文档",
"通讯录",
"微盘",
"高级功能",
"工作台",
"收藏",
"智能总结",
"回到最新位置",
}
def _row_key(r: dict[str, Any]) -> str:
return _clean_ui_text(str(r.get("name") or r.get("automation_id") or ""))
def _looks_like_search_dialog(
window_title: str, window_class: str, rows: list[dict[str, Any]]
) -> bool:
t = window_title or ""
c = window_class or ""
if "Search" in c or "搜索" in t or "添加客户" in t or "添加联系人" in t:
return True
for r in rows:
if _row_key(r) == "title":
v = _clean_ui_text(str(r.get("value") or ""))
if "添加" in v or "搜索" in v:
return True
return False
def _short_entity(text: str, *, limit: int = 16) -> str:
"""Clean contact/chat title: strip markup leftovers and @微信 suffix."""
s = _clean_ui_text(text)
if not s:
return ""
s = re.sub(r"@微信\s*$", "", s)
s = re.sub(r"@WeCom\s*$", "", s, flags=re.I)
s = s.strip(" -_|")
if len(s) > limit:
s = s[:limit].rstrip("-_")
return s
def _raw_value(r: dict[str, Any]) -> str:
return str(r.get("value") or "")
def _is_selected_buddy(raw_value: str) -> bool:
# WeCom marks the active conversation with semi-transparent white color markup.
return "#99ffffff" in (raw_value or "").lower()
def derive_step_label(
*,
window_title: str = "",
window_class: str = "",
locatable: Optional[list[dict[str, Any]]] = None,
max_hints: int = 2,
) -> str:
"""
Build a human-readable step label from window + page-specific UIA hints.
Prefers: search box text, page title, selected chat, compose draft, action buttons.
Avoids bare「会话列表」when a concrete chat/search/input signal exists.
"""
rows = list(locatable or [])
base = _clean_ui_text(window_title)
if not base:
for r in rows:
if (r.get("control_type") or "") == "WindowControl":
base = _clean_ui_text(str(r.get("name") or ""))
if base:
break
if not base and window_class:
base = window_class
has_panel_title = any(
_row_key(r) == "panel_title_label" and _clean_ui_text(str(r.get("value") or ""))
for r in rows
)
has_groupbuddy = any(_row_key(r) == "groupbuddyname" for r in rows)
has_search_result = any(_row_key(r) == "search_result" for r in rows)
has_searchedit = any(_row_key(r) in {"searchedit", "search_edit"} for r in rows)
search_dialog = _looks_like_search_dialog(window_title, window_class, rows)
scored: list[tuple[int, str]] = []
searchedit_value = ""
selected_buddy = ""
first_buddy = ""
for r in rows:
key = _row_key(r)
if not key or key in _LAYOUT_NOISE:
continue
raw = _raw_value(r)
value = _clean_ui_text(raw)
name_disp = _clean_ui_text(str(r.get("name") or ""))
ctype = str(r.get("control_type") or "")
if key in {"searchedit", "search_edit"} and value:
searchedit_value = _short_entity(value, limit=20)
# Main-window search AND add-friend dialog both matter.
scored.append((98, f"搜索{searchedit_value}"))
continue
if key == "send_rich_edit" and value:
draft = _short_entity(value, limit=12)
if draft:
scored.append((86, f"输入{draft}"))
continue
if key == "groupbuddyname" and value:
entity = _short_entity(value, limit=14)
if not entity:
continue
if _is_selected_buddy(raw) and not selected_buddy:
selected_buddy = entity
scored.append((92, entity))
elif not first_buddy and not r.get("is_offscreen"):
first_buddy = entity
continue
pri = _PAGE_HINT_KEYS.get(key)
if pri is not None and value:
if value in _CHROME_TEXT or value in _GLOBAL_NAV_TEXT:
continue
if key == "title" and value == base:
continue
if key == "main_chattext" and (has_panel_title or has_groupbuddy):
continue
entity = _short_entity(value, limit=16)
if not entity:
continue
scored.append((pri, entity))
continue
if ctype in {
"ButtonControl",
"EditControl",
"TabItemControl",
"TextControl",
"HyperlinkControl",
"MenuItemControl",
"CheckBoxControl",
"RadioButtonControl",
"ComboBoxControl",
"ListItemControl",
}:
label = _short_entity(value or name_disp, limit=16)
if (
not label
or label in _CHROME_TEXT
or label in _LAYOUT_NOISE
or label in _GLOBAL_NAV_TEXT
):
continue
scored.append((40, label))
# Conversation context when no selected buddy / search text yet
if (
has_groupbuddy
and not selected_buddy
and first_buddy
and not has_panel_title
and not searchedit_value
):
scored.append((78, first_buddy))
strong = any(p >= 90 for p, _ in scored)
if search_dialog and has_searchedit and not searchedit_value and not strong:
scored.append((75, "搜索框"))
elif search_dialog and has_search_result and searchedit_value and not strong:
scored.append((50, "搜索结果"))
elif (
has_groupbuddy
and not has_panel_title
and not strong
and not first_buddy
and not selected_buddy
):
scored.append((60, "会话列表"))
scored.sort(key=lambda x: (-x[0], x[1]))
hints: list[str] = []
seen: set[str] = set()
for _pri, text in scored:
if not text or text == base or text in seen:
continue
if text in _CHROME_TEXT or text in _GLOBAL_NAV_TEXT:
continue
compact = (
text[2:] if text.startswith(("搜索", "输入")) and len(text) > 2 else text
)
if compact and any(
compact == s or s.endswith(compact) or compact in s for s in seen
):
continue
seen.add(text)
if compact:
seen.add(compact)
hints.append(text)
if len(hints) >= max_hints:
break
parts = [p for p in [base, *hints] if p]
if not parts:
parts = ["step"]
return "_".join(parts)
def _label_slug_key(label: str) -> str:
return safe_slug(label, fallback="step")
def _unique_step_dirname(steps_root: Path, idx: int, label: str) -> str:
"""NN_label, with -2/-3 suffix if the folder already exists."""
slug = _label_slug_key(label)
base = f"{idx:02d}_{slug}"
if not (steps_root / base).exists():
return base
n = 2
while (steps_root / f"{base}-{n}").exists():
n += 1
return f"{base}-{n}"
def _existing_label_slugs(flow: dict[str, Any]) -> set[str]:
out: set[str] = set()
for s in flow.get("steps") or []:
lab = s.get("label") or ""
name = str(s.get("name") or "")
if lab:
out.add(_label_slug_key(str(lab)))
m = re.match(r"^\d{2}_(.+?)(?:-\d+)?$", name)
if m:
out.add(m.group(1))
return out
def resolve_step_label(
flow: dict[str, Any],
*,
window_title: str = "",
window_class: str = "",
locatable: Optional[list[dict[str, Any]]] = None,
) -> str:
"""Derive label; if it collides with earlier steps, add more hints."""
used = _existing_label_slugs(flow)
label = derive_step_label(
window_title=window_title,
window_class=window_class,
locatable=locatable,
max_hints=2,
)
if _label_slug_key(label) not in used:
return label
label3 = derive_step_label(
window_title=window_title,
window_class=window_class,
locatable=locatable,
max_hints=3,
)
if _label_slug_key(label3) not in used:
return label3
# Last resort: keep richest label; folder uniquifier adds -2
return label3
def default_captures_root(tool_dir: Optional[Path] = None) -> Path:
base = tool_dir or Path(__file__).resolve().parent
return base / "_captures"
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
def read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def build_meta(
*,
window: dict[str, Any],
scene: str,
skill_root: Optional[str] = None,
extra: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
now = datetime.now().astimezone()
meta = {
"schema": "openclaw.ui_inspector.capture.v1",
"surface": "windows",
"captured_at": now.isoformat(),
"scene": scene,
"skill_root": skill_root,
"app": {
"title": window.get("title"),
"class_name": window.get("class_name"),
"process_name": window.get("process_name"),
"pid": window.get("pid"),
"hwnd": window.get("hwnd"),
},
"environment": {
"os": "windows",
"tz": now.tzname(),
},
"tool": {
"name": "openclaw-ui-inspector",
"version": __version__,
"driver": "uiautomation",
},
}
if extra:
meta["extra"] = extra
return meta
def noteworthy_from_index(index: list[dict[str, Any]], *, limit: int = 80) -> list[dict[str, Any]]:
rows = [
r
for r in index
if (r.get("name") or r.get("value") or r.get("automation_id"))
]
rows.sort(
key=lambda r: (
0 if r.get("value") else 1,
0 if r.get("name") else 1,
int(r.get("depth") or 0) if isinstance(r.get("depth"), int) and r.get("depth") >= 0 else 99,
)
)
return rows[:limit]
def capture_window_png(hwnd: int, dest: Path) -> bool:
try:
from PIL import ImageGrab
except ImportError:
return False
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
rect = wintypes.RECT()
if not user32.GetWindowRect(int(hwnd), ctypes.byref(rect)):
return False
bbox = (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
return False
img = ImageGrab.grab(bbox=bbox, all_screens=True)
dest.parent.mkdir(parents=True, exist_ok=True)
img.save(str(dest))
return True
except Exception:
try:
img = ImageGrab.grab(all_screens=True)
dest.parent.mkdir(parents=True, exist_ok=True)
img.save(str(dest))
return True
except Exception:
return False
# --- Flow document API -------------------------------------------------
def create_flow(
captures_root: Path,
*,
flow_name: str = "desktop-flow",
) -> Path:
"""开始流程:新建一份流程文档目录。"""
captures_root.mkdir(parents=True, exist_ok=True)
slug = safe_slug(flow_name, fallback="flow")
flow_dir = captures_root / f"{_local_ts()}_{slug}"
flow_dir.mkdir(parents=True, exist_ok=False)
(flow_dir / "steps").mkdir(parents=True, exist_ok=True)
now = datetime.now().astimezone().isoformat()
flow = {
"schema": "openclaw.ui_inspector.flow.v1",
"flow_name": flow_name,
"status": "open",
"created_at": now,
"updated_at": now,
"ended_at": None,
"step_count": 0,
"steps": [],
"tool": {
"name": "openclaw-ui-inspector",
"version": __version__,
},
}
write_json(flow_dir / "flow.json", flow)
write_flow_summary(flow_dir, flow)
return flow_dir
def load_flow(flow_dir: Path) -> dict[str, Any]:
return read_json(flow_dir / "flow.json")
def save_flow(flow_dir: Path, flow: dict[str, Any]) -> None:
flow["updated_at"] = datetime.now().astimezone().isoformat()
write_json(flow_dir / "flow.json", flow)
write_flow_summary(flow_dir, flow)
def next_step_index(flow: dict[str, Any]) -> int:
return int(flow.get("step_count") or 0) + 1
def add_step_dir(
flow_dir: Path,
flow: dict[str, Any],
*,
window_title: str = "",
window_class: str = "",
locatable: Optional[list[dict[str, Any]]] = None,
label: Optional[str] = None,
) -> tuple[Path, int, str]:
"""Allocate steps/NN_<page-fingerprint>/ under an open flow."""
if flow.get("status") != "open":
raise RuntimeError("当前流程已结束,请先「开始流程」。")
idx = next_step_index(flow)
if not label:
label = resolve_step_label(
flow,
window_title=window_title,
window_class=window_class,
locatable=locatable,
)
steps_root = flow_dir / "steps"
steps_root.mkdir(parents=True, exist_ok=True)
step_name = _unique_step_dirname(steps_root, idx, label)
step_dir = steps_root / step_name
step_dir.mkdir(parents=True, exist_ok=False)
return step_dir, idx, step_name
def register_step(
flow_dir: Path,
flow: dict[str, Any],
*,
step_index: int,
step_name: str,
window: dict[str, Any],
stats: dict[str, Any],
label: Optional[str] = None,
) -> dict[str, Any]:
entry = {
"index": step_index,
"name": step_name,
"label": label or step_name,
"captured_at": datetime.now().astimezone().isoformat(),
"window_title": window.get("title"),
"window_class": window.get("class_name"),
"pid": window.get("pid"),
"hwnd": window.get("hwnd"),
"findall": stats.get("findall_count"),
"locatable": stats.get("locatable_count"),
"kept": stats.get("nodes_kept"),
"uia_sparse": stats.get("uia_sparse"),
"path": f"steps/{step_name}",
}
steps = list(flow.get("steps") or [])
steps.append(entry)
flow["steps"] = steps
flow["step_count"] = len(steps)
save_flow(flow_dir, flow)
return entry
def end_flow(flow_dir: Path, flow: dict[str, Any]) -> dict[str, Any]:
if flow.get("status") == "ended":
return flow
flow["status"] = "ended"
flow["ended_at"] = datetime.now().astimezone().isoformat()
save_flow(flow_dir, flow)
return flow
def write_flow_summary(flow_dir: Path, flow: dict[str, Any]) -> None:
lines = [
f"# 流程:{flow.get('flow_name')}",
"",
f"- status: `{flow.get('status')}`",
f"- created_at: `{flow.get('created_at')}`",
f"- updated_at: `{flow.get('updated_at')}`",
f"- ended_at: `{flow.get('ended_at')}`",
f"- step_count: `{flow.get('step_count')}`",
"",
"## 步骤",
"",
]
steps = flow.get("steps") or []
if not steps:
lines.append("(尚无拾取步骤。点「拾取当前页」追加。)")
lines.append("")
else:
for s in steps:
lines.append(f"### {s.get('index')}. `{s.get('name')}`")
lines.append("")
if s.get("label"):
lines.append(f"- label: `{s.get('label')}`")
lines.append(
f"- window: `{s.get('window_title')}` / `{s.get('window_class')}`"
)
lines.append(
f"- stats: findall=`{s.get('findall')}` locatable=`{s.get('locatable')}` "
f"kept=`{s.get('kept')}` sparse=`{s.get('uia_sparse')}`"
)
lines.append(f"- path: `{s.get('path')}/`")
lines.append("")
lines.extend(
[
"## 说明",
"",
"- 一个流程对应本目录一份文档(`flow.json` + `SUMMARY.md` + `steps/`)。",
"- 每拾取一页,在 `steps/` 下新增一步;给 AI 时优先读本 `SUMMARY.md` 与各步 `locatable.json`。",
"- Name ≠ 界面中文时请看 Value。",
"",
]
)
(flow_dir / "SUMMARY.md").write_text("\n".join(lines), encoding="utf-8")
def write_step_summary(
step_dir: Path,
*,
meta: dict[str, Any],
stats: Optional[dict[str, Any]] = None,
noteworthy: Optional[list[dict[str, Any]]] = None,
) -> None:
app = meta.get("app") or {}
lines = [
"# Step Summary",
"",
f"- captured_at: `{meta.get('captured_at')}`",
f"- window: `{app.get('title')}` / `{app.get('class_name')}` pid=`{app.get('pid')}`",
"",
]
if stats:
lines.extend(
[
"## Stats",
"",
f"- kept: `{stats.get('nodes_kept')}`",
f"- findall: `{stats.get('findall_count')}`",
f"- locatable: `{stats.get('locatable_count')}`",
f"- uia_sparse: `{stats.get('uia_sparse')}`",
"",
]
)
if noteworthy:
lines.extend(["## Notable", ""])
for item in noteworthy[:40]:
lines.append(
f"- `{item.get('control_type')}` Name=`{item.get('name')}` "
f"Value=`{item.get('value')}`"
)
lines.append("")
lines.extend(
[
"## Files",
"",
"- `meta.json` / `tree.json` / `index.jsonl` / `locatable.json` / `screen.png`",
"",
]
)
(step_dir / "SUMMARY.md").write_text("\n".join(lines), encoding="utf-8")
# Back-compat aliases used by older smoke scripts
def create_session_dir(captures_root: Path, *, scene: str = "desktop") -> Path:
return create_flow(captures_root, flow_name=scene)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
"""调用 jiangchang_desktop_wake(只调不复制注入逻辑)。
- 主窗 WeWorkWindow → ``ensure_ui_ready("wecom")``
- 搜索浮层 SearchResultWindow2 → ``ensure_ui_ready("wecom_search")``
未安装 kit 时降级为跳过,不阻断采集。
"""
from __future__ import annotations
import time
from typing import Any, Optional
_CACHE_TTL_SEC = 45.0
# profile -> cache
_caches: dict[str, dict[str, Any]] = {}
_PROFILE_WECOM = "wecom"
_PROFILE_WECOM_SEARCH = "wecom_search"
_THRESHOLDS = {
_PROFILE_WECOM: 80,
_PROFILE_WECOM_SEARCH: 20,
}
def resolve_wake_profile(*, class_name: str = "", title: str = "") -> Optional[str]:
"""返回应调用的 kit profile id;非企微相关则 None。"""
cls = (class_name or "").strip()
title = title or ""
if cls == "SearchResultWindow2":
return _PROFILE_WECOM_SEARCH
if cls == "WeWorkWindow":
return _PROFILE_WECOM
if "WeWork" in cls and ("企业微信" in title or "WeCom" in title or "wecom" in title.lower()):
return _PROFILE_WECOM
return None
def looks_like_wecom(*, class_name: str = "", title: str = "") -> bool:
return resolve_wake_profile(class_name=class_name, title=title) is not None
def kit_available() -> bool:
try:
from jiangchang_desktop_wake import ensure_ui_ready # noqa: F401
return True
except ImportError:
return False
def auto_ensure_if_wecom(
*,
class_name: str = "",
title: str = "",
hwnd: Optional[int] = None,
force: bool = False,
findall_hint: Optional[int] = None,
) -> dict[str, Any]:
"""若目标像企微主窗/搜索浮层则静默 ensure;否则跳过。"""
profile = resolve_wake_profile(class_name=class_name, title=title)
if profile is None:
return {
"applicable": False,
"skipped": True,
"reason": "not_wecom",
"available": kit_available(),
}
warm_min = int(_THRESHOLDS.get(profile, 80))
cache = _caches.setdefault(
profile, {"t": 0.0, "warm": False, "hwnd": 0, "findall": 0}
)
now = time.monotonic()
hint = int(findall_hint) if findall_hint is not None else -1
if (
not force
and cache.get("warm")
and (now - float(cache.get("t") or 0.0)) < _CACHE_TTL_SEC
and (hint < 0 or hint >= warm_min)
):
return {
"applicable": True,
"available": True,
"skipped": True,
"reason": "cache_warm",
"ok": True,
"warm": True,
"wake_applied": False,
"findall": int(cache.get("findall") or 0),
"hwnd": int(cache.get("hwnd") or 0),
"profile": profile,
}
try:
from jiangchang_desktop_wake import ensure_ui_ready
from jiangchang_desktop_wake.errors import DesktopWakeError
except ImportError:
return {
"applicable": True,
"available": False,
"skipped": True,
"reason": "kit_not_installed",
"ok": False,
"profile": profile,
"hint": "请安装 jiangchang-platform-kit>=1.2.6(含 wecom_search)后再采企微",
}
t0 = time.perf_counter()
try:
result = ensure_ui_ready(
profile,
hwnd=int(hwnd) if hwnd else None,
raise_on_cold=True,
)
except DesktopWakeError as exc:
raw = (getattr(exc, "code", None) or "").strip()
code = raw[6:] if raw.startswith("ERROR:") else (raw or "WAKE_FAILED")
return {
"applicable": True,
"available": True,
"skipped": False,
"ok": False,
"warm": False,
"wake_applied": False,
"error_code": code,
"message": str(getattr(exc, "message", None) or exc),
"elapsed_ms": int(round((time.perf_counter() - t0) * 1000)),
"profile": profile,
}
except Exception as exc:
return {
"applicable": True,
"available": True,
"skipped": False,
"ok": False,
"warm": False,
"error_code": "ENSURE_EXCEPTION",
"message": f"{type(exc).__name__}: {exc}",
"elapsed_ms": int(round((time.perf_counter() - t0) * 1000)),
"profile": profile,
}
after = result.probe_after
findall = int(after.findall_count) if after is not None else 0
report = {
"applicable": True,
"available": True,
"skipped": False,
"ok": bool(result.warm),
"warm": bool(result.warm),
"wake_applied": bool(result.wake_applied),
"findall": findall,
"hwnd": int(result.hwnd) if result.hwnd else 0,
"elapsed_ms": int(round((time.perf_counter() - t0) * 1000)),
"profile": profile,
"markers_found": list(after.markers_found) if after is not None else [],
}
if result.warm:
cache["t"] = now
cache["warm"] = True
cache["hwnd"] = report["hwnd"]
cache["findall"] = findall
else:
cache["warm"] = False
return report
def format_kit_wake_line(report: Optional[dict[str, Any]]) -> str:
if not report:
return "kit_wake: (none)"
if not report.get("applicable"):
return "kit_wake: n/a (非企微)"
if report.get("reason") == "kit_not_installed":
return "kit_wake: 未安装 platform-kit(企微可能冷树)"
prof = report.get("profile") or "?"
if report.get("skipped") and report.get("reason") == "cache_warm":
return (
f"kit_wake[{prof}]: cache warm=True findall={report.get('findall')} "
f"hwnd={report.get('hwnd')}"
)
if report.get("ok"):
return (
f"kit_wake[{prof}]: warm={report.get('warm')} "
f"wake_applied={report.get('wake_applied')} "
f"findall={report.get('findall')} elapsed_ms={report.get('elapsed_ms')}"
)
return (
f"kit_wake[{prof}]: FAILED code={report.get('error_code')} "
f"msg={report.get('message')}"
)

View File

@@ -0,0 +1,5 @@
uiautomation>=2.0.18
# 可选:导出 screen.png 需要 Pillow;没有也能 dump JSON
Pillow>=10.0.0
# 企微自动暖树:与技能同款 ensure_ui_ready(请用宿主共享 venv 或本机安装)
# jiangchang-platform-kit>=1.2.5

10
tools/ui_inspector/run.py Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""Entry: python tools/ui_inspector/run.py [--out DIR] [--scene NAME]"""
from __future__ import annotations
import runpy
from pathlib import Path
if __name__ == "__main__":
runpy.run_path(str(Path(__file__).resolve().parent / "app.py"), run_name="__main__")

View File

@@ -0,0 +1,132 @@
"""Smoke test for flow document API + step labeling + kit_wake helpers (v0.8.0)."""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
import artifacts as art
import kit_wake
def _assert_kit_wake_detect() -> None:
assert kit_wake.looks_like_wecom(class_name="WeWorkWindow", title="企业微信")
assert kit_wake.resolve_wake_profile(class_name="SearchResultWindow2") == "wecom_search"
assert not kit_wake.looks_like_wecom(class_name="Chrome_WidgetWin_1", title="Google")
r = kit_wake.auto_ensure_if_wecom(class_name="NotWeCom", title="x")
assert r.get("skipped") and r.get("reason") == "not_wecom"
print("OK kit_wake detect")
def _assert_label() -> None:
msg_page = [
{"control_type": "TreeItemControl", "name": "groupbuddyname", "value": "某群"},
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "你好"},
{"control_type": "TreeItemControl", "name": "con_op", "value": "消息"},
]
searching = [
{"control_type": "TreeItemControl", "name": "groupbuddyname", "value": "徐书建"},
{"control_type": "TreeControl", "name": "searchedit", "value": "陈德炼"},
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "陈德炼"},
]
selected = [
{
"control_type": "TreeItemControl",
"name": "groupbuddyname",
"value": "陈德炼<c #99ffffff>@微信</c>",
},
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "您好"},
]
contacts = [
{"control_type": "TreeItemControl", "name": "panel_title_label", "value": "RPA之家"},
{"control_type": "TreeItemControl", "name": "main_chattext", "value": "新的客户"},
]
new_customer = [
{"control_type": "TreeItemControl", "name": "panel_title_label", "value": "新的客户"},
{"control_type": "TreeItemControl", "name": "ww_btn_text", "value": "添加"},
]
add_empty = [
{"control_type": "TreeItemControl", "name": "title", "value": "添加客户"},
{"control_type": "TreeControl", "name": "searchedit", "value": ""},
{"control_type": "WindowControl", "name": "添加客户", "value": ""},
]
add_result = [
{"control_type": "TreeItemControl", "name": "title", "value": "添加客户"},
{"control_type": "TreeControl", "name": "searchedit", "value": "13316463772"},
{"control_type": "TreeItemControl", "name": "main_chattext", "value": "Nancy"},
{"control_type": "TreeItemControl", "name": "ww_btn_text", "value": "添加为"},
{"control_type": "TreeControl", "name": "search_result", "value": ""},
]
a = art.derive_step_label(window_title="企业微信", locatable=msg_page)
b = art.derive_step_label(window_title="企业微信", locatable=searching)
c = art.derive_step_label(window_title="企业微信", locatable=selected)
d = art.derive_step_label(window_title="企业微信", locatable=contacts)
e = art.derive_step_label(window_title="企业微信", locatable=new_customer)
f = art.derive_step_label(window_title="添加客户", locatable=add_empty)
g = art.derive_step_label(window_title="添加客户", locatable=add_result)
assert "输入你好" in a or "某群" in a, a
assert "搜索陈德炼" in b, b
assert "陈德炼" in c and "输入您好" in c, c
assert "RPA之家" in d, d
assert "新的客户" in e and "添加" in e, e
assert f.startswith("添加客户"), f
assert "13316463772" in g or "搜索13316463772" in g, g
assert "会话列表" not in a and "会话列表" not in b and "会话列表" not in c
print(f"OK labels:\n {a}\n {b}\n {c}\n {d}\n {e}\n {f}\n {g}")
def main() -> int:
_assert_kit_wake_detect()
_assert_label()
with tempfile.TemporaryDirectory(prefix="ui_flow_smoke_") as tmp:
root = Path(tmp)
flow_dir = art.create_flow(root, flow_name="demo-flow")
flow = art.load_flow(flow_dir)
loc = [
{
"control_type": "TreeItemControl",
"name": "groupbuddyname",
"value": "陈德炼<c #99ffffff>@微信</c>",
},
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "您好"},
]
label = art.resolve_step_label(
flow, window_title="企业微信", window_class="WeWorkWindow", locatable=loc
)
step_dir, idx, name = art.add_step_dir(
flow_dir,
flow,
window_title="企业微信",
window_class="WeWorkWindow",
locatable=loc,
label=label,
)
assert idx == 1
assert "陈德炼" in name, name
art.write_json(step_dir / "meta.json", {"ok": True})
art.register_step(
flow_dir,
flow,
step_index=idx,
step_name=name,
window={"title": "企业微信", "class_name": "WeWorkWindow", "pid": 1, "hwnd": 2},
stats={"findall_count": 10, "locatable_count": 5, "nodes_kept": 3, "uia_sparse": False},
label=label,
)
flow = art.end_flow(flow_dir, art.load_flow(flow_dir))
assert flow["status"] == "ended"
print(f"OK flow={flow_dir.name} step={name}")
print("OK ui_inspector flow smoke passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())