chore: auto release commit (2026-09-14 15:11:41)
All checks were successful
技能自动化发布 / release (push) Successful in 9s

This commit is contained in:
chenfuqiang
2026-09-14 15:11:41 +08:00
parent ce96693564
commit c5103e2650
17 changed files with 2626 additions and 2 deletions

View File

@@ -3,6 +3,15 @@
本文件对应技能市场 **「更新日志」** Tab。 本文件对应技能市场 **「更新日志」** Tab。
发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。 发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。
## 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 ## 1.0.1
- 首个版本:票据合规性"秒级预审"上线 - 首个版本:票据合规性"秒级预审"上线

370
PLATFORM_USAGE.md Normal file
View File

@@ -0,0 +1,370 @@
# 平台内使用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 Normal file
View File

@@ -0,0 +1,212 @@
# 发票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 Normal file
View File

@@ -0,0 +1,217 @@
# 发票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系统

View File

@@ -1,7 +1,7 @@
--- ---
name: 票据合规预审助手 name: 票据合规预审助手
description: "高校报销场景的发票合规性秒级预审:核对字段完整性、购买方税号白名单、开票日期与出差行程逻辑、住宿交通经费标准,对连号、同商家集中开票、节假日开票等风险点红黄灯预警,生成《初审意见单》。" description: "高校报销场景的发票合规性秒级预审:核对字段完整性、购买方税号白名单、开票日期与出差行程逻辑、住宿交通经费标准,对连号、同商家集中开票、节假日开票等风险点红黄灯预警,生成《初审意见单》。"
version: 1.0.1 version: 1.0.2
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:

95
demo_rpa.py Normal file
View File

@@ -0,0 +1,95 @@
#!/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())

View File

