diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c59a8..d469c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 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、多账号矩阵、登录状态持久化与完整运行生命周期 diff --git a/SKILL.md b/SKILL.md index b7d9845..4336914 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.58 +version: 1.0.59 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`。 ### 架构铁律(先读) diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md index 3154e1d..7afe19c 100644 --- a/development/CHECKLIST.md +++ b/development/CHECKLIST.md @@ -147,6 +147,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/坐标仅在控件树确认不可用后使用 --- diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index ff913ae..d71ddbc 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -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//`,见各 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`,复用窗口唯一性、UIA作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。 +6. **手机**:先按 `RPA_MOBILE.md` 完成设备、context、element map 和验收设计。当前 `examples/mobile_rpa/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。 **技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。 diff --git a/development/README.md b/development/README.md index 7dcbe06..c376b45 100644 --- a/development/README.md +++ b/development/README.md @@ -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),不要写进本目录。 diff --git a/development/REQUIREMENTS.md b/development/REQUIREMENTS.md index 360fbfb..3f2bd5d 100644 --- a/development/REQUIREMENTS.md +++ b/development/REQUIREMENTS.md @@ -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` 与宿主多入口) diff --git a/development/RPA.md b/development/RPA.md index 729c4dc..cc65225 100644 --- a/development/RPA.md +++ b/development/RPA.md @@ -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` — 运行时目录与环境变量约定 diff --git a/development/RPA_BROWSER.md b/development/RPA_BROWSER.md new file mode 100644 index 0000000..15ede81 --- /dev/null +++ b/development/RPA_BROWSER.md @@ -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 和业务字段都不得跨平台照抄。 diff --git a/development/RPA_DESKTOP.md b/development/RPA_DESKTOP.md new file mode 100644 index 0000000..beffc67 --- /dev/null +++ b/development/RPA_DESKTOP.md @@ -0,0 +1,171 @@ +# 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 根控件。 +- 在父窗口和弹窗范围内按 UIA 属性定位。 +- 真实激活窗口、点击、逐字输入和轮询状态。 +- 处理独立弹窗与嵌套弹窗。 +- 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。 + +example 只提炼通用模式;企业微信名称、窗口类、控件名、列表顺序和业务结果不得复制到其他软件。 + +## 2. 拾取工具和步骤 + +可使用影刀元素拾取器、Microsoft Inspect/Accessibility Insights、FlaUInspect 或库自带树打印能力。 + +操作步骤: + +1. 启动目标软件,登录并手工进入准确页面。 +2. 记录目标进程名、PID、主窗口标题和 Window Class。 +3. 用检查器点选目标元素,记录: + - AutomationId + - Name + - ControlType + - ClassName + - FrameworkId + - IsEnabled / IsOffscreen + - BoundingRectangle + - 父容器链 +4. 保存截图和原始 UIA 树/拾取器导出文件。 +5. 从长路径中找出稳定窗口、业务父容器和叶子属性。 +6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据。 +7. 写入 element map。 +8. 运行 `probe-ui`,验证关键窗口和元素。 +9. 关闭重开软件、切换一次页面后再次验证。 + +### 从拾取结果提炼定位器 + +影刀等工具可能导出很长的 UIA 路径。不要整条照抄: + +```text +原始:主窗口 → 多层布局 → 列表 → item[2] → status_layout +提炼:进程/窗口 → 通讯录列表父容器 → 业务文案或稳定叶子属性 +``` + +长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。 + +## 3. 定位优先级 + +从高到低: + +1. `Process/PID + WindowClass + WindowTitle` +2. 目标窗口内 `AutomationId + ControlType` +3. 稳定 `Name + ControlType` +4. 业务父容器 + 稳定子元素属性 +5. 可见业务文案 + 相对关系 +6. `ClassName/FrameworkId` 辅助组合 +7. 有业务依据的 index +8. UIA 元素实时 BoundingRectangle 派生坐标 +9. 局部截图模板/OCR +10. 写死绝对坐标:禁止作为生产方案 + +顶层窗口或关键按钮出现多个候选时,返回 `ERROR:ELEMENT_AMBIGUOUS`,不得默认使用第一个。 + +## 4. 窗口与控件规则 + +### 窗口 + +- 校验窗口属于预期进程和当前用户会话。 +- 主窗口、搜索窗、确认窗分别建立作用域。 +- 应用可能重建窗口;页面切换后重新取 HWND 和 UIA root。 +- 自动化进程与目标进程权限级别必须兼容;不要为了绕过权限注入目标进程。 +- 多开场景必须由账号、PID或明确窗口信息选择,歧义时停止。 + +### 控件 + +- 在最小业务父容器中搜索。 +- 默认要求唯一匹配。 +- 检查可见、启用、未离屏和边界矩形有效。 +- 虚拟列表只包含当前实例化元素;滚动后重新枚举。 +- 不长期保存 Control 对象。 + +## 5. 输入、点击与等待 + +### 点击 + +1. 激活目标窗口。 +2. 重新定位元素。 +3. 检查边界矩形。 +4. 使用 UIA Click/Invoke 或正常鼠标点击。 +5. 等待后置窗口/控件/业务状态。 + +UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。 + +### 输入 + +- 先点击聚焦。 +- 清空并逐字输入,敏感值不写日志。 +- 输入后读取 Value/Text 或通过后续业务状态验证。 +- 剪贴板可能泄露敏感信息,非必要不使用。 + +### 等待 + +优先等待: + +- 目标窗口出现/消失。 +- 控件出现、启用或状态变化。 +- 列表产生业务结果。 +- 成功/失败提示出现。 + +固定 sleep 只作短缓冲,并使用可中断等待;不能作为成功条件。 + +## 6. UIA 不可用时的降级 + +先检查: + +1. 权限级别和 Windows 会话是否一致。 +2. 是否选错进程或子窗口。 +3. 控件是否离屏或尚未实例化。 +4. 应用是否为 Electron(可评估 Playwright)。 +5. 是否为自绘、游戏引擎、远程桌面或安全控件。 + +只有控件树确认不可用时才使用图像: + +- 固定或记录 DPI、缩放、分辨率和主题。 +- 限定目标窗口和搜索区域。 +- 记录模板来源、置信度阈值和多匹配处理。 +- 匹配不唯一时停止。 +- 点击后仍验证业务状态。 + +U盾、人脸、动态口令和安全键盘交给人工,不用 OCR、Hook 或坐标绕过。 + +## 7. 诊断和留证 + +桌面 `health` / `probe-ui` 至少输出: + +- UIA 依赖是否可用。 +- 目标进程和主窗口是否存在。 +- 关键窗口/控件是否各唯一匹配。 +- 当前窗口标题、Class、PID。 +- DPI/缩放诊断。 + +定位失败保存: + +- 目标窗口和全屏截图。 +- UIA 树。 +- 顶层窗口枚举。 +- element map 逻辑名、候选数量和属性。 +- 应用版本、Windows版本、DPI和会话类型。 + +## 8. example 路由 + +真实成功参考见 [`../examples/real_desktop_rpa/README.md`](../examples/real_desktop_rpa/README.md)。该 example 默认只演示 `probe`、`dry-run` 和 `assisted`;不可逆动作必须由业务技能显式实现确认策略。 diff --git a/development/RPA_ELEMENT_LOCATING.md b/development/RPA_ELEMENT_LOCATING.md new file mode 100644 index 0000000..45670c4 --- /dev/null +++ b/development/RPA_ELEMENT_LOCATING.md @@ -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. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。 +4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。 +5. 为每个动作实现前置状态、定位、动作和后置状态。 +6. 把诊断探针、界面树导出和失败截图作为实现的一部分。 +7. 不把 mock 通过、命令未报错或弹窗关闭当成真实业务成功。 +8. 不擅自切换到 Hook、注入、私有协议、验证码破解或其他禁止路线。 + +## 9. 交付门禁 + +- [ ] 关键元素均有元素清单和真实拾取证据 +- [ ] 每个不可逆动作默认唯一定位 +- [ ] 重启会话后定位仍然有效 +- [ ] 动态数据或列表顺序变化后不会误点 +- [ ] 每个动作有真实后置状态 +- [ ] UI结构变化会停止并留证 +- [ ] fallback 均已人工验证 +- [ ] 截图和控件树不泄露敏感数据 +- [ ] 真实主路径至少完整跑通一次 +- [ ] example 状态明确区分“已验证成功”和“规范骨架” diff --git a/development/RPA_MOBILE.md b/development/RPA_MOBILE.md new file mode 100644 index 0000000..4e2dd05 --- /dev/null +++ b/development/RPA_MOBILE.md @@ -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)。在团队提供真实成功案例前,该目录只能作为实施清单和验收契约,不能作为“已跑通代码”宣传或直接复制。 diff --git a/examples/README.md b/examples/README.md index 2923b41..5f9cdff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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// tests/ ``` -- 仅 `simulator_browser_rpa` 可额外包含 `sandbox/` +- `simulator_browser_rpa` 可额外包含 `sandbox/` +- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture - 仅未来确实需要样例数据时可加 `fixtures/` - **不要**在 `examples//` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件 @@ -76,6 +81,8 @@ examples// - 真实浏览器 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 与「禁止照抄」章节。 diff --git a/examples/mobile_rpa/README.md b/examples/mobile_rpa/README.md new file mode 100644 index 0000000..985574b --- /dev/null +++ b/examples/mobile_rpa/README.md @@ -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. 用户人工验收通过。 diff --git a/examples/real_browser_rpa/README.md b/examples/real_browser_rpa/README.md index 2c6a7b3..2e2d594 100644 --- a/examples/real_browser_rpa/README.md +++ b/examples/real_browser_rpa/README.md @@ -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/-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 diff --git a/examples/real_browser_rpa/assets/element-map.sample.yaml b/examples/real_browser_rpa/assets/element-map.sample.yaml new file mode 100644 index 0000000..a7bc28f --- /dev/null +++ b/examples/real_browser_rpa/assets/element-map.sample.yaml @@ -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: 待填写 diff --git a/examples/real_browser_rpa/scripts/service/task_rpa.py b/examples/real_browser_rpa/scripts/service/task_rpa.py index 6e80c4c..d6c744c 100644 --- a/examples/real_browser_rpa/scripts/service/task_rpa.py +++ b/examples/real_browser_rpa/scripts/service/task_rpa.py @@ -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 diff --git a/examples/real_desktop_rpa/README.md b/examples/real_desktop_rpa/README.md new file mode 100644 index 0000000..5404b3d --- /dev/null +++ b/examples/real_desktop_rpa/README.md @@ -0,0 +1,135 @@ +# 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) +3. 本 README 的“原始拾取到生产定位器” + +## 成功案例验证了什么 + +```text +枚举目标顶层窗口 +→ 按进程/窗口类/标题确认作用域 +→ 从 HWND 获取 UIA root +→ 在父容器中定位元素 +→ 激活窗口并真实点击/输入 +→ 等待子窗口或业务结果 +→ 处理独立/嵌套弹窗 +→ probe-ui 诊断 +``` + +来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。 + +## 目录结构 + +```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 +``` + +## 原始拾取到生产定位器 + +来源案例的影刀 `selectorsV2` 导出通常包含: + +```text +Window(class=SearchExternalsWnd,title=添加客户) + → 多层 Layout/index + → search_layout + → searchedit +``` + +正确提炼方式: + +1. 保留稳定顶层窗口:进程、Window Class、标题。 +2. 保留最小业务父容器:如 `search_layout`。 +3. 使用叶子元素稳定属性:如 `Name=searchedit`。 +4. 删除只反映当前布局的深层 `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 必须放进稳定父容器。 +- 行顺序不是身份;用业务文案或稳定属性选择结果行。 +- 只有主定位成功、窗口前台且矩形有效时,才允许从 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` | 作用域内唯一定位、点击、输入 | +| `scripts/util/selectors.py` | `scripts/util/_selectors.py` | element spec;必须全部替换 | +| `scripts/service/wecom_reference_flow.py` | `scripts/service/_flow.py` | 只复制流程分层,不复制企微 selector | +| `assets/element-map.sample.yaml` | `assets/-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` +- 企业微信列表第 0/1 行含义 +- 来源案例的本机路径、账号和业务数据 +- 任何未经目标软件真实拾取验证的 fallback + +## 验收 + +- `probe` 能明确报告依赖、窗口和关键元素 +- 每个关键元素在目标作用域内唯一 +- 关闭重开软件后仍能定位 +- 多窗口时不会误选 +- UI变化时停止并保存截图/UIA树 +- dry-run 不执行不可逆提交 +- assisted 清楚提示用户确认 +- 提交后仅在业务后置状态成立时报告成功 diff --git a/examples/real_desktop_rpa/assets/element-map.sample.yaml b/examples/real_desktop_rpa/assets/element-map.sample.yaml new file mode 100644 index 0000000..1e9faf5 --- /dev/null +++ b/examples/real_desktop_rpa/assets/element-map.sample.yaml @@ -0,0 +1,64 @@ +source_case: add-friend-wecom +status: verified_source_redacted_example +surface: windows +driver: uiautomation +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 + expected_count: 1 + postcondition: 通讯录业务面板出现 + 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 diff --git a/examples/real_desktop_rpa/assets/selector-capture.sample.xml b/examples/real_desktop_rpa/assets/selector-capture.sample.xml new file mode 100644 index 0000000..cc6dc47 --- /dev/null +++ b/examples/real_desktop_rpa/assets/selector-capture.sample.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/examples/real_desktop_rpa/scripts/service/desktop_session.py b/examples/real_desktop_rpa/scripts/service/desktop_session.py new file mode 100644 index 0000000..0a0efb9 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/desktop_session.py @@ -0,0 +1,111 @@ +"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。""" + +from __future__ import annotations + +import ctypes +import ntpath +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 +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) -> None: + user32.ShowWindow(window.hwnd, SW_RESTORE) + if not user32.SetForegroundWindow(window.hwnd): + raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={window.hwnd}") diff --git a/examples/real_desktop_rpa/scripts/service/uia_locator.py b/examples/real_desktop_rpa/scripts/service/uia_locator.py new file mode 100644 index 0000000..4cb5695 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/uia_locator.py @@ -0,0 +1,96 @@ +"""作用域内 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 _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 _matches(control: Any, spec: ElementSpec) -> bool: + if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name: + 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 + 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) -> None: + activate_window(window) + 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) + 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)) diff --git a/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py b/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py new file mode 100644 index 0000000..4290c30 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py @@ -0,0 +1,37 @@ +"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from service.desktop_session import 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, SEARCH_INPUT + + +def probe() -> dict[str, Any]: + main = resolve_unique_window(MAIN_WINDOW) + root = root_from_window(main) + contacts = locate_unique(root, CONTACTS_ENTRY) + return { + "success": True, + "main_window": asdict(main), + "contacts_entry": { + "name": getattr(contacts, "Name", ""), + "control_type": getattr(contacts, "ControlTypeName", ""), + }, + } + + +def fill_search_dry_run(target: str) -> dict[str, Any]: + """要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。""" + search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW) + 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": "已填写搜索条件,未发送邀请", + } diff --git a/examples/real_desktop_rpa/scripts/util/selectors.py b/examples/real_desktop_rpa/scripts/util/selectors.py new file mode 100644 index 0000000..af13f26 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/util/selectors.py @@ -0,0 +1,44 @@ +"""脱敏 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 + automation_id: Optional[str] = None + control_type: Optional[str] = None + class_name: Optional[str] = None + ancestor_name: Optional[str] = None + 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", +) +SEARCH_INPUT = ElementSpec( + "search_input", + name="searchedit", + ancestor_name="search_layout", +) diff --git a/examples/real_desktop_rpa/tests/test_selector_contract.py b/examples/real_desktop_rpa/tests/test_selector_contract.py new file mode 100644 index 0000000..35a4479 --- /dev/null +++ b/examples/real_desktop_rpa/tests/test_selector_contract.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from service.desktop_session import ( # noqa: E402 + DesktopRpaError, + WindowCandidate, + resolve_unique_window, +) +from util.selectors import 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") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/README.md b/examples/simulator_browser_rpa/README.md index c8f60e4..22951ed 100644 --- a/examples/simulator_browser_rpa/README.md +++ b/examples/simulator_browser_rpa/README.md @@ -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 可控也要定义作用域、唯一性和后置状态 ## 目录结构 diff --git a/scripts/util/constants.py b/scripts/util/constants.py index f00da0a..dc872bb 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.58" +SKILL_VERSION = "1.0.59" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.2" diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py index 1aebfaf..f16ff07 100644 --- a/tests/test_docs_standards.py +++ b/tests/test_docs_standards.py @@ -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):