Compare commits

..

16 Commits

Author SHA1 Message Date
707d97760f feat: 匠厂数据管理展示元数据规范——元数据表初始化/校验、display_order 契约、文档与测试
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-01 11:39:28 +08:00
d17804e45b 同步 Platform Kit 1.0.17 运行时环境变量契约
All checks were successful
技能自动化发布 / release (push) Successful in 4s
2026-06-30 14:41:54 +08:00
a3bd8faf87 docs(config): unify config.get reads and remove ALLOW_* gates
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-30 10:29:03 +08:00
30803a0834 chore: auto release commit (2026-06-21 10:51:04)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-21 10:51:05 +08:00
f3f7a9b9e5 chore: auto release commit (2026-06-17 09:48:59)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-17 09:49:00 +08:00
79098dad5c chore: auto release commit (2026-06-17 09:21:19)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-17 09:21:21 +08:00
b267ca3266 chore: auto release commit (2026-06-15 17:32:47)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-15 17:32:49 +08:00
87ef00835b chore: auto release commit (2026-06-14 11:17:52)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-14 11:17:53 +08:00
c7d7832749 chore: auto release commit (2026-06-14 11:12:03)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-06-14 11:12:04 +08:00
955816c8a7 docs: remove internal developer reminder from user-facing README
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-13 15:38:47 +08:00
7827cc75b2 chore: auto release commit (2026-06-13 14:59:54)
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-06-13 14:59:54 +08:00
a24f6809da chore: auto release commit (2026-06-08 14:19:45)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-08 14:19:46 +08:00
5b555e4313 去掉依赖 2026-06-07 14:50:44 +08:00
4c5bc31b0e 继续优化代码 2026-06-07 12:02:17 +08:00
45cf1741e1 继续完善代码 2026-06-07 11:51:23 +08:00
1bbcc65cf3 重新调整模板 2026-06-07 11:28:45 +08:00
115 changed files with 7855 additions and 3061 deletions

View File

@@ -1,5 +1,5 @@
# ── 运行模式(对应 adapter 四档,见 references/ADAPTER.md──
OPENCLAW_TEST_TARGET=simulator_rpa # mock | simulator_rpa | real_api | real_rpa
# ── 运行模式 / adapter 档位(见 references/ADAPTER.md──
OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_rpa
# ── 目标系统 ──
TARGET_BASE_URL=https://sandbox.jc2009.com # 仿真/生产地址,可被进程环境变量覆盖

View File

@@ -5,7 +5,7 @@ on:
jobs:
release:
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
secrets:
PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }}
with:

8
.gitignore vendored
View File

@@ -1,4 +1,10 @@
__pycache__/
*.py[cod]
.env
*.env.local
*.env.local
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/

131
README.md
View File