@@ -13,9 +13,11 @@ from typing import List, Optional
from service.task_service import ( from service.task_service import (
cmd_add_whitelist, cmd_add_whitelist,
cmd_config_path, cmd_config_path,
cmd_extract_invoices,
cmd_health, cmd_health,
cmd_init_db, cmd_init_db,
cmd_list_holidays, cmd_list_holidays,
cmd_list_rpa_invoices,
cmd_list_reports, cmd_list_reports,
cmd_list_whitelist, cmd_list_whitelist,
cmd_mark_verified, cmd_mark_verified,
@@ -23,10 +25,13 @@ from service.task_service import (
cmd_prereview_batch_input, cmd_prereview_batch_input,
cmd_remove_whitelist, cmd_remove_whitelist,
cmd_render_html, cmd_render_html,
cmd_rpa_invoice_stats,
cmd_run, cmd_run,
cmd_set_standard, cmd_set_standard,
cmd_show_rpa_invoice,
cmd_show_report, cmd_show_report,
cmd_stats, cmd_stats,
cmd_test_rpa_connection,
cmd_version, cmd_version,
cmd_view_standards, cmd_view_standards,
) )
@@ -134,6 +139,41 @@ def _handle_list_holidays(args: argparse.Namespace) -> int:
return cmd_list_holidays(year=_cli_str_or_none(args.year)) return cmd_list_holidays(year=_cli_str_or_none(args.year))
def _handle_extract_invoices(args: argparse.Namespace) -> int:
return cmd_extract_invoices(
username=str(args.username or "").strip(),
password=str(args.password or "").strip(),
profile_dir=str(args.profile_dir or "").strip(),
headless=bool(args.headless),
detail_mode=bool(args.detail_mode),
)
def _handle_test_rpa_connection(args: argparse.Namespace) -> int:
return cmd_test_rpa_connection(
username=str(args.username or "").strip(),
password=str(args.password or "").strip(),
profile_dir=str(args.profile_dir or "").strip(),
)
def _handle_list_rpa_invoices(args: argparse.Namespace) -> int:
return cmd_list_rpa_invoices(
limit=int(args.limit or 20),
batch_id=_cli_str_or_none(args.batch_id),
username=_cli_str_or_none(args.username),
status=_cli_str_or_none(args.status),
)
def _handle_show_rpa_invoice(args: argparse.Namespace) -> int:
return cmd_show_rpa_invoice(record_id=int(args.record_id))
def _handle_rpa_invoice_stats(args: argparse.Namespace) -> int:
return cmd_rpa_invoice_stats()
def _print_full_usage() -> None: def _print_full_usage() -> None:
print("票据合规预审助手(main.py)可用命令:") print("票据合规预审助手(main.py)可用命令:")
print(" python main.py prereview --invoice-json <file|-> [--purpose 文本] [--project-no 编号]") print(" python main.py prereview --invoice-json <file|-> [--purpose 文本] [--project-no 编号]")
@@ -152,6 +192,9 @@ def _print_full_usage() -> None:
print(" python main.py list-holidays [--year YYYY]") print(" python main.py list-holidays [--year YYYY]")
print(" python main.py stats") print(" python main.py stats")
print(" python main.py run [target [input_id]] [-t target] [-i input_id]") print(" python main.py run [target [input_id]] [-t target] [-i input_id]")
print(" python main.py extract-invoices --username U --password P --profile-dir DIR")
print(" [--headless] [--detail-mode]")
print(" python main.py test-rpa-connection --username U --password P --profile-dir DIR")
print(" python main.py health") print(" python main.py health")
print(" python main.py config-path") print(" python main.py config-path")
print(" python main.py version") print(" python main.py version")
@@ -244,6 +287,36 @@ def build_parser() -> ZhArgumentParser:
sp = sub.add_parser("stats", help="预审统计") sp = sub.add_parser("stats", help="预审统计")
sp.set_defaults(handler=lambda _a: cmd_stats()) sp.set_defaults(handler=lambda _a: cmd_stats())
# RPA commands
sp = sub.add_parser("extract-invoices", help="从网站提取发票信息")
sp.add_argument("--username", required=True, metavar="用户名")
sp.add_argument("--password", required=True, metavar="密码")
sp.add_argument("--profile-dir", required=True, metavar="浏览器配置目录", dest="profile_dir")
sp.add_argument("--headless", action="store_true", help="无头模式(生产不建议)")
sp.add_argument("--detail-mode", action="store_true", help="提取详细信息", dest="detail_mode")
sp.set_defaults(handler=_handle_extract_invoices)
sp = sub.add_parser("test-rpa-connection", help="测试RPA连接和登录")
sp.add_argument("--username", required=True, metavar="用户名")
sp.add_argument("--password", required=True, metavar="密码")
sp.add_argument("--profile-dir", required=True, metavar="浏览器配置目录", dest="profile_dir")
sp.set_defaults(handler=_handle_test_rpa_connection)
# RPA数据查询命令
sp = sub.add_parser("list-rpa-invoices", help="查询RPA提取的发票数据")
sp.add_argument("--limit", default=20, type=int, metavar="条数")
sp.add_argument("--batch-id", default=None, metavar="批次ID", dest="batch_id")
sp.add_argument("--username", default=None, metavar="用户名")
sp.add_argument("--status", default=None, metavar="状态")
sp.set_defaults(handler=_handle_list_rpa_invoices)
sp = sub.add_parser("show-rpa-invoice", help="查看RPA提取的发票详情")
sp.add_argument("--record-id", required=True, type=int, metavar="记录ID", dest="record_id")
sp.set_defaults(handler=_handle_show_rpa_invoice)
sp = sub.add_parser("rpa-invoice-stats", help="获取RPA提取统计信息")
sp.set_defaults(handler=_handle_rpa_invoice_stats)
sp = sub.add_parser("run", help="任务执行命令骨架") sp = sub.add_parser("run", help="任务执行命令骨架")
sp.add_argument("--target", "-t", default=None, metavar="目标") sp.add_argument("--target", "-t", default=None, metavar="目标")
sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id") sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id")

View File

