chore: auto release commit (2026-09-14 22:22:49)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
5
.openclaw-skill-template
Normal file
5
.openclaw-skill-template
Normal file
@@ -0,0 +1,5 @@
|
||||
# 此文件仅存在于 skill-template 源仓库。
|
||||
# 用 tools/scaffold_skill 创建新技能时,脚本会从目标目录删除本文件。
|
||||
# 若业务技能仓库中仍存在本文件,说明未正确 scaffold,tests 会失败。
|
||||
template_slug: your-skill-slug
|
||||
template_version: "1.0.32"
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,30 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
本文件对应技能市场 **「更新日志」** Tab。
|
||||
发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。
|
||||
本文件对应技能市场 **「更新日志」** Tab。
|
||||
发布 CI 会按当前 tag 版本(如 `v1.0.3` → `1.0.3`)提取匹配的 `##` 小节,写入该版本的 `changelog`。
|
||||
|
||||
约定:
|
||||
|
||||
- 每个正式版本一节,标题形如 `## 1.0.3` 或 `## [1.0.3] - YYYY-MM-DD`
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
---
|
||||
|
||||
## 1.0.4
|
||||
|
||||
- RPA 流程优化:轮询间隔 5s→1s,填表改用 fill(),去掉冗余等待,整体提速约 3-5 倍
|
||||
- 支持 Profile 持久化复用登录态,已登录场景秒级完成
|
||||
- 修复两层登录(sandbox 门户 + 智慧校园)在新 tab 场景下的 page 引用问题
|
||||
- 完善开发文档骨架:tests、examples、development、assets
|
||||
|
||||
## 1.0.3
|
||||
|
||||
- 修复SKILL.md中RPA功能声明与实际实现不一致的问题
|
||||
- 更新actions.json,添加RPA相关的action配置
|
||||
- 更新用户意图路由表,添加RPA功能相关的用户意图
|
||||
- 更新硬规则,添加RPA功能相关的安全规则
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- 新增RPA(机器人流程自动化)功能,支持从网站自动提取发票信息
|
||||
- 新增CLI命令:`extract-invoices`、`test-rpa-connection`、`list-rpa-invoices`、`show-rpa-invoice`、`rpa-invoice-stats`
|
||||
- 新增RPA数据存储功能,提取的发票数据自动保存到数据库
|
||||
- 新增RPA数据服务模块,支持查询和管理RPA提取的数据
|
||||
- 新增平台内使用指南,支持CLI、Action、对话三种使用方式
|
||||
- 优化数据存储架构,新增`rpa_invoices`表存储RPA提取的数据
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- 首个版本:票据合规性"秒级预审"上线
|
||||
- 四步审核:字段完整性校验、税号白名单比对、开票日期与出差行程逻辑、住宿费/交通席位等级标准比对
|
||||
- 风控预警:连号发票、同商家集中开票、节假日/周末开票
|
||||
- 输出《初审意见单》HTML 报告(数据管理侧栏可预览、可重建),预审历史入库
|
||||
- 支持发票真伪人工查验结果回填(回填"不符"自动升级为驳回)
|
||||
- 内置示例经费标准库、税号白名单与节假日种子数据,均可在数据管理中维护
|
||||
- 宿主入口:对话任务(预审/查询/统计/批量)、数据管理(表单预审、行级查验回填、标准与白名单维护、HTML 报告)
|
||||
- 初始版本:RPA 自动登录 sandbox.jc2009.com → 进入智慧校园 → 票据合规预审页面 → 提取发票数据
|
||||
- 支持两层登录:sandbox 门户(手机号+密码)+ 智慧校园师生登录(demo 账号)
|
||||
- 支持 Profile 持久化,复用登录态
|
||||
- CLI 命令:`extract-invoices`、`test-rpa-connection`、`prereview`、`render-html` 等
|
||||
|
||||
29
DEMO.md
29
DEMO.md
@@ -2,21 +2,26 @@
|
||||
demo_video_url: ""
|
||||
---
|
||||
|
||||
演示视频录制后请把地址填入上方 `demo_video_url`。以下是无视频时的图文演示脚本。
|
||||
# 演示说明
|
||||
|
||||
## 演示一:报销前自查一张住宿费发票(对话)
|
||||
演示票据合规预审助手的 RPA 发票提取流程:从登录 sandbox 平台到提取出发票数据的完整过程。
|
||||
|
||||
1. 对话任务中说:"帮我预审一张住宿费发票:号码 24312000000012345678,2026-08-18 开票,金额 1650 元,购买方广州华商学院,销售方广州某某酒店;8 月 17-20 日出差广州,副高及以上。"
|
||||
2. 助手返回三色清单:✅ 字段齐全、✅ 税号在白名单、✅ 日期在行程内、✅ 住宿费 550 元/人/天未超标;⚠️ 提醒去查验平台核验真伪 → 结论"待确认"。
|
||||
3. 打开数据管理"初审意见"表,点行级「回填查验结果」选"通过",结论保持待确认但真伪状态更新。
|
||||
## 演示流程
|
||||
|
||||
## 演示二:一眼看出为什么被驳回(数据管理)
|
||||
1. 打开 sandbox.jc2009.com,自动登录门户(手机号+密码)
|
||||
2. 在平台首页找到并点击「智慧校园」
|
||||
3. 登录智慧校园系统(demo 账号)
|
||||
4. 导航到「财务处 → 票据合规预审」
|
||||
5. 页面自动提取:报销事由、预算项目、差旅审批单、发票列表(发票号、金额、类型、公司名、日期、税号、验真状态)
|
||||
|
||||
1. 表顶栏「票据合规预审」提交一张抬头税号错误的发票(金额也超标准)。
|
||||
2. 结论"驳回":❌ 购买方税号不在白名单、❌ 住宿费 1200 元/人/天超过 480 元上限,均附《差旅费管理办法》条款引用。
|
||||
3. 顶栏「生成初审意见单」→ 侧栏打开 `report-N.html`,三色清单排版一目了然。
|
||||
## 运行命令
|
||||
|
||||
## 演示三:维护标准与统计(管理员)
|
||||
```powershell
|
||||
$env:PYTHONPATH="D:\jiangchang\huashang\jiangchang-platform-kit\jiangchang-platform-kit\src"
|
||||
cd D:\jiangchang\huashang\verify-invoice-default\verify-invoice-default
|
||||
python scripts\main.py extract-invoices --username 15527406659 --password qwer1234 --profile-dir "$env:TEMP\verify-invoice-edge-profile"
|
||||
```
|
||||
|
||||
1. 在"经费标准库"表把"一线城市/副高及以上"的住宿上限改为学校最新标准,注明依据文件与条款。
|
||||
2. 对话里问"预审统计一下",查看通过率、驳回率与集中开票商家排行。
|
||||
## 没有视频时
|
||||
|
||||
有视频后把 `demo_video_url` 填上即可,正文可保留为补充说明。
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
# 平台内使用RPA功能的完整指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本指南详细说明如何将带RPA功能的verify-invoice-default技能发布到平台,并在平台内使用RPA功能。
|
||||
|
||||
## 🎯 平台内使用方式
|
||||
|
||||
### 1. **通过CLI命令使用**(推荐)
|
||||
|
||||
在平台内,用户可以通过以下CLI命令使用RPA功能:
|
||||
|
||||
#### **1.1 提取发票信息**
|
||||
```bash
|
||||
# 基本提取
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "用户名" \
|
||||
--password "密码" \
|
||||
--profile-dir "浏览器配置目录"
|
||||
|
||||
# 详细信息提取
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "用户名" \
|
||||
--password "密码" \
|
||||
--profile-dir "浏览器配置目录" \
|
||||
--detail-mode
|
||||
```
|
||||
|
||||
#### **1.2 测试RPA连接**
|
||||
```bash
|
||||
python scripts/main.py test-rpa-connection \
|
||||
--username "用户名" \
|
||||
--password "密码" \
|
||||
--profile-dir "浏览器配置目录"
|
||||
```
|
||||
|
||||
#### **1.3 查询提取的数据**
|
||||
```bash
|
||||
# 查询所有提取记录
|
||||
python scripts/main.py list-rpa-invoices
|
||||
|
||||
# 按批次ID查询
|
||||
python scripts/main.py list-rpa-invoices --batch-id "批次ID"
|
||||
|
||||
# 按状态查询
|
||||
python scripts/main.py list-rpa-invoices --status "extracted"
|
||||
|
||||
# 查看详细记录
|
||||
python scripts/main.py show-rpa-invoice --record-id 1
|
||||
|
||||
# 获取统计信息
|
||||
python scripts/main.py rpa-invoice-stats
|
||||
```
|
||||
|
||||
### 2. **通过Action调用**(平台集成)
|
||||
|
||||
在平台内,可以通过`run_skill_action`调用RPA功能:
|
||||
|
||||
```bash
|
||||
# 提取发票信息(异步)
|
||||
run_skill_action --action extract-invoices \
|
||||
--input '{"username": "用户名", "password": "密码", "profile_dir": "目录"}'
|
||||
|
||||
# 查询提取记录
|
||||
run_skill_action --action list-rpa-invoices \
|
||||
--input '{"limit": 10, "batch_id": "批次ID"}'
|
||||
|
||||
# 获取统计信息
|
||||
run_skill_action --action rpa-invoice-stats
|
||||
```
|
||||
|
||||
### 3. **通过对话交互**(LLM集成)
|
||||
|
||||
用户可以通过自然语言与LLM交互来使用RPA功能:
|
||||
|
||||
**示例对话:**
|
||||
```
|
||||
用户:帮我从网站提取发票信息
|
||||
LLM:好的,我需要一些信息来执行RPA操作:
|
||||
1. 网站用户名
|
||||
2. 网站密码
|
||||
3. 浏览器配置目录(需要先通过account-manager获取)
|
||||
|
||||
请提供这些信息,我将为您提取发票信息。
|
||||
```
|
||||
|
||||
## 🔧 平台内配置步骤
|
||||
|
||||
### 步骤1:获取浏览器配置目录
|
||||
|
||||
在平台内,用户需要先通过account-manager获取浏览器配置目录:
|
||||
|
||||
```bash
|
||||
# 登录account-manager
|
||||
python account-manager/scripts/main.py ensure-web --platform sandbox-jc2009
|
||||
|
||||
# 或者使用简化的命令
|
||||
python account-manager/scripts/main.py pick-web --platform sandbox-jc2009 --ensure
|
||||
```
|
||||
|
||||
### 步骤2:配置环境变量
|
||||
|
||||
创建`.env`文件配置必要的环境变量:
|
||||
|
||||
```env
|
||||
# RPA配置
|
||||
OPENCLAW_BROWSER_HEADLESS=0
|
||||
OPENCLAW_RECORD_VIDEO=1
|
||||
HUMAN_WAIT_TIMEOUT=120
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
### 步骤3:初始化数据库
|
||||
|
||||
```bash
|
||||
python scripts/main.py init-db
|
||||
```
|
||||
|
||||
## 📊 数据流架构
|
||||
|
||||
### 平台内数据流
|
||||
|
||||
```
|
||||
用户请求 → LLM/CLI → RPA模块 → 浏览器自动化 → 网站数据提取
|
||||
↓
|
||||
数据存储 → SQLite数据库
|
||||
↓
|
||||
查询接口 → 数据管理界面
|
||||
```
|
||||
|
||||
### 数据存储位置
|
||||
|
||||
1. **SQLite数据库**:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/verify-invoice-default/data.db`
|
||||
- `rpa_invoices`表:存储RPA提取的原始数据
|
||||
- `invoice_records`表:存储预审后的发票记录
|
||||
- `prereview_reports`表:存储预审报告
|
||||
|
||||
2. **录屏文件**:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/verify-invoice-default/videos/`
|
||||
|
||||
3. **日志文件**:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/verify-invoice-default/logs/`
|
||||
|
||||
## 🎮 平台内使用场景
|
||||
|
||||
### 场景1:批量发票提取
|
||||
|
||||
**用户需求**:从网站批量提取100张发票信息
|
||||
|
||||
**操作步骤**:
|
||||
1. 用户通过CLI或对话发起请求
|
||||
2. RPA模块自动登录网站
|
||||
3. 提取发票列表信息
|
||||
4. 保存到数据库
|
||||
5. 返回提取结果
|
||||
|
||||
**命令示例**:
|
||||
```bash
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "/path/to/profile"
|
||||
```
|
||||
|
||||
### 场景2:发票信息查询
|
||||
|
||||
**用户需求**:查看之前提取的发票信息
|
||||
|
||||
**操作步骤**:
|
||||
1. 用户查询提取记录
|
||||
2. 系统从数据库读取数据
|
||||
3. 返回结构化结果
|
||||
|
||||
**命令示例**:
|
||||
```bash
|
||||
# 查询最近10条记录
|
||||
python scripts/main.py list-rpa-invoices --limit 10
|
||||
|
||||
# 查看特定记录详情
|
||||
python scripts/main.py show-rpa-invoice --record-id 5
|
||||
```
|
||||
|
||||
### 场景3:统计分析
|
||||
|
||||
**用户需求**:查看RPA提取的统计信息
|
||||
|
||||
**操作步骤**:
|
||||
1. 用户请求统计信息
|
||||
2. 系统查询数据库统计
|
||||
3. 返回统计结果
|
||||
|
||||
**命令示例**:
|
||||
```bash
|
||||
python scripts/main.py rpa-invoice-stats
|
||||
```
|
||||
|
||||
## 🔐 安全考虑
|
||||
|
||||
### 1. 账号安全
|
||||
|
||||
- **密码加密**:建议在`.env`中配置密码,不要硬编码
|
||||
- **权限控制**:通过account-manager管理账号权限
|
||||
- **会话管理**:浏览器配置目录隔离不同用户
|
||||
|
||||
### 2. 数据安全
|
||||
|
||||
- **数据脱敏**:日志中自动脱敏敏感信息
|
||||
- **访问控制**:通过平台权限系统控制数据访问
|
||||
- **审计日志**:记录所有RPA操作
|
||||
|
||||
### 3. 操作安全
|
||||
|
||||
- **频率控制**:避免频繁请求,防止被网站限制
|
||||
- **人工兜底**:验证码、人脸等必须等待人工完成
|
||||
- **错误处理**:完善的异常捕获和错误报告
|
||||
|
||||
## 📈 平台集成优势
|
||||
|
||||
### 1. 统一管理
|
||||
|
||||
- **账号管理**:通过account-manager统一管理浏览器配置
|
||||
- **权限控制**:平台级权限管理
|
||||
- **日志聚合**:统一日志查看和分析
|
||||
|
||||
### 2. 数据共享
|
||||
|
||||
- **数据存储**:统一数据存储和备份
|
||||
- **数据查询**:通过数据管理界面查询
|
||||
- **数据导出**:支持多种格式导出
|
||||
|
||||
### 3. 监控运维
|
||||
|
||||
- **健康检查**:平台级健康检查
|
||||
- **性能监控**:RPA操作性能监控
|
||||
- **告警通知**:异常情况自动告警
|
||||
|
||||
## 🚀 发布到平台的步骤
|
||||
|
||||
### 步骤1:准备发布文件
|
||||
|
||||
确保以下文件已准备就绪:
|
||||
|
||||
```
|
||||
verify-invoice-default/
|
||||
├── SKILL.md # 技能说明
|
||||
├── README.md # 用户说明
|
||||
├── TUTORIAL.md # 使用教程
|
||||
├── CHANGELOG.md # 更新日志
|
||||
├── scripts/ # 脚本目录
|
||||
│ ├── main.py # CLI入口
|
||||
│ └── service/ # 服务模块
|
||||
├── development/ # 开发文档
|
||||
└── examples/ # 示例文件
|
||||
```
|
||||
|
||||
### 步骤2:验证功能
|
||||
|
||||
```bash
|
||||
# 测试CLI命令
|
||||
python scripts/main.py health
|
||||
python scripts/main.py version
|
||||
|
||||
# 测试RPA功能
|
||||
python scripts/main.py test-rpa-connection \
|
||||
--username "测试用户" \
|
||||
--password "测试密码" \
|
||||
--profile-dir "测试目录"
|
||||
```
|
||||
|
||||
### 步骤3:打包发布
|
||||
|
||||
```bash
|
||||
# 使用release脚本
|
||||
python tools/release.ps1
|
||||
|
||||
# 或者手动打包
|
||||
tar -czf verify-invoice-default.tar.gz verify-invoice-default/
|
||||
```
|
||||
|
||||
### 步骤4:上传到平台
|
||||
|
||||
1. 登录平台管理后台
|
||||
2. 上传技能包
|
||||
3. 配置技能元数据
|
||||
4. 发布技能
|
||||
|
||||
## 📝 平台内使用示例
|
||||
|
||||
### 示例1:通过CLI提取发票
|
||||
|
||||
```bash
|
||||
# 1. 获取浏览器配置
|
||||
python account-manager/scripts/main.py ensure-web --platform sandbox-jc2009
|
||||
|
||||
# 2. 提取发票信息
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "/path/to/profile"
|
||||
|
||||
# 3. 查看提取结果
|
||||
python scripts/main.py list-rpa-invoices --limit 5
|
||||
```
|
||||
|
||||
### 示例2:通过对话交互
|
||||
|
||||
```
|
||||
用户:我想提取网站上的发票信息
|
||||
LLM:好的,我来帮您提取发票信息。请提供:
|
||||
1. 网站用户名:15527406659
|
||||
2. 网站密码:qwer1234
|
||||
3. 浏览器配置目录:/path/to/profile
|
||||
|
||||
LLM:正在执行RPA提取...
|
||||
LLM:成功提取了10张发票信息,已保存到数据库。
|
||||
|
||||
用户:查看提取的发票详情
|
||||
LLM:为您查询到最近5条提取记录:
|
||||
1. 批次ID: abc123, 提取时间: 2026-09-14 10:30:00, 状态: 已提取
|
||||
2. 批次ID: def456, 提取时间: 2026-09-14 11:00:00, 状态: 已提取
|
||||
...
|
||||
```
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### 问题1:浏览器启动失败
|
||||
|
||||
**错误**:`ERROR:MISSING_BROWSER 浏览器启动失败`
|
||||
|
||||
**解决方案**:
|
||||
1. 检查是否已安装Chrome或Edge浏览器
|
||||
2. 确认`profile_dir`是否有效
|
||||
3. 检查是否有其他浏览器进程占用配置目录
|
||||
|
||||
### 问题2:登录失败
|
||||
|
||||
**错误**:`ERROR:LOGIN_TIMEOUT 登录失败或超时`
|
||||
|
||||
**解决方案**:
|
||||
1. 检查用户名和密码是否正确
|
||||
2. 确认网络连接是否正常
|
||||
3. 检查是否有验证码需要人工处理
|
||||
|
||||
### 问题3:数据保存失败
|
||||
|
||||
**错误**:数据库写入失败
|
||||
|
||||
**解决方案**:
|
||||
1. 检查数据库文件权限
|
||||
2. 确认数据库表是否已创建
|
||||
3. 检查磁盘空间是否充足
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **如何获取浏览器配置目录?**
|
||||
- 使用`account-manager`工具:`python account-manager/scripts/main.py ensure-web --platform sandbox-jc2009`
|
||||
|
||||
2. **如何查看提取的发票数据?**
|
||||
- 使用命令:`python scripts/main.py list-rpa-invoices`
|
||||
|
||||
3. **如何导出提取的数据?**
|
||||
- 使用命令:`python scripts/main.py show-rpa-invoice --record-id 1`,然后复制JSON输出
|
||||
|
||||
### 联系方式
|
||||
|
||||
- 技术支持:support@jiangchang.com
|
||||
- 文档地址:https://docs.jiangchang.com/verify-invoice-default
|
||||
- 问题反馈:https://github.com/jiangchang/verify-invoice-default/issues
|
||||
212
RPA_README.md
212
RPA_README.md
@@ -1,212 +0,0 @@
|
||||
# 发票RPA功能
|
||||
|
||||
## 简介
|
||||
|
||||
本功能为verify-invoice-default技能添加了RPA(机器人流程自动化)能力,可以自动从网站提取发票信息,用于后续的票据合规预审。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🤖 **自动登录**:支持用户名密码自动登录
|
||||
- 🌐 **页面导航**:自动导航到发票预检页面
|
||||
- 📊 **信息提取**:提取发票列表和详细信息
|
||||
- 🎭 **拟人操作**:模拟真实用户操作,避免被检测
|
||||
- ⚠️ **错误处理**:处理登录超时、验证码等异常情况
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 获取浏览器配置目录
|
||||
|
||||
```bash
|
||||
# 确保已安装account-manager
|
||||
cd account-manager
|
||||
python scripts/main.py ensure-web --platform sandbox-jc2009
|
||||
```
|
||||
|
||||
### 2. 测试连接
|
||||
|
||||
```bash
|
||||
cd verify-invoice-default/verify-invoice-default
|
||||
python scripts/main.py test-rpa-connection \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "从account-manager获取的profile_dir"
|
||||
```
|
||||
|
||||
### 3. 提取发票信息
|
||||
|
||||
```bash
|
||||
# 基本提取
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "从account-manager获取的profile_dir"
|
||||
|
||||
# 详细信息提取
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "从account-manager获取的profile_dir" \
|
||||
--detail-mode
|
||||
```
|
||||
|
||||
## 命令行参数
|
||||
|
||||
### extract-invoices
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--username` | 是 | 网站用户名 |
|
||||
| `--password` | 是 | 网站密码 |
|
||||
| `--profile-dir` | 是 | 浏览器配置目录 |
|
||||
| `--headless` | 否 | 无头模式(生产不建议) |
|
||||
| `--detail-mode` | 否 | 提取详细信息 |
|
||||
|
||||
### test-rpa-connection
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--username` | 是 | 网站用户名 |
|
||||
| `--password` | 是 | 网站密码 |
|
||||
| `--profile-dir` | 是 | 浏览器配置目录 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 成功输出
|
||||
|
||||
```json
|
||||
{
|
||||
"total_count": 10,
|
||||
"invoices": [
|
||||
{
|
||||
"row_index": 0,
|
||||
"row_text": "发票信息...",
|
||||
"invoice_number": "12345678",
|
||||
"amount": "100.00"
|
||||
}
|
||||
],
|
||||
"extraction_complete": true
|
||||
}
|
||||
```
|
||||
|
||||
### 失败输出
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误信息",
|
||||
"stage": "错误发生阶段"
|
||||
}
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
verify-invoice-default/verify-invoice-default/
|
||||
├── scripts/
|
||||
│ ├── service/
|
||||
│ │ ├── rpa/
|
||||
│ │ │ ├── __init__.py # RPA模块初始化
|
||||
│ │ │ ├── browser_session.py # 浏览器会话管理
|
||||
│ │ │ ├── login_handler.py # 登录处理
|
||||
│ │ │ ├── invoice_extractor.py # 发票信息提取
|
||||
│ │ │ └── invoice_rpa.py # RPA主流程
|
||||
│ │ └── rpa_invoice_service.py # RPA数据服务
|
||||
│ ├── db/
|
||||
│ │ ├── connection.py # 数据库连接
|
||||
│ │ └── rpa_invoice_schema.py # RPA数据表结构
|
||||
│ └── cli/
|
||||
│ └── app.py # CLI入口(已更新)
|
||||
├── test_rpa.py # 测试脚本
|
||||
├── demo_rpa.py # 演示脚本
|
||||
├── RPA_README.md # 本文件
|
||||
├── RPA_USAGE.md # 详细使用说明
|
||||
└── PLATFORM_USAGE.md # 平台内使用指南
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 浏览器启动失败
|
||||
|
||||
**错误**:`ERROR:MISSING_BROWSER 浏览器启动失败`
|
||||
|
||||
**解决**:
|
||||
- 确保已安装Chrome或Edge浏览器
|
||||
- 检查`profile_dir`是否有效
|
||||
- 确保没有其他浏览器进程占用配置目录
|
||||
|
||||
### 2. 登录失败
|
||||
|
||||
**错误**:`ERROR:LOGIN_TIMEOUT 登录失败或超时`
|
||||
|
||||
**解决**:
|
||||
- 检查用户名和密码是否正确
|
||||
- 确保网络连接正常
|
||||
- 检查是否有验证码需要人工处理
|
||||
|
||||
### 3. 页面加载失败
|
||||
|
||||
**错误**:`导航到发票页面失败`
|
||||
|
||||
**解决**:
|
||||
- 检查网络连接
|
||||
- 确保已登录
|
||||
- 检查目标URL是否正确
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 架构设计
|
||||
|
||||
RPA功能采用分层架构设计:
|
||||
|
||||
1. **浏览器会话层**:管理浏览器的启动、配置和关闭
|
||||
2. **登录处理层**:处理网站登录流程
|
||||
3. **信息提取层**:从页面提取发票信息
|
||||
4. **主流程层**:协调各层完成RPA任务
|
||||
|
||||
### 安全考虑
|
||||
|
||||
1. **合规性**:仅用于授权范围内的正常界面操作
|
||||
2. **频率控制**:避免频繁请求,防止被网站限制
|
||||
3. **人工兜底**:验证码、人脸等必须等待人工完成
|
||||
4. **数据安全**:不要在日志中暴露敏感信息
|
||||
|
||||
### 扩展性
|
||||
|
||||
RPA功能设计为可扩展的:
|
||||
|
||||
1. **支持新网站**:只需修改URL和选择器
|
||||
2. **支持新字段**:在提取器中添加新的选择器
|
||||
3. **支持新操作**:可以添加点击、填写等操作
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新的提取字段
|
||||
|
||||
1. 在`invoice_extractor.py`的`INVOICE_SELECTORS`中添加选择器
|
||||
2. 在`_extract_invoice_from_row`函数中添加提取逻辑
|
||||
3. 更新`format_invoice_data`函数的输出格式
|
||||
|
||||
### 修改登录流程
|
||||
|
||||
1. 在`login_handler.py`的`LOGIN_SELECTORS`中更新选择器
|
||||
2. 修改`_try_auto_login`函数的登录逻辑
|
||||
3. 更新`_is_logged_in`函数的检测逻辑
|
||||
|
||||
### 添加新的RPA功能
|
||||
|
||||
1. 在`service/rpa/`目录下创建新模块
|
||||
2. 在`__init__.py`中导出新功能
|
||||
3. 在`task_service.py`中添加对应的命令函数
|
||||
4. 在`cli/app.py`中添加命令行参数
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2026-09-14)
|
||||
- 初始版本
|
||||
- 实现基本登录和页面导航
|
||||
- 实现发票信息提取
|
||||
- 集成到现有CLI系统
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目遵循与verify-invoice-default技能相同的许可证。
|
||||
217
RPA_USAGE.md
217
RPA_USAGE.md
@@ -1,217 +0,0 @@
|
||||
# 发票RPA功能使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
本功能为verify-invoice-default技能添加了RPA(机器人流程自动化)能力,可以自动从`https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck`网站提取发票信息。
|
||||
|
||||
## 功能特性
|
||||
|
||||
1. **自动登录**:支持用户名密码自动登录
|
||||
2. **页面导航**:自动导航到发票预检页面
|
||||
3. **信息提取**:提取发票列表和详细信息
|
||||
4. **拟人操作**:模拟真实用户操作,避免被检测
|
||||
5. **错误处理**:处理登录超时、验证码等异常情况
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **浏览器配置目录**:需要通过account-manager获取`profile_dir`
|
||||
2. **Python环境**:需要Python 3.10+环境
|
||||
3. **依赖包**:需要`jiangchang-platform-kit>=1.2.11`
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 获取浏览器配置目录
|
||||
|
||||
首先,需要通过account-manager获取浏览器配置目录:
|
||||
|
||||
```bash
|
||||
# 登录account-manager
|
||||
python account-manager/scripts/main.py ensure-web --platform sandbox-jc2009
|
||||
|
||||
# 或者使用简化的命令
|
||||
python account-manager/scripts/main.py pick-web --platform sandbox-jc2009 --ensure
|
||||
```
|
||||
|
||||
### 2. 测试RPA连接
|
||||
|
||||
在使用前,建议先测试连接和登录功能:
|
||||
|
||||
```bash
|
||||
cd verify-invoice-default/verify-invoice-default
|
||||
python scripts/main.py test-rpa-connection \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "从account-manager获取的profile_dir"
|
||||
```
|
||||
|
||||
### 3. 提取发票信息
|
||||
|
||||
#### 基本提取(列表模式)
|
||||
|
||||
```bash
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "从account-manager获取的profile_dir"
|
||||
```
|
||||
|
||||
#### 详细信息提取
|
||||
|
||||
```bash
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "从account-manager获取的profile_dir" \
|
||||
--detail-mode
|
||||
```
|
||||
|
||||
#### 无头模式(不推荐生产使用)
|
||||
|
||||
```bash
|
||||
python scripts/main.py extract-invoices \
|
||||
--username "15527406659" \
|
||||
--password "qwer1234" \
|
||||
--profile-dir "从account-manager获取的profile_dir" \
|
||||
--headless
|
||||
```
|
||||
|
||||
## 命令参数说明
|
||||
|
||||
### extract-invoices 命令
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--username` | 是 | 网站用户名 |
|
||||
| `--password` | 是 | 网站密码 |
|
||||
| `--profile-dir` | 是 | 浏览器配置目录 |
|
||||
| `--headless` | 否 | 无头模式(生产不建议) |
|
||||
| `--detail-mode` | 否 | 提取详细信息 |
|
||||
|
||||
### test-rpa-connection 命令
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--username` | 是 | 网站用户名 |
|
||||
| `--password` | 是 | 网站密码 |
|
||||
| `--profile-dir` | 是 | 浏览器配置目录 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 成功输出
|
||||
|
||||
```json
|
||||
{
|
||||
"total_count": 10,
|
||||
"invoices": [
|
||||
{
|
||||
"row_index": 0,
|
||||
"row_text": "发票信息...",
|
||||
"invoice_number": "12345678",
|
||||
"amount": "100.00"
|
||||
}
|
||||
],
|
||||
"extraction_complete": true
|
||||
}
|
||||
```
|
||||
|
||||
### 失败输出
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误信息",
|
||||
"stage": "错误发生阶段"
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 浏览器启动失败
|
||||
|
||||
**错误信息**:`ERROR:MISSING_BROWSER 浏览器启动失败`
|
||||
|
||||
**解决方案**:
|
||||
- 确保已安装Chrome或Edge浏览器
|
||||
- 检查`profile_dir`是否有效
|
||||
- 确保没有其他浏览器进程占用配置目录
|
||||
|
||||
### 2. 登录失败
|
||||
|
||||
**错误信息**:`ERROR:LOGIN_TIMEOUT 登录失败或超时`
|
||||
|
||||
**解决方案**:
|
||||
- 检查用户名和密码是否正确
|
||||
- 确保网络连接正常
|
||||
- 检查是否有验证码需要人工处理
|
||||
|
||||
### 3. 页面加载失败
|
||||
|
||||
**错误信息**:`导航到发票页面失败`
|
||||
|
||||
**解决方案**:
|
||||
- 检查网络连接
|
||||
- 确保已登录
|
||||
- 检查目标URL是否正确
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 架构设计
|
||||
|
||||
```
|
||||
invoice_rpa.py # RPA主流程协调
|
||||
├── browser_session.py # 浏览器会话管理
|
||||
├── login_handler.py # 登录处理
|
||||
└── invoice_extractor.py # 发票信息提取
|
||||
```
|
||||
|
||||
### 核心流程
|
||||
|
||||
1. **启动浏览器**:使用Playwright启动持久化浏览器上下文
|
||||
2. **登录网站**:自动输入用户名密码,处理验证码
|
||||
3. **导航页面**:跳转到发票预检页面
|
||||
4. **提取信息**:解析页面DOM,提取发票数据
|
||||
5. **返回结果**:格式化输出提取的发票信息
|
||||
|
||||
### 安全特性
|
||||
|
||||
1. **拟人操作**:模拟真实用户操作节奏
|
||||
2. **错误处理**:完善的异常捕获和错误报告
|
||||
3. **资源清理**:确保浏览器正确关闭
|
||||
4. **日志记录**:详细的操作日志
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 添加新的提取字段
|
||||
|
||||
1. 在`invoice_extractor.py`的`INVOICE_SELECTORS`中添加选择器
|
||||
2. 在`_extract_invoice_from_row`函数中添加提取逻辑
|
||||
3. 更新`format_invoice_data`函数的输出格式
|
||||
|
||||
### 修改登录流程
|
||||
|
||||
1. 在`login_handler.py`的`LOGIN_SELECTORS`中更新选择器
|
||||
2. 修改`_try_auto_login`函数的登录逻辑
|
||||
3. 更新`_is_logged_in`函数的检测逻辑
|
||||
|
||||
### 添加新的RPA功能
|
||||
|
||||
1. 在`service/rpa/`目录下创建新模块
|
||||
2. 在`__init__.py`中导出新功能
|
||||
3. 在`task_service.py`中添加对应的命令函数
|
||||
4. 在`cli/app.py`中添加命令行参数
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **合规性**:仅用于授权范围内的正常界面操作
|
||||
2. **频率控制**:避免频繁请求,防止被网站限制
|
||||
3. **人工兜底**:验证码、人脸等必须等待人工完成
|
||||
4. **数据安全**:不要在日志中暴露敏感信息
|
||||
5. **资源管理**:确保浏览器会话正确关闭
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2026-09-14)
|
||||
- 初始版本
|
||||
- 实现基本登录和页面导航
|
||||
- 实现发票信息提取
|
||||
- 集成到现有CLI系统
|
||||
2
SKILL.md
2
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 票据合规预审助手
|
||||
description: "高校报销场景的发票合规性秒级预审:核对字段完整性、购买方税号白名单、开票日期与出差行程逻辑、住宿交通经费标准,对连号、同商家集中开票、节假日开票等风险点红黄灯预警,生成《初审意见单》。"
|
||||
version: 1.0.3
|
||||
version: 1.0.4
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
|
||||
120
TUTORIAL.md
120
TUTORIAL.md
@@ -1,91 +1,85 @@
|
||||
数据管理与对话任务是本技能两种**平行**的用法:想在表上对着数据操作、维护标准库,走数据管理;想把发票信息直接发给助手要结论,走对话。本技能不提供定时任务(预审对象逐次由人指定,没有可以无人值守批量跑的队列)。
|
||||
本技能有以下使用方式:
|
||||
|
||||
- **CLI 命令**:通过命令行执行发票提取、票据预审等操作
|
||||
- **对话任务**:用自然语言让 Agent 完成票据合规预审
|
||||
|
||||
## 本技能做什么
|
||||
|
||||
你把发票的票面信息(可粘贴文字或由助手读照片转写)连同报销事由、出差行程一起交给技能;技能按"字段完整性 → 税号白名单 → 行程日期逻辑 → 住宿/交通标准 → 风控标记"五道关逐张体检,给出 通过 / 待确认 / 驳回 结论和一份《初审意见单》报告,并把历史记录保存下来,供你回填发票真伪查验结果、管理员维护标准库。
|
||||
票据合规预审助手,帮助财务人员快速完成差旅报销发票的合规性预审:
|
||||
|
||||
1. **RPA 发票提取**:自动登录智慧校园系统,从票据合规预审页面提取发票数据
|
||||
2. **四步审核**:发票字段完整性、税号白名单、日期逻辑、经费标准
|
||||
3. **初审意见单**:生成 HTML 格式的审核报告
|
||||
|
||||
## 开始前
|
||||
|
||||
- 准备好发票票面信息:发票号码、开票日期、金额、购买方名称与税号、销售方名称;差旅报销另备出差起止日期、目的地、职级档位
|
||||
- 管理员确认"税号白名单""经费标准库"已按学校现行规定维护(首次安装时内置了示例种子)
|
||||
- 如需回填发票真伪,先到全国增值税发票查验平台(inv-veri.chinatax.gov.cn)完成核验
|
||||
- 已安装 Python 3.10+、Playwright
|
||||
- 已安装 `jiangchang-platform-kit`(或设置 `PYTHONPATH` 指向其 `src/`)
|
||||
- 已安装 Edge 或 Chrome 浏览器
|
||||
|
||||
## 入口一:数据管理
|
||||
## 入口一:CLI 命令
|
||||
|
||||
左侧表列表中能看到本技能的五张表:发票登记、初审意见、经费标准库、税号白名单、节假日。
|
||||
### 提取发票数据
|
||||
|
||||
### 预审一张或多张发票
|
||||
```powershell
|
||||
$env:PYTHONPATH="D:\jiangchang\huashang\jiangchang-platform-kit\jiangchang-platform-kit\src"
|
||||
cd D:\jiangchang\huashang\verify-invoice-default\verify-invoice-default
|
||||
|
||||
1. 点左侧"发票登记"表,再点表顶栏的「票据合规预审」按钮
|
||||
2. 在表单里粘贴发票字段信息(支持一张的对象写法,也支持多张合并成一批),按需填写报销事由、预算项目编号、出差起止、目的地城市、职级
|
||||
3. 提交后稍等片刻,页面提示"预审完成 N 张:通过 x,待确认 y,驳回 z";表格自动刷新出现本次记录
|
||||
4. 切到"初审意见"表查看每张发票的结论摘要
|
||||
python scripts\main.py extract-invoices --username 15527406659 --password qwer1234 --profile-dir "$env:TEMP\verify-invoice-edge-profile"
|
||||
```
|
||||
|
||||
### 查看《初审意见单》报告
|
||||
参数说明:
|
||||
|
||||
1. 在"初审意见"表点表顶栏的「生成初审意见单」按钮(不选行时重建最近 10 份)
|
||||
2. 左侧 HTML 报告区出现 `report-编号` 条目,点开即可预览三色清单与规则引用
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `--username` | sandbox 门户手机号 |
|
||||
| `--password` | sandbox 门户密码 |
|
||||
| `--profile-dir` | 浏览器 Profile 目录(保存登录态,多次复用) |
|
||||
| `--headless` | 可选,无头模式(生产不建议) |
|
||||
| `--detail-mode` | 可选,提取详细信息 |
|
||||
|
||||
### 回填发票真伪查验结果
|
||||
### 测试连接
|
||||
|
||||
1. 在"初审意见"表找到目标行(预审结论为"待确认"的行通常卡在这一步)
|
||||
2. 点该行操作列的「回填查验结果」
|
||||
3. 在表单里选择查验结果:通过 / 不符,填写备注后提交
|
||||
4. 回填"不符"后,该行结论自动升级为"驳回"
|
||||
```powershell
|
||||
python scripts\main.py test-rpa-connection --username 15527406659 --password qwer1234 --profile-dir "$env:TEMP\verify-invoice-edge-profile"
|
||||
```
|
||||
|
||||
### 维护税号白名单与经费标准
|
||||
### 票据合规预审
|
||||
|
||||
1. 打开"税号白名单"表:点顶栏「新增税号白名单」录入税号与单位名称;发现错误条目点行内「删除税号」
|
||||
2. 打开"经费标准库"表:双击或用行内「更新经费标准」修改标准值;请同步核对"依据文件""条款"两列,保持可追溯
|
||||
```powershell
|
||||
python scripts\main.py prereview --invoice-json invoices.json
|
||||
```
|
||||
|
||||
### 重建初审意见单
|
||||
|
||||
```powershell
|
||||
python scripts\main.py render-html
|
||||
```
|
||||
|
||||
## 入口二:对话任务
|
||||
|
||||
新建任务,选择本技能后,直接把要办的事说清楚。
|
||||
直接告诉 Agent:
|
||||
|
||||
### 预审发票(最常用)
|
||||
> 帮我从智慧校园提取发票数据
|
||||
|
||||
把发票信息连同报销上下文一起发给助手,例如:
|
||||
> 对这些发票做票据合规预审
|
||||
|
||||
> 帮我预审一张住宿费发票:号码 24312000000012345678,开票日期 2026-08-18,金额 1650 元,购买方广州华商学院(税号 91440101MA9XXYYY0A),销售方广州某某酒店;我 8 月 17 到 20 日出差广州,职级副高及以上,项目编号 KYGX2026001
|
||||
|
||||
助手会复述解读三色清单:驳回项会读出违反的标准条款,待确认项会告诉你下一步该做什么(如去查验平台核验真伪)。
|
||||
|
||||
你也可以直接发发票照片,让助手转写字段后再确认预审。
|
||||
|
||||
### 查历史、查标准、查统计
|
||||
|
||||
- "查一下我最近预审过的发票"→ 助手返回历史结论列表;"只看被驳回的"也可以
|
||||
- "出差的住宿费标准是多少"→ 返回对应城市等级与职级的上限
|
||||
- "预审统计一下"→ 返回总量、通过率、驳回率与集中开票商家
|
||||
|
||||
### 回填查验结果
|
||||
|
||||
> 我刚在查验平台查了报告 12 那张发票,结果是通过的
|
||||
|
||||
助手会把该记录的真伪状态改为"已查验通过";若你说"不符",结论会升级为驳回。
|
||||
|
||||
## 入口三:定时任务
|
||||
|
||||
本技能不需要定时:预审的对象是逐次提交的具体发票,由人发起即可,没有适合无人值守周期执行的任务。
|
||||
Agent 会调用本技能的 CLI 命令完成操作并返回结果。
|
||||
|
||||
## 主能力对照表
|
||||
|
||||
| 能力 | 数据管理 | 对话任务 |
|
||||
|------|----------|----------|
|
||||
| 预审发票(单张/一批) | 「票据合规预审」按钮(表单) | 直接说事由+票面信息 |
|
||||
| 批量预审 input 目录 | 不支持 | 支持(进任务中心,有进度) |
|
||||
| 查看《初审意见单》 | 「生成初审意见单」按钮 + 侧栏预览 | 让助手重建后去侧栏打开 |
|
||||
| 回填真伪查验结果 | 行级「回填查验结果」 | 对话说明报告编号与结果 |
|
||||
| 查询历史 / 统计 | 查看初审意见表 | 对话查询 |
|
||||
| 查看经费标准 | 经费标准库表 | 对话查询 |
|
||||
| 维护标准 / 白名单 | 表上直接改 / 顶栏行级按钮 | 也可以让助手改 |
|
||||
| 定时预审 | 不提供 | 不提供 |
|
||||
| 能力 | CLI 命令 | 对话触发 |
|
||||
|---|---|---|
|
||||
| RPA 发票提取 | `extract-invoices` | "提取发票数据" |
|
||||
| 票据合规预审 | `prereview` | "做票据预审" |
|
||||
| 重建意见单 | `render-html` | "重建意见单" |
|
||||
| 查看预审历史 | `list-reports` | "查看预审记录" |
|
||||
|
||||
## 常见卡住怎么办
|
||||
|
||||
- **预审按钮点了没反应**:发票字段信息没填或格式不对。至少要有发票号码、开票日期、金额;多张时写成一个数组
|
||||
- **提示"购买方税号不在白名单"但我校确实能报**:白名单还没维护该税号。找管理员在"税号白名单"表新增后重新预审
|
||||
- **住宿费被判超标但标准不对**:标准库的数值可能还是示例种子。请管理员按学校现行文件修改"经费标准库"后再试
|
||||
- **结论一直是"待确认"**:多半卡在发票真伪未查验。到查验平台核验后回填结果即可
|
||||
- **看不到《初审意见单》报告**:先在"初审意见"表点一次「生成初审意见单」,再到左侧 HTML 报告区打开
|
||||
- **发票照片识别不准**:照片模糊、折叠会导致字段缺失。重新拍摄平整、光线均匀的票面,或缺的字段手动补上再预审
|
||||
| 问题 | 解决方法 |
|
||||
|---|---|
|
||||
| 浏览器没弹出 | 检查 `OPENCLAW_BROWSER_HEADLESS=0`,确认 Edge/Chrome 已安装 |
|
||||
| 登录超时 | 检查网络,确认 sandbox.jc2009.com 可访问 |
|
||||
| 智慧校园进不去 | 删除 Profile 目录重新运行,让它重新登录 |
|
||||
| 提取不到发票 | 检查 Profile 目录下是否有登录态,运行 `test-rpa-connection` 测试 |
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# assets
|
||||
|
||||
- `actions.json`:Skill Action manifest 最小示例(按入口定位表决定是否保留/扩展;见 `development/PRODUCT_SURFACES.md`)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。
|
||||
- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。
|
||||
- `html/example-report.html`:HTML 报告页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`,见 `references/ACTIONS.md`「HTML 页面报告」。
|
||||
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
|
||||
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。
|
||||
|
||||
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列);placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层默认展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
|
||||
|
||||
- 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);写法契约见 [`development/TUTORIAL_SURFACES.md`](../development/TUTORIAL_SURFACES.md)
|
||||
- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md))
|
||||
- 开发规范见 [`development/`](../development/)
|
||||
@@ -1,593 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "verify-invoice-default",
|
||||
"actions": [
|
||||
{
|
||||
"id": "prereview-invoice",
|
||||
"label": "票据合规预审",
|
||||
"description": "录入发票结构化字段(可由发票照片识别后粘贴)与报销上下文,执行四步合规审核,生成红黄绿灯清单与《初审意见单》。",
|
||||
"placements": ["agent", "toolbar"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["invoice_records"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "prereview",
|
||||
"args": [
|
||||
"--invoice-json", "{{invoiceJson}}",
|
||||
"--purpose", "{{purpose}}",
|
||||
"--project-no", "{{projectNo}}",
|
||||
"--trip-start", "{{tripStart}}",
|
||||
"--trip-end", "{{tripEnd}}",
|
||||
"--trip-city", "{{tripCity}}",
|
||||
"--staff-title", "{{staffTitle}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"invoiceJson": {
|
||||
"type": "string",
|
||||
"title": "发票字段 JSON",
|
||||
"description": "单张发票对象或同批数组,字段如 invoiceNumber、issueDate、amount、buyerTaxNo、sellerName、expenseCategory、city、days。",
|
||||
"display_type": "textarea"
|
||||
},
|
||||
"purpose": {
|
||||
"type": "string",
|
||||
"title": "报销事由",
|
||||
"description": "本次报销的事由文字。可选。"
|
||||
},
|
||||
"projectNo": {
|
||||
"type": "string",
|
||||
"title": "预算项目编号",
|
||||
"description": "系统带入的预算项目编号。可选。"
|
||||
},
|
||||
"tripStart": {
|
||||
"type": "string",
|
||||
"title": "出差开始日",
|
||||
"description": "差旅审批的出发日期,格式 YYYY-MM-DD。差旅报销建议填写。",
|
||||
"display_type": "date"
|
||||
},
|
||||
"tripEnd": {
|
||||
"type": "string",
|
||||
"title": "出差结束日",
|
||||
"description": "差旅审批的返回日期,格式 YYYY-MM-DD。可选。",
|
||||
"display_type": "date"
|
||||
},
|
||||
"tripCity": {
|
||||
"type": "string",
|
||||
"title": "目的地城市",
|
||||
"description": "出差目的地,用于匹配住宿费城市等级。可选。"
|
||||
},
|
||||
"staffTitle": {
|
||||
"type": "string",
|
||||
"title": "职级",
|
||||
"description": "报销人职级档位。",
|
||||
"enum": ["副高及以上", "中级及以下"]
|
||||
}
|
||||
},
|
||||
"required": ["invoiceJson"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "prereview-batch-input",
|
||||
"label": "批量预审(input 目录)",
|
||||
"description": "将多份发票字段 JSON 文件放入技能 input 目录后一键批量预审;任务中心可见进度。",
|
||||
"placements": ["agent"],
|
||||
"executionProfile": "async",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "prereview-batch-input",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "render-html",
|
||||
"label": "生成初审意见单",
|
||||
"description": "重建《初审意见单》HTML 报告并写入技能数据目录 html/(缺省重建最近 10 份)。",
|
||||
"placements": ["toolbar", "skill-detail", "agent"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "direct",
|
||||
"bind": {
|
||||
"tables": ["prereview_reports"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "render-html",
|
||||
"args": []
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"when": "afterSuccess",
|
||||
"do": "refreshCatalog"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mark-verified",
|
||||
"label": "回填查验结果",
|
||||
"description": "在全国增值税发票查验平台核验后,回填该发票的人工查验结果;回填“不符”则结论升级为驳回。",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["prereview_reports"],
|
||||
"inputMapping": {
|
||||
"reportId": "{{id}}"
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "mark-verified",
|
||||
"args": [
|
||||
"--report-id", "{{reportId}}",
|
||||
"--result", "{{result}}",
|
||||
"--note", "{{note}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reportId": {
|
||||
"type": "number",
|
||||
"title": "报告编号",
|
||||
"description": "初审意见单编号(由所在行预填)。"
|
||||
},
|
||||
"result": {
|
||||
"type": "string",
|
||||
"title": "查验结果",
|
||||
"description": "在全国增值税发票查验平台的核验结论。",
|
||||
"enum": ["pass", "fail"]
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"title": "查验备注",
|
||||
"description": "备注说明。可选。"
|
||||
}
|
||||
},
|
||||
"required": ["reportId", "result"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "list-reports",
|
||||
"label": "查询初审历史",
|
||||
"description": "查看最近的票据预审结论列表。",
|
||||
"placements": ["agent"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "list-reports",
|
||||
"args": [
|
||||
"--limit", "{{limit}}",
|
||||
"--verdict", "{{verdict}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"title": "条数",
|
||||
"description": "返回条数,默认 20。可选。"
|
||||
},
|
||||
"verdict": {
|
||||
"type": "string",
|
||||
"title": "结论筛选",
|
||||
"description": "通过 / 待确认 / 驳回。可选。",
|
||||
"enum": ["通过", "待确认", "驳回"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "view-standards",
|
||||
"label": "查看经费标准",
|
||||
"description": "查看住宿费上限与交通席位等级标准(城市等级 × 职级)。",
|
||||
"placements": ["agent", "skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "view-standards",
|
||||
"args": [
|
||||
"--category", "{{category}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"title": "费用类别",
|
||||
"description": "住宿费 / 交通费。可选。",
|
||||
"enum": ["住宿费", "交通费"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "set-standard",
|
||||
"label": "更新经费标准",
|
||||
"description": "按(费用类别、城市等级、职级)更新标准值;不存在时新增。",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["expense_standards"],
|
||||
"inputMapping": {
|
||||
"category": "{{category}}",
|
||||
"cityTier": "{{city_tier}}",
|
||||
"staffTitle": "{{staff_title}}"
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "set-standard",
|
||||
"args": [
|
||||
"--category", "{{category}}",
|
||||
"--city-tier", "{{cityTier}}",
|
||||
"--staff-title", "{{staffTitle}}",
|
||||
"--max-amount", "{{maxAmount}}",
|
||||
"--unit", "{{unit}}",
|
||||
"--basis-doc", "{{basisDoc}}",
|
||||
"--clause", "{{clause}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"title": "费用类别",
|
||||
"description": "住宿费 / 交通费(由所在行预填)。"
|
||||
},
|
||||
"cityTier": {
|
||||
"type": "string",
|
||||
"title": "城市等级",
|
||||
"description": "一线城市 / 省会及计划单列市 / 其他地区 / 通用(由所在行预填)。"
|
||||
},
|
||||
"staffTitle": {
|
||||
"type": "string",
|
||||
"title": "职级",
|
||||
"description": "副高及以上 / 中级及以下(由所在行预填)。"
|
||||
},
|
||||
"maxAmount": {
|
||||
"type": "number",
|
||||
"title": "标准值",
|
||||
"description": "新的标准值(住宿费为元/人/天)。"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"title": "单位",
|
||||
"description": "如 元/人/天。可选。"
|
||||
},
|
||||
"basisDoc": {
|
||||
"type": "string",
|
||||
"title": "依据文件",
|
||||
"description": "标准出处文件名。可选。"
|
||||
},
|
||||
"clause": {
|
||||
"type": "string",
|
||||
"title": "条款",
|
||||
"description": "标准出处条款号。可选。"
|
||||
}
|
||||
},
|
||||
"required": ["category", "cityTier", "staffTitle", "maxAmount"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "add-whitelist",
|
||||
"label": "新增税号白名单",
|
||||
"description": "向准予报销的购买方税号白名单新增一条记录。",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["taxid_whitelist"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "add-whitelist",
|
||||
"args": [
|
||||
"--tax-no", "{{taxNo}}",
|
||||
"--name", "{{orgName}}",
|
||||
"--note", "{{note}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taxNo": {
|
||||
"type": "string",
|
||||
"title": "税号",
|
||||
"description": "购买方统一社会信用代码。"
|
||||
},
|
||||
"orgName": {
|
||||
"type": "string",
|
||||
"title": "单位名称",
|
||||
"description": "税号对应单位全称。"
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"title": "备注",
|
||||
"description": "维护说明。可选。"
|
||||
}
|
||||
},
|
||||
"required": ["taxNo", "orgName"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "remove-whitelist",
|
||||
"label": "删除税号",
|
||||
"description": "从税号白名单中删除该条记录。",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["taxid_whitelist"],
|
||||
"inputMapping": {
|
||||
"taxNo": "{{tax_no}}"
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "remove-whitelist",
|
||||
"args": [
|
||||
"--tax-no", "{{taxNo}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taxNo": {
|
||||
"type": "string",
|
||||
"title": "税号",
|
||||
"description": "要删除的税号(由所在行预填)。"
|
||||
}
|
||||
},
|
||||
"required": ["taxNo"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"label": "预审统计",
|
||||
"description": "查看预审总量、通过/待确认/驳回分布与集中开票商家。",
|
||||
"placements": ["agent", "skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "stats",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "health",
|
||||
"label": "运行检查",
|
||||
"description": "检查技能运行环境并返回结构化结果。",
|
||||
"placements": ["skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "health",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "version",
|
||||
"label": "查看版本",
|
||||
"description": "返回技能版本和机器标识。",
|
||||
"placements": ["skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "version",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "config-path",
|
||||
"label": "查看配置位置",
|
||||
"description": "返回用户 .env 与模板路径。",
|
||||
"placements": ["skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "config-path",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "extract-invoices",
|
||||
"label": "提取发票信息",
|
||||
"description": "从网站自动提取发票信息,需要提供登录凭证和浏览器配置目录。",
|
||||
"placements": ["agent"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "extract-invoices",
|
||||
"args": [
|
||||
"--username", "{{username}}",
|
||||
"--password", "{{password}}",
|
||||
"--profile-dir", "{{profileDir}}",
|
||||
"--headless", "{{headless}}",
|
||||
"--detail-mode", "{{detailMode}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "用户名",
|
||||
"description": "网站登录用户名"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"title": "密码",
|
||||
"description": "网站登录密码"
|
||||
},
|
||||
"profileDir": {
|
||||
"type": "string",
|
||||
"title": "浏览器配置目录",
|
||||
"description": "通过account-manager获取的浏览器配置目录路径"
|
||||
},
|
||||
"headless": {
|
||||
"type": "boolean",
|
||||
"title": "无头模式",
|
||||
"description": "是否使用无头模式(生产不建议)",
|
||||
"default": false
|
||||
},
|
||||
"detailMode": {
|
||||
"type": "boolean",
|
||||
"title": "详细模式",
|
||||
"description": "是否提取详细信息",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["username", "password", "profileDir"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "test-rpa-connection",
|
||||
"label": "测试RPA连接",
|
||||
"description": "测试RPA连接和登录功能。",
|
||||
"placements": ["agent", "skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "test-rpa-connection",
|
||||
"args": [
|
||||
"--username", "{{username}}",
|
||||
"--password", "{{password}}",
|
||||
"--profile-dir", "{{profileDir}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "用户名",
|
||||
"description": "网站登录用户名"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"title": "密码",
|
||||
"description": "网站登录密码"
|
||||
},
|
||||
"profileDir": {
|
||||
"type": "string",
|
||||
"title": "浏览器配置目录",
|
||||
"description": "通过account-manager获取的浏览器配置目录路径"
|
||||
}
|
||||
},
|
||||
"required": ["username", "password", "profileDir"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "list-rpa-invoices",
|
||||
"label": "查询RPA提取记录",
|
||||
"description": "查询RPA提取的发票数据记录。",
|
||||
"placements": ["agent", "skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "list-rpa-invoices",
|
||||
"args": [
|
||||
"--limit", "{{limit}}",
|
||||
"--batch-id", "{{batchId}}",
|
||||
"--username", "{{username}}",
|
||||
"--status", "{{status}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"title": "条数",
|
||||
"description": "返回条数,默认20",
|
||||
"default": 20
|
||||
},
|
||||
"batchId": {
|
||||
"type": "string",
|
||||
"title": "批次ID",
|
||||
"description": "按批次ID筛选"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "用户名",
|
||||
"description": "按用户名筛选"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"title": "状态",
|
||||
"description": "按状态筛选",
|
||||
"enum": ["extracted", "processed", "error"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "show-rpa-invoice",
|
||||
"label": "查看RPA提取详情",
|
||||
"description": "查看RPA提取的发票数据详情。",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"bind": {
|
||||
"tables": ["rpa_invoices"],
|
||||
"inputMapping": {
|
||||
"recordId": "{{id}}"
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "show-rpa-invoice",
|
||||
"args": [
|
||||
"--record-id", "{{recordId}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recordId": {
|
||||
"type": "number",
|
||||
"title": "记录ID",
|
||||
"description": "RPA提取记录ID"
|
||||
}
|
||||
},
|
||||
"required": ["recordId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rpa-invoice-stats",
|
||||
"label": "RPA提取统计",
|
||||
"description": "获取RPA提取的统计信息。",
|
||||
"placements": ["agent", "skill-detail"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "rpa-invoice-stats",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# 示例 JSON
|
||||
|
||||
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
|
||||
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
|
||||
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
|
||||
- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。
|
||||
- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。
|
||||
- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。
|
||||
- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"_comment": "形状参考:Action 参数引用同库另一张表时用 Lookup。单选与多选同一 lookup 对象,多选只加 multiple:true。完整契约见 references/ACTIONS.md「跨表引用:Lookup」与 references/SCHEMA.md。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "send-with-template",
|
||||
"label": "按模板发送(示例)",
|
||||
"description": "演示内联 lookup 单选:从 message_templates 选模板;-- 表示自动挑选。",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["leads"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "engage",
|
||||
"args": ["--template-id", "{{templateId}}", "--content", "{{content}}"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"title": "正文",
|
||||
"description": "填了就按这里发;留空则用下面的话术模板。",
|
||||
"display_type": "textarea"
|
||||
},
|
||||
"templateId": {
|
||||
"type": "number",
|
||||
"title": "话术模板",
|
||||
"description": "仅在正文留空时生效。选 -- = 自动挑选。",
|
||||
"display_type": "lookup",
|
||||
"lookup": {
|
||||
"ref_table": "message_templates",
|
||||
"value_column": "id",
|
||||
"label_column": "name",
|
||||
"extra_column": "channel",
|
||||
"filter_column": "is_active",
|
||||
"filter_value": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "probe-with-engines",
|
||||
"label": "按引擎抽检(多选示例)",
|
||||
"description": "演示 lookup.multiple:从 engines 多选,提交为逗号分隔 id。",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["queries"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "query",
|
||||
"args": ["batch-probe", "--engine-ids", "{{engineIds}}"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"engineIds": {
|
||||
"type": "string",
|
||||
"title": "将使用的引擎",
|
||||
"description": "可多选;提交值为逗号分隔编号,如 1,2。",
|
||||
"display_type": "lookup",
|
||||
"lookup": {
|
||||
"ref_table": "engines",
|
||||
"value_column": "id",
|
||||
"label_column": "display_name",
|
||||
"filter_column": "enabled",
|
||||
"filter_value": 1,
|
||||
"multiple": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"_comment": "形状参考:Action 表单 file/directory 用 pathRoot 指定原生对话框起始目录(相对技能数据根)。省略则宿主默认 {skill_data}/input。完整契约见 references/ACTIONS.md「本地路径选择与 pathRoot」。勿与宿主导入面板(replaces:import)混用本示例。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "import-pack",
|
||||
"label": "导入数据包",
|
||||
"description": "从本技能输入目录子文件夹选择 JSON 并写入业务表。",
|
||||
"placements": ["toolbar", "agent"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": { "tables": ["business_items"] },
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["file"],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"title": "数据包文件",
|
||||
"description": "请选择 input/数据包/ 下的 JSON。",
|
||||
"format": "file",
|
||||
"display_type": "file",
|
||||
"pathRoot": "input/数据包"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "import-pack",
|
||||
"args": ["--file", "{{file}}"]
|
||||
},
|
||||
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"id": "render-html",
|
||||
"label": "生成 HTML",
|
||||
"description": "根据业务数据生成 HTML 报告,写入技能数据目录 html/(示例:请改 bind.tables / entrypoint)。",
|
||||
"placements": [
|
||||
"toolbar",
|
||||
"skill-detail",
|
||||
"agent"
|
||||
],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "direct",
|
||||
"bind": {
|
||||
"tables": [
|
||||
"your_source_table"
|
||||
]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "render-html",
|
||||
"args": []
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"when": "afterSuccess",
|
||||
"do": "refreshCatalog"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"id": "item-create",
|
||||
"label": "新增记录",
|
||||
"description": "新增业务记录并执行领域副作用(走技能 CLI,非宿主直写库)。",
|
||||
"replaces": "create",
|
||||
"placements": [],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["items"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "title": "名称" },
|
||||
"note": { "type": "string", "title": "备注" }
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "item",
|
||||
"args": ["create", "--name", "{{name}}", "--note", "{{note}}"]
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"id": "item-delete",
|
||||
"label": "删除记录",
|
||||
"description": "删除业务记录并清理关联副作用。",
|
||||
"replaces": "delete",
|
||||
"placements": [],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "confirm",
|
||||
"confirmation": {
|
||||
"message": "确定删除该记录?关联数据将一并清理。"
|
||||
},
|
||||
"bind": {
|
||||
"tables": ["items"],
|
||||
"inputMapping": {
|
||||
"id": "$row.$pk"
|
||||
}
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable", "clearSelection"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "number", "title": "编号", "readOnly": true }
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "item",
|
||||
"args": ["delete", "--id", "{{id}}"]
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"id": "item-import",
|
||||
"label": "导入记录",
|
||||
"description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。",
|
||||
"replaces": "import",
|
||||
"placements": [],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["items"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["filePath"],
|
||||
"properties": {
|
||||
"filePath": {
|
||||
"type": "string",
|
||||
"title": "导入文件",
|
||||
"description": "由宿主导入面板传入的 Excel/CSV 路径。",
|
||||
"format": "file"
|
||||
},
|
||||
"onError": {
|
||||
"type": "string",
|
||||
"title": "导入规则(出错时)",
|
||||
"enum": ["跳过该行,继续导入", "立即停止,不再导入后续行"],
|
||||
"default": "跳过该行,继续导入"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "item",
|
||||
"args": ["import", "--file", "{{filePath}}", "--on-error", "{{onError}}"]
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"id": "item-update",
|
||||
"label": "编辑记录",
|
||||
"description": "编辑业务记录并重算派生字段。",
|
||||
"replaces": "update",
|
||||
"placements": [],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["items"],
|
||||
"inputMapping": {
|
||||
"id": "$row.$pk",
|
||||
"name": "$row.name",
|
||||
"note": "$row.note"
|
||||
}
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "number", "title": "编号", "readOnly": true },
|
||||
"name": { "type": "string", "title": "名称" },
|
||||
"note": { "type": "string", "title": "备注" }
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "item",
|
||||
"args": ["update", "--id", "{{id}}", "--name", "{{name}}", "--note", "{{note}}"]
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"id": "process-selected",
|
||||
"label": "处理勾选",
|
||||
"description": "对勾选行执行业务处理。示例:声明 labelColumn,让确认弹层「已选择」摘要显示可读名称。",
|
||||
"placements": ["toolbar", "batch"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "confirm",
|
||||
"confirmation": {
|
||||
"message": "将按技能业务规则处理勾选记录(具体是否跳过某些状态由 CLI 决定)。"
|
||||
},
|
||||
"bind": {
|
||||
"tables": ["business_items"],
|
||||
"inputMapping": {
|
||||
"ids": "$selection.ids"
|
||||
},
|
||||
"labelColumn": "display_name"
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["ids"],
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "string",
|
||||
"title": "编号列表",
|
||||
"description": "逗号分隔主键;宿主会预填勾选行",
|
||||
"readOnly": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "process",
|
||||
"args": ["--ids", "{{ids}}"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"cells": [
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "qty",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "status",
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
},
|
||||
{
|
||||
"rowId": "99",
|
||||
"column": "print_done_at",
|
||||
"tone": "danger",
|
||||
"message": "环节到期未填",
|
||||
"alert": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": {
|
||||
"userId": "u1",
|
||||
"displayName": "张三",
|
||||
"roles": ["clerk"]
|
||||
},
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "sync-records"]
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": {
|
||||
"userId": "u1"
|
||||
},
|
||||
"actions": {
|
||||
"mode": "open"
|
||||
},
|
||||
"data": {
|
||||
"tables": {
|
||||
"orders": {
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{
|
||||
"field": "owner_user_id",
|
||||
"op": "eq",
|
||||
"valueFrom": "actor.userId"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"version": "1.0.92",
|
||||
"skill": "your-skill-slug"
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"_comment": "形状参考:互斥模式用 inputSchema.properties.*.visibleWhen 联动显隐,勿只靠 description 灰字。完整契约见 references/ACTIONS.md「字段条件显隐」。勿与 POLICY.md L3 行态 visibleWhen 混淆。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "scrape-links-demo",
|
||||
"label": "采集链接(示例)",
|
||||
"description": "演示按采集方式切换参数:关键词路径 vs 作者主页路径。",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["demo_items"]
|
||||
},
|
||||
"confirmation": {
|
||||
"message": "将按所选方式采集链接(示例确认文案)。是否继续?"
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "run",
|
||||
"args": [
|
||||
"--collect-mode",
|
||||
"{{collectMode}}",
|
||||
"--keyword",
|
||||
"{{keyword}}",
|
||||
"--author-url",
|
||||
"{{authorUrl}}",
|
||||
"--max-count",
|
||||
"{{maxCount}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["collectMode"],
|
||||
"properties": {
|
||||
"collectMode": {
|
||||
"type": "string",
|
||||
"title": "采集方式",
|
||||
"description": "先选方式,下方只出现对应参数。",
|
||||
"enum": ["按关键词采集", "按作者主页采集"],
|
||||
"default": "按关键词采集"
|
||||
},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"title": "关键词",
|
||||
"description": "例如 rpa。",
|
||||
"visibleWhen": {
|
||||
"field": "collectMode",
|
||||
"equals": "按关键词采集"
|
||||
}
|
||||
},
|
||||
"authorUrl": {
|
||||
"type": "string",
|
||||
"title": "作者主页链接",
|
||||
"description": "例如 https://example.com/author/...",
|
||||
"display_type": "url",
|
||||
"visibleWhen": {
|
||||
"collectMode": "按作者主页采集"
|
||||
}
|
||||
},
|
||||
"maxCount": {
|
||||
"type": "number",
|
||||
"title": "数量上限",
|
||||
"default": 50,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f8fb;
|
||||
--ink: #0f172a;
|
||||
--muted: #64748b;
|
||||
--card: #ffffff;
|
||||
--line: #e2e8f0;
|
||||
--page-pad-x: clamp(14px, 2.5vw, 24px);
|
||||
--page-pad-y: clamp(12px, 2vw, 20px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 8px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 28px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.section h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
}
|
||||
.meta {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
table.info th { width: 140px; }
|
||||
.verdict-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.verdict {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
.verdict-pass { background: #16a34a; }
|
||||
.verdict-warn { background: #d97706; }
|
||||
.verdict-reject { background: #dc2626; }
|
||||
.summary { margin: 6px 0 14px; font-size: 14px; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
.ref { display: block; margin-top: 4px; color: #2563eb; font-size: 12px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
/*
|
||||
宿主数据管理在 iframe 内预览本页:应横向铺满可用宽度,仅纵向滚动。
|
||||
布局底线见 references/ACTIONS.md「宿主预览布局」。
|
||||
*/
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{TITLE}}</h1>
|
||||
<p>票据合规预审助手 · 报告生成于 · {{GENERATED_AT}}</p>
|
||||
</header>
|
||||
<main>
|
||||
<div class="card">
|
||||
{{BODY}}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
amazon
|
||||
shopee
|
||||
xinghang
|
||||
alibaba
|
||||
kingdee
|
||||
icbc
|
||||
pingpong
|
||||
worldfirst
|
||||
paypal
|
||||
lianlian
|
||||
single-window
|
||||
export-rebate
|
||||
etax
|
||||
@@ -1,4 +0,0 @@
|
||||
all
|
||||
batch
|
||||
multi
|
||||
default
|
||||
@@ -1,25 +0,0 @@
|
||||
scrape
|
||||
receive
|
||||
download
|
||||
export
|
||||
add
|
||||
fill
|
||||
enter
|
||||
bind
|
||||
withdraw
|
||||
reconcile
|
||||
verify
|
||||
submit
|
||||
query
|
||||
process
|
||||
reply
|
||||
generate
|
||||
draft
|
||||
review
|
||||
confirm
|
||||
track
|
||||
upload
|
||||
match
|
||||
triage
|
||||
ship
|
||||
reprice
|
||||
@@ -1,491 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
|
||||
"title": "SkillActionManifest",
|
||||
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)与可选 hoverHint(数据管理工具栏悬停,需宿主已支持该字段)。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "actions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"description": "与 SKILL.md metadata.openclaw.slug 一致"
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/action" },
|
||||
"description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序展示。toolbar/batch:表顶栏全部直接展示(无「更多操作」溢出)。row:行级入口,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action、同一顺序);不在本字段配置排序、截断或菜单树。调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。表顶栏宽时左右横排,窄时左右分栏且栏内竖排。"
|
||||
},
|
||||
"policy": {
|
||||
"$ref": "#/$defs/policyDeclaration",
|
||||
"description": "可选。启用 Data Policy(L1/L2)。缺省或不 enabled = L0,宿主不拉 Snapshot。详见 references/POLICY.md。"
|
||||
},
|
||||
"cellPresentation": {
|
||||
"$ref": "#/$defs/cellPresentationDeclaration",
|
||||
"description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"cellPresentationDeclaration": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "仅 true 启用;false 等同 P0。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"policyDeclaration": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "仅 true 启用;false 等同 L0。"
|
||||
},
|
||||
"entrypoint": {
|
||||
"$ref": "#/$defs/entrypoint",
|
||||
"description": "enabled=true 时必填。推荐 command=policy,args 可含 {{table}}/{{skill}}/{{userId}}。"
|
||||
},
|
||||
"appliesTo": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["data-management"]
|
||||
},
|
||||
"description": "缺省 [\"data-management\"]。本期宿主只实现 data-management。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "enabled": { "const": true } },
|
||||
"required": ["enabled"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["entrypoint"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"placement": {
|
||||
"type": "string",
|
||||
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
|
||||
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。row=行级入口(操作列/行右键/⋯ 等由宿主呈现,无独立 row-context-menu placement)。placements 与 executionProfile 正交,不做条件限制。"
|
||||
},
|
||||
"mutationKind": {
|
||||
"type": "string",
|
||||
"enum": ["create", "update", "delete", "import"],
|
||||
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
|
||||
},
|
||||
"scalarArg": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
},
|
||||
"inputProperty": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["string", "number", "boolean", "object"],
|
||||
"description": "模板 Phase 1 仅推荐 string/number/boolean;根 inputSchema.type=object 的 properties 使用基础标量类型"
|
||||
},
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"default": {},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"minItems": 1
|
||||
},
|
||||
"sensitive": { "type": "boolean" },
|
||||
"readOnly": {
|
||||
"type": "boolean",
|
||||
"description": "只读。有「已选择」且字段来自 inputMapping(或约定 id 字段)时,宿主 form 默认不渲染该控件,预设值仍提交;无摘要时只读展示。勿用 visibleWhen 代替(visibleWhen 隐藏不提交)。"
|
||||
},
|
||||
"revealInForm": {
|
||||
"type": "boolean",
|
||||
"description": "可选逃生阀。true 时即使弹层有「已选择」摘要,也仍渲染该 readOnly 字段。不改变提交 payload。省略=映射只读字段在有摘要时默认折叠。"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "可选。绑定业务表物理列名;Action 弹窗(toolbar/batch)与 replaces create/update 抽屉均可复用该列的中文名与 lookup 下拉"
|
||||
},
|
||||
"display_type": {
|
||||
"type": "string",
|
||||
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。表单控件提示;与 format 二选一即可。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。display_type 别名,语义相同。"
|
||||
},
|
||||
"pathRoot": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选。仅对 display_type/format 为 file 或 directory 有意义:原生选文件/目录对话框的起始文件夹,相对当前技能数据根(如 input/库存数据)。省略则宿主默认打开 {skill_data}/input。禁止绝对路径、盘符与 ..。Agent/CLI 忽略。见 references/ACTIONS.md「本地路径选择与 pathRoot」。"
|
||||
},
|
||||
"lookup": {
|
||||
"$ref": "#/$defs/lookupConfig",
|
||||
"description": "可选。内联同库表关联 Lookup(与列 options_json lookup 对象同构)。用于当前表没有对应列时;推荐同时设 display_type=lookup。见 ACTIONS.md「跨表引用:Lookup」。"
|
||||
},
|
||||
"visibleWhen": {
|
||||
"description": "可选。表单字段条件显隐。宿主在数据管理/定时任务参数表单中:规则不满足则隐藏该字段、提交时省略、且不校验 required。支持 {field,equals} / {field,in:[...]},或单键简写 {collectMode:\"按关键词采集\"} / {collectMode:[\"a\",\"b\"]}。Agent/CLI 无 UI 时同样按当前 input 求值后剥离隐藏字段。",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": { "type": "string", "minLength": 1 },
|
||||
"equals": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["field", "in"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": { "type": "string", "minLength": 1 },
|
||||
"in": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"maxProperties": 1,
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{ "type": ["string", "number", "boolean"] },
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"lookupConfig": {
|
||||
"type": "object",
|
||||
"required": ["ref_table", "value_column", "label_column"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ref_table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "同库被引用表名"
|
||||
},
|
||||
"value_column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "提交值列(编号 / 外键)"
|
||||
},
|
||||
"label_column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "主文案列"
|
||||
},
|
||||
"extra_column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "可选。第三展示列;有值时宿主拼成「value · label(extra)」"
|
||||
},
|
||||
"filter_column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "可选。等值过滤列"
|
||||
},
|
||||
"filter_value": {
|
||||
"type": ["string", "number", "boolean", "null"],
|
||||
"description": "可选。与 filter_column 配对的等值"
|
||||
},
|
||||
"multiple": {
|
||||
"type": "boolean",
|
||||
"description": "可选。true=多选 Lookup(存/提交 TEXT 逗号分隔 id);省略或 false=单选(默认)。与 CRUD options_json lookup 同构。"
|
||||
}
|
||||
},
|
||||
"description": "同库 FK 式 Lookup。展示:value · label;有 extra 时 value · label(extra)。multiple 时多选。宿主亦接受 camelCase 键名。"
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["type", "properties"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "const": "object" },
|
||||
"required": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/inputProperty" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "object",
|
||||
"required": ["type", "command", "args"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "const": "cli" },
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/scalarArg" },
|
||||
"description": "仅允许 string/number/boolean 标量数组"
|
||||
}
|
||||
}
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"bind": {
|
||||
"type": "object",
|
||||
"required": ["tables"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tables": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "英文 snake_case 业务表名;须真实存在于技能 SQLite / _jiangchang_tables"
|
||||
},
|
||||
"description": "数据管理表级绑定:决定 toolbar/row/batch Action 出现在哪些表。宿主兼容旧技能时,缺失 bind 可视为全表展示;新技能 toolbar Action 必须显式声明。"
|
||||
},
|
||||
"inputMapping": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string", "minLength": 1 },
|
||||
"description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。"
|
||||
},
|
||||
"columnMapping": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"description": "可选。将 inputSchema 属性名映射到业务表物理列名,供 Action 弹窗与 replaces create/update 抽屉复用列 displayName / lookup options。"
|
||||
},
|
||||
"labelColumn": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "可选。数据管理 Action 确认/表单弹层「已选择」摘要用的可读标签列(#id + 标签)。仅影响展示,不改变提交 payload。省略时宿主按常见列名启发(title/name 等),再不行只显示编号。"
|
||||
}
|
||||
},
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping / labelColumn。concurrency/locks 勿写入。"
|
||||
},
|
||||
"hostEffectId": {
|
||||
"type": "string",
|
||||
"enum": ["reloadTable", "refreshCatalog", "clearSelection"],
|
||||
"description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。"
|
||||
},
|
||||
"hostEffects": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"onSuccess": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/hostEffectId" },
|
||||
"uniqueItems": true,
|
||||
"description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。"
|
||||
},
|
||||
"onFailure": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/hostEffectId" },
|
||||
"uniqueItems": true,
|
||||
"description": "sync 失败,或 async 启动被拒时触发。"
|
||||
}
|
||||
},
|
||||
"description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。"
|
||||
},
|
||||
"actionStep": {
|
||||
"type": "object",
|
||||
"required": ["when", "do"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"when": {
|
||||
"type": "string",
|
||||
"enum": ["before", "afterSuccess", "afterFailure"],
|
||||
"description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。"
|
||||
},
|
||||
"do": {
|
||||
"type": "string",
|
||||
"enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"],
|
||||
"description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "deleteByFk / deleteRows 目标表(snake_case)。"
|
||||
},
|
||||
"fk": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "deleteByFk 时子表外键列名。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "do": { "const": "deleteByFk" } },
|
||||
"required": ["do"]
|
||||
},
|
||||
"then": { "required": ["table", "fk"] }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "do": { "const": "deleteRows" } },
|
||||
"required": ["do"]
|
||||
},
|
||||
"then": { "required": ["table"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": {
|
||||
"type": "object",
|
||||
"required": ["id", "label", "description", "placements", "entrypoint", "executionProfile"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"hoverHint": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选。数据管理工具栏悬停文案。与 description / confirmation.message 独立;省略则不显示悬停,不回退。需宿主已支持该字段。当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出。"
|
||||
},
|
||||
"placements": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"items": { "$ref": "#/$defs/placement" },
|
||||
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。"
|
||||
},
|
||||
"replaces": { "$ref": "#/$defs/mutationKind" },
|
||||
"entrypoint": { "$ref": "#/$defs/entrypoint" },
|
||||
"inputSchema": { "$ref": "#/$defs/inputSchema" },
|
||||
"confirmation": { "$ref": "#/$defs/confirmation" },
|
||||
"executionProfile": {
|
||||
"type": "string",
|
||||
"enum": ["sync", "async"],
|
||||
"description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。"
|
||||
},
|
||||
"interaction": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "direct", "confirm", "form"],
|
||||
"description": "数据管理等可点击入口的交互方式(与 executionProfile 正交)。direct=一点即跑;confirm=轻量确认(文案用 confirmation.message);form=参数表单;auto/省略=宿主按 inputSchema+confirmation 推断。cron 触发与 agent 忽略本字段。"
|
||||
},
|
||||
"interactionByPlacement": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"toolbar": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"row": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"batch": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"cron": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"agent": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"skill-detail": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"create": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"update": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"delete": { "type": "string", "enum": ["direct", "confirm", "form"] },
|
||||
"import": { "type": "string", "enum": ["direct", "confirm", "form"] }
|
||||
},
|
||||
"description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。"
|
||||
},
|
||||
"bind": { "$ref": "#/$defs/bind" },
|
||||
"hostEffects": { "$ref": "#/$defs/hostEffects" },
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": { "$ref": "#/$defs/actionStep" },
|
||||
"description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。"
|
||||
},
|
||||
"authz": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"capability": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选文档/静态提示用能力名;宿主不得仅凭此显隐按钮,运行时以 policy Snapshot 为准。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"properties": {
|
||||
"placements": {
|
||||
"contains": { "const": "toolbar" }
|
||||
}
|
||||
},
|
||||
"required": ["placements"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["bind"]
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["replaces"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["bind"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json",
|
||||
"title": "SkillCellPresentationDocument",
|
||||
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "table", "rowKey", "cells"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"minLength": 1,
|
||||
"description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "业务表物理名(snake_case)"
|
||||
},
|
||||
"rowKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "用于匹配行的业务表列名(如 id、feishu_record_id);cells[].rowId 的值取自该列"
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选。ISO-8601 或技能约定的生成时间,便于排障"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "当前应对该表生效的呈现项;空数组 = 该表无特殊呈现(清除旧样式)。同 (rowId, column) 重复时宿主可后者覆盖前者。",
|
||||
"items": { "$ref": "#/$defs/cellItem" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"tone": {
|
||||
"type": "string",
|
||||
"enum": ["danger", "warning", "info", "success", "muted"],
|
||||
"description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。"
|
||||
},
|
||||
"cellItem": {
|
||||
"type": "object",
|
||||
"required": ["rowId", "column", "tone"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rowId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "rowKey 列的字符串形式主键/业务键"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "要强调的业务列名(snake_case),必须是真实列"
|
||||
},
|
||||
"tone": { "$ref": "#/$defs/tone" },
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"description": "可选。悬停/无障碍说明(短文案);不要塞长报告"
|
||||
},
|
||||
"alert": {
|
||||
"type": "boolean",
|
||||
"description": "可选,默认 false。true = 可计入进页/同步后的提醒汇总(如 Toast);填完数据并重算后该项消失则提醒自然关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-policy-snapshot.schema.json",
|
||||
"title": "SkillDataPolicySnapshot",
|
||||
"description": "匠厂 Skill Data Policy Snapshot(schemaVersion 1)。由技能 policy CLI 输出到 stdout;宿主用于数据管理按钮/行列过滤。详见 references/POLICY.md。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "actor", "actions"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"required": ["userId"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"userId": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string" },
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"attributes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"type": "object",
|
||||
"required": ["mode"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["allowlist", "open"],
|
||||
"description": "v1 仅允许 allowlist/open;denylist 非法。"
|
||||
},
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
},
|
||||
"description": "mode=allowlist 时必填;mode=open 时应省略或 []。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "mode": { "const": "allowlist" } },
|
||||
"required": ["mode"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["ids"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"hostCapabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["refresh", "export", "view", "edit", "delete", "import"]
|
||||
},
|
||||
"description": "省略=不限制宿主内置能力;写出则按 allowlist(含 []=全禁)。"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tables": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/tablePolicy" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "技能私有扩展;宿主必须忽略未知键。"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"predicate": {
|
||||
"type": "object",
|
||||
"required": ["field", "op"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eq",
|
||||
"ne",
|
||||
"in",
|
||||
"not_in",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"is_null",
|
||||
"is_not_null"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"description": "与 valueFrom 互斥;in/not_in 时为非空数组。"
|
||||
},
|
||||
"valueFrom": {
|
||||
"type": "string",
|
||||
"const": "actor.userId",
|
||||
"description": "v1 仅允许 actor.userId。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/predicate" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["all"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"all": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/predicate" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["any"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"any": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/predicate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"tablePolicy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"accessible": { "type": "boolean" },
|
||||
"columns": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"visible": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
}
|
||||
},
|
||||
"writable": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rows": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["scope", "open", "delegate"]
|
||||
},
|
||||
"scope": { "$ref": "#/$defs/scope" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
95
demo_rpa.py
95
demo_rpa.py
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""发票RPA功能演示脚本。
|
||||
|
||||
使用方法:
|
||||
1. 首先获取浏览器配置目录:
|
||||
python account-manager/scripts/main.py ensure-web --platform sandbox-jc2009
|
||||
|
||||
2. 运行演示:
|
||||
python demo_rpa.py --profile-dir "从account-manager获取的profile_dir"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加scripts目录到Python路径
|
||||
scripts_dir = os.path.join(os.path.dirname(__file__), "scripts")
|
||||
if scripts_dir not in sys.path:
|
||||
sys.path.insert(0, scripts_dir)
|
||||
|
||||
from service.rpa import run_invoice_rpa
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函数。"""
|
||||
parser = argparse.ArgumentParser(description="发票RPA功能演示")
|
||||
parser.add_argument("--profile-dir", required=True, help="浏览器配置目录")
|
||||
parser.add_argument("--username", default="15527406659", help="用户名")
|
||||
parser.add_argument("--password", default="qwer1234", help="密码")
|
||||
parser.add_argument("--headless", action="store_true", help="无头模式")
|
||||
parser.add_argument("--detail", action="store_true", help="提取详细信息")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("发票RPA功能演示")
|
||||
print("=" * 60)
|
||||
print(f"目标网站: https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck")
|
||||
print(f"用户名: {args.username[:3]}***")
|
||||
print(f"浏览器配置: {args.profile_dir}")
|
||||
print(f"模式: {'无头' if args.headless else '有头'}")
|
||||
print(f"提取模式: {'详细' if args.detail else '列表'}")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
print("\n正在启动浏览器并登录...")
|
||||
result = await run_invoice_rpa(
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
profile_dir=args.profile_dir,
|
||||
headless=args.headless,
|
||||
detail_mode=args.detail,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print("\n✅ 发票信息提取成功!")
|
||||
print(f"提取到 {result.data.get('total_count', 0)} 张发票信息")
|
||||
|
||||
# 显示前3条发票信息
|
||||
invoices = result.data.get('invoices', [])
|
||||
if invoices:
|
||||
print("\n前3条发票信息:")
|
||||
for i, invoice in enumerate(invoices[:3]):
|
||||
print(f"\n发票 {i+1}:")
|
||||
print(f" 行文本: {invoice.get('row_text', 'N/A')[:100]}...")
|
||||
if 'invoice_number' in invoice:
|
||||
print(f" 发票号码: {invoice['invoice_number']}")
|
||||
if 'amount' in invoice:
|
||||
print(f" 金额: {invoice['amount']}")
|
||||
|
||||
# 保存完整结果到文件
|
||||
output_file = "invoice_extraction_result.json"
|
||||
import json
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result.data, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n完整结果已保存到: {output_file}")
|
||||
|
||||
else:
|
||||
print(f"\n❌ 发票信息提取失败!")
|
||||
print(f"错误信息: {result.error}")
|
||||
print(f"错误阶段: {result.stage}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 运行异常: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("演示完成")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,132 +1,355 @@
|
||||
# REQUIREMENTS —— 票据合规预审助手(verify-invoice-default)
|
||||
# 技能需求文档模板
|
||||
|
||||
> 需求来源:《核心建设内容:各部门"Skill"深度需求规格书》§4.1 财务处 Skill 001(票据合规性"秒级预审")。
|
||||
> 详细设计见工作区《财务处Skill001-票据合规秒级预审-开发文档.md》;本文是该技能仓内的需求与验收主文档。
|
||||
> 定位原则:辅助非决策、审慎非激进 —— AI 只做预审提示,真伪与最终结论由人确认。
|
||||
这份文档用于给技术人员、产品人员和实施人员统一描述某个 skill 的研发需求。
|
||||
|
||||
## 0. 技能标识
|
||||
建议原则:
|
||||
|
||||
- 拟定 slug:`verify-invoice-default`
|
||||
- 中文 name:票据合规预审助手
|
||||
- 中文 description:高校报销场景的发票合规性秒级预审:核对字段完整性、购买方税号白名单、开票日期与出差行程逻辑、住宿交通经费标准,对连号、同商家集中开票、节假日开票等风险点红黄灯预警,生成《初审意见单》。
|
||||
- account-manager platform_key:不适用(无网页 RPA,无账号下发)
|
||||
- 命名形态:scope 型(verify ∈ 动词白名单;default ∈ scope 白名单;21 字符)
|
||||
- 网页 RPA 登录策略:`not_needed`(无浏览器自动化;不使用 account-manager)
|
||||
- 一个 skill 对应一份主需求文档
|
||||
- 需求先写清楚,再进入开发
|
||||
- 涉及外部系统时先读 `AI_RPA.md`,明确 Agent / RPA 边界、授权、风险与人工介入
|
||||
- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节
|
||||
- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md`、`references/`、`development/` 下的技术规范
|
||||
|
||||
## 0.1 宿主入口定位
|
||||
如果你是从 `skill-template` 复制新技能,请复制本文件后,把下面所有占位内容替换成你的真实项目内容。
|
||||
|
||||
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 教程章节 | 不挂理由 |
|
||||
|---------------------------|------|----------|------|----------|----------|
|
||||
| `prereview-invoice`(prereview) | 挂 | toolbar(bind invoice_records,表单) | 不挂 | 入口一/入口二 | 预审由单据驱动,无无人值守场景 |
|
||||
| `prereview-batch-input`(prereview-batch-input,async) | 挂 | 不挂 | 不挂 | 入口二 | 批量输入依赖 input 目录,属对话长任务 |
|
||||
| `render-html`(render-html) | 挂 | toolbar(bind prereview_reports)+ skill-detail | 不挂 | 入口一/入口二 | 同预审 |
|
||||
| `mark-verified` | 不挂 | row(prereview_reports,预填 id) | 不挂 | 入口一 | 查验回填是"对着记录"的操作 |
|
||||
| `list-reports` / `stats` | 挂 | 不挂 | 不挂 | 入口二 | 查询类,对话即达 |
|
||||
| `view-standards` | 挂 | 仅展示表 | 不挂 | 入口二 | 标准库日常只读 |
|
||||
| `set-standard` | 不挂 | row(expense_standards,预填键) | 不挂 | 入口一 | 管理员在表上改值更直接 |
|
||||
| `add-whitelist` | 不挂 | toolbar(taxid_whitelist,表单) | 不挂 | 入口一 | 结构化录入,表单更稳 |
|
||||
| `remove-whitelist` | 不挂 | row(taxid_whitelist,预填税号) | 不挂 | 入口一 | 同上 |
|
||||
| `health` / `version` / `config-path` | 不挂 | 不挂 | 不挂 | N/A | 运维诊断,仅 skill-detail |
|
||||
---
|
||||
|
||||
整技能无 cron placement:`TUTORIAL.md` 入口三已声明"不需要定时"及理由。
|
||||
# REQUIREMENTS
|
||||
|
||||
## 0.2 AI + RPA 协作与授权边界
|
||||
## 0. 技能标识(复制 REQUIREMENTS 时必填)
|
||||
|
||||
- 本技能**无 RPA**:不启动浏览器、不操作桌面/手机、不调用外部 HTTP。
|
||||
- 发票照片 → 结构化字段由 Agent 多模态能力或用户粘贴完成;技能只接收结构化 JSON。
|
||||
- 目标系统:无外部系统;全部计算在本地 SQLite。
|
||||
- 禁止路线(全技能适用):破解 / 逆向 / 私有协议 / 验证码绕过等;本技能不涉及。
|
||||
- 国税查验接口、OCR API、财务系统对接均为后续扩展,接口到位前预审结果"待人工查验"闭环。
|
||||
- 拟定 slug(英文,符合 `development/NAMING.md`):
|
||||
- 中文 name:
|
||||
- 中文 description(一句话):
|
||||
- account-manager platform_key(如有):
|
||||
- 命名形态:标准型 / scope 型
|
||||
- **网页 RPA(如有)— 登录策略(三选一,须写明):**
|
||||
- `required`:目标站必须登录才能闭环(用户须完成/保持站点登录;技能每轮主路径须有登录门禁 + 验证码/人工等待)
|
||||
- `optional`:登录可提升能力,但不登录也能完成主路径(**仅当**公开页确可闭环时选用;会话依赖型采集/评论/私信**禁止**默认抄成 optional)
|
||||
- `not_needed`:公开页即可,不要求站点登录
|
||||
- 无论上列哪一种:**都必须**经 account-manager **`ensure-web`**(或等价)取得 `profile_dir`(见 `development/RPA.md` §0.2);不得使用系统默认浏览器用户目录
|
||||
- **登记 ≠ 登录**:ensure/add-web 只建账号与 Profile;站点登录由本技能在有头浏览器中完成
|
||||
- **反模式**:把「需要登录态才能采到数据」的需求写成 `optional`,再省略 `ensure_logged_in`
|
||||
|
||||
## 0.3 业务行生命周期
|
||||
## 0.1 宿主入口定位(必填)
|
||||
|
||||
不适用:预审为同步单据计算,无"处理队列"状态机;`prereview_reports.verify_status`(待查验/已查验通过/已查验不符)仅记录查验回填状态,不承担抢占/重试语义。
|
||||
先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)。对话任务 / 数据管理 / 定时任务在宿主上**平级**;挂哪些由本技能业务与 UX 定,模板不规定「必须三入口都挂满」。用户文档如何写见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)。
|
||||
|
||||
对每个主能力填一行(可增行;「不挂」必须写理由):
|
||||
|
||||
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 教程章节 | 不挂理由(若不挂) |
|
||||
|---------------------------|------|----------|------|----------|--------------------|
|
||||
| (例)`<action-or-cli>` | 挂 / 不挂 | 不挂 / toolbar / row / batch / replaces / 仅展示表 | 挂 / 不挂 | 入口一 / 入口二 / 入口三 / N/A | |
|
||||
|
||||
说明:
|
||||
|
||||
- 数据管理列可写组合(如 `toolbar + row`)
|
||||
- 「仅展示表」= 用户要看过程/结果,但不在表上触发该能力
|
||||
- 定位为「挂」的入口须在 `assets/actions.json`(及必要表元数据)中落实,并与对话路径共用同一 domain service
|
||||
- **教程章节**:该能力在 `TUTORIAL.md` 中落在哪一节;整技能无定时时,入口三仍须写「不需要 + 理由」
|
||||
|
||||
### 0.1.1 用户市场文档(与入口表一致)
|
||||
|
||||
- `README.md`(说明 Tab):只写能力 / 场景 / 边界;**禁止**分步操作与 Agent 话术(见 `TUTORIAL_SURFACES.md` §1)
|
||||
- `TUTORIAL.md`(教程 Tab):覆盖 §0.1 中所有「挂」的入口;准备/载入类优先数据管理 toolbar,**不要**把 skill-detail 当主路径
|
||||
- 定位为「不挂」定时:教程须有 N/A 声明,与本表理由一致
|
||||
|
||||
## 0.2 AI + RPA 协作与授权边界(涉及外部系统时必填)
|
||||
|
||||
### Agent 设计
|
||||
|
||||
- 用户会怎样描述本任务:
|
||||
- 支持哪些用户意图;分别映射到哪个 Action:
|
||||
- 每种意图必须提取哪些参数:
|
||||
- 缺少哪些参数时必须追问:
|
||||
- 哪些动作必须先展示对象、数量和影响,再取得明确确认:
|
||||
- Agent 最终如何解释成功、部分成功、失败、等待人工和停止:
|
||||
|
||||
### 授权与平台边界
|
||||
|
||||
- 目标浏览器 / 桌面软件 / 手机 App:
|
||||
- 账号、设备、数据和业务权限由谁授权:
|
||||
- 是否有获得授权的官方 API;若不用,原因是什么:
|
||||
- 目标平台规则、合同或客户约定中的自动化边界:
|
||||
- 明确禁止的实现路线:破解 / 反编译 / 逆向 / 私有协议 / 抓包拼隐藏接口 / Hook 或注入 / 验证码破解 / 设备指纹伪造 / 自动换号:
|
||||
- 正常界面无法闭环时的上报与停止条件:
|
||||
|
||||
### RPA 风控设计
|
||||
|
||||
- 执行端:浏览器 / 桌面 / Android / iOS:
|
||||
- 真实界面版本、拾取工具和拾取日期:
|
||||
- element map 文件路径;关键元素是否全部登记:
|
||||
- 每个关键元素的作用域、主定位器、匹配数量和已验证 fallback:
|
||||
- 页面 / iframe / 窗口 / 弹窗 / Native / WebView 如何选择:
|
||||
- 会话、Profile、窗口或设备如何准备与互斥:
|
||||
- 单批上限、单位时间操作量和最长运行时间:
|
||||
- 普通动作节奏(真实环境默认 `1~3` 秒):
|
||||
- 页面 / 控件真实状态如何判断;禁止用固定睡眠代替的节点:
|
||||
- 哪些错误允许有限重试;退避策略:
|
||||
- 哪些信号必须立即停止(频控、封禁、权限不足、结构变化等):
|
||||
- 哪些步骤进入人工处理(验证码、人脸、指纹、短信、U盾等):
|
||||
- 如何防止重复提交并支持必要的断点续跑:
|
||||
- 需要保存哪些日志、截图、视频和业务结果:
|
||||
- 定位失败时如何保存 DOM/UIA/XML、候选数量和环境版本:
|
||||
|
||||
## 0.3 业务行生命周期(有「处理某一行」类能力时必填)
|
||||
|
||||
若本技能存在对业务表某一行做生成、触达、私信、发布等**有副作用**的处理,须遵守 [`../references/WORK_ITEM_STATUS.md`](../references/WORK_ITEM_STATUS.md),并在此声明**本技能状态 → 角色**映射(状态名自定,角色固定):
|
||||
|
||||
| 角色 | 本技能状态值(可多个) | 说明 |
|
||||
|------|------------------------|------|
|
||||
| 可操作(operable) | (例)新建 / 待生成 / … | 默认唯一可被处理 Action 捞起 |
|
||||
| 进行中(in_progress) | (例)生成中 / 触达中 / … | 抢占后、结束前 |
|
||||
| 成功类终态 | (例)已生成 / 已私信 / … | 默认不可再处理 |
|
||||
| 失败类终态 | (例)失败 / 触达失败 / … | 默认不可再处理;失败原因字段: |
|
||||
| 跳过类终态 | (例)已跳过 / … | 默认不可再处理 |
|
||||
|
||||
无此类处理队列时整节写「不适用」即可。
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
明确 `verify-invoice-default` 的研发范围、交付标准与验收要求,作为开发与测试统一依据。
|
||||
说明这份需求文档的作用,以及它解决什么问题。
|
||||
|
||||
模板写法:
|
||||
|
||||
- 本文档用于明确 `your-skill-slug` 的研发范围、交付标准、依赖关系和验收要求。
|
||||
- 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。
|
||||
|
||||
## 2. 业务背景
|
||||
|
||||
全校教职工报销需财务处逐张人工核验票据(真伪、抬头税号、日期逻辑、经费标准),单张 5-8 分钟、退单率高。需要开发 `verify-invoice-default`:对发票结构化字段自动完成合规预审,输出《初审意见单》(✅/⚠️/❌ 三色清单),把人工审核压缩到秒级并降低退单率。
|
||||
说明为什么要做这个 skill,它服务什么业务场景,解决什么实际问题。
|
||||
|
||||
模板写法:
|
||||
|
||||
- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
|
||||
- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。
|
||||
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`。
|
||||
|
||||
填写提示:
|
||||
|
||||
- `<业务场景>`:例如“每日批量导入外部系统结果”“Agent 触发的单次查询”
|
||||
- `<输入数据>`:例如“关键词列表”“批次 ID”“账号标识”
|
||||
- `<核心动作>`:例如“调用 adapter 提交”“采集并入库”“对账摘要生成”
|
||||
- `<输出结果>`:例如“结构化 JSON”“任务摘要”“本地 SQLite 记录”
|
||||
|
||||
## 3. 开发目标
|
||||
|
||||
- 解析并校验发票字段 JSON(单张/同批多张,上限 50 张/批)
|
||||
- 实现四步规则引擎(F1-F4 / V1-V3 / S1-S3 / R1-R3,共 13 条规则)
|
||||
- 输出结构化 JSON + 写入本地业务表 + 生成《初审意见单》HTML(`html/` + `render-html`)
|
||||
- 支持查验结果回填、标准库/白名单维护、预审统计
|
||||
- 失败可诊断(稳定错误码 `ERROR:<CODE>:` + 统一日志)
|
||||
说明本次开发希望最终交付什么结果。
|
||||
|
||||
模板写法(按 skill 类型勾选/改写):
|
||||
|
||||
- 解析并校验 `<输入数据>` 的关键字段
|
||||
- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>`
|
||||
- 输出结构化结果(stdout JSON 或固定文本 + 机读字段)
|
||||
- 写入必要的业务表(见 `references/SCHEMA.md`;无业务表也可)
|
||||
- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志)
|
||||
- 完成 CLI 入口与 `health` / `version` / `init-db` 最小可运行验证
|
||||
- 完成正式环境发布与安装验证(如适用)
|
||||
|
||||
## 4. 功能范围
|
||||
|
||||
说明这次开发明确要实现哪些功能。
|
||||
|
||||
模板写法:
|
||||
|
||||
- 支持 `<命令1>`(如 `run`)
|
||||
- 支持 `<命令2>`(如 `health` / 业务只读查询)
|
||||
- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API)
|
||||
- 支持执行 `<核心业务动作>`
|
||||
- 支持记录 `<日志 / 状态 / 结果>`
|
||||
|
||||
建议将功能拆成“必须实现”和“可后续扩展”。
|
||||
|
||||
### 必须实现
|
||||
|
||||
- `health` / `version` / `init-db` / `config-path`(模板基座;`run` 为模板演示入口,不承载业务)
|
||||
- `prereview`(主命令)、`prereview-batch-input`(async Action)
|
||||
- `render-html` / `list-reports` / `show-report` / `mark-verified`
|
||||
- `view-standards` / `set-standard` / `list-whitelist` / `add-whitelist` / `remove-whitelist` / `list-holidays` / `stats`
|
||||
- 业务表 5 张 + `_jiangchang_*` 中文展示元数据 + 种子数据(标准 8 条、税号 1 条、节假日 12 天)
|
||||
- `assets/actions.json` 按 §0.1 定位表声明
|
||||
- 支持 `python scripts/main.py health`
|
||||
- 支持 `python scripts/main.py version`
|
||||
- 支持 `python scripts/main.py init-db`(幂等建库;宿主安装/更新后可静默调用)
|
||||
- 支持 `python scripts/main.py <your-main-command>`
|
||||
- 支持核心业务编排(HTTP / 批处理 / 可选 RPA,按四档 adapter 选型)
|
||||
- 支持失败可诊断(统一文件日志 + 任务中心 `finish`)
|
||||
- 完成 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) 入口定位表,并按表落实已挂入口(对话 / 数据管理 / 定时按本技能 UX)
|
||||
|
||||
### 可后续扩展
|
||||
|
||||
- 国税查验 API 直连、OCR API 集成、财务系统对接(项目余额校验)、PDF 意见单、跨批次连号检测、销方黑名单、定时入口
|
||||
- 支持失败自动重试
|
||||
- 支持多 `<目标系统>` 轮询
|
||||
- 入口定位表中已标明「本期不挂」且写了理由的能力(例如暂不挂定时)
|
||||
|
||||
## 5. 非功能要求
|
||||
|
||||
- 目录结构符合 `skill-template` 规范;入口统一 `scripts/main.py`
|
||||
- 单文件 < 900 行;UTF-8 without BOM;Windows UTF-8 输出兼容
|
||||
- 机读输出 JSON;错误前缀 `ERROR:`;主命令 `task_start`/`task_failed` 日志齐全,无敏感明文
|
||||
- 只存发票结构化字段,不存影像与身份证号等个人信息
|
||||
- 测试离线可过(无网络、无浏览器)
|
||||
说明除功能外,对稳定性、可维护性、目录规范、日志、编码等方面的要求。
|
||||
|
||||
- 目录结构必须符合当前 `skill-template` 规范
|
||||
- 入口必须统一为 `scripts/main.py`
|
||||
- 用户市场四 Tab 写在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;LLM/运行契约写在 `SKILL.md`;开发规范在 `development/`
|
||||
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
|
||||
- Windows 环境下需保证 UTF-8 输出兼容
|
||||
- 必须具备基本日志能力;敏感字段脱敏
|
||||
- 只做授权范围内的正常界面自动化;禁止破解、逆向、未授权私有协议、安全验证绕过、进程注入、凭据窃取、自动换号和无限重试
|
||||
- RPA 的页面加载与成功判断必须由真实状态驱动;正常业务动作之间默认随机等待 `1~3` 秒
|
||||
- RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args`
|
||||
- 网页 RPA:启动前必须经 ensure-web 拿到 `profile_dir`;生产有头;站点登录策略见 §0(required / optional / not_needed);`required` 时每轮须登录门禁
|
||||
|
||||
## 6. 输入输出要求
|
||||
|
||||
### 输入
|
||||
|
||||
- `invoice_json`:发票字段(camelCase,兼容 snake_case/中文键),支持文件路径 / 内联 JSON / stdin
|
||||
- 上下文:`--purpose` `--project-no` `--trip-start` `--trip-end` `--trip-city` `--staff-title`
|
||||
- 批量:`input/` 目录 JSON 文件(`{"invoices": [...], "context": {...}}` 或纯数组)
|
||||
填写本 skill 实际接受的参数与数据来源:
|
||||
|
||||
- `<参数名>`:含义、是否必填、默认值
|
||||
- `<依赖系统>` 返回的结构化数据(如有)
|
||||
- 环境变量 / `.env` 中的关键配置(引用 `CONFIG.md`)
|
||||
|
||||
### 输出
|
||||
|
||||
- 成功:stdout JSON(`ok/batch_no/count/verdict_counts/reports[]/disclaimer`)
|
||||
- 失败:`ERROR:INVOICE_INVALID|INVOICE_INPUT_MISSING|REPORT_NOT_FOUND|VERIFY_INPUT_INVALID|STANDARD_INPUT_INVALID|WHITELIST_INPUT_INVALID|ENTITLEMENT_DENIED|QUERY_FAILED` + 中文 message
|
||||
- 落库:`invoice_records`、`prereview_reports`;文件:`html/report-{id}.html`
|
||||
- 成功结果(stdout / JSON 字段说明)
|
||||
- 错误输出(`ERROR:` 稳定码 + 中文 message)
|
||||
- 数据库记录(表名、关键字段,见 `references/SCHEMA.md`)
|
||||
- 可选:RPA 截图/视频路径(artifacts)
|
||||
|
||||
## 7. 依赖的兄弟技能或外部系统
|
||||
|
||||
- 无兄弟技能依赖;无外部系统
|
||||
- `requirements.txt`:无特有依赖(公共能力由共享 runtime 提供)
|
||||
### 兄弟技能依赖
|
||||
|
||||
- `<sibling-skill-slug>`:`<用途>`(例如 account-manager 提供 profile_dir / 租约)
|
||||
|
||||
### 外部系统依赖
|
||||
|
||||
- `<目标系统>`:`<用途>`(HTTP API / 仿真 sandbox / 真实网页)
|
||||
- 浏览器(如适用):系统 Chrome/Edge,非 Playwright 内置 Chromium
|
||||
- Gitea / 匠厂客户端:发布与正式环境验证
|
||||
|
||||
### Python 包依赖(requirements.txt)
|
||||
|
||||
- 本技能若需**特有** Python 三方包,写入根目录 `requirements.txt`。
|
||||
- `jiangchang-platform-kit`、`playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
|
||||
- `SKILL.md` 的 `platform_kit_min_version` 是运行契约/兼容性声明,**不是** pip 依赖声明。
|
||||
- 版本须尽量收窄(如 `requests>=2.31.0,<3`),避免共享 venv 冲突。
|
||||
- **不要**把系统运行时组件写进 requirements.txt;在 `health` / preflight 中检测并提示。
|
||||
|
||||
## 8. 不在本次范围内的内容
|
||||
|
||||
- 不做自动报销提交、不改财务账务
|
||||
- 不做发票真伪自动判定(只提示查验 + 回填)
|
||||
- 不存发票影像;不接 OCR/国税 API/财务系统(扩展项)
|
||||
- 不兼容旧模板历史结构
|
||||
明确本次“不做什么”,避免范围蔓延:
|
||||
|
||||
- 本次不实现 `<功能A>`
|
||||
- 本次不处理 `<平台B>` / `<档位>`
|
||||
- 本次不做 `<高级能力>`
|
||||
- 本次不兼容旧模板历史结构(`docs/`、`optional/`、`skill_main.py` 等)
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 目录结构合规;目录名 = `SKILL.md` slug = `constants.SKILL_SLUG`;版本三处一致(SKILL.md/constants/CHANGELOG=1.0.0)
|
||||
- `health` / `version` / `init-db` 正常;`python tests/run_tests.py -v` 全绿
|
||||
- 规则引擎金标准测试覆盖 13 条规则正反例;三种代表性场景(通过/驳回/待确认)真实跑通
|
||||
- `mark-verified` 回填"不符"后结论升级驳回;`stats` 与记录一致
|
||||
- HTML 意见单生成、可重建、宿主侧栏可预览
|
||||
- `init_db()` 写入 `_jiangchang_tables/_jiangchang_columns`,中文 display_name 完整,`test_display_metadata` 通过
|
||||
- `assets/actions.json` 与 §0.1 定位表、`TUTORIAL.md` 章节一致
|
||||
- 发布前 `developer_ids` 替换为本人匠厂用户 ID;发布后按入口定位表完成宿主多入口验收(含用户体验)
|
||||
什么情况下才算开发完成:
|
||||
|
||||
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
|
||||
- `health`、`version`、`init-db` 命令执行正常
|
||||
- `python tests/run_tests.py -v` 必跑测试全部通过(mock / 离线门禁;**mock 通 ≠ 完成**)
|
||||
- 主命令在业务默认档(模板为 `real_rpa`)真实跑通;不得只 mock 通就交活
|
||||
- 业务表(若有)写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
|
||||
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
|
||||
- 字段展示顺序与 `PRAGMA table_info(<业务表>)` 的 cid 一致;不依赖 `display_order`
|
||||
- `tests/test_display_metadata.py` 通过
|
||||
- 真实联调样例仍放 `tests/integration/`,默认套件不包含真实外联(与业务 `.env` 默认 `real_rpa` 不冲突)
|
||||
- `SKILL.md` 的 `developer_ids` 已配置为开发者本人匠厂用户 ID(开发期不公开时,否则本人无法在市场安装自测;取 ID 步骤见 `development/DEVELOPMENT.md` §6)
|
||||
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
|
||||
- 安装后按本技能入口定位表与 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15、`PRODUCT_SURFACES.md`);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
|
||||
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或其他规避平台限制的实现
|
||||
- RPA 真实主路径已验证状态等待、`1~3` 秒正常操作节奏、有限重试、账号 / 设备串行、人工介入、结果核验和结束存证
|
||||
- RPA 关键元素已在真实界面拾取并登记 element map;关键动作唯一定位,重启会话后复测通过
|
||||
- 页面/窗口/设备结构变化时停止并保存截图与 DOM/UIA/XML,不运行时猜 selector
|
||||
|
||||
## 10. 开发注意事项
|
||||
|
||||
- `cli` 只做参数解析;核心逻辑在 `service`(prereview_service 唯一内核,多入口复用)
|
||||
- 业务表间禁止 SQLite 外键;时间字段 Unix 秒
|
||||
- 保留模板演示 `cmd_run`(POLICY-RPA-003 录屏集成守护要求 service 层保留视频集成令牌);本技能业务命令不使用录屏
|
||||
- 修改开发规范时同步 `POLICY_MATRIX.md` 与对应守护测试
|
||||
- 只修改当前 skill 仓库,不要改动无关兄弟项目
|
||||
- 正常界面或获得授权的官方 API 无法完成闭环时,停止并上报;不得擅自改走破解、逆向或绕过安全机制的路线
|
||||
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
|
||||
- 涉及界面时再按端阅读 `RPA_ELEMENT_LOCATING.md` 和 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`
|
||||
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`)
|
||||
- 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)
|
||||
|
||||
## 11. 变更记录
|
||||
|
||||
| 日期 | 版本 | 变更人 | 变更内容 |
|
||||
|------|------|--------|----------|
|
||||
| 2026-09-13 | v1.0 | 开发组 | 初版:四步规则引擎 + 标准库/白名单/查验回填 + 初审意见单 |
|
||||
| YYYY-MM-DD | v1.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. 文档目标
|
||||
|
||||
本文档用于明确 `your-skill-slug` 的研发需求、范围和验收标准,作为开发与测试的统一依据。
|
||||
|
||||
## 2. 业务背景
|
||||
|
||||
当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
|
||||
因此需要开发 `your-skill-slug`,自动完成 `<核心动作>`,输出 `<输出结果>`。
|
||||
|
||||
## 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 | 张三 | 初版 |
|
||||
```
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# 人工验收评估场景(金标准用例)
|
||||
|
||||
> 配套自动化测试:`tests/test_prereview_rules.py`。以下场景用于宿主安装后的人工验收(对话 / 数据管理双入口)。
|
||||
|
||||
## 场景 A:全绿通过
|
||||
|
||||
**输入**:发票号码 20 位数电票、工作日开票、金额 1650 元(大写壹仟陆佰伍拾元整)、购买方税号在白名单、住宿费、城市广州、3 天、出差区间覆盖开票日、职级副高及以上、已回填查验通过。
|
||||
|
||||
**预期**:结论 ✅ 通过;F1-F4、V1-V3、S1 全部出现在通过项;意见单 HTML 三色清单排版正确。
|
||||
|
||||
## 场景 B:驳回(硬伤)
|
||||
|
||||
**输入**:购买方税号不在白名单 + 住宿费折合 1200 元/人/天(一线城市/中级及以下,上限 480)。
|
||||
|
||||
**预期**:结论 ❌ 驳回;V2、S1 为驳回项,均附《差旅费管理办法》条款引用;对话中助手读出驳回原因与"换开发票"建议。
|
||||
|
||||
## 场景 C:待确认(黄灯)
|
||||
|
||||
**输入**:同批两张连号发票(号码差 1)、开票日为国庆节、未查验真伪。
|
||||
|
||||
**预期**:结论 ⚠️ 待确认;R1 连号、R3 节假日、V1 未查验三条黄灯,附查验平台指引;回填查验"不符"后结论升级为 ❌ 驳回。
|
||||
|
||||
## 场景 D:数据不齐(人工兜底)
|
||||
|
||||
**输入**:发票照片模糊,缺少发票号码与金额。
|
||||
|
||||
**预期**:F1 黄灯提示"重新拍摄/补录",不硬性判错;助手引导用户补齐字段后重新预审。
|
||||
|
||||
## 场景 E:标准维护
|
||||
|
||||
**输入**:管理员在"经费标准库"表把"一线城市/副高及以上"住宿上限改为学校新标准。
|
||||
|
||||
**预期**:再次预审超标发票时,S1 驳回明细中的上限数值与依据条款同步更新。
|
||||
@@ -1,45 +0,0 @@
|
||||
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: 待填写
|
||||
@@ -1,355 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,135 +0,0 @@
|
||||
"""浏览器会话启动(系统 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
|
||||
@@ -1,450 +0,0 @@
|
||||
"""人工验证等待:保存登录倒计时、滑块、短信验证码等(不自动破解)。"""
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -1,637 +0,0 @@
|
||||
"""真实网站关键词搜索 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)
|
||||
@@ -1,146 +0,0 @@
|
||||
"""轻量任务编排示例(无真实数据库依赖)。"""
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
"""示例常量(复制到新 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",
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""示例结构化日志工具。"""
|
||||
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,63 +0,0 @@
|
||||
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()
|
||||
@@ -1,170 +0,0 @@
|
||||
# -*- 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()
|
||||
@@ -1,34 +0,0 @@
|
||||
# -*- 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()
|
||||
@@ -1,117 +0,0 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,129 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,127 +0,0 @@
|
||||
"""作用域内 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))
|
||||
@@ -1,58 +0,0 @@
|
||||
"""企业微信成功案例的安全提炼: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": "已填写搜索条件,未发送邀请",
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"""脱敏 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",
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
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()
|
||||
@@ -1,155 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,355 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,32 +0,0 @@
|
||||
"""批量提交 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
|
||||
@@ -1,36 +0,0 @@
|
||||
"""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},
|
||||
)
|
||||
@@ -1,109 +0,0 @@
|
||||
"""仿真浏览器 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)
|
||||
@@ -1,126 +0,0 @@
|
||||
"""浏览器会话启动(系统 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
|
||||
@@ -1,400 +0,0 @@
|
||||
"""仿真批量提交 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)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""轻量任务编排:校验输入 → 选 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
|
||||
@@ -1,52 +0,0 @@
|
||||
"""仿真浏览器 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()
|
||||
@@ -1,38 +0,0 @@
|
||||
"""示例日志工具。"""
|
||||
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,63 +0,0 @@
|
||||
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()
|
||||
@@ -1,64 +0,0 @@
|
||||
# -*- 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()
|
||||
@@ -1,39 +0,0 @@
|
||||
# -*- 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()
|
||||
@@ -1,134 +0,0 @@
|
||||
# -*- 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()
|
||||
@@ -1,74 +0,0 @@
|
||||
# -*- 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()
|
||||
@@ -1,8 +1,6 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py *_test.py
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
norecursedirs = .git __pycache__ .pytest_cache integration desktop samples fixtures artifacts diagnostics
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
addopts = -v --tb=short
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
||||
# 数据管理单元格呈现(Cell Presentation)契约(v1.0)
|
||||
|
||||
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
|
||||
> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。
|
||||
> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。
|
||||
用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。
|
||||
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 能力档位
|
||||
|
||||
| 档位 | 技能行为 | 宿主行为 |
|
||||
|------|----------|----------|
|
||||
| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 |
|
||||
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 |
|
||||
|
||||
多数技能保持 **P0**。需要视觉强调时再启用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 职责边界
|
||||
|
||||
| 角色 | 负责 | 不负责 |
|
||||
|------|------|--------|
|
||||
| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
|
||||
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
|
||||
|
||||
**派生状态(重要):** 呈现是计算结果,不是粘性开关。
|
||||
用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 文档形状(冻)
|
||||
|
||||
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"cells": [
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "qty",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "status",
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `schemaVersion` | 是 | 固定 `1` |
|
||||
| `skill` | 是 | 与 slug 一致 |
|
||||
| `table` | 是 | 当前文档作用的业务表 |
|
||||
| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) |
|
||||
| `generatedAt` | 否 | 排障用 |
|
||||
| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** |
|
||||
|
||||
### 2.1 `cells[]` 项
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `rowId` | 是 | `rowKey` 列值的字符串形式 |
|
||||
| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 |
|
||||
| `tone` | 是 | 见 §3 |
|
||||
| `message` | 否 | 短说明(≤500);悬停 / 无障碍 |
|
||||
| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 |
|
||||
|
||||
同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。
|
||||
`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。
|
||||
|
||||
---
|
||||
|
||||
## 3. `tone` 闭集(冻)
|
||||
|
||||
| tone | 语义(技能怎么选) | 宿主怎么呈现(示例,主题可调) |
|
||||
|------|-------------------|-------------------------------|
|
||||
| `danger` | 必须立刻处理 / 已违规 / 严重超时 | 强强调(如浅红底) |
|
||||
| `warning` | 即将到期 / 需注意 | 中等强调(如浅黄) |
|
||||
| `info` | 提示性标记 | 轻强调(如浅蓝) |
|
||||
| `success` | 正向完成态(少用在「待填」场景) | 浅绿等 |
|
||||
| `muted` | 弱化 / 次要 | 灰底或灰字 |
|
||||
|
||||
**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。
|
||||
以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。
|
||||
|
||||
「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐持久化(给宿主实现)
|
||||
|
||||
v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
row_key TEXT NOT NULL,
|
||||
row_id TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
tone TEXT NOT NULL,
|
||||
message TEXT,
|
||||
alert INTEGER NOT NULL DEFAULT 0,
|
||||
generated_at TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE (table_name, row_id, column_name)
|
||||
);
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。
|
||||
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。
|
||||
- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。
|
||||
|
||||
未创建该旁路表 = P0,宿主忽略。
|
||||
|
||||
---
|
||||
|
||||
## 5. 在 `actions.json` 中启用(可选,推荐)
|
||||
|
||||
与 `policy` 类似,顶层可选:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"cellPresentation": {
|
||||
"enabled": true
|
||||
},
|
||||
"actions": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `cellPresentation` | 省略 = P0 |
|
||||
| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 |
|
||||
|
||||
v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。
|
||||
|
||||
---
|
||||
|
||||
## 6. 何时重算(技能必须遵守)
|
||||
|
||||
至少在以下时机用**同一套 domain 规则**重算并覆盖该表呈现:
|
||||
|
||||
1. 全量/增量把外部数据写入本地投影之后(如同步成功)
|
||||
2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等)
|
||||
3. (可选)用户显式「刷新呈现」类 Action
|
||||
|
||||
进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。
|
||||
提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 与其它契约的关系
|
||||
|
||||
| 契约 | 关系 |
|
||||
|------|------|
|
||||
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 |
|
||||
| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 |
|
||||
| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 |
|
||||
| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 反模式
|
||||
|
||||
- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案
|
||||
- 技能下发 CSS / 内联颜色
|
||||
- 只在首次超时时打标、填完不重算导致红字残留
|
||||
- 宿主写死某个技能 slug 的特殊染色逻辑
|
||||
- 把 Cell Presentation 做成仅服务 SLA 的专用协议
|
||||
|
||||
---
|
||||
|
||||
## 9. 示例场景(说明用途不限)
|
||||
|
||||
| 场景 | column | tone | message 例 |
|
||||
|------|--------|------|------------|
|
||||
| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 |
|
||||
| 库存低于安全线 | `qty` | `warning` | 低于安全库存 |
|
||||
| 审核驳回 | `status` | `danger` | 已驳回,请修改 |
|
||||
| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 |
|
||||
|
||||
完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。
|
||||
@@ -1,88 +0,0 @@
|
||||
# 通用业务技能模板 CLI 标准
|
||||
|
||||
将 `{baseDir}` 替换为技能根目录(含 `SKILL.md`、`scripts/` 的目录)。
|
||||
|
||||
## 解释器(必读)
|
||||
|
||||
必须使用宿主**共享 Python runtime**(`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,已在 PATH / `VIRTUAL_ENV`)。一条命令直接跑,不要先探系统 Python。
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/main.py <command> …
|
||||
```
|
||||
|
||||
| 场景 | 写法 |
|
||||
|------|------|
|
||||
| **推荐(Agent)** | `python {baseDir}/scripts/main.py <command> …` |
|
||||
| 手工排查(环境未注入时) | `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/Scripts/python.exe`(Windows)或 `…/.venv/bin/python3`(Unix) |
|
||||
|
||||
**禁止**对技能脚本使用 `uv run python …`(会创建临时环境,加载不到 `jiangchang-platform-kit` / `jiangchang_skill_core`)。
|
||||
**禁止** Agent 自行 `pip install` / `uv pip install` 往临时环境塞依赖。
|
||||
|
||||
## Agent 分流(业务技能复制后必改)
|
||||
|
||||
用本表列出**本技能**哪些子命令可走短 CLI,哪些必须走 Skill Action。默认:短查询/只读写本地可用短 CLI;长耗时 / RPA / 需进度取消进**黑名单**。有 async/RPA 的技能:**黑名单不得为空**,且黑名单命令须映射到 `actions.json` 中的 async Action。
|
||||
|
||||
| 类型 | 本技能命令(自填) | Agent |
|
||||
|------|--------------------|-------|
|
||||
| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` |
|
||||
| **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 页面报告」。
|
||||
|
||||
## 最小命令
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/main.py health
|
||||
python {baseDir}/scripts/main.py config-path
|
||||
python {baseDir}/scripts/main.py version
|
||||
python {baseDir}/scripts/main.py init-db
|
||||
```
|
||||
|
||||
## 标准行为
|
||||
|
||||
- **任意 CLI 启动前**执行 `bootstrap_skill_config()`(`main.py` 与 `cli.app.main()` 均触发)。
|
||||
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。
|
||||
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
|
||||
- **`version`**:输出 JSON(`version`、`skill`)。
|
||||
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
|
||||
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
|
||||
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
|
||||
- **排查顺序**(由快到慢):
|
||||
1. 任务中心 / Run Journal `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`
|
||||
2. 统一日志文件 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(含 `trace_id`、`task_failed` 等)
|
||||
3. 业务表(宿主数据管理;有才建)
|
||||
4. RPA artifacts / `video_log` / 失败截图(开发规范见 `development/RPA.md`);未开启录屏时可能只有截图或日志
|
||||
- **录屏默认关闭**:模板 `.env.example` 为 `OPENCLAW_RECORD_VIDEO=0`;若未开启录屏,任务**可能不会**打印录屏路径。
|
||||
- 需要录屏时在用户 `.env` 或进程环境变量中设置 **`OPENCLAW_RECORD_VIDEO=1`**;开启后任务完成时 CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`),且 `finish()` 含 video 字段。
|
||||
|
||||
## config-path(配置路径)
|
||||
|
||||
```json
|
||||
{
|
||||
"skill": "your-skill-slug",
|
||||
"env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env",
|
||||
"example_path": "{skill_root}/.env.example"
|
||||
}
|
||||
```
|
||||
|
||||
## health 标准输出(runtime diagnostics)
|
||||
|
||||
`health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,典型字段:
|
||||
|
||||
- `python_executable` — 当前 Python 解释器路径(应落在共享 python-runtime)
|
||||
- `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok`
|
||||
- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning)
|
||||
- `media_assets_root`、`ffmpeg_path`、`ffmpeg_available`、`background_music_mp3_count`
|
||||
- `runtime_issue[warning|error]` — 非致命/致命问题列表
|
||||
- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径
|
||||
|
||||
## 通用业务命令骨架
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/main.py run
|
||||
python {baseDir}/scripts/main.py run -t <target> -i <input_id>
|
||||
```
|
||||
|
||||
| 命令 | 用途 | Agent 注意 |
|
||||
|------|------|------------|
|
||||
| `run` | 执行一次任务(常为长任务 / RPA) | **走 `run_skill_action`**,不要 bash poll |
|
||||
@@ -1,383 +0,0 @@
|
||||
# Skill Data Policy 契约(v1.0)
|
||||
|
||||
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
|
||||
> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。
|
||||
> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(行态 `visibleWhen` 等)。
|
||||
> **注意:** L3 预留的行态 `visibleWhen` **不是** Action 参数表单的 `inputSchema.properties.*.visibleWhen`(表单控件显隐,见 [`ACTIONS.md`](ACTIONS.md)「字段条件显隐」)。二者同名不同义,勿混用。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 能力档位(必须按此理解)
|
||||
|
||||
| 档位 | 技能配置 | 宿主行为 |
|
||||
|------|----------|----------|
|
||||
| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 |
|
||||
| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 |
|
||||
| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 |
|
||||
| **L3** | (预留,本契约不冻结行为) | 如**行态** `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等(≠ Action 表单 `inputSchema.visibleWhen`) |
|
||||
|
||||
**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 已冻结最佳实践(v1.0)
|
||||
|
||||
| 议题 | 决定 | 技能开发者怎么做 |
|
||||
|------|------|------------------|
|
||||
| Skill Action 授权模式 | **仅** `allowlist` / `open`;v1 **禁止** `denylist` | 有角色就算出允许的 action id 列表;只控行不控按钮时用 `open` |
|
||||
| `hostCapabilities` | **省略 = 不限制**宿主内置;写出则按 allowlist(`[]` = 全禁内置) | 只控业务按钮时**不要写**该字段;要限导出/删除时再显式列出 |
|
||||
| scope 运算符 | 保留小白名单(见 §3.4);**禁止** like/raw/嵌套 | 日常优先 `eq` / `in` / `is_null`;比较符按需 |
|
||||
| `valueFrom` | v1 **仅** `actor.userId` | 角色只在技能内用来算 Snapshot,**不要**用 `actor.roles` 做行过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 启用字段(冻在 `assets/actions.json` 顶层)
|
||||
|
||||
`actions.json` 根对象增加**可选**字段 `policy`(与 `schemaVersion` / `skill` / `actions` 同级)。
|
||||
|
||||
### 1.1 形状(L0/L1 必冻)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"policy": {
|
||||
"enabled": true,
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "policy",
|
||||
"args": ["--table", "{{table}}"]
|
||||
},
|
||||
"appliesTo": ["data-management"]
|
||||
},
|
||||
"actions": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 冻结说明 |
|
||||
|------|------|----------|
|
||||
| `policy` | 否 | 缺省 = L0 |
|
||||
| `policy.enabled` | 若存在 `policy` 则**必填** | 仅 `true` 启用;`false` 等同 L0 |
|
||||
| `policy.entrypoint` | `enabled: true` 时必填 | 与 Action `entrypoint` 同构:`type` 目前仅 `"cli"` |
|
||||
| `policy.entrypoint.command` | 是 | 子命令名;**推荐**固定为 `policy` |
|
||||
| `policy.entrypoint.args` | 是 | JSON 数组;可含占位符,由宿主替换后再拼接 CLI |
|
||||
| `policy.appliesTo` | 否 | 缺省 `["data-management"]`。本期宿主**只实现** `data-management`;其它值忽略但不报错 |
|
||||
|
||||
### 1.2 占位符白名单(冻)
|
||||
|
||||
| 占位符 | 含义 | 例 |
|
||||
|--------|------|----|
|
||||
| `{{table}}` | 当前数据管理选中的**物理表名** | `customs_timelines` |
|
||||
| `{{skill}}` | 当前技能 slug | 与 manifest `skill` 一致 |
|
||||
| `{{userId}}` | 当前宿主用户 id | 与注入的 `JIANGCHANG_USER_ID` 一致 |
|
||||
|
||||
未列出的 `{{…}}`:**禁止**;宿主应视为契约错误(fail-closed,见 §6)。
|
||||
|
||||
### 1.3 显式不放进启用块的内容(防推翻)
|
||||
|
||||
以下**不得**出现在 `actions.json.policy` 中(属于技能内部或 Snapshot):
|
||||
|
||||
- 角色列表、人员表、飞书 app id、外部 IAM URL
|
||||
- 具体 allowlist / scope(那是 Snapshot 的事)
|
||||
- 按角色复制的 Action 定义
|
||||
|
||||
### 1.4 Action 级可选提示(不冻为运行时权威)
|
||||
|
||||
允许(文档/静态检查用),**宿主不得仅凭此显隐按钮**:
|
||||
|
||||
```json
|
||||
"authz": { "capability": "optional.skill.defined.string" }
|
||||
```
|
||||
|
||||
运行时权威仅为 Snapshot §2 的 `actions`。
|
||||
|
||||
---
|
||||
|
||||
## 2. Snapshot 最小字段集(`policy` CLI 标准输出)
|
||||
|
||||
- 编码:UTF-8 JSON 对象,写在 **stdout**(可先打日志到 stderr/文件,但 stdout 必须是单 JSON)。
|
||||
- 成功退出码 `0`;业务上「无权限看任何东西」仍是 `0` + 合法 Snapshot(例如空 allowlist),不是靠非 0 表示未授权。
|
||||
- 非 0 或非合法 JSON → §6 fail-closed。
|
||||
|
||||
### 2.1 根对象(L0 不出现;L1 必冻)
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `schemaVersion` | 是 | `integer` | 本期冻结为 **`1`** |
|
||||
| `skill` | 是 | `string` | 必须等于 manifest `skill` |
|
||||
| `actor` | 是 | `object` | 见 §2.2 |
|
||||
| `actions` | 是 | `object` | 见 §2.3 |
|
||||
| `hostCapabilities` | 否 | `string[]` | 见 §2.4;**省略 = 不限制宿主内置按钮**(兼容) |
|
||||
| `data` | 否 | `object` | 见 §3;省略 = 不限制行/列(L1-only) |
|
||||
| `extensions` | 否 | `object` | 技能私有扩展;宿主**必须忽略**未知键 |
|
||||
|
||||
根级未知字段:宿主**忽略**;**不得**依赖未写入本契约的字段做安全决策。
|
||||
|
||||
### 2.2 `actor`(L1 必冻)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `userId` | 是 | 非空;应与 `JIANGCHANG_USER_ID` / `{{userId}}` 对齐 |
|
||||
| `displayName` | 否 | 展示用 |
|
||||
| `roles` | 否 | `string[]`,角色**码**;仅供展示 / 技能自检。**不得**用于 v1 scope 的 `valueFrom` |
|
||||
| `attributes` | 否 | `object`,仅允许 JSON 标量值;本期 scope **不**解引用 |
|
||||
|
||||
### 2.3 `actions`(L1 必冻)— Skill Action 授权
|
||||
|
||||
**最佳实践:** 角色 → 技能内计算 → `allowlist` 的 id 列表。宿主不理解角色名。
|
||||
|
||||
```json
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "register-submit"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 冻结取值 |
|
||||
|------|------|----------|
|
||||
| `mode` | 是 | **仅两值**:`"allowlist"` \| `"open"` |
|
||||
| `ids` | `mode=allowlist` 时必填 | `string[]`;元素为 **`actions.json` 里的 action.id** |
|
||||
| `ids` | `mode=open` 时 | 必须省略或 `[]`;宿主视为**不限制** skill Action |
|
||||
|
||||
规则:
|
||||
|
||||
1. 宿主先按现有规则得到候选集:`placements` ∩ `bind.tables`(及 batch 等)。
|
||||
2. 若 `mode=allowlist`:候选 ∩ `ids`。漏配 id = 少按钮(安全侧失败)。
|
||||
3. `ids` 中的未知 id:**忽略**(不报错)。
|
||||
4. `mode=open`:等价「L1 不限制按钮」,可与 L2 行列限制并存。
|
||||
5. **`denylist` 不是 v1 合法值**;出现则 §6 fail-closed。
|
||||
|
||||
`replaces` 接管的默认新增/编辑/删除/导入:视为对应 Action 的 id 是否在 allowlist 中;不在则宿主隐藏该接管按钮(导入按钮是否可用还受 `hostCapabilities` 含 `import` 影响)。`mode=open` 时不按 id 限制 replaces。
|
||||
|
||||
### 2.4 `hostCapabilities`(L1 可选冻)
|
||||
|
||||
控制数据管理**宿主内置**能力,与 skill `action.id` **命名空间分离**。
|
||||
|
||||
本期枚举冻结:`refresh` | `export` | `view` | `edit` | `delete` | `import`。
|
||||
|
||||
| 写法 | 含义 | 何时用 |
|
||||
|------|------|--------|
|
||||
| **省略字段** | **不限制**(与今日一致) | **默认推荐**:只控 skill 按钮 / 行数据时 |
|
||||
| 写出非空数组 | 仅允许列出的内置能力(allowlist) | 要限导出、删除等时 |
|
||||
| 写出 `[]` | 禁止全部上表内置能力 | 极少见;慎用 |
|
||||
|
||||
未知字符串:忽略。**禁止**把「省略」解释成全禁。
|
||||
|
||||
---
|
||||
|
||||
## 3. L2:`data` 与 scope 语法白名单
|
||||
|
||||
### 3.1 `data` 形状
|
||||
|
||||
```json
|
||||
"data": {
|
||||
"tables": {
|
||||
"customs_timelines": {
|
||||
"accessible": true,
|
||||
"columns": {
|
||||
"visible": ["id", "contract_no", "status"],
|
||||
"writable": ["status"]
|
||||
},
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.tables` | 若存在 `data` 则建议有 | `key` = 物理表名 |
|
||||
| `accessible` | 否 | 缺省 `true`。`false`:宿主可拒绝打开该表 |
|
||||
| `columns.visible` / `writable` | 否 | 物理列名;省略 = 不额外限制 |
|
||||
| `rows` | 否 | 见下 |
|
||||
|
||||
`writable` 应 ⊆ `visible`;若违规,宿主按交集降级,不 fail-closed。
|
||||
|
||||
### 3.2 `rows.mode`(冻)
|
||||
|
||||
| 值 | 本期 | 含义 |
|
||||
|----|------|------|
|
||||
| `scope` | **实现目标** | 宿主将 `scope` 编译为安全查询约束 |
|
||||
| `open` | 允许 | 显式不限制行 |
|
||||
| `delegate` | **仅占位** | 本期未实现时 fail-closed / 提示;**禁止**当 `open` |
|
||||
|
||||
缺省:无 `rows` = `open`。
|
||||
|
||||
### 3.3 `scope` 布尔结构(冻)
|
||||
|
||||
```text
|
||||
Scope =
|
||||
| { "all": Predicate[] } // AND,长度 ≥ 1
|
||||
| { "any": Predicate[] } // OR,长度 ≥ 1
|
||||
| Predicate // 单个谓词的语法糖
|
||||
```
|
||||
|
||||
**禁止**:嵌套超过一层。
|
||||
|
||||
### 3.4 `Predicate` 白名单(冻)
|
||||
|
||||
**运算符(宿主必须实现):** `eq` | `ne` | `in` | `not_in` | `gt` | `gte` | `lt` | `lte` | `is_null` | `is_not_null`
|
||||
|
||||
**推荐日常只用:** `eq` / `in` / `is_null`(及 `is_not_null`)。
|
||||
|
||||
**明确排除:** `like` / `contains` / `sql` / `raw` / `expr`。
|
||||
|
||||
**`valueFrom` v1 仅:** `actor.userId`。
|
||||
`actor.roles` / `actor.attributes.*` **非法** → fail-closed。
|
||||
|
||||
`value` 与 `valueFrom` 互斥。标量:`string` | `number` | `boolean` | `null`(与 null 比较优先用 `is_null`)。
|
||||
|
||||
### 3.5 与宿主原有过滤的关系
|
||||
|
||||
最终行集 = **原有表查询**(搜索、排序、分页、用户 Filter) **AND** policy `scope`。宿主必须把 scope 下推到查询层。
|
||||
|
||||
---
|
||||
|
||||
## 4. 宿主消费算法
|
||||
|
||||
```text
|
||||
IF manifest.policy?.enabled !== true:
|
||||
→ L0;END
|
||||
|
||||
调用 entrypoint(替换占位符),解析 Snapshot
|
||||
IF 失败 / 非法 / skill 不匹配 / mode 非法:
|
||||
→ fail-closed(§6);END
|
||||
|
||||
候选 = filter(placements, bind.tables, …)
|
||||
IF mode == allowlist: 候选 ∩ ids
|
||||
IF mode == open: 候选不变
|
||||
|
||||
渲染 skill 按钮 ← 候选
|
||||
渲染宿主内置 ← hostCapabilities(省略则不限制)
|
||||
应用 data.tables[T](若有)
|
||||
```
|
||||
|
||||
拉取时机(建议):进入表、切换表、刷新。短缓存不得跨 `userId`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 身份与环境
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 用户主键 | `JIANGCHANG_USER_ID` |
|
||||
| 技能解析 | 飞书 / 本地表 / HTTP IAM 等,**契约不规定** |
|
||||
|
||||
---
|
||||
|
||||
## 6. Fail-closed(启用后必冻)
|
||||
|
||||
当且仅当 `policy.enabled === true`:
|
||||
|
||||
| 情况 | 宿主行为 |
|
||||
|------|----------|
|
||||
| CLI 非 0 / 超时 / 无 JSON | 不渲染该表 skill 写操作与(建议)导出;明确错误;**禁止**回退 L0 全开 |
|
||||
| `schemaVersion` ≠ 1 / `skill` 不匹配 / `actions.mode` 非法 | 同上 |
|
||||
| `rows.mode=scope` 但 predicate 非法 | 不返回行或整表不可用;**禁止**忽略 scope |
|
||||
| `rows.mode=delegate`(未实现) | 提示不支持;**禁止**当 `open` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全边界
|
||||
|
||||
1. Snapshot **只**服务宿主 UI 与查询约束。
|
||||
2. 写 Action / 敏感读的 **domain service 必须再次鉴权**。
|
||||
3. 禁止「只藏按钮」宣称为完整权限方案。
|
||||
4. Cron / Agent **不**自动套用本 Snapshot。
|
||||
|
||||
---
|
||||
|
||||
## 8. 最小示例
|
||||
|
||||
模板仓库另有可复制示例:
|
||||
|
||||
- [`../assets/examples/skill-policy-snapshot.allowlist.example.json`](../assets/examples/skill-policy-snapshot.allowlist.example.json)
|
||||
- [`../assets/examples/skill-policy-snapshot.scope.example.json`](../assets/examples/skill-policy-snapshot.scope.example.json)
|
||||
|
||||
### 8.1 L0
|
||||
|
||||
不写 `policy` 字段。
|
||||
|
||||
### 8.2 L1 allowlist(不碰宿主内置)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1", "displayName": "张三", "roles": ["clerk"] },
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "register-submit"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 L1 + 限制导出
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1", "roles": ["clerk"] },
|
||||
"actions": { "mode": "allowlist", "ids": ["whoami"] },
|
||||
"hostCapabilities": ["refresh", "view"]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 L1 open + L2 scope
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1" },
|
||||
"actions": { "mode": "open" },
|
||||
"data": {
|
||||
"tables": {
|
||||
"orders": {
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 刻意延后
|
||||
|
||||
| 项 | 备注 |
|
||||
|----|------|
|
||||
| `actions.mode: denylist` | 另开 `schemaVersion` |
|
||||
| `valueFrom: actor.roles` | **明确不做** |
|
||||
| `visibleWhen` / 嵌套 scope / `like` | L3 或永不做(此处指**行态**策略字段;Action 表单 `inputSchema.visibleWhen` 见 ACTIONS.md,不属本表) |
|
||||
| `rows.mode: delegate` 宿主实现 | 字段名已占坑 |
|
||||
| 平台全局 Role UI / cron·agent 套用 Snapshot | 另议 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 冻结检查清单
|
||||
|
||||
- [x] L0 缺省无歧义
|
||||
- [x] 启用字段仅 `enabled` + `entrypoint` + 可选 `appliesTo`
|
||||
- [x] `actions.mode` 仅 `allowlist` / `open`
|
||||
- [x] `hostCapabilities` 省略 = 不限制
|
||||
- [x] scope 小白名单;`valueFrom` 仅 `actor.userId`
|
||||
- [x] fail-closed;写路径技能自检
|
||||
@@ -1,841 +0,0 @@
|
||||
# 数据存储与数据管理展示规范
|
||||
|
||||
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
|
||||
|
||||
**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。
|
||||
|
||||
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py`;`scripts/db/connection.py` 的 `init_db()` 会一并初始化。
|
||||
|
||||
## 职责边界
|
||||
|
||||
### 技能负责
|
||||
|
||||
- 定义真实 SQLite 表结构
|
||||
- 使用英文 `snake_case` 表名和字段名
|
||||
- 使用 `_jiangchang_tables` 提供表级展示元数据
|
||||
- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明)
|
||||
- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里)
|
||||
- 定义哪些字段可见、可搜索、可创建、可编辑
|
||||
- 定义字段展示类型和枚举选项
|
||||
- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
|
||||
- 保证元数据与真实数据库结构一致
|
||||
- 保证元数据初始化幂等
|
||||
|
||||
### 宿主负责
|
||||
|
||||
- 读取 `_jiangchang_*` 元数据
|
||||
- 读取 SQLite `PRAGMA table_info`
|
||||
- **数据管理侧栏/表列表**:当库中存在 `_jiangchang_tables` 时,**仅展示**在该表注册且 `visible=1` 的业务表;孤儿物理表(有表、无注册元数据)**不得**出现在侧栏,直接访问 schema API 应返回「表不存在」。极老库无 `_jiangchang_tables` 时,宿主可保留物理表枚举 fallback(兼容 `_schema_meta` 时代)
|
||||
- 根据 schema 渲染数据管理页面
|
||||
- 根据 `editable` 推导字段是否可创建或编辑
|
||||
- 在**新增 / 编辑**表单中,若列有 `_jiangchang_columns.description`,在标签下方展示该说明(灰字;空则不显示)
|
||||
- 在数据管理**表格**中,若表有 `_jiangchang_tables.grid_help` 则在表上方展示可折叠说明;若列有 `_jiangchang_columns.grid_help` 则在列头显示「?」;空/未写则不渲染。**不**把 `description` 当作表格说明
|
||||
- 打开**新增**表单时,对可创建字段用 SQLite 列 `DEFAULT`(`PRAGMA table_info.dflt_value`)预填;无 DEFAULT 则留空
|
||||
- 执行通用查询、新增、编辑、删除、导入、导出
|
||||
- 对字段名、表名、类型和权限进行最终校验
|
||||
- 不猜测技能业务含义
|
||||
- 不根据具体技能名称写特殊逻辑
|
||||
- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果
|
||||
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则
|
||||
|
||||
## 初始化入口
|
||||
|
||||
幂等建库/迁移:`python scripts/main.py init-db`。业务仓储也可懒触发 `init_db()`,与 CLI 结果一致。
|
||||
|
||||
SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`;SQL 文件里的 `-- 注释` 也不会成为可查询结构。
|
||||
因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info` 的 `cid`)。
|
||||
|
||||
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
|
||||
|
||||
## 技能名称(SKILL.md)
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: 中文业务名称
|
||||
description: 中文技能说明
|
||||
metadata:
|
||||
openclaw:
|
||||
slug: english-kebab-slug
|
||||
---
|
||||
```
|
||||
|
||||
- `name`:用户可见的中文业务名称。
|
||||
- `description`:能力说明,不是技能名称。
|
||||
- `metadata.openclaw.slug`:机器标识,kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md)。
|
||||
- 复制模板后不得将 `your-skill-slug`、`My Skill` 等占位内容作为正式技能发布名称。
|
||||
|
||||
## 数据库路径与命名
|
||||
|
||||
```text
|
||||
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db
|
||||
```
|
||||
|
||||
- 数据库文件:英文 slug,例如 `your-skill-slug.db`(默认 `{skill-slug}.db`)。
|
||||
- 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items`、`created_at`。
|
||||
- **禁止**用中文作为 SQLite 真实表名或字段名。
|
||||
|
||||
## 外部来源只读与本地数据可写
|
||||
|
||||
某些技能会从外部系统读取数据并写入技能自己的本地数据库。需要区分:
|
||||
|
||||
```text
|
||||
外部来源数据库:
|
||||
可能只读,不应被修改
|
||||
|
||||
技能本地数据库:
|
||||
可以根据业务语义允许新增、编辑或删除
|
||||
```
|
||||
|
||||
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
|
||||
|
||||
若创建/更新/删除/导入带有领域副作用(自动建目录、加密、级联清理、归一化字段),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete|import` 接管默认按钮。其中 **import** 时用户仍使用宿主「导入数据」面板,确认后由技能写库。详见 [`ACTIONS.md`](ACTIONS.md) 与 `assets/examples/replaces-*.action.example.json`。
|
||||
|
||||
## 表与表之间:禁止 SQLite 外键
|
||||
|
||||
业务表之间的「属于哪条父记录 / 引用哪条来源」**只存业务 id**(整数或文本标量),**禁止**在 DDL 中写 SQLite `FOREIGN KEY` / `REFERENCES`,也**不要**指望 `PRAGMA foreign_keys = ON` 替你校验或级联。
|
||||
|
||||
| 要做的事 | 正确做法 | 禁止 |
|
||||
|----------|----------|------|
|
||||
| 子表指向父表 | 子表列如 `case_id INTEGER NOT NULL`;写入前在 repository/service 查父表是否存在 | `FOREIGN KEY (case_id) REFERENCES parent(id)` |
|
||||
| 父表记「最新一版」 | 父表列如 `latest_draft_id INTEGER NOT NULL DEFAULT 0`;由 service 更新指针 | 同上 |
|
||||
| 删除 / 覆盖 / 级联清理 | repository/service 内显式 `DELETE` / `UPDATE`;需要时用 Action `steps` 的 `deleteByFk` | 依赖 DB 级联或宿主 CRUD 被 FK 拦住 |
|
||||
| 数据管理里显示名称 | `_jiangchang_columns.display_type = lookup` + `options_json` 对象(见下文「跨表引用 lookup」) | 把 lookup 当成 DDL 外键 |
|
||||
|
||||
**为什么:** 宿主数据管理对业务表执行通用增删改,会直接写 SQLite。一旦 DDL 带外键,删除、迁移 rename 父表、或父表 id 变更时极易出现「本地数据操作失败」且难排查。关联语义属于技能业务,必须在代码里写清。
|
||||
|
||||
**迁移注意:** 避免「`ALTER TABLE parent RENAME TO parent_old` → 建新 parent → 删 old」这类会隐性改写子表 FK 指向的套路;优先 `ADD COLUMN` + 拷列,或重建子表且**不带** FK。
|
||||
|
||||
`display_metadata_validator` 与 `validate_template_database` 会扫描业务表 DDL;含 `FOREIGN KEY` / `REFERENCES` 时报错(见 `development/CHECKLIST.md` A6-6)。
|
||||
|
||||
## 标准数据同步语义(外源 → 本地库)
|
||||
|
||||
适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。
|
||||
|
||||
| 要求 | 说明 |
|
||||
|------|------|
|
||||
| 外部来源默认只读 | 技能不得修改外源 |
|
||||
| 本地 SQLite 可事务写入 | 写入走 repository + 事务 |
|
||||
| 幂等 | 同一业务键重复同步结果稳定 |
|
||||
| 稳定唯一业务键 | 明确 upsert / 匹配字段 |
|
||||
| 全量或增量 | 在实现与文档中显式声明 |
|
||||
| 完整快照才提交 | 完整性校验通过后再 commit;调用方**必须显式提供**完整性状态(如 keyword-only 必填的 `snapshot_complete`),**禁止**默认视为完整,**禁止**靠数据条数猜测 |
|
||||
| 空结果 / 读取失败保护 | 读取失败、分页不完整或不可信空结果(`snapshot_complete=False`)**不得**清库或误删;仅当完整快照**显式确认**为空(`snapshot_complete=True` 且结果为空)时,才可按全量失效策略处理旧数据 |
|
||||
| 全量失效策略 | 物理删除、`removed`/`is_active` 标记、或保留历史——必须三选一并写清 |
|
||||
| 部分失败 | 不得伪装成整体成功 |
|
||||
| 半成品 | 事务失败不得留下可见不一致状态 |
|
||||
|
||||
建议统一返回统计:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 100,
|
||||
"inserted": 10,
|
||||
"updated": 20,
|
||||
"unchanged": 65,
|
||||
"removed": 5
|
||||
}
|
||||
```
|
||||
|
||||
### 同步数据 vs 导出当前数据表 vs 特殊业务报告 vs HTML 页面
|
||||
|
||||
| 能力 | 语义 | 负责方 |
|
||||
|------|------|--------|
|
||||
| **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
|
||||
| **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
|
||||
| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` |
|
||||
| **HTML 页面报告** | 侧栏可预览的整页 HTML | 写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) |
|
||||
|
||||
同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。
|
||||
|
||||
## 元数据表(宿主兼容)
|
||||
|
||||
宿主读取字段(与匠厂 `metadata-resolver` 一致):
|
||||
|
||||
### `_jiangchang_tables` 表级标准
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `table_name` | 是 | 真实英文表名 |
|
||||
| `display_name` | 是 | 用户可见的中文业务名称 |
|
||||
| `description` | 否 | 表说明(表单/备注用途);**不**用于表格上方横幅 |
|
||||
| `grid_help` | 否 | 数据管理表格上方说明;空/未写则不出横幅。与 `description` 独立,禁止互拷(见下节) |
|
||||
| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
|
||||
| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
|
||||
| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
|
||||
| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 |
|
||||
| `default_visible_columns_json` | 否 | 数据管理**默认展示列**(见下节);空则全显 |
|
||||
|
||||
#### 表级默认排序 `default_sort_json`(宿主支持)
|
||||
|
||||
技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退:
|
||||
|
||||
`updated_at DESC` → `created_at DESC` → 主键 DESC。
|
||||
|
||||
JSON 数组(可多字段,按顺序):
|
||||
|
||||
```json
|
||||
[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `column` 必须是该表真实列名;敏感列会被宿主忽略
|
||||
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
|
||||
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
|
||||
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
|
||||
|
||||
示例(消息时间线):
|
||||
|
||||
```python
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="messages",
|
||||
display_name="消息",
|
||||
readonly=0,
|
||||
default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]',
|
||||
)
|
||||
```
|
||||
|
||||
#### 表级默认展示列 `default_visible_columns_json`(宿主支持)
|
||||
|
||||
技能可声明该表在数据管理表格中的**默认展示列**(软默认,不是硬隐藏):
|
||||
|
||||
- **已配置**:首次打开(用户尚未改过「列」)只显示名单中的列;其余列仍在「列」面板中,用户可勾回
|
||||
- **未配置 / 空 / 无效**:显示全部 schema 可见列(与今日行为一致)
|
||||
- **用户改过「列」或表头「隐藏此列」后**:以本机偏好为准;「恢复默认列布局」后重新回到技能默认(若有)
|
||||
|
||||
JSON 数组(物理列名;也可写 `{"column":"col"}` 对象,宿主同样接受):
|
||||
|
||||
```json
|
||||
["stat_type","period","stage","ops_count","timeout_ratio"]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 列名必须是该表真实列,且 `_jiangchang_columns.visible=1`(硬隐藏列本来就不进表格)
|
||||
- 与 `visible=0` **不同**:`visible=0` = 列从数据管理 schema /「列」面板移除;本字段只影响默认勾选状态
|
||||
- 名单为空或全部无效时,宿主 fail-open:全显
|
||||
- 通过 `upsert_table_metadata(..., default_visible_columns_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
|
||||
|
||||
示例(时效统计:默认不展示编号与时间戳列):
|
||||
|
||||
```python
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="timeliness_stats",
|
||||
display_name="时效统计",
|
||||
readonly=1,
|
||||
default_visible_columns_json=(
|
||||
'["stat_type","period","stage","ops_count",'
|
||||
'"timeout_open","timeout_done","timeout_total","timeout_ratio"]'
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
#### 表格说明 `grid_help`(宿主支持)
|
||||
|
||||
技能可给数据管理**表格**写说明,与新增/编辑表单的 `description` **完全独立**:
|
||||
|
||||
| 写在哪 | 宿主做什么 | 空/未写 |
|
||||
|--------|------------|---------|
|
||||
| `_jiangchang_tables.grid_help` | 表格上方可折叠横幅(首行摘要;有后续行才出展开) | 不出横幅 |
|
||||
| `_jiangchang_columns.grid_help` | 列头「?」,点击查看该列说明 | 不出「?」 |
|
||||
|
||||
规则:
|
||||
|
||||
- **opt-in**:大多数表/列不需要写;不写则行为与今日一致
|
||||
- **禁止**把 `description` 拷进 `grid_help`,也禁止把表格规则写进 `description`(`description` 只给表单灰字)
|
||||
- 多行文本时:第一行是摘要,其余行点「展开」后可见
|
||||
- 通过 `upsert_table_metadata(..., grid_help=...)` / `upsert_column_metadata(..., grid_help=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
|
||||
- 需已支持该字段的匠厂宿主;更旧版本会忽略未知列,不写则无影响
|
||||
|
||||
示例(仅在确有表格规则时写;模板默认业务表不要预填):
|
||||
|
||||
```python
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="timeliness_records",
|
||||
display_name="时效登记",
|
||||
readonly=1,
|
||||
grid_help="超时按环节规则判定。\n交单:预计到港日前 3 天须交单",
|
||||
)
|
||||
upsert_column_metadata(
|
||||
cursor,
|
||||
table_name="timeliness_records",
|
||||
column_name="submit_date",
|
||||
display_name="交单日期",
|
||||
grid_help="预计到港日前 3 天须交单",
|
||||
editable=0,
|
||||
)
|
||||
```
|
||||
|
||||
禁止:
|
||||
|
||||
- 使用中文真实表名
|
||||
- 使用 `_schema_meta` 作为新的正式契约
|
||||
- 只写英文物理名而不写中文 `display_name`
|
||||
- 让元数据引用不存在的表
|
||||
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
|
||||
|
||||
### `_jiangchang_columns` 字段级标准
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `table_name` | 是 | 所属物理表 |
|
||||
| `column_name` | 是 | 真实英文字段名 |
|
||||
| `display_name` | 是 | 用户可见的中文字段名(表头与表单标签);保持简短 |
|
||||
| `description` | 否 | 字段说明(给人看的短文案);**宿主在新增/编辑表单标签下方展示**(见下节);不要塞进 `display_name` |
|
||||
| `grid_help` | 否 | 数据管理表头「?」;空/未写则不出。与表单 `description` 独立,禁止互拷 |
|
||||
| `display_order` | 否 | 兼容字段,默认保持 `NULL` |
|
||||
| `visible` | 是 | 是否进入数据管理 schema /「列」面板(`0` = **硬隐藏**,用户无法勾回;默认不展示但可勾回请用表级 `default_visible_columns_json`) |
|
||||
| `searchable` | 是 | 是否参与**列筛选**与**顶栏全局搜索**(`0` = 两者均不参与);详见下节「数据管理:列筛选与全局搜索」 |
|
||||
| `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 |
|
||||
| `display_type` | 否 | 字段渲染类型;稳定取值见下节「表单与展示:稳定字段类型」 |
|
||||
| `options_json` | 否 | 枚举选项 JSON |
|
||||
|
||||
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
|
||||
|
||||
#### 表单与展示:稳定字段类型(宿主支持,须与 Action 对称)
|
||||
|
||||
数据管理「新增 / 编辑」表单与 Skill Action 参数表单应使用**同一套控件语义**(配置入口不同,见 [`ACTIONS.md`](ACTIONS.md))。新技能只使用下表;宿主须两边都实现,避免「Action 有、CRUD 没有」。
|
||||
|
||||
**本版稳定支持(须齐):**
|
||||
|
||||
| `display_type`(CRUD) | 物理类型建议 | 列表展示 | 新增/编辑控件 | 存库约定 |
|
||||
|------------------------|--------------|----------|---------------|----------|
|
||||
| `text`(或缺省) | `TEXT` / 一般标量 | 原文 | 单行文本 | 原文 |
|
||||
| `textarea` | `TEXT` | 原文(可截断) | 多行文本 | 原文 |
|
||||
| `json` | `TEXT` | 原文 | 多行文本(JSON 字符串) | 原文;提交时宿主可做基础 JSON 合法性校验 |
|
||||
| `number` | `INTEGER` / `REAL` / `NUMERIC` | 原文 | **数字输入**(`type=number`) | 数字;无 `display_type` 时,物理类型为 INT/REAL/NUMERIC 也应走数字控件 |
|
||||
| `boolean` | `INTEGER` 0/1 或 BOOL | 开关语义展示 | 开关 | `0` / `1` |
|
||||
| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` |
|
||||
| `lookup` | 跨表引用 id;`options_json` **对象**;可选 `multiple` | 单选:标签映射;多选:多标签(如「豆包、元宝」) | 单选下拉;多选见下文 | 单选:业务 id 标量;多选:`TEXT` 逗号分隔 id(非 SQLite 外键) |
|
||||
| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) |
|
||||
| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` |
|
||||
| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 |
|
||||
| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 |
|
||||
| `email` | `TEXT` | 原文 | 单行输入 `type=email`(薄层;校验从宽) | 原文 |
|
||||
|
||||
说明灰字与创建默认值见下一小节;与 Action 的对照写在 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。
|
||||
|
||||
**明确不在本版稳定范围(勿在新技能宣传为已支持):**
|
||||
|
||||
- 文件 / 图片上传
|
||||
- 复杂 `oneOf` / nested object 表单(任意 JSON 数组当业务多选控件)
|
||||
- 把业务可编辑时间存成毫秒、ISO 文本或 SQLite `datetime` 文本(审计字段规范见「标准时间字段」)
|
||||
|
||||
> **Lookup 多选**已纳入本版契约(见下文 `multiple`),与「任意多选/标签数组」不是同一能力。
|
||||
|
||||
`datetime_unix_milliseconds`:仅兼容**列表展示**旧数据;**新技能不要用**;可编辑时间一律 `datetime_unix_seconds`。
|
||||
|
||||
#### 新增/编辑表单:字段说明与默认值(宿主支持)
|
||||
|
||||
数据管理「新增记录 / 编辑记录」与 Skill Action 弹窗是**两套表单**;CRUD 侧约定如下(交互对齐 Action 的 `inputSchema.description` / `default`,但**配置入口不同**)。
|
||||
|
||||
| 能力 | 技能写什么 | 宿主做什么 |
|
||||
|------|------------|------------|
|
||||
| 字段说明 | `_jiangchang_columns.description`(可选) | 新增/编辑表单中,在标签下方以灰字展示;空则不显示 |
|
||||
| 创建默认值 | 物理列 SQLite `DEFAULT`(如 `limit_n INTEGER NOT NULL DEFAULT 0`) | 打开「新增」时,对可创建字段预填该默认值;无 DEFAULT 则留空 |
|
||||
| 编辑已有行 | — | **不**用 DEFAULT 覆盖已有行数据 |
|
||||
| 表格列头标题 | `display_name` | 短名;不要把长说明塞进列头文字 |
|
||||
| 表格列头「?」 | `_jiangchang_columns.grid_help`(可选) | 有内容才显示「?」;**不**回退 `description` |
|
||||
| 表格上方说明 | `_jiangchang_tables.grid_help`(可选) | 有内容才显示横幅;**不**回退表级 `description` |
|
||||
|
||||
规则与禁止:
|
||||
|
||||
- **推荐**对易填错、语义特殊的可编辑字段写 `description`(示例链接、`0` 表示不限制等);非强制每个字段都有
|
||||
- **禁止**把说明硬塞进 `display_name`(如 `评论上限(0不限)`);表头会变脏,也无法与 Action 说明对齐
|
||||
- 业务语义(如「0 = 不限制」)写在 `description` 里;宿主只展示,**不**解读业务含义
|
||||
- 第一期**不**新增元数据「表单默认值」列;CRUD 默认值 = DDL `DEFAULT`,与 Action 的 `inputSchema.default` 分开配置
|
||||
- 同一业务字段若同时出现在 Action 与新增表单,**文案应对齐**(短标题 + 说明),但分别写在 `actions.json` 与 `_jiangchang_columns` / DDL
|
||||
- 数字类可编辑列推荐 `display_type: "number"`(或依赖 INT/REAL/NUMERIC 推断)并在 DDL 写清 `DEFAULT`(如条数上限默认 `0`)
|
||||
|
||||
示例(可编辑列种子 + DDL 默认):
|
||||
|
||||
```sql
|
||||
-- 物理列:创建默认 0
|
||||
comment_limit INTEGER NOT NULL DEFAULT 0
|
||||
```
|
||||
|
||||
```python
|
||||
seed_column_metadata_batch(
|
||||
cursor,
|
||||
table_name="videos",
|
||||
columns=(
|
||||
{
|
||||
"column_name": "video_url",
|
||||
"display_name": "视频链接",
|
||||
"editable": 1,
|
||||
"display_type": "url",
|
||||
"description": "填平台视频页链接;需包含可识别的视频路径片段。",
|
||||
},
|
||||
{
|
||||
"column_name": "comment_limit",
|
||||
"display_name": "采集评论条数",
|
||||
"editable": 1,
|
||||
"display_type": "number",
|
||||
"description": "最多采多少条。填 0 = 不限制;填正整数则按该数量封顶。",
|
||||
},
|
||||
),
|
||||
valid_column_names=physical,
|
||||
)
|
||||
```
|
||||
|
||||
Action 参数表单的 `title` / `description` / `default` / `display_type` 见 [`ACTIONS.md`](ACTIONS.md);勿与本节 CRUD 配置入口混用。
|
||||
|
||||
#### 数据管理:列筛选与全局搜索(宿主支持)
|
||||
|
||||
数据管理表格提供两类与 `searchable` 相关的检索能力,技能通过 `_jiangchang_columns.searchable` 声明是否参与:
|
||||
|
||||
| 能力 | 用户入口 | `searchable = 1` 时 | `searchable = 0` 时 |
|
||||
|------|----------|---------------------|---------------------|
|
||||
| **列筛选** | 表格「筛选」→ 字段 / 操作符 / 值 | 出现在「字段」下拉,可按类型选值与操作符 | 不出现在列筛选字段列表 |
|
||||
| **全局搜索** | 顶栏搜索框 | 参与 OR 模糊匹配(宿主对参与列数有上限,如最多 8 列) | 不参与全局搜索 |
|
||||
|
||||
**存库 vs 展示(核心契约)**
|
||||
|
||||
- **列表**:有 `options_json` 时展示 **label**(如「启用」「停用」)
|
||||
- **筛选 / 查询 / 全局搜索**:比较与匹配 **value**(如 `0`/`1`、`active`、`toutiaohao`)
|
||||
- 宿主负责 label ↔ value 映射;**禁止**为迁就 UI 把存库改成中文枚举,也**禁止**在 `description` 里写「筛选请填 0/1」
|
||||
|
||||
**筛选栏控件 ≠ 新增/编辑表单(勿混用)**
|
||||
|
||||
列筛选「值」与表单使用同一套**类型语义**,但控件形态不同:筛选栏须与「字段」「操作符」同一行工具栏对齐(如统一 `h-9` 下拉),**枚举 / boolean / lookup 在筛选栏一律用下拉 Select**,不用新增/编辑表单里的开关或横向分段单选。表单侧仍按上表「新增/编辑控件」配置。
|
||||
|
||||
| 列类型 | 筛选「值」控件 | 常用操作符(宿主按类型裁剪) | 技能须配置 |
|
||||
|--------|----------------|------------------------------|------------|
|
||||
| 文本 / `url` / `email` | 文本框 | 等于 / 包含 / 开头 / 结尾 / 为空… | 通常 `searchable: 1` |
|
||||
| 数字 | 数字框 | 等于 / 大于 / 小于… | `display_type: number` 或 INT/REAL |
|
||||
| 枚举(`options_json` **数组**) | 下拉(展示 label) | 等于 / 不等于 / 为空 | **`options_json` 必填** + `searchable: 1` |
|
||||
| `boolean` | 下拉(有 `options_json` 用其 label;否则宿主默认可选「是/否」) | 等于 / 不等于 / 为空 | `display_type: boolean`;**推荐同时写 `options_json`** 指定中文 label |
|
||||
| `lookup` 单选 | 下拉 | 等于 / 不等于 / 为空 | `display_type: lookup` + lookup 对象 |
|
||||
| `lookup` + `multiple: true` | 列筛选 UI 演进中;**全局搜索**已支持 | 等于时按逗号分隔 id 的 token 匹配 | 多选存 `TEXT` 逗号分隔 id |
|
||||
| `date` / `datetime_unix_seconds` | 日期 / 日期时间 | 比较类 | 时间列通常 `searchable: 0` |
|
||||
| 敏感列 | **不可筛** | — | 宿主强制排除 |
|
||||
|
||||
**全局搜索扩展(用户可搜中文)**
|
||||
|
||||
宿主在 `searchable = 1` 的列上除原文 `LIKE` 外,还应:
|
||||
|
||||
- 有 `options_json` 枚举:**label 或 value** 均可命中
|
||||
- `boolean` 无 options:认「是/否」等常见别名
|
||||
- `lookup` 多选:按逗号分隔 id 的 **token 边界** 匹配;选项 label 亦可参与
|
||||
- `datetime_unix_seconds`:搜 `YYYY-MM-DD` 形态可匹配当天 Unix 秒区间
|
||||
|
||||
**技能配置建议**
|
||||
|
||||
推荐:
|
||||
|
||||
- 用户常筛的状态列(启用/停用、待生成/已生成、`status` 等)→ `searchable: 1` + `options_json`(value 与 DDL 存库一致)
|
||||
- `boolean` 业务列:`display_type: boolean` **且** `options_json` 写中文 label(示例见下)
|
||||
|
||||
禁止:
|
||||
|
||||
- 仅为筛选把存库 value 改成中文
|
||||
- 在文档或 `description` 要求用户记内部 magic number / 英文 code
|
||||
|
||||
示例(启用开关 + 中文筛选):
|
||||
|
||||
```python
|
||||
{
|
||||
"column_name": "enabled",
|
||||
"display_name": "是否启用",
|
||||
"visible": 1,
|
||||
"searchable": 1,
|
||||
"editable": 1,
|
||||
"display_type": "boolean",
|
||||
"options_json": json.dumps(
|
||||
[{"value": 0, "label": "停用"}, {"value": 1, "label": "启用"}],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
Action 参数表单**无**列筛选对称能力;筛选语义以本节为准,表单对照见 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。
|
||||
|
||||
## editable 与 createable 的关系
|
||||
|
||||
技能模板只维护 `editable` 这一份字段权限声明:
|
||||
|
||||
```text
|
||||
skill metadata:
|
||||
editable
|
||||
|
||||
host runtime:
|
||||
createable / editable
|
||||
```
|
||||
|
||||
宿主根据以下因素计算字段是否能够出现在新增或编辑表单中:
|
||||
|
||||
- 表级 `readonly`
|
||||
- 字段 `editable`
|
||||
- 主键
|
||||
- generated 字段
|
||||
- sensitive 字段
|
||||
- BLOB 类型
|
||||
- not-null 约束
|
||||
- default value
|
||||
|
||||
通用规则:
|
||||
|
||||
```text
|
||||
新增表单:
|
||||
editable = 1
|
||||
且不是主键
|
||||
且不是 generated
|
||||
且不是敏感字段
|
||||
且不是 BLOB
|
||||
|
||||
编辑表单:
|
||||
editable = 1
|
||||
且不是主键
|
||||
且不是 generated
|
||||
且不是敏感字段
|
||||
且不是 BLOB
|
||||
```
|
||||
|
||||
若一张表:
|
||||
|
||||
```text
|
||||
readonly = 0
|
||||
但所有字段 editable = 0
|
||||
```
|
||||
|
||||
则:
|
||||
|
||||
- 这张表技术上可写,但不适合手工新增或编辑
|
||||
- 宿主不应打开空白新增表单
|
||||
- 技能应通过自身业务流程产生数据
|
||||
- 这不是宿主猜测业务,而是技能通过元数据声明的结果
|
||||
|
||||
## 系统字段标准
|
||||
|
||||
通常保持 `editable = 0` 的字段类型包括:
|
||||
|
||||
- 自增主键
|
||||
- generated 字段
|
||||
- `created_at` / `updated_at`
|
||||
- 同步时间戳
|
||||
- 内容哈希
|
||||
- 计算结果
|
||||
- 内部状态
|
||||
- 删除标记
|
||||
- 审计字段
|
||||
- 敏感凭据或密钥字段
|
||||
|
||||
不要把具体业务字段名写死;按字段类型和职责设置 `editable`。
|
||||
|
||||
## 表结构标准
|
||||
|
||||
- 业务表和字段使用英文 `snake_case`
|
||||
- 可写表优先使用单字段主键
|
||||
- 自增主键优先放在 `CREATE TABLE` 第一列
|
||||
- `created_at` 和 `updated_at` 放在业务字段末尾
|
||||
- 字段展示顺序以 `CREATE TABLE` 和 `PRAGMA table_info(cid)` 为准
|
||||
- 不依赖 `display_order` 重新排列字段
|
||||
- 不要让宿主通过英文字段名推断展示顺序
|
||||
- 复杂字段需要通过 `display_type` 或 `visible` 明确处理
|
||||
|
||||
推荐顺序(可按业务裁剪,但已有字段应遵守相对位置):
|
||||
|
||||
```text
|
||||
1. 主键 id
|
||||
2. 关联标识与核心业务字段
|
||||
3. 普通业务字段
|
||||
4. 状态、结果、错误等辅助字段
|
||||
5. created_at(如有)
|
||||
6. updated_at(如有)
|
||||
```
|
||||
|
||||
## 标准时间字段(新技能统一规范)
|
||||
|
||||
新技能业务表若需要时间审计字段,**统一**采用 Unix **秒级**时间戳,不要使用毫秒时间戳、ISO 字符串或 SQLite 文本 datetime。
|
||||
|
||||
| 项目 | 规范 |
|
||||
|------|------|
|
||||
| 物理字段 | `created_at INTEGER`、`updated_at INTEGER` |
|
||||
| 存储格式 | Unix 秒级整数 |
|
||||
| 展示元数据 `display_type` | `datetime_unix_seconds` |
|
||||
| 中文名 | `created_at` → **创建时间**;`updated_at` → **更新时间** |
|
||||
| 用户编辑 | **审计字段** `editable = 0`(系统维护,列表只展示格式化结果,**不**进可编辑表单) |
|
||||
| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` |
|
||||
| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` |
|
||||
|
||||
### 业务可编辑时间 vs 审计时间
|
||||
|
||||
| 场景 | `editable` | 列表 | 新增/编辑表单 |
|
||||
|------|------------|------|----------------|
|
||||
| `created_at` / `updated_at` 等审计列 | `0` | 格式化为 `YYYY-MM-DD HH:mm:ss` | 不出现(或只读,由宿主既有规则) |
|
||||
| 业务时间列(预约、截止、活动开始等) | `1` | 同上格式化 | **日期时间控件**;提交写入 Unix **秒**整数 |
|
||||
|
||||
宿主对 `display_type = datetime_unix_seconds`:
|
||||
|
||||
- **列表**:格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不**改库内原始整数
|
||||
- **可编辑表单**:提供日期时间选择;保存时写回 Unix **秒**(不要写 ISO 字符串、不要写毫秒)
|
||||
|
||||
### 自动生成与维护
|
||||
|
||||
- **created_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。
|
||||
- **updated_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。
|
||||
- **updated_at 更新**:业务表通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新(`init_updated_at_trigger`)。
|
||||
|
||||
实现参考:
|
||||
|
||||
- DDL 与 trigger:`scripts/db/connection.py`、`scripts/db/timestamp_columns.py`
|
||||
- 中文元数据种子:`scripts/db/display_metadata.py`
|
||||
- 自检:`scripts/db/display_metadata_validator.py`
|
||||
|
||||
### 系统自动写入表的只读约定
|
||||
|
||||
技能内部**系统自动写入**、不供用户在宿主数据管理中手工维护的表,应声明:
|
||||
|
||||
| 层级 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 |
|
||||
| 相关业务字段 `editable` | `0` | 元数据权限只约束宿主 UI,不限制技能代码写库 |
|
||||
|
||||
模板**默认不建**此类审计表。过程看 `jiangchang.log`,进度/终态看任务中心,业务结果看业务表。
|
||||
|
||||
### 业务行生命周期(处理队列)
|
||||
|
||||
若技能对某一业务行做「生成 / 触达 / 私信 / 发布」等有副作用处理,状态字段须遵守 **角色模型**(可操作 / 进行中 / 成功·失败·跳过类终态),**不要**把某一技能的状态名当成全平台唯一枚举。默认只处理可操作态;重做靠人工改回可操作。完整规则见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)。
|
||||
|
||||
建议:
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 状态列 | `options_json` 列出本技能全部业务状态(含进行中与各类终态) |
|
||||
| 失败原因 | 失败类终态应有用户可读的原因字段(勿写密钥) |
|
||||
| 可编辑 | 终态/进行中建议可改回可操作,或提供等价 Action |
|
||||
| `grid_help` | 可一句话说明「仅某某状态可处理」 |
|
||||
|
||||
表级 `readonly = 1` 时,宿主数据管理只读展示;技能 repository 仍可写入。
|
||||
|
||||
### 通用示例:`demo_items`(测试 fixture 参考)
|
||||
|
||||
以下表结构用于模板测试演示数据管理契约,**不是**模板默认业务表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE demo_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
generated_value TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
```
|
||||
|
||||
对应元数据要点:
|
||||
|
||||
| 物理字段 | editable | 说明 |
|
||||
|----------|----------|------|
|
||||
| id | 0 | 自增主键 |
|
||||
| title | 1 | 业务字段 |
|
||||
| description | 1 | 业务字段 |
|
||||
| status | 1 | 枚举,`options_json` 演示 |
|
||||
| generated_value | 0 | 系统/计算字段 |
|
||||
| created_at | 0 | 系统时间 |
|
||||
| updated_at | 0 | 系统时间 |
|
||||
|
||||
## 枚举字段 options_json
|
||||
|
||||
`options_json` 支持两种形态:
|
||||
|
||||
### 1)静态枚举(数组)
|
||||
|
||||
每项包含 `value` 与 `label`:
|
||||
|
||||
```json
|
||||
[
|
||||
{"value": "draft", "label": "草稿"},
|
||||
{"value": "published", "label": "已发布"},
|
||||
{"value": "archived", "label": "已归档"}
|
||||
]
|
||||
```
|
||||
|
||||
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
|
||||
|
||||
### 2)跨表引用 lookup(对象,用于关联 id 字段)
|
||||
|
||||
当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组)。这是**展示与表单选择**契约,**不是** SQLite 外键。
|
||||
|
||||
**单选(默认,与既有行为一致):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ref_table": "platforms",
|
||||
"value_column": "platform_key",
|
||||
"label_column": "display_name",
|
||||
"extra_column": "category",
|
||||
"filter_column": "enabled",
|
||||
"filter_value": 1
|
||||
}
|
||||
```
|
||||
|
||||
**多选(平行扩展,仅多一个开关):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ref_table": "engines",
|
||||
"value_column": "id",
|
||||
"label_column": "display_name",
|
||||
"filter_column": "enabled",
|
||||
"filter_value": 1,
|
||||
"multiple": true
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `ref_table` | 是 | 同库被引用表名(snake_case 标识符) |
|
||||
| `value_column` | 是 | 写入当前字段的值列(业务 id / 编号) |
|
||||
| `label_column` | 是 | 选项主文案列(名称) |
|
||||
| `extra_column` | 否 | 第三展示列(不必叫「备注」);有值时拼进括号 |
|
||||
| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) |
|
||||
| `multiple` | 否 | `true` = 多选 Lookup;省略或 `false` = **单选**(默认) |
|
||||
|
||||
宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 选项列表。
|
||||
|
||||
**存库约定:**
|
||||
|
||||
| 模式 | 物理列建议 | 存什么 |
|
||||
|------|------------|--------|
|
||||
| 单选 | 整数或文本标量 | 一个业务 id(如 `2`、`douyin`) |
|
||||
| 多选 | **`TEXT`** | 逗号分隔 id,如 `1,2,5`(与 `$selection.ids` 风格一致);**不要**建联接子表当 UI 多选 |
|
||||
|
||||
**统一展示文案(宿主固定一种,技能只配列):**
|
||||
|
||||
- 单选默认:`{value} · {label}`,例如 `2 · 通用私信-留资`、`douyin · 抖音`
|
||||
- 配置了 `extra_column` 且该行有非空值:`{value} · {label}({extra})`
|
||||
- 未配置 `extra_column`、或额外列为空:不加 `()`
|
||||
- `label` 为空或与 `value` 相同时:只显示 `value`(可再带额外括号)
|
||||
- **多选列表格**:按 id 拆开后逐个映射为 label,再用顿号/逗号拼接(如 `豆包、元宝`);过长可截断并提示「+N」
|
||||
|
||||
**控件呈现(宿主策略,非第二套契约):**
|
||||
|
||||
- 单选:下拉 Select(现状)
|
||||
- 多选且选项较少(建议 ≤6):勾选列表
|
||||
- 多选且选项较多:字段内 Chip + 可搜索下拉(**不是**再开一层业务弹窗)
|
||||
|
||||
Action 弹窗内联 Lookup 与上表 **同构**(字段名一致,含 `multiple`),见 [`ACTIONS.md`](ACTIONS.md)「跨表引用:Lookup」。
|
||||
|
||||
示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表;私信 Action 选话术 → `message_templates`;抽检 Action 多选引擎 → `engines` + `multiple: true`。
|
||||
|
||||
## 幂等初始化标准
|
||||
|
||||
- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化
|
||||
- 多次执行初始化不能重复记录
|
||||
- 重新初始化可以更新展示名、描述、`grid_help`、`visible`、`searchable`、`editable`、`display_type`、`options_json`
|
||||
- 元数据不能残留旧字段(删除或重命名字段时必须同步清理)
|
||||
- 元数据不能引用不存在的表或字段
|
||||
- 新增字段或表时必须同步增加元数据
|
||||
- 不要直接修改用户真实数据库作为迁移方式
|
||||
|
||||
实现 helper:
|
||||
|
||||
- `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入
|
||||
- `seed_column_metadata_batch` — 批量 upsert;**可选**传入 `valid_column_names` 时在 upsert 后自动 prune
|
||||
- `prune_stale_column_metadata` — 清理已删除物理字段的元数据
|
||||
|
||||
### 新增业务表时如何初始化
|
||||
|
||||
1. 在 `connection.py`(或迁移脚本)中 `CREATE TABLE` 真实物理表。
|
||||
2. 用 `PRAGMA table_info` 或 `list_physical_column_names` 取得**真实物理字段列表**(顺序以 `cid` 为准)。
|
||||
3. 调用 `upsert_table_metadata` 写入表级元数据。
|
||||
4. 调用 `seed_column_metadata_batch`,并传入 `valid_column_names=physical_column_names`:
|
||||
|
||||
```python
|
||||
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch, upsert_table_metadata
|
||||
|
||||
physical = list_physical_column_names(cursor, "demo_items")
|
||||
upsert_table_metadata(cursor, table_name="demo_items", display_name="示例记录", readonly=0)
|
||||
seed_column_metadata_batch(
|
||||
cursor,
|
||||
table_name="demo_items",
|
||||
columns=DEMO_ITEMS_COLUMN_METADATA,
|
||||
valid_column_names=physical,
|
||||
)
|
||||
```
|
||||
|
||||
传入 `valid_column_names` 时:先 upsert,再删除不在物理表中的陈旧 `_jiangchang_columns` 行。
|
||||
|
||||
### 字段删除或重命名时如何清理
|
||||
|
||||
1. 在 DDL / 迁移中完成物理字段删除或重命名。
|
||||
2. 重新执行 `seed_column_metadata_batch(..., valid_column_names=当前物理字段列表)`,自动 prune 旧字段元数据。
|
||||
3. 或单独调用 `prune_stale_column_metadata(cursor, table_name, valid_column_names)`。
|
||||
|
||||
**不传入 `valid_column_names` 时不会自动 prune**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。
|
||||
|
||||
不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。
|
||||
|
||||
## 表增删改与退役(技能 schema 迁移)
|
||||
|
||||
技能拥有 SQLite 物理 schema;宿主只渲染**已注册**的元数据。增删改表时技能须在 `init_db()` 中做幂等迁移,并与 `_jiangchang_*` 同步。
|
||||
|
||||
| 操作 | 技能必须做 |
|
||||
|------|------------|
|
||||
| **新增**用户可见表 | `CREATE TABLE` + `upsert_table_metadata` + 列元数据种子 |
|
||||
| **修改**表/列 | 物理迁移(`ALTER` / 重建)+ 更新或 `prune_stale_column_metadata` |
|
||||
| **临时隐藏**(表仍保留) | 元数据 `visible=0`(表与元数据行都保留) |
|
||||
| **永久废弃**用户可见表 | `drop_deprecated_user_table(...)`(见下) |
|
||||
|
||||
### 永久废弃用户可见表
|
||||
|
||||
使用 `scripts/db/display_metadata.py` 的 **`drop_deprecated_user_table`**(幂等):
|
||||
|
||||
1. `DROP TRIGGER`(若有 `{table}_set_updated_at` 等)
|
||||
2. `DROP TABLE IF EXISTS`
|
||||
3. `DELETE` 对应 `_jiangchang_columns` / `_jiangchang_tables` 行
|
||||
|
||||
```python
|
||||
from db.display_metadata import drop_deprecated_user_table
|
||||
|
||||
def init_db() -> None:
|
||||
...
|
||||
drop_deprecated_user_table(cur, "task_logs")
|
||||
init_display_metadata(cur)
|
||||
```
|
||||
|
||||
**反模式(禁止):** 只删 `_jiangchang_*` 元数据、保留物理表。宿主虽可能不展示孤儿表,但库内残留、自检失败,且违背「元数据与物理结构一致」。
|
||||
|
||||
**新技能禁止**创建或写入 `task_logs` 一类通用任务审计表。过程 → `jiangchang.log` / 运行日志;进度/终态 → 任务中心;业务结果 → 业务表(见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)、[`../development/LOGGING.md`](../development/LOGGING.md))。
|
||||
|
||||
### 旧库迁移测试(废弃表时必做)
|
||||
|
||||
复制 `tests/samples/test_table_deprecation_migration.py.sample`,构造「旧库含废弃表 + 元数据 + 历史行」→ `init_db()` 后断言:
|
||||
|
||||
- `sqlite_master` 无该表
|
||||
- `_jiangchang_*` 无残留行
|
||||
- `iter_user_tables` 不含该表
|
||||
|
||||
卸载技能**不删**用户数据目录;重装会复用旧库,迁移必须在 `init_db()` 中完成(见 [`../development/DATA_PATHS.md`](../development/DATA_PATHS.md))。
|
||||
|
||||
## 中文命名质量
|
||||
|
||||
- 使用普通用户能理解的业务用语:`error_msg` → **错误信息**,`created_at` → **创建时间**。
|
||||
- 禁止:`Task Logs`、`input编号`、`errormsg`、直接把物理名当 `display_name`。
|
||||
- 不宜向用户暴露的实现细节(如 `payload_json`、`selector`)应设 `visible = 0` 或给出可理解中文名。
|
||||
|
||||
## 元数据同步规则
|
||||
|
||||
1. 新增用户可见业务表 → 同步 `_jiangchang_tables`(`visible=1` 方可在数据管理展示)。
|
||||
2. 新增用户可见字段 → 同步 `_jiangchang_columns`。
|
||||
3. 删除/重命名表或字段 → 物理迁移 + 同步清理元数据;**废弃表**用 `drop_deprecated_user_table`,禁止只删元数据留物理表。
|
||||
4. 初始化脚本必须幂等。
|
||||
5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`。
|
||||
6. `init_db()` 后:`iter_user_tables` 中每个物理业务表都应有对应 `_jiangchang_tables.display_name`,或已被 `drop_deprecated_user_table` 物理删除。
|
||||
|
||||
## 开发自检
|
||||
|
||||
- `init_db()` 后运行 `tests/test_display_metadata.py` 与 `tests/test_data_management_contract.py`。
|
||||
- 若提供宿主 Action 入口,运行 `tests/test_actions_manifest.py`。
|
||||
- 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。
|
||||
|
||||
## 模板原则
|
||||
|
||||
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
|
||||
- 元数据 DDL 与种子 helper 以 `scripts/db/display_metadata.py` 为单一权威来源。
|
||||
- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。
|
||||
@@ -1,157 +0,0 @@
|
||||
# 业务行生命周期契约(Work Item Lifecycle)
|
||||
|
||||
> **状态:正式契约 v1.0。**
|
||||
> **目标:** 冻结「对某一行做有副作用处理」时的**状态角色与操作权**规则,使队列型技能(生成、触达、私信、发布等)行为一致、可验收。
|
||||
> **非目标:** 规定某一技能的具体状态码或状态个数;强制所有表都有 status;替代任务中心 / 运行日志;规定 Data Policy 授权。
|
||||
> **适用范围:** 有「处理业务行」类 Action 的技能。纯配置表、目录表、只读展示表标 N/A。
|
||||
|
||||
相关正交轴:[`ACTIONS.md`](ACTIONS.md)(入口与 sync/async)、[`SCHEMA.md`](SCHEMA.md)(表与展示)、[`POLICY.md`](POLICY.md)(谁能点)、任务中心(宿主 Job)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 为什么需要本契约
|
||||
|
||||
若「处理这一行」的按钮对**任意状态**都可点,会出现:
|
||||
|
||||
- 已成功的行被重复执行(重复发信、重复生成、覆盖产物)
|
||||
- 失败行被默认批量静默重试,运营无法区分「未做」与「做过但失败」
|
||||
- 任务中心显示成功,业务表仍停在初始态(结果未回写)
|
||||
|
||||
本契约要求:**默认可自动操作的只有「可操作」角色;其它角色须人工改回可操作后再处理。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 状态角色(通用,不绑死枚举)
|
||||
|
||||
技能自己定义业务状态字符串与中文标签;契约只认**角色**。一个角色可对应**一个或多个**业务状态。
|
||||
|
||||
| 角色 | 含义 | 默认可被处理 Action 捞起? |
|
||||
|------|------|---------------------------|
|
||||
| **operable(可操作)** | 尚未成功处理、允许自动或行操作启动处理 | **是(默认唯一)** |
|
||||
| **in_progress(进行中)** | 已抢占,正在处理 | **否**(防双跑) |
|
||||
| **terminal_success(成功类终态)** | 已按业务定义完成 | **否** |
|
||||
| **terminal_failure(失败类终态)** | 已尝试且失败;通常带失败原因 | **否** |
|
||||
| **terminal_skipped(跳过类终态)** | 人工放弃或不处理 | **否** |
|
||||
|
||||
### 1.1 举例(仅对照,不是规范枚举)
|
||||
|
||||
| 角色 | 技能 A(内容队列)可能叫 | 技能 B(线索触达)可能叫 |
|
||||
|------|--------------------------|--------------------------|
|
||||
| operable | 待生成、新建 | 新建、私信未发 |
|
||||
| in_progress | 生成中 | 触达中 |
|
||||
| terminal_success | 已生成 | 已私信 |
|
||||
| terminal_failure | 失败 | 触达失败 |
|
||||
| terminal_skipped | 已跳过 | 已忽略 |
|
||||
|
||||
技能可以有更多业务状态(审核中、待确认、部分成功等):须在需求里写明各自映射到哪一角色,或声明「不属于处理队列、本契约 N/A」。
|
||||
|
||||
### 1.2 技能必须声明映射
|
||||
|
||||
复制模板后,在 `development/REQUIREMENTS.md`(或等价需求)中写清:
|
||||
|
||||
```text
|
||||
处理队列表:<table>
|
||||
状态字段:<column>
|
||||
可操作:<status1>, <status2>, …
|
||||
进行中:<status…>
|
||||
成功类终态:…
|
||||
失败类终态:…(失败原因字段:…)
|
||||
跳过类终态:…
|
||||
```
|
||||
|
||||
未声明映射的「处理队列」技能,交活 Checklist 不得标 PASS。
|
||||
|
||||
---
|
||||
|
||||
## 2. 硬规则(技能实现必须遵守)
|
||||
|
||||
1. **默认只认 operable**
|
||||
行操作 / 批量 / 定时 / Agent 触发的「处理」类 Action,pick 与 claim **默认**只包含可操作状态。
|
||||
不得把成功、失败、跳过、进行中混进默认捞取集合(除非另做**显式命名**的「重试失败」类 Action,且确认文案写清范围——仍推荐不如人工改回可操作简单)。
|
||||
|
||||
2. **开始即原子抢占 → in_progress**
|
||||
在真正产生副作用前,用「仅当当前为 operable 时 UPDATE 为进行中」的方式抢占。抢占失败 → 跳过或返回明确错误(如「当前不是可操作状态」),**禁止**继续副作用。
|
||||
|
||||
3. **结束必须回写业务表**
|
||||
- 成功 → 对应成功类终态,并写入产物路径/摘要等业务字段(若有)
|
||||
- 失败 → 对应失败类终态,并写入**失败原因**(用户可读;勿塞密钥)
|
||||
- 禁止「任务中心成功、业务行仍停在可操作/进行中」而不回写
|
||||
|
||||
4. **重做靠人工改回 operable**
|
||||
成功 / 失败 / 跳过 / 卡住的进行中,默认不能再点处理。运营在数据管理把状态改回可操作后,方可再次处理。
|
||||
|
||||
5. **任务中心 ≠ 业务状态**
|
||||
Job「进行中 / 成功 / 失败」是宿主任务态。业务「进行中」必须写在业务表。进度 `emit` 不替代状态回写。
|
||||
|
||||
6. **过程与结果分工**(延续既有约定)
|
||||
过程 → 运行日志 / 任务中心 Activity;业务结果 → 业务表。**不要**靠新建 `task_logs` 一类审计表充当状态机。
|
||||
|
||||
---
|
||||
|
||||
## 3. Action 与文案约定
|
||||
|
||||
- 处理类 Action 的 `description` / `confirmation.message` 应写明:**仅处理「可操作」状态**(用本技能用户能懂的词,如「仅待生成」「仅新建」)。
|
||||
- 对非可操作行触发时:返回明确失败码与中文说明,提示「请先改回 xxx」。
|
||||
- 长任务(`async`):除任务中心进度外,业务行应尽快进入进行中,避免界面长时间仍显示可操作导致连点。
|
||||
|
||||
正交提醒:
|
||||
|
||||
| 轴 | 管什么 | 不管什么 |
|
||||
|----|--------|----------|
|
||||
| 本契约 | 什么**状态**能处理 | 谁有权点按钮 |
|
||||
| Data Policy | 谁有权 | 状态机 |
|
||||
| executionProfile | sync/async 通道 | 业务终态 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据管理展示建议
|
||||
|
||||
- 状态列:`options_json` 枚举完整业务状态(含进行中与各类终态)。
|
||||
- 失败原因列:失败类终态可见;成功后宜清空或保留末次原因(技能自定,须一致)。
|
||||
- 终态与进行中的状态字段建议 **可编辑**(或提供「改回可操作」Action),以便人工重做;**不要**只能靠删行重建。
|
||||
- `grid_help` 可一句话写清:「仅某某状态可处理;其它状态请改回后再操作。」
|
||||
|
||||
详见 [`SCHEMA.md`](SCHEMA.md)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 可观测性(与 ACTIONS / 日志约定对齐)
|
||||
|
||||
推荐阶段(名称可自定,须能对应到开始 / 关键节点 / 结束):
|
||||
|
||||
1. 已抢占(业务 → 进行中)
|
||||
2. 处理中关键步骤(可 `emit`)
|
||||
3. 回写成功或失败
|
||||
|
||||
故意失败一条时:业务表应为失败类终态 + 原因;任务中心为失败;日志含 `task_failed` / `error_code=`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 非目标与常见误区
|
||||
|
||||
| 误区 | 正确做法 |
|
||||
|------|----------|
|
||||
| 把「新建/成功/失败/跳过」写成全平台唯一枚举 | 只用角色;各技能自定状态名 |
|
||||
| 默认批量包含失败「方便自动重试」 | 默认不含;重试改回可操作或单独显式 Action |
|
||||
| 只有任务成功、不改业务状态 | 必须回写业务表 |
|
||||
| 用进行中替代任务中心 | 两者都要:业务防双跑,任务轨看进度 |
|
||||
| 所有表都套本契约 | 无「处理行」副作用的表标 N/A |
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收摘要(完整条目见 CHECKLIST)
|
||||
|
||||
有处理队列时至少验证:
|
||||
|
||||
1. 成功类终态再点处理 → 拒绝或跳过,不产生重复副作用
|
||||
2. 可操作行处理中 → 先见进行中,结束后见成功或失败+原因
|
||||
3. 失败行改回可操作后 → 可以再次处理
|
||||
4. 任务中心有过程(长任务)且业务表状态与结果一致
|
||||
|
||||
---
|
||||
|
||||
## 8. 版本
|
||||
|
||||
| 版本 | 说明 |
|
||||
|------|------|
|
||||
| v1.0 | 首版:角色模型 + 硬规则 + 需求映射 + 与 Action/SCHEMA/任务中心边界 |
|
||||
88
scripts/debug_page.py
Normal file
88
scripts/debug_page.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""调试脚本:启动浏览器,停在当前页面,打印 DOM 和截图,方便定位元素。"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _scripts_dir not in sys.path:
|
||||
sys.path.insert(0, _scripts_dir)
|
||||
|
||||
from service.rpa.browser_session import start_browser_session, close_browser_context
|
||||
|
||||
|
||||
async def main():
|
||||
profile_dir = os.environ.get("TEMP", "") + "\\verify-invoice-edge-profile"
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
pw, context, page = await start_browser_session(profile_dir, headless=False)
|
||||
|
||||
# 打开 sandbox 首页
|
||||
await page.goto("https://sandbox.jc2009.com/", wait_until="domcontentloaded", timeout=60000)
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
print("=" * 60)
|
||||
print(f"当前 URL: {page.url}")
|
||||
print("=" * 60)
|
||||
|
||||
# 打印所有可见文本
|
||||
try:
|
||||
body = await page.locator("body").inner_text()
|
||||
print("\n--- 页面文本(前2000字)---")
|
||||
print(body[:2000])
|
||||
except Exception as e:
|
||||
print(f"获取文本失败: {e}")
|
||||
|
||||
# 打印所有 input 元素
|
||||
print("\n--- 所有 input 元素 ---")
|
||||
inputs = page.locator("input")
|
||||
count = await inputs.count()
|
||||
for i in range(min(count, 20)):
|
||||
inp = inputs.nth(i)
|
||||
try:
|
||||
itype = await inp.get_attribute("type") or ""
|
||||
iname = await inp.get_attribute("name") or ""
|
||||
iid = await inp.get_attribute("id") or ""
|
||||
placeholder = await inp.get_attribute("placeholder") or ""
|
||||
value = await inp.input_value()
|
||||
print(f" [{i}] type={itype} name={iname} id={iid} placeholder={placeholder} value={value[:30]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 打印所有 button
|
||||
print("\n--- 所有 button ---")
|
||||
buttons = page.locator("button")
|
||||
bcount = await buttons.count()
|
||||
for i in range(min(bcount, 10)):
|
||||
btn = buttons.nth(i)
|
||||
try:
|
||||
text = await btn.inner_text()
|
||||
print(f" [{i}] text={text.strip()[:50]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 打印所有链接
|
||||
print("\n--- 包含'智慧校园'的元素 ---")
|
||||
campus = page.get_by_text("智慧校园", exact=False)
|
||||
ccount = await campus.count()
|
||||
print(f" 找到 {ccount} 个")
|
||||
for i in range(min(ccount, 5)):
|
||||
try:
|
||||
text = await campus.nth(i).inner_text()
|
||||
tag = await campus.nth(i).evaluate("el => el.tagName")
|
||||
print(f" [{i}] tag={tag} text={text.strip()[:80]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 截图
|
||||
ss_path = os.path.join(os.environ.get("TEMP", ""), "debug_sandbox_page.png")
|
||||
await page.screenshot(path=ss_path, full_page=True)
|
||||
print(f"\n截图已保存: {ss_path}")
|
||||
|
||||
print("\n按回车关闭浏览器...")
|
||||
input()
|
||||
|
||||
await close_browser_context(pw, context)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,16 +1,36 @@
|
||||
"""RPA模块:提供发票信息提取的自动化功能。"""
|
||||
"""RPA模块:提供发票信息提取的自动化功能。
|
||||
|
||||
from .browser_session import close_browser_context, start_browser_session
|
||||
from .invoice_extractor import extract_invoice_detail, extract_invoice_list, format_invoice_data
|
||||
完整流程:
|
||||
1. 登录 sandbox.jc2009.com(手机号+密码)
|
||||
2. 找到"智慧校园"并点击进入
|
||||
3. 登录智慧校园(默认 demo 账号)
|
||||
4. 导航到票据合规预审页面
|
||||
5. 提取发票数据
|
||||
"""
|
||||
|
||||
from .browser_session import close_browser_context, find_browser_executable, start_browser_session
|
||||
from .invoice_extractor import extract_form_fields, extract_invoice_detail, extract_invoice_list, format_invoice_data
|
||||
from .invoice_rpa import InvoiceRpaResult, run_invoice_rpa, run_invoice_rpa_with_video
|
||||
from .login_handler import login_if_needed
|
||||
from .login_handler import (
|
||||
enter_smart_campus,
|
||||
login_if_needed,
|
||||
login_sandbox,
|
||||
login_smart_campus,
|
||||
navigate_to_invoice_precheck,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"start_browser_session",
|
||||
"close_browser_context",
|
||||
"find_browser_executable",
|
||||
"login_if_needed",
|
||||
"login_sandbox",
|
||||
"enter_smart_campus",
|
||||
"login_smart_campus",
|
||||
"navigate_to_invoice_precheck",
|
||||
"extract_invoice_list",
|
||||
"extract_invoice_detail",
|
||||
"extract_form_fields",
|
||||
"format_invoice_data",
|
||||
"run_invoice_rpa",
|
||||
"run_invoice_rpa_with_video",
|
||||
|
||||
@@ -1,67 +1,147 @@
|
||||
"""浏览器会话管理:基于skill-template的real_browser_rpa示例。"""
|
||||
"""浏览器会话管理:基于 skill-template 的 simulator_browser_rpa 示例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
from jiangchang_skill_core.rpa import launch_persistent_browser, poll_until
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 目标网站配置
|
||||
TARGET_URL = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck"
|
||||
LOGIN_URL = "https://sandbox.jc2009.com/login"
|
||||
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_browser_executable() -> Optional[str]:
|
||||
"""查找系统 Chrome 或 Edge 浏览器。"""
|
||||
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
|
||||
|
||||
|
||||
# 向后兼容别名
|
||||
find_edge_executable = find_browser_executable
|
||||
|
||||
|
||||
def _clear_node_options() -> None:
|
||||
os.environ.pop("NODE_OPTIONS", None)
|
||||
|
||||
|
||||
async def start_browser_session(
|
||||
profile_dir: str,
|
||||
headless: bool = False,
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""启动浏览器会话。
|
||||
|
||||
"""启动持久化 Chrome/Edge 浏览器会话。
|
||||
|
||||
URL 不在此文件打开;由 invoice_rpa.py 负责 page.goto。
|
||||
|
||||
Args:
|
||||
profile_dir: 浏览器配置目录
|
||||
headless: 是否无头模式(生产必须为False)
|
||||
|
||||
headless: 是否无头模式(生产必须为 False)
|
||||
|
||||
Returns:
|
||||
(playwright, context, page) 三元组
|
||||
|
||||
|
||||
Raises:
|
||||
RuntimeError: 浏览器启动失败
|
||||
"""
|
||||
if not profile_dir:
|
||||
raise RuntimeError("ERROR:MISSING_BROWSER 缺少浏览器配置目录")
|
||||
|
||||
|
||||
# 确保有头模式(生产要求)
|
||||
if headless:
|
||||
logger.warning("生产环境强制使用有头模式")
|
||||
headless = False
|
||||
|
||||
try:
|
||||
# 启动持久化浏览器上下文
|
||||
pw, context = await launch_persistent_browser(
|
||||
user_data_dir=profile_dir,
|
||||
headless=headless,
|
||||
args=[
|
||||
"--start-maximized",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
],
|
||||
|
||||
# 确保 profile 目录存在
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
_clear_node_options()
|
||||
hl = headless if headless is not None else _headless_from_env()
|
||||
chrome = find_browser_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
|
||||
)
|
||||
|
||||
# 创建新页面
|
||||
page = await context.new_page()
|
||||
|
||||
# 导航到目标页面
|
||||
await page.goto(TARGET_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
logger.info("browser_started url=%s profile_dir=%s", page.url, profile_dir)
|
||||
return pw, context, page
|
||||
|
||||
|
||||
args = chrome_launch_args()
|
||||
logger.info(
|
||||
"browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
|
||||
chrome,
|
||||
profile_dir,
|
||||
args,
|
||||
hl,
|
||||
)
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
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)
|
||||
logger.info("browser_started profile_dir=%s", profile_dir)
|
||||
return pw, context, page
|
||||
except Exception:
|
||||
if context is not None:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
await pw.stop()
|
||||
raise
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("browser_start_failed error=%s", str(e))
|
||||
raise RuntimeError(f"ERROR:MISSING_BROWSER 浏览器启动失败: {str(e)}")
|
||||
@@ -70,22 +150,24 @@ async def start_browser_session(
|
||||
async def close_browser_context(pw: Any, context: Any) -> None:
|
||||
"""安全关闭浏览器上下文。"""
|
||||
try:
|
||||
if context:
|
||||
if context is not None:
|
||||
await context.close()
|
||||
if pw:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if pw is not None:
|
||||
await pw.stop()
|
||||
except Exception as e:
|
||||
logger.warning("browser_close_error error=%s", str(e))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def wait_for_page_ready(page: Any, timeout_sec: float = 30.0) -> bool:
|
||||
"""等待页面加载完成。"""
|
||||
async def _check_ready():
|
||||
try:
|
||||
# 检查页面是否加载完成
|
||||
if await page.evaluate("document.readyState") == "complete":
|
||||
ready_state = await page.evaluate("document.readyState")
|
||||
if ready_state == "complete":
|
||||
return True
|
||||
# 检查是否有加载指示器
|
||||
loading_selectors = [".loading", ".spinner", "[data-loading]"]
|
||||
for sel in loading_selectors:
|
||||
if await page.locator(sel).count() > 0:
|
||||
@@ -93,9 +175,16 @@ async def wait_for_page_ready(page: Any, timeout_sec: float = 30.0) -> bool:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
result = await poll_until(_check_ready, timeout_sec=timeout_sec, interval_sec=0.5)
|
||||
return result.ok
|
||||
|
||||
try:
|
||||
from jiangchang_skill_core.rpa import poll_until
|
||||
result = await poll_until(_check_ready, timeout_sec=timeout_sec, interval_sec=0.5)
|
||||
return result.ok
|
||||
except ImportError:
|
||||
# fallback: 简单等待
|
||||
import asyncio
|
||||
await asyncio.sleep(min(timeout_sec, 3.0))
|
||||
return True
|
||||
|
||||
|
||||
async def random_delay(min_sec: float = 1.0, max_sec: float = 3.0) -> None:
|
||||
|
||||
@@ -1,343 +1,264 @@
|
||||
"""发票信息提取器:从网站提取发票信息。"""
|
||||
"""发票信息提取器:从票据合规预审页面提取发票数据。
|
||||
|
||||
根据 sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck 页面 DOM 结构:
|
||||
- 报销事由:文本输入框
|
||||
- 预算项目:下拉选择框
|
||||
- 差旅审批单:下拉选择框
|
||||
- 选择发票:列表,每项包含 checkbox、发票号、金额、类型、公司名、日期、购方税号
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 发票信息提取选择器(需要根据实际页面调整)
|
||||
INVOICE_SELECTORS = {
|
||||
# 发票列表容器
|
||||
"invoice_list_container": [
|
||||
".invoice-list",
|
||||
".invoice-table",
|
||||
"[data-testid='invoice-list']",
|
||||
"table tbody",
|
||||
],
|
||||
|
||||
# 单个发票行
|
||||
"invoice_row": [
|
||||
"tr.invoice-item",
|
||||
".invoice-row",
|
||||
"[data-testid='invoice-row']",
|
||||
"tbody tr",
|
||||
],
|
||||
|
||||
# 发票详情页面元素
|
||||
"invoice_detail": {
|
||||
"invoice_code": [
|
||||
".invoice-code",
|
||||
"[data-testid='invoice-code']",
|
||||
"td:has-text('发票代码') + td",
|
||||
],
|
||||
"invoice_number": [
|
||||
".invoice-number",
|
||||
"[data-testid='invoice-number']",
|
||||
"td:has-text('发票号码') + td",
|
||||
],
|
||||
"invoice_date": [
|
||||
".invoice-date",
|
||||
"[data-testid='invoice-date']",
|
||||
"td:has-text('开票日期') + td",
|
||||
],
|
||||
"total_amount": [
|
||||
".total-amount",
|
||||
"[data-testid='total-amount']",
|
||||
"td:has-text('金额') + td",
|
||||
],
|
||||
"tax_amount": [
|
||||
".tax-amount",
|
||||
"[data-testid='tax-amount']",
|
||||
"td:has-text('税额') + td",
|
||||
],
|
||||
"buyer_name": [
|
||||
".buyer-name",
|
||||
"[data-testid='buyer-name']",
|
||||
"td:has-text('购买方') + td",
|
||||
],
|
||||
"buyer_tax_no": [
|
||||
".buyer-tax-no",
|
||||
"[data-testid='buyer-tax-no']",
|
||||
"td:has-text('购买方税号') + td",
|
||||
],
|
||||
"seller_name": [
|
||||
".seller-name",
|
||||
"[data-testid='seller-name']",
|
||||
"td:has-text('销售方') + td",
|
||||
],
|
||||
"seller_tax_no": [
|
||||
".seller-tax-no",
|
||||
"[data-testid='seller-tax-no']",
|
||||
"td:has-text('销售方税号') + td",
|
||||
],
|
||||
"items": [
|
||||
".invoice-items",
|
||||
"[data-testid='invoice-items']",
|
||||
".item-list",
|
||||
],
|
||||
},
|
||||
|
||||
# 分页控件
|
||||
"pagination": {
|
||||
"next_button": [
|
||||
".pagination .next",
|
||||
"button:has-text('下一页')",
|
||||
"[data-testid='next-page']",
|
||||
],
|
||||
"page_info": [
|
||||
".page-info",
|
||||
".pagination-info",
|
||||
"[data-testid='page-info']",
|
||||
],
|
||||
},
|
||||
|
||||
# 搜索/筛选控件
|
||||
"search": {
|
||||
"search_input": [
|
||||
"input[placeholder*='搜索']",
|
||||
"input[placeholder*='查询']",
|
||||
".search-input",
|
||||
],
|
||||
"search_button": [
|
||||
"button:has-text('搜索')",
|
||||
"button:has-text('查询')",
|
||||
".search-btn",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def extract_invoice_list(page: Any) -> List[Dict[str, Any]]:
|
||||
"""提取发票列表信息。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
|
||||
"""从票据合规预审页面提取发票列表。
|
||||
|
||||
页面中发票以列表项形式展示,每项包含:
|
||||
- Checkbox(勾选状态)
|
||||
- 发票号(8位数字)
|
||||
- 金额(¥xxx)
|
||||
- 类型(transport/lodging 等)
|
||||
- 公司名
|
||||
- 日期
|
||||
- 购方税号
|
||||
- 验真状态("未验真" 标记)
|
||||
|
||||
Returns:
|
||||
发票信息列表
|
||||
发票信息字典列表
|
||||
"""
|
||||
invoices = []
|
||||
|
||||
invoices: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
# 等待页面加载
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 查找发票列表容器
|
||||
container = await _find_element(page, INVOICE_SELECTORS["invoice_list_container"])
|
||||
if not container:
|
||||
logger.warning("invoice_list_container_not_found")
|
||||
return []
|
||||
|
||||
# 查找所有发票行
|
||||
rows = await _find_elements(page, INVOICE_SELECTORS["invoice_row"])
|
||||
logger.info("found_invoice_rows count=%s", len(rows))
|
||||
|
||||
# 提取每行发票信息
|
||||
for i, row in enumerate(rows):
|
||||
try:
|
||||
invoice_data = await _extract_invoice_from_row(page, row, i)
|
||||
if invoice_data:
|
||||
invoices.append(invoice_data)
|
||||
except Exception as e:
|
||||
logger.warning("extract_row_failed row=%s error=%s", i, str(e))
|
||||
continue
|
||||
|
||||
# 处理分页
|
||||
has_next = await _has_next_page(page)
|
||||
while has_next:
|
||||
# 点击下一页
|
||||
next_clicked = await _click_next_page(page)
|
||||
if not next_clicked:
|
||||
break
|
||||
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 提取下一页数据
|
||||
rows = await _find_elements(page, INVOICE_SELECTORS["invoice_row"])
|
||||
for i, row in enumerate(rows):
|
||||
# 等待发票区域加载(checkbox 出现即可开始提取)
|
||||
try:
|
||||
await page.locator('input[type="checkbox"]').first.wait_for(state="attached", timeout=10_000)
|
||||
except Exception:
|
||||
pass # 超时也继续尝试提取
|
||||
|
||||
# 方式一:通过 checkbox 定位发票项(最可靠)
|
||||
checkboxes = page.locator('input[type="checkbox"]')
|
||||
checkbox_count = await checkboxes.count()
|
||||
logger.info("found_checkboxes count=%s", checkbox_count)
|
||||
|
||||
if checkbox_count > 0:
|
||||
# 每个 checkbox 对应一个发票项,向上找到父容器
|
||||
for i in range(checkbox_count):
|
||||
try:
|
||||
invoice_data = await _extract_invoice_from_row(page, row, i)
|
||||
if invoice_data:
|
||||
invoices.append(invoice_data)
|
||||
checkbox = checkboxes.nth(i)
|
||||
# 获取 checkbox 所在的父级容器(卡片或行)
|
||||
parent = checkbox.locator("xpath=ancestor::div[contains(@class,'card') or contains(@class,'item') or contains(@class,'row') or contains(@class,'invoice')]").first
|
||||
if await parent.count() == 0:
|
||||
# fallback: 向上找 3 层
|
||||
parent = checkbox.locator("xpath=ancestor::div[3]").first
|
||||
|
||||
text = await parent.inner_text() if await parent.count() > 0 else ""
|
||||
is_checked = await checkbox.is_checked()
|
||||
|
||||
invoice = _parse_invoice_text(text)
|
||||
if invoice:
|
||||
invoice["selected"] = is_checked
|
||||
invoices.append(invoice)
|
||||
except Exception as e:
|
||||
logger.warning("extract_row_failed page_row=%s error=%s", i, str(e))
|
||||
logger.warning("extract_checkbox_item_failed index=%s error=%s", i, str(e))
|
||||
continue
|
||||
|
||||
has_next = await _has_next_page(page)
|
||||
|
||||
|
||||
# 方式二:如果方式一没提取到,通过文本匹配
|
||||
if not invoices:
|
||||
logger.info("fallback_to_text_extraction")
|
||||
invoices = await _extract_by_text_pattern(page)
|
||||
|
||||
logger.info("extraction_complete total_invoices=%s", len(invoices))
|
||||
return invoices
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("extract_invoice_list_failed error=%s", str(e))
|
||||
return []
|
||||
|
||||
|
||||
async def _extract_by_text_pattern(page: Any) -> List[Dict[str, Any]]:
|
||||
"""通过文本模式匹配提取发票信息(fallback)。"""
|
||||
invoices: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
# 查找包含发票号(8位数字)的元素
|
||||
invoice_pattern = page.locator("div, tr, li").filter(
|
||||
has_text=re.compile(r"\d{8}.*[¥¥]\s*[\d,]+")
|
||||
)
|
||||
count = await invoice_pattern.count()
|
||||
logger.info("text_pattern_match count=%s", count)
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
element = invoice_pattern.nth(i)
|
||||
text = await element.inner_text()
|
||||
invoice = _parse_invoice_text(text)
|
||||
if invoice:
|
||||
# 检查是否被选中(附近有 checkbox)
|
||||
checkbox = element.locator('input[type="checkbox"]')
|
||||
if await checkbox.count() > 0:
|
||||
invoice["selected"] = await checkbox.first.is_checked()
|
||||
else:
|
||||
invoice["selected"] = False
|
||||
invoices.append(invoice)
|
||||
except Exception as e:
|
||||
logger.warning("text_pattern_extract_failed index=%s error=%s", i, str(e))
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("text_pattern_extraction_error error=%s", str(e))
|
||||
|
||||
return invoices
|
||||
|
||||
|
||||
def _parse_invoice_text(text: str) -> Optional[Dict[str, Any]]:
|
||||
"""从发票文本中解析结构化数据。
|
||||
|
||||
典型格式:
|
||||
87654321 · ¥553 · transport
|
||||
中国铁路北京局集团有限公司 · 2026-03-12 · 购方税号 121000004000012345
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
full_text = " ".join(lines)
|
||||
|
||||
# 提取发票号(8位数字)
|
||||
invoice_no_match = re.search(r"(\d{8})", full_text)
|
||||
invoice_no = invoice_no_match.group(1) if invoice_no_match else ""
|
||||
if not invoice_no:
|
||||
return None
|
||||
|
||||
# 提取金额(¥ 后跟数字)
|
||||
amount_match = re.search(r"[¥¥]\s*([\d,]+\.?\d*)", full_text)
|
||||
amount = 0.0
|
||||
if amount_match:
|
||||
amount_str = amount_match.group(1).replace(",", "")
|
||||
try:
|
||||
amount = float(amount_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 提取类型(transport/lodging/meal/other)
|
||||
invoice_type = ""
|
||||
for tp in ["transport", "lodging", "meal", "other"]:
|
||||
if tp in full_text.lower():
|
||||
invoice_type = tp
|
||||
break
|
||||
|
||||
# 提取公司名(中文公司名,包含常见后缀)
|
||||
company_match = re.search(
|
||||
r"([\u4e00-\u9fa5]{2,}(?:公司|集团|酒店|商贸|有限|商店|宾馆|饭店))", full_text
|
||||
)
|
||||
company = company_match.group(1) if company_match else ""
|
||||
|
||||
# 提取日期(YYYY-MM-DD)
|
||||
date_match = re.search(r"(\d{4}-\d{2}-\d{2})", full_text)
|
||||
date_str = date_match.group(1) if date_match else ""
|
||||
|
||||
# 提取购方税号
|
||||
tax_no_match = re.search(r"购方税号\s*(\S+)", full_text)
|
||||
tax_no = tax_no_match.group(1) if tax_no_match else ""
|
||||
if not tax_no:
|
||||
# fallback: 查找 15-20 位字母数字混合税号
|
||||
tax_no_fallback = re.search(r"([0-9A-Z]{15,20})", full_text)
|
||||
if tax_no_fallback:
|
||||
tax_no = tax_no_fallback.group(1)
|
||||
|
||||
# 判断验真状态
|
||||
verified = "未验真" not in full_text
|
||||
|
||||
return {
|
||||
"invoice_no": invoice_no,
|
||||
"amount": amount,
|
||||
"invoice_type": invoice_type,
|
||||
"company": company,
|
||||
"date": date_str,
|
||||
"tax_no": tax_no,
|
||||
"verified": verified,
|
||||
}
|
||||
|
||||
|
||||
async def extract_invoice_detail(page: Any, invoice_index: int = 0) -> Optional[Dict[str, Any]]:
|
||||
"""提取单个发票的详细信息。
|
||||
|
||||
"""提取单个发票的详细信息(点击进入详情页后调用)。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
page: Playwright 页面对象
|
||||
invoice_index: 发票索引(从0开始)
|
||||
|
||||
|
||||
Returns:
|
||||
发票详细信息字典
|
||||
"""
|
||||
try:
|
||||
# 查找发票行
|
||||
rows = await _find_elements(page, INVOICE_SELECTORS["invoice_row"])
|
||||
if invoice_index >= len(rows):
|
||||
logger.warning("invoice_index_out_of_range index=%s total=%s", invoice_index, len(rows))
|
||||
rows = page.locator("tr").filter(has_text=re.compile(r"\d{6,}"))
|
||||
count = await rows.count()
|
||||
if invoice_index >= count:
|
||||
logger.warning("invoice_index_out_of_range index=%s total=%s", invoice_index, count)
|
||||
return None
|
||||
|
||||
# 点击发票行查看详情
|
||||
row = rows[invoice_index]
|
||||
await row.click()
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 提取详细信息
|
||||
detail = {}
|
||||
for field, selectors in INVOICE_SELECTORS["invoice_detail"].items():
|
||||
value = await _extract_text_by_selectors(page, selectors)
|
||||
if value:
|
||||
detail[field] = value
|
||||
|
||||
# 提取商品明细
|
||||
items = await _extract_invoice_items(page)
|
||||
if items:
|
||||
detail["items"] = items
|
||||
|
||||
logger.info("extract_invoice_detail success index=%s fields=%s", invoice_index, list(detail.keys()))
|
||||
return detail
|
||||
|
||||
|
||||
row = rows.nth(invoice_index)
|
||||
text = await row.inner_text()
|
||||
return _parse_invoice_text(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("extract_invoice_detail_failed error=%s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
async def _extract_invoice_from_row(page: Any, row: Any, index: int) -> Optional[Dict[str, Any]]:
|
||||
"""从行元素提取发票信息。"""
|
||||
try:
|
||||
# 提取行文本
|
||||
row_text = await row.inner_text()
|
||||
|
||||
# 尝试从文本中提取关键信息
|
||||
invoice_data = {
|
||||
"row_index": index,
|
||||
"row_text": row_text.strip(),
|
||||
"raw_text": row_text,
|
||||
}
|
||||
|
||||
# 尝试提取发票号码(通常是数字)
|
||||
invoice_match = re.search(r'(\d{8,20})', row_text)
|
||||
if invoice_match:
|
||||
invoice_data["invoice_number"] = invoice_match.group(1)
|
||||
|
||||
# 尝试提取金额(包含小数点的数字)
|
||||
amount_match = re.search(r'(\d+\.?\d*)', row_text)
|
||||
if amount_match:
|
||||
invoice_data["amount"] = amount_match.group(1)
|
||||
|
||||
return invoice_data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("extract_from_row_failed index=%s error=%s", index, str(e))
|
||||
return None
|
||||
async def extract_form_fields(page: Any) -> Dict[str, str]:
|
||||
"""提取页面表单字段(报销事由、预算项目、差旅审批单)。"""
|
||||
result: Dict[str, str] = {}
|
||||
|
||||
|
||||
async def _extract_invoice_items(page: Any) -> List[Dict[str, str]]:
|
||||
"""提取发票商品明细。"""
|
||||
items = []
|
||||
|
||||
# 报销事由
|
||||
try:
|
||||
item_containers = await _find_elements(page, INVOICE_SELECTORS["invoice_detail"]["items"])
|
||||
if not item_containers:
|
||||
return []
|
||||
|
||||
for container in item_containers:
|
||||
try:
|
||||
item_text = await container.inner_text()
|
||||
items.append({"text": item_text.strip()})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return items
|
||||
|
||||
reason_input = page.get_by_label("报销事由")
|
||||
if await reason_input.count() > 0:
|
||||
result["reimbursement_reason"] = (await reason_input.input_value()).strip()
|
||||
except Exception:
|
||||
return []
|
||||
pass
|
||||
|
||||
|
||||
async def _has_next_page(page: Any) -> bool:
|
||||
"""检查是否有下一页。"""
|
||||
# 预算项目(下拉框选中项)
|
||||
try:
|
||||
next_button = await _find_element(page, INVOICE_SELECTORS["pagination"]["next_button"])
|
||||
if next_button:
|
||||
# 检查按钮是否可用
|
||||
is_disabled = await next_button.get_attribute("disabled")
|
||||
if is_disabled is None: # 没有disabled属性
|
||||
return True
|
||||
return False
|
||||
budget_selects = page.locator("select")
|
||||
select_count = await budget_selects.count()
|
||||
for i in range(select_count):
|
||||
sel = budget_selects.nth(i)
|
||||
text = await sel.inner_text()
|
||||
if "GDST" in text or "乡村振兴" in text or "预算" in text:
|
||||
# 获取选中的 option
|
||||
selected = sel.locator("option:checked")
|
||||
if await selected.count() > 0:
|
||||
result["budget_project"] = (await selected.first.inner_text()).strip()
|
||||
else:
|
||||
result["budget_project"] = text.strip()[:100]
|
||||
break
|
||||
except Exception:
|
||||
return False
|
||||
pass
|
||||
|
||||
|
||||
async def _click_next_page(page: Any) -> bool:
|
||||
"""点击下一页按钮。"""
|
||||
# 差旅审批单(下拉框选中项)
|
||||
try:
|
||||
next_button = await _find_element(page, INVOICE_SELECTORS["pagination"]["next_button"])
|
||||
if next_button:
|
||||
await next_button.click()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("click_next_page_failed error=%s", str(e))
|
||||
return False
|
||||
travel_selects = page.locator("select")
|
||||
select_count = await travel_selects.count()
|
||||
for i in range(select_count):
|
||||
sel = travel_selects.nth(i)
|
||||
text = await sel.inner_text()
|
||||
if "CL-" in text or "差旅" in text or "北京" in text:
|
||||
selected = sel.locator("option:checked")
|
||||
if await selected.count() > 0:
|
||||
result["travel_approval"] = (await selected.first.inner_text()).strip()
|
||||
else:
|
||||
result["travel_approval"] = text.strip()[:100]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _find_element(page: Any, selectors: list) -> Optional[Any]:
|
||||
"""根据选择器列表查找元素。"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return element
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def _find_elements(page: Any, selectors: list) -> List[Any]:
|
||||
"""根据选择器列表查找多个元素。"""
|
||||
elements = []
|
||||
for selector in selectors:
|
||||
try:
|
||||
found_elements = await page.locator(selector).all()
|
||||
if found_elements:
|
||||
elements.extend(found_elements)
|
||||
break # 找到一组就停止
|
||||
except Exception:
|
||||
continue
|
||||
return elements
|
||||
|
||||
|
||||
async def _extract_text_by_selectors(page: Any, selectors: list) -> Optional[str]:
|
||||
"""根据选择器列表提取文本。"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0:
|
||||
text = await element.inner_text()
|
||||
if text and text.strip():
|
||||
return text.strip()
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def format_invoice_data(invoices: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
@@ -346,4 +267,4 @@ def format_invoice_data(invoices: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"total_count": len(invoices),
|
||||
"invoices": invoices,
|
||||
"extraction_complete": True,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"""发票RPA主流程:协调登录、导航、提取等操作。"""
|
||||
"""发票RPA主流程:协调登录、导航、提取等操作。
|
||||
|
||||
完整流程:
|
||||
1. 启动浏览器
|
||||
2. 登录 sandbox.jc2009.com(手机号+密码)
|
||||
3. 找到"智慧校园"并点击进入
|
||||
4. 登录智慧校园(默认 demo 账号)
|
||||
5. 导航到票据合规预审页面
|
||||
6. 提取发票数据
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -11,15 +19,28 @@ from jiangchang_skill_core.activity import interruptible_sleep, rpa_step
|
||||
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||
|
||||
from .browser_session import close_browser_context, start_browser_session
|
||||
from .invoice_extractor import extract_invoice_detail, extract_invoice_list, format_invoice_data
|
||||
from .login_handler import login_if_needed
|
||||
from .invoice_extractor import extract_form_fields, extract_invoice_list, format_invoice_data
|
||||
from .login_handler import (
|
||||
enter_smart_campus,
|
||||
login_sandbox,
|
||||
login_smart_campus,
|
||||
navigate_to_invoice_precheck,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_active_page(context: Any) -> Any:
|
||||
"""获取 context 中最新的活跃页面(处理新 tab 场景)。"""
|
||||
pages = context.pages
|
||||
if pages:
|
||||
return pages[-1]
|
||||
raise RuntimeError("ERROR:NO_PAGE 浏览器没有可用页面")
|
||||
|
||||
|
||||
class InvoiceRpaResult:
|
||||
"""RPA执行结果。"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
@@ -31,7 +52,7 @@ class InvoiceRpaResult:
|
||||
self.data = data or {}
|
||||
self.error = error
|
||||
self.stage = stage
|
||||
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
@@ -41,70 +62,52 @@ class InvoiceRpaResult:
|
||||
}
|
||||
|
||||
|
||||
@rpa_step("启动浏览器并登录")
|
||||
async def _start_and_login(
|
||||
profile_dir: str,
|
||||
username: str,
|
||||
password: str,
|
||||
headless: bool = False,
|
||||
) -> tuple[Any, Any, Any, bool]:
|
||||
"""启动浏览器并登录。"""
|
||||
@rpa_step("启动浏览器")
|
||||
async def _start_browser(profile_dir: str, headless: bool = False) -> tuple[Any, Any, Any]:
|
||||
"""启动浏览器会话。"""
|
||||
pw, context, page = await start_browser_session(profile_dir, headless=headless)
|
||||
|
||||
# 登录
|
||||
login_success = await login_if_needed(page, username, password)
|
||||
if not login_success:
|
||||
await close_browser_context(pw, context)
|
||||
raise RuntimeError("ERROR:LOGIN_TIMEOUT 登录失败或超时")
|
||||
|
||||
return pw, context, page, True
|
||||
return pw, context, page
|
||||
|
||||
|
||||
@rpa_step("导航到发票预检页面")
|
||||
async def _navigate_to_invoice_page(page: Any) -> bool:
|
||||
"""导航到发票预检页面。"""
|
||||
try:
|
||||
# 检查当前页面是否已在目标页面
|
||||
if "invoice-precheck" in page.url:
|
||||
logger.info("already_on_invoice_page url=%s", page.url)
|
||||
return True
|
||||
|
||||
# 导航到目标页面
|
||||
target_url = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck"
|
||||
await page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
# 等待页面加载
|
||||
await interruptible_sleep(3.0)
|
||||
|
||||
logger.info("navigated_to_invoice_page url=%s", page.url)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("navigation_failed error=%s", str(e))
|
||||
return False
|
||||
@rpa_step("登录 sandbox 门户")
|
||||
async def _login_sandbox_portal(page: Any, phone: str, password: str) -> bool:
|
||||
"""登录 sandbox.jc2009.com 门户。"""
|
||||
return await login_sandbox(page, phone, password)
|
||||
|
||||
|
||||
@rpa_step("提取发票信息")
|
||||
async def _extract_invoices(page: Any, detail_mode: bool = False) -> Dict[str, Any]:
|
||||
"""提取发票信息。"""
|
||||
try:
|
||||
if detail_mode:
|
||||
# 提取详细信息
|
||||
invoices = []
|
||||
rows = await page.locator("tr").all()
|
||||
for i in range(len(rows)):
|
||||
detail = await extract_invoice_detail(page, i)
|
||||
if detail:
|
||||
invoices.append(detail)
|
||||
return format_invoice_data(invoices)
|
||||
else:
|
||||
# 提取列表信息
|
||||
invoices = await extract_invoice_list(page)
|
||||
return format_invoice_data(invoices)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("extraction_failed error=%s", str(e))
|
||||
return {"error": str(e)}
|
||||
@rpa_step("进入智慧校园")
|
||||
async def _enter_campus(page: Any) -> bool:
|
||||
"""在平台首页找到并点击智慧校园。"""
|
||||
return await enter_smart_campus(page)
|
||||
|
||||
|
||||
@rpa_step("登录智慧校园")
|
||||
async def _login_campus(page: Any) -> bool:
|
||||
"""登录智慧校园系统。"""
|
||||
return await login_smart_campus(page)
|
||||
|
||||
|
||||
@rpa_step("导航到票据合规预审")
|
||||
async def _navigate_to_invoice(page: Any) -> bool:
|
||||
"""导航到票据合规预审页面。"""
|
||||
return await navigate_to_invoice_precheck(page)
|
||||
|
||||
|
||||
@rpa_step("提取发票数据")
|
||||
async def _extract_data(page: Any) -> Dict[str, Any]:
|
||||
"""提取发票列表和表单字段。"""
|
||||
# 提取表单字段
|
||||
form_fields = await extract_form_fields(page)
|
||||
logger.info("form_fields_extracted keys=%s", list(form_fields.keys()))
|
||||
|
||||
# 提取发票列表
|
||||
invoices = await extract_invoice_list(page)
|
||||
logger.info("invoices_extracted count=%s", len(invoices))
|
||||
|
||||
# 组合结果
|
||||
result = format_invoice_data(invoices)
|
||||
result.update(form_fields)
|
||||
return result
|
||||
|
||||
|
||||
async def run_invoice_rpa(
|
||||
@@ -117,69 +120,107 @@ async def run_invoice_rpa(
|
||||
batch_id: str = "",
|
||||
) -> InvoiceRpaResult:
|
||||
"""运行发票RPA主流程。
|
||||
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
password: 密码
|
||||
username: 手机号(sandbox 门户登录)
|
||||
password: 密码(sandbox 门户登录)
|
||||
profile_dir: 浏览器配置目录
|
||||
headless: 是否无头模式
|
||||
detail_mode: 是否提取详细信息
|
||||
detail_mode: 是否提取详细信息(预留)
|
||||
data_dir: 数据目录
|
||||
batch_id: 批次ID
|
||||
|
||||
|
||||
Returns:
|
||||
InvoiceRpaResult: 执行结果
|
||||
"""
|
||||
if not batch_id:
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
logger.info(
|
||||
"invoice_rpa_start username=%s batch_id=%s detail_mode=%s",
|
||||
username[:3] + "***", # 脱敏
|
||||
"invoice_rpa_start username=%s*** batch_id=%s detail_mode=%s",
|
||||
(username or "")[:3],
|
||||
batch_id,
|
||||
detail_mode,
|
||||
)
|
||||
|
||||
|
||||
pw = None
|
||||
context = None
|
||||
page = None
|
||||
|
||||
|
||||
try:
|
||||
# 启动浏览器并登录
|
||||
pw, context, page, _ = await _start_and_login(
|
||||
profile_dir, username, password, headless
|
||||
)
|
||||
|
||||
# 导航到发票页面
|
||||
if not await _navigate_to_invoice_page(page):
|
||||
# 步骤 1:启动浏览器
|
||||
pw, context, page = await _start_browser(profile_dir, headless)
|
||||
|
||||
# 步骤 2:登录 sandbox 门户
|
||||
sandbox_ok = await _login_sandbox_portal(page, username, password)
|
||||
if not sandbox_ok:
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error="导航到发票页面失败",
|
||||
error="登录 sandbox 门户失败",
|
||||
stage="sandbox_login",
|
||||
)
|
||||
|
||||
# 步骤 3:进入智慧校园(可能打开新 tab,需要刷新 page)
|
||||
campus_ok = await _enter_campus(page)
|
||||
if not campus_ok:
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error="进入智慧校园失败",
|
||||
stage="enter_campus",
|
||||
)
|
||||
|
||||
# 刷新 page 引用(处理新 tab 场景)
|
||||
page = _get_active_page(context)
|
||||
logger.info("page_refreshed_after_enter_campus url=%s", page.url)
|
||||
|
||||
# 步骤 4:登录智慧校园
|
||||
login_ok = await _login_campus(page)
|
||||
if not login_ok:
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error="登录智慧校园失败",
|
||||
stage="campus_login",
|
||||
)
|
||||
|
||||
# 刷新 page 引用
|
||||
page = _get_active_page(context)
|
||||
logger.info("page_refreshed_after_campus_login url=%s", page.url)
|
||||
|
||||
# 步骤 5:导航到票据合规预审
|
||||
nav_ok = await _navigate_to_invoice(page)
|
||||
if not nav_ok:
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error="导航到票据合规预审页面失败",
|
||||
stage="navigation",
|
||||
)
|
||||
|
||||
# 提取发票信息
|
||||
result_data = await _extract_invoices(page, detail_mode)
|
||||
|
||||
|
||||
# 刷新 page 引用
|
||||
page = _get_active_page(context)
|
||||
logger.info("page_refreshed_after_navigate url=%s", page.url)
|
||||
|
||||
# 步骤 6:提取发票数据
|
||||
result_data = await _extract_data(page)
|
||||
|
||||
if "error" in result_data:
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error=result_data["error"],
|
||||
stage="extraction",
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
"invoice_rpa_success total_invoices=%s batch_id=%s",
|
||||
result_data.get("total_count", 0),
|
||||
batch_id,
|
||||
)
|
||||
|
||||
|
||||
return InvoiceRpaResult(
|
||||
success=True,
|
||||
data=result_data,
|
||||
stage="completed",
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("invoice_rpa_failed error=%s batch_id=%s", str(e), batch_id)
|
||||
return InvoiceRpaResult(
|
||||
@@ -187,7 +228,7 @@ async def run_invoice_rpa(
|
||||
error=str(e),
|
||||
stage="unknown",
|
||||
)
|
||||
|
||||
|
||||
finally:
|
||||
# 关闭浏览器
|
||||
if pw and context:
|
||||
@@ -207,7 +248,7 @@ async def run_invoice_rpa_with_video(
|
||||
) -> InvoiceRpaResult:
|
||||
"""带录屏的发票RPA主流程。"""
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
async with RpaVideoSession(
|
||||
skill_slug=skill_slug,
|
||||
skill_data_dir=skill_data_dir,
|
||||
@@ -216,7 +257,7 @@ async def run_invoice_rpa_with_video(
|
||||
closing_title=closing_title,
|
||||
) as video:
|
||||
video.add_step("准备启动浏览器")
|
||||
|
||||
|
||||
# 运行RPA
|
||||
result = await run_invoice_rpa(
|
||||
username=username,
|
||||
@@ -227,10 +268,10 @@ async def run_invoice_rpa_with_video(
|
||||
data_dir=skill_data_dir,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
|
||||
if result.success:
|
||||
video.add_step(f"成功提取 {result.data.get('total_count', 0)} 张发票信息")
|
||||
else:
|
||||
video.add_step(f"提取失败: {result.error}")
|
||||
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,57 +1,34 @@
|
||||
"""登录处理器:处理网站登录流程。"""
|
||||
"""登录处理器:两层登录(sandbox 门户 + 智慧校园)。
|
||||
|
||||
流程:
|
||||
1. 检测 sandbox.jc2009.com 登录页 → 手机号+密码登录
|
||||
2. 平台首页找到"智慧校园" → 点击进入
|
||||
3. 智慧校园师生登录页 → demo 账号登录
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 登录相关选择器(需要根据实际页面调整)
|
||||
LOGIN_SELECTORS = {
|
||||
"username_input": [
|
||||
"input[name='username']",
|
||||
"input[placeholder*='用户名']",
|
||||
"input[placeholder*='账号']",
|
||||
"#username",
|
||||
"[data-testid='username']",
|
||||
],
|
||||
"password_input": [
|
||||
"input[name='password']",
|
||||
"input[type='password']",
|
||||
"#password",
|
||||
"[data-testid='password']",
|
||||
],
|
||||
"login_button": [
|
||||
"button[type='submit']",
|
||||
"button:has-text('登录')",
|
||||
"input[type='submit']",
|
||||
".login-btn",
|
||||
"[data-testid='login-button']",
|
||||
],
|
||||
"captcha_input": [
|
||||
"input[name='captcha']",
|
||||
"input[placeholder*='验证码']",
|
||||
"#captcha",
|
||||
],
|
||||
"captcha_image": [
|
||||
".captcha-image",
|
||||
"img[alt*='验证码']",
|
||||
"#captcha-image",
|
||||
],
|
||||
}
|
||||
# ── Sandbox 门户登录配置 ──
|
||||
SANDBOX_LOGIN_PHONE = "15527406659"
|
||||
SANDBOX_LOGIN_PASSWORD = "qwer1234"
|
||||
|
||||
# 登录成功检测
|
||||
LOGIN_SUCCESS_SELECTORS = [
|
||||
".user-info",
|
||||
".welcome",
|
||||
"[data-testid='user-avatar']",
|
||||
"a[href*='logout']",
|
||||
".dashboard",
|
||||
]
|
||||
# ── 智慧校园默认凭据 ──
|
||||
SMART_CAMPUS_DEFAULT_USER = "demo"
|
||||
SMART_CAMPUS_DEFAULT_PASSWORD = "demo123"
|
||||
|
||||
# ── 超时 ──
|
||||
NAVIGATION_TIMEOUT_MS = 30_000
|
||||
POLL_INTERVAL_SEC = 1.0 # 轮询间隔(秒)
|
||||
TYPE_DELAY_MS = 50 # 逐字符输入延迟(毫秒)
|
||||
|
||||
|
||||
async def login_if_needed(
|
||||
@@ -60,149 +37,286 @@ async def login_if_needed(
|
||||
password: str,
|
||||
wait_sec: int = 120,
|
||||
) -> bool:
|
||||
"""检查登录状态,如需要则执行登录。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
username: 用户名
|
||||
password: 密码
|
||||
wait_sec: 等待人工验证的超时时间
|
||||
|
||||
Returns:
|
||||
bool: 登录是否成功
|
||||
"""
|
||||
# 检查是否已登录
|
||||
if await _is_logged_in(page):
|
||||
logger.info("already_logged_in url=%s", page.url)
|
||||
return True
|
||||
|
||||
# 导航到登录页面
|
||||
if "/login" not in page.url:
|
||||
await page.goto("https://sandbox.jc2009.com/login", wait_until="domcontentloaded")
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 尝试自动登录
|
||||
login_success = await _try_auto_login(page, username, password)
|
||||
if login_success:
|
||||
return True
|
||||
|
||||
# 等待人工登录
|
||||
return await _wait_for_human_login(page, wait_sec)
|
||||
"""检查登录状态,如需要则执行登录。"""
|
||||
phone = username or SANDBOX_LOGIN_PHONE
|
||||
pwd = password or SANDBOX_LOGIN_PASSWORD
|
||||
|
||||
|
||||
async def _try_auto_login(page: Any, username: str, password: str) -> bool:
|
||||
"""尝试自动登录。"""
|
||||
try:
|
||||
# 查找用户名输入框
|
||||
username_input = await _find_element(page, LOGIN_SELECTORS["username_input"])
|
||||
if not username_input:
|
||||
logger.warning("username_input_not_found")
|
||||
return False
|
||||
|
||||
# 查找密码输入框
|
||||
password_input = await _find_element(page, LOGIN_SELECTORS["password_input"])
|
||||
if not password_input:
|
||||
logger.warning("password_input_not_found")
|
||||
return False
|
||||
|
||||
# 输入用户名(模拟人类输入)
|
||||
await username_input.click()
|
||||
await interruptible_sleep(0.5)
|
||||
await username_input.fill("")
|
||||
await page.keyboard.type(username, delay=100)
|
||||
await interruptible_sleep(0.5)
|
||||
|
||||
# 输入密码
|
||||
await password_input.click()
|
||||
await interruptible_sleep(0.5)
|
||||
await password_input.fill("")
|
||||
await page.keyboard.type(password, delay=100)
|
||||
await interruptible_sleep(0.5)
|
||||
|
||||
# 查找并点击登录按钮
|
||||
login_button = await _find_element(page, LOGIN_SELECTORS["login_button"])
|
||||
if not login_button:
|
||||
logger.warning("login_button_not_found")
|
||||
return False
|
||||
|
||||
await login_button.click()
|
||||
await interruptible_sleep(3.0)
|
||||
|
||||
# 检查是否登录成功
|
||||
return await _is_logged_in(page)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("auto_login_failed error=%s", str(e))
|
||||
sandbox_ok = await login_sandbox(page, phone, pwd)
|
||||
if not sandbox_ok:
|
||||
return False
|
||||
|
||||
campus_entered = await enter_smart_campus(page)
|
||||
if not campus_entered:
|
||||
return False
|
||||
|
||||
async def _wait_for_human_login(page: Any, wait_sec: int) -> bool:
|
||||
"""等待人工完成登录。"""
|
||||
print(f"请在浏览器中完成登录,最多等待 {wait_sec} 秒...")
|
||||
deadline = time.monotonic() + wait_sec
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if await _is_logged_in(page):
|
||||
print("检测到登录成功")
|
||||
campus_ok = await login_smart_campus(page)
|
||||
return campus_ok
|
||||
|
||||
|
||||
# ── 工具函数 ──
|
||||
|
||||
|
||||
def _is_sandbox_home(page_url: str) -> bool:
|
||||
return "select" in page_url or "/industries" in page_url
|
||||
|
||||
|
||||
async def _type_field(locator, text: str) -> None:
|
||||
"""快速填入字段:fill 直接设值(比 keyboard.type 快 10 倍)。"""
|
||||
await locator.click()
|
||||
await locator.fill(text)
|
||||
|
||||
|
||||
async def _poll_until(check_fn, timeout_sec: float = 30.0) -> bool:
|
||||
"""快速轮询:每 1 秒检测一次,检测到立刻返回。check_fn 必须是无参 async callable。"""
|
||||
elapsed = 0.0
|
||||
while elapsed < timeout_sec:
|
||||
if await check_fn():
|
||||
return True
|
||||
|
||||
# 检查是否有验证码需要处理
|
||||
captcha_present = await _check_captcha(page)
|
||||
if captcha_present:
|
||||
print("检测到验证码,请人工完成验证...")
|
||||
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
print("登录超时")
|
||||
await asyncio.sleep(POLL_INTERVAL_SEC)
|
||||
elapsed += POLL_INTERVAL_SEC
|
||||
return False
|
||||
|
||||
|
||||
async def _is_logged_in(page: Any) -> bool:
|
||||
"""检查是否已登录。"""
|
||||
# ── 阶段一:登录 sandbox.jc2009.com ──
|
||||
|
||||
|
||||
async def login_sandbox(page: Any, phone: str, password: str) -> bool:
|
||||
"""登录 sandbox.jc2009.com 门户。已登录则直接返回 True。"""
|
||||
logger.info("sandbox_login_start phone=%s***", phone[:3])
|
||||
|
||||
try:
|
||||
# 检查URL是否包含登录成功标识
|
||||
if "/login" not in page.url and "/index" in page.url:
|
||||
if _is_sandbox_home(page.url) or await page.get_by_role("button", name=re.compile(r"Sign out|退出")).count() > 0 or await page.get_by_text("智慧校园", exact=False).count() > 0:
|
||||
logger.info("sandbox_already_logged_in url=%s", page.url)
|
||||
return True
|
||||
|
||||
# 检查页面元素
|
||||
for selector in LOGIN_SUCCESS_SELECTORS:
|
||||
|
||||
# 找登录表单
|
||||
phone_input = page.get_by_role("textbox", name=re.compile(r"Phone|phone|手机"))
|
||||
if await phone_input.count() == 0:
|
||||
await page.goto("https://sandbox.jc2009.com/", wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
|
||||
# 用轮询替代固定等待
|
||||
async def _home_check():
|
||||
return _is_sandbox_home(page.url) or await page.get_by_text("智慧校园", exact=False).count() > 0
|
||||
await _poll_until(_home_check, timeout_sec=10.0)
|
||||
if _is_sandbox_home(page.url) or await page.get_by_text("智慧校园", exact=False).count() > 0:
|
||||
logger.info("sandbox_already_logged_in_after_redirect")
|
||||
return True
|
||||
phone_input = page.get_by_role("textbox", name=re.compile(r"Phone|phone|手机"))
|
||||
|
||||
if await phone_input.count() == 0:
|
||||
logger.warning("sandbox_login_page_not_found url=%s", page.url)
|
||||
return False
|
||||
|
||||
logger.info("sandbox_login_page_detected")
|
||||
|
||||
# 输入手机号(fill 直接设值,比 type 快)
|
||||
await _type_field(phone_input, phone)
|
||||
# 输入密码
|
||||
pwd_input = page.get_by_role("textbox", name=re.compile(r"Password|password|密码"))
|
||||
if await pwd_input.count() == 0:
|
||||
pwd_input = page.get_by_label("Password")
|
||||
await _type_field(pwd_input, password)
|
||||
|
||||
# 点击 Sign in
|
||||
sign_in_btn = page.get_by_role("button", name=re.compile(r"Sign in|登录"))
|
||||
await sign_in_btn.click()
|
||||
logger.info("sandbox_login_click_sign_in")
|
||||
|
||||
# 快速轮询等待登录成功
|
||||
async def _login_done():
|
||||
return (
|
||||
_is_sandbox_home(page.url)
|
||||
or await page.get_by_role("button", name=re.compile(r"Sign out|退出")).count() > 0
|
||||
or await page.get_by_text("智慧校园", exact=False).count() > 0
|
||||
)
|
||||
|
||||
if await _poll_until(_login_done, timeout_sec=30.0):
|
||||
logger.info("sandbox_login_success url=%s", page.url)
|
||||
return True
|
||||
|
||||
logger.warning("sandbox_login_timeout")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("sandbox_login_failed error=%s url=%s", str(e), page.url)
|
||||
return False
|
||||
|
||||
|
||||
# ── 阶段二:进入智慧校园 ──
|
||||
|
||||
|
||||
async def enter_smart_campus(page: Any) -> bool:
|
||||
"""在平台首页找到并点击"智慧校园"卡片。"""
|
||||
logger.info("enter_smart_campus_start url=%s", page.url)
|
||||
|
||||
try:
|
||||
campus_card = page.get_by_text("智慧校园", exact=False)
|
||||
count = await campus_card.count()
|
||||
if count == 0:
|
||||
logger.warning("smart_campus_not_found")
|
||||
return False
|
||||
|
||||
pages_before = len(page.context.pages)
|
||||
|
||||
# 点击卡片
|
||||
clicked = False
|
||||
for i in range(count):
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
el = campus_card.nth(i)
|
||||
parent = el.locator("xpath=ancestor::a[1]")
|
||||
if await parent.count() > 0:
|
||||
await parent.first.click()
|
||||
clicked = True
|
||||
break
|
||||
parent = el.locator("xpath=ancestor::div[contains(@class,'card') or contains(@class,'item') or contains(@class,'clickable')][1]")
|
||||
if await parent.count() > 0:
|
||||
await parent.first.click()
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not clicked:
|
||||
await campus_card.first.click()
|
||||
|
||||
# 快速检测是否有新 tab
|
||||
await asyncio.sleep(1.0)
|
||||
pages_after = len(page.context.pages)
|
||||
active_page = page
|
||||
if pages_after > pages_before:
|
||||
active_page = page.context.pages[-1]
|
||||
logger.info("new_tab url=%s", active_page.url)
|
||||
else:
|
||||
active_page = page.context.pages[-1]
|
||||
|
||||
# 快速轮询等待校园登录页
|
||||
async def _campus_page_ready():
|
||||
p = active_page
|
||||
if await p.get_by_text("师生登录", exact=False).count() > 0:
|
||||
return True
|
||||
if await p.get_by_label("账号").count() > 0:
|
||||
return True
|
||||
if await p.locator('input[type="password"]').count() > 0:
|
||||
return True
|
||||
if await p.get_by_text("工作台", exact=False).count() > 0:
|
||||
return True
|
||||
if await p.get_by_text("华江示范大学", exact=False).count() > 0:
|
||||
return True
|
||||
if "smartcampus" in p.url or "campus" in p.url:
|
||||
return True
|
||||
# 排除还在 sandbox 门户的情况
|
||||
if await p.get_by_text("Password sign-in", exact=False).count() > 0:
|
||||
return False
|
||||
login_btn = p.get_by_role("button", name=re.compile(r"登录"))
|
||||
if await login_btn.count() > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
if await _poll_until(_campus_page_ready, timeout_sec=30.0):
|
||||
logger.info("smart_campus_page_ready url=%s", active_page.url)
|
||||
return True
|
||||
|
||||
logger.warning("smart_campus_timeout url=%s", active_page.url)
|
||||
return False
|
||||
except Exception:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("enter_smart_campus_failed error=%s url=%s", str(e), page.url)
|
||||
return False
|
||||
|
||||
|
||||
async def _check_captcha(page: Any) -> bool:
|
||||
"""检查是否有验证码。"""
|
||||
# ── 阶段三:登录智慧校园 ──
|
||||
|
||||
|
||||
async def login_smart_campus(page: Any) -> bool:
|
||||
"""登录智慧校园系统。"""
|
||||
logger.info("smart_campus_login_start")
|
||||
|
||||
try:
|
||||
for selector in LOGIN_SELECTORS["captcha_input"]:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
|
||||
for selector in LOGIN_SELECTORS["captcha_image"]:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
|
||||
# 已登录直接返回
|
||||
if await page.get_by_text("工作台", exact=False).count() > 0:
|
||||
logger.info("smart_campus_already_logged_in")
|
||||
return True
|
||||
|
||||
# 填账号(如果为空)
|
||||
account_input = page.get_by_label("账号")
|
||||
if await account_input.count() == 0:
|
||||
account_input = page.locator('input[placeholder*="账号"], input[placeholder*="用户名"]')
|
||||
if await account_input.count() > 0:
|
||||
val = await account_input.input_value()
|
||||
if not val or not val.strip():
|
||||
await _type_field(account_input, SMART_CAMPUS_DEFAULT_USER)
|
||||
|
||||
# 填密码(如果为空)
|
||||
pwd_input = page.get_by_label("密码")
|
||||
if await pwd_input.count() == 0:
|
||||
pwd_input = page.locator('input[type="password"]')
|
||||
if await pwd_input.count() > 0:
|
||||
val = await pwd_input.input_value()
|
||||
if not val or not val.strip():
|
||||
await _type_field(pwd_input, SMART_CAMPUS_DEFAULT_PASSWORD)
|
||||
|
||||
# 点击登录
|
||||
login_btn = page.get_by_role("button", name=re.compile(r"登录|登 录"))
|
||||
if await login_btn.count() == 0:
|
||||
login_btn = page.locator('button:has-text("登录"), button[type="submit"]')
|
||||
await login_btn.first.click()
|
||||
logger.info("smart_campus_login_click")
|
||||
|
||||
# 快速轮询等待进入系统
|
||||
async def _workbench_ready():
|
||||
return await page.get_by_text("工作台", exact=False).count() > 0
|
||||
|
||||
if await _poll_until(_workbench_ready, timeout_sec=20.0):
|
||||
logger.info("smart_campus_login_success")
|
||||
return True
|
||||
|
||||
logger.warning("smart_campus_login_timeout")
|
||||
return False
|
||||
except Exception:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("smart_campus_login_failed error=%s", str(e))
|
||||
return False
|
||||
|
||||
|
||||
async def _find_element(page: Any, selectors: list) -> Optional[Any]:
|
||||
"""根据选择器列表查找元素。"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return element
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
# ── 导航到票据合规预审 ──
|
||||
|
||||
|
||||
async def navigate_to_invoice_precheck(page: Any) -> bool:
|
||||
"""从智慧校园主界面导航到票据合规预审。"""
|
||||
logger.info("navigate_to_invoice_precheck_start")
|
||||
|
||||
try:
|
||||
# 直接 URL 导航
|
||||
target_url = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck"
|
||||
await page.goto(target_url, wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
|
||||
|
||||
# 快速轮询等待页面标题出现
|
||||
async def _title_ready():
|
||||
return await page.get_by_text("票据合规性秒级预审", exact=False).count() > 0
|
||||
|
||||
if await _poll_until(_title_ready, timeout_sec=15.0):
|
||||
logger.info("invoice_precheck_page_loaded")
|
||||
return True
|
||||
|
||||
# fallback: 通过菜单
|
||||
logger.info("direct_nav_failed_try_menu")
|
||||
await page.goto("https://sandbox.jc2009.com/industries/campus/smartcampus/", wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
|
||||
|
||||
finance_menu = page.get_by_text("财务处", exact=True)
|
||||
if await finance_menu.count() > 0:
|
||||
await finance_menu.click()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
invoice_menu = page.get_by_text("票据合规预审", exact=True)
|
||||
if await invoice_menu.count() == 0:
|
||||
invoice_menu = page.get_by_text(re.compile(r"票据合规"))
|
||||
if await invoice_menu.count() > 0:
|
||||
await invoice_menu.first.click()
|
||||
if await _poll_until(_title_ready, timeout_sec=15.0):
|
||||
logger.info("invoice_precheck_page_loaded_via_menu")
|
||||
return True
|
||||
|
||||
logger.warning("invoice_precheck_page_not_loaded")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("navigate_to_invoice_precheck_failed error=%s", str(e))
|
||||
return False
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
"""技能标识、版本与平台公共库约束(票据合规预审助手)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
# ── 技能标识 ──
|
||||
SKILL_SLUG = "verify-invoice-default"
|
||||
SKILL_VERSION = "1.0.3"
|
||||
SKILL_VERSION = "1.0.4"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.verify_invoice_default"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
# ── 账号管理 ──
|
||||
TARGET_PLATFORM = "jc2009"
|
||||
LEASE_HOLDER = "verify-invoice-default"
|
||||
LEASE_TTL_SEC = "1800"
|
||||
|
||||
# ── Sandbox 登录凭据 ──
|
||||
SANDBOX_LOGIN_PHONE = "15527406659"
|
||||
SANDBOX_LOGIN_PASSWORD = "qwer1234"
|
||||
|
||||
# ── 智慧校园默认凭据 ──
|
||||
SMART_CAMPUS_DEFAULT_USER = "demo"
|
||||
SMART_CAMPUS_DEFAULT_PASSWORD = "demo123"
|
||||
|
||||
# ── 超时配置 ──
|
||||
DEFAULT_TIMEOUT_MS = 15_000
|
||||
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
|
||||
PAGE_LOAD_TIMEOUT_MS = 60_000
|
||||
DEFAULT_PORTAL_LOGIN_WAIT_SEC = 120
|
||||
|
||||
# ── 环境变量 ──
|
||||
ENV_TARGET_BASE_URL = "TARGET_BASE_URL"
|
||||
DEFAULT_TARGET_URL = "https://sandbox.jc2009.com"
|
||||
|
||||
93
test_rpa.py
93
test_rpa.py
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试RPA功能。"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加scripts目录到Python路径
|
||||
scripts_dir = os.path.join(os.path.dirname(__file__), "scripts")
|
||||
if scripts_dir not in sys.path:
|
||||
sys.path.insert(0, scripts_dir)
|
||||
|
||||
from service.rpa import run_invoice_rpa, close_browser_context
|
||||
from service.rpa.browser_session import start_browser_session
|
||||
|
||||
|
||||
async def test_basic_connection():
|
||||
"""测试基本连接。"""
|
||||
print("测试基本连接...")
|
||||
|
||||
# 注意:需要实际的profile_dir
|
||||
# 这里只是演示结构
|
||||
profile_dir = "test_profile_dir"
|
||||
username = "15527406659"
|
||||
password = "qwer1234"
|
||||
|
||||
try:
|
||||
# 启动浏览器
|
||||
pw, context, page = await start_browser_session(profile_dir, headless=False)
|
||||
print("浏览器启动成功")
|
||||
|
||||
# 关闭浏览器
|
||||
await close_browser_context(pw, context)
|
||||
print("浏览器关闭成功")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"测试失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_invoice_extraction():
|
||||
"""测试发票提取。"""
|
||||
print("测试发票提取...")
|
||||
|
||||
# 注意:需要实际的profile_dir
|
||||
profile_dir = "test_profile_dir"
|
||||
username = "15527406659"
|
||||
password = "qwer1234"
|
||||
|
||||
try:
|
||||
result = await run_invoice_rpa(
|
||||
username=username,
|
||||
password=password,
|
||||
profile_dir=profile_dir,
|
||||
headless=False,
|
||||
detail_mode=False,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print(f"提取成功: {result.data}")
|
||||
return True
|
||||
else:
|
||||
print(f"提取失败: {result.error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"测试异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""主测试函数。"""
|
||||
print("=" * 50)
|
||||
print("RPA功能测试")
|
||||
print("=" * 50)
|
||||
|
||||
# 运行测试
|
||||
success = asyncio.run(test_basic_connection())
|
||||
|
||||
if success:
|
||||
print("\n基本连接测试通过")
|
||||
else:
|
||||
print("\n基本连接测试失败")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("注意:实际运行需要有效的浏览器配置目录")
|
||||
print("请确保已通过account-manager获取profile_dir")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
tests/.gitignore
vendored
10
tests/.gitignore
vendored
@@ -1,10 +0,0 @@
|
||||
# 测试运行产物与本地密钥,勿提交
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
desktop/artifacts/
|
||||
integration/artifacts/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.sample
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user