Compare commits
13 Commits
2aa49b5263
...
v1.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
| e14cb97232 | |||
| 2c7b23ac77 | |||
| cdd8b663f4 | |||
| 285138ebf4 | |||
| 4cde7ea739 | |||
| 2b3fdda61e | |||
| 11289976c3 | |||
| 8617bb1368 | |||
| cc7c5e3b18 | |||
| 6fbdc6e478 | |||
| 89194774f5 | |||
| 9c7b27c675 | |||
| ccb12dad7c |
13
.github/workflows/release_skill.yaml
vendored
Normal file
13
.github/workflows/release_skill.yaml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: 技能自动化发布
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
|
||||||
|
secrets:
|
||||||
|
PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }}
|
||||||
|
with:
|
||||||
|
artifact_platform: windows
|
||||||
|
pyarmor_platform: windows.x86_64
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,4 +7,5 @@ __pycache__/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
/start_vent.bat
|
||||||
|
|||||||
112
DEMO.md
112
DEMO.md
@@ -2,23 +2,109 @@
|
|||||||
demo_video_url: ""
|
demo_video_url: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
> **模板说明(复制后删除本段)**
|
|
||||||
> 本文对应技能市场 **「演示」** Tab。
|
|
||||||
> - frontmatter 的 `demo_video_url`:演示视频地址(发布时同步为 `demo_video_url`);没有视频时保持空字符串 `""`。
|
|
||||||
> - 正文(发布时同步为 `demo_md`):可选,写视频看点、时间轴或无视频时的图文演示说明。
|
|
||||||
> **不要**把演示链接写进 `SKILL.md`(市场材料 ≠ Agent 契约)。
|
|
||||||
> 复制为业务技能后:填入真实可访问的视频 URL,并删掉本提示段。
|
|
||||||
|
|
||||||
# 演示说明
|
# 演示说明
|
||||||
|
|
||||||
用一两句话说明这段演示帮助用户看懂什么(例如:从发起任务到看到结果的完整过程)。
|
本演示帮助用户快速看懂「待办事项管理」技能从新建待办到标记完成的完整操作流程,以及各操作返回的结果样式。
|
||||||
|
|
||||||
## 建议观看顺序
|
## 建议观看顺序
|
||||||
|
|
||||||
1. 如何发起一次典型任务
|
1. 如何用自然语言发起一次待办新增
|
||||||
2. 执行过程中会看到什么提示 / 进度
|
2. 如何查看待办列表与统计
|
||||||
3. 成功后的结果长什么样
|
3. 如何批量标记完成 / 删除
|
||||||
|
4. 成功后的结果长什么样
|
||||||
|
|
||||||
## 没有视频时
|
## 关键操作与结果样例
|
||||||
|
|
||||||
|
### 1. 新增待办
|
||||||
|
|
||||||
|
在对话框中输入:
|
||||||
|
|
||||||
|
> “帮我加一条待办:周五前提交项目周报,优先级高。”
|
||||||
|
|
||||||
|
执行成功后返回:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "周五前提交项目周报",
|
||||||
|
"status": "pending",
|
||||||
|
"priority": "high",
|
||||||
|
"due_at": 1757337600,
|
||||||
|
"created_at": 1757251200,
|
||||||
|
"updated_at": 1757251200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 记下返回的 `id`,后续标记完成或删除时需要用到。
|
||||||
|
|
||||||
|
### 2. 查看待办列表
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
> “帮我看看现在有哪些待办。”
|
||||||
|
|
||||||
|
返回待办清单(默认按创建时间倒序,最多 100 条):
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"items": [
|
||||||
|
{ "id": 2, "title": "整理会议纪要", "status": "pending", "priority": "normal", "due_at": null },
|
||||||
|
{ "id": 1, "title": "周五前提交项目周报", "status": "pending", "priority": "high", "due_at": 1757337600 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可指定只看某一状态:
|
||||||
|
|
||||||
|
> “帮我看看已完成的待办。”
|
||||||
|
|
||||||
|
### 3. 标记完成
|
||||||
|
|
||||||
|
输入单条或批量编号:
|
||||||
|
|
||||||
|
> “把第 1 条待办标记为完成。”
|
||||||
|
>
|
||||||
|
> “把第 1、2 条待办都标记为完成。”
|
||||||
|
|
||||||
|
返回逐条处理结果:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"items": [
|
||||||
|
{ "ok": true, "id": 1, "todo": { "status": "done", ... } },
|
||||||
|
{ "ok": true, "id": 2, "todo": { "status": "done", ... } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 删除待办
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
> “把第 2 条待办删掉。”
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"items": [
|
||||||
|
{ "ok": true, "id": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 查看统计
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
> “帮我统计一下待办情况。”
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "total": 5, "pending": 3, "done": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
可在此用简短图文描述关键界面与结果样例;有视频后把 `demo_video_url` 填上即可,正文可保留为补充说明。
|
|
||||||
|
|||||||
73
README.md
73
README.md
@@ -1,45 +1,27 @@
|
|||||||
---
|
---
|
||||||
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。"
|
description: "用户可以指定待办事项,系统会自动提醒用户在指定时间完成或用户指定的时间点提醒。"
|
||||||
---
|
---
|
||||||
|
|
||||||
> **模板说明(复制后删除本段)**
|
# 待办事项
|
||||||
> 本文是技能市场详情与普通用户说明,**不是** Agent 参考索引、CLI 契约或开发文档。
|
|
||||||
> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约、数据结构、开发规范或 Agent 参考索引。
|
|
||||||
> 正文只回答用户关心的问题,**不要**把 Playwright、DOM、Python、RPA、references、development、schema 等作为面向用户的主说明内容;若业务上必须出现技术词,须用用户能理解的方式解释,且不能喧宾夺主。
|
|
||||||
> **复制后验收章节(标题可微调,建议保留):**「能帮你做什么」「适合什么场景」「使用前需要准备什么」「你可以这样告诉 Agent」「执行后你会得到什么」「注意事项」「常见问题」。
|
|
||||||
|
|
||||||
# 【技能名称】
|
> 用户可以创建待办事项,删除待办事项,修改待办事项,系统会自动提醒用户在指定时间完成或用户指定的时间点提醒。
|
||||||
|
|
||||||
> 用一两句话说明这个技能帮助用户完成什么工作。
|
|
||||||
> 这里不要解释技术实现,要从用户的业务目标出发描述价值。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 这个技能能帮你做什么
|
## 这个技能能帮你做什么
|
||||||
|
- 能够帮助用户提醒待办事项,记录待办事项,避免遗漏
|
||||||
用 3 到 6 条业务能力说明技能价值,例如:
|
|
||||||
|
|
||||||
- 【自动整理/查询/生成/同步/核对/提交】某类业务数据
|
|
||||||
- 帮你减少重复操作,降低人工录入或核对成本
|
|
||||||
- 在任务完成后给出清晰的处理结果、失败原因或后续建议
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 适合什么场景
|
## 使用场景
|
||||||
|
- 个人事务、生活安排
|
||||||
|
- 日常工作任务
|
||||||
|
|
||||||
列出典型使用场景。请使用用户熟悉的业务语言,例如:
|
|
||||||
|
|
||||||
- 每天需要重复处理同一类订单、单据、报表或客户资料
|
|
||||||
- 需要从多个系统中查询信息并汇总结果
|
|
||||||
- 需要按固定规则检查数据是否完整、是否异常
|
|
||||||
- 需要把处理结果保存为文件、记录或任务报告
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 使用前需要准备什么
|
## 使用前需要准备什么
|
||||||
|
|
||||||
说明用户在使用前要准备的东西,例如:
|
|
||||||
|
|
||||||
- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
|
- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
|
||||||
- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态
|
- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态
|
||||||
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
|
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
|
||||||
@@ -51,57 +33,20 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
|
|||||||
|
|
||||||
## 你可以这样告诉 Agent
|
## 你可以这样告诉 Agent
|
||||||
|
|
||||||
用自然语言示例展示用户可以怎么发起任务,例如:
|
> “帮我查询今天的【待办事项】”
|
||||||
|
|
||||||
> “帮我查询今天的【业务对象】,整理成表格。”
|
|
||||||
>
|
|
||||||
> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。”
|
|
||||||
>
|
|
||||||
> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。”
|
|
||||||
>
|
|
||||||
> “帮我根据这个文件里的内容,逐条完成【业务动作】。”
|
|
||||||
|
|
||||||
> 注意:示例应是自然语言,不要写命令行命令。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 执行后你会得到什么
|
## 执行后你会得到什么
|
||||||
|
- 查询待办事项的结果
|
||||||
说明用户会看到什么结果,例如:
|
|
||||||
|
|
||||||
- 任务是否成功完成
|
|
||||||
- 处理了多少条数据
|
|
||||||
- 哪些内容成功、哪些失败
|
|
||||||
- 失败原因和建议处理方式
|
|
||||||
- 生成的文件、表格、报告或记录
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
用用户能理解的方式说明边界和风险,例如:
|
|
||||||
|
|
||||||
- 请确认输入范围准确,避免处理错数据
|
|
||||||
- 涉及提交、付款、删除、审批等高风险动作时,技能应在执行前请求确认
|
|
||||||
- 如果外部系统登录失效、权限不足或页面变化,任务可能需要用户介入
|
|
||||||
- 不建议在未核对数据的情况下直接执行批量操作
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
### 为什么任务没有查到数据?
|
|
||||||
|
|
||||||
> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。
|
|
||||||
|
|
||||||
### 为什么任务执行到一半需要我操作?
|
|
||||||
|
|
||||||
> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。
|
|
||||||
|
|
||||||
### 结果文件在哪里查看?
|
|
||||||
|
|
||||||
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。
|
|
||||||
|
|
||||||
### 已经登记的账号在哪里查看?
|
### 已经登记的账号在哪里查看?
|
||||||
|
|
||||||
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。
|
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。
|
||||||
|
|||||||
90
SKILL.md
90
SKILL.md
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: 技能开发模板(通用业务版)
|
name: 待办事项
|
||||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||||
version: 1.0.98
|
version: 1.0.98
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
@@ -14,19 +14,15 @@ metadata:
|
|||||||
allowed-tools:
|
allowed-tools:
|
||||||
- bash
|
- bash
|
||||||
---
|
---
|
||||||
|
# 待办事项 (add-todo-all)
|
||||||
|
|
||||||
# 技能开发模板(skill-template)
|
提供待办事项的创建、查看、修改、标记完成、删除、批量操作能力;支持设置截止时间、优先级、分类标签;可生成待办清单文本输出;不持久化跨会话存储,会话结束数据丢失。
|
||||||
|
|
||||||
这是一个**用于复制的新技能模板**,不是业务 skill 本身。复制后请替换占位内容,实现你的真实业务逻辑。
|
## Agent 读取顺序
|
||||||
|
|
||||||
## Agent / 宿主编排(复制为业务技能后必改)
|
回答普通用户“是什么、能做什么、怎么开始”时,优先读取面向用户的 `README.md`;第一次操作步骤读 `TUTORIAL.md`。Agent 编排再按需读取 `references/CLI.md`、`references/ACTIONS.md` 和 `references/SCHEMA.md`,开发与浏览器账号细节见 `development/ACCOUNT_MANAGER.md`。
|
||||||
|
|
||||||
### AI + RPA 定位
|
市场四个材料分别是:`README.md` → `readme_md`、`TUTORIAL.md` → `tutorial_md`、`DEMO.md` → `demo_md` / `demo_video_url`、`CHANGELOG.md` → `changelog`。
|
||||||
|
|
||||||
- **Agent 是入口与编排者**:理解用户意图、补齐参数、判断风险、选择 Action,并用业务语言解释结果。
|
|
||||||
- **RPA 是确定性执行器**:只按已验证步骤操作授权范围内的浏览器、桌面软件或手机;不得破解、逆向、调用未授权私有协议或绕过验证码、安全验证和平台限制。
|
|
||||||
- 遇到验证码、人脸、U盾、频控、账号风险或权限不足时,转人工或安全停止,禁止自动硬闯、换号或无限重试。
|
|
||||||
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`;三端界面代码必须先读 `development/RPA_ELEMENT_LOCATING.md` 并完成真实拾取与 element map,再按浏览器/桌面/手机选读 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`;浏览器账号、Profile、多账号和租约另读 `development/ACCOUNT_MANAGER.md`。
|
|
||||||
|
|
||||||
### 架构铁律(先读)
|
### 架构铁律(先读)
|
||||||
|
|
||||||
@@ -51,11 +47,11 @@ allowed-tools:
|
|||||||
| 长耗时 / RPA / 浏览器 / 需要进度或取消;或 `executionProfile: "async"` | **必须** `run_skill_action`(先 `list_skill_actions` / `get_skill_action`) | **是**(仅因为该 Action 是 async) |
|
| 长耗时 / RPA / 浏览器 / 需要进度或取消;或 `executionProfile: "async"` | **必须** `run_skill_action`(先 `list_skill_actions` / `get_skill_action`) | **是**(仅因为该 Action 是 async) |
|
||||||
| 用户只问用法 / 能做什么 | 读根目录 `README.md`;逐步操作再读 `TUTORIAL.md`;**不要**为答用法去列目录或默认打开 `references/` | — |
|
| 用户只问用法 / 能做什么 | 读根目录 `README.md`;逐步操作再读 `TUTORIAL.md`;**不要**为答用法去列目录或默认打开 `references/` | — |
|
||||||
|
|
||||||
### 用户意图路由表(业务技能复制后必改)
|
### 用户意图路由表
|
||||||
|
|
||||||
复制为业务技能后,**必须**用本技能真实意图改写下表(保留三列结构)。格子写清:短 CLI 子命令,或 `run_skill_action` → **具体 action id**(及主要 `input`)。勿留模板占位语上线。
|
复制为业务技能后,**必须**用本技能真实意图改写下表(保留三列结构)。格子写清:短 CLI 子命令,或 `run_skill_action` → **具体 action id**(及主要 `input`)。勿留模板占位语上线。
|
||||||
|
|
||||||
| 用户意图(本技能自填) | Agent 怎么做 | 进任务中心? |
|
| 用户意图 | 准备 vs 执行 | 路由 |
|
||||||
|------------------------|--------------|--------------|
|
|------------------------|--------------|--------------|
|
||||||
| (例)查询 / 统计 / 列表 | 短 CLI:`<your-read-commands>` | 否 |
|
| (例)查询 / 统计 / 列表 | 短 CLI:`<your-read-commands>` | 否 |
|
||||||
| (例)写入 / 导入 / 改本地状态 | 短 CLI:`<your-prep-commands>` | 否 |
|
| (例)写入 / 导入 / 改本地状态 | 短 CLI:`<your-prep-commands>` | 否 |
|
||||||
@@ -63,35 +59,17 @@ allowed-tools:
|
|||||||
|
|
||||||
有长任务 / RPA 时:上表至少一行指向真实的 async action id,且该 id 须出现在 `assets/actions.json`;细节分流见 `references/CLI.md`。
|
有长任务 / RPA 时:上表至少一行指向真实的 async action id,且该 id 须出现在 `assets/actions.json`;细节分流见 `references/CLI.md`。
|
||||||
|
|
||||||
**硬规则:**
|
|
||||||
|
|
||||||
1. **禁止**对长任务使用 `exec`/`bash` 跑 `…/scripts/main.py run`(或等价业务命令)再 `process poll` 干等。
|
|
||||||
2. **禁止**用 `uv run python …` 跑技能脚本(会建临时环境,缺 `jiangchang_skill_core`)。
|
|
||||||
3. **禁止** Agent 自行 `pip install`。
|
|
||||||
4. **入口按定位表挂载**:复制业务技能后须在 `development/REQUIREMENTS.md` 填写入口定位表(见 `development/PRODUCT_SURFACES.md`)。定位需要数据管理 / 定时 / Agent 结构化调用时,必须提供 `actions.json` 并声明对应 `placements`;定位确认无需任何宿主结构化入口时可不提供。有长任务 / RPA 时**必须**提供至少一条 `executionProfile: "async"` 且 `placements` 含 `agent` 的 Action。
|
|
||||||
5. **一个业务能力一个 Action**,用多个 `placements` 挂入口;底层仍是同一 `scripts/main.py` CLI。
|
|
||||||
6. 是否进任务中心**只**看该 Action 的 `executionProfile`,与数据管理 / 定时 / Agent 入口无关。
|
|
||||||
7. **处理业务行**时遵守状态角色契约(可操作 / 进行中 / 终态);禁止对已成功/失败/跳过默认重复处理。详见 `references/WORK_ITEM_STATUS.md`。
|
|
||||||
|
|
||||||
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`、按需 `references/POLICY.md` / `references/WORK_ITEM_STATUS.md`(不要把 `development/` 当 Agent 主读路径)。
|
## CLI 契约
|
||||||
|
|
||||||
## 共享 Python(必读)
|
必须使用宿主共享 Python runtime:
|
||||||
|
|
||||||
宿主共享 runtime 在 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,并已前置到 PATH(同时设 `VIRTUAL_ENV`)。短 CLI 一条命令直接跑:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python {baseDir}/scripts/main.py health
|
python {baseDir}/scripts/main.py <command> …
|
||||||
python {baseDir}/scripts/main.py version
|
|
||||||
python {baseDir}/scripts/main.py config-path
|
|
||||||
python {baseDir}/scripts/main.py init-db
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.11`(包名路径 `jiangchang_skill_core`);**不要 vendor** `scripts/jiangchang_skill_core/`。
|
禁止使用 `uv run python`,也禁止向临时环境自行安装依赖。短 CLI 白名单为 `health`、`stats`、`version`、`config-path`、`init-db`、 和 `run` 是 Action 强制黑名单,必须通过 `run_skill_action` 执行。
|
||||||
- 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright`。
|
|
||||||
- `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。
|
|
||||||
- `health` 用 `collect_runtime_diagnostics` 做只读诊断。
|
|
||||||
|
|
||||||
配置:仓库 `.env.example` 为模板;用户 `.env` 在 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`。优先级:进程环境变量 > 用户 `.env` > `.env.example`。
|
|
||||||
|
|
||||||
## 面向用户问答(LLM 规则)
|
## 面向用户问答(LLM 规则)
|
||||||
|
|
||||||
@@ -121,48 +99,4 @@ python {baseDir}/scripts/main.py init-db
|
|||||||
- 若市场材料与 `SKILL.md` / `references` 表述冲突:对用户展示以 `README.md` / `TUTORIAL.md` 为准;对执行契约以 `SKILL.md` / `references` 为准。
|
- 若市场材料与 `SKILL.md` / `references` 表述冲突:对用户展示以 `README.md` / `TUTORIAL.md` 为准;对执行契约以 `SKILL.md` / `references` 为准。
|
||||||
- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`。
|
- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`。
|
||||||
|
|
||||||
## 文档分工
|
|
||||||
|
|
||||||
| 文档 | 读者 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| 根目录 `README.md` | 普通用户 | 市场 **说明** Tab(`readme_md`) |
|
|
||||||
| 根目录 `TUTORIAL.md` | 普通用户 | 市场 **教程** Tab(`tutorial_md`) |
|
|
||||||
| 根目录 `DEMO.md` | 普通用户 | 市场 **演示** Tab(`demo_video_url` + `demo_md`) |
|
|
||||||
| 根目录 `CHANGELOG.md` | 普通用户 | 市场 **更新日志** Tab(按版本小节 → `changelog`) |
|
|
||||||
| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 |
|
|
||||||
| `references/` | Agent 编排 / 调用 | CLI、Action、schema 契约(Agent 主读) |
|
|
||||||
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、深度技术规范;**产品入口**见 `development/PRODUCT_SURFACES.md` |
|
|
||||||
|
|
||||||
## 目录约定
|
|
||||||
|
|
||||||
复制后建议保留:
|
|
||||||
|
|
||||||
- `assets/`、`development/`、`evals/`、`references/`、`scripts/`、`tests/`
|
|
||||||
- CLI 入口固定为 `scripts/main.py`
|
|
||||||
- 业务逻辑按 `cli / db / service / util` 分层
|
|
||||||
|
|
||||||
## 定制入口
|
|
||||||
|
|
||||||
1. 从本模板创建新业务技能时,**不得保留模板 `.git`**;优先使用 [`tools/scaffold_skill.ps1`](tools/scaffold_skill.ps1)(见 [`tools/README.md`](tools/README.md))。
|
|
||||||
2. 业务技能仓库中**不得**存在 `.openclaw-skill-template`(仅 template 源仓库保留)。
|
|
||||||
3. 复制目录为你的新 skill 仓库,全局替换 `add-todo-all` 等占位词。
|
|
||||||
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制;开发前先读 `development/PRODUCT_SURFACES.md` 并填写入口定位表。
|
|
||||||
5. 用户市场四 Tab 材料写入根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`、`references/ACTIONS.md`。
|
|
||||||
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。
|
|
||||||
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。
|
|
||||||
|
|
||||||
## 技能命名与 slug
|
|
||||||
|
|
||||||
- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。
|
|
||||||
- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case;`name`、`description`、根 `README.md`:**中文**。
|
|
||||||
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`。
|
|
||||||
- `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。
|
|
||||||
- `metadata.openclaw.slug`:**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug,不使用中文。
|
|
||||||
- 复制后全局替换 `add-todo-all` 与 `add_todo_all`(脚手架会自动处理);正式技能不得保留英文占位 `name` 或 slug。
|
|
||||||
|
|
||||||
## 平台元数据
|
|
||||||
|
|
||||||
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表(ID 来自匠厂宿主「设置 → 用户信息」)。
|
|
||||||
- 开发期技能常为不公开:不加本人 ID,开发者自己也无法在市场安装自测。取 ID 与写入步骤见 `development/DEVELOPMENT.md` §6。
|
|
||||||
- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。
|
|
||||||
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`。
|
|
||||||
|
|||||||
47
TUTORIAL.md
47
TUTORIAL.md
@@ -1,12 +1,6 @@
|
|||||||
> **模板说明(复制后删除本段)**
|
# 待办事项管理 使用教程
|
||||||
> 本文对应技能市场 **「使用教程」** Tab(发布时同步为 `tutorial_md`)。
|
|
||||||
> 面向普通用户写**可跟着做的步骤**,不要写 CLI、目录结构、release、开发规范。
|
|
||||||
> 与根 `README.md`(技能说明)分工:README 讲「是什么 / 能做什么」;本文讲「第一次怎么用完」。
|
|
||||||
> **复制后验收章节(标题可微调):**「开始前」「第一步…」「第二步…」「常见卡住怎么办」。
|
|
||||||
|
|
||||||
# 【技能名称】使用教程
|
用最短路径带你完成一次从「新建待办」到「标记完成」的完整使用。
|
||||||
|
|
||||||
用最短路径带用户完成一次成功使用。步骤要具体,可用「打开匠厂 → …」这类操作语言。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,10 +10,8 @@
|
|||||||
|
|
||||||
1. 已安装并登录匠厂客户端
|
1. 已安装并登录匠厂客户端
|
||||||
2. 已在技能市场安装本技能(或更新到最新版)
|
2. 已在技能市场安装本技能(或更新到最新版)
|
||||||
3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
|
3. 本技能使用本地数据库存储待办,首次使用会自动初始化,无需手动准备
|
||||||
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
|
4. 如需设置截止时间,请提前想好具体日期时间;优先级可选「高 / 中 / 低」,不写默认「中」
|
||||||
5. 【网页自动化类技能】已登记账号可在「数据管理 / 账号管理」查看;每个账号都有独立浏览器会话
|
|
||||||
6. 【如需】需要的文件 / 时间范围 / 单号等已准备好
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,7 +19,11 @@
|
|||||||
|
|
||||||
在匠厂里新建任务或打开对话,用自然语言说明要做什么,例如:
|
在匠厂里新建任务或打开对话,用自然语言说明要做什么,例如:
|
||||||
|
|
||||||
> “帮我【完成某件业务事】,范围是【时间 / 对象 / 条件】。”
|
> “帮我加一条待办:周五前提交项目周报,优先级高。”
|
||||||
|
>
|
||||||
|
> “帮我看看现在有哪些待办。”
|
||||||
|
>
|
||||||
|
> “把第 3 条待办标记为完成。”
|
||||||
|
|
||||||
建议一次只交代一件事;范围说清楚,减少来回确认。
|
建议一次只交代一件事;范围说清楚,减少来回确认。
|
||||||
|
|
||||||
@@ -35,10 +31,11 @@
|
|||||||
|
|
||||||
## 第二步:按提示补充信息
|
## 第二步:按提示补充信息
|
||||||
|
|
||||||
如果 Agent 问你缺什么(账号、文件、筛选条件),按提示补充即可。
|
如果 Agent 问你缺什么,按提示补充即可。
|
||||||
|
|
||||||
- 有文件时:按界面提示选择或拖入
|
- **新增待办时**:标题是必填的;优先级和截止时间可选,不写会用默认值(优先级「中」,无截止时间)
|
||||||
- 有账号时:确认已在对应平台登录,且具备操作权限
|
- **标记完成 / 删除时**:需要提供待办编号(可一次给多个,用逗号隔开)
|
||||||
|
- **查看列表时**:可指定只看「待办中」或「已完成」,不指定则全部列出
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,9 +43,10 @@
|
|||||||
|
|
||||||
任务结束后,请核对:
|
任务结束后,请核对:
|
||||||
|
|
||||||
- 是否成功完成
|
- **新增成功**:会返回这条待办的编号、标题、状态、优先级等信息,请记下编号以便后续操作
|
||||||
- 处理了多少条 / 哪些失败
|
- **列表查询**:会返回待办清单,默认按创建时间倒序,最多 100 条
|
||||||
- 生成的文件、表格或记录是否在预期位置
|
- **标记完成 / 删除**:会逐条返回成功或失败,注意看是否有编号不存在、或已完成状态无法重复标记的情况
|
||||||
|
- **统计**:会显示总数、待办数、完成数
|
||||||
|
|
||||||
若失败,先看失败原因说明,再按下一节排查。
|
若失败,先看失败原因说明,再按下一节排查。
|
||||||
|
|
||||||
@@ -58,10 +56,11 @@
|
|||||||
|
|
||||||
| 现象 | 你可以先试 |
|
| 现象 | 你可以先试 |
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 |
|
| 提示标题不能为空 | 重新发起,把待办内容说清楚(标题不能留空) |
|
||||||
| 提示账号不存在 / 被占用 | 到「数据管理 / 账号管理」确认账号记录和状态,稍后再试;不要重复创建账号 |
|
| 提示待办编号不存在 | 先用「查看待办列表」确认正确编号,再操作 |
|
||||||
| 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 |
|
| 提示该待办已是完成状态 | 已完成的待办不能重复标记完成,无需再操作 |
|
||||||
| 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 |
|
| 优先级不生效 | 优先级只能是「高 / 中 / 低」,请用这三个词之一 |
|
||||||
| 结果不对 | 用更明确的条件再说一次,或缩小范围重试 |
|
| 截止时间不对 | 截止时间请用明确的日期时间表述(如「明天下午 6 点」「9 月 10 日 18:00」),避免模糊说法 |
|
||||||
|
| 列表数据为空 | 确认是否还没有新增过待办;或是否筛选了「已完成」但没有已完成项 |
|
||||||
|
|
||||||
仍无法解决时,把「你说了什么 + 界面提示原文」发给支持人员,便于排查。
|
仍无法解决时,把「你说了什么 + 界面提示原文」发给支持人员,便于排查。
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
"id": "health",
|
"id": "health",
|
||||||
"label": "运行检查",
|
"label": "运行检查",
|
||||||
"description": "检查技能运行环境并返回结构化结果,不启动浏览器。",
|
"description": "检查技能运行环境并返回结构化结果,不启动浏览器。",
|
||||||
"placements": ["skill-detail", "agent"],
|
"placements": [
|
||||||
|
"skill-detail",
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
"executionProfile": "sync",
|
"executionProfile": "sync",
|
||||||
"entrypoint": {
|
"entrypoint": {
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -18,7 +21,9 @@
|
|||||||
"id": "version",
|
"id": "version",
|
||||||
"label": "查看版本",
|
"label": "查看版本",
|
||||||
"description": "返回技能版本和机器标识。",
|
"description": "返回技能版本和机器标识。",
|
||||||
"placements": ["skill-detail"],
|
"placements": [
|
||||||
|
"skill-detail"
|
||||||
|
],
|
||||||
"executionProfile": "sync",
|
"executionProfile": "sync",
|
||||||
"entrypoint": {
|
"entrypoint": {
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -30,13 +35,188 @@
|
|||||||
"id": "config-path",
|
"id": "config-path",
|
||||||
"label": "查看配置位置",
|
"label": "查看配置位置",
|
||||||
"description": "返回技能配置文件位置。",
|
"description": "返回技能配置文件位置。",
|
||||||
"placements": ["skill-detail"],
|
"placements": [
|
||||||
|
"skill-detail"
|
||||||
|
],
|
||||||
"executionProfile": "sync",
|
"executionProfile": "sync",
|
||||||
"entrypoint": {
|
"entrypoint": {
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
"command": "config-path",
|
"command": "config-path",
|
||||||
"args": []
|
"args": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "add-todo",
|
||||||
|
"label": "添加待办",
|
||||||
|
"description": "添加待办事项。",
|
||||||
|
"placements": [
|
||||||
|
"skill-detail",
|
||||||
|
"toolbar"
|
||||||
|
],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "todo",
|
||||||
|
"args": [
|
||||||
|
"add",
|
||||||
|
"--title",
|
||||||
|
"{{title}}",
|
||||||
|
"--priority",
|
||||||
|
"{{priority}}",
|
||||||
|
"--due-at",
|
||||||
|
"{{due-at}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interaction": "form",
|
||||||
|
"bind": {
|
||||||
|
"tables": [
|
||||||
|
"todos"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "待办标题",
|
||||||
|
"description": "待办事项的标题,必填。"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "优先级",
|
||||||
|
"description": "待办事项的优先级,可选。",
|
||||||
|
"default": "normal",
|
||||||
|
"enum": [
|
||||||
|
"high",
|
||||||
|
"normal",
|
||||||
|
"low"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"due-at": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "提醒时间",
|
||||||
|
"description": "格式 YYYY-MM-DD HH:MM,如 2026-08-01。",
|
||||||
|
"display_type": "datetime_unix_seconds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"priority"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hostEffects": {
|
||||||
|
"onSuccess": [
|
||||||
|
"reloadTable"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "todo-create",
|
||||||
|
"label": "新增记录",
|
||||||
|
"description": "添加待办事项。",
|
||||||
|
"replaces": "create",
|
||||||
|
"placements": ["toolbar"],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "form",
|
||||||
|
"bind": {
|
||||||
|
"tables": [
|
||||||
|
"todos"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hostEffects": {
|
||||||
|
"onSuccess": [
|
||||||
|
"reloadTable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "待办标题",
|
||||||
|
"description": "待办事项的标题,必填。"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "优先级",
|
||||||
|
"description": "待办事项的优先级,可选。",
|
||||||
|
"default": "normal",
|
||||||
|
"enum": [
|
||||||
|
"high",
|
||||||
|
"normal",
|
||||||
|
"low"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"due-at": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "提醒时间",
|
||||||
|
"description": "格式 YYYY-MM-DD HH:MM,如 2026-08-01。",
|
||||||
|
"display_type": "datetime_unix_seconds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"priority"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "todo",
|
||||||
|
"args": [
|
||||||
|
"add",
|
||||||
|
"--title",
|
||||||
|
"{{title}}",
|
||||||
|
"--priority",
|
||||||
|
"{{priority}}",
|
||||||
|
"--due-at",
|
||||||
|
"{{due-at}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "todo-delete",
|
||||||
|
"label": "删除记录",
|
||||||
|
"description": "删除待办事项。",
|
||||||
|
"replaces": "delete",
|
||||||
|
"placements": ["row"],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "confirm",
|
||||||
|
"confirmation": {
|
||||||
|
"message": "确定删除该记录?关联数据将一并清理。"
|
||||||
|
},
|
||||||
|
"bind": {
|
||||||
|
"tables": [
|
||||||
|
"todos"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hostEffects": {
|
||||||
|
"onSuccess": [
|
||||||
|
"reloadTable",
|
||||||
|
"clearSelection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "编号",
|
||||||
|
"readOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "todo",
|
||||||
|
"args": [
|
||||||
|
"delete",
|
||||||
|
"ids",
|
||||||
|
"{{id}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -312,11 +312,11 @@ git remote -v
|
|||||||
|
|
||||||
复制后,至少要全局检查并替换下面这些内容:
|
复制后,至少要全局检查并替换下面这些内容:
|
||||||
|
|
||||||
- `add-todo-all`
|
- `your-skill-slug`
|
||||||
- `your-platform-key`(若 skill 涉及外部平台对接,可能会用到这类占位)
|
- `your-platform-key`(若 skill 涉及外部平台对接,可能会用到这类占位)
|
||||||
- `技能开发模板(通用业务版)`
|
- `技能开发模板(通用业务版)`
|
||||||
- `你的平台名`
|
- `你的平台名`
|
||||||
- `openclaw.skill.add_todo_all`
|
- `openclaw.skill.your_skill_slug`
|
||||||
|
|
||||||
如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换:
|
如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换:
|
||||||
|
|
||||||
|
|||||||
@@ -1,332 +1,227 @@
|
|||||||
# REQUIREMENTS
|
# REQUIREMENTS
|
||||||
|
|
||||||
## 0. 技能标识(复制 REQUIREMENTS 时必填)
|
## 0. 技能标识
|
||||||
|
|
||||||
- 拟定 slug(英文,符合 `development/NAMING.md`):add-todo-all
|
- 拟定 slug(英文,符合 `development/NAMING.md`):add-todo-all
|
||||||
- 中文 name:待办事项
|
- 中文 name:待办事项
|
||||||
- 中文 description(一句话):提供待办事项的创建、查看、修改、标记完成、删除、批量操作能力;支持设置截止时间、优先级、分类标签;可生成待办清单文本输出;不持久化跨会话存储,会话结束数据丢失。
|
- 中文 description(一句话):提供待办事项的创建、查看、标记完成、删除、批量操作能力;支持设置截止时间与优先级;数据持久化在本地 SQLite。
|
||||||
- account-manager platform_key(如有):
|
- account-manager platform_key(如有):无(纯本地数据技能,不涉及外部账号登录)
|
||||||
- 命名形态:标准型 / scope 型
|
- 命名形态:标准型
|
||||||
- **网页 RPA(如有)— 登录策略(三选一,须写明):**
|
- **网页 RPA(如有)— 登录策略:** `not_needed`
|
||||||
- `required`:目标站必须登录才能闭环(用户须完成/保持站点登录;技能每轮主路径须有登录门禁 + 验证码/人工等待)
|
- 本技能不涉及任何网页自动化、浏览器操作或外部系统登录,所有数据存储与操作均在本地 SQLite 中完成。
|
||||||
- `optional`:登录可提升能力,但不登录也能完成主路径(**仅当**公开页确可闭环时选用;会话依赖型采集/评论/私信**禁止**默认抄成 optional)
|
- 不调用 account-manager,不使用 profile_dir。
|
||||||
- `not_needed`:公开页即可,不要求站点登录
|
|
||||||
- 无论上列哪一种:**都必须**经 account-manager **`ensure-web`**(或等价)取得 `profile_dir`(见 `development/RPA.md` §0.2);不得使用系统默认浏览器用户目录
|
|
||||||
- **登记 ≠ 登录**:ensure/add-web 只建账号与 Profile;站点登录由本技能在有头浏览器中完成
|
|
||||||
- **反模式**:把「需要登录态才能采到数据」的需求写成 `optional`,再省略 `ensure_logged_in`
|
|
||||||
|
|
||||||
## 0.1 宿主入口定位(必填)
|
## 0.1 宿主入口定位
|
||||||
|
|
||||||
先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)。对话任务 / 数据管理 / 定时任务在宿主上**平级**;挂哪些由本技能业务与 UX 定,模板不规定「必须三入口都挂满」。
|
|
||||||
|
|
||||||
对每个主能力填一行(可增行;「不挂」必须写理由):
|
|
||||||
|
|
||||||
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 不挂理由(若不挂) |
|
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 不挂理由(若不挂) |
|
||||||
|---------------------------|------|----------|------|--------------------|
|
|---------------------------|------|----------|------|--------------------|
|
||||||
| (例)`<action-or-cli>` | 挂 / 不挂 | 不挂 / toolbar / row / batch / replaces / 仅展示表 | 挂 / 不挂 | |
|
| `todo add` | 挂 | toolbar(新增按钮) | 不挂 | 定时无意义 |
|
||||||
|
| `todo list` | 挂 | 仅展示表 | 不挂 | 定时无意义 |
|
||||||
|
| `todo done` | 挂 | row + batch(标记完成) | 不挂 | 定时无意义 |
|
||||||
|
| `todo delete` | 挂 | row + batch(删除) | 不挂 | 定时无意义 |
|
||||||
|
| `todo stats` | 挂 | toolbar(统计) | 不挂 | 定时无意义 |
|
||||||
|
| `remind` | 挂 | toolbar | 挂 | 定时扫描到期/待办项并提醒 |
|
||||||
|
| `init-db` | 不挂 | 不挂 | 不挂 | 宿主安装/更新时静默调用,不对用户暴露 |
|
||||||
|
| `health` | 不挂 | 不挂 | 不挂 | 内部诊断命令 |
|
||||||
|
| `version` | 不挂 | 不挂 | 不挂 | 内部诊断命令 |
|
||||||
|
| `config-path` | 不挂 | 不挂 | 不挂 | 内部诊断命令 |
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 数据管理列可写组合(如 `toolbar + row`)
|
- `todos` 表在数据管理中为**整表只读**(`readonly=1`),所有增删改必须通过技能 Action(`todo add/done/delete`)走,宿主不直写业务表。
|
||||||
- 「仅展示表」= 用户要看过程/结果,但不在表上触发该能力
|
- 数据管理页面默认展示列:`title`、`status`、`priority`、`due_at`、`created_at`;默认按 `created_at` 倒序。
|
||||||
- 定位为「挂」的入口须在 `assets/actions.json`(及必要表元数据)中落实,并与对话路径共用同一 domain service
|
- `status` 与 `priority` 字段在数据管理中以下拉枚举展示(pending=待办/done=完成;high=高/normal=中/low=低)。
|
||||||
|
|
||||||
## 0.2 AI + RPA 协作与授权边界(涉及外部系统时必填)
|
## 0.2 AI + RPA 协作与授权边界
|
||||||
|
|
||||||
### Agent 设计
|
### Agent 设计
|
||||||
|
|
||||||
- 用户会怎样描述本任务:
|
- 用户会怎样描述本任务:
|
||||||
|
- “帮我加一条待办:周五前提交周报,优先级高。”
|
||||||
|
- “帮我看看有哪些待办。”
|
||||||
|
- “把第 1、3 条标记为完成。”
|
||||||
|
- “把第 2 条删掉。”
|
||||||
|
- “统计一下待办情况。”
|
||||||
- 支持哪些用户意图;分别映射到哪个 Action:
|
- 支持哪些用户意图;分别映射到哪个 Action:
|
||||||
|
- 新增待办 → `todo add`
|
||||||
|
- 查看列表 → `todo list`
|
||||||
|
- 标记完成 → `todo done`
|
||||||
|
- 删除待办 → `todo delete`
|
||||||
|
- 统计概览 → `todo stats`
|
||||||
|
- 到期提醒 → `remind`(async)
|
||||||
- 每种意图必须提取哪些参数:
|
- 每种意图必须提取哪些参数:
|
||||||
|
- 新增:`title`(必填)、`priority`(可选,默认 normal)、`due_at`(可选,Unix 秒)
|
||||||
|
- 查看列表:`status`(可选,pending/done)、`limit`(可选,默认 100)
|
||||||
|
- 标记完成:`ids`(必填,单个或逗号分隔多个)
|
||||||
|
- 删除:`ids`(必填,单个或逗号分隔多个)
|
||||||
|
- 提醒:`seconds`(可选,模拟运行秒数,默认 3)
|
||||||
- 缺少哪些参数时必须追问:
|
- 缺少哪些参数时必须追问:
|
||||||
|
- 新增时缺少 `title` 必须追问
|
||||||
|
- 标记完成/删除时缺少 `ids` 必须追问
|
||||||
- 哪些动作必须先展示对象、数量和影响,再取得明确确认:
|
- 哪些动作必须先展示对象、数量和影响,再取得明确确认:
|
||||||
|
- 批量删除(影响多条数据,删除不可恢复)
|
||||||
|
- 批量标记完成(可提示将标记的数量)
|
||||||
- Agent 最终如何解释成功、部分成功、失败、等待人工和停止:
|
- Agent 最终如何解释成功、部分成功、失败、等待人工和停止:
|
||||||
|
- 成功:返回操作后的待办数据或统计结果
|
||||||
|
- 部分成功:批量操作中逐条返回 `ok/id/error`,告知哪些成功哪些失败及原因
|
||||||
|
- 失败:返回 `ERROR:` 前缀 + 中文原因(如标题为空、编号不存在、已完成不可重复标记)
|
||||||
|
- 等待人工:本技能无外部依赖,无需人工介入
|
||||||
|
- 停止:async 任务支持 `JobStopped` 取消
|
||||||
|
|
||||||
### 授权与平台边界
|
### 授权与平台边界
|
||||||
|
|
||||||
- 目标浏览器 / 桌面软件 / 手机 App:
|
- 目标浏览器 / 桌面软件 / 手机 App:无
|
||||||
- 账号、设备、数据和业务权限由谁授权:
|
- 账号、设备、数据和业务权限由谁授权:无需外部授权,数据存于本地 SQLite
|
||||||
- 是否有获得授权的官方 API;若不用,原因是什么:
|
- 是否有获得授权的官方 API;若不用,原因是什么:不涉及外部系统
|
||||||
- 目标平台规则、合同或客户约定中的自动化边界:
|
- 目标平台规则、合同或客户约定中的自动化边界:不适用
|
||||||
- 明确禁止的实现路线:破解 / 反编译 / 逆向 / 私有协议 / 抓包拼隐藏接口 / Hook 或注入 / 验证码破解 / 设备指纹伪造 / 自动换号:
|
- 明确禁止的实现路线:不适用(无外部交互)
|
||||||
- 正常界面无法闭环时的上报与停止条件:
|
- 正常界面无法闭环时的上报与停止条件:不适用
|
||||||
|
|
||||||
### RPA 风控设计
|
### RPA 风控设计
|
||||||
|
|
||||||
- 执行端:浏览器 / 桌面 / Android / iOS:
|
不适用。本技能为纯本地数据操作,不涉及任何 RPA、浏览器、桌面或移动端自动化。
|
||||||
- 真实界面版本、拾取工具和拾取日期:
|
|
||||||
- element map 文件路径;关键元素是否全部登记:
|
|
||||||
- 每个关键元素的作用域、主定位器、匹配数量和已验证 fallback:
|
|
||||||
- 页面 / iframe / 窗口 / 弹窗 / Native / WebView 如何选择:
|
|
||||||
- 会话、Profile、窗口或设备如何准备与互斥:
|
|
||||||
- 单批上限、单位时间操作量和最长运行时间:
|
|
||||||
- 普通动作节奏(真实环境默认 `1~3` 秒):
|
|
||||||
- 页面 / 控件真实状态如何判断;禁止用固定睡眠代替的节点:
|
|
||||||
- 哪些错误允许有限重试;退避策略:
|
|
||||||
- 哪些信号必须立即停止(频控、封禁、权限不足、结构变化等):
|
|
||||||
- 哪些步骤进入人工处理(验证码、人脸、指纹、短信、U盾等):
|
|
||||||
- 如何防止重复提交并支持必要的断点续跑:
|
|
||||||
- 需要保存哪些日志、截图、视频和业务结果:
|
|
||||||
- 定位失败时如何保存 DOM/UIA/XML、候选数量和环境版本:
|
|
||||||
|
|
||||||
## 0.3 业务行生命周期(有「处理某一行」类能力时必填)
|
## 0.3 业务行生命周期
|
||||||
|
|
||||||
若本技能存在对业务表某一行做生成、触达、私信、发布等**有副作用**的处理,须遵守 [`../references/WORK_ITEM_STATUS.md`](../references/WORK_ITEM_STATUS.md),并在此声明**本技能状态 → 角色**映射(状态名自定,角色固定):
|
不适用。
|
||||||
|
|
||||||
| 角色 | 本技能状态值(可多个) | 说明 |
|
本技能的 `todos` 表为用户直接管理的 CRUD 数据,不存在自动处理队列:
|
||||||
|------|------------------------|------|
|
- `pending` → `done` 的状态变更由用户主动触发(`todo done`),非技能自动捞起处理。
|
||||||
| 可操作(operable) | (例)新建 / 待生成 / … | 默认唯一可被处理 Action 捞起 |
|
- 无「进行中」抢占、无失败重试、无跳过逻辑。
|
||||||
| 进行中(in_progress) | (例)生成中 / 触达中 / … | 抢占后、结束前 |
|
- 因此不适用 `WORK_ITEM_STATUS.md` 的处理队列生命周期。
|
||||||
| 成功类终态 | (例)已生成 / 已私信 / … | 默认不可再处理 |
|
|
||||||
| 失败类终态 | (例)失败 / 触达失败 / … | 默认不可再处理;失败原因字段: |
|
|
||||||
| 跳过类终态 | (例)已跳过 / … | 默认不可再处理 |
|
|
||||||
|
|
||||||
无此类处理队列时整节写「不适用」即可。
|
|
||||||
|
|
||||||
## 1. 文档目标
|
## 1. 文档目标
|
||||||
|
|
||||||
说明这份需求文档的作用,以及它解决什么问题。
|
|
||||||
|
|
||||||
模板写法:
|
|
||||||
|
|
||||||
- 本文档用于明确 `add-todo-all` 的研发范围、交付标准、依赖关系和验收要求。
|
- 本文档用于明确 `add-todo-all` 的研发范围、交付标准、依赖关系和验收要求。
|
||||||
- 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。
|
- 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。
|
||||||
|
|
||||||
## 2. 业务背景
|
## 2. 业务背景
|
||||||
|
|
||||||
说明为什么要做这个 skill,它服务什么业务场景,解决什么实际问题。
|
- 当前用户在日常工作中需要管理个人或团队的待办事项,人工记录容易遗漏且难以批量管理。
|
||||||
|
- 现有流程主要依赖手动记录,存在「不易追踪进度、无法批量操作、缺少到期提醒」等问题。
|
||||||
模板写法:
|
- 因此需要开发 `add-todo-all`,用于在本地自动完成待办的创建、查询、状态变更、批量删除与到期提醒,输出结构化 JSON 结果并持久化到本地 SQLite。
|
||||||
|
|
||||||
- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
|
|
||||||
- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。
|
|
||||||
- 因此需要开发 `add-todo-all`,用于自动完成 `<核心动作>`,输出 `<输出结果>`。
|
|
||||||
|
|
||||||
填写提示:
|
|
||||||
|
|
||||||
- `<业务场景>`:例如“每日批量导入外部系统结果”“Agent 触发的单次查询”
|
|
||||||
- `<输入数据>`:例如“关键词列表”“批次 ID”“账号标识”
|
|
||||||
- `<核心动作>`:例如“调用 adapter 提交”“采集并入库”“对账摘要生成”
|
|
||||||
- `<输出结果>`:例如“结构化 JSON”“任务摘要”“本地 SQLite 记录”
|
|
||||||
|
|
||||||
## 3. 开发目标
|
## 3. 开发目标
|
||||||
|
|
||||||
说明本次开发希望最终交付什么结果。
|
- 解析并校验待办输入的关键字段(标题非空、优先级合法、状态合法)
|
||||||
|
- 通过本地 SQLite 完成待办的增删改查
|
||||||
模板写法(按 skill 类型勾选/改写):
|
- 输出结构化结果(stdout JSON)
|
||||||
|
- 写入业务表 `todos`(含 `created_at` / `updated_at` Unix 秒级时间戳)
|
||||||
- 解析并校验 `<输入数据>` 的关键字段
|
- 支持失败可诊断(稳定错误信息、`ERROR:` 前缀)
|
||||||
- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>`
|
|
||||||
- 输出结构化结果(stdout JSON 或固定文本 + 机读字段)
|
|
||||||
- 写入必要的业务表(见 `references/SCHEMA.md`;无业务表也可)
|
|
||||||
- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志)
|
|
||||||
- 完成 CLI 入口与 `health` / `version` / `init-db` 最小可运行验证
|
- 完成 CLI 入口与 `health` / `version` / `init-db` 最小可运行验证
|
||||||
- 完成正式环境发布与安装验证(如适用)
|
- 实现 async 提醒任务范式(`job_context` + `emit` + `finish`)
|
||||||
|
|
||||||
## 4. 功能范围
|
## 4. 功能范围
|
||||||
|
|
||||||
说明这次开发明确要实现哪些功能。
|
|
||||||
|
|
||||||
模板写法:
|
|
||||||
|
|
||||||
- 支持 `<命令1>`(如 `run`)
|
|
||||||
- 支持 `<命令2>`(如 `health` / 业务只读查询)
|
|
||||||
- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API)
|
|
||||||
- 支持执行 `<核心业务动作>`
|
|
||||||
- 支持记录 `<日志 / 状态 / 结果>`
|
|
||||||
|
|
||||||
建议将功能拆成“必须实现”和“可后续扩展”。
|
|
||||||
|
|
||||||
### 必须实现
|
### 必须实现
|
||||||
|
|
||||||
- 支持 `python scripts/main.py health`
|
- 支持 `python scripts/main.py health`(运行时健康检查)
|
||||||
- 支持 `python scripts/main.py version`
|
- 支持 `python scripts/main.py version`(版本信息 JSON)
|
||||||
- 支持 `python scripts/main.py init-db`(幂等建库;宿主安装/更新后可静默调用)
|
- 支持 `python scripts/main.py init-db`(幂等建库与数据管理元数据)
|
||||||
- 支持 `python scripts/main.py <your-main-command>`
|
- 支持 `python scripts/main.py todo add <title> [-p high|normal|low] [-d <unix秒>]`(新增待办)
|
||||||
- 支持核心业务编排(HTTP / 批处理 / 可选 RPA,按四档 adapter 选型)
|
- 支持 `python scripts/main.py todo list [-s pending|done] [-n <limit>]`(列出待办,默认 100 条,按创建时间倒序)
|
||||||
- 支持失败可诊断(统一文件日志 + 任务中心 `finish`)
|
- 支持 `python scripts/main.py todo done <id>`(标记完成,支持逗号分隔批量)
|
||||||
- 完成 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) 入口定位表,并按表落实已挂入口(对话 / 数据管理 / 定时按本技能 UX)
|
- 支持 `python scripts/main.py todo delete <id>`(删除,支持逗号分隔批量)
|
||||||
|
- 支持 `python scripts/main.py todo stats`(统计总数/待办数/完成数)
|
||||||
|
- 支持 `python scripts/main.py remind [-s <秒>]`(async 提醒任务,演示进度推送与终态上报)
|
||||||
|
- 支持 `python scripts/main.py config-path`(输出 .env 与模板路径)
|
||||||
|
- 业务内核统一在 `service/todo_service.py`,CLI / Action / 数据管理按钮共用同一内核
|
||||||
|
- 数据管理元数据:`todos` 表及字段具备中文 `display_name`,状态/优先级为枚举下拉,整表只读
|
||||||
|
|
||||||
### 可后续扩展
|
### 可后续扩展
|
||||||
|
|
||||||
- 支持失败自动重试
|
- 支持失败自动重试
|
||||||
- 支持多 `<目标系统>` 轮询
|
- 支持多用户/多工作空间隔离
|
||||||
- 入口定位表中已标明「本期不挂」且写了理由的能力(例如暂不挂定时)
|
- 支持分类标签
|
||||||
|
- 支持截止时间到期的真实推送通知(当前 demo 仅统计,不做真实推送)
|
||||||
|
|
||||||
## 5. 非功能要求
|
## 5. 非功能要求
|
||||||
|
|
||||||
说明除功能外,对稳定性、可维护性、目录规范、日志、编码等方面的要求。
|
|
||||||
|
|
||||||
- 目录结构必须符合当前 `skill-template` 规范
|
- 目录结构必须符合当前 `skill-template` 规范
|
||||||
- 入口必须统一为 `scripts/main.py`
|
- 入口必须统一为 `scripts/main.py`
|
||||||
- 用户市场四 Tab 写在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;LLM/运行契约写在 `SKILL.md`;开发规范在 `development/`
|
- 用户市场四 Tab 写在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;LLM/运行契约写在 `SKILL.md`;开发规范在 `development/`
|
||||||
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
|
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
|
||||||
- Windows 环境下需保证 UTF-8 输出兼容
|
- Windows 环境下需保证 UTF-8 输出兼容
|
||||||
- 必须具备基本日志能力;敏感字段脱敏
|
- 必须具备基本日志能力;敏感字段脱敏
|
||||||
- 只做授权范围内的正常界面自动化;禁止破解、逆向、未授权私有协议、安全验证绕过、进程注入、凭据窃取、自动换号和无限重试
|
- 业务表时间字段统一使用 Unix 秒级整数(`created_at` / `updated_at`)
|
||||||
- RPA 的页面加载与成功判断必须由真实状态驱动;正常业务动作之间默认随机等待 `1~3` 秒
|
- 数据管理展示顺序由 `PRAGMA table_info` 的 cid 决定,不依赖 `display_order`
|
||||||
- RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args`
|
|
||||||
- 网页 RPA:启动前必须经 ensure-web 拿到 `profile_dir`;生产有头;站点登录策略见 §0(required / optional / not_needed);`required` 时每轮须登录门禁
|
|
||||||
|
|
||||||
## 6. 输入输出要求
|
## 6. 输入输出要求
|
||||||
|
|
||||||
### 输入
|
### 输入
|
||||||
|
|
||||||
填写本 skill 实际接受的参数与数据来源:
|
- `title`:待办标题,必填,非空字符串
|
||||||
|
- `priority`:优先级,可选,枚举 `high` / `normal` / `low`,默认 `normal`
|
||||||
- `<参数名>`:含义、是否必填、默认值
|
- `due_at`:截止时间,可选,Unix 秒整数,默认 null
|
||||||
- `<依赖系统>` 返回的结构化数据(如有)
|
- `status`:列表过滤状态,可选,枚举 `pending` / `done`
|
||||||
- 环境变量 / `.env` 中的关键配置(引用 `CONFIG.md`)
|
- `limit`:列表返回上限,可选,整数,默认 100
|
||||||
|
- `ids`:标记完成/删除的待办编号,必填,单个数字或逗号分隔的数字列表
|
||||||
|
- `seconds`:提醒任务模拟运行秒数,可选,整数,默认 3
|
||||||
|
- 环境变量 / `.env`:本技能无强制环境变量依赖(`.env.example` 为模板通用项,本技能不使用 RPA 相关配置)
|
||||||
|
|
||||||
### 输出
|
### 输出
|
||||||
|
|
||||||
- 成功结果(stdout / JSON 字段说明)
|
- 成功结果(stdout JSON):
|
||||||
- 错误输出(`ERROR:` 稳定码 + 中文 message)
|
- 新增:单条待办对象 `{id, title, status, priority, due_at, created_at, updated_at}`
|
||||||
- 数据库记录(表名、关键字段,见 `references/SCHEMA.md`)
|
- 列表:`{count, items: [Todo...]}`
|
||||||
- 可选:RPA 截图/视频路径(artifacts)
|
- 标记完成/删除:`{count, items: [{ok, id, ...}]}`
|
||||||
|
- 统计:`{total, pending, done}`
|
||||||
|
- 错误输出:
|
||||||
|
- `ERROR:TODO_INVALID: <原因>`(标题为空、优先级非法等)
|
||||||
|
- 批量操作中失败项返回 `{ok: false, id, error}`
|
||||||
|
- 数据库记录:表 `todos`,字段 `id, title, status, priority, due_at, created_at, updated_at`
|
||||||
|
- async 提醒任务:通过 `emit` 推送进度,通过 `finish` 上报终态
|
||||||
|
|
||||||
## 7. 依赖的兄弟技能或外部系统
|
## 7. 依赖的兄弟技能或外部系统
|
||||||
|
|
||||||
### 兄弟技能依赖
|
### 兄弟技能依赖
|
||||||
|
|
||||||
- `<sibling-skill-slug>`:`<用途>`(例如 account-manager 提供 profile_dir / 租约)
|
- 无(不依赖 account-manager 或其他兄弟技能)
|
||||||
|
|
||||||
### 外部系统依赖
|
### 外部系统依赖
|
||||||
|
|
||||||
- `<目标系统>`:`<用途>`(HTTP API / 仿真 sandbox / 真实网页)
|
- 无(纯本地 SQLite 存储,不访问任何外部 API 或网页)
|
||||||
- 浏览器(如适用):系统 Chrome/Edge,非 Playwright 内置 Chromium
|
|
||||||
- Gitea / 匠厂客户端:发布与正式环境验证
|
- Gitea / 匠厂客户端:发布与正式环境验证
|
||||||
|
|
||||||
### Python 包依赖(requirements.txt)
|
### Python 包依赖(requirements.txt)
|
||||||
|
|
||||||
- 本技能若需**特有** Python 三方包,写入根目录 `requirements.txt`。
|
- 本技能无特有 Python 三方包依赖(仅使用 Python 标准库 `sqlite3`、`argparse`、`asyncio`、`json` 等)
|
||||||
- `jiangchang-platform-kit`、`playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
|
- `jiangchang-platform-kit` 由宿主共享 runtime 提供,不写入 requirements
|
||||||
- `SKILL.md` 的 `platform_kit_min_version` 是运行契约/兼容性声明,**不是** pip 依赖声明。
|
|
||||||
- 版本须尽量收窄(如 `requests>=2.31.0,<3`),避免共享 venv 冲突。
|
|
||||||
- **不要**把系统运行时组件写进 requirements.txt;在 `health` / preflight 中检测并提示。
|
|
||||||
|
|
||||||
## 8. 不在本次范围内的内容
|
## 8. 不在本次范围内的内容
|
||||||
|
|
||||||
明确本次“不做什么”,避免范围蔓延:
|
- 本次不实现分类标签功能
|
||||||
|
- 本次不实现多用户/多工作空间隔离
|
||||||
- 本次不实现 `<功能A>`
|
- 本次不实现截止时间到期的真实推送通知(remind 仅为 async 范式演示,统计后不做真实推送)
|
||||||
- 本次不处理 `<平台B>` / `<档位>`
|
- 本次不实现失败自动重试
|
||||||
- 本次不做 `<高级能力>`
|
|
||||||
- 本次不兼容旧模板历史结构(`docs/`、`optional/`、`skill_main.py` 等)
|
- 本次不兼容旧模板历史结构(`docs/`、`optional/`、`skill_main.py` 等)
|
||||||
|
|
||||||
## 9. 验收标准
|
## 9. 验收标准
|
||||||
|
|
||||||
什么情况下才算开发完成:
|
|
||||||
|
|
||||||
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
|
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
|
||||||
- `health`、`version`、`init-db` 命令执行正常
|
- `health`、`version`、`init-db` 命令执行正常
|
||||||
- `python tests/run_tests.py -v` 必跑测试全部通过(mock / 离线门禁;**mock 通 ≠ 完成**)
|
- `python tests/run_tests.py -v` 必跑测试全部通过
|
||||||
- 主命令在业务默认档(模板为 `real_rpa`)真实跑通;不得只 mock 通就交活
|
- `todo add` / `todo list` / `todo done` / `todo delete` / `todo stats` 命令在本地真实跑通
|
||||||
- 业务表(若有)写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
|
- 业务表 `todos` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
|
||||||
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
|
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;`todos` 表及字段具备中文 `display_name`
|
||||||
- 字段展示顺序与 `PRAGMA table_info(<业务表>)` 的 cid 一致;不依赖 `display_order`
|
- `todos` 表 `readonly=1`,字段 `editable=0`,宿主不直写业务表
|
||||||
|
- `status` / `priority` 字段具备 `options_json` 枚举下拉
|
||||||
|
- 字段展示顺序与 `PRAGMA table_info(todos)` 的 cid 一致;不依赖 `display_order`
|
||||||
- `tests/test_display_metadata.py` 通过
|
- `tests/test_display_metadata.py` 通过
|
||||||
- 真实联调样例仍放 `tests/integration/`,默认套件不包含真实外联(与业务 `.env` 默认 `real_rpa` 不冲突)
|
- `SKILL.md` 的 `developer_ids` 已配置为开发者本人匠厂用户 ID
|
||||||
- `SKILL.md` 的 `developer_ids` 已配置为开发者本人匠厂用户 ID(开发期不公开时,否则本人无法在市场安装自测;取 ID 步骤见 `development/DEVELOPMENT.md` §6)
|
|
||||||
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
|
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
|
||||||
- 安装后按本技能入口定位表与 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15、`PRODUCT_SURFACES.md`);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
|
- 安装后按本技能入口定位表与 `actions.json` 声明完成宿主多入口验收
|
||||||
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或其他规避平台限制的实现
|
|
||||||
- RPA 真实主路径已验证状态等待、`1~3` 秒正常操作节奏、有限重试、账号 / 设备串行、人工介入、结果核验和结束存证
|
|
||||||
- RPA 关键元素已在真实界面拾取并登记 element map;关键动作唯一定位,重启会话后复测通过
|
|
||||||
- 页面/窗口/设备结构变化时停止并保存截图与 DOM/UIA/XML,不运行时猜 selector
|
|
||||||
|
|
||||||
## 10. 开发注意事项
|
## 10. 开发注意事项
|
||||||
|
|
||||||
- 只修改当前 skill 仓库,不要改动无关兄弟项目
|
- 只修改当前 skill 仓库,不要改动无关兄弟项目
|
||||||
- 正常界面或获得授权的官方 API 无法完成闭环时,停止并上报;不得擅自改走破解、逆向或绕过安全机制的路线
|
- `cli` 只做参数解析;核心业务逻辑在 `service/todo_service.py`
|
||||||
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
|
- 单业务内核、多入口:CLI / Action / 数据管理按钮都调用 `todo_service` 的同一函数,禁止按入口分叉业务行为
|
||||||
- 涉及界面时再按端阅读 `RPA_ELEMENT_LOCATING.md` 和 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`
|
- 数据表为只读,所有增删改通过技能 Action 走,宿主不直写
|
||||||
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`)
|
|
||||||
- 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)
|
- 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)
|
||||||
|
|
||||||
## 11. 变更记录
|
## 11. 变更记录
|
||||||
|
|
||||||
| 日期 | 版本 | 变更人 | 变更内容 |
|
| 日期 | 版本 | 变更人 | 变更内容 |
|
||||||
|------|------|--------|----------|
|
|------|------|--------|----------|
|
||||||
| YYYY-MM-DD | v1.0 | `<姓名>` | 初版需求文档 |
|
| 2026-09-07 | v0.1.0 | - | 初版需求文档 |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 建议使用方式
|
|
||||||
|
|
||||||
1. 拿到业务技能仓库(Gitea clone,或本地从 `skill-template` scaffold / 复制;见 `DEVELOPMENT.md` §4)
|
|
||||||
2. 先写 / 填全本文件 `REQUIREMENTS.md`
|
|
||||||
3. 再按 `DEVELOPMENT.md` 进入开发(含尽早配置 `developer_ids`)
|
|
||||||
4. 开发过程中补充 `references/CLI.md`、`references/SCHEMA.md` 及 `development/` 技术规范
|
|
||||||
5. 发布前对照第 9 节验收标准逐项检查;release 后按 `DEVELOPMENT.md` §15 做宿主多入口验收
|
|
||||||
|
|
||||||
## 最小模板示例
|
|
||||||
|
|
||||||
下面给出一个可直接参考的简化版(复制后替换占位符):
|
|
||||||
|
|
||||||
```md
|
|
||||||
# REQUIREMENTS
|
|
||||||
|
|
||||||
## 1. 文档目标
|
|
||||||
|
|
||||||
本文档用于明确 `add-todo-all` 的研发需求、范围和验收标准,作为开发与测试的统一依据。
|
|
||||||
|
|
||||||
## 2. 业务背景
|
|
||||||
|
|
||||||
当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
|
|
||||||
因此需要开发 `add-todo-all`,自动完成 `<核心动作>`,输出 `<输出结果>`。
|
|
||||||
|
|
||||||
## 3. 开发目标
|
|
||||||
|
|
||||||
- 解析并校验输入
|
|
||||||
- 通过 adapter 完成 mock / simulator / real 档位动作
|
|
||||||
- 输出结构化结果;业务结果写入业务表(有才建)
|
|
||||||
- 支持失败可诊断
|
|
||||||
|
|
||||||
## 4. 功能范围
|
|
||||||
|
|
||||||
- 支持 health / version / init-db / run
|
|
||||||
|
|
||||||
## 5. 非功能要求
|
|
||||||
|
|
||||||
- 入口统一为 scripts/main.py
|
|
||||||
- 保持 UTF-8 输出;结构符合 skill-template
|
|
||||||
|
|
||||||
## 6. 输入输出要求
|
|
||||||
|
|
||||||
### 输入
|
|
||||||
|
|
||||||
- target_id、input_id(按业务定义)
|
|
||||||
|
|
||||||
### 输出
|
|
||||||
|
|
||||||
- 成功 JSON / 摘要
|
|
||||||
- ERROR: 稳定错误码
|
|
||||||
- 任务中心终态(`finish`)
|
|
||||||
|
|
||||||
## 7. 依赖的兄弟技能或外部系统
|
|
||||||
|
|
||||||
- `<sibling-skill>`(按需)
|
|
||||||
- `<目标系统>` API 或 sandbox
|
|
||||||
|
|
||||||
## 8. 不在本次范围内的内容
|
|
||||||
|
|
||||||
- 不实现自动重试
|
|
||||||
- 不实现 `<本次不做>`
|
|
||||||
|
|
||||||
## 9. 验收标准
|
|
||||||
|
|
||||||
- 命令可运行;tests/run_tests.py -v 通过
|
|
||||||
- 业务表(若有)符合 SCHEMA;过程看运行日志,进度看任务中心
|
|
||||||
- `developer_ids` 已配置为「你的用户ID」(匠厂设置中复制的正整数)
|
|
||||||
- 正式环境安装验证通过;已声明的宿主入口(新建任务 / 数据管理 / 定时任务 / 任务中心 / 技能详情等)按 DEVELOPMENT §15 与入口定位表自测
|
|
||||||
## 10. 开发注意事项
|
|
||||||
|
|
||||||
- 不修改无关项目;不引入旧模板结构
|
|
||||||
|
|
||||||
## 11. 变更记录
|
|
||||||
|
|
||||||
| 日期 | 版本 | 变更人 | 变更内容 |
|
|
||||||
|------|------|--------|----------|
|
|
||||||
| YYYY-MM-DD | v1.0 | 张三 | 初版 |
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 共享 Python Runtime
|
## 共享 Python Runtime
|
||||||
|
|
||||||
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.11` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored,应由共享 venv 的 site-packages 提供。
|
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.11` 及其传递依赖,含 `playwright`)。 在技能仓库内,不得 vendored `jiangchang_skill_core`,应由共享 venv 的 site-packages 提供。
|
||||||
|
|
||||||
技能根目录 `requirements.txt` **只声明技能特有依赖**;**不要**重复声明 `jiangchang-platform-kit` 或 `playwright`。`SKILL.md` 的 `platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。
|
技能根目录 `requirements.txt` **只声明技能特有依赖**;**不要**重复声明 `jiangchang-platform-kit` 或 `playwright`。`SKILL.md` 的 `platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。
|
||||||
|
|
||||||
|
|||||||
88
examples/README.md
Normal file
88
examples/README.md
Normal 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 与「禁止照抄」章节。
|
||||||
94
examples/mobile_rpa/README.md
Normal file
94
examples/mobile_rpa/README.md
Normal 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. 用户人工验收通过。
|
||||||
35
examples/real_api/README.md
Normal file
35
examples/real_api/README.md
Normal 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/` 仍按模板主目录规则编写
|
||||||
|
- 当前阶段:**不可作为实现参考**
|
||||||
0
examples/real_api/scripts/service/.gitkeep
Normal file
0
examples/real_api/scripts/service/.gitkeep
Normal file
0
examples/real_api/scripts/util/.gitkeep
Normal file
0
examples/real_api/scripts/util/.gitkeep
Normal file
0
examples/real_api/tests/.gitkeep
Normal file
0
examples/real_api/tests/.gitkeep
Normal file
204
examples/real_browser_rpa/README.md
Normal file
204
examples/real_browser_rpa/README.md
Normal 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 入口。
|
||||||
45
examples/real_browser_rpa/assets/element-map.sample.yaml
Normal file
45
examples/real_browser_rpa/assets/element-map.sample.yaml
Normal 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: 待填写
|
||||||
355
examples/real_browser_rpa/scripts/service/account_client.py
Normal file
355
examples/real_browser_rpa/scripts/service/account_client.py
Normal 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)
|
||||||
135
examples/real_browser_rpa/scripts/service/browser_session.py
Normal file
135
examples/real_browser_rpa/scripts/service/browser_session.py
Normal 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
|
||||||
450
examples/real_browser_rpa/scripts/service/human_verification.py
Normal file
450
examples/real_browser_rpa/scripts/service/human_verification.py
Normal 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,
|
||||||
|
)
|
||||||
637
examples/real_browser_rpa/scripts/service/task_rpa.py
Normal file
637
examples/real_browser_rpa/scripts/service/task_rpa.py
Normal 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)
|
||||||
146
examples/real_browser_rpa/scripts/service/task_service.py
Normal file
146
examples/real_browser_rpa/scripts/service/task_service.py
Normal 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
31
examples/real_browser_rpa/scripts/util/constants.py
Normal file
31
examples/real_browser_rpa/scripts/util/constants.py
Normal 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",
|
||||||
|
)
|
||||||
34
examples/real_browser_rpa/scripts/util/logging.py
Normal file
34
examples/real_browser_rpa/scripts/util/logging.py
Normal 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
|
||||||
10
examples/real_browser_rpa/tests/conftest.py
Normal file
10
examples/real_browser_rpa/tests/conftest.py
Normal 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))
|
||||||
63
examples/real_browser_rpa/tests/test_account_client.py
Normal file
63
examples/real_browser_rpa/tests/test_account_client.py
Normal 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()
|
||||||
170
examples/real_browser_rpa/tests/test_human_verification.py
Normal file
170
examples/real_browser_rpa/tests/test_human_verification.py
Normal 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()
|
||||||
34
examples/real_browser_rpa/tests/test_stop_reason.py
Normal file
34
examples/real_browser_rpa/tests/test_stop_reason.py
Normal 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()
|
||||||
152
examples/real_desktop_rpa/README.md
Normal file
152
examples/real_desktop_rpa/README.md
Normal 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 清楚提示用户确认
|
||||||
|
- 提交后仅在业务后置状态成立时报告成功
|
||||||
117
examples/real_desktop_rpa/assets/element-map.sample.yaml
Normal file
117
examples/real_desktop_rpa/assets/element-map.sample.yaml
Normal 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
|
||||||
26
examples/real_desktop_rpa/assets/selector-capture.sample.xml
Normal file
26
examples/real_desktop_rpa/assets/selector-capture.sample.xml
Normal 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>
|
||||||
129
examples/real_desktop_rpa/scripts/service/desktop_session.py
Normal file
129
examples/real_desktop_rpa/scripts/service/desktop_session.py
Normal 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)
|
||||||
127
examples/real_desktop_rpa/scripts/service/uia_locator.py
Normal file
127
examples/real_desktop_rpa/scripts/service/uia_locator.py
Normal 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))
|
||||||
@@ -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": "已填写搜索条件,未发送邀请",
|
||||||
|
}
|
||||||
63
examples/real_desktop_rpa/scripts/util/selectors.py
Normal file
63
examples/real_desktop_rpa/scripts/util/selectors.py
Normal 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",
|
||||||
|
)
|
||||||
99
examples/real_desktop_rpa/tests/test_selector_contract.py
Normal file
99
examples/real_desktop_rpa/tests/test_selector_contract.py
Normal 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()
|
||||||
35
examples/simulator_api/README.md
Normal file
35
examples/simulator_api/README.md
Normal 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/` 仍按模板主目录规则编写
|
||||||
|
- 当前阶段:**不可作为实现参考**
|
||||||
0
examples/simulator_api/scripts/service/.gitkeep
Normal file
0
examples/simulator_api/scripts/service/.gitkeep
Normal file
0
examples/simulator_api/scripts/util/.gitkeep
Normal file
0
examples/simulator_api/scripts/util/.gitkeep
Normal file
0
examples/simulator_api/tests/.gitkeep
Normal file
0
examples/simulator_api/tests/.gitkeep
Normal file
163
examples/simulator_browser_rpa/README.md
Normal file
163
examples/simulator_browser_rpa/README.md
Normal 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。
|
||||||
155
examples/simulator_browser_rpa/sandbox/demo_app.html
Normal file
155
examples/simulator_browser_rpa/sandbox/demo_app.html
Normal 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>
|
||||||
355
examples/simulator_browser_rpa/scripts/service/account_client.py
Normal file
355
examples/simulator_browser_rpa/scripts/service/account_client.py
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
100
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal file
100
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal 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
|
||||||
52
examples/simulator_browser_rpa/scripts/util/constants.py
Normal file
52
examples/simulator_browser_rpa/scripts/util/constants.py
Normal 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()
|
||||||
38
examples/simulator_browser_rpa/scripts/util/logging.py
Normal file
38
examples/simulator_browser_rpa/scripts/util/logging.py
Normal 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
|
||||||
0
examples/simulator_browser_rpa/tests/.gitkeep
Normal file
0
examples/simulator_browser_rpa/tests/.gitkeep
Normal file
10
examples/simulator_browser_rpa/tests/conftest.py
Normal file
10
examples/simulator_browser_rpa/tests/conftest.py
Normal 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))
|
||||||
63
examples/simulator_browser_rpa/tests/test_account_client.py
Normal file
63
examples/simulator_browser_rpa/tests/test_account_client.py
Normal 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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
134
examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py
Normal file
134
examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py
Normal 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()
|
||||||
74
examples/simulator_browser_rpa/tests/test_stop_reason.py
Normal file
74
examples/simulator_browser_rpa/tests/test_stop_reason.py
Normal 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()
|
||||||
@@ -24,7 +24,7 @@ python {baseDir}/scripts/main.py <command> …
|
|||||||
|
|
||||||
| 类型 | 本技能命令(自填) | Agent |
|
| 类型 | 本技能命令(自填) | Agent |
|
||||||
|------|--------------------|-------|
|
|------|--------------------|-------|
|
||||||
| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` |
|
| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `todo` / `remind` | 可用 bash/exec + 共享 `python` |
|
||||||
| **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` |
|
| **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` |
|
||||||
|
|
||||||
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。
|
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
|
|
||||||
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py`;`scripts/db/connection.py` 的 `init_db()` 会一并初始化。
|
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py`;`scripts/db/connection.py` 的 `init_db()` 会一并初始化。
|
||||||
|
|
||||||
|
## 本技能业务表
|
||||||
|
|
||||||
|
| 物理表 | Data Management | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `todos` | 可见 | 待办列表 |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
### 技能负责
|
### 技能负责
|
||||||
|
|||||||
0
scripts/cli/__init__.py
Normal file
0
scripts/cli/__init__.py
Normal file
@@ -1,8 +1,4 @@
|
|||||||
"""CLI:argparse 与分发模板。
|
"""CLI:argparse 解析与分发(业务内核在 service,本层只做参数→service 映射)。"""
|
||||||
|
|
||||||
只负责参数解析并转交 service;业务内核在 domain / task_service,勿在此按入口复制逻辑。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -10,11 +6,17 @@ import sys
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from service.task_service import (
|
from service.task_service import (
|
||||||
|
cmd_add,
|
||||||
cmd_config_path,
|
cmd_config_path,
|
||||||
|
cmd_delete,
|
||||||
|
cmd_done,
|
||||||
cmd_health,
|
cmd_health,
|
||||||
cmd_init_db,
|
cmd_init_db,
|
||||||
cmd_run,
|
cmd_list,
|
||||||
|
cmd_remind,
|
||||||
|
cmd_stats,
|
||||||
cmd_version,
|
cmd_version,
|
||||||
|
cmd_run,
|
||||||
)
|
)
|
||||||
from util.config_bootstrap import bootstrap_skill_config
|
from util.config_bootstrap import bootstrap_skill_config
|
||||||
from util.argparse_zh import ZhArgumentParser
|
from util.argparse_zh import ZhArgumentParser
|
||||||
@@ -29,64 +31,92 @@ def _cli_str_or_none(raw: Optional[str]) -> Optional[str]:
|
|||||||
return v or None
|
return v or None
|
||||||
|
|
||||||
|
|
||||||
def _handle_run(args: argparse.Namespace) -> int:
|
def _parse_ids(raw: str) -> list[int]:
|
||||||
tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()]
|
"""解析 ``1`` 或 ``1,2,3`` 为 int 列表。"""
|
||||||
if len(tail) > 2:
|
parts = [p.strip() for p in (raw or "").split(",") if p.strip()]
|
||||||
print("❌ 参数过多。")
|
try:
|
||||||
print("用法:python main.py run [target [input_id]] | run [-t target] [-i input_id]")
|
return [int(p) for p in parts]
|
||||||
return 1
|
except ValueError as exc:
|
||||||
|
raise SystemExit(f"参数错误:ids 必须是数字或逗号分隔的数字列表,收到 {raw!r}\n{exc}")
|
||||||
t_target: Optional[str] = None
|
|
||||||
t_input: Optional[str] = None
|
|
||||||
if len(tail) == 2:
|
|
||||||
t_target, t_input = tail[0], tail[1]
|
|
||||||
elif len(tail) == 1:
|
|
||||||
if tail[0].isdigit():
|
|
||||||
t_input = tail[0]
|
|
||||||
else:
|
|
||||||
t_target = tail[0]
|
|
||||||
|
|
||||||
pick_t = _cli_str_or_none(getattr(args, "target", None))
|
|
||||||
pick_i = _cli_str_or_none(getattr(args, "input_id", None))
|
|
||||||
target = pick_t or t_target
|
|
||||||
input_id = pick_i or t_input
|
|
||||||
return cmd_run(target=target, input_id=input_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_full_usage() -> None:
|
def _print_full_usage() -> None:
|
||||||
print("通用业务技能模板(main.py)可用命令:")
|
print("通用业务技能模板(main.py)可用命令:")
|
||||||
print(" python main.py run [target [input_id]] [-t target] [-i input_id]")
|
print(" python main.py init-db # 初始化数据库与元数据")
|
||||||
print(" python main.py health")
|
print(" python main.py todo add <title> [-p high|normal|low] [-d <unix秒>")
|
||||||
print(" python main.py config-path")
|
print(" python main.py todo list [-s pending|done] [-n <limit>]")
|
||||||
print(" python main.py version")
|
print(" python main.py todo done <id>")
|
||||||
print(" python main.py init-db")
|
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_run_subparser(sub) -> None:
|
||||||
|
sp = sub.add_parser("todo", help="待办事项操作")
|
||||||
|
todo_sub = sp.add_subparsers(dest="todo_cmd", required=True, parser_class=ZhArgumentParser)
|
||||||
|
|
||||||
|
p = todo_sub.add_parser("add", help="新增一条待办")
|
||||||
|
p.add_argument("-t","--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:
|
def build_parser() -> ZhArgumentParser:
|
||||||
p = ZhArgumentParser(
|
p = ZhArgumentParser(
|
||||||
prog="main.py",
|
prog="main.py",
|
||||||
description="通用业务技能模板:任务执行命令骨架、健康检查、版本输出。",
|
description="待办事项",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
)
|
)
|
||||||
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
|
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
|
||||||
|
|
||||||
sp = sub.add_parser("run", help="任务执行命令骨架")
|
sp = sub.add_parser("init-db", help="幂等初始化 SQLite 与数据管理元数据")
|
||||||
sp.add_argument("--target", "-t", default=None, metavar="目标")
|
sp.set_defaults(handler=lambda _a: cmd_init_db())
|
||||||
sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id")
|
|
||||||
sp.add_argument("run_tail", nargs="*", metavar="位置参数")
|
_add_run_subparser(sub)
|
||||||
sp.set_defaults(handler=_handle_run)
|
_add_remind_subparser(sub)
|
||||||
|
|
||||||
sp = sub.add_parser("health", help="健康检查")
|
sp = sub.add_parser("health", help="健康检查")
|
||||||
sp.set_defaults(handler=lambda _a: cmd_health())
|
sp.set_defaults(handler=lambda _a: cmd_health())
|
||||||
|
|
||||||
sp = sub.add_parser("config-path", help="输出用户 .env 与模板路径")
|
|
||||||
sp.set_defaults(handler=lambda _a: cmd_config_path())
|
|
||||||
|
|
||||||
sp = sub.add_parser("version", help="版本信息(JSON)")
|
sp = sub.add_parser("version", help="版本信息(JSON)")
|
||||||
sp.set_defaults(handler=lambda _a: cmd_version())
|
sp.set_defaults(handler=lambda _a: cmd_version())
|
||||||
|
|
||||||
sp = sub.add_parser("init-db", help="幂等初始化本地数据库与数据管理元数据")
|
sp = sub.add_parser("config-path", help="输出 .env 与模板路径")
|
||||||
sp.set_defaults(handler=lambda _a: cmd_init_db())
|
sp.set_defaults(handler=lambda _a: cmd_config_path())
|
||||||
|
|
||||||
|
sp = sub.add_parser("run", help="运行占位符")
|
||||||
|
sp.set_defaults(handler=lambda _a: cmd_run())
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@@ -98,11 +128,14 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
if not argv:
|
if not argv:
|
||||||
_print_full_usage()
|
_print_full_usage()
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if len(argv) == 2 and argv[0] not in {
|
if len(argv) == 2 and argv[0] not in {
|
||||||
"run", "health", "config-path", "version", "init-db",
|
"run", "health", "config-path", "version", "init-db",
|
||||||
"-h", "--help",
|
"-h", "--help",
|
||||||
}:
|
}:
|
||||||
return cmd_run(target=argv[0], input_id=argv[1])
|
return cmd_run(target=argv[0], input_id=argv[1])
|
||||||
|
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
return int(args.handler(args))
|
return int(args.handler(args))
|
||||||
|
|
||||||
|
|||||||
0
scripts/db/__init__.py
Normal file
0
scripts/db/__init__.py
Normal file
@@ -1,25 +1,108 @@
|
|||||||
"""SQLite 连接与幂等初始化模板。
|
"""SQLite 连接与幂等初始化。
|
||||||
|
|
||||||
模板默认只建数据管理元数据表。业务表由复制后的技能自行 CREATE,并同步展示元数据。
|
``init_db`` 建两张表:
|
||||||
|
1. ``todos`` — 业务表(demo:待办事项)
|
||||||
|
2. ``_jiangchang_tables`` / ``_jiangchang_columns`` — 宿主数据管理展示元数据
|
||||||
|
|
||||||
|
业务表字段顺序就是宿主列展示顺序(cid 顺序)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from db.display_metadata import init_display_metadata
|
from db.display_metadata import (
|
||||||
|
init_display_metadata_tables,
|
||||||
|
seed_column_metadata_batch,
|
||||||
|
upsert_table_metadata,
|
||||||
|
)
|
||||||
from util.runtime_paths import get_db_path
|
from util.runtime_paths import get_db_path
|
||||||
|
from db.timestamp_columns import init_updated_at_trigger
|
||||||
|
|
||||||
|
ALL_BUSINESS_TABLES:tuple[str, ...] = ("todos")
|
||||||
|
|
||||||
|
# ---- 业务表 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 DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_TODOS_INDEX = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_conn() -> sqlite3.Connection:
|
def get_conn() -> sqlite3.Connection:
|
||||||
return sqlite3.connect(get_db_path())
|
return sqlite3.connect(get_db_path())
|
||||||
|
|
||||||
|
|
||||||
|
def _init_business_timestamp_triggers(cursor) -> None:
|
||||||
|
""" for table_name in ALL_BUSINESS_TABLES:
|
||||||
|
init_updated_at_trigger(cursor, table_name) """
|
||||||
|
init_updated_at_trigger(cursor, "todos")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_todos_display_metadata(cursor) -> None:
|
||||||
|
"""写 ``todos`` 表与字段的中文展示元数据。"""
|
||||||
|
upsert_table_metadata(
|
||||||
|
cursor,
|
||||||
|
table_name="todos",
|
||||||
|
display_name="待办事项",
|
||||||
|
description="本技能的核心业务表:每条待办事项一行。",
|
||||||
|
grid_help="所有的待办事项,包括待办、已办、已删除。",
|
||||||
|
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:
|
def init_db() -> None:
|
||||||
|
"""幂等创建/迁移本地 SQLite 与数据管理元数据。"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
init_display_metadata(cur)
|
# 1. 元数据表(先建,方便其它步骤 upsert)
|
||||||
|
init_display_metadata_tables(cur)
|
||||||
|
# 2. 业务表
|
||||||
|
cur.execute(_TODOS_DDL)
|
||||||
|
cur.execute(_TODOS_INDEX)
|
||||||
|
_init_business_timestamp_triggers(cur)
|
||||||
|
# 3. 业务表展示元数据
|
||||||
|
_seed_todos_display_metadata(cur)
|
||||||
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from db.timestamp_columns import has_updated_at_trigger
|
|||||||
from util.constants import SKILL_SLUG
|
from util.constants import SKILL_SLUG
|
||||||
from util.slug_naming import LEGACY_EXEMPT_SLUGS, validate_slug_semantics
|
from util.slug_naming import LEGACY_EXEMPT_SLUGS, validate_slug_semantics
|
||||||
|
|
||||||
_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"add-todo-all", "add_todo_all"})
|
_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"})
|
||||||
_ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
|
_ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
|
||||||
re.compile(r"^my\s+skill$", re.I),
|
re.compile(r"^my\s+skill$", re.I),
|
||||||
re.compile(r"^your[-_\s]?skill", re.I),
|
re.compile(r"^your[-_\s]?skill", re.I),
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""待办事项 CLI 入口。
|
||||||
skill-template CLI 入口。
|
|
||||||
|
|
||||||
复制为新技能后,请修改注释与常量,但保留当前分层结构:
|
分层:cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。
|
||||||
cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。
|
|
||||||
|
|
||||||
jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit,
|
独立运行说明:
|
||||||
不在技能仓库内 vendored。
|
真实技能依赖宿主共享 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 os
|
||||||
import sys
|
import sys
|
||||||
|
from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
|
||||||
|
import logging
|
||||||
|
from util.logging_config import setup_skill_logging, get_skill_logger
|
||||||
|
|
||||||
if sys.platform == "win32":
|
logger = logging.getLogger(__name__)
|
||||||
import io
|
|
||||||
|
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
||||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
if _scripts_dir not in sys.path:
|
|
||||||
sys.path.insert(0, _scripts_dir)
|
|
||||||
|
|
||||||
from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
|
|
||||||
|
|
||||||
apply_cli_local_defaults()
|
apply_cli_local_defaults()
|
||||||
|
|
||||||
from util.config_bootstrap import bootstrap_skill_config
|
from util.config_bootstrap import bootstrap_skill_config # noqa: E402
|
||||||
|
|
||||||
bootstrap_skill_config()
|
bootstrap_skill_config()
|
||||||
|
setup_skill_logging("add-todo-all", __name__)
|
||||||
|
|
||||||
from cli.app import main
|
|
||||||
|
|
||||||
|
|
||||||
|
from cli.app import main # noqa: E402
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
get_skill_logger().info("Hello, world!")
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
0
scripts/service/__init__.py
Normal file
0
scripts/service/__init__.py
Normal file
@@ -1,18 +1,19 @@
|
|||||||
"""任务编排模板。
|
"""任务编排层(对齐模板 task_service.py 的职责分工)。
|
||||||
|
|
||||||
编排层:job_context / emit / checkpoint / finish、鉴权与错误转换。
|
- ``cmd_health`` / ``cmd_version`` / ``cmd_config_path`` / ``cmd_init_db``:标准 CLI;
|
||||||
过程看统一文件日志;进度/终态看任务中心;业务结果写入业务表(有才建)。
|
- ``cmd_remind``:演示 async 任务范式(``job_context`` + ``emit`` + ``finish``),
|
||||||
领域业务请放在 ``service/<domain>_service.py``,由本模块调用;多入口(CLI/Action/cron)复用同一内核。
|
真实平台会推送到任务中心 UI;本 demo 在 stdout 打印 JSON Lines 模拟。
|
||||||
复制后在 cmd_run(或 cmd_sync_*)中接线到真实 domain service,勿按 toolbar/cron/agent 分叉业务。
|
|
||||||
|
业务内核(CRUD)在 ``todo_service``,本模块只做编排,不复制业务逻辑。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines
|
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 jiangchang_skill_core.activity import JobStopped, emit, finish, job_context
|
||||||
@@ -29,6 +30,9 @@ from util.logging_config import get_skill_logger, setup_skill_logging
|
|||||||
from util.runtime_paths import get_db_path, get_skill_data_dir, get_skill_root
|
from util.runtime_paths import get_db_path, get_skill_data_dir, get_skill_root
|
||||||
from db.connection import init_db
|
from db.connection import init_db
|
||||||
|
|
||||||
|
from service import todo_service
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_task_logger():
|
def _get_task_logger():
|
||||||
try:
|
try:
|
||||||
@@ -146,19 +150,16 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 标准命令(sync,当场返回) ----------------
|
||||||
|
|
||||||
def cmd_config_path() -> int:
|
def cmd_config_path() -> int:
|
||||||
example_path = os.path.join(get_skill_root(), ".env.example")
|
example_path = os.path.join(get_skill_root(), ".env.example")
|
||||||
env_path = config.get_env_file_path() or ""
|
env_path = config.get_env_file_path() or ""
|
||||||
print(
|
print(json.dumps(
|
||||||
json.dumps(
|
{"skill": SKILL_SLUG, "env_path": env_path, "example_path": os.path.abspath(example_path)},
|
||||||
{
|
ensure_ascii=False,
|
||||||
"skill": SKILL_SLUG,
|
))
|
||||||
"env_path": env_path,
|
|
||||||
"example_path": os.path.abspath(example_path),
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -179,6 +180,7 @@ def cmd_health() -> int:
|
|||||||
f"env_path: {env_path}",
|
f"env_path: {env_path}",
|
||||||
f"env_exists: {env_exists}",
|
f"env_exists: {env_exists}",
|
||||||
f"example_path: {os.path.abspath(example_path)}",
|
f"example_path: {os.path.abspath(example_path)}",
|
||||||
|
f"db_path: {get_db_path()}",
|
||||||
]
|
]
|
||||||
for line in lines:
|
for line in lines:
|
||||||
print(line)
|
print(line)
|
||||||
@@ -191,25 +193,118 @@ def cmd_version() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_init_db() -> int:
|
def cmd_init_db() -> int:
|
||||||
"""幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。"""
|
"""幂等创建本地 SQLite 与数据管理元数据。"""
|
||||||
try:
|
try:
|
||||||
init_db()
|
init_db()
|
||||||
except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败
|
except Exception as exc: # CLI 边界统一成可机读失败
|
||||||
print(
|
print(json.dumps(
|
||||||
json.dumps(
|
{"ok": False, "skill": SKILL_SLUG, "error": str(exc)},
|
||||||
{"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,
|
ensure_ascii=False,
|
||||||
)
|
))
|
||||||
)
|
return 1
|
||||||
|
print(json.dumps(
|
||||||
|
{"ok": True, "skill": SKILL_SLUG, "db_path": get_db_path()},
|
||||||
|
ensure_ascii=False,
|
||||||
|
))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 业务命令(sync) ----------------
|
||||||
|
|
||||||
|
def cmd_list(*, status: Optional[str] = None, limit: int = 100) -> int:
|
||||||
|
todos = todo_service.list_todos(status=status, limit=limit)
|
||||||
|
_get_task_logger().info(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:
|
||||||
|
_get_task_logger().error(f"ERROR:TODO_INVALID: {exc}")
|
||||||
|
return 1
|
||||||
|
_get_task_logger().info(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)})
|
||||||
|
_get_task_logger().info(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})
|
||||||
|
_get_task_logger().info(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 interruptible_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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
176
scripts/service/todo_service.py
Normal file
176
scripts/service/todo_service.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""待办事项领域服务(业务内核)。
|
||||||
|
|
||||||
|
按模板铁律:「单业务内核、多入口」——CLI / Action / 数据管理按钮 / 定时任务
|
||||||
|
都调用这里的同一个函数,禁止按入口分叉业务行为。
|
||||||
|
|
||||||
|
直接用 sqlite3 做持久化(demo 简化,未抽 repository)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from db.connection import get_conn
|
||||||
|
from util.logging_config import get_skill_logger, setup_skill_logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 业务常量(不暴露给宿主,技能自定)
|
||||||
|
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:
|
||||||
|
logger.warning("标题不能为空")
|
||||||
|
raise ValueError("标题不能为空")
|
||||||
|
if priority not in VALID_PRIORITY:
|
||||||
|
logger.warning(f"优先级不合法,可选:{sorted(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:
|
||||||
|
logger.warning(f"状态不合法,可选:{sorted(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:
|
||||||
|
logger.warning(f"待办 {todo_id} 不存在")
|
||||||
|
raise ValueError(f"待办 {todo_id} 不存在")
|
||||||
|
if todo.status == STATUS_DONE:
|
||||||
|
logger.warning(f"待办 {todo_id} 已是完成状态")
|
||||||
|
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()
|
||||||
0
scripts/util/__init__.py
Normal file
0
scripts/util/__init__.py
Normal file
@@ -1,5 +1,4 @@
|
|||||||
"""argparse 中文错误说明。"""
|
"""argparse 中文错误说明(与模板一致)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""技能配置初始化:.env.example 落盘与用户 .env 缺失项合并。"""
|
"""配置初始化:从 .env.example 复制到用户 .env,并合并缺失键。
|
||||||
|
|
||||||
|
行为对齐模板:``main.py`` 与 ``cli.app.main()`` 启动时均会调用一次。
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -11,8 +13,11 @@ from util.runtime_paths import get_skill_root
|
|||||||
|
|
||||||
|
|
||||||
def bootstrap_skill_config() -> str:
|
def bootstrap_skill_config() -> str:
|
||||||
"""确保用户数据目录 .env 存在,并追加 .env.example 中的新配置项。"""
|
"""确保用户 .env 存在,并追加 .env.example 中的新配置项。"""
|
||||||
example_path = os.path.join(get_skill_root(), ".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)
|
env_path = config.ensure_env_file(SKILL_SLUG, example_path)
|
||||||
comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None
|
comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None
|
||||||
config.merge_missing_env_keys(example_path, env_path, comment_skill=comment)
|
config.merge_missing_env_keys(example_path, env_path, comment_skill=comment)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
"""技能标识与版本(待办事项)。
|
||||||
|
|
||||||
|
真实技能需保证 slug = 目录名 = DB 文件名 = ``metadata.openclaw.slug``。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# 英文 kebab-case 机器标识;与目录名、actions.json 的 ``skill`` 字段一致
|
||||||
SKILL_SLUG = "add-todo-all"
|
SKILL_SLUG = "add-todo-all"
|
||||||
SKILL_VERSION = "1.0.98"
|
SKILL_VERSION = "0.1.0"
|
||||||
LOG_LOGGER_NAME = "openclaw.skill.add_todo_all"
|
LOG_LOGGER_NAME = "openclaw.skill.add-todo-all"
|
||||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||||
|
|
||||||
|
#定义业务表
|
||||||
|
TODOS_TABLE = "todos"
|
||||||
|
BUSINESS_TABLES:tuple[str, ...] = (TODOS_TABLE,)
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
|
"""日志:直接用 stdlib logging(真实模板用 jiangchang_skill_core.unified_logging)。
|
||||||
|
|
||||||
from jiangchang_skill_core.unified_logging import (
|
为保持与模板 ``from util.logging_config import get_skill_logger, setup_skill_logging``
|
||||||
attach_unified_file_handler,
|
一致的调用面,本 demo 提供同名薄封装。
|
||||||
ensure_trace_for_process,
|
"""
|
||||||
get_skill_log_file_path,
|
from __future__ import annotations
|
||||||
get_skill_logger,
|
|
||||||
get_unified_logs_dir,
|
|
||||||
setup_skill_logging,
|
|
||||||
subprocess_env_with_trace,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
import logging
|
||||||
"attach_unified_file_handler",
|
|
||||||
"ensure_trace_for_process",
|
import jiangchang_skill_core.unified_logging as unified_logging
|
||||||
"get_skill_log_file_path",
|
|
||||||
"get_skill_logger",
|
from util.constants import LOG_LOGGER_NAME
|
||||||
"get_unified_logs_dir",
|
|
||||||
"setup_skill_logging",
|
_configured = False
|
||||||
"subprocess_env_with_trace",
|
|
||||||
]
|
|
||||||
|
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
|
||||||
|
unified_logging.setup_skill_logging(skill_slug, logger_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_logger() -> logging.Logger:
|
||||||
|
#return logging.getLogger(LOG_LOGGER_NAME)
|
||||||
|
return unified_logging.get_skill_logger()
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""数据根、技能目录、兄弟技能根路径与用户数据子目录解析。"""
|
"""技能数据目录与 DB 路径解析(简化版)。
|
||||||
|
|
||||||
|
真实模板用 ``jiangchang_skill_core.runtime_env``;本 demo 复用其 mock。
|
||||||
|
数据目录 = ``{data_root}/{user_id}/{slug}/``,DB 即 ``{slug}.db``。
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from jiangchang_skill_core.runtime_env import get_data_root, get_sibling_skills_root, get_user_id
|
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
|
||||||
|
|
||||||
from util.constants import SKILL_SLUG
|
from util.constants import SKILL_SLUG
|
||||||
|
|
||||||
@@ -32,28 +35,22 @@ CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR"
|
|||||||
|
|
||||||
|
|
||||||
def get_skill_root() -> str:
|
def get_skill_root() -> str:
|
||||||
|
"""技能根目录(含 SKILL.md / scripts/ 的目录)。"""
|
||||||
return os.path.dirname(_SCRIPTS_DIR)
|
return os.path.dirname(_SCRIPTS_DIR)
|
||||||
|
|
||||||
|
|
||||||
def get_openclaw_root() -> str:
|
|
||||||
return get_sibling_skills_root(_SCRIPTS_DIR)
|
|
||||||
|
|
||||||
|
|
||||||
def get_skills_root() -> str:
|
|
||||||
return get_sibling_skills_root(_SCRIPTS_DIR)
|
|
||||||
|
|
||||||
|
|
||||||
def get_skill_data_dir() -> str:
|
def get_skill_data_dir() -> str:
|
||||||
|
"""本技能数据目录:``{data_root}/{user_id}/{slug}/``。"""
|
||||||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_db_path(filename: str | None = None) -> str:
|
def get_db_path(filename: str | None = None) -> str:
|
||||||
|
"""SQLite 文件路径,默认 ``{slug}.db``。"""
|
||||||
name = filename or f"{SKILL_SLUG}.db"
|
name = filename or f"{SKILL_SLUG}.db"
|
||||||
return os.path.join(get_skill_data_dir(), name)
|
return os.path.join(get_skill_data_dir(), name)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_subdir(value: str) -> str:
|
def _normalize_subdir(value: str) -> str:
|
||||||
cleaned = value.strip().strip("/\\")
|
cleaned = value.strip().strip("/\\")
|
||||||
if not cleaned or cleaned.startswith(".."):
|
if not cleaned or cleaned.startswith(".."):
|
||||||
@@ -153,3 +150,4 @@ def list_resolved_data_paths() -> dict[str, str]:
|
|||||||
"temp_dir": get_temp_dir(create=False),
|
"temp_dir": get_temp_dir(create=False),
|
||||||
"videos_dir": get_videos_dir(create=False),
|
"videos_dir": get_videos_dir(create=False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset(
|
|||||||
{
|
{
|
||||||
"account-manager",
|
"account-manager",
|
||||||
"skill-template",
|
"skill-template",
|
||||||
"add-todo-all",
|
"your-skill-slug",
|
||||||
"add_todo_all",
|
"your_skill_slug",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
1
start_vent.ps1
Normal file
1
start_vent.ps1
Normal file
@@ -0,0 +1 @@
|
|||||||
|
D:\jiangchang-data\python-runtime\.venv\Scripts\Activate.ps1
|
||||||
@@ -355,8 +355,10 @@ class TestActionsManifest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_bindable_business_tables_helper_loads_template_db(self) -> None:
|
def test_bindable_business_tables_helper_loads_template_db(self) -> None:
|
||||||
"""helper 本身必须可用:模板默认无业务表,隔离库仍应可读且不含元数据表。"""
|
"""helper 本身必须可用:模板默认无业务表,隔离库仍应可读且不含元数据表。"""
|
||||||
|
from util.constants import BUSINESS_TABLES
|
||||||
|
|
||||||
bindable = _load_bindable_business_tables()
|
bindable = _load_bindable_business_tables()
|
||||||
self.assertEqual(bindable, set())
|
self.assertEqual(bindable, set(BUSINESS_TABLES))
|
||||||
self.assertNotIn("_jiangchang_tables", bindable)
|
self.assertNotIn("_jiangchang_tables", bindable)
|
||||||
self.assertNotIn("_jiangchang_columns", bindable)
|
self.assertNotIn("_jiangchang_columns", bindable)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class TestDbSmoke(unittest.TestCase):
|
|||||||
with IsolatedDataRoot():
|
with IsolatedDataRoot():
|
||||||
from db.connection import get_conn, init_db
|
from db.connection import get_conn, init_db
|
||||||
from db.display_metadata import METADATA_TABLES, iter_user_tables
|
from db.display_metadata import METADATA_TABLES, iter_user_tables
|
||||||
|
from util.constants import BUSINESS_TABLES
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
init_db()
|
init_db()
|
||||||
@@ -26,9 +27,9 @@ class TestDbSmoke(unittest.TestCase):
|
|||||||
self.assertIn("_jiangchang_tables", tables)
|
self.assertIn("_jiangchang_tables", tables)
|
||||||
self.assertIn("_jiangchang_columns", tables)
|
self.assertIn("_jiangchang_columns", tables)
|
||||||
self.assertNotIn("task_logs", tables)
|
self.assertNotIn("task_logs", tables)
|
||||||
self.assertEqual(list(iter_user_tables(cur)), [])
|
self.assertEqual(set(iter_user_tables(cur)), set(BUSINESS_TABLES))
|
||||||
leftover = tables - METADATA_TABLES
|
leftover = set(tables) - set(METADATA_TABLES)
|
||||||
self.assertEqual(leftover, set())
|
self.assertEqual(leftover, set(BUSINESS_TABLES))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class TestDisplayMetadata(unittest.TestCase):
|
|||||||
def test_init_db_metadata_idempotent(self) -> None:
|
def test_init_db_metadata_idempotent(self) -> None:
|
||||||
with IsolatedDataRoot():
|
with IsolatedDataRoot():
|
||||||
from db.connection import get_conn, init_db
|
from db.connection import get_conn, init_db
|
||||||
|
from util.constants import BUSINESS_TABLES
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
init_db()
|
init_db()
|
||||||
@@ -27,9 +28,9 @@ class TestDisplayMetadata(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
|
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
|
||||||
self.assertEqual(int(cur.fetchone()[0]), 0)
|
self.assertEqual(int(cur.fetchone()[0]), len(BUSINESS_TABLES))
|
||||||
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
|
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
|
||||||
self.assertEqual(int(cur.fetchone()[0]), 0)
|
self.assertGreater(int(cur.fetchone()[0]), 0)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
|
||||||
)
|
)
|
||||||
@@ -203,12 +204,13 @@ class TestDisplayMetadata(unittest.TestCase):
|
|||||||
with IsolatedDataRoot():
|
with IsolatedDataRoot():
|
||||||
from db.connection import get_conn, init_db
|
from db.connection import get_conn, init_db
|
||||||
from db.display_metadata import iter_user_tables
|
from db.display_metadata import iter_user_tables
|
||||||
|
from util.constants import BUSINESS_TABLES
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
tables = list(iter_user_tables(conn.cursor()))
|
tables = list(iter_user_tables(conn.cursor()))
|
||||||
self.assertEqual(tables, [])
|
self.assertEqual(set(tables), set(BUSINESS_TABLES))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -321,13 +321,13 @@ class TestDocsStandards(unittest.TestCase):
|
|||||||
def test_tools_readme_documents_actions_scaffold(self) -> None:
|
def test_tools_readme_documents_actions_scaffold(self) -> None:
|
||||||
text = self._read("tools/README.md")
|
text = self._read("tools/README.md")
|
||||||
self.assertIn("actions.json", text)
|
self.assertIn("actions.json", text)
|
||||||
self.assertIn("add-todo-all", text)
|
self.assertIn("your-skill-slug", text)
|
||||||
self.assertIn("add_todo_all", text)
|
self.assertIn("your_skill_slug", text)
|
||||||
|
|
||||||
def test_development_md_documents_both_slug_placeholders(self) -> None:
|
def test_development_md_documents_both_slug_placeholders(self) -> None:
|
||||||
text = self._read("development/DEVELOPMENT.md")
|
text = self._read("development/DEVELOPMENT.md")
|
||||||
self.assertIn("add-todo-all", text)
|
self.assertIn("your-skill-slug", text)
|
||||||
self.assertIn("add_todo_all", text)
|
self.assertIn("your_skill_slug", text)
|
||||||
|
|
||||||
def test_schema_md_documents_data_management_contract(self) -> None:
|
def test_schema_md_documents_data_management_contract(self) -> None:
|
||||||
text = self._read("references/SCHEMA.md")
|
text = self._read("references/SCHEMA.md")
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import unittest
|
|||||||
from _support import get_skill_root
|
from _support import get_skill_root
|
||||||
|
|
||||||
TEMPLATE_MARKER = ".openclaw-skill-template"
|
TEMPLATE_MARKER = ".openclaw-skill-template"
|
||||||
TEMPLATE_SLUG_KEBAB = "add-todo-all"
|
TEMPLATE_SLUG_KEBAB = "your-skill-slug"
|
||||||
TEMPLATE_SLUG_UNDERSCORE = "add_todo_all"
|
TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug"
|
||||||
SCAFFOLD_TEST_SLUG = "query-demo-records"
|
SCAFFOLD_TEST_SLUG = "query-demo-records"
|
||||||
SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records"
|
SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records"
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ class TestSkillMetadata(unittest.TestCase):
|
|||||||
|
|
||||||
if skill_dir == "skill-template":
|
if skill_dir == "skill-template":
|
||||||
# 模板仓库本体:允许占位 slug
|
# 模板仓库本体:允许占位 slug
|
||||||
self.assertEqual(SKILL_SLUG, "add-todo-all")
|
self.assertEqual(SKILL_SLUG, "your-skill-slug")
|
||||||
else:
|
else:
|
||||||
self.assertNotEqual(SKILL_SLUG, "add-todo-all", "复制为真实技能后不得保留占位 slug")
|
self.assertNotEqual(SKILL_SLUG, "your-skill-slug", "复制为真实技能后不得保留占位 slug")
|
||||||
self.assertEqual(SKILL_SLUG, skill_dir)
|
self.assertEqual(SKILL_SLUG, skill_dir)
|
||||||
self.assertEqual(slug_md, skill_dir)
|
self.assertEqual(slug_md, skill_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class TestSlugNaming(unittest.TestCase):
|
|||||||
self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope")
|
self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope")
|
||||||
|
|
||||||
def test_classify_legacy_exempt(self) -> None:
|
def test_classify_legacy_exempt(self) -> None:
|
||||||
segments = parse_slug_segments("add-todo-all")
|
segments = parse_slug_segments("your-skill-slug")
|
||||||
self.assertEqual(classify_slug_form(segments, slug="add-todo-all"), "legacy_exempt")
|
self.assertEqual(classify_slug_form(segments, slug="your-skill-slug"), "legacy_exempt")
|
||||||
|
|
||||||
def test_valid_hyphenated_platform_slug(self) -> None:
|
def test_valid_hyphenated_platform_slug(self) -> None:
|
||||||
slug = "fill-export-declaration-single-window"
|
slug = "fill-export-declaration-single-window"
|
||||||
@@ -79,7 +79,7 @@ class TestSlugNaming(unittest.TestCase):
|
|||||||
self.assertTrue(any("48" in err for err in errors))
|
self.assertTrue(any("48" in err for err in errors))
|
||||||
|
|
||||||
def test_legacy_exempt_set(self) -> None:
|
def test_legacy_exempt_set(self) -> None:
|
||||||
self.assertIn("add-todo-all", LEGACY_EXEMPT_SLUGS)
|
self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS)
|
||||||
self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS)
|
self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
|
|||||||
- **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc`
|
- **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc`
|
||||||
- 删除目标中的 `.openclaw-skill-template` 标记
|
- 删除目标中的 `.openclaw-skill-template` 标记
|
||||||
- 将文本文件中的模板占位 slug 替换为新 slug:
|
- 将文本文件中的模板占位 slug 替换为新 slug:
|
||||||
- `add-todo-all` → 新技能 kebab-case slug(如 `scrape-demo-records`)
|
- `your-skill-slug` → 新技能 kebab-case slug(如 `scrape-demo-records`)
|
||||||
- `add_todo_all` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等)
|
- `your_skill_slug` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等)
|
||||||
- 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符
|
- 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符
|
||||||
- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action
|
- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action
|
||||||
- **不**移除 Schema 中的 `bind.tables` 支持,**不**把 `bind` 当模板残留清理
|
- **不**移除 Schema 中的 `bind.tables` 支持,**不**把 `bind` 当模板残留清理
|
||||||
|
|||||||
Reference in New Issue
Block a user