@@ -1,64 +1,99 @@
# 匠厂 技能开发模板(通用业务)
---
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。"
---
这是一个**规范化的新技能模板仓库**,用于复制出新的 skill 项目;它本身**不是业务 skill**。
> **模板说明(复制后删除本段)**
> 本文是技能市场详情与普通用户说明,**不是** Agent 参考索引、CLI 契约或开发文档。
> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约、数据结构、开发规范或 Agent 参考索引。
> 正文只回答用户关心的问题,**不要**把 Playwright、DOM、Python、RPA、references、development、schema 等作为面向用户的主说明内容;若业务上必须出现技术词,须用用户能理解的方式解释,且不能喧宾夺主。
## 模板目标
# 【技能名称】
- 对齐当前规范 skill 的目录结构:`assets/``references/``scripts/``tests/``evals/`
- 对齐当前规范脚手架分层:`scripts/cli``scripts/db``scripts/service``scripts/util``scripts/jiangchang_skill_core`
- 提供最小可运行入口:`python scripts/main.py health` / `version`,以及通用任务命令骨架 `run` / `logs` / `log-get`
- 内置 **隔离测试体系**`IsolatedDataRoot`、双键环境、`OPENCLAW_TEST_TARGET` 档位与 adapter profile 策略(详见 [`references/TESTING.md`](references/TESTING.md)
- 让新技能从一开始就按规范落地,不再沿用旧模板的 `docs/``optional/``skill_main.py` 结构
> 用一两句话说明这个技能帮助用户完成什么工作。
> 这里不要解释技术实现,要从用户的业务目标出发描述价值。
## 新技能使用步骤
---
1. 复制本目录为新的 skill 仓库。
2. 全局替换 `your-skill-slug``your-platform-key``您的技能显示名称` 等占位内容。
3. 修改 `SKILL.md``references/``scripts/util/constants.py`
4.`scripts/service/task_service.py` 中实现真实业务编排与外部系统对接。
5. 运行 `python tests/run_tests.py -v`,再执行 `python scripts/main.py health``version` 做最小验证。
## 这个技能能帮你做什么
如果你的技能在平台里默认是非公开的(`access_scope = 0`),建议在 `SKILL.md``metadata.openclaw.developer_ids` 中填写开发者用户 ID 列表。这样在正式发布后,平台会为这些开发者补可见权限,避免「技能已上架但开发者自己在市场中看不到」。
用 3 到 6 条业务能力说明技能价值,例如:
开发教程入口:
- 【自动整理/查询/生成/同步/核对/提交】某类业务数据
- 帮你减少重复操作,降低人工录入或核对成本
- 在任务完成后给出清晰的处理结果、失败原因或后续建议
- <a href="references/REQUIREMENTS.md" target="_blank" rel="noopener noreferrer">需求文档模板</a>:给技术人员编写和查看研发需求的标准模板
- <a href="references/DEVELOPMENT.md" target="_blank" rel="noopener noreferrer">开发教程</a>:给技术人员的完整开发步骤说明
- <a href="references/TESTING.md" target="_blank" rel="noopener noreferrer">测试开发指南</a>:默认套件、隔离数据根、档位开关与 FakeAdapter 怎么用
---
## 目录说明
## 适合什么场景
| 路径 | 用途 |
|------|------|
| `SKILL.md` | 技能清单与触发说明模板 |
| `assets/` | 示例输出与轻量 schema |
| `references/` | 面向用户与编排的文档模板 |
| `scripts/` | 规范分层后的代码骨架 |
| `tests/` | 单元测试与分层测试范式(默认根目录 unittest |
| `evals/` | 人工/半自动评估材料 |
| `requirements.txt` | 本技能 Python 三方依赖声明(由匠厂宿主安装到共享 runtime |
| `.github/workflows/release_skill.yaml` | 标准发布工作流 |
| `release.ps1` | 对齐现有 skill 的发布脚本入口 |
列出典型使用场景。请使用用户熟悉的业务语言,例如:
## 最小命令
- 每天需要重复处理同一类订单、单据、报表或客户资料
- 需要从多个系统中查询信息并汇总结果
- 需要按固定规则检查数据是否完整、是否异常
- 需要把处理结果保存为文件、记录或任务报告
```bash
python scripts/main.py health
python scripts/main.py version
```
---
## 注意
## 使用前需要准备什么
- 不要再往模板里引入旧式 `docs/``optional/` 目录。
- 新技能若不需要某些目录,也建议先保留结构,再按实际业务填充内容。
- `metadata.openclaw.developer_ids` 是发布元数据,不是用户展示文案;用于非公开技能的开发者默认可见授权。
- **发起 `release.ps1` 之前**,务必确认 `python tests/run_tests.py -v` 已全部通过。
说明用户在使用前要准备的东西,例如:
## Python 依赖requirements.txt
- 相关账号已登录,且具备查询、导出或提交权限
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
- 如果涉及外部系统,请确认网络、账号、验证码或审批流程可正常使用
- 在技能**根目录**维护 `requirements.txt`,声明本技能特有的 Python 三方包。
- 匠厂宿主在**安装或更新**技能后,会将该文件中的依赖安装到共享 Python runtime
`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`Windows 示例:`D:\jiangchang-data\python-runtime\.venv`)。
- 平台通用依赖由宿主 `resources/jiangchang-python-runtime/pyproject.toml` 维护;**不要**把业务依赖写进宿主 pyproject。
- **Skill 业务代码不得自动 `pip install`**;缺依赖时应在 `health` / preflight 中给出清晰错误。
- **系统级组件**(如 Microsoft Visual C++ Redistributable**不要**写进 `requirements.txt`;仅在 `health` / preflight 中提示用户自行安装。
---
## 你可以这样告诉 Agent
用自然语言示例展示用户可以怎么发起任务,例如:
> “帮我查询今天的【业务对象】,整理成表格。”
>
> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。”
>
> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。”
>
> “帮我根据这个文件里的内容,逐条完成【业务动作】。”
> 注意:示例应是自然语言,不要写命令行命令。
---
## 执行后你会得到什么
说明用户会看到什么结果,例如:
- 任务是否成功完成
- 处理了多少条数据
- 哪些内容成功、哪些失败
- 失败原因和建议处理方式
- 生成的文件、表格、报告或记录
---
## 注意事项
用用户能理解的方式说明边界和风险,例如:
- 请确认输入范围准确,避免处理错数据
- 涉及提交、付款、删除、审批等高风险动作时,技能应在执行前请求确认
- 如果外部系统登录失效、权限不足或页面变化,任务可能需要用户介入
- 不建议在未核对数据的情况下直接执行批量操作
---
## 常见问题
### 为什么任务没有查到数据?
> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。
### 为什么任务执行到一半需要我操作?
> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。
### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。

View File

@@ -1,11 +1,12 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,覆盖任务执行、日志记录、外部系统对接、隔离测试等典型能力。复制后请按 references/DEVELOPMENT.md 完成定制。"
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.14
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: your-skill-slug
platform_kit_min_version: "1.0.17"
emoji: "📦"
category: "通用"
developer_ids:
@@ -17,38 +18,80 @@ allowed-tools:
# 技能开发模板skill-template
这是一个**用于复制的新技能模板**,不是业务技能本身。新建技能时,应复制本仓库结构,再把占位内容替换成你的真实业务实现
这是一个**用于复制的新技能模板**,不是业务 skill 本身。复制后请替换占位内容,实现你的真实业务逻辑
## 模板使用方式
## 面向用户问答LLM 规则)
1. 复制目录为你的新 skill 仓库
2. 全局替换 `your-skill-slug``技能开发模板(通用业务版)` 等占位词
3.`references/CLI.md``scripts/` 分层与 `README.md` 的说明补业务逻辑。
- 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明
- 根目录 `README.md` 是面向普通用户的说明
当用户询问以下问题时,**必须优先读取**根目录 `README.md`,并用用户能理解的业务语言回答:
- 这个技能是做什么的
- 这个技能怎么用
- 适合什么场景
- 使用前要准备什么
- 执行后会得到什么
- 能不能帮我完成某个业务任务
- **不要**把 `SKILL.md``references/``development/` 中的技术细节直接作为用户回答。
- **只有当**用户明确询问命令、参数、输出结构、开发、调试、集成或排错时,才读取 `references/``development/`
-`README.md``SKILL.md` / `references` 表述冲突:对用户展示与市场说明以 `README.md` 为准对执行契约、CLI、schema、运行约束以 `SKILL.md` / `references` 为准。
- 回答用户时**不要**暴露 Playwright、DOM、Python、RPA 等实现细节,除非用户明确询问技术实现。
## 文档分工
| 文档 | 读者 | 用途 |
|------|------|------|
| 根目录 `README.md` | 普通用户 | 技能市场详情页说明(`metadata.readme_md` 主来源) |
| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 |
| `references/` | Agent 编排/调用 | 渐进式加载CLI 契约、字段 schema 等 |
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、技术规范 |
## 目录约定
- 根目录结构参考现有规范技能:`assets/``references/``scripts/``tests/``evals/`
- CLI 入口固定为 `scripts/main.py`
- 业务逻辑按 `cli / db / service / util / jiangchang_skill_core` 分层。
复制后建议保留:
- `assets/``development/``evals/``references/``scripts/``tests/`
- CLI 入口固定为 `scripts/main.py`
- 业务逻辑按 `cli / db / service / util` 分层
## 最小命令
```bash
python {baseDir}/scripts/main.py health
python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py version
```
配置:仓库 `.env.example` 为模板;用户 `.env``{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`,启动时自动 bootstrap。优先级进程环境变量 > 用户 `.env` > `.env.example`
## 运行依赖
- Python 运行环境由匠厂宿主注入共享 runtime`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 本技能**特有**的 Python 三方依赖请声明在根目录 `requirements.txt`;宿主安装/更新技能后会自动安装到共享 venv
- Python 运行环境由匠厂宿主注入**共享 runtime**`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 公共能力来自共享 runtime 安装的 `jiangchang-platform-kit>=1.0.17``jiangchang_skill_core` 包);**不要 vendor** `scripts/jiangchang_skill_core/`,新技能不得在仓库内保留该目录副本
- config、logging、runtime_env、rpa、media-assets、video_session、runtime_diagnostics 等均从共享 venv 的 `jiangchang_skill_core` import而非技能目录副本。
- 根目录 `requirements.txt` **只声明技能特有** Python 三方依赖;`jiangchang-platform-kit``playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
- `metadata.openclaw.platform_kit_min_version`(当前 `1.0.17`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是**技能 pip 依赖声明。
- Skill 代码**不要**自行 `pip install`;系统级依赖(如 VC++ Runtime仅在 `health` / preflight 中提示用户安装。
- `health` 使用 `collect_runtime_diagnostics` 输出统一 runtime 诊断(只读,不下载/修复 media-assets
## 重要说明
## 定制入口
- 复制后请同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
- 如技能无需持久化,可保留 `db/` 目录但不主动调用
- `metadata.openclaw.developer_ids` 用于声明技能发布后的默认开发者可见用户 ID 列表
- 当技能在平台中 `access_scope = 0`(不公开)时,任务执行流程会把 `developer_ids` 中的用户自动补写到 `skill_user_access`,使这些开发者仍可在技能市场中查看该技能
1. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词
2.`development/DEVELOPMENT.md` 完成开发与文档定制
3. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md`
4. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
5. 本地 SQLite 的中文表名/字段名与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序)。
## 技能命名与 slug
- `name`frontmatter**用户可见的中文业务名称**`description` 是说明,不是名称。
- `metadata.openclaw.slug`**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug不使用中文。
- 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。
## 平台元数据
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。
-`access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`
- 面向用户与编排的文档写在 `references/`,不要再新增旧式 `docs/` / `optional/` 结构。

View File

@@ -3,4 +3,6 @@
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。
面向编排与用户文档见 `references/README.md``references/CLI.md``RUNTIME.md``SCHEMA.md`
- 用户市场说明见根目录 [`README.md`](../README.md)
- Agent 调用/编排参考见 [`references/`](../references/)
- 开发规范见 [`development/`](../development/)

122
development/ADAPTER.md Normal file
View File

@@ -0,0 +1,122 @@
# 适配器标准:真实/仿真 × API/RPA 四档模式
> 凡是"连接三方系统"ERP、CRM、SaaS、银行等的 skill都应采用本文的 **adapter 四档模式**。这样同一套业务逻辑可以在不同档位间切换:开发用 mock、半集成用 simulator、上线用真实互不影响。
## 为什么要分档
对接一个外部系统,工程上无非四种方式的组合:
| | API有接口 | RPA操作界面 |
|---|---|---|
| **真实系统** | `real_api` 真实 API | `real_rpa` 真实界面 |
| **仿真/离线** | `mock` 纯内存/fixture | `simulator_rpa` 仿真站点/桌面仿真 |
### 四档定义
| 档位 | 含义 | 默认策略 |
|------|------|----------|
| **`mock`** | 纯内存或 fixture**默认单测/CI** | 模板 `.env.example` 默认 `OPENCLAW_TEST_TARGET=mock` |
| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 开发联调可选 |
| **`real_api`** | 真实 API | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_api` |
| **`real_rpa`** | 真实浏览器/真实系统 | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_rpa` |
- **mock**:纯离线、不联网,给单测 / CI / 开发自测,**保证可重复**。
- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),跑端到端流程但不碰生产。
- **real_api**:有官方接口时**首选**(最稳、最快、最易维护)。
- **real_rpa**:没有 API 只能操作生产界面,**风险最高、放最后**。
> 推荐优先级:**real_api > simulator_rpa > real_rpa**mock 永远保留做 CI。
配置读取见 `CONFIG.md`**bootstrap 之后业务代码只通过 `config.get*()``OPENCLAW_TEST_TARGET` 等项**(进程 env > 用户 `.env` > `.env.example`)。
## 目录骨架
```
scripts/service/<domain>_adapter/
__init__.py # dispatch按档位返回对应 adapter 实例
base.py # 数据契约dataclass+ AdapterBase 接口
mock.py # 离线仿真,给 CI/单测
real_api.py # 真实系统 API
sim_rpa.py # 仿真平台 RPA
real_rpa.py # 真实系统 RPA占位谨慎实现
```
模板示例(均在 `examples/`**不在** `scripts/`
- **仿真浏览器 RPA 完整案例**`examples/simulator_browser_rpa/` — mock + simulator_rpa + sandbox 页面 + adapter 分层 + 测试
- **真实浏览器 RPA 完整案例**`examples/real_browser_rpa/` — 登录/验证码/滚动采集
- **真实 API / 仿真 API**`examples/real_api/``examples/simulator_api/` — 当前为规划占位,尚未沉淀可复制实现
复制 adapter 分层时,以 `examples/simulator_browser_rpa/scripts/service/adapter/` 为权威参考,在新技能中创建 `scripts/service/<domain>_adapter/` 并按 README copy map 选择性复制。
> 先判断属于四象限哪一种(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`。示例是**参考架构与边界**,不是业务代码原样复制。
## 档位 dispatch
`OPENCLAW_TEST_TARGET` 统一决定用哪个 adapter
| `OPENCLAW_TEST_TARGET` | adapter | 用途 |
|---|---|---|
| `unit` / `mock` | `MockAdapter` | 单测 / CI离线 |
| `simulator_rpa` | `SimRpaAdapter` | 开发/演示,操作仿真平台 |
| `real_api` | `RealApiAdapter` | 生产,走官方接口 |
| `real_rpa` | `RealRpaAdapter` | 生产,操作真实界面 |
```python
# __init__.py
from jiangchang_skill_core import config
def get_adapter():
target = (config.get("OPENCLAW_TEST_TARGET") or "mock").lower()
if target in ("unit", "mock"):
return MockAdapter()
if target == "real_api":
return RealApiAdapter()
if target == "real_rpa":
return RealRpaAdapter()
return SimRpaAdapter()
```
`get_adapter()` 实现应配合 `tests/adapter_test_utils.py` 的 profile 策略:**默认必跑测试不得误设 `OPENCLAW_TEST_TARGET=real_*`**。
## contract tests
每个 adapter **必须覆盖同一套契约测试**(复制 `tests/samples/test_service_contract.py.sample`
- 至少覆盖:**timeout**、**unauthorized**、**invalid response**、**empty result**
- **mock 不允许真实网络**
- 使用 `FakeAdapter` 或等价 stub 模拟异常路径
## 兄弟依赖
依赖 account-manager 或其他兄弟技能时:
1.`SKILL.md``metadata.openclaw.dependencies.required` 声明。
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`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/``util/``db/`)。
5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。
```python
# 普通兄弟技能 — 走 sibling_bridge
from service.sibling_bridge import call_sibling_json
result = call_sibling_json("account-manager", ["list", "--limit", "10"])
```
```python
# account-manager 账号/租约 — 集中在 account_client.py
from service.account_client import pick_web_account, release_lease
account = pick_web_account(platform="target_platform")
try:
...
finally:
release_lease(account.get("lease_token"))
```
## 相关文档
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
- `CONFIG.md``.env` 里如何配置运行模式与目标地址
- `TESTING.md` — 测试 target 与隔离体系

99
development/CONFIG.md Normal file
View File

@@ -0,0 +1,99 @@
# 配置标准:`.env` 规范与首次落盘机制
> 解决"每个 skill 每次都要手动搞一堆环境变量、账号、地址"的痛点。统一约定:**模板内置 `.env.example`skill 首次运行自动落盘到用户数据目录,之后从那里读。**
## 核心原则
1. **三层优先级**`进程环境变量` > `{数据目录}/.env` > 仓库 `.env.example` 默认值。
2. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。
3. **首次运行自动 copybootstrap**`scripts/main.py` 启动与 `cli.app.main()` 均会调用 `util.config_bootstrap.bootstrap_skill_config()`,把仓库根目录 `.env.example` 复制到
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`**已存在则不整体覆盖**,保护用户改过的值)。
4. **技能升级合并缺失项**`merge_missing_env_keys` 会把 `.env.example` 中新增、但用户 `.env` 尚未包含的 key **追加**进去(带注释块)。
5. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。
## 标准配置项(`.env` 分组)
每个 skill 的 `.env.example` 至少包含这些通用项(按需增减):
```ini
# ── 运行模式 / adapter 档位(见 ADAPTER.md──
OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_rpa
# ── 目标系统(示例占位,复制后改为业务地址)──
TARGET_BASE_URL=https://sandbox.jc2009.com
# ── 默认账号(仅非敏感标识;密码走 account-manager──
DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ──
OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启反检测指纹(默认)
# ── 录屏与失败存证 ──
OPENCLAW_RECORD_VIDEO=1 # 1=录屏+字幕+旁白+背景音+MP4RPA 默认开,见 RPA.md
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──
STEP_DELAY_MIN=1.0
STEP_DELAY_MAX=5.0
HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒)
```
**业务专属配置必须带技能前缀**(如 `DEMO_XXX``MY_SKILL_XXX`**不要污染全局命名空间**(禁止 `SCRAPE_1688_*``RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
## 🚫 红线:敏感信息不进 `.env`
`.env` 只放**非敏感运行参数**模式、地址、开关、超时、账号标识。密码、密钥、动态口令、token——
-**account-manager** 注册凭据,用 `--secret-storage env --secret-ref XXX` 引用;
- 真实值由宿主/用户通过进程环境变量注入,**不落任何文件、不进 git**。
`.gitignore` 必须包含:
```gitignore
.env
*.env.local
```
## 配置 bootstrap模板提供
模板在 `scripts/util/config_bootstrap.py` 提供 `bootstrap_skill_config()`
```python
from util.config_bootstrap import bootstrap_skill_config
bootstrap_skill_config() # main.py 与 cli.app.main() 启动时调用
```
内部委托共享 runtime 的 `jiangchang_skill_core.config`
```python
config.ensure_env_file(SKILL_SLUG, example_path) # 首次落盘
config.merge_missing_env_keys(example_path, env_path) # 升级后追加缺失 key
```
业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`
```python
config.get("TARGET_BASE_URL")
config.get_bool("OPENCLAW_BROWSER_HEADLESS")
config.get_float("STEP_DELAY_MIN", 1.0)
```
## health / config-path
- **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok``ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`
- **`config-path`**:输出 JSON包含 `skill``env_path`(用户数据目录 `.env`)、`example_path`(仓库 `.env.example` 绝对路径),便于排查落盘位置。
## doctor / setup 命令(可选)
每个 skill 可提供自检命令:
- `python scripts/main.py doctor`检查浏览器、Profile/账号、`.env` 落盘、设备等。
- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`
## 相关文档
- `RPA.md` — 三端 RPA 标准与各开关含义
- `ADAPTER.md``OPENCLAW_TEST_TARGET` 四档模式
- `RUNTIME.md``JIANGCHANG_*` 环境变量与数据目录约定

View File

@@ -92,12 +92,12 @@
your-skill/
├─ .github/
├─ assets/
├─ development/
├─ evals/
├─ references/
├─ scripts/
│ ├─ cli/
│ ├─ db/
│ ├─ jiangchang_skill_core/
│ ├─ service/
│ └─ util/
├─ tests/
@@ -111,7 +111,10 @@ your-skill/
各目录职责如下:
- `assets/`放示例输出、schema、静态说明资源
- `references/`:放研发和编排需要长期维护的文档
- `README.md`:面向用户的市场详情页说明
- `SKILL.md`:面向 LLM / 平台的技能入口
- `references/`Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA
- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范
- `scripts/`:放真正的代码
- `tests/`:放自动化测试
- `evals/`:放人工验收材料、评估清单、示例场景
@@ -125,7 +128,6 @@ scripts/
├─ main.py
├─ cli/
├─ db/
├─ jiangchang_skill_core/
├─ service/
└─ util/
```
@@ -151,24 +153,30 @@ scripts/
- `util/`
作用:常量、日志、路径、时间工具、通用帮助函数
- `jiangchang_skill_core/`
作用运行时环境、统一日志、配置读取、RPA 共享库vendor 自 platform-kit
一般按现有规范技能复制,不要自己乱改结构
公共能力config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics从共享 runtime 的 `jiangchang-platform-kit>=1.0.17` import**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
## 3.2 开发 RPA 类 skill
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读三份标准**`references/RPA.md`(三端范式与反反爬)、`references/CONFIG.md``.env` 落盘与读取)、`references/ADAPTER.md`(四档 adapter
2. **从模板复制骨架**
- `scripts/service/example_adapter/` → 改名为 `scripts/service/<domain>_adapter/`
- 参考 `scripts/service/example_browser_rpa.py` 写浏览器会话逻辑
1. **先读三份标准**`RPA.md`(三端范式与反反爬)、`CONFIG.md``.env` 落盘与读取)、`ADAPTER.md`(四档 adapter
2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map
- 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`
- `examples/real_browser_rpa/scripts/service/browser_session.py`
- `examples/real_browser_rpa/scripts/service/human_verification.py`
- `examples/real_browser_rpa/scripts/service/task_rpa.py`
- `examples/real_browser_rpa/scripts/service/account_client.py`
- 若是 **仿真浏览器 RPA**(自有 sandbox、表单批量提交、可控 DOM**必须先读** `examples/simulator_browser_rpa/README.md`,再按需复制到 `scripts/service/`
- `examples/simulator_browser_rpa/scripts/service/browser_session.py`
- `examples/simulator_browser_rpa/scripts/service/adapter/`
- `examples/simulator_browser_rpa/scripts/service/task_service.py`
- `sandbox/demo_app.html` 仅留在 examples不进入生产 skill
3. **只用共享库,不在 skill 里重写反反爬**
```python
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass
```
vendor 源码在 `scripts/jiangchang_skill_core/rpa/`(与 `jiangchang-platform-kit` 保持同步拷贝)
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本
4. **mock 档必须离线可跑**`OPENCLAW_TEST_TARGET=mock`sim_rpa / real_* 按需单独测。
5. **桌面/手机**:本期标准见 RPA.md 第 2/3 节,复用 `jiangchang_desktop_sdk` / `screencast`**不要在新 skill 里重复造包**(尚待实战验证)。
@@ -176,9 +184,11 @@ scripts/
### 3.1 `requirements.txt` 依赖规范
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能特有的 Python 三方依赖。
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。
- 匠厂宿主安装/更新技能后,会将其安装到共享 venv`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- **公共依赖**`jiangchang-platform-kit``playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`
- `SKILL.md``metadata.openclaw.platform_kit_min_version`(当前 `1.0.17`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
- `pkg>=1.2.0,<2`
@@ -186,7 +196,46 @@ scripts/
- 不推荐:`chromadb>=0.5.0`
- 推荐:`chromadb>=0.5.23,<0.6`
- **不要**把系统组件VC++ Runtime、浏览器安装包等写进 `requirements.txt`;这类前置条件写在 `health` / preflight 文档与错误提示中。
- 若技能无额外 Python 依赖,可保留仅含注释的 `requirements.txt`(宿主会识别为 empty 并跳过安装)
- 若技能无额外 Python 依赖,可保留空文件或仅含注释说明;**不要**为 platform-kit 或 playwright 保留占位行
- 独立本地开发环境若缺少公共包,可手动安装;**生产/宿主运行**由共享 runtime 负责。
### 3.3 公共能力不要复制
以下模块由 platform-kit 提供,技能内**不要**重复实现或 vendored 拷贝:
| 能力 | 导入方式 |
|------|----------|
| config | `from jiangchang_skill_core import config` |
| logging | `from jiangchang_skill_core.unified_logging import ...``util.logging_config` 薄封装 |
| runtime_env | `from jiangchang_skill_core.runtime_env import ...``util.runtime_paths` 薄封装 |
| rpa | `from jiangchang_skill_core.rpa import ...` |
| media_assets | `from jiangchang_skill_core.media_assets import ...` |
| video_session | `from jiangchang_skill_core.rpa.video_session import RpaVideoSession` |
| runtime_diagnostics | `from jiangchang_skill_core import collect_runtime_diagnostics, format_runtime_health_lines` |
`health` 应委托 `collect_runtime_diagnostics`,不要在 `scripts/util/` 自建 `runtime_diagnostics.py`
### 3.4 发布打包约束PyArmor 与编码)
release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前发布链路可能运行在 **PyArmor 免费/试用模式**,因此有两条**发布硬约束**
**单文件行数(仅 `scripts/**/*.py`**
- 每个 `scripts/` 下 Python 文件必须 **小于 1000 行**`>= 1000` 可能在 CI 加密阶段失败)。
- 推荐单文件控制在 **900 行以内**;接近上限时应拆分模块。
- 不要把完整 RPA 主流程、DOM 选择器、数据库、CLI、日志全部堆进一个大文件。
- 正确做法是拆分到 `scripts/cli/``scripts/service/``scripts/db/``scripts/util/`
`examples/` 里的 Python 示例也建议保持模块化,但**行数限制不作为 examples 的发布硬约束**;不要对 README、HTML、测试数据等文档/资源套用 PyArmor 行数规则。
**文本编码UTF-8 without BOM**
- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**
- Python 文件**绝对不能**带 UTF-8 BOM否则 PyArmor/CI 可能报:`invalid non-printable character U+FEFF`
- Windows 编辑器容易保存成 UTF-8 BOM提交前须确认文件开头无 `U+FEFF`
- 不要只写「UTF-8」必须明确 **UTF-8 without BOM**
本地可用 `python tests/run_tests.py release_packaging_constraints -v` 自检;默认必跑套件中的 `test_release_packaging_constraints.py` 会守护上述规则。
## 4. 开发一个新 skill 的标准步骤
@@ -208,9 +257,10 @@ scripts/
复制后优先改下面这些地方:
1. `SKILL.md`
2. `scripts/util/constants.py`
3. `references/` 下的文案
4. `scripts/service/` 下的业务占位实现(优先改 `task_service.py`
2. 根目录 `README.md`(用户市场说明)
3. `scripts/util/constants.py`
4. `references/` `development/` 下的文案
5. `scripts/service/` 下的业务占位实现(优先改 `task_service.py`
最先要统一的是:
@@ -234,47 +284,44 @@ scripts/
如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换:
- `run` 命令中的中文提示与别名策略(见 `references/CLI.md`
- `references/CLI.md` 的命令示例
- `references/README.md` 的用户话术
- `references/SCHEMA.md` 的字段映射补充说明
- `run` 命令中的中文提示与别名策略(见 `../references/CLI.md`
- `../references/CLI.md` 的命令示例
- `../README.md` 的用户话术
- `../references/SCHEMA.md` 的字段映射补充说明
如需浏览器自动化,不要在模板里保留空的 `platform_playwright.py`;请按业务新建 `xxx_playwright.py`(命名自定),并在 `task_service.py` 中按需引用。
## 6. `SKILL.md` 应该怎么写
## 6. `SKILL.md` 与根 `README.md` 应该怎么写
`SKILL.md` 是技能清单,不是设计文档。
### `SKILL.md`LLM / 平台入口)
`SKILL.md` 是给 LLM 与 OpenClaw 平台看的技能入口,**不是**用户市场详情页。
应该重点写:
- 技能名称
- 技能描述
- `slug`
作用:技能的唯一英文标识,通常用于仓库名、发布包名、运行时目录名、平台主键匹配等
示例:`your-skill-slug``disburse-payroll-icbc``tax-invoice-verify`
- `category`
作用:技能在平台中的分类,用于市场展示与归类,不是代码内部键
示例:`通用``内容``办公协作``物流``抖音``小红书`
- `developer_ids`(如需给非公开技能自动补开发者可见权限)
作用:声明发布后默认拥有可见权限的开发者用户 ID 列表
- `dependencies`
作用:声明该技能依赖的兄弟技能或运行前置能力,便于平台或编排层识别依赖关系
示例(按业务填写;发布类可能会依赖账号 / 内容技能,其它领域可能完全不同):
```yaml
dependencies:
required:
- some-sibling-skill
```
- 何时使用本技能
- 对用户的引导话术
- CLI 使用原则
- YAML frontmatter`name``description``version``metadata.openclaw`(含 `slug``platform_kit_min_version``developer_ids` 等)
- 何时触发本技能、CLI 入口摘要、运行契约要点
- 指向 `references/`Agent 调用)与 `development/`(开发规范)的分工说明
不要在 `SKILL.md` 里写大量用户市场文案或实现细节。
### 根 `README.md`(用户市场说明)
`README.md` 是技能市场详情页主来源(`metadata.readme_md`),面向普通用户。
应包含 YAML frontmatter 的 `description`(市场列表简介),正文用用户能理解的语言说明:
- 能做什么、适合什么场景、使用前准备什么
- 如何自然语言告诉 Agent
- 执行结果、注意事项、常见问题
不要在根 `README.md` 里写开发教程、目录规范、release、requirements 等技术细节。
不要在 `SKILL.md` 里写大量实现细节。
实现细节放在:
- `references/`
- 代码注释
- `service/` 实现
- `development/`
- `references/`CLI / SCHEMA
- 代码注释与 `service/` 实现
### 关于 `metadata.openclaw.developer_ids`
@@ -303,33 +350,31 @@ metadata:
- 第一个 ID 会同步到 `skills.developer_id`
- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权
## 7. `references/` 应该放什么
## 7. 文档目录分工
`references/`当前规范 skill 的文档中心,建议至少有这些
当前规范 skill 的文档按读者拆分
- `README.md`
面向内部说明和技能作用介绍
**根 `README.md`**
- `CLI.md`
写清楚命令、参数、默认值、兄弟技能调用方式
- 用户市场详情页说明(含 frontmatter `description`
- `RUNTIME.md`
写清楚运行时目录、环境变量、入口约定
**`SKILL.md`**
- `SCHEMA.md`
写清楚数据库路径、核心表结构、日志表结构
- LLM / OpenClaw 平台技能入口
- `DEVELOPMENT.md`
写给技术人员的开发教程,也就是本文档
**`references/`**Agent 运行/编排/调用时渐进加载)
- `TESTING.md`
写给技术人员的测试分层、隔离数据根与档位开关指南(详见 [`references/TESTING.md`](TESTING.md)
- `README.md` — Agent 参考索引(**不是**市场说明;不得含 YAML frontmatter 或 `description`
- `CLI.md` — 命令、参数、默认值、兄弟技能调用方式
- `SCHEMA.md` — 数据库路径、核心表结构、日志表结构
- 可按需增加 `ERRORS.md``INTEGRATION.md` 等编排向补充
如果后面某个 skill 需要更细的说明,可以再加:
**`development/`**(开发者 / AI 编程代理)
- `ERRORS.md`
- `INTEGRATION.md`
- `PLATFORMS.md`
- `REQUIREMENTS.md` — 需求与验收
- `DEVELOPMENT.md` — 开发教程(本文档)
- `TESTING.md` — 测试分层与隔离(详见 [`TESTING.md`](TESTING.md)
- `ADAPTER.md``RPA.md``CONFIG.md``RUNTIME.md` — 技术规范
## 8. `assets/` 应该放什么
@@ -344,7 +389,7 @@ metadata:
比如日志记录、机读 JSON 的 schema
不要把正式研发文档放到 `assets/`
文档应该进 `references/`
用户说明进根 `README.md`Agent 编排资料进 `references/`;开发规范进 `development/`
## 9. `cli` 层怎么写
@@ -400,6 +445,8 @@ metadata:
- `db/task_logs_repository.py`
只做增删查改(模板默认表:`task_logs`
- `db/display_metadata.py`
创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`
不要在 `db` 层里:
@@ -501,10 +548,24 @@ python scripts/main.py <your-command>
3. **写完 service 业务后**:从 `tests/samples/test_service_contract.py.sample` 复制一份做契约测试。
4. **接外部系统时**:写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` 显式开启。
详见 `references/TESTING.md`
详见 `TESTING.md`
## 15. 发布到正式环境验证
### release workflow 通道
`.github/workflows/release_skill.yaml` 默认引用:
```yaml
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
```
约定如下:
- `jiangchang-platform-kit``main` 分支即稳定通道kit 的开发、测试、验证须在合并 `main` 前完成。
- skill **不需要**锁定 workflow 版本(不要改为 `@v1``@v1.0.x` 等)。
- skill 运行时最低能力要求通过 `SKILL.md``metadata.openclaw.platform_kit_min_version` 声明。
在执行 `release.ps1` 之前,必须先在本地确认 `python tests/run_tests.py -v` 通过。测试不通过不得发起正式发布。
当本地开发、自测和联调完成后,还需要把 skill 发布到正式环境做一次完整验证。建议技术人员严格按下面顺序执行,不要跳步。
@@ -558,7 +619,7 @@ python scripts/main.py <your-command>
如果工作流失败,不要继续做平台验证,而应该先回到代码仓库排查,例如:
- 发布脚本是否正常推送
- `SKILL.md``scripts/``references/` 是否齐全
- `SKILL.md``README.md``scripts/``references/``development/` 是否齐全
- 工作流文件是否存在
- 发布包结构是否符合模板规范
@@ -621,7 +682,7 @@ python scripts/main.py <your-command>
- [ ] 目录结构符合当前模板
- [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `scripts/util/constants.py` 已修改
- [ ] `references/CLI.md` 示例命令已改成真实命令
- [ ] `../references/CLI.md` 示例命令已改成真实命令
- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
- [ ] 没有残留旧平台名
- [ ] `health` / `version` 可运行
@@ -629,6 +690,8 @@ python scripts/main.py <your-command>
- [ ] `release.ps1` 存在
- [ ] `.github/workflows/release_skill.yaml` 存在
- [ ] `python tests/run_tests.py -v` 全部通过
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**PyArmor 试用/免费模式限制;推荐 < 900 行)
- [ ] 源码/文档/配置为 **UTF-8 without BOM**Python 文件不得含 `U+FEFF`
- [ ] 没有新增默认必跑测试访问真实网络或浏览器
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
@@ -658,7 +721,7 @@ python scripts/main.py <your-command>
- `task_service.py`
- `sibling_bridge.py`
- `references/CLI.md`
- `../references/CLI.md`
### 错误 3把业务逻辑写进 CLI
@@ -683,16 +746,17 @@ python scripts/main.py <your-command>
现在的新模板原则是:
- 不做旧结构兼容
- 统一走 `references/` + `scripts/main.py`
- 用户说明在根 `README.md`Agent 资料在 `references/`,开发规范在 `development/`
- 统一走 `scripts/main.py` 作为 CLI 入口
## 18. 推荐开发顺序总结
如果让一个新人照着做,我建议他按这个顺序:
1. 复制模板并改目录名
2. 改 `SKILL.md`
2. 改 `SKILL.md` 与根 `README.md`
3. 改 `scripts/util/constants.py`
4. 改 `references/`
4. 改 `references/``development/`
5. 改 `scripts/cli/app.py`
6. 改 `scripts/service/`
7. 跑 `python tests/run_tests.py -v`
@@ -706,7 +770,7 @@ python scripts/main.py <your-command>
- 目录结构统一
- 入口统一为 `scripts/main.py`
- 文档统一放 `references/`
- 用户说明在根 `README.md`Agent 资料在 `references/`,开发规范在 `development/`
- 业务核心逻辑统一放 `scripts/service/`
- 不再使用旧模板历史结构

14
development/README.md Normal file
View File

@@ -0,0 +1,14 @@
# 开发资料入口
本目录面向**人类开发者**与 **AI 编程代理**。开始定制 skill 前,建议按以下顺序阅读:
1. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
2. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
3. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
4. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
5. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
6. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。
用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。

251
development/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,251 @@
# 技能需求文档模板
这份文档用于给技术人员、产品人员和实施人员统一描述某个 skill 的研发需求。
建议原则:
- 一个 skill 对应一份主需求文档
- 需求先写清楚,再进入开发
- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节
- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md``references/``development/` 下的技术规范
如果你是从 `skill-template` 复制新技能,请复制本文件后,把下面所有占位内容替换成你的真实项目内容。
---
# REQUIREMENTS
## 1. 文档目标
说明这份需求文档的作用,以及它解决什么问题。
模板写法:
- 本文档用于明确 `your-skill-slug` 的研发范围、交付标准、依赖关系和验收要求。
- 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。
## 2. 业务背景
说明为什么要做这个 skill它服务什么业务场景解决什么实际问题。
模板写法:
- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 `task_logs` 便于追踪。
填写提示:
- `<业务场景>`例如“每日批量导入外部系统结果”“Agent 触发的单次查询”
- `<输入数据>`:例如“关键词列表”“批次 ID”“账号标识”
- `<核心动作>`:例如“调用 adapter 提交”“采集并入库”“对账摘要生成”
- `<输出结果>`:例如“结构化 JSON”“任务摘要”“本地 SQLite 记录”
## 3. 开发目标
说明本次开发希望最终交付什么结果。
模板写法(按 skill 类型勾选/改写):
- 解析并校验 `<输入数据>` 的关键字段
- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>`
- 输出结构化结果stdout JSON 或固定文本 + 机读字段)
- 写入 `task_logs`(及必要的业务表,见 `references/SCHEMA.md`
- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志)
- 完成 CLI 入口与 `health` / `version` 最小可运行验证
- 完成正式环境发布与安装验证(如适用)
## 4. 功能范围
说明这次开发明确要实现哪些功能。
模板写法:
- 支持 `<命令1>`(如 `run`
- 支持 `<命令2>`(如 `logs` / `log-get`
- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API
- 支持执行 `<核心业务动作>`
- 支持记录 `<日志 / 状态 / 结果>`
建议将功能拆成“必须实现”和“可后续扩展”。
### 必须实现
- 支持 `python scripts/main.py health`
- 支持 `python scripts/main.py version`
- 支持 `python scripts/main.py <your-main-command>`
- 支持核心业务编排HTTP / 批处理 / 可选 RPA按四档 adapter 选型)
- 支持写入任务日志(`task_logs`
### 可后续扩展
- 支持定时批跑
- 支持失败自动重试
- 支持多 `<目标系统>` 轮询
## 5. 非功能要求
说明除功能外,对稳定性、可维护性、目录规范、日志、编码等方面的要求。
- 目录结构必须符合当前 `skill-template` 规范
- 入口必须统一为 `scripts/main.py`
- 用户说明写在根 `README.md`LLM/运行契约写在 `SKILL.md`;开发规范在 `development/`
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
- Windows 环境下需保证 UTF-8 输出兼容
- 必须具备基本日志能力;敏感字段脱敏
- RPA 类 skillPlaywright 由宿主共享 runtime 提供;技能侧不要 `playwright install`URL 不要放进 `launch_persistent_context``args`
## 6. 输入输出要求
### 输入
填写本 skill 实际接受的参数与数据来源:
- `<参数名>`:含义、是否必填、默认值
- `<依赖系统>` 返回的结构化数据(如有)
- 环境变量 / `.env` 中的关键配置(引用 `CONFIG.md`
### 输出
- 成功结果stdout / JSON 字段说明)
- 错误输出(`ERROR:` 稳定码 + 中文 message
- 数据库记录(表名、关键字段,见 `references/SCHEMA.md`
- 可选RPA 截图/视频路径artifacts
## 7. 依赖的兄弟技能或外部系统
### 兄弟技能依赖
- `<sibling-skill-slug>``<用途>`(例如 account-manager 提供 profile_dir / 租约)
### 外部系统依赖
- `<目标系统>``<用途>`HTTP API / 仿真 sandbox / 真实网页)
- 浏览器(如适用):系统 Chrome/Edge非 Playwright 内置 Chromium
- Gitea / 匠厂客户端:发布与正式环境验证
### Python 包依赖requirements.txt
- 本技能若需**特有** Python 三方包,写入根目录 `requirements.txt`
- `jiangchang-platform-kit``playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
- `SKILL.md``platform_kit_min_version` 是运行契约/兼容性声明,**不是** pip 依赖声明。
- 版本须尽量收窄(如 `requests>=2.31.0,<3`),避免共享 venv 冲突。
- **不要**把系统运行时组件写进 requirements.txt`health` / preflight 中检测并提示。
## 8. 不在本次范围内的内容
明确本次“不做什么”,避免范围蔓延:
- 本次不实现 `<功能A>`
- 本次不处理 `<平台B>` / `<档位>`
- 本次不做 `<高级能力>`
- 本次不兼容旧模板历史结构(`docs/``optional/``skill_main.py` 等)
## 9. 验收标准
什么情况下才算开发完成:
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
- `health``version` 命令执行正常
- 主命令(如 `run`)在 mock / simulator 档位可重复验证
- `python tests/run_tests.py -v` 必跑测试全部通过
- `task_logs` 写入和查询符合 `references/SCHEMA.md`
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
- `tests/test_display_metadata.py` 通过
- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联
- 发布后 Gitea 工作流成功;匠厂技能市场可见最新版本;安装后可在“新建任务”中调用
## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`
- 发布前完成本地验证、工作流验证和正式环境安装验证
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| YYYY-MM-DD | v1.0 | `<姓名>` | 初版需求文档 |
---
## 建议使用方式
1. 先写 `REQUIREMENTS.md`
2. 再按 `DEVELOPMENT.md` 进入开发
3. 开发过程中补充 `references/CLI.md``references/SCHEMA.md``development/` 技术规范
4. 发布前对照第 9 节验收标准逐项检查
## 最小模板示例
下面给出一个可直接参考的简化版(复制后替换占位符):
```md
# REQUIREMENTS
## 1. 文档目标
本文档用于明确 `your-skill-slug` 的研发需求、范围和验收标准,作为开发与测试的统一依据。
## 2. 业务背景
当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
因此需要开发 `your-skill-slug`,自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 task_logs。
## 3. 开发目标
- 解析并校验输入
- 通过 adapter 完成 mock / simulator / real 档位动作
- 输出结构化结果并写入 task_logs
- 支持失败可诊断
## 4. 功能范围
- 支持 health / version / run
- 支持 logs / log-get
## 5. 非功能要求
- 入口统一为 scripts/main.py
- 保持 UTF-8 输出;结构符合 skill-template
## 6. 输入输出要求
### 输入
- target_id、input_id按业务定义
### 输出
- 成功 JSON / 摘要
- ERROR: 稳定错误码
- task_logs 记录
## 7. 依赖的兄弟技能或外部系统
- `<sibling-skill>`(按需)
- `<目标系统>` API 或 sandbox
## 8. 不在本次范围内的内容
- 不实现自动重试
- 不实现 `<本次不做>`
## 9. 验收标准
- 命令可运行tests/run_tests.py -v 通过
- task_logs 符合 SCHEMA
- 正式环境安装验证通过
## 10. 开发注意事项
- 不修改无关项目;不引入旧模板结构
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| YYYY-MM-DD | v1.0 | 张三 | 初版 |
```

282
development/RPA.md Normal file
View File

@@ -0,0 +1,282 @@
# RPA 操作标准(浏览器 / 桌面 / 手机)
> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill都应先读这份文档按这里的选型和范式落地不要每个项目重新踩坑。
我们开发的各类 skill本质上都是在替人操作三类界面**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:保持登录态、有头运行、拟人操作、失败存证、人工兜底。
---
## 0. 三端通用范式(先看这个)
无论操作哪类界面,都遵循同一套约定:
| 约定 | 说明 |
|------|------|
| **保持登录态** | 复用持久化 Profile / session避免每次重新登录触发风控账号由 account-manager 下发,不硬编码 |
| **有头运行** | 默认有头headless 易被识别 / 难人工介入);`OPENCLAW_BROWSER_HEADLESS=1` 仅给 CI |
| **拟人操作** | 真实事件isTrusted=true逐字输入、随机延迟、贝塞尔鼠标轨迹严禁 JS 直接设值/JS 点击/JS 跳转 |
| **步骤间随机等待** | 每两步操作之间 `random_delay(min,max)`,区间由 `.env` 配置(默认 1~5s |
| **人工兜底HITL** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` |
| **选择器纪律** | 语义选择器优先id/name/text/aria**F12 确认后再写,严禁凭记忆猜 DOM** |
| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 |
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
> 三端各自实现一个会话抽象 `RpaSession`launch / login / act / screenshot / close上层 skill 不感知是浏览器还是手机。
---
## 1. 浏览器(标准已成熟)
**选型Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts``receive-order` 已落地)。
### 1.0 生产路径(优先)
| 项 | 标准 |
|----|------|
| 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context``channel="chrome"` 或 Edge不用内置 Chromium**技能内不要 `playwright install`** |
| 登录态 | 持久 Profile 目录账号、profile、lease **统一走 account-manager**(或对应兄弟技能),不硬编码密码 |
| CDP | **仅作诊断 / 桌面宿主类场景****不要**作为强风控站点的默认生产路径 |
| 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 |
| 模式 | 默认有头 `OPENCLAW_BROWSER_HEADLESS=0`;无头仅 CI |
| 反检测 | stealth 默认开 `OPENCLAW_PLAYWRIGHT_STEALTH=1`(见 1.1 |
### 1.1 Playwright 启动标准
1. **默认有头**`OPENCLAW_BROWSER_HEADLESS=0``.env.example` 默认值)。
2. **stealth 默认开**`OPENCLAW_PLAYWRIGHT_STEALTH=1`;通过 `add_init_script` 注入指纹淡化脚本。
3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。
4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。
5. **不要默认传 `--disable-blink-features=AutomationControlled`**platform-kit stealth 已覆盖,额外 flag 可能适得其反。
6. **可以** `ignore_default_args=["--enable-automation"]`platform-kit `launch_persistent_browser` 已处理)。
7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、持久 profile**不要**直接拼接搜索结果 URL 或 DOM 注入。
指纹淡化stealth典型项`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages` 等。共享实现见 `jiangchang_skill_core.rpa`platform-kit **>= 1.0.17**)。
**拟人操作**(必做):
- 输入:逐字符 `keyboard.type(delay=90~240ms)`**先真实点击聚焦再输入,绝不 `el.value=`**。
- 鼠标:贝塞尔曲线轨迹 + 微抖动;进场随机晃动。
- 导航:用真实点击触发,**不要 `window.location.href=` / JS 点击跳转**。
- 翻页:真实点击翻页控件,注意排除禁用态。
- 延迟:每步之间 `random_delay``.env``STEP_DELAY_MIN/MAX`)。
### 1.2 页面启动标准
**`launch_persistent_context``args` 只能放 Chrome 启动参数**(例如 `--start-maximized``--disable-blink-features=AutomationControlled` 等)。
**绝对不要把 `https://...` 这类 URL 放进 `args`。** 否则 Playwright 会报错:
```
BrowserType.launch_persistent_context: Arguments can not specify page to be opened
```
**正确做法(生产路径):**
1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, ...)`
2. `page = await context.new_page()`
3. `await page.goto(start_url, wait_until="domcontentloaded", timeout=60000)`
对强风控站点,如果需要更拟人,可以启动后通过**地址栏输入 / 真实点击进入**目标页,但**仍然不能把 URL 塞进 launch args**。
完整参考实现见:
- `examples/real_browser_rpa/README.md`**开发真实浏览器 RPA 类 skill 时必须先读**
- `examples/real_browser_rpa/scripts/service/browser_session.py`
**生产路径不要依赖 CDP 接管现有页面**CDP 仅用于诊断或桌面宿主已打开浏览器的场景。
### 1.3 HITL / 验证码
- 自动处理失败时**允许等待人工**滑块、短信、人脸、U盾、动态口令等。
- 等待人工验证时必须有:**字幕 step**(用户可见动作说明)、**结构化日志**、**超时**`HUMAN_WAIT_TIMEOUT`)。
- 检测到风控页URL/DOM 特征)→ 抛 `ERROR:CAPTCHA_NEED_HUMAN`,轮询等待或超时,**不要强行绕过平台安全机制**。
- **不要自动操作滑块**。
> 共享库:`jiangchang_skill_core.rpa.wait_for_captcha_pass`**真实浏览器 RPA 成功案例**见 `examples/real_browser_rpa/`
>
> - 先读 `examples/real_browser_rpa/README.md`
> - 再参考 `examples/real_browser_rpa/scripts/service/human_verification.py`
> - 再参考 `examples/real_browser_rpa/scripts/service/task_rpa.py`
### 1.4 Playwright 与安装边界
- Playwright 是宿主共享 runtime 提供的通用 Python 包能力;**正式 skill 不负责安装**。
- skill 侧**禁止**在 `requirements.txt`、安装脚本、运行脚本、根 `README.md` 用户说明中加入 `playwright install``pip install playwright` 或任何自动安装浏览器驱动的逻辑。
- 真实浏览器 RPA 默认使用**系统 Chrome/Edge**,由宿主/runtime 管理。
- **不要**执行 `playwright install chromium``playwright install chrome` 等浏览器驱动安装命令。
- 仅当**本地独立开发环境排障**时,开发者可手工检查或补装 Python 包 `playwright`;该操作**不得**进入 skill 交付文件与自动流程。
页面启动仍遵循 1.2`launch_persistent_context``args` 只能放 Chrome 启动参数,**不能把 URL 塞进 `args`**;打开页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入。
### 引用方式
共享实现位于宿主共享 runtime 安装的 `jiangchang-platform-kit``jiangchang_skill_core.rpa`)。复制后的业务技能直接 import**技能仓库不得保留 rpa 公共代码副本**
```python
from jiangchang_skill_core.rpa import (
launch_persistent_browser,
anti_detect,
wait_for_captcha_pass,
capture_failure,
errors,
)
from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRIPT
```
- `RpaVideoSession` 来自 platform-kit **>= 1.0.17**ffmpeg、背景音乐、media-assets 由 platform-kit 统一解析;已提供前置/后置缓冲、字幕、TTS 旁白、背景音乐循环、结尾淡出。
- `health` 对上述资源做只读诊断,不下载、不修复。
### 1.5 真实浏览器 RPA 示例(必读)
开发**真实网站 + 浏览器操作 + 登录态/验证码/滚动采集**类 skill 时:
1. **必须先阅读** `examples/real_browser_rpa/README.md`
2. 再参考以下代码:
- `examples/real_browser_rpa/scripts/service/browser_session.py` — persistent context 启动
- `examples/real_browser_rpa/scripts/service/human_verification.py` — 人工验证等待
- `examples/real_browser_rpa/scripts/service/task_rpa.py` — RPA 主流程
- `examples/real_browser_rpa/scripts/service/account_client.py` — account-manager CLI 封装
仿真浏览器 RPA 的 adapter 分层权威参考见 `examples/simulator_browser_rpa/scripts/service/adapter/`**不能**替代真实浏览器 RPA 规范。
### 1.6 仿真浏览器 RPA 示例(必读)
开发**自有仿真页面 / sandbox / 可控 DOM 的浏览器 RPA**(表单填写、批量提交、弹窗确认)时:
1. **必须先阅读** `examples/simulator_browser_rpa/README.md`
2. 再参考:
- `examples/simulator_browser_rpa/scripts/service/browser_session.py`
- `examples/simulator_browser_rpa/scripts/service/adapter/`base / mock / simulator_rpa / dispatch
- `examples/simulator_browser_rpa/scripts/service/task_service.py`
- `examples/simulator_browser_rpa/sandbox/demo_app.html`
> **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。
---
## 2. 桌面软件Windows 原生程序)
**选型pywinautoUIA backend为主 + 图像识别兜底。**
桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。
| 技术 | 优先级 | 适用 / 说明 |
|------|--------|------|
| **pywinauto`backend="uia"`** | ✅ 首选 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType**稳定、不依赖屏幕坐标**,纯 Python |
| **FlaUI**(经 pythonnet 调 .NET | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 |
| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 |
| **PyAutoGUI / SikuliX图像识别** | ⚠️ 兜底 | 控件树完全拿不到时Flash/远程桌面/纯自绘 UI**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 |
### 桌面端注意事项
- **窗口聚焦/置顶**:操作前确保目标窗口前置,避免误操作其它窗口。
- **DPI/缩放**图像识别方案必须固定显示缩放比例UIA 方案不受影响(优先用 UIA 即是为此)。
- **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`
- **人工兜底**U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`
```bash
pip install pywinauto # UIA 自动化
# 图像兜底pip install pyautogui opencv-python
```
> 状态:桌面端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
---
## 3. 手机软件USB 连接电脑)
**选型Android 用 uiautomator2或 AppiumiOS 用 Appium + WebDriverAgent需 Mac** 底层都是经 USB 的 ADB / WDA。
| 平台 | 技术 | 优先级 | 说明 |
|------|------|--------|------|
| **Android** | **uiautomator2**python 原生) | ✅ 首选 | ADB over USB直接拿控件树点击/输入,比 Appium 轻快,纯 Python |
| Android | **Appium**uiautomator2 driver | 备选 | 需要跨平台统一接口、或团队已有 Appium 资产时用 |
| Android | **Airtest + Poco**(网易开源) | 兜底 | 图像+控件混合,自带 IDE 可录制;控件树拿不到时用 |
| **iOS** | **Appium + WebDriverAgentXCUITest** | 唯一可行 | **必须有一台 Mac 做中转**Windows host 无法直接驱动 iOS |
| 投屏/人工介入 | **scrcpy** | 辅助 | USB 投屏到电脑,配合人工过验证码/人脸 |
### 手机端注意事项
- **设备就绪检查**`adb devices` 确认已授权连接;放进 `doctor` 自检。
- **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。
- **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。
- **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`
```bash
# Android首选
pip install uiautomator2
python -m uiautomator2 init # 初始化设备端 agent
# 或统一走 Appiumpip install Appium-Python-Client另需 Appium Server
```
> 状态:手机端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
---
## 4. 统一错误码RPA 场景)
skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层判断与重试:
| 错误码 | 含义 | 上层处理建议 |
|--------|------|------|
| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程 |
| `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 |
| `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 |
| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 |
| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 |
| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB |
| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 |
---
## 5. 存证与录屏规范
### 5.1 截图存证
- **路径**`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。
- **Playwright 不负责录屏**,仅浏览器自动化。
- **常见 tag**`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`
### 5.2 RPA 视频 step 标准
字幕是**用户可见动作说明**不是技术日志。step 要贴近真实动作,不要只在大流程入口打点。
推荐关键动作示例(按业务裁剪):
- 启动浏览器
- 打开首页
- 检查登录状态
- 定位输入框
- 输入关键词xxx
- 点击搜索
- 等待结果
- 打开详情页
- 提取信息
- 写入结果
- 任务完成
技术诊断、重复跳过、DB 写入可以显示但**通常不需要旁白**。
`title` / `closing_title` 必须由 skill 传入**中文业务文案**(如「开始执行示例任务」「示例任务执行完成」)。
### 5.3 录屏成片标准
- RPA skill 默认 `OPENCLAW_RECORD_VIDEO=1`
- 使用 platform-kit 的 **`RpaVideoSession`****skill 不要自行合成视频**(不要自己调 ffmpeg 拼 MP4
- `OPENCLAW_RECORD_VIDEO=0` 时 session 无副作用(不启 ffmpeg、不写字幕文件
- **ffmpeg 是唯一录屏器**Windows`gdigrab` + `desktop`)。
- **最终视频**`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4`
- **中间产物**`rpa-artifacts/{batch_id}/capture.mp4``subtitles/``logs/` 等。
- 任务完成后 CLI / `result_summary` 应包含:`video_path``raw_video``video_log``video_warnings``music_path``voiceover_path``audio_warnings`(见 `scripts/service/task_run_support.py`)。
模板最小示范见 `scripts/service/task_service.py``_run_template_demo()`
---
## 相关文档
- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
- `CONFIG.md``.env` 配置规范与首次落盘机制
- `RUNTIME.md` — 运行时目录与环境变量约定

127
development/RUNTIME.md Normal file
View File

@@ -0,0 +1,127 @@
# 运行时约定
## 共享 Python Runtime
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime`jiangchang-platform-kit>=1.0.17` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored应由共享 venv 的 site-packages 提供。
技能根目录 `requirements.txt` **只声明技能特有依赖****不要**重复声明 `jiangchang-platform-kit``playwright``SKILL.md``platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。
| 场景 | 推荐做法 |
|------|----------|
| 日常运行 | 由宿主匠厂触发技能 |
| 手工排查 / 测试机 | 使用共享 runtime 的 `python.exe` 执行 `scripts/main.py` |
| **不推荐** | 在技能目录内 `uv run python …` — 可能创建临时 venv加载不到宿主共享 runtime |
占位命令(路径因环境而异,勿写死本机目录):
```text
Windows:
{JIANGCHANG_DATA_ROOT}\python-runtime\.venv\Scripts\python.exe {baseDir}\scripts\main.py health
通用:
<shared-python> {baseDir}/scripts/main.py health
```
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入;开发模式下也可能通过 `JIANGCHANG_DATA_ROOT` 解析(见 `jiangchang_skill_core.runtime_env`)。
## Runtime 诊断platform-kit 1.0.17+
`health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段:
- `skill_slug``python_executable``platform_kit_version``platform_kit_min_version`
- `jiangchang_skill_core_file` — 若从技能目录加载会输出 `runtime_issue[warning]`
- `JIANGCHANG_DATA_ROOT` / `resolved_data_root`
- `media_assets_root``ffmpeg_path``background_music_*`
- `record_video_enabled``runtime_issue[warning|error]`
`health` 是**只读诊断****不会**触发 media-assets 下载或修复。并补充 `env_path` / `env_exists` / `example_path` 配置路径字段。
## 配置 bootstrap
- 仓库内 `.env.example` 是配置模板(单一事实来源)。
- 用户实际 `.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.0.17`**不得** vendored `scripts/jiangchang_skill_core/`
## media-assets / ffmpeg / 背景音乐
背景音乐、ffmpeg 等共享资源由 platform-kit 统一解析,默认路径:
```text
{JIANGCHANG_DATA_ROOT}/shared/media-assets
```
(由宿主注入 `JIANGCHANG_DATA_ROOT`。)
RPA 录屏成片(`RpaVideoSession`、ffmpeg 路径、背景音乐探测均由 platform-kit 提供;技能只 import不重复实现。
## 目录结构
新技能建议采用以下根目录结构:
- `assets/`
- `development/`
- `references/`
- `scripts/`
- `tests/`
- `evals/`
**标准 `scripts/` 分层(不含 `jiangchang_skill_core/` 副本):**
- `scripts/main.py`:唯一 CLI 入口
- `scripts/cli/`:参数解析与命令分发
- `scripts/db/`SQLite 或本地持久化层
- `scripts/service/`:业务用例与外部交互
- `scripts/util/`:通用工具、常量、日志、路径薄封装
## 数据路径
推荐:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/
```
数据库文件建议:
```text
{...}/{skill_slug}.db
```
- 业务表使用英文 snake_case中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。
- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。
## 明确禁止
- **不得** vendored `scripts/jiangchang_skill_core/`
- **不得**在技能内本地重复实现 `RuntimeDiagnostics` / `collect_runtime_diagnostics`
- **不得**写死开发机绝对路径作为运行时约定
## 测试时的运行时隔离
本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间设置:
- `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录
- `JIANGCHANG_USER_ID``_test`
退出时两个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。
详见 `TESTING.md` 第 3 节。
## 编码与输出
- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**不要只写「UTF-8」
- Python 文件**绝对不能**带 UTF-8 BOM否则 release CI 的 PyArmor 阶段可能报:`invalid non-printable character U+FEFF`
- Windows 编辑器容易保存成 UTF-8 BOM提交前确认文件开头无 `U+FEFF`
- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装
- 机读输出优先单行 JSON
- 错误前缀建议统一 `ERROR:`
## 发布打包约束(`scripts/**/*.py`
release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式:
- `scripts/**/*.py` 单文件必须 **< 1000 行**`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。
- 接近上限时拆分到 `cli/``service/``db/``util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。
- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。

View File

@@ -1,6 +1,6 @@
# 测试开发指南
面向复制 `skill-template` 后的新业务 skill**如何把自动化测试当作一等公民**,而不是等业务写完再补文档级别的空话。本文串起模板自带的 unittest 入口、`tests/` 目录分层与安全档位约定;**更细的开关取值、表格字段与环境变量组合仍以 [`tests/README.md`](../tests/README.md) 为权威来源**。建议你随手开一个编辑器分页:`references/TESTING.md`(本篇)、[`references/DEVELOPMENT.md`](DEVELOPMENT.md)(整体节奏)、[`tests/README.md`](../tests/README.md)(落地细则)。
面向复制 `skill-template` 后的新业务 skill**如何把自动化测试当作一等公民**,而不是等业务写完再补文档级别的空话。本文串起模板自带的 unittest 入口、`tests/` 目录分层与安全档位约定;**更细的开关取值、表格字段与环境变量组合仍以 [`tests/README.md`](../tests/README.md) 为权威来源**。建议你随手开一个编辑器分页:`TESTING.md`(本篇)、[`DEVELOPMENT.md`](DEVELOPMENT.md)(整体节奏)、[`tests/README.md`](../tests/README.md)(落地细则)。
默认心智模型可以用一句话概括:**根目录 `test_*.py` = CI / 本地每次提交都应能通过的无外联套件**`*integration*``*.sample``desktop/` = 只在人被明确要求时才启用的高风险或重量级路径。
@@ -22,12 +22,15 @@
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
- CLI导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health` / `version` / `logs` / `log-get` 冒烟;
- CLI导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`runtime diagnostics/ `version` / `logs` / `log-get` 冒烟;
- 架构守护:无 `scripts/jiangchang_skill_core/``platform-kit>=1.0.17` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等);
- **真实 subprocess**[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移;
- 运行时:`runtime_paths`**`CLAW_*` / `JIANGCHANG_*` 并发兜底
- 运行时:`runtime_paths`**`JIANGCHANG_*` 隔离**
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py)
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序(见 `test_display_metadata.py`
- **adapter profile**[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
- **发布打包守护**[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration并由档位变量放行
@@ -37,7 +40,7 @@
## 3. 数据隔离IsolatedDataRoot
[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,**镜像写入四套变量**`CLAW_DATA_ROOT``JIANGCHANG_DATA_ROOT`同一 tempfile、以及用户镜像 ID`CLAW_USER_ID``JIANGCHANG_USER_ID``_test`
[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,写入 `JIANGCHANG_DATA_ROOT`tempfile`JIANGCHANG_USER_ID``_test`
结束时:**恢复原 environ**,删除目录。
@@ -69,22 +72,14 @@ def test_whatever():
| `mock` | 与 `unit` 类似的安全档位(显式语义) |
| `simulator_api` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| `simulator_rpa` | 仅允许 **仿真页面 / 录播 RPA** |
| `real_api` | 真实 API另需 `ALLOW_REAL_API=1` |
| `real_rpa` | 真实 RPA另需 `ALLOW_REAL_RPA=1` |
| `real_api` | 真实 API显式设 `OPENCLAW_TEST_TARGET=real_api` |
| `real_rpa` | 真实 RPA显式设 `OPENCLAW_TEST_TARGET=real_rpa` |
未设置环境变量 ⇒ 等价 `unit`
授权开关(显式 `1`)语义 **`ALLOW_REAL_API` / `ALLOW_REAL_RPA` / `ALLOW_WRITE_ACTIONS`**——摘录 [`tests/README.md`](../tests/README.md) §5.2
默认策略摘要:**不要在 unittest 必跑路径误设 `OPENCLAW_TEST_TARGET=real_*`**。
| 变量 | 作用 |
|------|------|
| `ALLOW_REAL_API=1` | 允许 `real_api` profile 访问真实 HTTP 通道 |
| `ALLOW_REAL_RPA=1` | 允许 `real_rpa` profile 驱动真实浏览器/RPA |
| `ALLOW_WRITE_ACTIONS=1` | 在 `real_*` 下允许**写**操作(提交表单、下单等) |
默认策略摘要见 §5.3**不要在 unittest 必跑路径误把闸门打开**。
兼容别名:`OPENCLOW_TEST_TARGET`(历史拼写)。
档位读取与业务代码一致:经 `jiangchang_skill_core.config.get("OPENCLAW_TEST_TARGET")`(见 `CONFIG.md`)。
---
@@ -162,7 +157,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
| **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref |
| mock 优先 | 逻辑应在 service + FakeAdapter而不是巨胖 CLI 断言 |
| 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 |
| **integration = 显式开关** | `OPENCLAW_TEST_TARGET` + `ALLOW_REAL_*` / `ALLOW_WRITE_ACTIONS` |
| **integration = 显式档位** | `OPENCLAW_TEST_TARGET``real_*` 只放 integration / 手动触发) |
---
@@ -178,8 +173,56 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
---
## 延伸阅读
## 11. pytest 收集卫生标准
模板根目录提供 `pytest.ini`(或等价配置),约束:
- **只收集** `test_*.py` / `*_test.py`
- **不让** `.txt`、结果文件、日志文件被 pytest 误收集
- `norecursedirs` 排除 `integration/``desktop/``samples/``fixtures/``artifacts/``diagnostics/`
测试结果文件**不要**放在 `tests/` 根目录;应放 `tests/artifacts/``tests/diagnostics/` 并加入 `.gitignore`
默认 `python tests/run_tests.py` 仍只发现 `tests/` 根目录一层 `test_*.py`(不递归子目录),与 pytest 策略一致。
---
## 12. RPA / video 测试标准
- `RpaVideoSession` 调用**不跑真实 ffmpeg**:单测中使用 `unittest.mock` patch session 或设 `OPENCLAW_RECORD_VIDEO=0`
- 断言 `title` / `closing_title` 是**中文**业务文案。
- 断言 video artifact 会进入 `result_summary``video_path``raw_video``video_log` 等)。
- 断言 step 文案贴近用户动作不是技术日志如「准备执行示例任务」而非「enter cmd_run」
参考 `tests/test_video_service.py`
---
## 13. 宿主 E2E 标准
- 使用 `jiangchang_desktop_sdk.e2e_helpers`(见 `tests/desktop/``.sample`)。
- **不伪造**用户数据目录;通过宿主 IPC 获取真实 `skill_data_dir`
- E2E **不自动生成**真实密钥或生产凭证。
---
## 14. 测试前检查清单(模板守护)
复制新 skill 或修改模板后,确认:
- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright`
- [ ] 无 `scripts/jiangchang_skill_core/` vendored 副本
- [ ] `platform_kit_min_version` **>= 1.0.17**`SKILL.md` + `constants.py`
- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行)
- [ ] `config-path` 可输出用户 `.env` 路径 JSON
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`
- [ ] pytest **不会**误收集 `tests/` 下的 `.txt` / 日志 / 结果文件
- [ ] `python tests/run_tests.py -v` 全部通过
- [ ] `scripts/**/*.py` 单文件 < 1000 行(`test_release_packaging_constraints.py`
- [ ] 文本文件为 UTF-8 without BOM`U+FEFF`(同上)
---
- [`tests/README.md`](../tests/README.md) — 表格、变量与目录细则
- [`references/DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序
- [`DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序
- [`tests/integration/README.md`](../tests/integration/README.md) — 高风险用法

69
examples/README.md Normal file
View File

@@ -0,0 +1,69 @@
# Skill Template Examples
`examples/` 是面向**技术人员与 AI 编程工具**的**核心实现案例**目录:提供可运行、可测试、可复制的参考代码,用于指导新技能的 `scripts/` 应如何编写。
**`examples/` 不是完整 skill 仓库。** 不要整包照搬 `examples/<mode>/` 到新技能根目录。应按各 example README 的 **copy map****选择性复制**到新技能的 `scripts/`(及按需的 `tests/`)。
`README.md``SKILL.md``references/``development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。
## 四类外部交互方式
一级目录固定为四种模式(不可删除、不可移动):
| 目录 | 类型 | 状态 | 适用场景 |
|---|---|---|---|
| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 |
| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 |
| `real_api` | 真实 API | **占位** | 真实系统接口、token、权限、限流尚未沉淀可复制实现 |
| `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器尚未沉淀可复制实现 |
## 两类浏览器 RPA 的区别
| | real_browser_rpa | simulator_browser_rpa |
|---|---|---|
| 目标 | 真实第三方网页 | 自有仿真页面 / sandbox |
| 风控 | 高(验证码、登录态、反爬) | 低DOM 可控) |
| 账号 | 通常需 account-manager + profile | 示例可用 env / demo 账号 |
| 用途 | 生产级真实采集/操作参考 | adapter 分层 + 表单 RPA 教学 |
## 如何选择示例
| 你的场景 | 参考目录 |
|---|---|
| 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** |
| 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** |
| 真实系统 API | **real_api**(当前占位,不可作为实现参考) |
| mock / sandbox API无浏览器 | **simulator_api**(当前占位,不可作为实现参考) |
## 轻量结构约定
每个 `examples/<mode>/` 推荐结构:
```text
examples/<mode>/
README.md
scripts/
service/
util/
tests/
```
-`simulator_browser_rpa` 可额外包含 `sandbox/`
- 仅未来确实需要样例数据时可加 `fixtures/`
- **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md``references/``development/``assets/``.github/`、release workflow 等完整 skill 仓库层文件
## `scripts/` 与 `examples/` 边界
| | `scripts/` | `examples/` |
|---|---|---|
| 定位 | 真实技能业务逻辑的标准骨架 | 核心实现案例与复制参考 |
| 内容 | 干净的技能骨架,无教学/demo 代码 | 可运行参考实现,非完整 skill |
| 旧示例 | **不得**出现 `example_*` 目录或文件 | 四类一级目录固定保留 |
复制规则:
- 真实浏览器 RPA → 参考 `examples/real_browser_rpa/`
- 仿真浏览器 RPA → 参考 `examples/simulator_browser_rpa/`adapter 分层权威参考:`scripts/service/adapter/`
- `real_api/``simulator_api/` 当前为规划占位,**不得**作为实现参考
> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。

View File

@@ -0,0 +1,35 @@
# Real API Example
这个目录用于沉淀「真实系统 API」类 skill 的可复制成功案例。
## 适用场景(规划)
- 目标系统提供真实 API
- 需要 token / secret / credential
- 需要请求重试、权限校验、限流处理、schema 校验和幂等写库
## 当前状态:规划占位
- **本目录当前为规划占位**,仅保留最小结构(`README.md``scripts/``tests/` 占位)。
- **尚未沉淀可复制实现**;目录内无参考代码。
- **AI 编程工具不得**基于空目录自行脑补 API 对接实现。
- 后续补齐可复制案例后,再按 README copy map 选择性复制到新技能 `scripts/`
## 轻量结构
```text
real_api/
README.md
scripts/
service/
util/
tests/
```
不要在本目录添加 `SKILL.md`、市场 README、`references/``development/``.github/` 等完整 skill 仓库层文件。
## 复制边界(待案例补齐后生效)
- 参考代码将从 `examples/real_api/scripts/` 选择性复制到新技能 `scripts/`
-`README.md``SKILL.md``references/` 仍按模板主目录规则编写
- 当前阶段:**不可作为实现参考**

View File

View File

View File

@@ -0,0 +1,193 @@
# Real Browser RPA Example
这是一个真实网站浏览器 RPA skill 的可复制参考实现,适用于需要登录、人工验证、搜索、滚动加载、采集和入库的场景。
**说明:**
- 本示例是通用模板,不绑定具体平台。
- 示例抽象自已跑通的真实浏览器 RPA skill抖音视频链接采集但代码已改成中性示例命名。
- 新 skill 开发时可以复制本目录,再替换目标站点、选择器、解析逻辑和业务字段。
## 适用场景
- 真实网站操作(非 sandbox / mock
- 需要登录态与 persistent Chrome/Edge profile
- 需要通过 account-manager 管理账号和 `profile_dir`
- 需要处理人工登录、滑块验证码、短信验证码
- 需要关键词搜索、滚动加载、采集可见条目
- 需要结构化日志、用户友好的停止原因、失败留痕(截图/视频)
## 目录结构
```
real_browser_rpa/
├── README.md
├── scripts/
│ ├── service/
│ │ ├── account_client.py # subprocess 调用 account-manager
│ │ ├── browser_session.py # 系统 Chrome + persistent context
│ │ ├── human_verification.py # 滑块/短信/保存登录弹窗检测(不依赖 task_rpa 错误类型)
│ │ ├── task_rpa.py # RPA 主流程
│ │ └── task_service.py # 轻量编排(示例无 DB
│ └── util/
│ ├── constants.py # 平台常量、selector 占位
│ └── logging.py # 结构化日志与脱敏
└── tests/
├── test_human_verification.py # 验证码误判防护
└── test_stop_reason.py # 停止原因文案
```
## 核心流程
1. **pick account** — 通过 `account_client.pick_web_account()` 获取 `profile_dir` 与租约
2. **启动浏览器**`launch_persistent_context` + `new_page()` + `page.goto(start_url)`
3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成
4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现
5. **保存登录弹窗** — 等待“是否保存登录信息”倒计时消失
6. **人工验证(登录后)** — 再次检测
7. **搜索** — 定位搜索框 → 输入关键词 → 点击搜索
8. **等待结果容器**`#search-result-container`(示例 selector
9. **滚动采集循环** — 采集可见条目 → 判断 stop_reason → 滚动加载
10. **返回结果**`ScrapeRunResult` + `format_stop_reason_for_user()`
11. **释放租约**`finally``release_lease()`
## 开发新 skill 时如何复制
1. **先阅读**本 README 及下方「Copy map」「复制边界」章节
2. 按 copy map **选择性复制**到新技能 `scripts/`**不要**整包照搬 `examples/real_browser_rpa/`
3. 修改 `util/constants.py` 中的 `TARGET_PLATFORM``DEFAULT_START_URL`、selector 常量
4. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**
5. 替换 `human_verification.py` 中的验证码 selector若目标站点 DOM 不同)
6.`task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
7. 在新技能根目录补齐 `scripts/main.py` CLI`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
## Copy map复制到新技能 `scripts/`
| 来源examples | 目标(新技能) | 说明 |
|---|---|---|
| `examples/real_browser_rpa/scripts/service/browser_session.py` | `scripts/service/browser_session.py` | persistent context 启动范式 |
| `examples/real_browser_rpa/scripts/service/account_client.py` | `scripts/service/account_client.py` | account-manager CLI 集中封装 |
| `examples/real_browser_rpa/scripts/service/human_verification.py` | `scripts/service/human_verification.py` | 人工验证等待 |
| `examples/real_browser_rpa/scripts/service/task_rpa.py` | `scripts/service/<业务名>_rpa.py``scripts/service/task_rpa.py` | RPA 主流程,按业务改名 |
| `examples/real_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 参考并合并编排层 |
| `examples/real_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 平台常量与 selector须替换 |
| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 |
| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 |
| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
**复制时必须替换:** selector、URL、平台名称、账号平台字段、slug、logger 名等业务标识。**不要**照搬示例字段到其他平台。
**浏览器与启动约束:**
- **不要**在技能内安装 Playwright浏览器与 Python 包由宿主/runtime 提供
- `launch_persistent_context``args` **只放 Chrome 参数****不要把 URL 放进 `args`**
- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入
## 复制边界:哪些能复制,哪些必须替换
### 可以直接参考/复制的
- `browser_session.py` 的启动结构:**persistent context → new_page → goto**
- `human_verification.py` 的人工验证等待模式(容器级检测 + 等待人工,不自动破解)
- `task_rpa.py` 的流程分层、结构化日志点、`stop_reason` 设计
- `account_client.py` 的 account-manager CLI **集中封装**方式subprocess 仅限此文件)
- `tests/test_human_verification.py` 的误判防护测试思路
### 必须替换的
- `SKILL_SLUG`
- `TARGET_PLATFORM`
- `LEASE_HOLDER`
- `DEFAULT_START_URL`
- `SEARCH_INPUT_SELECTORS` / `SEARCH_BUTTON_SELECTOR`
- `RESULT_CONTAINER_SELECTOR` / `RESULT_ITEM_SELECTOR`
- `SCROLL_CONTAINER_SELECTORS`
- `RESULT_END_TEXT`
- `LOGGED_IN_SELECTORS` / `LOGGED_OUT_SELECTOR`(见下方说明)
- `_parse_item_locator()` 业务字段解析
- 用户可见中文文案
- 数据库写入逻辑
- 任务日志逻辑
- 录屏/截图业务标题
### 禁止照抄的
- 不要照抄目标站点 selector示例 selector 不等于真实 DOM
- 不要照抄平台名、slug、logger 名
- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖
- 不要把示例里的 result selector 当成真实站点 selector
- 不要把示例代码原样发布为业务 skill
- 不要把真实账号、手机号、cookie、token 写入代码或 README
### 登录 selector 说明
`task_rpa.py` 中的 `LOGGED_IN_SELECTORS``LOGGED_OUT_SELECTOR` **只是示例**,偏中文「登录」按钮和常见站点结构。复制到新 skill 后:
- **必须**用目标站点 F12 / DOM 实测后替换
- **不允许**凭经验猜 selector
- **不允许**因为示例在本机曾跑通就直接照搬到其他平台
### account-manager 路径解析说明
`account_client.py` 末尾的 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 是**开发环境兜底路径**,仅用于模板/本机调试。复制到真实 skill 后:
- **优先**通过 `ACCOUNT_MANAGER_ROOT`、Platform Kit `get_sibling_skills_root` 或宿主运行环境解析 account-manager
- **不要**把个人机器路径作为生产依赖
## 必须保留的安全原则
- **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
- **日志脱敏** — 不输出完整手机号/账号
- **租约释放** — `pick-web --lease` 后必须在 `finally` 释放
- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位)
## 常见坑
| 坑 | 正确做法 |
|---|---|
| 直接 `import account_manager.service...` | 通过 `subprocess` 调用 `account-manager/scripts/main.py` |
| 把 URL 放进 `launch_persistent_context(args=[url])` | `args` 只放 Chrome 参数URL 用 `page.goto(start_url)` |
| 启动后直接操作 DOM | 先 `context.new_page()``page.goto()` |
| 用整页 `body.inner_text()` 判断验证码 | 限定在 `[role='dialog']``.vc-captcha-verify` 等容器 |
| 搜索结果含“验证码教程”文字就误判 | 单元测试覆盖:无验证 DOM 时不应 `present=True` |
| 无结构化日志 | 关键节点打 `rpa_start` / `browser_ready` / `collect_round` 等 |
| 把 CLI 参数暴露给普通用户 | 用户说明放根 `README.md`;运行契约与触发规则放 `SKILL.md`LLM/平台入口) |
## 需要替换的内容清单
| 文件 | 替换项 |
|---|---|
| `util/constants.py` | `SKILL_SLUG``TARGET_PLATFORM``DEFAULT_START_URL`、所有 selector |
| `browser_session.py` | `get_start_url()` 配置来源、stealth 脚本(可选) |
| `human_verification.py` | `SLIDER_SELECTORS``SMS_SELECTORS``DIALOG_TEXT_CONTAINERS` |
| `task_rpa.py` | `LOGGED_IN_SELECTORS``LOGGED_OUT_SELECTOR``_parse_item_locator()` |
| `account_client.py` | `PLACEHOLDER_PLATFORM``LEASE_HOLDER`、账号 setup 文案 |
| `task_service.py` | 接入 DB、任务日志、entitlement、视频留痕 |
## 依赖说明
本目录是 **示例**,不影响 skill-template 根目录运行。真实 skill 需要:
- Python 3.10+
- Python 包 `playwright`:属于**宿主共享 runtime** 的通用能力;技能侧**不要**在 `requirements.txt` 中重复声明,也**不要**在代码或脚本中执行 `pip install playwright`
- 真实 skill **默认使用系统 Chrome/Edge****不使用** Playwright 下载的内置 Chromium
- 技能侧**不要**执行 `playwright install``playwright install chromium` 或类似命令
- 若本地独立开发环境缺少 Playwright Python 包,只能作为开发机临时环境补装;**生产/宿主运行**由共享 runtime 解决
- `jiangchang_skill_core`(如 `find_chrome_executable` 等,由 platform-kit 提供)
- 兄弟 skill `account-manager`
## 运行测试
```bash
cd examples/real_browser_rpa
python -m pytest tests/ -v
```
测试为纯函数/Mock DOM**不需要启动真实浏览器**。
## 当前状态
- 示例代码已就绪,可直接作为新 skill 复制起点。
- 未绑定具体平台业务字段selector 为占位示例。
- 未接入真实数据库与 CLI 入口。

