Compare commits

..

17 Commits

Author SHA1 Message Date
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
c15e23e0d4 docs: 沉淀 account-manager 多账号与 Profile 工业标准
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-24 16:50:57 +08:00
0ea924958f docs: 沉淀AI+RPA合规与工业级风控规范
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-24 15:55:47 +08:00
20e89774ca docs: add third-party acceptance CHECKLIST and acceptance-reports
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-22 17:54:18 +08:00
4ed49495cf chore: default OPENCLAW_TEST_TARGET to real_rpa; host UX acceptance
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-07-20 11:15:20 +08:00
8101b5ac01 docs: add extensible SHARED_REPOS catalog for public commons
All checks were successful
技能自动化发布 / release (push) Successful in 11s
2026-07-20 10:30:36 +08:00
c15854c20d docs: align host placements (row/batch), Gitea clone, developer_ids QA
All checks were successful
技能自动化发布 / release (push) Successful in 16s
2026-07-20 09:54:44 +08:00
8570d5803d docs(development): clarify onboarding, developer_ids self-test, host multi-entry QA
All checks were successful
技能自动化发布 / release (push) Successful in 20s
2026-07-20 09:20:38 +08:00
1ff70e26a5 Release v1.0.51: precipitate ensure-web, config empty-value, and login-required standards
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-18 16:30:06 +08:00
0b9a8b2107 Release v1.0.50: web RPA profile/headed hard rules (POLICY-RPA-004)
All checks were successful
技能自动化发布 / release (push) Successful in 17s
2026-07-18 09:31:40 +08:00
287816aa2a docs(actions): align executionProfile mental model with host default sync (v1.0.49)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-18 08:44:16 +08:00
62 changed files with 4463 additions and 463 deletions

View File

@@ -1,17 +1,20 @@
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 # 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
OPENCLAW_TEST_TARGET=mock # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认 mock,日常正式使用请改为 real_rpa OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock
# 目标网站地址:技能要访问的网站或服务地址 # 目标网站地址:技能要访问的网站或服务地址
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可
# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) # 默认登录账号:仅填写非敏感的账号标识(如工号/登录名/手机号)
DEFAULT_LOGIN_ID=04110001 # 只填账号标识,不要填密码;密码请在「账号管理」中登记 DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码
# 是否显示浏览器窗口:打开后能否看见自动化操作过程 # 默认账号编号:仅填账号管理里的纯数字主键(一般留空)
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(推荐,便于排查),1=后台静默运行 DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号请填上方登录账号
# 浏览器防检测:降低被网站识别为自动化工具的概率 # 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启(推荐),0=关闭;一般保持默认 OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制
# 是否录制操作过程:用于留证、复现问题或向他人演示 # 是否录制操作过程:用于留证、复现问题或向他人演示
OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1 OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1
@@ -19,11 +22,11 @@ OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并
# 失败时是否截图:任务出错时自动保存现场截图,便于排查 # 失败时是否截图:任务出错时自动保存现场截图,便于排查
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图 OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图
# 步骤最短等待:两步操作之间随机停顿的下限,模拟真人节奏 # 步骤最短等待:两个正常业务动作之间随机停顿的下限
STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大 STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大
# 步骤最长等待:两步操作之间随机停顿的上限 # 步骤最长等待:两步操作之间随机停顿的上限
STEP_DELAY_MAX=5.0 # 单位:秒;须大于等于最短等待;一般保持默认 STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断
# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间 # 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间
HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300 HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300

View File

@@ -9,6 +9,102 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 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、多账号矩阵、登录状态持久化与完整运行生命周期
- 明确账号创建后的查看位置:匠厂数据管理、account-manager CLI、本地用户数据目录及 Profile 目录
- 强制所有账号选择路径取得租约并在异常出口释放,修复模板中指定数字账号 ID 时缺少租约的问题
- 增加关闭重开保持登录、多账号隔离、租约冲突、异常释放和自助排障验收项
- 新增机器门禁,防止后续模板退回到裸浏览器、空租约或只做单账号冒烟测试
## 1.0.57
- 新增 AI + RPA 技能开发总纲,明确 Agent 负责理解与编排、RPA 负责浏览器 / 桌面 / 手机的确定性执行
- 增加第三方软件自动化合规红线:禁止破解、逆向、私有协议、安全验证绕过、Hook / 注入、自动换号和无限重试
- 补齐工业级 RPA 风控、人工介入、状态等待、串行互斥、有限重试、结果核验与前后缓冲标准
- 真实业务默认步骤节奏统一为 1~3 秒,并增加自动测试守护
- 需求模板和交活 Checklist 增加授权、风险确认、不可逆操作、风控信号与结构化结果验收
## 1.0.56
- 新增第三方交活验收清单 `development/CHECKLIST.md`(观察结果、不重复开发规范)
- 验收报告强制写入仓库根目录 `acceptance-reports/YYYYMMDD-HHMMSS-acceptance.md`,便于技术人员按 FAIL 项整改
## 1.0.55
- 模板 `.env.example` 默认 `OPENCLAW_TEST_TARGET=real_rpa`;四档保留;明确 mock 只保单测/CI,mock 通 ≠ 交活
- 验收要求 release 到匠厂并看用户体验(进度、失败提示、有头 RPA 等)
## 1.0.54
- 新增 `development/SHARED_REPOS.md`:可扩展的公开公共仓目录(skill-template / account-manager / jiangchang-platform-kit),含类型、Gitea 地址、clone 与红线
- 开发入口与 RUNTIME / RPA / tools 文档挂接该目录,便于技术人员与 AI 定位公共依赖
## 1.0.53
- 宿主入口对齐:`row` / `batch` 升格为正式 placements;支持 `bind.inputMapping` 与 `readOnly`;已声明入口均须自测(含技能详情直调)
- 开发文档:自建 Gitea(git.jc2009.com)与 scaffold 双来源;`developer_ids` 示例改为占位「你的用户ID」;压缩 AI 工具章节
## 1.0.52
- 开发文档补齐全流程:Gitea 克隆与本地模板复制两种拿仓方式;`developer_ids` 作为开发期自测通行证(下载安装注册 → 设置复制用户 ID)
- 发布后宿主验收明确覆盖新建任务、数据管理、定时任务与任务中心;`development/README` 增加步骤索引
## 1.0.51
- 沉淀网页 RPA 首启标准:业务技能默认 `ensure-web`(有则用、无则登记);登记账号 ≠ 已登录目标站;租约全忙返回占用错误且不重复建号
- 明确会话依赖型采集须声明登录 `required` 并做登录门禁;配置留空 + 行尾说明不得当成假默认值;账号可用登录标识选择,展示优先登录名
- 最低共享库版本声明上调至 1.2.2(含配置空值解析修复);说明宿主按需升级、不因源上有更新自动追新
## 1.0.50
- 沉淀网页 RPA 硬规则:必须先经账号管理取得 `profile_dir` 再开浏览器;生产有头;站点登录按平台约定(不全局强制);新增 `POLICY-RPA-004` 自动检测
- 用户教程 / 技能说明占位强调「先登记账号」;需求模板增加登录策略三选一(required / optional / not_needed)
## 1.0.49
- 明确 Skill Action 心智:默认按 sync;仅长任务 / RPA 显式 async 进任务中心;匠厂宿主省略 `executionProfile` 时按 sync;新技能仍须显式声明
## 1.0.48 ## 1.0.48
- 新增配置注释硬门禁 `POLICY-CONFIG-004`:`.env.example` 每个配置项必须有中文行头与行尾说明,禁止开发文档指向,测试不通过则无法发布 - 新增配置注释硬门禁 `POLICY-CONFIG-004`:`.env.example` 每个配置项必须有中文行头与行尾说明,禁止开发文档指向,测试不通过则无法发布

View File

@@ -40,9 +40,12 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
说明用户在使用前要准备的东西,例如: 说明用户在使用前要准备的东西,例如:
- 相关账号已登录,且具备查询、导出或提交权限 - **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
- 相关业务权限(查询、导出、提交等)已具备
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息 - 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
- 如果涉及外部系统,请确认网络、账号、验证码或审批流程可正常使用 - 如果涉及验证码或人工确认,请保证能及时在弹出的浏览器窗口中操作
--- ---
@@ -98,3 +101,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
### 结果文件在哪里查看? ### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。 > 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。
### 已经登记的账号在哪里查看?
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。

View File

@@ -1,12 +1,12 @@
--- ---
name: 技能开发模板(通用业务版) name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.48 version: 1.0.65
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
slug: your-skill-slug slug: your-skill-slug
platform_kit_min_version: "1.2.0" platform_kit_min_version: "1.2.2"
emoji: "📦" emoji: "📦"
category: "通用" category: "通用"
developer_ids: developer_ids:
@@ -22,12 +22,19 @@ allowed-tools:
## Agent / 宿主编排(复制为业务技能后必改) ## Agent / 宿主编排(复制为业务技能后必改)
### AI + RPA 定位
- **Agent 是入口与编排者**:理解用户意图、补齐参数、判断风险、选择 Action,并用业务语言解释结果。
- **RPA 是确定性执行器**:只按已验证步骤操作授权范围内的浏览器、桌面软件或手机;不得破解、逆向、调用未授权私有协议或绕过验证码、安全验证和平台限制。
- 遇到验证码、人脸、U盾、频控、账号风险或权限不足时,转人工或安全停止,禁止自动硬闯、换号或无限重试。
- 开发者与 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`。
### 架构铁律(先读) ### 架构铁律(先读)
- **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑,禁止按 `source` 分叉业务行为。 - **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑,禁止按 `source` 分叉业务行为。
- **`placements`**:只决定 Action **出现在哪里**(toolbar / cron / agent / skill-detail)。 - **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。
- **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。 - **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**;每个 Action **必须**显式写 `sync` 或 `async`。 - **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。 - **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。
- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。 - **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。
@@ -74,7 +81,7 @@ python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py init-db python {baseDir}/scripts/main.py init-db
``` ```
- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.0`(包名路径 `jiangchang_skill_core`);**不要 vendor** `scripts/jiangchang_skill_core/`。 - 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.2`(包名路径 `jiangchang_skill_core`);**不要 vendor** `scripts/jiangchang_skill_core/`。
- 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright`。 - 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright`。
- `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。 - `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。
- `health` 用 `collect_runtime_diagnostics` 做只读诊断。 - `health` 用 `collect_runtime_diagnostics` 做只读诊断。
@@ -150,6 +157,7 @@ python {baseDir}/scripts/main.py init-db
## 平台元数据 ## 平台元数据
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。 - `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表(ID 来自匠厂宿主「设置 → 用户信息」)。
- 开发期技能常为不公开:不加本人 ID,开发者自己也无法在市场安装自测。取 ID 与写入步骤见 `development/DEVELOPMENT.md` §6。
- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。 - 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`。 - `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`。

View File

@@ -16,7 +16,10 @@
1. 已安装并登录匠厂客户端 1. 已安装并登录匠厂客户端
2. 已在技能市场安装本技能(或更新到最新版) 2. 已在技能市场安装本技能(或更新到最新版)
3. 【如需】相关账号已登录、权限可用;需要的文件 / 时间范围 / 单号等已准备好 3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
5. 【网页自动化类技能】已登记账号可在「数据管理 / 账号管理」查看;每个账号都有独立浏览器会话
6. 【如需】需要的文件 / 时间范围 / 单号等已准备好
--- ---
@@ -56,6 +59,7 @@
| 现象 | 你可以先试 | | 现象 | 你可以先试 |
|------|------------| |------|------------|
| 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 | | 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 |
| 提示账号不存在 / 被占用 | 到「数据管理 / 账号管理」确认账号记录和状态,稍后再试;不要重复创建账号 |
| 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 | | 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 |
| 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 | | 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 |
| 结果不对 | 用更明确的条件再说一次,或缩小范围重试 | | 结果不对 | 用更明确的条件再说一次,或缩小范围重试 |

View File

@@ -0,0 +1,30 @@
# 验收报告目录(acceptance-reports)
本目录存放**技能交活验收报告**,供技术人员查看并按未通过项整改。
## 谁写、写什么
- 验收人(或 AI 编程工具)对照 `development/CHECKLIST.md` 执行验收后,**必须**在本目录落盘报告。
- 标准与条目定义见:`development/CHECKLIST.md`(含强制输出规则)。
## 命名(强制)
```text
YYYYMMDD-HHMMSS-acceptance.md
```
示例:`20260722-175000-acceptance.md`
- 使用验收执行时的本地时间
- 不要覆盖旧报告;保留历史便于对比是否修好
## 技术人员怎么用
1. `git pull` 拉取含报告的最新代码(或验收人推送后的提交)
2. 打开本目录中**时间戳最新**的一份 `*-acceptance.md`
3. 只看文末「给技术人员的修改清单」及所有 `FAIL` / 未通过的 `NEED_MANUAL`
4. 改完后告知验收人复验;新一轮会再生成一份新时间戳报告
## 模板仓说明
本 `skill-template` 仓库通常不产生业务验收报告;此目录随模板复制到新技能后使用。

View File

@@ -4,7 +4,7 @@
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**;`task-log-record.schema.json` 等)。 - `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**;`task-log-record.schema.json` 等)。
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填);`row`/`batch` placements 与 `concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 `skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping`;placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
- 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md) - 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md)
- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md)) - Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md))

View File

@@ -26,7 +26,12 @@
"placement": { "placement": {
"type": "string", "type": "string",
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"], "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"description": "稳定支持 toolbar/cron/agent/skill-detail;row/batch 为预留入口,新技能示例不得使用。placements 与 executionProfile 正交,不做条件限制。" "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": { "scalarArg": {
"type": ["string", "number", "boolean"] "type": ["string", "number", "boolean"]
@@ -48,7 +53,17 @@
"type": "array", "type": "array",
"minItems": 1 "minItems": 1
}, },
"sensitive": { "type": "boolean" } "sensitive": { "type": "boolean" },
"readOnly": {
"type": "boolean",
"description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等"
},
"column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "可选。绑定业务表物理列名;replaces create/update 时宿主可复用该列的中文名与 lookup 下拉"
}
} }
}, },
"inputSchema": { "inputSchema": {
@@ -110,10 +125,24 @@
"pattern": "^[a-z][a-z0-9_]*$", "pattern": "^[a-z][a-z0-9_]*$",
"description": "英文 snake_case 业务表名;须真实存在于技能 SQLite / _jiangchang_tables" "description": "英文 snake_case 业务表名;须真实存在于技能 SQLite / _jiangchang_tables"
}, },
"description": "数据管理表级绑定:决定 toolbar Action 出现在哪些表。宿主兼容旧技能时,缺失 bind 可视为全表展示;新技能 toolbar Action 必须显式声明。" "description": "数据管理表级绑定:决定 toolbar/row/batch Action 出现在哪些表。宿主兼容旧技能时,缺失 bind 可视为全表展示;新技能 toolbar Action 必须显式声明。"
},
"inputMapping": {
"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": "Phase 1 仅沉淀 bind.tables;不包含 selection/inputMapping/columns/row/concurrency/locks" "description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping。concurrency/locks 勿写入。"
}, },
"action": { "action": {
"type": "object", "type": "object",
@@ -135,10 +164,11 @@
}, },
"placements": { "placements": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 0,
"items": { "$ref": "#/$defs/placement" }, "items": { "$ref": "#/$defs/placement" },
"description": "决定 Action 出现在哪里;与 executionProfile 正交" "description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。"
}, },
"replaces": { "$ref": "#/$defs/mutationKind" },
"entrypoint": { "$ref": "#/$defs/entrypoint" }, "entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" }, "inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" }, "confirmation": { "$ref": "#/$defs/confirmation" },
@@ -147,6 +177,27 @@
"enum": ["sync", "async"], "enum": ["sync", "async"],
"description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。" "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" } "bind": { "$ref": "#/$defs/bind" }
}, },
"if": { "if": {
@@ -160,7 +211,17 @@
"then": { "then": {
"required": ["bind"] "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

@@ -0,0 +1,216 @@
# account-manager 接入标准(账号 / Profile / 租约)
> 本文是浏览器 RPA 技能接入账号体系的统一标准。开发者和 AI 编程工具在编写 `account_client.py`、处理多账号、排查登录状态或验收浏览器任务前,必须阅读本文。
## 目录
- [1. 为什么必须依赖 account-manager](#1-为什么必须依赖-account-manager)
- [2. 核心模型](#2-核心模型)
- [3. 强制规则](#3-强制规则)
- [4. 标准生命周期](#4-标准生命周期)
- [5. 账号选择与多账号](#5-账号选择与多账号)
- [6. 创建后去哪里查看](#6-创建后去哪里查看)
- [7. 登录状态与 Profile 验收](#7-登录状态与-profile-验收)
- [8. 租约与异常退出](#8-租约与异常退出)
- [9. 工业级测试](#9-工业级测试)
- [10. 排障顺序与提问证据](#10-排障顺序与提问证据)
- [11. 安全边界](#11-安全边界)
## 1. 为什么必须依赖 account-manager
真实用户会长期使用同一个账号,也会在批量发布、矩阵运营、多店铺、多租户等场景中使用多个账号。一次打开临时浏览器、成功操作一两条数据,不能证明技能可交付。
account-manager 统一负责:
- 账号身份及平台归属;
- 一账号一份持久化浏览器 Profile;
- Cookie、会话和登录状态的持续复用;
- 多任务之间的账号租约与并发互斥;
- 账号状态、最近错误和运维排查入口。
业务技能只负责编排业务动作,不得再造一套账号库,也不得随意找一个浏览器目录直接运行。
## 2. 核心模型
| 对象 | 含义 | 业务技能应如何使用 |
|------|------|--------------------|
| `account` | 某个平台上的一个账号身份 | 通过 account-manager CLI 获取,不自行落库 |
| `profile_dir` | 该账号独占的浏览器用户数据目录 | 只传给 `launch_persistent_context`,长期复用 |
| 登录状态 | 目标站 Cookie / Session 等实际会话 | 由业务技能打开同一 Profile 后检查;账号已登记不代表已登录 |
| `lease_token` | 当前任务对账号的临时独占权 | 打开浏览器前取得,所有退出路径释放 |
| `session_status` / error | 会话健康和最近故障线索 | 登录失效或账号异常时按约定回写 |
关系必须保持为:
```text
一个账号记录 ──绑定──► 一个稳定 profile_dir
│
└──任务运行前取得 lease_token ──► 同一时刻只允许一个任务使用
```
## 3. 强制规则
1. 所有浏览器 RPA,包括公开页和无需登录的页面,都必须先经 account-manager 取得 `profile_dir`。
2. 一个账号只能绑定一个稳定 Profile;不同账号禁止共用 Profile。
3. 禁止使用本机日常 Chrome / Edge 用户目录,禁止每次运行创建无归属的临时 Profile。
4. 获取账号时必须同时取得租约;仅 `account get <id>` 不能代替租约。
5. 业务代码只通过单一 `scripts/service/account_client.py` 集中调用 account-manager CLI。
6. 禁止 import account-manager 的 `service/`、`db/`、`util/`、`rpa_helpers` 等内部模块。
7. 未取得非空 `profile_dir` 和 `lease_token` 时禁止打开浏览器。
8. 账号均被占用时返回 `ERROR:LEASE_CONFLICT`,不得自动创建重复账号或换号绕过。
9. 所有成功、失败、取消和异常路径都必须在 `finally` 中释放租约。
10. 密码、Cookie、Token、短信验证码等敏感信息不得写入业务技能配置、数据库、日志或错误信息。
对应机器门禁:`POLICY-RPA-004`、`POLICY-RPA-006`。
## 4. 标准生命周期
```text
解析用户指定账号
→ ensure / pick 账号并取得租约
→ 校验账号平台、profile_dir、lease_token
→ 用 profile_dir 启动持久浏览器
→ 打开目标站并检查实际登录状态
→ 执行业务并记录结果
→ 回写会话状态 / 最近错误
→ 关闭浏览器
→ finally 释放租约
```
参考结构:
```python
account = pick_web_account(platform=TARGET_PLATFORM, account_id=account_id, login_id=login_id)
lease_token = account["lease_token"]
try:
profile_dir = account["profile_dir"]
# 只用 profile_dir 启动 persistent context,然后检查登录并执行任务
finally:
release_lease(lease_token)
```
浏览器启动、登录门禁、业务执行和关闭顺序见 [`RPA.md`](RPA.md) §0.2;CLI 封装边界见 [`ADAPTER.md`](ADAPTER.md)。
## 5. 账号选择与多账号
选择优先级:
1. 用户明确指定纯数字 `DEFAULT_ACCOUNT_ID`:先确认账号存在且平台一致,再通过可定位该账号的 pick 流程取得租约;不得只 `get` 后返回空租约。
2. 用户指定 `DEFAULT_LOGIN_ID`:使用 `ensure-web --login-id` 获取或登记,并取得租约。
3. 用户未指定账号:按平台 `ensure-web`,有则选择可用账号,无则登记一条。
4. 多条记录无法唯一确定:返回 `ERROR:AMBIGUOUS_ACCOUNT`,要求用户明确选择。
5. 匹配账号均被占用:返回 `ERROR:LEASE_CONFLICT`,等待或由运维确认租约状态。
批量和矩阵任务必须以账号为隔离单元:
- 每个账号独立 Profile、独立租约、独立任务结果和失败原因;
- 同一账号默认串行,不因追求吞吐提高同账号并发;
- 一个账号失败不得污染其他账号的 Profile 或结果;
- 设置单账号批量上限、单位时间操作上限和任务总时长;
- 风控、封禁或权限错误不得自动切换下一个账号继续试探。
## 6. 创建后去哪里查看
账号创建成功后必须可见、可查,不得只相信命令没有报错。
### 6.1 宿主数据管理
优先在匠厂宿主的**数据管理页面**查看 account-manager 数据:
- 账号记录:确认账号 ID、平台、账号名称 / 登录标识、状态、`profile_dir`、会话状态;
- 租约记录:确认当前持有者、用途、到期时间和是否已释放;
- 凭据记录:只能查看类型、引用和脱敏值,不应出现明文密码或 Token。
宿主版本若直接展示数据库表,对应核心表为 `accounts`、`account_leases`、`credentials`、`platforms`。不得在数据管理页面或 SQLite 工具中随意修改记录。
### 6.2 account-manager CLI
在 account-manager 仓库或宿主提供的入口中检查:
```powershell
python scripts/main.py account list --platform <platform_key> --limit 50
python scripts/main.py account get <account_id>
python scripts/main.py lease list --active
python scripts/main.py account open <account_id>
```
`account open` 仅用于查看指定 Profile。业务运行仍须走带租约的 pick / ensure 流程。
### 6.3 本地用户数据目录
实际数据按宿主注入的用户隔离:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/
├── account-manager.db
└── profiles/
└── {domain}/
└── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/
```
- `account-manager.db` 保存账号、平台、凭据元数据和租约。
- `profiles/...` 是真实浏览器用户数据目录,保存对应账号的 Cookie / Session 等浏览器状态。
- 实际路径以 `account get <id>` 返回的 `profile_dir` 为准,不得在代码里拼写固定盘符或用户 ID。
- Profile 正在使用时禁止复制、移动、删除或同时打开;需要迁移或清理时先停止任务并释放租约。
## 7. 登录状态与 Profile 验收
“账号已创建”只表示账号记录和 Profile 已准备,不代表目标站已经登录。
最小验收必须完成:
1. 数据管理页面或 `account list` 能看到账号记录。
2. `profile_dir` 非空、真实存在,并位于当前用户的 account-manager 数据目录。
3. 两个不同账号的 `profile_dir` 不相同。
4. 第一次用该 Profile 打开目标站,按平台要求人工登录或扫码。
5. 正常关闭浏览器并释放租约。
6. 再次取得同一账号租约,用同一 `profile_dir` 重开浏览器。
7. 验证登录状态仍有效;失效时返回 `ERROR:REQUIRE_LOGIN`,不得假成功。
## 8. 租约与异常退出
- 租约持有者应使用技能 slug 或可追踪任务标识,`purpose` 写明用途。
- 租约 TTL 应覆盖正常任务时长;长任务需要平台支持的续租策略,不能依赖永不过期。
- `finally release_lease(...)` 是最低要求,浏览器关闭失败也不能跳过释放尝试。
- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理。
- 禁止看到租约冲突就直接删数据库记录、删 Profile、换账号或新建重复账号。
## 9. 工业级测试
除单账号冒烟测试外,至少覆盖:
- 指定账号 ID、指定登录标识和自动选择三条路径;
- 账号不存在、平台不匹配、Profile 缺失、返回格式异常;
- 同一账号并发抢占和 `LEASE_CONFLICT`;
- 业务成功、业务失败、用户取消、浏览器崩溃后的租约释放;
- 登录过期、验证码 / 二次验证、账号风险和权限不足;
- 两个以上账号轮换且 Profile、登录态、结果互不污染;
- 关闭重开后登录保持;
- 批量长时间运行、限流、有限重试和断点续跑。
不得以“单账号成功操作一两次”作为多账号或矩阵能力已经交付的证明。
## 10. 排障顺序与提问证据
开发者和 AI 在群里提问前按顺序自查:
1. 阅读本文以及 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)。
2. 在数据管理页面确认账号记录是否存在、平台和状态是否正确。
3. 执行 `account list/get`,核对账号 ID、`profile_dir`、`session_status`。
4. 在文件系统确认 `profile_dir` 是否存在、是否错用了其他用户目录。
5. 执行 `lease list --active`,确认是否被其他任务占用或残留。
6. 查看任务日志、错误码、失败截图和浏览器当前页面。
7. 判断问题属于账号不存在、租约冲突、登录失效、Profile 损坏、页面变化还是业务失败。
仍需协助时必须提供:技能 slug、平台、脱敏账号标识 / 账号 ID、任务时间、错误码、账号与租约检查结果、`profile_dir` 是否存在、脱敏日志和截图。禁止发送密码、Cookie、Token 或验证码。
## 11. 安全边界
- 账号和 Profile 只能用于用户或企业明确授权的正常业务操作。
- 多账号管理用于账号隔离、矩阵业务和稳定调度,不得用于绕过频控、封禁或平台限制。
- 禁止破解协议、逆向客户端、伪造设备指纹、验证码破解、养号、撞库、代理池轮换或自动换号继续试探。
- 风控、封禁、安全警告和权限不足必须停止或转人工。
- 不手工编辑 `account-manager.db`,不在账号使用期间操作 Profile 文件。
完整合规与风控边界见 [`AI_RPA.md`](AI_RPA.md)。

