Compare commits

..

57 Commits

Author SHA1 Message Date
2e5a8d5eed Add DATA_PATHS golden standard and resolve_data_path helpers.
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Document skill-owned file layout under user data dir, extend runtime_paths with standard subdir helpers, add POLICY-DATA-PATH-001, and update CONFIG/RUNTIME cross-references.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:14:38 +08:00
6f958ded28 feat(template): SRCP v1 standards, job_context/finish gold sample (v1.0.38, platform-kit 1.2.0)
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:27:44 +08:00
3bc15d1241 chore: auto release commit (2026-07-11 15:57:40)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-11 15:57:43 +08:00
c67079fb9e chore: auto release commit (2026-07-11 15:00:19)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-11 15:00:20 +08:00
dc4eecbb35 chore: auto release commit (2026-07-11 14:43:15)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-11 14:43:16 +08:00
766d162245 fix: harden skill template contracts and scaffolding
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Align scaffold slug replacement, Action manifest strict boundaries, metadata prune API, field permission validation, and task_logs readonly semantics with documented template contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:55:46 +08:00
35c20fc0f2 feat: standardize skill data management and actions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:23:14 +08:00
48a86e56f1 chore: auto release commit (2026-07-03 18:50:07)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-03 18:50:08 +08:00
7136689efe feat: 固化 verb-noun-platform slug 命名规范与语义校验
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-03 10:24:05 +08:00
e366605299 feat: 规范标准时间字段 created_at/updated_at(Unix 秒级、元数据、trigger)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-01 18:51:33 +08:00
707d97760f feat: 匠厂数据管理展示元数据规范——元数据表初始化/校验、display_order 契约、文档与测试
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-01 11:39:28 +08:00
d17804e45b 同步 Platform Kit 1.0.17 运行时环境变量契约
All checks were successful
技能自动化发布 / release (push) Successful in 4s
2026-06-30 14:41:54 +08:00
a3bd8faf87 docs(config): unify config.get reads and remove ALLOW_* gates
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-30 10:29:03 +08:00
30803a0834 chore: auto release commit (2026-06-21 10:51:04)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-21 10:51:05 +08:00
f3f7a9b9e5 chore: auto release commit (2026-06-17 09:48:59)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-17 09:49:00 +08:00
79098dad5c chore: auto release commit (2026-06-17 09:21:19)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-17 09:21:21 +08:00
b267ca3266 chore: auto release commit (2026-06-15 17:32:47)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-15 17:32:49 +08:00
87ef00835b chore: auto release commit (2026-06-14 11:17:52)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-14 11:17:53 +08:00
c7d7832749 chore: auto release commit (2026-06-14 11:12:03)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-06-14 11:12:04 +08:00
955816c8a7 docs: remove internal developer reminder from user-facing README
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-13 15:38:47 +08:00
7827cc75b2 chore: auto release commit (2026-06-13 14:59:54)
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-06-13 14:59:54 +08:00
a24f6809da chore: auto release commit (2026-06-08 14:19:45)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-08 14:19:46 +08:00
5b555e4313 去掉依赖 2026-06-07 14:50:44 +08:00
4c5bc31b0e 继续优化代码 2026-06-07 12:02:17 +08:00
45cf1741e1 继续完善代码 2026-06-07 11:51:23 +08:00
1bbcc65cf3 重新调整模板 2026-06-07 11:28:45 +08:00
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
155 changed files with 17041 additions and 464 deletions

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
# ── 运行模式 / adapter 档位(见 development/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=0 # 0=默认不录屏1=启用录屏+字幕+旁白+背景音+MP4
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──
STEP_DELAY_MIN=1.0 # 步骤间随机等待下限(秒)
STEP_DELAY_MAX=5.0 # 步骤间随机等待上限(秒)
HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒)
# ── 数据目录子路径(见 development/DATA_PATHS.md留空则用 skill 数据目录下默认子文件夹)──
# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace / CWD
# SKILL_DOWNLOAD_DIR=downloads
# SKILL_IMPORT_DIR=imports
# SKILL_EXPORT_DIR=exports
# SKILL_UPLOAD_DIR=uploads
# SKILL_CACHE_DIR=cache
# SKILL_TEMP_DIR=temp

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/

5
.openclaw-skill-template Normal file
View File

@@ -0,0 +1,5 @@
# 此文件仅存在于 skill-template 源仓库。
# 用 tools/scaffold_skill 创建新技能时,脚本会从目标目录删除本文件。
# 若业务技能仓库中仍存在本文件,说明未正确 scaffoldtests 会失败。
template_slug: your-skill-slug
template_version: "1.0.32"

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 返回的结果说明查看生成文件、任务记录或处理报告。

109
SKILL.md
View File

