From 1ff70e26a5ba616681b8fcf98ad67eed3af8f40c Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 18 Jul 2026 16:30:06 +0800 Subject: [PATCH] Release v1.0.51: precipitate ensure-web, config empty-value, and login-required standards --- .env.example | 7 +- CHANGELOG.md | 6 + README.md | 4 +- SKILL.md | 6 +- TUTORIAL.md | 4 +- development/ADAPTER.md | 9 +- development/CONFIG.md | 15 +- development/DEVELOPMENT.md | 6 +- development/POLICY_MATRIX.md | 4 +- development/REQUIREMENTS.md | 10 +- development/RPA.md | 23 ++- development/RUNTIME.md | 16 +- development/TESTING.md | 4 +- examples/real_browser_rpa/README.md | 7 +- .../scripts/service/account_client.py | 174 ++++++++++++++---- .../scripts/service/task_rpa.py | 13 +- .../scripts/service/task_service.py | 14 +- examples/simulator_browser_rpa/README.md | 4 +- .../scripts/service/account_client.py | 174 ++++++++++++++---- scripts/util/constants.py | 4 +- tests/_support.py | 2 +- tests/test_platform_import.py | 6 +- tests/test_template_runtime_standard.py | 2 +- 23 files changed, 382 insertions(+), 132 deletions(-) diff --git a/.env.example b/.env.example index d3c2d8b..674a57f 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,11 @@ OPENCLAW_TEST_TARGET=mock # 可选:mock=本地模拟,simulator_ # 目标网站地址:技能要访问的网站或服务地址 TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 -# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) -DEFAULT_LOGIN_ID=04110001 # 只填账号标识,不要填密码;密码请在「账号管理」中登记 +# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名/手机号) +DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码 + +# 默认账号编号:仅填账号管理里的纯数字主键(一般留空) +DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号请填上方登录账号 # 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) diff --git a/CHANGELOG.md b/CHANGELOG.md index d79f6c7..d8df1a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.51 + +- 沉淀网页 RPA 首启标准:业务技能默认 `ensure-web`(有则用、无则登记);登记账号 ≠ 已登录目标站;租约全忙返回占用错误且不重复建号 +- 明确会话依赖型采集须声明登录 `required` 并做登录门禁;配置留空 + 行尾说明不得当成假默认值;账号可用登录标识选择,展示优先登录名 +- 最低共享库版本声明上调至 1.2.2(含配置空值解析修复);说明宿主按需升级、不因源上有更新自动追新 + ## 1.0.50 - 沉淀网页 RPA 硬规则:必须先经账号管理取得 `profile_dir` 再开浏览器;生产有头;站点登录按平台约定(不全局强制);新增 `POLICY-RPA-004` 自动检测 diff --git a/README.md b/README.md index 54b2acc..cca8610 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ description: "用用户能理解的方式说明这个技能能做什么、适合 说明用户在使用前要准备的东西,例如: -- **网页自动化类:** 先在「账号管理」登记本技能对应平台的账号(浏览器会话由系统隔离管理)。未登记账号不要直接跑真实网站 -- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否登记账号」不是一回事——登记账号是前置,登录目标站看平台 +- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站 +- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事 - 相关业务权限(查询、导出、提交等)已具备 - 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息 - 如果涉及验证码或人工确认,请保证能及时在弹出的浏览器窗口中操作 diff --git a/SKILL.md b/SKILL.md index fc7692a..b700fe2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,12 +1,12 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.50 +version: 1.0.51 author: 深圳匠厂科技有限公司 metadata: openclaw: slug: your-skill-slug - platform_kit_min_version: "1.2.0" + platform_kit_min_version: "1.2.2" emoji: "📦" category: "通用" developer_ids: @@ -74,7 +74,7 @@ python {baseDir}/scripts/main.py config-path 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`。 - `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。 - `health` 用 `collect_runtime_diagnostics` 做只读诊断。 diff --git a/TUTORIAL.md b/TUTORIAL.md index a14c039..c680a83 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -16,8 +16,8 @@ 1. 已安装并登录匠厂客户端 2. 已在技能市场安装本技能(或更新到最新版) -3. 【网页自动化类技能必做】在「账号管理」中为本技能所需平台**先登记账号**,并确认浏览器会话可用;**不要**在未配置账号时直接跑真实网站(会弄脏本机会话、加重风控) -4. 【按平台】若本技能说明「需要先登录目标站」,请按界面提示完成登录或扫码;若说明「可不登录也能用」,仍须完成上一步账号登记 +3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站 +4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录 5. 【如需】需要的文件 / 时间范围 / 单号等已准备好 --- diff --git a/development/ADAPTER.md b/development/ADAPTER.md index a0ae9ed..79e3a39 100644 --- a/development/ADAPTER.md +++ b/development/ADAPTER.md @@ -100,8 +100,9 @@ def get_adapter(): 2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。 3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。 4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。 -5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease,需在运维文档说明排查方式(查 account-manager lease 列表 / 手动释放)。 -6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先 `pick_web_account` 拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。 +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 `;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id`;皆空 → 按平台 `ensure-web`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配 → `ERROR:AMBIGUOUS_ACCOUNT`。 ```python # 普通兄弟技能 — 走 sibling_bridge @@ -111,10 +112,10 @@ result = call_sibling_json("account-manager", ["list", "--limit", "10"]) ``` ```python -# account-manager 账号/租约 — 集中在 account_client.py +# account-manager 账号/租约 — 集中在 account_client.py(ensure-web) 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: ... finally: diff --git a/development/CONFIG.md b/development/CONFIG.md index 3f6ab80..73d839e 100644 --- a/development/CONFIG.md +++ b/development/CONFIG.md @@ -79,7 +79,10 @@ OPENCLAW_TEST_TARGET=mock # 可选:mock=本地模拟,simulator_ TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 # 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) -DEFAULT_LOGIN_ID=04110001 # 只填账号标识,不要填密码;密码请在「账号管理」中登记 +DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码 + +# 默认账号编号:仅填账号管理里的纯数字主键(一般留空) +DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号/用户名请填上方登录账号 # 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) @@ -161,6 +164,16 @@ config.get_bool("OPENCLAW_BROWSER_HEADLESS") 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`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok`、`ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`。 diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index b7f53aa..bc8e4fe 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -164,7 +164,7 @@ scripts/ 作用:常量、日志、路径、时间工具、通用帮助函数 `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 @@ -202,7 +202,7 @@ scripts/ 技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。 - **公共依赖**(`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`。 - **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。 - **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法: @@ -869,7 +869,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea - [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`) - [ ] `git remote -v` 指向**本技能**远端,URL 不含 skill-template 仓库名 - [ ] `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. 常见错误 diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md index 1fe61a5..43c4bbc 100644 --- a/development/POLICY_MATRIX.md +++ b/development/POLICY_MATRIX.md @@ -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-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4;应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg(`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` | | POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` | -| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器);登录不全局强制,但不得跳过 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-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` | @@ -59,5 +59,5 @@ | RPA 拟人操作、选择器纪律、HITL 超时 | development/RPA.md §0–§1 | 行为与 DOM 质量,无法静态扫描 | | adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 | | `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` | -| platform_kit_min_version >= 1.2.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 | 待宿主升级后完整生效;模板侧已强制显式绑定 | diff --git a/development/REQUIREMENTS.md b/development/REQUIREMENTS.md index b18a19f..7cce087 100644 --- a/development/REQUIREMENTS.md +++ b/development/REQUIREMENTS.md @@ -23,10 +23,12 @@ - account-manager platform_key(如有): - 命名形态:标准型 / scope 型 - **网页 RPA(如有)— 登录策略(三选一,须写明):** - - `required`:目标站必须登录才能闭环(用户须先登记账号,并在有头浏览器中完成/保持登录) - - `optional`:登录可提升能力,但不登录也能完成主路径 + - `required`:目标站必须登录才能闭环(用户须完成/保持站点登录;技能每轮主路径须有登录门禁 + 验证码/人工等待) + - `optional`:登录可提升能力,但不登录也能完成主路径(**仅当**公开页确可闭环时选用;会话依赖型采集/评论/私信**禁止**默认抄成 optional) - `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. 文档目标 @@ -108,7 +110,7 @@ - Windows 环境下需保证 UTF-8 输出兼容 - 必须具备基本日志能力;敏感字段脱敏 - 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. 输入输出要求 diff --git a/development/RPA.md b/development/RPA.md index dfc70ce..c38dec6 100644 --- a/development/RPA.md +++ b/development/RPA.md @@ -51,19 +51,24 @@ async def open_login(page): | 规则 | 要求 | |------|------| | **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`)当作交付或真实站联调常态(无头易风控、难人工介入,且易污染身份)。 | | **登录不全局强制** | **是否要在目标站完成登录**由平台决定(如部分公开页可不登录也能闭环)。由做该技能的同事在 `REQUIREMENTS.md` / 用户教程中约定;**模板不强制「所有网页 RPA 必须先登录」**。 | +| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 | | **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 | +| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`);**禁止**把会话依赖型采集默认抄成 `optional`。 | **反模式(禁止):** -- 未 `pick` 到 `profile_dir` 仍 `launch` / 仍打开页面 +- 未拿到 `profile_dir` 仍 `launch` / 仍打开页面 - 用系统默认用户数据目录或随意空目录反复跑真实站 → **污染本机 Profile、加重风控** - 把「本站可不登录」写成「可以不走 account-manager」 - 真实站联调默认无头狂跑 +- 仅用哈希 class / 不稳定 CSS 作为**唯一**登录态判定(须结合稳定文案、`get_by_role`、URL/业务 DOM;F12 实页确认) +- 把「业务空结果」(如无评论)与 `LEASE_CONFLICT` / `REQUIRE_LOGIN` 混成同一错误码 -**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → 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`** | -| Profile / 账号 | **必须**经 account-manager 取得 `profile_dir`(见 §0.2);密码不进 `.env`;**是否站点登录**按平台在需求/教程约定 | +| Profile / 账号 | **必须**经 account-manager **`ensure-web`**(或 `pick-web --ensure`)取得 `profile_dir`(见 §0.2);密码不进 `.env`;**是否站点登录**按平台在需求/教程约定;登记 ≠ 已登录 | | CDP | **仅作诊断 / 桌面宿主类场景**;**不要**作为强风控站点的默认生产路径 | | 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 | | 模式 | **有头** `OPENCLAW_BROWSER_HEADLESS=0`(生产强制,见 §0.2) | @@ -91,7 +96,7 @@ async def open_login(page): 5. **不要默认传 `--disable-blink-features=AutomationControlled`**;platform-kit stealth 已覆盖,额外 flag 可能适得其反。 6. **可以** `ignore_default_args=["--enable-automation"]`(platform-kit `launch_persistent_browser` 已处理)。 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**)。 @@ -208,7 +213,7 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI | Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 | | 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`)+ `account_client.py` subprocess | | 登录 | **双层**:门户 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` 等内部模块 | | Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback;共享 sandbox **不要求**为技能加 testid | | Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 | @@ -282,14 +287,18 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层 | 错误码 | 含义 | 上层处理建议 | |--------|------|------| -| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程 | +| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程(有头等待人工) | | `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 | | `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 | +| `ERROR:LEASE_CONFLICT` | 已有账号但均被租约占用(ensure 不新建) | 稍后重试或释放租约;**不是**「无账号」 | +| `ERROR:AMBIGUOUS_ACCOUNT` | `--login-id` / `--label` 匹配到多条 | 改用数字 id 或更精确标识 | | `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 | | `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 | | `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB | | `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 | +业务空结果(如「本页无评论」)用业务码或成功空列表表达,**不要**复用上表账号/登录错误码。 + --- ## 5. 存证与录屏规范 diff --git a/development/RUNTIME.md b/development/RUNTIME.md index 991d0ba..84ecff9 100644 --- a/development/RUNTIME.md +++ b/development/RUNTIME.md @@ -2,10 +2,20 @@ ## 共享 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 依赖声明。 +### 何时上调 `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`) | @@ -27,7 +37,7 @@ Unix(未注入时): 共享解释器通常位于 `{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 诊断,**不在技能内重复实现**。典型字段: @@ -45,7 +55,7 @@ Unix(未注入时): - 用户实际 `.env`:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/.env`。 - `scripts/main.py` 与 `cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`。 - 配置优先级:**进程环境变量** > **用户 `.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 / 背景音乐 diff --git a/development/TESTING.md b/development/TESTING.md index a75ab46..b8aba94 100644 --- a/development/TESTING.md +++ b/development/TESTING.md @@ -137,7 +137,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t 浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认: - [ ] 数据目录 `.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`(联调有头;勿默认无头) - [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`) - [ ] 失败先查 `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` - [ ] 无 `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`(或等价诊断行) - [ ] `config-path` 可输出用户 `.env` 路径 JSON - [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py` diff --git a/examples/real_browser_rpa/README.md b/examples/real_browser_rpa/README.md index cfbaebd..5bc1228 100644 --- a/examples/real_browser_rpa/README.md +++ b/examples/real_browser_rpa/README.md @@ -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)` 3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成 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`;勿把无头当交付常态 - **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」 - **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供 @@ -142,7 +143,7 @@ real_browser_rpa/ - **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用 - **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成 - **日志脱敏** — 不输出完整手机号/账号 -- **租约释放** — `pick-web --lease` 后必须在 `finally` 释放 +- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放 - **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位) ## 常见坑 diff --git a/examples/real_browser_rpa/scripts/service/account_client.py b/examples/real_browser_rpa/scripts/service/account_client.py index a385374..3c05cff 100644 --- a/examples/real_browser_rpa/scripts/service/account_client.py +++ b/examples/real_browser_rpa/scripts/service/account_client.py @@ -1,4 +1,7 @@ -"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。""" +"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。 + +业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。 +""" from __future__ import annotations @@ -7,8 +10,9 @@ import logging import os import subprocess 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 util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM @@ -19,12 +23,17 @@ logger = logging.getLogger(__name__) PLACEHOLDER_PLATFORM = TARGET_PLATFORM ACCOUNT_SETUP_MESSAGE = ( - f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。" - f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、" - "status=active、带 profile_dir 的账号后重新运行。" + f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。" + f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。" ) -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): @@ -38,13 +47,65 @@ def mask_login_id(login_id: str) -> str: 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: """解析 account-manager CLI 入口路径。 - 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root(路径推断 / JIANGCHANG_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")): 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 "")) message = str(err.get("message") or "") 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: raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") - raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。") + raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。") if not isinstance(payload, dict): - raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。") + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") 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 -def _pick_web_with_lease(platform: str) -> Dict[str, Any]: - proc = _run_argv( - [ - "account", - "pick-web", - "--platform", - platform, - "--lease", - "--holder", - LEASE_HOLDER, - "--ttl-sec", - LEASE_TTL_SEC, - ] - ) +def _ensure_web_with_lease( + 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", + "ensure-web", + "--platform", + platform, + "--url", + start_url, + "--auth-strategy", + DEFAULT_AUTH_STRATEGY, + "--label", + label, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if lid: + argv.extend(["--login-id", lid]) + + proc = _run_argv(argv) out = proc.stdout or "" if proc.returncode != 0 and not out.strip(): raise AccountManagerError( - "PICK_WEB_FAILED", - (proc.stderr or "").strip() or "pick-web 子进程失败", + "ENSURE_WEB_FAILED", + (proc.stderr or "").strip() or "ensure-web 子进程失败", ) payload = _parse_last_json(out) if isinstance(payload, dict) and payload.get("success") is False: return _validate_pick_payload(payload) if not isinstance(payload, dict): - raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。") - return payload + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + return _validate_pick_payload(payload) 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 -def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict: - """获取网页账号(profile_dir + lease_token)。""" +def pick_web_account( + platform: str, + account_id: Optional[str] = None, + login_id: Optional[str] = None, +) -> dict: + """获取网页账号(profile_dir + lease_token)。 + + 优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。 + """ platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM - if account_id: - try: - aid = int(account_id) - except (TypeError, ValueError): - raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}") - return _pick_by_id(platform_key, aid) + aid_raw = (account_id or "").strip() + if aid_raw and not _is_placeholder_config(aid_raw): + if not aid_raw.isdigit(): + if not (login_id or "").strip(): + login_id = aid_raw + aid_raw = "" + else: + return _pick_by_id(platform_key, int(aid_raw)) + + lid = (login_id or "").strip() or None + if lid and _is_placeholder_config(lid): + lid = None try: - return _pick_web_with_lease(platform_key) + return _ensure_web_with_lease(platform_key, login_id=lid) except AccountManagerError as exc: 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 diff --git a/examples/real_browser_rpa/scripts/service/task_rpa.py b/examples/real_browser_rpa/scripts/service/task_rpa.py index 07adf3e..0bd580e 100644 --- a/examples/real_browser_rpa/scripts/service/task_rpa.py +++ b/examples/real_browser_rpa/scripts/service/task_rpa.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging import os import random @@ -11,6 +10,8 @@ import time from dataclasses import dataclass, field 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.human_verification import ( HumanVerificationWaitResult, @@ -102,11 +103,11 @@ def _step_cb(cb: Optional[StepCallback], text: str) -> None: async def _random_delay() -> None: lo = int(os.getenv("RPA_STEP_DELAY_MIN_MS") or "900") hi = int(os.getenv("RPA_STEP_DELAY_MAX_MS") or "2600") - await 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: - await asyncio.sleep(random.uniform(1.2, 3.5)) + await interruptible_sleep(random.uniform(1.2, 3.5)) def _headless() -> bool: @@ -156,7 +157,7 @@ async def _open_login_panel_if_needed(page) -> None: btn = page.locator(LOGGED_OUT_SELECTOR).first if await _visible(btn): await btn.click() - await asyncio.sleep(random.uniform(1.0, 3.0)) + await interruptible_sleep(random.uniform(1.0, 3.0)) except Exception: pass @@ -175,7 +176,7 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None: if await _is_logged_in(page): print("[登录] 检测到登录成功") return - await asyncio.sleep(2.0) + await interruptible_sleep(2.0) raise RuntimeError( f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。" @@ -253,7 +254,7 @@ async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool: return True except Exception: pass - await asyncio.sleep(0.8) + await interruptible_sleep(0.8) return False diff --git a/examples/real_browser_rpa/scripts/service/task_service.py b/examples/real_browser_rpa/scripts/service/task_service.py index 08f7fc8..c609922 100644 --- a/examples/real_browser_rpa/scripts/service/task_service.py +++ b/examples/real_browser_rpa/scripts/service/task_service.py @@ -6,7 +6,12 @@ import asyncio import uuid 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 ( ScrapeRunResult, format_stop_reason_for_user, @@ -79,7 +84,12 @@ async def run_keyword_search_task( account: Optional[dict[str, Any]] = None 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") scrape_result: ScrapeRunResult = await run_keyword_search_async( diff --git a/examples/simulator_browser_rpa/README.md b/examples/simulator_browser_rpa/README.md index 7ae6c29..b8ea8b4 100644 --- a/examples/simulator_browser_rpa/README.md +++ b/examples/simulator_browser_rpa/README.md @@ -50,7 +50,7 @@ simulator_browser_rpa/ | 文件 | 职责 | |---|---| | `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`、截图 | | `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` | | `adapter/mock.py` | 不启浏览器,供 unit/mock/CI | @@ -59,7 +59,7 @@ simulator_browser_rpa/ ## 网页 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` - **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager diff --git a/examples/simulator_browser_rpa/scripts/service/account_client.py b/examples/simulator_browser_rpa/scripts/service/account_client.py index 64bed11..3c05cff 100644 --- a/examples/simulator_browser_rpa/scripts/service/account_client.py +++ b/examples/simulator_browser_rpa/scripts/service/account_client.py @@ -1,4 +1,7 @@ -"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。""" +"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。 + +业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。 +""" from __future__ import annotations @@ -7,8 +10,9 @@ import logging import os import subprocess 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 util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM @@ -19,12 +23,17 @@ logger = logging.getLogger(__name__) PLACEHOLDER_PLATFORM = TARGET_PLATFORM ACCOUNT_SETUP_MESSAGE = ( - f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。" - f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、" - "status=active、带 profile_dir 的账号后重新运行。" + f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。" + f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。" ) -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): @@ -38,12 +47,65 @@ def mask_login_id(login_id: str) -> str: 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: """解析 account-manager CLI 入口路径。 优先级: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")): 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): return candidate + # 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录 dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py" if os.path.isfile(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 "")) message = str(err.get("message") or "") 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: raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") - raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。") + raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。") if not isinstance(payload, dict): - raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。") + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") 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 -def _pick_web_with_lease(platform: str) -> Dict[str, Any]: - proc = _run_argv( - [ - "account", - "pick-web", - "--platform", - platform, - "--lease", - "--holder", - LEASE_HOLDER, - "--ttl-sec", - LEASE_TTL_SEC, - ] - ) +def _ensure_web_with_lease( + 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", + "ensure-web", + "--platform", + platform, + "--url", + start_url, + "--auth-strategy", + DEFAULT_AUTH_STRATEGY, + "--label", + label, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if lid: + argv.extend(["--login-id", lid]) + + proc = _run_argv(argv) out = proc.stdout or "" if proc.returncode != 0 and not out.strip(): raise AccountManagerError( - "PICK_WEB_FAILED", - (proc.stderr or "").strip() or "pick-web 子进程失败", + "ENSURE_WEB_FAILED", + (proc.stderr or "").strip() or "ensure-web 子进程失败", ) payload = _parse_last_json(out) if isinstance(payload, dict) and payload.get("success") is False: return _validate_pick_payload(payload) if not isinstance(payload, dict): - raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。") - return payload + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + return _validate_pick_payload(payload) 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 -def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict: - """获取网页账号(profile_dir + lease_token)。""" +def pick_web_account( + platform: str, + account_id: Optional[str] = None, + login_id: Optional[str] = None, +) -> dict: + """获取网页账号(profile_dir + lease_token)。 + + 优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。 + """ platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM - if account_id: - try: - aid = int(account_id) - except (TypeError, ValueError): - raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}") - return _pick_by_id(platform_key, aid) + aid_raw = (account_id or "").strip() + if aid_raw and not _is_placeholder_config(aid_raw): + if not aid_raw.isdigit(): + if not (login_id or "").strip(): + login_id = aid_raw + aid_raw = "" + else: + return _pick_by_id(platform_key, int(aid_raw)) + + lid = (login_id or "").strip() or None + if lid and _is_placeholder_config(lid): + lid = None try: - return _pick_web_with_lease(platform_key) + return _ensure_web_with_lease(platform_key, login_id=lid) except AccountManagerError as exc: 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 diff --git a/scripts/util/constants.py b/scripts/util/constants.py index ebc62ec..b6ba12c 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.50" +SKILL_VERSION = "1.0.51" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" -PLATFORM_KIT_MIN_VERSION = "1.2.0" +PLATFORM_KIT_MIN_VERSION = "1.2.2" diff --git a/tests/_support.py b/tests/_support.py index d58b47f..b1ee8ee 100644 --- a/tests/_support.py +++ b/tests/_support.py @@ -35,7 +35,7 @@ def get_skill_root() -> str: 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.""" from unittest.mock import patch diff --git a/tests/test_platform_import.py b/tests/test_platform_import.py index 4482b8f..bf1f122 100644 --- a/tests/test_platform_import.py +++ b/tests/test_platform_import.py @@ -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 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") with open(md_path, encoding="utf-8") as f: 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") with open(req_path, encoding="utf-8") as f: diff --git a/tests/test_template_runtime_standard.py b/tests/test_template_runtime_standard.py index 6230dc0..4525454 100644 --- a/tests/test_template_runtime_standard.py +++ b/tests/test_template_runtime_standard.py @@ -34,7 +34,7 @@ FORBIDDEN_PHRASES = ( POSITIVE_MARKERS = ( "jiangchang-platform-kit", - "1.2.0", + "1.2.2", "共享 runtime", )