View File

@@ -1,6 +1,8 @@
# 适配器标准:真实/仿真 × API/RPA 四档模式 # 适配器标准:真实/仿真 × API/RPA 四档模式
> 凡是"连接三方系统"(ERP、CRM、SaaS、银行等)的 skill,都应采用本文的 **adapter 四档模式**。这样同一套业务逻辑可以在不同档位间切换:开发用 mock、半集成用 simulator、上线用真实,互不影响。 > 凡是"连接三方系统"(ERP、CRM、SaaS、银行等)的 skill,都应采用本文的 **adapter 四档模式**。同一套业务逻辑可在不同档位间切换;**四档都保留**。
先遵守 [`AI_RPA.md`](AI_RPA.md) 的授权与技术红线:`real_api` 只连接公开或明确授权的官方接口;不得通过抓包、逆向或猜测私有协议构造“API”。没有授权 API 时使用正常界面 RPA;正常界面无法闭环且只能绕过安全机制时,停止并上报。
## 为什么要分档 ## 为什么要分档
@@ -15,17 +17,17 @@
| 档位 | 含义 | 默认策略 | | 档位 | 含义 | 默认策略 |
|------|------|----------| |------|------|----------|
| **`mock`** | 纯内存或 fixture,**默认单测/CI** | 模板 `.env.example` 默认 `OPENCLAW_TEST_TARGET=mock` | | **`mock`** | 纯内存或 fixture,离线 | **单测 / CI 必过**;mock 不通不要往下走 |
| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 开发联调可选 | | **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 按业务场景选用 |
| **`real_api`** | 真实 API | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_api` | | **`real_api`** | 真实 API | 按业务场景选用(有官方接口时) |
| **`real_rpa`** | 真实浏览器/真实系统 | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_rpa` | | **`real_rpa`** | 真实浏览器/真实系统 | **模板 `.env.example` 默认**;业务自测与交活用此档 |
- **mock**:纯离线、不联网,给单测 / CI / 开发自测,**保证可重复**。 - **mock**:纯离线、不联网,给单测 / CI,**保证可重复**。**mock 通 ≠ 开发完成**。
- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),跑端到端流程但不碰生产。 - **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),按场景选用。
- **real_api**:有官方接口时**首选**(最稳、最快、最易维护)。 - **real_api**:有官方接口时按场景选用。
- **real_rpa**:没有 API 只能操作生产界面,**风险最高、放最后**。 - **real_rpa**:真实浏览器路径;模板默认,须真实跑通后再交活。
> 推荐优先级:**real_api > simulator_rpa > real_rpa**,mock 永远保留做 CI。 > **口径**:业务配置默认 `real_rpa`;单测/CI 用 `mock`。不得只 mock 通就交活;须 release 到匠厂宿主验收(含用户体验)。
配置读取见 `CONFIG.md`:**bootstrap 之后业务代码只通过 `config.get*()` 读 `OPENCLAW_TEST_TARGET` 等项**(进程 env > 用户 `.env` > `.env.example`)。 配置读取见 `CONFIG.md`:**bootstrap 之后业务代码只通过 `config.get*()` 读 `OPENCLAW_TEST_TARGET` 等项**(进程 env > 用户 `.env` > `.env.example`)。
@@ -72,7 +74,7 @@ scripts/service/
from jiangchang_skill_core import config from jiangchang_skill_core import config
def get_adapter(): def get_adapter():
target = (config.get("OPENCLAW_TEST_TARGET") or "mock").lower() target = (config.get("OPENCLAW_TEST_TARGET") or "real_rpa").lower()
if target in ("unit", "mock"): if target in ("unit", "mock"):
return MockAdapter() return MockAdapter()
if target == "real_api": if target == "real_api":
@@ -100,7 +102,10 @@ def get_adapter():
2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。 2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。 3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。 4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。
5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease,需在运维文档说明排查方式(查 account-manager lease 列表 / 手动释放)。 5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get <id>` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally release lease`;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。
6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先经 `pick_web_account`(底层 **`ensure-web` / `pick-web --ensure`**)拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。登记账号 ≠ 站点已登录。
7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → 校验指定账号后带租约 pick;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id --lease`;皆空 → 按平台 `ensure-web --lease`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配或无法确保拿到指定 ID → `ERROR:AMBIGUOUS_ACCOUNT`。
8. **运行前校验**:`profile_dir` 和 `lease_token` 均须非空;账号平台必须匹配;缺任一字段立即失败,不打开浏览器。
```python ```python
# 普通兄弟技能 — 走 sibling_bridge # 普通兄弟技能 — 走 sibling_bridge
@@ -110,10 +115,10 @@ result = call_sibling_json("account-manager", ["list", "--limit", "10"])
``` ```
```python ```python
# account-manager 账号/租约 — 集中在 account_client.py # account-manager 账号/租约 — 集中在 account_client.py(ensure-web)
from service.account_client import pick_web_account, release_lease from service.account_client import pick_web_account, release_lease
account = pick_web_account(platform="target_platform") account = pick_web_account(platform="target_platform", login_id=None)
try: try:
... ...
finally: finally:
@@ -123,5 +128,6 @@ finally:
## 相关文档 ## 相关文档
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式 - `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
- `ACCOUNT_MANAGER.md` — 账号、Profile、租约、多账号、查看与排障标准
- `CONFIG.md` — `.env` 里如何配置运行模式与目标地址 - `CONFIG.md` — `.env` 里如何配置运行模式与目标地址
- `TESTING.md` — 测试 target 与隔离体系 - `TESTING.md` — 测试 target 与隔离体系

227
development/AI_RPA.md Normal file
View File

