Compare commits

..

3 Commits

Author SHA1 Message Date
8570d5803d docs(development): clarify onboarding, developer_ids self-test, host multi-entry QA
All checks were successful
技能自动化发布 / release (push) Successful in 20s
2026-07-20 09:20:38 +08:00
1ff70e26a5 Release v1.0.51: precipitate ensure-web, config empty-value, and login-required standards
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-18 16:30:06 +08:00
0b9a8b2107 Release v1.0.50: web RPA profile/headed hard rules (POLICY-RPA-004)
All checks were successful
技能自动化发布 / release (push) Successful in 17s
2026-07-18 09:31:40 +08:00
27 changed files with 650 additions and 198 deletions

View File

@@ -4,11 +4,14 @@ OPENCLAW_TEST_TARGET=mock # 可选mock=本地模拟simulator_
# 目标网站地址:技能要访问的网站或服务地址 # 目标网站地址:技能要访问的网站或服务地址
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可
# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) # 默认登录账号:仅填写非敏感的账号标识(如工号/登录名/手机号
DEFAULT_LOGIN_ID=04110001 # 只填账号标识,不要填密码;密码请在「账号管理」中登记 DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码
# 是否显示浏览器窗口:打开后能否看见自动化操作过程 # 默认账号编号:仅填账号管理里的纯数字主键(一般留空)
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口推荐便于排查1=后台静默运行 DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号请填上方登录账号
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口必须便于介入与排查1=后台静默(勿作日常用法)
# 浏览器防检测:降低被网站识别为自动化工具的概率 # 浏览器防检测:降低被网站识别为自动化工具的概率
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启推荐0=关闭;一般保持默认 OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启推荐0=关闭;一般保持默认

View File