@@ -0,0 +1,103 @@
"""RPA发票数据表结构定义。"""
from __future__ import annotations
# RPA发票数据表DDL
RPA_INVOICES_DDL = """
CREATE TABLE IF NOT EXISTS rpa_invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id TEXT NOT NULL,
source_url TEXT NOT NULL,
extraction_time INTEGER NOT NULL DEFAULT (unixepoch()),
username TEXT,
invoice_data_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'extracted',
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
RPA_INVOICES_INDEX = """
CREATE INDEX IF NOT EXISTS idx_rpa_invoices_batch
ON rpa_invoices(batch_id)
"""
# RPA发票数据表列定义
RPA_INVOICES_COLUMNS = (
{
"column_name": "id",
"display_name": "编号",
"description": "主键",
"visible": 1,
"searchable": 1,
"editable": 0,
},
{
"column_name": "batch_id",
"display_name": "批次ID",
"description": "RPA提取批次的唯一标识",
"visible": 1,
"searchable": 1,
"editable": 0,
},
{
"column_name": "source_url",
"display_name": "来源网址",
"description": "RPA操作的目标网站URL",
"visible": 1,
"searchable": 1,
"editable": 0,
},
{
"column_name": "extraction_time",
"display_name": "提取时间",
"description": "RPA提取完成的时间(Unix秒级时间戳)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
{
"column_name": "username",
"display_name": "操作用户",
"description": "执行RPA操作的用户",
"visible": 1,
"searchable": 1,
"editable": 0,
},
{
"column_name": "invoice_data_json",
"display_name": "发票数据",
"description": "提取的发票数据JSON(完整数据)",
"visible": 0,
"searchable": 0,
"editable": 0,
"display_type": "textarea",
},
{
"column_name": "status",
"display_name": "状态",
"description": "提取状态:extracted/processed/error",
"visible": 1,
"searchable": 1,
"editable": 0,
"options_json": '[{"value":"extracted","label":"已提取"},{"value":"processed","label":"已处理"},{"value":"error","label":"错误"}]',
},
{
"column_name": "error_message",
"display_name": "错误信息",
"description": "RPA执行过程中的错误信息",
"visible": 1,
"searchable": 1,
"editable": 0,
},
{
"column_name": "created_at",
"display_name": "创建时间",
"description": "记录创建时间(Unix秒级时间戳)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
)

View File

@@ -0,0 +1,18 @@
"""RPA模块:提供发票信息提取的自动化功能。"""
from .browser_session import close_browser_context, start_browser_session
from .invoice_extractor import 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
__all__ = [
"start_browser_session",
"close_browser_context",
"login_if_needed",
"extract_invoice_list",
"extract_invoice_detail",
"format_invoice_data",
"run_invoice_rpa",
"run_invoice_rpa_with_video",
"InvoiceRpaResult",
]

View File

@@ -0,0 +1,104 @@
"""浏览器会话管理:基于skill-template的real_browser_rpa示例。"""
from __future__ import annotations
import logging
import os
import random
from typing import Any, 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"
async def start_browser_session(
profile_dir: str,
headless: bool = False,
) -> Tuple[Any, Any, Any]:
"""启动浏览器会话。
Args:
profile_dir: 浏览器配置目录
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",
],
)
# 创建新页面
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
except Exception as e:
logger.error("browser_start_failed error=%s", str(e))
raise RuntimeError(f"ERROR:MISSING_BROWSER 浏览器启动失败: {str(e)}")
async def close_browser_context(pw: Any, context: Any) -> None:
"""安全关闭浏览器上下文。"""
try:
if context:
await context.close()
if pw:
await pw.stop()
except Exception as e:
logger.warning("browser_close_error error=%s", str(e))
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":
return True
# 检查是否有加载指示器
loading_selectors = [".loading", ".spinner", "[data-loading]"]
for sel in loading_selectors:
if await page.locator(sel).count() > 0:
return False
return True
except Exception:
return False
result = await poll_until(_check_ready, timeout_sec=timeout_sec, interval_sec=0.5)
return result.ok
async def random_delay(min_sec: float = 1.0, max_sec: float = 3.0) -> None:
"""随机延迟,模拟人类操作节奏。"""
delay = random.uniform(min_sec, max_sec)
await interruptible_sleep(delay)

View File

@@ -0,0 +1,349 @@
"""发票信息提取器:从网站提取发票信息。"""
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页面对象
Returns:
发票信息列表
"""
invoices = []
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):
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 page_row=%s error=%s", i, str(e))
continue
has_next = await _has_next_page(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_invoice_detail(page: Any, invoice_index: int = 0) -> Optional[Dict[str, Any]]:
"""提取单个发票的详细信息。
Args:
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))
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
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_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
except Exception:
return []
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
except Exception:
return False
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
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
def format_invoice_data(invoices: List[Dict[str, Any]]) -> Dict[str, Any]:
"""格式化发票数据为结构化格式。"""
return {
"total_count": len(invoices),
"invoices": invoices,
"extraction_complete": True,
}

View File