@@ -0,0 +1,227 @@
# AI + RPA 技能开发总纲
本文是技术人员与 AI 编程工具进入本模板后的**第一份架构说明**。先理解本文,再按业务类型阅读 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)、[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) 与对应 `examples/`。
## 目录
1. [一句话定义](#1-一句话定义)
2. [职责边界](#2-职责边界)
3. [标准执行闭环](#3-标准执行闭环)
4. [合规与技术红线](#4-合规与技术红线)
5. [工业级 RPA 风控](#5-工业级-rpa-风控)
6. [等待与操作节奏](#6-等待与操作节奏)
7. [三端统一与选型](#7-三端统一与选型)
8. [结果契约](#8-结果契约)
9. [开发与交付顺序](#9-开发与交付顺序)
## 1. 一句话定义
**AI Agent 是用户入口与业务编排者,RPA 是确定性执行器。**
- Agent 负责理解用户意图、补齐参数、判断风险、选择 Action、发起任务和解释结果。
- RPA 负责按已验证的步骤操作浏览器、桌面软件或手机,不临时理解模糊业务。
- 业务规则放在唯一 domain service 中;Agent、CLI、Action、定时任务与数据管理入口复用同一内核。
- RPA 必须返回结构化结果、进度与存证,由 Agent 用业务语言反馈给用户。
本模板追求的是**稳定、合规、可控制、可追溯、可交付的工业级界面自动化**,不是与第三方安全机制对抗。
## 2. 职责边界
| 层级 | 负责 | 不负责 |
|------|------|--------|
| Agent | 意图理解、缺参追问、风险确认、Action 选择、结果解释 | 不猜界面步骤,不临时改业务规则,不直接绕过安全验证 |
| Action / 编排层 | 参数校验、鉴权、任务状态、进度、暂停停止、日志、人工介入 | 不按入口复制业务逻辑 |
| Domain Service | 业务规则、幂等、防重复、状态流转、结果汇总 | 不关心来自 Agent、定时任务还是工具栏 |
| Adapter / RPA | 调官方 API 或按确定流程操作界面 | 不理解模糊意图,不自行扩大权限或操作范围 |
| 宿主 | Action 展示、任务中心、数据管理、共享 runtime | 不替技能实现业务规则 |
Agent 与 RPA 的边界必须保持清晰:
- 把不确定的理解与沟通交给 Agent。
- 把可验证、可重复的执行步骤交给代码和 RPA。
- 不让 Agent 在运行时自由生成高风险界面操作。
- 不让 RPA 根据模糊文本自行决定付款、删除、审批、发布或发送范围。
## 3. 标准执行闭环
```text
用户自然语言
→ Agent 识别意图与参数
→ 缺参追问 / 风险确认
→ 选择已声明的 Skill Action
→ 编排层做校验、鉴权、预检、幂等检查
→ Domain Service 执行业务规则
→ Adapter 选择 API / 浏览器 / 桌面 / 手机
→ RPA 分步骤执行并持续上报进度
→ 验证码、人脸、U盾等转人工处理
→ 返回结构化结果、日志、截图与可选视频
→ Agent 向用户解释结果与下一步
```
### 执行前
Agent 与编排层必须确认:
- 用户意图、操作对象、范围与必填参数明确。
- 当前账号、数据、设备和目标软件属于用户或企业授权范围。
- 查询、准备数据与外部副作用已经分开。
- 付款、删除、审批、发布、发送等不可逆动作已取得明确确认。
- 浏览器 Profile、桌面窗口或手机设备已通过预检。
- 同一业务键不会被重复提交。
### 执行中
- 每个用户可感知的动作都要有中文进度 step。
- 使用可暂停、可停止的等待;禁止 RPA 主路径裸 `asyncio.sleep`。
- 同一账号、Profile 或设备默认串行执行。
- 遇到安全验证、权限不足、频控或风险提示时,转人工或安全停止。
- 批量任务记录处理位置,支持失败定位和必要的断点续跑。
### 执行后
- 先验证业务成功标志,再记录成功;不得把“点击完成”当成“业务成功”。
- 返回成功、部分成功、失败、停止或等待人工等明确状态。
- 保存必要的任务日志、错误码、失败截图和可选录屏。
- Agent 根据结构化结果解释完成数量、失败原因和下一步,不直接粘贴技术日志。
## 4. 合规与技术红线
### 允许
- 使用用户或企业合法拥有、明确授权的账号与业务权限。
- 通过正常界面入口完成用户本来有权完成的操作。
- 使用浏览器 DOM、Windows UI Automation、Android/iOS 控件树等公开自动化能力。
- 采用真实点击、逐字输入、自然鼠标轨迹、分段滚动和合理业务节奏。
- 遇到验证码、人脸、指纹、短信、U盾或动态口令时暂停并请求人工处理。
- 在授权范围内保存必要的日志、截图、视频和业务结果。
### 禁止
- 破解、反编译、逆向第三方软件或分析其私有协议。
- 调用未经公开授权的隐藏接口,或通过抓包拼装私有请求代替正常界面。
- 绕过登录、验证码、人脸、指纹、短信、U盾、设备绑定、签名或权限校验。
- 注入、Hook、篡改第三方进程、客户端、APK、IPA、安全模块或通信内容。
- 窃取、共享或复用未授权的 Cookie、Token、Session、密码或设备凭据。
- 撞库、暴力尝试、自动破解验证码、批量养号或伪造身份。
- 通过换账号、换代理、伪造设备指纹、提高并发等方式规避平台限制。
- 识别到风险提示或封禁后继续自动重试、试探或隐藏自动化行为。
“采用 RPA”不代表任何任务都可以自动执行。技能仍须遵守账号授权、数据权限、目标平台规则、合同约定和适用法律;边界不清时停止并请求负责人确认。
## 5. 工业级 RPA 风控
### 风控信号与处理
| 信号 | 标准处理 |
|------|----------|
| 页面或软件加载缓慢 | 等待真实状态,必要时有限重试 |
| 普通网络错误 | 递增退避后有限重试 |
| 登录失效 | 请求用户重新登录 |
| 验证码、短信、人脸、指纹、U盾 | 进入人工处理,禁止自动绕过 |
| 频率限制 | 停止当前批次或长时间退避 |
| 账号风险、封禁或安全警告 | 立即停止,禁止自动换号或继续试探 |
| 权限不足 | 返回明确失败,不尝试扩大或绕过权限 |
| 页面或控件结构变化 | 截图并停止,交由开发人员重新验证 |
| 提交结果不明确 | 查询业务状态;确认前禁止再次提交 |
### 运行限制
- 同一账号、Profile、桌面会话或手机设备默认只运行一个任务。
- 设置单批数量、单位时间操作量和总运行时长上限。
- 只对明确可重试错误做有限重试;禁止无限循环。
- 采用递增退避,不以快速重复点击或刷新解决延迟。
- 关键副作用采用“准备数据 → 用户确认 → 执行 → 结果核验”两阶段流程。
- 风控策略用于减少误操作、系统压力和误判,不得用于规避第三方安全控制。
## 6. 等待与操作节奏
区分三类等待,禁止只靠固定睡眠碰运气。
### 状态等待
页面加载、控件可用、弹窗消失、保存成功、下载完成等必须等待**真实条件**并设置超时。固定等待不能代替状态判断。
### 操作节奏
真实业务环境中,两个正常业务动作之间默认随机等待 **1~3 秒**:
```env
STEP_DELAY_MIN=1.0
STEP_DELAY_MAX=3.0
```
- 输入采用逐字符随机延迟。
- 鼠标采用自然移动轨迹与小幅随机偏移。
- 滚动采用分段操作。
- 提交后等待业务结果,禁止重复点击。
- 测试 / sandbox 可使用更短节奏,但不得把测试节奏用于真实第三方系统。
### 前后缓冲
- 浏览器或软件启动:先等待会话与目标页面/窗口真实就绪,再额外缓冲约 `1.5~2.5` 秒。
- 页面导航:关键控件就绪后,再缓冲约 `1~2` 秒。
- 普通流程结束:确认业务结果、写日志与存证后,再缓冲约 `2~3` 秒关闭。
- 付款、审批、发布等重要动作:确认结果并截图后,可缓冲约 `3~5` 秒。
缓冲时间只用于稳定界面和改善用户观感,不能作为成功判定。RPA 等待必须使用 `interruptible_sleep` 或共享可控制原语。
## 7. 三端统一与选型
优先选择最稳定、最受支持的执行方式:
1. 本地确定性逻辑可以完成时,不启动 RPA。
2. 有稳定且获得授权的官方 API 时,优先 API。
3. 只能通过网页操作时,使用浏览器 RPA。
4. 目标为 Windows 原生软件时,优先 UI Automation。
5. 目标为手机 App 时,使用系统控件树与正常设备自动化。
6. 只有破解、逆向或绕过安全机制才能完成时,停止开发并上报,不改走违规路线。
三端详细标准见 [`RPA.md`](RPA.md)。当前浏览器 RPA 已有可复制案例;桌面与手机仍须基于真实项目继续沉淀,未验证内容不得包装为成熟方案。
## 8. 结果契约
RPA 至少返回:
```json
{
"success": true,
"status": "success",
"message": "任务已完成",
"summary": {
"total": 10,
"success_count": 9,
"failed_count": 1
},
"items": [],
"errors": [],
"artifacts": {
"screenshots": [],
"video_path": null
},
"next_actions": []
}
```
状态至少能区分:
- `success`:全部完成。
- `partial`:部分成功,列出失败项。
- `failed`:未完成,给出稳定错误码与阶段。
- `waiting_human`:等待人工验证或接管,不是假失败。
- `confirmation_required`:等待用户批准副作用。
- `stopped`:用户或宿主主动停止。
具体字段可按业务扩展,但不得只返回控制台文本或把技术 traceback 直接交给用户。
## 9. 开发与交付顺序
1. 填写 [`REQUIREMENTS.md`](REQUIREMENTS.md) 中的 Agent、授权、风险、人工介入与结果设计。
2. 判断采用 API、浏览器、桌面或手机,并选择真实 / 仿真 adapter。
3. 定义用户意图、Action、输入 schema 与确认策略。
4. 先实现 domain service、结构化结果和 mock 测试。
5. 再实现 RPA 会话、真实操作、状态等待、进度、截图与人工介入。
6. 在真实界面上确认 selector / 控件,禁止凭经验猜测。
7. 完成仿真测试、真实业务闭环和宿主多入口验收。
8. 使用 [`CHECKLIST.md`](CHECKLIST.md) 做第三方交活验收。
AI 编程工具不得为了“跑通”擅自采用破解、逆向、私有协议、验证码绕过、进程注入、无限重试或未授权凭据。正常界面无法完成闭环时,应停止并向负责人报告边界。

177
development/CHECKLIST.md Normal file
View File

@@ -0,0 +1,177 @@
# 技能交活验收 Checklist(第三方视角)
checklist_version: 1.0
本清单用于**旁人 / 测试 / AI 编程工具**对任意业务技能做交活验收。
它描述的是「验什么、怎样算过」,**不是**开发规范摘要。写法与实现约束见同目录其他文档。
---
## 验收输出强制规则(凡执行本清单必须遵守)
> 面向 AI 与人工验收员:只要开始按本 Checklist 验收,下列规则**全部强制**,无需用户在提示词里再重复。
1. **必须落盘报告**,禁止只在对话里给出口头结论。
2. **报告目录(唯一合法位置)**:技能仓库**根目录**下的 `acceptance-reports/`
(与 `development/`、`scripts/` 平级;**禁止**写到 `development/`、`tests/`、临时目录或其他路径。)
3. **报告文件名(唯一合法格式)**:`YYYYMMDD-HHMMSS-acceptance.md`
示例:`20260722-175000-acceptance.md`
时间取验收执行时的本地时间;**禁止**使用 `report.md`、`验收.md`、无时间戳的文件名。
4. 若 `acceptance-reports/` 不存在,**先创建该目录再写入**。
5. 报告**开头**必须写明:
- 完整相对路径:`acceptance-reports/YYYYMMDD-HHMMSS-acceptance.md`
- 技能 slug、当前 git 提交短哈希(若可得)、`checklist_version`
6. 报告正文必须**逐条**对应下方条目,每条给出:`PASS` / `FAIL` / `N/A` / `NEED_MANUAL`,并附简短证据(文件路径、命令、观察结果)。
7. 报告末尾必须有「给技术人员的修改清单」:只列出未通过项(`FAIL` 与未通过的 `NEED_MANUAL`),按 P0 → P1 → P2 排序。
8. **禁止修改业务代码**来“凑”验收通过;本清单的执行结果是报告,不是改代码。
9. 若用户只说「按 Checklist 验收」而未指定路径:仍必须遵守本节省略规则,不得省略落盘。
**结果取值**
| 值 | 含义 |
|----|------|
| PASS | 已满足 |
| FAIL | 不满足 |
| N/A | 本技能不适用(须说明原因) |
| NEED_MANUAL | 须人工点选/真机;报告中写清已做或未做 |
**级别**
| 级别 | 含义 |
|------|------|
| P0 | 不通过不可交活 |
| P1 | 限期整改 |
| P2 | 体验建议 |
---
## 0. 技能画像(验收前先填,用于裁剪)
先根据技能实际情况勾选;后续章节不适用则整项标 `N/A` 并注明原因。
- [ ] S0-1 交互形态:纯本地 / API / 网页RPA / 桌面RPA / 手机RPA / 编排(调兄弟技能)
- [ ] S0-2 目标站登录策略:required / optional / not_needed / 不适用
- [ ] S0-3 是否依赖账号会话(account-manager 或等价 Profile):是 / 否
- [ ] S0-4 持久化:无 / 仅任务日志 / 另有业务表
- [ ] S0-5 Skill Action:无 / 仅同步短任务 / 含异步长任务
- [ ] S0-6 是否需要在任务中心查看进度或结果:是 / 否
- [ ] S0-7 外部系统账号、数据、设备与操作权限的授权来源:
- [ ] S0-8 是否包含提交、付款、删除、审批、发布、发送等不可逆副作用:是 / 否
---
## 1. 安装与可运行(所有技能)
- [ ] A1-1 **P0** 按平台方式加载/安装后,技能可被识别(有名称,能打开详情或执行入口)
- [ ] A1-2 **P0** 健康检查(如 `health`)能跑完且结果可读;缺关键条件时有明确失败提示,而非空白崩溃
- [ ] A1-3 **P0** 版本查询(如 `version`)返回的版本与对外宣称一致
- [ ] A1-4 **P1** 若有用户可见配置:说明可读;按说明改关键项后行为符合说明
---
## 2. 对外说明是否自洽(所有技能)
- [ ] A2-1 **P0** 市场「说明」能让非开发者理解:做什么、给谁用、开始前准备什么
- [ ] A2-2 **P0** 「说明/教程」不以开发目录、内部实现细节作为用户主路径
- [ ] A2-3 **P1** 「教程」可按真实顺序跟做(准备 → 步骤 → 失败怎么办)
- [ ] A2-4 **P1** 「演示」结构完整(有演示信息位;无视频则标明暂无,不假装有)
- [ ] A2-5 **P0** 「更新日志」能对应到当前交付版本
- [ ] A2-6 **P0** 平台/Agent 侧说明中,常见用户意图的触发方式与真实能力一致(短操作 vs 长任务不混淆)
---
## 3. 主能力行为(所有技能)
- [ ] A3-1 **P0** 主成功路径能按宣称完成核心动作(至少 1 次完整闭环)
- [ ] A3-2 **P0** 缺参或非法输入时稳定失败(可读错误,不假成功)
- [ ] A3-3 **P1** 失败可诊断:能从任务记录/提示判断失败阶段,而非只有“失败了”
- [ ] A3-4 **P0** 同一能力从已声明的不同入口触发时,业务结果一致(不出现入口分叉成两套逻辑)
---
## 4. 调度与长任务(有 Action 或长任务时适用)
- [ ] A4-1 **P0** 秒级只读/短操作:不进入任务中心也能完成(若技能提供此类能力)
- [ ] A4-2 **P0** 长耗时/浏览器/需进度或取消的能力:经 Agent 或技能动作触发后进入任务中心(或平台等价异步队列)
- [ ] A4-3 **P0** 异步任务在任务中心能看到进行中与结束状态;结束有成功或失败结论
- [ ] A4-4 **P1** 进行中的进度/步骤信息对用户可理解
- [ ] A4-5 **P1** 已对外暴露的动作参数覆盖主路径关键输入(无“命令行能做、动作入口不能做”的关键缺口)
---
## 5. 账号与浏览器会话(网页 RPA 适用;其他形态标 N/A 或改测等价项)
- [ ] A5-1 **P0** 未准备好账号/浏览器档案时,主路径失败并提示,不会默默用本机默认浏览器身份跑起来
- [ ] A5-2 **P0** 登录策略为 required 时:未登录不能把做不成的事当成成功
- [ ] A5-3 **P0** 交付联调默认能看见浏览器窗口(有头);不能把无头当作唯一可验收形态
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入)
- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
- [ ] A5-8 **P0** 每个账号使用独立且稳定的 Profile;不同账号不共用,本机日常浏览器目录不参与运行
- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放
- [ ] A5-10 **P0** **NEED_MANUAL** 登录后关闭浏览器并释放租约,再取同一账号重开,登录状态仍有效;失效时明确提示重新登录
- [ ] A5-11 **P1** 至少两个账号连续或轮换测试通过,Profile、登录态、结果和失败互不污染
- [ ] A5-12 **P1** 租约冲突、残留租约和账号登录失效能按文档自助定位,不通过删库、删 Profile 或自动换号处理
---
## 6. 结果落点(按画像裁剪)
- [ ] A6-1 **P0** 宣称可追溯时:跑完后任务记录中有对应条目(成功/失败均有)
- [ ] A6-2 **P0** 宣称写入业务数据或可在数据管理查看时:成功跑完后能看到新业务数据;仅有任务摘要、业务侧仍空 → **FAIL**
- [ ] A6-3 **P1** 重复执行同一业务键时,结果符合宣称(更新/去重/追加),无莫名脏数据或静默丢数据
- [ ] A6-4 **P1** 「零结果成功」与「失败」可区分
- [ ] A6-5 **P1** 若产出文件:落在约定数据位置且可找到,不丢在不明临时路径
---
## 7. 可观测与安全观感(所有技能)
- [ ] A7-1 **P0** 失败提示或任务结论不泄露密码、完整 Cookie、明文 token 等敏感信息
- [ ] 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** 用户可见文案偏业务语言,而非大段开发黑话
---
## 8. AI + RPA 合规与工业级风控(外部系统技能适用)
- [ ] A8-1 **P0** 已确认账号、数据、设备和业务操作均在用户或企业授权范围内;边界不明不能以“默认允许”通过
- [ ] A8-2 **P0** 代码与运行路径不包含破解、反编译、逆向、未授权私有协议、抓包拼隐藏接口、Hook / 注入、验证码破解、设备指纹伪造、撞库、养号、自动换号或代理池规避限制
- [ ] A8-3 **P0** 验证码、人脸、指纹、短信、U盾、频控、账号风险和权限不足能转人工或安全停止,不自动硬闯
- [ ] A8-4 **P0** 付款、删除、审批、发布、发送等不可逆动作执行前有明确对象、范围和用户确认
- [ ] A8-5 **P1** 页面 / 控件加载、保存和业务成功由真实状态判断,不以固定等待代替成功判定
- [ ] A8-6 **P1** 真实 RPA 两个正常业务动作之间采用约 `1~3` 秒随机节奏;输入、鼠标和滚动符合正常人工操作方式
- [ ] A8-7 **P1** 同一账号、Profile 或设备默认串行;批量、频率和运行时长有上限
- [ ] 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/坐标仅在控件树确认不可用后使用
---
## 9. 质量门禁与交活底线(所有技能)
- [ ] A9-1 **P0** 仓库默认测试套件通过(执行技能约定的一键测试命令,如 `python tests/run_tests.py -v`)
- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免)
- [ ] A9-3 **P0** **NEED_MANUAL** 已声明的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过
- [ ] A9-4 **P1** 对外版本一致:详情、version、更新日志、发布标签能对上
- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
---
## 10. 报告结尾必填
报告末尾固定三块:
1. **结论**:通过 / 不通过(任意 P0 为 FAIL 或不通过的 NEED_MANUAL → 不通过)
2. **统计**:PASS / FAIL / N/A / NEED_MANUAL 数量;P0 FAIL 列表
3. **给技术人员的修改清单**:仅未通过项,带条目 ID 与期望现象

View File

@@ -48,17 +48,17 @@
```ini ```ini
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 # 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
OPENCLAW_TEST_TARGET=mock # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认 mock,日常正式使用请改为 real_rpa OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock
# 是否显示浏览器窗口:打开后能否看见自动化操作过程 # 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(推荐,便于排查),1=后台静默运行 OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
``` ```
### 反例(禁止) ### 反例(禁止)
```ini ```ini
# ── 运行模式 / adapter 档位(见 development/ADAPTER.md)── # ── 运行模式 / adapter 档位(见 development/ADAPTER.md)──
OPENCLAW_TEST_TARGET=real_rpa # 生产默认真实 RPA;单测/CI 可改为 mock OPENCLAW_TEST_TARGET=mock # 见 development/ADAPTER.md;单测用 mock
# ── 好看视频 / 百度账号(须与 account-manager 平台 key 一致)── # ── 好看视频 / 百度账号(须与 account-manager 平台 key 一致)──
TARGET_PLATFORM=baidu TARGET_PLATFORM=baidu
@@ -73,19 +73,22 @@ HAOKAN_VIDEO_SELECTOR=video.art-video
```ini ```ini
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 # 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
OPENCLAW_TEST_TARGET=mock # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认 mock,日常正式使用请改为 real_rpa OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock
# 目标网站地址:技能要访问的网站或服务地址 # 目标网站地址:技能要访问的网站或服务地址
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可
# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) # 默认登录账号:仅填写非敏感的账号标识(如工号/登录名)
DEFAULT_LOGIN_ID=04110001 # 只填账号标识,不要填密码;密码请在「账号管理」中登记 DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码
# 是否显示浏览器窗口:打开后能否看见自动化操作过程 # 默认账号编号:仅填账号管理里的纯数字主键(一般留空)
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(推荐,便于排查),1=后台静默运行 DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号/用户名请填上方登录账号
# 浏览器防检测:降低被网站识别为自动化工具的概率 # 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启(推荐),0=关闭;一般保持默认 OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制
# 是否录制操作过程:用于留证、复现问题或向他人演示 # 是否录制操作过程:用于留证、复现问题或向他人演示
OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1 OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1
@@ -93,11 +96,11 @@ OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并
# 失败时是否截图:任务出错时自动保存现场截图,便于排查 # 失败时是否截图:任务出错时自动保存现场截图,便于排查
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图 OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图
# 步骤最短等待:两步操作之间随机停顿的下限,模拟真人节奏 # 步骤最短等待:两个正常业务动作之间随机停顿的下限
STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大 STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大
# 步骤最长等待:两步操作之间随机停顿的上限 # 步骤最长等待:两步操作之间随机停顿的上限
STEP_DELAY_MAX=5.0 # 单位:秒;须大于等于最短等待;一般保持默认 STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断
# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间 # 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间
HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300 HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300
@@ -161,6 +164,17 @@ config.get_bool("OPENCLAW_BROWSER_HEADLESS")
config.get_float("STEP_DELAY_MIN", 1.0) config.get_float("STEP_DELAY_MIN", 1.0)
``` ```
### 空值、行尾注释与假默认(硬规则)
- **留空即未配置**:`KEY=` 或 `KEY= # 说明` 必须读成空字符串,再走技能自己的「自动 / ensure」逻辑;**禁止**把示例说明、`# 可填…` 当成真实配置值。
- **需要 platform-kit ≥ 1.2.2**:旧版解析曾把「空值 + 行尾 `#`」误当成 `# 可填…` 这种假值。技能若依赖正确空值语义,须在 `SKILL.md` 声明 `platform_kit_min_version: "1.2.2"`(或更高)。
- **网页账号配置语义**(有则用):
- `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;校验指定记录后仍必须取得该账号租约,不能只 `account get`
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`;非数字 `DEFAULT_ACCOUNT_ID` 可兼容为 login_id,但不推荐
- 占位串(`#…`、`none`、`n/a`、`default-account`)一律当未配置
- **用户隔离必须一致**:调用 account-manager 时继承宿主注入的 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`;不得覆盖为开发者自己的固定路径,否则账号会写到另一位用户的数据目录。
- **不要**在 `.env.example` 里塞「看起来像真值」的演示账号(如假手机号),以免首次落盘后被当成用户配置。
## health / config-path ## health / config-path
- **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok`、`ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`。 - **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok`、`ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`。
@@ -176,6 +190,7 @@ config.get_float("STEP_DELAY_MIN", 1.0)
## 相关文档 ## 相关文档
- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释) - `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释)
- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收
- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释) - `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释)
- `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定 - `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定
- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范 - `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范

View File

@@ -1,6 +1,6 @@
# 技能开发教程 # 技能开发教程
这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到 `skill-template` 后,可以**一步一步开发出一个新的 skill**。 这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到**业务技能仓库**后,可以**一步一步开发出一个新的 skill**。
本文默认你开发的是当前最常见的一类业务 skill: 本文默认你开发的是当前最常见的一类业务 skill:
@@ -13,77 +13,40 @@
## 推荐 AI 开发工具 ## 推荐 AI 开发工具
当前 skill 开发建议尽量配合 AI 编程工具使用。这样做不是为了替代技术人员,而是为了提升以下环节的效率: 建议用 AI 编程工具辅助搭目录、补样板、写测试与排错。团队宜统一 1~2 个主力,避免协作习惯发散。
- 搭建标准目录结构 | 场景 | 建议 |
- 生成样板代码 |------|------|
- 理解旧项目代码 | 一体化 IDE | Cursor 或 Windsurf |
- 批量补文档、注释和测试 | 终端深度代理 | Claude Code 或 Aider |
- 辅助排查报错与重构代码 | 国内生态 | Trae / 通义灵码 |
| VS Code 插件 | GitHub Copilot 或 Cline |
建议团队统一选择 1 到 2 个主力工具长期使用,避免每个人工具链差异太大,导致协作方式不一致。 官方下载与文档以各产品站点为准;技能开发步骤以本文与同目录规范为准,不依赖某一家工具的专有流程。
下面先列国外主流工具,再列国内主流工具。链接优先使用官方站点、官方文档或官方安装入口。 开发中常用的**公开公共仓**(skill-template / account-manager / jiangchang-platform-kit 等,可 `git clone` 学习,目录可扩展)见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
### 国外主流工具
| 工具 | 类型 | 适合场景 | 官方入口 |
|------|------|----------|----------|
| Cursor | 独立 AI IDE | 代码编辑、Agent 开发、整仓理解 | <a href="https://www.cursor.com/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://www.cursor.com/downloads" target="_blank" rel="noopener noreferrer">下载</a> |
| Windsurf | 独立 AI IDE | Agent 编程、项目生成、连续开发流 | <a href="https://docs.codeium.com/windsurf" target="_blank" rel="noopener noreferrer">文档</a> / <a href="https://windsurf.com/download" target="_blank" rel="noopener noreferrer">下载</a> |
| GitHub Copilot | IDE 插件 / 编程助手 | 日常补全、解释代码、生成函数、配合 VS Code 或 JetBrains 使用 | <a href="https://github.com/copilot" target="_blank" rel="noopener noreferrer">官网</a> |
| Claude Code | 终端 / IDE 编程代理 | 命令行开发、代码库分析、自动改代码、运行命令 | <a href="https://www.anthropic.com/claude-code" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://docs.anthropic.com/en/docs/claude-code/" target="_blank" rel="noopener noreferrer">文档</a> |
| Codex | 终端 / IDE / Web 编程代理 | OpenAI 官方编码代理,适合代码生成、理解、调试、评审 | <a href="https://developers.openai.com/codex/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://developers.openai.com/codex/quickstart" target="_blank" rel="noopener noreferrer">快速开始</a> |
| Aider | 终端 AI 编程工具 | 已有代码仓库的增量开发、终端协作、快速提交 | <a href="https://www.aider.chat/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://aider.chat/docs/" target="_blank" rel="noopener noreferrer">文档</a> |
| Cline | VS Code / JetBrains 插件 | 编辑器内 Agent 开发、命令执行、浏览器联动调试 | <a href="https://cline.bot/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://docs.cline.bot/introduction/welcome" target="_blank" rel="noopener noreferrer">文档</a> / <a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank" rel="noopener noreferrer">VS Code 插件</a> |
### 国内主流工具
| 工具 | 类型 | 适合场景 | 官方入口 |
|------|------|----------|----------|
| Trae | 独立 AI IDE | AI 辅助写代码、项目搭建、对话式开发 | <a href="https://www.trae.ai/home" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://www.trae.ai/download" target="_blank" rel="noopener noreferrer">下载</a> |
| 通义灵码 | 独立 IDE / IDE 插件 | 国内团队日常编码、问答、补全、代码生成 | <a href="https://tongyi.aliyun.com/lingma/?channel=yy_AiBot" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://tongyi.aliyun.com/lingma/download" target="_blank" rel="noopener noreferrer">下载</a> |
| CodeGeeX | IDE 插件 / 开源助手 | 代码补全、生成、注释、跨语言辅助 | <a href="https://github.com/zai-org/CodeGeeX" target="_blank" rel="noopener noreferrer">GitHub</a> / <a href="https://marketplace.visualstudio.com/items?itemName=aminer.codegeex" target="_blank" rel="noopener noreferrer">VS Code 插件</a> |
| 腾讯 CodeBuddy | IDE 插件 | 代码补全、测试生成、智能问答、腾讯云开发体系协作 | <a href="https://www.codebuddy.ai/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://www.tencentcloud.com/document/product/1256" target="_blank" rel="noopener noreferrer">文档</a> / <a href="https://marketplace.visualstudio.com/items?itemName=Tencent-Cloud.coding-copilot" target="_blank" rel="noopener noreferrer">VS Code 插件</a> |
| 百度文心快码(Baidu Comate) | IDE 插件 | 国内研发团队辅助编码、解释、测试、优化 | <a href="https://comate.baidu.com/zh" target="_blank" rel="noopener noreferrer">官网</a> |
### 选型建议
如果你们团队主要做这类 Python skill 开发,我建议这样选:
- 想要一体化最强体验:优先试 `Cursor` 或 `Windsurf`
- 想要命令行深度协作:优先试 `Claude Code` 或 `Aider`
- 想继续基于 VS Code 插件体系:优先试 `GitHub Copilot`、`Cline`、`通义灵码`、`CodeBuddy`
- 想优先使用国内生态与中文支持:优先试 `Trae`、`通义灵码`、`CodeGeeX`、`CodeBuddy`、`文心快码`
### 团队落地建议
为了减少培训成本,建议内部至少统一一套主工具方案:
- 国外方案:`Cursor` + `Claude Code`
- 国内方案:`Trae` + `通义灵码`
- VS Code 插件方案:`GitHub Copilot` + `Cline`
不建议每位技术人员完全自由发挥,否则后续在:
- 提示词写法
- 代码修改习惯
- 调试方式
- 提交节奏
这些方面会越来越不统一。
## 1. 先理解模板的定位 ## 1. 先理解模板的定位
`skill-template` 不是业务 skill,它只是一个**新 skill 仓库模板**。 `skill-template` 不是业务 skill,它只是一个**新 skill 仓库模板**。
你不应该直接在这个仓库里开发业务,而应该: 开发任何外部系统技能前,**先读 [`AI_RPA.md`](AI_RPA.md)**。本模板的统一定位是:Agent 负责理解、确认与编排,RPA 负责通过正常界面确定性执行;只做授权范围内的正规业务自动化,禁止破解、逆向、私有协议、验证码绕过或其他对抗第三方安全机制的实现。
0. 按 [`NAMING.md`](NAMING.md) 确定 slug(`{verb}-{noun-phrase}-{platform}`) 你不应该直接在这个仓库里开发业务。新技能仓库有**两种合法来源**(详见 §4「第一步」):
1. **优先**用 [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1) 创建新目录(见 [`tools/README.md`](../tools/README.md))
2. 在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`) | 来源 | 适用情况 | 怎么做 |
3. 把占位内容替换掉 |------|----------|--------|
4. 再开始写业务逻辑 | **A. Gitea 克隆** | 项目经理已在自建 Gitea([https://git.jc2009.com/](https://git.jc2009.com/),**不是** Gitea 官网)开好业务仓并给你权限 | `git clone` 到本地后按 [`REQUIREMENTS.md`](REQUIREMENTS.md) 开发 |
| **B. 本地模板复制** | 你本机已有 `skill-template` 源码,要新建尚未灌仓的技能目录 | **优先** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(见 [`tools/README.md`](../tools/README.md));也可手工复制但必须清掉模板 `.git` |
拿到仓库后的推荐顺序:
0. 按 [`NAMING.md`](NAMING.md) 确定 / 核对 slug(`{verb}-{noun-phrase}-{platform}`)
1. **先填写**本仓 [`REQUIREMENTS.md`](REQUIREMENTS.md)(范围与验收),再写业务代码
2. 来源 B:在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`)
3. 把占位内容替换掉;**尽早**配置 `developer_ids`(见 §6,开发期宿主自测必做)
4. 再实现 `scripts/service/` 等业务逻辑
5. 本地测试通过 → `release.ps1` → Gitea CI → 匠厂多入口验收(§15)
> **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git`;`git remote -v` 必须指向新技能仓库,不能仍是 skill-template。 > **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git`;`git remote -v` 必须指向新技能仓库,不能仍是 skill-template。
@@ -164,13 +127,15 @@ scripts/
作用:常量、日志、路径、时间工具、通用帮助函数 作用:常量、日志、路径、时间工具、通用帮助函数
`util/logging_config.py` 是 `jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger `util/logging_config.py` 是 `jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger
公共能力(config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP)从共享 runtime 的 `jiangchang-platform-kit>=1.2.0` import,**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。 公共能力(config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP)从共享 runtime 的 `jiangchang-platform-kit>=1.2.2` import,**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
## 3.2 开发 RPA 类 skill ## 3.2 开发 RPA 类 skill
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地: 若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读三份标准**:`RPA.md`(三端范式与反反爬)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。 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): 2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map):
- 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`: - 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`:
- `examples/real_browser_rpa/scripts/service/browser_session.py` - `examples/real_browser_rpa/scripts/service/browser_session.py`
@@ -185,14 +150,18 @@ scripts/
- `examples/simulator_browser_rpa/scripts/service/task_service.py`(async 编排) - `examples/simulator_browser_rpa/scripts/service/task_service.py`(async 编排)
- `sandbox/demo_app.html` 仅留在 examples,不进入生产 skill - `sandbox/demo_app.html` 仅留在 examples,不进入生产 skill
- **禁止**:`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess - **禁止**:`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
- **网页硬规则**:未拿到非空 `profile_dir + lease_token` 禁止开浏览器;一账号一 Profile;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
3. **只用共享库,不在 skill 里重写反反爬**: 3. **只用共享库,不在 skill 里重写反反爬**:
```python ```python
from jiangchang_skill_core import config from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass
``` ```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。 上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
4. **mock 档必须离线可跑**(`OPENCLAW_TEST_TARGET=mock`);sim_rpa / real_* 按需单独测。 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 / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。
--- ---
@@ -201,7 +170,7 @@ scripts/
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。 技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。
- **公共依赖**(`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。 - **公共依赖**(`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.0`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。 - `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.2`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。 - 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。 - **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法: - **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
@@ -255,13 +224,26 @@ release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前
下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。 下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。
### 第一步:复制模板并改目录名 ### 第一步:拿到新技能仓库(两种来源)
例如你要开发 `disburse-payroll-icbc` 一类领域 skill(目录名 = slug): 例如你要开发 `disburse-payroll-icbc` 一类领域 skill(目录名 = slug)。下列两种来源**同等合法**,按你实际拿到仓库的方式选一条。
#### 推荐方式(首选) #### 来源 A:从 Gitea 克隆(项目经理已开仓)
在 **skill-template 仓库根目录**执行: 1. 确认项目经理已在自建 Gitea([https://git.jc2009.com/](https://git.jc2009.com/),公司基于开源 Gitea 自建的代码仓库,**不要**访问 Gitea 官网当作业务仓地址)创建**本技能**仓库,并给你拉取/推送权限。
2. 克隆到本地(示例):
```powershell
git clone https://git.jc2009.com/<org>/<your-skill-slug>.git
cd <your-skill-slug>
git remote -v # 确认 origin 指向本技能仓,不是 skill-template
```
3. 若仓内已是从本模板 scaffold 好的结构,直接进入「第二步」与 [`REQUIREMENTS.md`](REQUIREMENTS.md);**不要**再整仓复制 `skill-template` 覆盖,以免冲掉已有提交或串 Git 历史。
#### 来源 B:本地已有 skill-template 时复制 / scaffold
在 **skill-template 仓库根目录**执行(推荐):
```powershell ```powershell
.\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc .\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc
@@ -275,7 +257,7 @@ git remote -v # 确认 origin 不是 skill-template
脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。 脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。
#### 禁止方式 ##### 禁止方式(来源 B)
| 做法 | 后果 | | 做法 | 后果 |
|------|------| |------|------|
@@ -283,7 +265,7 @@ git remote -v # 确认 origin 不是 skill-template
| ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template | | ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template |
| ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 | | ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 |
#### 若已手工复制(补救) ##### 若已手工复制(补救)
```powershell ```powershell
cd <新技能目录> cd <新技能目录>
@@ -295,7 +277,7 @@ git remote add origin <新技能仓库 URL>
git remote -v git remote -v
``` ```
#### AI / 编程代理复制红线 ##### AI / 编程代理复制红线
| 禁止 | 说明 | | 禁止 | 说明 |
|------|------| |------|------|
@@ -306,9 +288,9 @@ git remote -v
目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。 目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。
### 第二步:先改 4 个最关键的标识 ### 第二步:先改关键标识与占位
复制后优先改下面这些地方: 拿到仓库并填好 / 更新 [`REQUIREMENTS.md`](REQUIREMENTS.md) 后,优先改下面这些地方:
1. `SKILL.md` 1. `SKILL.md`
2. 根目录市场四 Tab:`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md` 2. 根目录市场四 Tab:`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`
@@ -324,7 +306,7 @@ git remote -v
- 平台内部键 - 平台内部键
- 日志 logger 名 - 日志 logger 名
此外,如果该技能发布后默认不公开(`access_scope = 0`),建议一开始就把 `SKILL.md` 中的 `metadata.openclaw.developer_ids` 配好。这样后续发布到平台时,开发者本人仍能在技能市场中看到并验证该技能。 **开发 / 联调阶段务必尽早**配置 `SKILL.md` 中的 `metadata.openclaw.developer_ids`(完整目的与取 ID 步骤见 §6「关于 developer_ids」)。开发期技能在匠厂常为不公开;不加本人用户 ID,**技术人员自己也无法在技能市场看到并安装自测**。
## 5. 哪些占位内容必须替换 ## 5. 哪些占位内容必须替换
@@ -384,14 +366,28 @@ git remote -v
- `references/`(CLI / SCHEMA) - `references/`(CLI / SCHEMA)
- 代码注释与 `service/` 实现 - 代码注释与 `service/` 实现
### 关于 `metadata.openclaw.developer_ids` ### 关于 `metadata.openclaw.developer_ids`(开发自测必做)
这是一个平台发布元数据字段,用于解决下面这个问题: #### 目的(先理解再填)
- 技能发布后若平台记录中的 `access_scope = 0`,技能默认不公开 开发 / 测试阶段,技能 `release` 到匠厂后,平台侧**默认不公开**(`access_scope = 0`):
- 如果不额外授权,连开发者自己也可能在技能市场里看不到这个技能
因此可以在 `SKILL.md` 中声明: - 技能市场里对普通人**不可见**
- **若不额外授权,连开发该技能的技术人员自己也看不见、装不了**
- 看不见 → 无法安装 → 无法在宿主做 §15 的多入口验收(新建任务、数据管理、定时任务等)
因此 `developer_ids` 不是可有可无的装饰字段,而是**开发期自测通行证**:把本人(及需要一起验收的同事)的匠厂**用户 ID** 写进 `SKILL.md`,发布时平台将这些用户补进可见 / 可访问范围,使开发者能在「对其他人仍不可见」的前提下完成安装与测试。
#### 如何获取匠厂用户 ID(完整步骤)
1. **下载并安装**匠厂客户端:[https://jc2009.com/product.html](https://jc2009.com/product.html)
2. **注册并登录**(使用将用于开发自测的账号)
3. 打开客户端左下角头像旁的**设置**(齿轮)
4. 在 **用户信息** 中查看 **用户 ID**(正整数),点击旁边的 **复制**
5. 将该 ID 写入本技能 `SKILL.md` 的 `metadata.openclaw.developer_ids`(见下例)
6. **必须替换**模板里的示例 ID(如 `10032`、`12428` 等占位),不要原样留着模板作者的 ID 却指望自己账号能看见技能
#### 在 `SKILL.md` 中声明
```yaml ```yaml
metadata: metadata:
@@ -399,17 +395,17 @@ metadata:
slug: your-skill-slug slug: your-skill-slug
category: 通用 category: 通用
developer_ids: developer_ids:
- 1032 - 12345 # 换成「你的用户ID」(匠厂「设置 → 用户信息」复制的正整数)
- 12428
``` ```
约定如下: 约定如下(原有规则保留):
- 只允许填写正整数用户 ID - 只允许填写正整数用户 ID(来自匠厂宿主,不是 Gitea / Git 账号名)
- 推荐使用数组,即使当前只有 1 个开发者 - 推荐使用数组,即使当前只有 1 个开发者
- 发布时平台会把这些用户自动补写到 `skill_user_access` - 发布时平台会把这些用户自动补写到 `skill_user_access`
- 第一个 ID 会同步到 `skills.developer_id` - 第一个 ID 会同步到 `skills.developer_id`
- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权 - 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权
- **首次正式 release 前**就应配好;配错或漏配时,CI 可能成功,但你在技能市场仍找不到技能
## 7. 文档目录分工 ## 7. 文档目录分工
@@ -743,7 +739,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
.\release.ps1 .\release.ps1
``` ```
如果你的技能使用了 `metadata.openclaw.developer_ids`,那么这一步触发的发布工作流除了同步 `skills` / `skill_versions` 外,还会在平台侧自动补开发者可见权限。测试非公开技能时,建议重点验证这部分是否生效。 发布前 **`developer_ids` 应已配置**(见 §6)。本步触发的发布工作流除了同步 `skills` / `skill_versions` 外,还会在平台侧自动补开发者可见权限;发布后应重点确认:用本人账号能在技能市场看到并安装该技能。
这一步会自动完成标准发布动作,包括: 这一步会自动完成标准发布动作,包括:
@@ -788,9 +784,11 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- 工作流文件是否存在 - 工作流文件是否存在
- 发布包结构是否符合模板规范 - 发布包结构是否符合模板规范
### 第四步:进入匠厂平台下载安装包 ### 第四步:进入匠厂客户端(用于安装验收)
当工作流成功后,就可以进入匠厂平台验证最终安装效果。 当工作流成功后,就可以进入匠厂客户端验证最终安装效果。
若你已按 §6 为 `developer_ids` 安装并登录过匠厂,**直接使用同一客户端、同一账号**即可,无需重复下载。若尚未安装:
匠厂产品下载地址: 匠厂产品下载地址:
@@ -802,11 +800,13 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
匠厂产品页可从这里进入:[产品下载 - 匠厂](https://jc2009.com/product.html) 匠厂产品页可从这里进入:[产品下载 - 匠厂](https://jc2009.com/product.html)
### 第五步:安装匠厂后,在技能市场检查最新 skill > 取用户 ID 写入 `developer_ids` 的完整步骤见 §6;此处侧重发布后的安装验收。
安装并启动匠厂后,进入左侧“技能市场”,搜索或查找刚刚发布的 skill,确认以下内容: ### 第五步:在技能市场检查最新 skill
- 技能可以被正常检索到 使用已写入 `developer_ids` 的账号登录并启动匠厂后,进入左侧“技能市场”,搜索或查找刚刚发布的 skill,确认以下内容:
- 技能可以被正常检索到(开发期不公开时,**仅** `developer_ids` 内账号可见)
- 技能名称、说明、版本信息正确 - 技能名称、说明、版本信息正确
- 最新版本已经同步出来 - 最新版本已经同步出来
- 可以正常安装或更新 - 可以正常安装或更新
@@ -825,11 +825,35 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- 安装后状态正常 - 安装后状态正常
- 不会出现缺文件、缺入口或安装失败的问题 - 不会出现缺文件、缺入口或安装失败的问题
### 第七步:在“新建任务”中实际使用该 skill ### 第七步:按声明做宿主多入口验收(已声明的入口都要测;须看用户体验)
安装完成后,不要只停留在“已安装”状态,还需要进入“新建任务”页面,真正调用一次该 skill,完成最终验证。 安装完成后,不要只停留在“已安装”状态,也**不要**只在本地 mock 通就认为完成。宿主侧栏有多条与技能相关的入口;**按本技能 `assets/actions.json` 的 `placements` / `executionProfile` 声明逐项自测**(契约见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md))。未声明的入口可以跳过;**已声明却测不到,视为验收失败**。不要只测对话、忽略其它入口。
建议至少验证: **用户体验(与功能入口并列,后台通了不算完):**
- 失败时用户能否看懂原因(对话 / 任务中心 / 提示),不是只有技术日志
- 长任务(async)是否有可见进度,能否取消或等待说明
- 网页 RPA 是否有头运行;需登录/验证码时是否停下等人,而不是静默失败
- 数据管理:表名/字段中文、按钮文案是否清楚;结果是否写回用户看得见的地方
- 市场说明 / 教程是否与真实行为一致
| 宿主入口(侧栏 / 界面) | 技能侧如何挂上 | 建议验收什么 |
|-------------------------|----------------|--------------|
| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 |
| **数据管理 · 行内** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 行内操作链接可见(需有主键列);点按后入参/行为正确 |
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 |
| **技能市场 → 技能详情** | `placements` 含 `skill-detail`(详情直调) | **必验**安装与四 Tab。详情页「技能直调」按钮随宿主版本上线;**已声明则须在具备该 UI 的宿主上完成按钮自测**(与其它入口同等对待,不要只靠 Agent) |
补充说明:
- **进不进任务中心只看 `executionProfile`**,与从哪个入口触发无关(既有正交规则不变)。
- 数据管理还依赖库表元数据(`_jiangchang_*` 等,见 [`../references/SCHEMA.md`](../references/SCHEMA.md));仅有 Action、没有可展示库表时,侧栏可能看不到表。
- `row` / `batch` / `toolbar` / `cron` / `agent` / `skill-detail` 均以**当前匠厂宿主已实现能力**为准;字段与示例见 [`../references/ACTIONS.md`](../references/ACTIONS.md)。
**新建任务(对话)最小检查:**
- 新任务中可以正常选择或触发该 skill - 新任务中可以正常选择或触发该 skill
- skill 能被正确唤起 - skill 能被正确唤起
@@ -840,6 +864,8 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
![新建任务中使用技能](../assets/screenshots/new-task-usage.png) ![新建任务中使用技能](../assets/screenshots/new-task-usage.png)
若第五步在技能市场**搜不到**本技能:先核对登录账号的用户 ID 是否已写入 `developer_ids` 并随本次 release 发布(见 §6);不要只反复重装客户端。
## 16. 发布前检查清单 ## 16. 发布前检查清单
每个新 skill 发布前,建议技术人员逐条确认: 每个新 skill 发布前,建议技术人员逐条确认:
@@ -848,6 +874,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform) - [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform)
- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致 - [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致
- [ ] `SKILL.md` 中 slug、名称、描述都已替换 - [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `SKILL.md` 的 `developer_ids` 已换成**本人**匠厂用户 ID(设置 → 用户信息 → 复制),不是模板示例 ID
- [ ] `scripts/util/constants.py` 已修改 - [ ] `scripts/util/constants.py` 已修改
- [ ] `../references/CLI.md` 示例命令已改成真实命令 - [ ] `../references/CLI.md` 示例命令已改成真实命令
- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现 - [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
@@ -858,7 +885,9 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag) - [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag)
- [ ] `.github/workflows/release_skill.yaml` 存在 - [ ] `.github/workflows/release_skill.yaml` 存在
- [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`) - [ ] `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)) - [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md))
- [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测 - [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行) - [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行)
@@ -867,7 +896,10 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀 - [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`) - [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`)
- [ ] `git remote -v` 指向**本技能**远端,URL 不含 skill-template 仓库名 - [ ] `git remote -v` 指向**本技能**远端,URL 不含 skill-template 仓库名
- [ ] `git log` 首条提交属于本技能(非模板历史) - [ ] `git log` 首条提交属于本技能(非模板历史)(来源 A 从 Gitea 克隆的已有业务仓,以该仓历史为准)
- [ ] 业务 `.env` / `.env.example` 默认 `OPENCLAW_TEST_TARGET=real_rpa`(单测/CI 仍用 mock);**未只靠 mock 交活**
- [ ] 发布后计划在宿主按 §15 第七步验收:已声明入口 + **用户体验**(进度、失败提示、有头 RPA、文案一致等)
- [ ] 网页 RPA:`ensure-web` 取得 `profile_dir` 再开浏览器;`.env` 默认有头;REQUIREMENTS 写明登录策略(required/optional/not_needed,会话依赖型勿抄 optional);用户 README/TUTORIAL 区分「登记账号」与「站点登录」(`RPA.md` §0.2 / `POLICY-RPA-004`);依赖 kit 空值解析等修复时上调 `platform_kit_min_version`
## 17. 常见错误 ## 17. 常见错误

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 盾 | | 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 |
| 存证路径 | 截图、录屏、`video_log`、失败 artifacts | | 存证路径 | 截图、录屏、`video_log`、失败 artifacts |
**没有关键节点日志 = 客户现场不可排障。** 不要把 `print` 当作日志;stdout 面向用户与 Agent,文件日志与 Run Journal 面向运维。
--- ---
## 2. 日志分层 ## 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 | ### 3.1 长任务通用最低集(所有业务 skill)
|------|-----------------|
| CLI 入口启动 | `cli_start`(`cli/app.py`,已由模板提供) | | 事件名 | 级别 | 说明 |
| 参数解析完成 | `run_args target_id=... input_id=...` | |--------|------|------|
| 鉴权开始 | `entitlement_check_start` | | `cli_start` | info | CLI 入口(模板已提供) |
| 鉴权成功 | `entitlement_ok` | | `task_start` | info | 主任务开始(含 `task_type=`) |
| 鉴权失败 | `entitlement_failed`(`warning`) | | `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 |
| 任务开始 | `task_start task_type=... target_id=... input_id=... batch_id=...` + `emit("开始处理任务")` | | `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 |
| adapter / test target 选择 | `adapter_selected adapter=... system=...` | | `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` |
| 账号 / profile / lease 获取 | `account_acquired account_id=...` | | `task_log_saved` | info | 写入 `task_logs` 后 |
| 账号 / lease 释放 | `account_released account_id=...` |
| 外部 API 开始 | `external_call_start system=... operation=...` | ### 3.2 RPA / 桌面 / 浏览器额外最低集
| 外部 API 结束 | `external_call_done system=... operation=... elapsed_ms=... status=...` |
| 兄弟技能调用开始 / 结束 | 同上,或 `sibling_call_start/done skill_slug=...` | 当 `scripts/service/` 使用 `RpaVideoSession` / `uiautomation` / `playwright` / `@rpa_step` 时,额外要求:
| RPA 操作开始 / 结束 | `rpa_start` / `rpa_done` + `video.add_step` 中文步骤 |
| 打开页面 | step:「打开 xxx 页面」 | | 事件名 | 说明 |
| 检查登录 | step + log | |--------|------|
| 定位输入框 / 点击提交 | step + log | | `rpa_start` / `rpa_done` 或 `rpa_failed` | RPA 段起止(可与 `task_*` 同条合并,但事件名须可 grep) |
| 等待结果 | step + log(含 timeout 预期) | | `stage=` | 至少 3 个不同阶段里程碑(如 `open_window` / `locate_control` / `submit` / `wait_result`) |
| 批量循环 | `batch_progress current=... total=...` | | `risk_blocked` | 若有配额/工作时段/熔断等前置拦截 |
| 重试 | `retry_attempt n=... reason=...`(`warning`) |
| 跳过 / 部分失败 | `skipped` / `partial_failure`(`warning`) | **不要**要求每个 Click 一行日志;要求的是**阶段里程碑 + 全部失败出口**。
| 人工介入 | `human_intervention type=captcha|login|ukey|otp`(`warning`) |
| 失败截图 / 视频 | `artifact_saved artifact_path=...` / `video_path=...` | ### 3.3 其它常见节点(按业务选用)
| 任务成功 | `task_success ...` + `task_log_saved status=success` |
| 任务失败 | `task_failed ...`(`exception` 保留 traceback)+ `task_log_saved status=failed` | | 阶段 | 建议事件 |
|------|----------|
| 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

@@ -19,6 +19,9 @@
| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) | | POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) |
| POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4;应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg(`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` | | POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4;应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg(`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` |
| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` | | POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` |
| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` |
| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` |
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` | | POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` | | POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` | | POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |
@@ -27,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-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-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-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-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-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` | | 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` |
@@ -34,7 +40,7 @@
| POLICY-SKILL-ACTION-001 | 存在 `development/SKILL_ACTION_RUNTIME.md`;schema 含 `executionProfile`;业务技能(非模板占位 slug)若 service 含 `RpaVideoSession` 则 `actions.json` 须有 `executionProfile=async` 且 placements 含 `agent` 的 action | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md | hard | 文档/schema/manifest 检查 | `tests/test_development_policy_guard.py::TestPolicySkillAction001` | | POLICY-SKILL-ACTION-001 | 存在 `development/SKILL_ACTION_RUNTIME.md`;schema 含 `executionProfile`;业务技能(非模板占位 slug)若 service 含 `RpaVideoSession` 则 `actions.json` 须有 `executionProfile=async` 且 placements 含 `agent` 的 action | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md | hard | 文档/schema/manifest 检查 | `tests/test_development_policy_guard.py::TestPolicySkillAction001` |
| POLICY-SKILL-ACTION-002 | 每个 Action 必须显式声明 `executionProfile`,且只能是 `sync` 或 `async` | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema required + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction002`;`tests/test_actions_manifest.py` | | POLICY-SKILL-ACTION-002 | 每个 Action 必须显式声明 `executionProfile`,且只能是 `sync` 或 `async` | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema required + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction002`;`tests/test_actions_manifest.py` |
| POLICY-SKILL-ACTION-003 | toolbar Action 必须声明合法非空 `bind.tables`(snake_case、唯一) | references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema if/then + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction003`;`tests/test_actions_manifest.py` | | POLICY-SKILL-ACTION-003 | toolbar Action 必须声明合法非空 `bind.tables`(snake_case、唯一) | references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema if/then + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction003`;`tests/test_actions_manifest.py` |
| POLICY-SKILL-ACTION-004 | `placements` 与 `executionProfile` 正交;文档不得把数据管理入口与异步执行方式硬绑定;Schema 须接受 toolbar/cron/agent/skill-detail × sync/async 全部组合 | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | Schema 4×2 矩阵校验 + 文档禁止短语扫描 | `tests/test_actions_manifest.py::test_schema_allows_all_placement_execution_profile_combinations`;`tests/test_development_policy_guard.py::TestPolicySkillAction004` | | POLICY-SKILL-ACTION-004 | `placements` 与 `executionProfile` 正交;文档不得把数据管理入口与异步执行方式硬绑定;Schema 须接受 toolbar/row/batch/cron/agent/skill-detail × sync/async 全部组合 | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | Schema 6×2 矩阵校验 + 文档禁止短语扫描 | `tests/test_actions_manifest.py::test_schema_allows_all_placement_execution_profile_combinations`;`tests/test_development_policy_guard.py::TestPolicySkillAction004` |
| POLICY-ARCH-CORE-001 | 同一业务能力的 Action、CLI、Agent、cron、数据管理入口复用同一业务内核(文档要求已声明) | development/DEVELOPMENT.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;真实复用靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyArchCore001`;`tests/samples/test_action_core_contract.py.sample` | | POLICY-ARCH-CORE-001 | 同一业务能力的 Action、CLI、Agent、cron、数据管理入口复用同一业务内核(文档要求已声明) | development/DEVELOPMENT.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;真实复用靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyArchCore001`;`tests/samples/test_action_core_contract.py.sample` |
| POLICY-DATA-SYNC-001 | 同步型业务须遵循幂等、事务、完整性与空结果保护(文档已声明) | references/SCHEMA.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;完整语义靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyDataSync001`;`tests/samples/test_sync_contract.py.sample` | | POLICY-DATA-SYNC-001 | 同步型业务须遵循幂等、事务、完整性与空结果保护(文档已声明) | references/SCHEMA.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;完整语义靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyDataSync001`;`tests/samples/test_sync_contract.py.sample` |
@@ -55,8 +61,9 @@
| slug 命名语义(verb-noun-platform) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy | | slug 命名语义(verb-noun-platform) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 | | 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 |
| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` | | 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` |
| RPA 拟人操作、选择器纪律、HITL 超时 | development/RPA.md §0–§1 | 行为与 DOM 质量,无法静态扫描 | | 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 | 需业务实现后人工补测 | | adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 |
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` | | `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
| platform_kit_min_version >= 1.2.0 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` | | platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |
| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 | | 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 |

View File

@@ -1,20 +1,47 @@
# 开发资料入口 # 开发资料入口
本目录面向**人类开发者**与 **AI 编程代理**。开始定制 skill 前,建议按以下顺序阅读: 本目录面向**人类开发者**与 **AI 编程代理**。技术人员与编程 AI **以本目录为主读路径**;`references/` 是 CLI / Action / Schema 契约细节,根目录市场四 Tab 面向最终用户。
1. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准 ## 新技能全流程(先看这张表)
2. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
3. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
4. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
5. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
6. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
7. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
8. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
9. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
10. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
11. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)(`scaffold_skill.ps1`)。 按顺序做;细节与约束见 [`DEVELOPMENT.md`](DEVELOPMENT.md) 对应章节(**不替代**下文深度规范,只作导航)。
| 步骤 | 做什么 | 详见 |
|------|--------|------|
| 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 总纲**;界面技能先完成元素拾取/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」 |
| 6 | 本地单测(mock)通过后,按业务默认 **`real_rpa`** 真实跑通;再 `release.ps1`;看 Gitea CI。**mock 通 ≠ 完成** | [`ADAPTER.md`](ADAPTER.md);[`DEVELOPMENT.md`](DEVELOPMENT.md) §15 |
| 7 | 匠厂安装后,按已声明入口自测,并看**用户体验**(进度、失败提示、有头 RPA 等) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15;[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) |
| 8 | **交活验收(第三方)**:对照 [`CHECKLIST.md`](CHECKLIST.md) 验收;报告写入仓库根目录 [`acceptance-reports/`](../acceptance-reports/) | [`CHECKLIST.md`](CHECKLIST.md) |
脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)(`scaffold_skill.ps1`)。业务仓也可直接从自建 Gitea clone,见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §4 来源 A。
**公开公共仓(可 clone 学习,可扩展登记):** [`SHARED_REPOS.md`](SHARED_REPOS.md) — 当前含 skill-template、account-manager、jiangchang-platform-kit。
## 深度规范阅读顺序
开始定制 skill 前,建议按以下顺序阅读:
1. [`AI_RPA.md`](AI_RPA.md) — **AI Agent + RPA 总纲**:职责边界、合规红线、风控、操作节奏与完整闭环
2. [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) — **浏览器账号标准**:一账号一 Profile、租约、多账号、数据查看、验收与排障
3. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展)
4. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
5. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
6. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
7. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
8. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
9. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
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)。 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),不要写进本目录。 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md),不要写进本目录。

View File

@@ -6,6 +6,7 @@
- 一个 skill 对应一份主需求文档 - 一个 skill 对应一份主需求文档
- 需求先写清楚,再进入开发 - 需求先写清楚,再进入开发
- 涉及外部系统时先读 `AI_RPA.md`,明确 Agent / RPA 边界、授权、风险与人工介入
- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节 - 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节
- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md`、`references/`、`development/` 下的技术规范 - 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md`、`references/`、`development/` 下的技术规范
@@ -22,6 +23,51 @@
- 中文 description(一句话): - 中文 description(一句话):
- account-manager platform_key(如有): - account-manager platform_key(如有):
- 命名形态:标准型 / scope 型 - 命名形态:标准型 / scope 型
- **网页 RPA(如有)— 登录策略(三选一,须写明):**
- `required`:目标站必须登录才能闭环(用户须完成/保持站点登录;技能每轮主路径须有登录门禁 + 验证码/人工等待)
- `optional`:登录可提升能力,但不登录也能完成主路径(**仅当**公开页确可闭环时选用;会话依赖型采集/评论/私信**禁止**默认抄成 optional)
- `not_needed`:公开页即可,不要求站点登录
- 无论上列哪一种:**都必须**经 account-manager **`ensure-web`**(或等价)取得 `profile_dir`(见 `development/RPA.md` §0.2);不得使用系统默认浏览器用户目录
- **登记 ≠ 登录**:ensure/add-web 只建账号与 Profile;站点登录由本技能在有头浏览器中完成
- **反模式**:把「需要登录态才能采到数据」的需求写成 `optional`,再省略 `ensure_logged_in`
## 0.1 AI + RPA 协作与授权边界(涉及外部系统时必填)
### Agent 设计
- 用户会怎样描述本任务:
- 支持哪些用户意图;分别映射到哪个 Action:
- 每种意图必须提取哪些参数:
- 缺少哪些参数时必须追问:
- 哪些动作必须先展示对象、数量和影响,再取得明确确认:
- Agent 最终如何解释成功、部分成功、失败、等待人工和停止:
### 授权与平台边界
- 目标浏览器 / 桌面软件 / 手机 App:
- 账号、设备、数据和业务权限由谁授权:
- 是否有获得授权的官方 API;若不用,原因是什么:
- 目标平台规则、合同或客户约定中的自动化边界:
- 明确禁止的实现路线:破解 / 反编译 / 逆向 / 私有协议 / 抓包拼隐藏接口 / Hook 或注入 / 验证码破解 / 设备指纹伪造 / 自动换号:
- 正常界面无法闭环时的上报与停止条件:
### RPA 风控设计
- 执行端:浏览器 / 桌面 / Android / iOS:
- 真实界面版本、拾取工具和拾取日期:
- element map 文件路径;关键元素是否全部登记:
- 每个关键元素的作用域、主定位器、匹配数量和已验证 fallback:
- 页面 / iframe / 窗口 / 弹窗 / Native / WebView 如何选择:
- 会话、Profile、窗口或设备如何准备与互斥:
- 单批上限、单位时间操作量和最长运行时间:
- 普通动作节奏(真实环境默认 `1~3` 秒):
- 页面 / 控件真实状态如何判断;禁止用固定睡眠代替的节点:
- 哪些错误允许有限重试;退避策略:
- 哪些信号必须立即停止(频控、封禁、权限不足、结构变化等):
- 哪些步骤进入人工处理(验证码、人脸、指纹、短信、U盾等):
- 如何防止重复提交并支持必要的断点续跑:
- 需要保存哪些日志、截图、视频和业务结果:
- 定位失败时如何保存 DOM/UIA/XML、候选数量和环境版本:
## 1. 文档目标 ## 1. 文档目标
@@ -102,7 +148,10 @@
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:` - 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
- Windows 环境下需保证 UTF-8 输出兼容 - Windows 环境下需保证 UTF-8 输出兼容
- 必须具备基本日志能力;敏感字段脱敏 - 必须具备基本日志能力;敏感字段脱敏
- 只做授权范围内的正常界面自动化;禁止破解、逆向、未授权私有协议、安全验证绕过、进程注入、凭据窃取、自动换号和无限重试
- RPA 的页面加载与成功判断必须由真实状态驱动;正常业务动作之间默认随机等待 `1~3` 秒
- RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args` - RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args`
- 网页 RPA:启动前必须经 ensure-web 拿到 `profile_dir`;生产有头;站点登录策略见 §0(required / optional / not_needed);`required` 时每轮须登录门禁
## 6. 输入输出要求 ## 6. 输入输出要求
@@ -156,21 +205,29 @@
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致 - 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
- `health`、`version`、`init-db` 命令执行正常 - `health`、`version`、`init-db` 命令执行正常
- 主命令(如 `run`)在 mock / simulator 档位可重复验证 - `python tests/run_tests.py -v` 必跑测试全部通过(mock / 离线门禁;**mock 通 ≠ 完成**)
- `python tests/run_tests.py -v` 必跑测试全部通过 - 主命令在业务默认档(模板为 `real_rpa`)真实跑通;不得只 mock 通就交活
- `task_logs` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范) - `task_logs` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name` - `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order` - 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
- `tests/test_display_metadata.py` 通过 - `tests/test_display_metadata.py` 通过
- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联 - 真实联调样例仍放 `tests/integration/`,默认套件不包含真实外联(与业务 `.env` 默认 `real_rpa` 不冲突)
- 发布后 Gitea 工作流成功;匠厂技能市场可见最新版本;安装后可在“新建任务”中调用 - `SKILL.md` 的 `developer_ids` 已配置为开发者本人匠厂用户 ID(开发期不公开时,否则本人无法在市场安装自测;取 ID 步骤见 `development/DEVELOPMENT.md` §6)
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
- 安装后按本技能 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或其他规避平台限制的实现
- RPA 真实主路径已验证状态等待、`1~3` 秒正常操作节奏、有限重试、账号 / 设备串行、人工介入、结果核验和结束存证
- RPA 关键元素已在真实界面拾取并登记 element map;关键动作唯一定位,重启会话后复测通过
- 页面/窗口/设备结构变化时停止并保存截图与 DOM/UIA/XML,不运行时猜 selector
## 10. 开发注意事项 ## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目 - 只修改当前 skill 仓库,不要改动无关兄弟项目
- 正常界面或获得授权的官方 API 无法完成闭环时,停止并上报;不得擅自改走破解、逆向或绕过安全机制的路线
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md` - 先判断四象限类型(`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`) - `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`)
- 发布前完成本地验证、工作流验证和正式环境安装验证 - 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)
## 11. 变更记录 ## 11. 变更记录
@@ -182,10 +239,11 @@
## 建议使用方式 ## 建议使用方式
1. 先写 `REQUIREMENTS.md` 1. 拿到业务技能仓库(Gitea clone,或本地从 `skill-template` scaffold / 复制;见 `DEVELOPMENT.md` §4)
2. 再按 `DEVELOPMENT.md` 进入开发 2. 先写 / 填全本文件 `REQUIREMENTS.md`
3. 开发过程中补充 `references/CLI.md`、`references/SCHEMA.md` 及 `development/` 技术规范 3. 再按 `DEVELOPMENT.md` 进入开发(含尽早配置 `developer_ids`)
4. 发布前对照第 9 节验收标准逐项检查 4. 开发过程中补充 `references/CLI.md`、`references/SCHEMA.md` 及 `development/` 技术规范
5. 发布前对照第 9 节验收标准逐项检查;release 后按 `DEVELOPMENT.md` §15 做宿主多入口验收
## 最小模板示例 ## 最小模板示例
@@ -246,8 +304,8 @@
- 命令可运行;tests/run_tests.py -v 通过 - 命令可运行;tests/run_tests.py -v 通过
- task_logs 符合 SCHEMA - task_logs 符合 SCHEMA
- 正式环境安装验证通过 - `developer_ids` 已配置为「你的用户ID」(匠厂设置中复制的正整数)
- 正式环境安装验证通过;已声明的宿主入口(新建任务 / 数据管理 / 定时任务 / 任务中心 / 技能详情等)按 DEVELOPMENT §15 自测
## 10. 开发注意事项 ## 10. 开发注意事项
- 不修改无关项目;不引入旧模板结构 - 不修改无关项目;不引入旧模板结构

