Compare commits

...

46 Commits

Author SHA1 Message Date
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
ae5466854f fix: sync unified logging probe before file handler attach
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-02 15:18:59 +08:00
b7163f12a5 chore: sync ffmpeg video session and logging fallback
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:29:02 +08:00
f52536bdcc chore: sync ffmpeg RpaVideoSession, remove Playwright recording
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:07:02 +08:00
38343d22ca chore: sync RpaVideoSession standard and default OPENCLAW_RECORD_VIDEO=1
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 13:29:50 +08:00
685ad20bd2 完善模板,加入RPA标准 2026-05-31 10:33:05 +08:00
e4418d68e4 docs: standardize skill requirements declaration 2026-05-29 17:38:15 +08:00
c20d6498da feat(template): 升级 desktop E2E 模板到本系列最佳实践
基于 disburse-payroll-icbc 实战经验沉淀,让所有未来新 skill 复制模板后直接
享受本系列全部改进:

- record_screencast.py.sample:
  · 字幕脚本改用 "step: N/X" 模式 + ASCII 关键词锚(避免编码问题)
  · 展示三元组 (keyword, text, duration) 用法(依赖 platform-kit subtitle.py)
  · 加 TEST_FILE_NAME / MUSIC_SUBDIR 切换常量
  · 文档说明绕开 SDK ask() / send_file 的最佳实践

- test_desktop_smoke.py.sample:
  · docstring 加"何时用此模板 / 何时改用 _with_attachment"指引
  · 标注已知 SDK 限制(ask() user_idx<0 / send_file NotImplementedError)

- test_desktop_smoke_with_attachment.py.sample(新增):
  · 完整附件场景模板,import jiangchang_desktop_sdk.e2e_helpers 6 个 API
  · 7 步 step: N/7 模式,跟 record_screencast.py.sample 字幕脚本对齐
  · 占位符 SKILL_SLUG / PROMPT / EXPECTED_KEYWORDS_IN_REPLY

- README.md: 追加 3 节
  · 模板选择指南(纯问答 vs 附件场景)
  · 已知 SDK 坑(send_file / ask / 字幕关键词 / bring_to_front)
  · 录屏新能力(activate_window / monitor_index / 字幕三元组)

- conftest.py: 同步到支持新版 platform-kit src/ 布局的 SDK 路径推断

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:14:59 +08:00
a656534461 feat(tests/desktop): add record_screencast.py.sample template
Generic screencast recording entry point for skill demo videos.
Copy to record_screencast.py and update SKILL_SLUG + SUBTITLE_SCRIPT.
Requires jiangchang-platform-kit/tools/screencast + mss + ffmpeg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:39:04 +08:00
579db91ab2 fix: revert accidental name change in release_skill.yaml 2026-05-10 12:11:13 +08:00
fd247f6ec3 测试仓库 2026-05-10 12:04:09 +08:00
d347e36fea chore: minor doc polish after refactor review
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 11:56:05 +08:00
5c18d308bb chore: bump version to 1.0.14 for release
All checks were successful
技能自动化发布 / release (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 10:08:13 +08:00
f0032b2201 docs: generalize skill-template references + add TESTING.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 10:01:15 +08:00
f3f59278b4 refactor: task_logs, task_service, run CLI; remove platform_playwright
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 10:00:37 +08:00
4730cd527d 继续完善测试模板 2026-05-03 18:08:57 +08:00
dd6236866e 新增完善测试模板 2026-05-03 17:52:52 +08:00
07fa0b0038 修正开发者ID 2026-04-19 18:19:51 +08:00
b1e93a323f docs: generalize skill template development guide
All checks were successful
技能自动化发布 / release (push) Successful in 23s
2026-04-14 10:05:16 +08:00
f214598470 docs: explain developer_ids in skill template
All checks were successful
技能自动化发布 / release (push) Successful in 44s
2026-04-14 09:19:01 +08:00
5abe67f340 docs: add developer_ids to skill template metadata
All checks were successful
技能自动化发布 / release (push) Successful in 42s
2026-04-14 09:04:31 +08:00
599d7cff48 docs: add Codex and open tool links in new tab
All checks were successful
技能自动化发布 / release (push) Successful in 41s
2026-04-13 15:20:50 +08:00
f1060f2a26 docs: add recommended AI development tools
All checks were successful
技能自动化发布 / release (push) Successful in 33s
2026-04-13 15:11:51 +08:00
864c1a5e2b docs: open documentation links in new tab
All checks were successful
技能自动化发布 / release (push) Successful in 24s
2026-04-13 15:04:26 +08:00
3e3d8c79ad docs: add requirements link to README
All checks were successful
技能自动化发布 / release (push) Successful in 23s
2026-04-13 14:59:19 +08:00
ba3d86553f docs: add requirements template
All checks were successful
技能自动化发布 / release (push) Successful in 25s
2026-04-13 14:48:28 +08:00
a5b56c09f5 docs: add production release verification guide
All checks were successful
技能自动化发布 / release (push) Successful in 25s
2026-04-13 14:28:16 +08:00
daadf8e25c docs: add direct development guide link
All checks were successful
技能自动化发布 / release (push) Successful in 31s
2026-04-13 13:52:40 +08:00
298448840d docs: standardize skill-template and add development guide
All checks were successful
技能自动化发布 / release (push) Successful in 22s
2026-04-13 13:46:23 +08:00
f11c596bde Revert "feat: release ZIP keeps scripts/ tree; JIANGCHANG_SKILLS_ROOT and sibling skills root"
This reverts commit f973208fe3.
2026-04-06 18:45:28 +08:00
f973208fe3 feat: release ZIP keeps scripts/ tree; JIANGCHANG_SKILLS_ROOT and sibling skills root
All checks were successful
skill-release-placeholder / release (push) Successful in 1s
2026-04-06 18:38:29 +08:00
5bc3ce6810 chore: sync from OpenClaw workspace (2026-04-05 09:17) 2026-04-05 09:17:42 +08:00
127 changed files with 10067 additions and 464 deletions

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# ── 运行模式 / adapter 档位(见 references/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
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──
STEP_DELAY_MIN=1.0 # 步骤间随机等待下限(秒)
STEP_DELAY_MAX=5.0 # 步骤间随机等待上限(秒)
HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒)

View File

@@ -1,27 +1,13 @@
# -----------------------------------------------------------------------------
# 技能发布工作流(占位)
# -----------------------------------------------------------------------------
# 不同组织使用不同的复用工作流、制品格式与加密策略。
# 使用本模板时,请将下面 jobs 整段替换为你们自己的 workflow
# 或删除本文件若暂不需要 GitHub Actions。
#
# 设计原则(行业常见):
# - 通过 tag 触发发布(如 v*
# - 制品与版本号可追溯,与 SKILL.md 中 version 对齐
# -----------------------------------------------------------------------------
name: skill-release-placeholder
name: 技能自动化发布
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Placeholder
run: |
echo "Replace this workflow with your organization's reusable workflow."
echo "Example pattern: jobs.release.uses: <org>/<kit>/.github/workflows/reusable-release-skill.yaml@ref"
exit 0
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
secrets:
PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }}
with:
artifact_platform: windows
pyarmor_platform: windows.x86_64

12
.gitignore vendored
View File

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

108
README.md
View File

@@ -1,23 +1,99 @@
# 头条号批量发布toutiao-publisher
---
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。"
---
基于团队 **skill-template** 骨架的头条号发布技能仓库;当前为**占位阶段**,仅含 CLI 骨架与文档,**发布功能待后续迭代**。
> **模板说明(复制后删除本段)**
> 本文是技能市场详情与普通用户说明,**不是** Agent 参考索引、CLI 契约或开发文档。
> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约、数据结构、开发规范或 Agent 参考索引。
> 正文只回答用户关心的问题,**不要**把 Playwright、DOM、Python、RPA、references、development、schema 等作为面向用户的主说明内容;若业务上必须出现技术词,须用用户能理解的方式解释,且不能喧宾夺主。
## 目录一览
# 【技能名称】
| 路径 | 作用 |
|------|------|
| `SKILL.md` | 技能清单YAML 头 + Markdown 正文) |
| `scripts/skill_main.py` | CLI 入口:`health` / `version` |
| `docs/` | 运行时与可移植性说明 |
| `optional/` | 可选片段路径、SQLite 等),默认不引用 |
> 用一两句话说明这个技能帮助用户完成什么工作。
> 这里不要解释技术实现,要从用户的业务目标出发描述价值。
## 本地试跑
---
```bash
python scripts/skill_main.py health
python scripts/skill_main.py version
```
## 这个技能能帮你做什么
## 版本
用 3 到 6 条业务能力说明技能价值,例如:
`SKILL.md``version` 字段对齐更新。
- 【自动整理/查询/生成/同步/核对/提交】某类业务数据
- 帮你减少重复操作,降低人工录入或核对成本
- 在任务完成后给出清晰的处理结果、失败原因或后续建议
---
## 适合什么场景
列出典型使用场景。请使用用户熟悉的业务语言,例如:
- 每天需要重复处理同一类订单、单据、报表或客户资料
- 需要从多个系统中查询信息并汇总结果
- 需要按固定规则检查数据是否完整、是否异常
- 需要把处理结果保存为文件、记录或任务报告
---
## 使用前需要准备什么
说明用户在使用前要准备的东西,例如:
- 相关账号已登录,且具备查询、导出或提交权限
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
- 如果涉及外部系统,请确认网络、账号、验证码或审批流程可正常使用
---
## 你可以这样告诉 Agent
用自然语言示例展示用户可以怎么发起任务,例如:
> “帮我查询今天的【业务对象】,整理成表格。”
>
> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。”
>
> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。”
>
> “帮我根据这个文件里的内容,逐条完成【业务动作】。”
> 注意:示例应是自然语言,不要写命令行命令。
---
## 执行后你会得到什么
说明用户会看到什么结果,例如:
- 任务是否成功完成
- 处理了多少条数据
- 哪些内容成功、哪些失败
- 失败原因和建议处理方式
- 生成的文件、表格、报告或记录
---
## 注意事项
用用户能理解的方式说明边界和风险,例如:
- 请确认输入范围准确,避免处理错数据
- 涉及提交、付款、删除、审批等高风险动作时,技能应在执行前请求确认
- 如果外部系统登录失效、权限不足或页面变化,任务可能需要用户介入
- 不建议在未核对数据的情况下直接执行批量操作
---
## 常见问题
### 为什么任务没有查到数据?
> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。
### 为什么任务执行到一半需要我操作?
> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。
### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。

View File

@@ -1,52 +1,90 @@
---
# ---------------------------------------------------------------------------
# 技能清单Skill Manifest
# ---------------------------------------------------------------------------
name: 头条号批量发布
description: 头条号批量发布技能(骨架阶段:仅健康检查与版本;发布逻辑待实现)。
version: 0.1.0
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.14
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: toutiao-publisher
emoji: "📰"
category: "内容发布"
skill:
slug: toutiao-publisher
emoji: "📰"
category: "内容发布"
slug: your-skill-slug
platform_kit_min_version: "1.0.17"
emoji: "📦"
category: "通用"
developer_ids:
- 10032
- 12428
allowed-tools:
- bash
---
# 头条号批量发布toutiao-publisher
# 技能开发模板skill-template
## 使用时机
这是一个**用于复制的新技能模板**,不是业务 skill 本身。复制后请替换占位内容,实现你的真实业务逻辑。
- 用户需要**在头条号侧批量或自动化发布内容**时(具体话术与流程待业务实现后补充)。
## 面向用户问答LLM 规则)
## 执行步骤
- 本文(`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/``development/``evals/``references/``scripts/``tests/`
- CLI 入口固定为 `scripts/main.py`
- 业务逻辑按 `cli / db / service / util` 分层
## 最小命令
```bash
python3 {baseDir}/scripts/skill_main.py health
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`
```bash
python3 {baseDir}/scripts/skill_main.py version
```
## 运行依赖
### 发布与其它子命令
- 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/` 下增加发布入口,并在此文档补充命令与参数说明。
## 定制入口
## 环境依赖
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`
详见本仓库 `docs/RUNTIME.md``CLAW_DATA_ROOT``CLAW_USER_ID` 等)。
## 平台元数据
## 数据与隐私
本技能若产生持久化数据,应仅写入 `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/toutiao-publisher/` 下;不得将用户数据提交到版本库
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。
-`access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`

8
assets/README.md Normal file
View File

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

View File

@@ -0,0 +1,4 @@
# 示例 JSON
- `version-response.json``python main.py version` 成功时 stdout 单行对象的形状参考。
- `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。

View File

@@ -0,0 +1,12 @@
{
"id": 1,
"task_type": "demo_task",
"target_id": "demo_target_1",
"input_id": "demo_input_42",
"input_title": "示例任务输入标题",
"status": "success",
"error_msg": null,
"result_summary": "{\"processed\": 10, \"skipped\": 0}",
"created_at": "2026-04-01T10:00:00",
"updated_at": "2026-04-01T10:00:00"
}

View File

@@ -0,0 +1,4 @@
{
"version": "1.0.0",
"skill": "your-skill-slug"
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/task-log-record.schema.json",
"title": "TaskLogRecord",
"description": "通用业务技能 log-get 返回的单条任务日志对象模板",
"type": "object",
"required": ["id", "task_type", "status", "created_at", "updated_at"],
"properties": {
"id": { "type": "integer" },
"task_type": { "type": "string" },
"target_id": { "type": ["string", "null"] },
"input_id": { "type": ["string", "null"] },
"input_title": { "type": ["string", "null"] },
"status": { "type": "string" },
"error_msg": { "type": ["string", "null"] },
"result_summary": { "type": ["string", "null"] },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
},
"additionalProperties": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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_*` 环境变量与数据目录约定