View File

@@ -0,0 +1,201 @@
"""account-manager CLI 集成(仅 subprocess不 import 兄弟 skill 内部模块)。"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
from typing import Any, Dict, List, Optional
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
from util.logging import mask_text
logger = logging.getLogger(__name__)
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = (
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。"
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}"
"status=active、带 profile_dir 的账号后重新运行。"
)
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
class AccountManagerError(Exception):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def mask_login_id(login_id: str) -> str:
return mask_text(login_id)
def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。
优先级ACCOUNT_MANAGER_ROOT → get_sibling_skills_root路径推断 / JIANGCHANG_SKILLS_ROOT→ 开发机兜底。
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
"""
env_root = (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")
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
skills_root = get_sibling_skills_root(scripts_dir)
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
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
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
"未找到 account-manager请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。",
)
def _parse_last_json(stdout: str) -> Any:
lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
for raw in reversed(lines):
if raw.startswith("ERROR:"):
continue
try:
return json.loads(raw)
except json.JSONDecodeError:
continue
raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。")
def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]:
main_py = _resolve_account_manager_main()
env = os.environ.copy()
return subprocess.run(
[sys.executable, main_py, *argv_suffix],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
def _normalize_error_code(raw: str) -> str:
code = (raw or "").strip()
if code.startswith("ERROR:"):
code = code[6:]
return code
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
message = str(err.get("message") or "")
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE)
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 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-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,
]
)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
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
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
raw = _parse_last_json(out)
if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict):
data = raw["data"]
elif isinstance(raw, dict):
data = raw
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
f"账号 {account_id} 不是 {platform} 平台账号。",
)
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
"""获取网页账号profile_dir + lease_token"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
if account_id:
try:
aid = int(account_id)
except (TypeError, ValueError):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
return _pick_by_id(platform_key, aid)
try:
return _pick_web_with_lease(platform_key)
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
raise
def release_lease(lease_token: Optional[str]) -> None:
token = (lease_token or "").strip()
if not token:
return
try:
proc = _run_argv(["lease", "release", token])
if proc.returncode != 0:
logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip())
except Exception:
logger.warning("lease_release_exception", exc_info=True)

View File

@@ -0,0 +1,135 @@
"""浏览器会话启动(系统 Chrome/Edge + Playwright persistent context"""
from __future__ import annotations
import logging
import os
from typing import Any, Optional, Tuple
from util.constants import DEFAULT_START_URL
logger = logging.getLogger(__name__)
DEFAULT_PAGE_TIMEOUT_MS = 15_000
GOTO_TIMEOUT_MS = 60_000
STEALTH_INIT_SCRIPT = """
(() => {
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
})();
"""
def get_start_url() -> str:
return (os.getenv("TARGET_START_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
def _headless_from_env() -> bool:
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
return v in ("1", "true", "yes", "on")
def _find_chrome_executable() -> str | None:
try:
from jiangchang_skill_core.runtime_env import find_chrome_executable
chrome = find_chrome_executable()
if chrome and os.path.isfile(chrome):
return chrome
except ImportError:
pass
candidates = [
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
]
for path in candidates:
if path and os.path.isfile(path):
return path
return None
async def start_browser_session(
profile_dir: str,
*,
headless: Optional[bool] = None,
) -> Tuple[Any, Any, Any]:
"""
启动持久化 Chrome profilenew_page + goto 入口页,返回 (playwright, context, page)。
真实 skill 需要安装 playwright 与 jiangchang_skill_core。
"""
from playwright.async_api import async_playwright
hl = headless if headless is not None else _headless_from_env()
start_url = get_start_url()
chrome = _find_chrome_executable()
if not chrome:
raise RuntimeError(
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
)
launch_args = ["--start-maximized", "--disable-blink-features=AutomationControlled"]
logger.info(
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
chrome,
profile_dir,
start_url,
launch_args,
hl,
)
print(f"[浏览器] 系统 Chrome: {chrome}")
print(f"[浏览器] profile_dir: [{profile_dir}]")
print(f"[浏览器] startup_url={start_url}")
print(f"[浏览器] args={launch_args}")
pw = await async_playwright().start()
context = None
try:
context = await pw.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=hl,
executable_path=chrome,
locale="zh-CN",
no_viewport=True,
args=launch_args,
ignore_default_args=["--enable-automation"],
)
await context.add_init_script(STEALTH_INIT_SCRIPT)
page = await context.new_page()
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
try:
await page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
except Exception as exc:
raise RuntimeError(
f"浏览器已启动,但未能打开目标首页 {start_url}"
"请检查网络、Chrome profile、代理或页面访问状态。"
f"{exc}"
) from exc
return pw, context, page
except Exception:
if context is not None:
try:
await context.close()
except Exception:
pass
await pw.stop()
raise
async def close_browser_context(pw, context) -> None:
try:
if context is not None:
await context.close()
except Exception:
pass
try:
if pw is not None:
await pw.stop()
except Exception:
pass

View File

@@ -0,0 +1,450 @@
"""人工验证等待:保存登录倒计时、滑块、短信验证码等(不自动破解)。"""
from __future__ import annotations
import asyncio
import logging
import random
import re
import time
from dataclasses import dataclass
from typing import Optional
from util.constants import LOG_LOGGER_NAME
logger = logging.getLogger(LOG_LOGGER_NAME)
TEXT_EXCERPT_MAX = 120
SAVE_LOGIN_STRONG_MARKERS = (
"是否保存登录信息",
"下次登录更便捷",
"保存登录信息",
)
SAVE_LOGIN_CONTAINER_SELECTORS = (
"[role=\"dialog\"]",
".semi-modal",
".semi-modal-content",
)
SLIDER_SELECTORS = [
".vc-captcha-verify.slide",
".captcha_verify_bar--title",
".captcha-slider-tips",
"#captcha_verify_image",
".captcha-slider-btn",
]
SMS_SELECTORS = [
".second-verify-panel",
".uc-ui-verify_sms-input",
".uc-ui-verify-new_header-title",
"input[placeholder='手机号']",
"input[placeholder='请输入验证码']",
]
DIALOG_TEXT_CONTAINERS = [
"[role='dialog']",
".semi-modal",
".semi-modal-content",
".vc-captcha-verify",
".second-verify-panel",
]
RISK_MARKERS = (
"操作频繁",
"访问过于频繁",
"风险验证",
"安全验证",
)
@dataclass
class HumanVerificationState:
present: bool
kind: str
message: str
selector: str = ""
text_excerpt: str = ""
@dataclass
class HumanVerificationWaitResult:
timed_out: bool
kind: str = ""
code: str = ""
message: str = ""
stage: str = ""
def _text_excerpt(text: str) -> str:
cleaned = re.sub(r"\s+", " ", (text or "").strip())
if len(cleaned) <= TEXT_EXCERPT_MAX:
return cleaned
return cleaned[:TEXT_EXCERPT_MAX] + "..."
def _contains_any(text: str, markers: tuple[str, ...]) -> bool:
return any(m in text for m in markers)
async def _selector_visible(page, selector: str) -> bool:
try:
loc = page.locator(selector).first
return await loc.is_visible(timeout=500)
except Exception:
return False
async def _selector_inner_text(page, selector: str) -> str:
try:
return await page.locator(selector).first.inner_text(timeout=1000)
except Exception:
return ""
async def _visible_text_from_first_visible(page, selectors: tuple[str, ...] | list[str]) -> tuple[str, str]:
for sel in selectors:
try:
loc = page.locator(sel).first
if await loc.is_visible(timeout=500):
text = await loc.inner_text(timeout=1000)
return sel, text or ""
except Exception:
continue
return "", ""
def _save_login_popup_from_text(text: str) -> bool:
if not text:
return False
if _contains_any(text, SAVE_LOGIN_STRONG_MARKERS):
return True
if "保存" in text and "取消" in text and ("登录" in text or "登录信息" in text):
return True
return False
async def detect_save_login_popup(page) -> bool:
sel, text = await _visible_text_from_first_visible(page, SAVE_LOGIN_CONTAINER_SELECTORS)
if sel and _save_login_popup_from_text(text):
return True
return False
async def wait_save_login_countdown_if_present(page, timeout_sec: int = 8) -> bool:
"""若出现“保存登录信息”倒计时弹窗则等待其消失;未出现则立即返回 False。"""
if not await detect_save_login_popup(page):
return False
logger.info("save_login_popup_detected")
print("[登录] 检测到“保存登录信息”倒计时弹窗,等待其自动关闭...")
start = time.monotonic()
deadline = start + timeout_sec
while time.monotonic() < deadline:
if not await detect_save_login_popup(page):
elapsed = round(time.monotonic() - start, 2)
logger.info("save_login_popup_gone elapsed_sec=%s", elapsed)
return True
await asyncio.sleep(0.5)
elapsed = round(time.monotonic() - start, 2)
logger.warning(
"save_login_popup_still_visible elapsed_sec=%s timeout_sec=%s",
elapsed,
timeout_sec,
)
print(
f"[登录] 警告:保存登录信息弹窗超过 {timeout_sec} 秒仍未消失,"
"将继续后续流程(您可能已手工点击保存/取消)。"
)
return True
async def detect_slider_verification(page) -> Optional[HumanVerificationState]:
sel_slide = ".vc-captcha-verify.slide"
if await _selector_visible(page, sel_slide):
text = await _selector_inner_text(page, sel_slide)
return HumanVerificationState(
present=True,
kind="slider",
message="滑块验证码",
selector=sel_slide,
text_excerpt=_text_excerpt(text),
)
sel_title = ".captcha_verify_bar--title"
if await _selector_visible(page, sel_title):
title_text = await _selector_inner_text(page, sel_title)
if "请完成下列验证后继续" in title_text:
return HumanVerificationState(
present=True,
kind="slider",
message="滑块验证码",
selector=sel_title,
text_excerpt=_text_excerpt(title_text),
)
sel_tips = ".captcha-slider-tips"
if await _selector_visible(page, sel_tips):
tips_text = await _selector_inner_text(page, sel_tips)
if "拖动完成上方拼图" in tips_text:
return HumanVerificationState(
present=True,
kind="slider",
message="滑块验证码",
selector=sel_tips,
text_excerpt=_text_excerpt(tips_text),
)
if await _selector_visible(page, "#captcha_verify_image"):
for btn_sel in (".captcha-slider-btn", ".captcha_verify_slide--button", ".captcha-slider"):
if await _selector_visible(page, btn_sel):
return HumanVerificationState(
present=True,
kind="slider",
message="滑块验证码",
selector=f"#captcha_verify_image+{btn_sel}",
text_excerpt="",
)
return None
async def detect_sms_verification(page) -> Optional[HumanVerificationState]:
sel_panel = ".second-verify-panel"
if await _selector_visible(page, sel_panel):
text = await _selector_inner_text(page, sel_panel)
return HumanVerificationState(
present=True,
kind="sms",
message="短信验证码",
selector=sel_panel,
text_excerpt=_text_excerpt(text),
)
sel_sms = ".uc-ui-verify_sms-input"
if await _selector_visible(page, sel_sms):
text = await _selector_inner_text(page, sel_sms)
return HumanVerificationState(
present=True,
kind="sms",
message="短信验证码",
selector=sel_sms,
text_excerpt=_text_excerpt(text),
)
sel_hdr = ".uc-ui-verify-new_header-title"
if await _selector_visible(page, sel_hdr):
hdr_text = await _selector_inner_text(page, sel_hdr)
if "短信验证" in hdr_text:
return HumanVerificationState(
present=True,
kind="sms",
message="短信验证码",
selector=sel_hdr,
text_excerpt=_text_excerpt(hdr_text),
)
phone_visible = await _selector_visible(
page,
".second-verify-panel input[placeholder='手机号'], .uc-ui-verify_sms-input input[placeholder='手机号']",
)
code_visible = await _selector_visible(
page,
".second-verify-panel input[placeholder='请输入验证码'], .uc-ui-verify_sms-input input[placeholder='请输入验证码']",
)
if phone_visible and code_visible:
return HumanVerificationState(
present=True,
kind="sms",
message="短信验证码",
selector="sms-phone-and-code-inputs",
text_excerpt="手机号+请输入验证码",
)
return None
async def detect_human_verification_from_containers(page) -> Optional[HumanVerificationState]:
sel, text = await _visible_text_from_first_visible(page, DIALOG_TEXT_CONTAINERS)
if not sel or not text:
return None
if _contains_any(text, RISK_MARKERS):
return HumanVerificationState(
present=True,
kind="risk",
message="安全/频控验证",
selector=sel,
text_excerpt=_text_excerpt(text),
)
if sel in (".vc-captcha-verify", ".vc-captcha-verify-visibility"):
if "请完成下列验证后继续" in text or "拖动完成上方拼图" in text:
return HumanVerificationState(
present=True,
kind="slider",
message="滑块验证码",
selector=sel,
text_excerpt=_text_excerpt(text),
)
if sel in (".second-verify-panel", ".uc-ui-verify_sms-input"):
if "短信验证" in text or "获取验证码" in text:
return HumanVerificationState(
present=True,
kind="sms",
message="短信验证码",
selector=sel,
text_excerpt=_text_excerpt(text),
)
return None
async def detect_human_verification(page) -> HumanVerificationState:
"""主检测入口:仅在验证码/弹窗容器级检测,不扫整页 body。"""
state = await detect_slider_verification(page)
if state is not None:
return state
state = await detect_sms_verification(page)
if state is not None:
return state
state = await detect_human_verification_from_containers(page)
if state is not None:
return state
return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="")
def detect_human_verification_from_text(text: str) -> HumanVerificationState:
"""
根据 HTML/容器文本判断验证弹窗(单元测试辅助,不能作为真实页面主检测入口)。
只检查验证容器 DOM 标记,不根据正文关键词误判。
"""
html_lower = (text or "").lower()
has_slider_dom = (
"vc-captcha-verify" in html_lower
and "slide" in html_lower
and (
"captcha-slider-tips" in html_lower
or "captcha_verify_bar--title" in html_lower
or "captcha_verify_image" in html_lower
)
)
if has_slider_dom:
selector = ".vc-captcha-verify.slide"
if "captcha-slider-tips" in html_lower:
selector = ".captcha-slider-tips"
return HumanVerificationState(
present=True,
kind="slider",
message="滑块验证码",
selector=selector,
text_excerpt="",
)
has_sms_dom = "second-verify-panel" in html_lower or "uc-ui-verify_sms-input" in html_lower
if has_sms_dom:
selector = ".second-verify-panel" if "second-verify-panel" in html_lower else ".uc-ui-verify_sms-input"
return HumanVerificationState(
present=True,
kind="sms",
message="短信验证码",
selector=selector,
text_excerpt="",
)
return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="")
def _wait_log_message(state: HumanVerificationState, wait_sec: int) -> str:
if state.kind == "slider":
return f"[人工验证] 检测到滑块验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..."
if state.kind == "sms":
return f"[人工验证] 检测到短信验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..."
return f"[人工验证] 检测到安全验证,请在浏览器中手工处理,最多等待 {wait_sec} 秒..."
def _timeout_error_code(kind: str) -> str:
if kind == "slider":
return "SLIDER_VERIFICATION_TIMEOUT"
if kind == "sms":
return "SMS_VERIFICATION_TIMEOUT"
return "HUMAN_VERIFICATION_TIMEOUT"
def _timeout_error_message(kind: str, wait_sec: int) -> str:
if kind == "slider":
return (
f"滑块验证码未在 {wait_sec} 秒内完成,任务已停止。"
"请重新运行任务并及时在浏览器中完成验证。"
)
if kind == "sms":
return (
f"短信验证码未在 {wait_sec} 秒内完成,任务已停止。"
"请重新运行任务并及时完成短信验证。"
)
return (
f"安全验证未在 {wait_sec} 秒内完成,任务已停止。"
"请重新运行任务并及时在浏览器中完成验证。"
)
async def wait_human_verification_if_present(
page,
*,
wait_sec: int = 180,
stage: str = "unknown",
) -> Optional[HumanVerificationWaitResult]:
"""
若存在人工验证则等待用户完成;无验证或已完成返回 None超时返回 HumanVerificationWaitResult。
不自动拖动滑块、不自动填写短信验证码。
"""
state = await detect_human_verification(page)
if not state.present:
return None
logger.warning(
"human_verification_detected stage=%s kind=%s selector=%s text=%s",
stage,
state.kind,
state.selector,
state.text_excerpt,
)
print(_wait_log_message(state, wait_sec))
last_state = state
start = time.monotonic()
deadline = start + wait_sec
while time.monotonic() < deadline:
state = await detect_human_verification(page)
if not state.present:
elapsed = round(time.monotonic() - start, 2)
logger.info(
"human_verification_passed stage=%s kind=%s elapsed_sec=%s",
stage,
last_state.kind,
elapsed,
)
await asyncio.sleep(random.uniform(1.0, 2.0))
return None
last_state = state
await asyncio.sleep(2.0)
logger.warning(
"human_verification_timeout stage=%s kind=%s wait_sec=%s selector=%s",
stage,
last_state.kind,
wait_sec,
last_state.selector,
)
return HumanVerificationWaitResult(
timed_out=True,
kind=last_state.kind,
code=_timeout_error_code(last_state.kind),
message=_timeout_error_message(last_state.kind, wait_sec),
stage=stage,
)

View File

@@ -0,0 +1,641 @@
"""真实网站关键词搜索 RPA 示例async Playwright"""
from __future__ import annotations
import asyncio
import logging
import os
import random
import re
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from service.browser_session import close_browser_context, get_start_url, start_browser_session
from service.human_verification import (
HumanVerificationWaitResult,
wait_human_verification_if_present,
wait_save_login_countdown_if_present,
)
from util.constants import (
DEFAULT_MAX_ITEMS,
DEFAULT_MAX_SCROLLS,
DEFAULT_NO_NEW_ROUNDS_LIMIT,
HUMAN_WAIT_TIMEOUT,
LOG_LOGGER_NAME,
RESULT_CONTAINER_SELECTOR,
RESULT_END_TEXT,
RESULT_ITEM_SELECTOR,
SCROLL_CONTAINER_SELECTORS,
SEARCH_BUTTON_SELECTOR,
SEARCH_INPUT_SELECTORS,
)
from util.logging import mask_text, safe_log_value
logger = logging.getLogger(LOG_LOGGER_NAME)
@dataclass
class ScrapeError:
code: str
message: str
browser_started: bool = False
stage: Optional[str] = None
@dataclass
class ScrapeRunResult:
success: bool
items: List[Dict[str, Any]] = field(default_factory=list)
stop_reason: Optional[str] = None
error: Optional[ScrapeError] = None
StepCallback = Callable[[str], None]
# 示例登录态 selector — 复制到新 skill 后必须用目标站点 F12/DOM 实测替换,禁止凭经验猜测或跨平台照抄
LOGGED_IN_SELECTORS = (
"a[href*='/user/self']",
"[data-user-avatar]",
".user-avatar",
)
LOGGED_OUT_SELECTOR = "button:has-text('登录')"
def format_stop_reason_for_user(stop_reason: str | None, collected_count: int) -> str:
"""将内部 stop_reason 枚举翻译为用户可理解的中文说明。"""
if stop_reason == "max_items":
return (
f"已达到本次默认采集规模({collected_count} 条)。"
"如需更多,可以继续说「继续采集更多」或「尽量多采集」。"
)
if stop_reason == "end_marker":
return "页面提示没有更多结果,本次采集已完成。"
if stop_reason == "no_new_rounds":
return "连续多次加载没有发现新结果,本次采集已完成。"
if stop_reason == "max_scrolls":
return (
"已达到本次页面加载保护范围,本次采集已完成。"
"如需更深度采集,可以说明「尽量多采集」。"
)
if stop_reason == "human_verification_timeout":
return "人工验证未在规定时间内完成,任务已停止。"
return "本次采集已完成。"
def _mask_account_ref(account: Dict[str, Any]) -> str:
for key in ("login_id", "phone", "mobile"):
raw = str(account.get(key) or "").strip()
if raw:
return mask_text(raw)
aid = account.get("id") or account.get("account_id")
if aid is not None:
return f"id:{aid}"
return "unknown"
def _step_cb(cb: Optional[StepCallback], text: str) -> None:
if cb:
cb(text)
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))
async def _scroll_wait() -> None:
await asyncio.sleep(random.uniform(1.2, 3.5))
def _headless() -> bool:
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
return v in ("1", "true", "yes", "on")
def _no_new_limit() -> int:
try:
return int(os.getenv("RPA_NO_NEW_ROUNDS_LIMIT") or str(DEFAULT_NO_NEW_ROUNDS_LIMIT))
except ValueError:
return DEFAULT_NO_NEW_ROUNDS_LIMIT
async def _visible(locator) -> bool:
try:
return await locator.count() > 0 and await locator.is_visible(timeout=500)
except Exception:
return False
async def _is_logged_out(page) -> bool:
try:
btn = page.locator(LOGGED_OUT_SELECTOR).first
return await _visible(btn)
except Exception:
return False
async def _is_logged_in(page) -> bool:
if await _is_logged_out(page):
return False
for sel in LOGGED_IN_SELECTORS:
try:
loc = page.locator(sel).first
if await _visible(loc):
return True
except Exception:
continue
return False
async def _open_login_panel_if_needed(page) -> None:
if not await _is_logged_out(page):
return
try:
btn = page.locator(LOGGED_OUT_SELECTOR).first
if await _visible(btn):
await btn.click()
await asyncio.sleep(random.uniform(1.0, 3.0))
except Exception:
pass
async def _ensure_logged_in(page, *, wait_sec: int) -> None:
if await _is_logged_in(page):
return
await _open_login_panel_if_needed(page)
if await _is_logged_in(page):
return
print(f"[登录] 请在浏览器中完成登录,最多等待 {wait_sec} 秒...")
deadline = time.monotonic() + wait_sec
while time.monotonic() < deadline:
if await _is_logged_in(page):
print("[登录] 检测到登录成功")
return
await asyncio.sleep(2.0)
raise RuntimeError(
f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。"
f"请重新运行任务并在浏览器打开后及时完成登录。"
)
async def _find_search_input(page) -> Tuple[Optional[Any], str]:
for sel in SEARCH_INPUT_SELECTORS:
try:
loc = page.locator(sel).first
if await _visible(loc):
return loc, sel
except Exception:
continue
candidates: List[Tuple[int, Any, str]] = []
try:
inputs = await page.locator("input").all()
except Exception:
inputs = []
for inp in inputs:
try:
if not await inp.is_visible():
continue
box = await inp.bounding_box()
if not box or box.get("width", 0) < 120:
continue
placeholder = await inp.get_attribute("placeholder") or ""
score = box.get("width", 0)
if "搜索" in placeholder:
score += 500
if box.get("y", 9999) < 200:
score += 200
candidates.append((score, inp, f"input placeholder={placeholder!r}"))
except Exception:
continue
if candidates:
candidates.sort(key=lambda x: x[0], reverse=True)
_, inp, sel = candidates[0]
return inp, sel
try:
ph = page.get_by_placeholder(re.compile("搜索"))
if await ph.count() > 0 and await ph.first.is_visible():
return ph.first, "placeholder=搜索"
except Exception:
pass
return None, ""
async def _find_search_button(page):
try:
btn = page.get_by_role("button", name="搜索")
if await btn.count() > 0 and await btn.first.is_visible():
return btn.first
except Exception:
pass
try:
btn = page.locator(SEARCH_BUTTON_SELECTOR).first
if await btn.count() > 0 and await btn.is_visible():
return btn
except Exception:
pass
return None
async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool:
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
try:
url = page.url or ""
if "/search" in url or "search" in url.lower():
if await page.locator(RESULT_CONTAINER_SELECTOR).count() > 0:
return True
except Exception:
pass
await asyncio.sleep(0.8)
return False
async def _has_end_marker(page) -> bool:
try:
container = page.locator(RESULT_CONTAINER_SELECTOR).first
if await container.count() > 0:
text = await container.inner_text(timeout=2000)
if RESULT_END_TEXT in text or "暂时没有更多了" in text:
return True
except Exception:
pass
try:
if await page.locator(f"text={RESULT_END_TEXT}").count() > 0:
return True
except Exception:
pass
return False
async def _mouse_wheel_scroll(page) -> Tuple[str, int]:
wheel_delta = random.randint(650, 1200)
for sel in SCROLL_CONTAINER_SELECTORS:
loc = page.locator(sel)
try:
if await loc.count() > 0 and await loc.first.is_visible():
await loc.first.hover()
await _random_delay()
await page.mouse.wheel(0, wheel_delta)
return sel, wheel_delta
except Exception:
continue
await page.mouse.wheel(0, wheel_delta)
return "page", wheel_delta
async def _parse_item_locator(item) -> Dict[str, Any]:
item_id = await item.get_attribute("data-item-id") or await item.get_attribute("id") or ""
title = ""
try:
title = (await item.inner_text(timeout=1000)).strip()[:200]
except Exception:
pass
return {
"item_id": item_id or title[:32] or str(id(item)),
"title": title,
}
async def _collect_visible_items(page, seen_ids: set[str]) -> List[Dict[str, Any]]:
new_items: List[Dict[str, Any]] = []
try:
items = await page.locator(RESULT_ITEM_SELECTOR).all()
except Exception:
items = []
for item in items:
try:
parsed = await _parse_item_locator(item)
item_id = parsed.get("item_id")
if not item_id or item_id in seen_ids:
continue
seen_ids.add(str(item_id))
new_items.append(parsed)
except Exception:
continue
return new_items
async def _count_visible_items(page) -> int:
try:
return await page.locator(RESULT_ITEM_SELECTOR).count()
except Exception:
return 0
def _fail_result(error: ScrapeError, *, collected: int = 0) -> ScrapeRunResult:
logger.warning(
"rpa_failed code=%s stage=%s message=%s collected=%s",
error.code,
error.stage,
error.message,
collected,
)
return ScrapeRunResult(success=False, error=error)
def _fail_on_verification_timeout(
verification_timeout: HumanVerificationWaitResult,
*,
collected: int = 0,
) -> ScrapeRunResult:
return _fail_result(
ScrapeError(
code=verification_timeout.code,
message=verification_timeout.message,
browser_started=True,
stage=verification_timeout.stage or "unknown",
),
collected=collected,
)
async def run_keyword_search_async(
account: Dict[str, Any],
keyword: str,
*,
max_items: int = DEFAULT_MAX_ITEMS,
max_scrolls: int = DEFAULT_MAX_SCROLLS,
data_dir: str = "",
batch_id: str = "",
step_callback: Optional[StepCallback] = None,
) -> ScrapeRunResult:
profile_dir = account.get("profile_dir") or ""
account_ref = _mask_account_ref(account)
wait_sec = int(os.getenv("HUMAN_WAIT_TIMEOUT") or str(HUMAN_WAIT_TIMEOUT))
no_new_limit = _no_new_limit()
logger.info(
"rpa_start keyword=%s max_items=%s max_scrolls=%s wait_sec=%s batch_id=%s account_ref=%s",
safe_log_value(keyword),
max_items,
max_scrolls,
wait_sec,
batch_id,
account_ref,
)
if not profile_dir:
err = ScrapeError(
"ACCOUNT_NOT_FOUND",
"账号缺少 profile_dir。",
browser_started=False,
stage="account_prepare",
)
return _fail_result(err)
pw = None
context = None
page = None
browser_started = False
collected_count = 0
try:
_step_cb(step_callback, "启动关键词搜索采集任务")
try:
pw, context, page = await start_browser_session(profile_dir, headless=_headless())
except RuntimeError as exc:
msg = str(exc)
err = ScrapeError(
"MISSING_BROWSER" if "MISSING_BROWSER" in msg else "UNKNOWN_ERROR",
msg.replace("ERROR:MISSING_BROWSER ", "").replace("ERROR:", ""),
browser_started="未能打开目标首页" in msg,
stage="browser_start" if "未能打开目标首页" in msg else "account_prepare",
)
return _fail_result(err)
browser_started = True
logger.info(
"browser_ready start_url=%s current_url=%s profile_dir_exists=%s",
get_start_url(),
page.url,
os.path.isdir(profile_dir),
)
_step_cb(step_callback, "打开目标站点首页")
await _random_delay()
verification_timeout = await wait_human_verification_if_present(
page, wait_sec=wait_sec, stage="browser_start"
)
if verification_timeout:
return _fail_on_verification_timeout(verification_timeout)
_step_cb(step_callback, "检查账号登录状态")
logger.info("login_check_start current_url=%s", page.url)
try:
await _ensure_logged_in(page, wait_sec=wait_sec)
except RuntimeError as exc:
msg = str(exc)
code = "LOGIN_TIMEOUT" if "LOGIN_TIMEOUT" in msg else "REQUIRE_LOGIN"
err = ScrapeError(
code,
msg.replace("ERROR:LOGIN_TIMEOUT ", "").replace("ERROR:", ""),
browser_started=True,
stage="login_check",
)
return _fail_result(err)
logger.info("login_check_done current_url=%s", page.url)
await wait_save_login_countdown_if_present(page)
verification_timeout = await wait_human_verification_if_present(
page, wait_sec=wait_sec, stage="login_check"
)
if verification_timeout:
return _fail_on_verification_timeout(verification_timeout)
_step_cb(step_callback, "定位搜索框")
search_input, input_selector = await _find_search_input(page)
if search_input is None:
err = ScrapeError(
"SEARCH_INPUT_NOT_FOUND",
"未找到搜索输入框。",
browser_started=True,
stage="search",
)
return _fail_result(err)
logger.info("search_input_found selector=%s", input_selector)
_step_cb(step_callback, f"输入关键词:{keyword}")
await search_input.click()
await _random_delay()
await page.keyboard.press("Control+A")
await page.keyboard.type(keyword, delay=random.randint(90, 240))
await _random_delay()
_step_cb(step_callback, "点击搜索")
search_btn = await _find_search_button(page)
search_method = "button"
if search_btn is not None:
await search_btn.click()
else:
search_method = "enter"
await page.keyboard.press("Enter")
logger.info(
"search_submitted keyword=%s method=%s current_url=%s",
safe_log_value(keyword),
search_method,
page.url,
)
await _random_delay()
verification_timeout = await wait_human_verification_if_present(
page, wait_sec=wait_sec, stage="search_verification"
)
if verification_timeout:
return _fail_on_verification_timeout(verification_timeout)
_step_cb(step_callback, "等待搜索结果")
if not await _wait_search_results(page):
verification_timeout = await wait_human_verification_if_present(
page, wait_sec=wait_sec, stage="search_results"
)
if verification_timeout:
return _fail_on_verification_timeout(verification_timeout)
if not await _wait_search_results(page, timeout_sec=10.0):
err = ScrapeError(
"RESULT_CONTAINER_NOT_FOUND",
"搜索结果未出现,且未检测到需要人工处理的验证码,请检查关键词或页面状态。",
browser_started=True,
stage="search",
)
return _fail_result(err)
container_count = await page.locator(RESULT_CONTAINER_SELECTOR).count()
logger.info(
"search_results_ready current_url=%s container_count=%s",
page.url,
container_count,
)
try:
await page.locator(RESULT_CONTAINER_SELECTOR).first.wait_for(state="visible", timeout=15_000)
except Exception:
err = ScrapeError(
"RESULT_CONTAINER_NOT_FOUND",
"搜索结果容器未出现。",
browser_started=True,
stage="search",
)
return _fail_result(err)
seen_ids: set[str] = set()
collected: List[Dict[str, Any]] = []
no_new_rounds = 0
stop_reason = "max_scrolls"
rounds_executed = 0
for scroll_round in range(max_scrolls + 1):
rounds_executed = scroll_round
_step_cb(step_callback, "读取当前结果列表")
visible_count = await _count_visible_items(page)
batch = await _collect_visible_items(page, seen_ids)
new_count = len(batch)
if batch:
collected.extend(batch)
no_new_rounds = 0
else:
no_new_rounds += 1
collected_count = len(collected)
end_marker = await _has_end_marker(page)
logger.info(
"collect_round round=%s max_scrolls=%s visible_count=%s new_count=%s "
"collected=%s no_new_rounds=%s no_new_limit=%s end_marker=%s current_url=%s",
scroll_round,
max_scrolls,
visible_count,
new_count,
collected_count,
no_new_rounds,
no_new_limit,
end_marker,
page.url,
)
if len(collected) >= max_items:
collected = collected[:max_items]
stop_reason = "max_items"
break
if end_marker:
stop_reason = "end_marker"
_step_cb(step_callback, "发现结果到底或达到采集数量")
break
if no_new_rounds >= no_new_limit:
stop_reason = "no_new_rounds"
logger.info(
"collect_no_new_stop round=%s no_new_rounds=%s no_new_limit=%s collected=%s",
scroll_round,
no_new_rounds,
no_new_limit,
collected_count,
)
break
if scroll_round >= max_scrolls:
break
_step_cb(step_callback, "滚动加载更多结果")
scroll_target, wheel_delta = await _mouse_wheel_scroll(page)
logger.info(
"scroll round=%s target=%s wheel_delta=%s",
scroll_round,
scroll_target,
wheel_delta,
)
await _scroll_wait()
verification_timeout = await wait_human_verification_if_present(
page, wait_sec=wait_sec, stage="collect"
)
if verification_timeout:
return _fail_on_verification_timeout(
verification_timeout,
collected=collected_count,
)
logger.info(
"collect_stop reason=%s collected=%s rounds=%s no_new_rounds=%s",
stop_reason,
collected_count,
rounds_executed,
no_new_rounds,
)
if not collected:
err = ScrapeError(
"NO_RESULTS",
"未采集到任何结果。",
browser_started=True,
stage="collect",
)
return _fail_result(err, collected=0)
_step_cb(step_callback, "采集完成")
logger.info(
"rpa_success collected=%s stop_reason=%s",
len(collected[:max_items]),
stop_reason,
)
return ScrapeRunResult(success=True, items=collected[:max_items], stop_reason=stop_reason)
except Exception as exc:
err = ScrapeError(
"UNKNOWN_ERROR",
str(exc),
browser_started=browser_started,
stage="rpa",
)
return _fail_result(err, collected=collected_count)
finally:
await close_browser_context(pw, context)

View File

@@ -0,0 +1,137 @@
"""轻量任务编排示例(无真实数据库依赖)。"""
from __future__ import annotations
import asyncio
import uuid
from typing import Any, Optional
from service.account_client import AccountManagerError, pick_web_account, release_lease
from service.task_rpa import (
ScrapeRunResult,
format_stop_reason_for_user,
run_keyword_search_async,
)
from util.constants import (
DEFAULT_MAX_ITEMS,
DEFAULT_MAX_SCROLLS,
SKILL_SLUG,
TARGET_PLATFORM,
)
def _error_payload(
code: str,
message: str,
*,
browser_started: Optional[bool] = None,
stage: Optional[str] = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"success": False,
"error": {"code": code, "message": message},
}
if browser_started is not None:
payload["browser_started"] = browser_started
if stage:
payload["stage"] = stage
return payload
def _success_payload(
*,
keyword: str,
collected_count: int,
items: list[dict[str, Any]],
stop_reason: Optional[str],
) -> dict[str, Any]:
return {
"success": True,
"skill": SKILL_SLUG,
"keyword": keyword,
"collected_count": collected_count,
"items": items,
"stop_reason": stop_reason,
"stop_reason_message": format_stop_reason_for_user(stop_reason, collected_count),
}
async def run_keyword_search_task(
keyword: str,
*,
max_items: int = DEFAULT_MAX_ITEMS,
max_scrolls: int = DEFAULT_MAX_SCROLLS,
account_id: Optional[str] = None,
platform: str = TARGET_PLATFORM,
) -> dict[str, Any]:
"""
编排一次关键词搜索采集。
真实 skill 中还应:
- 写 SQLite 入库items 表)
- 写 scrape_tasks / task_logs 任务日志
- 使用 RpaVideoSession 录制步骤留痕
- 失败时 capture_failure 截图
"""
batch_id = uuid.uuid4().hex[:12]
data_dir = "" # 示例占位;真实 skill 使用 get_skill_data_dir()
lease_token: Optional[str] = None
account: Optional[dict[str, Any]] = None
try:
account = pick_web_account(platform, account_id)
lease_token = account.get("lease_token")
scrape_result: ScrapeRunResult = await run_keyword_search_async(
account,
keyword,
max_items=max_items,
max_scrolls=max_scrolls,
data_dir=data_dir,
batch_id=batch_id,
)
if not scrape_result.success:
err = scrape_result.error
return _error_payload(
err.code if err else "UNKNOWN_ERROR",
err.message if err else "未知错误",
browser_started=err.browser_started if err else True,
stage=err.stage if err else "rpa",
)
collected_count = len(scrape_result.items)
return _success_payload(
keyword=keyword,
collected_count=collected_count,
items=scrape_result.items[:10],
stop_reason=scrape_result.stop_reason,
)
except AccountManagerError as exc:
return _error_payload(
exc.code,
exc.message,
browser_started=False,
stage="account_prepare",
)
finally:
release_lease(lease_token)
def run_keyword_search_task_sync(
keyword: str,
*,
max_items: int = DEFAULT_MAX_ITEMS,
max_scrolls: int = DEFAULT_MAX_SCROLLS,
account_id: Optional[str] = None,
platform: str = TARGET_PLATFORM,
) -> dict[str, Any]:
return asyncio.run(
run_keyword_search_task(
keyword,
max_items=max_items,
max_scrolls=max_scrolls,
account_id=account_id,
platform=platform,
)
)

View File

@@ -0,0 +1,31 @@
"""示例常量(复制到新 skill 时请替换为目标站点与业务字段)。"""
SKILL_SLUG = "example-real-browser-rpa"
LOG_LOGGER_NAME = "openclaw.skill.example_real_browser_rpa"
TARGET_PLATFORM = "target_platform"
LEASE_HOLDER = "example-real-browser-rpa"
LEASE_TTL_SEC = "1800"
DEFAULT_START_URL = "https://example.com"
DEFAULT_MAX_ITEMS = 100
DEFAULT_MAX_SCROLLS = 40
DEFAULT_NO_NEW_ROUNDS_LIMIT = 5
HUMAN_WAIT_TIMEOUT = 180
RESULT_END_TEXT = "没有更多结果了"
# 示例 selector — 复制到新 skill 时按目标站点 DOM 替换
SEARCH_INPUT_SELECTORS = (
'input[type="search"]',
'input[name="q"]',
'input[placeholder*="搜索"]',
)
SEARCH_BUTTON_SELECTOR = 'button:has-text("搜索")'
RESULT_CONTAINER_SELECTOR = "#search-result-container"
RESULT_ITEM_SELECTOR = "[data-result-item]"
SCROLL_CONTAINER_SELECTORS = (
"#search-result-container",
".search-result-scroll",
".route-scroll-container",
)

View File

@@ -0,0 +1,34 @@
"""示例结构化日志工具。"""
from __future__ import annotations
import logging
import re
from typing import Any
from util.constants import LOG_LOGGER_NAME
def get_logger(name: str | None = None) -> logging.Logger:
return logging.getLogger(name or LOG_LOGGER_NAME)
def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str:
s = (value or "").strip()
if len(s) >= 11 and s.isdigit():
return s[:3] + "****" + s[-4:]
if len(s) > keep_start + keep_end:
return s[:keep_start] + "****" + s[-keep_end:]
return "****"
def safe_log_value(value: Any) -> str:
if value is None:
return ""
text = str(value)
if re.fullmatch(r"\d{7,}", text):
return mask_text(text)
if "@" in text and len(text) > 6:
local, _, domain = text.partition("@")
return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}"
return text

View File

@@ -0,0 +1,10 @@
"""pytest 路径配置:将 examples/real_browser_rpa/scripts 加入 import 路径。"""
from __future__ import annotations
import sys
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))

View File

@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
"""人工验证检测单元测试DOM 精准检测,不依赖真实浏览器)。"""
from __future__ import annotations
import asyncio
import time
import unittest
from service.human_verification import (
HumanVerificationWaitResult,
detect_human_verification,
detect_human_verification_from_text,
wait_human_verification_if_present,
wait_save_login_countdown_if_present,
)
SLIDER_HTML = """
<div class="vc-captcha-verify slide">
<div class="captcha_verify_bar--title">请完成下列验证后继续</div>
<div class="captcha-slider-tips">按住左边按钮拖动完成上方拼图</div>
</div>
"""
SMS_HTML = """
<div class="second-verify-panel">
<div class="uc-ui-verify_sms-input">
<div class="uc-ui-verify-new_header-title">短信验证</div>
<input placeholder="手机号" />
<input placeholder="请输入验证码" />
<button>获取验证码</button>
</div>
</div>
"""
SEARCH_RESULTS_HTML = """
<div id="search-result-container">
<div data-result-item="1">RPA 滑块验证码识别教程</div>
<div data-result-item="2">短信验证码自动化</div>
</div>
"""
class MockLocator:
def __init__(self, selector: str, visible: set[str], texts: dict[str, str]) -> None:
self._selector = selector
self._visible = visible
self._texts = texts
@property
def first(self) -> MockLocator:
return self
async def is_visible(self, timeout: int = 500) -> bool:
return self._selector in self._visible
async def inner_text(self, timeout: int = 1000) -> str:
return self._texts.get(self._selector, "")
async def count(self) -> int:
return 1 if self._selector in self._visible else 0
class MockPage:
def __init__(self, visible: set[str], texts: dict[str, str] | None = None) -> None:
self._visible = visible
self._texts = texts or {}
def locator(self, selector: str) -> MockLocator:
return MockLocator(selector, self._visible, self._texts)
class TestHumanVerificationFromText(unittest.TestCase):
def test_slider_dom_detect(self) -> None:
state = detect_human_verification_from_text(SLIDER_HTML)
self.assertTrue(state.present)
self.assertEqual(state.kind, "slider")
self.assertTrue(state.selector)
def test_sms_dom_detect(self) -> None:
state = detect_human_verification_from_text(SMS_HTML)
self.assertTrue(state.present)
self.assertEqual(state.kind, "sms")
self.assertTrue(state.selector)
def test_search_results_not_misclassified(self) -> None:
state = detect_human_verification_from_text(SEARCH_RESULTS_HTML)
self.assertFalse(state.present)
def test_body_keywords_without_dom_not_misclassified(self) -> None:
text = "RPA 滑块验证码识别教程\n短信验证码自动化"
state = detect_human_verification_from_text(text)
self.assertFalse(state.present)
class TestHumanVerificationDomDetect(unittest.TestCase):
def test_slider_dom_visible(self) -> None:
page = MockPage(
{".vc-captcha-verify.slide"},
{".vc-captcha-verify.slide": "请完成下列验证后继续"},
)
state = asyncio.run(detect_human_verification(page))
self.assertTrue(state.present)
self.assertEqual(state.kind, "slider")
self.assertEqual(state.selector, ".vc-captcha-verify.slide")
def test_sms_dom_visible(self) -> None:
page = MockPage(
{".second-verify-panel"},
{".second-verify-panel": "短信验证 获取验证码"},
)
state = asyncio.run(detect_human_verification(page))
self.assertTrue(state.present)
self.assertEqual(state.kind, "sms")
def test_no_verification_when_empty(self) -> None:
page = MockPage(set())
state = asyncio.run(detect_human_verification(page))
self.assertFalse(state.present)
class TestSaveLoginCountdown(unittest.TestCase):
def test_wait_not_present_no_delay(self) -> None:
page = MockPage(set())
start = time.monotonic()
result = asyncio.run(wait_save_login_countdown_if_present(page, timeout_sec=8))
elapsed = time.monotonic() - start
self.assertFalse(result)
self.assertLess(elapsed, 1.0)
class TestHumanVerificationWait(unittest.TestCase):
def test_wait_timeout_slider_dom(self) -> None:
page = MockPage(
{".vc-captcha-verify.slide"},
{".vc-captcha-verify.slide": "请完成下列验证后继续"},
)
result = asyncio.run(
wait_human_verification_if_present(page, wait_sec=1, stage="search_verification")
)
self.assertIsInstance(result, HumanVerificationWaitResult)
self.assertTrue(result.timed_out)
self.assertEqual(result.code, "SLIDER_VERIFICATION_TIMEOUT")
self.assertIn("滑块验证码未在 1 秒内完成", result.message)
self.assertEqual(result.stage, "search_verification")
def test_wait_timeout_sms_dom(self) -> None:
page = MockPage(
{".second-verify-panel"},
{".second-verify-panel": "短信验证 获取验证码"},
)
result = asyncio.run(
wait_human_verification_if_present(page, wait_sec=1, stage="login_check")
)
self.assertIsInstance(result, HumanVerificationWaitResult)
self.assertTrue(result.timed_out)
self.assertEqual(result.code, "SMS_VERIFICATION_TIMEOUT")
self.assertEqual(result.stage, "login_check")
def test_collect_no_verification_immediate_none(self) -> None:
page = MockPage(set())
start = time.monotonic()
err = asyncio.run(wait_human_verification_if_present(page, wait_sec=180, stage="collect"))
elapsed = time.monotonic() - start
self.assertIsNone(err)
self.assertLess(elapsed, 1.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""stop_reason 用户友好文案单元测试。"""
from __future__ import annotations
import unittest
from service.task_rpa import format_stop_reason_for_user
class TestFormatStopReasonForUser(unittest.TestCase):
def test_max_items(self) -> None:
msg = format_stop_reason_for_user("max_items", 100)
self.assertIn("默认采集规模", msg)
self.assertIn("100", msg)
def test_end_marker(self) -> None:
msg = format_stop_reason_for_user("end_marker", 42)
self.assertIn("没有更多结果", msg)
def test_no_new_rounds(self) -> None:
msg = format_stop_reason_for_user("no_new_rounds", 30)
self.assertIn("连续多次加载没有发现新结果", msg)
def test_max_scrolls(self) -> None:
msg = format_stop_reason_for_user("max_scrolls", 80)
self.assertIn("页面加载保护范围", msg)
def test_none_or_unknown(self) -> None:
self.assertEqual(format_stop_reason_for_user(None, 10), "本次采集已完成。")
self.assertEqual(format_stop_reason_for_user("unknown", 10), "本次采集已完成。")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,35 @@
# Simulator API Example
这个目录用于沉淀「仿真环境 / API」类 skill 的可复制成功案例。
## 适用场景(规划)
- 使用 mock / sandbox / simulator API
- 不需要启动浏览器
- 适合演示输入校验、API 调用、结果入库、日志、错误契约和测试
## 当前状态:规划占位
- **本目录当前为规划占位**,仅保留最小结构(`README.md``scripts/``tests/` 占位)。
- **尚未沉淀可复制实现**;目录内无参考代码。
- **AI 编程工具不得**基于空目录自行脑补 mock API 或仿真接口实现。
- 后续补齐可复制案例后,再按 README copy map 选择性复制到新技能 `scripts/`
## 轻量结构
```text
simulator_api/
README.md
scripts/
service/
util/
tests/
```
不要在本目录添加 `SKILL.md`、市场 README、`references/``development/``.github/` 等完整 skill 仓库层文件。
## 复制边界(待案例补齐后生效)
- 参考代码将从 `examples/simulator_api/scripts/` 选择性复制到新技能 `scripts/`
-`README.md``SKILL.md``references/` 仍按模板主目录规则编写
- 当前阶段:**不可作为实现参考**