View File

@@ -2,7 +2,17 @@
> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill,都应先读这份文档,按这里的选型和范式落地,不要每个项目重新踩坑。 > 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill,都应先读这份文档,按这里的选型和范式落地,不要每个项目重新踩坑。
我们开发的各类 skill,本质上都是在替人操作三类界面:**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:保持登录态、有头运行、拟人操作、失败存证、人工兜底。 先读 [`AI_RPA.md`](AI_RPA.md):Agent 负责理解、确认与编排,RPA 只负责授权范围内的确定性界面执行。RPA 是代替人完成正常业务操作的工具,**不是**破解、逆向、私有协议调用或对抗第三方安全机制的手段。
网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。账号设计、查看、租约、多账号和验收标准先读 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md);二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
我们开发的各类 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)
--- ---
@@ -12,19 +22,27 @@
| 约定 | 说明 | | 约定 | 说明 |
|------|------| |------|------|
| **保持登录态** | 复用持久化 Profile / session,避免每次重新登录触发风控;账号由 account-manager 下发,不硬编码 | | **可控会话 / Profile** | 网页版见下方 **§0.2**(必须走 account-manager 的 `profile_dir`);桌面/手机用对应持久会话,不硬编码密码 |
| **有头运行** | 默认有头(headless 易被识别 / 难人工介入);`OPENCLAW_BROWSER_HEADLESS=1` 仅给 CI | | **有头运行** | 生产 RPA **必须有头**(见 §0.2);禁止把无头当交付/联调常态 |
| **拟人操作** | 真实事件(isTrusted=true),逐字输入、随机延迟、贝塞尔鼠标轨迹;严禁 JS 直接设值/JS 点击/JS 跳转 | | **正常人工节奏** | 真实点击、逐字输入、自然鼠标轨迹、分段滚动;严禁 JS 直接设值/JS 点击/JS 跳转绕过页面流程 |
| **步骤间随机等待** | 每两步操作之间 `random_delay(min,max)`,区间由 `.env` 配置(默认 1~5s) | | **步骤间随机等待** | 每两个正常业务动作之间 `random_delay(min,max)`,真实环境默认 **1~3 秒**;状态等待另算,不得用固定睡眠代替 |
| **人工兜底(HITL)** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 | | **人工兜底(HITL)** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` | | **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/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` 等,见下方错误码表 | | **统一错误码** | `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) | | **步骤可控制** | 每条 RPA 指令 = 一个 `@rpa_step` 或 `video.add_step`;等待用 `interruptible_sleep`;宿主通过 control.json 暂停/继续/停止(SRCP,见 [`LOGGING.md`](LOGGING.md) §2.5) |
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 | | **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
> 三端各自实现一个会话抽象 `RpaSession`(launch / login / act / screenshot / close),上层 skill 不感知是浏览器还是手机。 > 三端各自实现一个会话抽象 `RpaSession`(launch / login / act / screenshot / close),上层 skill 不感知是浏览器还是手机。
### 0.0 合规红线
- 只使用用户或企业合法拥有、明确授权的账号、数据、设备和业务权限。
- 优先正常界面或获得授权的官方 API;只有破解、逆向或绕过安全机制才能实现时,停止并上报。
- 禁止反编译、私有协议、抓包拼装隐藏接口、Hook / 注入、客户端篡改、验证码破解、设备指纹伪造、撞库、养号、自动换号或代理池规避限制。
- 验证码、人脸、指纹、短信、U盾、动态口令必须交给人工;权限不足、账号风险、频控或封禁必须安全停止。
- 合规与完整职责边界以 [`AI_RPA.md`](AI_RPA.md) 为准。
### 0.1 步骤控制与进度(SRCP,platform-kit >= 1.2.0) ### 0.1 步骤控制与进度(SRCP,platform-kit >= 1.2.0)
- **禁止** `scripts/util/progress.py` 或 `print({"type":"progress",...})`;进度只走 `emit` / Run Journal。 - **禁止** `scripts/util/progress.py` 或 `print({"type":"progress",...})`;进度只走 `emit` / Run Journal。
@@ -44,34 +62,102 @@ async def open_login(page):
规范权威定义:[`LOGGING.md`](LOGGING.md) §2.5。金样代码:[`scripts/service/task_service.py`](../scripts/service/task_service.py)。 规范权威定义:[`LOGGING.md`](LOGGING.md) §2.5。金样代码:[`scripts/service/task_service.py`](../scripts/service/task_service.py)。
### 0.2 网页版 RPA 硬规则(Profile / 有头 / 登录分平台)
凡**打开网页、用浏览器自动化**的技能(真实站或仿真站),必须遵守(`POLICY-RPA-004`):
| 规则 | 要求 |
|------|------|
| **Profile 强制** | 启动浏览器前必须已从 **account-manager**(经本技能 `account_client`)拿到可用的 **`profile_dir`(及租约约定)**。只用该目录做 `launch_persistent_context`。 |
| **首启用 ensure** | 业务技能默认走 **`account ensure-web`**(或 `pick-web --ensure`),经 `account_client.pick_web_account` 封装。无匹配账号时自动 `add-web` 再返回;**有账号但均被租约占用 → `ERROR:LEASE_CONFLICT`,禁止再新建重复账号**。 |
| **拿不到就停** | 未拿到 `profile_dir`、ensure 失败、租约失败 → **立即失败退出,禁止开浏览器**。不得静默落到系统默认 Chrome/Edge 用户目录。 |
| **有头强制** | 生产与用户侧联调:**`OPENCLAW_BROWSER_HEADLESS=0`(有头)**。禁止把无头(`=1`)当作交付或真实站联调常态(无头易风控、难人工介入,且易污染身份)。 |
| **登录不全局强制** | **是否要在目标站完成登录**由平台决定(如部分公开页可不登录也能闭环)。由做该技能的同事在 `REQUIREMENTS.md` / 用户教程中约定;**模板不强制「所有网页 RPA 必须先登录」**。 |
| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 |
| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 |
| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`);**禁止**把会话依赖型采集默认抄成 `optional`。 |
| **账号必须可见可查** | 创建后须能在宿主数据管理、`account list/get` 和实际 `profile_dir` 中核验;不得把“命令没报错”当作创建与登录验收。 |
| **一账号一 Profile** | 不同账号必须使用不同且稳定的 `profile_dir`;不得共用、临时创建或指向本机日常浏览器目录。 |
| **租约贯穿任务** | 所有账号选择路径(包括指定数字 ID)都必须取得非空 `lease_token`;成功、失败、取消和异常均在 `finally` 释放。 |
**反模式(禁止):**
- 未拿到 `profile_dir` 仍 `launch` / 仍打开页面
- 用系统默认用户数据目录或随意空目录反复跑真实站 → **污染本机 Profile、加重风控**
- 把「本站可不登录」写成「可以不走 account-manager」
- 真实站联调默认无头狂跑
- 仅用哈希 class / 不稳定 CSS 作为**唯一**登录态判定(须结合稳定文案、`get_by_role`、URL/业务 DOM;F12 实页确认)
- 把「业务空结果」(如无评论)与 `LEASE_CONFLICT` / `REQUIRE_LOGIN` 混成同一错误码
**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → ensure-web → RPA → `finally release_lease`)。
账号完整生命周期、数据目录、数据管理查看方式、矩阵场景和排障证据见 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)。不得只测试单账号一两次就宣称支持批量或多账号。
---
### 0.3 工业级风控、等待与收尾
**状态等待优先:**页面加载、控件可用、弹窗消失、保存成功、下载完成和业务结果出现,必须等待真实条件并设置超时;禁止用固定 `sleep(3)` 假设成功。
**操作节奏:**
- 两个正常业务动作之间默认随机等待 `1~3` 秒。
- 输入采用逐字符随机延迟;鼠标使用自然轨迹;滚动采用分段操作。
- 同一账号、Profile、桌面会话或手机设备默认串行。
- 单批数量、单位时间操作量和运行时长必须有上限。
- 只对明确可重试错误做有限重试;普通网络错误可递增退避,风险提示和权限错误不得自动重试。
**前后缓冲:**
- 启动浏览器、桌面软件或手机会话后,先等待目标页面 / 窗口 / 控件真实就绪,再额外缓冲约 `1.5~2.5` 秒。
- 页面导航后,关键控件就绪再缓冲约 `1~2` 秒。
- 流程结束时,先确认业务结果、写日志并完成存证,再缓冲约 `2~3` 秒关闭会话。
- 付款、审批、发布等重要动作确认结果并截图后,可缓冲约 `3~5` 秒。
缓冲用于稳定界面和改善用户体验,**不能作为成功判断**。RPA 主路径使用 `interruptible_sleep` 等可控制等待,确保暂停与停止能够生效。
**风险信号:**
| 信号 | 标准处理 |
|------|----------|
| 验证码 / 人脸 / 指纹 / U盾 | 进入人工处理 |
| 频率限制 | 停止当前批次或长时间退避 |
| 账号风险 / 封禁 / 安全警告 | 立即停止,禁止换号继续 |
| 权限不足 | 明确失败,不尝试绕过 |
| 页面 / 控件结构变化 | 截图并停止,重新人工验证定位 |
| 提交结果不明确 | 查询业务状态,确认前禁止再次提交 |
--- ---
## 1. 浏览器(标准已成熟) ## 1. 浏览器(标准已成熟)
**选型:Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts`、`receive-order` 已落地)。 **选型:Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts`、`receive-order` 已落地)。
元素拾取、定位优先级、iframe/弹窗/Shadow DOM、动态列表和唯一性标准见 [`RPA_BROWSER.md`](RPA_BROWSER.md)。本节继续定义会话、启动、HITL 和工程边界。
### 1.0 生产路径(优先) ### 1.0 生产路径(优先)
| 项 | 标准 | | 项 | 标准 |
|----|------| |----|------|
| 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context`,`channel="chrome"` 或 Edge;不用内置 Chromium,**技能内不要 `playwright install`** | | 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context`,`channel="chrome"` 或 Edge;不用内置 Chromium,**技能内不要 `playwright install`** |
| 登录态 | 持久 Profile 目录;账号、profile、lease **统一走 account-manager**(或对应兄弟技能),不硬编码密码 | | Profile / 账号 | **必须**经 account-manager **`ensure-web`**(或 `pick-web --ensure`)取得 `profile_dir`(见 §0.2);密码不进 `.env`;**是否站点登录**按平台在需求/教程约定;登记 ≠ 已登录 |
| CDP | **仅作诊断 / 桌面宿主类场景**;**不要**作为强风控站点的默认生产路径 | | CDP | **仅作诊断 / 桌面宿主类场景**;**不要**作为强风控站点的默认生产路径 |
| 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 | | 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 |
| 模式 | 默认有头 `OPENCLAW_BROWSER_HEADLESS=0`;无头仅 CI | | 模式 | **有头** `OPENCLAW_BROWSER_HEADLESS=0`(生产强制,见 §0.2) |
| 反检测 | stealth 默认开 `OPENCLAW_PLAYWRIGHT_STEALTH=1`(见 1.1) | | 浏览器兼容配置 | `OPENCLAW_PLAYWRIGHT_STEALTH` 仅用于经评估的兼容配置;不得用于伪造身份或绕过平台限制 |
### 1.1 Playwright 启动标准 ### 1.1 Playwright 启动标准
1. **默认有头**:`OPENCLAW_BROWSER_HEADLESS=0`(`.env.example` 默认值)。 1. **有头**:`OPENCLAW_BROWSER_HEADLESS=0`(`.env.example` 默认;网页 RPA 生产路径禁止改成无头常态,见 §0.2)。
2. **stealth 默认开**:`OPENCLAW_PLAYWRIGHT_STEALTH=1`;通过 `add_init_script` 注入指纹淡化脚本。 2. **stealth 须受控**:`OPENCLAW_PLAYWRIGHT_STEALTH` 仅在目标平台授权、规则与项目评估允许时启用;不得把它理解为规避风控或伪造设备身份。
3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。 3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。
4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。 4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。
5. **不要默认传 `--disable-blink-features=AutomationControlled`**;platform-kit stealth 已覆盖,额外 flag 可能适得其反。 5. **不要默认传 `--disable-blink-features=AutomationControlled`**;platform-kit stealth 已覆盖,额外 flag 可能适得其反。
6. **可以** `ignore_default_args=["--enable-automation"]`(platform-kit `launch_persistent_browser` 已处理)。 6. **可以** `ignore_default_args=["--enable-automation"]`(platform-kit `launch_persistent_browser` 已处理)。
7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、持久 profile;**不要**直接拼接搜索结果 URL 或 DOM 注入。 7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、account-manager 持久 profile;**不要**直接拼接搜索结果 URL 或 DOM 注入。
8. **先 Profile 再开浏览器**:`user_data_dir` 必须来自 ensure/pick 到的 `profile_dir`;未拿到则不得进入本节后续步骤。
指纹淡化(stealth)典型项:`navigator.webdriver=undefined`、`chrome.runtime`、`permissions.query`、`plugins`、`languages` 等。共享实现见 `jiangchang_skill_core.rpa`(platform-kit **>= 1.2.0**)。 浏览器兼容配置由共享 `jiangchang_skill_core.rpa` 提供(platform-kit **>= 1.2.0**)。业务技能不得自行扩展指纹伪造、代理轮换或规避安全检测的代码;遇到明确风控信号时转人工或停止。
**拟人操作**(必做): **拟人操作**(必做):
@@ -79,7 +165,7 @@ async def open_login(page):
- 鼠标:贝塞尔曲线轨迹 + 微抖动;进场随机晃动。 - 鼠标:贝塞尔曲线轨迹 + 微抖动;进场随机晃动。
- 导航:用真实点击触发,**不要 `window.location.href=` / JS 点击跳转**。 - 导航:用真实点击触发,**不要 `window.location.href=` / JS 点击跳转**。
- 翻页:真实点击翻页控件,注意排除禁用态。 - 翻页:真实点击翻页控件,注意排除禁用态。
- 延迟:每步之间 `random_delay`(`.env` 中 `STEP_DELAY_MIN/MAX`)。 - 延迟:两个正常业务动作之间 `random_delay`(`.env` 中 `STEP_DELAY_MIN/MAX`,默认 1~3 秒);页面状态仍须显式等待。
### 1.2 页面启动标准 ### 1.2 页面启动标准
@@ -186,7 +272,7 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
| Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 | | Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 |
| 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`)+ `account_client.py` subprocess | | 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`)+ `account_client.py` subprocess |
| 登录 | **双层**:门户 HITL(`#portal-user` / `#portal-pass` 类泛化 DOM)+ 业务系统登录(技能内自写) | | 登录 | **双层**:门户 HITL(`#portal-user` / `#portal-pass` 类泛化 DOM)+ 业务系统登录(技能内自写) |
| 账号 | `url` 用行业根,不用 `/login`;`auth_strategy=per_session_manual`;`pick_web_account` + `release_lease` | | 账号 | `url` 用行业根,不用 `/login`;`auth_strategy=per_session_manual`;`pick_web_account`(底层 ensure-web)+ `release_lease` |
| 禁止 | **不要** `import account-manager` 的 `rpa_helpers` 等内部模块 | | 禁止 | **不要** `import account-manager` 的 `rpa_helpers` 等内部模块 |
| Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback;共享 sandbox **不要求**为技能加 testid | | Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback;共享 sandbox **不要求**为技能加 testid |
| Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 | | Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 |
@@ -197,13 +283,14 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
## 2. 桌面软件(Windows 原生程序) ## 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/名字),坐标点击只做最后兜底。 桌面端常见于 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 运行时 | | **FlaUI**(经 pythonnet 调 .NET) | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 |
| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 | | **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 |
| **PyAutoGUI / SikuliX(图像识别)** | ⚠️ 兜底 | 控件树完全拿不到时(Flash/远程桌面/纯自绘 UI);**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 | | **PyAutoGUI / SikuliX(图像识别)** | ⚠️ 兜底 | 控件树完全拿不到时(Flash/远程桌面/纯自绘 UI);**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 |
@@ -214,13 +301,15 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
- **DPI/缩放**:图像识别方案必须固定显示缩放比例;UIA 方案不受影响(优先用 UIA 即是为此)。 - **DPI/缩放**:图像识别方案必须固定显示缩放比例;UIA 方案不受影响(优先用 UIA 即是为此)。
- **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`。 - **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`。
- **人工兜底**:U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`。 - **人工兜底**:U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`。
- **禁止侵入**:不注入或 Hook 第三方进程,不修改客户端文件,不反编译或调用未授权私有协议。
```bash ```bash
pip install pywinauto # UIA 自动化 pip install uiautomation # 当前真实成功案例
# 或:pip install pywinauto # UIA backend
# 图像兜底:pip install pyautogui opencv-python # 图像兜底:pip install pyautogui opencv-python
``` ```
> 状态:桌面端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。 > 状态:UIA 技术路线已经 `add-friend-wecom` 真实项目验证。脱敏、收紧后的参考 example 见 `examples/real_desktop_rpa/`;目标软件 selector 仍必须重新拾取。
--- ---
@@ -228,6 +317,8 @@ pip install pywinauto # UIA 自动化
**选型:Android 用 uiautomator2(或 Appium);iOS 用 Appium + WebDriverAgent(需 Mac)。** 底层都是经 USB 的 ADB / WDA。 **选型: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 | | **Android** | **uiautomator2**(python 原生) | ✅ 首选 | ADB over USB,直接拿控件树点击/输入,比 Appium 轻快,纯 Python |
@@ -242,6 +333,7 @@ pip install pywinauto # UIA 自动化
- **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。 - **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。
- **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。 - **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。
- **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`。 - **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`。
- **设备边界**:使用正常授权设备与官方 App;不 Root / 越狱,不 Hook,不修改 APK / IPA,不伪造设备身份或绕过签名、绑定与安全校验。
```bash ```bash
# Android(首选) # Android(首选)
@@ -250,7 +342,7 @@ python -m uiautomator2 init # 初始化设备端 agent
# 或统一走 Appium:pip install Appium-Python-Client(另需 Appium Server) # 或统一走 Appium:pip install Appium-Python-Client(另需 Appium Server)
``` ```
> 状态:手机端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。 > 状态:手机端标准已定义,但**尚无团队真实成功案例**。`examples/mobile_rpa/` 仅是 example 完成规格,不能作为已跑通代码照抄;收到成功案例后再升级状态。
--- ---
@@ -260,14 +352,18 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
| 错误码 | 含义 | 上层处理建议 | | 错误码 | 含义 | 上层处理建议 |
|--------|------|------| |--------|------|------|
| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程 | | `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程(有头等待人工) |
| `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 | | `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 |
| `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 | | `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 |
| `ERROR:LEASE_CONFLICT` | 已有账号但均被租约占用(ensure 不新建) | 稍后重试或释放租约;**不是**「无账号」 |
| `ERROR:AMBIGUOUS_ACCOUNT` | `--login-id` / `--label` 匹配到多条 | 改用数字 id 或更精确标识 |
| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 | | `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 |
| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 | | `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 |
| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB | | `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB |
| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 | | `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 |
业务空结果(如「本页无评论」)用业务码或成功空列表表达,**不要**复用上表账号/登录错误码。
--- ---
## 5. 存证与录屏规范 ## 5. 存证与录屏规范
@@ -320,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 的四档适配器模式 - `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
- `CONFIG.md` — `.env` 配置规范与首次落盘机制 - `CONFIG.md` — `.env` 配置规范与首次落盘机制
- `RUNTIME.md` — 运行时目录与环境变量约定 - `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

@@ -2,10 +2,22 @@
## 共享 Python Runtime ## 共享 Python Runtime
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.0` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored,应由共享 venv 的 site-packages 提供。 **skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.2` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored,应由共享 venv 的 site-packages 提供。
技能根目录 `requirements.txt` **只声明技能特有依赖**;**不要**重复声明 `jiangchang-platform-kit` 或 `playwright`。`SKILL.md` 的 `platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。 技能根目录 `requirements.txt` **只声明技能特有依赖**;**不要**重复声明 `jiangchang-platform-kit` 或 `playwright`。`SKILL.md` 的 `platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。
kit **源码仓**(可 clone 学习,勿 vendor)与其它公开公共仓登记见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
### 何时上调 `platform_kit_min_version`
| 情况 | 做法 |
|------|------|
| 技能依赖 kit 某次修复/新 API(如 `.env` 空值+行尾 `#` 解析、新 RPA helper) | 把 `metadata.openclaw.platform_kit_min_version` 调到**需要的最低版本**(例如 `"1.2.2"`) |
| PyPI/私有源刚发了更新,但本技能行为未依赖 | **不必**仅为「追新」抬版本 |
| 宿主升级时机 | 宿主在技能/宿主声明的最低版本**未满足**时按需升级共享 venv;**不会**仅因源上有更新就后台拉 latest |
模板默认声明须与当前文档所依赖的最低 kit 能力对齐(见根 `SKILL.md`)。
| 场景 | 推荐做法 | | 场景 | 推荐做法 |
|------|----------| |------|----------|
| 日常运行(宿主 / Agent) | 使用 PATH 上的共享 venv `python`(`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`) | | 日常运行(宿主 / Agent) | 使用 PATH 上的共享 venv `python`(`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`) |
@@ -27,7 +39,7 @@ Unix(未注入时):
共享解释器通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入(见 `jiangchang_skill_core.runtime_env`)。 共享解释器通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入(见 `jiangchang_skill_core.runtime_env`)。
## Runtime 诊断(platform-kit 1.2.0+) ## Runtime 诊断(platform-kit 1.2.2+)
`health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段: `health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段:
@@ -45,7 +57,7 @@ Unix(未注入时):
- 用户实际 `.env`:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/.env`。 - 用户实际 `.env`:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/.env`。
- `scripts/main.py` 与 `cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`。 - `scripts/main.py` 与 `cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`。
- 配置优先级:**进程环境变量** > **用户 `.env`** > **`.env.example` 默认值**。 - 配置优先级:**进程环境变量** > **用户 `.env`** > **`.env.example` 默认值**。
- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.2.0`,**不得** vendored `scripts/jiangchang_skill_core/`。 - 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.2.2`,**不得** vendored `scripts/jiangchang_skill_core/`。
## media-assets / ffmpeg / 背景音乐 ## media-assets / ffmpeg / 背景音乐