@@ -0,0 +1,236 @@
"""发票RPA主流程:协调登录、导航、提取等操作。"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any, Dict, Optional
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
logger = logging.getLogger(__name__)
class InvoiceRpaResult:
"""RPA执行结果。"""
def __init__(
self,
success: bool,
data: Optional[Dict[str, Any]] = None,
error: Optional[str] = None,
stage: Optional[str] = None,
):
self.success = success
self.data = data or {}
self.error = error
self.stage = stage
def to_dict(self) -> Dict[str, Any]:
return {
"success": self.success,
"data": self.data,
"error": self.error,
"stage": self.stage,
}
@rpa_step("启动浏览器并登录")
async def _start_and_login(
profile_dir: str,
username: str,
password: str,
headless: bool = False,
) -> tuple[Any, Any, Any, bool]:
"""启动浏览器并登录。"""
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
@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("提取发票信息")
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)}
async def run_invoice_rpa(
username: str,
password: str,
profile_dir: str,
headless: bool = False,
detail_mode: bool = False,
data_dir: str = "",
batch_id: str = "",
) -> InvoiceRpaResult:
"""运行发票RPA主流程。
Args:
username: 用户名
password: 密码
profile_dir: 浏览器配置目录
headless: 是否无头模式
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] + "***", # 脱敏
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):
return InvoiceRpaResult(
success=False,
error="导航到发票页面失败",
stage="navigation",
)
# 提取发票信息
result_data = await _extract_invoices(page, detail_mode)
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(
success=False,
error=str(e),
stage="unknown",
)
finally:
# 关闭浏览器
if pw and context:
await close_browser_context(pw, context)
async def run_invoice_rpa_with_video(
username: str,
password: str,
profile_dir: str,
skill_slug: str,
skill_data_dir: str,
headless: bool = False,
detail_mode: bool = False,
title: str = "开始提取发票信息",
closing_title: str = "发票信息提取完成",
) -> InvoiceRpaResult:
"""带录屏的发票RPA主流程。"""
batch_id = uuid.uuid4().hex[:12]
async with RpaVideoSession(
skill_slug=skill_slug,
skill_data_dir=skill_data_dir,
batch_id=batch_id,
title=title,
closing_title=closing_title,
) as video:
video.add_step("准备启动浏览器")
# 运行RPA
result = await run_invoice_rpa(
username=username,
password=password,
profile_dir=profile_dir,
headless=headless,
detail_mode=detail_mode,
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

View File

@@ -0,0 +1,208 @@
"""登录处理器:处理网站登录流程。"""
from __future__ import annotations
import logging
import time
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",
],
}
# 登录成功检测
LOGIN_SUCCESS_SELECTORS = [
".user-info",
".welcome",
"[data-testid='user-avatar']",
"a[href*='logout']",
".dashboard",
]
async def login_if_needed(
page: Any,
username: str,
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)
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))
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("检测到登录成功")
return True
# 检查是否有验证码需要处理
captcha_present = await _check_captcha(page)
if captcha_present:
print("检测到验证码,请人工完成验证...")
await interruptible_sleep(2.0)
print("登录超时")
return False
async def _is_logged_in(page: Any) -> bool:
"""检查是否已登录。"""
try:
# 检查URL是否包含登录成功标识
if "/login" not in page.url and "/index" in page.url:
return True
# 检查页面元素
for selector in LOGIN_SUCCESS_SELECTORS:
try:
element = page.locator(selector)
if await element.count() > 0 and await element.is_visible():
return True
except Exception:
continue
return False
except Exception:
return False
async def _check_captcha(page: Any) -> bool:
"""检查是否有验证码。"""
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
return False
except Exception:
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

View File