View File

View File

@@ -0,0 +1,148 @@
# Simulator Browser RPA Example
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 Playwright 完成登录、表单填写、弹窗确认、结果解析。
本示例抽象自已跑通的「批量提交类」浏览器自动化 skill但已去业务化不包含真实银行/真实资金语义。
## 适合什么场景
- 自有 **仿真系统 / sandbox / training 页面**
- 需要 **浏览器 RPA** 做端到端演示或回归
- 表单较复杂多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析
- 需要 **adapter 分层**mockCI与 simulator_rpa半集成可切换
## 不适合什么场景
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA后续参考 `real_api`
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
## 目录结构
```
simulator_browser_rpa/
├── README.md
├── sandbox/
│ └── demo_app.html # 本地仿真页面(稳定 data-testid / name
├── scripts/
│ ├── service/
│ │ ├── browser_session.py # 浏览器启动URL 仅 page.goto
│ │ ├── account_client.py # 账号来源集中封装
│ │ ├── task_service.py # 编排层
│ │ └── adapter/
│ │ ├── base.py # BatchItem / BatchSubmitResult 契约
│ │ ├── mock.py # mock/unit 档
│ │ ├── simulator_rpa.py
│ │ └── __init__.py # select_adapter
│ └── util/
│ ├── constants.py
│ └── logging.py
└── tests/
├── test_adapter_dispatch.py
├── test_simulator_rpa_unit.py
└── test_stop_reason.py
```
## 文件职责
| 文件 | 职责 |
|---|---|
| `browser_session.py` | 系统 Chrome/Edge + launch args 规范;支持 profile / 无 profile |
| `account_client.py` | 示例从环境变量读账号;真实 skill 可在此集中对接 account-manager |
| `adapter/base.py` | 数据契约与 `BatchAdapterBase` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `adapter/simulator_rpa.py` | 仿真 RPA 主流程 |
| `task_service.py` | 校验输入 → 选 adapter → 提交 → 返回 summary |
| `sandbox/demo_app.html` | 可控 DOM配合 selector 稳定 |
| `tests/` | dispatch、URL 不进 launch args、失败转结果 |
## 核心流程
1. `task_service.run_batch_submit(target, items)` 校验参数
2. `select_adapter()``OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA
3. simulator 档:`pick_simulator_account()``set_credentials()``submit_batch()`
4. RPA登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id`
5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult`
## 复制到新 skill 时怎么做
按 copy map **选择性复制**到 `scripts/`**不要**整包照搬本目录,**不要**把本 README 当作新技能根 README。
## Copy map复制到新技能 `scripts/`
| 来源examples | 目标(新技能) | 说明 |
|---|---|---|
| `examples/simulator_browser_rpa/scripts/service/browser_session.py` | `scripts/service/browser_session.py` | URL 仅 `page.goto`,不进 launch args |
| `examples/simulator_browser_rpa/scripts/service/account_client.py` | `scripts/service/account_client.py` | 账号来源集中封装 |
| `examples/simulator_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 编排层,合并业务逻辑 |
| `examples/simulator_browser_rpa/scripts/service/adapter/` | `scripts/service/<domain>_adapter/` | **adapter 分层权威参考** |
| `examples/simulator_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 须替换业务常量 |
| `examples/simulator_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选 |
| `examples/simulator_browser_rpa/tests/` | `tests/` | 可按需复制 dispatch / unit 测试 |
| `examples/simulator_browser_rpa/sandbox/demo_app.html` | — | **仅仿真测试用**,不进入生产 skill |
| `examples/simulator_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
**边界说明:**
- `sandbox/` 只用于仿真测试与本地回归,**不进入**真实生产 skill
- `adapter/` 分层base / mock / simulator_rpa是仿真浏览器 RPA 的权威参考
- `mock` / `simulator_rpa` 只用于测试与仿真,**不要**混入 `real_browser_rpa` 的真实站点逻辑
- `demo_app.html` 是可运行沙箱页面,**不是**真实业务系统
**保留:**
- adapter 分层base / mock / simulator_rpa / `select_adapter`
- `browser_session.py` 启动规范(**URL 不进 launch args**
- `account_client.py` 集中封装模式
- 关键节点结构化日志 + 失败截图
- 测试dispatch、launch args、错误转 `BatchSubmitResult`
**替换:**
- `BatchItem` 字段与校验规则
- `sandbox/demo_app.html``SIMULATOR_BASE_URL` 指向的页面
- 全部 DOM selector必须 F12 实测)
- `task_service.py` 输入输出与 DB/任务日志
- mock 返回字段、成功页 `batch_id` 解析逻辑
## 明确禁止
- 不要复制 `__pycache__``.pytest_cache``artifacts/``recordings/` 等生成物
- **不要在技能侧**执行 `playwright install` / `pip install playwright`Python 包由宿主共享 runtime 提供)
- **不要把 URL 放进** `launch_persistent_context(args=[...])`
- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess
- **不要把真实生产系统**配置到 `simulator_rpa` 档位
- 不要照抄 slug、logger 名、演示账号到生产 skill
## 依赖说明
- Python 3.10+
- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要写入 `requirements.txt`,不要 `pip install playwright`
- 默认使用**系统 Chrome/Edge**,不使用 Playwright 内置 Chromium
- 技能侧不要执行 `playwright install` / `playwright install chromium`
- 本地独立开发若缺 Playwright Python 包,仅作开发机临时补装;生产由宿主解决
- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选,用于查找系统浏览器)
## 环境变量(示例)
| 变量 | 说明 |
|---|---|
| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock默认 → Simulator RPA |
| `SIMULATOR_BASE_URL` | 仿真页 URL默认本地 `sandbox/demo_app.html` 的 file URI |
| `SIMULATOR_LOGIN_ID` / `PASSWORD` / `TOKEN_PIN` | 演示账号 |
| `SIMULATOR_PROFILE_DIR` | 可选 persistent profile |
| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头模式CI 可用) |
## 运行测试
```bash
cd examples/simulator_browser_rpa
python -m pytest tests/ -v
```
测试为 mock/纯函数,**不需要启动真实浏览器**。
## 当前状态
- 示例代码与 sandbox 页面已就绪,可作为仿真浏览器 RPA 复制起点。
- 未接入完整 CLI、数据库与 entitlement。

View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>仿真批量提交系统(示例 Sandbox</title>
<style>
body { font-family: sans-serif; margin: 24px; max-width: 960px; }
section { margin-bottom: 24px; padding: 16px; border: 1px solid #ccc; }
.hidden { display: none; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ddd; padding: 8px; }
dialog-like { display: block; border: 2px solid #333; padding: 16px; margin-top: 16px; }
</style>
</head>
<body>
<h1>仿真业务系统 · 批量提交(示例)</h1>
<section id="login-step1-section">
<h2>登录 · 第一步</h2>
<form name="simLoginStep1" data-testid="login-step1">
<label>账号 <input name="loginId" data-testid="login-id" /></label>
<button type="submit">下一步</button>
</form>
</section>
<section id="login-step2-section" class="hidden">
<h2>登录 · 第二步</h2>
<form name="simLoginStep2" data-testid="login-step2">
<label>密码 <input name="password" type="password" data-testid="password" /></label>
<label>验证码 <input name="captcha" data-testid="captcha" /></label>
<button type="submit">登录</button>
</form>
</section>
<section id="batch-section" class="hidden">
<h2>批量提交</h2>
<form name="simBatchForm" data-testid="batch-form">
<label>来源账户
<select name="fromAccountNo" data-testid="from-account">
<option value="">请选择</option>
<option value="acct-demo-001">演示账户 acct-demo-001</option>
<option value="acct-demo-002">演示账户 acct-demo-002</option>
</select>
</label>
<button type="button" id="tab-page-fill" data-testid="tab-page-fill">页面填写</button>
<button type="button" id="btn-add-row" data-testid="add-row">新增一行</button>
<table id="batch-table" data-testid="batch-table">
<thead><tr><th>#</th><th>姓名</th><th>账号</th><th>备注</th><th>金额</th></tr></thead>
<tbody id="batch-rows"></tbody>
</table>
<label>用途 <input name="purpose" data-testid="purpose" /></label>
<button type="submit" data-testid="batch-submit">提交批次</button>
</form>
</section>
<section id="success-section" class="hidden">
<h2>提交成功</h2>
<p data-testid="batch-id" id="batch-id-display"></p>
<p data-testid="success-message">批次已受理,等待后续处理。</p>
</section>
<div id="pin-dialog" class="hidden" role="dialog" aria-modal="true" data-testid="pin-dialog">
<h3>确认 PIN</h3>
<input type="password" inputmode="numeric" data-testid="pin-input" />
<button type="button" id="pin-confirm" data-testid="pin-confirm">确认提交</button>
</div>
<script>
const DEMO_CAPTCHA = "0000";
let rowCount = 0;
document.querySelector('form[name="simLoginStep1"]').addEventListener('submit', function (e) {
e.preventDefault();
document.getElementById('login-step1-section').classList.add('hidden');
document.getElementById('login-step2-section').classList.remove('hidden');
});
document.querySelector('form[name="simLoginStep2"]').addEventListener('submit', function (e) {
e.preventDefault();
const cap = document.querySelector('input[name="captcha"]').value;
if (cap !== DEMO_CAPTCHA) {
alert('验证码错误(演示值 0000');
return;
}
document.getElementById('login-step2-section').classList.add('hidden');
document.getElementById('batch-section').classList.remove('hidden');
ensureRow(1);
});
document.getElementById('tab-page-fill').addEventListener('click', function () {
document.getElementById('batch-table').scrollIntoView();
});
document.getElementById('btn-add-row').addEventListener('click', function () {
rowCount += 1;
addRow(rowCount);
});
function ensureRow(index) {
if (!document.querySelector('tr[data-row-index="' + index + '"]')) {
addRow(index);
}
rowCount = Math.max(rowCount, index);
}
function addRow(index) {
rowCount = Math.max(rowCount, index);
const tbody = document.getElementById('batch-rows');
const tr = document.createElement('tr');
tr.setAttribute('data-row-index', String(index));
tr.innerHTML =
'<td>' + index + '</td>' +
'<td><input name="name" data-testid="row-name-' + index + '" /></td>' +
'<td><input name="account" data-testid="row-account-' + index + '" /></td>' +
'<td><input name="note" data-testid="row-note-' + index + '" /></td>' +
'<td><input name="amount" data-testid="row-amount-' + index + '" /></td>';
tbody.appendChild(tr);
}
document.querySelector('form[name="simBatchForm"]').addEventListener('submit', function (e) {
e.preventDefault();
document.getElementById('pin-dialog').classList.remove('hidden');
});
document.getElementById('pin-confirm').addEventListener('click', function () {
const pin = document.querySelector('[data-testid="pin-input"]').value;
if (!pin || pin.length < 4) {
alert('请输入演示 PIN');
return;
}
document.getElementById('pin-dialog').classList.add('hidden');
const batchId = 'SIM-' + Date.now();
document.getElementById('batch-section').classList.add('hidden');
document.getElementById('success-section').classList.remove('hidden');
document.getElementById('batch-id-display').textContent = batchId;
document.getElementById('batch-id-display').dataset.batchId = batchId;
window.location.hash = '#/batch/' + batchId;
});
</script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
"""仿真账号来源封装(示例级;真实 skill 可在此集中对接 account-manager"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
from util.constants import (
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
ENV_SIMULATOR_LOGIN_ID,
ENV_SIMULATOR_PASSWORD,
ENV_SIMULATOR_PROFILE_DIR,
ENV_SIMULATOR_TOKEN_PIN,
)
@dataclass
class SimulatorAccount:
login_id: str
password: str
token_pin: str
profile_dir: str = ""
def pick_simulator_account() -> SimulatorAccount:
"""
示例账号来源:优先读环境变量,否则返回内置 demo 账号。
真实 skill 若依赖 account-manager应把 subprocess / sibling_bridge 调用
集中在本模块,不要散落到 task_service 或 RPA 主流程中。
"""
return SimulatorAccount(
login_id=(os.getenv(ENV_SIMULATOR_LOGIN_ID) or DEFAULT_DEMO_LOGIN_ID).strip(),
password=(os.getenv(ENV_SIMULATOR_PASSWORD) or DEFAULT_DEMO_PASSWORD).strip(),
token_pin=(os.getenv(ENV_SIMULATOR_TOKEN_PIN) or DEFAULT_DEMO_TOKEN_PIN).strip(),
profile_dir=(os.getenv(ENV_SIMULATOR_PROFILE_DIR) or "").strip(),
)

View File

@@ -0,0 +1,41 @@
"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。"""
from __future__ import annotations
import logging
from typing import Optional
from jiangchang_skill_core import config
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
from service.adapter.mock import MockBatchAdapter
from service.adapter.simulator_rpa import SimulatorBrowserRpaAdapter
logger = logging.getLogger(__name__)
__all__ = [
"select_adapter",
"BatchAdapterBase",
"BatchItem",
"BatchSubmitResult",
"MockBatchAdapter",
"SimulatorBrowserRpaAdapter",
]
def select_adapter(artifacts_dir: Optional[str] = None) -> BatchAdapterBase:
target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower()
if target in ("mock", "unit"):
logger.info("target '%s': MockBatchAdapter", target)
return MockBatchAdapter()
if target == "real_rpa":
logger.warning("target real_rpa 未实现,回退 MockBatchAdapter")
return MockBatchAdapter()
if target and target != "simulator_rpa":
logger.warning("未知 target '%s',回退 simulator_rpa", target)
logger.info("target '%s': SimulatorBrowserRpaAdapter", target or "(unset=default)")
return SimulatorBrowserRpaAdapter(artifacts_dir=artifacts_dir)

View File

@@ -0,0 +1,32 @@
"""批量提交 adapter 基类与数据契约。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class BatchItem:
row_index: int
name: str
account: str
amount: float
note: str = ""
@dataclass
class BatchSubmitResult:
ok: bool
batch_id: Optional[str]
submitted_count: int
submitted_amount: float
error_msg: Optional[str]
artifacts: Dict[str, Any] = field(default_factory=dict)
class BatchAdapterBase:
name: str = "base"
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
raise NotImplementedError

View File

@@ -0,0 +1,36 @@
"""mock / unit 档位:不启动浏览器。"""
from __future__ import annotations
import time
from typing import List
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
class MockBatchAdapter(BatchAdapterBase):
name = "mock"
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
del target
if not items:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="items 为空",
artifacts={"adapter": self.name},
)
time.sleep(0.05)
total = sum(it.amount for it in items)
fake_batch_id = f"MOCK-{int(time.time())}"
return BatchSubmitResult(
ok=True,
batch_id=fake_batch_id,
submitted_count=len(items),
submitted_amount=total,
error_msg=None,
artifacts={"adapter": self.name},
)

View File

@@ -0,0 +1,341 @@
"""仿真浏览器 RPA操作 sandbox/demo_app.html 完成批量提交。"""
from __future__ import annotations
import logging
import os
import random
import re
import time
from typing import List, Optional
from jiangchang_skill_core import config
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
from service.browser_session import browser_session, find_chrome_executable
from util.constants import (
DEFAULT_DEMO_CAPTCHA,
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
DEFAULT_TIMEOUT_MS,
LOGIN_NAVIGATE_TIMEOUT_MS,
SUBMIT_NAVIGATE_TIMEOUT_MS,
resolve_simulator_base_url,
)
logger = logging.getLogger(__name__)
_PAUSE_MIN_MS = 500
_PAUSE_MAX_MS = 2000
class RpaError(Exception):
def __init__(self, message: str, screenshot_path: Optional[str] = None):
super().__init__(message)
self.screenshot_path = screenshot_path
class SimulatorBrowserRpaAdapter(BatchAdapterBase):
name = "simulator_browser_rpa"
def __init__(
self,
base_url: Optional[str] = None,
headless: Optional[bool] = None,
artifacts_dir: Optional[str] = None,
) -> None:
self.base_url = (base_url or resolve_simulator_base_url()).rstrip("/")
if headless is None:
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
else:
self.headless = headless
self.artifacts_dir = artifacts_dir
self._login_id: str = DEFAULT_DEMO_LOGIN_ID
self._password: str = DEFAULT_DEMO_PASSWORD
self._token_pin: str = DEFAULT_DEMO_TOKEN_PIN
self._profile_dir: Optional[str] = None
def set_credentials(
self,
login_id: str,
password: str,
token_pin: str,
profile_dir: str,
) -> None:
self._login_id = login_id
self._password = password
self._token_pin = token_pin
stripped = (profile_dir or "").strip()
self._profile_dir = stripped or None
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
try:
from playwright.sync_api import sync_playwright # noqa: F401
except ImportError:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=(
"playwright Python 包不可用。"
"生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。"
),
artifacts={"adapter": self.name},
)
if not items:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="items 为空",
artifacts={"adapter": self.name},
)
if not find_chrome_executable():
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
artifacts={"adapter": self.name},
)
start_url = self.base_url
try:
with browser_session(
start_url,
profile_dir=self._profile_dir,
headless=self.headless,
) as page:
try:
logger.info("rpa_login_start url=%s", start_url)
self._login(page)
logger.info("rpa_login_done")
self._goto_batch_page(page)
logger.info("rpa_batch_page_ready")
self._fill_batch_form(page, target, items)
logger.info("rpa_batch_form_filled rows=%s", len(items))
batch_id = self._submit_and_get_batch_id(page)
logger.info("rpa_submit_success batch_id=%s", batch_id)
total = sum(it.amount for it in items)
return BatchSubmitResult(
ok=True,
batch_id=batch_id,
submitted_count=len(items),
submitted_amount=total,
error_msg=None,
artifacts={"adapter": self.name},
)
except RpaError as exc:
logger.error("rpa_failed: %s", exc)
arts = {"adapter": self.name}
if exc.screenshot_path:
arts["screenshot"] = exc.screenshot_path
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=str(exc),
artifacts=arts,
)
except Exception as exc:
sp = self._safe_screenshot(page, "unexpected_error")
logger.exception("rpa_unexpected: %s", exc)
arts = {"adapter": self.name}
if sp:
arts["screenshot"] = sp
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
artifacts=arts,
)
except Exception as outer:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"启动浏览器失败:{outer}",
artifacts={"adapter": self.name},
)
def _pause(self, min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
time.sleep(random.uniform(min_ms, max_ms) / 1000.0)
def _login(self, page) -> None:
try:
page.wait_for_selector('form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
self._pause()
page.fill('form[name="simLoginStep1"] input[name="loginId"]', self._login_id)
self._pause()
page.click('form[name="simLoginStep1"] button[type="submit"]')
except Exception as exc:
sp = self._safe_screenshot(page, "login_step1_failed")
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
self._pause()
page.fill('form[name="simLoginStep2"] input[name="password"]', self._password)
page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
self._pause()
page.click('form[name="simLoginStep2"] button[type="submit"]')
except Exception as exc:
sp = self._safe_screenshot(page, "login_step2_failed")
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "login_navigate_failed")
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
def _goto_batch_page(self, page) -> None:
try:
page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
self._pause()
except Exception as exc:
sp = self._safe_screenshot(page, "goto_batch_failed")
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
def _select_target_account(self, page, target: str) -> None:
sel = page.locator('select[name="fromAccountNo"]')
sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
n = sel.locator("option").count()
matched_value: Optional[str] = None
first_value: Optional[str] = None
for i in range(n):
opt = sel.locator("option").nth(i)
val = opt.get_attribute("value") or ""
if not val.strip():
continue
label_txt = opt.inner_text()
if first_value is None:
first_value = val
if target.strip() and target.strip() in label_txt:
matched_value = val
break
pick = matched_value or first_value
if not pick:
raise RpaError("来源账户下拉无可选项")
if not matched_value and target.strip():
logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick)
sel.select_option(value=pick)
self._pause()
def _fill_batch_form(self, page, target: str, items: List[BatchItem]) -> None:
try:
page.get_by_role("button", name=re.compile(r"页面填写")).click()
except Exception as exc:
sp = self._safe_screenshot(page, "tab_switch_failed")
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
try:
self._select_target_account(page, target)
except RpaError:
raise
except Exception as exc:
sp = self._safe_screenshot(page, "select_account_failed")
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
try:
existing = page.locator("tr[data-row-index]").count()
need_more = max(0, len(items) - existing)
add_btn = page.get_by_role("button", name=re.compile(r"新增一行"))
for _ in range(need_more):
self._pause(400, 900)
add_btn.click()
except Exception as exc:
sp = self._safe_screenshot(page, "add_rows_failed")
raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc
for it in items:
try:
row = page.locator(f'tr[data-row-index="{it.row_index}"]')
row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
self._pause(300, 800)
row.locator('input[name="name"]').fill(it.name)
row.locator('input[name="account"]').fill(it.account)
row.locator('input[name="note"]').fill(it.note or "")
row.locator('input[name="amount"]').fill(f"{it.amount}")
except Exception as exc:
sp = self._safe_screenshot(page, f"fill_row_{it.row_index}_failed")
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
try:
page.fill('input[name="purpose"]', "仿真批量提交示例")
except Exception as exc:
logger.warning("填写用途失败(非致命):%s", exc)
def _submit_and_get_batch_id(self, page) -> str:
try:
self._pause(800, 1500)
page.locator('form[name="simBatchForm"] button[type="submit"]').click()
except Exception as exc:
sp = self._safe_screenshot(page, "click_submit_failed")
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
try:
page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "pin_dialog_not_open")
raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc
try:
pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]')
self._pause(500, 1200)
pin_input.fill(self._token_pin)
except Exception as exc:
sp = self._safe_screenshot(page, "fill_pin_failed")
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
try:
page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
except Exception as exc:
sp = self._safe_screenshot(page, "click_confirm_failed")
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "success_area_not_visible")
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
batch_id = page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
if not batch_id:
batch_id = page.locator('[data-testid="batch-id"]').inner_text().strip()
url = page.url
if not batch_id and "#/batch/" in url:
batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/")
if not batch_id:
sp = self._safe_screenshot(page, "batch_id_empty")
raise RpaError(f"无法解析 batch_idurl={url}", screenshot_path=sp)
return batch_id
def _safe_screenshot(self, page, tag: str) -> Optional[str]:
if not self.artifacts_dir:
return None
try:
os.makedirs(self.artifacts_dir, exist_ok=True)
path = os.path.join(self.artifacts_dir, f"{tag}_{int(time.time())}.png")
page.screenshot(path=path, full_page=True)
logger.info("screenshot_saved path=%s", path)
return path
except Exception as exc:
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
return None

View File

@@ -0,0 +1,138 @@
"""浏览器会话启动(系统 Chrome/EdgeURL 仅通过 page.goto 打开)。"""
from __future__ import annotations
import logging
import os
from contextlib import contextmanager
from typing import Any, Generator, Iterator, List, Optional, Tuple
logger = logging.getLogger(__name__)
DEFAULT_PAGE_TIMEOUT_MS = 15_000
GOTO_TIMEOUT_MS = 60_000
CHROME_LAUNCH_ARGS: List[str] = [
"--start-maximized",
"--disable-blink-features=AutomationControlled",
]
STEALTH_INIT_SCRIPT = """
(() => {
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
})();
"""
def chrome_launch_args() -> List[str]:
return list(CHROME_LAUNCH_ARGS)
def _headless_from_env() -> bool:
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
return v in ("1", "true", "yes", "on")
def _find_chrome_executable() -> str | None:
try:
from jiangchang_skill_core.runtime_env import find_chrome_executable
chrome = find_chrome_executable()
if chrome and os.path.isfile(chrome):
return chrome
except ImportError:
pass
candidates = [
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
]
for path in candidates:
if path and os.path.isfile(path):
return path
return None
def _clear_node_options() -> None:
os.environ.pop("NODE_OPTIONS", None)
@contextmanager
def browser_session(
start_url: str,
*,
profile_dir: Optional[str] = None,
headless: Optional[bool] = None,
) -> Generator[Any, None, None]:
"""
同步 Playwright 浏览器会话。
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install。
- launch args 仅放 Chrome 参数,不含 URL。
- 流程launch → new_page → goto(start_url)
"""
from playwright.sync_api import sync_playwright
_clear_node_options()
hl = headless if headless is not None else _headless_from_env()
chrome = _find_chrome_executable()
if not chrome:
raise RuntimeError(
"未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。"
)
args = chrome_launch_args()
logger.info(
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
chrome,
profile_dir or "",
start_url,
args,
hl,
)
browser_holder = None
context = None
with sync_playwright() as pw:
try:
if profile_dir:
context = pw.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=hl,
executable_path=chrome,
locale="zh-CN",
no_viewport=True,
args=args,
ignore_default_args=["--enable-automation"],
)
else:
browser_holder = pw.chromium.launch(
headless=hl,
executable_path=chrome,
args=args,
ignore_default_args=["--enable-automation"],
)
context = browser_holder.new_context(no_viewport=True, locale="zh-CN")
context.add_init_script(STEALTH_INIT_SCRIPT)
page = context.new_page()
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
yield page
finally:
try:
if context is not None:
context.close()
except Exception as exc:
logger.warning("browser_context_close_failed: %s", exc)
if browser_holder is not None:
try:
browser_holder.close()
except Exception as exc:
logger.warning("browser_close_failed: %s", exc)
def find_chrome_executable() -> str | None:
return _find_chrome_executable()

View File

@@ -0,0 +1,104 @@
"""轻量任务编排:校验输入 → 选 adapter → 提交批次。"""
from __future__ import annotations
import os
import tempfile
import uuid
from typing import Any, Dict, List, Optional
from service.account_client import pick_simulator_account
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter, select_adapter
from util.logging import get_logger
logger = get_logger(__name__)
def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optional[str]]:
if not items:
return [], "items 不能为空"
parsed: List[BatchItem] = []
for idx, raw in enumerate(items, start=1):
name = str(raw.get("name") or "").strip()
account = str(raw.get("account") or "").strip()
note = str(raw.get("note") or "").strip()
try:
amount = float(raw.get("amount"))
except (TypeError, ValueError):
return [], f"{idx} 行 amount 无效"
if amount <= 0:
return [], f"{idx} 行 amount 必须大于 0"
if not name or not account:
return [], f"{idx} 行 name/account 不能为空"
parsed.append(
BatchItem(
row_index=int(raw.get("row_index") or idx),
name=name,
account=account,
amount=amount,
note=note,
)
)
return parsed, None
def run_batch_submit(
target: str,
items: List[Dict[str, Any]],
*,
force: bool = False,
artifacts_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""
最小编排示例。
真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。
"""
del force # 示例占位;真实 skill 可用于跳过确认
target_key = (target or "").strip()
if not target_key:
return {"success": False, "error": {"code": "INVALID_TARGET", "message": "target 不能为空"}}
batch_items, err = _validate_items(items)
if err:
return {"success": False, "error": {"code": "INVALID_ITEMS", "message": err}}
art_dir = artifacts_dir or os.path.join(
tempfile.gettempdir(),
"openclaw-simulator-rpa-artifacts",
uuid.uuid4().hex[:8],
)
os.makedirs(art_dir, exist_ok=True)
logger.info("batch_submit_start target=%s item_count=%s artifacts_dir=%s", target_key, len(batch_items), art_dir)
adapter = select_adapter(artifacts_dir=art_dir)
if isinstance(adapter, SimulatorBrowserRpaAdapter):
account = pick_simulator_account()
adapter.set_credentials(
account.login_id,
account.password,
account.token_pin,
account.profile_dir,
)
result = adapter.submit_batch(target_key, batch_items)
summary: Dict[str, Any] = {
"success": result.ok,
"target": target_key,
"adapter": result.artifacts.get("adapter") or adapter.name,
"submitted_count": result.submitted_count,
"submitted_amount": result.submitted_amount,
"batch_id": result.batch_id,
"artifacts": result.artifacts,
"artifacts_dir": art_dir,
}
if not result.ok:
summary["error"] = {"code": "BATCH_SUBMIT_FAILED", "message": result.error_msg or "提交失败"}
logger.info(
"batch_submit_done success=%s batch_id=%s adapter=%s",
result.ok,
result.batch_id,
adapter.name,
)
return summary

View File

@@ -0,0 +1,34 @@
"""仿真浏览器 RPA 示例常量。"""
from __future__ import annotations
import os
from pathlib import Path
SKILL_SLUG = "example-simulator-browser-rpa"
LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa"
DEFAULT_TIMEOUT_MS = 15_000
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000
DEFAULT_DEMO_LOGIN_ID = "demo001"
DEFAULT_DEMO_PASSWORD = "demo-password"
DEFAULT_DEMO_CAPTCHA = "0000"
DEFAULT_DEMO_TOKEN_PIN = "123456"
ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL"
ENV_SIMULATOR_LOGIN_ID = "SIMULATOR_LOGIN_ID"
ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD"
ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN"
ENV_SIMULATOR_PROFILE_DIR = "SIMULATOR_PROFILE_DIR"
_EXAMPLE_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html"
def resolve_simulator_base_url() -> str:
env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip()
if env:
return env.rstrip("/")
return DEFAULT_DEMO_APP_PATH.as_uri()

View File

@@ -0,0 +1,15 @@
"""示例日志工具。"""
from __future__ import annotations
import logging
from util.constants import LOG_LOGGER_NAME
def get_logger(name: str | None = None) -> logging.Logger:
return logging.getLogger(name or LOG_LOGGER_NAME)
def configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s")

View File

@@ -0,0 +1,10 @@
"""pytest 路径:将 examples/simulator_browser_rpa/scripts 加入 import 路径。"""
from __future__ import annotations
import sys
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""adapter 工厂 dispatch 单元测试。"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from service.adapter import (
BatchItem,
MockBatchAdapter,
SimulatorBrowserRpaAdapter,
select_adapter,
)
class TestAdapterDispatch(unittest.TestCase):
def test_default_returns_simulator_rpa(self) -> None:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("OPENCLAW_TEST_TARGET", None)
adapter = select_adapter()
self.assertIsInstance(adapter, SimulatorBrowserRpaAdapter)
def test_unit_returns_mock(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "unit"}):
self.assertIsInstance(select_adapter(), MockBatchAdapter)
def test_mock_returns_mock(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "mock"}):
self.assertIsInstance(select_adapter(), MockBatchAdapter)
def test_simulator_rpa_returns_simulator(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_rpa"}):
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
def test_real_rpa_falls_back_to_mock(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "real_rpa"}):
self.assertIsInstance(select_adapter(), MockBatchAdapter)
def test_unknown_target_falls_back_simulator_rpa(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_api"}):
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
class TestMockAdapter(unittest.TestCase):
def test_mock_success(self) -> None:
items = [
BatchItem(row_index=1, name="Alice", account="1001", amount=10.5),
BatchItem(row_index=2, name="Bob", account="1002", amount=20.0),
]
result = MockBatchAdapter().submit_batch("acct-demo-001", items)
self.assertTrue(result.ok)
self.assertEqual(result.submitted_count, 2)
self.assertEqual(result.submitted_amount, 30.5)
self.assertTrue((result.batch_id or "").startswith("MOCK-"))
def test_mock_empty_items(self) -> None:
result = MockBatchAdapter().submit_batch("acct-demo-001", [])
self.assertFalse(result.ok)
self.assertEqual(result.error_msg, "items 为空")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。"""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.browser_session import CHROME_LAUNCH_ARGS
class TestSimulatorRpaUnit(unittest.TestCase):
def test_set_credentials_and_empty_profile_dir(self) -> None:
adapter = SimulatorBrowserRpaAdapter()
adapter.set_credentials("demo001", "pwd", "pin", " ")
self.assertEqual(adapter._login_id, "demo001")
self.assertIsNone(adapter._profile_dir)
def test_submit_batch_empty_items(self) -> None:
adapter = SimulatorBrowserRpaAdapter()
result = adapter.submit_batch("acct-demo-001", [])
self.assertFalse(result.ok)
self.assertIn("items", result.error_msg or "")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None)
def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None:
adapter = SimulatorBrowserRpaAdapter()
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
msg = result.error_msg or ""
self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg)
def test_chrome_launch_args_must_not_contain_url(self) -> None:
for arg in CHROME_LAUNCH_ARGS:
lowered = arg.lower()
self.assertFalse(lowered.startswith("http://"))
self.assertFalse(lowered.startswith("https://"))
self.assertFalse(lowered.startswith("file://"))
@patch("service.adapter.simulator_rpa.browser_session")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
def test_start_url_opened_via_goto_not_launch_args(
self,
_chrome: MagicMock,
mock_session: MagicMock,
) -> None:
from service.adapter.simulator_rpa import RpaError
adapter = SimulatorBrowserRpaAdapter(base_url="file:///demo/demo_app.html")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
page = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = page
mock_cm.__exit__.return_value = False
mock_session.return_value = mock_cm
with patch.object(adapter, "_login", side_effect=RpaError("login fail")):
result = adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
mock_session.assert_called_once()
called_start_url = mock_session.call_args[0][0]
self.assertEqual(called_start_url, "file:///demo/demo_app.html")
for arg in CHROME_LAUNCH_ARGS:
self.assertNotIn(arg, called_start_url)
def test_safe_screenshot_without_artifacts_dir(self) -> None:
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
page = MagicMock()
self.assertIsNone(adapter._safe_screenshot(page, "tag"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""RPA 失败结果与 artifacts 单元测试。"""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.adapter.simulator_rpa import RpaError
class TestBatchSubmitFailureResult(unittest.TestCase):
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
@patch("service.adapter.simulator_rpa.browser_session")
def test_rpa_error_converts_to_failed_result(self, mock_session: MagicMock, _chrome: MagicMock) -> None:
page = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = page
mock_cm.__exit__.return_value = False
mock_session.return_value = mock_cm
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
with patch.object(adapter, "_login", side_effect=RpaError("登录第一步失败timeout", screenshot_path="/tmp/art/x.png")):
result = adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
self.assertIn("登录第一步失败", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa")
self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
@patch("service.adapter.simulator_rpa.browser_session")
def test_unexpected_error_includes_adapter_name(self, mock_session: MagicMock, _chrome: MagicMock) -> None:
page = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = page
mock_cm.__exit__.return_value = False
mock_session.return_value = mock_cm
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
with patch.object(adapter, "_login", side_effect=RuntimeError("boom")):
result = adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
self.assertIn("未预期异常", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa")
self.assertNotIn("screenshot", result.artifacts)
if __name__ == "__main__":
unittest.main()

8
pytest.ini Normal file
View File

@@ -0,0 +1,8 @@
[pytest]
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
norecursedirs = .git __pycache__ .pytest_cache integration desktop samples fixtures artifacts diagnostics
filterwarnings =
ignore::DeprecationWarning

View File

@@ -1,115 +0,0 @@
# 适配器标准:真实/仿真 × API/RPA 四档模式
> 凡是"连接三方系统"ERP、CRM、SaaS、银行等的 skill都应采用本文的 **adapter 四档模式**。这样同一套业务逻辑可以在不同档位间切换开发用仿真、CI 用 mock、上线用真实互不影响。
## 为什么要分档
对接一个外部系统,工程上无非四种方式的组合:
| | API有接口 | RPA操作界面 |
|---|---|---|
| **真实系统** | `real_api` 真实 API | `real_rpa` 真实界面 |
| **仿真系统** | `mock` 离线仿真 | `sim_rpa` 仿真平台界面 |
- **mock**:纯离线、不联网,给单测 / CI / 开发自测,**保证可重复**。
- **sim_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),跑真实端到端流程但不碰生产,**默认开发档位**。
- **real_api**:有官方接口时**首选**(最稳、最快、最易维护)。
- **real_rpa**:没有 API 只能操作生产界面,**风险最高、放最后**。
> 推荐优先级:**real_api > sim_rpa > real_rpa**mock 永远保留做 CI。
## 目录骨架
业务逻辑只依赖 `base` 里定义的接口与数据契约,具体档位实现各自一个文件:
```
scripts/service/<domain>_adapter/
__init__.py # dispatch按档位返回对应 adapter 实例
base.py # 数据契约dataclass+ AdapterBase 接口NotImplementedError
mock.py # 离线仿真,给 CI/单测
real_api.py # 真实系统 API
sim_rpa.py # 仿真平台 RPAPlaywright/pywinauto/uiautomator2
real_rpa.py # 真实系统 RPA占位谨慎实现
```
> 参考实现:`disburse-payroll-icbc/scripts/service/disburse_adapter/`(已有 `base.py` / `mock.py` / `icbc_simulator_rpa.py`)。
> 模板示例:`scripts/service/example_adapter/`(复制改名即用)。
### 引用方式
```python
from service.example_adapter import get_adapter
from service.example_adapter.base import ExampleItem
adapter = get_adapter() # 由 OPENCLAW_TEST_TARGET 决定档位
result = adapter.do_batch(
target="https://sandbox.jc2009.com",
items=[ExampleItem(id="1", label="demo")],
)
```
mock 档位纯离线验证:
```bash
set OPENCLAW_TEST_TARGET=mock
python -c "import sys; sys.path.insert(0,'scripts'); from service.example_adapter import get_adapter; from service.example_adapter.base import ExampleItem; r=get_adapter().do_batch('x',[ExampleItem('1','a')]); print(r.ok, r.serial_no)"
```
## base.py 写法(要点)
1. **数据契约用 `@dataclass`**:输入项(如 `PayrollItem`)、批量结果(如 `BatchResult{ok, serial_no, error_msg, artifacts}`)。
2. **接口方法抛 `NotImplementedError`**:所有档位实现同一签名。
3. **以"批"为单位返回**:真实 RPA 往往是整批提交,结果按批次返回,不要假设逐条。
4. **结果里带 `artifacts`**:截图/录屏/日志路径,便于排查。
```python
@dataclass
class XxxResult:
ok: bool
serial_no: Optional[str]
error_msg: Optional[str]
artifacts: Dict[str, Any] = field(default_factory=dict)
class AdapterBase:
name = "base"
def do_batch(self, target: str, items: list) -> XxxResult:
raise NotImplementedError
```
## 档位 dispatch
`OPENCLAW_TEST_TARGET` 统一决定用哪个 adapter与现有测试体系一致
| `OPENCLAW_TEST_TARGET` | adapter | 用途 |
|---|---|---|
| `unit` / `mock` | `MockAdapter` | 单测 / CI离线 |
| `simulator_rpa`(默认) | `SimRpaAdapter` | 开发/演示,操作仿真平台 |
| `real_api` | `RealApiAdapter` | 生产,走官方接口 |
| `real_rpa` | `RealRpaAdapter` | 生产,操作真实界面(最后手段) |
```python
# __init__.py
def get_adapter():
target = (os.environ.get("OPENCLAW_TEST_TARGET") or "simulator_rpa").lower()
if target in ("unit", "mock"):
return MockAdapter()
if target == "real_api":
return RealApiAdapter()
if target == "real_rpa":
return RealRpaAdapter()
return SimRpaAdapter() # 默认
```
> 实际可改为读 `.env` 里的运行模式(见 `CONFIG.md``OPENCLAW_TEST_TARGET` 作为进程级覆盖优先。
## 测试要求
- **mock 档必须能纯离线跑通**,进 CI。
- **每个 adapter 单测同一组用例**(契约测试),保证档位间行为一致。
- RPA 档的真实联网测试单独标记,不进默认 CI参考 `tests/desktop/`)。
## 相关文档
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
- `CONFIG.md``.env` 里如何配置运行模式与目标地址
- `TESTING.md` — 测试 target 与隔离体系

View File

@@ -1,4 +1,4 @@
# 通用业务技能模板 CLI 模板
# 通用业务技能模板 CLI 标准
`{baseDir}` 替换为技能根目录(含 `SKILL.md``scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。
@@ -6,41 +6,82 @@
```bash
python {baseDir}/scripts/main.py health
python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py version
```
## 通用业务命令骨架
## 标准行为
模板默认提供以下命令(复制后按需保留或扩展):
- **任意 CLI 启动前**执行 `bootstrap_skill_config()``main.py``cli.app.main()` 均触发)。
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets不执行业务动作**;不输出敏感值。
- **`config-path`**:输出 JSON包含 `skill``env_path``example_path`
- **`version`**:输出 JSON`version``skill`)。
- **`run`**:长时间无 stdout **不代表卡死**;应通过 `logs` / `log-get` 排查。
- **任务完成后**若有 video artifactCLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`)。
## config-path配置路径
```json
{
"skill": "your-skill-slug",
"env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env",
"example_path": "{skill_root}/.env.example"
}
```
## health 标准输出runtime diagnostics
`health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,典型字段:
- `python_executable` — 当前 Python 解释器路径
- `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok`
- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning
- `media_assets_root``ffmpeg_path``ffmpeg_available``background_music_mp3_count`
- `runtime_issue[warning|error]` — 非致命/致命问题列表
- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径
## 通用业务命令骨架
```bash
python {baseDir}/scripts/main.py run
python {baseDir}/scripts/main.py run -t <target> -i <input_id>
python {baseDir}/scripts/main.py logs
python {baseDir}/scripts/main.py logs --task-type disburse --status failed
python {baseDir}/scripts/main.py logs --task-type demo --status failed
python {baseDir}/scripts/main.py log-get <log_id>
```
含义:
- `run`:执行一次任务(具体语义由你的 skill 决定)
- `logs`:列出最近 N 条任务日志,可按 task_type / status / target 过滤
- `log-get`:按 log_id 查看单条任务日志JSON
| 命令 | 用途 |
|------|------|
| `run` | 执行一次任务 |
| `logs` | 列出最近 N 条任务日志 |
| `log-get` | 按 log_id 查看单条任务日志JSON |
## 若你的技能是发布类
## 查询/导出类(可选)
可以把 `run` 命令对外别名为 `publish`,参数 `--target` 别名为 `--account-id`,参数 `--input-id` 保持不变
复制后按需增加,例如:`contacts``contact-get``export` 或业务等价命令
## 若你的技能依赖兄弟技能
使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 sibling_bridge.py 自身。
使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 `sibling_bridge.py` 自身;并在 `SKILL.md` 声明 `dependencies.required`
```bash
# 假设 base 与兄弟技能同级
python {baseDir}/../<sibling-skill-slug>/scripts/main.py ...
## 手工排查命令(推荐)
**建议使用宿主共享 python-runtime**,避免技能目录内临时 venv 加载不到公共库:
```text
Windows:
{JIANGCHANG_DATA_ROOT}\python-runtime\.venv\Scripts\python.exe {baseDir}\scripts\main.py health
通用:
<shared-python> {baseDir}/scripts/main.py health
```
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
**不建议**在生产/测试机使用技能目录内 `uv run python`,以免加载不到共享 runtime 中的 `jiangchang-platform-kit` / `playwright`
## 模板约定
- 最小模板至少保留 `health` / `version`
- 最小模板至少保留 `health` / `config-path` / `version`
- 通用业务技能建议使用 `run` / `logs` / `log-get`
- 不要再用旧模板的 `publish` / `--account-id` / `--article-id` 命名

View File

@@ -1,99 +0,0 @@
# 配置标准:`.env` 规范与首次落盘机制
> 解决"每个 skill 每次都要手动搞一堆环境变量、账号、地址"的痛点。统一约定:**模板内置 `.env.example`skill 首次运行自动落盘到用户数据目录,之后从那里读。**
## 核心原则
1. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。
2. **首次运行自动 copy**`setup` / `doctor` / 首次 `run` 时,把 `.env.example` 复制到
`{CLAW_DATA_ROOT}/{user}/{slug}/.env`**已存在则不覆盖**,保护用户改过的值)。
3. **三级优先级读取**`进程环境变量` > `数据目录/.env` > `.env.example 默认值`
4. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。
## 标准配置项
每个 skill 的 `.env.example` 至少包含这些(按需增减):
```ini
# ── 运行模式(对应 adapter 四档,见 ADAPTER.md──
OPENCLAW_TEST_TARGET=simulator_rpa # mock | simulator_rpa | real_api | real_rpa
# ── 目标系统 ──
TARGET_BASE_URL=https://sandbox.jc2009.com # 仿真/生产地址,可被进程环境变量覆盖
# ── 默认账号(仅非敏感标识;密码走 account-manager──
DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ──
OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启反检测指纹(默认)
# ── 存证 ──
OPENCLAW_RECORD_VIDEO=1 # 1=录屏+字幕+背景音+MP4成片RPA技能默认开见 RPA.md
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──
STEP_DELAY_MIN=1.0 # 步骤间随机等待下限(秒)
STEP_DELAY_MAX=5.0 # 步骤间随机等待上限(秒)
HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒)
```
## 🚫 红线:敏感信息不进 `.env`
`.env` 只放**非敏感运行参数**模式、地址、开关、超时。密码、密钥、动态口令、token——
-**account-manager** 注册凭据,用 `--secret-storage env --secret-ref XXX` 引用;
- 真实值由宿主/用户通过环境变量注入,**不落任何文件、不进 git**。
- 参考 `disburse-payroll-icbc/scripts/cli/cmd_setup.py`:账号写入 account-manager密码只登记 `secret-ref`(如 `ICBC_SIM_PASSWORD`)。
`.gitignore` 必须包含:
```gitignore
.env
*.env.local
```
## 配置读取层(模板提供)
模板会提供一个统一 `config.py`,约定行为:
```python
config.get("TARGET_BASE_URL") # 三级优先级解析
config.get_bool("OPENCLAW_BROWSER_HEADLESS")
config.get_float("STEP_DELAY_MIN", 1.0)
config.ensure_env_file() # 首次把 .env.example copy 到数据目录
```
- 启动时调一次 `ensure_env_file()` 完成落盘。
- 所有业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`,便于统一管理与测试覆盖。
### 引用方式
实现位于 `jiangchang-platform-kit``jiangchang_skill_core.config`;模板 vendor 拷贝在 `scripts/jiangchang_skill_core/config.py`
```python
from jiangchang_skill_core import config
from util.constants import SKILL_SLUG
from util.runtime_paths import get_skill_root
import os
example = os.path.join(get_skill_root(), ".env.example")
config.ensure_env_file(SKILL_SLUG, example)
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS")
target = config.get("TARGET_BASE_URL")
timeout = config.get_int("HUMAN_WAIT_TIMEOUT", 180)
```
## doctor / setup 命令
每个 skill 应提供自检命令(参考 `disburse``test_doctor`
- `python scripts/main.py doctor`检查浏览器是否安装、Profile/账号是否就绪、`.env` 是否已落盘、设备(手机)是否连接。
- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`
## 相关文档
- `RPA.md` — 三端 RPA 标准与各开关含义
- `ADAPTER.md``OPENCLAW_TEST_TARGET` 四档模式
- `RUNTIME.md``CLAW_*` 环境变量与数据目录约定

View File

@@ -1,64 +1,19 @@
---
description: "规范化的新技能模板说明复制后替换名称、CLI 示例与 task_service 等业务实现。"
---
# Agent 参考索引
# 技能模板说明
本目录只是 Agent **运行、编排、调用** skill 时可渐进加载的资料索引,**不是**用户市场说明。
这个仓库是**给开发者复制的新技能模板**,不是终端用户直接调用的业务 skill。
**边界规则:**
## 它提供什么
- 本文**不得**包含 YAML frontmatter**不得**包含 `description` 字段。
- 技能市场详情与普通用户说明**必须**放在根目录 [`README.md`](../README.md)。
- 本目录只保留运行时引用资料(如 `CLI.md``SCHEMA.md`);开发规范放在 [`../development/`](../development/)。
- 标准目录结构
- 最小 CLI 入口(`run` / `logs` / `log-get` / `health` / `version`
- 通用任务日志表骨架(`task_logs`
- `service` 层占位模块(`task_service.py`、通用 `sibling_bridge.py`
- 成熟的隔离测试体系IsolatedDataRoot、FakeAdapter、6 档测试 target、profile 策略)
- 与现有规范 skill 一致的发布脚本与 GitHub workflow
## 复制后你需要改什么
- `SKILL.md` 中的名称、描述、slug、触发说明
- `SKILL.md``metadata.openclaw.developer_ids`(如需让非公开技能默认授权给开发者查看)
- `references/CLI.md` 里的命令示例
- `scripts/util/constants.py` 中的 slug / 版本 / logger 名
- `scripts/service/task_service.py` 中的 `cmd_run` 真实业务逻辑
- 如有特定 task_type`references/SCHEMA.md` 中补充字段映射说明
## `developer_ids` 是做什么的
`metadata.openclaw.developer_ids` 是平台发布元数据,不是终端用户文案。
它用于声明:当技能发布后,如果平台侧将该技能设置为 `access_scope = 0`(不公开),哪些开发者用户仍应自动获得可见权限。
约定如下:
- 字段位置:`SKILL.md` -> `metadata.openclaw.developer_ids`
- 推荐格式:正整数数组,例如 `[1032, 12428]`
- 发布效果:发布接口会把这些用户补写到 `skill_user_access`
- 第一个 ID 会作为主开发者同步到 `skills.developer_id`
如果你的技能本来就是公开技能,或暂时不需要开发者专属可见性,这个字段可以留空数组。
## 不建议再保留的旧结构
- 旧模板里的 `docs/`
- 旧模板里的 `optional/`
- 旧入口 `scripts/skill_main.py`
新模板统一使用 `scripts/main.py` 作为入口。
## 参考文档索引
| 文档 | 内容 |
| 文档 | 用途 |
|------|------|
| `DEVELOPMENT.md` | 从复制模板到开发出新 skill 的完整教程 |
| `RUNTIME.md` | 运行时目录结构与 `CLAW_*` 环境变量约定 |
| `CLI.md` | CLI 命令与参数示例 |
| `SCHEMA.md` | task_logs 与字段映射说明 |
| `TESTING.md` | 测试 target 与隔离体系 |
| **`RPA.md`** | **三端 RPA 标准(浏览器/桌面/手机):选型、拟人操作、反反爬、人工兜底、存证** |
| **`ADAPTER.md`** | **真实/仿真 × API/RPA 四档适配器模式** |
| **`CONFIG.md`** | **`.env` 配置规范与首次自动落盘机制** |
| `REQUIREMENTS.md` | 依赖与运行要求 |
| 根目录 [`README.md`](../README.md) | 用户市场详情页说明 |
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口 |
| [`CLI.md`](CLI.md) | 命令行入口、参数与调用契约 |
| [`SCHEMA.md`](SCHEMA.md) | 输入输出、数据库与字段说明 |
| [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) |
> 开发"操作浏览器/桌面/手机"或"对接 ERP/CRM/SaaS/银行"类 skill 前,**务必先读 `RPA.md` + `ADAPTER.md` + `CONFIG.md`** 这三份标准,避免重复踩坑。
按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;运行时环境细节见 `../development/RUNTIME.md`

View File

@@ -1,354 +0,0 @@
# 技能需求文档模板
这份文档用于给技术人员、产品人员和实施人员统一描述某个 skill 的研发需求。
建议原则:
- 一个 skill 对应一份主需求文档
- 需求先写清楚,再进入开发
- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节
- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md``CLI.md``SCHEMA.md``RUNTIME.md`
如果你是从 `skill-template` 复制新技能,请复制本文件后,把下面所有占位内容替换成你的真实项目内容。
---
# REQUIREMENTS
## 1. 文档目标
说明这份需求文档的作用,以及它解决什么问题。
模板写法:
- 本文档用于明确 `【技能名称】` 的研发范围、交付标准、依赖关系和验收要求。
- 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。
示例:
- 本文档用于明确 `your-skill-slug`(示例:`disburse-payroll-icbc`)的研发范围、交付标准、依赖关系和验收要求。
## 2. 业务背景
说明为什么要做这个 skill它服务什么业务场景解决什么实际问题。
模板写法:
- 当前业务场景中,用户需要在 `【目标平台】` 完成 `【业务动作】`
- 现有流程主要依赖人工处理,存在 `【效率低 / 容易出错 / 不可批量 / 不可追踪】` 等问题。
- 因此需要开发 `【技能名称】`,将该流程标准化、自动化,并接入匠厂技能体系中。
示例:
- 当前财务团队需要将工资代发批次与银行接口对账,人工导出表格、核对差异耗时且易错。
- 因此需要开发 `disburse-payroll-icbc`,用于自动拉取代发结果、写入任务日志 `task_logs` 并输出对账摘要,支持审计追踪。
## 3. 开发目标
说明本次开发希望最终交付什么结果。
模板写法:
- 完成 `【技能名称】` 的标准目录结构搭建
- 完成 CLI 入口与基础命令
- 完成核心业务流程
- 完成与兄弟技能或外部系统的必要集成
- 完成正式环境发布验证
示例:
- 完成 `your-skill-slug` 的标准 skill 结构建设
- 提供 `health``version``run``logs``log-get` 等命令
- 支持对接银行 / 薪酬系统 API示例并记录任务执行结果
- 支持写入任务日志 `task_logs`,并可在正式环境中安装验证
## 4. 功能范围
说明这次开发明确要实现哪些功能。
模板写法:
- 支持 `【命令1】`
- 支持 `【命令2】`
- 支持读取 `【数据来源】`
- 支持执行 `【核心业务动作】`
- 支持记录 `【日志 / 状态 / 结果】`
建议将功能拆成“必须实现”和“可后续扩展”。
示例:
### 必须实现
- 支持 `python scripts/main.py health`
- 支持 `python scripts/main.py version`
- 支持 `python scripts/main.py run`
- 支持从 `some-sibling-skill` 读取批次元数据(如需要)
- 支持执行核心业务编排HTTP / 批处理 / 可选 RPA
- 支持写入任务日志(`task_logs`
### 可后续扩展
- 支持定时批跑
- 支持失败自动重试
- 支持多目标轮询
## 5. 非功能要求
说明除功能外,对稳定性、可维护性、目录规范、日志、编码等方面的要求。
模板写法:
- 目录结构必须符合当前 skill 模板规范
- 入口必须统一为 `scripts/main.py`
- 输出格式应尽量机读友好
- 错误前缀统一为 `ERROR:`
- Windows 环境下需保证 UTF-8 输出兼容
- 必须具备基本日志能力
示例:
- 项目结构必须对齐 `skill-template`
- CLI 入口统一使用 `scripts/main.py`
- 关键执行结果应可通过 JSON 或固定文本结构返回
- 任务日志必须可追踪(`task_logs`
- 不允许重新引入旧模板中的 `docs/``optional/``scripts/skill_main.py`
## 6. 输入输出要求
说明这个 skill 接收什么输入,产出什么输出。
模板写法:
### 输入
- `【输入参数1】`
- `【输入参数2】`
- `【依赖系统返回的数据】`
### 输出
- `【标准输出】`
- `【错误输出】`
- `【数据库记录】`
- `【日志文件】`
示例:
### 输入
- `target_id`:可选,指定任务目标(账号 / 客户 / 平台)
- `input_id`:可选,指定输入批次或记录
- 兄弟技能返回的结构化数据(如有)
### 输出
- 成功结果stdout / JSON
- 错误输出(`ERROR:` / `FAIL:`
- 在本地数据库写入 `task_logs`
- 在日志目录中写入执行日志
## 7. 依赖的兄弟技能或外部系统
说明开发该 skill 时依赖哪些内部技能或外部平台。
模板写法:
### 兄弟技能依赖
- `【skill-a】`:作用是 `【用途】`
- `【skill-b】`:作用是 `【用途】`
### 外部系统依赖
- `【平台名】`:作用是 `【用途】`
- `【浏览器 / API / 第三方服务】`:作用是 `【用途】`
### Python 包依赖requirements.txt
- 本技能若需额外 Python 三方包,写入根目录 `requirements.txt`
- 匠厂宿主安装/更新技能后,会安装到 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 版本须尽量收窄(如 `requests>=2.31.0,<3`),避免共享 venv 冲突。
- **不要**把系统运行时组件(如 Microsoft Visual C++ Redistributable写进 requirements.txt。
### 系统运行时前置条件preflight / health
- 若依赖 Windows 原生 DLL、本机浏览器 channel 等**系统级**能力,在 `health` / preflight 中检测并提示用户自行安装。
- 此类依赖**不**通过 pip / requirements.txt 由宿主自动安装。
示例:
### 兄弟技能依赖
- `some-sibling-skill`:提供主数据或凭证索引(按业务填写)
### 外部系统依赖
- 银行 / 政务 HTTP API示例核心外部接口
- Chromium / Chrome / Edge可选用于浏览器自动化
- Gitea用于代码托管和发布工作流
- 匠厂客户端:用于正式环境安装与验证
## 8. 不在本次范围内的内容
这一节非常重要,用来明确本次“不做什么”,避免研发越做越散。
模板写法:
- 本次不实现 `【功能A】`
- 本次不处理 `【平台B】`
- 本次不做 `【高级能力】`
- 本次不兼容 `【旧结构 / 旧逻辑】`
示例:
- 本次不实现多租户隔离之外的附加报表
- 本次不实现实时流式处理
- 本次不实现自动重试机制
- 本次不兼容旧模板中的历史目录结构
## 9. 验收标准
说明什么情况下才算真正开发完成。
模板写法:
- 代码结构符合模板规范
- 最小命令可运行
- 核心命令可运行
- 正式环境可安装
- 匠厂中可看到最新版本
- 可以在新建任务中实际使用
示例:
- `health``version` 命令执行正常
- `run` 主流程可运行
- 发布后 Gitea 工作流成功
- 匠厂技能市场能看到最新版本
- skill 可以正常安装
- 安装后可在“新建任务”中调用
**测试与日志相关验收(所有新 skill 必须满足)**
- `python tests/run_tests.py -v` 必跑测试全部通过
- 任务日志task_logs写入和查询符合预期
- 真实联调(如有)已在 tests/integration/ 中验证,且默认套件不包含真实外联
## 10. 开发注意事项
说明研发过程中必须特别注意的事项。
模板写法:
- 不要修改无关 skill
- 不要破坏现有模板规范
- 优先遵守目录和分层约定
- 不要在 CLI 层堆业务逻辑
- 发布前必须先做本地最小验证
示例:
- 只允许修改当前 skill 仓库,不要改动其他兄弟项目
- 如使用浏览器自动化,优先复用已验证过的定位器和流程
- `cli` 只做参数解析,核心逻辑统一放到 `service`
- 发布前必须完成本地验证、工作流验证和正式环境安装验证
## 11. 变更记录
这一节用于记录需求文档本身的变化,不是 git 提交记录的替代,而是给技术人员看“需求范围怎么变了”。
模板写法:
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| 2026-04-13 | v1.0 | 张三 | 初版需求文档 |
| 2026-04-14 | v1.1 | 李四 | 增加正式环境验收要求 |
---
## 建议使用方式
建议每个新 skill 开发时,按下面顺序使用文档:
1. 先写 `references/REQUIREMENTS.md`
2. 再按 `references/DEVELOPMENT.md` 进入开发
3. 开发过程中补充 `CLI.md``SCHEMA.md``RUNTIME.md``TESTING.md`
4. 发布前回到 `REQUIREMENTS.md` 对照验收标准逐项检查
## 最小模板示例
下面给出一个可直接参考的简化版示例:
```md
# REQUIREMENTS
## 1. 文档目标
本文档用于明确 `your-skill-slug` 的研发需求、范围和验收标准,作为开发与测试的统一依据。
## 2. 业务背景
采购审计需要将政府采购公告结构化归档;人工复制粘贴效率低,需要可追溯的任务日志。
## 3. 开发目标
- 完成 `your-skill-slug` 的标准 skill 结构
- 支持核心业务批处理 / HTTP 对接主流程
- 支持任务日志记录task_logs
## 4. 功能范围
- 支持 `health`
- 支持 `version`
- 支持 `run`
- 支持查询任务日志logs / log-get
## 5. 非功能要求
- 入口统一为 `scripts/main.py`
- 保持 UTF-8 输出
- 结构符合 skill 模板规范
## 6. 输入输出要求
### 输入
- `target_id`
- `input_id`
### 输出
- 成功结果
- 错误信息
- task_logs 记录
## 7. 依赖的兄弟技能或外部系统
- (按需填写兄弟 skill
- 政务公告站点 HTTP API示例
## 8. 不在本次范围内的内容
- 不实现浏览器可视化回放(如需另行立项)
- 不实现自动重试
## 9. 验收标准
- 命令可运行
- `python tests/run_tests.py -v` 全部通过
- task_logs 写入符合 SCHEMA 约定
- 能完成正式环境安装验证
- 能在新建任务中使用
## 10. 开发注意事项
- 不修改无关项目
- 不引入旧模板结构
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| 2026-04-13 | v1.0 | 张三 | 初版 |
```

View File

@@ -1,175 +0,0 @@
# RPA 操作标准(浏览器 / 桌面 / 手机)
> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill都应先读这份文档按这里的选型和范式落地不要每个项目重新踩坑。
我们开发的各类 skill本质上都是在替人操作三类界面**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:保持登录态、有头运行、拟人操作、失败存证、人工兜底。
---
## 0. 三端通用范式(先看这个)
无论操作哪类界面,都遵循同一套约定:
| 约定 | 说明 |
|------|------|
| **保持登录态** | 复用持久化 Profile / session避免每次重新登录触发风控账号由 account-manager 下发,不硬编码 |
| **有头运行** | 默认有头headless 易被识别 / 难人工介入);`OPENCLAW_BROWSER_HEADLESS=1` 仅给 CI |
| **拟人操作** | 真实事件isTrusted=true逐字输入、随机延迟、贝塞尔鼠标轨迹严禁 JS 直接设值/JS 点击/JS 跳转 |
| **步骤间随机等待** | 每两步操作之间 `random_delay(min,max)`,区间由 `.env` 配置(默认 1~5s |
| **人工兜底HITL** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` |
| **选择器纪律** | 语义选择器优先id/name/text/aria**F12 确认后再写,严禁凭记忆猜 DOM** |
| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 |
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
> 三端各自实现一个会话抽象 `RpaSession`launch / login / act / screenshot / close上层 skill 不感知是浏览器还是手机。
---
## 1. 浏览器(标准已成熟)
**选型Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(参考 `1688-scrape-contacts``disburse-payroll-icbc`)。
| 项 | 标准 |
|----|------|
| 驱动 | PlaywrightPython |
| 浏览器 | 系统已装 Chrome/Edge`channel="chrome"`**不用内置 Chromium无需 `playwright install`** |
| 登录态 | `launch_persistent_context` + 用户 Profile 目录(由 account-manager 下发) |
| 模式 | 默认有头;`OPENCLAW_BROWSER_HEADLESS=1` 转无头 |
| 反反爬 | stealth 指纹注入 + 拟人输入/鼠标/延迟 + 滑块停下等人工(见下) |
### 1.1 防反爬三件套(必做)
1. **指纹淡化stealth**:启动时 `add_init_script` 注入,抹平自动化特征——
`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages``hardwareConcurrency``deviceMemory`、WebGL vendor。
启动参数 `--disable-blink-features=AutomationControlled` + `ignore_default_args=["--enable-automation"]`
开关:`OPENCLAW_PLAYWRIGHT_STEALTH`(默认开)。
2. **拟人操作**
- 输入:逐字符 `keyboard.type(delay=90~240ms)`**先真实点击聚焦再输入,绝不 `el.value=`**。
- 鼠标:贝塞尔曲线轨迹移动 + 微抖动;进场随机晃动 2~4 次。
- 导航:用真实点击触发,**不要 `window.location.href=` / `el.click()` 这类 JS 跳转/点击**。
- 翻页:真实点击翻页箭头,注意排除禁用态选择器。
- 延迟:每步之间 `random_delay`
3. **风控页 = 停下等人工**:检测到滑块/拦截URL 含 `punish/x5sec/baxia/tmd`,或 DOM 命中 `#nocaptcha/.nc-container/punish-component` 等)→ 抛 `ERROR:CAPTCHA_NEED_HUMAN`,轮询等人工通过或超时,**不自动操作滑块**。
> 参考实现:`1688-scrape-contacts/scripts/service/anti_detect.py`、`util/playwright_stealth.py`、`service/login_service.py`(验证码等待)。已提升为共享库 `jiangchang_skill_core.rpa`(见 platform-kit
### 1.2 安装
```bash
pip install playwright # 仅装包;用系统 Chrome无需 playwright install chromium
```
### 引用方式
共享实现位于 `jiangchang-platform-kit``jiangchang_skill_core.rpa`;模板通过 vendor 拷贝引用:
```python
# scripts/jiangchang_skill_core/rpa/ — vendored from platform-kit
from jiangchang_skill_core.rpa import (
launch_persistent_browser,
anti_detect,
wait_for_captcha_pass,
capture_failure,
errors,
)
from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRIPT
```
完整示例见 `scripts/service/example_browser_rpa.py``scripts/service/example_adapter/sim_rpa.py`
---
## 2. 桌面软件Windows 原生程序)
**选型pywinautoUIA backend为主 + 图像识别兜底。**
桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。
| 技术 | 优先级 | 适用 / 说明 |
|------|--------|------|
| **pywinauto`backend="uia"`** | ✅ 首选 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType**稳定、不依赖屏幕坐标**,纯 Python |
| **FlaUI**(经 pythonnet 调 .NET | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 |
| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 |
| **PyAutoGUI / SikuliX图像识别** | ⚠️ 兜底 | 控件树完全拿不到时Flash/远程桌面/纯自绘 UI**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 |
### 桌面端注意事项
- **窗口聚焦/置顶**:操作前确保目标窗口前置,避免误操作其它窗口。
- **DPI/缩放**图像识别方案必须固定显示缩放比例UIA 方案不受影响(优先用 UIA 即是为此)。
- **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`
- **人工兜底**U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`
```bash
pip install pywinauto # UIA 自动化
# 图像兜底pip install pyautogui opencv-python
```
> 状态:桌面端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
---
## 3. 手机软件USB 连接电脑)
**选型Android 用 uiautomator2或 AppiumiOS 用 Appium + WebDriverAgent需 Mac** 底层都是经 USB 的 ADB / WDA。
| 平台 | 技术 | 优先级 | 说明 |
|------|------|--------|------|
| **Android** | **uiautomator2**python 原生) | ✅ 首选 | ADB over USB直接拿控件树点击/输入,比 Appium 轻快,纯 Python |
| Android | **Appium**uiautomator2 driver | 备选 | 需要跨平台统一接口、或团队已有 Appium 资产时用 |
| Android | **Airtest + Poco**(网易开源) | 兜底 | 图像+控件混合,自带 IDE 可录制;控件树拿不到时用 |
| **iOS** | **Appium + WebDriverAgentXCUITest** | 唯一可行 | **必须有一台 Mac 做中转**Windows host 无法直接驱动 iOS |
| 投屏/人工介入 | **scrcpy** | 辅助 | USB 投屏到电脑,配合人工过验证码/人脸 |
### 手机端注意事项
- **设备就绪检查**`adb devices` 确认已授权连接;放进 `doctor` 自检。
- **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。
- **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。
- **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`
```bash
# Android首选
pip install uiautomator2
python -m uiautomator2 init # 初始化设备端 agent
# 或统一走 Appiumpip install Appium-Python-Client另需 Appium Server
```
> 状态:手机端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
---
## 4. 统一错误码RPA 场景)
skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层判断与重试:
| 错误码 | 含义 | 上层处理建议 |
|--------|------|------|
| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程 |
| `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 |
| `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 |
| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 |
| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 |
| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB |
| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 |
---
## 5. 存证规范
- **路径**`{CLAW_DATA_ROOT}/{user}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。
- **运行录屏成片**RPA 型技能默认 `OPENCLAW_RECORD_VIDEO=1`,表示 **ffmpeg 录制运行过程** + 字幕 + 背景音乐 + 最终 MP4 **一体开启**。由 `RpaVideoSession` 统一触发,不在 CLI/E2E 单独实现。
- **Playwright 不负责录屏**,仅浏览器自动化。
- **ffmpeg 是唯一录屏器**Windows`gdigrab` + `desktop`)。
- **最终视频**`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4`
- **中间产物**`rpa-artifacts/{batch_id}/capture.mp4``subtitles/``logs/` 等,**不放最终 MP4**。
- **常见 tag**`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`
---
## 相关文档
- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
- `CONFIG.md``.env` 配置规范与首次落盘机制
- `RUNTIME.md` — 运行时目录与环境变量约定

View File

@@ -1,51 +0,0 @@
# 运行时约定
## 目录结构
新技能建议采用以下根目录结构:
- `assets/`
- `references/`
- `scripts/`
- `tests/`
- `evals/`
## `scripts/` 分层
- `scripts/main.py`:唯一 CLI 入口
- `scripts/cli/`:参数解析与命令分发
- `scripts/db/`SQLite 或本地持久化层
- `scripts/service/`:业务用例与外部交互
- `scripts/util/`:通用工具、常量、日志、路径
- `scripts/jiangchang_skill_core/`:运行时与统一日志副本
## 数据路径
推荐:
```text
{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/
```
数据库文件建议:
```text
{...}/{skill_slug}.db
```
## 测试时的运行时隔离
本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间同时设置:
- `CLAW_DATA_ROOT` / `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录
- `CLAW_USER_ID` / `JIANGCHANG_USER_ID``_test`
退出时四个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。
详见 `references/TESTING.md` 第 3 节。
## 编码与输出
- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装
- 机读输出优先单行 JSON
- 错误前缀建议统一 `ERROR:`

View File

@@ -1,38 +1,173 @@
# 数据存储模板
# 数据存储与数据管理展示规范
## 数据库路径
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py``scripts/db/connection.py``init_db()` 会一并初始化。
`{DATA_ROOT}/{USER_ID}/your-skill-slug/your-skill-slug.db`
## 职责边界
## 通用任务日志表task_logs
| 层级 | 负责内容 |
|------|----------|
| **技能(本仓库)** | `SKILL.md.name` 中文技能名;英文物理表/字段名;`_jiangchang_*` 中文展示元数据;`CREATE TABLE` 字段物理顺序 |
| **宿主(匠厂)** | 读取元数据与 PRAGMA 顺序并展示;不猜测业务语义、不按英文字段名重排 |
模板默认建表:
SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`SQL 文件里的 `-- 注释` 也不会成为可查询结构。
因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info``cid`)。
| 字段 | 类型 | 说明 |
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
## 技能名称SKILL.md
```yaml
---
name: 中文业务名称
description: 中文技能说明
metadata:
openclaw:
slug: english-kebab-slug
---
```
- `name`:用户可见的中文业务名称(可含品牌,如 `GEO 文章生成``1688 联系人采集`)。
- `description`:能力说明,不是技能名称。
- `metadata.openclaw.slug`机器标识kebab-case 英文;目录名与默认数据库文件名使用 slug。
- 复制模板后不得将 `your-skill-slug``My Skill` 等占位内容作为正式技能发布名称。
## 数据库路径与命名
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db
```
- 数据库文件:英文 slug例如 `scrape-contacts.db``account-manager.db`(默认 `{skill-slug}.db`)。
- 业务表、字段:**英文 snake_case** 物理名,例如 `task_logs``created_at`
- **禁止**用中文作为 SQLite 真实表名或字段名。
## 元数据表(宿主兼容)
宿主读取字段(与 `jiangchang` `metadata-resolver.ts` 一致):
### `_jiangchang_tables`
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | INTEGER PK | 自增主键 |
| `task_type` | TEXT NOT NULL | 任务类型disburse / reconcile / verify ... |
| `target_id` | TEXT | 任务目标(账号、平台、客户等的 ID |
| `input_id` | TEXT | 输入对象 ID |
| `input_title` | TEXT | 输入对象标题快照 |
| `status` | TEXT NOT NULL | success / failed / partial / require_login 等 |
| `error_msg` | TEXT | 错误说明 |
| `result_summary` | TEXT | 结果摘要(建议存 JSON 字符串) |
| `created_at` | INTEGER | Unix 时间戳 |
| `updated_at` | INTEGER | Unix 时间戳 |
| `table_name` | 是 | 物理表名 |
| `display_name` | 是 | 中文表名(用户可见 |
| `description` | 否 | 表说明 |
| `sort_order` | 否 | 宿主目录排序;与字段顺序无关 |
| `visible` | 是 | `1` 展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 只读 / `0` 可写(业务语义) |
## 不同业务的字段映射建议
### `_jiangchang_columns`
| 业务场景 | task_type | target_id 含义 | input_id 含义 |
|---|---|---|---|
| 发布类 | publish | 账号 ID | 文章 ID |
| 工资代发 | disburse | 付款账户 | 工资表批次 ID |
| 对账 | reconcile | 银行 / 平台 | 对账批次 ID |
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 物理表名 |
| `column_name` | 是 | 物理字段名 |
| `display_name` | 是 | 中文字段名(用户可见) |
| `description` | 否 | 字段说明 |
| `display_order` | 否 | **遗留兼容字段;模板写入时固定 NULL重复初始化会清理历史非空值不得用于调整展示顺序** |
| `visible` | 是 | 是否展示 |
| `searchable` | 是 | 是否可搜索 |
| `editable` | 是 | 是否可编辑(与宿主权限结合) |
| `display_type` | 否 | 如 `text` / `textarea` |
| `options_json` | 否 | 枚举选项 JSON |
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
写入应**幂等**`INSERT … ON CONFLICT DO UPDATE`),数据库升级时同步维护元数据。
## 业务字段物理顺序
用户在「数据管理」中看到的列顺序 = `CREATE TABLE` 定义顺序 = `PRAGMA table_info``cid` 顺序。
**宿主不会**按英文字段名、`display_order` 或语义规则重排。
推荐顺序(可按业务裁剪,但已有字段应遵守相对位置):
```text
1. 主键 id
2. 关联标识与核心业务字段
3. 普通业务字段
4. 状态、结果、错误等辅助字段
5. created_at如有
6. updated_at如有
```
### 模板默认:`task_logs`
物理定义(权威顺序):
```sql
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
target_id TEXT,
input_id TEXT,
input_title TEXT,
status TEXT NOT NULL,
error_msg TEXT,
result_summary TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
对应中文展示(由 `init_db()` 写入元数据表):
| 物理字段 | 中文名 |
|----------|--------|
| id | 编号 |
| task_type | 任务类型 |
| target_id | 目标编号 |
| input_id | 输入编号 |
| input_title | 输入标题 |
| status | 状态 |
| error_msg | 错误信息 |
| result_summary | 结果摘要 |
| created_at | 创建时间 |
| updated_at | 更新时间 |
表中文名:**任务日志**。
禁止事项:
- 不要把 `created_at` 放在第一列;
- 不要把 `id` 随意放在中间;
- 不要指望宿主按英文字段名排序;
- 若需调整历史表字段顺序,应通过规范迁移**重建表**,而不是在宿主侧配置掩盖。
复合主键或特殊表结构,请在本文档或技能自有 SCHEMA 补充中说明例外。
## 中文命名质量
- 使用普通用户能理解的业务用语:`task_logs`**任务日志**`error_msg`**错误信息**
- 禁止:`Task Logs``input编号``errormsg`、直接把物理名当 `display_name`
- 不宜向用户暴露的实现细节(如 `payload_json``selector`)应设 `visible = 0` 或给出可理解中文名。
## 元数据同步规则
1. 新增用户可见业务表 → 同步 `_jiangchang_tables`
2. 新增用户可见字段 → 同步 `_jiangchang_columns`
3. 删除/重命名表或字段 → 同步清理或迁移元数据。
4. 初始化脚本必须幂等。
5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`
## 不同业务的 `task_logs` 映射
| 业务场景 | task_type | target_id | input_id |
|----------|-----------|-----------|----------|
| 发布类 | publish | 账号 ID | 内容 ID |
| 工资代发 | disburse | 付款账户 | 批次 ID |
| 对账 | reconcile | 银行/平台 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID |
| 报关 | declare | 港口 / 海关 | 报关批次 ID |
业务特有字段优先放入 `result_summary`JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。
## 开发自检
- `init_db()` 后运行 `tests/test_display_metadata.py`
- 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。
## 模板原则
- 模板不做历史迁移兼容设计
- 新 skill 直接从当前 schema 起步
- 业务有特殊字段时,建议放 `result_summary`JSON 字符串),不要乱加列
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
- 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。

View File

@@ -1,3 +1,15 @@
<#
.SYNOPSIS
Local git release for skill-template repo.
.DESCRIPTION
Handles git commit / push / semver tag / push tag only.
Encryption, ZIP packaging, and marketplace sync are done by CI
(.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main).
Requires: git, PowerShell 5+
#>
[CmdletBinding()]
param(
[string]$Prefix = "v",
@@ -11,13 +23,228 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1"
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
if (-not (Test-Path $sharedScript)) {
throw "Shared release script not found: $sharedScript"
function Remove-GitNoise {
param([string[]]$Lines)
return @($Lines | Where-Object {
$_ -and ($_ -notmatch '^warning:\s+unable to access ')
})
}
& $sharedScript @PSBoundParameters
exit $LASTEXITCODE
function Invoke-Git {
param([Parameter(Mandatory = $true)][string]$Args)
Write-Host ">> git $Args" -ForegroundColor DarkGray
& cmd /c "git $Args 2>&1" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
}
function Get-GitOutput {
param([Parameter(Mandatory = $true)][string]$Args)
$output = & cmd /c "git $Args 2>&1"
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
return @(Remove-GitNoise -Lines @($output))
}
function Test-Repo {
$output = & cmd /c "git rev-parse --is-inside-work-tree 2>&1"
if ($LASTEXITCODE -ne 0) {
return $false
}
$clean = Remove-GitNoise -Lines @($output)
return (($clean | Select-Object -First 1) -eq "true")
}
function Get-CurrentBranch {
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
return $b
}
function Get-StatusPorcelain {
$lines = @(Get-GitOutput "status --porcelain")
return $lines
}
function Parse-SemVerTag {
param(
[string]$Tag,
[string]$TagPrefix
)
$escaped = [regex]::Escape($TagPrefix)
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
if (-not $m.Success) { return $null }
return [pscustomobject]@{
Raw = $Tag
Major = [int]$m.Groups[1].Value
Minor = [int]$m.Groups[2].Value
Patch = [int]$m.Groups[3].Value
}
}
function Get-NextTag {
param([string]$TagPrefix)
$tags = Get-GitOutput "tag --list"
$parsed = @()
foreach ($t in $tags) {
$t = $t.Trim()
if (-not $t) { continue }
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
if ($null -ne $obj) { $parsed += $obj }
}
if ($parsed.Count -eq 0) {
return "${TagPrefix}1.0.1"
}
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
}
function Assert-SkillReleasePackagingSources {
param([Parameter(Mandatory = $true)][string]$SkillRoot)
$scriptsDir = Join-Path $SkillRoot "scripts"
$mainPy = Join-Path $scriptsDir "main.py"
if (-not (Test-Path -LiteralPath $mainPy)) {
return
}
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($text)) {
return
}
$need = @()
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
foreach ($p in ($need | Select-Object -Unique)) {
$folder = Join-Path $scriptsDir $p
if (-not (Test-Path -LiteralPath $folder)) {
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
}
}
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
}
function Ensure-CleanOrAutoCommit {
param(
[switch]$DoAutoCommit,
[switch]$NeedClean,
[switch]$IsDryRun,
[string]$Msg
)
$status = @(Get-StatusPorcelain)
if ($status.Length -eq 0) { return }
if ($NeedClean) {
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
Remove-GitNoise -Lines @(& cmd /c "git status --short 2>&1") | ForEach-Object { Write-Host $_ }
throw "Abort: dirty working tree."
}
$commitMsg = $Msg
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
}
if (-not $DoAutoCommit) {
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
}
if ($IsDryRun) {
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
return
}
Invoke-Git "add -A"
Invoke-Git "commit -m `"$commitMsg`""
}
try {
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
if (-not (Test-Repo)) {
throw "Current directory is not a git repository."
}
$branch = Get-CurrentBranch
if ([string]::IsNullOrWhiteSpace($branch)) {
throw "Unable to determine current branch."
}
if ($branch -notin @("main", "master")) {
throw "Current branch is '$branch'. Release is only allowed from main/master."
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow
} else {
Invoke-Git "fetch --tags --prune origin"
}
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
}
$null = Remove-GitNoise -Lines @(& cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1')
$hasUpstream = ($LASTEXITCODE -eq 0)
if ($DryRun) {
if ($hasUpstream) {
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
} else {
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
}
} else {
if ($hasUpstream) {
Invoke-Git "push"
} else {
Invoke-Git "push -u origin $branch"
}
}
$nextTag = Get-NextTag -TagPrefix $Prefix
Write-Host "Next tag: $nextTag" -ForegroundColor Green
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
if ($existing.Length -gt 0) {
throw "Tag already exists: $nextTag"
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
exit 0
}
Invoke-Git "tag -a $nextTag -m `"$Message`""
Invoke-Git "push origin $nextTag"
Write-Host "Release success: $nextTag" -ForegroundColor Green
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
exit 0
}
catch {
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View File

@@ -1,7 +1 @@
# Skill-specific Python dependencies.
# Keep this file minimal.
# Platform-provided shared dependencies are installed by Jiangchang runtime.
# Add only dependencies required by this skill.
#
# Browser RPA: uses system Chrome/Edge; no `playwright install chromium` needed.
playwright>=1.42.0,<2
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。

View File

@@ -7,12 +7,14 @@ import sys
from typing import List, Optional
from service.task_service import (
cmd_config_path,
cmd_health,
cmd_log_get,
cmd_logs,
cmd_run,
cmd_version,
)
from util.config_bootstrap import bootstrap_skill_config
from util.argparse_zh import ZhArgumentParser
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger, setup_skill_logging
@@ -55,6 +57,7 @@ def _print_full_usage() -> None:
print(" python main.py logs [--limit N] [--status s] [--task-type t] [--target-id tid]")
print(" python main.py log-get <log_id>")
print(" python main.py health")
print(" python main.py config-path")
print(" python main.py version")
@@ -88,6 +91,9 @@ def build_parser() -> ZhArgumentParser:
sp = sub.add_parser("health", help="健康检查")
sp.set_defaults(handler=lambda _a: cmd_health())
sp = sub.add_parser("config-path", help="输出用户 .env 与模板路径")
sp.set_defaults(handler=lambda _a: cmd_config_path())
sp = sub.add_parser("version", help="版本信息(JSON)")
sp.set_defaults(handler=lambda _a: cmd_version())
return p
@@ -95,12 +101,15 @@ def build_parser() -> ZhArgumentParser:
def main(argv: Optional[List[str]] = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
bootstrap_skill_config()
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
get_skill_logger().info("cli_start argv=%s", sys.argv)
if not argv:
_print_full_usage()
return 1
if len(argv) == 2 and argv[0] not in {"run", "logs", "log-get", "health", "version", "-h", "--help"}:
if len(argv) == 2 and argv[0] not in {
"run", "logs", "log-get", "health", "config-path", "version", "-h", "--help"
}:
return cmd_run(target=argv[0], input_id=argv[1])
parser = build_parser()
args = parser.parse_args(argv)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import sqlite3
from db.display_metadata import init_display_metadata
from util.runtime_paths import get_db_path
@@ -31,6 +32,7 @@ def init_db() -> None:
)
"""
)
init_display_metadata(cur)
conn.commit()
finally:
conn.close()

View File

@@ -0,0 +1,164 @@
"""匠厂数据管理展示元数据_jiangchang_tables / _jiangchang_columns
SQLite 无原生表/字段 COMMENT中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。
字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。
"""
from __future__ import annotations
from typing import Iterable
# 宿主会隐藏这些表,不作为业务表展示(见 jiangchang electron/data-management/types.ts
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
TASK_LOGS_TABLE = "task_logs"
_JIANGCHANG_TABLES_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0
)
"""
_JIANGCHANG_COLUMNS_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_columns (
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
display_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
searchable INTEGER NOT NULL DEFAULT 1,
editable INTEGER NOT NULL DEFAULT 1,
display_type TEXT,
options_json TEXT,
PRIMARY KEY (table_name, column_name)
)
"""
TASK_LOGS_TABLE_METADATA = {
"table_name": TASK_LOGS_TABLE,
"display_name": "任务日志",
"description": "记录技能任务的执行状态和结果",
"visible": 1,
"readonly": 0,
}
TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "created_at", "display_name": "创建时间", "description": "记录创建时间", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "updated_at", "display_name": "更新时间", "description": "记录最后更新时间", "visible": 1, "searchable": 1, "editable": 0},
)
def init_display_metadata_tables(cursor) -> None:
cursor.execute(_JIANGCHANG_TABLES_DDL)
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
def upsert_table_metadata(cursor, *, table_name: str, display_name: str, description: str | None = None, visible: int = 1, readonly: int = 0) -> None:
cursor.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, description, visible, readonly)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
visible = excluded.visible,
readonly = excluded.readonly
""",
(table_name, display_name, description, visible, readonly),
)
def upsert_column_metadata(
cursor,
*,
table_name: str,
column_name: str,
display_name: str,
description: str | None = None,
visible: int = 1,
searchable: int = 1,
editable: int = 1,
) -> None:
# display_order 保留为 NULL宿主按 PRAGMA table_info cid 展示字段,不读 display_order。
cursor.execute(
"""
INSERT INTO _jiangchang_columns (
table_name, column_name, display_name, description,
display_order, visible, searchable, editable
)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?)
ON CONFLICT(table_name, column_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
display_order = NULL,
visible = excluded.visible,
searchable = excluded.searchable,
editable = excluded.editable
""",
(table_name, column_name, display_name, description, visible, searchable, editable),
)
def seed_task_logs_display_metadata(cursor) -> None:
meta = TASK_LOGS_TABLE_METADATA
upsert_table_metadata(
cursor,
table_name=str(meta["table_name"]),
display_name=str(meta["display_name"]),
description=str(meta["description"]) if meta.get("description") else None,
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
for column in TASK_LOGS_COLUMN_METADATA:
upsert_column_metadata(
cursor,
table_name=TASK_LOGS_TABLE,
column_name=str(column["column_name"]),
display_name=str(column["display_name"]),
description=str(column["description"]) if column.get("description") else None,
visible=int(column["visible"]),
searchable=int(column["searchable"]),
editable=int(column["editable"]),
)
def init_display_metadata(cursor) -> None:
init_display_metadata_tables(cursor)
seed_task_logs_display_metadata(cursor)
def iter_user_tables(cursor) -> Iterable[str]:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
for (name,) in cursor.fetchall():
if name in METADATA_TABLES:
continue
yield str(name)
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def list_physical_column_names(cursor, table_name: str) -> list[str]:
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
rows = cursor.fetchall()
rows.sort(key=lambda row: int(row[0]))
return [str(row[1]) for row in rows]

View File

@@ -0,0 +1,248 @@
"""数据管理展示元数据校验(模板开发自检,不依赖宿主)。"""
from __future__ import annotations
import os
import re
import sqlite3
from dataclasses import dataclass, field
from db.display_metadata import (
METADATA_TABLES,
TASK_LOGS_COLUMN_METADATA,
TASK_LOGS_TABLE,
iter_user_tables,
list_physical_column_names,
)
from util.constants import SKILL_SLUG
_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"})
_ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
re.compile(r"^my\s+skill$", re.I),
re.compile(r"^your[-_\s]?skill", re.I),
re.compile(r"^skill[-_\s]?template$", re.I),
re.compile(r"^[a-z0-9_-]+$"),
)
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
@dataclass
class ValidationReport:
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def _has_cjk(text: str) -> bool:
return bool(_CJK_RE.search(text))
def _looks_like_raw_physical_name(display_name: str, physical_name: str) -> bool:
if not display_name.strip():
return True
if display_name.strip().lower() == physical_name.strip().lower():
return True
compact = display_name.replace(" ", "").replace("_", "").lower()
if compact == physical_name.replace("_", "").lower():
return True
return False
def _parse_frontmatter_field(text: str, field_name: str) -> str:
parts = text.split("---", 2)
if len(parts) < 2:
return ""
for line in parts[1].splitlines():
match = re.match(rf"^{re.escape(field_name)}:\s*(.+)\s*$", line.strip())
if match:
return match.group(1).strip().strip("\"'")
return ""
def _parse_openclaw_slug(text: str) -> str:
parts = text.split("---", 2)
if len(parts) < 2:
return ""
lines = parts[1].splitlines()
for index, line in enumerate(lines):
if re.match(r"^\s+openclaw:\s*$", line):
for inner in lines[index + 1 :]:
if inner.strip() and inner[0] not in (" ", "\t"):
break
match = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner)
if match:
return match.group(2).strip().strip("\"'")
return ""
def parse_skill_frontmatter(skill_root: str) -> dict[str, str]:
path = os.path.join(skill_root, "SKILL.md")
with open(path, encoding="utf-8") as f:
text = f.read()
return {
"name": _parse_frontmatter_field(text, "name"),
"slug": _parse_openclaw_slug(text),
}
def validate_skill_frontmatter(skill_root: str, *, allow_template_placeholders: bool = True) -> ValidationReport:
report = ValidationReport()
data = parse_skill_frontmatter(skill_root)
name = str(data.get("name") or "").strip()
slug = str(data.get("slug") or "").strip()
if not name:
report.errors.append("SKILL.md frontmatter.name 缺失")
elif not _has_cjk(name):
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
report.warnings.append("SKILL.md frontmatter.name 建议使用清晰的中文业务名称(模板占位可暂保留)")
else:
report.errors.append("SKILL.md frontmatter.name 应使用用户可见的中文业务名称")
else:
for pattern in _ENGLISH_PLACEHOLDER_NAME_PATTERNS:
if pattern.fullmatch(name):
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
report.warnings.append(f"SKILL.md frontmatter.name 仍为占位风格:{name!r}")
else:
report.errors.append(f"SKILL.md frontmatter.name 不应为英文占位名:{name!r}")
break
if not slug:
report.errors.append("SKILL.md metadata.openclaw.slug 缺失")
elif not _KEBAB_SLUG.fullmatch(slug):
report.errors.append(f"metadata.openclaw.slug 必须为 kebab-case{slug!r}")
elif slug in _TEMPLATE_PLACEHOLDER_SLUGS and not allow_template_placeholders:
report.errors.append(f"metadata.openclaw.slug 仍为模板占位:{slug!r}")
constants_slug = SKILL_SLUG.strip()
if slug and constants_slug and slug != constants_slug:
report.warnings.append(
f"SKILL.md slug ({slug!r}) 与 util.constants.SKILL_SLUG ({constants_slug!r}) 不一致"
)
return report
def column_order_warnings(table_name: str, ordered_names: list[str]) -> list[str]:
warnings: list[str] = []
if "id" in ordered_names and ordered_names.index("id") != 0:
warnings.append(f"{table_name}: 字段 id 通常应定义在 CREATE TABLE 的第一列")
if "created_at" in ordered_names:
idx = ordered_names.index("created_at")
trailing = ordered_names[idx + 1 :]
if trailing and trailing != ["updated_at"]:
warnings.append(
f"{table_name}: created_at 之后通常只应跟 updated_at不应再放置其他业务字段"
)
if "updated_at" in ordered_names and "created_at" in ordered_names:
if ordered_names.index("updated_at") < ordered_names.index("created_at"):
warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前")
return warnings
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
(table_name,),
)
return cursor.fetchone() is not None
def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: bool = True) -> ValidationReport:
report = ValidationReport()
cur = conn.cursor()
has_tables_meta = _table_exists(cur, "_jiangchang_tables")
has_columns_meta = _table_exists(cur, "_jiangchang_columns")
if not has_tables_meta:
report.errors.append("缺少数据管理元数据表 _jiangchang_tables")
if not has_columns_meta:
report.errors.append("缺少数据管理元数据表 _jiangchang_columns")
if not has_tables_meta or not has_columns_meta:
return report
for table_name in iter_user_tables(cur):
cur.execute(
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cur.fetchone()
if not row or not str(row[0] or "").strip():
report.errors.append(f"业务表 {table_name!r} 缺少 _jiangchang_tables.display_name")
elif not _has_cjk(str(row[0])):
report.errors.append(f"业务表 {table_name!r} 的 display_name 应使用中文业务名称")
physical_columns = list_physical_column_names(cur, table_name)
if check_column_order:
report.warnings.extend(column_order_warnings(table_name, physical_columns))
for column_name in physical_columns:
cur.execute(
"""
SELECT display_name, visible
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
col_row = cur.fetchone()
if not col_row:
report.errors.append(
f"字段 {table_name}.{column_name} 缺少 _jiangchang_columns 元数据"
)
continue
display_name = str(col_row[0] or "").strip()
visible = col_row[1] is None or int(col_row[1]) != 0
if visible and not display_name:
report.errors.append(f"字段 {table_name}.{column_name} 的 display_name 为空")
elif visible and _looks_like_raw_physical_name(display_name, column_name):
report.errors.append(
f"字段 {table_name}.{column_name} 的 display_name 不应直接等于英文物理名:{display_name!r}"
)
elif visible and not _has_cjk(display_name):
report.errors.append(
f"字段 {table_name}.{column_name} 的 display_name 应使用中文:{display_name!r}"
)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES:
continue
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
(table_name,),
)
if not cur.fetchone():
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
continue
physical = list_physical_column_names(cur, str(table_name))
if str(column_name) not in physical:
report.errors.append(f"元数据引用了不存在的字段:{table_name}.{column_name}")
cur.execute("SELECT table_name FROM _jiangchang_tables")
for (table_name,) in cur.fetchall():
if table_name in METADATA_TABLES:
continue
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
(table_name,),
)
if not cur.fetchone():
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
return report
def validate_template_database(conn: sqlite3.Connection) -> ValidationReport:
report = validate_display_metadata(conn)
physical = list_physical_column_names(conn.cursor(), TASK_LOGS_TABLE)
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
if physical != expected:
report.errors.append(
f"task_logs 物理字段顺序应为 {expected},当前为 {physical}"
)
return report

View File

@@ -1,16 +0,0 @@
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging + config + rpa in sync.
from .config import ensure_env_file, get, get_bool, get_float, get_int
try:
from . import rpa
except ImportError:
rpa = None # type: ignore[assignment,misc]
__all__ = [
"ensure_env_file",
"get",
"get_bool",
"get_float",
"get_int",
"rpa",
]

View File

@@ -1,126 +0,0 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""三级优先级配置读取 + 首次 .env 落盘(进程 env > 数据目录 .env > .env.example"""
from __future__ import annotations
import os
import shutil
from typing import Any
from .runtime_env import get_data_root, get_user_id
_skill_slug: str | None = None
_example_path: str | None = None
_env_file_path: str | None = None
_user_env_cache: dict[str, str] | None = None
_example_defaults_cache: dict[str, str] | None = None
def _parse_env_file(path: str) -> dict[str, str]:
"""标准库手写 .env 解析(不引 python-dotenv"""
result: dict[str, str] = {}
if not path or not os.path.isfile(path):
return result
with open(path, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if not key:
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
elif " #" in value:
value = value.split(" #", 1)[0].strip()
result[key] = value
return result
def _get_user_env() -> dict[str, str]:
global _user_env_cache
if _user_env_cache is not None:
return _user_env_cache
if _env_file_path and os.path.isfile(_env_file_path):
_user_env_cache = _parse_env_file(_env_file_path)
else:
_user_env_cache = {}
return _user_env_cache
def _get_example_defaults() -> dict[str, str]:
global _example_defaults_cache
if _example_defaults_cache is not None:
return _example_defaults_cache
if _example_path and os.path.isfile(_example_path):
_example_defaults_cache = _parse_env_file(_example_path)
else:
_example_defaults_cache = {}
return _example_defaults_cache
def reset_cache() -> None:
"""测试用:清空解析缓存。"""
global _user_env_cache, _example_defaults_cache
_user_env_cache = None
_example_defaults_cache = None
def ensure_env_file(skill_slug: str, example_path: str) -> str:
"""首次把 .env.example copy 到 {data_root}/{user}/{slug}/.env已存在不覆盖返回路径。"""
global _skill_slug, _example_path, _env_file_path
_skill_slug = skill_slug
_example_path = os.path.abspath(example_path)
dest_dir = os.path.join(get_data_root(), get_user_id(), skill_slug)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, ".env")
_env_file_path = dest
if not os.path.isfile(dest) and os.path.isfile(_example_path):
shutil.copy2(_example_path, dest)
reset_cache()
return dest
def get(key: str, default: Any = None) -> str | None:
"""进程环境变量 > 数据目录 .env > .env.example 默认值。"""
env_val = os.environ.get(key)
if env_val is not None and env_val != "":
return env_val
user_val = _get_user_env().get(key)
if user_val is not None and user_val != "":
return user_val
example_val = _get_example_defaults().get(key)
if example_val is not None and example_val != "":
return example_val
return default
def get_bool(key: str, default: bool = False) -> bool:
val = get(key)
if val is None:
return default
return str(val).strip().lower() in ("1", "true", "yes", "on")
def get_float(key: str, default: float) -> float:
val = get(key)
if val is None:
return default
try:
return float(val)
except (TypeError, ValueError):
return default
def get_int(key: str, default: int) -> int:
val = get(key)
if val is None:
return default
try:
return int(val)
except (TypeError, ValueError):
return default

View File

@@ -1,66 +0,0 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""浏览器 RPA 共享库stealth、拟人操作、启动封装、HITL 等待、失败存证。"""
from __future__ import annotations
from . import anti_detect, artifacts, errors, human_login, stealth
try:
from .browser import launch_persistent_browser
except ImportError:
launch_persistent_browser = None # type: ignore[misc, assignment]
try:
from .video_session import RpaVideoSession
except ImportError:
RpaVideoSession = None # type: ignore[misc, assignment]
__all__ = [
"anti_detect",
"artifacts",
"errors",
"human_login",
"stealth",
"launch_persistent_browser",
"RpaVideoSession",
# re-export common anti_detect helpers
"random_delay",
"human_delay_short",
"human_delay_page",
"human_delay_batch",
"human_mouse_move",
"human_mouse_wiggle",
"human_click",
"human_type",
"human_scroll",
# human_login
"wait_for_captcha_pass",
"wait_for_login",
"DEFAULT_CAPTCHA_MARKERS",
# artifacts
"artifact_path",
"capture_failure",
# stealth
"STEALTH_INIT_SCRIPT",
"stealth_enabled",
"persistent_context_launch_parts",
]
from .anti_detect import (
human_click,
human_delay_batch,
human_delay_page,
human_delay_short,
human_mouse_move,
human_mouse_wiggle,
human_scroll,
human_type,
random_delay,
)
from .artifacts import artifact_path, capture_failure
from .human_login import DEFAULT_CAPTCHA_MARKERS, wait_for_captcha_pass, wait_for_login
from .stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)

View File

@@ -1,216 +0,0 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""防反爬:随机延迟、人工鼠标轨迹模拟、拟人滚动。
所有延迟使用 random.uniform() + asyncio.sleep()。
鼠标移动使用三次贝塞尔曲线 + 随机抖动,避免机械直线轨迹。
"""
from __future__ import annotations
import asyncio
import math
import random
from typing import Optional
# ── 随机延迟 ────────────────────────────────────────────────
async def random_delay(min_s: float, max_s: float) -> float:
"""随机延迟 min_s ~ max_s 秒,返回实际延迟秒数。"""
delay = random.uniform(min_s, max_s)
await asyncio.sleep(delay)
return delay
async def human_delay_short() -> float:
"""页面内操作间短延迟 0.5~2 秒(点击/滚动)。"""
return await random_delay(0.5, 2.0)
async def human_delay_page(min_s: float = 2, max_s: float = 8) -> float:
"""页面间/店铺间切换延迟(可配置范围)。"""
return await random_delay(min_s, max_s)
async def human_delay_batch(min_s: float = 5, max_s: float = 15) -> float:
"""批次间/翻页间长延迟。"""
return await random_delay(min_s, max_s)
# ── 鼠标轨迹 ────────────────────────────────────────────────
async def human_mouse_move(
page,
target_x: float,
target_y: float,
steps: Optional[int] = None,
) -> None:
"""模拟人工鼠标移动到目标坐标。
使用三次贝塞尔曲线生成 S/C 形路径,中间控制点随机偏移,
每步添加微小随机抖动,避免完美直线移动。
Args:
page: Playwright Page 对象。
target_x: 目标 X 坐标。
target_y: 目标 Y 坐标。
steps: 移动步数None 时按距离自动计算)。
"""
if steps is None:
dist = math.hypot(target_x, target_y)
steps = max(8, int(dist / 15))
# 随机起始点(模拟鼠标从页面其他位置移动过来)
start_x = target_x + random.uniform(-300, 300)
start_y = target_y + random.uniform(-200, 200)
# 两个贝塞尔控制点(制造弯曲路径)
dx = target_x - start_x
dy = target_y - start_y
cp1_x = start_x + dx * random.uniform(0.2, 0.5) + random.uniform(-120, 120)
cp1_y = start_y + dy * random.uniform(0.1, 0.4) + random.uniform(-100, 100)
cp2_x = start_x + dx * random.uniform(0.5, 0.8) + random.uniform(-100, 100)
cp2_y = start_y + dy * random.uniform(0.4, 0.7) + random.uniform(-80, 80)
for i in range(steps + 1):
t = i / steps
# 三次贝塞尔: B(t) = (1-t)³·P0 + 3(1-t)²·t·P1 + 3(1-t)·t²·P2 + t³·P3
u = 1 - t
x = (
(u ** 3) * start_x
+ 3 * (u ** 2) * t * cp1_x
+ 3 * u * (t ** 2) * cp2_x
+ (t ** 3) * target_x
)
y = (
(u ** 3) * start_y
+ 3 * (u ** 2) * t * cp1_y
+ 3 * u * (t ** 2) * cp2_y
+ (t ** 3) * target_y
)
# 微小随机抖动
x += random.uniform(-1.5, 1.5)
y += random.uniform(-1.5, 1.5)
await page.mouse.move(x, y)
await asyncio.sleep(random.uniform(0.003, 0.015))
# 最终精确到位
await page.mouse.move(target_x, target_y)
async def human_click(
page,
selector: Optional[str] = None,
*,
x: Optional[float] = None,
y: Optional[float] = None,
) -> None:
"""拟人点击:贝塞尔轨迹移动 → 微延迟 → 点击。
支持两种定位方式:
- selector: CSS 选择器,自动取元素中心附近随机偏移
- x, y: 直接指定坐标(自动加微小随机偏移)
Args:
page: Playwright Page 对象。
selector: CSS 选择器(与 x/y 二选一)。
x: 目标 X 坐标。
y: 目标 Y 坐标。
"""
if selector:
locator = page.locator(selector).first
box = await locator.bounding_box()
if box is None:
raise ValueError(f"无法获取元素边界: {selector}")
target_x = box["x"] + box["width"] * random.uniform(0.3, 0.7)
target_y = box["y"] + box["height"] * random.uniform(0.3, 0.7)
elif x is not None and y is not None:
target_x = x + random.uniform(-3, 3)
target_y = y + random.uniform(-3, 3)
else:
raise ValueError("必须提供 selector 或 (x, y) 坐标")
await human_mouse_move(page, target_x, target_y)
await human_delay_short()
await page.mouse.click(target_x, target_y)
# ── 滚动模拟 ────────────────────────────────────────────────
async def human_scroll(
page, direction: str = "down", amount: Optional[int] = None
) -> None:
"""模拟人工滚动(分段 + 变速 + 微延迟)。
Args:
page: Playwright Page 对象。
direction: "up""down"
amount: 滚动像素总量,默认随机 300~800。
"""
if amount is None:
amount = random.randint(300, 800)
sign = -1 if direction == "up" else 1
remaining = amount
step_count = random.randint(3, 7)
for i in range(step_count):
step = remaining // (step_count - i) + random.randint(-30, 30)
step = max(10, min(step, remaining))
remaining -= step
await page.mouse.wheel(0, sign * step)
await asyncio.sleep(random.uniform(0.05, 0.2))
if remaining > 0:
await page.mouse.wheel(0, sign * remaining)
await human_delay_short()
# ── 进场鼠标随机晃动 ────────────────────────────────────────
async def human_mouse_wiggle(page) -> None:
"""页面进入后随机移动鼠标 2~4 次(贝塞尔轨迹),模拟真人到场环顾。"""
try:
vp = page.viewport_size or {"width": 1280, "height": 800}
except Exception:
vp = {"width": 1280, "height": 800}
w, h = vp.get("width", 1280), vp.get("height", 800)
for _ in range(random.randint(2, 4)):
tx = random.uniform(w * 0.2, w * 0.8)
ty = random.uniform(h * 0.2, h * 0.7)
await human_mouse_move(page, tx, ty)
await asyncio.sleep(random.uniform(0.2, 0.8))
# ── 拟人逐字输入 ────────────────────────────────────────────
async def human_type(page, locator, text: str) -> None:
"""真人式输入:贝塞尔移动到输入框 → 真实点击聚焦 → 逐字符 keyboard.type。
全程使用 isTrusted=true 的可信事件,绝不用 JS 设置 value。
"""
box = await locator.bounding_box()
if box is None:
raise ValueError("human_type: 无法获取输入框边界")
tx = box["x"] + box["width"] * random.uniform(0.3, 0.7)
ty = box["y"] + box["height"] * random.uniform(0.3, 0.7)
await human_mouse_move(page, tx, ty)
await asyncio.sleep(random.uniform(0.15, 0.5))
await page.mouse.click(tx, ty)
await asyncio.sleep(random.uniform(0.2, 0.6))
# 清空已有内容(用键盘,不用 JS
await page.keyboard.press("Control+A")
await page.keyboard.press("Delete")
await asyncio.sleep(random.uniform(0.2, 0.5))
for ch in text:
await page.keyboard.type(ch, delay=random.uniform(90, 240))

View File

@@ -1,29 +0,0 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""RPA 失败存证路径与截图。"""
from __future__ import annotations
import os
from datetime import datetime
def _artifacts_enabled() -> bool:
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower()
return v not in ("0", "false", "no", "off")
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:
"""返回 {data_dir}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.{ext}"""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
directory = os.path.join(data_dir, "rpa-artifacts", batch_id)
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, f"{tag}_{ts}.{ext}")
async def capture_failure(page, data_dir: str, batch_id: str, tag: str) -> str:
"""受 OPENCLAW_ARTIFACTS_ON_FAILURE 控制,截图并返回路径;禁用时返回空字符串。"""
if not _artifacts_enabled():
return ""
path = artifact_path(data_dir, batch_id, tag, ext="png")
await page.screenshot(path=path, full_page=True)
return path

View File

@@ -1,58 +0,0 @@
"""统一 persistent context 启动封装(仅浏览器自动化,不负责录屏)。"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from ..runtime_env import find_chrome_executable
from .stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
if TYPE_CHECKING:
from playwright.async_api import BrowserContext
async def launch_persistent_browser(
playwright,
*,
profile_dir: str,
channel: str = "chrome",
executable_path: str | None = None,
headless: bool | None = None,
extra_args: list[str] | None = None,
) -> "BrowserContext":
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
录屏由 RpaVideoSessionffmpeg负责本函数不向 Playwright 传递任何录屏参数。
"""
if headless is None:
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
headless = v in ("1", "true", "yes", "on")
chrome = executable_path or find_chrome_executable()
if not chrome:
raise RuntimeError(
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
)
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
launch_kwargs: dict = dict(
user_data_dir=profile_dir,
headless=headless,
executable_path=chrome,
locale="zh-CN",
no_viewport=True,
args=args,
)
if ignore is not None:
launch_kwargs["ignore_default_args"] = ignore
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
if stealth_enabled():
await context.add_init_script(STEALTH_INIT_SCRIPT)
return context

View File

@@ -1,20 +0,0 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""RPA 场景统一错误码stdout / 异常消息前缀)。"""
ERR_REQUIRE_LOGIN = "ERROR:REQUIRE_LOGIN"
ERR_LOGIN_TIMEOUT = "ERROR:LOGIN_TIMEOUT"
ERR_CAPTCHA_NEED_HUMAN = "ERROR:CAPTCHA_NEED_HUMAN"
ERR_RATE_LIMITED = "ERROR:RATE_LIMITED"
ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
__all__ = [
"ERR_REQUIRE_LOGIN",
"ERR_LOGIN_TIMEOUT",
"ERR_CAPTCHA_NEED_HUMAN",
"ERR_RATE_LIMITED",
"ERR_MISSING_BROWSER",
"ERR_DEVICE_NOT_READY",
"ERR_WINDOW_NOT_FOUND",
]

View File

@@ -1,97 +0,0 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""验证码/登录 HITL 等待(站点无关通用部分)。"""
from __future__ import annotations
import asyncio
DEFAULT_CAPTCHA_MARKERS = (
"punish",
"baxia",
"nc_login",
"tmd",
"x5sec",
"安全验证",
"请按住滑块",
"拖动到最右边",
)
async def wait_for_captcha_pass(
page,
captcha_markers: tuple[str, ...] = DEFAULT_CAPTCHA_MARKERS,
timeout_sec: int = 120,
) -> bool:
"""
检测到验证码/拦截页时,暂停等待用户手工完成验证。
每 3 秒轮询一次页面状态,直到页面不再是拦截状态或超时。
Returns:
True — 验证码已通过,可以继续
False — 超时,用户未在规定时间内完成验证
"""
print(
f"[验证码] 检测到安全拦截,请在浏览器中手工完成验证。"
f"等待最多 {timeout_sec} 秒..."
)
elapsed = 0
while elapsed < timeout_sec:
await asyncio.sleep(3)
elapsed += 3
try:
url = page.url or ""
text = ""
try:
text = await page.locator("body").inner_text(timeout=2_000)
except Exception:
pass
haystack = (url + " " + text[:1000]).lower()
still_blocked = any(m.lower() in haystack for m in captcha_markers)
if not still_blocked:
print(f"[验证码] 已通过!({elapsed}s 后检测到正常页面)")
return True
except Exception:
pass
print(f"[验证码] 超时 {timeout_sec}s用户未完成验证")
return False
async def wait_for_login(
page,
success_selectors: list[str],
timeout_sec: int = 180,
) -> bool:
"""
轮询等待登录成功标志出现(站点专属选择器由调用方传入)。
Args:
page: Playwright Page 对象。
success_selectors: 登录成功后应出现的 CSS 选择器列表。
timeout_sec: 最长等待秒数。
Returns:
True — 检测到登录成功标志
False — 超时
"""
print(
f"[登录] 请在浏览器中完成登录。等待最多 {timeout_sec} 秒,"
"期间程序不会操作页面..."
)
elapsed = 0
while elapsed < timeout_sec:
await asyncio.sleep(2)
elapsed += 2
for selector in success_selectors:
try:
el = await page.query_selector(selector)
if el is not None:
print("[登录] 检测到登录成功!")
return True
except Exception:
pass
print(f"[登录] 超时 {timeout_sec}s未完成登录")
return False

View File

@@ -1,67 +0,0 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""淡化 Playwright 自动化指纹,降低 baxia/x5sec 风控命中率。
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0或 false/off/no
"""
from __future__ import annotations
import os
STEALTH_INIT_SCRIPT = """
(() => {
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
try {
window.chrome = window.chrome || {};
window.chrome.runtime = window.chrome.runtime || {};
} catch (e) {}
try {
const orig = navigator.permissions && navigator.permissions.query;
if (orig) {
navigator.permissions.query = (p) =>
p && p.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: orig(p);
}
} catch (e) {}
try {
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
} catch (e) {}
try {
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
} catch (e) {}
try {
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
} catch (e) {}
try {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
} catch (e) {}
try {
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function (p) {
if (p === 37445) return 'Intel Inc.';
if (p === 37446) return 'Intel Iris OpenGL Engine';
return getParameter.call(this, p);
};
} catch (e) {}
})();
"""
def stealth_enabled() -> bool:
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
return v not in ("0", "false", "no", "off")
def persistent_context_launch_parts(
*, extra_args: list[str] | None = None
) -> tuple[list[str], list[str] | None]:
"""返回 (args, ignore_default_args)。ignore_default_args 为 None 时不要传给 launch。"""
args = ["--start-maximized"]
if extra_args:
args = args + list(extra_args)
if not stealth_enabled():
return args, None
if "--disable-blink-features=AutomationControlled" not in args:
args.append("--disable-blink-features=AutomationControlled")
return args, ["--enable-automation"]

View File

@@ -1,461 +0,0 @@
"""RPA 运行录屏会话ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。"""
from __future__ import annotations
import asyncio
import os
import random
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, IO, List, Optional
from .. import config
_JIANGCHANG_SUBTITLE_STYLE = (
"FontName=Arial,FontSize=14,"
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
"Outline=2,Shadow=1,Alignment=2"
)
_DEFAULT_MUSIC_ROOT = os.environ.get(
"MEDIA_ASSETS_ROOT",
r"D:\OpenClaw\client-commons\media-assets",
)
_CAPTURE_FRAMERATE = 15
def _record_video_enabled() -> bool:
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
def _resolve_music_file() -> Optional[Path]:
music_dir = Path(_DEFAULT_MUSIC_ROOT) / "music"
if not music_dir.is_dir():
return None
files = list(music_dir.rglob("*.mp3"))
if not files:
return None
return random.choice(files)
@dataclass
class _StepEntry:
start: float
text: str
duration: float = 4.0
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
MIN_DURATION = 1.0
GAP = 0.1
steps = sorted(entries, key=lambda e: e.start)
if not steps:
steps = [_StepEntry(0.0, "任务执行中", 4.0)]
for i in range(len(steps) - 1):
cur, nxt = steps[i], steps[i + 1]
max_end = nxt.start - GAP
new_dur = max_end - cur.start
if new_dur < MIN_DURATION:
cur.duration = MIN_DURATION
nxt.start = cur.start + MIN_DURATION + GAP
else:
cur.duration = min(cur.duration, new_dur)
def _fmt(s: float) -> str:
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = int(s % 60)
ms = int((s % 1) * 1000)
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
for i, e in enumerate(steps, 1):
f.write(f"{i}\n")
f.write(f"{_fmt(e.start)} --> {_fmt(e.start + e.duration)}\n")
f.write(f"{e.text}\n\n")
def _escape_srt_path(path: str) -> str:
return path.replace("\\", "/").replace(":", "\\:")
def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError:
return False, "ffmpeg not found"
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
return False, err[:500] if err else f"exit {result.returncode}"
return True, ""
def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
if sys.platform != "win32":
return None
return [
"ffmpeg",
"-y",
"-f",
"gdigrab",
"-framerate",
str(_CAPTURE_FRAMERATE),
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
capture_path,
]
def _compose_capture_mp4(
capture_path: Path,
srt_path: Path,
output_path: Path,
*,
music_file: Optional[Path] = None,
music_volume: float = 0.15,
) -> tuple[bool, str]:
output_path.parent.mkdir(parents=True, exist_ok=True)
srt_esc = _escape_srt_path(str(srt_path.resolve()))
subtitle_filter = (
f"subtitles='{srt_esc}'"
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
)
if music_file and music_file.is_file():
filter_complex = (
f"[0:v]{subtitle_filter}[vout];"
f"[1:a]volume={music_volume},apad[aout]"
)
cmd = [
"ffmpeg",
"-y",
"-i",
str(capture_path),
"-i",
str(music_file),
"-filter_complex",
filter_complex,
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-pix_fmt",
"yuv420p",
str(output_path),
]
else:
cmd = [
"ffmpeg",
"-y",
"-i",
str(capture_path),
"-vf",
subtitle_filter,
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-pix_fmt",
"yuv420p",
str(output_path),
]
return _run_ffmpeg(cmd)
class RpaVideoSession:
"""Service/RPA 层统一 ffmpeg 桌面录屏OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
def __init__(
self,
*,
skill_slug: str,
skill_data_dir: str,
batch_id: str,
title: str = "",
) -> None:
self.skill_slug = skill_slug
self.skill_data_dir = os.path.abspath(skill_data_dir)
self.batch_id = batch_id
self.title = title
self.enabled = _record_video_enabled()
self.warnings: List[str] = []
self.output_video_path: Optional[str] = None
self.capture_path: Optional[str] = None
self.artifacts: Dict[str, Any] = {}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_batch = "".join(c if c.isalnum() or c in "-_" else "_" for c in batch_id)
self._video_stem = f"{skill_slug}_{ts}_{safe_batch}"
self._videos_dir = os.path.join(self.skill_data_dir, "videos")
self._artifact_root = os.path.join(
self.skill_data_dir, "rpa-artifacts", batch_id
)
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
self._logs_dir = os.path.join(self._artifact_root, "logs")
self._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log")
self._capture_path = os.path.join(self._artifact_root, "capture.mp4")
self._srt_path = os.path.join(self._subtitles_dir, f"{self._video_stem}.srt")
self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
self._steps: List[_StepEntry] = []
self._t0: Optional[float] = None
self._ffmpeg_proc: Optional[subprocess.Popen] = None
self._ffmpeg_record_log_file: Optional[IO[str]] = None
if self.enabled:
os.makedirs(self._videos_dir, exist_ok=True)
os.makedirs(self._subtitles_dir, exist_ok=True)
os.makedirs(self._logs_dir, exist_ok=True)
os.makedirs(self._artifact_root, exist_ok=True)
def _record_log_hint(self) -> str:
if self.enabled and self._ffmpeg_record_log_path:
return f"; record_log={self._ffmpeg_record_log_path}"
return ""
def _append_warning(self, code: str, detail: str = "") -> None:
msg = code if not detail else f"{code}: {detail}"
if code in (
"ffmpeg_record_start_failed",
"ffmpeg_capture_missing",
"ffmpeg_compose_failed",
):
msg += self._record_log_hint()
self.warnings.append(msg)
def step(self, text: str, *, duration: float = 4.0) -> None:
self.add_step(text, duration=duration)
def add_step(self, text: str, *, duration: float = 4.0) -> None:
"""记录一步骤字幕(相对录屏开始时间)。"""
if not self.enabled:
return
if self._t0 is None:
self._t0 = time.monotonic()
elapsed = time.monotonic() - self._t0
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
def _close_ffmpeg_record_log(self) -> None:
fh = self._ffmpeg_record_log_file
self._ffmpeg_record_log_file = None
if fh is None:
return
try:
fh.close()
except Exception:
pass
def _start_ffmpeg_capture(self) -> None:
if not self.enabled:
return
if not shutil.which("ffmpeg"):
self.warnings.append("ffmpeg_not_found")
return
cmd = _build_desktop_record_cmd(self._capture_path)
if cmd is None:
self._append_warning(
"ffmpeg_record_start_failed",
"desktop capture only supported on Windows",
)
return
stderr_target: Any = subprocess.DEVNULL
try:
os.makedirs(self._logs_dir, exist_ok=True)
self._ffmpeg_record_log_file = open(
self._ffmpeg_record_log_path, "a", encoding="utf-8"
)
stderr_target = self._ffmpeg_record_log_file
except Exception as exc:
self.warnings.append(f"ffmpeg_record_log_open_failed: {exc}")
try:
self._ffmpeg_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=stderr_target,
)
self.capture_path = self._capture_path
except Exception as exc:
self._append_warning("ffmpeg_record_start_failed", str(exc))
self._ffmpeg_proc = None
self._close_ffmpeg_record_log()
def _stop_ffmpeg_capture(self) -> None:
proc = self._ffmpeg_proc
if proc is None:
self._close_ffmpeg_record_log()
return
if proc.poll() is None:
try:
if proc.stdin:
proc.stdin.write(b"q")
proc.stdin.flush()
except Exception:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=20)
except subprocess.TimeoutExpired:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
pass
self._ffmpeg_proc = None
self._close_ffmpeg_record_log()
time.sleep(0.3)
async def __aenter__(self) -> "RpaVideoSession":
if self.enabled:
self._t0 = time.monotonic()
if self.title:
self.add_step(self.title, duration=3.0)
self._start_ffmpeg_capture()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if self.enabled:
self._stop_ffmpeg_capture()
await self.finalize()
async def finalize(self) -> None:
"""停止录制后合成最终 MP4失败只记 warning不抛异常。"""
if not self.enabled:
self.artifacts = self.summary()
return
if not self._steps and self.title:
self.add_step(self.title)
try:
capture = Path(self._capture_path)
if not capture.is_file() or capture.stat().st_size <= 0:
self._append_warning("ffmpeg_capture_missing")
self.artifacts = self.summary()
return
self.capture_path = str(capture.resolve())
entries = list(self._steps)
if entries:
entries[-1].duration = max(entries[-1].duration, 5.0)
try:
_generate_srt(entries, self._srt_path)
except Exception as exc:
self.warnings.append(f"subtitle_write_failed: {exc}")
music = _resolve_music_file()
if music is None:
self.warnings.append("background_music_missing")
srt_file = Path(self._srt_path)
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
ok, err = _compose_capture_mp4(
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output),
music_file=music,
)
if not ok and music is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
ok, err = _compose_capture_mp4(
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output),
music_file=None,
)
if not ok and not use_srt:
ok, err = _run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(capture),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
str(self._planned_output),
]
)
if not ok:
self._append_warning("ffmpeg_compose_failed", err)
self.artifacts = self.summary()
return
self.output_video_path = str(Path(self._planned_output).resolve())
except Exception as exc:
self.warnings.append(f"video_finalize_error: {exc}")
self.artifacts = self.summary()
def summary(self) -> Dict[str, Any]:
record_log = (
os.path.abspath(self._ffmpeg_record_log_path)
if self.enabled and self._ffmpeg_record_log_path
else None
)
return {
"enabled": self.enabled,
"path": self.output_video_path,
"capture_path": self.capture_path,
"record_log_path": record_log,
"warnings": list(self.warnings),
}
def to_artifact_dict(self) -> Dict[str, Any]:
data = self.summary()
data["batch_id"] = self.batch_id
return data

View File

@@ -1,109 +0,0 @@
"""
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
模板复制后通常无需大改;如组织环境不同,再按项目实际调整。
"""
from __future__ import annotations
import os
import sys
CLI_LOCAL_DEV_ENABLED = True
DEFAULT_LOCAL_USER_ID = "10032"
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
def platform_default_data_root() -> str:
if sys.platform == "win32":
return WIN_DEFAULT_DATA_ROOT
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
def get_data_root() -> str:
env = (
os.getenv("CLAW_DATA_ROOT")
or os.getenv("JIANGCHANG_DATA_ROOT")
or ""
).strip()
if env:
return env
return platform_default_data_root()
def get_user_id() -> str:
return (
os.getenv("CLAW_USER_ID")
or os.getenv("JIANGCHANG_USER_ID")
or ""
).strip() or "_anon"
def _looks_like_skills_root(path: str) -> bool:
if not path or not os.path.isdir(path):
return False
# 通过常见基础技能存在与否,判断这是不是 skills 根目录
# 这里列举的是平台基础设施类技能,不包含具体业务技能
for marker in (
"llm-manager",
"skill-template",
):
if os.path.isdir(os.path.join(path, marker)):
return True
return False
def get_skills_root() -> str:
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
v = (os.getenv(key) or "").strip()
if v:
return os.path.normpath(v)
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
if sys.platform == "win32" and not app:
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
if app:
nested = os.path.join(app, "skills")
if _looks_like_skills_root(nested):
return os.path.normpath(nested)
if _looks_like_skills_root(app):
return os.path.normpath(app)
if sys.platform == "win32":
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
if _looks_like_skills_root(nested):
return os.path.normpath(nested)
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT)
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
if skill_scripts_dir:
scripts = os.path.abspath(skill_scripts_dir)
skill_root = os.path.dirname(scripts)
inferred = os.path.dirname(skill_root)
if _looks_like_skills_root(inferred):
return os.path.normpath(inferred)
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
v = (os.getenv(key) or "").strip()
if v:
return os.path.normpath(v)
return get_skills_root()
def apply_cli_local_defaults() -> None:
enabled = CLI_LOCAL_DEV_ENABLED
if not enabled:
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
enabled = v in ("1", "true", "yes", "on")
if not enabled:
return
if not (os.getenv("CLAW_DATA_ROOT") or os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
if not (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip():
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()

View File

@@ -1,213 +0,0 @@
"""
统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
按日轮转;行内带 trace_id 与 skill_slug便于跨技能排查。
"""
from __future__ import annotations
import logging
import os
import sys
import uuid
from logging.handlers import TimedRotatingFileHandler
from typing import Optional, Tuple
from .runtime_env import get_data_root, get_user_id
_skill_slug: str = ""
_logger_name: str = ""
# 避免 logging 内部错误再向 stderr 打印 "--- Logging error ---"
logging.raiseExceptions = False
def get_unified_logs_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), "logs")
try:
os.makedirs(path, exist_ok=True)
except OSError:
pass
return path
def get_skill_log_file_path() -> str:
"""与 setup 写入的主日志文件一致(供终端提示等)。"""
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
if override:
parent = os.path.dirname(os.path.abspath(override))
if parent:
try:
os.makedirs(parent, exist_ok=True)
except OSError:
pass
return os.path.abspath(override)
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
def _can_open_log_file(log_path: str) -> Tuple[bool, str]:
"""探测日志文件是否可追加写入(真实 open非 os.access"""
try:
parent = os.path.dirname(os.path.abspath(log_path))
if parent:
os.makedirs(parent, exist_ok=True)
with open(log_path, "a", encoding="utf-8"):
pass
return True, ""
except Exception as exc:
return False, str(exc)
def ensure_trace_for_process() -> str:
"""本进程调用链 ID沿用 JIANGCHANG_TRACE_ID否则生成并写入环境子进程可继承"""
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if existing:
return existing
tid = uuid.uuid4().hex[:12]
os.environ["JIANGCHANG_TRACE_ID"] = tid
return tid
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
"""subprocess.run(..., env=...) 时使用,保证带上当前 trace。"""
ensure_trace_for_process()
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if not tid:
tid = ensure_trace_for_process()
base = os.environ if environ is None else environ
return {**base, "JIANGCHANG_TRACE_ID": tid}
class _SkillContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-"
record.skill_slug = _skill_slug or "-"
return True
_FORMAT = (
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
)
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
def _log_level_from_env() -> int:
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
return getattr(logging, v, None) or logging.INFO
def _backup_count() -> int:
try:
n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip())
return max(1, min(n, 365))
except ValueError:
return 30
def _clear_logger_handlers(log: logging.Logger) -> None:
for h in list(log.handlers):
log.removeHandler(h)
try:
h.close()
except Exception:
pass
def _attach_fallback_handler(log: logging.Logger) -> None:
"""文件日志不可用时仅挂 NullHandler不向 stderr 打印 logging 异常。"""
log.addHandler(logging.NullHandler())
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
"""
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
须在进程早期调用(如 CLI main 在业务日志之前)。
文件日志不可写时降级为 NullHandler不影响 CLI 返回码。
"""
global _skill_slug, _logger_name
logging.raiseExceptions = False
ensure_trace_for_process()
_skill_slug = skill_slug
_logger_name = logger_name
log = logging.getLogger(logger_name)
_clear_logger_handlers(log)
log.setLevel(_log_level_from_env())
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
filt = _SkillContextFilter()
log_path = get_skill_log_file_path()
ok, _err = _can_open_log_file(log_path)
if ok:
try:
fh = TimedRotatingFileHandler(
log_path,
when="midnight",
interval=1,
backupCount=_backup_count(),
encoding="utf-8",
delay=False,
)
fh.setFormatter(fmt)
fh.addFilter(filt)
log.addHandler(fh)
except Exception:
_attach_fallback_handler(log)
else:
_attach_fallback_handler(log)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
try:
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(logging.WARNING)
sh.setFormatter(fmt)
sh.addFilter(filt)
log.addHandler(sh)
except Exception:
pass
log.propagate = False
def get_skill_logger() -> logging.Logger:
if not _logger_name:
raise RuntimeError("get_skill_logger: call setup_skill_logging first")
return logging.getLogger(_logger_name)
def attach_unified_file_handler(
log_path: str,
*,
skill_slug: str,
logger_name: str,
level: int = logging.DEBUG,
) -> logging.Logger:
"""
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
"""
global _skill_slug
logging.raiseExceptions = False
ensure_trace_for_process()
_skill_slug = skill_slug
lg = logging.getLogger(logger_name)
_clear_logger_handlers(lg)
lg.setLevel(level)
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
filt = _SkillContextFilter()
ok, _err = _can_open_log_file(log_path)
if ok:
try:
fh = logging.FileHandler(log_path, encoding="utf-8", delay=False)
fh.setFormatter(fmt)
fh.addFilter(filt)
lg.addHandler(fh)
except Exception:
_attach_fallback_handler(lg)
else:
_attach_fallback_handler(lg)
lg.propagate = False
return lg

View File

@@ -5,6 +5,9 @@ skill-template CLI 入口。
复制为新技能后,请修改注释与常量,但保留当前分层结构:
cliargv→ service业务编排→ db持久化→ util通用工具
jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit
不在技能仓库内 vendored。
"""
from __future__ import annotations
@@ -25,6 +28,10 @@ from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
apply_cli_local_defaults()
from util.config_bootstrap import bootstrap_skill_config
bootstrap_skill_config()
from cli.app import main
if __name__ == "__main__":

View File

@@ -12,9 +12,9 @@ def check_entitlement(skill_slug: str) -> Tuple[bool, str]:
auth_base = (os.getenv("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/")
if not auth_base:
return True, ""
user_id = (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
user_id = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
if not user_id:
return False, "鉴权失败:缺少用户身份(CLAW_USER_ID / JIANGCHANG_USER_ID"
return False, "鉴权失败缺少用户身份JIANGCHANG_USER_ID"
auth_api_key = (os.getenv("JIANGCHANG_AUTH_API_KEY") or "").strip()
timeout = int((os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip())

View File

@@ -1,26 +0,0 @@
"""示例 adapter 四档 dispatch。
由 ``OPENCLAW_TEST_TARGET``(或 ``config.get``)决定档位。
复制本目录后把 ``example_adapter`` 改名为 ``<domain>_adapter``。
"""
from __future__ import annotations
import os
from .mock import MockAdapter
from .real_api import RealApiAdapter
from .real_rpa import RealRpaAdapter
from .sim_rpa import SimRpaAdapter
def get_adapter():
"""返回当前档位 adapter 实例。"""
target = (os.environ.get("OPENCLAW_TEST_TARGET") or "simulator_rpa").strip().lower()
if target in ("unit", "mock"):
return MockAdapter()
if target == "real_api":
return RealApiAdapter()
if target == "real_rpa":
return RealRpaAdapter()
return SimRpaAdapter()

View File

@@ -1,37 +0,0 @@
"""示例适配器数据契约与基类。
复制本目录为 ``<domain>_adapter/``,按 ADAPTER.md 实现各档位。
业务逻辑只依赖 ``base`` 里的接口,不感知 mock / sim_rpa / real_* 差异。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class ExampleItem:
"""单条业务输入(示例)。"""
id: str
label: str
@dataclass
class ExampleResult:
"""批量操作结果(以批为单位返回)。"""
ok: bool
serial_no: Optional[str]
error_msg: Optional[str]
artifacts: Dict[str, Any] = field(default_factory=dict)
class AdapterBase:
"""四档 adapter 统一接口;子类实现 ``do_batch``。"""
name = "base"
def do_batch(self, target: str, items: List[ExampleItem]) -> ExampleResult:
raise NotImplementedError

View File

@@ -1,24 +0,0 @@
"""离线 mock 档位纯内存仿真CI / 单测必跑。"""
from __future__ import annotations
from .base import AdapterBase, ExampleItem, ExampleResult
class MockAdapter(AdapterBase):
name = "mock"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
if not items:
return ExampleResult(
ok=False,
serial_no=None,
error_msg="empty batch",
artifacts={},
)
return ExampleResult(
ok=True,
serial_no=f"MOCK-{len(items)}",
error_msg=None,
artifacts={"mode": "mock", "target": target, "count": len(items)},
)

View File

@@ -1,14 +0,0 @@
"""真实 API 档位占位:有官方接口时在此实现。"""
from __future__ import annotations
from .base import AdapterBase, ExampleItem, ExampleResult
class RealApiAdapter(AdapterBase):
name = "real_api"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
raise NotImplementedError(
"RealApiAdapter: 复制 example_adapter 后在此对接真实 API"
)

View File

@@ -1,14 +0,0 @@
"""真实 RPA 档位占位:无 API 时最后手段,谨慎实现。"""
from __future__ import annotations
from .base import AdapterBase, ExampleItem, ExampleResult
class RealRpaAdapter(AdapterBase):
name = "real_rpa"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
raise NotImplementedError(
"RealRpaAdapter: 复制 example_adapter 后在此实现生产界面 RPA"
)

View File

@@ -1,108 +0,0 @@
"""仿真 RPA 档位:演示 RpaVideoSession + launch_persistent_browser需浏览器默认开发档"""
from __future__ import annotations
import asyncio
import os
from datetime import datetime
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import anti_detect, artifacts, errors, launch_persistent_browser
from jiangchang_skill_core.rpa.human_login import wait_for_captcha_pass
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from util.constants import SKILL_SLUG
from util.runtime_paths import get_skill_data_dir, get_skill_root
from .base import AdapterBase, ExampleItem, ExampleResult
class SimRpaAdapter(AdapterBase):
name = "sim_rpa"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
return asyncio.run(self._do_batch_async(target, items))
async def _do_batch_async(self, target: str, items: list[ExampleItem]) -> ExampleResult:
example_path = os.path.join(get_skill_root(), ".env.example")
config.ensure_env_file(SKILL_SLUG, example_path)
url = config.get("TARGET_BASE_URL") or target or "https://example.com"
batch_id = f"sim_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
data_dir = get_skill_data_dir()
profile_dir = os.path.join(data_dir, "browser-profile")
os.makedirs(profile_dir, exist_ok=True)
try:
from playwright.async_api import async_playwright
except ImportError:
return ExampleResult(
ok=False,
serial_no=None,
error_msg="playwright not installed",
artifacts={},
)
artifact_paths: dict = {}
async with RpaVideoSession(
skill_slug=SKILL_SLUG,
skill_data_dir=data_dir,
batch_id=batch_id,
title="技能仿真演示",
) as video:
video.add_step("正在打开目标页面")
try:
async with async_playwright() as pw:
context = await launch_persistent_browser(
pw,
profile_dir=profile_dir,
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
await anti_detect.human_mouse_wiggle(page)
search = page.locator('input[type="search"], input[name="q"]').first
if await search.count() > 0:
demo_text = items[0].label if items else "openclaw-demo"
video.step("正在填写演示内容")
await anti_detect.human_type(page, search, demo_text)
await anti_detect.human_delay_short()
video.step("正在等待人工验证(如有)")
if not await wait_for_captcha_pass(
page,
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
):
shot = await artifacts.capture_failure(
page, data_dir, batch_id, "captcha"
)
if shot:
artifact_paths["captcha"] = shot
return ExampleResult(
ok=False,
serial_no=None,
error_msg=errors.ERR_CAPTCHA_NEED_HUMAN,
artifacts={**artifact_paths, **video.to_artifact_dict()},
)
video.step("演示任务完成")
await context.close()
except Exception as exc:
video.step("演示异常结束")
return ExampleResult(
ok=False,
serial_no=None,
error_msg=str(exc),
artifacts={**artifact_paths, **video.to_artifact_dict()},
)
arts = {"mode": "sim_rpa", "url": url, **artifact_paths, **video.to_artifact_dict()}
if video.output_video_path:
arts["video_path"] = video.output_video_path
return ExampleResult(
ok=True,
serial_no=f"SIM-{len(items)}",
error_msg=None,
artifacts=arts,
)

View File

@@ -1,88 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""浏览器 RPA 最小用法示例(复制到新 skill 后按需改)。
演示标准流程:
1. config.ensure_env_file 首次落盘 .env
2. launch_persistent_browser 启动 stealth persistent context
3. 拟人操作mouse_wiggle / human_type / human_click
4. 验证码 HITL 等待
5. 失败 capture_failure 截图
运行(需系统 Chrome/Edge + playwright 包):
python scripts/service/example_browser_rpa.py
"""
from __future__ import annotations
import asyncio
import os
import sys
_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import (
anti_detect,
artifacts,
errors,
launch_persistent_browser,
wait_for_captcha_pass,
)
from util.constants import SKILL_SLUG
from util.runtime_paths import get_skill_data_dir, get_skill_root
async def demo() -> int:
example_path = os.path.join(get_skill_root(), ".env.example")
config.ensure_env_file(SKILL_SLUG, example_path)
url = config.get("TARGET_BASE_URL") or "https://example.com"
data_dir = get_skill_data_dir()
profile_dir = os.path.join(data_dir, "browser-profile")
os.makedirs(profile_dir, exist_ok=True)
batch_id = "demo"
try:
from playwright.async_api import async_playwright
except ImportError:
print("ERROR: playwright not installed")
return 1
async with async_playwright() as pw:
context = await launch_persistent_browser(
pw,
profile_dir=profile_dir,
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
)
page = context.pages[0] if context.pages else await context.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
await anti_detect.human_mouse_wiggle(page)
search = page.locator('input[type="search"], input[name="q"]').first
if await search.count() > 0:
await anti_detect.human_type(page, search, "openclaw-rpa-demo")
await anti_detect.human_click(page, selector='input[type="search"]')
if not await wait_for_captcha_pass(
page,
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
):
shot = await artifacts.capture_failure(page, data_dir, batch_id, "captcha")
print(errors.ERR_CAPTCHA_NEED_HUMAN, shot or "")
return 1
print("OK browser RPA demo finished")
return 0
except Exception as exc:
shot = await artifacts.capture_failure(page, data_dir, batch_id, "error")
print(f"ERROR: {exc}", shot or "")
return 1
finally:
await context.close()
if __name__ == "__main__":
raise SystemExit(asyncio.run(demo()))

View File

@@ -1,7 +1,7 @@
"""兄弟技能 CLI 调用模板
"""兄弟技能 CLI 调用工具
用工具:通过子进程调用同级其他 skill 的 main.py并解析 JSON 输出。
具体调用哪些兄弟技能,由具体业务 skill 决定(在 task_service.py 中按需 import 调用)
通过子进程调用同级其他 skill 的 main.py并解析 JSON 输出。
具体调用哪些兄弟技能,由复制后的业务 skill 在 service 层按需决定
"""
from __future__ import annotations
@@ -50,16 +50,8 @@ def call_sibling_json(skill_slug: str, args: List[str]) -> Optional[Dict[str, An
def get_sibling_main_path(skill_slug: str) -> str:
"""获取兄弟技能的 main.py 绝对路径。
使用示例(在你的 task_service.py 中):
使用示例(在 task_service.py 中):
from service.sibling_bridge import get_sibling_main_path, call_sibling_json
# 调用名为 account-manager 的兄弟技能:
result = call_sibling_json("account-manager", ["list", "--limit", "10"])
"""
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
# ============================================================
# 复制本模板后,按需在你自己的 task_service.py 中调用上面的工具。
# 不要在本文件中硬编码具体兄弟技能函数(如 get_account_manager_main_path
# 那是发布类技能的特定需求,不属于通用模板。
# ============================================================

View File

@@ -0,0 +1,84 @@
"""任务运行辅助:录屏信息组装与 CLI 输出(通用模板,无业务逻辑)。"""
from __future__ import annotations
from typing import Any, Dict, Optional
def build_video_info(
video_summary: dict[str, Any],
compose_context_before: Optional[dict[str, Any]] = None,
compose_context_after: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""将 RpaVideoSession.summary() 转为 task log / CLI 可用的 video 块。"""
return {
"enabled": video_summary.get("enabled", False),
"path": video_summary.get("path"),
"capture_path": video_summary.get("capture_path"),
"record_log_path": video_summary.get("record_log_path"),
"warnings": list(video_summary.get("warnings") or []),
"voiceover_path": video_summary.get("voiceover_path"),
"music_path": video_summary.get("music_path"),
"audio_warnings": list(video_summary.get("audio_warnings") or []),
"compose_context": {
**(compose_context_before or {}),
"after_compose": compose_context_after or {},
},
}
def merge_video_into_result_summary(
payload: dict[str, Any],
video_info: Optional[dict[str, Any]],
) -> dict[str, Any]:
"""把 video artifact 字段写入 result_summary JSON 对象。"""
if not video_info:
return payload
payload = dict(payload)
payload["video"] = video_info
payload["video_path"] = video_info.get("path")
payload["raw_video"] = video_info.get("capture_path")
payload["video_log"] = video_info.get("record_log_path")
payload["video_warnings"] = list(video_info.get("warnings") or [])
payload["voiceover_path"] = video_info.get("voiceover_path")
payload["music_path"] = video_info.get("music_path")
payload["audio_warnings"] = list(video_info.get("audio_warnings") or [])
return payload
def _print_video_summary(video_block: Optional[dict[str, Any]]) -> None:
"""任务完成后在 CLI 打印录屏路径、日志与诊断信息。"""
if not isinstance(video_block, dict) or not video_block.get("enabled"):
return
record_log = video_block.get("record_log_path")
if record_log:
print(f"录屏日志:{record_log}")
path = video_block.get("path")
if path:
print(f"录屏路径:{path}")
else:
print("录屏路径:未生成")
warnings = video_block.get("warnings") or []
if warnings:
print(f"视频诊断:{'; '.join(str(w) for w in warnings)}")
capture = video_block.get("capture_path")
if capture:
print(f"原始录屏路径:{capture}")
voiceover = video_block.get("voiceover_path")
if voiceover:
print(f"旁白路径:{voiceover}")
music = video_block.get("music_path")
if music:
print(f"背景音乐:{music}")
audio_warnings = video_block.get("audio_warnings") or []
if audio_warnings:
print(f"音频诊断:{'; '.join(str(w) for w in audio_warnings)}")
compose_ctx = video_block.get("compose_context")
if isinstance(compose_ctx, dict):
print(
"视频合成上下文:"
f"media_assets_root={compose_ctx.get('media_assets_root', '')}; "
f"ffmpeg_path={compose_ctx.get('ffmpeg_path', '')}; "
f"background_music_issue={compose_ctx.get('background_music_issue') or ''}; "
f"background_music_sample_path={compose_ctx.get('background_music_sample_path', '')}"
)

View File

@@ -6,25 +6,81 @@
from __future__ import annotations
import asyncio
import json
import sys
from typing import Optional
import os
import uuid
from typing import Any, Dict, Optional
from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from db import task_logs_repository as tlr
from service.entitlement_service import check_entitlement
from util.constants import SKILL_SLUG, SKILL_VERSION
from service.task_run_support import (
_print_video_summary,
build_video_info,
merge_video_into_result_summary,
)
from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
from util.runtime_paths import get_skill_data_dir, get_skill_root
from util.timeutil import unix_to_iso
async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]:
"""最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。"""
batch_id = uuid.uuid4().hex[:12]
data_dir = get_skill_data_dir()
async with RpaVideoSession(
skill_slug=SKILL_SLUG,
skill_data_dir=data_dir,
batch_id=batch_id,
title="开始执行示例任务",
closing_title="示例任务执行完成",
) as video:
video.add_step("准备执行示例任务")
if target:
video.add_step(f"接收目标参数:{target}")
if input_id:
video.add_step(f"接收输入标识:{input_id}")
video.add_step("示例任务执行完成")
video_summary = video.summary()
video_info = build_video_info(video_summary, {}, {})
return 1, video_info
def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int:
"""通用任务执行入口模板。复制后请实现真实业务逻辑。"""
_ = (target, input_id)
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
print(f"{reason}")
return 1
rc, video_info = asyncio.run(_run_template_demo(target, input_id))
_print_video_summary(video_info)
summary_payload = merge_video_into_result_summary(
{
"template_demo": True,
"target": target,
"input_id": input_id,
},
video_info,
)
tlr.save_task_log(
task_type="demo",
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg="模板仓库未实现真实业务",
result_summary=json.dumps(summary_payload, ensure_ascii=False),
)
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
return 1
return rc
def cmd_logs(
@@ -88,8 +144,43 @@ def cmd_log_get(log_id: str) -> int:
return 0
def cmd_config_path() -> int:
example_path = os.path.join(get_skill_root(), ".env.example")
env_path = config.get_env_file_path() or ""
print(
json.dumps(
{
"skill": SKILL_SLUG,
"env_path": env_path,
"example_path": os.path.abspath(example_path),
},
ensure_ascii=False,
)
)
return 0
def cmd_health() -> int:
return 0 if sys.version_info >= (3, 10) else 1
runtime = collect_runtime_diagnostics(
skill_slug=SKILL_SLUG,
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
skill_root=get_skill_root(),
)
example_path = os.path.join(get_skill_root(), ".env.example")
env_path = config.get_env_file_path() or ""
env_exists = bool(env_path and os.path.isfile(env_path))
health_status = "failed" if runtime.has_fatal_issues else "ok"
lines = [
f"{SKILL_SLUG} health: {health_status}",
*format_runtime_health_lines(runtime),
f"env_path: {env_path}",
f"env_exists: {env_exists}",
f"example_path: {os.path.abspath(example_path)}",
]
for line in lines:
print(line)
return 1 if runtime.has_fatal_issues else 0
def cmd_version() -> int:

View File

@@ -0,0 +1,19 @@
"""技能配置初始化:.env.example 落盘与用户 .env 缺失项合并。"""
from __future__ import annotations
import os
from jiangchang_skill_core import config
from util.constants import SKILL_SLUG, SKILL_VERSION
from util.runtime_paths import get_skill_root
def bootstrap_skill_config() -> str:
"""确保用户数据目录 .env 存在,并追加 .env.example 中的新配置项。"""
example_path = os.path.join(get_skill_root(), ".env.example")
env_path = config.ensure_env_file(SKILL_SLUG, example_path)
comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None
config.merge_missing_env_keys(example_path, env_path, comment_skill=comment)
return env_path

View File

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

View File

@@ -1,6 +1,6 @@
# skill-template 测试说明(企业数字员工技能)
本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite**真实 API / 真实 RPA** 必须通过环境变量显式授权,且仅以 ``*.sample`` 或复制后的文件提供范式。
本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite**真实 API / 真实 RPA** 必须显式设置 ``OPENCLAW_TEST_TARGET``,且仅以 ``*.sample`` 或复制后的文件提供范式。
复制为新技能后,请保留隔离数据根与 profile 策略,再按域扩展用例。
@@ -16,9 +16,9 @@
| 纯函数 / 业务规则 | `tests/test_*.py` | 是 | 不依赖真实外部系统 |
| service 层契约 | 从 `tests/samples/test_service_contract.py.sample` 复制到 `tests/test_service_contract.py` | 是,复制后 | 用 `FakeAdapter` / stub 注入外部依赖 |
| golden fixture 回归 | 从 `tests/samples/test_golden_cases.py.sample` 复制到 `tests/test_golden_cases.py` | 是,复制后 | 输入样例 + 期望输出,适合解析、计算、校验类技能 |
| 仿真 API | `tests/integration/test_simulator_api_contract.py.sample` | 否 | 复制并设置 `OPENCLAW_TEST_TARGET=simulator_api`(或兼容键名 `OPENCLOW_TEST_TARGET`后按需运行 |
| 仿真 API | `tests/integration/test_simulator_api_contract.py.sample` | 否 | 复制并设置 `OPENCLAW_TEST_TARGET=simulator_api` 后按需运行 |
| 仿真 RPA / 页面操作 | `tests/integration/test_simulator_rpa_contract.py.sample` | 否 | 用 localhost / 仿真页面,不碰真实系统 |
| 真实 API | `tests/integration/test_real_api_contract.py.sample` | 否 | 必须 `OPENCLAW_TEST_TARGET=real_api` 且 `ALLOW_REAL_API=1` |
| 真实 API | `tests/integration/test_real_api_contract.py.sample` | 否 | 必须 `OPENCLAW_TEST_TARGET=real_api` |
| 真实 RPA | `tests/integration/test_real_rpa_contract.py.sample` | 否 | 最高风险,只能手动触发 |
| 桌面宿主 E2E | `tests/desktop/test_desktop_smoke.py.sample` | 否 | 需要 pytest + `jiangchang_desktop_sdk` |
@@ -30,8 +30,8 @@
从本模板复制出新技能仓库后,建议至少逐项确认:
- [ ] `python tests/run_tests.py -v` 能通过。
- [ ] `python scripts/main.py health` 能通过。
- [ ] `python tests/run_tests.py -v` 能通过(含 platform-kit 导入与无 vendored core 守护)
- [ ] `python scripts/main.py health` 能通过(输出 runtime diagnosticswarning 不导致非零退出)
- [ ] `python scripts/main.py version` 输出 JSON且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。
- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。
- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API不打开真实 RPA。
@@ -39,7 +39,7 @@
- [ ] 至少有 1 个缺必填字段 / 非法输入测试。
- [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。
- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏
- [ ] 如果技能有 Python 三方依赖,`requirements.txt` 已声明且版本约束合理(尽量收窄范围)。
- [ ] 如果技能有**特有** Python 三方依赖,`requirements.txt` 已声明且版本约束合理(尽量收窄范围)公共依赖platform-kit、playwright由宿主共享 runtime 提供,**不要**写入技能 requirements
- [ ] `health` 能在依赖缺失或原生运行库未就绪时给出清晰、可操作的错误(非裸 traceback
- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。
@@ -70,24 +70,29 @@ python tests/run_tests.py test_cli_smoke
|------|------|
| CLI 冒烟import ``cli.app.main`` | ``test_cli_smoke.py`` |
| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` |
| 运行路径与 **CLAW_*/JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| 运行路径与 **JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` |
| DB / ``task_logs`` 骨架冒烟 | ``test_db_smoke.py`` |
| 数据管理元数据 / 字段顺序 | ``test_display_metadata.py`` |
| **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) |
| **无 vendored ``jiangchang_skill_core``** | ``test_platform_import.py`` |
| **runtime diagnostics 架构守护** | ``test_runtime_diagnostics_integration.py`` |
| **文档/runtime 标准守护** | ``test_template_runtime_standard.py`` |
| **配置 bootstrap / config-path** | ``test_config_bootstrap.py`` |
| **health runtime diagnostics** | ``test_health_runtime.py`` |
复制为新技能后**不得删除**上述架构守护测试,除非同步更新模板标准并理解影响。
### 1.3 数据隔离(``_support.IsolatedDataRoot``
同时设置(进入同一临时目录 / 用户
进入同一临时目录 / 用户:
| ``CLAW_DATA_ROOT`` | ``JIANGCHANG_DATA_ROOT`` |
|--------------------|---------------------------|
| 同一 ``tempfile.mkdtemp(prefix="skill-test-")`` | 同上 |
| 变量 | 值 |
|------|-----|
| ``JIANGCHANG_DATA_ROOT`` | ``tempfile.mkdtemp(prefix="skill-test-")`` |
| ``JIANGCHANG_USER_ID`` | ``_test`` |
| ``CLAW_USER_ID`` | ``JIANGCHANG_USER_ID`` |
|------------------|-------------------------|
| ``_test`` | ``_test`` |
退出时四个键均恢复(原不存在则 ``pop``),并删除临时目录。
退出时两个键均恢复(原不存在则 ``pop``),并删除临时目录。
### 1.4 产物与密钥
@@ -118,11 +123,11 @@ python tests/run_tests.py test_cli_smoke
---
## 5. 环境变量:测试档位与授权
## 5. 环境变量:测试档位
### 5.1 测试档位(二选一变量名,后者为历史拼写兼容)
### 5.1 测试档位
- ``OPENCLAW_TEST_TARGET`` 或 ``OPENCLOW_TEST_TARGET``
- ``OPENCLAW_TEST_TARGET``
**合法取值**(非法值将使 ``get_test_target()`` 抛 ``ValueError``
@@ -132,23 +137,15 @@ python tests/run_tests.py test_cli_smoke
| ``mock`` | 与 ``unit`` 类似的安全档位(显式语义) |
| ``simulator_api`` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| ``simulator_rpa`` | 仅允许 **仿真页面 / 录播 RPA** |
| ``real_api`` | 真实 API另需 ``ALLOW_REAL_API=1`` |
| ``real_rpa`` | 真实 RPA另需 ``ALLOW_REAL_RPA=1`` |
| ``real_api`` | 真实 API显式设 ``OPENCLAW_TEST_TARGET=real_api`` |
| ``real_rpa`` | 真实 RPA显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` |
未设置时等价 ``unit``。
### 5.2 授权开关(显式 ``1``
| 变量 | 作用 |
|------|------|
| ``ALLOW_REAL_API=1`` | 允许 ``real_api`` profile 访问真实 HTTP 通道 |
| ``ALLOW_REAL_RPA=1`` | 允许 ``real_rpa`` profile 驱动真实浏览器/RPA |
| ``ALLOW_WRITE_ACTIONS=1`` | 在 ``real_*`` 下允许**写**操作(提交表单、下单等) |
### 5.3 默认安全策略(``adapter_test_utils``
### 5.2 默认安全策略(``adapter_test_utils``
- 默认 ``unit``:不访问真实 API、不跑真实 RPA、不做写操作、不读取真实密钥、不写真实数据根。
- ``assert_no_real_network_env()`` 断言默认未误 ``ALLOW_REAL_*``(用于必跑用例自检)。
- ``assert_no_real_network_env()`` 断言默认未误 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``(用于必跑用例自检)。
---
@@ -163,7 +160,7 @@ python tests/run_tests.py test_cli_smoke
- 默认测试只能使用 **mock、stub、`FakeAdapter`、fixtures、隔离 SQLite** 等安全手段。
- 业务逻辑应优先在 **service 层** 编写与测试,不要只断言 CLI 文案。
- 异常路径应返回**结构化错误**(如统一错误码/字段),避免裸异常直接穿透到 CLI。
- 有真实系统联调需求时,测试应写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` / `ALLOW_REAL_*` / `ALLOW_WRITE_ACTIONS` 等**显式开关**;不得默认开启。
- 有真实系统联调需求时,测试应写在 `tests/integration/`,并显式设置 ``OPENCLAW_TEST_TARGET`` ``real_api`` / ``real_rpa``;不得默认开启。
---

View File

@@ -3,10 +3,8 @@
测试公共支撑。
1. 将 scripts/ 加入 sys.path便于 `from cli…` / `from db…` / `from util…` 等导入。
2. `IsolatedDataRoot`同时设置 CLAW_* 与 JIANGCHANG_* 数据根与用户 ID
与 `jiangchang_skill_core.runtime_env` 的优先级一致CLAW_* 优先),
避免只设 JIANGCHANG_* 时仍读到真实 CLAW_DATA_ROOT。
退出时四个变量均恢复;原不存在的键会删除;并 rmtree 临时目录。
2. `IsolatedDataRoot`:设置 `JIANGCHANG_DATA_ROOT``JIANGCHANG_USER_ID`
退出时恢复原值并删除临时目录,避免污染真实用户数据目录。
"""
from __future__ import annotations
@@ -24,9 +22,7 @@ if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
_ISOLATION_KEYS = (
"CLAW_DATA_ROOT",
"JIANGCHANG_DATA_ROOT",
"CLAW_USER_ID",
"JIANGCHANG_USER_ID",
)
@@ -39,15 +35,25 @@ def get_skill_root() -> str:
return _SKILL_ROOT
def platform_kit_version_patch(version: str = "1.0.17"):
"""Mock installed jiangchang-platform-kit version for health/diagnostics tests."""
from unittest.mock import patch
return patch(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
return_value=version,
)
class IsolatedDataRoot:
"""
在独立临时目录下模拟数据根,避免污染本机默认数据目录。
进入上下文时(同一套隔离值写入 CLAW_* 与 JIANGCHANG_*
- CLAW_DATA_ROOT / JIANGCHANG_DATA_ROOT -> 同一 tempfile.mkdtemp(prefix='skill-test-')
- CLAW_USER_ID / JIANGCHANG_USER_ID -> _test与业务默认用户区分
进入上下文时:
- JIANGCHANG_DATA_ROOT -> tempfile.mkdtemp(prefix='skill-test-')
- JIANGCHANG_USER_ID -> _test与业务默认用户区分
退出时:个变量均恢复为进入前状态(若原先未设置则 pop并删除临时目录。
退出时:个变量均恢复为进入前状态(若原先未设置则 pop并删除临时目录。
"""
def __init__(self, user_id: str = "_test") -> None:
@@ -59,9 +65,7 @@ class IsolatedDataRoot:
self._tmp = tempfile.mkdtemp(prefix="skill-test-")
for k in _ISOLATION_KEYS:
self._old[k] = os.environ.get(k)
os.environ["CLAW_DATA_ROOT"] = self._tmp
os.environ["JIANGCHANG_DATA_ROOT"] = self._tmp
os.environ["CLAW_USER_ID"] = self._user_id
os.environ["JIANGCHANG_USER_ID"] = self._user_id
return self._tmp

View File

@@ -1,17 +1,17 @@
# -*- coding: utf-8 -*-
"""
企业技能对接外部系统ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA
测试侧共用的 **profile / 授权 / 禁止真实外呼** 工具。
测试侧共用的 **profile / 禁止真实外呼** 工具。
- 仅标准库;不发起 HTTP不启动浏览器。
- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并配合环境变量显式授权
- 仅标准库 + jiangchang_skill_core.config;不发起 HTTP不启动浏览器。
- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并显式设置 ``OPENCLAW_TEST_TARGET``
"""
from __future__ import annotations
import os
from typing import Any, Final
# 允许的 OPENCLAW_TEST_TARGET / OPENCLOW_TEST_TARGET 取值(后者为历史拼写兼容)
from jiangchang_skill_core import config
ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset(
{
"unit",
@@ -25,12 +25,12 @@ ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset(
def _raw_test_target_env() -> str:
return (os.environ.get("OPENCLAW_TEST_TARGET") or os.environ.get("OPENCLOW_TEST_TARGET") or "").strip()
return (config.get("OPENCLAW_TEST_TARGET") or "").strip()
def get_test_target() -> str:
"""
读取 ``OPENCLAW_TEST_TARGET`` ``OPENCLOW_TEST_TARGET``(兼容旧拼写)。
读取 ``OPENCLAW_TEST_TARGET``(经 ``config.get`` 三级合并)。
未设置时返回 ``unit``。非法取值抛出 ``ValueError``,便于尽早发现拼写错误。
"""
@@ -46,18 +46,15 @@ def get_test_target() -> str:
def real_access_allowed(profile: str) -> bool:
"""
判断当前环境是否允许 ``profile`` 做「真实通道」访问(仍不代替业务鉴权)
- ``real_api``:需要 ``ALLOW_REAL_API == "1"``。
- ``real_rpa``:需要 ``ALLOW_REAL_RPA == "1"``。
- 写操作另需 ``ALLOW_WRITE_ACTIONS == "1"``(由 ``should_skip_profile(..., write=True)`` 组合检查)。
判断当前 ``OPENCLAW_TEST_TARGET`` 是否允许运行 ``profile`` 档位测试
"""
if profile == "real_api":
return os.environ.get("ALLOW_REAL_API", "") == "1"
if profile == "real_rpa":
return os.environ.get("ALLOW_REAL_RPA", "") == "1"
target = get_test_target()
if profile in ("mock", "simulator_api", "simulator_rpa"):
return True
if profile == "real_api":
return target == "real_api"
if profile == "real_rpa":
return target == "real_rpa"
raise ValueError(f"unknown profile for real_access_allowed: {profile!r}")
@@ -67,42 +64,19 @@ def should_skip_profile(profile: str, write: bool = False) -> tuple[bool, str]:
策略摘要:
- ``mock``:永不跳过(内存 / FakeAdapter无外呼
- ``simulator_api``:仅当 ``get_test_target() == "simulator_api"`` 时不跳过。
- ``simulator_rpa``:仅当 target 为 ``simulator_rpa`` 时不跳过。
- ``real_api``target 须为 ``real_api`` 且 ``ALLOW_REAL_API=1````write=True`` 还须 ``ALLOW_WRITE_ACTIONS=1``
- ``real_rpa``target 须为 ``real_rpa`` 且 ``ALLOW_REAL_RPA=1````write=True`` 还须 ``ALLOW_WRITE_ACTIONS=1``。
- ``simulator_api`` / ``simulator_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。
- ``real_api`` / ``real_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。
- ``write`` 参数保留以兼容 integration 样例签名;档位一致即允许写操作测试
"""
_ = write
target = get_test_target()
if profile == "mock":
return (False, "")
if profile == "simulator_api":
if target != "simulator_api":
return (True, f"OPENCLAW_TEST_TARGET must be simulator_api (got {target!r})")
return (False, "")
if profile == "simulator_rpa":
if target != "simulator_rpa":
return (True, f"OPENCLAW_TEST_TARGET must be simulator_rpa (got {target!r})")
return (False, "")
if profile == "real_api":
if target != "real_api":
return (True, f"OPENCLAW_TEST_TARGET must be real_api (got {target!r})")
if os.environ.get("ALLOW_REAL_API", "") != "1":
return (True, "ALLOW_REAL_API is not 1")
if write and os.environ.get("ALLOW_WRITE_ACTIONS", "") != "1":
return (True, "ALLOW_WRITE_ACTIONS is not 1 (write requested)")
return (False, "")
if profile == "real_rpa":
if target != "real_rpa":
return (True, f"OPENCLAW_TEST_TARGET must be real_rpa (got {target!r})")
if os.environ.get("ALLOW_REAL_RPA", "") != "1":
return (True, "ALLOW_REAL_RPA is not 1")
if write and os.environ.get("ALLOW_WRITE_ACTIONS", "") != "1":
return (True, "ALLOW_WRITE_ACTIONS is not 1 (write requested)")
if profile in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"):
if target != profile:
return (True, f"OPENCLAW_TEST_TARGET must be {profile} (got {target!r})")
return (False, "")
raise ValueError(f"unknown profile: {profile!r}")
@@ -134,9 +108,11 @@ class FakeAdapter:
def assert_no_real_network_env() -> None:
"""
断言当前进程未显式打开「真实外呼」授权开关(默认安全 CI / 本地开发)。
断言默认必跑套件未误设真实外联档位(``real_api`` / ``real_rpa``)。
若需在受控环境跑真实集成,请显式设置 ``ALLOW_REAL_*`` 并使用 ``tests/integration/*.sample``。
若需在受控环境跑真实集成,请显式设置 ``OPENCLAW_TEST_TARGET`` 并使用 ``tests/integration/*.sample``。
"""
assert os.environ.get("ALLOW_REAL_API", "") != "1", "ALLOW_REAL_API must not be 1 in default unit tests"
assert os.environ.get("ALLOW_REAL_RPA", "") != "1", "ALLOW_REAL_RPA must not be 1 in default unit tests"
target = get_test_target()
assert target not in ("real_api", "real_rpa"), (
f"OPENCLAW_TEST_TARGET must not be {target!r} in default unit tests"
)

View File

@@ -11,7 +11,7 @@
|------|------------------|------------------------|
| 目的 | 默认可**升级**为根目录 `test_*.py`**业务单元 / service 契约 / golden** 范式 | **外联联调**范式:网络、浏览器、仿真或真实系统 |
| 默认执行 | `.sample` 不执行;复制到根目录并改名后才进入默认套件 | **永远不**作为默认 unittest 的一部分;复制后仍需显式环境变量与手动/CI 任务触发 |
| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API(授权)、真实 RPA最高风险 |
| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API、真实 RPA最高风险 |
**原则**:只要会访问**网络**、**浏览器**、**真实或仿真外部系统**,应优先放在 `integration`(并保持 `.sample` 直至团队同意启用),而不是写进默认 `tests/test_*.py`
@@ -21,20 +21,15 @@
|------|------------------------------|----------|
| 本地 / CI 对接**仿真 HTTP** | ``simulator_api`` | 仿真服务如 ``http://localhost:5180`` |
| **仿真页面** / 录播 RPA | ``simulator_rpa`` | 本地页面或沙箱浏览器 profile |
| **真实 API**(只读优先) | ``real_api`` | ``ALLOW_REAL_API=1``;写操作另需 ``ALLOW_WRITE_ACTIONS=1`` |
| **真实 RPA**(最高风险) | ``real_rpa`` | ``ALLOW_REAL_RPA=1``**仅手动触发** |
| **真实 API** | ``real_api`` | **仅手动触发** |
| **真实 RPA**(最高风险) | ``real_rpa`` | **仅手动触发** |
## 环境变量(与 ``adapter_test_utils`` 一致)
- **目标档位**(二选一键名,后者为历史拼写):``OPENCLAW_TEST_TARGET`` 或 ``OPENCLOW_TEST_TARGET``
- **目标档位**``OPENCLAW_TEST_TARGET``
取值:``unit`` | ``mock`` | ``simulator_api`` | ``simulator_rpa`` | ``real_api`` | ``real_rpa``
默认未设置等价于 ``unit``。
- **授权开关**(显式 ``1`` 才启用):
- ``ALLOW_REAL_API=1``
- ``ALLOW_REAL_RPA=1``
- ``ALLOW_WRITE_ACTIONS=1``(对写操作 / 提交表单类步骤)
## 安全约束
- **禁止**在样例或复制后的测试代码中硬编码真实账号、密码、token、cookie、生产 URL。

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_real_api_contract.py需 OPENCLAW_TEST_TARGET=real_api 且 ALLOW_REAL_API=1
# 样例:复制为 test_real_api_contract.py需 OPENCLAW_TEST_TARGET=real_api。
"""真实 HTTP API只读优先默认跳过。"""
from __future__ import annotations
@@ -15,7 +15,6 @@ class TestRealApiContractSample(unittest.TestCase):
raise unittest.SkipTest(reason)
# 写操作示例(复制后使用):
# skip_w, _ = should_skip_profile("real_api", write=True)
# 需要 ALLOW_WRITE_ACTIONS=1
# 禁止在此文件写入真实 token从 vault / 环境注入读取 endpoint。
self.assertFalse(skip)

View File

@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_real_rpa_contract.py需 OPENCLAW_TEST_TARGET=real_rpa 且 ALLOW_REAL_RPA=1
# 样例:复制为 test_real_rpa_contract.py需 OPENCLAW_TEST_TARGET=real_rpa。
"""
真实 RPA / 桌面自动化(**最高风险**,仅手动触发)。
默认只读思路示例:打开沙箱门户、读取标题、**不提交表单**。
任何写操作必须 ``should_skip_profile('real_rpa', write=True)`` 且 ``ALLOW_WRITE_ACTIONS=1``
写操作测试同样须显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` 后手动触发
"""
from __future__ import annotations

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import os
import unittest
from jiangchang_skill_core import config
from adapter_test_utils import (
ALLOWED_TEST_TARGETS,
FakeAdapter,
@@ -24,34 +26,22 @@ def _restore_env(saved: dict[str, str | None]) -> None:
os.environ.pop(k, None)
else:
os.environ[k] = v
config.reset_cache()
_ENV_KEYS = (
"OPENCLAW_TEST_TARGET",
"OPENCLOW_TEST_TARGET",
"ALLOW_REAL_API",
"ALLOW_REAL_RPA",
"ALLOW_WRITE_ACTIONS",
)
_ENV_KEYS = ("OPENCLAW_TEST_TARGET",)
class TestAdapterProfilePolicy(unittest.TestCase):
def test_get_test_target_reads_opencylow_typo_alias(self) -> None:
"""兼容环境变量历史拼写 OPENCLOW_TEST_TARGET。"""
saved = _save_env(_ENV_KEYS)
try:
os.environ.pop("OPENCLAW_TEST_TARGET", None)
os.environ.pop("OPENCLOW_TEST_TARGET", None)
os.environ["OPENCLOW_TEST_TARGET"] = "mock"
self.assertEqual(get_test_target(), "mock")
finally:
_restore_env(saved)
def tearDown(self) -> None:
config.reset_cache()
def test_get_test_target_defaults_to_unit(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
config.reset_cache()
self.assertEqual(get_test_target(), "unit")
finally:
_restore_env(saved)
@@ -62,6 +52,7 @@ class TestAdapterProfilePolicy(unittest.TestCase):
for k in _ENV_KEYS:
os.environ.pop(k, None)
os.environ["OPENCLAW_TEST_TARGET"] = "not_a_valid_target"
config.reset_cache()
with self.assertRaises(ValueError):
get_test_target()
finally:
@@ -74,6 +65,7 @@ class TestAdapterProfilePolicy(unittest.TestCase):
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "simulator_api"
config.reset_cache()
skip, _ = should_skip_profile("simulator_api")
self.assertFalse(skip)
finally:
@@ -84,6 +76,7 @@ class TestAdapterProfilePolicy(unittest.TestCase):
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
config.reset_cache()
self.assertEqual(get_test_target(), "unit")
for prof in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"):
skip, reason = should_skip_profile(prof)
@@ -93,46 +86,23 @@ class TestAdapterProfilePolicy(unittest.TestCase):
finally:
_restore_env(saved)
def test_real_api_requires_allow_flag(self) -> None:
def test_real_api_runs_when_target_matches(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_api"
os.environ.pop("ALLOW_REAL_API", None)
skip, reason = should_skip_profile("real_api")
self.assertTrue(skip)
self.assertIn("ALLOW_REAL_API", reason)
os.environ["ALLOW_REAL_API"] = "1"
skip2, _ = should_skip_profile("real_api", write=False)
self.assertFalse(skip2)
config.reset_cache()
skip, _ = should_skip_profile("real_api", write=True)
self.assertFalse(skip)
finally:
_restore_env(saved)
def test_real_api_write_requires_allow_write_actions(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_api"
os.environ["ALLOW_REAL_API"] = "1"
os.environ.pop("ALLOW_WRITE_ACTIONS", None)
skip, reason = should_skip_profile("real_api", write=True)
self.assertTrue(skip)
self.assertIn("ALLOW_WRITE_ACTIONS", reason)
os.environ["ALLOW_WRITE_ACTIONS"] = "1"
skip2, _ = should_skip_profile("real_api", write=True)
self.assertFalse(skip2)
finally:
_restore_env(saved)
def test_real_rpa_requires_allow_rpa_flag(self) -> None:
def test_real_rpa_runs_when_target_matches(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_rpa"
os.environ.pop("ALLOW_REAL_RPA", None)
skip, reason = should_skip_profile("real_rpa")
self.assertTrue(skip)
self.assertIn("ALLOW_REAL_RPA", reason)
os.environ["ALLOW_REAL_RPA"] = "1"
skip2, _ = should_skip_profile("real_rpa", write=False)
self.assertFalse(skip2)
config.reset_cache()
skip, _ = should_skip_profile("real_rpa", write=True)
self.assertFalse(skip)
finally:
_restore_env(saved)
@@ -146,10 +116,10 @@ class TestAdapterProfilePolicy(unittest.TestCase):
FakeAdapter("unauthorized").call({})
def test_assert_no_real_network_env(self) -> None:
saved = _save_env(("ALLOW_REAL_API", "ALLOW_REAL_RPA"))
saved = _save_env(_ENV_KEYS)
try:
os.environ.pop("ALLOW_REAL_API", None)
os.environ.pop("ALLOW_REAL_RPA", None)
os.environ.pop("OPENCLAW_TEST_TARGET", None)
config.reset_cache()
assert_no_real_network_env()
finally:
_restore_env(saved)

Some files were not shown because too many files have changed in this diff Show More