131
development/SHARED_REPOS.md Normal file
View File

@@ -0,0 +1,131 @@
# 公共仓库目录(可扩展)
本页是技能开发用到的**公开公共仓唯一登记处**,面向技术人员与 AI 编程代理。
- 仓均在公司**自建 Gitea**:[https://git.jc2009.com/](https://git.jc2009.com/)(基于开源 Gitea 自建,**不是** Gitea 官网)。
- 技术人员一般**可直接访问、随时 `git clone` 到本地**,用于研究、学习、对照与跟进更新。
- **当前登记 3 项**;以后新增公开公共仓时,**只在本页按类型加行**,不必改散落长文。
深度规范仍在 [`RPA.md`](RPA.md)、[`RUNTIME.md`](RUNTIME.md)、[`ADAPTER.md`](ADAPTER.md)、[`DEVELOPMENT.md`](DEVELOPMENT.md);本页只做**指路 + 边界**。
---
## 1. 类型(可扩展)
| 类型 id | 含义 | 典型消费方式 |
|---------|------|--------------|
| `skill-template` | 业务技能作者脚手架与规范源 | clone 学习;`scaffold` / 复制出新业务仓;**不要**在模板仓写业务 |
| `sibling-skill` | 与业务技能并列安装的公开基础设施技能 | clone 学契约;运行时经 **CLI / 封装客户端** 调用 |
| `python-sdk` | 共享 Python 库(源码仓 + 宿主共享 venv 安装) | clone 读实现 / 贡献;运行时 **`import`**,**禁止 vendor** 进技能仓 |
| `asset-bundle` | (预留)共享媒体/资源包等 | 按该仓说明拉取或由 kit/宿主解析 |
| `tooling` | (预留)发布脚本、脚手架、CI 辅助等 | clone 使用或引用其 workflow |
新增类型时:先在本表加一行类型说明,再在 §2 登记表增加实例。
---
## 2. 当前登记表
| 短名 | 类型 | Gitea(唯一入口) | 一句话职责 |
|------|------|-------------------|------------|
| **skill-template** | `skill-template` | [https://git.jc2009.com/client-commons/skill-template](https://git.jc2009.com/client-commons/skill-template) | 新业务技能的开发模板、目录规范与发布约定 |
| **account-manager** | `sibling-skill` | [https://git.jc2009.com/client-commons/account-manager](https://git.jc2009.com/client-commons/account-manager) | 账号 / 凭据 / 浏览器 Profile / 租约的统一入口 |
| **jiangchang-platform-kit** | `python-sdk` | [https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit](https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit) | 共享 SDK(`jiangchang_skill_core` 等);技能发布可复用 CI 也在此仓 |
本地常见路径(仅作对照,以你机器为准):
- `D:\OpenClaw\client-commons\skill-template`
- `D:\OpenClaw\client-commons\account-manager`
- `D:\OpenClaw\client-jiangchang\jiangchang-platform-kit`
### 2.1 skill-template
| 项 | 说明 |
|----|------|
| **Clone 为了什么** | 读 `development/`、跟模板版本、用 `tools/scaffold_skill` 开新技能 |
| **怎么用** | 来源 B:scaffold;或学完后在业务仓开发。规范以本仓为准 |
| **红线** | 不要在模板仓写业务;业务仓 `origin` 不得指向本仓;不得保留 `.openclaw-skill-template` |
| **深入** | 本目录 [`README.md`](README.md)、[`DEVELOPMENT.md`](DEVELOPMENT.md) |
```powershell
git clone https://git.jc2009.com/client-commons/skill-template.git
```
### 2.2 account-manager
| 项 | 说明 |
|----|------|
| **Clone 为了什么** | 读 CLI / `references/INTEGRATION.md`、平台表、租约与错误码;排 `NO_ACCOUNT` / `LEASE_CONFLICT` 等 |
| **为什么必须用** | 支撑真实业务的一账号一 Profile、登录状态持久化、多账号矩阵和并发互斥;禁止浏览器“裸奔” |
| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir + lease_token`(`ensure-web` / `pick-web`) |
| **红线** | **禁止**业务技能 `import` 其内部 `rpa_helpers` / 散落存密码(见 `POLICY-RPA-001`);对方文档若写「同进程 rpa_helpers」,**以本模板约束为准** |
| **创建后查看** | 宿主数据管理;CLI `account list/get`、`lease list`;本地用户数据目录中的 `account-manager.db` 与 `profiles/` |
| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`、`SCHEMA.md`;本模板 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)、[`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) |
```powershell
git clone https://git.jc2009.com/client-commons/account-manager.git
```
### 2.3 jiangchang-platform-kit
| 项 | 说明 |
|----|------|
| **Clone 为了什么** | 读 `jiangchang_skill_core`(config / logging / activity·SRCP / rpa / sibling_bridge 等)、跟版本、贡献共享 API |
| **怎么用(业务技能)** | 运行时由**宿主共享 venv** 提供;技能内 `import jiangchang_skill_core...`。`SKILL.md` 的 `platform_kit_min_version` 是兼容声明 |
| **红线** | **不要**把 `jiangchang-platform-kit` / `playwright` 写入技能 `requirements.txt`;**不要**在技能仓 vendor `jiangchang_skill_core/`(`POLICY-RUNTIME-002`) |
| **深入** | 对方仓 `README.md`;本模板 [`RUNTIME.md`](RUNTIME.md)、[`LOGGING.md`](LOGGING.md) |
```powershell
git clone https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit.git
```
私有 PyPI 索引(维护/排障用,日常技能开发通常不必手装):
`https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/`
---
## 3. 三者关系(别当成三个无关收藏夹)
```text
规范 / 脚手架 ──────────► skill-template(本仓)
│
业务技能 scripts/ ──import──► 宿主共享 venv 中的 platform-kit
│
└──CLI──► account-manager(兄弟技能,要先拿到 profile_dir 等)
```
- **写规范、开新仓** → 打开 **skill-template**
- **少写公共 Python、进度/RPA 原语** → 用 **kit**(运行靠宿主;源码可 clone)
- **账号与浏览器 Profile** → 用 **account-manager**(运行靠 CLI;源码可 clone)
---
## 4. 何时该打开哪个仓
| 场景 | 优先打开 |
|------|----------|
| 不知道新技能目录/发布/developer_ids 怎么做 | **skill-template** `development/` |
| 定 `platform_key`、ensure-web、租约、凭证存储 | **account-manager** + 本模板 RPA/ADAPTER |
| 账号创建后不知道去哪看、登录态不保持、多账号互相污染 | 本模板 **ACCOUNT_MANAGER.md** + account-manager 数据管理 / CLI |
| 看 `emit` / `finish` / `@rpa_step` / `launch_persistent_browser` 实现 | **jiangchang-platform-kit** |
| 技能发布 CI / reusable workflow 从哪来 | **jiangchang-platform-kit** `.github/workflows/` |
| 「公共东西到底在哪」 | **本页登记表** |
---
## 5. 以后如何新增一条公共仓(维护约定)
1. 确认公开可访问,URL 使用 `https://git.jc2009.com/...`(勿写 Gitea 官网)。
2. 选定或新增 §1 **类型**。
3. 在 §2 **登记表**加一行;必要时增加 `§2.x` 短节(消费方式 + 红线 + clone 示例)。
4. 若改变业务技能硬约束,同步 [`POLICY_MATRIX.md`](POLICY_MATRIX.md) / 相关深度文档。
5. **不要**只在某个业务技能 README 里私藏链接而不登记本页。
---
## 6. 相关链接
- 开发入口:[`README.md`](README.md)
- 运行时:[`RUNTIME.md`](RUNTIME.md)
- 网页 RPA / 账号:[`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)
- 脚手架:[`../tools/README.md`](../tools/README.md)

View File

@@ -23,16 +23,23 @@
4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。 4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。
5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。 5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。
6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。 6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。
7. **显式声明 `executionProfile`** — 每个 action **必须**写明 `sync` 或 `async`;禁止依赖宿主缺省;禁止第三种模糊模式(`auto` / `background` / `deferred` 等)。 7. **显式声明 `executionProfile`** — 心智默认 sync、例外才 async;每个 action **必须**写明 `sync` 或 `async`;宿主省略时按 sync,新技能仍禁止赌缺省;禁止第三种模糊模式(`auto` / `background` / `deferred` 等)。
--- ---
## 2. `executionProfile`:sync vs async ## 2. `executionProfile`:sync vs async
### 心智(先记住)
1. **默认按 sync 想**:多数 Action(诊断、统计、入队、秒级查询)是短调用,当场返回,**不进**任务中心。
2. **只有特殊情况标 async**:开浏览器 / RPA / 长下载 / 需要进度·暂停·停止时,才显式 `"executionProfile": "async"`,进入**任务中心**。
3. **匠厂宿主**:`actions.json` 若**省略** `executionProfile`,按 **`sync`** 处理(不再把漏写当成进任务中心)。
4. **新技能仍须显式写出** `sync` 或 `async`(模板 Schema / `POLICY-SKILL-ACTION-002` 硬门禁),禁止赌宿主缺省;显式写出也便于人和 AI 阅读。
| 值 | 宿主行为 | 超时 | 任务中心 | | 值 | 宿主行为 | 超时 | 任务中心 |
|----|----------|------|----------| |----|----------|------|----------|
| **`sync`** | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **30s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 | | **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **30s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
| **`async`** | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 | | **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 |
直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。 直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。
@@ -45,9 +52,9 @@
| `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` | | `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` |
| 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 | | 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 |
合法:`sync|async` × `toolbar|cron|agent|skill-detail`。 合法:`sync|async` × `toolbar|row|batch|cron|agent|skill-detail`。
历史说明:旧文档曾错误地把数据管理按钮入口与异步执行方式绑死。该约束**已废止**;模板测试与 Schema **不得**再建立「入口位置决定 sync/async」一类硬耦合。宿主省略 `executionProfile` 时的兼容行为以宿主实现为准,**新技能不得依赖**。 历史说明:旧文档曾错误地把数据管理按钮入口与异步执行方式绑死。该约束**已废止**;模板测试与 Schema **不得**再建立「入口位置决定 sync/async」一类硬耦合。旧版宿主曾「省略 → async」;**当前宿主为「省略 → sync」**。新技能仍须显式声明,不得依赖省略行为。
--- ---
@@ -55,14 +62,18 @@
多个入口统一走 `POST /api/skill-actions/run`,可能带 `source.kind`: 多个入口统一走 `POST /api/skill-actions/run`,可能带 `source.kind`:
| 入口 | 常见 `source.kind` | 说明 | | 入口(匠厂侧栏 / 界面) | 常见 `source.kind` | 技能侧 | 开发者怎么测 |
|------|-------------------|------| |-------------------------|-------------------|--------|--------------|
| **Agent** | `agent` | `run_skill_action`;按 manifest 的 executionProfile 执行 | | **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 |
| **数据管理** | `data-management` | toolbar 按钮;须 `bind.tables` | | **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 |
| **定时任务** | `cron` | Cron 配置参数后调用同一 Action | | **数据管理 · 行内** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 同行操作链接(需主键) |
| **技能详情** | (依宿主) | 与上同一 CLI / 同一业务内核 | | **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 |
| **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 |
| **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement |
| **技能详情** | `skill-detail` | `placements` 含 `skill-detail` | 市场详情安装/四 Tab;详情直调按钮随宿主上线后**同等自测** |
技能 **不要** 在 Python 里按 `source` / placement 写业务分叉;宿主读 manifest 分流展示与等待策略。 技能 **不要** 在 Python 里按 `source` / placement 写业务分叉;宿主读 manifest 分流展示与等待策略。
**已声明的入口都要自测**,不要只测对话。操作清单见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §15 第七步。
--- ---
@@ -74,7 +85,8 @@
|-------------|-------------------------|-----------------|----------------| |-------------|-------------------------|-----------------|----------------|
| 运行检查 / 版本 / 配置路径 | `sync` | `skill-detail`, `agent` | 无 | | 运行检查 / 版本 / 配置路径 | `sync` | `skill-detail`, `agent` | 无 |
| 库 / 队列统计 | `sync` | `skill-detail`, `agent` | 无 | | 库 / 队列统计 | `sync` | `skill-detail`, `agent` | 无 |
| 外源 → 本地库同步 | `sync` 或 `async` | 可含 `toolbar`(须 `bind.tables`)、`cron`、`agent` | 可选 | | 外源 → 本地库同步 | `sync` 或 `async` | 可含 `toolbar`(须 `bind.tables`)、`cron`、`agent`、`skill-detail` | 可选 |
| 单行 / 批量数据管理动作 | `sync` 或 `async` | `row` / `batch`(建议 `bind.tables`;行内常用 `inputMapping`) | 可选 |
| 数据导入 / 失败重置 | `sync` 或 `async` | `skill-detail`, `agent` | 可选 | | 数据导入 / 失败重置 | `sync` 或 `async` | `skill-detail`, `agent` | 可选 |
| **单条**副作用 RPA | `async`(建议) | `agent`, `skill-detail` | **建议** | | **单条**副作用 RPA | `async`(建议) | `agent`, `skill-detail` | **建议** |
| **批量顺序** RPA(pick) | `async`(建议) | `toolbar`, `cron`, `agent` | **建议** | | **批量顺序** RPA(pick) | `async`(建议) | `toolbar`, `cron`, `agent` | **建议** |

View File

@@ -81,7 +81,7 @@ def test_whatever():
未设置环境变量 ⇒ 等价 `unit`。 未设置环境变量 ⇒ 等价 `unit`。
默认策略摘要:**不要在 unittest 必跑路径误设 `OPENCLAW_TEST_TARGET=real_*`**。 默认策略摘要:**不要在 unittest 必跑路径误设 `OPENCLAW_TEST_TARGET=real_*`**(CI 仍离线)。业务 `.env` 模板默认是 `real_rpa`,与「单测用 mock」不冲突;**mock 通 ≠ 交活**。
档位读取与业务代码一致:经 `jiangchang_skill_core.config.get("OPENCLAW_TEST_TARGET")`(见 `CONFIG.md`)。 档位读取与业务代码一致:经 `jiangchang_skill_core.config.get("OPENCLAW_TEST_TARGET")`(见 `CONFIG.md`)。
@@ -137,7 +137,8 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认: 浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认:
- [ ] 数据目录 `.env` 中 `OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`) - [ ] 数据目录 `.env` 中 `OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`)
- [ ] account-manager:`platform ensure` + 账号 `active` + 有 `profile_dir` - [ ] account-manager:`ensure-web`(或 `pick-web --ensure`)+ 账号有 `profile_dir`(无 profile 禁止开浏览器,见 `RPA.md` §0.2)
- [ ] `OPENCLAW_BROWSER_HEADLESS=0`(联调有头;勿默认无头)
- [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`) - [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`)
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据) - [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `python tests/run_tests.py -v` 仍全部通过(mock 离线);example 内 `pytest` 不启真实浏览器 - [ ] 默认 `python tests/run_tests.py -v` 仍全部通过(mock 离线);example 内 `pytest` 不启真实浏览器
@@ -200,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`) - `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`、写入 `save_task_log`(`POLICY-LOGGING-002` / `003`)
- 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`) - 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`)
- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`) - 交付代码是否在 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`(或注明已有测试覆盖)**。 新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。
@@ -261,7 +265,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright` - [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright`
- [ ] 无 `scripts/jiangchang_skill_core/` vendored 副本 - [ ] 无 `scripts/jiangchang_skill_core/` vendored 副本
- [ ] `platform_kit_min_version` **>= 1.2.0**(`SKILL.md` + `constants.py`) - [ ] `platform_kit_min_version` **>= 1.2.2**(`SKILL.md` + `constants.py`)
- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行) - [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行)
- [ ] `config-path` 可输出用户 `.env` 路径 JSON - [ ] `config-path` 可输出用户 `.env` 路径 JSON
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py` - [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`

View File

@@ -4,16 +4,20 @@
**`examples/` 不是完整 skill 仓库。** 不要整包照搬 `examples/<mode>/` 到新技能根目录。应按各 example README 的 **copy map**,**选择性复制**到新技能的 `scripts/`(及按需的 `tests/`)。 **`examples/` 不是完整 skill 仓库。** 不要整包照搬 `examples/<mode>/` 到新技能根目录。应按各 example README 的 **copy map**,**选择性复制**到新技能的 `scripts/`(及按需的 `tests/`)。
复制任何案例前先读 [`../development/AI_RPA.md`](../development/AI_RPA.md):案例只示范授权范围内的正常界面自动化,禁止把它扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避平台限制的实现。
根 `README.md`、`SKILL.md`、`references/`、`development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。 根 `README.md`、`SKILL.md`、`references/`、`development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。
## 四类外部交互方式 ## 外部交互示例矩阵
一级目录固定为四种模式(不可删除、不可移动): adapter 仍是 `真实/仿真 × API/RPA` 四档;RPA 再按浏览器、Windows 桌面和手机三种界面拆分。原四类目录不可删除、不可移动,桌面和手机目录用于补足界面维度。
| 目录 | 类型 | 状态 | 适用场景 | | 目录 | 类型 | 状态 | 适用场景 |
|---|---|---|---| |---|---|---|---|
| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 | | `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 |
| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 | | `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、权限、限流(尚未沉淀可复制实现) | | `real_api` | 真实 API | **占位** | 真实系统接口、token、权限、限流(尚未沉淀可复制实现) |
| `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器(尚未沉淀可复制实现) | | `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器(尚未沉淀可复制实现) |
@@ -42,6 +46,8 @@
|---|---| |---|---|
| 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** | | 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** |
| 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** | | 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** |
| Windows 原生软件 + UIA | **real_desktop_rpa** |
| Android/iOS App | **mobile_rpa**(当前仅规格,不可作为成功实现参考) |
| 真实系统 API | **real_api**(当前占位,不可作为实现参考) | | 真实系统 API | **real_api**(当前占位,不可作为实现参考) |
| mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) | | mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) |
@@ -58,7 +64,8 @@ examples/<mode>/
tests/ tests/
``` ```
- 仅 `simulator_browser_rpa` 可额外包含 `sandbox/` - `simulator_browser_rpa` 可额外包含 `sandbox/`
- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture
- 仅未来确实需要样例数据时可加 `fixtures/` - 仅未来确实需要样例数据时可加 `fixtures/`
- **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件 - **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件
@@ -74,6 +81,8 @@ examples/<mode>/
- 真实浏览器 RPA → 参考 `examples/real_browser_rpa/` - 真实浏览器 RPA → 参考 `examples/real_browser_rpa/`
- 仿真浏览器 RPA → 参考 `examples/simulator_browser_rpa/`(adapter 分层权威参考:`scripts/service/adapter/`) - 仿真浏览器 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/` 当前为规划占位,**不得**作为实现参考 - `real_api/`、`simulator_api/` 当前为规划占位,**不得**作为实现参考
> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。 > 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 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

@@ -7,6 +7,7 @@
- 本示例是通用模板,不绑定具体平台。 - 本示例是通用模板,不绑定具体平台。
- 示例抽象自已跑通的真实浏览器 RPA skill(抖音视频链接采集),但代码已改成中性示例命名。 - 示例抽象自已跑通的真实浏览器 RPA skill(抖音视频链接采集),但代码已改成中性示例命名。
- 新 skill 开发时可以复制本目录,再替换目标站点、选择器、解析逻辑和业务字段。 - 新 skill 开发时可以复制本目录,再替换目标站点、选择器、解析逻辑和业务字段。
- 只用于授权范围内的正常界面操作;禁止破解、逆向、私有协议、验证码绕过、自动换号或其他规避平台限制的扩展。
## 适用场景 ## 适用场景
@@ -32,6 +33,8 @@ real_browser_rpa/
│ └── util/ │ └── util/
│ ├── constants.py # 平台常量、selector 占位 │ ├── constants.py # 平台常量、selector 占位
│ └── logging.py # 结构化日志与脱敏 │ └── logging.py # 结构化日志与脱敏
├── assets/
│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写
└── tests/ └── tests/
├── test_human_verification.py # 验证码误判防护 ├── test_human_verification.py # 验证码误判防护
└── test_stop_reason.py # 停止原因文案 └── test_stop_reason.py # 停止原因文案
@@ -39,7 +42,7 @@ real_browser_rpa/
## 核心流程 ## 核心流程
1. **pick account** — 通过 `account_client.pick_web_account()` 获取 `profile_dir` 与租约 1. **ensure account** — 通过 `account_client.pick_web_account()`(底层 `ensure-web`)获取 `profile_dir` 与租约;无账号则自动登记,全忙则 `LEASE_CONFLICT` 不重复建号。登记 ≠ 站点已登录。
2. **启动浏览器** — `launch_persistent_context` + `new_page()` + `page.goto(start_url)` 2. **启动浏览器** — `launch_persistent_context` + `new_page()` + `page.goto(start_url)`
3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成 3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成
4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现 4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现
@@ -54,12 +57,13 @@ real_browser_rpa/
## 开发新 skill 时如何复制 ## 开发新 skill 时如何复制
1. **先阅读**本 README 及下方「Copy map」「复制边界」章节 1. **先阅读**本 README 及下方「Copy map」「复制边界」章节
2. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`) 2. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map
3. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量 3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
4. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**) 4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
5. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同) 5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
6. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession 6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
7. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README 7. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
## Copy map(复制到新技能 `scripts/`) ## Copy map(复制到新技能 `scripts/`)
@@ -72,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/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/constants.py` | `scripts/util/constants.py` | 平台常量与 selector(须替换) |
| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 | | `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/tests/` | `tests/` | 可按需复制测试思路 |
| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README | | `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
@@ -79,6 +84,10 @@ real_browser_rpa/
**浏览器与启动约束:** **浏览器与启动约束:**
- **先 Profile 再开浏览器**:必须 `pick_web_account`(底层 ensure-web)拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2)
- **RPA 等待**:主路径用 `interruptible_sleep`,禁止裸 `asyncio.sleep`(`POLICY-CONTROL-003`)
- **有头**:生产 / 联调 `OPENCLAW_BROWSER_HEADLESS=0`;勿把无头当交付常态
- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供 - **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
- `launch_persistent_context` 的 `args` **只放 Chrome 参数**;**不要把 URL 放进 `args`** - `launch_persistent_context` 的 `args` **只放 Chrome 参数**;**不要把 URL 放进 `args`**
- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入 - 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入
@@ -116,6 +125,8 @@ real_browser_rpa/
- 不要照抄平台名、slug、logger 名 - 不要照抄平台名、slug、logger 名
- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖 - 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖
- 不要把示例里的 result selector 当成真实站点 selector - 不要把示例里的 result selector 当成真实站点 selector
- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器
- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性
- 不要把示例代码原样发布为业务 skill - 不要把示例代码原样发布为业务 skill
- 不要把真实账号、手机号、cookie、token 写入代码或 README - 不要把真实账号、手机号、cookie、token 写入代码或 README
@@ -138,8 +149,9 @@ real_browser_rpa/
- **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用 - **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成 - **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
- **不对抗平台风控** — 频控、账号风险、封禁或权限不足时停止并交由人工处理
- **日志脱敏** — 不输出完整手机号/账号 - **日志脱敏** — 不输出完整手机号/账号
- **租约释放** — `pick-web --lease` 后必须在 `finally` 释放 - **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放
- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位) - **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位)
## 常见坑 ## 常见坑

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

