This commit is contained in:
2026-09-09 10:29:02 +08:00
parent 2aa49b5263
commit ccb12dad7c
102 changed files with 7879 additions and 559 deletions

88
examples/README.md Normal file
View File

@@ -0,0 +1,88 @@
# Skill Template Examples
`examples/` 是面向**技术人员与 AI 编程工具**的**核心实现案例**目录:提供可运行、可测试、可复制的参考代码,用于指导新技能的 `scripts/` 应如何编写。
**`examples/` 不是完整 skill 仓库。** 不要整包照搬 `examples/<mode>/` 到新技能根目录。应按各 example README 的 **copy map**,**选择性复制**到新技能的 `scripts/`(及按需的 `tests/`)。
复制任何案例前先读 [`../development/AI_RPA.md`](../development/AI_RPA.md):案例只示范授权范围内的正常界面自动化,禁止把它扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避平台限制的实现。
根 `README.md`、`SKILL.md`、`references/`、`development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。
## 外部交互示例矩阵
adapter 仍是 `真实/仿真 × API/RPA` 四档;RPA 再按浏览器、Windows 桌面和手机三种界面拆分。原四类目录不可删除、不可移动,桌面和手机目录用于补足界面维度。
| 目录 | 类型 | 状态 | 适用场景 |
|---|---|---|---|
| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 |
| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 |
| `real_desktop_rpa` | 真实 Windows 桌面 RPA | **已有成功来源并已提炼** | UIA 拾取、窗口/控件、probe、dry-run/assisted |
| `mobile_rpa` | Android/iOS RPA | **仅规格,待成功案例** | 设备、控件树、Native/WebView、example 完成契约 |
| `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** |
| Windows 原生软件 + UIA | **real_desktop_rpa** |
| Android/iOS App | **mobile_rpa**(当前仅规格,不可作为成功实现参考) |
| 真实系统 API | **real_api**(当前占位,不可作为实现参考) |
| mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) |
## 轻量结构约定
每个 `examples/<mode>/` 推荐结构:
```text
examples/<mode>/
README.md
scripts/
service/
util/
tests/
```
- `simulator_browser_rpa` 可额外包含 `sandbox/`
- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture
- 仅未来确实需要样例数据时可加 `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/`)
- Windows 桌面 RPA → 参考 `examples/real_desktop_rpa/`(真实成功来源:`add-friend-wecom`)
- 手机 RPA → 先按 `examples/mobile_rpa/` 定义完成标准;真实成功案例补齐前不得照骨架宣称跑通
- `real_api/`、`simulator_api/` 当前为规划占位,**不得**作为实现参考
> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。

View File

@@ -0,0 +1,94 @@
# Mobile RPA Example Specification
本目录定义 Android/iOS example 必须具备的结构和验收契约。**当前没有团队真实成功案例,因此本目录不是可复制实现,也不得宣称已跑通。**
先读:
1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md)
2. [`../../development/RPA_MOBILE.md`](../../development/RPA_MOBILE.md)
## 为什么现在不放伪代码实现
移动端 selector、driver、Activity/context、权限弹窗和设备兼容性必须在真实授权设备上验证。没有成功案例时放一套看似完整的代码,会诱导开发者和 AI 猜 `resource-id`、默认取第一个设备或用坐标硬点,违背模板目标。
## 未来成功 example 的固定结构
```text
mobile_rpa/
README.md
android/
README.md
assets/element-map.yaml
scripts/service/device_session.py
scripts/service/mobile_locator.py
scripts/service/example_flow.py
tests/test_locator_contract.py
tests/integration/test_real_device.py.sample
ios/
README.md
assets/element-map.yaml
scripts/service/device_session.py
scripts/service/mobile_locator.py
scripts/service/example_flow.py
tests/test_locator_contract.py
tests/integration/test_real_device.py.sample
```
只有对应端出现真实成功案例后才创建代码文件。
## Android example 完成标准
- 唯一设备 serial 和设备互斥
- doctor:ADB授权、包名、Activity、屏幕状态、driver
- Appium Inspector/UIAutoDev 拾取步骤和原始 XML
- element map:resource-id/content-desc/text/class/父容器
- Native/WebView context 选择
- 点击、输入、滑动和真实后置状态
- 权限弹窗、系统弹窗和人工验证
- screenshot + page source 失败留证
- 虚拟列表滚动后重新定位
- 至少一台授权真机完整跑通
## iOS example 完成标准
- Mac、WebDriverAgent签名和设备 UDID 准备
- doctor:WDA、bundle id、系统版本、屏幕状态
- Appium Inspector 拾取和原始 page source
- accessibility id/name、predicate/class chain 定位
- Native/WebView context 选择
- 系统 alert 和人工验证
- screenshot + page source 失败留证
- 至少一台授权真机完整跑通
## element map 最小字段
```yaml
logical_name: login_button
surface: android
screen: 登录页
scope:
package: com.example.app
activity: .LoginActivity
primary_locator:
strategy: resource_id
value: com.example.app:id/login
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 首页业务标记出现
verified_device: 待填写
verified_app_version: 待填写
```
## 状态升级规则
满足以下全部条件后,才可把本目录状态改为“已有成功案例”:
1. 提供用户确认的成功项目来源。
2. 在真实授权设备上跑通完整业务闭环。
3. 保存脱敏 element map 和失败证据样例。
4. 有默认离线单测和显式真实集成测试。
5. 经过设备重连、App重启和页面变化验证。
6. 用户人工验收通过。

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,204 @@
# 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 # 结构化日志与脱敏
├── assets/
│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写
└── tests/
├── test_human_verification.py # 验证码误判防护
└── test_stop_reason.py # 停止原因文案
```
## 核心流程
1. **ensure account** — 通过 `account_client.pick_web_account()`(底层 `ensure-web`)获取 `profile_dir` 与租约;无账号则自动登记,全忙则 `LEASE_CONFLICT` 不重复建号。登记 ≠ 站点已登录。
2. **启动浏览器** — `launch_persistent_context` + `new_page()` + `page.goto(start_url)`
3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成
4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现
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. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map
3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
7. 在 `task_service.py` 中接入真实 DB、RpaVideoSession
8. 在新技能根目录补齐 `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/assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实 Pick Locator/F12 拾取后填写 |
| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 |
| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
**复制时必须替换:** selector、URL、平台名称、账号平台字段、slug、logger 名等业务标识。**不要**照搬示例字段到其他平台。
**浏览器与启动约束:**
- **先 Profile 再开浏览器**:必须 `pick_web_account`(底层 ensure-web)拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2)
- **RPA 等待**:主路径用 `interruptible_sleep`,禁止裸 `asyncio.sleep`(`POLICY-CONTROL-003`)
- **有头**:生产 / 联调 `OPENCLAW_BROWSER_HEADLESS=0`;勿把无头当交付常态
- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
- `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
- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器
- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性
- 不要把示例代码原样发布为业务 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 调用
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
- **不对抗平台风控** — 频控、账号风险、封禁或权限不足时停止并交由人工处理
- **日志脱敏** — 不输出完整手机号/账号
- **租约释放** — `ensure-web` / `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,45 @@
surface: browser
status: placeholder_requires_real_site_inspection
elements:
- logical_name: search_input
purpose: 输入搜索关键词
scope:
kind: main
business_container: 待真实拾取
primary_locator:
strategy: role_or_label
value: 待真实拾取
fallback_locators: []
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 搜索结果容器出现
verified_target_version: 待填写
- logical_name: search_button
purpose: 提交搜索
scope:
logical_parent: search_form
primary_locator:
strategy: role
value: button,name=搜索
fallback_locators: []
expected_count: 1
postcondition:
description: 结果页或业务错误状态出现
verified_target_version: 待填写
- logical_name: result_container
purpose: 承载搜索结果
scope:
kind: main
primary_locator:
strategy: stable_business_container
value: 待真实拾取
fallback_locators: []
expected_count: 1
postcondition:
description: 列表项、空结果或结束标记可区分
verified_target_version: 待填写

View File