@@ -1,52 +1,103 @@
---
# ---------------------------------------------------------------------------
# 技能清单Skill Manifest
# ---------------------------------------------------------------------------
name: 头条号批量发布
description: 头条号批量发布技能(骨架阶段:仅健康检查与版本;发布逻辑待实现)。
version: 0.1.0
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.39
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: toutiao-publisher
emoji: "📰"
category: "内容发布"
skill:
slug: toutiao-publisher
emoji: "📰"
category: "内容发布"
slug: your-skill-slug
platform_kit_min_version: "1.2.0"
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 等实现细节,除非用户明确询问技术实现。
- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`
## 文档分工
| 文档 | 读者 | 用途 |
|------|------|------|
| 根目录 `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.2.0``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.2.0`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是**技能 pip 依赖声明。
- Skill 代码**不要**自行 `pip install`;系统级依赖(如 VC++ Runtime仅在 `health` / preflight 中提示用户安装。
- `health` 使用 `collect_runtime_diagnostics` 输出统一 runtime 诊断(只读,不下载/修复 media-assets
实现中:后续将在 `scripts/` 下增加发布入口,并在此文档补充命令与参数说明。
## 定制入口
## 环境依赖
1. 从本模板创建新业务技能时,**不得保留模板 `.git`**;优先使用 [`tools/scaffold_skill.ps1`](tools/scaffold_skill.ps1)(见 [`tools/README.md`](tools/README.md))。
2. 业务技能仓库中**不得**存在 `.openclaw-skill-template`(仅 template 源仓库保留)。
3. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词。
4.`development/DEVELOPMENT.md` 完成开发与文档定制。
5. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md``references/ACTIONS.md`
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序)。
详见本仓库 `docs/RUNTIME.md``CLAW_DATA_ROOT``CLAW_USER_ID` 等)。
## 技能命名与 slug
## 数据与隐私
- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。
- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case`name``description`、根 `README.md`**中文**。
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`
- `name`frontmatter**用户可见的中文业务名称**`description` 是说明,不是名称。
- `metadata.openclaw.slug`**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug不使用中文。
- 复制后全局替换 `your-skill-slug``your_skill_slug`(脚手架会自动处理);正式技能不得保留英文占位 `name` 或 slug。
本技能若产生持久化数据,应仅写入 `{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`

11
assets/README.md Normal file
View File

@@ -0,0 +1,11 @@
# assets
- `actions.json`Skill Action manifest 最小示例(**可选**;不需要宿主入口时可删除)。
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema`skill-actions.schema.json` 为**新技能严格规范**`task-log-record.schema.json` 等)。
`skill-actions.schema.json` 使用 `additionalProperties: false` 表达模板推荐契约;宿主运行时可能更宽松以兼容历史 manifest。`row`/`batch` placements 与 `bind`/`concurrency`/`locks` 为预留能力,当前模板示例不得使用。
- 用户市场说明见根目录 [`README.md`](../README.md)
- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md)
- 开发规范见 [`development/`](../development/)

39
assets/actions.json Normal file
View File

@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "health",
"label": "运行检查",
"description": "检查技能运行环境并返回结构化结果。",
"placements": ["skill-detail", "agent"],
"entrypoint": {
"type": "cli",
"command": "health",
"args": []
}
},
{
"id": "version",
"label": "查看版本",
"description": "返回技能版本和机器标识。",
"placements": ["skill-detail"],
"entrypoint": {
"type": "cli",
"command": "version",
"args": []
}
},
{
"id": "config-path",
"label": "查看配置位置",
"description": "返回技能配置文件位置。",
"placements": ["skill-detail"],
"entrypoint": {
"type": "cli",
"command": "config-path",
"args": []
}
}
]
}

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,13 @@
amazon
shopee
xinghang
alibaba
kingdee
icbc
pingpong
worldfirst
paypal
lianlian
single-window
export-rebate
etax

4
assets/naming/scopes.txt Normal file
View File

@@ -0,0 +1,4 @@
all
batch
multi
default

25
assets/naming/verbs.txt Normal file
View File

@@ -0,0 +1,25 @@
scrape
receive
download
export
add
fill
enter
bind
withdraw
reconcile
verify
submit
query
process
reply
generate
draft
review
confirm
track
upload
match
triage
ship
reprice

View File

@@ -0,0 +1,128 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
"title": "SkillActionManifest",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范schemaVersion 1。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
"type": "object",
"required": ["schemaVersion", "skill", "actions"],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"skill": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"description": "与 SKILL.md metadata.openclaw.slug 一致"
},
"actions": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/action" }
}
},
"$defs": {
"placement": {
"type": "string",
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"description": "模板 Phase 1 稳定支持 toolbar/cron/agent/skill-detailrow/batch 为预留入口,新技能示例不得使用"
},
"scalarArg": {
"type": ["string", "number", "boolean"]
},
"inputProperty": {
"type": "object",
"required": ["type"],
"additionalProperties": true,
"properties": {
"type": {
"type": "string",
"enum": ["string", "number", "boolean", "object"],
"description": "模板 Phase 1 仅推荐 string/number/boolean根 inputSchema.type=object 的 properties 使用基础标量类型"
},
"title": { "type": "string" },
"description": { "type": "string" },
"default": {},
"enum": {
"type": "array",
"minItems": 1
},
"sensitive": { "type": "boolean" }
}
},
"inputSchema": {
"type": "object",
"required": ["type", "properties"],
"additionalProperties": false,
"properties": {
"type": { "const": "object" },
"required": {
"type": "array",
"items": { "type": "string" }
},
"properties": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/inputProperty" }
}
}
},
"entrypoint": {
"type": "object",
"required": ["type", "command", "args"],
"additionalProperties": false,
"properties": {
"type": { "const": "cli" },
"command": {
"type": "string",
"minLength": 1
},
"args": {
"type": "array",
"items": { "$ref": "#/$defs/scalarArg" },
"description": "仅允许 string/number/boolean 标量数组"
}
}
},
"confirmation": {
"type": "object",
"required": ["message"],
"additionalProperties": false,
"properties": {
"message": {
"type": "string",
"minLength": 1
}
}
},
"action": {
"type": "object",
"required": ["id", "label", "description", "placements", "entrypoint"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
"minLength": 1
},
"label": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string",
"minLength": 1
},
"placements": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/placement" }
},
"entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" }
},
"description": "模板 Phase 1 不包含 bind/concurrency/locks 等预留能力字段;宿主类型可能定义这些字段,但新技能不得依赖"
}
}
}

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

127
development/ADAPTER.md Normal file
View File

@@ -0,0 +1,127 @@
# 适配器标准:真实/仿真 × 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 adapter委托 *_playwright.py
real_rpa.py # 真实系统 RPA占位谨慎实现
scripts/service/
<platform>_playwright.py # RPA 主流程(厚);与薄 sim_rpa.py 并列
account_client.py # account-manager subprocess 唯一封装
browser_session.py # async persistent context 启动
```
模板示例(均在 `examples/`**不在** `scripts/`
- **仿真浏览器 RPA 完整案例**`examples/simulator_browser_rpa/` — async mock + simulator_rpa + `simulator_playwright.py` + sandbox 页面 + adapter 分层 + 测试
- **真实浏览器 RPA 完整案例**`examples/real_browser_rpa/` — 登录/验证码/滚动采集 + `account_client.py`
- **真实 API / 仿真 API**`examples/real_api/``examples/simulator_api/` — 当前为规划占位,尚未沉淀可复制实现
复制 adapter 分层时,以修正后的 `examples/simulator_browser_rpa/`(薄 adapter + `simulator_playwright.py`)与 `examples/real_browser_rpa/scripts/service/account_client.py` 为权威参考,在新技能中创建 `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` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa``real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/``util/``db/`)。
5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。
```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 与隔离体系

116
development/CONFIG.md Normal file
View File

@@ -0,0 +1,116 @@
# 配置标准:`.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──
# 浏览器 RPA simulator_rpa 联调账号来自 account-managerplatform + profile_dir不是此处的 DEFAULT_LOGIN_ID
DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ──
OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启反检测指纹(默认)
# ── 录屏与失败存证 ──
OPENCLAW_RECORD_VIDEO=0 # 0=默认不录屏1=启用录屏+字幕+旁白+背景音+MP4见 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``SKILL_DOWNLOAD_DIR`**不要污染全局命名空间**(禁止 `SCRAPE_1688_*``RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
### 数据目录子路径(下载 / 导入 / 导出)
技能**自己写入**的文件默认在 `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` 下标准子目录(`downloads/``imports/``exports/` 等)。须通过 `util.runtime_paths.resolve_data_path()``get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
- 权威说明:[`DATA_PATHS.md`](DATA_PATHS.md)
- `.env.example` 中路径类配置**留空即用默认**;若写相对路径,必须相对 **skill 数据目录****禁止** `./outputs/...` 这类相对 workspace 的写法
- 可选 env`SKILL_DOWNLOAD_DIR``SKILL_IMPORT_DIR``SKILL_EXPORT_DIR` 等(见 DATA_PATHS.md
### 录屏开关(`OPENCLAW_RECORD_VIDEO`
- **模板默认 `OPENCLAW_RECORD_VIDEO=0`**,降低本地开发 / 调试成本(不启 ffmpeg、不生成 MP4
- 需要**生产留证、合规记录、客户现场复现或排障**时,可在用户 `.env` 或进程环境变量中设为 `OPENCLAW_RECORD_VIDEO=1`
- **默认关闭不代表可以删除录屏接入**RPA / 长任务代码仍须保留 `RpaVideoSession``video.add_step` 与 video artifact 写入 `result_summary`(见 `RPA.md` §5.3)。
- **已落盘的用户 `.env` 不会被模板整体覆盖**;老用户保留原值,技能升级时仅通过 `merge_missing_env_keys` **追加** `.env.example` 中新增的 key。
## 🚫 红线:敏感信息不进 `.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` 绝对路径),便于排查落盘位置;业务 skill 建议附加 `list_resolved_data_paths()` 中的子目录路径(见 [`DATA_PATHS.md`](DATA_PATHS.md))。
## 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_DATA_ROOT` 与数据根约定
- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范

160
development/DATA_PATHS.md Normal file
View File

@@ -0,0 +1,160 @@
# 用户数据目录与文件路径黄金标准
> 本文是 skill **读写本地文件** 的权威约定。凡涉及下载、导入、导出、缓存、RPA 存证等路径复制模板后须遵守本文Agent 实现业务 skill 时也应先读本文。
---
## 1. 根目录
所有技能持久化数据(配置、数据库、业务文件)默认落在:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/
```
示例:
```text
D:\jiangchang-data\12500\download-video-baidu-haokan\
```
实现:`util.runtime_paths.get_skill_data_dir()`
**禁止**把技能自己写入的文件落到:
- Agent / 宿主 **workspace**(进程 CWD
- 技能安装目录(含 `.openclaw/skills/...`
- 未在本文声明的任意相对 CWD 路径
---
## 2. 标准子目录树
| 子目录 | 用途 | 默认 helper |
|--------|------|-------------|
| `downloads/` | 从外部拉取的原始文件视频、回单、PDF… | `get_downloads_dir()` |
| `imports/` | 用户/Agent 放入的待处理清单urls.txt、batch.json、CSV | `get_imports_dir()` |
| `exports/` | 导出给用户的结果xlsx/csv/json | `get_exports_dir()` |
| `uploads/` | 待发布到外部平台的 staging可选 | `get_uploads_dir()` |
| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` |
| `temp/` | 单次任务 scratch任务结束可删 | `get_temp_dir()` |
| `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` |
| `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` |
| `{skill_slug}.db` | SQLite | `get_db_path()` |
| `.env` | 用户配置 | `config.get_env_file_path()` |
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。
---
## 3. 两类路径
### A. 技能拥有skill-owned
技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。
- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。
- **必须**通过 `resolve_data_path()``get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
### B. 用户显式输入user-owned
用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。
- 技能**只读**,路径可以是用户磁盘任意位置。
- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/imports/<相对路径>`**不**相对 workspace/CWD。
---
## 4. 配置项(`.env`
可选覆盖项(**留空则用默认子目录**
```ini
# ── 数据目录子路径(见 development/DATA_PATHS.md──
# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace
# SKILL_DOWNLOAD_DIR=downloads/videos
# SKILL_IMPORT_DIR=imports
# SKILL_EXPORT_DIR=exports
# SKILL_UPLOAD_DIR=uploads
# SKILL_CACHE_DIR=cache
# SKILL_TEMP_DIR=temp
```
| 规则 | 说明 |
|------|------|
| 未配置 | `{skill_data_dir}/{default_subdir}` |
| 相对值 `downloads/videos` | `{skill_data_dir}/downloads/videos` |
| 绝对值 `D:\nas\exports` | 高级用户/NAS 挂载,原样使用 |
| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 |
业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。
---
## 5. 代码用法
```python
from util.runtime_paths import (
get_downloads_dir,
get_imports_dir,
resolve_data_path,
resolve_input_path,
list_resolved_data_paths,
)
# 下载保存A 类)
dest = os.path.join(get_downloads_dir(), f"{vid}.mp4")
# 或带 env 覆盖
out_dir = resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads")
# CLI -i urls.txtB 类,相对路径 → imports/
manifest = resolve_input_path(args.input_path)
# health 诊断
paths = list_resolved_data_paths()
```
**反模式(禁止):**
```python
# ❌ 相对 CWDAgent 从 workspace 执行时会写错盘
os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR"))
# ❌ .env.example 里写 ./outputs/videos
DOWNLOAD_OUTPUT_DIR=./outputs/videos
```
---
## 6. health / config-path
`health``config-path` 建议输出 `list_resolved_data_paths()` 中的路径,便于排查「文件写到哪里去了」。展示路径须与 `run` 实际使用的一致。
---
## 7. 与 RPA 存证的关系
- 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1
- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3
业务 `downloads/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
---
## 8. 测试
- 单元测试须用 `IsolatedDataRoot`,断言路径落在临时数据根下。
- `tests/test_runtime_paths.py` 覆盖 `resolve_data_path` / `resolve_input_path`
- `POLICY-DATA-PATH-001`:禁止 `.env.example``./` 路径;禁止业务代码 `abspath(config.get(...))`
---
## 9. 相关文档
| 文档 | 内容 |
|------|------|
| [`RUNTIME.md`](RUNTIME.md) | 数据根、共享 runtime、编码 |
| [`CONFIG.md`](CONFIG.md) | `.env` bootstrap 与三层优先级 |
| [`RPA.md`](RPA.md) | rpa-artifacts / videos 存证 |
| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | POLICY-DATA-PATH-001 |

936
development/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,936 @@
# 技能开发教程
这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到 `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 仓库模板**。
你不应该直接在这个仓库里开发业务,而应该:
0. 按 [`NAMING.md`](NAMING.md) 确定 slug`{verb}-{noun-phrase}-{platform}`
1. **优先**用 [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1) 创建新目录(见 [`tools/README.md`](../tools/README.md)
2. 在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`
3. 把占位内容替换掉
4. 再开始写业务逻辑
> **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git``git remote -v` 必须指向新技能仓库,不能仍是 skill-template。
## 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、可选浏览器自动化
**负责业务关键节点日志**`task_start` / 外部调用 / `task_failed` 等,见 [`LOGGING.md`](LOGGING.md)
- `util/`
作用:常量、日志、路径、时间工具、通用帮助函数
`util/logging_config.py``jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger
公共能力config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP从共享 runtime 的 `jiangchang-platform-kit>=1.2.0` import**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
## 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`async
- `examples/simulator_browser_rpa/scripts/service/account_client.py`subprocess对齐 `real_browser_rpa`
- `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py`RPA 主流程,复制后改名为 `<platform>_playwright.py`
- `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock
- `examples/simulator_browser_rpa/scripts/service/task_service.py`async 编排)
- `sandbox/demo_app.html` 仅留在 examples不进入生产 skill
- **禁止**`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
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.2.0`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** 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`。
### 第一步:复制模板并改目录名
例如你要开发 `disburse-payroll-icbc` 一类领域 skill目录名 = slug
#### 推荐方式(首选)
在 **skill-template 仓库根目录**执行:
```powershell
.\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc
cd D:\OpenClaw\client-gdcm\disburse-payroll-icbc
git init
git remote add origin <你的新技能 Gitea/Git 仓库 URL>
git remote -v # 确认 origin 不是 skill-template
```
跨平台可用 `python tools/scaffold_skill.py --slug ... --destination ...`(见 [`tools/README.md`](../tools/README.md))。
脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。
#### 禁止方式
| 做法 | 后果 |
|------|------|
| ❌ 资源管理器整文件夹复制后不做 Git 清理 | 隐藏 `.git` 被带走push 串到 template |
| ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template |
| ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 |
#### 若已手工复制(补救)
```powershell
cd <新技能目录>
Remove-Item -Recurse -Force .git
Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
git remote -v
```
#### AI / 编程代理复制红线
| 禁止 | 说明 |
|------|------|
| 保留模板 `.git` | 必须先 `Remove-Item -Recurse -Force .git` 再 `git init` |
| `origin` 仍指向 skill-template | 复制后必须 `git remote -v` 自检 |
| 保留 `.openclaw-skill-template` | 仅 template 源仓库可有;业务技能不得保留 |
| 复制 `.pytest_cache` / `__pycache__` | 用 scaffold 已排除;手工复制应删 |
目录名要和 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` — 数据库路径、`_jiangchang_*` 数据管理契约、日志表结构
- `ACTIONS.md` — Skill Action manifest、宿主入口、参数与执行契约
- 可按需增加 `ERRORS.md`、`INTEGRATION.md` 等编排向补充
**`development/`**(开发者 / AI 编程代理)
- `REQUIREMENTS.md` — 需求与验收
- `DEVELOPMENT.md` — 开发教程(本文档)
- `TESTING.md` — 测试分层与隔离(详见 [`TESTING.md`](TESTING.md)
- `LOGGING.md` — 日志分层、必打节点、敏感信息红线
- `ADAPTER.md`、`RPA.md`、`CONFIG.md`、`RUNTIME.md` — 技术规范
## 8. `assets/` 应该放什么
`assets/` 不放业务代码,只放辅助材料。
建议放:
- `actions.json`
宿主 Action 入口 manifest可选见 `references/ACTIONS.md`
- `examples/`
比如 `version` 输出示例、`log-get` 输出示例
- `schemas/`
比如 `skill-actions.schema.json`、日志记录、机读 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`
放鉴权逻辑
### 日志要求(`cmd_run` / 核心业务)
`service` 层是**关键业务日志**的落点,须遵循 [`LOGGING.md`](LOGGING.md)
- `cmd_run` 及主任务入口必须:`log.info("task_start ...")`、`log.info("task_log_saved ...")`;失败路径必须 `log.exception("task_failed ...")`。
- 外部 API、兄弟技能、RPA 操作须记录**开始 / 结束 / 耗时 / 状态**`external_call_start` / `external_call_done elapsed_ms=...`)。
- 长任务 / RPA 须配合 `activity.emit` 或 `RpaVideoSession.add_step` 输出用户可见进度;`cmd_run` 须 `job_context` + 各出口 `finish`(见 [`LOGGING.md`](LOGGING.md) §2.5)。
- 使用 `from util.logging_config import get_skill_logger`**不要**用 `print` 替代 logger 做排障日志。
- **禁止**在日志中写入 password、token、cookie 等敏感明文(见 `LOGGING.md` §敏感信息红线)。
### 一个很重要的原则
不要把所有逻辑都堆进一个文件。
推荐流向是:
`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/display_metadata.py`
创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`
- `db/display_metadata_validator.py`
元数据完整性、editable 权限、options_json 等自检
- `db/timestamp_columns.py`
标准时间字段 trigger 与维护约定(`created_at` / `updated_at`Unix 秒级)
不要在 `db` 层里:
- 调浏览器
- 打印用户提示
- 拼接业务流程
## 11.5 Skill Action manifest可选
若技能需要出现在宿主**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中,提供 `assets/actions.json`。
- 契约说明见 [`references/ACTIONS.md`](../references/ACTIONS.md)
- JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)
- 模板最小示例仅暴露 `health` / `version` / `config-path` 三个 CLI 子命令
- 没有宿主直接操作需求时,**可以删除** `actions.json`;删除后勿保留失效引用
- 复制为新技能后scaffold 会自动将 `your-skill-slug` 与 `your_skill_slug` 替换为新 slug后者用于 `LOG_LOGGER_NAME` 等下划线形式action 业务命令由技能作者自行调整
宿主执行方式:
```text
python {skillRoot}/scripts/main.py <command> <args...>
```
CLI 必须提供稳定退出码、结构化成功输出,以及 `ERROR:<CODE>:` / `HINT:` 错误契约(详见 `references/ACTIONS.md`)。
## 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
```
除 `health` / `version` / `logs` 外,执行一次 `run`(或你的主任务命令)后,应确认**统一日志文件**已生成并可检索:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
```
其中应能看到 `cli_start`、`task_start` 等带 `trace_id` / `skill_slug` 的行。路径与分层说明见 [`LOGGING.md`](LOGGING.md)。
### 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` 通过。测试不通过不得发起正式发布。
`release.ps1` 会在 **auto commit 之前**、推送 tag 之前,对**当前工作区**自动执行默认测试(`python tests/run_tests.py -v`);失败则中止 release不会留下失败状态的自动提交。仅紧急排障可使用 `-SkipTests` 跳过(会打印黄色警告)。`-DryRun` 预览发布时**仍会实际运行**默认测试,便于在不打 tag 的情况下验证门禁。
新增或调整开发规范时,须同步 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md) 与 [`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py)(或注明由既有测试覆盖)。
当本地开发、自测和联调完成后,还需要把 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 发布前,建议技术人员逐条确认:
- [ ] 目录结构符合当前模板
- [ ] slug 符合 [`NAMING.md`](NAMING.md)verb-noun-platform
- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致
- [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `scripts/util/constants.py` 已修改
- [ ] `../references/CLI.md` 示例命令已改成真实命令
- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
- [ ] 没有残留旧平台名
- [ ] `health` / `version` 可运行
- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag
- [ ] `.github/workflows/release_skill.yaml` 存在
- [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`
- [ ] 主任务入口(如 `cmd_run`)有关键日志:`task_start`、`task_log_saved`;失败路径使用 `logger.exception`
- [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md)
- [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**PyArmor 试用/免费模式限制;推荐 < 900 行)
- [ ] 源码/文档/配置为 **UTF-8 without BOM**Python 文件不得含 `U+FEFF`
- [ ] 没有新增默认必跑测试访问真实网络或浏览器
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`
- [ ] `git remote -v` 指向**本技能**远端URL 不含 skill-template 仓库名
- [ ] `git log` 首条提交属于本技能(非模板历史)
## 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 入口
### 错误 5平台放 slug 最前
表现:
- slug 形如 `erp-download-settlement-report`(平台段错误地放在最前)
- 语义校验或 `test_slug_naming` 失败
要改:
- 动词放首段,平台放末段,例如 `download-settlement-report-erp`
- 见 [`NAMING.md`](NAMING.md)
### 错误 6noun 段过长导致超 48 字符
表现:
- slug 超过 48 字符
- 中间名词段堆砌过多
要改:
- 精简 noun-phrase保留核心业务含义
- 见 [`NAMING.md`](NAMING.md) §3 硬约束
### 错误 7只改目录名未改 slug
表现:
- 与「错误 1」相同但目录已改名而 `SKILL.md` / `constants.py` 仍为占位
要检查:
- `SKILL.md` 的 `metadata.openclaw.slug`
- `scripts/util/constants.py` 的 `SKILL_SLUG`
### 错误 8复制模板时带走了 `.git`
表现:
- `git remote -v` 仍显示 skill-template 的 origin
- push 到错误仓库,或 log 里全是模板历史
- 新技能目录里仍有 `.openclaw-skill-template`
修复:
```powershell
Remove-Item -Recurse -Force .git
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
```
以后复制请优先用 `tools/scaffold_skill.ps1`(见 [`tools/README.md`](../tools/README.md))。
## 18. 推荐开发顺序总结
如果让一个新人照着做,我建议他按这个顺序:
1. 按 [`NAMING.md`](NAMING.md) 确定 slug复制模板并改目录名
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 一多,就会越来越乱。

309
development/LOGGING.md Normal file
View File

@@ -0,0 +1,309 @@
# 日志规范
本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能**必须**在实现前通读本文,并在 code review 时对照「必打日志节点清单」人工检查。
---
## 1. 为什么日志是必需的
OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览器、ERP、微信等外部系统。这类任务具备以下特征
- **耗时长**:单次 run 可能数分钟甚至更久stdout 长时间无输出不代表卡死。
- **失败点多**网络抖动、页面改版、登录态失效、验证码、U 盾等都会导致中途失败。
- **环境差异大**:客户 OS、浏览器版本、代理、杀毒软件、屏幕分辨率各不相同无法复现开发机行为。
- **必须离线排查**:技术支持往往无法远程登录客户电脑,只能依赖本地落盘的日志与 artifacts。
日志用于定位:
| 维度 | 说明 |
|------|------|
| 失败阶段 | 任务停在鉴权、取号、打开页面、提交还是等待结果 |
| 输入范围 | `task_type``target_id``input_id``batch_id` |
| 外部系统状态 | HTTP 状态、RPA 步骤、兄弟技能返回码 |
| 耗时 | `elapsed_ms`、批量 `current/total` |
| 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 |
| 存证路径 | 截图、录屏、`video_log`、失败 artifacts |
**没有关键节点日志 = 客户现场不可排障。** 不要把 `print` 当作日志stdout 面向用户与 Agent文件日志与 Run Journal 面向运维。
---
## 2. 日志分层
技能日志分四层,职责互不替代:
### 2.1 统一文件日志(开发 / 运维排查)
**来源**`jiangchang_skill_core.unified_logging`(技能内通过 `util.logging_config` 薄封装 import
| API | 用途 |
|-----|------|
| `setup_skill_logging` | CLI 入口启动时初始化(`cli/app.py``main()` |
| `get_skill_logger` | 获取带 `skill_slug` 的 logger |
| `subprocess_env_with_trace` | 子进程 / 兄弟技能调用时传递 `trace_id` |
**路径**
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
```
**特征**:每行带 `trace_id``skill_slug`;适合 grep、离线打包发给技术支持。
### 2.2 Activity / Run Journal宿主 UI 与用户可见进度)
**来源**`jiangchang_skill_core.activity`
| API | 用途 |
|-----|------|
| `emit` | 推送用户可读进度(不写 stdout进入步骤前自动步骤闸门SRCP |
| `step` | 结构化步骤 |
| `finish` | 任务结束;**唯一**写单行 result JSON 到 stdout 的场景 |
| `job_context` | 包裹 `cmd_run`;未捕获异常 / `JobStopped` 时自动 `finish` |
| `rpa_step` | RPA 专用步骤:闸门 + ▶/✓ emit |
| `interruptible_sleep` | RPA 等待(替代裸 `asyncio.sleep`),可暂停/停止 |
| `checkpoint` | 长循环内 consult control一般由 kit 自动调用) |
**路径**
```text
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl
```
**约定**
- `emit` **不写 stdout**;长任务应持续 emit避免用户以为卡死。
- `finish` 才输出单行 result JSON 供宿主解析。
- RPA 类技能:`RpaVideoSession.add_step` 会自动同步 activity无需重复手写每步 emit
- **禁止**自建 `scripts/util/progress.py` 或向 stdout 打印 `type:progress` JSON进度只走 Run Journal。
- 宿主通过 `{job_id}.control.json` 下发暂停/继续/停止;技能侧由 platform-kit 在步骤闸门自动处理(见 §2.5)。
### 2.5 步骤控制SRCP v1platform-kit >= 1.2.0
**权威行为定义在本文**platform-kit README 为实现细节参考。
| 通道 | 方向 | 载体 |
|------|------|------|
| 进度 | 技能 → 宿主 | Run Journal`emit` / `rpa_step` / `add_step` |
| 结果 | 技能 → 宿主 | `finish()` → Journal 终态 + stdout 一行 result |
| 控制 | 宿主 → 技能 | `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.control.json` |
`control.json``command` 只认:`none` | `pause` | `resume` | `stop`
技能作者三件事:
1. `cmd_run` 外包 `with job_context(skill=SKILL_SLUG):`
2. 关键步骤 `emit(...)``@rpa_step` / `video.add_step`
3. 每个出口 `finish(status=..., message=..., skill=SKILL_SLUG, **fields)` — **不要**手写多行 JSON 结果
RPA 等待用 `interruptible_sleep`**不要**裸 `asyncio.sleep`(否则暂停响应滞后)。
暂停在**步骤边界**生效(当前 `@rpa_step` / `emit` 执行完后、下一步开始前),与影刀「当前指令完成后暂停」一致。
Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`),供宿主任务中心显示状态。
### 2.3 `task_logs`(任务结果审计)
**来源**`db/task_logs_repository.save_task_log`
- 适合 `python scripts/main.py logs` / `log-get <id>` 查询。
- 记录任务最终状态、`result_summary`(可含 `video_path``video_log` 等)。
- 面向「这次任务成功还是失败、摘要是什么」,不是逐步 trace。
- **鉴权失败与未捕获异常也必须写入 `task_logs`**,保证 `logs` / `log-get` 排查链不断(见模板 `cmd_run` 示范)。
表结构与字段见 [`references/SCHEMA.md`](../references/SCHEMA.md)。
### 2.4 RPA video step / artifacts用户可见动作与存证
**来源**`RpaVideoSession``rpa-artifacts/` 目录
- video artifact 是**标准可启用能力****不保证默认每次生成 MP4**(模板默认 `OPENCLAW_RECORD_VIDEO=0`)。
- **`OPENCLAW_RECORD_VIDEO=0`**:不生成最终录屏,但 RPA / 长任务仍须保留 `RpaVideoSession``video.add_step` 与 Activity 进度同步。
- **`OPENCLAW_RECORD_VIDEO=1`**`result_summary` 应包含 `video_path``raw_video``video_log``video_warnings` 等字段CLI 可打印录屏路径与诊断。
- 用户可见的中文步骤(「打开登录页」「点击提交」)。
- 失败截图:`{数据目录}/rpa-artifacts/{batch_id}/...`(受 `OPENCLAW_ARTIFACTS_ON_FAILURE` 控制,默认开)
详见 [`RPA.md`](RPA.md) §5 存证与录屏规范。
---
## 3. 必打日志节点清单
以下节点应在**生产代码**中有对应 `log.info` / `log.warning` / `log.exception`(或 Activity / RPA step。复制模板后按业务增删但**不可整体省略**。
| 阶段 | 建议日志 / emit |
|------|-----------------|
| CLI 入口启动 | `cli_start``cli/app.py`,已由模板提供) |
| 参数解析完成 | `run_args target_id=... input_id=...` |
| 鉴权开始 | `entitlement_check_start` |
| 鉴权成功 | `entitlement_ok` |
| 鉴权失败 | `entitlement_failed``warning` |
| 任务开始 | `task_start task_type=... target_id=... input_id=... batch_id=...` + `emit("开始处理任务")` |
| adapter / test target 选择 | `adapter_selected adapter=... system=...` |
| 账号 / profile / lease 获取 | `account_acquired account_id=...` |
| 账号 / lease 释放 | `account_released account_id=...` |
| 外部 API 开始 | `external_call_start system=... operation=...` |
| 外部 API 结束 | `external_call_done system=... operation=... elapsed_ms=... status=...` |
| 兄弟技能调用开始 / 结束 | 同上,或 `sibling_call_start/done skill_slug=...` |
| RPA 操作开始 / 结束 | `rpa_start` / `rpa_done` + `video.add_step` 中文步骤 |
| 打开页面 | step「打开 xxx 页面」 |
| 检查登录 | step + log |
| 定位输入框 / 点击提交 | step + log |
| 等待结果 | step + log含 timeout 预期) |
| 批量循环 | `batch_progress current=... total=...` |
| 重试 | `retry_attempt n=... reason=...``warning` |
| 跳过 / 部分失败 | `skipped` / `partial_failure``warning` |
| 人工介入 | `human_intervention type=captcha|login|ukey|otp``warning` |
| 失败截图 / 视频 | `artifact_saved artifact_path=...` / `video_path=...` |
| 任务成功 | `task_success ...` + `task_log_saved status=success` |
| 任务失败 | `task_failed ...``exception` 保留 traceback+ `task_log_saved status=failed` |
---
## 4. 字段规范
推荐在 `log.info` 等消息中使用 **key=value** 结构化字段,便于 grep
| 字段 | 含义 |
|------|------|
| `task_type` | 任务类型(与 `task_logs.task_type` 对齐) |
| `target_id` | 目标标识(平台、店铺、报表类型等) |
| `input_id` | 输入记录 ID |
| `batch_id` | 批次 / 录屏批次 |
| `stage` | 阶段:`auth` / `run` / `cleanup` |
| `system` | 外部系统名:`erp` / `wechat` / `1688` |
| `operation` | 操作名:`submit_order` / `fetch_report` |
| `adapter` | adapter 档位:`mock` / `simulator_api` / `real_api` |
| `elapsed_ms` | 耗时毫秒 |
| `status` | `ok` / `failed` / `timeout` / `skipped` |
| `error_code` | 业务错误码(非 Python 异常名) |
| `artifact_path` | 截图 / 附件路径 |
| `video_path` | 录屏成品路径 |
示例:
```text
external_call_done system=erp operation=download_report elapsed_ms=4523 status=ok
batch_progress current=7 total=20 target_id=store-001
```
---
## 5. 日志级别规范
| 级别 | 使用场景 |
|------|----------|
| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度、`task_log_saved` |
| **warning** | 可恢复问题、重试、跳过、人工介入、部分失败、鉴权失败(业务可预期) |
| **exception** | 异常路径;**必须**用 `log.exception(...)` 保留 traceback |
| **debug** | 低频诊断;**默认不依赖 debug 才能排查问题** |
原则:**生产排障应主要靠 info + warning + exception**,不要把 debug 当作唯一线索。
---
## 6. 敏感信息红线
### 禁止记录原始值
以下字段**不得**以明文出现在日志、Activity、task_logs 的 `error_msg` / `result_summary`、stdout
- `password``token``cookie``secret``api_key``authorization`
- 验证码、动态口令全文
- 身份证、银行卡、手机号**完整值**
### 建议记录的安全替代
| 替代字段 | 示例 |
|----------|------|
| `credential_ref` | vault / account-manager 引用 ID |
| `account_id` | 账号表主键 |
| `masked_user` | `138****8000` |
| `domain` | `erp.example.com` |
| `last4` | 卡号后四位 |
| `hash` / `short_id` | 内容摘要 |
变量名可以叫 `token`,但日志里不能出现 `token=eyJhbG...` 或 f-string 拼接明文。
---
## 7. 推荐代码示例service 层)
```python
from util.logging_config import get_skill_logger
from jiangchang_skill_core.activity import emit, finish, job_context, rpa_step, interruptible_sleep
from util.constants import SKILL_SLUG
log = get_skill_logger()
def cmd_run(target=None, input_id=None):
task_type = "your_task"
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s target_id=%s input_id=%s",
task_type, target, input_id,
)
emit("开始处理任务", skill=SKILL_SLUG, stage="run")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason)
finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED")
return 1
t0 = time.monotonic()
# ... 外部调用 / RPA@rpa_step 或 video.add_step等待用 interruptible_sleep ...
elapsed_ms = int((time.monotonic() - t0) * 1000)
log.info(
"external_call_done system=%s operation=%s elapsed_ms=%d status=%s",
"erp", "submit", elapsed_ms, "ok",
)
tlr.save_task_log(..., status="success", ...)
log.info("task_log_saved task_type=%s status=success", task_type)
finish(status="success", message="任务完成", skill=SKILL_SLUG)
return 0
except Exception:
log.exception(
"task_failed task_type=%s target_id=%s input_id=%s",
task_type, target, input_id,
)
emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run")
tlr.save_task_log(..., status="failed", error_msg="...", ...)
finish(status="failed", message="任务执行异常", skill=SKILL_SLUG)
return 1
```
模板示范见 [`scripts/service/task_service.py`](../scripts/service/task_service.py) 的 `cmd_run`
---
## 8. 错误排查顺序
客户现场或测试机排障,建议按以下顺序(由快到慢、由用户可见到运维细节):
1. **CLI stdout** — 是否有 `ERROR:<CODE>:``HINT:`、模板提示文案。
2. **`python scripts/main.py logs`** — 最近任务是否写入 `task_logs`、status 与 `error_msg`
3. **`python scripts/main.py log-get <id>`** — 单条 JSON`result_summary`video 路径等)。
4. **统一日志文件**`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`grep `trace_id``task_failed`)。
5. **Run Journal**`{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`(宿主 UI 进度来源)。
6. **RPA artifacts**`rpa-artifacts/` 失败截图;若 `OPENCLAW_RECORD_VIDEO=1` 还有录屏 MP4、`video_log`。未开启录屏时可能只有截图或日志,需要完整录屏请在用户 `.env` 中设置 `OPENCLAW_RECORD_VIDEO=1`
长时间无 stdout **不代表卡死**;先看 Run Journal / Activity再看统一日志。
---
## 9. 与现有文档关系
| 文档 | 关系 |
|------|------|
| [`RUNTIME.md`](RUNTIME.md) | 统一日志能力来源、`util.logging_config` 薄封装、数据根约定 |
| [`TESTING.md`](TESTING.md) | 日志相关 hard policy 测试(`test_development_policy_guard.py` |
| [`RPA.md`](RPA.md) | RPA step、`RpaVideoSession`、video / artifacts 标准 |
| [`SCHEMA.md`](../references/SCHEMA.md) | `task_logs` 表结构与查询命令 |
| [`CLI.md`](../references/CLI.md) | `logs` / `log-get` 命令与手工排查入口 |
| [`CONFIG.md`](CONFIG.md) | 敏感配置不进日志;`.env` 边界 |
| [`DEVELOPMENT.md`](DEVELOPMENT.md) | `service/` 层日志职责与发布前检查清单 |
| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | 可自动检测的 logging hard policy 索引 |

237
development/NAMING.md Normal file
View File

@@ -0,0 +1,237 @@
# 技能 slug 命名规范verb-noun-platform
> 本文是匠厂**新业务 skill** 的 slug / Git 仓库名 / 目录名命名权威说明。复制 `skill-template` 出去开发新技能前**必读**Agent 在开发者询问命名或 slug 时也应先读本文。
---
## 1. 适用范围
| 范围 | 说明 |
|------|------|
| ✅ **适用** | 从 `skill-template` **复制出去的新技能** |
| ❌ **不追溯** | 已上线历史技能(如 `account-manager``reconcile-finance``scrape-contacts-lianjia` 等)保持不动 |
| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 |
| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 |
**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。
---
## 2. 命名格式
标准形态(约 95% 新技能):
```text
{verb}-{noun-phrase}-{platform}
```
| 部分 | 规则 |
|------|------|
| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) |
| **noun-phrase** | 13 个词(按 `-` 分段),描述业务对象 |
| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window` |
**展示层分工不变:**
| 字段 | 语言 | 示例 |
|------|------|------|
| slug / repo / 目录 / `SKILL_SLUG` / DB 文件名 | **英文** kebab-case | `download-settlement-report-amazon` |
| `SKILL.md``name``description`、根 `README.md` | **中文** | `亚马逊结算报表下载` |
---
## 3. 硬约束(新技能强制校验)
| 约束 | 值 |
|------|-----|
| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) |
| 总段数(按 `-` 分割) | **35 段**标准型scope 型见 [§4](#4-三类合法形态) |
| 总长度 | **≤ 48 字符** |
| 字符集 | 小写字母、数字、连字符kebab-case |
| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx` |
实现校验见 `scripts/util/slug_naming.py``scripts/db/display_metadata_validator.py``SKILL.md` frontmatter 自检时调用。
---
## 4. 三类合法形态
### 4.1 标准型95%
`verb-noun-platform``verb-noun-noun-platform`35 段)
- 第 1 段 ∈ 动词白名单
- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单
- 中间为 noun-phrase13 段)
示例:
- `download-settlement-report-amazon`
- `add-product-shopee`
- `enter-sales-receipt-kingdee`
- `fill-export-declaration-single-window`
### 4.2 scope 型(少见)
`verb-noun-scope`
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default`
- 第 1 段 ∈ 动词白名单
- 总段数 35
示例:`generate-article-all`
> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。
### 4.3 历史豁免(不校验语义)
以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`
- `account-manager`
- `skill-template`
- `your-skill-slug` / `your_skill_slug`(模板占位)
---
## 5. 动词白名单
词表文件:[`assets/naming/verbs.txt`](../assets/naming/verbs.txt)(每行一词,便于后续扩展)
| 动词 | 动词 | 动词 | 动词 |
|------|------|------|------|
| scrape | receive | download | export |
| add | fill | enter | bind |
| withdraw | reconcile | verify | submit |
| query | process | reply | generate |
| draft | review | confirm | track |
| upload | match | triage | ship |
| reprice | | | |
---
## 6. 平台白名单
词表文件:[`assets/naming/platforms.txt`](../assets/naming/platforms.txt)
| 平台 | 平台 | 平台 |
|------|------|------|
| amazon | shopee | xinghang |
| alibaba | kingdee | icbc |
| pingpong | worldfirst | paypal |
| lianlian | single-window | export-rebate |
| etax | | |
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all``batch``multi``default`
---
## 7. slug 与展示字段分工
```yaml
---
name: 亚马逊结算报表下载 # 中文,用户可见
description: "从卖家中心下载结算报表并入库" # 中文说明
metadata:
openclaw:
slug: download-settlement-report-amazon # 英文机器标识
---
```
同步修改:
- 目录名
- `metadata.openclaw.slug`
- `scripts/util/constants.py` 中的 `SKILL_SLUG`
- 默认数据库路径 `{slug}/{slug}.db`
三者必须一致。
---
## 8. 与 account-manager 的关系
slug **末段 platform**`account-manager` 中的 `platform_key` **语义对应**,但**不要求字符串完全相等**。
| slug 末段 (platform) | account-manager platform_key示例 |
|----------------------|--------------------------------------|
| amazon | `amazon_sim` / `amazon` |
| shopee | `shopee` |
| kingdee | `kingdee` |
新技能若需账号下发,在 `REQUIREMENTS.md` §0 填写拟用的 `platform_key`,并与兄弟技能调用参数对齐。
---
## 9. 复制新技能流程
1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符
2. **复制目录****推荐** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)`-Slug` 与目录名一致);勿整包复制带走 `.git`
2.5. **清理 Git若未用 scaffold** — 删除 `.git``.openclaw-skill-template``.pytest_cache`;再 `git init``git remote add origin`
3. **全局替换**`your-skill-slug` → 新 slug更新 `SKILL.md``constants.py`、文档中的占位
4. **填写 REQUIREMENTS** — 完成 [`REQUIREMENTS.md`](REQUIREMENTS.md) §0 技能标识
5. **开发 & 自检**`python tests/run_tests.py -v`,含 slug 语义测试;`git remote -v` 确认非 template
---
## 10. 正例 / 反例
### ✅ 正例
| slug | 说明 |
|------|------|
| `download-settlement-report-amazon` | 标准型4 段 |
| `add-product-shopee` | 标准型3 段 |
| `enter-sales-receipt-kingdee` | 标准型 |
| `fill-export-declaration-single-window` | 标准型,平台含连字符 |
| `generate-article-all` | scope 型 |
### ❌ 反例
| slug | 原因 |
|------|------|
| `amazon-download-settlement-report` | 平台在最前 |
| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 |
| `reconcile-finance` | 新技能禁止 2 段无平台 |
| `download_settlement_amazon` | 非 kebab-case下划线 |
---
## 11. 反模式
- **平台前置**`amazon-download-...``shopee-add-...`
- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称
- **只改目录名**`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步
- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读
- **仿历史 2 段 repo**`reconcile-finance``content-manager`
---
## 12. 演进说明(当前不启用)
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。
---
## 13. 历史形态说明
以下为代表性**历史 repo**,新技能**勿仿**
| 历史 slug | 说明 |
|-----------|------|
| `account-manager` | 跨平台账号管理,非 verb-noun-platform |
| `reconcile-finance` | 2 段无 platform |
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) |
| `skill-template` | 模板仓库本身 |
校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。
---
## 14. 相关文档
| 文档 | 内容 |
|------|------|
| [`DEVELOPMENT.md`](DEVELOPMENT.md) | 复制模板步骤、发布前检查清单 |
| [`REQUIREMENTS.md`](REQUIREMENTS.md) | §0 技能标识必填项 |
| [`../references/SCHEMA.md`](../references/SCHEMA.md) | kebab-case 与数据库路径 |
| [`../SKILL.md`](../SKILL.md) | frontmatter 命名摘要 |

View File

@@ -0,0 +1,49 @@
# 开发规范 → 自动检测索引
本文件是 **development/*.md 规范到机器检测的追溯表**,不是另一份长规范。新增或调整 hard 规则时,须同步更新本矩阵与 `tests/test_development_policy_guard.py`(或注明已有测试覆盖)。
| policy_id | 规则摘要 | 来源 | 强制级别 | 自动检测 | 对应测试 |
|-----------|----------|------|----------|----------|----------|
| POLICY-STRUCTURE-001 | 标准 `scripts/` 分层:`main.py``cli/``service/``db/``util/` 必须存在 | development/DEVELOPMENT.md §2 新 skill 的标准目录结构development/RUNTIME.md §目录结构 | hard | 目录存在性检查 | `tests/test_development_policy_guard.py::TestPolicyStructure001` |
| POLICY-RUNTIME-001 | 不得 vendored `scripts/jiangchang_skill_core/` | development/RUNTIME.md §共享 Python Runtime、§明确禁止 | hard | 目录不存在检查 | `tests/test_development_policy_guard.py::TestPolicyRuntime001`(另见 `tests/test_platform_import.py` |
| POLICY-RUNTIME-002 | `requirements.txt` 非注释依赖行不得声明 `jiangchang-platform-kit` / `playwright` | development/RUNTIME.md §共享 Python Runtimedevelopment/DEVELOPMENT.md §3.1 requirements.txt 依赖规范 | hard | 解析依赖行 | `tests/test_development_policy_guard.py::TestPolicyRuntime002`(另见 `tests/test_platform_import.py` |
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtimedevelopment/RPA.md §1.4 Playwright 与安装边界development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py``*.ps1``*.sh``requirements.txt``.github/workflows/*`(不扫 `development/*.md` | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
| POLICY-CONFIG-001 | 仓库根目录必须存在 `.env.example` | development/CONFIG.md §核心原则、§标准配置项 | hard | 文件存在性 | `tests/test_development_policy_guard.py::TestPolicyConfig001` |
| POLICY-CONFIG-002 | `.gitignore` 必须忽略 `.env``*.env.local` | development/CONFIG.md §红线:敏感信息不进 .env | hard | 解析 `.gitignore` 行 | `tests/test_development_policy_guard.py::TestPolicyConfig002` |
| POLICY-CONFIG-003 | 业务代码不得直接 `os.environ` / `os.getenv` / `os.putenv` / `environ.get`;须经 `jiangchang_skill_core.config.get*`(允许 `main.py`、测试、config_bootstrap/runtime 边界文件) | development/CONFIG.md §配置 bootstrapdevelopment/ADAPTER.md §档位 dispatch | hard | 扫描 `scripts/**/*.py` 并排除边界文件 | `tests/test_development_policy_guard.py::TestPolicyConfig003` |
| POLICY-TESTING-001 | 默认根目录 `tests/test_*.py` 不得硬编码 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`(白名单仅 `test_adapter_profile_policy.py`,用于档位 helper 自测) | development/TESTING.md §默认必跑测试要做什么、§4 测试目标档位 | hard | 扫描 `tests/test_*.py` 非注释行;白名单外禁止上述文本 | `tests/test_development_policy_guard.py::TestPolicyTesting001` |
| POLICY-TESTING-002 | `tests/integration/` 下 Python 测试文件若存在,默认应为 `.sample`,不得进入默认套件 | development/TESTING.md §7 真实联调测试的安全约束、§8 新 skill 的最小测试清单 | hard | 检查 `tests/integration/*.py``.sample` 后缀 | `tests/test_development_policy_guard.py::TestPolicyTesting002` |
| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py` |
| POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` |
| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession``video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3development/LOGGING.mddevelopment/CONFIG.md | hard | 扫描 `scripts/service/*.py`service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` |
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM`U+FEFF` | development/DEVELOPMENT.md §3.4development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |
| POLICY-LOGGING-001 | CLI 入口必须调用 `setup_skill_logging` | development/LOGGING.mddevelopment/RUNTIME.md | hard | 扫描 `scripts/cli/app.py``setup_skill_logging` 与 logger info | `tests/test_development_policy_guard.py::TestPolicyLogging001` |
| POLICY-LOGGING-002 | service 主任务入口必须使用 `get_skill_logger` | development/LOGGING.mddevelopment/DEVELOPMENT.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging002` |
| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info``log.exception`/`logger.exception`,并写入 `save_task_log` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` |
| POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.mddevelopment/RPA.md | hard | 扫描 `scripts/service/task_service.py``emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` |
| POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.mddevelopment/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` |
| POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` |
| POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(``finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` |
| POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`RPA 等待须 `interruptible_sleep` | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` |
| POLICY-DATA-PATH-001 | 技能拥有路径须经 `resolve_data_path` / `get_*_dir``.env.example` 禁止 `./` CWD 相对路径;业务代码禁止 `abspath(config.get(...))` | development/DATA_PATHS.md | hard | 扫描 `.env.example``scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyDataPath001` |
---
## 暂不自动检测(软规则)
以下规范仍须人工遵守或依赖 code review / AI 提示词,**不**做成默认测试,避免误伤:
| 规则摘要 | 来源 | 原因 |
|----------|------|------|
| `cli/` 只解析参数、不写业务逻辑 | development/DEVELOPMENT.md §9 | 需语义理解,无法可靠静态判定 |
| `service/` 编排流向与模块拆分粒度 | development/DEVELOPMENT.md §10 | 架构软约束 |
| `db/` 层不得调浏览器或拼业务流程 | development/DEVELOPMENT.md §11 | 需跨文件调用图 |
| slug 命名语义verb-noun-platform | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 |
| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` |
| RPA 拟人操作、选择器纪律、HITL 超时 | development/RPA.md §0§1 | 行为与 DOM 质量,无法静态扫描 |
| adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 |
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
| platform_kit_min_version >= 1.2.0 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |

19
development/README.md Normal file
View File

@@ -0,0 +1,19 @@
# 开发资料入口
本目录面向**人类开发者**与 **AI 编程代理**。开始定制 skill 前,建议按以下顺序阅读:
1. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
2. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
3. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
4. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
5. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**
6. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
7. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
8. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
9. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
10. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)`scaffold_skill.ps1`)。
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。
用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。

259
development/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,259 @@
# 技能需求文档模板
这份文档用于给技术人员、产品人员和实施人员统一描述某个 skill 的研发需求。
建议原则:
- 一个 skill 对应一份主需求文档
- 需求先写清楚,再进入开发
- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节
- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md``references/``development/` 下的技术规范
如果你是从 `skill-template` 复制新技能,请复制本文件后,把下面所有占位内容替换成你的真实项目内容。
---
# REQUIREMENTS
## 0. 技能标识(复制 REQUIREMENTS 时必填)
- 拟定 slug英文符合 `development/NAMING.md`
- 中文 name
- 中文 description一句话
- account-manager platform_key如有
- 命名形态:标准型 / scope 型
## 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`(含 `created_at` / `updated_at` Unix 秒级规范)
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
- `tests/test_display_metadata.py` 通过
- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联
- 发布后 Gitea 工作流成功;匠厂技能市场可见最新版本;安装后可在“新建任务”中调用
## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`
- 发布前完成本地验证、工作流验证和正式环境安装验证
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| YYYY-MM-DD | v1.0 | `<姓名>` | 初版需求文档 |
---
## 建议使用方式
1. 先写 `REQUIREMENTS.md`
2. 再按 `DEVELOPMENT.md` 进入开发
3. 开发过程中补充 `references/CLI.md``references/SCHEMA.md``development/` 技术规范
4. 发布前对照第 9 节验收标准逐项检查
## 最小模板示例
下面给出一个可直接参考的简化版(复制后替换占位符):
```md
# REQUIREMENTS
## 1. 文档目标
本文档用于明确 `your-skill-slug` 的研发需求、范围和验收标准,作为开发与测试的统一依据。
## 2. 业务背景
当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
因此需要开发 `your-skill-slug`,自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 task_logs。
## 3. 开发目标
- 解析并校验输入
- 通过 adapter 完成 mock / simulator / real 档位动作
- 输出结构化结果并写入 task_logs
- 支持失败可诊断
## 4. 功能范围
- 支持 health / version / run
- 支持 logs / log-get
## 5. 非功能要求
- 入口统一为 scripts/main.py
- 保持 UTF-8 输出;结构符合 skill-template
## 6. 输入输出要求
### 输入
- target_id、input_id按业务定义
### 输出
- 成功 JSON / 摘要
- ERROR: 稳定错误码
- task_logs 记录
## 7. 依赖的兄弟技能或外部系统
- `<sibling-skill>`(按需)
- `<目标系统>` API 或 sandbox
## 8. 不在本次范围内的内容
- 不实现自动重试
- 不实现 `<本次不做>`
## 9. 验收标准
- 命令可运行tests/run_tests.py -v 通过
- task_logs 符合 SCHEMA
- 正式环境安装验证通过
## 10. 开发注意事项
- 不修改无关项目;不引入旧模板结构
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| YYYY-MM-DD | v1.0 | 张三 | 初版 |
```

325
development/RPA.md Normal file
View File

@@ -0,0 +1,325 @@
# 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` 等,见下方错误码表 |
| **步骤可控制** | 每条 RPA 指令 = 一个 `@rpa_step``video.add_step`;等待用 `interruptible_sleep`;宿主通过 control.json 暂停/继续/停止SRCP见 [`LOGGING.md`](LOGGING.md) §2.5 |
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
> 三端各自实现一个会话抽象 `RpaSession`launch / login / act / screenshot / close上层 skill 不感知是浏览器还是手机。
### 0.1 步骤控制与进度SRCPplatform-kit >= 1.2.0
- **禁止** `scripts/util/progress.py``print({"type":"progress",...})`;进度只走 `emit` / Run Journal。
- **禁止** RPA 主路径裸 `asyncio.sleep`;用 `jiangchang_skill_core.activity.interruptible_sleep`
- RPA 函数用 `@rpa_step("中文步骤名")` 拆分;或在 `RpaVideoSession``video.add_step("中文步骤")`
- `cmd_run``with job_context(skill=SKILL_SLUG):`,所有出口 `finish(...)`
- 暂停在步骤边界生效;浏览器会话暂停时不强制关闭,恢复后同进程继续。
```python
from jiangchang_skill_core.activity import emit, finish, job_context, interruptible_sleep, rpa_step
@rpa_step("打开登录页")
async def open_login(page):
await page.goto("https://example.com")
await interruptible_sleep(1.5)
```
规范权威定义:[`LOGGING.md`](LOGGING.md) §2.5。金样代码:[`scripts/service/task_service.py`](../scripts/service/task_service.py)。
---
## 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.2.0**)。
**拟人操作**(必做):
- 输入:逐字符 `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.2.0**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/`(含 async `simulator_playwright.py`、薄 `simulator_rpa` adapter、`account_client.py` subprocess**不能**替代真实浏览器 RPA 规范。
### 1.6 仿真浏览器 RPA 示例(必读)
开发**自有仿真页面 / sandbox / 可控 DOM 的浏览器 RPA**(表单填写、批量提交、弹窗确认)时:
1. **必须先阅读** `examples/simulator_browser_rpa/README.md`
2. 再参考:
- `examples/simulator_browser_rpa/scripts/service/browser_session.py` — async persistent context**不含 goto**
- `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py` — RPA 主流程(厚)
- `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock + dispatch
- `examples/simulator_browser_rpa/scripts/service/account_client.py` — account-manager subprocess
- `examples/simulator_browser_rpa/scripts/service/task_service.py` — async 编排
- `examples/simulator_browser_rpa/sandbox/demo_app.html`
> **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox / 行业仿真 → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。
### 1.7 行业仿真平台浏览器 RPAjc2009 类)
适用:**自有或共享 `sandbox.jc2009.com` 等行业仿真平台**,不是真实高风控第三方站。
工程范式与 §1.5 相同:
| 项 | 标准 |
|----|------|
| Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 |
| 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`+ `account_client.py` subprocess |
| 登录 | **双层**:门户 HITL`#portal-user` / `#portal-pass` 类泛化 DOM+ 业务系统登录(技能内自写) |
| 账号 | `url` 用行业根,不用 `/login``auth_strategy=per_session_manual``pick_web_account` + `release_lease` |
| 禁止 | **不要** `import account-manager``rpa_helpers` 等内部模块 |
| Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback共享 sandbox **不要求**为技能加 testid |
| Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 |
权威 example**修正后的** `examples/simulator_browser_rpa/`(不是任何生产技能仓库)。
---
## 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 录屏成片标准
- **skill-template 默认 `OPENCLAW_RECORD_VIDEO=0`**(开发调试更快,不启 ffmpeg
- **`OPENCLAW_RECORD_VIDEO=1` 时启用录屏**字幕、旁白、背景音、MP4 成片由 platform-kit 统一处理。
- 使用 platform-kit 的 **`RpaVideoSession`****skill 不要自行合成视频**(不要自己调 ffmpeg 拼 MP4
- **默认关闭 ≠ 可以删除录屏接入**RPA / 长任务 **service 层**`scripts/service/*.py`**必须保留** `RpaVideoSession``video.add_step`、video artifact merge 到 `result_summary`(见 `POLICY-RPA-003`)。
- `OPENCLAW_RECORD_VIDEO=0` 时 session **无副作用**(不启 ffmpeg、不写字幕文件、不生成最终 MP4**`RpaVideoSession.add_step` 仍可同步 Activity 进度**。
- 生产留证 / 合规 / 客户现场复现时,由用户在 `.env` 或进程环境变量中开启 `OPENCLAW_RECORD_VIDEO=1`
- **ffmpeg 是唯一录屏器**Windows`gdigrab` + `desktop`)。
- **最终视频**`OPENCLAW_RECORD_VIDEO=1` 时):`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4`
- **中间产物**`rpa-artifacts/{batch_id}/capture.mp4``subtitles/``logs/` 等。
- 任务完成后 CLI / `result_summary` 应包含 video 字段(开启录屏时有路径;关闭时仍保留 `video` 结构与诊断占位):`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` — 运行时目录与环境变量约定

129
development/RUNTIME.md Normal file
View File

@@ -0,0 +1,129 @@
# 运行时约定
## 共享 Python Runtime
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime`jiangchang-platform-kit>=1.2.0` 及其传递依赖,含 `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.2.0+
`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.2.0`**不得** 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 或本地持久化层(标准时间字段见 `references/SCHEMA.md`
- `scripts/service/`:业务用例与外部交互
- `scripts/util/`:通用工具、常量、日志、路径薄封装
## 数据路径
推荐:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/
```
数据库文件建议:
```text
{...}/{skill_slug}.db
```
**业务文件(下载、导入、导出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。
- 业务表使用英文 snake_case中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。
- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。
## 明确禁止
- **不得** vendored `scripts/jiangchang_skill_core/`
- **不得**在技能内本地重复实现 `RuntimeDiagnostics` / `collect_runtime_diagnostics`
- **不得**写死开发机绝对路径作为运行时约定
## 测试时的运行时隔离
本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间设置:
- `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录
- `JIANGCHANG_USER_ID``_test`
退出时两个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。
详见 `TESTING.md` 第 3 节。
## 编码与输出
- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**不要只写「UTF-8」
- Python 文件**绝对不能**带 UTF-8 BOM否则 release CI 的 PyArmor 阶段可能报:`invalid non-printable character U+FEFF`
- Windows 编辑器容易保存成 UTF-8 BOM提交前确认文件开头无 `U+FEFF`
- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装
- 机读输出优先单行 JSON
- 错误前缀建议统一 `ERROR:`
## 发布打包约束(`scripts/**/*.py`
release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式:
- `scripts/**/*.py` 单文件必须 **< 1000 行**`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。
- 接近上限时拆分到 `cli/``service/``db/``util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。
- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。

276
development/TESTING.md Normal file
View File

@@ -0,0 +1,276 @@
# 测试开发指南
面向复制 `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.2.0` 导入来源、文档/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` 创建幂等与仓储读写;
- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序、editable 权限(见 `test_display_metadata.py``test_data_management_contract.py`
- Skill Action manifest`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`
- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`
- **adapter profile**[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
- **发布打包守护**[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
- **开发规范守护hard policy**[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) ——规则索引见 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md),只执行可机器检测的 hard 规则。
- **slug 语义**[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。
**原则:`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 凭证写死进仓库。
---
## 7.1 simulator_rpa 联调检查表
浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认:
- [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`
- [ ] account-manager`platform ensure` + 账号 `active` + 有 `profile_dir`
- [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `python tests/run_tests.py -v` 仍全部通过mock 离线example 内 `pytest` 不启真实浏览器
参考实现:`examples/simulator_browser_rpa/README.md`
---
## 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脱敏
- [ ] 若有 `assets/actions.json``tests/test_actions_manifest.py` 通过;不需要 Action 时可删除 manifest。
- [ ] 若有本地 SQLite 业务表,`_jiangchang_*` 元数据与 `tests/test_data_management_contract.py` 规则一致。
- [ ] 真实 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 / 手动触发) |
---
## 9.1 开发规范守护测试
[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 将 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md) 中的 **hard policy** 落地为默认必跑 unittest
- 每条失败信息包含 `policy_id` 与来源文档路径,便于定位规范出处。
- 只覆盖目录结构、runtime 依赖边界、配置读取、默认测试安全档位、RPA 禁令等**可静态扫描**规则;软规则列在矩阵「暂不自动检测」小节,**不**做成测试。
- `POLICY-TESTING-001`:根目录 `tests/test_*.py` 非白名单文件不得出现 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`**唯一白名单** `test_adapter_profile_policy.py`(档位 helper 自测)。`adapter_test_utils.py``tests/integration/*.sample` 不在默认套件内,不受此条扫描。
- `POLICY-RPA-002`:只禁止 `subprocess` / `os.system` / `os.popen` **直接执行** ffmpeg不误伤 `ffmpeg_path` 等诊断字段。
- 与已有测试分工:`test_release_packaging_constraints.py` 继续守护 PyArmor 行数与 UTF-8 BOM`test_no_rpa_helpers_import.py` 与 policy guard 的 `POLICY-RPA-001` 语义一致,二者并存防回归。
### 日志规范测试
[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 中的 `POLICY-LOGGING-*` 规则会检查 logging **hard policy**(见 [`POLICY_MATRIX.md`](POLICY_MATRIX.md)
- CLI 是否调用 `setup_skill_logging``POLICY-LOGGING-001`
- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`、写入 `save_task_log``POLICY-LOGGING-002` / `003`
- 长任务模板是否使用 Activity 或 RPA video step`POLICY-LOGGING-004`
- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`
**局限**:自动检测只覆盖可静态判断的**底线****不**判断业务日志是否足够完整。复制新 skill 后,仍须人工对照 [`LOGGING.md`](LOGGING.md) §必打日志节点清单做 code review。
新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。
---
## 10. 测试和发布的关系
[`release.ps1`](../release.ps1) **默认**在 **auto commit 之前**、创建 tag 之前,对**当前工作区**执行 `python tests/run_tests.py -v`;测试失败会终止 release不会自动提交、不会打 tag。仅紧急排障时可传 `-SkipTests`(会输出黄色警告,不应作为常规发布路径)。`-DryRun` 仍会实际运行默认测试。
即便手工发布,也应在运行 `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 测试标准
- 模板 `.env.example` 默认 **`OPENCLAW_RECORD_VIDEO=0`**;默认必跑测试**不应启动真实 ffmpeg**patch session 或显式设 `OPENCLAW_RECORD_VIDEO=0`)。
- **但测试必须守护录屏接入能力**`POLICY-RPA-003` 扫描 `scripts/service/*.py``RpaVideoSession` 被构造、`video.add_step` 被调用、video artifact 结构 merge 进 `result_summary``video` / `video_path` 等字段),与默认是否录屏无关。
- 断言 `title` / `closing_title` 是**中文**业务文案。
- 断言 step 文案贴近用户动作不是技术日志如「准备执行示例任务」而非「enter cmd_run」
- 若需测试**真实录屏**ffmpeg 成片),必须显式设 `OPENCLAW_RECORD_VIDEO=1`,并放在 `tests/integration/` 或手动触发场景,**不得**进入默认 unittest 套件。
参考 `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.2.0**`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
这里放人工评估脚本、示例输入输出、回归验收清单等材料。
模板仓库保持最小占位,不再放旧式杂项代码片段。

79
examples/README.md Normal file
View File

@@ -0,0 +1,79 @@
# 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 / jc2009 类 |
| 风控 | 高(验证码、登录态、反爬) | 低DOM 可控) |
| 账号 | account-manager + profile | account-manager + profile`simulator_rpa` 档) |
| 用途 | 生产级真实采集/操作参考 | adapter 分层 + 表单 RPA 教学 |
### 工程范式对比
| 工程范式 | real_browser_rpa | simulator_browser_rpa修正后 |
|---|---|---|
| async Playwright | ✅ | ✅ |
| 薄 adapter + `*_playwright.py` | `task_rpa.py` | `simulator_playwright.py` |
| account-manager subprocess | ✅ | ✅(`simulator_rpa` 档位) |
两者工程范式一致,差异在目标系统(真实站 vs 仿真站/本地 demo
## 如何选择示例
| 你的场景 | 参考目录 |
|---|---|
| 真实网站 + 浏览器 + 登录/验证码/滚动 | **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,154 @@
# Simulator Browser RPA Example
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 **async Playwright** 完成门户门闩、两步登录、表单填写、PIN 弹窗、结果解析。
本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。
## 适合什么场景
- 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html`
- 需要 **浏览器 RPA** 做端到端演示或回归
- 表单较复杂:门户 HITL、多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析
- 需要 **adapter 分层**`mock`CI`simulator_rpa`(半集成)可切换
## 不适合什么场景
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
## 目录结构
```
simulator_browser_rpa/
├── README.md
├── sandbox/
│ └── demo_app.html # 本地仿真页面(门户门闩 + 批量提交教学 DOM
├── scripts/
│ ├── service/
│ │ ├── browser_session.py # async 启动 persistent context不含 goto
│ │ ├── account_client.py # account-manager subprocess对齐 real_browser_rpa
│ │ ├── simulator_playwright.py # RPA 主流程(厚)
│ │ ├── task_service.py # async 编排层
│ │ └── adapter/
│ │ ├── base.py # BatchItem / BatchSubmitResult 契约
│ │ ├── mock.py # mock/unit 档
│ │ ├── simulator_rpa.py # 薄 adapterpick / lease / 委托 RPA
│ │ └── __init__.py # select_adapter
│ └── util/
│ ├── constants.py
│ └── logging.py
└── tests/
├── test_adapter_dispatch.py
├── test_no_rpa_helpers_import.py
├── test_simulator_rpa_unit.py
└── test_stop_reason.py
```
## 文件职责
| 文件 | 职责 |
|---|---|
| `browser_session.py` | async 系统 Chrome/Edge + launch args**不在此 goto** |
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account` / `release_lease` |
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapterpick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `sandbox/demo_app.html` | 可控 DOM含可选门户门闩 + 原批量提交流程 |
## 核心流程
1. `await run_batch_submit(target, items)` 校验参数
2. `select_adapter()``OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA
3. `simulator_rpa` 档:`SimulatorBrowserRpaAdapter.submit_batch``pick_web_account(sim_batch)``submit_batch_rpa``release_lease`
4. RPA门户门闩若可见则 HITL 轮询)→ 登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id`
5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult`
## 两档仿真目标
| 档位 | 目标 | 说明 |
|---|---|---|
| 本地教学 | `sandbox/demo_app.html``file://` | 结构教学 + mock 单测默认 |
| 线上联调 | `https://sandbox.jc2009.com/...` | 设 `SIMULATOR_BASE_URL` + account-manager `sim_batch` 账号 |
联调时账号 `url` 用行业根路径,**不要**写 `/login` 子路径;`auth_strategy=per_session_manual`
## account-manager 注册示例(泛化)
```bash
python .../account-manager/scripts/main.py account add-web \
--platform sim_batch \
--login-id demo001 \
--url file:///.../demo_app.html \
--environment simulator \
--auth-strategy per_session_manual
```
线上 sandbox 时将 `--url` 换为 `https://sandbox.jc2009.com/...` 占位路径(按团队部署填写)。
**PIN/密码演示值**RPA 从 account 的 `login_id` 取账号;`password` / `token_pin` 允许从 account 字段或环境变量 `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` 回退;验证码固定演示值 `0000`(仅 sandbox 用)。
## Copy map复制到新技能 `scripts/`
| 来源examples | 目标(新技能) | 说明 |
|---|---|---|
| `browser_session.py` | `scripts/service/browser_session.py` | asyncURL 仅 `page.goto`,不进 launch args |
| `account_client.py` | `scripts/service/account_client.py` | subprocess对齐 `real_browser_rpa` |
| `simulator_playwright.py` | `scripts/service/<platform>_playwright.py` | RPA 主流程(厚) |
| `adapter/` | `scripts/service/<domain>_adapter/` | 薄 `simulator_rpa` + mock |
| `task_service.py` | `scripts/service/task_service.py` | async 编排 |
| `util/constants.py` | `scripts/util/constants.py` | 须替换 `TARGET_PLATFORM` 等业务常量 |
| `tests/` | `tests/` | 可按需复制 dispatch / unit / 禁止 import 守护 |
| `sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill |
## 明确禁止
- **不要** `import account-manager` 内部模块(`rpa_helpers``inject_account_manager_scripts_path``get_account_credential`
- **不要** sync Playwright 用于完整 RPA 主路径
- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess
- **不要在技能侧**执行 `playwright install` / `pip install playwright`
- **不要把 URL 放进** `launch_persistent_context(args=[...])`
- **不要** vendor `scripts/jiangchang_skill_core/`
- **不要**照抄 slug、logger 名、演示账号到生产 skill
> 旧版 `pick_simulator_account()` / `SimulatorAccount` / `set_credentials()` 已废弃;账号统一由 account-manager 下发。
## 联调检查表
- [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 mock
- [ ] account-manager`platform ensure` + 账号 `active` + 有 `profile_dir`
- [ ] 目标 sandbox UI 已部署(跨团队)
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(可用 `--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `tests/run_tests.py` / 本 example `pytest` 仍全部通过mock 离线)
## 依赖说明
- Python 3.10+
- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要 `pip install playwright`
- 默认使用**系统 Chrome/Edge**;技能侧不要 `playwright install`
- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选)
## 环境变量(示例)
| 变量 | 说明 |
|---|---|
| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock`simulator_rpa` → 仿真 RPA |
| `SIMULATOR_BASE_URL` | 仿真页 URL默认本地 `demo_app.html` 的 file URI |
| `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` | 演示密码/PIN 回退(账号字段优先) |
| `PORTAL_LOGIN_WAIT_SEC` | 门户 HITL 超时(默认 120 |
| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头CI 可用) |
## 运行测试
```bash
cd examples/simulator_browser_rpa
python -m pytest tests/ -v
```
测试为 mock/纯函数,**不启动真实浏览器**。
## 当前状态
- async Playwright + 薄 adapter + `simulator_playwright.py` 工程范式已对齐 `real_browser_rpa`
- 未接入完整 CLI、数据库与 entitlement。

View File

@@ -0,0 +1,155 @@
<!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="portal-section">
<h2>平台门户登录(示例)</h2>
<form name="portalGate">
<label>门户账号 <input id="portal-user" name="portalUser" /></label>
<label>门户密码 <input id="portal-pass" name="portalPass" type="password" /></label>
<button type="button" id="portal-enter">进入业务系统</button>
</form>
</section>
<section id="login-step1-section" class="hidden">
<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.getElementById('portal-enter').addEventListener('click', function () {
document.getElementById('portal-section').classList.add('hidden');
document.getElementById('login-step1-section').classList.remove('hidden');
});
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,199 @@
"""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 → 开发机兜底。
"""
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,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"
async 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"
async 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,109 @@
"""仿真浏览器 RPA 薄 adapterpick 账号、lease、委托 RPA、release lease。"""
from __future__ import annotations
import logging
from typing import List, Optional
from jiangchang_skill_core import config
from service.account_client import AccountManagerError, pick_web_account, release_lease
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
from service.browser_session import find_chrome_executable
from service.simulator_playwright import submit_batch_rpa
from util.constants import TARGET_PLATFORM, resolve_simulator_base_url
logger = logging.getLogger(__name__)
def _resolve_entry_url(account: dict) -> str:
url = str(account.get("url") or "").strip()
if url:
return url.rstrip("/")
return resolve_simulator_base_url()
class SimulatorBrowserRpaAdapter(BatchAdapterBase):
name = "simulator_rpa"
def __init__(
self,
artifacts_dir: Optional[str] = None,
headless: Optional[bool] = None,
) -> None:
self.artifacts_dir = artifacts_dir
if headless is None:
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
else:
self.headless = headless
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
try:
from playwright.async_api import async_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},
)
lease_token: Optional[str] = None
try:
account = pick_web_account(TARGET_PLATFORM)
lease_token = account.get("lease_token")
base_url = _resolve_entry_url(account)
return await submit_batch_rpa(
account,
target=target,
items=items,
base_url=base_url,
artifacts_dir=self.artifacts_dir,
headless=self.headless,
)
except AccountManagerError as exc:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"ERROR:{exc.code} {exc.message}",
artifacts={"adapter": self.name},
)
except Exception as exc:
logger.exception("adapter_unexpected: %s", exc)
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
artifacts={"adapter": self.name},
)
finally:
release_lease(lease_token)

View File

@@ -0,0 +1,126 @@
"""浏览器会话启动(系统 Chrome/Edge + async Playwright persistent context"""
from __future__ import annotations
import logging
import os
from typing import Any, List, Optional, Tuple
logger = logging.getLogger(__name__)
DEFAULT_PAGE_TIMEOUT_MS = 15_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 as _find
chrome = _find()
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)
async def start_browser_session(
profile_dir: str,
*,
headless: Optional[bool] = None,
) -> Tuple[Any, Any, Any]:
"""
启动持久化 Chrome profile返回 (playwright, context, page)。
URL 不在此文件打开;由 simulator_playwright.py 负责 page.goto。
"""
from playwright.async_api import async_playwright
_clear_node_options()
hl = headless if headless is not None else _headless_from_env()
chrome = find_chrome_executable()
if not chrome:
raise RuntimeError(
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
)
args = chrome_launch_args()
logger.info(
"browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
chrome,
profile_dir,
args,
hl,
)
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=args,
ignore_default_args=["--enable-automation"],
)
await context.add_init_script(STEALTH_INIT_SCRIPT)
page = context.pages[0] if context.pages else await context.new_page()
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
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_session(playwright: Any, context: Any) -> None:
try:
if context is not None:
await context.close()
except Exception:
pass
try:
if playwright is not None:
await playwright.stop()
except Exception:
pass

View File

@@ -0,0 +1,400 @@
"""仿真批量提交 RPA 主流程async Playwright"""
from __future__ import annotations
import asyncio
import logging
import os
import random
import re
import time
from dataclasses import dataclass
from typing import Any, List, Optional
from jiangchang_skill_core import config
from service.adapter.base import BatchItem, BatchSubmitResult
from service.browser_session import (
close_browser_session,
find_chrome_executable,
start_browser_session,
)
from util.constants import (
DEFAULT_DEMO_CAPTCHA,
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
DEFAULT_TIMEOUT_MS,
ENV_SIMULATOR_PASSWORD,
ENV_SIMULATOR_TOKEN_PIN,
LOGIN_NAVIGATE_TIMEOUT_MS,
SUBMIT_NAVIGATE_TIMEOUT_MS,
portal_login_wait_sec,
)
logger = logging.getLogger(__name__)
GOTO_TIMEOUT_MS = 60_000
_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
@dataclass
class _Credentials:
login_id: str
password: str
token_pin: str
def _resolve_credentials(account: dict[str, Any]) -> _Credentials:
login_id = str(
account.get("login_id") or account.get("username") or DEFAULT_DEMO_LOGIN_ID
).strip()
password = str(
account.get("password")
or os.getenv(ENV_SIMULATOR_PASSWORD)
or config.get(ENV_SIMULATOR_PASSWORD)
or DEFAULT_DEMO_PASSWORD
).strip()
token_pin = str(
account.get("token_pin")
or account.get("pin")
or os.getenv(ENV_SIMULATOR_TOKEN_PIN)
or config.get(ENV_SIMULATOR_TOKEN_PIN)
or DEFAULT_DEMO_TOKEN_PIN
).strip()
return _Credentials(login_id=login_id, password=password, token_pin=token_pin)
def _headless() -> bool:
return config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
async def _pause(min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0)
async def _visible(locator) -> bool:
try:
return await locator.count() > 0 and await locator.is_visible(timeout=500)
except Exception:
return False
async def _wait_portal_gate(page, wait_sec: int) -> None:
portal_user = page.locator("#portal-user")
try:
if not await _visible(portal_user):
return
except Exception:
return
print(f"[门户] 检测到平台门户登录门闩,请手工完成门户登录,最多等待 {wait_sec} 秒...")
deadline = time.monotonic() + wait_sec
while time.monotonic() < deadline:
try:
portal_section = page.locator("#portal-section")
login_step1 = page.locator('form[name="simLoginStep1"]')
if not await _visible(portal_section) or await _visible(login_step1):
print("[门户] 门户门闩已通过,进入业务系统登录")
return
except Exception:
pass
await asyncio.sleep(2.0)
raise RpaError("ERROR:LOGIN_TIMEOUT 门户登录超时,请重新运行并及时完成门户登录。")
async def _login(page, creds: _Credentials, artifacts_dir: str | None) -> None:
try:
await page.wait_for_selector(
'form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS
)
await _pause()
await page.fill('form[name="simLoginStep1"] input[name="loginId"]', creds.login_id)
await _pause()
await page.click('form[name="simLoginStep1"] button[type="submit"]')
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_step1_failed")
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
await _pause()
await page.fill('form[name="simLoginStep2"] input[name="password"]', creds.password)
await page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
await _pause()
await page.click('form[name="simLoginStep2"] button[type="submit"]')
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_step2_failed")
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_navigate_failed")
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
async def _goto_batch_page(page, artifacts_dir: str | None) -> None:
try:
await page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
await _pause()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "goto_batch_failed")
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
async def _select_target_account(page, target: str) -> None:
sel = page.locator('select[name="fromAccountNo"]')
await sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
n = await 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 = (await opt.get_attribute("value")) or ""
if not val.strip():
continue
label_txt = await 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)
await sel.select_option(value=pick)
await _pause()
async def _fill_batch_form(
page, target: str, items: List[BatchItem], artifacts_dir: str | None
) -> None:
try:
await page.get_by_role("button", name=re.compile(r"页面填写")).click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "tab_switch_failed")
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
try:
await _select_target_account(page, target)
except RpaError:
raise
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "select_account_failed")
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
try:
existing = await 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):
await _pause(400, 900)
await add_btn.click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "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}"]')
await row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
await _pause(300, 800)
await row.locator('input[name="name"]').fill(it.name)
await row.locator('input[name="account"]').fill(it.account)
await row.locator('input[name="note"]').fill(it.note or "")
await row.locator('input[name="amount"]').fill(f"{it.amount}")
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, f"fill_row_{it.row_index}_failed")
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
try:
await page.fill('input[name="purpose"]', "仿真批量提交示例")
except Exception as exc:
logger.warning("填写用途失败(非致命):%s", exc)
async def _submit_and_get_batch_id(page, token_pin: str, artifacts_dir: str | None) -> str:
try:
await _pause(800, 1500)
await page.locator('form[name="simBatchForm"] button[type="submit"]').click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "click_submit_failed")
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
try:
await page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "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"]')
await _pause(500, 1200)
await pin_input.fill(token_pin)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "fill_pin_failed")
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
try:
await page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "click_confirm_failed")
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "success_area_not_visible")
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
batch_id = await page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
if not batch_id:
batch_id = (await 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 = await _safe_screenshot(page, artifacts_dir, "batch_id_empty")
raise RpaError(f"无法解析 batch_idurl={url}", screenshot_path=sp)
return batch_id
async def _safe_screenshot(page, artifacts_dir: str | None, tag: str) -> Optional[str]:
if not artifacts_dir:
return None
try:
os.makedirs(artifacts_dir, exist_ok=True)
path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png")
await 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
async def submit_batch_rpa(
account: dict[str, Any],
*,
target: str,
items: list[BatchItem],
base_url: str,
artifacts_dir: str | None,
video_session: Any = None,
headless: bool | None = None,
) -> BatchSubmitResult:
del video_session # 示例占位;真实 skill 可接入 RpaVideoSession
profile_dir = str(account.get("profile_dir") or "").strip()
if not profile_dir:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="账号缺少 profile_dir",
artifacts={"adapter": "simulator_rpa"},
)
if not items:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="items 为空",
artifacts={"adapter": "simulator_rpa"},
)
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": "simulator_rpa"},
)
creds = _resolve_credentials(account)
portal_wait = portal_login_wait_sec()
hl = headless if headless is not None else _headless()
pw = None
context = None
page = None
try:
pw, context, page = await start_browser_session(profile_dir, headless=hl)
logger.info("rpa_goto url=%s", base_url)
await page.goto(base_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
await _wait_portal_gate(page, portal_wait)
logger.info("rpa_login_start")
await _login(page, creds, artifacts_dir)
logger.info("rpa_login_done")
await _goto_batch_page(page, artifacts_dir)
logger.info("rpa_batch_page_ready")
await _fill_batch_form(page, target, items, artifacts_dir)
logger.info("rpa_batch_form_filled rows=%s", len(items))
batch_id = await _submit_and_get_batch_id(page, creds.token_pin, artifacts_dir)
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": "simulator_rpa"},
)
except RpaError as exc:
logger.error("rpa_failed: %s", exc)
arts = {"adapter": "simulator_rpa"}
if exc.screenshot_path:
arts["screenshot"] = exc.screenshot_path
elif page is not None:
sp = await _safe_screenshot(page, artifacts_dir, "rpa_error")
if sp:
arts["screenshot"] = sp
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=str(exc),
artifacts=arts,
)
except Exception as exc:
logger.exception("rpa_unexpected: %s", exc)
arts = {"adapter": "simulator_rpa"}
if page is not None:
sp = await _safe_screenshot(page, artifacts_dir, "unexpected_error")
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,
)
finally:
await close_browser_session(pw, context)

View File

@@ -0,0 +1,100 @@
"""轻量任务编排:校验输入 → 选 adapter → 提交批次。"""
from __future__ import annotations
import os
import tempfile
import uuid
from typing import Any, Dict, List, Optional
from service.adapter import BatchItem, 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
async 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 等。
账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。
"""
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)
result = await 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,52 @@
"""仿真浏览器 RPA 示例常量。"""
from __future__ import annotations
import os
from pathlib import Path
from jiangchang_skill_core import config
SKILL_SLUG = "example-simulator-browser-rpa"
LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa"
TARGET_PLATFORM = "sim_batch"
LEASE_HOLDER = "example-simulator-browser-rpa"
LEASE_TTL_SEC = "1800"
DEFAULT_TIMEOUT_MS = 15_000
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000
DEFAULT_PORTAL_LOGIN_WAIT_SEC = 120
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_PASSWORD = "SIMULATOR_PASSWORD"
ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN"
ENV_PORTAL_LOGIN_WAIT_SEC = "PORTAL_LOGIN_WAIT_SEC"
_EXAMPLE_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html"
def portal_login_wait_sec() -> int:
raw = (
os.getenv(ENV_PORTAL_LOGIN_WAIT_SEC)
or config.get(ENV_PORTAL_LOGIN_WAIT_SEC)
or str(DEFAULT_PORTAL_LOGIN_WAIT_SEC)
)
try:
return max(1, int(str(raw).strip()))
except (TypeError, ValueError):
return DEFAULT_PORTAL_LOGIN_WAIT_SEC
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,38 @@
"""示例日志工具。"""
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 configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s")
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/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.IsolatedAsyncioTestCase):
async 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 = await 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-"))
async def test_mock_empty_items(self) -> None:
result = await 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,39 @@
# -*- coding: utf-8 -*-
"""禁止 import account-manager 内部 RPA 辅助模块。"""
from __future__ import annotations
import os
import re
import unittest
from pathlib import Path
FORBIDDEN_PATTERNS = (
re.compile(r"\brpa_helpers\b"),
re.compile(r"\binject_account_manager_scripts_path\b"),
re.compile(r"\bget_account_credential\b"),
)
SCRIPTS_ROOT = Path(__file__).resolve().parents[1] / "scripts"
class TestNoRpaHelpersImport(unittest.TestCase):
def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None:
violations: list[str] = []
for root, _dirs, files in os.walk(SCRIPTS_ROOT):
for name in files:
if not name.endswith(".py"):
continue
path = Path(root) / name
text = path.read_text(encoding="utf-8")
for pattern in FORBIDDEN_PATTERNS:
if pattern.search(text):
violations.append(f"{path.relative_to(SCRIPTS_ROOT)}: {pattern.pattern}")
self.assertEqual(
violations,
[],
msg="Forbidden account-manager internal imports found:\n" + "\n".join(violations),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。"""
from __future__ import annotations
import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.browser_session import CHROME_LAUNCH_ARGS
from service.simulator_playwright import RpaError
class TestSimulatorRpaUnit(unittest.IsolatedAsyncioTestCase):
async def test_submit_batch_empty_items(self) -> None:
adapter = SimulatorBrowserRpaAdapter()
result = await 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)
async 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 = await 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.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
async def test_adapter_delegates_to_playwright_with_account_url(
self,
_chrome: MagicMock,
mock_pick: MagicMock,
mock_rpa: AsyncMock,
) -> None:
from service.adapter.base import BatchSubmitResult
mock_pick.return_value = {
"login_id": "demo001",
"profile_dir": "/tmp/profile",
"url": "file:///demo/demo_app.html",
"lease_token": "tok-1",
}
mock_rpa.return_value = BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="login fail",
artifacts={"adapter": "simulator_rpa"},
)
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
mock_rpa.assert_awaited_once()
call_kwargs = mock_rpa.call_args.kwargs
self.assertEqual(call_kwargs["base_url"], "file:///demo/demo_app.html")
for arg in CHROME_LAUNCH_ARGS:
self.assertNotIn(arg, call_kwargs["base_url"])
@patch("service.simulator_playwright.start_browser_session", new_callable=AsyncMock)
async def test_playwright_opens_url_via_goto_not_launch_args(self, mock_start: AsyncMock) -> None:
from service.simulator_playwright import submit_batch_rpa
page = AsyncMock()
page.goto = AsyncMock()
mock_start.return_value = (MagicMock(), MagicMock(), page)
account = {"profile_dir": "/tmp/p", "login_id": "demo001"}
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
with patch(
"service.simulator_playwright._wait_portal_gate",
new_callable=AsyncMock,
return_value=None,
):
with patch(
"service.simulator_playwright._login",
new_callable=AsyncMock,
side_effect=RpaError("login fail"),
):
with patch(
"service.simulator_playwright.find_chrome_executable",
return_value="C:/Chrome/chrome.exe",
):
with patch(
"service.simulator_playwright.close_browser_session",
new_callable=AsyncMock,
):
result = await submit_batch_rpa(
account,
target="acct-demo-001",
items=items,
base_url="file:///demo/demo_app.html",
artifacts_dir=None,
)
self.assertFalse(result.ok)
page.goto.assert_awaited_once()
goto_url = page.goto.call_args[0][0]
self.assertEqual(goto_url, "file:///demo/demo_app.html")
for arg in CHROME_LAUNCH_ARGS:
self.assertNotIn(arg, goto_url)
async def test_safe_screenshot_without_artifacts_dir(self) -> None:
from service.simulator_playwright import _safe_screenshot
page = AsyncMock()
self.assertIsNone(await _safe_screenshot(page, None, "tag"))
class TestSyncHelpers(unittest.TestCase):
def test_asyncio_run_smoke(self) -> None:
async def _noop() -> bool:
return True
self.assertTrue(asyncio.run(_noop()))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""RPA 失败结果与 artifacts 单元测试。"""
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.adapter.base import BatchSubmitResult
class TestBatchSubmitFailureResult(unittest.IsolatedAsyncioTestCase):
@patch("service.adapter.simulator_rpa.release_lease")
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
async def test_rpa_error_converts_to_failed_result(
self,
_chrome: MagicMock,
mock_pick: MagicMock,
mock_rpa: AsyncMock,
_release: MagicMock,
) -> None:
mock_pick.return_value = {
"profile_dir": "/tmp/profile",
"lease_token": "tok",
}
mock_rpa.return_value = BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="登录第一步失败timeout",
artifacts={"adapter": "simulator_rpa", "screenshot": "/tmp/art/x.png"},
)
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
self.assertIn("登录第一步失败", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa")
self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png")
@patch("service.adapter.simulator_rpa.release_lease")
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
async def test_unexpected_error_includes_adapter_name(
self,
_chrome: MagicMock,
mock_pick: MagicMock,
mock_rpa: AsyncMock,
_release: MagicMock,
) -> None:
mock_pick.return_value = {
"profile_dir": "/tmp/profile",
"lease_token": "tok",
}
mock_rpa.side_effect = RuntimeError("boom")
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
self.assertIn("未预期异常", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_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

195
references/ACTIONS.md Normal file
View File

@@ -0,0 +1,195 @@
# Skill Action Manifest匠厂宿主
本文件说明 `assets/actions.json``scripts/main.py` CLI 的对应关系,供 Agent 编排与宿主 **Skill Action Runtime** 使用。
**边界:** 用户市场说明见根目录 [`README.md`](../README.md)CLI 参数与错误码细节见 [`CLI.md`](CLI.md)。
## 模板严格规范 vs 宿主向后兼容
| 角色 | 定位 |
|------|------|
| **skill-template** | 新技能推荐的**严格规范**:必填字段、明确类型、`additionalProperties: false` |
| **宿主运行时** | **向后兼容**的历史 manifest 解析器:可能接受部分缺省字段(如 `description``placements``entrypoint.args`、input property `type` |
**新技能不应依赖宿主缺省行为。** 复制模板后按 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) 与本文完整填写 manifest。
JSON Schema 使用 `additionalProperties: false` 表达模板严格边界;这与宿主共享类型中的可选字段并不矛盾——宿主为兼容旧技能可能更宽松,模板为防漂移要求更严。
## Action 是否必需
Action 是**可选能力**
- 没有宿主直接操作需求的技能,**可以不提供** `actions.json`
- 需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中的技能,**必须**提供 `assets/actions.json`
## 文件位置
```text
{skill_root}/assets/actions.json
```
宿主扫描路径示例:
```text
~/.openclaw/skills/your-skill-slug/assets/actions.json
```
## 顶层结构
| 字段 | 必填 | 说明 |
|------|------|------|
| `schemaVersion` | 是 | 当前固定为 `1` |
| `skill` | 是 | 必须与 `SKILL.md``metadata.openclaw.slug` 一致 |
| `actions` | 是 | Action 数组,每项 `id` 唯一 |
模板最小示例见 [`assets/actions.json`](../assets/actions.json)JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
## Phase 1 当前稳定支持(新技能应只使用这些)
| 层级 | 稳定字段 / 能力 |
|------|----------------|
| 顶层 | `schemaVersion``skill``actions` |
| action | `id``label``description``placements``entrypoint`;可选 `inputSchema``confirmation` |
| entrypoint | `type = cli``command``args`**必填** JSON 数组,项为 `string` / `number` / `boolean` |
| placements | `toolbar``skill-detail``agent``cron` |
| inputSchema | 根 `type: object`properties 使用 `string` / `number` / `boolean`;支持 `enum``default``required``sensitive` |
| confirmation | `{ "message": "..." }` |
## 预留能力(当前不可当作已实现能力)
以下在宿主共享类型或 Schema 枚举中可能出现,但 **Phase 1 Runtime 尚未完整支持****新模板示例不得使用**
| 类别 | 预留项 | 说明 |
|------|--------|------|
| placements | `row``batch` | 行内 / 批量入口,未来扩展 |
| action 字段 | `bind``concurrency``locks` | 绑定、并发与锁控制,宿主后续实现 |
| inputSchema | `array`、nested `object``oneOf` 等 | 复杂表单与联合类型,不属于当前推荐模板范围 |
可以在文档或 Schema 枚举中保留这些值供未来对齐,但不要让技能作者误以为它们已经可执行。
## 执行模型
宿主将 action 转为 argv再执行
```text
python {skill_root}/scripts/main.py <command> <args...>
```
- `entrypoint.type` 当前只支持 `cli`
- `entrypoint.command` → CLI 子命令(须真实存在)
- `entrypoint.args` → 子命令参数(**必须为 JSON 数组**
- `{{fieldName}}` → 来自 `inputSchema` 用户输入的简单模板替换
完整子命令定义见 [`scripts/cli/app.py`](../scripts/cli/app.py)。
### CLI 输出契约
技能 CLI 必须:
- 使用稳定的退出码
- 成功时输出可解析的结构化结果
- 失败时输出稳定错误信息
- 对可预期错误提供稳定错误码
- 对用户可处理的问题提供 `HINT`
- 不依赖 Agent 连续对话才能完成
- 不自行实现宿主任务中心
- 长时间任务允许异步执行,由宿主任务中心承载状态
- 不在 CLI 中硬编码宿主安装目录
- 不直接依赖某个具体 UI 页面
建议错误输出格式:
```text
ERROR:<CODE>: <message>
HINT: <next step>
```
## placements入口位置
| 值 | 含义 |
|----|------|
| `toolbar` | 数据管理第一排技能按钮 |
| `skill-detail` | 技能详情或技能市场详情页 |
| `agent` | Agent 可直接调用 |
| `cron` | 定时任务可直接调用 |
**预留能力(当前宿主可能尚未完整支持,模板示例请勿使用):**
| 值 | 说明 |
|----|------|
| `row` | 行内操作(未来扩展) |
| `batch` | 批量操作(未来扩展) |
可以在 manifest 中作为未来扩展值定义,但不要让技能作者误以为 `row` / `batch` 已经完整可用。
## inputSchema 与参数表单
当前宿主稳定支持:
- `object` / `string` / `number` / `boolean`
- `enum` / `default` / `required` / `sensitive`
- 简单模板替换(`{{fieldName}}`
示例:
```json
{
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"title": "查询条件"
},
"limit": {
"type": "number",
"title": "返回数量",
"default": 50
}
}
}
}
```
约定:
- `boolean` 在宿主中渲染为开关
- `enum` 在宿主中渲染为选择框
- `required` 字段在提交前校验
- `sensitive` 字段不得出现在低信任入口
- 需要确认的副作用操作使用 `confirmation.message`
- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明
- `entrypoint.args` 项当前只允许 `string``number``boolean`;不允许对象或 `null`
- **不要**在当前模板中宣传 `nested object``oneOf`、复杂数组等尚未稳定支持的能力
## 风险与入口配置
- 查询、诊断、版本检查可以放到 `skill-detail``agent`
- 低风险、常用动作才考虑放 `toolbar`
- 会修改本地数据、访问外部系统、执行长时间任务的动作必须谨慎配置
- 有副作用的动作应添加 `confirmation.message`
- 高风险动作不应默认出现在 `toolbar`
-`sensitive` 参数的 action 不应放入 `toolbar``cron`,除非有明确安全设计
- 不要为了「按钮多」而暴露所有 CLI 子命令
- 一个 CLI 命令可以拆成多个语义清晰的 action但每个 action 必须有明确语义
## 模板示例 action 一览
| id | CLI | placements | 说明 |
|----|-----|------------|------|
| `health` | `health` | skill-detail, agent | 运行环境检查 |
| `version` | `version` | skill-detail | 版本 JSON |
| `config-path` | `config-path` | skill-detail | 配置路径 JSON |
复制为新技能后,请按业务需要增删 action不需要宿主入口时可删除整个 `actions.json`
## 与宿主 API参考
| 宿主 API | 用途 |
|----------|------|
| `GET /api/skill-actions/{skillSlug}` | 读取 manifest |
| `POST /api/skill-actions/run` | `{ skillSlug, actionId, input?, source? }` |
| `GET /api/skill-actions/jobs` | 任务中心 |
具体路径与字段以宿主实现为准;技能侧只需保证 manifest 与 CLI 契约稳定。

94
references/CLI.md Normal file
View File

@@ -0,0 +1,94 @@
# 通用业务技能模板 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 **不代表卡死**RPA / 外部调用期间应通过 Activity 或 Run Journal 看进度。
- **排查顺序**(由快到慢):
1. `python {baseDir}/scripts/main.py logs`
2. `python {baseDir}/scripts/main.py log-get <log_id>`
3. 统一日志文件 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(含 `trace_id``task_failed` 等)
4. Run Journal `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`
5. RPA artifacts / `video_log` / 失败截图(见 `development/RPA.md`);未开启录屏时可能只有截图或日志
- **录屏默认关闭**:模板 `.env.example``OPENCLAW_RECORD_VIDEO=0`;若未开启录屏,任务**可能不会**打印录屏路径。
- 需要录屏时在用户 `.env` 或进程环境变量中设置 **`OPENCLAW_RECORD_VIDEO=1`**;开启后任务完成时 CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`),且 `result_summary` 含 video 字段。
## 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` 命名

20
references/README.md Normal file
View File

@@ -0,0 +1,20 @@
# 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) | 本地 SQLite 与数据管理展示契约 |
| [`ACTIONS.md`](ACTIONS.md) | Skill Action manifest**可选**能力)与宿主入口契约 |
| [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) |
按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;配置宿主按钮/定时任务/Agent 入口时读 `ACTIONS.md`;运行时环境细节见 `../development/RUNTIME.md`

408
references/SCHEMA.md Normal file
View File

@@ -0,0 +1,408 @@
# 数据存储与数据管理展示规范
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py``scripts/db/connection.py``init_db()` 会一并初始化。
## 职责边界
### 技能负责
- 定义真实 SQLite 表结构
- 使用英文 `snake_case` 表名和字段名
- 使用 `_jiangchang_tables` 提供表级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据
- 定义哪些字段可见、可搜索、可创建、可编辑
- 定义字段展示类型和枚举选项
- 保证元数据与真实数据库结构一致
- 保证元数据初始化幂等
### 宿主负责
- 读取 `_jiangchang_*` 元数据
- 读取 SQLite `PRAGMA table_info`
- 根据 schema 渲染数据管理页面
- 根据 `editable` 推导字段是否可创建或编辑
- 执行通用查询、新增、编辑、删除、导入、导出
- 对字段名、表名、类型和权限进行最终校验
- 不猜测技能业务含义
- 不根据具体技能名称写特殊逻辑
SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`SQL 文件里的 `-- 注释` 也不会成为可查询结构。
因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info``cid`)。
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
## 技能名称SKILL.md
```yaml
---
name: 中文业务名称
description: 中文技能说明
metadata:
openclaw:
slug: english-kebab-slug
---
```
- `name`:用户可见的中文业务名称。
- `description`:能力说明,不是技能名称。
- `metadata.openclaw.slug`机器标识kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md)。
- 复制模板后不得将 `your-skill-slug``My Skill` 等占位内容作为正式技能发布名称。
## 数据库路径与命名
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db
```
- 数据库文件:英文 slug例如 `your-skill-slug.db`(默认 `{skill-slug}.db`)。
- 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items``task_logs``created_at`
- **禁止**用中文作为 SQLite 真实表名或字段名。
## 外部来源只读与本地数据可写
某些技能会从外部系统读取数据并写入技能自己的本地数据库。需要区分:
```text
外部来源数据库:
可能只读,不应被修改
技能本地数据库:
可以根据业务语义允许新增、编辑或删除
```
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly``_jiangchang_columns.editable` 声明本地表的实际写权限。
## 元数据表(宿主兼容)
宿主读取字段(与匠厂 `metadata-resolver` 一致):
### `_jiangchang_tables` 表级标准
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 真实英文表名 |
| `display_name` | 是 | 用户可见的中文业务名称 |
| `description` | 否 | 表说明 |
| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
禁止:
- 使用中文真实表名
- 使用 `_schema_meta` 作为新的正式契约
- 只写英文物理名而不写中文 `display_name`
- 让元数据引用不存在的表
### `_jiangchang_columns` 字段级标准
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 所属物理表 |
| `column_name` | 是 | 真实英文字段名 |
| `display_name` | 是 | 用户可见的中文字段名 |
| `description` | 否 | 字段说明 |
| `display_order` | 否 | 兼容字段,默认保持 `NULL` |
| `visible` | 是 | 是否在数据管理表格中展示 |
| `searchable` | 是 | 是否参与搜索 |
| `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 |
| `display_type` | 否 | 字段渲染类型(如 `text``textarea``datetime_unix_seconds` |
| `options_json` | 否 | 枚举选项 JSON |
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
## editable 与 createable 的关系
技能模板只维护 `editable` 这一份字段权限声明:
```text
skill metadata:
editable
host runtime:
createable / editable
```
宿主根据以下因素计算字段是否能够出现在新增或编辑表单中:
- 表级 `readonly`
- 字段 `editable`
- 主键
- generated 字段
- sensitive 字段
- BLOB 类型
- not-null 约束
- default value
通用规则:
```text
新增表单:
editable = 1
且不是主键
且不是 generated
且不是敏感字段
且不是 BLOB
编辑表单:
editable = 1
且不是主键
且不是 generated
且不是敏感字段
且不是 BLOB
```
若一张表:
```text
readonly = 0
但所有字段 editable = 0
```
则:
- 这张表技术上可写,但不适合手工新增或编辑
- 宿主不应打开空白新增表单
- 技能应通过自身业务流程产生数据
- 这不是宿主猜测业务,而是技能通过元数据声明的结果
## 系统字段标准
通常保持 `editable = 0` 的字段类型包括:
- 自增主键
- generated 字段
- `created_at` / `updated_at`
- 同步时间戳
- 内容哈希
- 计算结果
- 内部状态
- 删除标记
- 审计字段
- 敏感凭据或密钥字段
不要把具体业务字段名写死;按字段类型和职责设置 `editable`
## 表结构标准
- 业务表和字段使用英文 `snake_case`
- 可写表优先使用单字段主键
- 自增主键优先放在 `CREATE TABLE` 第一列
- `created_at``updated_at` 放在业务字段末尾
- 字段展示顺序以 `CREATE TABLE``PRAGMA table_info(cid)` 为准
- 不依赖 `display_order` 重新排列字段
- 不要让宿主通过英文字段名推断展示顺序
- 复杂字段需要通过 `display_type``visible` 明确处理
推荐顺序(可按业务裁剪,但已有字段应遵守相对位置):
```text
1. 主键 id
2. 关联标识与核心业务字段
3. 普通业务字段
4. 状态、结果、错误等辅助字段
5. created_at如有
6. updated_at如有
```
## 标准时间字段(新技能统一规范)
新技能业务表若需要时间审计字段,**统一**采用 Unix **秒级**时间戳不要使用毫秒时间戳、ISO 字符串或 SQLite 文本 datetime。
| 项目 | 规范 |
|------|------|
| 物理字段 | `created_at INTEGER``updated_at INTEGER` |
| 存储格式 | Unix 秒级整数 |
| 展示元数据 `display_type` | `datetime_unix_seconds` |
| 中文名 | `created_at`**创建时间**`updated_at`**更新时间** |
| 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果) |
| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` |
| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` |
### 自动生成与维护
- **created_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。
- **updated_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。
- **updated_at 更新**:模板默认表 `task_logs` 通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新。
实现参考:
- DDL 与 trigger`scripts/db/connection.py``scripts/db/timestamp_columns.py`
- 中文元数据种子:`scripts/db/display_metadata.py`
- 自检:`scripts/db/display_metadata_validator.py`
宿主只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。
### 模板默认:`task_logs`
`task_logs` 是技能内部**系统自动写入**的任务审计日志(由 `task_logs_repository` INSERT/UPDATE不是供用户在宿主数据管理中手工维护的业务表。
权限设计:
| 层级 | 值 | 说明 |
|------|-----|------|
| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 |
| 所有业务字段 `editable` | `0` | 含 `task_type``status``error_msg``result_summary` 等 |
| 技能内部 repository | 正常 INSERT/UPDATE | 元数据权限只约束宿主 UI不限制技能代码写库 |
物理定义(权威顺序):
```sql
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
target_id TEXT,
input_id TEXT,
input_title TEXT,
status TEXT NOT NULL,
error_msg TEXT,
result_summary TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
```
对应中文展示(由 `init_db()` 写入元数据表):
| 物理字段 | 中文名 | editable |
|----------|--------|----------|
| id | 编号 | 0 |
| task_type | 任务类型 | 0 |
| target_id | 目标编号 | 0 |
| input_id | 输入编号 | 0 |
| input_title | 输入标题 | 0 |
| status | 状态 | 0 |
| error_msg | 错误信息 | 0 |
| result_summary | 结果摘要 | 0 |
| created_at | 创建时间 | 0 |
| updated_at | 更新时间 | 0 |
表中文名:**任务日志**;表级 `readonly = 1`(宿主只读;技能 repository 仍可写入)。
### 通用示例:`demo_items`(测试 fixture 参考)
以下表结构用于模板测试演示数据管理契约,**不是**模板默认业务表:
```sql
CREATE TABLE demo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft',
generated_value TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
```
对应元数据要点:
| 物理字段 | editable | 说明 |
|----------|----------|------|
| id | 0 | 自增主键 |
| title | 1 | 业务字段 |
| description | 1 | 业务字段 |
| status | 1 | 枚举,`options_json` 演示 |
| generated_value | 0 | 系统/计算字段 |
| created_at | 0 | 系统时间 |
| updated_at | 0 | 系统时间 |
## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value``label`
```json
[
{"value": "draft", "label": "草稿"},
{"value": "published", "label": "已发布"},
{"value": "archived", "label": "已归档"}
]
```
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
## 幂等初始化标准
- `_jiangchang_tables``_jiangchang_columns` 必须通过 upsert 初始化
- 多次执行初始化不能重复记录
- 重新初始化可以更新展示名、描述、`visible``searchable``editable``display_type``options_json`
- 元数据不能残留旧字段(删除或重命名字段时必须同步清理)
- 元数据不能引用不存在的表或字段
- 新增字段或表时必须同步增加元数据
- 不要直接修改用户真实数据库作为迁移方式
实现 helper
- `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入
- `seed_column_metadata_batch` — 批量 upsert**可选**传入 `valid_column_names` 时在 upsert 后自动 prune
- `prune_stale_column_metadata` — 清理已删除物理字段的元数据
### 新增业务表时如何初始化
1.`connection.py`(或迁移脚本)中 `CREATE TABLE` 真实物理表。
2.`PRAGMA table_info``list_physical_column_names` 取得**真实物理字段列表**(顺序以 `cid` 为准)。
3. 调用 `upsert_table_metadata` 写入表级元数据。
4. 调用 `seed_column_metadata_batch`,并传入 `valid_column_names=physical_column_names`
```python
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch, upsert_table_metadata
physical = list_physical_column_names(cursor, "demo_items")
upsert_table_metadata(cursor, table_name="demo_items", display_name="示例记录", readonly=0)
seed_column_metadata_batch(
cursor,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=physical,
)
```
传入 `valid_column_names` 时:先 upsert再删除不在物理表中的陈旧 `_jiangchang_columns` 行。
### 字段删除或重命名时如何清理
1. 在 DDL / 迁移中完成物理字段删除或重命名。
2. 重新执行 `seed_column_metadata_batch(..., valid_column_names=当前物理字段列表)`,自动 prune 旧字段元数据。
3. 或单独调用 `prune_stale_column_metadata(cursor, table_name, valid_column_names)`
**不传入 `valid_column_names` 时不会自动 prune**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。
不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。
## 中文命名质量
- 使用普通用户能理解的业务用语:`task_logs`**任务日志**`error_msg`**错误信息**
- 禁止:`Task Logs``input编号``errormsg`、直接把物理名当 `display_name`
- 不宜向用户暴露的实现细节(如 `payload_json``selector`)应设 `visible = 0` 或给出可理解中文名。
## 元数据同步规则
1. 新增用户可见业务表 → 同步 `_jiangchang_tables`
2. 新增用户可见字段 → 同步 `_jiangchang_columns`
3. 删除/重命名表或字段 → 同步清理或迁移元数据。
4. 初始化脚本必须幂等。
5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`
## 不同业务的 `task_logs` 映射
| 业务场景 | task_type | target_id | input_id |
|----------|-----------|-----------|----------|
| 发布类 | publish | 账号 ID | 内容 ID |
| 工资代发 | disburse | 付款账户 | 批次 ID |
| 对账 | reconcile | 平台标识 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID |
业务特有字段优先放入 `result_summary`JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。
## 开发自检
- `init_db()` 后运行 `tests/test_display_metadata.py``tests/test_data_management_contract.py`
- 若提供宿主 Action 入口,运行 `tests/test_actions_manifest.py`
- 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。
## 模板原则
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
- 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。
- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。

View File

@@ -1,3 +1,19 @@
<#
.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+
After fetch, runs default tests against the current working tree (before auto-commit):
python tests/run_tests.py -v
Use -SkipTests only for emergency diagnostics. -DryRun still runs tests.
#>
[CmdletBinding()]
param(
[string]$Prefix = "v",
@@ -5,19 +21,260 @@ param(
[switch]$AutoCommit,
[switch]$RequireClean,
[string]$CommitMessage,
[switch]$DryRun
[switch]$DryRun,
[switch]$SkipTests
)
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 Invoke-DefaultTests {
param(
[Parameter(Mandatory = $true)][string]$SkillRoot,
[switch]$Skip
)
if ($Skip) {
Write-Host "WARNING: Skipping tests before release. This should only be used for emergency diagnostics." -ForegroundColor Yellow
return
}
$runTests = Join-Path $SkillRoot "tests\run_tests.py"
if (-not (Test-Path -LiteralPath $runTests)) {
throw "Release check failed: tests/run_tests.py not found."
}
Write-Host ">> python tests/run_tests.py -v" -ForegroundColor DarkGray
& python $runTests -v
if ($LASTEXITCODE -ne 0) {
throw "Release aborted: default tests failed (python tests/run_tests.py -v)."
}
}
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"
}
$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
}
Invoke-DefaultTests -SkillRoot $skillRoot -Skip:$SkipTests
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$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))

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

@@ -0,0 +1,40 @@
"""SQLite 连接与任务日志表迁移模板。"""
from __future__ import annotations
import sqlite3
from db.display_metadata import init_display_metadata
from db.timestamp_columns import init_task_logs_timestamp_maintenance
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 DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
)
init_task_logs_timestamp_maintenance(cur)
init_display_metadata(cur)
conn.commit()
finally:
conn.close()

View File

@@ -0,0 +1,283 @@
"""匠厂数据管理展示元数据_jiangchang_tables / _jiangchang_columns
SQLite 无原生表/字段 COMMENT中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。
字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。
"""
from __future__ import annotations
from typing import Iterable
# 宿主会隐藏这些表,不作为业务表展示(见 jiangchang electron/data-management/types.ts
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
TASK_LOGS_TABLE = "task_logs"
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
_JIANGCHANG_TABLES_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0
)
"""
_JIANGCHANG_COLUMNS_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_columns (
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
display_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
searchable INTEGER NOT NULL DEFAULT 1,
editable INTEGER NOT NULL DEFAULT 1,
display_type TEXT,
options_json TEXT,
PRIMARY KEY (table_name, column_name)
)
"""
TASK_LOGS_TABLE_METADATA = {
"table_name": TASK_LOGS_TABLE,
"display_name": "任务日志",
"description": "记录技能任务的执行状态和结果(系统自动写入,宿主数据管理只读展示)",
"visible": 1,
"readonly": 1,
}
TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 0},
{
"column_name": "created_at",
"display_name": "创建时间",
"description": "记录创建时间Unix 秒级时间戳,数据库自动生成)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": DATETIME_UNIX_SECONDS,
},
{
"column_name": "updated_at",
"display_name": "更新时间",
"description": "记录最后更新时间Unix 秒级时间戳,自动维护)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": DATETIME_UNIX_SECONDS,
},
)
def init_display_metadata_tables(cursor) -> None:
cursor.execute(_JIANGCHANG_TABLES_DDL)
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
def upsert_table_metadata(cursor, *, table_name: str, display_name: str, description: str | None = None, visible: int = 1, readonly: int = 0) -> None:
cursor.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, description, visible, readonly)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
visible = excluded.visible,
readonly = excluded.readonly
""",
(table_name, display_name, description, visible, readonly),
)
def upsert_column_metadata(
cursor,
*,
table_name: str,
column_name: str,
display_name: str,
description: str | None = None,
visible: int = 1,
searchable: int = 1,
editable: int = 1,
display_type: str | None = None,
options_json: str | None = None,
) -> None:
# display_order 保留为 NULL宿主按 PRAGMA table_info cid 展示字段,不读 display_order。
cursor.execute(
"""
INSERT INTO _jiangchang_columns (
table_name, column_name, display_name, description,
display_order, visible, searchable, editable, display_type, options_json
)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
ON CONFLICT(table_name, column_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
display_order = NULL,
visible = excluded.visible,
searchable = excluded.searchable,
editable = excluded.editable,
display_type = excluded.display_type,
options_json = excluded.options_json
""",
(
table_name,
column_name,
display_name,
description,
visible,
searchable,
editable,
display_type,
options_json,
),
)
def seed_task_logs_display_metadata(cursor) -> None:
meta = TASK_LOGS_TABLE_METADATA
upsert_table_metadata(
cursor,
table_name=str(meta["table_name"]),
display_name=str(meta["display_name"]),
description=str(meta["description"]) if meta.get("description") else None,
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
seed_column_metadata_batch(
cursor,
table_name=TASK_LOGS_TABLE,
columns=TASK_LOGS_COLUMN_METADATA,
valid_column_names=[str(column["column_name"]) for column in TASK_LOGS_COLUMN_METADATA],
)
def seed_column_metadata_batch(
cursor,
*,
table_name: str,
columns: Iterable[dict[str, object]],
valid_column_names: Iterable[str] | None = None,
) -> None:
"""批量 upsert 字段元数据;传入 valid_column_names 时会在 upsert 后清理陈旧字段元数据。"""
for column in columns:
upsert_column_metadata(
cursor,
table_name=table_name,
column_name=str(column["column_name"]),
display_name=str(column["display_name"]),
description=str(column["description"]) if column.get("description") else None,
visible=int(column.get("visible", 1)),
searchable=int(column.get("searchable", 1)),
editable=int(column.get("editable", 1)),
display_type=str(column["display_type"]) if column.get("display_type") else None,
options_json=str(column["options_json"]) if column.get("options_json") else None,
)
if valid_column_names is not None:
prune_stale_column_metadata(cursor, table_name, valid_column_names)
def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None:
"""删除表中已不存在物理字段的元数据行(幂等初始化后不得残留旧字段)。"""
valid = set(valid_column_names)
cursor.execute(
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
(table_name,),
)
for (column_name,) in cursor.fetchall():
if str(column_name) not in valid:
cursor.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?",
(table_name, column_name),
)
def get_table_readonly(cursor, table_name: str) -> int | None:
cursor.execute(
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cursor.fetchone()
if not row:
return None
return int(row[0] or 0)
def get_column_editable(cursor, table_name: str, column_name: str) -> int | None:
cursor.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
row = cursor.fetchone()
if not row:
return None
return int(row[0] or 0)
def list_editable_business_columns(cursor, table_name: str) -> list[str]:
"""返回 editable=1 的业务字段名(不含宿主侧主键/generated 等二次过滤)。"""
physical = list_physical_column_names(cursor, table_name)
cursor.execute(
"""
SELECT column_name, editable
FROM _jiangchang_columns
WHERE table_name = ?
""",
(table_name,),
)
editable_set = {
str(column_name)
for column_name, editable in cursor.fetchall()
if editable is not None and int(editable) == 1
}
return [name for name in physical if name in editable_set]
def table_allows_manual_create(cursor, table_name: str) -> bool:
"""表级 readonly=0 且至少有一个 editable=1 字段时,宿主才应提供空白新增表单。"""
readonly = get_table_readonly(cursor, table_name)
if readonly is None or int(readonly) != 0:
return False
return bool(list_editable_business_columns(cursor, table_name))
def init_display_metadata(cursor) -> None:
init_display_metadata_tables(cursor)
seed_task_logs_display_metadata(cursor)
def iter_user_tables(cursor) -> Iterable[str]:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
for (name,) in cursor.fetchall():
if name in METADATA_TABLES:
continue
yield str(name)
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def list_physical_column_names(cursor, table_name: str) -> list[str]:
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
rows = cursor.fetchall()
rows.sort(key=lambda row: int(row[0]))
return [str(row[1]) for row in rows]

View File

@@ -0,0 +1,510 @@
"""数据管理展示元数据校验(模板开发自检,不依赖宿主)。"""
from __future__ import annotations
import json
import os
import re
import sqlite3
from dataclasses import dataclass, field
from db.display_metadata import (
DATETIME_UNIX_SECONDS,
METADATA_TABLES,
STANDARD_TIMESTAMP_COLUMNS,
TASK_LOGS_COLUMN_METADATA,
TASK_LOGS_TABLE,
iter_user_tables,
list_editable_business_columns,
list_physical_column_names,
table_allows_manual_create,
)
from db.timestamp_columns import has_updated_at_trigger
from util.constants import SKILL_SLUG
from util.slug_naming import LEGACY_EXEMPT_SLUGS, validate_slug_semantics
_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"})
_ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
re.compile(r"^my\s+skill$", re.I),
re.compile(r"^your[-_\s]?skill", re.I),
re.compile(r"^skill[-_\s]?template$", re.I),
re.compile(r"^[a-z0-9_-]+$"),
)
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
@dataclass
class ValidationReport:
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def _has_cjk(text: str) -> bool:
return bool(_CJK_RE.search(text))
def _looks_like_raw_physical_name(display_name: str, physical_name: str) -> bool:
if not display_name.strip():
return True
if display_name.strip().lower() == physical_name.strip().lower():
return True
compact = display_name.replace(" ", "").replace("_", "").lower()
if compact == physical_name.replace("_", "").lower():
return True
return False
def _parse_frontmatter_field(text: str, field_name: str) -> str:
parts = text.split("---", 2)
if len(parts) < 2:
return ""
for line in parts[1].splitlines():
match = re.match(rf"^{re.escape(field_name)}:\s*(.+)\s*$", line.strip())
if match:
return match.group(1).strip().strip("\"'")
return ""
def _parse_openclaw_slug(text: str) -> str:
parts = text.split("---", 2)
if len(parts) < 2:
return ""
lines = parts[1].splitlines()
for index, line in enumerate(lines):
if re.match(r"^\s+openclaw:\s*$", line):
for inner in lines[index + 1 :]:
if inner.strip() and inner[0] not in (" ", "\t"):
break
match = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner)
if match:
return match.group(2).strip().strip("\"'")
return ""
def parse_skill_frontmatter(skill_root: str) -> dict[str, str]:
path = os.path.join(skill_root, "SKILL.md")
with open(path, encoding="utf-8") as f:
text = f.read()
return {
"name": _parse_frontmatter_field(text, "name"),
"slug": _parse_openclaw_slug(text),
}
def validate_skill_frontmatter(skill_root: str, *, allow_template_placeholders: bool = True) -> ValidationReport:
report = ValidationReport()
data = parse_skill_frontmatter(skill_root)
name = str(data.get("name") or "").strip()
slug = str(data.get("slug") or "").strip()
if not name:
report.errors.append("SKILL.md frontmatter.name 缺失")
elif not _has_cjk(name):
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
report.warnings.append("SKILL.md frontmatter.name 建议使用清晰的中文业务名称(模板占位可暂保留)")
else:
report.errors.append("SKILL.md frontmatter.name 应使用用户可见的中文业务名称")
else:
for pattern in _ENGLISH_PLACEHOLDER_NAME_PATTERNS:
if pattern.fullmatch(name):
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
report.warnings.append(f"SKILL.md frontmatter.name 仍为占位风格:{name!r}")
else:
report.errors.append(f"SKILL.md frontmatter.name 不应为英文占位名:{name!r}")
break
if not slug:
report.errors.append("SKILL.md metadata.openclaw.slug 缺失")
elif not _KEBAB_SLUG.fullmatch(slug):
report.errors.append(f"metadata.openclaw.slug 必须为 kebab-case{slug!r}")
elif slug in _TEMPLATE_PLACEHOLDER_SLUGS and not allow_template_placeholders:
report.errors.append(f"metadata.openclaw.slug 仍为模板占位:{slug!r}")
constants_slug = SKILL_SLUG.strip()
if slug and constants_slug and slug != constants_slug:
report.warnings.append(
f"SKILL.md slug ({slug!r}) 与 util.constants.SKILL_SLUG ({constants_slug!r}) 不一致"
)
if slug and slug not in LEGACY_EXEMPT_SLUGS and _KEBAB_SLUG.fullmatch(slug):
naming_errors, naming_warnings = validate_slug_semantics(
slug,
strict=not allow_template_placeholders,
)
report.errors.extend(naming_errors)
report.warnings.extend(naming_warnings)
return report
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]:
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
info: dict[str, dict[str, object]] = {}
for row in cursor.fetchall():
info[str(row[1])] = {
"cid": int(row[0]),
"name": str(row[1]),
"type": str(row[2] or ""),
"notnull": int(row[3] or 0),
"dflt_value": row[4],
"pk": int(row[5] or 0),
}
return info
def _table_column_xinfo(cursor, table_name: str) -> dict[str, dict[str, object]]:
"""PRAGMA table_xinfo 可识别 generated/hidden 字段SQLite 3.31+)。"""
cursor.execute(f"PRAGMA table_xinfo({_quote_identifier(table_name)})")
info: dict[str, dict[str, object]] = {}
for row in cursor.fetchall():
hidden = int(row[6] or 0) if len(row) > 6 else 0
info[str(row[1])] = {
"cid": int(row[0]),
"name": str(row[1]),
"type": str(row[2] or ""),
"notnull": int(row[3] or 0),
"dflt_value": row[4],
"pk": int(row[5] or 0),
"hidden": hidden,
}
return info
def _is_generated_column(xinfo: dict[str, object]) -> bool:
hidden = int(xinfo.get("hidden") or 0)
return hidden in (2, 3)
def _is_blob_column(column_info: dict[str, object]) -> bool:
return "BLOB" in str(column_info.get("type") or "").upper()
def _default_uses_unixepoch(default_value: object) -> bool:
if default_value is None:
return False
text = str(default_value).strip().lower()
return "unixepoch" in text
def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]:
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。"""
errors: list[str] = []
warnings: list[str] = []
if options_json is None or str(options_json).strip() == "":
return errors, warnings
try:
parsed = json.loads(str(options_json))
except json.JSONDecodeError as exc:
errors.append(f"options_json 不是合法 JSON{exc}")
return errors, warnings
if not isinstance(parsed, list):
errors.append("options_json 必须是 JSON 数组")
return errors, warnings
for index, item in enumerate(parsed):
if not isinstance(item, dict):
errors.append(f"options_json[{index}] 必须是对象")
continue
if "value" not in item or "label" not in item:
errors.append(f"options_json[{index}] 必须包含 value 与 label")
return errors, warnings
def validate_column_permissions(
cursor,
table_name: str,
*,
physical_columns: list[str] | None = None,
) -> tuple[list[str], list[str]]:
"""校验常见系统字段 editable=0并识别技术上可写但不适合手工新增的表。"""
errors: list[str] = []
warnings: list[str] = []
ordered = physical_columns or list_physical_column_names(cursor, table_name)
column_info = _table_column_info(cursor, table_name)
column_xinfo = _table_column_xinfo(cursor, table_name)
cursor.execute(
"""
SELECT column_name, editable, options_json
FROM _jiangchang_columns
WHERE table_name = ?
""",
(table_name,),
)
meta_by_column = {
str(row[0]): {"editable": row[1], "options_json": row[2]}
for row in cursor.fetchall()
}
for column_name, meta in meta_by_column.items():
physical = column_info.get(column_name) or {}
xinfo = column_xinfo.get(column_name) or physical
if not physical and column_name not in column_xinfo:
continue
pk = int(physical.get("pk") or xinfo.get("pk") or 0)
editable = meta["editable"]
if pk and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.{column_name} 是主键editable 应为 0")
if column_name == "id" and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.id 的 editable 应为 0自增主键")
if _is_generated_column(xinfo) and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.{column_name} 是 generated 字段editable 应为 0")
if _is_blob_column(physical or xinfo) and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.{column_name} 是 BLOB 字段editable 应为 0")
opt_errors, _ = validate_options_json(meta.get("options_json"))
for item in opt_errors:
errors.append(f"{table_name}.{column_name}: {item}")
cursor.execute(
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
readonly_row = cursor.fetchone()
readonly = int(readonly_row[0]) if readonly_row else None
editable_columns = list_editable_business_columns(cursor, table_name)
if readonly == 0 and not editable_columns:
warnings.append(
f"{table_name}: 表级 readonly=0 但所有字段 editable=0"
"宿主不应打开空白新增表单;数据应由技能业务流程产生"
)
if readonly == 1 and editable_columns:
warnings.append(
f"{table_name}: 表级 readonly=1 但存在 editable=1 字段,"
"宿主应以表级只读为准"
)
return errors, warnings
def _expected_timestamp_display_name(column_name: str) -> str:
if column_name == "created_at":
return "创建时间"
if column_name == "updated_at":
return "更新时间"
return ""
def validate_standard_timestamp_columns(
cursor,
table_name: str,
*,
physical_columns: list[str] | None = None,
) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
ordered = physical_columns or list_physical_column_names(cursor, table_name)
column_info = _table_column_info(cursor, table_name)
for column_name in STANDARD_TIMESTAMP_COLUMNS:
if column_name not in ordered:
continue
physical = column_info.get(column_name)
if not physical:
errors.append(f"{table_name}.{column_name} 缺少物理字段定义")
continue
if "INT" not in str(physical["type"]).upper():
errors.append(
f"{table_name}.{column_name} 字段类型应为 INTEGERUnix 秒级时间戳),当前为 {physical['type']!r}"
)
if column_name == "created_at" and not _default_uses_unixepoch(physical["dflt_value"]):
errors.append(
f"{table_name}.created_at 应设置数据库默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}"
)
if column_name == "updated_at" and not _default_uses_unixepoch(physical["dflt_value"]):
errors.append(
f"{table_name}.updated_at 应设置数据库初始默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}"
)
cursor.execute(
"""
SELECT display_name, display_type, editable, searchable, display_order
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
meta_row = cursor.fetchone()
if not meta_row:
errors.append(f"{table_name}.{column_name} 缺少 _jiangchang_columns 元数据")
continue
display_name, display_type, editable, searchable, display_order = meta_row
expected_name = _expected_timestamp_display_name(column_name)
if str(display_name or "").strip() != expected_name:
errors.append(
f"{table_name}.{column_name} 的 display_name 应为 {expected_name!r},当前为 {display_name!r}"
)
if str(display_type or "").strip() != DATETIME_UNIX_SECONDS:
errors.append(
f"{table_name}.{column_name} 的 display_type 应为 {DATETIME_UNIX_SECONDS!r},当前为 {display_type!r}"
)
if editable is None or int(editable) != 0:
errors.append(f"{table_name}.{column_name} 的 editable 应为 0系统时间字段不可编辑")
if display_order is not None:
errors.append(f"{table_name}.{column_name} 的 display_order 应为 NULL")
if searchable is not None and int(searchable) != 0:
warnings.append(f"{table_name}.{column_name} 的时间字段通常不必开启 searchable")
if "created_at" in ordered and "updated_at" in ordered:
if ordered.index("updated_at") < ordered.index("created_at"):
warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前")
if "updated_at" in ordered and not has_updated_at_trigger(cursor, table_name):
errors.append(
f"{table_name}.updated_at 缺少自动维护 trigger期望 {table_name}_set_updated_at"
)
return errors, warnings
def column_order_warnings(table_name: str, ordered_names: list[str]) -> list[str]:
warnings: list[str] = []
if "id" in ordered_names and ordered_names.index("id") != 0:
warnings.append(f"{table_name}: 字段 id 通常应定义在 CREATE TABLE 的第一列")
if "created_at" in ordered_names:
idx = ordered_names.index("created_at")
trailing = ordered_names[idx + 1 :]
if trailing and trailing != ["updated_at"]:
warnings.append(
f"{table_name}: created_at 之后通常只应跟 updated_at不应再放置其他业务字段"
)
if "updated_at" in ordered_names and "created_at" in ordered_names:
if ordered_names.index("updated_at") < ordered_names.index("created_at"):
warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前")
return warnings
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
(table_name,),
)
return cursor.fetchone() is not None
def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: bool = True) -> ValidationReport:
report = ValidationReport()
cur = conn.cursor()
has_tables_meta = _table_exists(cur, "_jiangchang_tables")
has_columns_meta = _table_exists(cur, "_jiangchang_columns")
if not has_tables_meta:
report.errors.append("缺少数据管理元数据表 _jiangchang_tables")
if not has_columns_meta:
report.errors.append("缺少数据管理元数据表 _jiangchang_columns")
if not has_tables_meta or not has_columns_meta:
return report
for table_name in iter_user_tables(cur):
cur.execute(
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cur.fetchone()
if not row or not str(row[0] or "").strip():
report.errors.append(f"业务表 {table_name!r} 缺少 _jiangchang_tables.display_name")
elif not _has_cjk(str(row[0])):
report.errors.append(f"业务表 {table_name!r} 的 display_name 应使用中文业务名称")
physical_columns = list_physical_column_names(cur, table_name)
if check_column_order:
report.warnings.extend(column_order_warnings(table_name, physical_columns))
for column_name in physical_columns:
cur.execute(
"""
SELECT display_name, visible
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
col_row = cur.fetchone()
if not col_row:
report.errors.append(
f"字段 {table_name}.{column_name} 缺少 _jiangchang_columns 元数据"
)
continue
display_name = str(col_row[0] or "").strip()
visible = col_row[1] is None or int(col_row[1]) != 0
if visible and not display_name:
report.errors.append(f"字段 {table_name}.{column_name} 的 display_name 为空")
elif visible and _looks_like_raw_physical_name(display_name, column_name):
report.errors.append(
f"字段 {table_name}.{column_name} 的 display_name 不应直接等于英文物理名:{display_name!r}"
)
elif visible and not _has_cjk(display_name):
report.errors.append(
f"字段 {table_name}.{column_name} 的 display_name 应使用中文:{display_name!r}"
)
ts_errors, ts_warnings = validate_standard_timestamp_columns(
cur,
table_name,
physical_columns=physical_columns,
)
report.errors.extend(ts_errors)
report.warnings.extend(ts_warnings)
perm_errors, perm_warnings = validate_column_permissions(
cur,
table_name,
physical_columns=physical_columns,
)
report.errors.extend(perm_errors)
report.warnings.extend(perm_warnings)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES:
continue
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
(table_name,),
)
if not cur.fetchone():
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
continue
physical = list_physical_column_names(cur, str(table_name))
if str(column_name) not in physical:
report.errors.append(f"元数据引用了不存在的字段:{table_name}.{column_name}")
cur.execute("SELECT table_name FROM _jiangchang_tables")
for (table_name,) in cur.fetchall():
if table_name in METADATA_TABLES:
continue
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
(table_name,),
)
if not cur.fetchone():
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
return report
def validate_template_database(conn: sqlite3.Connection) -> ValidationReport:
report = validate_display_metadata(conn)
physical = list_physical_column_names(conn.cursor(), TASK_LOGS_TABLE)
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
if physical != expected:
report.errors.append(
f"task_logs 物理字段顺序应为 {expected},当前为 {physical}"
)
return report

View File

@@ -0,0 +1,86 @@
"""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
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()
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)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(task_type, target_id, input_id, input_title or "", status, error_msg, result_summary),
)
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()

View File

@@ -0,0 +1,36 @@
"""标准时间字段Unix 秒级时间戳、默认值与 updated_at 自动维护。"""
from __future__ import annotations
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
TASK_LOGS_UPDATED_AT_TRIGGER = "task_logs_set_updated_at"
_TASK_LOGS_UPDATED_AT_TRIGGER_DDL = f"""
CREATE TRIGGER IF NOT EXISTS {TASK_LOGS_UPDATED_AT_TRIGGER}
AFTER UPDATE ON task_logs
FOR EACH ROW
BEGIN
UPDATE task_logs
SET updated_at = unixepoch()
WHERE id = NEW.id AND updated_at = OLD.updated_at;
END
"""
def init_task_logs_timestamp_maintenance(cursor) -> None:
"""幂等创建 task_logs.updated_at 自动维护 trigger。"""
cursor.execute(_TASK_LOGS_UPDATED_AT_TRIGGER_DDL)
def has_updated_at_trigger(cursor, table_name: str) -> bool:
trigger_name = f"{table_name}_set_updated_at"
cursor.execute(
"""
SELECT 1 FROM sqlite_master
WHERE type = 'trigger' AND name = ? AND tbl_name = ?
LIMIT 1
""",
(trigger_name, table_name),
)
return cursor.fetchone() is not None

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
from typing import Tuple
import requests
from jiangchang_skill_core import config
def check_entitlement(skill_slug: str) -> Tuple[bool, str]:
auth_base = (config.get("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/")
if not auth_base:
return True, ""
user_id = (config.get("JIANGCHANG_USER_ID") or "").strip()
if not user_id:
return False, "鉴权失败缺少用户身份JIANGCHANG_USER_ID"
auth_api_key = (config.get("JIANGCHANG_AUTH_API_KEY") or "").strip()
timeout = int((config.get("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": (config.get("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,293 @@
"""任务编排、日志查询模板。
通用业务编排层:负责参数校验、鉴权、调用具体业务实现、写入任务日志。
复制后在 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.activity import JobStopped, emit, finish, job_context
from db import task_logs_repository as tlr
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
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 LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
from util.logging_config import get_skill_logger, setup_skill_logging
from util.runtime_paths import get_skill_data_dir, get_skill_root
from util.timeutil import unix_to_iso
def _get_task_logger():
try:
return get_skill_logger()
except RuntimeError:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
return get_skill_logger()
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:
"""通用任务执行入口模板。复制后请实现真实业务逻辑。"""
log = _get_task_logger()
task_type = "demo"
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s target_id=%s input_id=%s",
task_type,
target,
input_id,
)
emit("开始处理任务", skill=SKILL_SLUG, stage="run")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason)
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
tlr.save_task_log(
task_type=task_type,
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg=reason,
result_summary=json.dumps({"stage": "auth", "error": reason}, ensure_ascii=False),
)
log.info(
"task_log_saved task_type=%s target_id=%s input_id=%s stage=auth status=failed",
task_type,
target,
input_id,
)
finish(
status="failed",
message=reason,
skill=SKILL_SLUG,
error_code="ENTITLEMENT_DENIED",
stage="auth",
)
print(f"{reason}")
return 1
log.info("task_run_demo_start target_id=%s input_id=%s", target, input_id)
rc, video_info = asyncio.run(_run_template_demo(target, input_id))
log.info("task_run_demo_done target_id=%s input_id=%s status=failed", 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=task_type,
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg="模板仓库未实现真实业务",
result_summary=json.dumps(summary_payload, ensure_ascii=False),
)
log.info(
"task_log_saved task_type=%s target_id=%s input_id=%s status=failed",
task_type,
target,
input_id,
)
finish(
status="failed",
message="模板仓库未实现真实业务",
skill=SKILL_SLUG,
template_demo=True,
target=target,
input_id=input_id,
)
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
return rc
except JobStopped:
raise
except Exception:
log.exception(
"task_failed task_type=%s target_id=%s input_id=%s",
task_type,
target,
input_id,
)
emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run")
try:
tlr.save_task_log(
task_type=task_type,
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg="任务执行异常,详见统一日志",
result_summary=json.dumps(
{"stage": "run", "error": "unexpected_exception"},
ensure_ascii=False,
),
)
log.info(
"task_log_saved task_type=%s target_id=%s input_id=%s stage=run status=failed",
task_type,
target,
input_id,
)
except Exception:
log.exception(
"task_log_save_failed task_type=%s target_id=%s input_id=%s",
task_type,
target,
input_id,
)
finish(
status="failed",
message="任务执行异常,详见统一日志",
skill=SKILL_SLUG,
stage="run",
)
return 1
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)

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