@@ -1,4 +1,7 @@
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。""" """account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
"""
from __future__ import annotations from __future__ import annotations
@@ -7,8 +10,9 @@ import logging
import os import os
import subprocess import subprocess
import sys import sys
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from jiangchang_skill_core import config
from jiangchang_skill_core.runtime_env import get_sibling_skills_root from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
@@ -19,12 +23,17 @@ logger = logging.getLogger(__name__)
PLACEHOLDER_PLATFORM = TARGET_PLATFORM PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = ( ACCOUNT_SETUP_MESSAGE = (
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。" f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、" f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
"status=active、带 profile_dir 的账号后重新运行。"
) )
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。" LEASE_BUSY_MESSAGE = (
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
)
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
DEFAULT_START_URL = "https://www.example.com/"
class AccountManagerError(Exception): class AccountManagerError(Exception):
@@ -38,13 +47,65 @@ def mask_login_id(login_id: str) -> str:
return mask_text(login_id) return mask_text(login_id)
def format_account_label(account: dict) -> str:
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
for key in ("login_id", "account_label", "label"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
for key in ("id", "account_id"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
return ""
def _is_placeholder_config(raw: str) -> bool:
s = (raw or "").strip()
if not s:
return True
if s.startswith("#"):
return True
low = s.lower()
if low in ("default-account", "none", "null", "n/a", "-"):
return True
return False
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
"""从配置解析 (account_id, login_id)。
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
"""
account_id: Optional[str] = None
login_id: Optional[str] = None
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
if not _is_placeholder_config(aid_raw):
if aid_raw.isdigit():
account_id = aid_raw
else:
login_id = aid_raw
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
if not _is_placeholder_config(lid_raw):
login_id = lid_raw
if account_id:
return account_id, None
return None, login_id
def _resolve_account_manager_main() -> str: def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。 """解析 account-manager CLI 入口路径。
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root(路径推断 / JIANGCHANG_SKILLS_ROOT)→ 开发机兜底。 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。 末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
""" """
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip() env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")): if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py") return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
@@ -98,37 +159,102 @@ def _normalize_error_code(raw: str) -> str:
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]: def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if payload.get("success") is False: if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {} err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or "")) code = _normalize_error_code(str(err.get("code") or ""))
message = str(err.get("message") or "") message = str(err.get("message") or "")
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code: if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE)
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
)
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code: if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。") raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
if not payload.get("profile_dir"): if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。") raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload return payload
def _pick_web_with_lease(platform: str) -> Dict[str, Any]: def _ensure_web_with_lease(
proc = _run_argv( platform: str,
[ *,
"account", login_id: Optional[str] = None,
"pick-web", ) -> Dict[str, Any]:
"--platform", start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
platform, lid = (login_id or "").strip() or None
"--lease", label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
"--holder", argv = [
LEASE_HOLDER, "account",
"--ttl-sec", "ensure-web",
LEASE_TTL_SEC, "--platform",
] platform,
) "--url",
start_url,
"--auth-strategy",
DEFAULT_AUTH_STRATEGY,
"--label",
label,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if lid:
argv.extend(["--login-id", lid])
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"ENSURE_WEB_FAILED",
(proc.stderr or "").strip() or "ensure-web 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
return _validate_pick_payload(payload)
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or "" out = proc.stdout or ""
if proc.returncode != 0 and not out.strip(): if proc.returncode != 0 and not out.strip():
raise AccountManagerError( raise AccountManagerError(
@@ -136,14 +262,10 @@ def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
(proc.stderr or "").strip() or "pick-web 子进程失败", (proc.stderr or "").strip() or "pick-web 子进程失败",
) )
payload = _parse_last_json(out) payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False: return _validate_pick_payload(payload)
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
return payload
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]: def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)]) proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or "" out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"): if proc.returncode != 0 or out.startswith("ERROR:"):
@@ -156,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
else: else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。") raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
platform_key = str(data.get("platform_key") or "").lower() platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower(): if platform_key != platform.lower():
raise AccountManagerError( raise AccountManagerError(
@@ -165,24 +289,54 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
profile_dir = str(data.get("profile_dir") or "").strip() profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir: if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。") raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir if str(data.get("status") or "active").lower() != "active":
data["lease_token"] = "" raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
return data
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
"""获取网页账号(profile_dir + lease_token)。"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
if account_id:
try:
aid = int(account_id)
except (TypeError, ValueError):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
return _pick_by_id(platform_key, aid)
try: try:
return _pick_web_with_lease(platform_key) selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
platform: str,
account_id: Optional[str] = None,
login_id: Optional[str] = None,
) -> dict:
"""获取网页账号(profile_dir + lease_token)。
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
aid_raw = (account_id or "").strip()
if aid_raw and not _is_placeholder_config(aid_raw):
if not aid_raw.isdigit():
if not (login_id or "").strip():
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):
lid = None
try:
return _ensure_web_with_lease(platform_key, login_id=lid)
except AccountManagerError as exc: except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code: if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import os import os
import random import random
@@ -11,6 +10,9 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from jiangchang_skill_core import config
from jiangchang_skill_core.activity import interruptible_sleep
from service.browser_session import close_browser_context, get_start_url, start_browser_session from service.browser_session import close_browser_context, get_start_url, start_browser_session
from service.human_verification import ( from service.human_verification import (
HumanVerificationWaitResult, HumanVerificationWaitResult,
@@ -100,13 +102,13 @@ def _step_cb(cb: Optional[StepCallback], text: str) -> None:
async def _random_delay() -> None: async def _random_delay() -> None:
lo = int(os.getenv("RPA_STEP_DELAY_MIN_MS") or "900") lo = max(0.0, config.get_float("STEP_DELAY_MIN", 1.0))
hi = int(os.getenv("RPA_STEP_DELAY_MAX_MS") or "2600") hi = max(lo, config.get_float("STEP_DELAY_MAX", 3.0))
await asyncio.sleep(random.uniform(lo / 1000.0, hi / 1000.0)) await interruptible_sleep(random.uniform(lo, hi))
async def _scroll_wait() -> None: async def _scroll_wait() -> None:
await asyncio.sleep(random.uniform(1.2, 3.5)) await interruptible_sleep(random.uniform(1.2, 3.5))
def _headless() -> bool: def _headless() -> bool:
@@ -156,7 +158,7 @@ async def _open_login_panel_if_needed(page) -> None:
btn = page.locator(LOGGED_OUT_SELECTOR).first btn = page.locator(LOGGED_OUT_SELECTOR).first
if await _visible(btn): if await _visible(btn):
await btn.click() await btn.click()
await asyncio.sleep(random.uniform(1.0, 3.0)) await interruptible_sleep(random.uniform(1.0, 3.0))
except Exception: except Exception:
pass pass
@@ -175,7 +177,7 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
if await _is_logged_in(page): if await _is_logged_in(page):
print("[登录] 检测到登录成功") print("[登录] 检测到登录成功")
return return
await asyncio.sleep(2.0) await interruptible_sleep(2.0)
raise RuntimeError( raise RuntimeError(
f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。" f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。"
@@ -186,42 +188,22 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
async def _find_search_input(page) -> Tuple[Optional[Any], str]: async def _find_search_input(page) -> Tuple[Optional[Any], str]:
for sel in SEARCH_INPUT_SELECTORS: for sel in SEARCH_INPUT_SELECTORS:
try: try:
loc = page.locator(sel).first loc = page.locator(sel)
if await _visible(loc): count = await loc.count()
if count == 1 and await _visible(loc):
return loc, sel return loc, sel
if count > 1:
logger.warning("search_input_ambiguous selector=%s count=%s", sel, count)
except Exception: except Exception:
continue 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: try:
ph = page.get_by_placeholder(re.compile("搜索")) ph = page.get_by_placeholder(re.compile("搜索"))
if await ph.count() > 0 and await ph.first.is_visible(): count = await ph.count()
return ph.first, "placeholder=搜索" 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: except Exception:
pass pass
return None, "" return None, ""
@@ -230,14 +212,24 @@ async def _find_search_input(page) -> Tuple[Optional[Any], str]:
async def _find_search_button(page): async def _find_search_button(page):
try: try:
btn = page.get_by_role("button", name="搜索") btn = page.get_by_role("button", name="搜索")
if await btn.count() > 0 and await btn.first.is_visible(): count = await btn.count()
return btn.first 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: except Exception:
pass pass
try: try:
btn = page.locator(SEARCH_BUTTON_SELECTOR).first btn = page.locator(SEARCH_BUTTON_SELECTOR)
if await btn.count() > 0 and await btn.is_visible(): count = await btn.count()
if count == 1 and await btn.is_visible():
return btn return btn
if count > 1:
logger.warning(
"search_button_ambiguous selector=%s count=%s",
SEARCH_BUTTON_SELECTOR,
count,
)
except Exception: except Exception:
pass pass
return None return None
@@ -253,7 +245,7 @@ async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool:
return True return True
except Exception: except Exception:
pass pass
await asyncio.sleep(0.8) await interruptible_sleep(0.8)
return False return False

View File

@@ -6,7 +6,12 @@ import asyncio
import uuid import uuid
from typing import Any, Optional from typing import Any, Optional
from service.account_client import AccountManagerError, pick_web_account, release_lease from service.account_client import (
AccountManagerError,
pick_web_account,
release_lease,
resolve_account_selectors_from_config,
)
from service.task_rpa import ( from service.task_rpa import (
ScrapeRunResult, ScrapeRunResult,
format_stop_reason_for_user, format_stop_reason_for_user,
@@ -79,7 +84,12 @@ async def run_keyword_search_task(
account: Optional[dict[str, Any]] = None account: Optional[dict[str, Any]] = None
try: try:
account = pick_web_account(platform, account_id) cfg_account_id, cfg_login_id = resolve_account_selectors_from_config()
account = pick_web_account(
platform,
account_id=account_id or cfg_account_id,
login_id=cfg_login_id,
)
lease_token = account.get("lease_token") lease_token = account.get("lease_token")
scrape_result: ScrapeRunResult = await run_keyword_search_async( scrape_result: ScrapeRunResult = await run_keyword_search_async(

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

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

@@ -4,6 +4,8 @@
本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。 本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。
本示例只教学授权范围内的正常界面自动化;禁止把 sandbox 做法扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避真实平台限制的实现。
## 适合什么场景 ## 适合什么场景
- 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html` - 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html`
@@ -16,6 +18,7 @@
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md) - **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA - 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill - **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态
## 目录结构 ## 目录结构
@@ -50,13 +53,19 @@ simulator_browser_rpa/
| 文件 | 职责 | | 文件 | 职责 |
|---|---| |---|---|
| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** | | `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** |
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account` / `release_lease`) | | `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) |
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 | | `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` | | `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI | | `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` | | `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 | | `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 |
## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致)
- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account`(ensure-web)拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0`
- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager
## 核心流程 ## 核心流程
1. `await run_batch_submit(target, items)` 校验参数 1. `await run_batch_submit(target, items)` 校验参数

View File

@@ -1,4 +1,7 @@
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。""" """account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
"""
from __future__ import annotations from __future__ import annotations
@@ -7,8 +10,9 @@ import logging
import os import os
import subprocess import subprocess
import sys import sys
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from jiangchang_skill_core import config
from jiangchang_skill_core.runtime_env import get_sibling_skills_root from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
@@ -19,12 +23,17 @@ logger = logging.getLogger(__name__)
PLACEHOLDER_PLATFORM = TARGET_PLATFORM PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = ( ACCOUNT_SETUP_MESSAGE = (
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。" f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、" f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
"status=active、带 profile_dir 的账号后重新运行。"
) )
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。" LEASE_BUSY_MESSAGE = (
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
)
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
DEFAULT_START_URL = "https://www.example.com/"
class AccountManagerError(Exception): class AccountManagerError(Exception):
@@ -38,12 +47,65 @@ def mask_login_id(login_id: str) -> str:
return mask_text(login_id) return mask_text(login_id)
def format_account_label(account: dict) -> str:
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
for key in ("login_id", "account_label", "label"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
for key in ("id", "account_id"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
return ""
def _is_placeholder_config(raw: str) -> bool:
s = (raw or "").strip()
if not s:
return True
if s.startswith("#"):
return True
low = s.lower()
if low in ("default-account", "none", "null", "n/a", "-"):
return True
return False
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
"""从配置解析 (account_id, login_id)。
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
"""
account_id: Optional[str] = None
login_id: Optional[str] = None
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
if not _is_placeholder_config(aid_raw):
if aid_raw.isdigit():
account_id = aid_raw
else:
login_id = aid_raw
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
if not _is_placeholder_config(lid_raw):
login_id = lid_raw
if account_id:
return account_id, None
return None, login_id
def _resolve_account_manager_main() -> str: def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。 """解析 account-manager CLI 入口路径。
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
""" """
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip() env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")): if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py") return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
@@ -53,6 +115,7 @@ def _resolve_account_manager_main() -> str:
if os.path.isfile(candidate): if os.path.isfile(candidate):
return candidate return candidate
# 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py" dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
if os.path.isfile(dev): if os.path.isfile(dev):
return dev return dev
@@ -96,37 +159,102 @@ def _normalize_error_code(raw: str) -> str:
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]: def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if payload.get("success") is False: if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {} err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or "")) code = _normalize_error_code(str(err.get("code") or ""))
message = str(err.get("message") or "") message = str(err.get("message") or "")
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code: if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE)
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
)
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code: if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。") raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
if not payload.get("profile_dir"): if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。") raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload return payload
def _pick_web_with_lease(platform: str) -> Dict[str, Any]: def _ensure_web_with_lease(
proc = _run_argv( platform: str,
[ *,
"account", login_id: Optional[str] = None,
"pick-web", ) -> Dict[str, Any]:
"--platform", start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
platform, lid = (login_id or "").strip() or None
"--lease", label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
"--holder", argv = [
LEASE_HOLDER, "account",
"--ttl-sec", "ensure-web",
LEASE_TTL_SEC, "--platform",
] platform,
) "--url",
start_url,
"--auth-strategy",
DEFAULT_AUTH_STRATEGY,
"--label",
label,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if lid:
argv.extend(["--login-id", lid])
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"ENSURE_WEB_FAILED",
(proc.stderr or "").strip() or "ensure-web 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
return _validate_pick_payload(payload)
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or "" out = proc.stdout or ""
if proc.returncode != 0 and not out.strip(): if proc.returncode != 0 and not out.strip():
raise AccountManagerError( raise AccountManagerError(
@@ -134,14 +262,10 @@ def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
(proc.stderr or "").strip() or "pick-web 子进程失败", (proc.stderr or "").strip() or "pick-web 子进程失败",
) )
payload = _parse_last_json(out) payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False: return _validate_pick_payload(payload)
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
return payload
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]: def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)]) proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or "" out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"): if proc.returncode != 0 or out.startswith("ERROR:"):
@@ -154,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
else: else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。") raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
platform_key = str(data.get("platform_key") or "").lower() platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower(): if platform_key != platform.lower():
raise AccountManagerError( raise AccountManagerError(
@@ -163,24 +289,54 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
profile_dir = str(data.get("profile_dir") or "").strip() profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir: if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。") raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir if str(data.get("status") or "active").lower() != "active":
data["lease_token"] = "" raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
return data
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
"""获取网页账号(profile_dir + lease_token)。"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
if account_id:
try:
aid = int(account_id)
except (TypeError, ValueError):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
return _pick_by_id(platform_key, aid)
try: try:
return _pick_web_with_lease(platform_key) selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
platform: str,
account_id: Optional[str] = None,
login_id: Optional[str] = None,
) -> dict:
"""获取网页账号(profile_dir + lease_token)。
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
aid_raw = (account_id or "").strip()
if aid_raw and not _is_placeholder_config(aid_raw):
if not aid_raw.isdigit():
if not (login_id or "").strip():
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):
lid = None
try:
return _ensure_web_with_lease(platform_key, login_id=lid)
except AccountManagerError as exc: except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code: if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

View File

@@ -80,9 +80,11 @@ Action 是**可选能力**:
| 字段 / 层 | 只决定什么 | 不决定什么 | | 字段 / 层 | 只决定什么 | 不决定什么 |
|-----------|------------|------------| |-----------|------------|------------|
| **`placements`** | Action **出现在哪里**(toolbar / cron / agent / skill-detail …) | 不决定 sync/async | | **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async,不决定点了是否弹窗 |
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除」 | 不强制;无配置时宿主仍直写 SQLite |
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | | **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口 | | **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 | | **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 | | **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
@@ -93,13 +95,16 @@ Action 是**可选能力**:
| 层级 | 稳定字段 / 能力 | | 层级 | 稳定字段 / 能力 |
|------|----------------| |------|----------------|
| 顶层 | `schemaVersion`、`skill`、`actions` | | 顶层 | `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`) | | entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
| placements | `toolbar`、`skill-detail`、`agent`、`cron` | | placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
| **bind** | `{ "tables": ["business_table"] }`(见下节) | | **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` — 接管默认新增/编辑/删除(须 `bind.tables`) |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive` | | **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) |
| confirmation | `{ "message": "..." }` | | inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` |
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) | | **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
## `bind.tables`(表级绑定) ## `bind.tables`(表级绑定)
@@ -113,14 +118,135 @@ Action 是**可选能力**:
规则: 规则:
- `bind` 为 object,`additionalProperties: false`;当前只允许 `tables`。 - `bind` 为 object,`additionalProperties: false`;当前允许 `tables`(必填键当存在 bind 时)与可选 `inputMapping`。
- `tables` 非空数组、元素唯一、英文 **snake_case**、不得为空字符串。 - `tables` 非空数组、元素唯一、英文 **snake_case**、不得为空字符串。
- **`placements` 含 `toolbar` 时必须显式声明非空 `bind.tables`**(模板 Schema `if/then` + 自动测试强制)。 - **`placements` 含 `toolbar` 时必须显式声明非空 `bind.tables`**(模板 Schema `if/then` + 自动测试强制)。
- `row` / `batch` **建议**同样声明 `bind.tables`,避免在无关表上露出按钮;未声明时宿主可能按兼容逻辑在更多表上展示。
- 表名须真实存在于技能 SQLite 与 `_jiangchang_tables`。 - 表名须真实存在于技能 SQLite 与 `_jiangchang_tables`。
- 希望出现在多张表时,显式列出所有表;**不要**依赖「无 bind 则全表展示」——那是宿主对**旧技能**的兼容,不是新技能标准。 - 希望出现在多张表时,显式列出所有表;**不要**依赖「无 bind 则全表展示」——那是宿主对**旧技能**的兼容,不是新技能标准。
- 本阶段**不**扩展 `selection` / `inputMapping` / `columns` / row binding / concurrency / locks。 - **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。
- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。
默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar,故无需 `bind` 示例污染默认 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 ```json
{ {
@@ -134,16 +260,42 @@ Action 是**可选能力**:
} }
``` ```
行内示例:
```json
{
"id": "retry-one",
"label": "重试本行",
"description": "对当前行执行一次业务重试。",
"placements": ["row"],
"executionProfile": "async",
"bind": {
"tables": ["business_records"],
"inputMapping": { "recordId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["recordId"],
"properties": {
"recordId": { "type": "string", "title": "记录 ID", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "retry",
"args": ["--id", "{{recordId}}"]
}
}
```
## 预留能力(当前不可当作已实现能力) ## 预留能力(当前不可当作已实现能力)
| 类别 | 预留项 | 说明 | | 类别 | 预留项 | 说明 |
|------|--------|------| |------|--------|------|
| placements | `row`、`batch` | 行内 / 批量入口,未来扩展;新技能示例不得使用 |
| action 字段 | `concurrency`、`locks` | 并发与锁控制,未稳定开放 | | action 字段 | `concurrency`、`locks` | 并发与锁控制,未稳定开放 |
| inputSchema | `array`、nested `object`、`oneOf` 等 | 复杂表单,不属于当前推荐范围 | | inputSchema | `array`、nested `object`、`oneOf` 等 | 复杂表单,不属于当前推荐范围 |
`bind` **不是**保留字段;已作为 Phase 1 表级绑定正式能力。 `bind`、`row`、`batch` **不是**保留能力;已按匠厂宿主正式支持写入本规范。
## 执行模型 ## 执行模型
宿主将 action 转为 argv,再用**共享 Python**(用户数据目录下 `python-runtime/.venv`)执行: 宿主将 action 转为 argv,再用**共享 Python**(用户数据目录下 `python-runtime/.venv`)执行:
@@ -161,16 +313,16 @@ python {skill_root}/scripts/main.py <command> <args...>
### `executionProfile`(仅 sync / async) ### `executionProfile`(仅 sync / async)
模板与新技能**只允许**这两种值;每个 Action **必须**显式写出;禁止发明 `auto` / `background` / `deferred`;**禁止**依赖宿主「省略字段时的兼容默认」。 **心智**:多数 Action 按 **sync**(短调用、不进任务中心);只有长耗时 / RPA / 需进度控制时才标 **async**。匠厂宿主若**省略**该字段,按 **sync** 处理;模板与新技能仍要求**每个 Action 显式写出** `sync` 或 `async`,禁止发明 `auto` / `background` / `deferred`,也禁止赌省略行为。
| 值 | 宿主行为 | 任务中心 | | 值 | 宿主行为 | 任务中心 |
|----|----------|----------| |----|----------|----------|
| `sync` | 调用方等待结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 不进 | | `sync`(或省略时的宿主缺省) | 调用方等待结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 不进 |
| `async` | 宿主创建后台 Job,立即返回 `jobId`;用 `emit` / `checkpoint` / `finish` 报告进度与终态 | **进入** | | `async`(须显式声明) | 宿主创建后台 Job,立即返回 `jobId`;用 `emit` / `checkpoint` / `finish` 报告进度与终态 | **进入** |
合法组合(全部允许):`sync|async` × `toolbar|cron|agent|skill-detail`。 合法组合(全部允许):`sync|async` × `toolbar|row|batch|cron|agent|skill-detail`。
经验建议(**不是** placement 硬限制):耗时不可预测、需要暂停/停止/进度展示的任务,用 `async`。 经验建议(**不是** placement 硬限制):耗时不可预测、需要暂停/停止/进度展示的任务,用 `async`;其余默认写 `sync`。
直接执行短 CLI 时,终端自然等待进程结束。宿主通过 Action 调用同一 CLI 时,再按 `executionProfile` 决定同步等待还是建 Job。技能**不得**自行实现第二套任务中心或后台队列。 直接执行短 CLI 时,终端自然等待进程结束。宿主通过 Action 调用同一 CLI 时,再按 `executionProfile` 决定同步等待还是建 Job。技能**不得**自行实现第二套任务中心或后台队列。
@@ -199,26 +351,27 @@ HINT: <next step>
| 值 | 含义 | | 值 | 含义 |
|----|------| |----|------|
| `toolbar` | 数据管理第一排技能按钮(须配合 `bind.tables`) | | `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) |
| `skill-detail` | 技能详情或技能市场详情页 | | `row` | 数据管理行内操作(建议 `bind.tables` + `inputMapping`;表需有主键) |
| `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) |
| `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) |
| `agent` | Agent 可直接调用(`run_skill_action`) | | `agent` | Agent 可直接调用(`run_skill_action`) |
| `cron` | 定时任务可直接调用 | | `cron` | 定时任务「技能直调」 |
**预留(模板示例请勿使用):** `row`、`batch`。
### Action 类型 × placements(常见示例,不是限制) ### Action 类型 × placements(常见示例,不是限制)
下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。 下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。
| Action 类型 | 常见 executionProfile | 常见 placements | confirmation | | Action 类型 | 常见 executionProfile | 常见 placements | confirmation |
|-------------|----------------------|-----------------|--------------| |-------------|----------------------|-----------------|--------------|
| health / version / config-path | sync | skill-detail, agent | 无 | | health / version / config-path | sync | skill-detail, agent | 无 |
| 统计 / 只读查询 | sync | skill-detail, agent | 无 | | 统计 / 只读查询 | sync | skill-detail, agent | 无 |
| 数据同步(外源 → 本地库) | sync 或 async | toolbar + cron + agent + skill-detail(按需) | 可选 | | 数据同步(外源 → 本地库) | sync 或 async | toolbar + cron + agent + skill-detail(按需) | 可选 |
| 单行重试 / 行内处理 | sync 或 async | row(可加 agent) | 可选 |
| 批量处理勾选行 | sync 或 async | batch(可加 toolbar/cron) | 可选 |
| 导入 / 重置 | sync 或 async | skill-detail, agent | 可选 | | 导入 / 重置 | sync 或 async | skill-detail, agent | 可选 |
| 单条副作用 RPA | async(建议) | agent, skill-detail | **建议** | | 单条副作用 RPA | async(建议) | agent, skill-detail | **建议** |
| 批量顺序 pick RPA | async(建议) | toolbar, cron, agent | **建议** | | 批量顺序 pick RPA | async(建议) | toolbar, cron, agent | **建议** |
## 同步数据 vs 导出当前表 vs 特殊业务报告 ## 同步数据 vs 导出当前表 vs 特殊业务报告
不要混用「导出」一词: 不要混用「导出」一词:
@@ -236,7 +389,7 @@ HINT: <next step>
当前宿主稳定支持: 当前宿主稳定支持:
- `object` / `string` / `number` / `boolean` - `object` / `string` / `number` / `boolean`
- `enum` / `default` / `required` / `sensitive` - `enum` / `default` / `required` / `sensitive` / `readOnly`
- 简单模板替换(`{{fieldName}}`) - 简单模板替换(`{{fieldName}}`)
示例: 示例:
@@ -281,6 +434,8 @@ HINT: <next step>
- `enum` 在宿主中渲染为选择框 - `enum` 在宿主中渲染为选择框
- `required` 字段在提交前校验 - `required` 字段在提交前校验
- `sensitive` 字段不得出现在低信任入口 - `sensitive` 字段不得出现在低信任入口
- `readOnly: true` 时数据管理表单只读展示该字段(仍随提交传入);适合行内/勾选预填的主键、编号列表
- `placements` 含 `batch` 的动作在数据管理表顶常驻,无勾选时禁用(与表删除一致)
- 需要确认的副作用操作使用 `confirmation.message` - 需要确认的副作用操作使用 `confirmation.message`
- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明 - 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明
- `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null` - `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null`

View File

@@ -78,6 +78,8 @@ metadata:
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。 不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
若创建/更新/删除带有领域副作用(自动建目录、加密、级联清理),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete` 接管默认按钮,详见 [`ACTIONS.md`](ACTIONS.md)。
## 标准数据同步语义(外源 → 本地库) ## 标准数据同步语义(外源 → 本地库)
适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。 适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。
@@ -354,7 +356,11 @@ CREATE TABLE demo_items (
## 枚举字段 options_json ## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`: `options_json` 支持两种形态:
### 1)静态枚举(数组)
每项包含 `value` 与 `label`:
```json ```json
[ [
@@ -366,6 +372,31 @@ CREATE TABLE demo_items (
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。 宿主据此渲染选择框;技能侧负责保证 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 初始化 - `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化

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]]: def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]:
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。""" """校验 options_json:静态枚举数组,或 lookup 对象。"""
errors: list[str] = [] errors: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
if options_json is None or str(options_json).strip() == "": 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: except json.JSONDecodeError as exc:
errors.append(f"options_json 不是合法 JSON:{exc}") errors.append(f"options_json 不是合法 JSON:{exc}")
return errors, warnings 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): if not isinstance(parsed, list):
errors.append("options_json 必须是 JSON 数组") errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)")
return errors, warnings return errors, warnings
for index, item in enumerate(parsed): for index, item in enumerate(parsed):
if not isinstance(item, dict): 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): with job_context(skill=SKILL_SLUG):
log.info( 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, task_type,
target, target,
input_id, input_id,
@@ -79,7 +79,10 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
try: try:
ok, reason = check_entitlement(SKILL_SLUG) ok, reason = check_entitlement(SKILL_SLUG)
if not ok: 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") emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
tlr.save_task_log( tlr.save_task_log(
task_type=task_type, 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", error_code="ENTITLEMENT_DENIED",
stage="auth", stage="auth",
) )
print(f"❌ {reason}") print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1 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)) 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) _print_video_summary(video_info)
summary_payload = merge_video_into_result_summary( 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, input_id,
) )
log.warning(
"task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB",
task_type,
)
finish( finish(
status="failed", status="failed",
message="模板仓库未实现真实业务", message="模板仓库未实现真实业务",
@@ -142,8 +156,9 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
template_demo=True, template_demo=True,
target=target, target=target,
input_id=input_id, 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 return rc
except JobStopped: except JobStopped:
raise raise

View File

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

View File

@@ -35,7 +35,7 @@ def get_skill_root() -> str:
return _SKILL_ROOT return _SKILL_ROOT
def platform_kit_version_patch(version: str = "1.2.0"): def platform_kit_version_patch(version: str = "1.2.2"):
"""Mock installed jiangchang-platform-kit version for health/diagnostics tests.""" """Mock installed jiangchang-platform-kit version for health/diagnostics tests."""
from unittest.mock import patch from unittest.mock import patch

View File

@@ -42,6 +42,10 @@
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。 推荐配合根目录下的 **`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 样例 ## 如何启用 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

@@ -15,7 +15,6 @@ from util.constants import SKILL_SLUG
_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" _TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"}) _ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"})
_RESERVED_PLACEMENTS = frozenset({"row", "batch"})
_RESERVED_ACTION_FIELDS = frozenset({"concurrency", "locks"}) _RESERVED_ACTION_FIELDS = frozenset({"concurrency", "locks"})
_ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"}) _ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"})
_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"}) _REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"})
@@ -191,7 +190,21 @@ class TestActionsManifest(unittest.TestCase):
placements = action.get("placements") placements = action.get("placements")
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array") 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: for placement in placements:
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}") self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
@@ -217,6 +230,31 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(bind_def.get("required"), ["tables"]) self.assertEqual(bind_def.get("required"), ["tables"])
self.assertIs(bind_def.get("additionalProperties"), False) 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: def test_manifest_validates_against_json_schema(self) -> None:
try: try:
import jsonschema import jsonschema
@@ -275,13 +313,13 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(missing, [], msg="\n".join(missing)) self.assertEqual(missing, [], msg="\n".join(missing))
def test_schema_allows_all_placement_execution_profile_combinations(self) -> None: def test_schema_allows_all_placement_execution_profile_combinations(self) -> None:
"""POLICY-SKILL-ACTION-004:4 placements × 2 profiles 必须全部通过 Schema。""" """POLICY-SKILL-ACTION-004:6 placements × 2 profiles 必须全部通过 Schema。"""
try: try:
import jsonschema import jsonschema
except ImportError: except ImportError:
self.fail("jsonschema is required to validate placement×executionProfile orthogonality") self.fail("jsonschema is required to validate placement×executionProfile orthogonality")
schema = _load_actions_schema() schema = _load_actions_schema()
placements = ("toolbar", "cron", "agent", "skill-detail") placements = ("toolbar", "row", "batch", "cron", "agent", "skill-detail")
profiles = ("sync", "async") profiles = ("sync", "async")
for placement in placements: for placement in placements:
for profile in profiles: for profile in profiles:
@@ -346,16 +384,6 @@ class TestActionsManifest(unittest.TestCase):
for snippet in forbidden_snippets: for snippet in forbidden_snippets:
self.assertNotIn(snippet, text) self.assertNotIn(snippet, text)
def test_template_example_does_not_use_reserved_placements(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
placements = set(action.get("placements") or [])
overlap = placements & _RESERVED_PLACEMENTS
self.assertFalse(
overlap,
msg=f"{action['id']} uses reserved placements not yet supported in template: {overlap}",
)
def test_template_variables_have_input_schema(self) -> None: def test_template_variables_have_input_schema(self) -> None:
manifest = _load_actions_manifest() manifest = _load_actions_manifest()
for action in manifest["actions"]: for action in manifest["actions"]:
@@ -480,6 +508,8 @@ class TestActionsManifest(unittest.TestCase):
"additionalProperties", "additionalProperties",
"placements", "placements",
"executionProfile", "executionProfile",
"interaction",
"confirmation",
): ):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
for forbidden in ( for forbidden in (

View File

@@ -84,8 +84,8 @@ class TestConfigBootstrap(unittest.TestCase):
config.reset_cache() config.reset_cache()
with open(example, encoding="utf-8") as f: with open(example, encoding="utf-8") as f:
example_text = f.read() example_text = f.read()
self.assertIn(_env_line(_TEST_TARGET_KEY, "mock").strip(), example_text) self.assertIn(_env_line(_TEST_TARGET_KEY, "real_" + "rpa").strip(), example_text)
self.assertEqual(config.get(_TEST_TARGET_KEY), "mock") self.assertEqual(config.get(_TEST_TARGET_KEY), "real_" + "rpa")
def test_config_path_outputs_json(self) -> None: def test_config_path_outputs_json(self) -> None:
with IsolatedDataRoot(user_id="_cfg_path"): with IsolatedDataRoot(user_id="_cfg_path"):

View File

@@ -35,6 +35,9 @@ POLICY_IDS = (
"POLICY-RPA-001", "POLICY-RPA-001",
"POLICY-RPA-002", "POLICY-RPA-002",
"POLICY-RPA-003", "POLICY-RPA-003",
"POLICY-RPA-004",
"POLICY-RPA-005",
"POLICY-RPA-006",
"POLICY-PACKAGING-001", "POLICY-PACKAGING-001",
"POLICY-PACKAGING-002", "POLICY-PACKAGING-002",
"POLICY-DOCS-001", "POLICY-DOCS-001",
@@ -43,6 +46,9 @@ POLICY_IDS = (
"POLICY-LOGGING-003", "POLICY-LOGGING-003",
"POLICY-LOGGING-004", "POLICY-LOGGING-004",
"POLICY-LOGGING-005", "POLICY-LOGGING-005",
"POLICY-LOGGING-006",
"POLICY-LOGGING-007",
"POLICY-LOGGING-008",
"POLICY-CONTROL-001", "POLICY-CONTROL-001",
"POLICY-CONTROL-002", "POLICY-CONTROL-002",
"POLICY-CONTROL-003", "POLICY-CONTROL-003",
@@ -142,11 +148,82 @@ RPA_003_SOURCE = (
"development/RPA.md §5.3; development/TESTING.md §12; development/POLICY_MATRIX.md" "development/RPA.md §5.3; development/TESTING.md §12; development/POLICY_MATRIX.md"
) )
RPA_004_SOURCE = (
"development/RPA.md §0.2; development/ADAPTER.md §兄弟依赖; development/POLICY_MATRIX.md"
)
RPA_004_HEADLESS_KEY_RE = re.compile(
r"(?m)^\s*OPENCLAW_BROWSER_HEADLESS\s*=\s*([^\s#]+)"
)
RPA_004_LAUNCH_MARKERS = (
"launch_persistent_context",
"launch_persistent_browser",
)
RPA_004_PICK_MARKER = "pick_web_account"
RPA_005_SOURCE = (
"development/AI_RPA.md §4–§6; development/RPA.md §0.0/§0.3; "
"development/POLICY_MATRIX.md"
)
RPA_005_REQUIRED_DOC_MARKERS = (
"AI Agent 是用户入口与业务编排者",
"RPA 是确定性执行器",
"破解",
"逆向",
"私有协议",
"验证码",
"人工处理",
"1~3 秒",
"状态等待",
)
RPA_005_STEP_DELAY_KEY_RE = re.compile(
r"(?m)^\s*(STEP_DELAY_MIN|STEP_DELAY_MAX)\s*=\s*([^\s#]+)"
)
RPA_006_SOURCE = (
"development/ACCOUNT_MANAGER.md; development/ADAPTER.md §兄弟依赖; "
"development/RPA.md §0.2; development/POLICY_MATRIX.md"
)
RPA_006_REQUIRED_DOC_MARKERS = (
"一账号一",
"profile_dir",
"lease_token",
"数据管理页面",
"account-manager.db",
"account list",
"lease list",
"关闭浏览器并释放租约",
"两个以上账号",
)
RPA_006_EXAMPLE_CLIENTS = (
"examples/real_browser_rpa/scripts/service/account_client.py",
"examples/simulator_browser_rpa/scripts/service/account_client.py",
)
LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md" LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md"
LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md" LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
LOGGING_003_SOURCE = "development/LOGGING.md" LOGGING_003_SOURCE = "development/LOGGING.md"
LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md" LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md"
LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.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( LOGGING_CALL_PATTERN = re.compile(
r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(", r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(",
@@ -615,6 +692,128 @@ class TestPolicyRpa003(unittest.TestCase):
) )
class TestPolicyRpa004(unittest.TestCase):
def test_env_example_browser_headless_defaults_to_headed(self) -> None:
skill_root = get_skill_root()
env_path = os.path.join(skill_root, ".env.example")
self.assertTrue(os.path.isfile(env_path), msg=".env.example missing")
text = _read_text(env_path)
match = RPA_004_HEADLESS_KEY_RE.search(text)
if match is None:
return
value = match.group(1).strip().strip("\"'")
self.assertEqual(
value,
"0",
msg=_policy_msg(
"POLICY-RPA-004",
RPA_004_SOURCE,
f".env.example OPENCLAW_BROWSER_HEADLESS must default to 0 (headed), got {value!r}",
),
)
def test_launch_persistent_requires_pick_web_account(self) -> None:
skill_root = get_skill_root()
script_texts = [
_read_text(path) for path in _walk_files(skill_root, "scripts", suffix=".py")
]
combined = "\n".join(script_texts)
uses_launch = any(marker in combined for marker in RPA_004_LAUNCH_MARKERS)
if not uses_launch:
return
self.assertIn(
RPA_004_PICK_MARKER,
combined,
msg=_policy_msg(
"POLICY-RPA-004",
RPA_004_SOURCE,
"scripts/ uses launch_persistent_* but missing pick_web_account "
"(must obtain profile_dir before opening browser)",
),
)
class TestPolicyRpa005(unittest.TestCase):
def test_ai_rpa_governance_doc_has_required_boundaries(self) -> None:
skill_root = get_skill_root()
path = os.path.join(skill_root, "development", "AI_RPA.md")
self.assertTrue(
os.path.isfile(path),
msg=_policy_msg("POLICY-RPA-005", RPA_005_SOURCE, "development/AI_RPA.md missing"),
)
text = _read_text(path)
missing = [marker for marker in RPA_005_REQUIRED_DOC_MARKERS if marker not in text]
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-RPA-005",
RPA_005_SOURCE,
"AI_RPA.md missing markers: " + ", ".join(missing),
),
)
def test_real_rpa_default_step_delay_is_one_to_three_seconds(self) -> None:
skill_root = get_skill_root()
env_path = os.path.join(skill_root, ".env.example")
values = {
key: value.strip().strip("\"'")
for key, value in RPA_005_STEP_DELAY_KEY_RE.findall(_read_text(env_path))
}
self.assertEqual(
values.get("STEP_DELAY_MIN"),
"1.0",
msg=_policy_msg(
"POLICY-RPA-005",
RPA_005_SOURCE,
f"STEP_DELAY_MIN must default to 1.0, got {values.get('STEP_DELAY_MIN')!r}",
),
)
self.assertEqual(
values.get("STEP_DELAY_MAX"),
"3.0",
msg=_policy_msg(
"POLICY-RPA-005",
RPA_005_SOURCE,
f"STEP_DELAY_MAX must default to 3.0, got {values.get('STEP_DELAY_MAX')!r}",
),
)
class TestPolicyRpa006(unittest.TestCase):
def test_account_manager_lifecycle_doc_has_required_markers(self) -> None:
skill_root = get_skill_root()
path = os.path.join(skill_root, "development", "ACCOUNT_MANAGER.md")
self.assertTrue(
os.path.isfile(path),
msg=_policy_msg("POLICY-RPA-006", RPA_006_SOURCE, "development/ACCOUNT_MANAGER.md missing"),
)
text = _read_text(path)
missing = [marker for marker in RPA_006_REQUIRED_DOC_MARKERS if marker not in text]
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-RPA-006",
RPA_006_SOURCE,
"ACCOUNT_MANAGER.md missing markers: " + ", ".join(missing),
),
)
def test_example_account_id_selection_keeps_lease(self) -> None:
skill_root = get_skill_root()
for rel in RPA_006_EXAMPLE_CLIENTS:
text = _read_text(os.path.join(skill_root, *rel.split("/")))
self.assertIn("_pick_by_id_with_lease", text, msg=f"{rel} must lease explicit account ids")
self.assertIn('"account",\n "pick-web"', text, msg=f"{rel} must use pick-web for lease")
self.assertIn("lease_token", text, msg=f"{rel} must validate lease_token")
self.assertNotIn(
'data["lease_token"] = ""',
text,
msg=f"{rel} must not return an explicit account without a lease",
)
def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]: def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]:
offenders: list[str] = [] offenders: list[str] = []
for path in _walk_files(skill_root, "scripts", suffix=".py"): for path in _walk_files(skill_root, "scripts", suffix=".py"):
@@ -735,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): class TestPolicyControl001(unittest.TestCase):
def test_no_util_progress_module(self) -> None: def test_no_util_progress_module(self) -> None:
skill_root = get_skill_root() skill_root = get_skill_root()
@@ -1157,7 +1471,7 @@ class TestPolicySkillAction004(unittest.TestCase):
msg=_policy_msg( msg=_policy_msg(
"POLICY-SKILL-ACTION-004", "POLICY-SKILL-ACTION-004",
"tests/test_actions_manifest.py", "tests/test_actions_manifest.py",
"missing Schema 4×2 orthogonality matrix test", "missing Schema 6×2 orthogonality matrix test",
), ),
) )