@@ -9,6 +9,22 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.52
- 开发文档补齐全流程Gitea 克隆与本地模板复制两种拿仓方式;`developer_ids` 作为开发期自测通行证(下载安装注册 → 设置复制用户 ID
- 发布后宿主验收明确覆盖新建任务、数据管理、定时任务与任务中心;`development/README` 增加步骤索引
## 1.0.51
- 沉淀网页 RPA 首启标准:业务技能默认 `ensure-web`(有则用、无则登记);登记账号 ≠ 已登录目标站;租约全忙返回占用错误且不重复建号
- 明确会话依赖型采集须声明登录 `required` 并做登录门禁;配置留空 + 行尾说明不得当成假默认值;账号可用登录标识选择,展示优先登录名
- 最低共享库版本声明上调至 1.2.2(含配置空值解析修复);说明宿主按需升级、不因源上有更新自动追新
## 1.0.50
- 沉淀网页 RPA 硬规则:必须先经账号管理取得 `profile_dir` 再开浏览器;生产有头;站点登录按平台约定(不全局强制);新增 `POLICY-RPA-004` 自动检测
- 用户教程 / 技能说明占位强调「先登记账号」需求模板增加登录策略三选一required / optional / not_needed
## 1.0.49 ## 1.0.49
- 明确 Skill Action 心智:默认按 sync仅长任务 / RPA 显式 async 进任务中心;匠厂宿主省略 `executionProfile` 时按 sync新技能仍须显式声明 - 明确 Skill Action 心智:默认按 sync仅长任务 / RPA 显式 async 进任务中心;匠厂宿主省略 `executionProfile` 时按 sync新技能仍须显式声明

View File

@@ -40,9 +40,11 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
说明用户在使用前要准备的东西,例如: 说明用户在使用前要准备的东西,例如:
- 相关账号已登录,且具备查询、导出或提交权限 - **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
- 相关业务权限(查询、导出、提交等)已具备
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息 - 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
- 如果涉及外部系统,请确认网络、账号、验证码或审批流程可正常使用 - 如果涉及验证码或人工确认,请保证能及时在弹出的浏览器窗口中操作
--- ---

View File

@@ -1,12 +1,12 @@
--- ---
name: 技能开发模板(通用业务版) name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.49 version: 1.0.52
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
slug: your-skill-slug slug: your-skill-slug
platform_kit_min_version: "1.2.0" platform_kit_min_version: "1.2.2"
emoji: "📦" emoji: "📦"
category: "通用" category: "通用"
developer_ids: developer_ids:
@@ -74,7 +74,7 @@ python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py init-db python {baseDir}/scripts/main.py init-db
``` ```
- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.0`(包名路径 `jiangchang_skill_core`**不要 vendor** `scripts/jiangchang_skill_core/` - 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.2`(包名路径 `jiangchang_skill_core`**不要 vendor** `scripts/jiangchang_skill_core/`
- 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright` - 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright`
- `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。 - `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。
- `health``collect_runtime_diagnostics` 做只读诊断。 - `health``collect_runtime_diagnostics` 做只读诊断。
@@ -150,6 +150,7 @@ python {baseDir}/scripts/main.py init-db
## 平台元数据 ## 平台元数据
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。 - `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表ID 来自匠厂宿主「设置 → 用户信息」)
- 开发期技能常为不公开:不加本人 ID开发者自己也无法在市场安装自测。取 ID 与写入步骤见 `development/DEVELOPMENT.md` §6。
-`access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access` -`access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id` - `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`

View File

@@ -16,7 +16,9 @@
1. 已安装并登录匠厂客户端 1. 已安装并登录匠厂客户端
2. 已在技能市场安装本技能(或更新到最新版) 2. 已在技能市场安装本技能(或更新到最新版)
3.如需】相关账号已登录、权限可用;需要的文件 / 时间范围 / 单号等已准备好 3.网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
5. 【如需】需要的文件 / 时间范围 / 单号等已准备好
--- ---

View File

@@ -100,7 +100,9 @@ def get_adapter():
2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**`call_sibling_json`**不要**在 `task_service.py``task_rpa.py` 等业务流程文件中到处散落 `subprocess.run` 2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**`call_sibling_json`**不要**在 `task_service.py``task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa``real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。 3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa``real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/``util/``db/`)。 4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/``util/``db/`)。
5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。 5. **pick/ensure lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。
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`
```python ```python
# 普通兄弟技能 — 走 sibling_bridge # 普通兄弟技能 — 走 sibling_bridge
@@ -110,10 +112,10 @@ result = call_sibling_json("account-manager", ["list", "--limit", "10"])
``` ```
```python ```python
# account-manager 账号/租约 — 集中在 account_client.py # account-manager 账号/租约 — 集中在 account_client.pyensure-web
from service.account_client import pick_web_account, release_lease from service.account_client import pick_web_account, release_lease
account = pick_web_account(platform="target_platform") account = pick_web_account(platform="target_platform", login_id=None)
try: try:
... ...
finally: finally:

View File

@@ -50,8 +50,8 @@
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 # 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
OPENCLAW_TEST_TARGET=mock # 可选mock=本地模拟simulator_rpa=仿真站点real_api=真实接口real_rpa=真实浏览器;模板默认 mock日常正式使用请改为 real_rpa OPENCLAW_TEST_TARGET=mock # 可选mock=本地模拟simulator_rpa=仿真站点real_api=真实接口real_rpa=真实浏览器;模板默认 mock日常正式使用请改为 real_rpa
# 是否显示浏览器窗口:打开后能否看见自动化操作过程 # 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(推荐,便于排查)1=后台静默运行 OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查)1=后台静默(勿作日常用法)
``` ```
### 反例(禁止) ### 反例(禁止)
@@ -79,10 +79,13 @@ OPENCLAW_TEST_TARGET=mock # 可选mock=本地模拟simulator_
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可
# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) # 默认登录账号:仅填写非敏感的账号标识(如工号/登录名)
DEFAULT_LOGIN_ID=04110001 # 只填账号标识,不要填密码;密码请在「账号管理」中登记 DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码
# 是否显示浏览器窗口:打开后能否看见自动化操作过程 # 默认账号编号:仅填账号管理里的纯数字主键(一般留空)
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口推荐便于排查1=后台静默运行 DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号/用户名请填上方登录账号
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口必须便于介入与排查1=后台静默(勿作日常用法)
# 浏览器防检测:降低被网站识别为自动化工具的概率 # 浏览器防检测:降低被网站识别为自动化工具的概率
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启推荐0=关闭;一般保持默认 OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启推荐0=关闭;一般保持默认
@@ -161,6 +164,16 @@ config.get_bool("OPENCLAW_BROWSER_HEADLESS")
config.get_float("STEP_DELAY_MIN", 1.0) config.get_float("STEP_DELAY_MIN", 1.0)
``` ```
### 空值、行尾注释与假默认(硬规则)
- **留空即未配置**`KEY=``KEY= # 说明` 必须读成空字符串,再走技能自己的「自动 / ensure」逻辑**禁止**把示例说明、`# 可填…` 当成真实配置值。
- **需要 platform-kit ≥ 1.2.2**:旧版解析曾把「空值 + 行尾 `#`」误当成 `# 可填…` 这种假值。技能若依赖正确空值语义,须在 `SKILL.md` 声明 `platform_kit_min_version: "1.2.2"`(或更高)。
- **网页账号配置语义**(有则用):
- `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;非数字非空可当 login_id不推荐优先用 `DEFAULT_LOGIN_ID`
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`
- 占位串(`#…``none``n/a``default-account`)一律当未配置
- **不要**在 `.env.example` 里塞「看起来像真值」的演示账号(如假手机号),以免首次落盘后被当成用户配置。
## health / config-path ## health / config-path
- **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok``ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path` - **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok``ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`

View File

@@ -77,13 +77,21 @@
`skill-template` 不是业务 skill它只是一个**新 skill 仓库模板**。 `skill-template` 不是业务 skill它只是一个**新 skill 仓库模板**。
你不应该直接在这个仓库里开发业务,而应该 你不应该直接在这个仓库里开发业务。新技能仓库有**两种合法来源**(详见 §4「第一步」
0. 按 [`NAMING.md`](NAMING.md) 确定 slug`{verb}-{noun-phrase}-{platform}` | 来源 | 适用情况 | 怎么做 |
1. **优先**用 [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1) 创建新目录(见 [`tools/README.md`](../tools/README.md) |------|----------|--------|
2. 在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git` | **A. Gitea 克隆** | 项目经理已在 [git.jc2009.com](https://git.jc2009.com/) 开好业务仓并给你权限 | `git clone` 到本地后按 [`REQUIREMENTS.md`](REQUIREMENTS.md) 开发 |
3. 把占位内容替换掉 | **B. 本地模板复制** | 你本机已有 `skill-template` 源码,要新建尚未灌仓的技能目录 | **优先** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(见 [`tools/README.md`](../tools/README.md));也可手工复制但必须清掉模板 `.git` |
4. 再开始写业务逻辑
拿到仓库后的推荐顺序:
0. 按 [`NAMING.md`](NAMING.md) 确定 / 核对 slug`{verb}-{noun-phrase}-{platform}`
1. **先填写**本仓 [`REQUIREMENTS.md`](REQUIREMENTS.md)(范围与验收),再写业务代码
2. 来源 B在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`
3. 把占位内容替换掉;**尽早**配置 `developer_ids`(见 §6开发期宿主自测必做
4. 再实现 `scripts/service/` 等业务逻辑
5. 本地测试通过 → `release.ps1` → Gitea CI → 匠厂多入口验收§15
> **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git``git remote -v` 必须指向新技能仓库,不能仍是 skill-template。 > **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git``git remote -v` 必须指向新技能仓库,不能仍是 skill-template。
@@ -164,13 +172,13 @@ scripts/
作用:常量、日志、路径、时间工具、通用帮助函数 作用:常量、日志、路径、时间工具、通用帮助函数
`util/logging_config.py``jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger `util/logging_config.py``jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger
公共能力config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP从共享 runtime 的 `jiangchang-platform-kit>=1.2.0` import**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。 公共能力config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP从共享 runtime 的 `jiangchang-platform-kit>=1.2.2` import**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
## 3.2 开发 RPA 类 skill ## 3.2 开发 RPA 类 skill
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地: 若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读三份标准**`RPA.md`三端范式与反反爬)、`CONFIG.md``.env` 落盘与读取)、`ADAPTER.md`(四档 adapter 1. **先读三份标准**`RPA.md`含 §0.2Profile 强制 / 有头 / 登录分平台)、`CONFIG.md``.env` 落盘与读取)、`ADAPTER.md`(四档 adapter
2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map 2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map
- 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/` - 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`
- `examples/real_browser_rpa/scripts/service/browser_session.py` - `examples/real_browser_rpa/scripts/service/browser_session.py`
@@ -185,6 +193,7 @@ scripts/
- `examples/simulator_browser_rpa/scripts/service/task_service.py`async 编排) - `examples/simulator_browser_rpa/scripts/service/task_service.py`async 编排)
- `sandbox/demo_app.html` 仅留在 examples不进入生产 skill - `sandbox/demo_app.html` 仅留在 examples不进入生产 skill
- **禁止**`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess - **禁止**`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
- **网页硬规则**:未拿到 `profile_dir` 禁止开浏览器;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
3. **只用共享库,不在 skill 里重写反反爬** 3. **只用共享库,不在 skill 里重写反反爬**
```python ```python
from jiangchang_skill_core import config from jiangchang_skill_core import config
@@ -201,7 +210,7 @@ scripts/
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。 技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。
- **公共依赖**`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。 - **公共依赖**`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.0`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。 - `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.2`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。 - 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。 - **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法: - **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
@@ -255,13 +264,26 @@ release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前
下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。 下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。
### 第一步:复制模板并改目录名 ### 第一步:拿到新技能仓库(两种来源)
例如你要开发 `disburse-payroll-icbc` 一类领域 skill目录名 = slug 例如你要开发 `disburse-payroll-icbc` 一类领域 skill目录名 = slug。下列两种来源**同等合法**,按你实际拿到仓库的方式选一条。
#### 推荐方式(首选 #### 来源 A从 Gitea 克隆(项目经理已开仓
在 **skill-template 仓库根目录**执行: 1. 确认项目经理已在 [https://git.jc2009.com/](https://git.jc2009.com/) 创建**本技能**仓库,并给你拉取/推送权限。
2. 克隆到本地(示例):
```powershell
git clone https://git.jc2009.com/<org>/<your-skill-slug>.git
cd <your-skill-slug>
git remote -v # 确认 origin 指向本技能仓,不是 skill-template
```
3. 若仓内已是从本模板 scaffold 好的结构,直接进入「第二步」与 [`REQUIREMENTS.md`](REQUIREMENTS.md)**不要**再整仓复制 `skill-template` 覆盖,以免冲掉已有提交或串 Git 历史。
#### 来源 B本地已有 skill-template 时复制 / scaffold
在 **skill-template 仓库根目录**执行(推荐):
```powershell ```powershell
.\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc .\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc
@@ -275,7 +297,7 @@ git remote -v # 确认 origin 不是 skill-template
脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。 脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。
#### 禁止方式 ##### 禁止方式(来源 B
| 做法 | 后果 | | 做法 | 后果 |
|------|------| |------|------|
@@ -283,7 +305,7 @@ git remote -v # 确认 origin 不是 skill-template
| ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template | | ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template |
| ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 | | ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 |
#### 若已手工复制(补救) ##### 若已手工复制(补救)
```powershell ```powershell
cd <新技能目录> cd <新技能目录>
@@ -295,7 +317,7 @@ git remote add origin <新技能仓库 URL>
git remote -v git remote -v
``` ```
#### AI / 编程代理复制红线 ##### AI / 编程代理复制红线
| 禁止 | 说明 | | 禁止 | 说明 |
|------|------| |------|------|
@@ -308,7 +330,7 @@ git remote -v
### 第二步:先改 4 个最关键的标识 ### 第二步:先改 4 个最关键的标识
复制后优先改下面这些地方: 拿到仓库并填好 / 更新 [`REQUIREMENTS.md`](REQUIREMENTS.md) 后,优先改下面这些地方:
1. `SKILL.md` 1. `SKILL.md`
2. 根目录市场四 Tab`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md` 2. 根目录市场四 Tab`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`
@@ -324,7 +346,7 @@ git remote -v
- 平台内部键 - 平台内部键
- 日志 logger 名 - 日志 logger 名
此外,如果该技能发布后默认不公开(`access_scope = 0`),建议一开始就把 `SKILL.md` 中的 `metadata.openclaw.developer_ids` 配好。这样后续发布到平台时,开发者本人仍能在技能市场看到并验证该技能 **开发 / 联调阶段务必尽早**配置 `SKILL.md` 中的 `metadata.openclaw.developer_ids`(完整目的与取 ID 步骤见 §6「关于 developer_ids」。开发期技能在匠厂常为不公开不加本人用户 ID**技术人员自己也无法在技能市场看到并安装自测**
## 5. 哪些占位内容必须替换 ## 5. 哪些占位内容必须替换
@@ -384,14 +406,28 @@ git remote -v
- `references/`CLI / SCHEMA - `references/`CLI / SCHEMA
- 代码注释与 `service/` 实现 - 代码注释与 `service/` 实现
### 关于 `metadata.openclaw.developer_ids` ### 关于 `metadata.openclaw.developer_ids`(开发自测必做)
这是一个平台发布元数据字段,用于解决下面这个问题: #### 目的(先理解再填)
- 技能发布后若平台记录中的 `access_scope = 0`,技能默认不公开 开发 / 测试阶段,技能 `release` 到匠厂后,平台侧通常为**不公开**`access_scope = 0`
- 如果不额外授权,连开发者自己也可能在技能市场里看不到这个技能
因此可以在 `SKILL.md` 中声明: - 技能市场里对普通人**不可见**
- **若不额外授权,连开发该技能的技术人员自己也看不见、装不了**
- 看不见 → 无法安装 → 无法在宿主做 §15 的多入口验收(新建任务、数据管理、定时任务等)
因此 `developer_ids` 不是可有可无的装饰字段,而是**开发期自测通行证**:把本人(及需要一起验收的同事)的匠厂**用户 ID** 写进 `SKILL.md`,发布时平台将这些用户补进可见 / 可访问范围,使开发者能在「对其他人仍不可见」的前提下完成安装与测试。
#### 如何获取匠厂用户 ID完整步骤
1. **下载并安装**匠厂客户端:[https://jc2009.com/product.html](https://jc2009.com/product.html)
2. **注册并登录**(使用将用于开发自测的账号)
3. 打开客户端左下角头像旁的**设置**(齿轮)
4. 在 **用户信息** 中查看 **用户 ID**(正整数),点击旁边的 **复制**
5. 将该 ID 写入本技能 `SKILL.md` 的 `metadata.openclaw.developer_ids`(见下例)
6. **必须替换**模板里的示例 ID如 `10032`、`12428` 等占位),不要原样留着模板作者的 ID 却指望自己账号能看见技能
#### 在 `SKILL.md` 中声明
```yaml ```yaml
metadata: metadata:
@@ -399,17 +435,17 @@ metadata:
slug: your-skill-slug slug: your-skill-slug
category: 通用 category: 通用
developer_ids: developer_ids:
- 1032 - 12580 # 换成你在匠厂「设置 → 用户信息」复制的用户 ID
- 12428
``` ```
约定如下: 约定如下(原有规则保留)
- 只允许填写正整数用户 ID - 只允许填写正整数用户 ID(来自匠厂宿主,不是 Gitea / Git 账号名)
- 推荐使用数组,即使当前只有 1 个开发者 - 推荐使用数组,即使当前只有 1 个开发者
- 发布时平台会把这些用户自动补写到 `skill_user_access` - 发布时平台会把这些用户自动补写到 `skill_user_access`
- 第一个 ID 会同步到 `skills.developer_id` - 第一个 ID 会同步到 `skills.developer_id`
- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权 - 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权
- **首次正式 release 前**就应配好配错或漏配时CI 可能成功,但你在技能市场仍找不到技能
## 7. 文档目录分工 ## 7. 文档目录分工
@@ -788,9 +824,11 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- 工作流文件是否存在 - 工作流文件是否存在
- 发布包结构是否符合模板规范 - 发布包结构是否符合模板规范
### 第四步:进入匠厂平台下载安装包 ### 第四步:进入匠厂客户端(用于安装验收)
当工作流成功后,就可以进入匠厂平台验证最终安装效果。 当工作流成功后,就可以进入匠厂客户端验证最终安装效果。
若你已按 §6 为 `developer_ids` 安装并登录过匠厂,**直接使用同一客户端、同一账号**即可,无需重复下载。若尚未安装:
匠厂产品下载地址: 匠厂产品下载地址:
@@ -802,11 +840,13 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
匠厂产品页可从这里进入:[产品下载 - 匠厂](https://jc2009.com/product.html) 匠厂产品页可从这里进入:[产品下载 - 匠厂](https://jc2009.com/product.html)
### 第五步:安装匠厂后,在技能市场检查最新 skill > 取用户 ID 写入 `developer_ids` 的完整步骤见 §6此处侧重发布后的安装验收。
安装并启动匠厂后,进入左侧“技能市场”,搜索或查找刚刚发布的 skill确认以下内容 ### 第五步:在技能市场检查最新 skill
- 技能可以被正常检索到 使用已写入 `developer_ids` 的账号登录并启动匠厂后,进入左侧“技能市场”,搜索或查找刚刚发布的 skill确认以下内容
- 技能可以被正常检索到(开发期不公开时,**仅** `developer_ids` 内账号可见)
- 技能名称、说明、版本信息正确 - 技能名称、说明、版本信息正确
- 最新版本已经同步出来 - 最新版本已经同步出来
- 可以正常安装或更新 - 可以正常安装或更新
@@ -825,11 +865,25 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- 安装后状态正常 - 安装后状态正常
- 不会出现缺文件、缺入口或安装失败的问题 - 不会出现缺文件、缺入口或安装失败的问题
### 第七步:在“新建任务”中实际使用该 skill ### 第七步:按声明做宿主多入口验收(不要只测对话)
安装完成后,不要只停留在“已安装”状态,还需要进入“新建任务”页面,真正调用一次该 skill完成最终验证 安装完成后,不要只停留在“已安装”状态。宿主侧栏有多条与技能相关的入口;**按本技能 `assets/actions.json` 的 `placements` / `executionProfile` 声明逐项测**(契约细节见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md))。未声明的入口可以跳过;**已声明却测不到,视为验收失败**
建议至少验证: | 宿主入口(侧栏 / 界面) | 技能侧如何挂上 | 建议验收什么 |
|-------------------------|----------------|--------------|
| **新建任务**(对话 Agent | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
| **数据管理** | `placements` 含 `toolbar`(须合法非空 `bind.tables`);表数据来自技能本地库 / `init-db` | 左侧能看到本技能库表;表顶栏出现对应按钮;点按后行为符合预期 |
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发到点或「立即运行」行为正确 |
| **任务中心** | 不是 placement由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致(对话 / 数据管理 / 定时等) |
| **技能市场 → 技能详情** | 安装与四 Tab 文案;`placements` 含 `skill-detail` 为契约预留 | **必验**:安装、说明 / 教程 / 演示 / 更新日志。详情页「技能直调」按钮:当前宿主 UI **尚未落地**manifest 可写 `skill-detail`,勿仅依赖该入口做主验收) |
补充说明:
- **进不进任务中心只看 `executionProfile`**,与从新建任务、数据管理还是定时任务触发无关(既有正交规则不变)。
- 数据管理还依赖库表元数据(`_jiangchang_*` 等,见 [`../references/SCHEMA.md`](../references/SCHEMA.md));仅有 Action、没有可展示库表时侧栏可能看不到表。
- 模板对 `row` / `batch` 的约定仍见 [`../references/ACTIONS.md`](../references/ACTIONS.md)(示例请勿使用);宿主能力以匠厂版本为准,新技能主路径仍以文档已稳定描述的 `toolbar` / `cron` / `agent` 为准。
**新建任务(对话)最小检查:**
- 新任务中可以正常选择或触发该 skill - 新任务中可以正常选择或触发该 skill
- skill 能被正确唤起 - skill 能被正确唤起
@@ -840,6 +894,8 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
![新建任务中使用技能](../assets/screenshots/new-task-usage.png) ![新建任务中使用技能](../assets/screenshots/new-task-usage.png)
若第五步在技能市场**搜不到**本技能:先核对登录账号的用户 ID 是否已写入 `developer_ids` 并随本次 release 发布(见 §6不要只反复重装客户端。
## 16. 发布前检查清单 ## 16. 发布前检查清单
每个新 skill 发布前,建议技术人员逐条确认: 每个新 skill 发布前,建议技术人员逐条确认:
@@ -848,6 +904,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] slug 符合 [`NAMING.md`](NAMING.md)verb-noun-platform - [ ] slug 符合 [`NAMING.md`](NAMING.md)verb-noun-platform
- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致 - [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致
- [ ] `SKILL.md` 中 slug、名称、描述都已替换 - [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `SKILL.md` 的 `developer_ids` 已换成**本人**匠厂用户 ID设置 → 用户信息 → 复制),不是模板示例 ID
- [ ] `scripts/util/constants.py` 已修改 - [ ] `scripts/util/constants.py` 已修改
- [ ] `../references/CLI.md` 示例命令已改成真实命令 - [ ] `../references/CLI.md` 示例命令已改成真实命令
- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现 - [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
@@ -867,7 +924,9 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀 - [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template` - [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`
- [ ] `git remote -v` 指向**本技能**远端URL 不含 skill-template 仓库名 - [ ] `git remote -v` 指向**本技能**远端URL 不含 skill-template 仓库名
- [ ] `git log` 首条提交属于本技能(非模板历史) - [ ] `git log` 首条提交属于本技能(非模板历史)(来源 A 从 Gitea 克隆的已有业务仓,以该仓历史为准)
- [ ] 发布后计划在宿主按 §15 第七步验收:已声明的 **新建任务 / 数据管理 / 定时任务 / 任务中心(async)** 均已覆盖
- [ ] 网页 RPA`ensure-web` 取得 `profile_dir` 再开浏览器;`.env` 默认有头REQUIREMENTS 写明登录策略required/optional/not_needed会话依赖型勿抄 optional用户 README/TUTORIAL 区分「登记账号」与「站点登录」(`RPA.md` §0.2 / `POLICY-RPA-004`);依赖 kit 空值解析等修复时上调 `platform_kit_min_version`
## 17. 常见错误 ## 17. 常见错误

View File

@@ -19,6 +19,7 @@
| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py` | | POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py` |
| POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` | | POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` |
| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession``video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3development/LOGGING.mddevelopment/CONFIG.md | hard | 扫描 `scripts/service/*.py`service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` | | POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession``video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3development/LOGGING.mddevelopment/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.2development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` | | POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM`U+FEFF` | development/DEVELOPMENT.md §3.4development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` | | POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM`U+FEFF` | development/DEVELOPMENT.md §3.4development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` | | POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |
@@ -58,5 +59,5 @@
| RPA 拟人操作、选择器纪律、HITL 超时 | development/RPA.md §0§1 | 行为与 DOM 质量,无法静态扫描 | | RPA 拟人操作、选择器纪律、HITL 超时 | development/RPA.md §0§1 | 行为与 DOM 质量,无法静态扫描 |
| adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 | | adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 |
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` | | `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
| platform_kit_min_version >= 1.2.0 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` | | platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |
| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 | | 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 |

View File

@@ -1,20 +1,38 @@
# 开发资料入口 # 开发资料入口
本目录面向**人类开发者**与 **AI 编程代理**。开始定制 skill 前,建议按以下顺序阅读:
1. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准 本目录面向**人类开发者**与 **AI 编程代理**。技术人员与编程 AI **以本目录为主读路径**`references/` 是 CLI / Action / Schema 契约细节,根目录市场四 Tab 面向最终用户。
2. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
3. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
4. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
5. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读** ## 新技能全流程(先看这张表
6. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
7. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
8. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
9. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读) 按顺序做;细节与约束见 [`DEVELOPMENT.md`](DEVELOPMENT.md) 对应章节(**不替代**下文深度规范,只作导航)。
10. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
11. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)`scaffold_skill.ps1`)。 | 步骤 | 做什么 | 详见 |
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),不要写进本目录。
| 1 | **拿到仓库**:① 项目经理在 [Gitea](https://git.jc2009.com/) 开仓并授权后 `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 | 匠厂技能市场安装后,按声明测:**新建任务(Agent)** / **数据管理** / **定时任务** / **任务中心**(async) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15契约见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) |
脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)`scaffold_skill.ps1`)。

View File

@@ -22,6 +22,13 @@
- 中文 description一句话 - 中文 description一句话
- account-manager platform_key如有 - account-manager platform_key如有
- 命名形态:标准型 / scope 型 - 命名形态:标准型 / scope 型
- **网页 RPA如有— 登录策略(三选一,须写明):**
- `required`:目标站必须登录才能闭环(用户须完成/保持站点登录;技能每轮主路径须有登录门禁 + 验证码/人工等待)
- `optional`:登录可提升能力,但不登录也能完成主路径(**仅当**公开页确可闭环时选用;会话依赖型采集/评论/私信**禁止**默认抄成 optional
- `not_needed`:公开页即可,不要求站点登录
- 无论上列哪一种:**都必须**经 account-manager **`ensure-web`**(或等价)取得 `profile_dir`(见 `development/RPA.md` §0.2);不得使用系统默认浏览器用户目录
- **登记 ≠ 登录**ensure/add-web 只建账号与 Profile站点登录由本技能在有头浏览器中完成
- **反模式**:把「需要登录态才能采到数据」的需求写成 `optional`,再省略 `ensure_logged_in`
## 1. 文档目标 ## 1. 文档目标
@@ -103,6 +110,7 @@
- Windows 环境下需保证 UTF-8 输出兼容 - Windows 环境下需保证 UTF-8 输出兼容
- 必须具备基本日志能力;敏感字段脱敏 - 必须具备基本日志能力;敏感字段脱敏
- RPA 类 skillPlaywright 由宿主共享 runtime 提供;技能侧不要 `playwright install`URL 不要放进 `launch_persistent_context``args` - RPA 类 skillPlaywright 由宿主共享 runtime 提供;技能侧不要 `playwright install`URL 不要放进 `launch_persistent_context``args`
- 网页 RPA启动前必须经 ensure-web 拿到 `profile_dir`;生产有头;站点登录策略见 §0required / optional / not_needed`required` 时每轮须登录门禁
## 6. 输入输出要求 ## 6. 输入输出要求
@@ -163,14 +171,16 @@
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order` - 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
- `tests/test_display_metadata.py` 通过 - `tests/test_display_metadata.py` 通过
- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联 - 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联
- 发布后 Gitea 工作流成功;匠厂技能市场可见最新版本;安装后可在“新建任务”中调用 - `SKILL.md``developer_ids` 已配置为开发者本人匠厂用户 ID开发期不公开时否则本人无法在市场安装自测取 ID 步骤见 `development/DEVELOPMENT.md` §6
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
- 安装后按本技能 `actions.json` 声明完成宿主多入口验收:至少覆盖已声明的「新建任务(Agent)」;若声明了 `toolbar` / `cron` / `async`,还须分别验收数据管理、定时任务、任务中心(见 `development/DEVELOPMENT.md` §15 第七步)
## 10. 开发注意事项 ## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目 - 只修改当前 skill 仓库,不要改动无关兄弟项目
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md` - 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md` - `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`
- 发布前完成本地验证、工作流验证和正式环境安装验证 - 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)
## 11. 变更记录 ## 11. 变更记录
@@ -182,10 +192,11 @@
## 建议使用方式 ## 建议使用方式
1. 先写 `REQUIREMENTS.md` 1. 拿到业务技能仓库Gitea clone或本地从 `skill-template` scaffold / 复制;见 `DEVELOPMENT.md` §4
2. 再按 `DEVELOPMENT.md` 进入开发 2. 先写 / 填全本文件 `REQUIREMENTS.md`
3. 开发过程中补充 `references/CLI.md``references/SCHEMA.md``development/` 技术规范 3. 再按 `DEVELOPMENT.md` 进入开发(含尽早配置 `developer_ids`
4. 发布前对照第 9 节验收标准逐项检查 4. 开发过程中补充 `references/CLI.md``references/SCHEMA.md``development/` 技术规范
5. 发布前对照第 9 节验收标准逐项检查release 后按 `DEVELOPMENT.md` §15 做宿主多入口验收
## 最小模板示例 ## 最小模板示例

View File

@@ -2,7 +2,7 @@
> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill都应先读这份文档按这里的选型和范式落地不要每个项目重新踩坑。 > 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill都应先读这份文档按这里的选型和范式落地不要每个项目重新踩坑。
我们开发的各类 skill本质上都是在替人操作三类界面**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**保持登录态、有头运行、拟人操作、失败存证、人工兜底。 我们开发的各类 skill本质上都是在替人操作三类界面**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**可控会话Profile、有头运行、拟人操作、失败存证、人工兜底。
--- ---
@@ -12,8 +12,8 @@
| 约定 | 说明 | | 约定 | 说明 |
|------|------| |------|------|
| **保持登录态** | 复用持久化 Profile / session避免每次重新登录触发风控账号由 account-manager 下发,不硬编码 | | **可控会话 / Profile** | 网页版见下方 **§0.2**(必须走 account-manager 的 `profile_dir`);桌面/手机用对应持久会话,不硬编码密码 |
| **有头运行** | 默认有头headless 易被识别 / 难人工介入);`OPENCLAW_BROWSER_HEADLESS=1` 仅给 CI | | **有头运行** | 生产 RPA **必须有头**(见 §0.2);禁止把无头当交付/联调常态 |
| **拟人操作** | 真实事件isTrusted=true逐字输入、随机延迟、贝塞尔鼠标轨迹严禁 JS 直接设值/JS 点击/JS 跳转 | | **拟人操作** | 真实事件isTrusted=true逐字输入、随机延迟、贝塞尔鼠标轨迹严禁 JS 直接设值/JS 点击/JS 跳转 |
| **步骤间随机等待** | 每两步操作之间 `random_delay(min,max)`,区间由 `.env` 配置(默认 1~5s | | **步骤间随机等待** | 每两步操作之间 `random_delay(min,max)`,区间由 `.env` 配置(默认 1~5s |
| **人工兜底HITL** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 | | **人工兜底HITL** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
@@ -44,6 +44,32 @@ async def open_login(page):
规范权威定义:[`LOGGING.md`](LOGGING.md) §2.5。金样代码:[`scripts/service/task_service.py`](../scripts/service/task_service.py)。 规范权威定义:[`LOGGING.md`](LOGGING.md) §2.5。金样代码:[`scripts/service/task_service.py`](../scripts/service/task_service.py)。
### 0.2 网页版 RPA 硬规则Profile / 有头 / 登录分平台)
凡**打开网页、用浏览器自动化**的技能(真实站或仿真站),必须遵守(`POLICY-RPA-004`
| 规则 | 要求 |
|------|------|
| **Profile 强制** | 启动浏览器前必须已从 **account-manager**(经本技能 `account_client`)拿到可用的 **`profile_dir`(及租约约定)**。只用该目录做 `launch_persistent_context`。 |
| **首启用 ensure** | 业务技能默认走 **`account ensure-web`**(或 `pick-web --ensure`),经 `account_client.pick_web_account` 封装。无匹配账号时自动 `add-web` 再返回;**有账号但均被租约占用 → `ERROR:LEASE_CONFLICT`,禁止再新建重复账号**。 |
| **拿不到就停** | 未拿到 `profile_dir`、ensure 失败、租约失败 → **立即失败退出,禁止开浏览器**。不得静默落到系统默认 Chrome/Edge 用户目录。 |
| **有头强制** | 生产与用户侧联调:**`OPENCLAW_BROWSER_HEADLESS=0`(有头)**。禁止把无头(`=1`)当作交付或真实站联调常态(无头易风控、难人工介入,且易污染身份)。 |
| **登录不全局强制** | **是否要在目标站完成登录**由平台决定(如部分公开页可不登录也能闭环)。由做该技能的同事在 `REQUIREMENTS.md` / 用户教程中约定;**模板不强制「所有网页 RPA 必须先登录」**。 |
| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 |
| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录网页操作都依赖账号管理下发的 **Profile**。 |
| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`**禁止**把会话依赖型采集默认抄成 `optional`。 |
**反模式(禁止):**
- 未拿到 `profile_dir``launch` / 仍打开页面
- 用系统默认用户数据目录或随意空目录反复跑真实站 → **污染本机 Profile、加重风控**
- 把「本站可不登录」写成「可以不走 account-manager」
- 真实站联调默认无头狂跑
- 仅用哈希 class / 不稳定 CSS 作为**唯一**登录态判定(须结合稳定文案、`get_by_role`、URL/业务 DOMF12 实页确认)
- 把「业务空结果」(如无评论)与 `LEASE_CONFLICT` / `REQUIRE_LOGIN` 混成同一错误码
**参考实现:** `examples/real_browser_rpa/``examples/simulator_browser_rpa/``pick_web_account` → ensure-web → RPA → `finally release_lease`)。
--- ---
## 1. 浏览器(标准已成熟) ## 1. 浏览器(标准已成熟)
@@ -55,21 +81,22 @@ async def open_login(page):
| 项 | 标准 | | 项 | 标准 |
|----|------| |----|------|
| 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context``channel="chrome"` 或 Edge不用内置 Chromium**技能内不要 `playwright install`** | | 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context``channel="chrome"` 或 Edge不用内置 Chromium**技能内不要 `playwright install`** |
| 登录态 | 持久 Profile 目录账号、profile、lease **统一走 account-manager**(或对应兄弟技能),不硬编码密码 | | Profile / 账号 | **必须**经 account-manager **`ensure-web`**(或 `pick-web --ensure`)取得 `profile_dir`(见 §0.2);密码不进 `.env`**是否站点登录**按平台在需求/教程约定;登记 ≠ 已登录 |
| CDP | **仅作诊断 / 桌面宿主类场景****不要**作为强风控站点的默认生产路径 | | CDP | **仅作诊断 / 桌面宿主类场景****不要**作为强风控站点的默认生产路径 |
| 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 | | 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 |
| 模式 | 默认有头 `OPENCLAW_BROWSER_HEADLESS=0`;无头仅 CI | | 模式 | **有头** `OPENCLAW_BROWSER_HEADLESS=0`(生产强制,见 §0.2 |
| 反检测 | stealth 默认开 `OPENCLAW_PLAYWRIGHT_STEALTH=1`(见 1.1 | | 反检测 | stealth 默认开 `OPENCLAW_PLAYWRIGHT_STEALTH=1`(见 1.1 |
### 1.1 Playwright 启动标准 ### 1.1 Playwright 启动标准
1. **默认有头**`OPENCLAW_BROWSER_HEADLESS=0``.env.example` 默认)。 1. **有头**`OPENCLAW_BROWSER_HEADLESS=0``.env.example` 默认;网页 RPA 生产路径禁止改成无头常态,见 §0.2)。
2. **stealth 默认开**`OPENCLAW_PLAYWRIGHT_STEALTH=1`;通过 `add_init_script` 注入指纹淡化脚本。 2. **stealth 默认开**`OPENCLAW_PLAYWRIGHT_STEALTH=1`;通过 `add_init_script` 注入指纹淡化脚本。
3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。 3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。
4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。 4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。
5. **不要默认传 `--disable-blink-features=AutomationControlled`**platform-kit stealth 已覆盖,额外 flag 可能适得其反。 5. **不要默认传 `--disable-blink-features=AutomationControlled`**platform-kit stealth 已覆盖,额外 flag 可能适得其反。
6. **可以** `ignore_default_args=["--enable-automation"]`platform-kit `launch_persistent_browser` 已处理)。 6. **可以** `ignore_default_args=["--enable-automation"]`platform-kit `launch_persistent_browser` 已处理)。
7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、持久 profile**不要**直接拼接搜索结果 URL 或 DOM 注入。 7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、account-manager 持久 profile**不要**直接拼接搜索结果 URL 或 DOM 注入。
8. **先 Profile 再开浏览器**`user_data_dir` 必须来自 ensure/pick 到的 `profile_dir`;未拿到则不得进入本节后续步骤。
指纹淡化stealth典型项`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages` 等。共享实现见 `jiangchang_skill_core.rpa`platform-kit **>= 1.2.0**)。 指纹淡化stealth典型项`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages` 等。共享实现见 `jiangchang_skill_core.rpa`platform-kit **>= 1.2.0**)。
@@ -186,7 +213,7 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
| Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 | | Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 |
| 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`+ `account_client.py` subprocess | | 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`+ `account_client.py` subprocess |
| 登录 | **双层**:门户 HITL`#portal-user` / `#portal-pass` 类泛化 DOM+ 业务系统登录(技能内自写) | | 登录 | **双层**:门户 HITL`#portal-user` / `#portal-pass` 类泛化 DOM+ 业务系统登录(技能内自写) |
| 账号 | `url` 用行业根,不用 `/login``auth_strategy=per_session_manual``pick_web_account` + `release_lease` | | 账号 | `url` 用行业根,不用 `/login``auth_strategy=per_session_manual``pick_web_account`(底层 ensure-web+ `release_lease` |
| 禁止 | **不要** `import account-manager``rpa_helpers` 等内部模块 | | 禁止 | **不要** `import account-manager``rpa_helpers` 等内部模块 |
| Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback共享 sandbox **不要求**为技能加 testid | | Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback共享 sandbox **不要求**为技能加 testid |
| Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 | | Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 |
@@ -260,14 +287,18 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
| 错误码 | 含义 | 上层处理建议 | | 错误码 | 含义 | 上层处理建议 |
|--------|------|------| |--------|------|------|
| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程 | | `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程(有头等待人工) |
| `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 | | `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 |
| `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 | | `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 |
| `ERROR:LEASE_CONFLICT` | 已有账号但均被租约占用ensure 不新建) | 稍后重试或释放租约;**不是**「无账号」 |
| `ERROR:AMBIGUOUS_ACCOUNT` | `--login-id` / `--label` 匹配到多条 | 改用数字 id 或更精确标识 |
| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 | | `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 |
| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 | | `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 |
| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB | | `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB |
| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 | | `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 |
业务空结果(如「本页无评论」)用业务码或成功空列表表达,**不要**复用上表账号/登录错误码。
--- ---
## 5. 存证与录屏规范 ## 5. 存证与录屏规范

View File

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

View File

@@ -62,14 +62,16 @@
多个入口统一走 `POST /api/skill-actions/run`,可能带 `source.kind` 多个入口统一走 `POST /api/skill-actions/run`,可能带 `source.kind`
| 入口 | 常见 `source.kind` | 说明 | | 入口(匠厂侧栏 / 界面) | 常见 `source.kind` | 技能侧 | 开发者怎么测 |
|------|-------------------|------| |-------------------------|-------------------|--------|--------------|
| **Agent** | `agent` | `run_skill_action`;按 manifest 的 executionProfile 执行 | | **新建任务**(对话 Agent | `agent` | `placements``agent``run_skill_action` | 侧栏「新建任务」→ 对话触发;按 manifest 的 executionProfile 执行 |
| **数据管理** | `data-management` | toolbar 按钮;须 `bind.tables` | | **数据管理** | `data-management` | `placements``toolbar`;须 `bind.tables` | 侧栏「数据管理」→ 打开本技能表 → 点表顶栏技能按钮 |
| **定时任务** | `cron` | Cron 配置参数后调用同一 Action | | **定时任务** | `cron` | `placements``cron` | 侧栏「定时任务」→ 新建「技能直调」并选本 Action |
| **技能详情** | 依宿主) | 与上同一 CLI / 同一业务内核 | | **任务中心** | 各入口触发的 Job | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement |
| **技能详情** | `skill-detail`(预留) | `placements` 可含 `skill-detail` | 市场详情四 Tab / 安装必验;详情页直调按钮宿主 UI **尚未落地**,主验收勿只靠此入口 |
技能 **不要** 在 Python 里按 `source` / placement 写业务分叉;宿主读 manifest 分流展示与等待策略。 技能 **不要** 在 Python 里按 `source` / placement 写业务分叉;宿主读 manifest 分流展示与等待策略。
发布后按入口逐项验收的操作清单见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §15 第七步。
--- ---

View File

@@ -137,7 +137,8 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认: 浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认:
- [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit` - [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`
- [ ] account-manager`platform ensure` + 账号 `active` + 有 `profile_dir` - [ ] account-manager`ensure-web`(或 `pick-web --ensure`+ 账号`profile_dir`(无 profile 禁止开浏览器,见 `RPA.md` §0.2
- [ ] `OPENCLAW_BROWSER_HEADLESS=0`(联调有头;勿默认无头)
- [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html` - [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据) - [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `python tests/run_tests.py -v` 仍全部通过mock 离线example 内 `pytest` 不启真实浏览器 - [ ] 默认 `python tests/run_tests.py -v` 仍全部通过mock 离线example 内 `pytest` 不启真实浏览器
@@ -261,7 +262,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright` - [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright`
- [ ]`scripts/jiangchang_skill_core/` vendored 副本 - [ ]`scripts/jiangchang_skill_core/` vendored 副本
- [ ] `platform_kit_min_version` **>= 1.2.0**`SKILL.md` + `constants.py` - [ ] `platform_kit_min_version` **>= 1.2.2**`SKILL.md` + `constants.py`
- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行) - [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行)
- [ ] `config-path` 可输出用户 `.env` 路径 JSON - [ ] `config-path` 可输出用户 `.env` 路径 JSON
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py` - [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`

View File

@@ -39,7 +39,7 @@ real_browser_rpa/
## 核心流程 ## 核心流程
1. **pick account** — 通过 `account_client.pick_web_account()` 获取 `profile_dir` 与租约 1. **ensure account** — 通过 `account_client.pick_web_account()`(底层 `ensure-web`获取 `profile_dir` 与租约;无账号则自动登记,全忙则 `LEASE_CONFLICT` 不重复建号。登记 ≠ 站点已登录。
2. **启动浏览器**`launch_persistent_context` + `new_page()` + `page.goto(start_url)` 2. **启动浏览器**`launch_persistent_context` + `new_page()` + `page.goto(start_url)`
3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成 3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成
4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现 4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现
@@ -79,6 +79,10 @@ real_browser_rpa/
**浏览器与启动约束:** **浏览器与启动约束:**
- **先 Profile 再开浏览器**:必须 `pick_web_account`(底层 ensure-web拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2
- **RPA 等待**:主路径用 `interruptible_sleep`,禁止裸 `asyncio.sleep``POLICY-CONTROL-003`
- **有头**:生产 / 联调 `OPENCLAW_BROWSER_HEADLESS=0`;勿把无头当交付常态
- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」
- **不要**在技能内安装 Playwright浏览器与 Python 包由宿主/runtime 提供 - **不要**在技能内安装 Playwright浏览器与 Python 包由宿主/runtime 提供
- `launch_persistent_context``args` **只放 Chrome 参数****不要把 URL 放进 `args`** - `launch_persistent_context``args` **只放 Chrome 参数****不要把 URL 放进 `args`**
- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入 - 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入
@@ -139,7 +143,7 @@ real_browser_rpa/
- **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用 - **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成 - **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
- **日志脱敏** — 不输出完整手机号/账号 - **日志脱敏** — 不输出完整手机号/账号
- **租约释放** — `pick-web --lease` 后必须在 `finally` 释放 - **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放
- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位) - **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位)
## 常见坑 ## 常见坑

View File

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

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import os import os
import random import random
@@ -11,6 +10,8 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from jiangchang_skill_core.activity import interruptible_sleep
from service.browser_session import close_browser_context, get_start_url, start_browser_session from service.browser_session import close_browser_context, get_start_url, start_browser_session
from service.human_verification import ( from service.human_verification import (
HumanVerificationWaitResult, HumanVerificationWaitResult,
@@ -102,11 +103,11 @@ def _step_cb(cb: Optional[StepCallback], text: str) -> None:
async def _random_delay() -> None: async def _random_delay() -> None:
lo = int(os.getenv("RPA_STEP_DELAY_MIN_MS") or "900") lo = int(os.getenv("RPA_STEP_DELAY_MIN_MS") or "900")
hi = int(os.getenv("RPA_STEP_DELAY_MAX_MS") or "2600") hi = int(os.getenv("RPA_STEP_DELAY_MAX_MS") or "2600")
await asyncio.sleep(random.uniform(lo / 1000.0, hi / 1000.0)) await interruptible_sleep(random.uniform(lo / 1000.0, hi / 1000.0))
async def _scroll_wait() -> None: async def _scroll_wait() -> None:
await asyncio.sleep(random.uniform(1.2, 3.5)) await interruptible_sleep(random.uniform(1.2, 3.5))
def _headless() -> bool: def _headless() -> bool:
@@ -156,7 +157,7 @@ async def _open_login_panel_if_needed(page) -> None:
btn = page.locator(LOGGED_OUT_SELECTOR).first btn = page.locator(LOGGED_OUT_SELECTOR).first
if await _visible(btn): if await _visible(btn):
await btn.click() await btn.click()
await asyncio.sleep(random.uniform(1.0, 3.0)) await interruptible_sleep(random.uniform(1.0, 3.0))
except Exception: except Exception:
pass pass
@@ -175,7 +176,7 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
if await _is_logged_in(page): if await _is_logged_in(page):
print("[登录] 检测到登录成功") print("[登录] 检测到登录成功")
return return
await asyncio.sleep(2.0) await interruptible_sleep(2.0)
raise RuntimeError( raise RuntimeError(
f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。" f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。"
@@ -253,7 +254,7 @@ async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool:
return True return True
except Exception: except Exception:
pass pass
await asyncio.sleep(0.8) await interruptible_sleep(0.8)
return False return False

View File

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

View File

@@ -50,13 +50,19 @@ simulator_browser_rpa/
| 文件 | 职责 | | 文件 | 职责 |
|---|---| |---|---|
| `browser_session.py` | async 系统 Chrome/Edge + launch args**不在此 goto** | | `browser_session.py` | async 系统 Chrome/Edge + launch args**不在此 goto** |
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account` / `release_lease` | | `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease` |
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 | | `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapterpick 账号、lease、委托 RPA、`finally release_lease` | | `adapter/simulator_rpa.py` | **薄** adapterpick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI | | `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` | | `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `sandbox/demo_app.html` | 可控 DOM含可选门户门闩 + 原批量提交流程 | | `sandbox/demo_app.html` | 可控 DOM含可选门户门闩 + 原批量提交流程 |
## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致)
- **先 Profile 再开浏览器**`simulator_rpa` 档必须 `pick_web_account`ensure-web拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0`
- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed但**仍须**走 account-manager
## 核心流程 ## 核心流程
1. `await run_batch_submit(target, items)` 校验参数 1. `await run_batch_submit(target, items)` 校验参数

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ POLICY_IDS = (
"POLICY-RPA-001", "POLICY-RPA-001",
"POLICY-RPA-002", "POLICY-RPA-002",
"POLICY-RPA-003", "POLICY-RPA-003",
"POLICY-RPA-004",
"POLICY-PACKAGING-001", "POLICY-PACKAGING-001",
"POLICY-PACKAGING-002", "POLICY-PACKAGING-002",
"POLICY-DOCS-001", "POLICY-DOCS-001",
@@ -142,6 +143,18 @@ RPA_003_SOURCE = (
"development/RPA.md §5.3; development/TESTING.md §12; development/POLICY_MATRIX.md" "development/RPA.md §5.3; development/TESTING.md §12; development/POLICY_MATRIX.md"
) )
RPA_004_SOURCE = (
"development/RPA.md §0.2; development/ADAPTER.md §兄弟依赖; development/POLICY_MATRIX.md"
)
RPA_004_HEADLESS_KEY_RE = re.compile(
r"(?m)^\s*OPENCLAW_BROWSER_HEADLESS\s*=\s*([^\s#]+)"
)
RPA_004_LAUNCH_MARKERS = (
"launch_persistent_context",
"launch_persistent_browser",
)
RPA_004_PICK_MARKER = "pick_web_account"
LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md" LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md"
LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md" LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
LOGGING_003_SOURCE = "development/LOGGING.md" LOGGING_003_SOURCE = "development/LOGGING.md"
@@ -615,6 +628,47 @@ class TestPolicyRpa003(unittest.TestCase):
) )
class TestPolicyRpa004(unittest.TestCase):
def test_env_example_browser_headless_defaults_to_headed(self) -> None:
skill_root = get_skill_root()
env_path = os.path.join(skill_root, ".env.example")
self.assertTrue(os.path.isfile(env_path), msg=".env.example missing")
text = _read_text(env_path)
match = RPA_004_HEADLESS_KEY_RE.search(text)
if match is None:
return
value = match.group(1).strip().strip("\"'")
self.assertEqual(
value,
"0",
msg=_policy_msg(
"POLICY-RPA-004",
RPA_004_SOURCE,
f".env.example OPENCLAW_BROWSER_HEADLESS must default to 0 (headed), got {value!r}",
),
)
def test_launch_persistent_requires_pick_web_account(self) -> None:
skill_root = get_skill_root()
script_texts = [
_read_text(path) for path in _walk_files(skill_root, "scripts", suffix=".py")
]
combined = "\n".join(script_texts)
uses_launch = any(marker in combined for marker in RPA_004_LAUNCH_MARKERS)
if not uses_launch:
return
self.assertIn(
RPA_004_PICK_MARKER,
combined,
msg=_policy_msg(
"POLICY-RPA-004",
RPA_004_SOURCE,
"scripts/ uses launch_persistent_* but missing pick_web_account "
"(must obtain profile_dir before opening browser)",
),
)
def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]: def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]:
offenders: list[str] = [] offenders: list[str] = []
for path in _walk_files(skill_root, "scripts", suffix=".py"): for path in _walk_files(skill_root, "scripts", suffix=".py"):

View File

@@ -238,6 +238,17 @@ class TestDocsStandards(unittest.TestCase):
msg="RPA.md must mention jc2009 / industry simulator / portal gate", msg="RPA.md must mention jc2009 / industry simulator / portal gate",
) )
def test_rpa_md_covers_web_profile_headed_and_login_split(self) -> None:
text = self._read("development/RPA.md")
self.assertIn("0.2", text)
self.assertIn("profile_dir", text)
self.assertIn("POLICY-RPA-004", text)
self.assertIn("有头", text)
self.assertTrue(
"登录不全局强制" in text or "登录 ≠ Profile" in text or "登录≠Profile" in text,
msg="RPA.md §0.2 must separate site-login from Profile requirement",
)
def test_simulator_example_readme_covers_async_and_account_manager(self) -> None: def test_simulator_example_readme_covers_async_and_account_manager(self) -> None:
text = self._read("examples/simulator_browser_rpa/README.md") text = self._read("examples/simulator_browser_rpa/README.md")
self.assertIn("async", text.lower()) self.assertIn("async", text.lower())

View File

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

View File

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