@@ -0,0 +1,248 @@
"""RPA发票数据服务:处理RPA提取的发票数据存储和查询。"""
from __future__ import annotations
import json
import sqlite3
from typing import Any, Dict, List, Optional
from db.connection import get_conn
from db.display_metadata import upsert_table_metadata, seed_column_metadata_batch
from db.rpa_invoice_schema import RPA_INVOICES_DDL, RPA_INVOICES_INDEX, RPA_INVOICES_COLUMNS
from util.constants import LOG_LOGGER_NAME
from util.logging_config import get_skill_logger
def init_rpa_invoice_table() -> None:
"""初始化RPA发票数据表。"""
conn = get_conn()
try:
cursor = conn.cursor()
# 创建表
cursor.execute(RPA_INVOICES_DDL)
cursor.execute(RPA_INVOICES_INDEX)
# 添加表元数据
upsert_table_metadata(
cursor,
table_name="rpa_invoices",
display_name="RPA发票提取",
description="通过RPA从网站提取的发票数据",
grid_help="查看RPA提取的发票数据,可按批次ID筛选",
visible=1,
readonly=1,
default_sort_json='[{"column":"id","direction":"desc"}]',
default_visible_columns_json='["id","batch_id","extraction_time","username","status","created_at"]',
)
# 添加列元数据
seed_column_metadata_batch(
cursor,
table_name="rpa_invoices",
columns=RPA_INVOICES_COLUMNS,
valid_column_names=[str(c["column_name"]) for c in RPA_INVOICES_COLUMNS],
)
conn.commit()
except Exception as e:
logger = get_skill_logger()
logger.error("init_rpa_invoice_table_failed error=%s", str(e))
raise
finally:
conn.close()
def save_rpa_invoice_data(
batch_id: str,
source_url: str,
invoice_data: Dict[str, Any],
username: str = "",
status: str = "extracted",
error_message: str = "",
) -> int:
"""保存RPA提取的发票数据。
Args:
batch_id: 批次ID
source_url: 来源网址
invoice_data: 发票数据
username: 操作用户
status: 状态
error_message: 错误信息
Returns:
保存的记录ID
"""
conn = get_conn()
try:
cursor = conn.cursor()
# 将发票数据转换为JSON字符串
invoice_data_json = json.dumps(invoice_data, ensure_ascii=False)
# 插入数据
cursor.execute("""
INSERT INTO rpa_invoices (
batch_id, source_url, username, invoice_data_json, status, error_message
) VALUES (?, ?, ?, ?, ?, ?)
""", (batch_id, source_url, username, invoice_data_json, status, error_message))
record_id = cursor.lastrowid
conn.commit()
logger = get_skill_logger()
logger.info(
"rpa_invoice_saved record_id=%s batch_id=%s invoice_count=%s",
record_id,
batch_id,
invoice_data.get("total_count", 0),
)
return record_id
except Exception as e:
logger = get_skill_logger()
logger.error("save_rpa_invoice_data_failed error=%s", str(e))
raise
finally:
conn.close()
def get_rpa_invoice_data(record_id: int) -> Optional[Dict[str, Any]]:
"""获取RPA提取的发票数据。"""
conn = get_conn()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM rpa_invoices WHERE id = ?", (record_id,))
row = cursor.fetchone()
if not row:
return None
# 转换为字典
columns = [desc[0] for desc in cursor.description]
data = dict(zip(columns, row))
# 解析JSON数据
if "invoice_data_json" in data and data["invoice_data_json"]:
try:
data["invoice_data"] = json.loads(data["invoice_data_json"])
except json.JSONDecodeError:
data["invoice_data"] = {}
return data
except Exception as e:
logger = get_skill_logger()
logger.error("get_rpa_invoice_data_failed error=%s", str(e))
return None
finally:
conn.close()
def list_rpa_invoices(
limit: int = 20,
batch_id: Optional[str] = None,
username: Optional[str] = None,
status: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""查询RPA提取的发票数据列表。"""
conn = get_conn()
try:
cursor = conn.cursor()
# 构建查询条件
conditions = []
params = []
if batch_id:
conditions.append("batch_id = ?")
params.append(batch_id)
if username:
conditions.append("username = ?")
params.append(username)
if status:
conditions.append("status = ?")
params.append(status)
where_clause = " AND ".join(conditions) if conditions else "1=1"
# 执行查询
query = f"""
SELECT id, batch_id, source_url, extraction_time, username, status, error_message, created_at
FROM rpa_invoices
WHERE {where_clause}
ORDER BY id DESC
LIMIT ?
"""
params.append(limit)
cursor.execute(query, params)
rows = cursor.fetchall()
# 转换为字典列表
columns = [desc[0] for desc in cursor.description]
result = [dict(zip(columns, row)) for row in rows]
return result
except Exception as e:
logger = get_skill_logger()
logger.error("list_rpa_invoices_failed error=%s", str(e))
return []
finally:
conn.close()
def get_rpa_invoice_stats() -> Dict[str, Any]:
"""获取RPA提取统计信息。"""
conn = get_conn()
try:
cursor = conn.cursor()
# 总记录数
cursor.execute("SELECT COUNT(*) FROM rpa_invoices")
total_count = cursor.fetchone()[0]
# 按状态统计
cursor.execute("""
SELECT status, COUNT(*) as count
FROM rpa_invoices
GROUP BY status
""")
status_stats = {row[0]: row[1] for row in cursor.fetchall()}
# 最近提取记录
cursor.execute("""
SELECT id, batch_id, extraction_time, username, status
FROM rpa_invoices
ORDER BY id DESC
LIMIT 5
""")
recent_extractions = [
{
"id": row[0],
"batch_id": row[1],
"extraction_time": row[2],
"username": row[3],
"status": row[4],
}
for row in cursor.fetchall()
]
return {
"total_count": total_count,
"status_stats": status_stats,
"recent_extractions": recent_extractions,
}
except Exception as e:
logger = get_skill_logger()
logger.error("get_rpa_invoice_stats_failed error=%s", str(e))
return {"error": str(e)}
finally:
conn.close()

View File

@@ -697,3 +697,292 @@ def cmd_stats() -> int:
log.exception("task_failed task_type=stats") log.exception("task_failed task_type=stats")
print("ERROR:QUERY_FAILED 统计查询异常,详见统一日志") print("ERROR:QUERY_FAILED 统计查询异常,详见统一日志")
return 1 return 1
# ---------------------------------------------------------------------------
# RPA 命令:发票信息提取
# ---------------------------------------------------------------------------
def cmd_extract_invoices(
username: str,
password: str,
profile_dir: str,
headless: bool = False,
detail_mode: bool = False,
) -> int:
"""从网站提取发票信息。"""
log = _get_task_logger()
task_type = "extract_invoices"
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s username=%s detail_mode=%s",
task_type,
username[:3] + "***",
detail_mode,
)
emit("开始提取发票信息", skill=SKILL_SLUG, stage="rpa_start")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning(
"entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED",
task_type,
)
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
finish(
status="failed",
message=reason,
skill=SKILL_SLUG,
error_code="ENTITLEMENT_DENIED",
stage="auth",
)
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
log.info("entitlement_ok task_type=%s stage=auth", task_type)
# 导入RPA模块
from service.rpa import run_invoice_rpa_with_video
# 运行RPA
result = asyncio.run(
run_invoice_rpa_with_video(
username=username,
password=password,
profile_dir=profile_dir,
skill_slug=SKILL_SLUG,
skill_data_dir=get_skill_root(),
headless=headless,
detail_mode=detail_mode,
title="开始提取发票信息",
closing_title="发票信息提取完成",
)
)
if result.success:
invoice_count = result.data.get("total_count", 0)
log.info(
"rpa_success task_type=%s invoice_count=%s stage=extraction",
task_type,
invoice_count,
)
emit(f"成功提取 {invoice_count} 张发票信息", skill=SKILL_SLUG, stage="rpa_complete")
# 保存到数据库
try:
from service.rpa_invoice_service import save_rpa_invoice_data
batch_id = uuid.uuid4().hex[:12]
record_id = save_rpa_invoice_data(
batch_id=batch_id,
source_url="https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck",
invoice_data=result.data,
username=username,
status="extracted",
)
log.info(
"rpa_data_saved record_id=%s batch_id=%s",
record_id,
batch_id,
)
# 添加记录ID到结果中
result.data["record_id"] = record_id
result.data["batch_id"] = batch_id
except Exception as save_error:
log.warning("rpa_data_save_failed error=%s", str(save_error))
# 即使保存失败,也继续输出结果
# 输出结果
_print_json(result.data)
finish(
status="success",
message=f"成功提取 {invoice_count} 张发票信息",
skill=SKILL_SLUG,
stage="extraction",
)
return 0
else:
log.warning(
"rpa_failed task_type=%s error=%s stage=%s",
task_type,
result.error,
result.stage,
)
emit("发票信息提取失败", type="warn", skill=SKILL_SLUG, stage="rpa_failed")
finish(
status="failed",
message=result.error or "发票信息提取失败",
skill=SKILL_SLUG,
error_code="RPA_FAILED",
stage=result.stage or "unknown",
)
print(f"ERROR:RPA_FAILED {result.error}")
return 1
except JobStopped:
raise
except Exception:
log.exception(
"task_failed task_type=%s",
task_type,
)
emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="rpa_error")
finish(
status="failed",
message="发票信息提取异常,详见统一日志",
skill=SKILL_SLUG,
stage="rpa_error",
)
return 1
def cmd_list_rpa_invoices(
limit: int = 20,
batch_id: Optional[str] = None,
username: Optional[str] = None,
status: Optional[str] = None,
) -> int:
"""查询RPA提取的发票数据。"""
log = _get_task_logger()
task_type = "list_rpa_invoices"
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s limit=%s batch_id=%s username=%s status=%s",
task_type,
limit,
batch_id,
username,
status,
)
try:
from service.rpa_invoice_service import list_rpa_invoices
records = list_rpa_invoices(
limit=limit,
batch_id=batch_id,
username=username,
status=status,
)
_print_json({
"ok": True,
"count": len(records),
"records": records,
})
return 0
except Exception as e:
log.exception("task_failed task_type=%s", task_type)
print(f"ERROR:QUERY_FAILED 查询RPA提取数据失败: {str(e)}")
return 1
def cmd_show_rpa_invoice(record_id: int) -> int:
"""查看RPA提取的发票数据详情。"""
log = _get_task_logger()
task_type = "show_rpa_invoice"
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=%s record_id=%s", task_type, record_id)
try:
from service.rpa_invoice_service import get_rpa_invoice_data
record = get_rpa_invoice_data(record_id)
if not record:
print(f"ERROR:RECORD_NOT_FOUND 未找到记录 ID={record_id}")
return 1
_print_json(record)
return 0
except Exception as e:
log.exception("task_failed task_type=%s", task_type)
print(f"ERROR:QUERY_FAILED 查询RPA发票详情失败: {str(e)}")
return 1
def cmd_rpa_invoice_stats() -> int:
"""获取RPA提取统计信息。"""
log = _get_task_logger()
task_type = "rpa_invoice_stats"
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=%s", task_type)
try:
from service.rpa_invoice_service import get_rpa_invoice_stats
stats = get_rpa_invoice_stats()
_print_json(stats)
return 0
except Exception as e:
log.exception("task_failed task_type=%s", task_type)
print(f"ERROR:QUERY_FAILED 获取RPA统计失败: {str(e)}")
return 1
def cmd_test_rpa_connection(
username: str,
password: str,
profile_dir: str,
) -> int:
"""测试RPA连接和登录。"""
log = _get_task_logger()
task_type = "test_rpa_connection"
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s username=%s",
task_type,
username[:3] + "***",
)
emit("测试RPA连接", skill=SKILL_SLUG, stage="test_start")
try:
# 导入RPA模块
from service.rpa import close_browser_context, start_browser_session
from service.rpa.login_handler import login_if_needed
# 启动浏览器
pw, context, page = asyncio.run(
start_browser_session(profile_dir, headless=False)
)
# 测试登录
login_success = asyncio.run(
login_if_needed(page, username, password, wait_sec=30)
)
# 关闭浏览器
asyncio.run(close_browser_context(pw, context))
if login_success:
log.info("rpa_connection_test_success task_type=%s", task_type)
emit("RPA连接测试成功", skill=SKILL_SLUG, stage="test_success")
_print_json({"success": True, "message": "连接和登录测试成功"})
finish(status="success", message="RPA连接测试成功", skill=SKILL_SLUG, stage="test")
return 0
else:
log.warning("rpa_connection_test_failed task_type=%s", task_type)
emit("RPA连接测试失败", type="warn", skill=SKILL_SLUG, stage="test_failed")
_print_json({"success": False, "message": "登录失败"})
finish(status="failed", message="登录失败", skill=SKILL_SLUG, stage="test")
return 1
except Exception as e:
log.exception("task_failed task_type=%s", task_type)
emit("RPA连接测试异常", type="warn", skill=SKILL_SLUG, stage="test_error")
finish(status="failed", message=f"测试异常: {str(e)}", skill=SKILL_SLUG, stage="test")
return 1

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(票据合规预审助手)。""" """技能标识、版本与平台公共库约束(票据合规预审助手)。"""
SKILL_SLUG = "verify-invoice-default" SKILL_SLUG = "verify-invoice-default"
SKILL_VERSION = "1.0.1" SKILL_VERSION = "1.0.2"
LOG_LOGGER_NAME = "openclaw.skill.verify_invoice_default" LOG_LOGGER_NAME = "openclaw.skill.verify_invoice_default"
PLATFORM_KIT_MIN_VERSION = "1.2.11" PLATFORM_KIT_MIN_VERSION = "1.2.11"

93
test_rpa.py Normal file
View File

@@ -0,0 +1,93 @@
#!/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()