View File

@@ -238,6 +238,32 @@ class TestDocsStandards(unittest.TestCase):
msg="RPA.md must mention jc2009 / industry simulator / portal gate", msg="RPA.md must mention jc2009 / industry simulator / portal gate",
) )
def test_rpa_md_covers_web_profile_headed_and_login_split(self) -> None:
text = self._read("development/RPA.md")
self.assertIn("0.2", text)
self.assertIn("profile_dir", text)
self.assertIn("POLICY-RPA-004", text)
self.assertIn("有头", text)
self.assertTrue(
"登录不全局强制" in text or "登录 ≠ Profile" in text or "登录≠Profile" in text,
msg="RPA.md §0.2 must separate site-login from Profile requirement",
)
def test_account_manager_standard_is_discoverable(self) -> None:
account_text = self._read("development/ACCOUNT_MANAGER.md")
for marker in (
"一账号一",
"profile_dir",
"lease_token",
"数据管理",
"account-manager.db",
"关闭浏览器并释放租约",
"两个以上账号",
):
self.assertIn(marker, account_text)
for rel in ("SKILL.md", "development/README.md", "development/RPA.md"):
self.assertIn("ACCOUNT_MANAGER.md", self._read(rel), msg=f"{rel} must link account standard")
def test_simulator_example_readme_covers_async_and_account_manager(self) -> None: def test_simulator_example_readme_covers_async_and_account_manager(self) -> None:
text = self._read("examples/simulator_browser_rpa/README.md") text = self._read("examples/simulator_browser_rpa/README.md")
self.assertIn("async", text.lower()) self.assertIn("async", text.lower())
@@ -472,12 +498,60 @@ class TestDocsStandards(unittest.TestCase):
def test_examples_top_level_modes_present(self) -> None: def test_examples_top_level_modes_present(self) -> None:
examples_dir = os.path.join(get_skill_root(), "examples") 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( self.assertTrue(
os.path.isdir(os.path.join(examples_dir, mode)), os.path.isdir(os.path.join(examples_dir, mode)),
msg=f"examples/{mode}/ must exist", 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: def test_examples_are_not_full_skill_repos(self) -> None:
examples_dir = os.path.join(get_skill_root(), "examples") examples_dir = os.path.join(get_skill_root(), "examples")
for mode in os.listdir(examples_dir): for mode in os.listdir(examples_dir):

View File

@@ -83,16 +83,16 @@ class TestPlatformImportSource(unittest.TestCase):
) )
) )
def test_platform_kit_min_version_is_1_2_0(self) -> None: def test_platform_kit_min_version_is_at_least_declared(self) -> None:
from jiangchang_skill_core import version_ge from jiangchang_skill_core import version_ge
from util.constants import PLATFORM_KIT_MIN_VERSION from util.constants import PLATFORM_KIT_MIN_VERSION
self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.2.0") self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.2.2")
md_path = os.path.join(get_skill_root(), "SKILL.md") md_path = os.path.join(get_skill_root(), "SKILL.md")
with open(md_path, encoding="utf-8") as f: with open(md_path, encoding="utf-8") as f:
md = f.read() md = f.read()
self.assertEqual(_parse_platform_kit_min_version(md), "1.2.0") self.assertEqual(_parse_platform_kit_min_version(md), "1.2.2")
req_path = os.path.join(get_skill_root(), "requirements.txt") req_path = os.path.join(get_skill_root(), "requirements.txt")
with open(req_path, encoding="utf-8") as f: with open(req_path, encoding="utf-8") as f:

