Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ee121ab3 | |||
| 139d176841 | |||
| 0b6bf0b2fa | |||
| 51be1517c0 | |||
| 98f057e26b | |||
| 8878a90315 | |||
| 2ecb1172f4 | |||
| d2c8c3240c | |||
| 2c9f610a8e | |||
| 2c9bf56054 | |||
| 633ecff4e2 | |||
| 46bc573777 | |||
| 0002e86729 | |||
| caf0191366 | |||
| ba284922e5 | |||
| 7cf139dfd2 | |||
| 6c1ffc73d3 | |||
| 8fa7ec2743 | |||
| 79d21c063d | |||
| c15e23e0d4 | |||
| 0ea924958f | |||
| 20e89774ca | |||
| 4ed49495cf |
28
.env.example
28
.env.example
@@ -1,5 +1,5 @@
|
||||
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
|
||||
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://);换环境时再改,一般保持默认即可
|
||||
@@ -13,8 +13,8 @@ DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手
|
||||
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
|
||||
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
|
||||
|
||||
# 浏览器防检测:降低被网站识别为自动化工具的概率
|
||||
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启(推荐),0=关闭;一般保持默认
|
||||
# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力
|
||||
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制
|
||||
|
||||
# 是否录制操作过程:用于留证、复现问题或向他人演示
|
||||
OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1
|
||||
@@ -22,32 +22,30 @@ OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并
|
||||
# 失败时是否截图:任务出错时自动保存现场截图,便于排查
|
||||
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图
|
||||
|
||||
# 步骤最短等待:两步操作之间随机停顿的下限,模拟真人节奏
|
||||
# 步骤最短等待:两个正常业务动作之间随机停顿的下限
|
||||
STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大
|
||||
|
||||
# 步骤最长等待:两步操作之间随机停顿的上限
|
||||
STEP_DELAY_MAX=5.0 # 单位:秒;须大于等于最短等待;一般保持默认
|
||||
STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断
|
||||
|
||||
# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间
|
||||
HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300
|
||||
|
||||
# 以下为可选目录配置。一般不用改;取消注释并填写后,才覆盖默认子文件夹。
|
||||
# 填写相对路径时,相对于本技能的数据目录(不是当前工作目录)。
|
||||
# 用户可见只有 input(上行)与 output(下行);见 development/DATA_PATHS.md。
|
||||
|
||||
# 下载目录:技能保存下载文件的位置
|
||||
# SKILL_DOWNLOAD_DIR=downloads # 相对本技能数据目录;留空或保持注释则用默认 downloads
|
||||
# 输入目录:用户/Agent 放入的待处理文件(清单、CSV、产品表等)
|
||||
# SKILL_INPUT_DIR=input # 相对本技能数据目录;留空或保持注释则用默认 input
|
||||
|
||||
# 导入目录:待导入文件放置的位置
|
||||
# SKILL_IMPORT_DIR=imports # 相对本技能数据目录;留空或保持注释则用默认 imports
|
||||
# 输出目录:技能/宿主交给用户拿走的结果(视频、报表、导出文件等)
|
||||
# SKILL_OUTPUT_DIR=output # 相对本技能数据目录;留空或保持注释则用默认 output
|
||||
|
||||
# 导出目录:技能导出文件的保存位置
|
||||
# SKILL_EXPORT_DIR=exports # 相对本技能数据目录;留空或保持注释则用默认 exports
|
||||
|
||||
# 上传目录:待上传文件放置的位置
|
||||
# 上传目录:待发布到外部平台的 staging(可选,不上侧栏)
|
||||
# SKILL_UPLOAD_DIR=uploads # 相对本技能数据目录;留空或保持注释则用默认 uploads
|
||||
|
||||
# 缓存目录:临时缓存文件的位置
|
||||
# 缓存目录:临时缓存文件的位置(不上侧栏)
|
||||
# SKILL_CACHE_DIR=cache # 相对本技能数据目录;留空或保持注释则用默认 cache
|
||||
|
||||
# 临时目录:运行中临时文件的位置
|
||||
# 临时目录:运行中临时文件的位置(不上侧栏)
|
||||
# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp
|
||||
|
||||
122
CHANGELOG.md
122
CHANGELOG.md
@@ -9,6 +9,128 @@
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
## 1.0.75
|
||||
|
||||
- 正式沉淀数据管理「单元格呈现」通用契约(`references/CELL_PRESENTATION.md`):技能声明某表某行某列的语义 tone,宿主映射主题样式
|
||||
- 用途不限(超时未填、库存预警、状态异常、审核驳回等);推荐旁路表 `_jiangchang_cell_presentation`;填完后重算即取消强调
|
||||
- 新增 Schema / 示例;`actions.json` 可选顶层 `cellPresentation.enabled`
|
||||
|
||||
## 1.0.74
|
||||
|
||||
- 沉淀宿主 Skill Action 编排契约:`hostEffects`(`onSuccess` / `onFailure`)与 `steps`(`before` / `afterSuccess` / `afterFailure`)
|
||||
- 支持成功后 `reloadTable` 等 UI 副作用与级联删除步骤;需匠厂宿主 ≥2.8.5;省略则不自动刷表
|
||||
- 更新 `skill-actions.schema.json`、ACTIONS.md、SKILL_ACTION_RUNTIME
|
||||
|
||||
## 1.0.73
|
||||
|
||||
- 正式发布 Data Policy 契约(`references/POLICY.md` v1.0):可选身份授权,默认 L0 零配置
|
||||
- 新增 Snapshot Schema 与示例;`actions.json` 支持顶层 `policy` 与 Action 可选 `authz` 提示
|
||||
- ACTIONS 扩展为六职责正交(含 policy)
|
||||
|
||||
## 1.0.72
|
||||
|
||||
- 澄清 `placements: "row"` 契约:表示数据管理**行级入口**;技能配置决定菜单项,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action,无独立菜单 DSL / `row-context-menu` placement)
|
||||
- 更新 ACTIONS.md、SKILL_ACTION_RUNTIME、DEVELOPMENT 验收表与 skill-actions.schema 描述
|
||||
|
||||
## 1.0.71
|
||||
|
||||
- 文档对齐:Skill Action `sync` 默认超时约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`);接近或超过约 60s 的任务应标 `async`
|
||||
- 更新 `SKILL_ACTION_RUNTIME.md`
|
||||
|
||||
## 1.0.70
|
||||
|
||||
- 对齐宿主:数据管理表顶栏技能按钮全部直接展示,取消「更多操作」溢出菜单
|
||||
- 补充宽窄布局说明:够宽左右横排一行;不够宽左右分栏、栏内竖排
|
||||
- 更新 ACTIONS.md 与 skill-actions.schema.json
|
||||
|
||||
## 1.0.69
|
||||
|
||||
- 用户数据目录收敛为上行 `input/` 与下行 `output/`(取代 downloads / imports / exports 三分法)
|
||||
- `runtime_paths` 提供 `get_input_dir()` / `get_output_dir()`;相对输入路径解析到 `input/`
|
||||
- 更新 DATA_PATHS.md、CONFIG、`.env.example`;缓存 / 临时 / RPA 录屏目录仍为内部目录
|
||||
|
||||
## 1.0.68
|
||||
|
||||
- 明确数据管理技能按钮顺序契约:同一 placement(toolbar / row / batch)下按 `actions` 数组相对顺序展示;调整数组先后即可改序
|
||||
- 说明宿主内置刷新 / 导出 / 查看等不参与该顺序;无独立 `sortOrder` 字段
|
||||
- 更新 ACTIONS.md 与 skill-actions.schema.json
|
||||
|
||||
## 1.0.67
|
||||
|
||||
- 移除模板内嵌的 `tools/ui_inspector`;UI 录制器产品迁至独立仓库 `jiangchang-ui-recorder`
|
||||
- 相关开发文档改为指向 Gitea 产品仓库入口
|
||||
|
||||
## 1.0.66
|
||||
|
||||
- 数据管理支持表级默认排序:`_jiangchang_tables.default_sort_json`(多字段 JSON);宿主优先采用,未配置则仍按 updated_at → created_at → 主键
|
||||
- 更新 SCHEMA.md 与 `upsert_table_metadata`;旧库幂等补列
|
||||
|
||||
## 1.0.65
|
||||
|
||||
- `replaces` create/update 可复用表列 lookup:字段名对齐列名,或使用 `bind.columnMapping` / 字段 `column`
|
||||
- 更新 ACTIONS.md 与 skill-actions.schema.json
|
||||
|
||||
## 1.0.64
|
||||
|
||||
- Skill Action 新增可选 `replaces: create|update|delete`:接管数据管理默认新增/编辑/删除,无配置时宿主仍直写 SQLite
|
||||
- 更新 ACTIONS.md 与 skill-actions.schema.json;`replaces` 须配合 `bind.tables`,可与空 `placements` 并用
|
||||
|
||||
## 1.0.63
|
||||
|
||||
- 数据管理支持字段级 lookup:`options_json` 可声明同库关联表(`ref_table` / `value_column` / `label_column`),宿主新建/编辑时渲染下拉
|
||||
- 更新 SCHEMA.md 与元数据校验,兼容静态枚举数组与 lookup 对象两种形态
|
||||
|
||||
## 1.0.62
|
||||
|
||||
- Skill Action 新增 `interaction` / `interactionByPlacement`:声明数据管理点击是直通、轻确认还是参数表单(与 `executionProfile` 正交)
|
||||
- 明确 `confirmation` 只负责风险文案;cron 触发与 agent 忽略 `interaction`
|
||||
- 补充 ACTIONS.md 示例(打开文件位置 = direct,打开浏览器 = confirm)
|
||||
|
||||
## 1.0.61
|
||||
|
||||
- 桌面 RPA 标准反哺:明确 Name≠Value、主窗最大化与禁止误 `SW_RESTORE`、同名叶子须绑父容器
|
||||
- 更新 `real_desktop_rpa` example:activate/Value 定位、element-map 补全「新的客户/添加」、README 与契约测试
|
||||
|
||||
## 1.0.60
|
||||
|
||||
- 强化日志规范:验收铁律、固定事件词表、失败须先写文件日志再 print
|
||||
- 新增 hard 门禁 POLICY-LOGGING-006/007/008(域模块有日志、RPA 事件词、ERROR 与 logger 共现)
|
||||
- 模板金样 `task_service` 示范 `entitlement_ok`、`rpa_start`/`stage=`、`task_failed error_code=`
|
||||
- 增加 `tests/samples/test_logging_contract.py.sample` 与交活 Checklist 日志 P0 项
|
||||
|
||||
## 1.0.59
|
||||
|
||||
- 新增浏览器、Windows 桌面、Android/iOS 三端元素拾取与定位标准,要求先真实拾取、填写 element map、验证唯一性和业务后置状态
|
||||
- 从已跑通的企业微信添加客户技能提炼真实 Windows UIA example,提供窗口唯一性、作用域定位、probe 和 dry-run/assisted 范式
|
||||
- 手机 RPA 增加设备、Appium Inspector、Native/WebView、控件树和失败留证标准;在成功案例补齐前明确标为规格骨架
|
||||
- 需求模板和交活 Checklist 增加禁止 AI 猜 selector、禁止 first/index 掩盖歧义、结构变化停止留证等门禁
|
||||
|
||||
## 1.0.58
|
||||
|
||||
- 新增 account-manager 接入标准,说明一账号一 Profile、多账号矩阵、登录状态持久化与完整运行生命周期
|
||||
- 明确账号创建后的查看位置:匠厂数据管理、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 与红线
|
||||
|
||||
@@ -41,6 +41,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
|
||||
说明用户在使用前要准备的东西,例如:
|
||||
|
||||
- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
|
||||
- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态
|
||||
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
|
||||
- 相关业务权限(查询、导出、提交等)已具备
|
||||
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
|
||||
@@ -100,3 +101,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
|
||||
### 结果文件在哪里查看?
|
||||
|
||||
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。
|
||||
|
||||
### 已经登记的账号在哪里查看?
|
||||
|
||||
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。
|
||||
|
||||
15
SKILL.md
15
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 技能开发模板(通用业务版)
|
||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||
version: 1.0.54
|
||||
version: 1.0.75
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -22,12 +22,21 @@ allowed-tools:
|
||||
|
||||
## 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` 分叉业务行为。
|
||||
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。
|
||||
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。其中 `row` 表示**行级入口**(宿主可呈现为操作列、行右键、行内「⋯」等;技能配什么就显示什么,无独立菜单 DSL)。数据管理技能按钮顺序见 `actions` **数组相对顺序**(详见 `references/ACTIONS.md`),无独立 sortOrder。
|
||||
- **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。
|
||||
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。
|
||||
- **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。
|
||||
- **`cellPresentation`(可选)**:需要数据管理「某格特殊标色/强调」时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。
|
||||
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。
|
||||
- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。
|
||||
|
||||
@@ -61,7 +70,7 @@ allowed-tools:
|
||||
5. **一个业务能力一个 Action**,用多个 `placements` 挂入口;底层仍是同一 `scripts/main.py` CLI。
|
||||
6. 是否进任务中心**只**看该 Action 的 `executionProfile`,与数据管理 / 定时 / Agent 入口无关。
|
||||
|
||||
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`(不要把 `development/` 当 Agent 主读路径)。
|
||||
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`、按需 `references/POLICY.md`(不要把 `development/` 当 Agent 主读路径)。
|
||||
|
||||
## 共享 Python(必读)
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
2. 已在技能市场安装本技能(或更新到最新版)
|
||||
3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
|
||||
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
|
||||
5. 【如需】需要的文件 / 时间范围 / 单号等已准备好
|
||||
5. 【网页自动化类技能】已登记账号可在「数据管理 / 账号管理」查看;每个账号都有独立浏览器会话
|
||||
6. 【如需】需要的文件 / 时间范围 / 单号等已准备好
|
||||
|
||||
---
|
||||
|
||||
@@ -58,6 +59,7 @@
|
||||
| 现象 | 你可以先试 |
|
||||
|------|------------|
|
||||
| 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 |
|
||||
| 提示账号不存在 / 被占用 | 到「数据管理 / 账号管理」确认账号记录和状态,稍后再试;不要重复创建账号 |
|
||||
| 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 |
|
||||
| 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 |
|
||||
| 结果不对 | 用更明确的条件再说一次,或缩小范围重试 |
|
||||
|
||||
30
acceptance-reports/README.md
Normal file
30
acceptance-reports/README.md
Normal 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` 仓库通常不产生业务验收报告;此目录随模板复制到新技能后使用。
|
||||
@@ -2,3 +2,5 @@
|
||||
|
||||
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
|
||||
- `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。
|
||||
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
|
||||
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
|
||||
|
||||
29
assets/examples/skill-cell-presentation.example.json
Normal file
29
assets/examples/skill-cell-presentation.example.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"cells": [
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "qty",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "status",
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
},
|
||||
{
|
||||
"rowId": "99",
|
||||
"column": "print_done_at",
|
||||
"tone": "danger",
|
||||
"message": "环节到期未填",
|
||||
"alert": true
|
||||
}
|
||||
]
|
||||
}
|
||||
13
assets/examples/skill-policy-snapshot.allowlist.example.json
Normal file
13
assets/examples/skill-policy-snapshot.allowlist.example.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": {
|
||||
"userId": "u1",
|
||||
"displayName": "张三",
|
||||
"roles": ["clerk"]
|
||||
},
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "sync-records"]
|
||||
}
|
||||
}
|
||||
28
assets/examples/skill-policy-snapshot.scope.example.json
Normal file
28
assets/examples/skill-policy-snapshot.scope.example.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": {
|
||||
"userId": "u1"
|
||||
},
|
||||
"actions": {
|
||||
"mode": "open"
|
||||
},
|
||||
"data": {
|
||||
"tables": {
|
||||
"orders": {
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{
|
||||
"field": "owner_user_id",
|
||||
"op": "eq",
|
||||
"valueFrom": "actor.userId"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
|
||||
"title": "SkillActionManifest",
|
||||
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
|
||||
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "actions"],
|
||||
"additionalProperties": false,
|
||||
@@ -19,14 +19,73 @@
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/action" }
|
||||
"items": { "$ref": "#/$defs/action" },
|
||||
"description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序展示。toolbar/batch:表顶栏全部直接展示(无「更多操作」溢出)。row:行级入口,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action、同一顺序);不在本字段配置排序、截断或菜单树。调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。表顶栏宽时左右横排,窄时左右分栏且栏内竖排。"
|
||||
},
|
||||
"policy": {
|
||||
"$ref": "#/$defs/policyDeclaration",
|
||||
"description": "可选。启用 Data Policy(L1/L2)。缺省或不 enabled = L0,宿主不拉 Snapshot。详见 references/POLICY.md。"
|
||||
},
|
||||
"cellPresentation": {
|
||||
"$ref": "#/$defs/cellPresentationDeclaration",
|
||||
"description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"cellPresentationDeclaration": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "仅 true 启用;false 等同 P0。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"policyDeclaration": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "仅 true 启用;false 等同 L0。"
|
||||
},
|
||||
"entrypoint": {
|
||||
"$ref": "#/$defs/entrypoint",
|
||||
"description": "enabled=true 时必填。推荐 command=policy,args 可含 {{table}}/{{skill}}/{{userId}}。"
|
||||
},
|
||||
"appliesTo": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["data-management"]
|
||||
},
|
||||
"description": "缺省 [\"data-management\"]。本期宿主只实现 data-management。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "enabled": { "const": true } },
|
||||
"required": ["enabled"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["entrypoint"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"placement": {
|
||||
"type": "string",
|
||||
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
|
||||
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。placements 与 executionProfile 正交,不做条件限制。"
|
||||
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。row=行级入口(操作列/行右键/⋯ 等由宿主呈现,无独立 row-context-menu placement)。placements 与 executionProfile 正交,不做条件限制。"
|
||||
},
|
||||
"mutationKind": {
|
||||
"type": "string",
|
||||
"enum": ["create", "update", "delete"],
|
||||
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。"
|
||||
},
|
||||
"scalarArg": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
@@ -52,6 +111,12 @@
|
||||
"readOnly": {
|
||||
"type": "boolean",
|
||||
"description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "可选。绑定业务表物理列名;replaces create/update 时宿主可复用该列的中文名与 lookup 下拉"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -120,9 +185,87 @@
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string", "minLength": 1 },
|
||||
"description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。"
|
||||
},
|
||||
"columnMapping": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"description": "可选。将 inputSchema 属性名映射到业务表物理列名,供 replaces create/update 抽屉复用列 displayName / lookup options。"
|
||||
}
|
||||
},
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping 供行内/批量预填。concurrency/locks 勿写入。"
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping。concurrency/locks 勿写入。"
|
||||
},
|
||||
"hostEffectId": {
|
||||
"type": "string",
|
||||
"enum": ["reloadTable", "refreshCatalog", "clearSelection"],
|
||||
"description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。"
|
||||
},
|
||||
"hostEffects": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"onSuccess": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/hostEffectId" },
|
||||
"uniqueItems": true,
|
||||
"description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。"
|
||||
},
|
||||
"onFailure": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/hostEffectId" },
|
||||
"uniqueItems": true,
|
||||
"description": "sync 失败,或 async 启动被拒时触发。"
|
||||
}
|
||||
},
|
||||
"description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。"
|
||||
},
|
||||
"actionStep": {
|
||||
"type": "object",
|
||||
"required": ["when", "do"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"when": {
|
||||
"type": "string",
|
||||
"enum": ["before", "afterSuccess", "afterFailure"],
|
||||
"description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。"
|
||||
},
|
||||
"do": {
|
||||
"type": "string",
|
||||
"enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"],
|
||||
"description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "deleteByFk / deleteRows 目标表(snake_case)。"
|
||||
},
|
||||
"fk": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "deleteByFk 时子表外键列名。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "do": { "const": "deleteByFk" } },
|
||||
"required": ["do"]
|
||||
},
|
||||
"then": { "required": ["table", "fk"] }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "do": { "const": "deleteRows" } },
|
||||
"required": ["do"]
|
||||
},
|
||||
"then": { "required": ["table"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": {
|
||||
"type": "object",
|
||||
@@ -144,10 +287,11 @@
|
||||
},
|
||||
"placements": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"minItems": 0,
|
||||
"items": { "$ref": "#/$defs/placement" },
|
||||
"description": "决定 Action 出现在哪里;与 executionProfile 正交"
|
||||
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。"
|
||||
},
|
||||
"replaces": { "$ref": "#/$defs/mutationKind" },
|
||||
"entrypoint": { "$ref": "#/$defs/entrypoint" },
|
||||
"inputSchema": { "$ref": "#/$defs/inputSchema" },
|
||||
"confirmation": { "$ref": "#/$defs/confirmation" },
|
||||
@@ -156,7 +300,46 @@
|
||||
"enum": ["sync", "async"],
|
||||
"description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。"
|
||||
},
|
||||
"bind": { "$ref": "#/$defs/bind" }
|
||||
"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" },
|
||||
"hostEffects": { "$ref": "#/$defs/hostEffects" },
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": { "$ref": "#/$defs/actionStep" },
|
||||
"description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。"
|
||||
},
|
||||
"authz": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"capability": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选文档/静态提示用能力名;宿主不得仅凭此显隐按钮,运行时以 policy Snapshot 为准。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"properties": {
|
||||
@@ -169,7 +352,17 @@
|
||||
"then": {
|
||||
"required": ["bind"]
|
||||
},
|
||||
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 时必须声明 bind.tables。"
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["replaces"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["bind"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
assets/schemas/skill-cell-presentation.schema.json
Normal file
78
assets/schemas/skill-cell-presentation.schema.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json",
|
||||
"title": "SkillCellPresentationDocument",
|
||||
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "table", "rowKey", "cells"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"minLength": 1,
|
||||
"description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "业务表物理名(snake_case)"
|
||||
},
|
||||
"rowKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "用于匹配行的业务表列名(如 id、feishu_record_id);cells[].rowId 的值取自该列"
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选。ISO-8601 或技能约定的生成时间,便于排障"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "当前应对该表生效的呈现项;空数组 = 该表无特殊呈现(清除旧样式)。同 (rowId, column) 重复时宿主可后者覆盖前者。",
|
||||
"items": { "$ref": "#/$defs/cellItem" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"tone": {
|
||||
"type": "string",
|
||||
"enum": ["danger", "warning", "info", "success", "muted"],
|
||||
"description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。"
|
||||
},
|
||||
"cellItem": {
|
||||
"type": "object",
|
||||
"required": ["rowId", "column", "tone"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rowId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "rowKey 列的字符串形式主键/业务键"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "要强调的业务列名(snake_case),必须是真实列"
|
||||
},
|
||||
"tone": { "$ref": "#/$defs/tone" },
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"description": "可选。悬停/无障碍说明(短文案);不要塞长报告"
|
||||
},
|
||||
"alert": {
|
||||
"type": "boolean",
|
||||
"description": "可选,默认 false。true = 可计入进页/同步后的提醒汇总(如 Toast);填完数据并重算后该项消失则提醒自然关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
196
assets/schemas/skill-policy-snapshot.schema.json
Normal file
196
assets/schemas/skill-policy-snapshot.schema.json
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-policy-snapshot.schema.json",
|
||||
"title": "SkillDataPolicySnapshot",
|
||||
"description": "匠厂 Skill Data Policy Snapshot(schemaVersion 1)。由技能 policy CLI 输出到 stdout;宿主用于数据管理按钮/行列过滤。详见 references/POLICY.md。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "actor", "actions"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"required": ["userId"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"userId": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string" },
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"attributes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"type": "object",
|
||||
"required": ["mode"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["allowlist", "open"],
|
||||
"description": "v1 仅允许 allowlist/open;denylist 非法。"
|
||||
},
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
},
|
||||
"description": "mode=allowlist 时必填;mode=open 时应省略或 []。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "mode": { "const": "allowlist" } },
|
||||
"required": ["mode"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["ids"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"hostCapabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["refresh", "export", "view", "edit", "delete", "import"]
|
||||
},
|
||||
"description": "省略=不限制宿主内置能力;写出则按 allowlist(含 []=全禁)。"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tables": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/tablePolicy" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "技能私有扩展;宿主必须忽略未知键。"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"predicate": {
|
||||
"type": "object",
|
||||
"required": ["field", "op"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eq",
|
||||
"ne",
|
||||
"in",
|
||||
"not_in",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"is_null",
|
||||
"is_not_null"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"description": "与 valueFrom 互斥;in/not_in 时为非空数组。"
|
||||
},
|
||||
"valueFrom": {
|
||||
"type": "string",
|
||||
"const": "actor.userId",
|
||||
"description": "v1 仅允许 actor.userId。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/predicate" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["all"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"all": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/predicate" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["any"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"any": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/predicate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"tablePolicy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"accessible": { "type": "boolean" },
|
||||
"columns": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"visible": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
}
|
||||
},
|
||||
"writable": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rows": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["scope", "open", "delegate"]
|
||||
},
|
||||
"scope": { "$ref": "#/$defs/scope" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
216
development/ACCOUNT_MANAGER.md
Normal file
216
development/ACCOUNT_MANAGER.md
Normal 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)。
|
||||
@@ -1,6 +1,8 @@
|
||||
# 适配器标准:真实/仿真 × 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` |
|
||||
| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 开发联调可选 |
|
||||
| **`real_api`** | 真实 API | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_api` |
|
||||
| **`real_rpa`** | 真实浏览器/真实系统 | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_rpa` |
|
||||
| **`mock`** | 纯内存或 fixture,离线 | **单测 / CI 必过**;mock 不通不要往下走 |
|
||||
| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 按业务场景选用 |
|
||||
| **`real_api`** | 真实 API | 按业务场景选用(有官方接口时) |
|
||||
| **`real_rpa`** | 真实浏览器/真实系统 | **模板 `.env.example` 默认**;业务自测与交活用此档 |
|
||||
|
||||
- **mock**:纯离线、不联网,给单测 / CI / 开发自测,**保证可重复**。
|
||||
- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),跑端到端流程但不碰生产。
|
||||
- **real_api**:有官方接口时**首选**(最稳、最快、最易维护)。
|
||||
- **real_rpa**:没有 API 只能操作生产界面,**风险最高、放最后**。
|
||||
- **mock**:纯离线、不联网,给单测 / CI,**保证可重复**。**mock 通 ≠ 开发完成**。
|
||||
- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),按场景选用。
|
||||
- **real_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`)。
|
||||
|
||||
@@ -72,7 +74,7 @@ scripts/service/
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
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"):
|
||||
return MockAdapter()
|
||||
if target == "real_api":
|
||||
@@ -100,9 +102,10 @@ def get_adapter():
|
||||
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` 模式。
|
||||
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。
|
||||
5. **pick/ensure 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` → `get <id>`;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id`;皆空 → 按平台 `ensure-web`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配 → `ERROR:AMBIGUOUS_ACCOUNT`。
|
||||
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
|
||||
# 普通兄弟技能 — 走 sibling_bridge
|
||||
@@ -125,5 +128,6 @@ finally:
|
||||
## 相关文档
|
||||
|
||||
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
|
||||
- `ACCOUNT_MANAGER.md` — 账号、Profile、租约、多账号、查看与排障标准
|
||||
- `CONFIG.md` — `.env` 里如何配置运行模式与目标地址
|
||||
- `TESTING.md` — 测试 target 与隔离体系
|
||||
|
||||
227
development/AI_RPA.md
Normal file
227
development/AI_RPA.md
Normal 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
177
development/CHECKLIST.md
Normal 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 与期望现象
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
```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=后台静默(勿作日常用法)
|
||||
@@ -58,7 +58,7 @@ OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查
|
||||
|
||||
```ini
|
||||
# ── 运行模式 / adapter 档位(见 development/ADAPTER.md)──
|
||||
OPENCLAW_TEST_TARGET=real_rpa # 生产默认真实 RPA;单测/CI 可改为 mock
|
||||
OPENCLAW_TEST_TARGET=mock # 见 development/ADAPTER.md;单测用 mock
|
||||
|
||||
# ── 好看视频 / 百度账号(须与 account-manager 平台 key 一致)──
|
||||
TARGET_PLATFORM=baidu
|
||||
@@ -73,7 +73,7 @@ HAOKAN_VIDEO_SELECTOR=video.art-video
|
||||
|
||||
```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://);换环境时再改,一般保持默认即可
|
||||
@@ -87,8 +87,8 @@ DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手
|
||||
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
|
||||
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
|
||||
|
||||
# 浏览器防检测:降低被网站识别为自动化工具的概率
|
||||
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启(推荐),0=关闭;一般保持默认
|
||||
# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力
|
||||
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制
|
||||
|
||||
# 是否录制操作过程:用于留证、复现问题或向他人演示
|
||||
OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1
|
||||
@@ -96,11 +96,11 @@ OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并
|
||||
# 失败时是否截图:任务出错时自动保存现场截图,便于排查
|
||||
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图
|
||||
|
||||
# 步骤最短等待:两步操作之间随机停顿的下限,模拟真人节奏
|
||||
# 步骤最短等待:两个正常业务动作之间随机停顿的下限
|
||||
STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大
|
||||
|
||||
# 步骤最长等待:两步操作之间随机停顿的上限
|
||||
STEP_DELAY_MAX=5.0 # 单位:秒;须大于等于最短等待;一般保持默认
|
||||
STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断
|
||||
|
||||
# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间
|
||||
HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300
|
||||
@@ -108,15 +108,15 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
|
||||
|
||||
四档运行模式的**实现与测试约定**见 [`ADAPTER.md`](ADAPTER.md);录屏管线见 [`RPA.md`](RPA.md)。这些内容**不要**写回 `.env.example` 注释。
|
||||
|
||||
**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_DOWNLOAD_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
|
||||
**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_OUTPUT_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
|
||||
|
||||
### 数据目录子路径(下载 / 导入 / 导出)
|
||||
### 数据目录子路径(输入 / 输出)
|
||||
|
||||
技能**自己写入**的文件默认在 `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` 下标准子目录(`downloads/`、`imports/`、`exports/` 等)。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
|
||||
用户可见目录只有 **`input/`(上行)** 与 **`output/`(下行)**。技能写入的业务产出默认落在 `output/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
|
||||
|
||||
- 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md)
|
||||
- `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法
|
||||
- 可选 env:`SKILL_DOWNLOAD_DIR`、`SKILL_IMPORT_DIR`、`SKILL_EXPORT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`)
|
||||
- 可选 env:`SKILL_INPUT_DIR`、`SKILL_OUTPUT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`)
|
||||
|
||||
### 录屏开关(`OPENCLAW_RECORD_VIDEO`)
|
||||
|
||||
@@ -169,9 +169,10 @@ 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`:仅**纯数字**视为账号主键;非数字非空可当 login_id(不推荐,优先用 `DEFAULT_LOGIN_ID`)
|
||||
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`
|
||||
- `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
|
||||
@@ -189,6 +190,7 @@ config.get_float("STEP_DELAY_MIN", 1.0)
|
||||
## 相关文档
|
||||
|
||||
- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释)
|
||||
- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收
|
||||
- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释)
|
||||
- `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定
|
||||
- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 用户数据目录与文件路径黄金标准
|
||||
|
||||
> 本文是 skill **读写本地文件** 的权威约定。凡涉及下载、导入、导出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。
|
||||
> 本文是 skill **读写本地文件** 的权威约定。凡涉及输入、输出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,11 +30,17 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
|
||||
## 2. 标准子目录树
|
||||
|
||||
用户可见目录(数据管理侧栏只挂这两个)按 **上行 / 下行** 划分:
|
||||
|
||||
| 子目录 | 方向 | 用途 | 默认 helper |
|
||||
|--------|------|------|-------------|
|
||||
| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` |
|
||||
| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` |
|
||||
|
||||
内部 / 可选目录(**不上侧栏**):
|
||||
|
||||
| 子目录 | 用途 | 默认 helper |
|
||||
|--------|------|-------------|
|
||||
| `downloads/` | 从外部拉取的原始文件(视频、回单、PDF…) | `get_downloads_dir()` |
|
||||
| `imports/` | 用户/Agent 放入的待处理清单(urls.txt、batch.json、CSV) | `get_imports_dir()` |
|
||||
| `exports/` | 导出给用户的结果(xlsx/csv/json) | `get_exports_dir()` |
|
||||
| `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` |
|
||||
| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` |
|
||||
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
|
||||
@@ -45,6 +51,15 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
|
||||
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。
|
||||
|
||||
**开发者硬规则:**
|
||||
|
||||
- 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`)
|
||||
- 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`)
|
||||
- 禁止再引入业务侧栏第三目录(如旧的 `downloads` / `imports` / `exports` 三分法)
|
||||
- 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」
|
||||
|
||||
宿主约定(与本模板对齐):工具栏「导入」默认从 `input/` 取文件;「导出」默认写到 `output/`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 两类路径
|
||||
@@ -54,6 +69,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。
|
||||
|
||||
- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。
|
||||
- 用户可拿走的业务产出 → **`output/`**。
|
||||
- **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
|
||||
|
||||
### B. 用户显式输入(user-owned)
|
||||
@@ -61,7 +77,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。
|
||||
|
||||
- 技能**只读**,路径可以是用户磁盘任意位置。
|
||||
- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/imports/<相对路径>`,**不**相对 workspace/CWD。
|
||||
- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/input/<相对路径>`,**不**相对 workspace/CWD。
|
||||
|
||||
---
|
||||
|
||||
@@ -72,9 +88,8 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
```ini
|
||||
# ── 数据目录子路径(见 development/DATA_PATHS.md)──
|
||||
# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace
|
||||
# SKILL_DOWNLOAD_DIR=downloads/videos
|
||||
# SKILL_IMPORT_DIR=imports
|
||||
# SKILL_EXPORT_DIR=exports
|
||||
# SKILL_INPUT_DIR=input
|
||||
# SKILL_OUTPUT_DIR=output/videos
|
||||
# SKILL_UPLOAD_DIR=uploads
|
||||
# SKILL_CACHE_DIR=cache
|
||||
# SKILL_TEMP_DIR=temp
|
||||
@@ -83,8 +98,8 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 未配置 | `{skill_data_dir}/{default_subdir}` |
|
||||
| 相对值 `downloads/videos` | `{skill_data_dir}/downloads/videos` |
|
||||
| 绝对值 `D:\nas\exports` | 高级用户/NAS 挂载,原样使用 |
|
||||
| 相对值 `output/videos` | `{skill_data_dir}/output/videos` |
|
||||
| 绝对值 `D:\nas\output` | 高级用户/NAS 挂载,原样使用 |
|
||||
| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 |
|
||||
|
||||
业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。
|
||||
@@ -95,20 +110,20 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
|
||||
```python
|
||||
from util.runtime_paths import (
|
||||
get_downloads_dir,
|
||||
get_imports_dir,
|
||||
get_input_dir,
|
||||
get_output_dir,
|
||||
resolve_data_path,
|
||||
resolve_input_path,
|
||||
list_resolved_data_paths,
|
||||
)
|
||||
|
||||
# 下载保存(A 类)
|
||||
dest = os.path.join(get_downloads_dir(), f"{vid}.mp4")
|
||||
# 下行产出(A 类)——视频、报表等
|
||||
dest = os.path.join(get_output_dir(), f"{vid}.mp4")
|
||||
|
||||
# 或带 env 覆盖
|
||||
out_dir = resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads")
|
||||
out_dir = resolve_data_path("SKILL_OUTPUT_DIR", "output")
|
||||
|
||||
# CLI -i urls.txt(B 类,相对路径 → imports/)
|
||||
# CLI -i urls.txt(B 类,相对路径 → input/)
|
||||
manifest = resolve_input_path(args.input_path)
|
||||
|
||||
# health 诊断
|
||||
@@ -123,6 +138,8 @@ os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR"))
|
||||
|
||||
# ❌ .env.example 里写 ./outputs/videos
|
||||
DOWNLOAD_OUTPUT_DIR=./outputs/videos
|
||||
|
||||
# ❌ 再拆 downloads / imports / exports 三个用户目录
|
||||
```
|
||||
|
||||
---
|
||||
@@ -138,7 +155,7 @@ DOWNLOAD_OUTPUT_DIR=./outputs/videos
|
||||
- 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1)
|
||||
- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3)
|
||||
|
||||
业务 `downloads/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
|
||||
业务 `output/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
`skill-template` 不是业务 skill,它只是一个**新 skill 仓库模板**。
|
||||
|
||||
开发任何外部系统技能前,**先读 [`AI_RPA.md`](AI_RPA.md)**。本模板的统一定位是:Agent 负责理解、确认与编排,RPA 负责通过正常界面确定性执行;只做授权范围内的正规业务自动化,禁止破解、逆向、私有协议、验证码绕过或其他对抗第三方安全机制的实现。
|
||||
|
||||
你不应该直接在这个仓库里开发业务。新技能仓库有**两种合法来源**(详见 §4「第一步」):
|
||||
|
||||
| 来源 | 适用情况 | 怎么做 |
|
||||
@@ -131,7 +133,9 @@ scripts/
|
||||
|
||||
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
|
||||
|
||||
1. **先读三份标准**:`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`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):
|
||||
- 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`:
|
||||
- `examples/real_browser_rpa/scripts/service/browser_session.py`
|
||||
@@ -146,15 +150,18 @@ scripts/
|
||||
- `examples/simulator_browser_rpa/scripts/service/task_service.py`(async 编排)
|
||||
- `sandbox/demo_app.html` 仅留在 examples,不进入生产 skill
|
||||
- **禁止**:`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
|
||||
- **网页硬规则**:未拿到 `profile_dir` 禁止开浏览器;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
|
||||
- **网页硬规则**:未拿到非空 `profile_dir + lease_token` 禁止开浏览器;一账号一 Profile;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
|
||||
3. **只用共享库,不在 skill 里重写反反爬**:
|
||||
```python
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass
|
||||
```
|
||||
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
|
||||
4. **mock 档必须离线可跑**(`OPENCLAW_TEST_TARGET=mock`);sim_rpa / real_* 按需单独测。
|
||||
5. **桌面/手机**:本期标准见 RPA.md 第 2/3 节,复用 `jiangchang_desktop_sdk` / `screencast`,**不要在新 skill 里重复造包**(尚待实战验证)。
|
||||
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。
|
||||
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 / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。
|
||||
|
||||
---
|
||||
|
||||
@@ -818,15 +825,23 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
- 安装后状态正常
|
||||
- 不会出现缺文件、缺入口或安装失败的问题
|
||||
|
||||
### 第七步:按声明做宿主多入口验收(已声明的入口都要测)
|
||||
### 第七步:按声明做宿主多入口验收(已声明的入口都要测;须看用户体验)
|
||||
|
||||
安装完成后,不要只停留在“已安装”状态。宿主侧栏有多条与技能相关的入口;**按本技能 `assets/actions.json` 的 `placements` / `executionProfile` 声明逐项自测**(契约见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md))。未声明的入口可以跳过;**已声明却测不到,视为验收失败**。不要只测对话、忽略其它入口。
|
||||
安装完成后,不要只停留在“已安装”状态,也**不要**只在本地 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` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 |
|
||||
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
|
||||
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
|
||||
| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 |
|
||||
@@ -870,7 +885,9 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag)
|
||||
- [ ] `.github/workflows/release_skill.yaml` 存在
|
||||
- [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`)
|
||||
- [ ] 主任务入口(如 `cmd_run`)有关键日志:`task_start`、`task_log_saved`;失败路径使用 `logger.exception`
|
||||
- [ ] 主任务入口有关键日志事件词:`task_start`、`task_log_saved`;失败路径 `task_failed error_code=` + `logger.exception`/`warning`(见 [`LOGGING.md`](LOGGING.md) §0、§3)
|
||||
- [ ] RPA / 域模块(`scripts/service/<domain>/` 等)非零文件日志;`print(ERROR:)` 旁有 logger(`POLICY-LOGGING-006`~`008`)
|
||||
- [ ] 本地跑一次主任务后,统一日志能 grep 到 §3.4 最低可验收串(不只 `cli_start`)
|
||||
- [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md))
|
||||
- [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测
|
||||
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行)
|
||||
@@ -880,7 +897,8 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`)
|
||||
- [ ] `git remote -v` 指向**本技能**远端,URL 不含 skill-template 仓库名
|
||||
- [ ] `git log` 首条提交属于本技能(非模板历史)(来源 A 从 Gitea 克隆的已有业务仓,以该仓历史为准)
|
||||
- [ ] 发布后计划在宿主按 §15 第七步验收:已声明的 **新建任务 / 数据管理(toolbar|row|batch) / 定时任务 / 任务中心(async) / 技能详情** 均已覆盖
|
||||
- [ ] 业务 `.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. 常见错误
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
# 日志规范
|
||||
|
||||
本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文,并在 code review 时对照「必打日志节点清单」人工检查。
|
||||
本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文。
|
||||
|
||||
自动 hard policy(`POLICY-LOGGING-001`~`008`)只保证底线;阶段是否够细仍须对照本文事件词表做 review。复制技能后可用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample) 做契约断言。
|
||||
|
||||
---
|
||||
|
||||
## 0. 验收铁律(先读)
|
||||
|
||||
| 通道 | 给谁看 | 能否单独当排障依据 |
|
||||
|------|--------|-------------------|
|
||||
| 统一文件日志 `jiangchang.log` | 运维 / 离线打包 | **必须能单独定位到失败阶段与 error_code** |
|
||||
| Activity / 任务中心 `emit` | 用户进度 | **不能**替代文件日志 |
|
||||
| `print` / stdout | CLI 一眼结果 | **禁止**作为唯一失败记录 |
|
||||
| `task_logs` | 终态审计 | 摘要层;**不能**替代逐步 trace |
|
||||
|
||||
硬规定:
|
||||
|
||||
1. **没有关键节点文件日志 = 客户现场不可排障。**
|
||||
2. **`emit` ≠ 文件日志**:任务中心有进度,而 `jiangchang.log` 只有 `cli_start`,视为交付不合格。
|
||||
3. **失败出口必须先写文件日志,再 `print` / `finish`**:凡 `print(...ERROR:...)`、`finish(status=failed|partial)`、队列 `mark_failed`,同一出口须有 `log.warning` / `log.exception`(含 `error_code=` 或 `task_failed`)。
|
||||
4. **域模块也要打点**:`scripts/service/<domain>/`、`*_flow.py`、`*_driver.py` 等业务文件不得零日志(见 `POLICY-LOGGING-006`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -24,8 +44,6 @@ OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览
|
||||
| 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 |
|
||||
| 存证路径 | 截图、录屏、`video_log`、失败 artifacts |
|
||||
|
||||
**没有关键节点日志 = 客户现场不可排障。** 不要把 `print` 当作日志;stdout 面向用户与 Agent,文件日志与 Run Journal 面向运维。
|
||||
|
||||
---
|
||||
|
||||
## 2. 日志分层
|
||||
@@ -130,36 +148,73 @@ Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`
|
||||
|
||||
---
|
||||
|
||||
## 3. 必打日志节点清单
|
||||
## 3. 固定事件词表(生产代码须出现)
|
||||
|
||||
以下节点应在**生产代码**中有对应 `log.info` / `log.warning` / `log.exception`(或 Activity / RPA step)。复制模板后按业务增删,但**不可整体省略**。
|
||||
日志消息推荐 **key=value**。下列**事件名字符串**须出现在 `log.info` / `log.warning` / `log.exception` 的消息中(可用 f-string / `%s`,但事件名本身须可 grep)。
|
||||
|
||||
| 阶段 | 建议日志 / emit |
|
||||
|------|-----------------|
|
||||
| CLI 入口启动 | `cli_start`(`cli/app.py`,已由模板提供) |
|
||||
| 参数解析完成 | `run_args target_id=... input_id=...` |
|
||||
| 鉴权开始 | `entitlement_check_start` |
|
||||
| 鉴权成功 | `entitlement_ok` |
|
||||
| 鉴权失败 | `entitlement_failed`(`warning`) |
|
||||
| 任务开始 | `task_start task_type=... target_id=... input_id=... batch_id=...` + `emit("开始处理任务")` |
|
||||
| adapter / test target 选择 | `adapter_selected adapter=... system=...` |
|
||||
| 账号 / profile / lease 获取 | `account_acquired account_id=...` |
|
||||
| 账号 / lease 释放 | `account_released account_id=...` |
|
||||
| 外部 API 开始 | `external_call_start system=... operation=...` |
|
||||
| 外部 API 结束 | `external_call_done system=... operation=... elapsed_ms=... status=...` |
|
||||
| 兄弟技能调用开始 / 结束 | 同上,或 `sibling_call_start/done skill_slug=...` |
|
||||
| RPA 操作开始 / 结束 | `rpa_start` / `rpa_done` + `video.add_step` 中文步骤 |
|
||||
| 打开页面 | step:「打开 xxx 页面」 |
|
||||
| 检查登录 | step + log |
|
||||
| 定位输入框 / 点击提交 | step + log |
|
||||
| 等待结果 | step + log(含 timeout 预期) |
|
||||
| 批量循环 | `batch_progress current=... total=...` |
|
||||
| 重试 | `retry_attempt n=... reason=...`(`warning`) |
|
||||
| 跳过 / 部分失败 | `skipped` / `partial_failure`(`warning`) |
|
||||
| 人工介入 | `human_intervention type=captcha|login|ukey|otp`(`warning`) |
|
||||
| 失败截图 / 视频 | `artifact_saved artifact_path=...` / `video_path=...` |
|
||||
| 任务成功 | `task_success ...` + `task_log_saved status=success` |
|
||||
| 任务失败 | `task_failed ...`(`exception` 保留 traceback)+ `task_log_saved status=failed` |
|
||||
### 3.1 长任务通用最低集(所有业务 skill)
|
||||
|
||||
| 事件名 | 级别 | 说明 |
|
||||
|--------|------|------|
|
||||
| `cli_start` | info | CLI 入口(模板已提供) |
|
||||
| `task_start` | info | 主任务开始(含 `task_type=`) |
|
||||
| `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 |
|
||||
| `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 |
|
||||
| `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` |
|
||||
| `task_log_saved` | info | 写入 `task_logs` 后 |
|
||||
|
||||
### 3.2 RPA / 桌面 / 浏览器额外最低集
|
||||
|
||||
当 `scripts/service/` 使用 `RpaVideoSession` / `uiautomation` / `playwright` / `@rpa_step` 时,额外要求:
|
||||
|
||||
| 事件名 | 说明 |
|
||||
|--------|------|
|
||||
| `rpa_start` / `rpa_done` 或 `rpa_failed` | RPA 段起止(可与 `task_*` 同条合并,但事件名须可 grep) |
|
||||
| `stage=` | 至少 3 个不同阶段里程碑(如 `open_window` / `locate_control` / `submit` / `wait_result`) |
|
||||
| `risk_blocked` | 若有配额/工作时段/熔断等前置拦截 |
|
||||
|
||||
**不要**要求每个 Click 一行日志;要求的是**阶段里程碑 + 全部失败出口**。
|
||||
|
||||
### 3.3 其它常见节点(按业务选用)
|
||||
|
||||
| 阶段 | 建议事件 |
|
||||
|------|----------|
|
||||
| adapter 选择 | `adapter_selected adapter=...` |
|
||||
| 账号 / lease | `account_acquired` / `account_released` |
|
||||
| 外部 API | `external_call_start` / `external_call_done`(含 `elapsed_ms=` / `status=`) |
|
||||
| 兄弟技能 | `sibling_call_start` / `sibling_call_done` |
|
||||
| 重试 / 跳过 / 人工 | `retry_attempt` / `skipped` / `human_intervention` |
|
||||
| 存证 | `artifact_saved` / `video_path=` |
|
||||
|
||||
### 3.4 最低可验收日志串(示例)
|
||||
|
||||
mock 或真实跑完一条主路径后,`jiangchang.log`(或宿主「运行日志」)应能 grep 到类似:
|
||||
|
||||
```text
|
||||
cli_start argv=...
|
||||
task_start task_type=add-one target_id=...
|
||||
entitlement_ok task_type=add-one
|
||||
rpa_start stage=open_window
|
||||
rpa_start stage=locate_control
|
||||
rpa_start stage=submit
|
||||
task_failed error_code=ELEMENT_NOT_FOUND ...
|
||||
task_log_saved status=failed
|
||||
```
|
||||
|
||||
成功路径则出现 `task_success` 而非 `task_failed`。若只有 `cli_start` → **不合格**。
|
||||
|
||||
---
|
||||
|
||||
## 3.5 必打节点与 Activity 对照
|
||||
|
||||
以下节点除文件日志外,长任务仍应 `emit` / `video.add_step`(用户可见),但**不能只 emit 不打文件日志**。
|
||||
|
||||
| 阶段 | 文件日志 | Activity |
|
||||
|------|----------|----------|
|
||||
| 任务开始 | `task_start` | `emit("开始…")` |
|
||||
| RPA 步骤 | `stage=...` | `add_step` / `@rpa_step` |
|
||||
| 批量进度 | `item_start` / `batch_progress` | `emit(..., progress=)` |
|
||||
| 失败 | `task_failed error_code=` | `finish(failed\|partial)` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
| 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-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-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` |
|
||||
@@ -28,6 +30,9 @@
|
||||
| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception`,并写入 `save_task_log` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` |
|
||||
| POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.md;development/RPA.md | hard | 扫描 `scripts/service/task_service.py` 含 `emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` |
|
||||
| POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` |
|
||||
| POLICY-LOGGING-006 | `scripts/service/**/*.py` 业务域文件须有 `log/logger/_logger.(info|warning|error|exception)`;豁免薄工具:`__init__.py`、`task_run_support.py`、`entitlement_service.py`、`sibling_bridge.py` | development/LOGGING.md §0、§3 | hard | 扫描 service 树 | `tests/test_development_policy_guard.py::TestPolicyLogging006` |
|
||||
| POLICY-LOGGING-007 | 若 service 层含 RPA 标记(`RpaVideoSession` / `uiautomation` / `playwright` / `rpa_step`),则须出现事件词 `task_start`、`task_failed` 或 `rpa_failed`、以及 `stage=` | development/LOGGING.md §3.1–§3.2 | hard | 合并扫描 `scripts/service/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyLogging007` |
|
||||
| POLICY-LOGGING-008 | `scripts/service/**/*.py` 中若 `print` 含 `ERROR:`,同一函数体内须有 `log/logger/_logger.(warning|error|exception)` | development/LOGGING.md §0 铁律 3 | hard | 按 `def` 切分共现检查 | `tests/test_development_policy_guard.py::TestPolicyLogging008` |
|
||||
| POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5;development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` |
|
||||
| POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(` 与 `finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` |
|
||||
| POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`(RPA 等待须 `interruptible_sleep`) | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` |
|
||||
@@ -56,8 +61,12 @@
|
||||
| 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` 守护,属脚手架场景 |
|
||||
| 共享 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 | 需业务实现后人工补测 |
|
||||
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
|
||||
| platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |
|
||||
| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 |
|
||||
| Data Policy 未启用(L0)时宿主不得拉 Snapshot / 不得改按钮与行列行为 | references/POLICY.md | soft;宿主实现契约 |
|
||||
| Data Policy 启用后 fail-closed(禁止回退 L0 全开);写路径 domain 必须再鉴权 | references/POLICY.md §6–§7 | soft;`POLICY-SKILL-AUTHZ-*` 硬门禁可后续加 |
|
||||
| `actions.mode` 仅 allowlist/open;`hostCapabilities` 省略=不限制;scope `valueFrom` 仅 `actor.userId` | references/POLICY.md §0.1 | soft;Schema 已约束 |
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
| 步骤 | 做什么 | 详见 |
|
||||
|------|--------|------|
|
||||
| 1 | **拿到仓库**:① 项目经理在 [自建 Gitea](https://git.jc2009.com/)(`git.jc2009.com`,非 Gitea 官网)开仓并授权后 `git clone`;或 ② 本地已有 `skill-template` 时用 scaffold / 复制出新目录(遵守 Git 红线) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §1、§4 |
|
||||
| 2 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) |
|
||||
| 3 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 |
|
||||
| 4 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 |
|
||||
| 5 | 本地 `python tests/run_tests.py -v` 通过后执行 `release.ps1`;看 Gitea CI | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15 |
|
||||
| 6 | 匠厂安装后,按 `actions.json` **已声明的全部入口**自测(新建任务、数据管理含 toolbar/row/batch、定时任务、任务中心、技能详情等) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15;[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) |
|
||||
| 2 | **先读 AI + RPA 总纲**;界面技能先完成元素拾取/element map,再按浏览器、桌面或手机读分端手册 | [`AI_RPA.md`](AI_RPA.md)、[`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) |
|
||||
| 3 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) |
|
||||
| 4 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 |
|
||||
| 5 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 |
|
||||
| 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。
|
||||
|
||||
@@ -23,18 +25,23 @@
|
||||
|
||||
开始定制 skill 前,建议按以下顺序阅读:
|
||||
|
||||
1. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展)
|
||||
2. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
|
||||
3. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
|
||||
4. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
|
||||
5. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
|
||||
6. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
|
||||
7. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
|
||||
8. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
|
||||
9. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
|
||||
10. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
|
||||
11. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
|
||||
12. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
|
||||
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)。
|
||||
用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md),不要写进本目录。
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- 一个 skill 对应一份主需求文档
|
||||
- 需求先写清楚,再进入开发
|
||||
- 涉及外部系统时先读 `AI_RPA.md`,明确 Agent / RPA 边界、授权、风险与人工介入
|
||||
- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节
|
||||
- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md`、`references/`、`development/` 下的技术规范
|
||||
|
||||
@@ -30,6 +31,44 @@
|
||||
- **登记 ≠ 登录**: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. 文档目标
|
||||
|
||||
说明这份需求文档的作用,以及它解决什么问题。
|
||||
@@ -109,6 +148,8 @@
|
||||
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
|
||||
- Windows 环境下需保证 UTF-8 输出兼容
|
||||
- 必须具备基本日志能力;敏感字段脱敏
|
||||
- 只做授权范围内的正常界面自动化;禁止破解、逆向、未授权私有协议、安全验证绕过、进程注入、凭据窃取、自动换号和无限重试
|
||||
- RPA 的页面加载与成功判断必须由真实状态驱动;正常业务动作之间默认随机等待 `1~3` 秒
|
||||
- RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args`
|
||||
- 网页 RPA:启动前必须经 ensure-web 拿到 `profile_dir`;生产有头;站点登录策略见 §0(required / optional / not_needed);`required` 时每轮须登录门禁
|
||||
|
||||
@@ -164,21 +205,27 @@
|
||||
|
||||
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
|
||||
- `health`、`version`、`init-db` 命令执行正常
|
||||
- 主命令(如 `run`)在 mock / simulator 档位可重复验证
|
||||
- `python tests/run_tests.py -v` 必跑测试全部通过
|
||||
- `python tests/run_tests.py -v` 必跑测试全部通过(mock / 离线门禁;**mock 通 ≠ 完成**)
|
||||
- 主命令在业务默认档(模板为 `real_rpa`)真实跑通;不得只 mock 通就交活
|
||||
- `task_logs` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
|
||||
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
|
||||
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
|
||||
- `tests/test_display_metadata.py` 通过
|
||||
- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联
|
||||
- 真实联调样例仍放 `tests/integration/`,默认套件不包含真实外联(与业务 `.env` 默认 `real_rpa` 不冲突)
|
||||
- `SKILL.md` 的 `developer_ids` 已配置为开发者本人匠厂用户 ID(开发期不公开时,否则本人无法在市场安装自测;取 ID 步骤见 `development/DEVELOPMENT.md` §6)
|
||||
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
|
||||
- 安装后按本技能 `actions.json` 声明完成宿主多入口验收:至少覆盖已声明的「新建任务(Agent)」;若声明了 `toolbar` / `cron` / `async`,还须分别验收数据管理、定时任务、任务中心(见 `development/DEVELOPMENT.md` §15 第七步)
|
||||
- 安装后按本技能 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
|
||||
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或其他规避平台限制的实现
|
||||
- RPA 真实主路径已验证状态等待、`1~3` 秒正常操作节奏、有限重试、账号 / 设备串行、人工介入、结果核验和结束存证
|
||||
- RPA 关键元素已在真实界面拾取并登记 element map;关键动作唯一定位,重启会话后复测通过
|
||||
- 页面/窗口/设备结构变化时停止并保存截图与 DOM/UIA/XML,不运行时猜 selector
|
||||
|
||||
## 10. 开发注意事项
|
||||
|
||||
- 只修改当前 skill 仓库,不要改动无关兄弟项目
|
||||
- 正常界面或获得授权的官方 API 无法完成闭环时,停止并上报;不得擅自改走破解、逆向或绕过安全机制的路线
|
||||
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
|
||||
- 涉及界面时再按端阅读 `RPA_ELEMENT_LOCATING.md` 和 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`
|
||||
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`)
|
||||
- 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)
|
||||
|
||||
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill,都应先读这份文档,按这里的选型和范式落地,不要每个项目重新踩坑。
|
||||
|
||||
网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
|
||||
先读 [`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)
|
||||
|
||||
---
|
||||
|
||||
## 0. 三端通用范式(先看这个)
|
||||
@@ -16,17 +24,25 @@
|
||||
|------|------|
|
||||
| **可控会话 / Profile** | 网页版见下方 **§0.2**(必须走 account-manager 的 `profile_dir`);桌面/手机用对应持久会话,不硬编码密码 |
|
||||
| **有头运行** | 生产 RPA **必须有头**(见 §0.2);禁止把无头当交付/联调常态 |
|
||||
| **拟人操作** | 真实事件(isTrusted=true),逐字输入、随机延迟、贝塞尔鼠标轨迹;严禁 JS 直接设值/JS 点击/JS 跳转 |
|
||||
| **步骤间随机等待** | 每两步操作之间 `random_delay(min,max)`,区间由 `.env` 配置(默认 1~5s) |
|
||||
| **正常人工节奏** | 真实点击、逐字输入、自然鼠标轨迹、分段滚动;严禁 JS 直接设值/JS 点击/JS 跳转绕过页面流程 |
|
||||
| **步骤间随机等待** | 每两个正常业务动作之间 `random_delay(min,max)`,真实环境默认 **1~3 秒**;状态等待另算,不得用固定睡眠代替 |
|
||||
| **人工兜底(HITL)** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
|
||||
| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` |
|
||||
| **选择器纪律** | 语义选择器优先(id/name/text/aria);**F12 确认后再写,严禁凭记忆猜 DOM** |
|
||||
| **选择器纪律** | 先真实拾取并填写 element map;语义/可访问性属性优先;关键动作唯一匹配;**严禁凭记忆猜 selector 或用 first/index 掩盖歧义** |
|
||||
| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 |
|
||||
| **步骤可控制** | 每条 RPA 指令 = 一个 `@rpa_step` 或 `video.add_step`;等待用 `interruptible_sleep`;宿主通过 control.json 暂停/继续/停止(SRCP,见 [`LOGGING.md`](LOGGING.md) §2.5) |
|
||||
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
|
||||
|
||||
> 三端各自实现一个会话抽象 `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)
|
||||
|
||||
- **禁止** `scripts/util/progress.py` 或 `print({"type":"progress",...})`;进度只走 `emit` / Run Journal。
|
||||
@@ -60,6 +76,9 @@ async def open_login(page):
|
||||
| **登记 ≠ 登录** | `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` 释放。 |
|
||||
|
||||
**反模式(禁止):**
|
||||
|
||||
@@ -72,12 +91,50 @@ async def open_login(page):
|
||||
|
||||
**参考实现:** `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. 浏览器(标准已成熟)
|
||||
|
||||
**选型:Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts`、`receive-order` 已落地)。
|
||||
|
||||
元素拾取、定位优先级、iframe/弹窗/Shadow DOM、动态列表和唯一性标准见 [`RPA_BROWSER.md`](RPA_BROWSER.md)。本节继续定义会话、启动、HITL 和工程边界。
|
||||
|
||||
### 1.0 生产路径(优先)
|
||||
|
||||
| 项 | 标准 |
|
||||
@@ -87,12 +144,12 @@ async def open_login(page):
|
||||
| CDP | **仅作诊断 / 桌面宿主类场景**;**不要**作为强风控站点的默认生产路径 |
|
||||
| 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 |
|
||||
| 模式 | **有头** `OPENCLAW_BROWSER_HEADLESS=0`(生产强制,见 §0.2) |
|
||||
| 反检测 | stealth 默认开 `OPENCLAW_PLAYWRIGHT_STEALTH=1`(见 1.1) |
|
||||
| 浏览器兼容配置 | `OPENCLAW_PLAYWRIGHT_STEALTH` 仅用于经评估的兼容配置;不得用于伪造身份或绕过平台限制 |
|
||||
|
||||
### 1.1 Playwright 启动标准
|
||||
|
||||
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 提供。
|
||||
4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。
|
||||
5. **不要默认传 `--disable-blink-features=AutomationControlled`**;platform-kit stealth 已覆盖,额外 flag 可能适得其反。
|
||||
@@ -100,7 +157,7 @@ async def open_login(page):
|
||||
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**)。业务技能不得自行扩展指纹伪造、代理轮换或规避安全检测的代码;遇到明确风控信号时转人工或停止。
|
||||
|
||||
**拟人操作**(必做):
|
||||
|
||||
@@ -108,7 +165,7 @@ async def open_login(page):
|
||||
- 鼠标:贝塞尔曲线轨迹 + 微抖动;进场随机晃动。
|
||||
- 导航:用真实点击触发,**不要 `window.location.href=` / JS 点击跳转**。
|
||||
- 翻页:真实点击翻页控件,注意排除禁用态。
|
||||
- 延迟:每步之间 `random_delay`(`.env` 中 `STEP_DELAY_MIN/MAX`)。
|
||||
- 延迟:两个正常业务动作之间 `random_delay`(`.env` 中 `STEP_DELAY_MIN/MAX`,默认 1~3 秒);页面状态仍须显式等待。
|
||||
|
||||
### 1.2 页面启动标准
|
||||
|
||||
@@ -226,13 +283,14 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
|
||||
|
||||
## 2. 桌面软件(Windows 原生程序)
|
||||
|
||||
**选型:pywinauto(UIA backend)为主 + 图像识别兜底。**
|
||||
**选型:Windows UI Automation(UIA)为主 + 图像识别兜底。** 当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。完整拾取、定位和降级标准见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md)。
|
||||
|
||||
桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。
|
||||
|
||||
| 技术 | 优先级 | 适用 / 说明 |
|
||||
|------|--------|------|
|
||||
| **pywinauto(`backend="uia"`)** | ✅ 首选 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python |
|
||||
| **uiautomation** | ✅ 已验证首选 | `add-friend-wecom` 真实成功案例使用;直接读取 UIA Window/Control 树 |
|
||||
| **pywinauto(`backend="uia"`)** | ✅ 兼容 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python |
|
||||
| **FlaUI**(经 pythonnet 调 .NET) | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 |
|
||||
| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 |
|
||||
| **PyAutoGUI / SikuliX(图像识别)** | ⚠️ 兜底 | 控件树完全拿不到时(Flash/远程桌面/纯自绘 UI);**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 |
|
||||
@@ -243,13 +301,15 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
|
||||
- **DPI/缩放**:图像识别方案必须固定显示缩放比例;UIA 方案不受影响(优先用 UIA 即是为此)。
|
||||
- **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`。
|
||||
- **人工兜底**:U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`。
|
||||
- **禁止侵入**:不注入或 Hook 第三方进程,不修改客户端文件,不反编译或调用未授权私有协议。
|
||||
|
||||
```bash
|
||||
pip install pywinauto # UIA 自动化
|
||||
pip install uiautomation # 当前真实成功案例
|
||||
# 或:pip install pywinauto # UIA backend
|
||||
# 图像兜底:pip install pyautogui opencv-python
|
||||
```
|
||||
|
||||
> 状态:桌面端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
|
||||
> 状态:UIA 技术路线已经 `add-friend-wecom` 真实项目验证。脱敏、收紧后的参考 example 见 `examples/real_desktop_rpa/`;目标软件 selector 仍必须重新拾取。
|
||||
|
||||
---
|
||||
|
||||
@@ -257,6 +317,8 @@ pip install pywinauto # UIA 自动化
|
||||
|
||||
**选型:Android 用 uiautomator2(或 Appium);iOS 用 Appium + WebDriverAgent(需 Mac)。** 底层都是经 USB 的 ADB / WDA。
|
||||
|
||||
设备准备、Appium Inspector/UIAutoDev 拾取、Native/WebView、定位优先级和失败 XML 留证见 [`RPA_MOBILE.md`](RPA_MOBILE.md)。
|
||||
|
||||
| 平台 | 技术 | 优先级 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| **Android** | **uiautomator2**(python 原生) | ✅ 首选 | ADB over USB,直接拿控件树点击/输入,比 Appium 轻快,纯 Python |
|
||||
@@ -271,6 +333,7 @@ pip install pywinauto # UIA 自动化
|
||||
- **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。
|
||||
- **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。
|
||||
- **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`。
|
||||
- **设备边界**:使用正常授权设备与官方 App;不 Root / 越狱,不 Hook,不修改 APK / IPA,不伪造设备身份或绕过签名、绑定与安全校验。
|
||||
|
||||
```bash
|
||||
# Android(首选)
|
||||
@@ -279,7 +342,7 @@ python -m uiautomator2 init # 初始化设备端 agent
|
||||
# 或统一走 Appium:pip install Appium-Python-Client(另需 Appium Server)
|
||||
```
|
||||
|
||||
> 状态:手机端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
|
||||
> 状态:手机端标准已定义,但**尚无团队真实成功案例**。`examples/mobile_rpa/` 仅是 example 完成规格,不能作为已跑通代码照抄;收到成功案例后再升级状态。
|
||||
|
||||
---
|
||||
|
||||
@@ -353,6 +416,10 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `RPA_ELEMENT_LOCATING.md` — 三端共用的拾取、element map、唯一性、降级和 AI 编程约束
|
||||
- `RPA_BROWSER.md` — 浏览器 Pick Locator、DOM 作用域和定位质量
|
||||
- `RPA_DESKTOP.md` — Windows UIA 拾取与已验证桌面路线
|
||||
- `RPA_MOBILE.md` — Android/iOS 拾取、context、设备与 XML 留证
|
||||
- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
|
||||
- `CONFIG.md` — `.env` 配置规范与首次落盘机制
|
||||
- `RUNTIME.md` — 运行时目录与环境变量约定
|
||||
|
||||
123
development/RPA_BROWSER.md
Normal file
123
development/RPA_BROWSER.md
Normal 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
189
development/RPA_DESKTOP.md
Normal 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`;不可逆动作必须由业务技能显式实现确认策略。
|
||||
178
development/RPA_ELEMENT_LOCATING.md
Normal file
178
development/RPA_ELEMENT_LOCATING.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 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。
|
||||
|
||||
开发期推荐使用独立仓 [jiangchang-ui-recorder](https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder)(本机常见路径 `D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`,`python run.py`):以 **Raw Event Journal → Action → Element Catalog → 异步 Observation** 录制,优先把 `action_flow.json` 与 `element_catalog.json` 交给 AI 编程工具;原始事件可重新归并,tree/index 作为诊断证据。默认不再让完整页面 dump 阻塞动作采集。本模板仓内的 `tools/ui_inspector` 已移除,勿再从 skill-template 启动。
|
||||
|
||||
## 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
141
development/RPA_MOBILE.md
Normal 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)。在团队提供真实成功案例前,该目录只能作为实施清单和验收契约,不能作为“已跑通代码”宣传或直接复制。
|
||||
@@ -104,7 +104,7 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均
|
||||
{...}/{skill_slug}.db
|
||||
```
|
||||
|
||||
**业务文件(下载、导入、导出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。
|
||||
**业务文件(输入、输出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。用户可见目录为 `input/`(上行)与 `output/`(下行)。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。
|
||||
|
||||
- 业务表使用英文 snake_case;中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。
|
||||
- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。
|
||||
|
||||
@@ -56,9 +56,11 @@ git clone https://git.jc2009.com/client-commons/skill-template.git
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| **Clone 为了什么** | 读 CLI / `references/INTEGRATION.md`、平台表、租约与错误码;排 `NO_ACCOUNT` / `LEASE_CONFLICT` 等 |
|
||||
| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir`(`ensure-web` / `pick-web`) |
|
||||
| **为什么必须用** | 支撑真实业务的一账号一 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」,**以本模板约束为准** |
|
||||
| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`;本模板 [`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) |
|
||||
| **创建后查看** | 宿主数据管理;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
|
||||
@@ -104,6 +106,7 @@ git clone https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit.git
|
||||
|------|----------|
|
||||
| 不知道新技能目录/发布/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/` |
|
||||
| 「公共东西到底在哪」 | **本页登记表** |
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。
|
||||
2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。
|
||||
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。
|
||||
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格语义标色;省略 = P0。
|
||||
4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。
|
||||
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 流**。
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
| 值 | 宿主行为 | 超时 | 任务中心 |
|
||||
|----|----------|------|----------|
|
||||
| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **30s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
|
||||
| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
|
||||
| **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 |
|
||||
|
||||
直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。
|
||||
@@ -48,7 +48,7 @@
|
||||
| 场景 | 建议 |
|
||||
|------|------|
|
||||
| 浏览器 / Playwright / RPA 主路径 | `async` + 建议 `confirmation` |
|
||||
| 单次可能超过 ~20s 的网络/下载/同步 | `async` |
|
||||
| 单次可能接近或超过约 60s 的网络/下载/同步 | `async` |
|
||||
| `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` |
|
||||
| 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 |
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|-------------------------|-------------------|--------|--------------|
|
||||
| **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 |
|
||||
| **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 |
|
||||
| **数据管理 · 行内** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 同行操作链接(需主键) |
|
||||
| **数据管理 · 行级** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 操作列和/或**行右键**(及可选「⋯」)可见并触发(需主键);技能配什么就显示什么 |
|
||||
| **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 |
|
||||
| **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 |
|
||||
| **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement |
|
||||
@@ -141,7 +141,7 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context
|
||||
| 能力 | 约定 |
|
||||
|------|------|
|
||||
| 队列表 | `status`: `pending` → `processing` → `success` / `failed` |
|
||||
| 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/imports/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) |
|
||||
| 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/input/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) |
|
||||
| 消费 | `run --pick N`,默认仅 `pending` |
|
||||
| 重试 | `reset` 或数据管理改 `pending`;**failed 不要自动重试** |
|
||||
| 批次 | 可选 `batch_id`,便于筛选与任务中心对照 |
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_whatever():
|
||||
|
||||
未设置环境变量 ⇒ 等价 `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`)。
|
||||
|
||||
@@ -201,8 +201,11 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
|
||||
- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`、写入 `save_task_log`(`POLICY-LOGGING-002` / `003`)
|
||||
- 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`)
|
||||
- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`)
|
||||
- **域模块**(`scripts/service/**`,豁免薄工具)是否有文件日志调用(`POLICY-LOGGING-006`)
|
||||
- RPA 技能是否具备事件词 `task_start` / `task_failed|rpa_failed` / `stage=` / 鉴权事件(`POLICY-LOGGING-007`)
|
||||
- `print(...ERROR:)` 是否与同函数内 `log.warning|error|exception` 共现(`POLICY-LOGGING-008`)
|
||||
|
||||
**局限**:自动检测只覆盖可静态判断的**底线**,**不**判断业务日志是否足够完整。复制新 skill 后,仍须人工对照 [`LOGGING.md`](LOGGING.md) §必打日志节点清单做 code review。
|
||||
**局限**:hard 保证事件词与「失败先落盘」底线,**不**判断每个控件一步一日志。复制新 skill 后仍建议对照 [`LOGGING.md`](LOGGING.md) §3 事件词表,并按需启用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample)。
|
||||
|
||||
新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。
|
||||
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
|
||||
**`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/` 复制。
|
||||
|
||||
## 四类外部交互方式
|
||||
## 外部交互示例矩阵
|
||||
|
||||
一级目录固定为四种模式(不可删除、不可移动):
|
||||
adapter 仍是 `真实/仿真 × API/RPA` 四档;RPA 再按浏览器、Windows 桌面和手机三种界面拆分。原四类目录不可删除、不可移动,桌面和手机目录用于补足界面维度。
|
||||
|
||||
| 目录 | 类型 | 状态 | 适用场景 |
|
||||
|---|---|---|---|
|
||||
| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 |
|
||||
| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 |
|
||||
| `real_desktop_rpa` | 真实 Windows 桌面 RPA | **已有成功来源并已提炼** | UIA 拾取、窗口/控件、probe、dry-run/assisted |
|
||||
| `mobile_rpa` | Android/iOS RPA | **仅规格,待成功案例** | 设备、控件树、Native/WebView、example 完成契约 |
|
||||
| `real_api` | 真实 API | **占位** | 真实系统接口、token、权限、限流(尚未沉淀可复制实现) |
|
||||
| `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器(尚未沉淀可复制实现) |
|
||||
|
||||
@@ -42,6 +46,8 @@
|
||||
|---|---|
|
||||
| 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** |
|
||||
| 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** |
|
||||
| Windows 原生软件 + UIA | **real_desktop_rpa** |
|
||||
| Android/iOS App | **mobile_rpa**(当前仅规格,不可作为成功实现参考) |
|
||||
| 真实系统 API | **real_api**(当前占位,不可作为实现参考) |
|
||||
| mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) |
|
||||
|
||||
@@ -58,7 +64,8 @@ examples/<mode>/
|
||||
tests/
|
||||
```
|
||||
|
||||
- 仅 `simulator_browser_rpa` 可额外包含 `sandbox/`
|
||||
- `simulator_browser_rpa` 可额外包含 `sandbox/`
|
||||
- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture
|
||||
- 仅未来确实需要样例数据时可加 `fixtures/`
|
||||
- **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件
|
||||
|
||||
@@ -74,6 +81,8 @@ examples/<mode>/
|
||||
|
||||
- 真实浏览器 RPA → 参考 `examples/real_browser_rpa/`
|
||||
- 仿真浏览器 RPA → 参考 `examples/simulator_browser_rpa/`(adapter 分层权威参考:`scripts/service/adapter/`)
|
||||
- Windows 桌面 RPA → 参考 `examples/real_desktop_rpa/`(真实成功来源:`add-friend-wecom`)
|
||||
- 手机 RPA → 先按 `examples/mobile_rpa/` 定义完成标准;真实成功案例补齐前不得照骨架宣称跑通
|
||||
- `real_api/`、`simulator_api/` 当前为规划占位,**不得**作为实现参考
|
||||
|
||||
> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。
|
||||
|
||||
94
examples/mobile_rpa/README.md
Normal file
94
examples/mobile_rpa/README.md
Normal 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. 用户人工验收通过。
|
||||
@@ -7,6 +7,7 @@
|
||||
- 本示例是通用模板,不绑定具体平台。
|
||||
- 示例抽象自已跑通的真实浏览器 RPA skill(抖音视频链接采集),但代码已改成中性示例命名。
|
||||
- 新 skill 开发时可以复制本目录,再替换目标站点、选择器、解析逻辑和业务字段。
|
||||
- 只用于授权范围内的正常界面操作;禁止破解、逆向、私有协议、验证码绕过、自动换号或其他规避平台限制的扩展。
|
||||
|
||||
## 适用场景
|
||||
|
||||
@@ -32,6 +33,8 @@ real_browser_rpa/
|
||||
│ └── util/
|
||||
│ ├── constants.py # 平台常量、selector 占位
|
||||
│ └── logging.py # 结构化日志与脱敏
|
||||
├── assets/
|
||||
│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写
|
||||
└── tests/
|
||||
├── test_human_verification.py # 验证码误判防护
|
||||
└── test_stop_reason.py # 停止原因文案
|
||||
@@ -54,12 +57,13 @@ real_browser_rpa/
|
||||
## 开发新 skill 时如何复制
|
||||
|
||||
1. **先阅读**本 README 及下方「Copy map」「复制边界」章节
|
||||
2. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
|
||||
3. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
|
||||
4. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
|
||||
5. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
|
||||
6. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
|
||||
7. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
|
||||
2. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map
|
||||
3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
|
||||
4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
|
||||
5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
|
||||
6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
|
||||
7. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
|
||||
8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
|
||||
|
||||
## Copy map(复制到新技能 `scripts/`)
|
||||
|
||||
@@ -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/util/constants.py` | `scripts/util/constants.py` | 平台常量与 selector(须替换) |
|
||||
| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 |
|
||||
| `examples/real_browser_rpa/assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实 Pick Locator/F12 拾取后填写 |
|
||||
| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 |
|
||||
| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
|
||||
|
||||
@@ -120,6 +125,8 @@ real_browser_rpa/
|
||||
- 不要照抄平台名、slug、logger 名
|
||||
- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖
|
||||
- 不要把示例里的 result selector 当成真实站点 selector
|
||||
- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器
|
||||
- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性
|
||||
- 不要把示例代码原样发布为业务 skill
|
||||
- 不要把真实账号、手机号、cookie、token 写入代码或 README
|
||||
|
||||
@@ -142,6 +149,7 @@ real_browser_rpa/
|
||||
|
||||
- **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用
|
||||
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
|
||||
- **不对抗平台风控** — 频控、账号风险、封禁或权限不足时停止并交由人工处理
|
||||
- **日志脱敏** — 不输出完整手机号/账号
|
||||
- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放
|
||||
- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位)
|
||||
|
||||
45
examples/real_browser_rpa/assets/element-map.sample.yaml
Normal file
45
examples/real_browser_rpa/assets/element-map.sample.yaml
Normal 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: 待填写
|
||||
@@ -159,6 +159,8 @@ def _normalize_error_code(raw: str) -> str:
|
||||
|
||||
|
||||
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:
|
||||
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||
code = _normalize_error_code(str(err.get("code") or ""))
|
||||
@@ -174,10 +176,10 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
if not payload.get("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
|
||||
|
||||
|
||||
@@ -226,7 +228,44 @@ def _ensure_web_with_lease(
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
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 ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"PICK_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "pick-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
proc = _run_argv(["account", "get", str(account_id)])
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 or out.startswith("ERROR:"):
|
||||
@@ -239,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
else:
|
||||
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()
|
||||
if platform_key != platform.lower():
|
||||
raise AccountManagerError(
|
||||
@@ -248,9 +289,26 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
profile_dir = str(data.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
|
||||
data["profile_dir"] = profile_dir
|
||||
data["lease_token"] = ""
|
||||
return data
|
||||
if str(data.get("status") or "active").lower() != "active":
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
|
||||
|
||||
try:
|
||||
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(
|
||||
@@ -271,7 +329,7 @@ def pick_web_account(
|
||||
login_id = aid_raw
|
||||
aid_raw = ""
|
||||
else:
|
||||
return _pick_by_id(platform_key, int(aid_raw))
|
||||
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):
|
||||
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
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
|
||||
@@ -101,9 +102,9 @@ def _step_cb(cb: Optional[StepCallback], text: str) -> None:
|
||||
|
||||
|
||||
async def _random_delay() -> None:
|
||||
lo = int(os.getenv("RPA_STEP_DELAY_MIN_MS") or "900")
|
||||
hi = int(os.getenv("RPA_STEP_DELAY_MAX_MS") or "2600")
|
||||
await interruptible_sleep(random.uniform(lo / 1000.0, hi / 1000.0))
|
||||
lo = max(0.0, config.get_float("STEP_DELAY_MIN", 1.0))
|
||||
hi = max(lo, config.get_float("STEP_DELAY_MAX", 3.0))
|
||||
await interruptible_sleep(random.uniform(lo, hi))
|
||||
|
||||
|
||||
async def _scroll_wait() -> None:
|
||||
@@ -187,42 +188,22 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
|
||||
async def _find_search_input(page) -> Tuple[Optional[Any], str]:
|
||||
for sel in SEARCH_INPUT_SELECTORS:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
if await _visible(loc):
|
||||
loc = page.locator(sel)
|
||||
count = await loc.count()
|
||||
if count == 1 and await _visible(loc):
|
||||
return loc, sel
|
||||
if count > 1:
|
||||
logger.warning("search_input_ambiguous selector=%s count=%s", sel, count)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
candidates: List[Tuple[int, Any, str]] = []
|
||||
try:
|
||||
inputs = await page.locator("input").all()
|
||||
except Exception:
|
||||
inputs = []
|
||||
for inp in inputs:
|
||||
try:
|
||||
if not await inp.is_visible():
|
||||
continue
|
||||
box = await inp.bounding_box()
|
||||
if not box or box.get("width", 0) < 120:
|
||||
continue
|
||||
placeholder = await inp.get_attribute("placeholder") or ""
|
||||
score = box.get("width", 0)
|
||||
if "搜索" in placeholder:
|
||||
score += 500
|
||||
if box.get("y", 9999) < 200:
|
||||
score += 200
|
||||
candidates.append((score, inp, f"input placeholder={placeholder!r}"))
|
||||
except Exception:
|
||||
continue
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
_, inp, sel = candidates[0]
|
||||
return inp, sel
|
||||
|
||||
try:
|
||||
ph = page.get_by_placeholder(re.compile("搜索"))
|
||||
if await ph.count() > 0 and await ph.first.is_visible():
|
||||
return ph.first, "placeholder=搜索"
|
||||
count = await ph.count()
|
||||
if count == 1 and await ph.is_visible():
|
||||
return ph, "placeholder=搜索"
|
||||
if count > 1:
|
||||
logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count)
|
||||
except Exception:
|
||||
pass
|
||||
return None, ""
|
||||
@@ -231,14 +212,24 @@ async def _find_search_input(page) -> Tuple[Optional[Any], str]:
|
||||
async def _find_search_button(page):
|
||||
try:
|
||||
btn = page.get_by_role("button", name="搜索")
|
||||
if await btn.count() > 0 and await btn.first.is_visible():
|
||||
return btn.first
|
||||
count = await btn.count()
|
||||
if count == 1 and await btn.is_visible():
|
||||
return btn
|
||||
if count > 1:
|
||||
logger.warning("search_button_ambiguous selector=role_button_search count=%s", count)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
btn = page.locator(SEARCH_BUTTON_SELECTOR).first
|
||||
if await btn.count() > 0 and await btn.is_visible():
|
||||
btn = page.locator(SEARCH_BUTTON_SELECTOR)
|
||||
count = await btn.count()
|
||||
if count == 1 and await btn.is_visible():
|
||||
return btn
|
||||
if count > 1:
|
||||
logger.warning(
|
||||
"search_button_ambiguous selector=%s count=%s",
|
||||
SEARCH_BUTTON_SELECTOR,
|
||||
count,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
63
examples/real_browser_rpa/tests/test_account_client.py
Normal file
63
examples/real_browser_rpa/tests/test_account_client.py
Normal 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()
|
||||
152
examples/real_desktop_rpa/README.md
Normal file
152
examples/real_desktop_rpa/README.md
Normal 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 清楚提示用户确认
|
||||
- 提交后仅在业务后置状态成立时报告成功
|
||||
117
examples/real_desktop_rpa/assets/element-map.sample.yaml
Normal file
117
examples/real_desktop_rpa/assets/element-map.sample.yaml
Normal 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
|
||||
26
examples/real_desktop_rpa/assets/selector-capture.sample.xml
Normal file
26
examples/real_desktop_rpa/assets/selector-capture.sample.xml
Normal 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>
|
||||
129
examples/real_desktop_rpa/scripts/service/desktop_session.py
Normal file
129
examples/real_desktop_rpa/scripts/service/desktop_session.py
Normal 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)
|
||||
127
examples/real_desktop_rpa/scripts/service/uia_locator.py
Normal file
127
examples/real_desktop_rpa/scripts/service/uia_locator.py
Normal 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))
|
||||
@@ -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": "已填写搜索条件,未发送邀请",
|
||||
}
|
||||
63
examples/real_desktop_rpa/scripts/util/selectors.py
Normal file
63
examples/real_desktop_rpa/scripts/util/selectors.py
Normal 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",
|
||||
)
|
||||
99
examples/real_desktop_rpa/tests/test_selector_contract.py
Normal file
99
examples/real_desktop_rpa/tests/test_selector_contract.py
Normal 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()
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。
|
||||
|
||||
本示例只教学授权范围内的正常界面自动化;禁止把 sandbox 做法扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避真实平台限制的实现。
|
||||
|
||||
## 适合什么场景
|
||||
|
||||
- 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html`
|
||||
@@ -16,6 +18,7 @@
|
||||
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
|
||||
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
|
||||
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
|
||||
- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态
|
||||
|
||||
## 目录结构
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@ def _normalize_error_code(raw: str) -> str:
|
||||
|
||||
|
||||
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:
|
||||
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||
code = _normalize_error_code(str(err.get("code") or ""))
|
||||
@@ -174,10 +176,10 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
if not payload.get("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
|
||||
|
||||
|
||||
@@ -226,7 +228,44 @@ def _ensure_web_with_lease(
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
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 ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"PICK_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "pick-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
proc = _run_argv(["account", "get", str(account_id)])
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 or out.startswith("ERROR:"):
|
||||
@@ -239,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
else:
|
||||
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()
|
||||
if platform_key != platform.lower():
|
||||
raise AccountManagerError(
|
||||
@@ -248,9 +289,26 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
profile_dir = str(data.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
|
||||
data["profile_dir"] = profile_dir
|
||||
data["lease_token"] = ""
|
||||
return data
|
||||
if str(data.get("status") or "active").lower() != "active":
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
|
||||
|
||||
try:
|
||||
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(
|
||||
@@ -271,7 +329,7 @@ def pick_web_account(
|
||||
login_id = aid_raw
|
||||
aid_raw = ""
|
||||
else:
|
||||
return _pick_by_id(platform_key, int(aid_raw))
|
||||
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):
|
||||
|
||||
63
examples/simulator_browser_rpa/tests/test_account_client.py
Normal file
63
examples/simulator_browser_rpa/tests/test_account_client.py
Normal 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()
|
||||
@@ -72,34 +72,46 @@ Action 是**可选能力**:
|
||||
|------|------|------|
|
||||
| `schemaVersion` | 是 | 当前固定为 `1` |
|
||||
| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 |
|
||||
| `actions` | 是 | Action 数组,每项 `id` 唯一 |
|
||||
| `actions` | 是 | Action 数组,每项 `id` 唯一;**数组相对顺序**决定数据管理 toolbar / row / batch 技能按钮的展示顺序(见下节) |
|
||||
|
||||
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
|
||||
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) 与 [`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
|
||||
|
||||
## 五职责正交(必读)
|
||||
## 六职责正交(必读)
|
||||
|
||||
| 字段 / 层 | 只决定什么 | 不决定什么 |
|
||||
|-----------|------------|------------|
|
||||
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async |
|
||||
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 |
|
||||
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除」 | 不强制;无配置时宿主仍直写 SQLite |
|
||||
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
|
||||
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口 |
|
||||
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
|
||||
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
|
||||
| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 |
|
||||
| **`cellPresentation`** | **可选**:某表某行某列的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) |
|
||||
| **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 |
|
||||
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
|
||||
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
|
||||
|
||||
一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。
|
||||
一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。需要按用户/角色藏按钮或滤行时,**不要**按角色复制 Action,应启用顶层 `policy` 并由技能算出 Snapshot(见 [`POLICY.md`](POLICY.md))。
|
||||
|
||||
## Phase 1 当前稳定支持(新技能应只使用这些)
|
||||
|
||||
| 层级 | 稳定字段 / 能力 |
|
||||
|------|----------------|
|
||||
| 顶层 | `schemaVersion`、`skill`、`actions` |
|
||||
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`bind`** |
|
||||
| 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(单元格呈现启用块) |
|
||||
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) |
|
||||
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
|
||||
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron` |
|
||||
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
|
||||
| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` — 接管默认新增/编辑/删除(须 `bind.tables`) |
|
||||
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) |
|
||||
| **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 |
|
||||
| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载单元格呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 |
|
||||
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` |
|
||||
| confirmation | `{ "message": "..." }` |
|
||||
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
|
||||
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
|
||||
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
|
||||
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
|
||||
| **hostEffects** | 可选;`onSuccess` / `onFailure` → `reloadTable` \| `refreshCatalog` \| `clearSelection`(匠厂宿主 ≥2.8.5) |
|
||||
| **steps** | 可选;有序 `{ when, do, table?, fk? }`,`when`=`before`\|`afterSuccess`\|`afterFailure`;新技能优先(≥2.8.5) |
|
||||
|
||||
## `bind.tables`(表级绑定)
|
||||
|
||||
@@ -122,6 +134,203 @@ Action 是**可选能力**:
|
||||
- **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。
|
||||
- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。
|
||||
|
||||
## `replaces`(可选:接管默认新增 / 编辑 / 删除)
|
||||
|
||||
有领域副作用的表(建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "account-add-web",
|
||||
"label": "新增账号",
|
||||
"description": "登记网页账号并初始化 profile 目录。",
|
||||
"replaces": "create",
|
||||
"placements": [],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": { "tables": ["accounts"] },
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["platform_key", "account_label"],
|
||||
"properties": {
|
||||
"platform_key": { "type": "string", "title": "平台" },
|
||||
"account_label": { "type": "string", "title": "账号名称" }
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "account",
|
||||
"args": ["add-web", "--platform", "{{platform_key}}", "--label", "{{account_label}}"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **全可选**:无 `replaces` 时,宿主默认按钮仍直写库,行为与以前一致。
|
||||
- 取值:`create`(新增)/ `update`(编辑)/ `delete`(删除)。
|
||||
- **必须**非空 `bind.tables`;同一技能同一表同一 `replaces` 至多一个 Action。
|
||||
- 与 `placements` 正交:可只接管默认按钮(`placements: []`),不必再挂 toolbar「第二个新增」。
|
||||
- 点默认按钮时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。
|
||||
- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。
|
||||
- 宿主导入仍直写:有 `replaces:create` 的表慎用宿主导入,或另做 import Action。
|
||||
- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。
|
||||
- **列元数据复用(lookup)**:create/update 抽屉可将 `inputSchema` 字段对齐业务列,复用中文名与 lookup 下拉:
|
||||
- 优先让属性名 = 物理列名(如 `platform_key`);
|
||||
- 或 `bind.columnMapping`:`{ "platform": "platform_key" }`;
|
||||
- 或字段上写 `"column": "platform_key"`。
|
||||
- **不要**在 actions.json 里写死会过期的全量平台 `enum`。
|
||||
|
||||
## `interaction`(点击交互方式)
|
||||
|
||||
与 `executionProfile` **正交**:一个 Action 可以是 `async` + `direct`(一点就进任务中心),也可以是 `sync` + `form`(填参后当场返回)。
|
||||
|
||||
| 值 | 数据管理体验 | 典型场景 |
|
||||
|----|--------------|----------|
|
||||
| `direct` | **一点即跑**,无弹窗 | 打开文件位置、无参只读、行内主键已映射 |
|
||||
| `confirm` | 轻量确认弹窗(无参数区;文案优先 `confirmation.message`) | 打开浏览器、有副作用但无可编辑入参 |
|
||||
| `form` | 完整参数表单(现状默认路径) | 导入、查询条件、需用户改写的字段 |
|
||||
| `auto` / 省略 | 宿主推断:有**可编辑**参数 → `form`;仅有 `confirmation` → `confirm`;否则 → `direct` | 旧技能兼容;新技能建议显式写出 |
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "reveal-profile",
|
||||
"label": "打开文件位置",
|
||||
"description": "在资源管理器中打开 Profile 目录。",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "direct",
|
||||
"bind": {
|
||||
"tables": ["accounts"],
|
||||
"inputMapping": { "accountId": "$row.$pk" }
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["accountId"],
|
||||
"properties": {
|
||||
"accountId": { "type": "number", "title": "账号 ID", "readOnly": true }
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "account",
|
||||
"args": ["reveal-profile", "{{accountId}}"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "open-browser",
|
||||
"label": "打开浏览器",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "confirm",
|
||||
"confirmation": { "message": "将用该账号 Profile 打开浏览器,仅供查看。" },
|
||||
"bind": {
|
||||
"tables": ["accounts"],
|
||||
"inputMapping": { "accountId": "$row.$pk" }
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["accountId"],
|
||||
"properties": {
|
||||
"accountId": { "type": "number", "readOnly": true }
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "account",
|
||||
"args": ["open", "{{accountId}}"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可选 `interactionByPlacement`:同一 Action 在不同入口用不同交互(例如 `row: "direct"`、`toolbar: "form"`)。值只能是 `direct`/`confirm`/`form`,不能写 `auto`。
|
||||
|
||||
**边界:**
|
||||
|
||||
- **cron 配置**:仍展示可编辑参数;`confirmation.message` 可作为配置页风险提示(由宿主决定)。
|
||||
- **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。
|
||||
- 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。
|
||||
|
||||
## `hostEffects` / `steps`(执行前后宿主编排,≥2.8.5)
|
||||
|
||||
与 `placements` / `executionProfile` / `interaction` **正交**。决定的是:数据管理里点完 Action 之后(或之前),宿主要不要**刷新当前表、刷新目录、清空多选、级联删子表**等。
|
||||
|
||||
- **省略 / 空** ⇒ 宿主**不**额外改 UI(≥2.8.5 起不再对 mutation 硬编码刷新)。本地库若已由技能写新,界面仍可能旧,直到用户点内置「刷新」或本 Action 声明了 `reloadTable`。
|
||||
- **仅数据管理交互路径生效**(toolbar / row / batch / skill-detail / `replaces`);**cron 与 agent 忽略**(与 `interaction` 相同)。
|
||||
- 需匠厂宿主 **≥ 2.8.5**;更旧宿主会忽略未知字段(若未做严格 Schema 校验)。
|
||||
- **新技能优先写 `steps`**;`hostEffects` 仍可作为「只刷 UI」的简写。两者可并存;同一 UI 效果若两边都声明,宿主可能各跑一次。
|
||||
|
||||
### `hostEffects`(UI 副作用简写)
|
||||
|
||||
```json
|
||||
{
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"],
|
||||
"onFailure": ["clearSelection"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 触发时机 |
|
||||
|------|----------|
|
||||
| `onSuccess` | **sync** 退出码 0;或 **async** 任务已被任务中心**接收**(不是 Job 跑完) |
|
||||
| `onFailure` | **sync** 失败;或 **async** **启动被拒** |
|
||||
|
||||
闭集 effect id:`reloadTable`(重载当前表 + 常伴随 Data Policy 再拉)、`refreshCatalog`(刷新技能目录)、`clearSelection`(清空多选)。未知 id 丢弃。
|
||||
|
||||
**async 注意**:`sync-data` 一类长同步若写 `onSuccess: ["reloadTable"]`,会在**入队成功时**就刷表,可能仍是旧数据;更适合 sync 的新建/登记,或等 Job 真正完成后再刷(若宿主后续扩展完成回调)。
|
||||
|
||||
### `steps`(有序编排,推荐)
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{ "when": "before", "do": "deleteByFk", "table": "child_rows", "fk": "parent_id" },
|
||||
{ "when": "afterSuccess", "do": "reloadTable" },
|
||||
{ "when": "afterFailure", "do": "clearSelection" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| `when` | 含义 |
|
||||
|--------|------|
|
||||
| `before` | 调用 CLI **之前** |
|
||||
| `afterSuccess` | 同 `hostEffects.onSuccess` |
|
||||
| `afterFailure` | 同 `hostEffects.onFailure` |
|
||||
|
||||
| `do` | 说明 |
|
||||
|------|------|
|
||||
| `reloadTable` / `refreshCatalog` / `clearSelection` | 同 hostEffects |
|
||||
| `deleteByFk` | 按外键批量删子表;须 `table` + `fk`;行按钮会把当前行主键当父键 |
|
||||
| `deleteRows` | 删指定表行;须 `table` |
|
||||
|
||||
规则:单次最多 **8** 步;未知步骤丢弃;`deleteByFk` / `deleteRows` 仅走 Skill Action steps 路径时可删 `metadata_readonly` 投影表(人工 CRUD 仍守只读)。整库只读 / 视图仍禁止写。
|
||||
|
||||
典型:业务表「新建 / 登记 / 更新」成功后加 `{ "when": "afterSuccess", "do": "reloadTable" }`,避免用户傻等旧表格。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "register-one",
|
||||
"label": "登记本行",
|
||||
"description": "更新当前行业务字段。",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["business_records"],
|
||||
"inputMapping": { "recordId": "$row.$pk" }
|
||||
},
|
||||
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "register",
|
||||
"args": ["--id", "{{recordId}}"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例:
|
||||
|
||||
```json
|
||||
@@ -228,12 +437,44 @@ HINT: <next step>
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) |
|
||||
| `row` | 数据管理行内操作(建议 `bind.tables` + `inputMapping`;表需有主键) |
|
||||
| `row` | 数据管理**行级入口**(建议 `bind.tables` + `inputMapping`;表需有主键)。技能声明后,由宿主呈现为操作列链接、**行右键菜单**和/或行内「⋯」等壳;**不**新增 `row-context-menu` 等 placement |
|
||||
| `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) |
|
||||
| `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) |
|
||||
| `agent` | Agent 可直接调用(`run_skill_action`) |
|
||||
| `cron` | 定时任务「技能直调」 |
|
||||
|
||||
### 行级入口 `row`(宿主呈现契约)
|
||||
|
||||
- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;配什么就出现什么。
|
||||
- **宿主决定怎么摆**:可用操作列、行右键、行内「⋯」等呈现**同一批** Action;点击均走同一 `launch`(`placement: "row"`),`interaction` / `executionProfile` 与壳无关。
|
||||
- **顺序**:各壳内技能项顺序 = 下方「数据管理技能按钮顺序」契约(`actions` 数组相对顺序)。
|
||||
- **宿主内置项**:查看 / 编辑 / 删除由宿主提供,**不是**技能 `actions.json` 配置出来的;若出现在行右键,须与技能项分组(例如分隔线后),且不得要求技能再配一份菜单 DSL。
|
||||
- **禁止**:为右键单独发明 placement、菜单注册表或第二套 Action 列表。
|
||||
|
||||
### 数据管理技能按钮顺序(宿主正式契约)
|
||||
|
||||
宿主**没有**单独的 `sortOrder` / `uiOrder` 字段。同一 `placement`(`toolbar` / `row` / `batch`)下,技能按钮的展示顺序 = `actions` 数组中**匹配该项 placement(且通过 `bind.tables` 过滤)的相对先后**。
|
||||
|
||||
规则:
|
||||
|
||||
1. 想调整顺序 → **调整 `actions` 数组先后**(不必改 `id` / `label`)。
|
||||
2. 无关 placement 的 Action(如仅 `skill-detail` 的 `health`)夹在中间**不影响** toolbar/row 的相对顺序;宿主按匹配子集保序渲染。
|
||||
3. 同一 Action 可同时挂多个 placement;它在各入口的位置,分别是该入口匹配子集里的相对位置。
|
||||
4. **宿主内置按钮不参与本契约、位置固定**:
|
||||
- 表顶栏右侧:删除(有勾选时)/ 刷新 / 新增 / 导入 / 导出 —— 技能 toolbar/batch 按钮在左侧,不能与右侧内置按钮混排。
|
||||
- 行级:技能 `row` 项按本顺序出现在操作列 / 行右键(及可选「⋯」)中;宿主「查看」→(非只读)「编辑」→「删除」为内置项,位置由宿主固定(操作列常在技能项之后;右键中可与技能项分组)。
|
||||
5. **表顶栏全部直接展示,无「更多操作」溢出菜单**:匹配到的 toolbar/batch 技能按钮一律渲染(顺序仍按本契约)。**行级**不以「顶栏溢出菜单」模型截断;行右键是 `row` 的正式呈现壳之一,不是 toolbar 式「更多操作」截断。
|
||||
6. **表顶栏宽窄布局(宿主行为,技能无需配置)**:
|
||||
- 够宽:左右各横排一行(左技能按钮 + 右宿主工具)。
|
||||
- 不够宽:保持左右分栏,栏内按钮上下竖排(左栏技能、右栏刷新/导出等)。
|
||||
|
||||
禁止:
|
||||
|
||||
- 臆造 `sortOrder` / `order` / `priority` 等字段指望宿主排序(模板 Schema `additionalProperties: false` 会拒收)。
|
||||
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
|
||||
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
|
||||
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
|
||||
|
||||
### Action 类型 × placements(常见示例,不是限制)
|
||||
|
||||
下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。
|
||||
|
||||
210
references/CELL_PRESENTATION.md
Normal file
210
references/CELL_PRESENTATION.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 数据管理单元格呈现(Cell Presentation)契约(v1.0)
|
||||
|
||||
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
|
||||
> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。
|
||||
> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。
|
||||
用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。
|
||||
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 能力档位
|
||||
|
||||
| 档位 | 技能行为 | 宿主行为 |
|
||||
|------|----------|----------|
|
||||
| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 |
|
||||
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 |
|
||||
|
||||
多数技能保持 **P0**。需要视觉强调时再启用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 职责边界
|
||||
|
||||
| 角色 | 负责 | 不负责 |
|
||||
|------|------|--------|
|
||||
| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
|
||||
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
|
||||
|
||||
**派生状态(重要):** 呈现是计算结果,不是粘性开关。
|
||||
用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 文档形状(冻)
|
||||
|
||||
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"cells": [
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "qty",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "status",
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `schemaVersion` | 是 | 固定 `1` |
|
||||
| `skill` | 是 | 与 slug 一致 |
|
||||
| `table` | 是 | 当前文档作用的业务表 |
|
||||
| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) |
|
||||
| `generatedAt` | 否 | 排障用 |
|
||||
| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** |
|
||||
|
||||
### 2.1 `cells[]` 项
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `rowId` | 是 | `rowKey` 列值的字符串形式 |
|
||||
| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 |
|
||||
| `tone` | 是 | 见 §3 |
|
||||
| `message` | 否 | 短说明(≤500);悬停 / 无障碍 |
|
||||
| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 |
|
||||
|
||||
同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。
|
||||
`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。
|
||||
|
||||
---
|
||||
|
||||
## 3. `tone` 闭集(冻)
|
||||
|
||||
| tone | 语义(技能怎么选) | 宿主怎么呈现(示例,主题可调) |
|
||||
|------|-------------------|-------------------------------|
|
||||
| `danger` | 必须立刻处理 / 已违规 / 严重超时 | 强强调(如浅红底) |
|
||||
| `warning` | 即将到期 / 需注意 | 中等强调(如浅黄) |
|
||||
| `info` | 提示性标记 | 轻强调(如浅蓝) |
|
||||
| `success` | 正向完成态(少用在「待填」场景) | 浅绿等 |
|
||||
| `muted` | 弱化 / 次要 | 灰底或灰字 |
|
||||
|
||||
**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。
|
||||
以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。
|
||||
|
||||
「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐持久化(给宿主实现)
|
||||
|
||||
v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
row_key TEXT NOT NULL,
|
||||
row_id TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
tone TEXT NOT NULL,
|
||||
message TEXT,
|
||||
alert INTEGER NOT NULL DEFAULT 0,
|
||||
generated_at TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE (table_name, row_id, column_name)
|
||||
);
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。
|
||||
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。
|
||||
- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。
|
||||
|
||||
未创建该旁路表 = P0,宿主忽略。
|
||||
|
||||
---
|
||||
|
||||
## 5. 在 `actions.json` 中启用(可选,推荐)
|
||||
|
||||
与 `policy` 类似,顶层可选:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"cellPresentation": {
|
||||
"enabled": true
|
||||
},
|
||||
"actions": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `cellPresentation` | 省略 = P0 |
|
||||
| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 |
|
||||
|
||||
v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。
|
||||
|
||||
---
|
||||
|
||||
## 6. 何时重算(技能必须遵守)
|
||||
|
||||
至少在以下时机用**同一套 domain 规则**重算并覆盖该表呈现:
|
||||
|
||||
1. 全量/增量把外部数据写入本地投影之后(如同步成功)
|
||||
2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等)
|
||||
3. (可选)用户显式「刷新呈现」类 Action
|
||||
|
||||
进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。
|
||||
提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 与其它契约的关系
|
||||
|
||||
| 契约 | 关系 |
|
||||
|------|------|
|
||||
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 |
|
||||
| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 |
|
||||
| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 |
|
||||
| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 反模式
|
||||
|
||||
- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案
|
||||
- 技能下发 CSS / 内联颜色
|
||||
- 只在首次超时时打标、填完不重算导致红字残留
|
||||
- 宿主写死某个技能 slug 的特殊染色逻辑
|
||||
- 把 Cell Presentation 做成仅服务 SLA 的专用协议
|
||||
|
||||
---
|
||||
|
||||
## 9. 示例场景(说明用途不限)
|
||||
|
||||
| 场景 | column | tone | message 例 |
|
||||
|------|--------|------|------------|
|
||||
| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 |
|
||||
| 库存低于安全线 | `qty` | `warning` | 低于安全库存 |
|
||||
| 审核驳回 | `status` | `danger` | 已驳回,请修改 |
|
||||
| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 |
|
||||
|
||||
完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。
|
||||
@@ -45,6 +45,7 @@ python {baseDir}/scripts/main.py init-db
|
||||
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
|
||||
- **`version`**:输出 JSON(`version`、`skill`)。
|
||||
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
|
||||
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
|
||||
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
|
||||
- **排查顺序**(由快到慢):
|
||||
1. `python {baseDir}/scripts/main.py logs`
|
||||
|
||||
382
references/POLICY.md
Normal file
382
references/POLICY.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Skill Data Policy 契约(v1.0)
|
||||
|
||||
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
|
||||
> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。
|
||||
> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(`visibleWhen` 等)。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 能力档位(必须按此理解)
|
||||
|
||||
| 档位 | 技能配置 | 宿主行为 |
|
||||
|------|----------|----------|
|
||||
| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 |
|
||||
| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 |
|
||||
| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 |
|
||||
| **L3** | (预留,本契约不冻结行为) | 如行态 `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等 |
|
||||
|
||||
**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 已冻结最佳实践(v1.0)
|
||||
|
||||
| 议题 | 决定 | 技能开发者怎么做 |
|
||||
|------|------|------------------|
|
||||
| Skill Action 授权模式 | **仅** `allowlist` / `open`;v1 **禁止** `denylist` | 有角色就算出允许的 action id 列表;只控行不控按钮时用 `open` |
|
||||
| `hostCapabilities` | **省略 = 不限制**宿主内置;写出则按 allowlist(`[]` = 全禁内置) | 只控业务按钮时**不要写**该字段;要限导出/删除时再显式列出 |
|
||||
| scope 运算符 | 保留小白名单(见 §3.4);**禁止** like/raw/嵌套 | 日常优先 `eq` / `in` / `is_null`;比较符按需 |
|
||||
| `valueFrom` | v1 **仅** `actor.userId` | 角色只在技能内用来算 Snapshot,**不要**用 `actor.roles` 做行过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 启用字段(冻在 `assets/actions.json` 顶层)
|
||||
|
||||
`actions.json` 根对象增加**可选**字段 `policy`(与 `schemaVersion` / `skill` / `actions` 同级)。
|
||||
|
||||
### 1.1 形状(L0/L1 必冻)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"policy": {
|
||||
"enabled": true,
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "policy",
|
||||
"args": ["--table", "{{table}}"]
|
||||
},
|
||||
"appliesTo": ["data-management"]
|
||||
},
|
||||
"actions": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 冻结说明 |
|
||||
|------|------|----------|
|
||||
| `policy` | 否 | 缺省 = L0 |
|
||||
| `policy.enabled` | 若存在 `policy` 则**必填** | 仅 `true` 启用;`false` 等同 L0 |
|
||||
| `policy.entrypoint` | `enabled: true` 时必填 | 与 Action `entrypoint` 同构:`type` 目前仅 `"cli"` |
|
||||
| `policy.entrypoint.command` | 是 | 子命令名;**推荐**固定为 `policy` |
|
||||
| `policy.entrypoint.args` | 是 | JSON 数组;可含占位符,由宿主替换后再拼接 CLI |
|
||||
| `policy.appliesTo` | 否 | 缺省 `["data-management"]`。本期宿主**只实现** `data-management`;其它值忽略但不报错 |
|
||||
|
||||
### 1.2 占位符白名单(冻)
|
||||
|
||||
| 占位符 | 含义 | 例 |
|
||||
|--------|------|----|
|
||||
| `{{table}}` | 当前数据管理选中的**物理表名** | `customs_timelines` |
|
||||
| `{{skill}}` | 当前技能 slug | 与 manifest `skill` 一致 |
|
||||
| `{{userId}}` | 当前宿主用户 id | 与注入的 `JIANGCHANG_USER_ID` 一致 |
|
||||
|
||||
未列出的 `{{…}}`:**禁止**;宿主应视为契约错误(fail-closed,见 §6)。
|
||||
|
||||
### 1.3 显式不放进启用块的内容(防推翻)
|
||||
|
||||
以下**不得**出现在 `actions.json.policy` 中(属于技能内部或 Snapshot):
|
||||
|
||||
- 角色列表、人员表、飞书 app id、外部 IAM URL
|
||||
- 具体 allowlist / scope(那是 Snapshot 的事)
|
||||
- 按角色复制的 Action 定义
|
||||
|
||||
### 1.4 Action 级可选提示(不冻为运行时权威)
|
||||
|
||||
允许(文档/静态检查用),**宿主不得仅凭此显隐按钮**:
|
||||
|
||||
```json
|
||||
"authz": { "capability": "optional.skill.defined.string" }
|
||||
```
|
||||
|
||||
运行时权威仅为 Snapshot §2 的 `actions`。
|
||||
|
||||
---
|
||||
|
||||
## 2. Snapshot 最小字段集(`policy` CLI 标准输出)
|
||||
|
||||
- 编码:UTF-8 JSON 对象,写在 **stdout**(可先打日志到 stderr/文件,但 stdout 必须是单 JSON)。
|
||||
- 成功退出码 `0`;业务上「无权限看任何东西」仍是 `0` + 合法 Snapshot(例如空 allowlist),不是靠非 0 表示未授权。
|
||||
- 非 0 或非合法 JSON → §6 fail-closed。
|
||||
|
||||
### 2.1 根对象(L0 不出现;L1 必冻)
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `schemaVersion` | 是 | `integer` | 本期冻结为 **`1`** |
|
||||
| `skill` | 是 | `string` | 必须等于 manifest `skill` |
|
||||
| `actor` | 是 | `object` | 见 §2.2 |
|
||||
| `actions` | 是 | `object` | 见 §2.3 |
|
||||
| `hostCapabilities` | 否 | `string[]` | 见 §2.4;**省略 = 不限制宿主内置按钮**(兼容) |
|
||||
| `data` | 否 | `object` | 见 §3;省略 = 不限制行/列(L1-only) |
|
||||
| `extensions` | 否 | `object` | 技能私有扩展;宿主**必须忽略**未知键 |
|
||||
|
||||
根级未知字段:宿主**忽略**;**不得**依赖未写入本契约的字段做安全决策。
|
||||
|
||||
### 2.2 `actor`(L1 必冻)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `userId` | 是 | 非空;应与 `JIANGCHANG_USER_ID` / `{{userId}}` 对齐 |
|
||||
| `displayName` | 否 | 展示用 |
|
||||
| `roles` | 否 | `string[]`,角色**码**;仅供展示 / 技能自检。**不得**用于 v1 scope 的 `valueFrom` |
|
||||
| `attributes` | 否 | `object`,仅允许 JSON 标量值;本期 scope **不**解引用 |
|
||||
|
||||
### 2.3 `actions`(L1 必冻)— Skill Action 授权
|
||||
|
||||
**最佳实践:** 角色 → 技能内计算 → `allowlist` 的 id 列表。宿主不理解角色名。
|
||||
|
||||
```json
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "register-submit"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 冻结取值 |
|
||||
|------|------|----------|
|
||||
| `mode` | 是 | **仅两值**:`"allowlist"` \| `"open"` |
|
||||
| `ids` | `mode=allowlist` 时必填 | `string[]`;元素为 **`actions.json` 里的 action.id** |
|
||||
| `ids` | `mode=open` 时 | 必须省略或 `[]`;宿主视为**不限制** skill Action |
|
||||
|
||||
规则:
|
||||
|
||||
1. 宿主先按现有规则得到候选集:`placements` ∩ `bind.tables`(及 batch 等)。
|
||||
2. 若 `mode=allowlist`:候选 ∩ `ids`。漏配 id = 少按钮(安全侧失败)。
|
||||
3. `ids` 中的未知 id:**忽略**(不报错)。
|
||||
4. `mode=open`:等价「L1 不限制按钮」,可与 L2 行列限制并存。
|
||||
5. **`denylist` 不是 v1 合法值**;出现则 §6 fail-closed。
|
||||
|
||||
`replaces` 接管的默认新增/编辑/删除:视为对应 Action 的 id 是否在 allowlist 中;不在则宿主隐藏该接管按钮。`mode=open` 时不按 id 限制 replaces。
|
||||
|
||||
### 2.4 `hostCapabilities`(L1 可选冻)
|
||||
|
||||
控制数据管理**宿主内置**能力,与 skill `action.id` **命名空间分离**。
|
||||
|
||||
本期枚举冻结:`refresh` | `export` | `view` | `edit` | `delete` | `import`。
|
||||
|
||||
| 写法 | 含义 | 何时用 |
|
||||
|------|------|--------|
|
||||
| **省略字段** | **不限制**(与今日一致) | **默认推荐**:只控 skill 按钮 / 行数据时 |
|
||||
| 写出非空数组 | 仅允许列出的内置能力(allowlist) | 要限导出、删除等时 |
|
||||
| 写出 `[]` | 禁止全部上表内置能力 | 极少见;慎用 |
|
||||
|
||||
未知字符串:忽略。**禁止**把「省略」解释成全禁。
|
||||
|
||||
---
|
||||
|
||||
## 3. L2:`data` 与 scope 语法白名单
|
||||
|
||||
### 3.1 `data` 形状
|
||||
|
||||
```json
|
||||
"data": {
|
||||
"tables": {
|
||||
"customs_timelines": {
|
||||
"accessible": true,
|
||||
"columns": {
|
||||
"visible": ["id", "contract_no", "status"],
|
||||
"writable": ["status"]
|
||||
},
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.tables` | 若存在 `data` 则建议有 | `key` = 物理表名 |
|
||||
| `accessible` | 否 | 缺省 `true`。`false`:宿主可拒绝打开该表 |
|
||||
| `columns.visible` / `writable` | 否 | 物理列名;省略 = 不额外限制 |
|
||||
| `rows` | 否 | 见下 |
|
||||
|
||||
`writable` 应 ⊆ `visible`;若违规,宿主按交集降级,不 fail-closed。
|
||||
|
||||
### 3.2 `rows.mode`(冻)
|
||||
|
||||
| 值 | 本期 | 含义 |
|
||||
|----|------|------|
|
||||
| `scope` | **实现目标** | 宿主将 `scope` 编译为安全查询约束 |
|
||||
| `open` | 允许 | 显式不限制行 |
|
||||
| `delegate` | **仅占位** | 本期未实现时 fail-closed / 提示;**禁止**当 `open` |
|
||||
|
||||
缺省:无 `rows` = `open`。
|
||||
|
||||
### 3.3 `scope` 布尔结构(冻)
|
||||
|
||||
```text
|
||||
Scope =
|
||||
| { "all": Predicate[] } // AND,长度 ≥ 1
|
||||
| { "any": Predicate[] } // OR,长度 ≥ 1
|
||||
| Predicate // 单个谓词的语法糖
|
||||
```
|
||||
|
||||
**禁止**:嵌套超过一层。
|
||||
|
||||
### 3.4 `Predicate` 白名单(冻)
|
||||
|
||||
**运算符(宿主必须实现):** `eq` | `ne` | `in` | `not_in` | `gt` | `gte` | `lt` | `lte` | `is_null` | `is_not_null`
|
||||
|
||||
**推荐日常只用:** `eq` / `in` / `is_null`(及 `is_not_null`)。
|
||||
|
||||
**明确排除:** `like` / `contains` / `sql` / `raw` / `expr`。
|
||||
|
||||
**`valueFrom` v1 仅:** `actor.userId`。
|
||||
`actor.roles` / `actor.attributes.*` **非法** → fail-closed。
|
||||
|
||||
`value` 与 `valueFrom` 互斥。标量:`string` | `number` | `boolean` | `null`(与 null 比较优先用 `is_null`)。
|
||||
|
||||
### 3.5 与宿主原有过滤的关系
|
||||
|
||||
最终行集 = **原有表查询**(搜索、排序、分页、用户 Filter) **AND** policy `scope`。宿主必须把 scope 下推到查询层。
|
||||
|
||||
---
|
||||
|
||||
## 4. 宿主消费算法
|
||||
|
||||
```text
|
||||
IF manifest.policy?.enabled !== true:
|
||||
→ L0;END
|
||||
|
||||
调用 entrypoint(替换占位符),解析 Snapshot
|
||||
IF 失败 / 非法 / skill 不匹配 / mode 非法:
|
||||
→ fail-closed(§6);END
|
||||
|
||||
候选 = filter(placements, bind.tables, …)
|
||||
IF mode == allowlist: 候选 ∩ ids
|
||||
IF mode == open: 候选不变
|
||||
|
||||
渲染 skill 按钮 ← 候选
|
||||
渲染宿主内置 ← hostCapabilities(省略则不限制)
|
||||
应用 data.tables[T](若有)
|
||||
```
|
||||
|
||||
拉取时机(建议):进入表、切换表、刷新。短缓存不得跨 `userId`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 身份与环境
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 用户主键 | `JIANGCHANG_USER_ID` |
|
||||
| 技能解析 | 飞书 / 本地表 / HTTP IAM 等,**契约不规定** |
|
||||
|
||||
---
|
||||
|
||||
## 6. Fail-closed(启用后必冻)
|
||||
|
||||
当且仅当 `policy.enabled === true`:
|
||||
|
||||
| 情况 | 宿主行为 |
|
||||
|------|----------|
|
||||
| CLI 非 0 / 超时 / 无 JSON | 不渲染该表 skill 写操作与(建议)导出;明确错误;**禁止**回退 L0 全开 |
|
||||
| `schemaVersion` ≠ 1 / `skill` 不匹配 / `actions.mode` 非法 | 同上 |
|
||||
| `rows.mode=scope` 但 predicate 非法 | 不返回行或整表不可用;**禁止**忽略 scope |
|
||||
| `rows.mode=delegate`(未实现) | 提示不支持;**禁止**当 `open` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全边界
|
||||
|
||||
1. Snapshot **只**服务宿主 UI 与查询约束。
|
||||
2. 写 Action / 敏感读的 **domain service 必须再次鉴权**。
|
||||
3. 禁止「只藏按钮」宣称为完整权限方案。
|
||||
4. Cron / Agent **不**自动套用本 Snapshot。
|
||||
|
||||
---
|
||||
|
||||
## 8. 最小示例
|
||||
|
||||
模板仓库另有可复制示例:
|
||||
|
||||
- [`../assets/examples/skill-policy-snapshot.allowlist.example.json`](../assets/examples/skill-policy-snapshot.allowlist.example.json)
|
||||
- [`../assets/examples/skill-policy-snapshot.scope.example.json`](../assets/examples/skill-policy-snapshot.scope.example.json)
|
||||
|
||||
### 8.1 L0
|
||||
|
||||
不写 `policy` 字段。
|
||||
|
||||
### 8.2 L1 allowlist(不碰宿主内置)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1", "displayName": "张三", "roles": ["clerk"] },
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "register-submit"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 L1 + 限制导出
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1", "roles": ["clerk"] },
|
||||
"actions": { "mode": "allowlist", "ids": ["whoami"] },
|
||||
"hostCapabilities": ["refresh", "view"]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 L1 open + L2 scope
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1" },
|
||||
"actions": { "mode": "open" },
|
||||
"data": {
|
||||
"tables": {
|
||||
"orders": {
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 刻意延后
|
||||
|
||||
| 项 | 备注 |
|
||||
|----|------|
|
||||
| `actions.mode: denylist` | 另开 `schemaVersion` |
|
||||
| `valueFrom: actor.roles` | **明确不做** |
|
||||
| `visibleWhen` / 嵌套 scope / `like` | L3 或永不做 |
|
||||
| `rows.mode: delegate` 宿主实现 | 字段名已占坑 |
|
||||
| 平台全局 Role UI / cron·agent 套用 Snapshot | 另议 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 冻结检查清单
|
||||
|
||||
- [x] L0 缺省无歧义
|
||||
- [x] 启用字段仅 `enabled` + `entrypoint` + 可选 `appliesTo`
|
||||
- [x] `actions.mode` 仅 `allowlist` / `open`
|
||||
- [x] `hostCapabilities` 省略 = 不限制
|
||||
- [x] scope 小白名单;`valueFrom` 仅 `actor.userId`
|
||||
- [x] fail-closed;写路径技能自检
|
||||
@@ -19,6 +19,8 @@
|
||||
| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 |
|
||||
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口(可选能力) |
|
||||
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
|
||||
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
|
||||
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理单元格呈现:语义 tone 标色/强调(用途不限;多数技能 P0 不配) |
|
||||
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
|
||||
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`。
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子标色读 `CELL_PRESENTATION.md`。
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 使用 `_jiangchang_columns` 提供字段级展示元数据
|
||||
- 定义哪些字段可见、可搜索、可创建、可编辑
|
||||
- 定义字段展示类型和枚举选项
|
||||
- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
|
||||
- 保证元数据与真实数据库结构一致
|
||||
- 保证元数据初始化幂等
|
||||
|
||||
@@ -27,6 +28,7 @@
|
||||
- 不猜测技能业务含义
|
||||
- 不根据具体技能名称写特殊逻辑
|
||||
- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果
|
||||
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则
|
||||
|
||||
## 初始化入口
|
||||
|
||||
@@ -78,6 +80,8 @@ metadata:
|
||||
|
||||
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
|
||||
|
||||
若创建/更新/删除带有领域副作用(自动建目录、加密、级联清理),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete` 接管默认按钮,详见 [`ACTIONS.md`](ACTIONS.md)。
|
||||
|
||||
## 标准数据同步语义(外源 → 本地库)
|
||||
|
||||
适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。
|
||||
@@ -131,6 +135,38 @@ metadata:
|
||||
| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
|
||||
| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
|
||||
| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
|
||||
| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 |
|
||||
|
||||
#### 表级默认排序 `default_sort_json`(宿主支持)
|
||||
|
||||
技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退:
|
||||
|
||||
`updated_at DESC` → `created_at DESC` → 主键 DESC。
|
||||
|
||||
JSON 数组(可多字段,按顺序):
|
||||
|
||||
```json
|
||||
[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `column` 必须是该表真实列名;敏感列会被宿主忽略
|
||||
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
|
||||
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
|
||||
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
|
||||
|
||||
示例(消息时间线):
|
||||
|
||||
```python
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="messages",
|
||||
display_name="消息",
|
||||
readonly=0,
|
||||
default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]',
|
||||
)
|
||||
```
|
||||
|
||||
禁止:
|
||||
|
||||
@@ -354,7 +390,11 @@ CREATE TABLE demo_items (
|
||||
|
||||
## 枚举字段 options_json
|
||||
|
||||
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`:
|
||||
`options_json` 支持两种形态:
|
||||
|
||||
### 1)静态枚举(数组)
|
||||
|
||||
每项包含 `value` 与 `label`:
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -366,6 +406,31 @@ CREATE TABLE demo_items (
|
||||
|
||||
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
|
||||
|
||||
### 2)表关联 lookup(对象,推荐用于外键式字段)
|
||||
|
||||
当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组):
|
||||
|
||||
```json
|
||||
{
|
||||
"ref_table": "platforms",
|
||||
"value_column": "platform_key",
|
||||
"label_column": "display_name",
|
||||
"filter_column": "enabled",
|
||||
"filter_value": 1
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `ref_table` | 是 | 同库被引用表名(snake_case 标识符) |
|
||||
| `value_column` | 是 | 写入当前字段的值列 |
|
||||
| `label_column` | 是 | 下拉展示名列 |
|
||||
| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) |
|
||||
|
||||
宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 下拉选项;**物理列仍存标量**(如 `platform_key` 文本、`account_id` 整数),不做嵌套子表。
|
||||
|
||||
示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表。
|
||||
|
||||
## 幂等初始化标准
|
||||
|
||||
- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化
|
||||
|
||||
@@ -24,7 +24,8 @@ CREATE TABLE IF NOT EXISTS _jiangchang_tables (
|
||||
description TEXT,
|
||||
sort_order INTEGER,
|
||||
visible INTEGER NOT NULL DEFAULT 1,
|
||||
readonly INTEGER NOT NULL DEFAULT 0
|
||||
readonly INTEGER NOT NULL DEFAULT 0,
|
||||
default_sort_json TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -82,23 +83,50 @@ TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _ensure_jiangchang_tables_columns(cursor) -> None:
|
||||
"""旧库补列:CREATE IF NOT EXISTS 不会给已有表加新字段。"""
|
||||
cursor.execute("PRAGMA table_info('_jiangchang_tables')")
|
||||
cols = {str(row[1]) for row in cursor.fetchall()}
|
||||
if "default_sort_json" not in cols:
|
||||
cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN default_sort_json TEXT")
|
||||
|
||||
|
||||
def init_display_metadata_tables(cursor) -> None:
|
||||
cursor.execute(_JIANGCHANG_TABLES_DDL)
|
||||
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
|
||||
_ensure_jiangchang_tables_columns(cursor)
|
||||
|
||||
|
||||
def upsert_table_metadata(cursor, *, table_name: str, display_name: str, description: str | None = None, visible: int = 1, readonly: int = 0) -> None:
|
||||
def upsert_table_metadata(
|
||||
cursor,
|
||||
*,
|
||||
table_name: str,
|
||||
display_name: str,
|
||||
description: str | None = None,
|
||||
visible: int = 1,
|
||||
readonly: int = 0,
|
||||
default_sort_json: str | None = None,
|
||||
) -> None:
|
||||
"""写入表级元数据。
|
||||
|
||||
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
|
||||
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
|
||||
为空则宿主回退到 updated_at → created_at → 主键。
|
||||
"""
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO _jiangchang_tables (table_name, display_name, description, visible, readonly)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO _jiangchang_tables (
|
||||
table_name, display_name, description, visible, readonly, default_sort_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(table_name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
visible = excluded.visible,
|
||||
readonly = excluded.readonly
|
||||
readonly = excluded.readonly,
|
||||
default_sort_json = excluded.default_sort_json
|
||||
""",
|
||||
(table_name, display_name, description, visible, readonly),
|
||||
(table_name, display_name, description, visible, readonly, default_sort_json),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ def _default_uses_unixepoch(default_value: object) -> bool:
|
||||
|
||||
|
||||
def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]:
|
||||
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。"""
|
||||
"""校验 options_json:静态枚举数组,或 lookup 对象。"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
if options_json is None or str(options_json).strip() == "":
|
||||
@@ -206,8 +206,24 @@ def validate_options_json(options_json: str | None) -> tuple[list[str], list[str
|
||||
except json.JSONDecodeError as exc:
|
||||
errors.append(f"options_json 不是合法 JSON:{exc}")
|
||||
return errors, warnings
|
||||
|
||||
if isinstance(parsed, dict):
|
||||
for key in ("ref_table", "value_column", "label_column"):
|
||||
if not str(parsed.get(key) or "").strip():
|
||||
errors.append(f"options_json lookup 必须包含非空 {key}")
|
||||
ident = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
for key in ("ref_table", "value_column", "label_column", "filter_column"):
|
||||
raw = parsed.get(key)
|
||||
if raw is None or raw == "":
|
||||
continue
|
||||
if not ident.match(str(raw)):
|
||||
errors.append(f"options_json.{key} 必须是安全标识符,当前为 {raw!r}")
|
||||
if parsed.get("filter_column") not in (None, "") and "filter_value" not in parsed:
|
||||
warnings.append("options_json lookup 声明了 filter_column 但未提供 filter_value")
|
||||
return errors, warnings
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
errors.append("options_json 必须是 JSON 数组")
|
||||
errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)")
|
||||
return errors, warnings
|
||||
for index, item in enumerate(parsed):
|
||||
if not isinstance(item, dict):
|
||||
|
||||
@@ -70,7 +70,7 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
||||
|
||||
with job_context(skill=SKILL_SLUG):
|
||||
log.info(
|
||||
"task_start task_type=%s target_id=%s input_id=%s",
|
||||
"task_start task_type=%s target_id=%s input_id=%s stage=run",
|
||||
task_type,
|
||||
target,
|
||||
input_id,
|
||||
@@ -79,7 +79,10 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
||||
try:
|
||||
ok, reason = check_entitlement(SKILL_SLUG)
|
||||
if not ok:
|
||||
log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason)
|
||||
log.warning(
|
||||
"entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED",
|
||||
task_type,
|
||||
)
|
||||
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
|
||||
tlr.save_task_log(
|
||||
task_type=task_type,
|
||||
@@ -103,12 +106,19 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
||||
error_code="ENTITLEMENT_DENIED",
|
||||
stage="auth",
|
||||
)
|
||||
print(f"❌ {reason}")
|
||||
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
|
||||
return 1
|
||||
|
||||
log.info("task_run_demo_start target_id=%s input_id=%s", target, input_id)
|
||||
log.info("entitlement_ok task_type=%s stage=auth", task_type)
|
||||
log.info("rpa_start stage=demo_prepare task_type=%s", task_type)
|
||||
log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id)
|
||||
rc, video_info = asyncio.run(_run_template_demo(target, input_id))
|
||||
log.info("task_run_demo_done target_id=%s input_id=%s status=failed", target, input_id)
|
||||
log.info("rpa_done stage=demo_prepare status=ok")
|
||||
log.info(
|
||||
"task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed",
|
||||
target,
|
||||
input_id,
|
||||
)
|
||||
_print_video_summary(video_info)
|
||||
|
||||
summary_payload = merge_video_into_result_summary(
|
||||
@@ -135,6 +145,10 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
||||
input_id,
|
||||
)
|
||||
|
||||
log.warning(
|
||||
"task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB",
|
||||
task_type,
|
||||
)
|
||||
finish(
|
||||
status="failed",
|
||||
message="模板仓库未实现真实业务",
|
||||
@@ -142,8 +156,9 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
||||
template_demo=True,
|
||||
target=target,
|
||||
input_id=input_id,
|
||||
error_code="TEMPLATE_STUB",
|
||||
)
|
||||
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
|
||||
print("ERROR:TEMPLATE_STUB 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
|
||||
return rc
|
||||
except JobStopped:
|
||||
raise
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.54"
|
||||
SKILL_VERSION = "1.0.73"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.2"
|
||||
|
||||
@@ -10,18 +10,19 @@ from util.constants import SKILL_SLUG
|
||||
|
||||
_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
STANDARD_SUBDIR_DOWNLOADS = "downloads"
|
||||
STANDARD_SUBDIR_IMPORTS = "imports"
|
||||
STANDARD_SUBDIR_EXPORTS = "exports"
|
||||
# 用户可见的上行/下行目录(数据管理侧栏只挂这两个)
|
||||
STANDARD_SUBDIR_INPUT = "input"
|
||||
STANDARD_SUBDIR_OUTPUT = "output"
|
||||
|
||||
# 内部/可选目录(不上侧栏)
|
||||
STANDARD_SUBDIR_UPLOADS = "uploads"
|
||||
STANDARD_SUBDIR_CACHE = "cache"
|
||||
STANDARD_SUBDIR_TEMP = "temp"
|
||||
STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts"
|
||||
STANDARD_SUBDIR_VIDEOS = "videos"
|
||||
|
||||
CONFIG_KEY_DOWNLOAD_DIR = "SKILL_DOWNLOAD_DIR"
|
||||
CONFIG_KEY_IMPORT_DIR = "SKILL_IMPORT_DIR"
|
||||
CONFIG_KEY_EXPORT_DIR = "SKILL_EXPORT_DIR"
|
||||
CONFIG_KEY_INPUT_DIR = "SKILL_INPUT_DIR"
|
||||
CONFIG_KEY_OUTPUT_DIR = "SKILL_OUTPUT_DIR"
|
||||
CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR"
|
||||
CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_DIR"
|
||||
CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR"
|
||||
@@ -81,27 +82,25 @@ def resolve_data_path(
|
||||
|
||||
|
||||
def resolve_input_path(raw_path: str, *, create_parent: bool = False) -> str:
|
||||
"""解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/imports/。"""
|
||||
"""解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/input/。"""
|
||||
text = (raw_path or "").strip()
|
||||
if not text:
|
||||
return text
|
||||
expanded = os.path.expanduser(text)
|
||||
if os.path.isabs(expanded):
|
||||
return os.path.abspath(expanded)
|
||||
imports_dir = get_imports_dir(create=create_parent)
|
||||
return os.path.abspath(os.path.join(imports_dir, os.path.normpath(expanded)))
|
||||
input_dir = get_input_dir(create=create_parent)
|
||||
return os.path.abspath(os.path.join(input_dir, os.path.normpath(expanded)))
|
||||
|
||||
|
||||
def get_downloads_dir(*, create: bool = True) -> str:
|
||||
return resolve_data_path(CONFIG_KEY_DOWNLOAD_DIR, STANDARD_SUBDIR_DOWNLOADS, create=create)
|
||||
def get_input_dir(*, create: bool = True) -> str:
|
||||
"""上行:用户/Agent 放入的待处理文件(清单、CSV、产品表等)。"""
|
||||
return resolve_data_path(CONFIG_KEY_INPUT_DIR, STANDARD_SUBDIR_INPUT, create=create)
|
||||
|
||||
|
||||
def get_imports_dir(*, create: bool = True) -> str:
|
||||
return resolve_data_path(CONFIG_KEY_IMPORT_DIR, STANDARD_SUBDIR_IMPORTS, create=create)
|
||||
|
||||
|
||||
def get_exports_dir(*, create: bool = True) -> str:
|
||||
return resolve_data_path(CONFIG_KEY_EXPORT_DIR, STANDARD_SUBDIR_EXPORTS, create=create)
|
||||
def get_output_dir(*, create: bool = True) -> str:
|
||||
"""下行:技能/宿主交给用户拿走的文件(视频、报表、导出 CSV 等)。"""
|
||||
return resolve_data_path(CONFIG_KEY_OUTPUT_DIR, STANDARD_SUBDIR_OUTPUT, create=create)
|
||||
|
||||
|
||||
def get_uploads_dir(*, create: bool = True) -> str:
|
||||
@@ -135,9 +134,8 @@ def list_resolved_data_paths() -> dict[str, str]:
|
||||
"""供 health / config-path 输出已解析的数据子目录(不创建目录)。"""
|
||||
return {
|
||||
"skill_data_dir": get_skill_data_dir(),
|
||||
"downloads_dir": get_downloads_dir(create=False),
|
||||
"imports_dir": get_imports_dir(create=False),
|
||||
"exports_dir": get_exports_dir(create=False),
|
||||
"input_dir": get_input_dir(create=False),
|
||||
"output_dir": get_output_dir(create=False),
|
||||
"uploads_dir": get_uploads_dir(create=False),
|
||||
"cache_dir": get_cache_dir(create=False),
|
||||
"temp_dir": get_temp_dir(create=False),
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
|
||||
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。
|
||||
|
||||
### 5. `test_logging_contract.py.sample`
|
||||
|
||||
**适合:** 长任务 / RPA 技能,需断言统一文件日志含 `task_start`、`task_failed|task_success`、`error_code=` 等事件词(见 [`development/LOGGING.md`](../../development/LOGGING.md) §3)。复制后用 `IsolatedDataRoot` + mock 域 RPA,**不要**依赖真实企微/浏览器。
|
||||
|
||||
---
|
||||
|
||||
## 如何启用 service contract 样例
|
||||
|
||||
56
tests/samples/test_logging_contract.py.sample
Normal file
56
tests/samples/test_logging_contract.py.sample
Normal 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()
|
||||
@@ -190,7 +190,21 @@ class TestActionsManifest(unittest.TestCase):
|
||||
|
||||
placements = action.get("placements")
|
||||
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
|
||||
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
|
||||
replaces = action.get("replaces")
|
||||
if replaces is not None:
|
||||
self.assertIn(
|
||||
replaces,
|
||||
{"create", "update", "delete"},
|
||||
msg=f"{action_id} invalid replaces {replaces!r}",
|
||||
)
|
||||
bind = action.get("bind") or {}
|
||||
tables = bind.get("tables") or []
|
||||
self.assertTrue(
|
||||
isinstance(tables, list) and len(tables) > 0,
|
||||
msg=f"{action_id} replaces requires non-empty bind.tables",
|
||||
)
|
||||
else:
|
||||
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
|
||||
for placement in placements:
|
||||
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
|
||||
|
||||
@@ -216,6 +230,55 @@ class TestActionsManifest(unittest.TestCase):
|
||||
self.assertEqual(bind_def.get("required"), ["tables"])
|
||||
self.assertIs(bind_def.get("additionalProperties"), False)
|
||||
|
||||
def test_schema_defines_interaction_fields(self) -> None:
|
||||
schema = _load_actions_schema()
|
||||
action_def = schema["$defs"]["action"]
|
||||
props = action_def["properties"]
|
||||
self.assertIn("interaction", props)
|
||||
self.assertEqual(
|
||||
set(props["interaction"].get("enum") or []),
|
||||
{"auto", "direct", "confirm", "form"},
|
||||
)
|
||||
self.assertIn("interactionByPlacement", props)
|
||||
by_placement = props["interactionByPlacement"]
|
||||
self.assertIs(by_placement.get("additionalProperties"), False)
|
||||
for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete"):
|
||||
self.assertIn(key, by_placement.get("properties") or {})
|
||||
self.assertEqual(
|
||||
set((by_placement["properties"][key].get("enum") or [])),
|
||||
{"direct", "confirm", "form"},
|
||||
)
|
||||
self.assertIn("replaces", props)
|
||||
self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind")
|
||||
self.assertEqual(
|
||||
set(schema["$defs"]["mutationKind"].get("enum") or []),
|
||||
{"create", "update", "delete"},
|
||||
)
|
||||
|
||||
def test_schema_defines_host_effects_and_steps(self) -> None:
|
||||
schema = _load_actions_schema()
|
||||
action_def = schema["$defs"]["action"]
|
||||
props = action_def["properties"]
|
||||
self.assertIn("hostEffects", props)
|
||||
self.assertEqual(props["hostEffects"].get("$ref"), "#/$defs/hostEffects")
|
||||
self.assertIn("steps", props)
|
||||
effects = schema["$defs"]["hostEffects"]
|
||||
self.assertIs(effects.get("additionalProperties"), False)
|
||||
for key in ("onSuccess", "onFailure"):
|
||||
self.assertIn(key, effects.get("properties") or {})
|
||||
effect_ids = set(schema["$defs"]["hostEffectId"].get("enum") or [])
|
||||
self.assertEqual(effect_ids, {"reloadTable", "refreshCatalog", "clearSelection"})
|
||||
step = schema["$defs"]["actionStep"]
|
||||
self.assertEqual(
|
||||
set(step["properties"]["when"].get("enum") or []),
|
||||
{"before", "afterSuccess", "afterFailure"},
|
||||
)
|
||||
self.assertEqual(
|
||||
set(step["properties"]["do"].get("enum") or []),
|
||||
{"deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"},
|
||||
)
|
||||
self.assertEqual(props["steps"].get("maxItems"), 8)
|
||||
|
||||
def test_manifest_validates_against_json_schema(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
@@ -469,8 +532,41 @@ class TestActionsManifest(unittest.TestCase):
|
||||
"additionalProperties",
|
||||
"placements",
|
||||
"executionProfile",
|
||||
"interaction",
|
||||
"confirmation",
|
||||
"数据管理技能按钮顺序",
|
||||
"数组相对顺序",
|
||||
"全部直接展示",
|
||||
"左右分栏",
|
||||
"行级入口",
|
||||
"行右键",
|
||||
"六职责正交",
|
||||
"POLICY.md",
|
||||
"hostEffects",
|
||||
"steps",
|
||||
"reloadTable",
|
||||
"afterSuccess",
|
||||
"afterFailure",
|
||||
"cellPresentation",
|
||||
"CELL_PRESENTATION.md",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
self.assertNotIn(
|
||||
"其余进「更多操作」",
|
||||
text,
|
||||
msg="ACTIONS.md must not document removed overflow more-menu truncation",
|
||||
)
|
||||
self.assertIn(
|
||||
"新增 `row-context-menu` 等 placement",
|
||||
text,
|
||||
msg="ACTIONS.md must forbid inventing a separate row-context-menu placement",
|
||||
)
|
||||
placement_enum = _load_actions_schema().get("$defs", {}).get("placement", {}).get("enum") or []
|
||||
self.assertNotIn(
|
||||
"row-context-menu",
|
||||
placement_enum,
|
||||
msg="skill-actions.schema must not add row-context-menu to placements enum",
|
||||
)
|
||||
for forbidden in (
|
||||
"数据管理 toolbar " + "只挂",
|
||||
"toolbar " + "只放 async",
|
||||
|
||||
93
tests/test_cell_presentation_contract.py
Normal file
93
tests/test_cell_presentation_contract.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cell Presentation 契约:schema + 文档 + 示例。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from util.runtime_paths import get_skill_root
|
||||
|
||||
|
||||
def _load_schema() -> dict:
|
||||
path = os.path.join(
|
||||
get_skill_root(),
|
||||
"assets",
|
||||
"schemas",
|
||||
"skill-cell-presentation.schema.json",
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _load_example() -> dict:
|
||||
path = os.path.join(
|
||||
get_skill_root(),
|
||||
"assets",
|
||||
"examples",
|
||||
"skill-cell-presentation.example.json",
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestCellPresentationContract(unittest.TestCase):
|
||||
def test_schema_file_exists_and_parses(self) -> None:
|
||||
schema = _load_schema()
|
||||
self.assertEqual(schema.get("title"), "SkillCellPresentationDocument")
|
||||
self.assertIs(schema.get("additionalProperties"), False)
|
||||
self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1)
|
||||
tones = set(schema["$defs"]["tone"].get("enum") or [])
|
||||
self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"})
|
||||
|
||||
def test_example_validates_against_schema(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema not installed")
|
||||
jsonschema.validate(instance=_load_example(), schema=_load_schema())
|
||||
|
||||
def test_actions_schema_defines_cell_presentation_enable_block(self) -> None:
|
||||
path = os.path.join(
|
||||
get_skill_root(), "assets", "schemas", "skill-actions.schema.json"
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
actions_schema = json.load(f)
|
||||
self.assertIn("cellPresentation", actions_schema["properties"])
|
||||
self.assertIn("cellPresentationDeclaration", actions_schema["$defs"])
|
||||
|
||||
def test_docs_and_example_exist(self) -> None:
|
||||
root = get_skill_root()
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "examples", "skill-cell-presentation.example.json")
|
||||
)
|
||||
)
|
||||
|
||||
def test_cell_presentation_md_covers_contract(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for marker in (
|
||||
"tone",
|
||||
"danger",
|
||||
"warning",
|
||||
"alert",
|
||||
"_jiangchang_cell_presentation",
|
||||
"cellPresentation",
|
||||
"派生",
|
||||
"任意 CSS",
|
||||
"用途不限",
|
||||
"schemaVersion",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -84,8 +84,8 @@ class TestConfigBootstrap(unittest.TestCase):
|
||||
config.reset_cache()
|
||||
with open(example, encoding="utf-8") as f:
|
||||
example_text = f.read()
|
||||
self.assertIn(_env_line(_TEST_TARGET_KEY, "mock").strip(), example_text)
|
||||
self.assertEqual(config.get(_TEST_TARGET_KEY), "mock")
|
||||
self.assertIn(_env_line(_TEST_TARGET_KEY, "real_" + "rpa").strip(), example_text)
|
||||
self.assertEqual(config.get(_TEST_TARGET_KEY), "real_" + "rpa")
|
||||
|
||||
def test_config_path_outputs_json(self) -> None:
|
||||
with IsolatedDataRoot(user_id="_cfg_path"):
|
||||
|
||||
@@ -36,6 +36,8 @@ POLICY_IDS = (
|
||||
"POLICY-RPA-002",
|
||||
"POLICY-RPA-003",
|
||||
"POLICY-RPA-004",
|
||||
"POLICY-RPA-005",
|
||||
"POLICY-RPA-006",
|
||||
"POLICY-PACKAGING-001",
|
||||
"POLICY-PACKAGING-002",
|
||||
"POLICY-DOCS-001",
|
||||
@@ -44,6 +46,9 @@ POLICY_IDS = (
|
||||
"POLICY-LOGGING-003",
|
||||
"POLICY-LOGGING-004",
|
||||
"POLICY-LOGGING-005",
|
||||
"POLICY-LOGGING-006",
|
||||
"POLICY-LOGGING-007",
|
||||
"POLICY-LOGGING-008",
|
||||
"POLICY-CONTROL-001",
|
||||
"POLICY-CONTROL-002",
|
||||
"POLICY-CONTROL-003",
|
||||
@@ -155,11 +160,70 @@ RPA_004_LAUNCH_MARKERS = (
|
||||
)
|
||||
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_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
|
||||
LOGGING_003_SOURCE = "development/LOGGING.md"
|
||||
LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md"
|
||||
LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.md"
|
||||
LOGGING_006_SOURCE = "development/LOGGING.md §0、§3"
|
||||
LOGGING_007_SOURCE = "development/LOGGING.md §3.1–§3.2"
|
||||
LOGGING_008_SOURCE = "development/LOGGING.md §0 铁律 3"
|
||||
|
||||
LOGGING_006_EXEMPT_BASENAMES = frozenset(
|
||||
{
|
||||
"__init__.py",
|
||||
"task_run_support.py",
|
||||
"entitlement_service.py",
|
||||
"sibling_bridge.py",
|
||||
}
|
||||
)
|
||||
|
||||
LOGGING_RPA_MARKERS = (
|
||||
"RpaVideoSession",
|
||||
"uiautomation",
|
||||
"playwright",
|
||||
"@rpa_step",
|
||||
"rpa_step(",
|
||||
)
|
||||
|
||||
LOGGING_CALL_PATTERN = re.compile(
|
||||
r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(",
|
||||
@@ -669,6 +733,87 @@ class TestPolicyRpa004(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
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]:
|
||||
offenders: list[str] = []
|
||||
for path in _walk_files(skill_root, "scripts", suffix=".py"):
|
||||
@@ -789,6 +934,121 @@ class TestPolicyLogging005(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
def _iter_service_py_files(skill_root: str) -> list[str]:
|
||||
service_dir = os.path.join(skill_root, "scripts", "service")
|
||||
if not os.path.isdir(service_dir):
|
||||
return []
|
||||
return list(_walk_files(skill_root, os.path.join("scripts", "service"), suffix=".py"))
|
||||
|
||||
|
||||
def _service_layer_combined_text(skill_root: str) -> str:
|
||||
parts: list[str] = []
|
||||
for path in _iter_service_py_files(skill_root):
|
||||
parts.append(_read_text(path))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
_LOGGING_DOMAIN_CALL_RE = re.compile(
|
||||
r"\b(log|logger|_logger)\s*\.\s*(info|warning|error|exception)\s*\(",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PRINT_ERROR_RE = re.compile(r"\bprint\s*\(.*ERROR:", re.IGNORECASE | re.DOTALL)
|
||||
_FUNC_DEF_RE = re.compile(r"(?m)^(async\s+)?def\s+\w+\s*\(")
|
||||
|
||||
|
||||
def _scan_print_error_without_logger(skill_root: str, path: str, text: str) -> list[str]:
|
||||
"""同一函数体内 print(...ERROR:) 须伴随 warning/error/exception 日志。"""
|
||||
offenders: list[str] = []
|
||||
matches = list(_FUNC_DEF_RE.finditer(text))
|
||||
if not matches:
|
||||
return offenders
|
||||
for i, m in enumerate(matches):
|
||||
start = m.start()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
body = text[start:end]
|
||||
if not _PRINT_ERROR_RE.search(body):
|
||||
continue
|
||||
if re.search(
|
||||
r"\b(log|logger|_logger)\s*\.\s*(warning|error|exception)\s*\(",
|
||||
body,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
continue
|
||||
lineno = text[:start].count("\n") + 1
|
||||
offenders.append(
|
||||
f"{_rel(skill_root, path)}:{lineno}: print(ERROR:) without log.warning|error|exception in same function"
|
||||
)
|
||||
return offenders
|
||||
|
||||
|
||||
class TestPolicyLogging006(unittest.TestCase):
|
||||
def test_service_domain_modules_have_file_logging(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
offenders: list[str] = []
|
||||
for path in _iter_service_py_files(skill_root):
|
||||
base = os.path.basename(path)
|
||||
if base in LOGGING_006_EXEMPT_BASENAMES:
|
||||
continue
|
||||
text = _read_text(path)
|
||||
if not _LOGGING_DOMAIN_CALL_RE.search(text):
|
||||
offenders.append(_rel(skill_root, path))
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-LOGGING-006",
|
||||
LOGGING_006_SOURCE,
|
||||
"service domain modules missing log/logger/_logger.(info|warning|error|exception):\n"
|
||||
+ "\n".join(offenders),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyLogging007(unittest.TestCase):
|
||||
def test_rpa_service_layer_has_event_tokens(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
combined = _service_layer_combined_text(skill_root)
|
||||
if not any(marker in combined for marker in LOGGING_RPA_MARKERS):
|
||||
self.skipTest("no RPA markers in scripts/service; LOGGING-007 N/A")
|
||||
missing: list[str] = []
|
||||
if "task_start" not in combined:
|
||||
missing.append("task_start")
|
||||
if "task_failed" not in combined and "rpa_failed" not in combined:
|
||||
missing.append("task_failed|rpa_failed")
|
||||
if "stage=" not in combined:
|
||||
missing.append("stage=")
|
||||
# entitlement tokens strongly recommended; require at least one
|
||||
if "entitlement_ok" not in combined and "entitlement_failed" not in combined:
|
||||
missing.append("entitlement_ok|entitlement_failed")
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-LOGGING-007",
|
||||
LOGGING_007_SOURCE,
|
||||
"RPA service layer missing event tokens: " + ", ".join(missing),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyLogging008(unittest.TestCase):
|
||||
def test_print_error_paired_with_logger(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
offenders: list[str] = []
|
||||
for path in _iter_service_py_files(skill_root):
|
||||
text = _read_text(path)
|
||||
offenders.extend(_scan_print_error_without_logger(skill_root, path, text))
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-LOGGING-008",
|
||||
LOGGING_008_SOURCE,
|
||||
"\n".join(offenders) or "print(ERROR:) must share function with log.warning|error|exception",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyControl001(unittest.TestCase):
|
||||
def test_no_util_progress_module(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
|
||||
@@ -249,6 +249,21 @@ class TestDocsStandards(unittest.TestCase):
|
||||
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:
|
||||
text = self._read("examples/simulator_browser_rpa/README.md")
|
||||
self.assertIn("async", text.lower())
|
||||
@@ -284,9 +299,21 @@ class TestDocsStandards(unittest.TestCase):
|
||||
root = get_skill_root()
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "POLICY.md")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
|
||||
self.assertTrue(
|
||||
os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json"))
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "schemas", "skill-policy-snapshot.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py")))
|
||||
|
||||
@@ -483,12 +510,60 @@ class TestDocsStandards(unittest.TestCase):
|
||||
|
||||
def test_examples_top_level_modes_present(self) -> None:
|
||||
examples_dir = os.path.join(get_skill_root(), "examples")
|
||||
for mode in ("real_" + "api", "real_browser_rpa", "simulator_api", "simulator_browser_rpa"):
|
||||
for mode in (
|
||||
"real_" + "api",
|
||||
"real_browser_rpa",
|
||||
"simulator_api",
|
||||
"simulator_browser_rpa",
|
||||
"real_desktop_rpa",
|
||||
"mobile_rpa",
|
||||
):
|
||||
self.assertTrue(
|
||||
os.path.isdir(os.path.join(examples_dir, mode)),
|
||||
msg=f"examples/{mode}/ must exist",
|
||||
)
|
||||
|
||||
def test_three_surface_locator_standards_are_present(self) -> None:
|
||||
required = {
|
||||
"development/RPA_ELEMENT_LOCATING.md": (
|
||||
"元素清单",
|
||||
"expected_count",
|
||||
"ERROR:RESULT_UNCONFIRMED",
|
||||
"AI 编程约束",
|
||||
),
|
||||
"development/RPA_BROWSER.md": (
|
||||
"Pick Locator",
|
||||
"iframe",
|
||||
"Shadow DOM",
|
||||
"禁止模式",
|
||||
),
|
||||
"development/RPA_DESKTOP.md": (
|
||||
"uiautomation",
|
||||
"add-friend-wecom",
|
||||
"AutomationId",
|
||||
"probe-ui",
|
||||
),
|
||||
"development/RPA_MOBILE.md": (
|
||||
"Appium Inspector",
|
||||
"resourceId",
|
||||
"Native/WebView",
|
||||
"尚无团队真实成功案例",
|
||||
),
|
||||
}
|
||||
for rel, markers in required.items():
|
||||
text = self._read(rel)
|
||||
for marker in markers:
|
||||
self.assertIn(marker, text, msg=f"{rel} missing {marker!r}")
|
||||
|
||||
def test_desktop_and_mobile_examples_state_validation_level(self) -> None:
|
||||
desktop = self._read("examples/real_desktop_rpa/README.md")
|
||||
self.assertIn("技术路线已真实验证", desktop)
|
||||
self.assertIn("add-friend-wecom", desktop)
|
||||
self.assertIn("dry-run", desktop)
|
||||
mobile = self._read("examples/mobile_rpa/README.md")
|
||||
self.assertIn("不是可复制实现", mobile)
|
||||
self.assertIn("不得宣称已跑通", mobile)
|
||||
|
||||
def test_examples_are_not_full_skill_repos(self) -> None:
|
||||
examples_dir = os.path.join(get_skill_root(), "examples")
|
||||
for mode in os.listdir(examples_dir):
|
||||
|
||||
@@ -89,11 +89,11 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
import util.runtime_paths as rp
|
||||
|
||||
importlib.reload(rp)
|
||||
path = rp.resolve_data_path(None, "downloads", create=True)
|
||||
path = rp.resolve_data_path(None, "output", create=True)
|
||||
norm = os.path.normcase(os.path.normpath(path))
|
||||
norm_tmp = os.path.normcase(os.path.normpath(tmp))
|
||||
self.assertTrue(norm.startswith(norm_tmp))
|
||||
self.assertTrue(norm.endswith(os.path.normcase(os.path.join("downloads"))))
|
||||
self.assertTrue(norm.endswith(os.path.normcase(os.path.join("output"))))
|
||||
|
||||
def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None:
|
||||
with IsolatedDataRoot() as tmp:
|
||||
@@ -102,20 +102,20 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
import util.runtime_paths as rp
|
||||
|
||||
importlib.reload(rp)
|
||||
os.environ["SKILL_DOWNLOAD_DIR"] = "downloads/videos"
|
||||
os.environ["SKILL_OUTPUT_DIR"] = "output/videos"
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
config.reset_cache()
|
||||
importlib.reload(rp)
|
||||
path = rp.resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads", create=False)
|
||||
path = rp.resolve_data_path("SKILL_OUTPUT_DIR", "output", create=False)
|
||||
norm = os.path.normcase(os.path.normpath(path))
|
||||
self.assertIn(os.path.normcase("downloads"), norm)
|
||||
self.assertIn(os.path.normcase("output"), norm)
|
||||
self.assertIn(os.path.normcase("videos"), norm)
|
||||
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
|
||||
os.environ.pop("SKILL_DOWNLOAD_DIR", None)
|
||||
os.environ.pop("SKILL_OUTPUT_DIR", None)
|
||||
config.reset_cache()
|
||||
|
||||
def test_resolve_input_path_relative_under_imports(self) -> None:
|
||||
def test_resolve_input_path_relative_under_input(self) -> None:
|
||||
with IsolatedDataRoot() as tmp:
|
||||
import importlib
|
||||
|
||||
@@ -124,9 +124,24 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
importlib.reload(rp)
|
||||
resolved = rp.resolve_input_path("urls.txt", create_parent=True)
|
||||
norm = os.path.normcase(os.path.normpath(resolved))
|
||||
self.assertIn(os.path.normcase("imports"), norm)
|
||||
self.assertIn(os.path.normcase("input"), norm)
|
||||
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
|
||||
|
||||
def test_get_input_and_output_dirs(self) -> None:
|
||||
with IsolatedDataRoot() as tmp:
|
||||
import importlib
|
||||
|
||||
import util.runtime_paths as rp
|
||||
|
||||
importlib.reload(rp)
|
||||
input_dir = rp.get_input_dir(create=True)
|
||||
output_dir = rp.get_output_dir(create=True)
|
||||
norm_tmp = os.path.normcase(os.path.normpath(tmp))
|
||||
self.assertTrue(os.path.normcase(os.path.normpath(input_dir)).startswith(norm_tmp))
|
||||
self.assertTrue(os.path.normcase(os.path.normpath(output_dir)).startswith(norm_tmp))
|
||||
self.assertTrue(input_dir.replace("\\", "/").endswith("/input"))
|
||||
self.assertTrue(output_dir.replace("\\", "/").endswith("/output"))
|
||||
|
||||
def test_list_resolved_data_paths_keys(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
import importlib
|
||||
@@ -137,12 +152,14 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
paths = rp.list_resolved_data_paths()
|
||||
for key in (
|
||||
"skill_data_dir",
|
||||
"downloads_dir",
|
||||
"imports_dir",
|
||||
"exports_dir",
|
||||
"input_dir",
|
||||
"output_dir",
|
||||
):
|
||||
self.assertIn(key, paths)
|
||||
self.assertTrue(paths[key])
|
||||
self.assertNotIn("downloads_dir", paths)
|
||||
self.assertNotIn("imports_dir", paths)
|
||||
self.assertNotIn("exports_dir", paths)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -61,7 +61,7 @@ class TestEnvExampleVideoDefaults(unittest.TestCase):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for key in (
|
||||
"OPENCLAW_" + "TEST_TARGET=mock",
|
||||
"OPENCLAW_" + "TEST_TARGET=real_" + "rpa",
|
||||
"OPENCLAW_RECORD_VIDEO=0",
|
||||
"OPENCLAW_ARTIFACTS_ON_FAILURE=1",
|
||||
"OPENCLAW_BROWSER_HEADLESS=0",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# skill-template 工具
|
||||
|
||||
## UI 拾取(开发期)
|
||||
|
||||
`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓:
|
||||
|
||||
- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder
|
||||
- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`
|
||||
- 启动:`python run.py`
|
||||
|
||||
模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。
|
||||
|
||||
## 业务仓也可以直接 clone(来源 A)
|
||||
|
||||
若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接:
|
||||
|
||||
Reference in New Issue
Block a user