775
development/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,775 @@
# 技能开发教程
这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到 `skill-template` 后,可以**一步一步开发出一个新的 skill**。
本文默认你开发的是当前最常见的一类业务 skill
- 有明确的 `scripts/main.py` CLI 入口
- 可能需要读写本地 SQLite例如任务日志 `task_logs`
- 可能需要调用兄弟技能或外部 HTTP / RPA
- 业务逻辑主要放在 `scripts/service/`
发布、对账、代发、报表分析等场景都只是业务特例;模板提供通用骨架,复制后按领域补齐实现即可。
## 推荐 AI 开发工具
当前 skill 开发建议尽量配合 AI 编程工具使用。这样做不是为了替代技术人员,而是为了提升以下环节的效率:
- 搭建标准目录结构
- 生成样板代码
- 理解旧项目代码
- 批量补文档、注释和测试
- 辅助排查报错与重构代码
建议团队统一选择 1 到 2 个主力工具长期使用,避免每个人工具链差异太大,导致协作方式不一致。
下面先列国外主流工具,再列国内主流工具。链接优先使用官方站点、官方文档或官方安装入口。
### 国外主流工具
| 工具 | 类型 | 适合场景 | 官方入口 |
|------|------|----------|----------|
| Cursor | 独立 AI IDE | 代码编辑、Agent 开发、整仓理解 | <a href="https://www.cursor.com/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://www.cursor.com/downloads" target="_blank" rel="noopener noreferrer">下载</a> |
| Windsurf | 独立 AI IDE | Agent 编程、项目生成、连续开发流 | <a href="https://docs.codeium.com/windsurf" target="_blank" rel="noopener noreferrer">文档</a> / <a href="https://windsurf.com/download" target="_blank" rel="noopener noreferrer">下载</a> |
| GitHub Copilot | IDE 插件 / 编程助手 | 日常补全、解释代码、生成函数、配合 VS Code 或 JetBrains 使用 | <a href="https://github.com/copilot" target="_blank" rel="noopener noreferrer">官网</a> |
| Claude Code | 终端 / IDE 编程代理 | 命令行开发、代码库分析、自动改代码、运行命令 | <a href="https://www.anthropic.com/claude-code" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://docs.anthropic.com/en/docs/claude-code/" target="_blank" rel="noopener noreferrer">文档</a> |
| Codex | 终端 / IDE / Web 编程代理 | OpenAI 官方编码代理,适合代码生成、理解、调试、评审 | <a href="https://developers.openai.com/codex/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://developers.openai.com/codex/quickstart" target="_blank" rel="noopener noreferrer">快速开始</a> |
| Aider | 终端 AI 编程工具 | 已有代码仓库的增量开发、终端协作、快速提交 | <a href="https://www.aider.chat/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://aider.chat/docs/" target="_blank" rel="noopener noreferrer">文档</a> |
| Cline | VS Code / JetBrains 插件 | 编辑器内 Agent 开发、命令执行、浏览器联动调试 | <a href="https://cline.bot/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://docs.cline.bot/introduction/welcome" target="_blank" rel="noopener noreferrer">文档</a> / <a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank" rel="noopener noreferrer">VS Code 插件</a> |
### 国内主流工具
| 工具 | 类型 | 适合场景 | 官方入口 |
|------|------|----------|----------|
| Trae | 独立 AI IDE | AI 辅助写代码、项目搭建、对话式开发 | <a href="https://www.trae.ai/home" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://www.trae.ai/download" target="_blank" rel="noopener noreferrer">下载</a> |
| 通义灵码 | 独立 IDE / IDE 插件 | 国内团队日常编码、问答、补全、代码生成 | <a href="https://tongyi.aliyun.com/lingma/?channel=yy_AiBot" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://tongyi.aliyun.com/lingma/download" target="_blank" rel="noopener noreferrer">下载</a> |
| CodeGeeX | IDE 插件 / 开源助手 | 代码补全、生成、注释、跨语言辅助 | <a href="https://github.com/zai-org/CodeGeeX" target="_blank" rel="noopener noreferrer">GitHub</a> / <a href="https://marketplace.visualstudio.com/items?itemName=aminer.codegeex" target="_blank" rel="noopener noreferrer">VS Code 插件</a> |
| 腾讯 CodeBuddy | IDE 插件 | 代码补全、测试生成、智能问答、腾讯云开发体系协作 | <a href="https://www.codebuddy.ai/" target="_blank" rel="noopener noreferrer">官网</a> / <a href="https://www.tencentcloud.com/document/product/1256" target="_blank" rel="noopener noreferrer">文档</a> / <a href="https://marketplace.visualstudio.com/items?itemName=Tencent-Cloud.coding-copilot" target="_blank" rel="noopener noreferrer">VS Code 插件</a> |
| 百度文心快码Baidu Comate | IDE 插件 | 国内研发团队辅助编码、解释、测试、优化 | <a href="https://comate.baidu.com/zh" target="_blank" rel="noopener noreferrer">官网</a> |
### 选型建议
如果你们团队主要做这类 Python skill 开发,我建议这样选:
- 想要一体化最强体验:优先试 `Cursor``Windsurf`
- 想要命令行深度协作:优先试 `Claude Code``Aider`
- 想继续基于 VS Code 插件体系:优先试 `GitHub Copilot``Cline``通义灵码``CodeBuddy`
- 想优先使用国内生态与中文支持:优先试 `Trae``通义灵码``CodeGeeX``CodeBuddy``文心快码`
### 团队落地建议
为了减少培训成本,建议内部至少统一一套主工具方案:
- 国外方案:`Cursor` + `Claude Code`
- 国内方案:`Trae` + `通义灵码`
- VS Code 插件方案:`GitHub Copilot` + `Cline`
不建议每位技术人员完全自由发挥,否则后续在:
- 提示词写法
- 代码修改习惯
- 调试方式
- 提交节奏
这些方面会越来越不统一。
## 1. 先理解模板的定位
`skill-template` 不是业务 skill它只是一个**新 skill 仓库模板**。
你不应该直接在这个仓库里开发业务,而应该:
1. 复制这个目录
2. 改成新 skill 的目录名
3. 把占位内容替换掉
4. 再开始写业务逻辑
## 2. 新 skill 的标准目录结构
复制模板后,你应该保留下面这套结构:
```text
your-skill/
├─ .github/
├─ assets/
├─ development/
├─ evals/
├─ references/
├─ scripts/
│ ├─ cli/
│ ├─ db/
│ ├─ service/
│ └─ util/
├─ tests/
├─ .gitignore
├─ README.md
├─ requirements.txt
├─ release.ps1
└─ SKILL.md
```
各目录职责如下:
- `assets/`放示例输出、schema、静态说明资源
- `README.md`:面向用户的市场详情页说明
- `SKILL.md`:面向 LLM / 平台的技能入口
- `references/`Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA
- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范
- `scripts/`:放真正的代码
- `tests/`:放自动化测试
- `evals/`:放人工验收材料、评估清单、示例场景
## 3. `scripts/` 内部如何分层
`scripts/` 不是乱放 Python 文件,而是按职责分层:
```text
scripts/
├─ main.py
├─ cli/
├─ db/
├─ service/
└─ util/
```
每层的职责要明确:
- `main.py`
作用:唯一 CLI 入口
只做启动、编码兼容、引入 `cli.app`
- `cli/`
作用:参数解析、命令分发、用法说明
不写具体业务逻辑
- `db/`
作用SQLite 连接、建表、repository
不写页面操作和业务编排
- `service/`
作用:核心业务逻辑
比如任务编排、调用兄弟技能、外部 API、可选浏览器自动化
- `util/`
作用:常量、日志、路径、时间工具、通用帮助函数
公共能力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. **先读三份标准**`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
```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
4. **mock 档必须离线可跑**`OPENCLAW_TEST_TARGET=mock`sim_rpa / real_* 按需单独测。
5. **桌面/手机**:本期标准见 RPA.md 第 2/3 节,复用 `jiangchang_desktop_sdk` / `screencast`**不要在新 skill 里重复造包**(尚待实战验证)。
---
### 3.1 `requirements.txt` 依赖规范
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。
- **公共依赖**`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`
- 对**原生扩展 / 高风险依赖**(如 `chromadb`、`onnxruntime`)建议 pin 或窄范围,避免无上限:
- 不推荐:`chromadb>=0.5.0`
- 推荐:`chromadb>=0.5.23,<0.6`
- **不要**把系统组件VC++ Runtime、浏览器安装包等写进 `requirements.txt`;这类前置条件写在 `health` / preflight 文档与错误提示中。
- 若技能无额外 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 的标准步骤
下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。
### 第一步:复制模板并改目录名
例如你要开发 `your-skill-slug`(或 `disburse-payroll-icbc` 一类领域 skill
1. 复制 `skill-template`
2. 新目录改成与 `slug` 一致的名称(如 `your-skill-slug`
3. 初始化为独立 git 仓库
4. 关联它自己的远端仓库
目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。
### 第二步:先改 4 个最关键的标识
复制后优先改下面这些地方:
1. `SKILL.md`
2. 根目录 `README.md`(用户市场说明)
3. `scripts/util/constants.py`
4. `references/` 与 `development/` 下的文案
5. `scripts/service/` 下的业务占位实现(优先改 `task_service.py`
最先要统一的是:
- 技能中文名
- `slug`
- 平台中文名
- 平台内部键
- 日志 logger 名
此外,如果该技能发布后默认不公开(`access_scope = 0`),建议一开始就把 `SKILL.md` 中的 `metadata.openclaw.developer_ids` 配好。这样后续发布到平台时,开发者本人仍能在技能市场中看到并验证该技能。
## 5. 哪些占位内容必须替换
复制后,至少要全局检查并替换下面这些内容:
- `your-skill-slug`
- `your-platform-key`(若 skill 涉及外部平台对接,可能会用到这类占位)
- `技能开发模板(通用业务版)`
- `你的平台名`
- `openclaw.skill.your_skill_slug`
如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换:
- `run` 命令中的中文提示与别名策略(见 `../references/CLI.md`
- `../references/CLI.md` 的命令示例
- `../README.md` 的用户话术
- `../references/SCHEMA.md` 的字段映射补充说明
如需浏览器自动化,不要在模板里保留空的 `platform_playwright.py`;请按业务新建 `xxx_playwright.py`(命名自定),并在 `task_service.py` 中按需引用。
## 6. `SKILL.md` 与根 `README.md` 应该怎么写
### `SKILL.md`LLM / 平台入口)
`SKILL.md` 是给 LLM 与 OpenClaw 平台看的技能入口,**不是**用户市场详情页。
应该重点写:
- 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 等技术细节。
实现细节放在:
- `development/`
- `references/`CLI / SCHEMA
- 代码注释与 `service/` 实现
### 关于 `metadata.openclaw.developer_ids`
这是一个平台发布元数据字段,用于解决下面这个问题:
- 技能发布后若平台记录中的 `access_scope = 0`,技能默认不公开
- 如果不额外授权,连开发者自己也可能在技能市场里看不到这个技能
因此可以在 `SKILL.md` 中声明:
```yaml
metadata:
openclaw:
slug: your-skill-slug
category: 通用
developer_ids:
- 1032
- 12428
```
约定如下:
- 只允许填写正整数用户 ID
- 推荐使用数组,即使当前只有 1 个开发者
- 发布时平台会把这些用户自动补写到 `skill_user_access`
- 第一个 ID 会同步到 `skills.developer_id`
- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权
## 7. 文档目录分工
当前规范 skill 的文档按读者拆分:
**根 `README.md`**
- 用户市场详情页说明(含 frontmatter `description`
**`SKILL.md`**
- LLM / OpenClaw 平台技能入口
**`references/`**Agent 运行/编排/调用时渐进加载)
- `README.md` — Agent 参考索引(**不是**市场说明;不得含 YAML frontmatter 或 `description`
- `CLI.md` — 命令、参数、默认值、兄弟技能调用方式
- `SCHEMA.md` — 数据库路径、核心表结构、日志表结构
- 可按需增加 `ERRORS.md`、`INTEGRATION.md` 等编排向补充
**`development/`**(开发者 / AI 编程代理)
- `REQUIREMENTS.md` — 需求与验收
- `DEVELOPMENT.md` — 开发教程(本文档)
- `TESTING.md` — 测试分层与隔离(详见 [`TESTING.md`](TESTING.md)
- `ADAPTER.md`、`RPA.md`、`CONFIG.md`、`RUNTIME.md` — 技术规范
## 8. `assets/` 应该放什么
`assets/` 不放业务代码,只放辅助材料。
建议放:
- `examples/`
比如 `version` 输出示例、`log-get` 输出示例
- `schemas/`
比如日志记录、机读 JSON 的 schema
不要把正式研发文档放到 `assets/`。
用户说明进根 `README.md`Agent 编排资料进 `references/`;开发规范进 `development/`。
## 9. `cli` 层怎么写
建议保持一个原则:
- `cli` 只负责解析参数和路由
- `service` 才负责干活
也就是说,`cli/app.py` 的职责是:
1. 打印帮助
2. 定义 `run / logs / log-get / health / version`
3. 把参数转交给 `service.task_service`
不要在 `cli/app.py` 里直接写:
- 浏览器自动化
- 子进程调用兄弟技能
- SQLite 逻辑
## 10. `service` 层怎么写
`service` 是核心层。
通常可以这样拆:
- `task_service.py`
放命令编排、参数兜底、结果分流(例如 `cmd_run`
- `sibling_bridge.py`
放通用兄弟技能子进程工具(`call_sibling_json`**具体调用哪个 slug 由业务在 `task_service.py` 决定**,不要在本文件硬编码发布类专用 helper
- `xxx_playwright.py`(按需新建)
浏览器后台自动化 **不在模板默认结构中**;若确有需要,按命名约定自建模块并在 `task_service.py` 引用
- `entitlement_service.py`
放鉴权逻辑
### 一个很重要的原则
不要把所有逻辑都堆进一个文件。
推荐流向是:
`cli.app` -> `service.task_service` -> `service.sibling_bridge` / (可选)`service.xxx_playwright` -> `db`
## 11. `db` 层怎么写
如果你的 skill 需要记录日志或本地状态,建议:
- `db/connection.py`
只做连接和建表
- `db/task_logs_repository.py`
只做增删查改(模板默认表:`task_logs`
不要在 `db` 层里:
- 调浏览器
- 打印用户提示
- 拼接业务流程
## 12. 如何接兄弟技能
如果 skill 要依赖兄弟 skill不要在业务代码里写死绝对路径。
统一通过:
- `scripts/util/runtime_paths.py`
- `scripts/service/sibling_bridge.py`
来做调用。
调用哪些兄弟技能由具体业务决定;请在 `task_service.py` 中使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)` 或 `get_sibling_main_path(skill_slug)`,不要在本模板仓库的 `sibling_bridge.py` 中堆积特定技能函数。
调用原则:
1. 通过 `get_skills_root()` 找到兄弟技能根目录
2. 再拼出对应 `scripts/main.py`
3. 用子进程调用
4. 机器可读输出优先 JSON
## 13. 如何开发一个新 skill
不管你开发的是发布类、采集类、分析类还是知识库类 skill建议都先按下面这个顺序推进
1. 先把目录结构搭完整
2. 先让 `health` / `version` 跑通
3. 再把核心 `service` 骨架跑通
4. 再接兄弟技能桥接、数据库或外部系统
5. 最后再补浏览器自动化、复杂流程编排或高风险集成
不要一开始就直接写页面选择器、复杂接口编排或深层业务逻辑。
推荐先确保这些基础能力正常:
- CLI 入口能跑通
- 基础命令输出稳定
- 关键依赖能取到
- 日志或本地状态能落下来
- 错误返回值格式定好了
如果你的 skill 属于外联型 / RPA 型,可以把上面的“核心 `service`”具体落成 `task_service.py`,再按需接 `sibling_bridge.py`、(可选)`*_playwright.py`。这只是示例路径组合,不代表模板绑定某一业务领域。
## 14. 本地开发的最小验证顺序
建议每次新 skill 开发时按下面顺序验证:
### 1. 验证入口
```bash
python scripts/main.py health
python scripts/main.py version
```
### 2. 验证 CLI 路由
```bash
python scripts/main.py -h
python scripts/main.py <your-command> -h
```
### 3. 验证本地日志与数据库
如果你的 skill 需要本地日志或数据库,再继续:
```bash
python scripts/main.py <your-log-command>
python scripts/main.py <your-detail-command> <id>
```
如果你沿用了模板中的通用任务日志骨架,那么这里可以具体对应成:
```bash
python scripts/main.py logs
python scripts/main.py log-get 1
```
### 4. 最后再验证真实业务
比如:
```bash
python scripts/main.py <your-command>
```
## 14.5 测试驱动的开发顺序
新 skill 不应该等所有业务做完才补测试。建议在每个开发阶段都先把对应测试跑一遍:
1. **目录搭建后**:跑 `python tests/run_tests.py -v`,确认全部默认测试套件通过。
- 此时 `test_skill_metadata.py` 会校验 `SKILL.md` slug 与 `constants.SKILL_SLUG` 一致;如果你只改了一边,会立刻发现。
2. **改完 constants 后**:再跑一次必跑套件,确认未引入回归。
3. **写完 service 业务后**:从 `tests/samples/test_service_contract.py.sample` 复制一份做契约测试。
4. **接外部系统时**:写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` 显式开启。
详见 `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 发布到正式环境做一次完整验证。建议技术人员严格按下面顺序执行,不要跳步。
### 第一步:在 skill 根目录执行 `release.ps1`
在当前 skill 仓库根目录执行:
```powershell
.\release.ps1
```
如果你的技能使用了 `metadata.openclaw.developer_ids`,那么这一步触发的发布工作流除了同步 `skills` / `skill_versions` 外,还会在平台侧自动补开发者可见权限。测试非公开技能时,建议重点验证这部分是否生效。
这一步会自动完成标准发布动作,包括:
1. 检查当前仓库状态
2. 自动提交尚未提交的改动
3. 推送最新代码到远程仓库
4. 自动创建新的语义化版本 tag
5. 推送 tag触发后续 CI 流程
如果你只是想先预览发布动作,可以先执行:
```powershell
.\release.ps1 -DryRun
```
### 第二步:到 Gitea 仓库查看工作流
发布命令执行完成后,远程仓库会自动触发 Gitea 工作流。此时应立即进入对应 skill 的仓库页面,切换到“工作流”页签查看执行状态。
重点确认:
- 是否已经出现最新一次发布记录
- 是否由最新提交触发
- 是否成功执行完 `release_skill.yaml`
下面这张图演示了在仓库页进入“工作流”的位置:
![Gitea 仓库工作流入口](../assets/screenshots/gitea-workflow-tab.png)
下面这张图演示了工作流成功后的状态页面:
![Gitea 工作流成功示例](../assets/screenshots/gitea-workflow-success.png)
### 第三步:确认工作流执行成功
只有当工作流状态为成功,才说明正式发布产物已经正确构建完成。
如果工作流失败,不要继续做平台验证,而应该先回到代码仓库排查,例如:
- 发布脚本是否正常推送
- `SKILL.md`、根 `README.md`、`scripts/`、`references/`、`development/` 是否齐全
- 工作流文件是否存在
- 发布包结构是否符合模板规范
### 第四步:进入匠厂平台下载安装包
当工作流成功后,就可以进入匠厂平台验证最终安装效果。
匠厂产品下载地址:
- [https://jc2009.com/product.html](https://jc2009.com/product.html)
打开页面后,下载并安装匠厂客户端。下面这张图演示了下载入口位置:
![匠厂客户端下载入口](../assets/screenshots/jc2009-download-page.png)
匠厂产品页可从这里进入:[产品下载 - 匠厂](https://jc2009.com/product.html)
### 第五步:安装匠厂后,在技能市场检查最新 skill
安装并启动匠厂后,进入左侧“技能市场”,搜索或查找刚刚发布的 skill确认以下内容
- 技能可以被正常检索到
- 技能名称、说明、版本信息正确
- 最新版本已经同步出来
- 可以正常安装或更新
下面这张图演示了在技能市场中查看已发布 skill 的位置:
![技能市场中的已发布技能](../assets/screenshots/market-installed-skill.png)
### 第六步:安装并实际启用该 skill
在技能市场中找到目标 skill 后,执行安装或更新操作,确保客户端本地已经拿到最新版本。
这一步建议至少确认:
- 安装按钮可以正常执行
- 安装后状态正常
- 不会出现缺文件、缺入口或安装失败的问题
### 第七步:在“新建任务”中实际使用该 skill
安装完成后,不要只停留在“已安装”状态,还需要进入“新建任务”页面,真正调用一次该 skill完成最终验证。
建议至少验证:
- 新任务中可以正常选择或触发该 skill
- skill 能被正确唤起
- 主要命令或主流程可以运行
- 返回结果、日志和行为符合预期
下面这张图演示了安装后在任务界面中实际使用 skill 的场景:
![新建任务中使用技能](../assets/screenshots/new-task-usage.png)
## 16. 发布前检查清单
每个新 skill 发布前,建议技术人员逐条确认:
- [ ] 目录结构符合当前模板
- [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `scripts/util/constants.py` 已修改
- [ ] `../references/CLI.md` 示例命令已改成真实命令
- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
- [ ] 没有残留旧平台名
- [ ] `health` / `version` 可运行
- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去
- [ ] `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` 后缀
## 17. 常见错误
### 错误 1只改了目录名没改 slug
表现:
- 数据目录不对
- 版本输出不对
- 日志名不对
要检查:
- `SKILL.md`
- `scripts/util/constants.py`
### 错误 2平台中文名改了内部键没改
表现:
- 兄弟技能筛选账号失败
- `run` 命令走错平台或 task_type
要检查:
- `task_service.py`
- `sibling_bridge.py`
- `../references/CLI.md`
### 错误 3把业务逻辑写进 CLI
表现:
- 文件越来越乱
- 不方便测
- 参数和业务耦合太重
要改回:
- `cli` 只做参数解析
- `service` 才做业务
### 错误 4保留了旧模板结构
表现:
- 仓库同时存在 `docs/`、`optional/`、`skill_main.py`
- 技术人员不知道看哪一套
现在的新模板原则是:
- 不做旧结构兼容
- 用户说明在根 `README.md`Agent 资料在 `references/`,开发规范在 `development/`
- 统一走 `scripts/main.py` 作为 CLI 入口
## 18. 推荐开发顺序总结
如果让一个新人照着做,我建议他按这个顺序:
1. 复制模板并改目录名
2. 改 `SKILL.md` 与根 `README.md`
3. 改 `scripts/util/constants.py`
4. 改 `references/` 与 `development/`
5. 改 `scripts/cli/app.py`
6. 改 `scripts/service/`
7. 跑 `python tests/run_tests.py -v`
8. 跑 `health` / `version`
9. 再做业务联调
10. 最后 release
## 19. 这份模板的底线要求
以后新建 skill至少要满足这几点
- 目录结构统一
- 入口统一为 `scripts/main.py`
- 用户说明在根 `README.md`Agent 资料在 `references/`,开发规范在 `development/`
- 业务核心逻辑统一放 `scripts/service/`
- 不再使用旧模板历史结构
如果做不到这些,后面 skill 一多,就会越来越乱。

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),不要写进本目录。

248
development/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,248 @@
# 技能需求文档模板
这份文档用于给技术人员、产品人员和实施人员统一描述某个 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`
- 真实联调(如有)放在 `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` — 运行时目录与环境变量约定

124
development/RUNTIME.md Normal file
View File

@@ -0,0 +1,124 @@
# 运行时约定
## 共享 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
```
## 明确禁止
- **不得** 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 行数约束。

227
development/TESTING.md Normal file
View File

@@ -0,0 +1,227 @@
# 测试开发指南
面向复制 `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/` = 只在人被明确要求时才启用的高风险或重量级路径。
---
## 1. 测试体系总览
本模板把测试分成四层漏斗:**默认必跑unittest + `run_tests.py`**、从 `tests/samples/` **按需复制的 service / golden**、放在 `tests/integration/` **默认不落盘的仿真或真实联调范式(多数仍是 `.sample`**、以及 **desktop E2Epytest + 宿主 SDK**
[`tests/run_tests.py`](../tests/run_tests.py) 只做三件事:把 `scripts/``tests/` 放进 `sys.path`、做 Windows UTF-8 包装、收集 **`tests/` 根目录**下的 `test_*.py`。它不递归子目录——这正是刻意的安全边界:**不想让 AI 或拷贝粘贴 accidentally 把 integration 拉进默认套件**。
当你在设计一个新 skill 的测试策略时,请先问自己:**这段代码在没有外部凭证与浏览器的前提下是否有意义?** 若有,留在默认套件;若无,放进 integration / `.sample`,并要求明确的 `OPENCLAW_TEST_TARGET` 组合开关。
更深表格化的目录映射、`FakeAdapter` 与 profile 的耦合细节见 [`tests/README.md`](../tests/README.md) 开头章节『我该把测试写在哪里』。
---
## 2. 默认必跑测试要做什么
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
- 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`**`JIANGCHANG_*` 隔离**
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py)
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
- **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并由档位变量放行
套件能力与表格参见 [`tests/README.md`](../tests/README.md) 「1.2 默认套件覆盖」。
---
## 3. 数据隔离IsolatedDataRoot
[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,写入 `JIANGCHANG_DATA_ROOT`tempfile`JIANGCHANG_USER_ID``_test`
结束时:**恢复原 environ**,删除目录。
这样可以断言:**SQLite DB / spill files / caches** 都在 sandbox不会在开发者桌面遗留 `{REAL_ROOT}`
示例:
```python
from _support import IsolatedDataRoot
def test_whatever():
with IsolatedDataRoot():
from db.connection import init_db
init_db()
# …断言读写均在隔离路径…
```
**不要把真实凭证路径硬编码进默认测试**:隔离不等于你有权触碰真实目录。
---
## 4. 测试目标档位OPENCLAW_TEST_TARGET
模板采用统一闸门:**你想跑到哪一层外部世界,就用变量明说**。合法档位(非法值会让 helper 抛错)如下——**直接摘录自 [`tests/README.md`](../tests/README.md) §5.1**
| 取值 | 含义 |
|------|------|
| `unit` | **默认**:单元 / 内存 / mock不跑仿真与真实外联 |
| `mock` | 与 `unit` 类似的安全档位(显式语义) |
| `simulator_api` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| `simulator_rpa` | 仅允许 **仿真页面 / 录播 RPA** |
| `real_api` | 真实 API显式设 `OPENCLAW_TEST_TARGET=real_api` |
| `real_rpa` | 真实 RPA显式设 `OPENCLAW_TEST_TARGET=real_rpa` |
未设置环境变量 ⇒ 等价 `unit`
默认策略摘要:**不要在 unittest 必跑路径误设 `OPENCLAW_TEST_TARGET=real_*`**。
档位读取与业务代码一致:经 `jiangchang_skill_core.config.get("OPENCLAW_TEST_TARGET")`(见 `CONFIG.md`)。
---
## 5. FakeAdapter怎么模拟外部系统
[`tests/adapter_test_utils.py`](../tests/adapter_test_utils.py) 暴露 `FakeAdapter`,典型四种 **mode**
| mode | 用途 |
|------|------|
| `success` | 构造干净的成功响应路径 |
| `timeout` | 模拟悬挂 / 慢链路 |
| `invalid_response` | 畸形负载 / schema drift |
| `unauthorized` | token / license / cookie 失效语义 |
**何时用它**service 层出现『调用第三方 HTTP / RPA stub』但又不能把真实系统纳入 CI。**契约测试**(复制 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))应优先组合 FakeAdapter而不是直接把 CLI when-json 断言堆上天。
把它看成:**你把不确定性收敛到可控的测试矩阵里**,而不是在生产日志里才第一次看到错位字段。
---
## 6. 怎么从 .sample 启用一个测试
步骤模板:
1. 找到范式文件(例如 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))。
2. **复制**到 `tests/` 根:`tests/test_service_contract.py`(去掉 `.sample`)。
3. 打开副本:**替换占位函数名 /技能特有枚举 / adapter profile**,删掉与本技能无关的示例断言。
4. 本地执行:`python tests/run_tests.py -v [可选筛选关键词]`
5. **不要把 integration `.sample` 批量改名混进根目录**——除非你已经读过 [`tests/integration/README.md`](../tests/integration/README.md) 的风险清单。
Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../tests/samples/test_golden_cases.py.sample))。
---
## 7. 真实联调测试的安全约束
任何 touching **真实租户数据** 的路径:
1. **禁止**硬编码 token / cookie / 内部域名落入仓库;
2. **禁止**默认套件隐式导入 integration
3. **真实 RPA** 只能标记为手动触发(双人复核 / 本地 `.env` 不入库)。
范式阅读 [`tests/integration/README.md`](../tests/integration/README.md):那里有针对凭证来源、目录 artifact 忽略策略的补充。
记住:**测试代码也是一种部署面**,别把 staging 凭证写死进仓库。
---
## 8. 新 skill 的最小测试清单
以下清单 **原文摘自 [`tests/README.md`](../tests/README.md) 「新技能最小测试清单」**(复制新仓库后逐项勾选):
- [ ] `python tests/run_tests.py -v` 能通过。
- [ ] `python scripts/main.py health` 能通过。
- [ ] `python scripts/main.py version` 输出 JSON`skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。
- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。
- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API不打开真实 RPA。
- [ ] 至少有 1 个成功路径测试。
- [ ] 至少有 1 个缺必填字段 / 非法输入测试。
- [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。
- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏
- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。
---
## 9. AI 编程工具使用测试时的红线
改编自 [`tests/README.md`](../tests/README.md) 「AI 编程工具注意事项」,压缩成 skill 开发者视角:
| 红线 | 解释 |
|------|------|
| 不改业务凑测试 | 除非需求变更已确认,否则**不要为了让 CI 变绿而砍掉业务分支** |
| **默认套件零外联** | 不把真实 HTTP / 浏览器写进 `tests/test_*.py` |
| `.sample` 尊重 | 集成范式改名前先读完 README别让 `.sample` 悄悄变成根测试 |
| **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref |
| mock 优先 | 逻辑应在 service + FakeAdapter而不是巨胖 CLI 断言 |
| 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 |
| **integration = 显式档位** | 仅 `OPENCLAW_TEST_TARGET``real_*` 只放 integration / 手动触发) |
---
## 10. 测试和发布的关系
**在运行 [`release.ps1`](../release.ps1) 之前,`python tests/run_tests.py -v` 必须绿色**。失败的默认套件意味着:
- 打包路径可能根本不可运行;
- CI 加密前的静态假设可能在宿主崩溃;
- metadata slug 漂移将被市场拒绝。
把『本地 unittest 绿』视作 tag 的前置条件,而不是『有空再跑』。发布流水线成功后仍要做安装验证——那是另一个维度;**测试是第一个维度**。
---
## 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) — 表格、变量与目录细则
- [`DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序
- [`tests/integration/README.md`](../tests/integration/README.md) — 高风险用法

View File

@@ -1,56 +0,0 @@
# 多宿主(多 Claw可移植性说明
## 设计目标
同一套技能仓库应能在**不同 Claw 实现**下工作,只要宿主满足本文档的**最小契约**:能启动进程、传入环境变量、并把 `SKILL.md` 提供给编排层阅读。
## 行业上常见的「技能包」形态
- **声明式清单**Markdown + YAML 头):描述名称、描述、版本、工具权限、触发场景。
- **可执行入口**:一至多个脚本/二进制,由宿主通过 `bash` / `python` 等调用。
- **用户数据与代码分离**:持久化数据落在用户可写目录,不写在安装目录内。
本模板按上述惯例组织;具体宿主如何解析 `SKILL.md` 的 YAML 键名,以各宿主文档为准。
## 标识技能:`metadata.skill`
本模板在 `SKILL.md` 中使用 **`metadata.skill.slug`** 作为**可移植**的机器可读标识(短横线命名,如 `my-skill`)。
若你的宿主仍要求其它键名(例如历史实现里的嵌套字段),请在宿主侧做**映射**,或在 `SKILL.md` 中**并列声明**两组 metadata保持 `slug` 值一致)。不要在业务代码里写死某一宿主品牌名。
## 环境变量:推荐前缀 `CLAW_*`
为减少对单一产品名的耦合,模板文档与可选片段推荐使用:
| 变量 | 含义 |
|------|------|
| `CLAW_DATA_ROOT` | 用户数据根目录(多技能共享的上一级) |
| `CLAW_USER_ID` | 当前工作空间或用户标识,用于数据隔离 |
| `CLAW_SKILLS_ROOT` | 可选;多个技能并排安装时的根目录,便于 `subprocess` 调用兄弟技能 |
宿主若已使用其它名称,推荐在**启动子进程时**注入别名,例如:
- 将宿主内部的「数据根」映射为 `CLAW_DATA_ROOT`
- 将宿主内部「用户 ID」映射为 `CLAW_USER_ID`
这样技能脚本无需分支判断宿主品牌。
## 路径布局约定(逻辑路径)
`CLAW_DATA_ROOT``CLAW_USER_ID` 可用时,本技能推荐将私有数据放在:
```text
{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/
```
其中 `skill_slug``SKILL.md``metadata.skill.slug` 一致。若环境变量缺失,`optional/paths_snippet.py` 中提供了**仅用于开发机**的 fallback见该文件注释生产环境应由宿主注入变量。
## 发布与制品
不同组织对加密、签名、制品格式要求不同。`.github/workflows/release_skill.yaml` 仅作占位:**务必替换**为你们自己的复用工作流或删除。模板不假设必须使用某一家的发布流水线。
## 自检
- [ ] `SKILL.md``slug` 与目录名/制品名策略是否与宿主一致
- [ ] 宿主文档要求的环境变量是否已全部注入
- [ ] 是否在文档中说明了「未注入变量时的行为」(拒绝运行 / 本地 fallback

View File

@@ -1,55 +0,0 @@
# 运行时契约(环境变量与目录)
本文档定义技能进程**建议依赖**的外部条件,便于不同 Claw 宿主统一接入。业务技能应**读取环境变量**,而不是在代码里写死路径或用户名。
## 必需程度说明
- **强烈建议**:生产环境由宿主注入;技能应对缺失给出明确错误提示,避免静默写到意外目录。
- **可选**:没有时技能仍可部分运行(例如只读 `health`)。
## 变量一览
### `CLAW_DATA_ROOT`(强烈建议)
用户数据根。多个技能、多个用户的数据都在此根之下分区。
- 典型场景:组织策略指定的盘符路径或 `~/.your-org-data`
- 未设置时:技能**不应**猜测网络盘;开发机 fallback 仅限 `optional/paths_snippet.py` 中说明的情形。
### `CLAW_USER_ID`(强烈建议)
当前会话所代表的用户或工作空间 ID字符串。与数据隔离强相关。
- 用于拼接:`{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/`
- 未设置时:可用匿名占位(如 `_anon`**仅用于开发**,生产应显式注入。
### `CLAW_SKILLS_ROOT`(可选)
多个技能以并列目录安装时的根路径,例如:
```text
{CLAW_SKILLS_ROOT}/skill-a/scripts/...
{CLAW_SKILLS_ROOT}/skill-b/scripts/...
```
编排型技能若需要通过子进程调用兄弟技能,应基于该变量定位脚本,避免写死绝对路径。
## 本技能推荐的数据目录
```text
{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/
```
- `skill_slug`:与 `SKILL.md``metadata.skill.slug` 一致。
- 在此目录下可放置 SQLite 文件、缓存、上传临时文件等;**不要**向版本库提交该目录内容。
## 标准输出约定(建议)
为便于宿主与自动化解析,建议:
- 致命错误:单行前缀 `ERROR:`,例如 `ERROR:MISSING_ENV_CLAW_DATA_ROOT`
- 成功:人类可读一行或多行;若有机读需求,可用 `JSON` 单行输出并在 `SKILL.md` 中说明格式。
## 与具体宿主的关系
若某宿主文档规定了另一套变量名,应在**宿主启动技能子进程时**注入为本文档中的 `CLAW_*` 名称,或在技能内使用一层极薄的 `getenv` 封装(见 `optional/paths_snippet.py` 注释示例)。**不要在业务模块中散落多套变量名判断。**

View File

@@ -1,33 +0,0 @@
# 技能形态与自检清单(无业务)
开发前先选定形态,避免把「编排、存储、浏览器」混在一个脚本里难以测试。
## 类型 A无状态工具型
- **特征**:不持久化用户数据,或只读配置文件;输入输出主要在 stdin/stdout。
- **数据目录**:通常不需要 `CLAW_DATA_ROOT` 下的专属库;若需要缓存,仍建议放在契约目录下。
- **自检**:离线可跑;`health` 不访问网络也可成功。
## 类型 B本地持久化型
- **特征**:使用 SQLite、本地文件等保存用户数据。
- **数据目录**:必须使用 `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/`
- **自检**:首次运行自动建库/建表;文档中说明库文件路径与备份方式。
## 类型 C编排型调用其它技能或外部 CLI
- **特征**:自身逻辑薄,主要 `subprocess` 或 HTTP 调用其它组件。
- **依赖**:在 `SKILL.md` 中明确写出**先决条件**(兄弟技能已安装、某 CLI 在 PATH 等)。
- **自检**`health` 可检查兄弟可执行文件是否存在;缺失时打印清晰错误。
## 类型 D混合型
- **特征**:既有本地存储,又调用外部能力。
- **建议**:拆模块(存储 / 编排 / 领域逻辑),入口脚本只做参数解析与调度。
## 发布前通用自检
- [ ] `SKILL.md` 中触发条件与示例命令与实际入口一致
- [ ] 未注入 `CLAW_DATA_ROOT` / `CLAW_USER_ID` 时行为已文档化
- [ ] 不向仓库提交用户数据、密钥、大型二进制
- [ ] 错误信息包含「如何修复」(缺什么环境变量、缺哪个依赖)

5
evals/README.md Normal file
View File

@@ -0,0 +1,5 @@
# evals
这里放人工评估脚本、示例输入输出、回归验收清单等材料。
模板仓库保持最小占位,不再放旧式杂项代码片段。

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()

View File

@@ -1,17 +0,0 @@
# optional/ 目录说明
本目录下的文件**不会**被 `scripts/skill_main.py` 自动引用。
## 为什么要单独放
- 避免模板入口脚本依赖过多,同事从「最小 health」起步时零干扰。
- 需要时**整文件复制**到 `scripts/` 或你们自己的包路径下,再按文件头注释改名、改常量。
## 文件列表
| 文件 | 用途 |
|------|------|
| `paths_snippet.py` | `CLAW_*` 数据目录解析与 fallback 说明 |
| `sqlite_minimal.py` | 无业务含义的 SQLite 建表示例 |
本技能数据子目录与 `SKILL_SLUG``toutiao-publisher`;若复制片段到其它项目请改为对应 slug 与表名。

View File

@@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
"""
可选片段:路径与环境变量(请复制到 scripts/ 或作为独立模块使用)
================================================================
【用途】
统一解析「用户数据根」「用户 ID」「本技能私有目录」避免在业务代码里重复 os.getenv。
【使用步骤】
1. 将本文件复制到 skills/your-slug/scripts/paths.py或任意模块名
2. 把 SKILL_SLUG 改为与 SKILL.md 中 metadata.skill.slug 一致。
3. 在业务代码中: from paths import get_skill_data_dir (按实际包路径调整)
【可移植性】
- 优先读取标准名 CLAW_*(见 docs/RUNTIME.md
- 若你的组织在宿主侧仍使用历史变量名,可在此文件 _aliases 列表中追加 (标准名, 备选名)
由宿主注入其一即可;不要在业务里再写第三套名字。
【注意】
- 未设置 CLAW_DATA_ROOT 时的 fallback 仅适合开发机;生产环境应由宿主注入。
"""
from __future__ import annotations
import os
import sys
# TODO: 复制本文件后改为你的 slug与 SKILL.md metadata.skill.slug 一致)
SKILL_SLUG = "toutiao-publisher"
def _getenv_first(names: tuple[str, ...]) -> str:
"""按顺序读取多个环境变量名,返回第一个非空值。"""
for n in names:
v = (os.getenv(n) or "").strip()
if v:
return v
return ""
def get_data_root() -> str:
"""
用户数据根目录。
顺序CLAW_DATA_ROOT → (可选)宿主别名,见下方元组。
若皆空Windows 默认 D:\\claw-data其它系统默认 ~/.claw-data —— 仅开发便利,生产请注入 CLAW_DATA_ROOT。
"""
root = _getenv_first(
(
"CLAW_DATA_ROOT",
# 在此追加组织内别名,例如 "MYORG_USER_DATA_ROOT",
)
)
if root:
return root
if sys.platform == "win32":
return r"D:\claw-data"
return os.path.join(os.path.expanduser("~"), ".claw-data")
def get_user_id() -> str:
"""当前用户或工作空间 ID未设置时用 _anon仅开发"""
uid = _getenv_first(
(
"CLAW_USER_ID",
# 在此追加别名,例如 "MYORG_WORKSPACE_ID",
)
)
return uid or "_anon"
def get_skill_data_dir() -> str:
"""
本技能可写目录:{数据根}/{用户ID}/{skill_slug}/
会自动 os.makedirs(..., exist_ok=True)。
"""
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_skills_root() -> str:
"""
可选:并列安装的多技能根目录。
未设置 CLAW_SKILLS_ROOT 时,默认使用本文件所在仓库的上一级目录的上一级
(即:.../toutiao-publisher/optional/ → 仅作开发时并列技能推断参考)。
"""
root = _getenv_first(("CLAW_SKILLS_ROOT",))
if root:
return root
# 模板占位:脚本位于 scripts/,仓库根为 dirname(dirname(__file__))
here = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(here)

View File

@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
"""
可选片段:最小 SQLite 示例(请复制后按需修改)
==============================================
【用途】
演示「单表 + 自增主键 + INTEGER 时间戳」的一种严谨写法;与具体业务无关。
【使用步骤】
1. 复制到 scripts/db_example.py或并入你的模块
2. 修改 TABLE_SQL 中的表名与字段;保持时间字段为 INTEGER Unix 秒UTC若需跨时区一致。
3. 在入口脚本中仅在需要持久化时 import。
【注意】
- 不在模板中做迁移兼容schema 变更请用你们组织的迁移策略。
- 数据库文件路径建议get_skill_data_dir() / "skill.db"paths_snippet 中函数)。
"""
from __future__ import annotations
import sqlite3
import time
# 示例表:与任何业务无关
TABLE_SQL = """
CREATE TABLE IF NOT EXISTS template_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键
action TEXT NOT NULL, -- 动作标识,如 health_ok
created_at INTEGER NOT NULL -- Unix 秒 UTC
);
"""
def connect(db_path: str) -> sqlite3.Connection:
return sqlite3.connect(db_path)
def init_db(conn: sqlite3.Connection) -> None:
conn.execute(TABLE_SQL)
conn.commit()
def record_action(conn: sqlite3.Connection, action: str) -> None:
conn.execute(
"INSERT INTO template_audit (action, created_at) VALUES (?, ?)",
(action, int(time.time())),
)
conn.commit()

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

87
references/CLI.md Normal file
View File

@@ -0,0 +1,87 @@
# 通用业务技能模板 CLI 标准
`{baseDir}` 替换为技能根目录(含 `SKILL.md``scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。
## 最小命令
```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 demo --status failed
python {baseDir}/scripts/main.py log-get <log_id>
```
| 命令 | 用途 |
|------|------|
| `run` | 执行一次任务 |
| `logs` | 列出最近 N 条任务日志 |
| `log-get` | 按 log_id 查看单条任务日志JSON |
## 查询/导出类(可选)
复制后按需增加,例如:`contacts``contact-get``export` 或业务等价命令。
## 若你的技能依赖兄弟技能
使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 `sibling_bridge.py` 自身;并在 `SKILL.md` 声明 `dependencies.required`
## 手工排查命令(推荐)
**建议使用宿主共享 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` / `config-path` / `version`
- 通用业务技能建议使用 `run` / `logs` / `log-get`
- 不要再用旧模板的 `publish` / `--account-id` / `--article-id` 命名

19
references/README.md Normal file
View File

@@ -0,0 +1,19 @@
# Agent 参考索引
本目录只是 Agent **运行、编排、调用** skill 时可渐进加载的资料索引,**不是**用户市场说明。
**边界规则:**
- 本文**不得**包含 YAML frontmatter**不得**包含 `description` 字段。
- 技能市场详情与普通用户说明**必须**放在根目录 [`README.md`](../README.md)。
- 本目录只保留运行时引用资料(如 `CLI.md``SCHEMA.md`);开发规范放在 [`../development/`](../development/)。
| 文档 | 用途 |
|------|------|
| 根目录 [`README.md`](../README.md) | 用户市场详情页说明 |
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口 |
| [`CLI.md`](CLI.md) | 命令行入口、参数与调用契约 |
| [`SCHEMA.md`](SCHEMA.md) | 输入输出、数据库与字段说明 |
| [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) |
按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;运行时环境细节见 `../development/RUNTIME.md`

38
references/SCHEMA.md Normal file
View File

@@ -0,0 +1,38 @@
# 数据存储模板
## 数据库路径
`{DATA_ROOT}/{USER_ID}/your-skill-slug/your-skill-slug.db`
## 通用任务日志表task_logs
模板默认建表:
| 字段 | 类型 | 说明 |
|------|------|------|
| `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 时间戳 |
## 不同业务的字段映射建议
| 业务场景 | task_type | target_id 含义 | input_id 含义 |
|---|---|---|---|
| 发布类 | publish | 账号 ID | 文章 ID |
| 工资代发 | disburse | 付款账户 | 工资表批次 ID |
| 对账 | reconcile | 银行 / 平台 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID |
| 报关 | declare | 港口 / 海关 | 报关批次 ID |
## 模板原则
- 模板不做历史迁移兼容设计
- 新 skill 直接从当前 schema 起步
- 业务有特殊字段时,建议放 `result_summary`JSON 字符串),不要乱加列

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
}

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。

116
scripts/cli/app.py Normal file
View File

@@ -0,0 +1,116 @@
"""CLIargparse 与分发模板。"""
from __future__ import annotations
import argparse
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
def _cli_str_or_none(raw: Optional[str]) -> Optional[str]:
if raw is None:
return None
v = str(raw).strip()
return v or None
def _handle_run(args: argparse.Namespace) -> int:
tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()]
if len(tail) > 2:
print("❌ 参数过多。")
print("用法python main.py run [target [input_id]] | run [-t target] [-i input_id]")
return 1
t_target: Optional[str] = None
t_input: Optional[str] = None
if len(tail) == 2:
t_target, t_input = tail[0], tail[1]
elif len(tail) == 1:
if tail[0].isdigit():
t_input = tail[0]
else:
t_target = tail[0]
pick_t = _cli_str_or_none(getattr(args, "target", None))
pick_i = _cli_str_or_none(getattr(args, "input_id", None))
target = pick_t or t_target
input_id = pick_i or t_input
return cmd_run(target=target, input_id=input_id)
def _print_full_usage() -> None:
print("通用业务技能模板main.py可用命令")
print(" python main.py run [target [input_id]] [-t target] [-i input_id]")
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")
def build_parser() -> ZhArgumentParser:
p = ZhArgumentParser(
prog="main.py",
description="通用业务技能模板:任务执行命令骨架、日志查询、健康检查、版本输出。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
sp = sub.add_parser("run", help="任务执行命令骨架")
sp.add_argument("--target", "-t", default=None, metavar="目标")
sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id")
sp.add_argument("run_tail", nargs="*", metavar="位置参数")
sp.set_defaults(handler=_handle_run)
sp = sub.add_parser("logs", help="查看任务日志")
sp.add_argument("--limit", type=int, default=10)
sp.add_argument("--status", default=None)
sp.add_argument("--task-type", default=None, dest="task_type")
sp.add_argument("--target-id", default=None, dest="target_id")
sp.set_defaults(handler=lambda a: cmd_logs(
limit=a.limit, status=a.status, task_type=a.task_type, target_id=a.target_id
))
sp = sub.add_parser("log-get", help="按 log_id 查看单条任务日志(JSON)")
sp.add_argument("log_id")
sp.set_defaults(handler=lambda a: cmd_log_get(a.log_id))
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
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", "config-path", "version", "-h", "--help"
}:
return cmd_run(target=argv[0], input_id=argv[1])
parser = build_parser()
args = parser.parse_args(argv)
return int(args.handler(args))

36
scripts/db/connection.py Normal file
View File

@@ -0,0 +1,36 @@
"""SQLite 连接与任务日志表迁移模板。"""
from __future__ import annotations
import sqlite3
from util.runtime_paths import get_db_path
def get_conn() -> sqlite3.Connection:
return sqlite3.connect(get_db_path())
def init_db() -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
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
)
"""
)
conn.commit()
finally:
conn.close()

View File

@@ -0,0 +1,88 @@
"""task_logs 表读写模板。
通用业务日志仓储:记录每次任务执行的 task_type / target / input / 状态 / 结果摘要。
不假设任何特定业务(发布、对账、审核等都可复用)。
"""
from __future__ import annotations
from typing import Any, List, Optional, Tuple
from db.connection import get_conn, init_db
from util.timeutil import now_unix
def save_task_log(
task_type: str,
target_id: Optional[str] = None,
input_id: Optional[str] = None,
input_title: Optional[str] = None,
status: str = "success",
error_msg: Optional[str] = None,
result_summary: Optional[str] = None,
) -> int:
init_db()
now = now_unix()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO task_logs
(task_type, target_id, input_id, input_title, status, error_msg, result_summary, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(task_type, target_id, input_id, input_title or "", status, error_msg, result_summary, now, now),
)
new_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
return new_id
def list_task_logs(
limit: int,
status: Optional[str] = None,
task_type: Optional[str] = None,
target_id: Optional[str] = None,
) -> List[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, task_type, target_id, input_id, input_title, status, error_msg, result_summary, "
"created_at, updated_at FROM task_logs WHERE 1=1 "
)
params: List[Any] = []
if status:
sql += "AND status = ? "
params.append(status)
if task_type:
sql += "AND task_type = ? "
params.append(task_type)
if target_id:
sql += "AND target_id = ? "
params.append(target_id)
sql += "ORDER BY created_at DESC, id DESC LIMIT ?"
params.append(int(limit))
cur.execute(sql, tuple(params))
return list(cur.fetchall())
finally:
conn.close()
def get_task_log_by_id(log_id: int) -> Optional[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, task_type, target_id, input_id, input_title, status, error_msg, result_summary, "
"created_at, updated_at FROM task_logs WHERE id = ?",
(int(log_id),),
)
return cur.fetchone()
finally:
conn.close()

38
scripts/main.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
skill-template CLI 入口。
复制为新技能后,请修改注释与常量,但保留当前分层结构:
cliargv→ service业务编排→ db持久化→ util通用工具
jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit
不在技能仓库内 vendored。
"""
from __future__ import annotations
import os
import sys
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
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__":
raise SystemExit(main())

View File

@@ -0,0 +1,46 @@
"""在线鉴权(可选模板)。"""
from __future__ import annotations
import os
from typing import Tuple
import requests
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("JIANGCHANG_USER_ID") or "").strip()
if not 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())
headers = {"Content-Type": "application/json"}
if auth_api_key:
headers["Authorization"] = f"Bearer {auth_api_key}"
payload = {
"user_id": user_id,
"skill_slug": skill_slug,
"trace_id": (os.getenv("JIANGCHANG_TRACE_ID") or "").strip(),
"context": {"entry": "main.py"},
}
try:
res = requests.post(f"{auth_base}/api/entitlements/check", json=payload, headers=headers, timeout=timeout)
except requests.RequestException as exc:
return False, f"鉴权请求失败:{exc}"
if res.status_code != 200:
return False, f"鉴权服务异常HTTP {res.status_code}"
try:
body = res.json()
except ValueError:
return False, "鉴权服务异常:返回非 JSON"
code = body.get("code")
data = body.get("data") or {}
if code != 200:
return False, str(body.get("msg") or "鉴权失败")
if not data.get("allow", False):
return False, str(data.get("reason") or "未购买或已过期")
return True, ""

View File

@@ -0,0 +1,57 @@
"""兄弟技能 CLI 调用工具。
通过子进程调用同级其他 skill 的 main.py并解析 JSON 输出。
具体调用哪些兄弟技能,由复制后的业务 skill 在 service 层按需决定。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from typing import Any, Dict, List, Optional
from util.logging_config import subprocess_env_with_trace
from util.runtime_paths import get_skills_root
def call_sibling_json(skill_slug: str, args: List[str]) -> Optional[Dict[str, Any]]:
"""通用兄弟技能调用器。
Args:
skill_slug: 兄弟技能的 slug即同级目录名
args: 传给 main.py 的命令行参数列表。
Returns:
若兄弟技能输出可解析的 JSON 对象则返回 dict否则返回 None。
"""
script_path = os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
if not os.path.isfile(script_path):
return None
proc = subprocess.run(
[sys.executable, script_path, *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=subprocess_env_with_trace(),
)
raw = (proc.stdout or "").strip()
if not raw or raw.startswith("ERROR"):
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def get_sibling_main_path(skill_slug: str) -> str:
"""获取兄弟技能的 main.py 绝对路径。
使用示例(在 task_service.py 中):
from service.sibling_bridge import get_sibling_main_path, call_sibling_json
result = call_sibling_json("account-manager", ["list", "--limit", "10"])
"""
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")

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

@@ -0,0 +1,188 @@
"""任务编排、日志查询模板。
通用业务编排层:负责参数校验、鉴权、调用具体业务实现、写入任务日志。
复制后在 cmd_run 中实现真正的业务逻辑。
"""
from __future__ import annotations
import asyncio
import json
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 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:
"""通用任务执行入口模板。复制后请实现真实业务逻辑。"""
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 rc
def cmd_logs(
limit: int = 10,
status: Optional[str] = None,
task_type: Optional[str] = None,
target_id: Optional[str] = None,
) -> int:
if limit <= 0:
limit = 10
rows = tlr.list_task_logs(limit, status, task_type, target_id)
if not rows:
print("暂无任务日志")
return 0
sep_line = "_" * 39
for idx, r in enumerate(rows):
rid, ttype, tid, iid, ititle, st, err, rsum, cat, uat = r
print(f"id{rid}")
print(f"task_type{ttype or ''}")
print(f"target_id{tid or ''}")
print(f"input_id{iid or ''}")
print(f"input_title{ititle or ''}")
print(f"status{st or ''}")
print(f"error_msg{err or ''}")
print(f"result_summary{rsum or ''}")
print(f"created_at{unix_to_iso(cat) or str(cat or '')}")
print(f"updated_at{unix_to_iso(uat) or str(uat or '')}")
if idx != len(rows) - 1:
print(sep_line)
print()
return 0
def cmd_log_get(log_id: str) -> int:
if not str(log_id).isdigit():
print("❌ log_id 必须是数字")
return 1
row = tlr.get_task_log_by_id(int(log_id))
if not row:
print("❌ 没有这条任务日志")
return 1
rid, ttype, tid, iid, ititle, st, err, rsum, cat, uat = row
print(
json.dumps(
{
"id": int(rid),
"task_type": ttype,
"target_id": tid,
"input_id": iid,
"input_title": ititle,
"status": st,
"error_msg": err,
"result_summary": rsum,
"created_at": unix_to_iso(cat),
"updated_at": unix_to_iso(uat),
},
ensure_ascii=False,
)
)
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:
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:
print(json.dumps({"version": SKILL_VERSION, "skill": SKILL_SLUG}, ensure_ascii=False))
return 0

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
技能入口脚本(模板)
====================
【职责】
- 作为宿主调用时的统一 CLI 入口子进程、终端、CI 均可)。
- 只做:参数解析、环境检查、分发到具体子命令;复杂逻辑放到同目录其它模块。
【如何扩展】
1. 在 main() 的 dispatch 字典中增加 "your_cmd": handler 项。
2. 实现 handler(argv) 或 handler();出错时打印 ERROR: 前缀信息并 sys.exit(非0)。
3. 在仓库根目录 SKILL.md「执行步骤」中补充示例命令。
【多宿主注意】
- 不要在本文件写死某一品牌宿主名。
- 路径与环境变量约定见 ../docs/RUNTIME.md可选辅助代码见 ../optional/paths_snippet.py需自行复制或 import 路径按项目调整)。
【编码】
Windows 下若宿主仍使用系统默认编码,可在宿主侧设置 UTF-8本模板不强制改 sys.stdout避免与宿主捕获冲突
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Callable, Dict, List, Optional
# 与 SKILL.md 中 metadata.openclaw.slug / metadata.skill.slug 保持一致
SKILL_SLUG = "toutiao-publisher"
def cmd_version(_args: argparse.Namespace) -> int:
"""打印版本信息(与 SKILL.md frontmatter 中 version 应对齐,此处为占位)。"""
payload = {
"skill_slug": SKILL_SLUG,
"version": "0.1.0",
"entry": "skill_main.py",
}
print(json.dumps(payload, ensure_ascii=False))
return 0
def cmd_health(_args: argparse.Namespace) -> int:
"""
健康检查:应快速、可离线(除非技能本身强依赖网络)。
失败时打印 ERROR: 前缀,便于宿主与自动化解析。
"""
# 示例:检查 Python 版本(可按需改为检查关键依赖 import
if sys.version_info < (3, 9):
print("ERROR:PYTHON_VERSION need >= 3.9", file=sys.stderr)
return 1
print(f"OK skill={SKILL_SLUG} python={sys.version.split()[0]}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="toutiao-publisher — Toutiao batch publish skill CLI (skeleton).",
)
sub = p.add_subparsers(dest="command", required=True)
sp = sub.add_parser("version", help="Print version JSON.")
sp.set_defaults(handler=cmd_version)
sp = sub.add_parser("health", help="Quick health check.")
sp.set_defaults(handler=cmd_health)
return p
def main(argv: Optional[List[str]] = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
parser = build_parser()
args = parser.parse_args(argv)
handler: Callable[[argparse.Namespace], int] = args.handler
return handler(args)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,12 @@
"""argparse 中文错误说明。"""
from __future__ import annotations
import argparse
import sys
class ZhArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> None:
print(f"参数错误:{message}\n请执行python main.py -h 查看帮助", file=sys.stderr)
self.exit(2)

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

@@ -0,0 +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

@@ -0,0 +1,21 @@
"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
from jiangchang_skill_core.unified_logging import (
attach_unified_file_handler,
ensure_trace_for_process,
get_skill_log_file_path,
get_skill_logger,
get_unified_logs_dir,
setup_skill_logging,
subprocess_env_with_trace,
)
__all__ = [
"attach_unified_file_handler",
"ensure_trace_for_process",
"get_skill_log_file_path",
"get_skill_logger",
"get_unified_logs_dir",
"setup_skill_logging",
"subprocess_env_with_trace",
]

View File

@@ -0,0 +1,34 @@
"""数据根、技能目录、兄弟技能根路径。"""
from __future__ import annotations
import os
from jiangchang_skill_core.runtime_env import get_data_root, get_sibling_skills_root, get_user_id
from util.constants import SKILL_SLUG
_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_skill_root() -> str:
return os.path.dirname(_SCRIPTS_DIR)
def get_openclaw_root() -> str:
return get_sibling_skills_root(_SCRIPTS_DIR)
def get_skills_root() -> str:
return get_sibling_skills_root(_SCRIPTS_DIR)
def get_skill_data_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path(filename: str | None = None) -> str:
name = filename or f"{SKILL_SLUG}.db"
return os.path.join(get_skill_data_dir(), name)

20
scripts/util/timeutil.py Normal file
View File

@@ -0,0 +1,20 @@
"""时间戳与 ISO 展示。"""
from __future__ import annotations
import time
from datetime import datetime
from typing import Optional
def now_unix() -> int:
return int(time.time())
def unix_to_iso(ts: Optional[int]) -> Optional[str]:
if ts is None:
return None
try:
return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
except (ValueError, OSError, OverflowError):
return None

10
tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# 测试运行产物与本地密钥,勿提交
__pycache__/
*.py[cod]
.pytest_cache/
desktop/artifacts/
integration/artifacts/
*.log
.env
.env.*
!.env.sample

169
tests/README.md Normal file
View File

@@ -0,0 +1,169 @@
# skill-template 测试说明(企业数字员工技能)
本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite**真实 API / 真实 RPA** 必须显式设置 ``OPENCLAW_TEST_TARGET``,且仅以 ``*.sample`` 或复制后的文件提供范式。
复制为新技能后,请保留隔离数据根与 profile 策略,再按域扩展用例。
---
## 我该把测试写在哪里?
人类开发者与 Cursor 等 AI 工具在加用例前,先对照下表决定**文件落点**与**是否默认执行**
| 场景 | 放在哪里 | 默认是否运行 | 说明 |
|------|----------|--------------|------|
| CLI / health / version / metadata / runtime 路径 | `tests/test_*.py` | 是 | 默认必跑,保持轻量、无外联 |
| 纯函数 / 业务规则 | `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` 后按需运行 |
| 仿真 RPA / 页面操作 | `tests/integration/test_simulator_rpa_contract.py.sample` | 否 | 用 localhost / 仿真页面,不碰真实系统 |
| 真实 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` |
说明:`python tests/run_tests.py` **只**收集 `tests/` **根目录**下的 `test_*.py`;子目录里的 `*.sample` 与 desktop 用例**不会**被默认发现。
---
## 新技能最小测试清单
从本模板复制出新技能仓库后,建议至少逐项确认:
- [ ] `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。
- [ ] 至少有 1 个成功路径测试。
- [ ] 至少有 1 个缺必填字段 / 非法输入测试。
- [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。
- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏
- [ ] 如果技能有**特有** Python 三方依赖,`requirements.txt` 已声明且版本约束合理尽量收窄范围公共依赖platform-kit、playwright由宿主共享 runtime 提供,**不要**写入技能 requirements。
- [ ] `health` 能在依赖缺失或原生运行库未就绪时给出清晰、可操作的错误(非裸 traceback
- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。
---
## 1. 默认必跑unittest
在**技能仓库根目录**执行:
```powershell
$env:PYTHONDONTWRITEBYTECODE = "1"
python tests/run_tests.py
python tests/run_tests.py -v
python tests/run_tests.py cli_smoke
python tests/run_tests.py test_cli_smoke
```
### 1.1 收集范围
- ``run_tests.py`` **只**发现 ``tests/`` **根目录**下 ``test_*.py``。
- **不递归**``tests/samples/``、``tests/integration/``、``tests/desktop/``(其中文件为 ``*.sample`` 或需 pytest 单独跑)。
- 启动时把 ``scripts/`` 与 ``tests/`` 加入 ``sys.path``。
- Windows 下将 **stdout/stderr** 包装为 **UTF-8**。
### 1.2 默认套件覆盖
| 能力 | 文件 |
|------|------|
| CLI 冒烟import ``cli.app.main`` | ``test_cli_smoke.py`` |
| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` |
| 运行路径与 **JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` |
| DB / ``task_logs`` 骨架冒烟 | ``test_db_smoke.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``
进入同一临时目录 / 用户:
| 变量 | 值 |
|------|-----|
| ``JIANGCHANG_DATA_ROOT`` | ``tempfile.mkdtemp(prefix="skill-test-")`` |
| ``JIANGCHANG_USER_ID`` | ``_test`` |
退出时两个键均恢复(原不存在则 ``pop``),并删除临时目录。
### 1.4 产物与密钥
- ``tests/.gitignore`` 忽略 ``__pycache__``、``.pytest_cache``、``*.pyc``、``desktop/artifacts/``、``integration/artifacts/``、``.env`` 等。
- 推荐运行前设置 ``PYTHONDONTWRITEBYTECODE=1`` 减少 ``.pyc``。
---
## 2. 可选Service / Golden``tests/samples/*.sample``
- 展示 **service 契约** 与 **fixture 回归** 写法;**默认不执行**。
- 启用:复制 ``tests/samples/test_service_contract.py.sample`` 等为 ``tests/test_*.py``(去掉 ``.sample``),按业务修改后再 ``python tests/run_tests.py``。
- 脱敏样例数据见 ``tests/fixtures/README.md`` 与 ``sample_request.json`` / ``expected_response.json``profile 结构见 ``adapter_profiles.sample.yaml``。
---
## 3. 可选Integration``tests/integration/*.sample``
- **默认不跑**;文件均为 ``*.py.sample``。
- 说明与开关见 **`tests/integration/README.md`**。
- **禁止**在样例中硬编码真实 token、密码、生产 URL凭证须来自密钥库或受控环境变量。
---
## 4. 可选Desktop E2Epytest
见 **`tests/desktop/README.md`**。示例为 `test_desktop_smoke.py.sample`,需复制为 `test_desktop_smoke.py` 后用 pytest 运行。
---
## 5. 环境变量:测试档位
### 5.1 测试档位
- ``OPENCLAW_TEST_TARGET``
**合法取值**(非法值将使 ``get_test_target()`` 抛 ``ValueError``
| 取值 | 含义 |
|------|------|
| ``unit`` | **默认**:单元 / 内存 / mock不跑仿真与真实外联 |
| ``mock`` | 与 ``unit`` 类似的安全档位(显式语义) |
| ``simulator_api`` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| ``simulator_rpa`` | 仅允许 **仿真页面 / 录播 RPA** |
| ``real_api`` | 真实 API显式设 ``OPENCLAW_TEST_TARGET=real_api`` |
| ``real_rpa`` | 真实 RPA显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` |
未设置时等价 ``unit``。
### 5.2 默认安全策略(``adapter_test_utils``
- 默认 ``unit``:不访问真实 API、不跑真实 RPA、不做写操作、不读取真实密钥、不写真实数据根。
- ``assert_no_real_network_env()`` 断言默认未误设 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``(用于必跑用例自检)。
---
## AI 编程工具注意事项Cursor 等)
以下约束适用于为本仓库或复制出的技能编写/修改测试时,**避免误伤业务与生产**
- **不要为了**让测试通过去改业务代码,除非用户明确要求修改实现。
- **不要**把真实外部系统调用写进默认的 `tests/test_*.py`(默认套件须无外联、无真实浏览器)。
- **不要**把 `*.sample` 改名为可执行测试或移入根目录并去掉 `.sample`,除非用户明确要求启用 integration / 真实联调。
- **不要**硬编码真实凭证、token、cookie、生产 URL占位请用明显虚构域名如 `example.invalid`)或文档约定的 `credential_ref`。
- 默认测试只能使用 **mock、stub、`FakeAdapter`、fixtures、隔离 SQLite** 等安全手段。
- 业务逻辑应优先在 **service 层** 编写与测试,不要只断言 CLI 文案。
- 异常路径应返回**结构化错误**(如统一错误码/字段),避免裸异常直接穿透到 CLI。
- 有真实系统联调需求时,测试应写在 `tests/integration/`,并显式设置 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``;不得默认开启。
---
## 6. 与 content-manager 的关系
- **未**复制 article / media / prompt / NotebookLM 等业务测试。
- 本骨架面向**通用外联技能**;业务契约请放在各自技能的 ``tests/samples`` / ``tests/integration`` 复制件中实现。

81
tests/_support.py Normal file
View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""
测试公共支撑。
1. 将 scripts/ 加入 sys.path便于 `from cli…` / `from db…` / `from util…` 等导入。
2. `IsolatedDataRoot`:设置 `JIANGCHANG_DATA_ROOT` 与 `JIANGCHANG_USER_ID`
退出时恢复原值并删除临时目录,避免污染真实用户数据目录。
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from typing import Optional
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, ".."))
_SCRIPTS_DIR = os.path.join(_SKILL_ROOT, "scripts")
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
_ISOLATION_KEYS = (
"JIANGCHANG_DATA_ROOT",
"JIANGCHANG_USER_ID",
)
def get_scripts_dir() -> str:
return _SCRIPTS_DIR
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:
"""
在独立临时目录下模拟数据根,避免污染本机默认数据目录。
进入上下文时:
- JIANGCHANG_DATA_ROOT -> tempfile.mkdtemp(prefix='skill-test-')
- JIANGCHANG_USER_ID -> _test与业务默认用户区分
退出时:两个变量均恢复为进入前状态(若原先未设置则 pop并删除临时目录。
"""
def __init__(self, user_id: str = "_test") -> None:
self._tmp: Optional[str] = None
self._old: dict[str, Optional[str]] = {}
self._user_id = user_id
def __enter__(self) -> str:
self._tmp = tempfile.mkdtemp(prefix="skill-test-")
for k in _ISOLATION_KEYS:
self._old[k] = os.environ.get(k)
os.environ["JIANGCHANG_DATA_ROOT"] = self._tmp
os.environ["JIANGCHANG_USER_ID"] = self._user_id
return self._tmp
def __exit__(self, *exc_info: object) -> None:
for k in _ISOLATION_KEYS:
v = self._old.get(k)
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
if self._tmp and os.path.isdir(self._tmp):
shutil.rmtree(self._tmp, ignore_errors=True)
self._tmp = None

118
tests/adapter_test_utils.py Normal file
View File

@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""
企业技能对接外部系统ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA
测试侧共用的 **profile / 禁止真实外呼** 工具。
- 仅标准库 + jiangchang_skill_core.config不发起 HTTP不启动浏览器。
- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并显式设置 ``OPENCLAW_TEST_TARGET``。
"""
from __future__ import annotations
from typing import Any, Final
from jiangchang_skill_core import config
ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset(
{
"unit",
"mock",
"simulator_api",
"simulator_rpa",
"real_api",
"real_rpa",
}
)
def _raw_test_target_env() -> str:
return (config.get("OPENCLAW_TEST_TARGET") or "").strip()
def get_test_target() -> str:
"""
读取 ``OPENCLAW_TEST_TARGET``(经 ``config.get`` 三级合并)。
未设置时返回 ``unit``。非法取值抛出 ``ValueError``,便于尽早发现拼写错误。
"""
raw = _raw_test_target_env()
if not raw:
return "unit"
if raw not in ALLOWED_TEST_TARGETS:
raise ValueError(
f"Invalid test target {raw!r}. Allowed: {sorted(ALLOWED_TEST_TARGETS)}"
)
return raw
def real_access_allowed(profile: str) -> bool:
"""
判断当前 ``OPENCLAW_TEST_TARGET`` 是否允许运行 ``profile`` 档位测试。
"""
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}")
def should_skip_profile(profile: str, write: bool = False) -> tuple[bool, str]:
"""
返回 ``(should_skip, reason)``。
策略摘要:
- ``mock``:永不跳过(内存 / FakeAdapter无外呼
- ``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 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}")
class FakeAdapter:
"""
模拟外部系统 adapter用于单元测试中的超时 / 鉴权 / 畸形响应等分支。
``mode````success`` | ``timeout`` | ``invalid_response`` | ``unauthorized``
"""
def __init__(self, mode: str = "success") -> None:
if mode not in ("success", "timeout", "invalid_response", "unauthorized"):
raise ValueError(f"unsupported FakeAdapter mode: {mode!r}")
self._mode = mode
def call(self, payload: Any) -> Any:
if self._mode == "success":
return {"success": True, "data": {"echo": payload}}
if self._mode == "timeout":
raise TimeoutError("simulated upstream timeout")
if self._mode == "invalid_response":
return object() # 非 dict模拟无法解析的响应体
if self._mode == "unauthorized":
raise PermissionError("simulated 401/403")
raise RuntimeError("unreachable")
def assert_no_real_network_env() -> None:
"""
断言默认必跑套件未误设真实外联档位(``real_api`` / ``real_rpa``)。
若需在受控环境跑真实集成,请显式设置 ``OPENCLAW_TEST_TARGET`` 并使用 ``tests/integration/*.sample``。
"""
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"
)

63
tests/desktop/README.md Normal file
View File

@@ -0,0 +1,63 @@
# 可选桌面 E2Epytest
本目录用于**匠厂桌面 + jiangchang_desktop_sdk** 的端到端冒烟,**不是** `python tests/run_tests.py` 默认路径的一部分。
## 依赖
- `pytest`
- `jiangchang_desktop_sdk`(由 `conftest.py` 按多级路径尝试加入 `sys.path`,见该文件说明)
- 匠厂桌面已安装或开发态可启动,且 `jiangchang://` 协议可用
## 为什么不默认跑
- 需要真实宿主环境,无法在纯 CI/无头环境稳定复现。
- `unittest``tests/run_tests.py` **只发现** `tests/` 根目录下 `test_*.py`**不递归**本目录。
- 示例文件为 **`test_desktop_smoke.py.sample`**,避免被误收集。
## 如何启用
1.`test_desktop_smoke.py.sample` **复制或重命名**为 `test_desktop_smoke.py`
2. 按你的技能名称、路由关键词、期望输出修改 `ask` 与断言。
3. 运行:
```powershell
pytest tests/desktop/test_desktop_smoke.py -v -s
```
失败时,`conftest.py` 中的 `failure_snapshot_dir` fixture 会将现场目录指向 `tests/desktop/artifacts/`(需自行避免将大文件提交进仓库)。
## 与后端测试的关系
- **后端 / CLI / DB**:请使用仓库根目录下 `python tests/run_tests.py`(见 `tests/README.md`)。
- **桌面 E2E**:仅在本目录用 **pytest** 维护,二者互补、互不替代。
若仅需验证 **service 业务逻辑**、契约与 golden 回归,**不要**写 desktop E2Edesktop 只用于验证**宿主 UI、路由、`jiangchang://` 协议、真实用户交互链路**等与桌面宿主强相关的行为。
## 模板选择指南
| 场景 | 用哪个模板 |
|---|---|
| 纯问答 skill不上传附件、不跑长流程 | `test_desktop_smoke.py.sample` |
| 需要上传附件 / RPA 长流程 / Agent 多轮工具调用 | `test_desktop_smoke_with_attachment.py.sample` |
录屏入口都用 `record_screencast.py.sample`,里面 `TEST_FILE_NAME` 常量切换测试文件。
## 已知 SDK 坑(来自 disburse-payroll-icbc 实战)
1. **`client.send_file()` 在 v2.0.17+ 抛 NotImplementedError**——附件场景必须用
`jiangchang_desktop_sdk.e2e_helpers.drop_file_into_composer`DataTransfer 拖拽)。
2. **`client.ask()` 内部 `user_msg_index<0` 死循环**——如果匠厂启动慢 / gateway 握手有
延迟,导致 15s 内没抓到 user 节点ask() 会锁死后续 900s 等待。建议长流程 / 附件场景
绕开 ask(),用 `jiangchang_desktop_sdk.e2e_helpers` 里的 `send_prompt_via_composer`
+ `wait_for_user_message` + `wait_for_agent_complete` 自己控时。
3. **字幕关键词建议用 ASCII**——虽然 platform-kit 已修了 subprocess 编码乱码,
保险起见字幕脚本里关键词用 `step: 1/` 这种 ASCII 锚而非中文短语。
4. **`client.bring_to_front()` 已升级**——v?? 起会 ctypes ShowWindow(SW_MAXIMIZE) +
SetForegroundWindow能真正铺满桌面。dev 模式下也能用(不依赖 jiangchang:// 协议)。
## 录屏新能力platform-kit v??
- `run_screencast(..., activate_window=True)` 自动激活+最大化匠厂窗口
- `run_screencast(..., monitor_index=1)` 多显示器场景显式录主屏
- 字幕脚本支持三元组 `(keyword, text, duration)` 给个别字幕指定独立时长,
覆盖长等待场景中间没日志输出的"字幕真空"

88
tests/desktop/conftest.py Normal file
View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2Epytest 收集时自动加载,负责把 jiangchang_desktop_sdk 加入 sys.path。
查找顺序:
1. 已 pip install 则直接 import
2. 环境变量 JIANGCHANG_KIT_ROOT → sdk/jiangchang-desktop-sdk/src旧版布局
3. 环境变量 JIANGCHANG_KIT_ROOT → src新版 platform-kit 布局);
4. 自本文件上溯skill 根 → workspace 根 → OpenClaw 根,拼路径;
5. 兜底 D:\\AI\\jiangchang-platform-kit\\src若存在
6. 都找不到则 pytest.exit 给出可操作的排障说明。
"""
from __future__ import annotations
import importlib.util
import os
import sys
import pytest
def _bootstrap_jiangchang_desktop_sdk() -> None:
if importlib.util.find_spec("jiangchang_desktop_sdk") is not None:
return
env_kit_root = (os.environ.get("JIANGCHANG_KIT_ROOT") or "").strip()
here = os.path.dirname(os.path.abspath(__file__))
skill_root = os.path.abspath(os.path.join(here, "..", ".."))
workspace_root = os.path.abspath(os.path.join(skill_root, ".."))
open_claw_root = os.path.abspath(os.path.join(workspace_root, ".."))
candidates = []
if env_kit_root:
candidates.append(os.path.join(env_kit_root, "sdk", "jiangchang-desktop-sdk", "src"))
candidates.append(os.path.join(env_kit_root, "src"))
candidates.append(
os.path.join(workspace_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src")
)
candidates.append(
os.path.join(open_claw_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src")
)
candidates.append(
os.path.join(workspace_root, "jiangchang-platform-kit", "src")
)
candidates.append(
os.path.join(open_claw_root, "jiangchang-platform-kit", "src")
)
default_kit_src = r"D:\AI\jiangchang-platform-kit\src"
if os.path.isdir(default_kit_src):
candidates.append(default_kit_src)
tried = []
for cand in candidates:
cand_abs = os.path.abspath(cand)
tried.append(cand_abs)
if os.path.isdir(cand_abs) and os.path.isdir(os.path.join(cand_abs, "jiangchang_desktop_sdk")):
if cand_abs not in sys.path:
sys.path.insert(0, cand_abs)
return
pytest.exit(
"无法定位 jiangchang_desktop_sdk。请任选一种方式解决\n"
" 1) pip install -e <path-to>/jiangchang-platform-kit\n"
" 2) 设置环境变量 JIANGCHANG_KIT_ROOT=<path-to>/jiangchang-platform-kit\n"
" SDK 新版位置:<root>\\src旧版<root>\\sdk\\jiangchang-desktop-sdk\\src\n"
" 3) 将 jiangchang-platform-kit 与本仓库放在同一 workspace 父级可推断的位置\n"
"已尝试的候选路径:\n - " + "\n - ".join(tried),
returncode=4,
)
_bootstrap_jiangchang_desktop_sdk()
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
@pytest.fixture
def failure_snapshot_dir() -> str:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
return _ARTIFACT_DIR

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""
Screencast 录制入口(模板)。
启用方式:
1. 复制为 record_screencast.py
2. 修改 SKILL_SLUG 和 SUBTITLE_SCRIPT
3. 决定 test_file 指向哪个测试(默认 test_desktop_smoke.py
有附件场景的用 test_desktop_smoke_with_attachment.py
4. 运行python tests/desktop/record_screencast.py
依赖:
1. pip install mss
2. 系统已安装 ffmpeg 并在 PATH 中
3. jiangchang-platform-kit 可推断路径(见 _bootstrap 逻辑)
4. MEDIA_ASSETS_ROOT 环境变量指向 media-assets 仓库(或使用默认路径)
平台 kit v?? 之后 run_screencast 自动激活并最大化匠厂窗口、支持指定显示器、
字幕支持每条独立时长。本模板已经利用这些能力,新 skill 复制后无需再内嵌
ctypes 激活代码。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _bootstrap_screencast() -> None:
"""将 jiangchang-platform-kit/tools 加入 sys.path使 screencast 包可导入。"""
here = Path(__file__).resolve()
skill_root = here.parents[2]
workspace_root = skill_root.parent
open_claw_root = workspace_root.parent
candidates = [
workspace_root / "jiangchang-platform-kit" / "tools",
open_claw_root / "jiangchang-platform-kit" / "tools",
Path(r"D:\AI\jiangchang-platform-kit") / "tools",
]
env_kit = os.environ.get("JIANGCHANG_KIT_ROOT", "")
if env_kit:
candidates.insert(0, Path(env_kit) / "tools")
for cand in candidates:
if (cand / "screencast" / "__init__.py").exists():
if str(cand) not in sys.path:
sys.path.insert(0, str(cand))
return
raise RuntimeError(
"找不到 jiangchang-platform-kit/tools/screencast。\n"
"请设置 JIANGCHANG_KIT_ROOT 环境变量,或将 platform-kit 放在 workspace "
"同级目录。"
)
_bootstrap_screencast()
from screencast import run_screencast # noqa: E402
# ============================================================
# ↓↓↓ 复制后只需修改下面这些 ↓↓↓
# ============================================================
SKILL_SLUG = "your-skill-slug" # 替换为本技能 slug
# 字幕脚本设计要点(来自 disburse-payroll-icbc 实战经验):
# 1. **关键词全用 ASCII**:保险起见绕开 Windows cp936/UTF-8 编码偶发问题。
# 虽然 platform-kit 已修了 subprocess 编码ASCII 关键词仍是最稳的。
# 2. **用 "step: N/X" 模式作触发锚**:在测试代码里加 _logger.info("step: 1/5 ...")
# 日志作为字幕触发点,能精确控制字幕出现时机,比依赖 SDK 内部日志更可靠。
# 3. **关键词每条只触发一次**,所以"step: 1/" 这种唯一前缀比"准备数据"更稳。
# 4. **三元组支持每条独立时长**(关键词, 文案, 显示秒数)
# long wait 场景可以让某条字幕停留更久(如 30s覆盖中间无日志输出的等待期。
SUBTITLE_SCRIPT = [
# (keyword, text) 二元组用默认时长5s
("ensure_app_running", "启动匠厂智能体平台"),
("wait_gateway_ready", "网关就绪"),
("step: 1/", "准备测试数据"),
("step: 2/", "执行技能操作"),
# (keyword, text, duration) 三元组指定独立时长
("step: 3/", "技能在后端持续执行,请稍候...", 30.0),
("step: 4/", "结果汇总返回"),
("PASSED", "测试通过 ✓"),
]
# 测试文件选择(按需切换):
# - test_desktop_smoke.py: 纯问答 skill 用这个
# - test_desktop_smoke_with_attachment.py: 需要上传附件的 skill 用这个
TEST_FILE_NAME = "test_desktop_smoke.py"
# 音乐子目录(按 skill 风格选):
# - "music/corporate": 商务严肃(金融、办公场景)
# - "music/calm": 舒缓(流程演示、配置类)
# - "music/upbeat": 活力(成功汇报、营销视频)
# - "music"(默认): 三个子目录混选
MUSIC_SUBDIR = "music/corporate"
# ============================================================
if __name__ == "__main__":
_here = Path(__file__).resolve()
test_file = str(_here.parent / TEST_FILE_NAME)
output_dir = str(_here.parent / "artifacts" / "recordings")
run_screencast(
skill_slug=SKILL_SLUG,
subtitle_script=SUBTITLE_SCRIPT,
pytest_args=[test_file, "-v", "-s"],
output_dir=output_dir,
music_subdir=MUSIC_SUBDIR,
# platform-kit 自动激活+最大化窗口Windows ctypes / 其他系统协议),
# 想关掉传 activate_window=False
# monitor_index=1 显式录主屏(多显示器场景推荐)
)

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample—— **简单问答场景**。
启用方式:
复制本文件为 test_desktop_smoke.py按目标技能修改提问文案与断言。
运行pytest tests/desktop/test_desktop_smoke.py -v -s
依赖pytest、jiangchang_desktop_sdk、匠厂桌面宿主已就绪。
【何时用此模板】
- 技能只需要发一句话给 Main Agent等回复做断言纯问答
- 不涉及上传附件 / 不涉及 RPA 长流程
【何时不要用此模板,改用 test_desktop_smoke_with_attachment.py.sample】
- 需要上传文件Excel / PDF / 图片)作为附件
- 技能内部跑 RPA / 长流程(> 2 分钟),需要精细等待
【已知 SDK 限制(来自 disburse-payroll-icbc 实战)】
- client.ask() 内部对"找到 user 消息节点"只给 15 秒窗口,找不到就锁死
user_msg_index=-1后续等待 assistant 永远 return None。如果你的场景里
匠厂启动较慢 / gateway 握手有延迟client.ask() 可能假死到 timeout。
此模板仍用 ask(),因为它对**简单场景**够用。复杂场景请用 _with_attachment.py.sample
里的"绕开 ask()"模式。
- client.send_file() 在 v2.0.17+ 抛 NotImplementedError**绝不要调用它**。
需要附件请用 _with_attachment.py.sample 里的拖拽方案。
"""
from __future__ import annotations
import logging
import os
import pytest
from jiangchang_desktop_sdk import AskOptions, JiangchangDesktopClient
_logger = logging.getLogger("skill-template-desktop-e2e-sample")
if not _logger.handlers:
_logger.setLevel(logging.INFO)
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("[E2E-sample] %(message)s"))
_logger.addHandler(_h)
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
class TestDesktopSmokeSample:
"""最小 smoke拉起桌面、等待网关、发一条与技能路由相关的问句并做宽松断言。"""
@pytest.fixture(autouse=True)
def connect_and_teardown(self, request):
client = JiangchangDesktopClient()
_logger.info("ensure_app_running")
client.ensure_app_running(wait_timeout_s=30)
client.bring_to_front()
client.wait_gateway_ready(timeout_ms=30000)
self.client = client
yield
try:
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
paths = client.snapshot(_ARTIFACT_DIR, tag=request.node.name)
_logger.error("snapshot: %s", paths)
finally:
client.disconnect()
def test_gateway_and_simple_ask(self) -> None:
"""请将 SKILL 名与路由关键词改成你复制后的真实技能。"""
answer = self.client.ask(
"请使用当前已安装技能执行一次 health 检查并返回 OK 或等价结果。",
AskOptions(timeout=120000, new_task=True),
)
assert answer
assert "OK" in answer or "ok" in answer.lower() or "健康" in answer