View File

@@ -34,7 +34,7 @@ FORBIDDEN_PHRASES = (
POSITIVE_MARKERS = ( POSITIVE_MARKERS = (
"jiangchang-platform-kit", "jiangchang-platform-kit",
"1.2.0", "1.2.2",
"共享 runtime", "共享 runtime",
) )

View File

@@ -61,7 +61,7 @@ class TestEnvExampleVideoDefaults(unittest.TestCase):
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
text = f.read() text = f.read()
for key in ( for key in (
"OPENCLAW_" + "TEST_TARGET=mock", "OPENCLAW_" + "TEST_TARGET=real_" + "rpa",
"OPENCLAW_RECORD_VIDEO=0", "OPENCLAW_RECORD_VIDEO=0",
"OPENCLAW_ARTIFACTS_ON_FAILURE=1", "OPENCLAW_ARTIFACTS_ON_FAILURE=1",
"OPENCLAW_BROWSER_HEADLESS=0", "OPENCLAW_BROWSER_HEADLESS=0",

View File

@@ -1,5 +1,15 @@
# skill-template 工具 # skill-template 工具
## 业务仓也可以直接 clone(来源 A)
若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接:
```powershell
git clone https://git.jc2009.com/<org>/<your-skill-slug>.git
```
完整说明见 [`../development/DEVELOPMENT.md`](../development/DEVELOPMENT.md) §4「来源 A」。公开公共仓(含本模板、account-manager、platform-kit)登记见 [`../development/SHARED_REPOS.md`](../development/SHARED_REPOS.md)。下面脚手架用于**本地已有 skill-template、要新建尚未灌仓目录**的场景(来源 B)。
## 推荐:脚手架创建新技能 ## 推荐:脚手架创建新技能
**不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。 **不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。