Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ff70e26a5 |
@@ -4,8 +4,11 @@ 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= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码
|
||||||
|
|
||||||
|
# 默认账号编号:仅填账号管理里的纯数字主键(一般留空)
|
||||||
|
DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号请填上方登录账号
|
||||||
|
|
||||||
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
|
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
|
||||||
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
|
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||||
|
|
||||||
|
## 1.0.51
|
||||||
|
|
||||||
|
- 沉淀网页 RPA 首启标准:业务技能默认 `ensure-web`(有则用、无则登记);登记账号 ≠ 已登录目标站;租约全忙返回占用错误且不重复建号
|
||||||
|
- 明确会话依赖型采集须声明登录 `required` 并做登录门禁;配置留空 + 行尾说明不得当成假默认值;账号可用登录标识选择,展示优先登录名
|
||||||
|
- 最低共享库版本声明上调至 1.2.2(含配置空值解析修复);说明宿主按需升级、不因源上有更新自动追新
|
||||||
|
|
||||||
## 1.0.50
|
## 1.0.50
|
||||||
|
|
||||||
- 沉淀网页 RPA 硬规则:必须先经账号管理取得 `profile_dir` 再开浏览器;生产有头;站点登录按平台约定(不全局强制);新增 `POLICY-RPA-004` 自动检测
|
- 沉淀网页 RPA 硬规则:必须先经账号管理取得 `profile_dir` 再开浏览器;生产有头;站点登录按平台约定(不全局强制);新增 `POLICY-RPA-004` 自动检测
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
|
|||||||
|
|
||||||
说明用户在使用前要准备的东西,例如:
|
说明用户在使用前要准备的东西,例如:
|
||||||
|
|
||||||
- **网页自动化类:** 先在「账号管理」登记本技能对应平台的账号(浏览器会话由系统隔离管理)。未登记账号不要直接跑真实网站
|
- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
|
||||||
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否登记账号」不是一回事——登记账号是前置,登录目标站看平台
|
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
|
||||||
- 相关业务权限(查询、导出、提交等)已具备
|
- 相关业务权限(查询、导出、提交等)已具备
|
||||||
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
|
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
|
||||||
- 如果涉及验证码或人工确认,请保证能及时在弹出的浏览器窗口中操作
|
- 如果涉及验证码或人工确认,请保证能及时在弹出的浏览器窗口中操作
|
||||||
|
|||||||
6
SKILL.md
6
SKILL.md
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: 技能开发模板(通用业务版)
|
name: 技能开发模板(通用业务版)
|
||||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||||
version: 1.0.50
|
version: 1.0.51
|
||||||
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` 做只读诊断。
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
1. 已安装并登录匠厂客户端
|
1. 已安装并登录匠厂客户端
|
||||||
2. 已在技能市场安装本技能(或更新到最新版)
|
2. 已在技能市场安装本技能(或更新到最新版)
|
||||||
3. 【网页自动化类技能必做】在「账号管理」中为本技能所需平台**先登记账号**,并确认浏览器会话可用;**不要**在未配置账号时直接跑真实网站(会弄脏本机会话、加重风控)
|
3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
|
||||||
4. 【按平台】若本技能说明「需要先登录目标站」,请按界面提示完成登录或扫码;若说明「可不登录也能用」,仍须完成上一步账号登记
|
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
|
||||||
5. 【如需】需要的文件 / 时间范围 / 单号等已准备好
|
5. 【如需】需要的文件 / 时间范围 / 单号等已准备好
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -100,8 +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` 拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。
|
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
|
||||||
@@ -111,10 +112,10 @@ result = call_sibling_json("account-manager", ["list", "--limit", "10"])
|
|||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# account-manager 账号/租约 — 集中在 account_client.py
|
# account-manager 账号/租约 — 集中在 account_client.py(ensure-web)
|
||||||
from service.account_client import pick_web_account, release_lease
|
from service.account_client import pick_web_account, release_lease
|
||||||
|
|
||||||
account = pick_web_account(platform="target_platform")
|
account = pick_web_account(platform="target_platform", login_id=None)
|
||||||
try:
|
try:
|
||||||
...
|
...
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ 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= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码
|
||||||
|
|
||||||
|
# 默认账号编号:仅填账号管理里的纯数字主键(一般留空)
|
||||||
|
DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号/用户名请填上方登录账号
|
||||||
|
|
||||||
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
|
# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器
|
||||||
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
|
OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法)
|
||||||
@@ -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`。
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ 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
|
||||||
|
|
||||||
@@ -202,7 +202,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 时的冲突风险。推荐范围写法:
|
||||||
@@ -869,7 +869,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
|||||||
- [ ] 本仓库**不是** 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` 首条提交属于本技能(非模板历史)
|
||||||
- [ ] 网页 RPA:先 account-manager `profile_dir` 再开浏览器;`.env` 默认有头;REQUIREMENTS 写明登录策略(required/optional/not_needed);用户 README/TUTORIAL 强调先登记账号(`RPA.md` §0.2 / `POLICY-RPA-004`)
|
- [ ] 网页 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. 常见错误
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +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.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` |
|
| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` |
|
||||||
| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
|
| 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-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
|
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
|
||||||
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
|
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
|
||||||
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |
|
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |
|
||||||
@@ -59,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 | 待宿主升级后完整生效;模板侧已强制显式绑定 |
|
||||||
|
|||||||
@@ -23,10 +23,12 @@
|
|||||||
- account-manager platform_key(如有):
|
- account-manager platform_key(如有):
|
||||||
- 命名形态:标准型 / scope 型
|
- 命名形态:标准型 / scope 型
|
||||||
- **网页 RPA(如有)— 登录策略(三选一,须写明):**
|
- **网页 RPA(如有)— 登录策略(三选一,须写明):**
|
||||||
- `required`:目标站必须登录才能闭环(用户须先登记账号,并在有头浏览器中完成/保持登录)
|
- `required`:目标站必须登录才能闭环(用户须完成/保持站点登录;技能每轮主路径须有登录门禁 + 验证码/人工等待)
|
||||||
- `optional`:登录可提升能力,但不登录也能完成主路径
|
- `optional`:登录可提升能力,但不登录也能完成主路径(**仅当**公开页确可闭环时选用;会话依赖型采集/评论/私信**禁止**默认抄成 optional)
|
||||||
- `not_needed`:公开页即可,不要求站点登录
|
- `not_needed`:公开页即可,不要求站点登录
|
||||||
- 无论上列哪一种:**都必须**经 account-manager 取得 `profile_dir`(见 `development/RPA.md` §0.2);不得使用系统默认浏览器用户目录
|
- 无论上列哪一种:**都必须**经 account-manager **`ensure-web`**(或等价)取得 `profile_dir`(见 `development/RPA.md` §0.2);不得使用系统默认浏览器用户目录
|
||||||
|
- **登记 ≠ 登录**:ensure/add-web 只建账号与 Profile;站点登录由本技能在有头浏览器中完成
|
||||||
|
- **反模式**:把「需要登录态才能采到数据」的需求写成 `optional`,再省略 `ensure_logged_in`
|
||||||
|
|
||||||
## 1. 文档目标
|
## 1. 文档目标
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@
|
|||||||
- Windows 环境下需保证 UTF-8 输出兼容
|
- Windows 环境下需保证 UTF-8 输出兼容
|
||||||
- 必须具备基本日志能力;敏感字段脱敏
|
- 必须具备基本日志能力;敏感字段脱敏
|
||||||
- RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args`
|
- RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args`
|
||||||
- 网页 RPA:启动前必须已 pick 到 `profile_dir`;生产有头;站点登录策略见 §0(required / optional / not_needed)
|
- 网页 RPA:启动前必须经 ensure-web 拿到 `profile_dir`;生产有头;站点登录策略见 §0(required / optional / not_needed);`required` 时每轮须登录门禁
|
||||||
|
|
||||||
## 6. 输入输出要求
|
## 6. 输入输出要求
|
||||||
|
|
||||||
|
|||||||
@@ -51,19 +51,24 @@ async def open_login(page):
|
|||||||
| 规则 | 要求 |
|
| 规则 | 要求 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **Profile 强制** | 启动浏览器前必须已从 **account-manager**(经本技能 `account_client`)拿到可用的 **`profile_dir`(及租约约定)**。只用该目录做 `launch_persistent_context`。 |
|
| **Profile 强制** | 启动浏览器前必须已从 **account-manager**(经本技能 `account_client`)拿到可用的 **`profile_dir`(及租约约定)**。只用该目录做 `launch_persistent_context`。 |
|
||||||
| **拿不到就停** | 未配置账号、未拿到 `profile_dir`、租约失败 → **立即失败退出,禁止开浏览器**。不得静默落到系统默认 Chrome/Edge 用户目录。 |
|
| **首启用 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`)当作交付或真实站联调常态(无头易风控、难人工介入,且易污染身份)。 |
|
| **有头强制** | 生产与用户侧联调:**`OPENCLAW_BROWSER_HEADLESS=0`(有头)**。禁止把无头(`=1`)当作交付或真实站联调常态(无头易风控、难人工介入,且易污染身份)。 |
|
||||||
| **登录不全局强制** | **是否要在目标站完成登录**由平台决定(如部分公开页可不登录也能闭环)。由做该技能的同事在 `REQUIREMENTS.md` / 用户教程中约定;**模板不强制「所有网页 RPA 必须先登录」**。 |
|
| **登录不全局强制** | **是否要在目标站完成登录**由平台决定(如部分公开页可不登录也能闭环)。由做该技能的同事在 `REQUIREMENTS.md` / 用户教程中约定;**模板不强制「所有网页 RPA 必须先登录」**。 |
|
||||||
|
| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 |
|
||||||
| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 |
|
| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 |
|
||||||
|
| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`);**禁止**把会话依赖型采集默认抄成 `optional`。 |
|
||||||
|
|
||||||
**反模式(禁止):**
|
**反模式(禁止):**
|
||||||
|
|
||||||
- 未 `pick` 到 `profile_dir` 仍 `launch` / 仍打开页面
|
- 未拿到 `profile_dir` 仍 `launch` / 仍打开页面
|
||||||
- 用系统默认用户数据目录或随意空目录反复跑真实站 → **污染本机 Profile、加重风控**
|
- 用系统默认用户数据目录或随意空目录反复跑真实站 → **污染本机 Profile、加重风控**
|
||||||
- 把「本站可不登录」写成「可以不走 account-manager」
|
- 把「本站可不登录」写成「可以不走 account-manager」
|
||||||
- 真实站联调默认无头狂跑
|
- 真实站联调默认无头狂跑
|
||||||
|
- 仅用哈希 class / 不稳定 CSS 作为**唯一**登录态判定(须结合稳定文案、`get_by_role`、URL/业务 DOM;F12 实页确认)
|
||||||
|
- 把「业务空结果」(如无评论)与 `LEASE_CONFLICT` / `REQUIRE_LOGIN` 混成同一错误码
|
||||||
|
|
||||||
**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → RPA → `finally release_lease`)。
|
**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → ensure-web → RPA → `finally release_lease`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@ 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 / 账号 | **必须**经 account-manager 取得 `profile_dir`(见 §0.2);密码不进 `.env`;**是否站点登录**按平台在需求/教程约定 |
|
| 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`(生产强制,见 §0.2) |
|
| 模式 | **有头** `OPENCLAW_BROWSER_HEADLESS=0`(生产强制,见 §0.2) |
|
||||||
@@ -91,7 +96,7 @@ async def open_login(page):
|
|||||||
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. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、account-manager 持久 profile;**不要**直接拼接搜索结果 URL 或 DOM 注入。
|
7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、account-manager 持久 profile;**不要**直接拼接搜索结果 URL 或 DOM 注入。
|
||||||
8. **先 Profile 再开浏览器**:`user_data_dir` 必须来自 pick 到的 `profile_dir`;未拿到则不得进入本节后续步骤。
|
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**)。
|
||||||
|
|
||||||
@@ -208,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` 清站点数据 |
|
||||||
@@ -282,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. 存证与录屏规范
|
||||||
|
|||||||
@@ -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 / 背景音乐
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ 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`(无 profile 禁止开浏览器,见 `RPA.md` §0.2)
|
- [ ] account-manager:`ensure-web`(或 `pick-web --ensure`)+ 账号有 `profile_dir`(无 profile 禁止开浏览器,见 `RPA.md` §0.2)
|
||||||
- [ ] `OPENCLAW_BROWSER_HEADLESS=0`(联调有头;勿默认无头)
|
- [ ] `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` 手工打开清站点数据)
|
||||||
@@ -262,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`
|
||||||
|
|||||||
@@ -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,7 +79,8 @@ real_browser_rpa/
|
|||||||
|
|
||||||
**浏览器与启动约束:**
|
**浏览器与启动约束:**
|
||||||
|
|
||||||
- **先 Profile 再开浏览器**:必须 `pick_web_account` 拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2)
|
- **先 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`;勿把无头当交付常态
|
- **有头**:生产 / 联调 `OPENCLAW_BROWSER_HEADLESS=0`;勿把无头当交付常态
|
||||||
- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」
|
- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」
|
||||||
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
|
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
|
||||||
@@ -142,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 应在关键失败点截图(示例中已留注释位)
|
||||||
|
|
||||||
## 常见坑
|
## 常见坑
|
||||||
|
|||||||
@@ -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,
|
||||||
[
|
*,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
|
||||||
|
lid = (login_id or "").strip() or None
|
||||||
|
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
|
||||||
|
argv = [
|
||||||
"account",
|
"account",
|
||||||
"pick-web",
|
"ensure-web",
|
||||||
"--platform",
|
"--platform",
|
||||||
platform,
|
platform,
|
||||||
|
"--url",
|
||||||
|
start_url,
|
||||||
|
"--auth-strategy",
|
||||||
|
DEFAULT_AUTH_STRATEGY,
|
||||||
|
"--label",
|
||||||
|
label,
|
||||||
"--lease",
|
"--lease",
|
||||||
"--holder",
|
"--holder",
|
||||||
LEASE_HOLDER,
|
LEASE_HOLDER,
|
||||||
"--ttl-sec",
|
"--ttl-sec",
|
||||||
LEASE_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_id(ensure-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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ simulator_browser_rpa/
|
|||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** |
|
| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** |
|
||||||
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account` / `release_lease`) |
|
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) |
|
||||||
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
|
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
|
||||||
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
|
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
|
||||||
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
|
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
|
||||||
@@ -59,7 +59,7 @@ simulator_browser_rpa/
|
|||||||
|
|
||||||
## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致)
|
## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致)
|
||||||
|
|
||||||
- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account` 拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
|
- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account`(ensure-web)拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
|
||||||
- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0`
|
- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0`
|
||||||
- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager
|
- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
[
|
*,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
|
||||||
|
lid = (login_id or "").strip() or None
|
||||||
|
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
|
||||||
|
argv = [
|
||||||
"account",
|
"account",
|
||||||
"pick-web",
|
"ensure-web",
|
||||||
"--platform",
|
"--platform",
|
||||||
platform,
|
platform,
|
||||||
|
"--url",
|
||||||
|
start_url,
|
||||||
|
"--auth-strategy",
|
||||||
|
DEFAULT_AUTH_STRATEGY,
|
||||||
|
"--label",
|
||||||
|
label,
|
||||||
"--lease",
|
"--lease",
|
||||||
"--holder",
|
"--holder",
|
||||||
LEASE_HOLDER,
|
LEASE_HOLDER,
|
||||||
"--ttl-sec",
|
"--ttl-sec",
|
||||||
LEASE_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_id(ensure-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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||||
|
|
||||||
SKILL_SLUG = "your-skill-slug"
|
SKILL_SLUG = "your-skill-slug"
|
||||||
SKILL_VERSION = "1.0.50"
|
SKILL_VERSION = "1.0.51"
|
||||||
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ FORBIDDEN_PHRASES = (
|
|||||||
|
|
||||||
POSITIVE_MARKERS = (
|
POSITIVE_MARKERS = (
|
||||||
"jiangchang-platform-kit",
|
"jiangchang-platform-kit",
|
||||||
"1.2.0",
|
"1.2.2",
|
||||||
"共享 runtime",
|
"共享 runtime",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user