View File

@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample—— **附件上传 + 长流程场景**。
启用方式:
1. 复制为 test_desktop_smoke_with_attachment.py
2. 修改 SKILL_SLUG / PROMPT / 断言为你技能的真实业务
3. 准备样例附件,或动态生成(参考 _prepare_sample_attachment
4. 运行pytest tests/desktop/test_desktop_smoke_with_attachment.py -v -s
依赖:
- pytest / jiangchang_desktop_sdk / 匠厂桌面宿主已就绪
- 通过 conftest.py 自动 bootstrapjiangchang_desktop_sdk.e2e_helpers 模块在 sys.path
【为什么用此模板而不是 test_desktop_smoke.py.sample】
- client.send_file() 在 v2.0.17+ 抛 NotImplementedError必须用拖拽方式
- client.ask() 在 user_idx<0 时会锁死(已知 SDK 缺陷),本模板绕开 ask()
在测试侧自己用 jiangchang_desktop_sdk.e2e_helpers 做等待
【字幕配合】
- 关键节点都打了 _logger.info("step: N/7 ..."),对应 record_screencast.py 的
SUBTITLE_SCRIPT 用 step: 关键词触发字幕
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import pytest
from jiangchang_desktop_sdk import JiangchangDesktopClient
from jiangchang_desktop_sdk.e2e_helpers import (
drop_file_into_composer,
send_prompt_via_composer,
wait_for_agent_complete,
wait_for_attachment_ready,
wait_for_composer_enabled,
wait_for_user_message,
)
# ============================================================
# ↓↓↓ 复制后修改这些 ↓↓↓
# ============================================================
SKILL_SLUG = "your-skill-slug"
PROMPT = (
"请使用 /your-skill-slug 处理我刚才上传的文件。"
"完成后请用清单方式汇总:[改成你技能要求的字段]。"
)
# 业务断言:根据你的技能输出修改
EXPECTED_KEYWORDS_IN_REPLY = ["关键词1", "关键词2"]
# ============================================================
_logger = logging.getLogger("desktop-e2e-with-attachment-sample")
if not _logger.handlers:
_logger.setLevel(logging.INFO)
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("[E2E] %(message)s"))
_logger.addHandler(_h)
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
def _prepare_sample_attachment(tmp_path: Path) -> Path:
"""在 tmp_path 下生成一个样例附件(按 skill 真实需要修改)。
示例:生成一个 5 行的 xlsx。你的 skill 可能需要 CSV / PDF / 图片等其他格式。
"""
from openpyxl import Workbook # noqa: WPS433
xlsx = tmp_path / "sample.xlsx"
wb = Workbook()
ws = wb.active
ws.append(["列1", "列2", "列3"])
for i in range(1, 6):
ws.append([f"值{i}A", f"值{i}B", i * 100])
wb.save(str(xlsx))
return xlsx
@pytest.fixture
def client(request):
c = JiangchangDesktopClient()
_logger.info("ensure_app_running")
c.ensure_app_running(wait_timeout_s=45)
c.bring_to_front()
c.wait_gateway_ready(timeout_ms=30000)
yield c
try:
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
paths = c.snapshot(_ARTIFACT_DIR, tag=request.node.name)
_logger.error("failure snapshot: %s", paths)
finally:
c.disconnect()
def test_desktop_with_attachment(client: JiangchangDesktopClient, tmp_path: Path) -> None:
_logger.info("step: 1/7 准备样例附件")
attachment_path = _prepare_sample_attachment(tmp_path)
client.new_task()
page = client.get_page()
client.bring_to_front()
_logger.info("step: 2/7 拖拽附件到聊天框")
drop_file_into_composer(page, attachment_path)
_logger.info("step: 3/7 等附件 stage-buffer 完成")
wait_for_attachment_ready(page, attachment_path.name)
wait_for_composer_enabled(page)
client.bring_to_front()
_logger.info("step: 4/7 键入并发送指令")
send_prompt_via_composer(page, PROMPT)
_logger.info("step: 5/7 Main Agent 接收,等待路由到技能")
user_idx = wait_for_user_message(page, PROMPT, timeout_s=60.0)
_logger.info("step: 6/7 技能在后端执行(可能 2-3 分钟)")
client.bring_to_front()
reply = wait_for_agent_complete(
page, user_idx,
overall_timeout_s=1200.0,
stable_seconds=5.0,
)
_logger.info("step: 7/7 校验回复内容")
_logger.info("reply length=%d", len(reply))
assert reply, "assistant 回复为空"
for kw in EXPECTED_KEYWORDS_IN_REPLY:
assert kw in reply, f"期望关键词 {kw!r} 未出现在回复中;片段:{reply[:400]!r}"