@@ -0,0 +1,355 @@
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
from typing import Any, Dict, List, Optional, Tuple
from jiangchang_skill_core import config
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
from util.logging import mask_text
logger = logging.getLogger(__name__)
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = (
f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
)
LEASE_BUSY_MESSAGE = (
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
)
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
DEFAULT_START_URL = "https://www.example.com/"
class AccountManagerError(Exception):
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 format_account_label(account: dict) -> str:
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
for key in ("login_id", "account_label", "label"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
for key in ("id", "account_id"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
return ""
def _is_placeholder_config(raw: str) -> bool:
s = (raw or "").strip()
if not s:
return True
if s.startswith("#"):
return True
low = s.lower()
if low in ("default-account", "none", "null", "n/a", "-"):
return True
return False
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
"""从配置解析 (account_id, login_id)。
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
"""
account_id: Optional[str] = None
login_id: Optional[str] = None
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
if not _is_placeholder_config(aid_raw):
if aid_raw.isdigit():
account_id = aid_raw
else:
login_id = aid_raw
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
if not _is_placeholder_config(lid_raw):
login_id = lid_raw
if account_id:
return account_id, None
return None, login_id
def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
"""
env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
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 not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
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", message or LEASE_BUSY_MESSAGE)
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
)
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload
def _ensure_web_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
) -> Dict[str, Any]:
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
lid = (login_id or "").strip() or None
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
argv = [
"account",
"ensure-web",
"--platform",
platform,
"--url",
start_url,
"--auth-strategy",
DEFAULT_AUTH_STRATEGY,
"--label",
label,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if lid:
argv.extend(["--login-id", lid])
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"ENSURE_WEB_FAILED",
(proc.stderr or "").strip() or "ensure-web 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
return _validate_pick_payload(payload)
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
return _validate_pick_payload(payload)
def _pick_by_id_with_lease(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。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
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。")
if str(data.get("status") or "active").lower() != "active":
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
try:
selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
platform: str,
account_id: Optional[str] = None,
login_id: Optional[str] = None,
) -> dict:
"""获取网页账号(profile_dir + lease_token)。
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
aid_raw = (account_id or "").strip()
if aid_raw and not _is_placeholder_config(aid_raw):
if not aid_raw.isdigit():
if not (login_id or "").strip():
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):
lid = None
try:
return _ensure_web_with_lease(platform_key, login_id=lid)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
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 profile,new_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,637 @@
"""真实网站关键词搜索 RPA 示例(async Playwright)。"""
from __future__ import annotations
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 jiangchang_skill_core import config
from jiangchang_skill_core.activity import interruptible_sleep
from jiangchang_skill_core.rpa import poll_until
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 = max(0.0, config.get_float("STEP_DELAY_MIN", 1.0))
hi = max(lo, config.get_float("STEP_DELAY_MAX", 3.0))
await interruptible_sleep(random.uniform(lo, hi))
async def _scroll_wait() -> None:
await interruptible_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 interruptible_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 interruptible_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)
count = await loc.count()
if count == 1 and await _visible(loc):
return loc, sel
if count > 1:
logger.warning("search_input_ambiguous selector=%s count=%s", sel, count)
except Exception:
continue
try:
ph = page.get_by_placeholder(re.compile("搜索"))
count = await ph.count()
if count == 1 and await ph.is_visible():
return ph, "placeholder=搜索"
if count > 1:
logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count)
except Exception:
pass
return None, ""
async def _find_search_button(page):
try:
btn = page.get_by_role("button", name="搜索")
count = await btn.count()
if count == 1 and await btn.is_visible():
return btn
if count > 1:
logger.warning("search_button_ambiguous selector=role_button_search count=%s", count)
except Exception:
pass
try:
btn = page.locator(SEARCH_BUTTON_SELECTOR)
count = await btn.count()
if count == 1 and await btn.is_visible():
return btn
if count > 1:
logger.warning(
"search_button_ambiguous selector=%s count=%s",
SEARCH_BUTTON_SELECTOR,
count,
)
except Exception:
pass
return None
async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool:
"""有界等待搜索结果容器出现(kit poll_until;区分加载中与超时未就绪)。"""
async def _ready() -> bool:
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:
return False
return False
result = await poll_until(_ready, timeout_sec=timeout_sec, interval_sec=0.8)
return result.ok
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,146 @@
"""轻量任务编排示例(无真实数据库依赖)。"""
from __future__ import annotations
import asyncio
import uuid
from typing import Any, Optional
from service.account_client import (
AccountManagerError,
pick_web_account,
release_lease,
resolve_account_selectors_from_config,
)
from service.task_rpa import (
ScrapeRunResult,
format_stop_reason_for_user,
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 表)
- 使用 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:
cfg_account_id, cfg_login_id = resolve_account_selectors_from_config()
account = pick_web_account(
platform,
account_id=account_id or cfg_account_id,
login_id=cfg_login_id,
)
lease_token = account.get("lease_token")
scrape_result: ScrapeRunResult = await run_keyword_search_async(
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,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

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,152 @@
# Real Windows Desktop RPA Example
这是从已跑通业务技能 `D:\OpenClaw\client-marketing\add-friend-wecom` 提炼的 Windows UIA 参考案例。它用于教技术人员和 AI 编程工具如何从真实拾取结果得到稳定定位器,并完成窗口、控件、动作、等待和诊断闭环。
## 状态
- **技术路线已真实验证**:Windows UIA + Python `uiautomation`
- **来源业务已真实跑通**:企业微信添加客户(通讯录 → 新的客户 → 添加 → 搜索 → 发送)
- **本目录默认安全模式**:只提供 probe、dry-run、assisted 范式,不默认发送真实邀请
- **不是完整 skill 仓库**:按 Copy map 选择性复制
## 先读
1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md)
2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md)(含 Name/Value、最大化激活、同名叶子)
3. 本 README 的“原始拾取到生产定位器”
## 成功案例验证了什么
```text
枚举目标顶层窗口
→ 激活并最大化主窗(勿无条件 SW_RESTORE)
→ 从 HWND 获取 UIA root / 业务子 pane
→ 在父容器中定位(Name 与 Value 分开看)
→ 真实点击并校验后置状态
→ 等待子窗口或业务结果
→ 处理独立/嵌套弹窗
→ probe-ui 诊断
```
来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。
本目录 `probe()` 是**最小探针**(主窗 + 通讯录入口,可选尝试新的客户行),**不等于**生产技能完整 probe。
## 目录结构
```text
real_desktop_rpa/
README.md
assets/
element-map.sample.yaml
selector-capture.sample.xml
scripts/
service/
desktop_session.py
uia_locator.py
wecom_reference_flow.py
util/
selectors.py
tests/
test_selector_contract.py
```
## 原始拾取到生产定位器
### 搜索框(添加客户窗)
```text
原始:Window(SearchExternalsWnd) → 多层 Layout/index → search_layout → searchedit
提炼:窗口 → search_layout → Name=searchedit
```
### 新的客户(易错)
```text
原始:通讯录 pane → wlist_view → item[2] → status_layout → main_chattext
提炼:wlist_view 内找 子控件 main_chattext.Value=新的客户 的那一行,再点该行 status_layout
禁止:全树第一个 Name=status_layout(常为「群聊」)
禁止:把选择器标题「新的客户」当成 UIA Name 去搜
```
### 添加按钮
```text
提炼:search_external → wbtn_container(子文案 Value=添加)
禁止:搜索旁 plus_button 下的同名 wbtn_container
```
正确提炼方式:
1. 保留稳定顶层窗口:进程、Window Class、标题;需要时加上业务子 pane。
2. 保留最小业务父容器。
3. 叶子用稳定 Name **和/或** Value / 子控件 Value。
4. 删除只反映当前布局的深层 index;index 仅作已验证兜底并写清含义。
5. 验证父容器内候选数量为 `1`。
6. 重启应用后再次验证。
不要把完整 XML 路径或未校验的 `item[0]` 原样变成生产定位器。
## Element map
本目录的 [`assets/element-map.sample.yaml`](assets/element-map.sample.yaml) 只展示来源案例的元素登记方法。复制后必须重新拾取目标软件并全部替换。
关键规则:
- 窗口不唯一时失败,不取 `hwnds[0]`。
- 控件不唯一时失败,不取第一个匹配。
- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器,并用文案校验。
- 行顺序不是身份;用业务文案(Value)选择结果行。
- 主窗依赖完整侧栏时先 maximize;`activate(maximize=False)` 不得冲掉已最大化状态。
- 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。
## 三种实施模式
| 模式 | 行为 | 用途 |
|---|---|---|
| `probe` | 只检查依赖、窗口和关键控件 | 安装、自检、版本变更排障 |
| `dry-run` | 导航并填写,提交前停止 | 开发和验收默认 |
| `assisted` | 填写后等待用户人工确认 | 不可逆动作或现场联调 |
业务技能若增加 `auto`,必须实现明确确认策略、频率限制、业务后置状态和 `RESULT_UNCONFIRMED`。
## Copy map
| 来源 | 目标 | 说明 |
|---|---|---|
| `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活/最大化 |
| `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位(含 Value)、点击、输入 |
| `scripts/util/selectors.py` | `scripts/util/<platform>_selectors.py` | element spec;必须全部替换 |
| `scripts/service/wecom_reference_flow.py` | `scripts/service/<business>_flow.py` | 只复制流程分层,不复制企微 selector |
| `assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实拾取后填写 |
| `tests/test_selector_contract.py` | `tests/test_selector_contract.py` | 定位器契约测试思路 |
## 必须补齐的业务能力
- 目标应用安装/版本 doctor
- 真实业务元素清单
- 失败截图、UIA树和顶层窗口快照
- Activity/RpaVideoSession 步骤
- 不可逆动作确认
- 业务成功/失败/结果不明确状态
- 频率限制、幂等和断点续跑
- 真实集成测试(默认不进 CI)
## 禁止照抄
- `WXWork`、`WeWorkWindow`、`SearchExternalsWnd`
- `org_op_new`、`searchedit`、`wbtn_container`、`main_chattext`
- 企业微信列表第 0/1/2 行含义或固定 index
- 来源案例的本机路径、账号和业务数据
- 任何未经目标软件真实拾取验证的 fallback
## 验收
- `probe` 能明确报告依赖、窗口和关键元素(生产技能覆盖关键跳转节点)
- 每个关键元素在目标作用域内唯一
- 关闭重开软件后仍能定位
- 多窗口时不会误选
- UI变化时停止并保存截图/UIA树
- dry-run 不执行不可逆提交
- assisted 清楚提示用户确认
- 提交后仅在业务后置状态成立时报告成功

View File

@@ -0,0 +1,117 @@
source_case: add-friend-wecom
status: verified_source_redacted_example
surface: windows
driver: uiautomation
notes:
- 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label
- 禁止全树第一个 status_layout / wbtn_container
- 主窗流程先 maximize;activate 勿无条件 SW_RESTORE
elements:
- logical_name: main_window
purpose: 企业微信主窗口
scope:
process_name: WXWork
primary_locator:
window_class: WeWorkWindow
title: 企业微信
expected_count: 1
postcondition: UIA root 可读取;业务需要时已最大化
replace_in_new_skill: true
- logical_name: contacts_entry
purpose: 打开通讯录
scope:
logical_parent: main_window
primary_locator:
name: org_op_new
control_type: TreeItem
value_hint: 通讯录
expected_count: 1
postcondition: 出现企业微信-通讯录业务 pane
replace_in_new_skill: true
- logical_name: contacts_pane
purpose: 通讯录业务子层作用域
scope:
logical_parent: main_window
primary_locator:
name_contains: 通讯录
expected_count: 1
postcondition: 可枚举 wlist_view
replace_in_new_skill: true
- logical_name: new_customer_row
purpose: 侧栏进入「新的客户」
scope:
logical_parent: contacts_pane
ancestor_name: wlist_view
primary_locator:
row_leaf_name: status_layout
child_name: main_chattext
child_value: 新的客户
expected_count: 1
postcondition: panel_title_label Value=新的客户 或出现 search_external
failure_note: 禁止点全树第一个 status_layout(常为群聊)
replace_in_new_skill: true
- logical_name: panel_title
purpose: 校验已进入新的客户页
scope:
logical_parent: contacts_pane
primary_locator:
name: panel_title_label
value: 新的客户
expected_count: 1
replace_in_new_skill: true
- logical_name: add_customer_button
purpose: 打开添加客户窗口
scope:
logical_parent: contacts_pane
ancestor_name: search_external
primary_locator:
name: wbtn_container
child_name: ww_btn_text
child_value: 添加
expected_count: 1
postcondition: 出现 SearchExternalsWnd
failure_note: 禁止点搜索旁 plus_button 下的 wbtn_container
replace_in_new_skill: true
- logical_name: add_customer_window
purpose: 添加客户窗口
scope:
process_name: WXWork
primary_locator:
window_class: SearchExternalsWnd
title: 添加客户
expected_count: 1
postcondition: search_input 可定位
replace_in_new_skill: true
- logical_name: search_input
purpose: 输入待搜索账号
scope:
logical_parent: add_customer_window
ancestor_name: search_layout
primary_locator:
name: searchedit
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition: 结果列表或用户不存在弹窗出现
replace_in_new_skill: true
- logical_name: verify_message_input
purpose: 填写添加验证语
scope:
window_class: InputReasonWnd
title_any:
- 输入认证信息
- 发送添加邀请
primary_locator:
name: name_edit
expected_count: 1
postcondition: 输入值可读取或发送按钮可用
replace_in_new_skill: true

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
脱敏教学摘录:展示“拾取器长路径”而非可直接复制的生产 selector。
生产代码应保留稳定窗口、业务父容器和叶子属性,删除无业务含义的深层 index。
-->
<selector name="search_input" source="inspector-export">
<window process="WXWork" class="SearchExternalsWnd" title="添加客户" />
<uia name="content_container" index="1" />
<uia name="search_layout" index="0" />
<uia name="inner_search_layout" index="1" />
<uia name="searchedit" index="1" />
</selector>
<!--
新的客户:长路径示意 → 生产应改为
wlist_view 内 child main_chattext.Value=新的客户 的行,再点该行 status_layout。
禁止全树第一个 status_layout。
-->
<selector name="new_customer_row" source="inspector-export">
<window process="WXWork" class="WeWorkWindow" title="企业微信" />
<pane name="企业微信-通讯录" />
<uia name="wlist_view" />
<uia name="item" index="2" />
<uia name="status_layout" />
<uia name="main_chattext" value="新的客户" />
</selector>

View File

@@ -0,0 +1,129 @@
"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。"""
from __future__ import annotations
import ctypes
import ntpath
import time
from ctypes import wintypes
from dataclasses import dataclass
from typing import Iterable
from util.selectors import WindowSpec
class DesktopRpaError(RuntimeError):
def __init__(self, code: str, message: str) -> None:
super().__init__(f"ERROR:{code} {message}")
self.code = code
self.message = message
@dataclass(frozen=True)
class WindowCandidate:
hwnd: int
pid: int
process_name: str
class_name: str
title: str
visible: bool
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
SW_RESTORE = 9
SW_MAXIMIZE = 3
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
def _process_name(pid: int) -> str:
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return ""
try:
size = wintypes.DWORD(32768)
buffer = ctypes.create_unicode_buffer(size.value)
if not kernel32.QueryFullProcessImageNameW(
handle,
0,
buffer,
ctypes.byref(size),
):
return ""
return ntpath.splitext(ntpath.basename(buffer.value))[0]
finally:
kernel32.CloseHandle(handle)
def enumerate_top_windows() -> list[WindowCandidate]:
found: list[WindowCandidate] = []
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
def callback(hwnd, _lparam):
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
cls = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, cls, 256)
length = user32.GetWindowTextLengthW(hwnd)
title = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, title, length + 1)
found.append(
WindowCandidate(
hwnd=int(hwnd),
pid=int(pid.value),
process_name=_process_name(int(pid.value)),
class_name=cls.value,
title=title.value,
visible=bool(user32.IsWindowVisible(hwnd)),
)
)
return True
user32.EnumWindows(callback, 0)
return found
def resolve_unique_window(
spec: WindowSpec,
*,
candidates: Iterable[WindowCandidate] | None = None,
) -> WindowCandidate:
pool = list(candidates) if candidates is not None else enumerate_top_windows()
matched = [
item
for item in pool
if item.visible
and item.process_name.casefold() == spec.process_name.casefold()
and item.class_name == spec.class_name
and (spec.title is None or item.title == spec.title)
]
if not matched:
raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != 1:
raise DesktopRpaError(
"ELEMENT_AMBIGUOUS",
f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域",
)
return matched[0]
def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None:
"""激活窗口。
maximize=True:主窗进入依赖完整侧栏的流程时使用。
maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。
"""
hwnd = window.hwnd
iconic = bool(user32.IsIconic(hwnd))
zoomed = bool(user32.IsZoomed(hwnd))
if maximize:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not zoomed:
user32.ShowWindow(hwnd, SW_MAXIMIZE)
else:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not user32.SetForegroundWindow(hwnd):
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}")
time.sleep(0.35 if maximize else 0.2)

View File

@@ -0,0 +1,127 @@
"""作用域内 UIA 唯一定位、真实点击和逐字输入。"""
from __future__ import annotations
import random
from typing import Any, Iterator
from service.desktop_session import DesktopRpaError, WindowCandidate, activate_window
from util.selectors import ElementSpec
def require_uiautomation():
try:
import uiautomation as auto
except ImportError as exc:
raise DesktopRpaError("UIA_UNAVAILABLE", "缺少 uiautomation 依赖") from exc
return auto
def root_from_window(window: WindowCandidate):
auto = require_uiautomation()
return auto.ControlFromHandle(window.hwnd)
def control_value(control: Any) -> str:
try:
auto = require_uiautomation()
raw = control.GetPropertyValue(auto.PropertyId.ValueValueProperty)
return str(raw or "").strip()
except Exception:
return ""
def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
if depth > max_depth:
return
yield root
if depth == max_depth:
return
try:
children = root.GetChildren()
except Exception:
children = []
for child in children:
yield from _walk(child, depth=depth + 1, max_depth=max_depth)
def _child_has_value(control: Any, child_name: str, expected_value: str, *, max_depth: int = 10) -> bool:
for node in _walk(control, max_depth=max_depth):
if (getattr(node, "Name", None) or "") != child_name:
continue
if control_value(node) == expected_value:
return True
return False
def _matches(control: Any, spec: ElementSpec) -> bool:
if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name:
return False
if spec.value is not None and not spec.child_value_name:
if control_value(control) != spec.value:
return False
if spec.automation_id is not None and (
getattr(control, "AutomationId", None) or ""
) != spec.automation_id:
return False
if spec.control_type is not None and (
(getattr(control, "ControlTypeName", None) or "").removesuffix("Control")
!= spec.control_type.removesuffix("Control")
):
return False
if spec.class_name is not None and (
getattr(control, "ClassName", None) or ""
) != spec.class_name:
return False
if spec.child_value_name and spec.value:
if not _child_has_value(control, spec.child_value_name, spec.value):
return False
return True
def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
search_root = root
if spec.ancestor_name:
ancestor_spec = ElementSpec(
logical_name=f"{spec.logical_name}.ancestor",
name=spec.ancestor_name,
)
search_root = locate_unique(root, ancestor_spec, max_depth=max_depth)
matched = [
control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec)
]
if not matched:
raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != spec.expected_count:
raise DesktopRpaError(
"ELEMENT_AMBIGUOUS",
f"{spec.logical_name} 期望 {spec.expected_count} 个,实际 {len(matched)} 个",
)
return matched[0]
def click_control(
control: Any,
*,
window: WindowCandidate,
maximize: bool = False,
) -> None:
activate_window(window, maximize=maximize)
rect = control.BoundingRectangle
if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000:
raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效")
control.Click(
x=random.randint(-2, 2),
y=random.randint(-2, 2),
simulateMove=True,
)
def type_text(control: Any, text: str, *, window: WindowCandidate) -> None:
auto = require_uiautomation()
click_control(control, window=window, maximize=False)
auto.SendKeys("{Ctrl}a", waitTime=0.05)
auto.SendKeys("{Delete}", waitTime=0.05)
for char in text:
auto.SendKeys(char, waitTime=random.uniform(0.04, 0.12))

View File

@@ -0,0 +1,58 @@
"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。"""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from service.desktop_session import activate_window, resolve_unique_window
from service.uia_locator import locate_unique, root_from_window, type_text
from util.selectors import (
ADD_CUSTOMER_WINDOW,
CONTACTS_ENTRY,
MAIN_WINDOW,
NEW_CUSTOMER_ROW,
SEARCH_INPUT,
)
def probe() -> dict[str, Any]:
"""最小 probe:主窗 + 通讯录入口。
生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。
调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。
"""
main = resolve_unique_window(MAIN_WINDOW)
activate_window(main, maximize=True)
root = root_from_window(main)
contacts = locate_unique(root, CONTACTS_ENTRY)
payload: dict[str, Any] = {
"success": True,
"minimized_probe": True,
"main_window": asdict(main),
"contacts_entry": {
"name": getattr(contacts, "Name", ""),
"control_type": getattr(contacts, "ControlTypeName", ""),
},
}
# 可选:若已在通讯录页,尝试按 Value 定位「新的客户」行(失败不视为 probe 失败)
try:
row = locate_unique(root, NEW_CUSTOMER_ROW)
payload["new_customer_row"] = {"name": getattr(row, "Name", "")}
except Exception:
payload["new_customer_row"] = None
return payload
def fill_search_dry_run(target: str) -> dict[str, Any]:
"""要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。"""
search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW)
activate_window(search_window, maximize=False)
root = root_from_window(search_window)
search_input = locate_unique(root, SEARCH_INPUT)
type_text(search_input, target, window=search_window)
return {
"success": True,
"mode": "dry-run",
"message": "已填写搜索条件,未发送邀请",
}

View File

@@ -0,0 +1,63 @@
"""脱敏 UIA element specs;复制到新技能后必须用真实拾取结果替换。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class WindowSpec:
logical_name: str
process_name: str
class_name: str
title: Optional[str] = None
@dataclass(frozen=True)
class ElementSpec:
logical_name: str
name: Optional[str] = None
"""UIA Name(内部 id,未必是界面中文)。"""
value: Optional[str] = None
"""UIA Value(可见中文文案常在此)。"""
automation_id: Optional[str] = None
control_type: Optional[str] = None
class_name: Optional[str] = None
ancestor_name: Optional[str] = None
"""先定位到该 Name 的祖先,再在其下搜索。"""
child_value_name: Optional[str] = None
"""在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。"""
expected_count: int = 1
MAIN_WINDOW = WindowSpec("main_window", "WXWork", "WeWorkWindow", "企业微信")
ADD_CUSTOMER_WINDOW = WindowSpec(
"add_customer_window",
"WXWork",
"SearchExternalsWnd",
"添加客户",
)
CONTACTS_ENTRY = ElementSpec(
"contacts_entry",
name="org_op_new",
control_type="TreeItem",
)
# 示意:用子控件 main_chattext 的 Value 定位「新的客户」行(禁止全树第一个 status_layout)
NEW_CUSTOMER_ROW = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
ADD_BUTTON = ElementSpec(
"add_customer_button",
name="wbtn_container",
ancestor_name="search_external",
)
SEARCH_INPUT = ElementSpec(
"search_input",
name="searchedit",
ancestor_name="search_layout",
)

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from service.desktop_session import ( # noqa: E402
DesktopRpaError,
WindowCandidate,
activate_window,
resolve_unique_window,
)
from service import uia_locator # noqa: E402
from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402
class SelectorContractTest(unittest.TestCase):
def test_unique_window_is_required(self) -> None:
candidate = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True)
self.assertEqual(
resolve_unique_window(MAIN_WINDOW, candidates=[candidate]),
candidate,
)
def test_duplicate_windows_fail_instead_of_taking_first(self) -> None:
candidates = [
WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True),
WindowCandidate(2, 11, "WXWork", "WeWorkWindow", "企业微信", True),
]
with self.assertRaises(DesktopRpaError) as ctx:
resolve_unique_window(MAIN_WINDOW, candidates=candidates)
self.assertEqual(ctx.exception.code, "ELEMENT_AMBIGUOUS")
def test_hidden_window_is_not_a_candidate(self) -> None:
hidden = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", False)
with self.assertRaises(DesktopRpaError) as ctx:
resolve_unique_window(MAIN_WINDOW, candidates=[hidden])
self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND")
def test_activate_without_maximize_skips_restore_when_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = True
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=False)
user32.ShowWindow.assert_not_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_activate_maximize_when_not_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = False
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=True)
user32.ShowWindow.assert_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_locate_unique_matches_child_value(self) -> None:
row = MagicMock()
row.Name = "status_layout"
row.AutomationId = ""
row.ControlTypeName = "TreeControl"
row.ClassName = ""
label = MagicMock()
label.Name = "main_chattext"
label.GetChildren.return_value = []
row.GetChildren.return_value = [label]
root = MagicMock()
root.Name = "wlist_view"
root.AutomationId = ""
root.ControlTypeName = "TreeControl"
root.ClassName = ""
root.GetChildren.return_value = [row]
with patch.object(uia_locator, "control_value", side_effect=lambda c: "新的客户" if c is label else ""):
spec = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
# ancestor is root itself when searching from root with name wlist_view
found = uia_locator.locate_unique(root, ElementSpec("wlist", name="wlist_view"))
self.assertIs(found, root)
found_row = uia_locator.locate_unique(root, spec)
self.assertIs(found_row, row)
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,163 @@
# Simulator Browser RPA Example
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 **async Playwright** 完成门户门闩、两步登录、表单填写、PIN 弹窗、结果解析。
本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。
本示例只教学授权范围内的正常界面自动化;禁止把 sandbox 做法扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避真实平台限制的实现。
## 适合什么场景
- 自有 **行业仿真系统** / `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
- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态
## 目录结构
```
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 # 薄 adapter:pick / 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`→ensure-web / `release_lease`) |
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 |
## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致)
- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account`(ensure-web)拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0`
- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager
## 核心流程
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` | async;URL 仅 `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,355 @@
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
from typing import Any, Dict, List, Optional, Tuple
from jiangchang_skill_core import config
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
from util.logging import mask_text
logger = logging.getLogger(__name__)
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = (
f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
)
LEASE_BUSY_MESSAGE = (
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
)
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
DEFAULT_START_URL = "https://www.example.com/"
class AccountManagerError(Exception):
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 format_account_label(account: dict) -> str:
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
for key in ("login_id", "account_label", "label"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
for key in ("id", "account_id"):
v = account.get(key)
if v is not None and str(v).strip():
return str(v).strip()
return ""
def _is_placeholder_config(raw: str) -> bool:
s = (raw or "").strip()
if not s:
return True
if s.startswith("#"):
return True
low = s.lower()
if low in ("default-account", "none", "null", "n/a", "-"):
return True
return False
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
"""从配置解析 (account_id, login_id)。
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
"""
account_id: Optional[str] = None
login_id: Optional[str] = None
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
if not _is_placeholder_config(aid_raw):
if aid_raw.isdigit():
account_id = aid_raw
else:
login_id = aid_raw
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
if not _is_placeholder_config(lid_raw):
login_id = lid_raw
if account_id:
return account_id, None
return None, login_id
def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
"""
env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
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 not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
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", message or LEASE_BUSY_MESSAGE)
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
)
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload
def _ensure_web_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
) -> Dict[str, Any]:
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
lid = (login_id or "").strip() or None
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
argv = [
"account",
"ensure-web",
"--platform",
platform,
"--url",
start_url,
"--auth-strategy",
DEFAULT_AUTH_STRATEGY,
"--label",
label,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if lid:
argv.extend(["--login-id", lid])
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"ENSURE_WEB_FAILED",
(proc.stderr or "").strip() or "ensure-web 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
return _validate_pick_payload(payload)
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
return _validate_pick_payload(payload)
def _pick_by_id_with_lease(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。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
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。")
if str(data.get("status") or "active").lower() != "active":
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
try:
selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
platform: str,
account_id: Optional[str] = None,
login_id: Optional[str] = None,
) -> dict:
"""获取网页账号(profile_dir + lease_token)。
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
aid_raw = (account_id or "").strip()
if aid_raw and not _is_placeholder_config(aid_raw):
if not aid_raw.isdigit():
if not (login_id or "").strip():
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):
lid = None
try:
return _ensure_web_with_lease(platform_key, login_id=lid)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
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 薄 adapter:pick 账号、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_id,url={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 中还应:写业务表、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,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

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

@@ -0,0 +1,126 @@
# 待办事项 demo(todo-skill)
> 这是匠厂 `skill-template` 的**教学型 demo**,复刻模板的分层与 Action 契约,
> 自带一份最小 mock 平台库,可以脱离宿主在本机直接跑通。
> 真实业务技能**不要** vendor `jiangchang_skill_core`(见模板 `SKILL.md`)。
## 这个 demo 演示什么
- **四层架构** `cli → service → db → util`(与 [../../scripts/](../../scripts) 一致)
- **CLI 入口**:`scripts/main.py` 子命令分发(argparse + 中文错误)
- **业务表 + 数据管理元数据**:`todos` 表 + `_jiangchang_tables` / `_jiangchang_columns`
- **Action 契约**:[assets/actions.json](assets/actions.json) 覆盖 `toolbar`/`row`/`batch`/`cron`/`agent`/`skill-detail` 六个 placement
- **sync / async 双形态**:CRUD 是 sync(当场返回);`remind` 是 async(演示 `job_context` / `emit` / `finish` 任务流)
- **mock 平台库**:`scripts/_mock_platform/jiangchang_skill_core/`,在没有宿主时自动注入 sys.path
## 快速开始
```bash
# 用独立的数据目录跑(避免污染默认位置)
# Windows PowerShell
$env:JIANGCHANG_USER_ID="demo_test"
$env:JIANGCHANG_DATA_ROOT="$env:TEMP\jc_demo_run"
# 1. 初始化数据库(幂等创建 todos 表 + 展示元数据)
python examples/todo_skill/scripts/main.py init-db
# 2. 新增三条待办
python examples/todo_skill/scripts/main.py todo add "写文档" -p high
python examples/todo_skill/scripts/main.py todo add "回邮件" -p normal
python examples/todo_skill/scripts/main.py todo add "买咖啡" -p low
# 3. 列出 / 按状态过滤
python examples/todo_skill/scripts/main.py todo list
python examples/todo_skill/scripts/main.py todo list -s pending
# 4. 批量标记完成(演示逗号分隔 ids,对应 actions.json 的 batch 入口)
python examples/todo_skill/scripts/main.py todo done 1,2
# 5. 统计
python examples/todo_skill/scripts/main.py todo stats
# 6. async 提醒任务(演示 job_context/emit/finish 任务流)
python examples/todo_skill/scripts/main.py remind -s 2
# 7. 删除 / 健康检查 / 版本
python examples/todo_skill/scripts/main.py todo delete 3
python examples/todo_skill/scripts/main.py health
python examples/todo_skill/scripts/main.py version
python examples/todo_skill/scripts/main.py config-path
```
数据落在 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/todo-skill/todo-skill.db`,
不设环境变量时回退到 `{系统临时目录}/jiangchang_demo_cache/demo_user/todo-skill/`。
## 目录结构
```
examples/todo_skill/
├── README.md # 本文(用户说明)
├── SKILL.md # 技能入口(Agent / LLM 读,对齐模板 frontmatter)
├── assets/
│ └── actions.json # Action 清单(6 placements × sync/async)
└── scripts/
├── main.py # CLI 入口;含 mock 平台库回退注入
├── _mock_platform/ # mock jiangchang_skill_core(仅 demo 独立运行用)
│ └── jiangchang_skill_core/
│ ├── __init__.py
│ ├── runtime_env.py # 数据根 / 用户 ID / apply_cli_local_defaults
│ ├── config.py # .env 加载与合并
│ ├── activity.py # job_context / emit / finish
│ └── diagnostics.py # health 体检
├── cli/
│ └── app.py # argparse 子命令分发
├── service/
│ ├── task_service.py # 标准命令 + remind async 编排
│ └── todo_service.py # 待办 CRUD 业务内核(多入口复用)
├── db/
│ ├── connection.py # SQLite 连接 + init_db(todos 表 + 元数据)
│ └── display_metadata.py # _jiangchang_tables/_columns 元数据 DDL
└── util/
├── constants.py # SKILL_SLUG / SKILL_VERSION
├── runtime_paths.py # 数据目录与 DB 路径
├── argparse_zh.py # 中文 argparse 错误
├── logging_config.py # stdlib logging 薄封装
└── config_bootstrap.py # .env.example → 用户 .env 合并
```
## 与真实模板的差异
| 维度 | 真实模板 | 本 demo |
|------|----------|----------|
| `jiangchang_skill_core` 来源 | 宿主共享 Python runtime(`jiangchang-platform-kit`) | 自带 mock(`scripts/_mock_platform/`) |
| RPA / 视频录制 | `RpaVideoSession` 完整链路 | 未实现(remind 仅模拟进度,不开浏览器) |
| 任务中心 UI | 宿主「Run Journal」推送 + 进度面板 | stdout 打印 JSON Lines 模拟 |
| 鉴权 | `entitlement_service` 调宿主接口 | 完全省略 |
| HTML 报告 | `render-html` action + `html/*.html` | 未实现(教学范围外) |
| 日志 | `jiangchang_skill_core.unified_logging` 统一文件日志 | stdlib logging,仅 stdout |
## 三大契约在 demo 中的对应
### 1. CLI 契约([scripts/cli/app.py](scripts/cli/app.py))
固定子命令:`init-db` / `todo add|list|done|delete|stats` / `remind` / `health` / `version` / `config-path`。
### 2. Actions 契约([assets/actions.json](assets/actions.json))
| action id | executionProfile | placements | 说明 |
|-----------|------------------|------------|------|
| `health` / `version` | sync | skill-detail | 标准 |
| `todo-add` | sync | toolbar, agent, skill-detail | 表单新增 |
| `todo-list` | sync | skill-detail, agent | 只读查询 |
| `todo-done` | sync | row, batch, agent | 行级 / 批量,含 `confirmation` |
| `todo-delete` | sync | row, batch | 行级 / 批量删除 |
| `todo-stats` | sync | skill-detail, agent | 只读统计 |
| `remind` | **async** | toolbar, cron, agent, skill-detail | 演示任务中心入口 |
### 3. 数据管理元数据([scripts/db/connection.py](scripts/db/connection.py))
- 业务表:`todos`(id/title/status/priority/due_at/created_at/updated_at,字段顺序即列展示顺序)
- 元数据表:`_jiangchang_tables` + `_jiangchang_columns`
- `todos` 表 `readonly=1`:增删改通过技能 Action 走,宿主不直写
## 冒烟测试
```bash
cd examples/todo_skill
python -m unittest discover -s tests
```

View File

@@ -0,0 +1,49 @@
---
name: 待办事项 demo
description: "匠厂 skill-template 的教学型 demo:用待办事项场景演示分层架构、CLI 契约、Action manifest 与 sync/async 双形态。"
version: 0.1.0
author: demo
metadata:
openclaw:
slug: todo-skill
platform_kit_min_version: "1.2.11"
emoji: "✅"
category: "通用"
developer_ids:
- 1
allowed-tools:
- bash
---
# 待办事项 demo(todo-skill)
这是匠厂 [`skill-template`](../../SKILL.md) 的**教学型 demo**,用最简单的待办事项业务
复刻模板的所有关键模式。它**不是**真实业务技能,自带一份 mock 平台库以便脱离宿主运行。
## 关键模式映射
| 模板铁律 | 本 demo 落地 |
|----------|-------------|
| `cli → service → db → util` 四层 | [scripts/](scripts/) 完整复刻同结构 |
| 单业务内核、多入口 | `service/todo_service.py` 是唯一 CRUD 内核,CLI/Action/数据管理按钮都调它 |
| CLI 固定入口 | [scripts/main.py](scripts/main.py) + `cli/app.py` argparse 分发 |
| Action manifest 正交字段 | [assets/actions.json](assets/actions.json) 覆盖 6 placements × sync/async |
| 数据管理元数据 | [scripts/db/connection.py](scripts/db/connection.py) 建 `todos` + `_jiangchang_*` |
| 长任务 async 范式 | [service/task_service.py](scripts/service/task_service.py) `cmd_remind` 用 `job_context`/`emit`/`finish` |
## Agent 触发路由表(demo)
| 用户意图 | Agent 怎么做 | 进任务中心? |
|----------|--------------|--------------|
| 看版本 / 检查环境 | bash + 共享 Python `version` / `health` | 否 |
| 列出 / 统计 / 新增 / 完成 / 删除 | bash + `todo list/stats/add/done/delete` | 否(sync) |
| 扫描并提醒 | `run_skill_action` → `remind`(async) | **是** |
## 独立运行
详见 [README.md](README.md)。设 `JIANGCHANG_DATA_ROOT` + `JIANGCHANG_USER_ID` 后可直接 `python scripts/main.py <cmd>` 跑通。
## 与真实模板的差异
本 demo 自带 `_mock_platform/jiangchang_skill_core`,**真实技能不得 vendor 平台库**。
其它省略项:RPA/视频、鉴权、HTML 报告、统一文件日志(详见 README)。

View File

@@ -0,0 +1,189 @@
{
"schemaVersion": 1,
"skill": "todo-skill",
"actions": [
{
"id": "health",
"label": "运行检查",
"description": "检查 demo 运行环境(Python / mock 平台库 / 数据库路径),不启动浏览器。",
"placements": ["skill-detail", "agent"],
"executionProfile": "sync",
"entrypoint": {
"type": "cli",
"command": "health",
"args": []
}
},
{
"id": "version",
"label": "查看版本",
"description": "返回 demo 版本与 slug。",
"placements": ["skill-detail"],
"executionProfile": "sync",
"entrypoint": {
"type": "cli",
"command": "version",
"args": []
}
},
{
"id": "todo-add",
"label": "新增待办",
"description": "在 todos 表新增一条待办事项(走技能 CLI,不直接写库)。",
"placements": ["toolbar", "agent", "skill-detail"],
"executionProfile": "sync",
"interaction": "form",
"bind": { "tables": ["todos"] },
"hostEffects": { "onSuccess": ["reloadTable"] },
"inputSchema": {
"type": "object",
"required": ["title"],
"properties": {
"title": {
"type": "string",
"title": "标题",
"description": "待办事项内容"
},
"priority": {
"type": "string",
"title": "优先级",
"enum": ["high", "normal", "low"],
"default": "normal"
},
"dueAt": {
"type": "number",
"title": "截止时间",
"description": "Unix 秒;可空",
"display_type": "datetime_unix_seconds"
}
}
},
"entrypoint": {
"type": "cli",
"command": "todo",
"args": ["add", "{{title}}", "--priority", "{{priority}}", "--due-at", "{{dueAt}}"]
}
},
{
"id": "todo-list",
"label": "查看列表",
"description": "列出待办事项(可选按状态过滤),返回 JSON。",
"placements": ["skill-detail", "agent"],
"executionProfile": "sync",
"inputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"title": "状态过滤",
"enum": ["pending", "done"],
"description": "留空则返回全部"
},
"limit": {
"type": "number",
"title": "返回数量",
"default": 100
}
}
},
"entrypoint": {
"type": "cli",
"command": "todo",
"args": ["list", "--status", "{{status}}", "--limit", "{{limit}}"]
}
},
{
"id": "todo-done",
"label": "标记完成",
"description": "将当前行待办标记为完成。",
"placements": ["row", "batch", "agent"],
"executionProfile": "sync",
"interaction": "confirm",
"confirmation": { "message": "将所选待办标记为完成?" },
"bind": { "tables": ["todos"] },
"hostEffects": { "onSuccess": ["reloadTable", "clearSelection"] },
"inputSchema": {
"type": "object",
"required": ["ids"],
"properties": {
"ids": {
"type": "string",
"title": "待办编号",
"description": "逗号分隔的待办 id(batch 入口由勾选映射)",
"readOnly": true
}
}
},
"entrypoint": {
"type": "cli",
"command": "todo",
"args": ["done", "{{ids}}"]
}
},
{
"id": "todo-delete",
"label": "删除待办",
"description": "删除当前行待办(技能级删除,会更新宿主表格)。",
"placements": ["row", "batch"],
"executionProfile": "sync",
"interaction": "confirm",
"confirmation": { "message": "确认删除所选待办?此操作不可撤销。" },
"bind": { "tables": ["todos"] },
"hostEffects": { "onSuccess": ["reloadTable", "clearSelection"] },
"inputSchema": {
"type": "object",
"required": ["ids"],
"properties": {
"ids": {
"type": "string",
"title": "待办编号",
"description": "逗号分隔的待办 id(batch 入口由勾选映射)",
"readOnly": true
}
}
},
"entrypoint": {
"type": "cli",
"command": "todo",
"args": ["delete", "{{ids}}"]
}
},
{
"id": "todo-stats",
"label": "查看统计",
"description": "返回待办事项简单统计(总数 / 待办 / 完成)。",
"placements": ["skill-detail", "agent"],
"executionProfile": "sync",
"entrypoint": {
"type": "cli",
"command": "todo",
"args": ["stats"]
}
},
{
"id": "remind",
"label": "提醒扫描",
"description": "演示 async 任务:扫描待办事项并模拟发出提醒。进入任务中心,支持进度与取消。",
"placements": ["toolbar", "cron", "agent", "skill-detail"],
"executionProfile": "async",
"bind": { "tables": ["todos"] },
"inputSchema": {
"type": "object",
"properties": {
"seconds": {
"type": "number",
"title": "运行秒数",
"description": "模拟任务持续秒数,用于演示进度推送",
"default": 3,
"minimum": 1
}
}
},
"entrypoint": {
"type": "cli",
"command": "remind",
"args": ["--seconds", "{{seconds}}"]
}
}
]
}

View File

@@ -0,0 +1,14 @@
"""jiangchang_skill_core mock 包(仅供 demo 独立运行使用)。
真实环境中本包来自宿主共享 Python runtime 的 ``jiangchang-platform-kit``,
不在技能仓库内 vendored。本 mock 只为让 demo 脱离宿主也能跑通,行为不
等价于真实平台库。
"""
from __future__ import annotations
from . import config as config # noqa: F401
from . import runtime_env as runtime_env # noqa: F401
from .activity import JobStopped, emit, finish, job_context # noqa: F401
from .diagnostics import collect_runtime_diagnostics, format_runtime_health_lines # noqa: F401
__version__ = "1.2.11-mock"

View File

@@ -0,0 +1,61 @@
"""activity mock:job_context / emit / finish 任务中心契约的最小实现。
真实平台 ``emit``/``finish`` 会写入 Run Journal(``{DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl``)
并推送到任务中心 UI;本 mock 只把进度打印到 stdout,便于在终端看到任务流。
"""
from __future__ import annotations
import json
import sys
import time
import uuid
from contextlib import contextmanager
from typing import Any, Iterator
class JobStopped(Exception):
"""用户/宿主主动停止任务时抛出。真实平台由心跳检查触发;demo 不会自动抛。"""
def _emit_record(payload: dict[str, Any]) -> None:
"""统一以 JSON Lines 打印到 stdout,模拟 Run Journal 一行。"""
line = json.dumps(payload, ensure_ascii=False, default=str)
print(f"[activity] {line}", flush=True)
def emit(message: str, *, type: str = "info", skill: str = "", **fields: Any) -> None:
"""报告进度事件。"""
_emit_record({
"ts": int(time.time()),
"event": "emit",
"type": type,
"skill": skill,
"message": message,
**fields,
})
def finish(*, status: str, message: str = "", skill: str = "", **fields: Any) -> None:
"""报告任务终态。``status`` ∈ ``ok``/``failed``/``stopped``。"""
_emit_record({
"ts": int(time.time()),
"event": "finish",
"status": status,
"skill": skill,
"message": message,
**fields,
})
# 真实平台 finish 后任务中心记录终态;mock 额外在 stderr 提示一句。
print(f"[activity] task_finished status={status} message={message or '(empty)'}", file=sys.stderr)
@contextmanager
def job_context(*, skill: str) -> Iterator[dict[str, Any]]:
"""任务上下文:开始时生成 jobId,结束时打印边界。"""
job_id = uuid.uuid4().hex
ctx = {"skill": skill, "job_id": job_id}
_emit_record({"ts": int(time.time()), "event": "job_start", "skill": skill, "job_id": job_id})
try:
yield ctx
finally:
_emit_record({"ts": int(time.time()), "event": "job_end", "skill": skill, "job_id": job_id})

View File

@@ -0,0 +1,89 @@
"""config mock:只读环境变量 + 简单 .env 合并。
真实平台有更完整的多层配置与缓存;本 mock 仅演示优先级:
进程环境变量 > 用户 .env > .env.example。
"""
from __future__ import annotations
import os
from typing import Optional
_cache: dict[str, str] = {}
_env_path: Optional[str] = None
def _load_env_file(path: str) -> dict[str, str]:
if not path or not os.path.isfile(path):
return {}
data: dict[str, str] = {}
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
data[key.strip()] = _strip_comment(value.strip())
return data
def _strip_comment(value: str) -> str:
"""去掉行尾 `` # 注释``(保持简单,不处理引号内 #)。"""
if " #" in value:
value = value.split(" #", 1)[0].rstrip()
return value
def get(key: str, default: Optional[str] = None) -> Optional[str]:
"""读配置:进程环境变量优先,其次已加载的 .env。"""
if key in os.environ:
return os.environ[key]
if key in _cache:
return _cache[key]
return default
def get_env_file_path() -> Optional[str]:
return _env_path
def ensure_env_file(skill_slug: str, example_path: str) -> str:
"""确保用户 .env 存在,缺失时从 example 复制。"""
global _env_path
from .runtime_env import get_data_root, get_user_id
user_env_dir = os.path.join(get_data_root(), get_user_id(), skill_slug)
os.makedirs(user_env_dir, exist_ok=True)
path = os.path.join(user_env_dir, ".env")
if not os.path.isfile(path) and os.path.isfile(example_path):
with open(example_path, encoding="utf-8") as src:
with open(path, "w", encoding="utf-8") as dst:
dst.write(src.read())
_env_path = path
_refresh_cache(path)
return path
def merge_missing_env_keys(example_path: str, env_path: str, comment_skill: Optional[str] = None) -> None:
"""把 example 中存在但 .env 缺失的键追加到 .env 末尾(带版本注释)。"""
if not os.path.isfile(example_path) or not os.path.isfile(env_path):
return
existing = _load_env_file(env_path)
example = _load_env_file(example_path)
missing = {k: v for k, v in example.items() if k not in existing}
if not missing:
return
with open(env_path, "a", encoding="utf-8") as f:
if comment_skill:
f.write(f"\n# === merged by {comment_skill} ===\n")
for k, v in missing.items():
f.write(f"{k}={v}\n")
_refresh_cache(env_path)
def reset_cache() -> None:
_cache.clear()
def _refresh_cache(path: str) -> None:
_cache.clear()
_cache.update(_load_env_file(path))

View File

@@ -0,0 +1,89 @@
"""runtime diagnostics mock:``health`` 命令的只读环境体检。"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from typing import Any
from . import config
from .runtime_env import get_data_root, get_user_id
# 避免循环导入:与 __init__.py 的 __version__ 保持同步
_CORE_VERSION = "1.2.11-mock"
@dataclass
class RuntimeDiagnostics:
skill_slug: str
python_executable: str
platform_kit_version: str
platform_kit_min_version: str
platform_kit_version_ok: bool
jiangchang_skill_core_file: str
data_root: str
user_id: str
env_path: str
env_exists: bool
has_fatal_issues: bool = False
issues: list[dict[str, str]] = field(default_factory=list)
def collect_runtime_diagnostics(
*,
skill_slug: str,
platform_kit_min_version: str,
skill_root: str,
) -> RuntimeDiagnostics:
"""收集只读运行时诊断。致命问题(缺 python、版本不符等)置 has_fatal_issues。"""
issues: list[dict[str, str]] = []
# 版本检查(mock 总是自报合格,便于 demo 跑通)
version_ok = True
if platform_kit_min_version and _CORE_VERSION < platform_kit_min_version:
version_ok = False
issues.append({
"level": "error",
"code": "PLATFORM_KIT_VERSION_TOO_LOW",
"message": f"jiangchang_skill_core {_CORE_VERSION} < 要求 {platform_kit_min_version}",
})
env_path = config.get_env_file_path() or ""
env_exists = bool(env_path and os.path.isfile(env_path))
if not env_exists:
issues.append({
"level": "warning",
"code": "ENV_FILE_MISSING",
"message": f"用户 .env 不存在:{env_path or '(未设置)'}",
})
return RuntimeDiagnostics(
skill_slug=skill_slug,
python_executable=sys.executable,
platform_kit_version=_CORE_VERSION,
platform_kit_min_version=platform_kit_min_version,
platform_kit_version_ok=version_ok,
jiangchang_skill_core_file=__file__,
data_root=get_data_root(),
user_id=get_user_id(),
env_path=env_path,
env_exists=env_exists,
has_fatal_issues=any(i["level"] == "error" for i in issues),
issues=issues,
)
def format_runtime_health_lines(runtime: RuntimeDiagnostics) -> list[str]:
"""格式化为可打印行,对齐真实 ``health`` 输出风格。"""
lines = [
f"python_executable: {runtime.python_executable}",
f"platform_kit_version: {runtime.platform_kit_version}",
f"platform_kit_min_version: {runtime.platform_kit_min_version}",
f"platform_kit_version_ok: {runtime.platform_kit_version_ok}",
f"jiangchang_skill_core_file: {runtime.jiangchang_skill_core_file}",
f"data_root: {runtime.data_root}",
f"user_id: {runtime.user_id}",
]
for issue in runtime.issues:
lines.append(f"runtime_issue[{issue['level']}]: {issue['code']} - {issue['message']}")
return lines

View File

@@ -0,0 +1,34 @@
"""runtime_env mock:解析数据根目录与用户 ID。
真实平台用宿主注入的环境变量;本 mock 在没有宿主时用本地临时目录兜底。
"""
from __future__ import annotations
import os
import tempfile
_DEFAULT_USER_ID = "demo_user"
def get_data_root() -> str:
"""数据根目录:真实平台来自 ``JIANGCHANG_DATA_ROOT``,demo 用本地缓存目录。"""
root = os.environ.get("JIANGCHANG_DATA_ROOT")
if root:
return root
cache = os.path.join(tempfile.gettempdir(), "jiangchang_demo_cache")
os.makedirs(cache, exist_ok=True)
return cache
def get_user_id() -> str:
return os.environ.get("JIANGCHANG_USER_ID") or _DEFAULT_USER_ID
def get_sibling_skills_root(scripts_dir: str) -> str:
"""同级技能根:父目录的父目录(即 ``examples/`` 之类)。"""
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(scripts_dir))))
def apply_cli_local_defaults() -> None:
"""CLI 本地默认配置:真实平台会调整 PATH/VIRTUAL_ENV 等;mock 仅占位。"""
return None

View File

@@ -0,0 +1,128 @@
"""CLI:argparse 解析与分发(业务内核在 service,本层只做参数→service 映射)。"""
from __future__ import annotations
import argparse
import sys
from typing import List, Optional
from service.task_service import (
cmd_add,
cmd_config_path,
cmd_delete,
cmd_done,
cmd_health,
cmd_init_db,
cmd_list,
cmd_remind,
cmd_stats,
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 _parse_ids(raw: str) -> list[int]:
"""解析 ``1`` 或 ``1,2,3`` 为 int 列表。"""
parts = [p.strip() for p in (raw or "").split(",") if p.strip()]
try:
return [int(p) for p in parts]
except ValueError as exc:
raise SystemExit(f"参数错误:ids 必须是数字或逗号分隔的数字列表,收到 {raw!r}\n{exc}")
def _print_full_usage() -> None:
print("待办事项 demo(main.py)可用命令:")
print(" python main.py init-db # 初始化数据库与元数据")
print(" python main.py todo add <title> [-p high|normal|low] [-d <unix秒>")
print(" python main.py todo list [-s pending|done] [-n <limit>]")
print(" python main.py todo done <id>")
print(" python main.py todo delete <id>")
print(" python main.py todo stats")
print(" python main.py remind [-s <秒>]")
print(" python main.py health / version / config-path")
def _add_todo_subparser(sub) -> None:
sp = sub.add_parser("todo", help="待办事项 CRUD")
todo_sub = sp.add_subparsers(dest="todo_cmd", required=True, parser_class=ZhArgumentParser)
p = todo_sub.add_parser("add", help="新增一条待办")
p.add_argument("title", help="待办标题(必填)")
p.add_argument("-p", "--priority", default="normal",
choices=["high", "normal", "low"], help="优先级,默认 normal")
p.add_argument("-d", "--due-at", type=int, default=None,
help="截止时间(Unix 秒,可选)")
p.set_defaults(handler=lambda a: cmd_add(
title=a.title, priority=a.priority, due_at=a.due_at))
p = todo_sub.add_parser("list", help="列出待办")
p.add_argument("-s", "--status", default=None,
choices=["pending", "done"], help="按状态过滤")
p.add_argument("-n", "--limit", type=int, default=100, help="返回数量上限")
p.set_defaults(handler=lambda a: cmd_list(status=a.status, limit=a.limit))
p = todo_sub.add_parser("done", help="标记完成(可传逗号分隔的多个 id)")
p.add_argument("ids", help="待办 id 或逗号分隔的 id 列表(如 1 或 1,2,3)")
p.set_defaults(handler=lambda a: cmd_done(todo_ids=_parse_ids(a.ids)))
p = todo_sub.add_parser("delete", help="删除(可传逗号分隔的多个 id)")
p.add_argument("ids", help="待办 id 或逗号分隔的 id 列表")
p.set_defaults(handler=lambda a: cmd_delete(todo_ids=_parse_ids(a.ids)))
p = todo_sub.add_parser("stats", help="简单统计")
p.set_defaults(handler=lambda _a: cmd_stats())
def _add_remind_subparser(sub) -> None:
sp = sub.add_parser("remind", help="演示 async 提醒任务(job_context/emit/finish)")
sp.add_argument("-s", "--seconds", type=int, default=3,
help="模拟运行秒数,默认 3")
sp.set_defaults(handler=lambda a: cmd_remind(seconds=a.seconds))
def build_parser() -> ZhArgumentParser:
p = ZhArgumentParser(
prog="main.py",
description="待办事项 demo:演示匠厂 skill-template 的分层与 Action 契约。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
sp = sub.add_parser("init-db", help="幂等初始化 SQLite 与数据管理元数据")
sp.set_defaults(handler=lambda _a: cmd_init_db())
_add_todo_subparser(sub)
_add_remind_subparser(sub)
sp = sub.add_parser("health", help="健康检查")
sp.set_defaults(handler=lambda _a: cmd_health())
sp = sub.add_parser("version", help="版本信息(JSON)")
sp.set_defaults(handler=lambda _a: cmd_version())
sp = sub.add_parser("config-path", help="输出 .env 与模板路径")
sp.set_defaults(handler=lambda _a: cmd_config_path())
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
parser = build_parser()
args = parser.parse_args(argv)
return int(args.handler(args))

View File

@@ -0,0 +1,96 @@
"""SQLite 连接与幂等初始化。
``init_db`` 建两张表:
1. ``todos`` — 业务表(demo:待办事项)
2. ``_jiangchang_tables`` / ``_jiangchang_columns`` — 宿主数据管理展示元数据
业务表字段顺序就是宿主列展示顺序(cid 顺序)。
"""
from __future__ import annotations
import sqlite3
from db.display_metadata import (
init_display_metadata_tables,
seed_column_metadata_batch,
upsert_table_metadata,
)
from util.runtime_paths import get_db_path
# ---- 业务表 DDL(待办事项)----
# 字段顺序 = 数据管理列展示顺序;状态/优先级用 options_json 给宿主做下拉筛选
_TODOS_DDL = """
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'normal',
due_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
_TODOS_INDEX = """
CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status)
"""
def get_conn() -> sqlite3.Connection:
return sqlite3.connect(get_db_path())
def _seed_todos_display_metadata(cursor) -> None:
"""写 ``todos`` 表与字段的中文展示元数据。"""
upsert_table_metadata(
cursor,
table_name="todos",
display_name="待办事项",
description="本技能的核心业务表:每条待办事项一行。",
grid_help="状态变更走技能 CLI(todo done / todo delete),不要在表里直接改。",
visible=1,
readonly=1, # 整表只读:增删改通过技能 Action 走,宿主不直写
default_sort_json='[{"column":"created_at","direction":"desc"}]',
default_visible_columns_json='["title","status","priority","due_at","created_at"]',
)
seed_column_metadata_batch(
cursor,
table_name="todos",
columns=[
{"column_name": "id", "display_name": "编号", "description": "主键",
"visible": 1, "searchable": 1, "editable": 0, "display_type": "number"},
{"column_name": "title", "display_name": "标题", "description": "待办事项内容",
"grid_help": "简明描述要做的事", "visible": 1, "searchable": 1, "editable": 0,
"display_type": "text"},
{"column_name": "status", "display_name": "状态", "description": "pending/done",
"grid_help": "pending=待办,done=完成", "visible": 1, "searchable": 1, "editable": 0,
"display_type": "enum",
"options_json": '[{"value":"pending","label":"待办"},{"value":"done","label":"完成"}]'},
{"column_name": "priority", "display_name": "优先级", "description": "high/normal/low",
"visible": 1, "searchable": 1, "editable": 0, "display_type": "enum",
"options_json": '[{"value":"high","label":"高"},{"value":"normal","label":"中"},{"value":"low","label":"低"}]'},
{"column_name": "due_at", "display_name": "截止时间", "description": "Unix 秒;可空",
"visible": 1, "searchable": 1, "editable": 0, "display_type": "datetime_unix_seconds"},
{"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": 0, "searchable": 0, "editable": 0, "display_type": "datetime_unix_seconds"},
],
)
def init_db() -> None:
"""幂等创建/迁移本地 SQLite 与数据管理元数据。"""
conn = get_conn()
try:
cur = conn.cursor()
# 1. 元数据表(先建,方便其它步骤 upsert)
init_display_metadata_tables(cur)
# 2. 业务表
cur.execute(_TODOS_DDL)
cur.execute(_TODOS_INDEX)
# 3. 业务表展示元数据
_seed_todos_display_metadata(cur)
conn.commit()
finally:
conn.close()

View File

@@ -0,0 +1,160 @@
"""匠厂数据管理展示元数据(``_jiangchang_tables`` / ``_jiangchang_columns``)。
SQLite 没有原生表/字段 COMMENT,宿主「数据管理」页面靠这两张元数据表
拿到中文表名/列名/可编辑性等。字段顺序由 ``CREATE TABLE`` + ``PRAGMA table_info``
的 ``cid`` 决定,不读 ``display_order``。
本文件是模板 ``scripts/db/display_metadata.py`` 的最小可用子集:
- ``init_display_metadata_tables``:建元数据表
- ``upsert_table_metadata`` / ``upsert_column_metadata``:写入展示元数据
- ``list_physical_column_names``:按 cid 列出物理字段名
"""
from __future__ import annotations
from typing import Iterable
# 宿主隐藏这些表,不作为业务表展示
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
_JIANGCHANG_TABLES_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
grid_help TEXT,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT,
default_visible_columns_json TEXT
)
"""
_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,
grid_help 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)
)
"""
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,
grid_help: str | None = None,
visible: int = 1,
readonly: int = 0,
default_sort_json: str | None = None,
default_visible_columns_json: str | None = None,
) -> None:
cursor.execute(
"""
INSERT INTO _jiangchang_tables (
table_name, display_name, description, grid_help, visible, readonly,
default_sort_json, default_visible_columns_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
grid_help = excluded.grid_help,
visible = excluded.visible,
readonly = excluded.readonly,
default_sort_json = excluded.default_sort_json,
default_visible_columns_json = excluded.default_visible_columns_json
""",
(
table_name, display_name, description, grid_help, visible, readonly,
default_sort_json, default_visible_columns_json,
),
)
def upsert_column_metadata(
cursor,
*,
table_name: str,
column_name: str,
display_name: str,
description: str | None = None,
grid_help: str | None = None,
visible: int = 1,
searchable: int = 1,
editable: int = 1,
display_type: str | None = None,
options_json: str | None = None,
) -> None:
cursor.execute(
"""
INSERT INTO _jiangchang_columns (
table_name, column_name, display_name, description, grid_help,
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,
grid_help = excluded.grid_help,
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, grid_help,
visible, searchable, editable, display_type, options_json,
),
)
def seed_column_metadata_batch(
cursor,
*,
table_name: str,
columns: Iterable[dict[str, object]],
) -> None:
"""批量 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,
grid_help=str(column["grid_help"]) if column.get("grid_help") 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,
)
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,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""待办事项 demo CLI 入口(结构对齐 skill-template 的 main.py)。
分层:cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。
独立运行说明:
真实技能依赖宿主共享 Python runtime 的 ``jiangchang_skill_core``。
本 demo 自带一份最小 mock(``scripts/_mock_platform/``),在没有宿主时
由本文件自动注入 sys.path,使 demo 在任意 Python 3.10+ 环境可直接跑通。
设置环境变量 ``JIANGCHANG_DATA_ROOT`` 即可切到真实平台 runtime 模式。
"""
from __future__ import annotations
import os
import sys
# Windows 终端 UTF-8(对齐模板)
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)
# 没有真实平台库时启用 mock(demo 自带,便于脱离宿主独立运行);
# 设环境变量 ``TODO_SKILL_USE_REAL_PLATFORM=1`` 可强制走真实 runtime。
if not os.environ.get("TODO_SKILL_USE_REAL_PLATFORM"):
try:
import jiangchang_skill_core # noqa: F401
except ImportError:
_mock_platform = os.path.join(_scripts_dir, "_mock_platform")
if _mock_platform not in sys.path:
sys.path.insert(0, _mock_platform)
from jiangchang_skill_core.runtime_env import apply_cli_local_defaults # noqa: E402
apply_cli_local_defaults()
from util.config_bootstrap import bootstrap_skill_config # noqa: E402
bootstrap_skill_config()
from cli.app import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,189 @@
"""任务编排层(对齐模板 task_service.py 的职责分工)。
- ``cmd_health`` / ``cmd_version`` / ``cmd_config_path`` / ``cmd_init_db``:标准 CLI;
- ``cmd_remind``:演示 async 任务范式(``job_context`` + ``emit`` + ``finish``),
真实平台会推送到任务中心 UI;本 demo 在 stdout 打印 JSON Lines 模拟。
业务内核(CRUD)在 ``todo_service``,本模块只做编排,不复制业务逻辑。
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from typing import Any, 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.connection import init_db
from service import todo_service
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_db_path, get_skill_root
def _get_task_logger():
try:
return get_skill_logger()
except RuntimeError:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
return get_skill_logger()
# ---------------- 标准命令(sync,当场返回) ----------------
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)}",
f"db_path: {get_db_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
def cmd_init_db() -> int:
"""幂等创建本地 SQLite 与数据管理元数据。"""
try:
init_db()
except Exception as exc: # CLI 边界统一成可机读失败
print(json.dumps(
{"ok": False, "skill": SKILL_SLUG, "error": str(exc)},
ensure_ascii=False,
))
return 1
print(json.dumps(
{"ok": True, "skill": SKILL_SLUG, "db_path": get_db_path()},
ensure_ascii=False,
))
return 0
# ---------------- 业务命令(sync) ----------------
def cmd_list(*, status: Optional[str] = None, limit: int = 100) -> int:
todos = todo_service.list_todos(status=status, limit=limit)
print(json.dumps(
{"count": len(todos), "items": [t.to_dict() for t in todos]},
ensure_ascii=False,
))
return 0
def cmd_add(*, title: str, priority: str = "normal", due_at: Optional[int] = None) -> int:
try:
todo = todo_service.add_todo(title=title, priority=priority, due_at=due_at)
except ValueError as exc:
print(f"ERROR:TODO_INVALID: {exc}")
return 1
print(json.dumps(todo.to_dict(), ensure_ascii=False))
return 0
def cmd_done(*, todo_ids: list[int]) -> int:
"""批量标记完成。返回 JSON 列表,每项含 ok/id/error。"""
results: list[dict] = []
for tid in todo_ids:
try:
todo = todo_service.mark_done(tid)
results.append({"ok": True, "id": tid, "todo": todo.to_dict()})
except ValueError as exc:
results.append({"ok": False, "id": tid, "error": str(exc)})
print(json.dumps({"count": len(results), "items": results}, ensure_ascii=False))
return 0 if all(r["ok"] for r in results) else 1
def cmd_delete(*, todo_ids: list[int]) -> int:
"""批量删除。"""
results: list[dict] = []
for tid in todo_ids:
deleted = todo_service.delete_todo(tid)
results.append({"ok": deleted, "id": tid})
print(json.dumps({"count": len(results), "items": results}, ensure_ascii=False))
return 0 if all(r["ok"] for r in results) else 1
def cmd_stats() -> int:
print(json.dumps(todo_service.stats(), ensure_ascii=False))
return 0
# ---------------- async 任务范式(demo) ----------------
async def _run_remind_async(*, seconds: int) -> dict[str, Any]:
"""模拟长耗时任务:每秒 emit 一次进度,最后 finish 终态。
真实平台会推送到任务中心 UI 并支持取消;本 demo 在 stdout 打印 JSON Lines。
"""
steps = max(1, int(seconds))
for i in range(1, steps + 1):
await asyncio.sleep(1)
emit(f"提醒任务进度 {i}/{steps}", skill=SKILL_SLUG, stage="remind", progress=i, total=steps)
# 真实业务在这里会查 todos 表挑出 due/pending 项发出提醒;demo 不做真实推送
summary = todo_service.stats()
return {"remind": True, "duration_seconds": steps, "stats": summary}
def cmd_remind(*, seconds: int = 3) -> int:
"""演示 async 任务编排。CLI 入口仍走同一内核;Agent 真实调度时
通过 ``run_skill_action``(executionProfile: async)触发。"""
log = _get_task_logger()
task_type = "remind"
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=%s stage=remind seconds=%s", task_type, seconds)
emit("开始执行提醒任务", skill=SKILL_SLUG, stage="remind")
try:
result = asyncio.run(_run_remind_async(seconds=seconds))
log.info("task_done task_type=%s status=ok", task_type)
finish(
status="ok",
message=f"提醒任务完成,处理 {seconds} 秒",
skill=SKILL_SLUG,
stage="remind",
**result,
)
print(json.dumps({"ok": True, "task_type": task_type, **result}, ensure_ascii=False))
return 0
except JobStopped:
raise
except Exception:
log.exception("task_failed task_type=%s", task_type)
emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="remind")
finish(
status="failed",
message="提醒任务执行异常,详见统一日志",
skill=SKILL_SLUG,
stage="remind",
)
return 1

View File

@@ -0,0 +1,167 @@
"""待办事项领域服务(业务内核)。
按模板铁律:「单业务内核、多入口」——CLI / Action / 数据管理按钮 / 定时任务
都调用这里的同一个函数,禁止按入口分叉业务行为。
直接用 sqlite3 做持久化(demo 简化,未抽 repository)。
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
from db.connection import get_conn
# 业务常量(不暴露给宿主,技能自定)
STATUS_PENDING = "pending"
STATUS_DONE = "done"
VALID_STATUS = {STATUS_PENDING, STATUS_DONE}
PRIORITY_HIGH = "high"
PRIORITY_NORMAL = "normal"
PRIORITY_LOW = "low"
VALID_PRIORITY = {PRIORITY_HIGH, PRIORITY_NORMAL, PRIORITY_LOW}
@dataclass
class Todo:
id: int
title: str
status: str
priority: str
due_at: Optional[int]
created_at: int
updated_at: int
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"status": self.status,
"priority": self.priority,
"due_at": self.due_at,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
def _now() -> int:
return int(time.time())
def add_todo(
*,
title: str,
priority: str = PRIORITY_NORMAL,
due_at: Optional[int] = None,
) -> Todo:
"""新增一条待办。领域校验:标题非空、优先级合法。"""
title = (title or "").strip()
if not title:
raise ValueError("标题不能为空")
if priority not in VALID_PRIORITY:
raise ValueError(f"优先级不合法,可选:{sorted(VALID_PRIORITY)}")
now = _now()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO todos (title, status, priority, due_at, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(title, STATUS_PENDING, priority, due_at, now, now),
)
new_id = cur.lastrowid
conn.commit()
return Todo(new_id, title, STATUS_PENDING, priority, due_at, now, now)
finally:
conn.close()
def list_todos(*, status: Optional[str] = None, limit: int = 100) -> list[Todo]:
"""列出待办;可按状态过滤。默认按创建时间倒序。"""
sql = "SELECT id, title, status, priority, due_at, created_at, updated_at FROM todos"
params: list = []
if status:
if status not in VALID_STATUS:
raise ValueError(f"状态不合法,可选:{sorted(VALID_STATUS)}")
sql += " WHERE status = ?"
params.append(status)
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(sql, params)
rows = cur.fetchall()
return [Todo(*row) for row in rows]
finally:
conn.close()
def get_todo(todo_id: int) -> Optional[Todo]:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, title, status, priority, due_at, created_at, updated_at "
"FROM todos WHERE id = ?",
(todo_id,),
)
row = cur.fetchone()
return Todo(*row) if row else None
finally:
conn.close()
def mark_done(todo_id: int) -> Todo:
"""标记一条待办为完成。"""
todo = get_todo(todo_id)
if not todo:
raise ValueError(f"待办 {todo_id} 不存在")
if todo.status == STATUS_DONE:
raise ValueError(f"待办 {todo_id} 已是完成状态")
now = _now()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE todos SET status = ?, updated_at = ? WHERE id = ?",
(STATUS_DONE, now, todo_id),
)
conn.commit()
todo.status = STATUS_DONE
todo.updated_at = now
return todo
finally:
conn.close()
def delete_todo(todo_id: int) -> bool:
"""删除一条待办;返回是否真的删了一行。"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
def stats() -> dict:
"""简单统计:用于 ``todo stats`` 命令与 demo 报告。"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT status, COUNT(*) FROM todos GROUP BY status")
by_status = {row[0]: int(row[1]) for row in cur.fetchall()}
cur.execute("SELECT COUNT(*) FROM todos")
total = int(cur.fetchone()[0])
return {
"total": total,
"pending": by_status.get(STATUS_PENDING, 0),
"done": by_status.get(STATUS_DONE, 0),
}
finally:
conn.close()

View File

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

View File

@@ -0,0 +1,24 @@
"""配置初始化:从 .env.example 复制到用户 .env,并合并缺失键。
行为对齐模板:``main.py`` 与 ``cli.app.main()`` 启动时均会调用一次。
"""
from __future__ import annotations
import os
from jiangchang_skill_core import config
from util.constants import SKILL_SLUG, SKILL_VERSION
from util.runtime_paths import get_skill_root
def bootstrap_skill_config() -> str:
"""确保用户 .env 存在,并追加 .env.example 中的新配置项。"""
example_path = os.path.join(get_skill_root(), ".env.example")
if not os.path.isfile(example_path):
# demo 没有强制 .env.example,直接返回空路径
return ""
env_path = config.ensure_env_file(SKILL_SLUG, example_path)
comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None
config.merge_missing_env_keys(example_path, env_path, comment_skill=comment)
return env_path

View File

@@ -0,0 +1,11 @@
"""技能标识与版本(demo:待办事项)。
真实技能需保证 slug = 目录名 = DB 文件名 = ``metadata.openclaw.slug``。
"""
from __future__ import annotations
# 英文 kebab-case 机器标识;与目录名、actions.json 的 ``skill`` 字段一致
SKILL_SLUG = "todo-skill"
SKILL_VERSION = "0.1.0"
LOG_LOGGER_NAME = "openclaw.skill.todo_skill"
PLATFORM_KIT_MIN_VERSION = "1.2.11"

View File

@@ -0,0 +1,29 @@
"""日志:直接用 stdlib logging(真实模板用 jiangchang_skill_core.unified_logging)。
为保持与模板 ``from util.logging_config import get_skill_logger, setup_skill_logging``
一致的调用面,本 demo 提供同名薄封装。
"""
from __future__ import annotations
import logging
from util.constants import LOG_LOGGER_NAME
_configured = False
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
global _configured
if _configured:
return
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logger = logging.getLogger(logger_name)
logger.setLevel(logging.INFO)
if not logger.handlers:
logger.addHandler(handler)
_configured = True
def get_skill_logger() -> logging.Logger:
return logging.getLogger(LOG_LOGGER_NAME)

View File

@@ -0,0 +1,32 @@
"""技能数据目录与 DB 路径解析(简化版)。
真实模板用 ``jiangchang_skill_core.runtime_env``;本 demo 复用其 mock。
数据目录 = ``{data_root}/{user_id}/{slug}/``,DB 即 ``{slug}.db``。
"""
from __future__ import annotations
import os
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
from util.constants import SKILL_SLUG
_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_skill_root() -> str:
"""技能根目录(含 SKILL.md / scripts/ 的目录)。"""
return os.path.dirname(_SCRIPTS_DIR)
def get_skill_data_dir() -> str:
"""本技能数据目录:``{data_root}/{user_id}/{slug}/``。"""
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path(filename: str | None = None) -> str:
"""SQLite 文件路径,默认 ``{slug}.db``。"""
name = filename or f"{SKILL_SLUG}.db"
return os.path.join(get_skill_data_dir(), name)

View File

View File

@@ -0,0 +1,25 @@
"""pytest 全局 fixture:把 ``scripts/`` 注入 sys.path 并隔离数据目录。"""
from __future__ import annotations
import os
import sys
import tempfile
import pytest
_SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
@pytest.fixture
def isolated_env(tmp_path, monkeypatch):
"""隔离的数据根目录,避免污染本机真实数据。"""
data_root = tmp_path / "data_root"
data_root.mkdir()
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", str(data_root))
monkeypatch.setenv("JIANGCHANG_USER_ID", "test_user")
# 重置 config mock 的缓存,让下一次 get 重新读 env
from jiangchang_skill_core import config
config.reset_cache()
return data_root

View File

@@ -0,0 +1,168 @@
"""冒烟测试:覆盖 demo 关键路径。
不依赖 pytest,可用 ``python -m unittest discover -s tests`` 跑。
但 fixture 用法对齐 pytest;这里把 fixture 用 ``setUp`` 模拟。
"""
from __future__ import annotations
import io
import json
import os
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
# 注入 scripts/ 到 sys.path
_HERE = os.path.dirname(os.path.abspath(__file__))
_SCRIPTS_DIR = os.path.join(os.path.dirname(_HERE), "scripts")
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
# 与 main.py 同样的 mock 注入:没有真实平台库时启用 mock
if not os.environ.get("TODO_SKILL_USE_REAL_PLATFORM"):
try:
import jiangchang_skill_core # noqa: F401
except ImportError:
_mock_platform = os.path.join(_SCRIPTS_DIR, "_mock_platform")
if _mock_platform not in sys.path:
sys.path.insert(0, _mock_platform)
class IsolatedEnv:
"""临时数据根目录,测试结束自动清理。"""
def __init__(self):
self._tmp = tempfile.mkdtemp(prefix="todo_test_")
self._prev_root = os.environ.get("JIANGCHANG_DATA_ROOT")
self._prev_user = os.environ.get("JIANGCHANG_USER_ID")
os.environ["JIANGCHANG_DATA_ROOT"] = os.path.join(self._tmp, "data")
os.environ["JIANGCHANG_USER_ID"] = "test_user"
os.makedirs(os.environ["JIANGCHANG_DATA_ROOT"], exist_ok=True)
def reset_config(self):
from jiangchang_skill_core import config
config.reset_cache()
def __enter__(self):
self.reset_config()
return self
def __exit__(self, *exc):
if self._prev_root is None:
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
else:
os.environ["JIANGCHANG_DATA_ROOT"] = self._prev_root
if self._prev_user is None:
os.environ.pop("JIANGCHANG_USER_ID", None)
else:
os.environ["JIANGCHANG_USER_ID"] = self._prev_user
return False
def _run_cli(argv: list[str]) -> tuple[int, str, str]:
"""调用 cli.app.main,返回 (rc, stdout, stderr)。"""
from cli.app import main
out = io.StringIO()
err = io.StringIO()
with redirect_stdout(out), redirect_stderr(err):
rc = main(argv)
return rc, out.getvalue(), err.getvalue()
class TestCliSmoke(unittest.TestCase):
def test_empty_argv_shows_usage_and_nonzero(self):
rc, out, _ = _run_cli([])
self.assertEqual(rc, 1)
self.assertIn("待办事项 demo", out)
def test_version_json(self):
with IsolatedEnv():
rc, out, _ = _run_cli(["version"])
self.assertEqual(rc, 0)
payload = json.loads(out.strip())
self.assertEqual(payload["skill"], "todo-skill")
self.assertIn("version", payload)
def test_init_db_creates_tables_and_is_idempotent(self):
with IsolatedEnv():
rc, out, _ = _run_cli(["init-db"])
self.assertEqual(rc, 0)
payload = json.loads(out.strip())
self.assertTrue(payload["ok"])
self.assertTrue(os.path.isfile(payload["db_path"]))
# 再来一次应仍成功
rc2, out2, _ = _run_cli(["init-db"])
self.assertEqual(rc2, 0)
self.assertEqual(json.loads(out2.strip())["db_path"], payload["db_path"])
def test_add_list_done_stats_full_flow(self):
with IsolatedEnv():
_run_cli(["init-db"])
rc, out, _ = _run_cli(["todo", "add", "任务A", "-p", "high"])
self.assertEqual(rc, 0)
item = json.loads(out.strip())
self.assertEqual(item["title"], "任务A")
self.assertEqual(item["status"], "pending")
todo_id = item["id"]
rc, out, _ = _run_cli(["todo", "list"])
self.assertEqual(rc, 0)
listing = json.loads(out.strip())
self.assertEqual(listing["count"], 1)
rc, out, _ = _run_cli(["todo", "done", str(todo_id)])
self.assertEqual(rc, 0)
result = json.loads(out.strip())
self.assertTrue(result["items"][0]["ok"])
self.assertEqual(result["items"][0]["todo"]["status"], "done")
rc, out, _ = _run_cli(["todo", "stats"])
stats = json.loads(out.strip())
self.assertEqual(stats, {"total": 1, "pending": 0, "done": 1})
def test_done_batch_with_comma_ids(self):
with IsolatedEnv():
_run_cli(["init-db"])
_run_cli(["todo", "add", "T1"])
_run_cli(["todo", "add", "T2"])
rc, out, _ = _run_cli(["todo", "done", "1,2"])
self.assertEqual(rc, 0)
result = json.loads(out.strip())
self.assertEqual(result["count"], 2)
self.assertTrue(all(r["ok"] for r in result["items"]))
def test_done_invalid_id_returns_error(self):
with IsolatedEnv():
_run_cli(["init-db"])
rc, out, _ = _run_cli(["todo", "done", "999"])
self.assertEqual(rc, 1)
result = json.loads(out.strip())
self.assertFalse(result["items"][0]["ok"])
def test_remind_async_pattern_emits_progress(self):
with IsolatedEnv():
_run_cli(["init-db"])
_run_cli(["todo", "add", "T1"])
rc, out, _ = _run_cli(["remind", "-s", "1"])
self.assertEqual(rc, 0)
# stdout 应含 emit 进度事件(mock 把 activity 打到 stdout)
self.assertIn('"event": "emit"', out)
self.assertIn('"event": "finish"', out)
self.assertIn('"status": "ok"', out)
# 最后一行是 CLI 结果 JSON
last_line = [l for l in out.strip().splitlines() if l.startswith("{")][-1]
payload = json.loads(last_line)
self.assertTrue(payload["ok"])
self.assertEqual(payload["task_type"], "remind")
def test_health_returns_zero(self):
with IsolatedEnv():
rc, out, _ = _run_cli(["health"])
self.assertEqual(rc, 0)
self.assertIn("todo-skill health: ok", out)
self.assertIn("platform_kit_version:", out)
if __name__ == "__main__":
unittest.main()