27
tests/fixtures/README.md vendored Normal file
View File

@@ -0,0 +1,27 @@
# fixtures脱敏样例
本目录存放 **golden case / contract test** 用的输入、期望输出与 **adapter profile 样例**
## 规则
- 只能放**虚构、脱敏、可公开**的数据(演示租户、演示 request_id 等)。
- **禁止**:真实客户名、真实订单/提单号、真实报关单、真实 token/cookie、真实内网 URL、生产环境凭证。
- `adapter_profiles.sample.yaml` 中的 URL 须使用 `localhost``example.invalid` 等明显占位;`credential_ref` 仅表达「从密钥库引用」的约定,**不得**内嵌密钥。
## 与本仓库测试的对应关系
| 文件 | 用途 |
|------|------|
| `sample_request.json` | 通用请求体样例,供 ``tests/samples/test_golden_cases.py.sample`` 演示读取。 |
| `expected_response.json` | 与上配套的期望摘要字段(业务复制后按域替换)。 |
| `adapter_profiles.sample.yaml` | 五类运行方式mock / simulator_api / simulator_rpa / real_api / real_rpa的配置表达样例。 |
---
## 给 AI 工具的替换规则
- 可以按业务**重命名字段**、增减键名,但必须保持**虚构、脱敏**;不得把真实客户标识原样搬进仓库。
- 可以**新增**更多 fixture 文件,例如 `case_valid_001.json`、`case_missing_required_001.json`,并在测试中通过相对路径读取。
- **禁止**将真实企业数据、真实单证截图中的文字、真实报关要素未经处理直接复制进 `fixtures/`。
- 若用例来自真实客户样本,须先**脱敏、泛化、替换编号与地名**,再作为模板数据提交。

View File

@@ -0,0 +1,40 @@
# 样例:五类 adapter 运行方式(复制到业务仓后按环境改名;勿提交真实凭证)
runtime:
profile: mock
adapters:
demo_external_system:
interface: demo
profile: mock
mock:
target: mock
channel: none
simulator_api:
target: simulator
channel: api
base_url: http://localhost:5180/api/demo
timeout_seconds: 10
simulator_rpa:
target: simulator
channel: rpa
entry_url: http://localhost:5180/select
timeout_seconds: 30
real_api:
target: real
channel: api
base_url: https://example.invalid/api
credential_ref: vault://tenant/demo/external-system/api
write_enabled: false
timeout_seconds: 20
real_rpa:
target: real
channel: rpa
entry_url: https://example.invalid/portal
credential_ref: vault://tenant/demo/external-system/rpa
write_enabled: false
timeout_seconds: 60

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