init autotrain project
This commit is contained in:
commit
2648440f22
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
config.local.json
|
||||
archive/
|
||||
data/input/
|
||||
data/mail/
|
||||
data/attachments/
|
||||
output/
|
||||
work/
|
||||
112
AGENTS.md
Normal file
112
AGENTS.md
Normal file
@ -0,0 +1,112 @@
|
||||
---
|
||||
trio: standard-v2
|
||||
trio-initialized: 2026-06-08
|
||||
---
|
||||
|
||||
# 铁路系统发票 · Agent 操作守则
|
||||
|
||||
> **上来先读这份**,再看 [`INDEX.md`](./INDEX.md) 找模块和导航。
|
||||
>
|
||||
> **通用三件套协议**见 [`../docs/trio-protocol.md`](../docs/trio-protocol.md)(文档维护节奏 / Handoff 写入 / 子项目嵌套 / 记忆三条线边界 / 语言规则 / 跨项目反例)。**本文件只列本项目专属守则**。
|
||||
>
|
||||
> **`trio: standard-v2`** = 本项目按当前标准维护三件套。
|
||||
|
||||
## 这是什么项目
|
||||
|
||||
铁路系统发票专项工作区,用于沉淀铁路系统相关发票资料、处理脚本、核对记录和衍生产出。
|
||||
|
||||
### 核心愿景(Core Vision)
|
||||
|
||||
**最终目标**:用户在 Web UI 上操作,脚本全自动完成开票、状态记录、结果文档输出。
|
||||
|
||||
**技术路线**:
|
||||
- **截屏驱动**:ADB 截屏 → 视觉 LLM(Qwen3-VL via SiliconFlow)识别页面元素、脱敏姓名、按钮位置
|
||||
- **脚本编排**:视觉 LLM 返回归一化 bbox(0-1000 坐标系)→ 脚本判断当前页面状态 → 决定下一步动作(tap / scroll / back)
|
||||
- **UI 配合**:Web UI 负责触发、监控进度、展示结果;脚本在子进程中运行,通过 CSV 文件与 UI 交换状态
|
||||
- **对话调试**:James 和 Claude Code 通过实际运行观察手机行为,持续修正脚本的判断逻辑和边界条件
|
||||
|
||||
**当前阶段**:H5 扫码开票单页面自动化。页面结构:单个开票单下挂 N 个乘客(脱敏姓名 + 身份证类型 + 脱敏身份证号 + 「开具」按钮),需要逐人点击「开具」走完子流程,记录每个乘客的开票结果。
|
||||
|
||||
**已完成的路径**(保留不动):
|
||||
1. 原生列表页视觉驱动批量(`android-vision-page-run`):截图 → list-rows 识行 → anchor 去重 → 点击 → 核验/发票管理子流程
|
||||
2. 原生列表页 XML 驱动(`android-page-run`):uiautomator dump → 解析 XML → 匹配 text/bounds
|
||||
|
||||
## 上手三步
|
||||
|
||||
1. 读 [`INDEX.md`](./INDEX.md),看项目结构和子模块导航。
|
||||
2. 找到目标模块目录,**先读它的本地文档**(如 `<module>/README.md` 或 `<module>/AGENTS.md` 若存在)。
|
||||
3. 看根目录或模块里有没有脚本入口、配置文件、环境变量样例。
|
||||
|
||||
## 项目专属硬规则
|
||||
|
||||
- **不要随手改 `.env` / 凭证 / `settings.json`**:敏感配置由项目所有者维护。
|
||||
- **不要主动删除文件**:废弃 / 旧版本 / 半成品请移动到 `archive/` 或 `不加载/` 这类约定目录,不要 `rm`。
|
||||
- **不要重命名公共接口、路由、对外 API 字段**:除非明确授权,这些是契约。
|
||||
- **涉及发票、金额、税号、单位名称等字段时先保留原始值**:规范化和清洗产物另存,不覆盖来源资料。
|
||||
- **改动前确认是否有依赖你正在改的代码的其他模块**:先 `grep` 引用再下手。
|
||||
|
||||
## 目录命名约定
|
||||
|
||||
| 子目录 | 用途 |
|
||||
|---|---|
|
||||
| `data/` | 本项目自有数据与导入材料 |
|
||||
| `scripts/` | 可执行脚本 |
|
||||
| `src/` 或 `lib/` | 主代码 |
|
||||
| `tests/` | 测试 |
|
||||
| `docs/` | 详细文档 |
|
||||
| `assets/` | 静态素材 |
|
||||
| `templates/` | 模板文件 |
|
||||
| `archive/` 或 `不加载/` | 归档区,不参与构建 |
|
||||
|
||||
## 项目专属"不要做的事"
|
||||
|
||||
- ❌ 删除文件(应该 `mv` 到归档目录)
|
||||
- ❌ 自动提交 secrets / 凭证
|
||||
- ❌ 覆盖原始发票资料或人工核对记录
|
||||
- ❌ 未经确认修改发票字段含义、金额口径、税率口径
|
||||
|
||||
## 架构与关键依赖
|
||||
|
||||
> 本节是给 Claude Code 看的"big picture"。具体命令清单在 `INDEX.md` 的"常用操作"段;详细用法在 `docs/usage.md`。这里只写**读多个文件才能拼出来的事**。
|
||||
|
||||
### 四个脚本的分工
|
||||
|
||||
`scripts/` 下没有 `src/lib`,所有逻辑都在四个独立 Python 文件里,彼此通过文件(CSV / 配置 / 截图)耦合,而不是 import:
|
||||
|
||||
| 脚本 | 角色 | 入口形式 |
|
||||
|---|---|---|
|
||||
| `invoice_tool.py` | 主 CLI,~1700 行,argparse 多子命令。覆盖:台账 / 名单 / 邮件归集 / 网页辅助页 / Android ADB 自动化 / 视觉驱动批量。所有路径常量、`run_adb`、`Config` 都在这里 | `python3 scripts/invoice_tool.py <subcmd>` |
|
||||
| `web_ui.py` | 本地 HTTP 服务(端口 8765),`import invoice_tool` 复用其常量和 `run_adb`,把批处理包成子进程跑 | `python3 scripts/web_ui.py` |
|
||||
| `vision_fallback.py` | 视觉调用层:adb 截屏 → base64 → 调 SiliconFlow Qwen3-VL grounding API → 解析归一化 bbox | `python3 scripts/vision_fallback.py {ping,detect-page,ask,list-rows}` |
|
||||
| `record_step.py` | 录制工具:`adb uiautomator dump` + `screencap` 落盘到 `work/replay/<session>/<NN_label>/`,用于"人工演示 → 反推脚本" | `python3 scripts/record_step.py "label" [--new] [--list]` |
|
||||
|
||||
`web_ui.py` 当前调用的是旧路径 `android-page-run`(XML/文字匹配),**没有接** `android-vision-page-run`。如果改 Web UI 的"开始运行"按钮,注意切换的是 `invoice_tool.py` 的子命令名。
|
||||
|
||||
### 两条 Android 自动化路径并存
|
||||
|
||||
12306 App 是脱敏姓名(`张*岩`)+ 输入身份证后 8 位的开票流程。代码里有两套实现,**都保留**:
|
||||
|
||||
1. **旧:`android-page-run` / `android-page-step`** — 走 `uiautomator dump` 读 XML,按 text / bounds 匹配。问题是 12306 列表页很多控件没有 text,全靠坐标。
|
||||
2. **新:`android-vision-page-run`** — 截屏交给 Qwen3-VL,模型返回归一化 bbox(**0-1000 坐标系**,不是像素),脚本再 denormalize 到屏幕像素后 tap。流程:截图 → `vision_fallback.list-rows` 识所有可见行 → anchor 去重防滑动后重复 tap → 子流程(核验 / 发票管理 / 成功页)走旧的状态机。
|
||||
|
||||
### 配置与外部依赖
|
||||
|
||||
- **ADB 路径硬编码**:`invoice_tool.py:39` 写死 `/opt/homebrew/share/android-commandlinetools/platform-tools/adb`(Apple Silicon Homebrew)。换机器或换 Intel Mac 要改这里。
|
||||
- **`config.local.json`**:除 `company_title / tax_id / receiver_email`(`config.example.json` 已示范)外,**视觉路径还需要一个 `vision` 段**:`base_url` / `api_key` / `model`(当前用 SiliconFlow + `Qwen/Qwen3-VL-8B-Instruct`,详见 memory `reference_siliconflow_qwen_vl.md`)。`vision_fallback.load_vision_config` 缺任一键直接抛错。
|
||||
- `config.local.json`、`work/`、`data/input|mail|attachments/`、`output/` 全在 `.gitignore` 里。
|
||||
|
||||
### 关键工作文件(都在 `work/`,CSV,UTF-8-BOM)
|
||||
|
||||
| 文件 | 内容 | 写入者 |
|
||||
|---|---|---|
|
||||
| `passenger_roster.csv` | 名单:姓名 / 身份证号 / 后 8 位 / 来源 | `import-roster` |
|
||||
| `invoice_tasks.csv` | 开票码主台账 | `import-codes` |
|
||||
| `android_invoice_progress.csv` | 逐人处理结果(masked_name / real_name / id_last8 / status) | Android 子命令 + Web UI 读 |
|
||||
| `android_page_queue.csv` | 当前可见页的待点击队列 | `android-build-page-queue` |
|
||||
| `replay/<session>/` | record_step.py 的演示快照(ui.xml + screen.png + texts.txt + meta.json) | `record_step.py` |
|
||||
|
||||
CSV header 常量都集中在 `invoice_tool.py` 顶部(`TASK_HEADERS` / `ROSTER_HEADERS` / `PROGRESS_HEADERS` / `PAGE_QUEUE_HEADERS`)。改字段要同步改这里 + 所有读它的子命令。
|
||||
|
||||
### 测试
|
||||
|
||||
`tests/` 当前是空目录。没有单测 / 集成测试框架;验证靠 `--dry-run` 子命令 + 真机跑 + `work/replay/` 录制比对。新增功能不强求加测试,但如果改了 CSV schema 或 ADB 命令组合,至少跑一次 `--dry-run --max-actions 1`。
|
||||
173
CHANGELOG.md
Normal file
173
CHANGELOG.md
Normal file
@ -0,0 +1,173 @@
|
||||
## 2026-06-11 #fix scope:android-h5-automation - 下载后自动退出发票预览页
|
||||
|
||||
- Why: 下载确认后会停在预览页,需要稳定回到成功页/列表继续下一人
|
||||
- 详见: scripts/invoice_tool.py:1004, scripts/invoice_tool.py:1892
|
||||
|
||||
## 2026-06-11 #fix scope:android-h5-automation - 下载发票文件名带真实姓名
|
||||
|
||||
- Why: 仅用脱敏名无法区分和归档,本地 PDF 需要直接体现发票归属人
|
||||
- 详见: scripts/invoice_tool.py:1919
|
||||
|
||||
## 2026-06-11 #fix scope:android-h5-automation - 处理发票下载完成确认弹窗
|
||||
|
||||
- Why: PDF 已保存后卡在下载进度 100% 弹窗,导致无法自动回到列表
|
||||
- 详见: scripts/invoice_tool.py:1004, scripts/invoice_tool.py:1886
|
||||
|
||||
## 2026-06-11 #fix scope:web-ui - 保存目录输入不再被自动刷新覆盖
|
||||
|
||||
- Why: UI 状态轮询会重置正在编辑的目录,导致用户无法自定义保存位置
|
||||
- 详见: scripts/web_ui.py:481, scripts/web_ui.py:696
|
||||
|
||||
## 2026-06-11 #feat scope:android-h5-automation scope:web-ui - 成功页支持下载发票到本地
|
||||
|
||||
- Why: 后续需要保留邮箱流程,同时默认把发票文件直接沉淀到电脑目录
|
||||
- 详见: scripts/invoice_tool.py:959, scripts/web_ui.py:212
|
||||
|
||||
## 2026-06-11 #fix #perf scope:android-h5-automation scope:web-ui - 修复核验同名歧义、优化续跑锚点、优化图库选码
|
||||
|
||||
- Why: UI 跑流程经常报错,需先验证 CLI、再固化续跑断点、再优化重试顺序
|
||||
- 详见: scripts/invoice_tool.py:402, scripts/invoice_tool.py:1485, scripts/web_ui.py:899
|
||||
|
||||
# 铁路系统发票 · CHANGELOG
|
||||
|
||||
> 每次动了什么记一条。详细记录写在各自模块目录下,根目录 CHANGELOG 是**强标签化的检索索引**。
|
||||
>
|
||||
> **如本项目下有子项目**(子目录里也有 AGENTS/INDEX/CHANGELOG 三件套):本 CHANGELOG **只记录跨多个子项目的同时操作**;单一子项目操作记在该子项目自己的 CHANGELOG 里。详见 `../docs/trio-protocol.md`。
|
||||
|
||||
## 格式规范(严格)
|
||||
|
||||
```text
|
||||
## YYYY-MM-DD #<type> scope:<name> [#<extra-tag>...] - <一句话主题>
|
||||
|
||||
- Why: <一句话动机,不复述 what>
|
||||
- 详见: <path 或 commit hash>
|
||||
```
|
||||
|
||||
**硬约束**:
|
||||
- 日期必须 ISO 格式 `YYYY-MM-DD`
|
||||
- 类型标签必须以 `#` 开头,从下面字典选一个为主标签
|
||||
- 作用域必须 `scope:<name>` 形式,name 用 kebab-case;多模块改动用多个 `scope:`
|
||||
- Why 一行不超过 80 字符
|
||||
- **不贴 diff、不复述 what**,那些进 commit 或模块自己的文档
|
||||
|
||||
## 类型标签字典
|
||||
|
||||
| 标签 | 含义 |
|
||||
|---|---|
|
||||
| `#feat` | 新功能 |
|
||||
| `#fix` | bug 修复 |
|
||||
| `#refactor` | 重构(无行为变化) |
|
||||
| `#perf` | 性能优化 |
|
||||
| `#docs` | 文档变更 |
|
||||
| `#test` | 测试相关 |
|
||||
| `#chore` | 构建/依赖/工具链/初始化 |
|
||||
| `#archive` | 归档/弃用 |
|
||||
| `#breaking` | 破坏性变更(叠加) |
|
||||
| `#deprecated` | 标记弃用(叠加) |
|
||||
| `#wip` | 进行中(叠加) |
|
||||
|
||||
## 检索示例
|
||||
|
||||
```bash
|
||||
grep -E "^## .* #feat .* scope:invoice" CHANGELOG.md
|
||||
grep "#breaking" CHANGELOG.md
|
||||
grep "^## 2026-06" CHANGELOG.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-09 #feat scope:android-automation #scope:vision-fallback - 视觉驱动批量开票路径打通
|
||||
|
||||
- Why: 用户改路线,手动扫码进列表 → 视觉抓行 → anchor 去重 → 按页批次开票
|
||||
- 详见: scripts/invoice_tool.py 新增 `android-vision-page-run` 命令(并行存在,不动老路径);scripts/vision_fallback.py 新增 `list-rows` 子命令 + 0-1000 归一化反推
|
||||
- docs/debug-trace-2026-06-09.md 全程实测时间线
|
||||
|
||||
## 2026-06-09 #chore scope:vision-fallback - 换用硅基流动 Qwen3-VL-8B-Instruct
|
||||
|
||||
- Why: 豆包 Doubao-Seed-2.0-mini OCR 准但 grounding 失败(返坐标完全猜的);Qwen3-VL 系列做过 grounding 训练,实测 bbox 误差 <25px
|
||||
- 详见: config.local.json provider 改 siliconflow,model Qwen/Qwen3-VL-8B-Instruct;费用估算 21-25 次/全程 ≈ ¥0.05-0.1
|
||||
|
||||
## 2026-06-09 #fix scope:android-automation - 兜底 back 退过头 + 2 字脱敏名正则失配
|
||||
|
||||
- Why: no_match 名 tap 后核验弹窗解析失败,旧 back+back 兜底把列表页一起退到 12306 首页
|
||||
- 详见: invoice_tool.py 核验弹窗正则 `{1,3}`→`{0,3}` 兼容澳*/吴* 类两字名;safe_back_until_list 加首页检测 + 单次 back 后 1.5s 等待,外层 abort 闸门避免误操作
|
||||
|
||||
## 2026-06-09 #feat scope:replay - 录制工具 record_step.py
|
||||
|
||||
- Why: 视觉模型坐标精度够但子流程边界(2 字名 / 已开过 / 已退 H5)边界 case 多,人工示范+录制比盲调更快
|
||||
- 详见: scripts/record_step.py — `python3 scripts/record_step.py "label"` 当前 session 累加;`--new` 开新 session
|
||||
|
||||
## 2026-06-09 #feat scope:vision-fallback - 接入火山豆包视觉模型作为 adb dump 兜底
|
||||
|
||||
- Why: adb 节点解析对 H5 内嵌 UI / 弹窗状态不稳,加视觉 LLM 二次确认
|
||||
- 详见: scripts/vision_fallback.py (detect-page / ask / ping),config.local.json vision 段;模型 doubao-seed-1-6-flash-250615
|
||||
|
||||
## 2026-06-09 #feat scope:web-ui - 开始运行流程改成"先回顶 + 截屏建队列 + 才操作"
|
||||
|
||||
- Why: 原流程开始即翻页,用户没看到本页识别结果就直接 swipe,体验跟"卡死"无异
|
||||
- 详见: scripts/web_ui.py start_runner 前置同步两步骤;新 UI 复选框"开始前回顶"默认勾选
|
||||
|
||||
## 2026-06-09 #feat scope:android-automation - 翻页到底 fingerprint 检测 + 回顶命令
|
||||
|
||||
- Why: swipe 后页面没变化以前会无限滑;新加 android-page-top 让人能从底部回到列表顶端开干
|
||||
- 详见: scripts/invoice_tool.py android_page_top / android_page_step _last_done_fp 状态比对
|
||||
|
||||
## 2026-06-09 #fix scope:android-automation - 运行时异常软失败,不整体崩 runner
|
||||
|
||||
- Why: 单个人开票流程出错以前会让整批停掉,无人值守语义不成立
|
||||
- 详见: scripts/invoice_tool.py mark_clicked_as_runtime_error / android_page_run try-except
|
||||
|
||||
## 2026-06-09 #feat scope:android-automation - 本页 retry 一次 + 身份证首末位增强匹配
|
||||
|
||||
- Why: 用户要求整页跑完后失败者再走一遍才翻页;同名歧义用脱敏身份证首末位解掉
|
||||
- 详见: scripts/invoice_tool.py build_visible_page_queue / match_roster / RETRYABLE_FAILURES
|
||||
|
||||
## 2026-06-09 #fix scope:web-ui - 启动命令默认带 --auto-next 避免整页 skip 时空转
|
||||
|
||||
- Why: 当前页全 skip_progress / no_match 时不上滑下一页,20 步连续 page_done 无进展
|
||||
- 详见: scripts/web_ui.py start_runner
|
||||
|
||||
## 2026-06-08 #feat scope:web-ui - 增加本地批处理进度页面
|
||||
|
||||
- Why: 50 人名单批处理需要可视化上传入口、实时状态和逐人结果表
|
||||
- 详见: scripts/web_ui.py / scripts/invoice_tool.py / docs/usage.md
|
||||
|
||||
## 2026-06-08 #feat scope:android-automation - 增加扫码列表页队列和翻页处理
|
||||
|
||||
- Why: 列表页开具按钮不是固定坐标,需要按当前页姓名和 bounds 生成队列逐个处理
|
||||
- 详见: scripts/invoice_tool.py / docs/usage.md
|
||||
|
||||
## 2026-06-08 #feat scope:android-automation - 增加 12306 单步开票状态机
|
||||
|
||||
- Why: 截图逐页校准成本高,需要用 XML 状态识别减少人工判断和 token 消耗
|
||||
- 详见: scripts/invoice_tool.py / docs/usage.md
|
||||
|
||||
## 2026-06-08 #feat scope:android-automation - 增加 Android 端身份证后 8 位自动填入
|
||||
|
||||
- Why: 12306 App 扫码后需按脱敏姓名匹配名单并填写身份证后 8 位,人工逐个填写太慢
|
||||
- 详见: scripts/invoice_tool.py / templates/android_flow.example.json / docs/usage.md
|
||||
|
||||
## 2026-06-08 #fix scope:batch-invoice - 识别 PNG 扫码开票单为 App 扫码流程
|
||||
|
||||
- Why: 真实铁路扫码开票单截图要求 12306App 扫码,不能误判为网页自动开票
|
||||
- 详见: scripts/invoice_tool.py / work/invoice_tasks.csv / docs/usage.md
|
||||
|
||||
## 2026-06-08 #feat scope:batch-invoice - 落地铁路发票批量处理本地工具
|
||||
|
||||
- Why: 普通代购票缺统一批量开票入口,需要用官方页面配合本地台账和收票归集降本
|
||||
- 详见: scripts/invoice_tool.py / docs/usage.md / INDEX.md
|
||||
|
||||
## 2026-06-10 #feat scope:h5-automation scope:web-ui - H5扫码开票单视觉驱动批量 + 优雅退出 + UI模式切换
|
||||
|
||||
- Why: H5单页挂N个乘客,back重置scroll;手机被占用(微信来电)时常发生,需优雅退出而非崩溃
|
||||
- 详见: scripts/invoice_tool.py android-h5-page-run + _h5_scroll_down;scripts/web_ui.py mode=h5/h5+模式选择器;三处优雅退出(PREECHECK_FAILED/PAGE_AWAY/sub-flow无法回列表)
|
||||
|
||||
## 2026-06-10 #feat scope:web-ui scope:qr-decode - 粘贴二维码截图自动解码 + ADB 在手机打开
|
||||
|
||||
- Why: 用户拿到开票二维码截图后不用再手动扫,贴到 UI 自动解码→在手机打开对应页面
|
||||
- 详见: scripts/invoice_tool.py decode_qr_image(OpenCV QRCodeDetector);scripts/web_ui.py /api/decode-qr /api/open-on-phone
|
||||
|
||||
## 2026-06-08 #chore scope:init - 项目初始化
|
||||
|
||||
- Why: 新专项需要独立工作区和三件套入口,便于后续发票资料处理与核对
|
||||
- 详见: AGENTS.md / INDEX.md / 本文件
|
||||
123
INDEX.md
Normal file
123
INDEX.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 铁路系统发票
|
||||
|
||||
铁路系统发票专项工作区,用于沉淀铁路系统相关发票资料、处理脚本、核对记录和衍生产出。
|
||||
|
||||
> 🤖 Agent 上手先读 [`AGENTS.md`](./AGENTS.md) 的操作守则(通用协议在 [`../docs/trio-protocol.md`](../docs/trio-protocol.md));改动后记得追加 [`CHANGELOG.md`](./CHANGELOG.md)(强标签格式见文件顶部)。
|
||||
|
||||
<!--
|
||||
## 当前接力点 (Handoff)
|
||||
|
||||
**未完成续跑逻辑调整**:当前锚点已识别正确(王*凝),但需要调整为「先处理当前页可见、跳过已完成、优先失败项」而不是「只处理锚点之后」。
|
||||
|
||||
### 当前状态盘点
|
||||
- ✅ CLI 健全:validate 通过,dry-run 续跑锚点生效
|
||||
- ✅ 核验弹窗同名歧义已修复:优先用进度表 in_progress 精确身份
|
||||
- ✅ QR 重推已修复:UI 粘贴后自动推新图到手机图库
|
||||
- ✅ 续跑锚点已固化:从最后更新人后开始,不会从顶部旧失败项抢跑
|
||||
- 🚧 调整中:当前页处理顺序(失败优先、跳过已完成)
|
||||
|
||||
### 下次上手
|
||||
1. 确认手机当前在 12306 H5 开票列表,不要停在业务须知页
|
||||
2. 如果不在列表页,用 UI 粘贴正确的二维码重新进入
|
||||
3. 调整续跑逻辑:当前页优先失败项重试、跳过已完成、再处理未处理
|
||||
4. 小范围验证后再批量跑
|
||||
|
||||
### 关键指针
|
||||
- work/android_invoice_progress.csv:39 锚点为姚*文(最新 completed)
|
||||
- scripts/invoice_tool.py:1551 续跑锚点过滤逻辑
|
||||
- scripts/invoice_tool.py:1543 失败重试逻辑
|
||||
|
||||
## 当前接力点 (Handoff)
|
||||
|
||||
**H5 全自动开票已跑通 18 人,剩余待处理约 9 人。等待时间已优化,下次直接继续跑即可**(2026-06-10)。
|
||||
|
||||
### 状态盘点
|
||||
|
||||
- ✅ H5 全流程跑通:截屏识行 → 逐人开具 → 7状态机子流程 → back回列表 → 滚动
|
||||
- ✅ 四级恢复链:sub-flow error → safe_back → 说明页检测 → auto_scan(扫码→相册→选QR)→回列表
|
||||
- ✅ XML+视觉渐进 fallback:`_xml_or_vision_click()` 三级查找覆盖所有 H5 按钮
|
||||
- ✅ 邮件死循环已修:`completed` 标记移到发邮件之前
|
||||
- ✅ 视觉坐标反归一化已修:`px = norm * width / 1000`
|
||||
- ✅ 法条说明页误判已修:`is_on_instructions_page()` 排除
|
||||
- ✅ 等待时间优化:short_wait=1s, wait_primary=5s, wait_secondary=10s
|
||||
- ✅ Web UI 功能:粘贴QR解码、模式切换(H5/列表)、视觉刷新当前页、手动标记完成
|
||||
|
||||
### 下次上手
|
||||
|
||||
1. `python3 scripts/web_ui.py &` 启动UI
|
||||
2. 浏览器 `http://127.0.0.1:8765`,选「H5开票单」模式
|
||||
3. 如果手机不在列表页 → 点「重新进入开票页」或直接点「开始运行」(自动恢复)
|
||||
4. 点「开始运行」继续处理剩余人
|
||||
|
||||
### 关键文件
|
||||
|
||||
- `scripts/invoice_tool.py`: `android-h5-page-run`、`auto_scan_qr_to_list`、`_xml_or_vision_click`、`_vision_find_button` 等
|
||||
- `scripts/web_ui.py`: 模式切换 + 视觉刷新 + 手动标记
|
||||
- `scripts/vision_fallback.py`: list-rows / ask / ping(带 --image)
|
||||
- `work/android_invoice_progress.csv`: 36人进度
|
||||
- `work/last_qr_url.txt`: 已保存的 QR URL
|
||||
- `work/qr_code.png`: 已生成的 QR 图片(用于 auto_scan)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
铁路系统发票/
|
||||
├── AGENTS.md
|
||||
├── CHANGELOG.md
|
||||
├── CLAUDE.md -> AGENTS.md
|
||||
├── INDEX.md
|
||||
├── config.example.json
|
||||
├── config.local.json(本地自建,忽略提交)
|
||||
├── data/
|
||||
│ ├── attachments/
|
||||
│ ├── input/开票码批次/
|
||||
│ └── mail/
|
||||
├── docs/
|
||||
│ └── usage.md
|
||||
├── output/
|
||||
├── scripts/
|
||||
│ └── invoice_tool.py
|
||||
├── templates/
|
||||
│ └── android_flow.example.json
|
||||
└── work/
|
||||
```
|
||||
|
||||
## 子模块导航
|
||||
|
||||
(本项目暂无子目录)
|
||||
|
||||
## 常用操作
|
||||
|
||||
```bash
|
||||
# 初始化目录和配置模板
|
||||
python3 scripts/invoice_tool.py init
|
||||
cp config.example.json config.local.json
|
||||
|
||||
# 导入二维码/手工码生成开票任务
|
||||
python3 scripts/invoice_tool.py import-codes
|
||||
|
||||
# 导入名单并匹配身份证后 8 位
|
||||
python3 scripts/invoice_tool.py import-roster --input /path/to/名单.xlsx
|
||||
python3 scripts/invoice_tool.py match-roster --masked-name '张*岩'
|
||||
|
||||
# Android 页面读取和身份证后 8 位输入
|
||||
python3 scripts/invoice_tool.py android-dump --output work/android_page.txt
|
||||
python3 scripts/invoice_tool.py android-type-id --tap 500,1200
|
||||
|
||||
# 本地 Web UI:上传名单、看进度、启动/停止批处理
|
||||
python3 scripts/web_ui.py
|
||||
|
||||
# 生成网页辅助开票页
|
||||
python3 scripts/invoice_tool.py rpa-plan
|
||||
|
||||
# 归集邮件/附件并导出汇总
|
||||
python3 scripts/invoice_tool.py collect-mail
|
||||
python3 scripts/invoice_tool.py collect-attachments
|
||||
python3 scripts/invoice_tool.py export-summary
|
||||
```
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 📓 演绎记录:[CHANGELOG.md](./CHANGELOG.md)
|
||||
- 🤖 Agent 守则:[AGENTS.md](./AGENTS.md)
|
||||
- 使用说明:[docs/usage.md](./docs/usage.md)
|
||||
7
config.example.json
Normal file
7
config.example.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"company_title": "请填写公司全称",
|
||||
"tax_id": "请填写统一社会信用代码",
|
||||
"receiver_email": "invoice@example.com",
|
||||
"invoice_success_action": "download",
|
||||
"invoice_output_dir": "output/invoices"
|
||||
}
|
||||
208
docs/debug-trace-2026-06-09.md
Normal file
208
docs/debug-trace-2026-06-09.md
Normal file
@ -0,0 +1,208 @@
|
||||
# 调试跟踪 · 2026-06-09 实测
|
||||
|
||||
> 真机调试现场记录。按时间顺序追加,**不要回头改老条目**——错就再写一条新的把前条作废。
|
||||
> 命名规则:`docs/debug-trace-YYYY-MM-DD.md`,一天一份。
|
||||
|
||||
## 环境快照
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 日期 | 2026-06-09 |
|
||||
| Web UI | `http://127.0.0.1:8765` (PID 71108,启动 01:02) |
|
||||
| Android 设备 | `10ADCX2GUK002A8` (已 `adb devices` 确认) |
|
||||
| 名单总数 | 149 |
|
||||
| 当前进度 | 5 完成 / 3 失败 / 141 待办 |
|
||||
| 日志路径 | `work/web_runner.log` · `work/web_ui.log` |
|
||||
|
||||
## 验证目标(来自 Handoff)
|
||||
|
||||
1. 新版"开始运行"流程:回顶 → 建队列 → page-run --auto-next
|
||||
2. 翻页是否会在真到底时停在 `stop_reason=bottom_reached`,不再无限滑
|
||||
3. 决定 `scripts/vision_fallback.py` 接入到哪些叉路:
|
||||
- (a) `page_bottom_reached` 之前先视觉确认
|
||||
- (b) 未知页面状态异常时视觉判断
|
||||
- (c) 关键按钮 adb dump 找不到时视觉给坐标
|
||||
|
||||
## 时间线
|
||||
|
||||
### [14:33] 现场盘点
|
||||
|
||||
- Web UI 健康,HTTP 200。`work/web_runner.log` 只有 prelude 两段:
|
||||
- `android-page-top rc=0 / at_top iterations=2`
|
||||
- `android-build-page-queue rc=0 / rows=7 pending=0`
|
||||
- 顶页 7 行全部 skipped:3 个 `skip_progress`(程*桐 / 朱*玉 / 王*平,已开过)+ 4 个 `no_match`(张*丽 / 李*国 / 王*楠 / 陈*胜,名单匹配不上)
|
||||
- runner 子进程已不在(`ps -ef | grep invoice_tool` 空)。Web UI 重启后内存里的 `RUNNER` 是 None,前次留下的日志是孤儿。
|
||||
- 结论:**需要重新触发一次"开始运行",从顶部跑起,观察后续翻页**。
|
||||
|
||||
### 待办清单
|
||||
|
||||
- [ ] 调 `/api/start` 触发完整流程
|
||||
- [ ] tail -f `work/web_runner.log` 观察 `page_done` / `page_open` / `page_bottom_reached` 是否按预期出现
|
||||
- [ ] 真到底时是否停在 `stop_reason=bottom_reached`
|
||||
- [ ] 出现需要 fallback 的卡点时,跑 `python3 scripts/vision_fallback.py detect-page` 抓 JSON
|
||||
|
||||
### [14:37] 从零开始 · 清空工作态
|
||||
|
||||
用户决定从零起测,流程**从 UI 上传名单**开始。
|
||||
|
||||
把 `work/` 下 7 个状态文件 `mv` 到 `archive/reset_20260609_<ts>/`(**遵循"不 rm,只归档"项目规则**):
|
||||
|
||||
| 文件 | 用途 |
|
||||
|---|---|
|
||||
| `passenger_roster.csv` | 名单 |
|
||||
| `android_invoice_progress.csv` | 进度 |
|
||||
| `android_page_queue.csv` | 当前页队列 |
|
||||
| `web_runner.log` | runner 日志 |
|
||||
| `web_ui.log` | UI 日志 |
|
||||
| `invoice_tasks.csv` | 任务清单 |
|
||||
| `last_screenshot.png` | 最近截屏 |
|
||||
|
||||
保留 `work/android_*.txt`(纯调试 dump 快照,不影响行为)。
|
||||
|
||||
清空后 `/api/status` 响应:
|
||||
- `counts: {total:0, completed:0, failed:0, in_progress:0, pending:0}` ✓
|
||||
- `roster: []` ✓
|
||||
- `runner.status: failed`(残留 RUNNER 指针指向上次的死子进程,无害,下次 start 会覆盖)
|
||||
|
||||
**下一步**:等用户在浏览器里 `http://127.0.0.1:8765` 点"上传名单"导入 xlsx,再观察 `/api/status` 是否吃进 149 行。
|
||||
|
||||
### [14:55] 方向大调整 · 改走视觉驱动
|
||||
|
||||
用户暂停传统路径,提新方案:
|
||||
- 手机端**用户手动**扫码进入开票界面(不需要程序回顶)
|
||||
- 点"开始"→ **直接截图 → 视觉识别**当前页所有可见乘客行 → 按页为批次开票
|
||||
- 一批完后**滑动**,**保证至少 1 行重叠**,视觉识别新页 → 去掉重叠 → 下一批
|
||||
- 一直滑到底
|
||||
|
||||
锁定决策(三问三答):
|
||||
1. 子流程(核验/提交/邮件)→ **保留 adb XML 驱动**(视觉只用列表页 + 翻页)
|
||||
2. 翻页去重 → **anchor 驱动**(记上批最后一行 masked_name,新截图视觉找位置,以下为新行)
|
||||
3. 前置自检 → **做**(调一次 detect-page 要 `page=list`)
|
||||
|
||||
我自定的小决策:
|
||||
- `button_bbox` 取中心 tap
|
||||
- 截图原始像素传给豆包,要求按真实坐标返
|
||||
- 批次内失败重试 1 次,翻页后不回头
|
||||
- 老的 `android-page-run` 路径保留并行
|
||||
|
||||
### [15:00] 步骤① 视觉 list-rows 接口已加
|
||||
|
||||
`scripts/vision_fallback.py` 新增 `list-rows` 子命令:
|
||||
- 截图 → 读 PNG header 提取真实像素 width/height → 塞给 prompt
|
||||
- prompt 要求豆包返回 `{rows:[{masked_name, id_first, id_last, button_bbox:[x1,y1,x2,y2]}], scroll_position, page_ok}`
|
||||
- 增加 `parse_json_lenient`,容错 markdown 代码块包裹
|
||||
|
||||
下一步:跑 `python3 scripts/vision_fallback.py list-rows` 抽样验证 bbox 准确性,再做步骤②。
|
||||
|
||||
### [15:03] 步骤③ dry-run 失败 · 豆包 key 401
|
||||
|
||||
`list-rows` 调用返回 401 AuthenticationError(`The API key doesn't exist`)。
|
||||
昨晚 handoff 明确说"测完明天记得把它置空或撤销"—— 用户应该已撤销。
|
||||
|
||||
**阻塞**:需要用户在 `config.local.json` 重填 `vision.api_key` 才能继续视觉路径验证。
|
||||
|
||||
### [15:18] 模型切换 · Doubao-Seed-2.0-mini
|
||||
|
||||
用户提供新 key,授权范围 `Doubao-Seed-2.0-mini`。查方舟 `/models`:
|
||||
- 实际 model id:`doubao-seed-2-0-mini-260428`(2026-04 版,带 audio 模态最新版)
|
||||
- 状态 Active,modalities 含 image
|
||||
- 之前认为应该用 `doubao-seed-1-6-vision-250815`,但用户 key 限定 mini → 改用 mini
|
||||
|
||||
更新 `config.local.json`:
|
||||
- `vision.api_key` → 新 key
|
||||
- `vision.model` → `doubao-seed-2-0-mini-260428`
|
||||
|
||||
### [15:21] 通路验证 OK,但手机黑屏
|
||||
|
||||
`python3 scripts/vision_fallback.py detect-page` 返回 200,2.97s 响应,JSON 合规。
|
||||
模型回答:`page=other, summary=当前页面为全黑,无法识别具体内容`。
|
||||
说明:**API 通,但截屏拿到的是锁屏黑画面**。需要用户先解锁 + 打开 12306 列表页。
|
||||
|
||||
成本对账(本次调用):
|
||||
- prompt_tokens=1618(图片占大头)
|
||||
- completion_tokens=212(含 182 reasoning)
|
||||
- 单次成本估算几厘钱
|
||||
|
||||
**阻塞**:等用户解锁手机 + 切到扫码开票单列表页,然后重跑 `list-rows`。
|
||||
|
||||
### [15:30] 步骤③ dry-run 结果:豆包 mini 不能给坐标
|
||||
|
||||
跑 `list-rows`,7 个名字 OCR 完美(全对),但 **bbox 是模型按"7 行均分屏"猜的等差数列**:
|
||||
- 模型 reasoning 漏嘴:"按钮的x1是720,y1是290,...大概的坐标"
|
||||
- y 值 295/385/475/565/655/745/835 等差 90;真实 y 值 845/1139/1433/1727/2021/2315/2609 等差 294
|
||||
- 误差 500-1700 px,完全不可用
|
||||
|
||||
**结论**:Doubao-Seed-2.0-mini 没有 visual grounding 能力,只能 OCR 不能定位。
|
||||
|
||||
### [15:36] 换硅基流动 Qwen3-VL-8B,grounding 通过
|
||||
|
||||
用户给了硅基流动 key + `Qwen/Qwen3-VL-8B-Instruct`。更新 `config.local.json`。
|
||||
|
||||
重跑 `list-rows`,bbox 精度大幅提升,但发现 Qwen 用 **0-1000 归一化坐标**(Qwen 系标准约定,不论 prompt 怎么写都按这个返)。
|
||||
反推到绝对像素:`x = qwen_x * image_width / 1000`,误差稳定在 5-21 px,**全部落在按钮内**(按钮 250×94)。
|
||||
|
||||
代码改动:
|
||||
1. `vision_fallback.py` 加 `denormalize_bboxes()`:任一坐标 > 1000 视为已是绝对像素,否则按 0-1000 反推。同时在每行注入 `tap_point=[cx,cy]`。
|
||||
2. prompt 改为明确要求 Qwen 返 0-1000 归一化,免得不同图片混乱。
|
||||
|
||||
成本量级:Qwen3-VL-8B / 硅基流动,单次 ~4200 token ≈ 几厘钱;全程 21-25 次 ≈ ¥0.05-0.1。可以接受。
|
||||
|
||||
### [15:42] 进入步骤② · android-vision-page-run
|
||||
|
||||
设计循环(已对齐):
|
||||
- 自检 detect-page == list
|
||||
- 视觉 list-rows → anchor 去重 → 匹配 roster → tap pending 行
|
||||
- tap 后复用现有 `android_invoice_step` 跑子流程(核验/提交/邮件)直到回列表
|
||||
- 本批末行 masked_name 作下批 anchor
|
||||
- swipe `y=2300 → y=200`(2800 屏保 1-2 行重叠)
|
||||
- 终止:scroll_position=bottom 且去重后空集 / 或 anchor 连续两批没变(安全网)
|
||||
|
||||
### [15:50] 步骤② 完成 + dry-run 全过
|
||||
|
||||
- `invoice_tool.py` 新增 `android_vision_page_run` + 4 个 helper(`call_vision_subcommand` / `vision_detect_list_page` / `vision_list_rows` / `android_swipe_up` + `drive_invoice_subflow`),老命令 `android-page-run` 不动并行存在
|
||||
- 注册新 subparser `android-vision-page-run` 含 `--dry-run / --max-actions / --max-batches / --no-precheck` 等
|
||||
- dry-run 输出:precheck ✓ / 视觉 7 行 ✓ / roster 匹配 3 pending(程*桐/朱*玉/王*平)/ tap 坐标对齐 XML 真值
|
||||
|
||||
### [15:51] 单人真测 · 程*桐 → no_ticket 分支闭环
|
||||
|
||||
`--max-actions 1`:tap [1029, 841] → 视觉抓出"扫码开票单 / 您没有可开具电子发票的客票" → 标 `no_ticket` → back 回列表(1 次)→ `sub_back_to_list steps=1` → max_actions 命中即停。
|
||||
完整流程 ✓:**视觉抓行 + tap + 子流程分支 + 状态写回 + 自动退出**,全部按预期。
|
||||
|
||||
### [15:55] 用户改要求 · "不管 roster,识别出来就 tap"
|
||||
|
||||
用户决定:no_match 名也 tap,12306 返啥就记啥。改 `android_vision_page_run` 移除 `match_count != 1` 跳过,无名单匹配也进 sub-flow(预期:已开过 → no_ticket 干净退;未开 → 核验弹窗 → 报错 → 兜底 back)。
|
||||
|
||||
### [16:04] 翻车一 · denormalize 阈值 bug
|
||||
|
||||
`--max-actions 2`,视觉抓到屏幕已被人为滚动到的 9 行新名字。tap 坐标变成 [816, 156] / [816, 261] — y 极小,落在标题栏。
|
||||
原因:Qwen 这次最大坐标返回 1013(赵*丹 底部行),`denormalize_bboxes` 的判定 `> 1000 视为绝对像素` 太严,1013 被误判 → bbox 没反归一 → 中心点直接用归一化值当像素 tap。
|
||||
修:阈值改成 `max <= 1100 且 image_min_dim > 1100`,允许 Qwen rounding 飘到 1000-1030。
|
||||
|
||||
### [16:08] 翻车二 · 2 字脱敏名正则失配 + back 退过头
|
||||
|
||||
修阈值后真 tap 落点:李*霞 ✓ 进 no_ticket、澳* tap ✓ 进核验框,但核验弹窗内"请输入乘车人 澳* 证件号码后8位" 被正则 `[一-鿿]{1,3}` 强制 ≥1 字过滤,**澳* 是 2 字脱敏**,无法解析。
|
||||
报错 → 兜底连按 4 次 back → 把列表页一起退出 → 手机停在 12306 首页。
|
||||
|
||||
### [16:13] 三处修复
|
||||
|
||||
1. 核验正则:`[一-鿿]{0,3}` 兼容 澳* / 吴* / 周* 之类 2 字名
|
||||
2. `safe_back_until_list` 改:max_back 4→2,每次后 sleep 1→1.5s,加 `is_on_12306_home()` 检测,退过头立刻 stop
|
||||
3. 外层 abort 闸门:子流程返回非 `back_to_list` 时,如不在列表页就 `return`,不再 tap
|
||||
|
||||
### [16:14] 用户提出录制示范方案
|
||||
|
||||
用户决定换打法:**他手动操作一遍 → 我用 `record_step.py` 抓 XML+screenshot → 反推脚本逻辑**。
|
||||
新工具 `scripts/record_step.py` 就位(session 自增、`--new` / `--list` / 默认追加)。
|
||||
本次 session 未真正录制,留给下次。
|
||||
|
||||
### [16:15] 收尾 · 用户重开 session
|
||||
|
||||
写 CHANGELOG 4 条 + INDEX.md Handoff 全状态盘点 + 本日志收口。下次开会的 Claude 看 INDEX 接力点即可上手。
|
||||
|
||||
---
|
||||
|
||||
> 本日志到此封顶。后续工作重新写 `docs/debug-trace-YYYY-MM-DD.md`,不在本文件追加。
|
||||
|
||||
---
|
||||
|
||||
> 后续条目继续以 `### [HH:MM] 标题` 追加。每条只写**发生了什么 / 看到了什么 / 接下来做什么**。
|
||||
294
docs/usage.md
Normal file
294
docs/usage.md
Normal file
@ -0,0 +1,294 @@
|
||||
# 铁路系统发票批量处理使用说明
|
||||
|
||||
## 原则
|
||||
|
||||
- 只使用铁路 12306 官方页面/App 完成开票。
|
||||
- 本工具不保存 12306 账号密码,不绕过验证码或身份核验。
|
||||
- 公司抬头、税号、接收邮箱从本地配置读取;不要写入 `.env`。
|
||||
- `config.local.json` 已被 `.gitignore` 忽略,不要把真实公司信息写进 `config.example.json`。
|
||||
|
||||
## 初始化
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py init
|
||||
```
|
||||
|
||||
先复制一份本地配置:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.local.json
|
||||
```
|
||||
|
||||
然后把 `config.local.json` 里的三项改成真实值:
|
||||
|
||||
```json
|
||||
{
|
||||
"company_title": "公司全称",
|
||||
"tax_id": "统一社会信用代码",
|
||||
"receiver_email": "统一收票邮箱"
|
||||
}
|
||||
```
|
||||
|
||||
## 导入开票码
|
||||
|
||||
把旅行社给的二维码图片或 PDF 放到:
|
||||
|
||||
```text
|
||||
data/input/开票码批次/
|
||||
```
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py import-codes
|
||||
```
|
||||
|
||||
输出台账:
|
||||
|
||||
```text
|
||||
work/invoice_tasks.csv
|
||||
```
|
||||
|
||||
如果本机没有二维码解码器,任务会进入 `待补码`。人工扫码后,把链接或码写入:
|
||||
|
||||
```text
|
||||
data/input/开票码批次/manual_codes.csv
|
||||
```
|
||||
|
||||
再执行一次 `import-codes`。
|
||||
|
||||
如果这是铁路“扫码开票单”截图,二维码通常只能用 12306 App 扫。可以在
|
||||
`manual_codes.csv` 里用 `APP_SCAN` 补录开票单号和代办人:
|
||||
|
||||
```csv
|
||||
source_file,passenger,ticket_ref,code
|
||||
data/input/开票码批次/ScreenShot_xxx.png,张*木,EKP37846902581249178,APP_SCAN
|
||||
```
|
||||
|
||||
## 真实手工 POC
|
||||
|
||||
第一次必须用 1-3 张真实开票码在 Mac 上跑通:
|
||||
|
||||
1. 把真实二维码图片/PDF 放进 `data/input/开票码批次/`。
|
||||
2. 执行 `python3 scripts/invoice_tool.py import-codes`。
|
||||
3. 打开 `work/invoice_tasks.csv` 看状态:
|
||||
- `待网页开票`:说明二维码可在 Mac 网页端继续,后续可做更强的网页自动化。
|
||||
- `待人工扫码` / `待补码`:说明大概率要 12306 App 扫码,Mac 端只能做台账、收票和归档自动化。
|
||||
4. 手动登录 12306,完成身份核验。
|
||||
5. 执行 `python3 scripts/invoice_tool.py rpa-plan`,打开 `output/rpa_helper.html`。
|
||||
6. 用辅助页逐条打开官方页面,填写统一公司抬头、税号、邮箱并提交。
|
||||
7. 收到邮件后,把 `.eml` 放入 `data/mail/`,或把附件放入 `data/attachments/`。
|
||||
8. 执行归集和汇总命令,检查 `output/发票汇总.xlsx`。
|
||||
|
||||
## Android 自动填写身份证后 8 位
|
||||
|
||||
适用场景:12306 App 扫码后,页面显示脱敏姓名(如 `张*岩`),要求输入身份证后 8 位验证码。
|
||||
|
||||
### 本地 Web UI
|
||||
|
||||
启动本地页面:
|
||||
|
||||
```bash
|
||||
python3 scripts/web_ui.py
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8765
|
||||
```
|
||||
|
||||
页面支持上传 `.xlsx/.xlsm/.csv` 名单、查看成功/失败/处理中/未操作统计、显示手机连接/当前 App 状态、启动手机 12306、抓取当前页队列、启动/停止批处理,并持续读取:
|
||||
|
||||
```text
|
||||
work/android_invoice_progress.csv
|
||||
```
|
||||
|
||||
这个 CSV 是逐人处理结果表,页面里的“下载进度表”会直接下载它。
|
||||
|
||||
### 1. 导入 Excel 名单
|
||||
|
||||
名单里至少要有“姓名”和“身份证/证件号码”两类列名。支持 `.xlsx/.xlsm/.csv`:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py import-roster --input /path/to/名单.xlsx
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```text
|
||||
work/passenger_roster.csv
|
||||
```
|
||||
|
||||
先离线验证一个脱敏姓名:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py match-roster --masked-name '张*岩'
|
||||
```
|
||||
|
||||
如果出现 `ambiguous`,说明同名脱敏后匹配到多人,必须人工确认。
|
||||
|
||||
### 2. 连接 Android 手机
|
||||
|
||||
手机开启 USB 调试后连接 Mac:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/share/android-commandlinetools/platform-tools/adb devices
|
||||
```
|
||||
|
||||
先手工在 12306 App 扫码,进入要求填写身份证后 8 位的页面。
|
||||
|
||||
读取当前页面文字:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-dump --output work/android_page.txt
|
||||
```
|
||||
|
||||
如果能看到 `张*岩` 这类文字,就可以自动匹配并输入身份证后 8 位:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-type-id --tap 500,1200
|
||||
```
|
||||
|
||||
`--tap` 是输入框坐标,需要按手机页面实际位置改。也可以用按钮/输入框可访问文字:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-type-id --click-text '证件号码后8位' --contains
|
||||
```
|
||||
|
||||
### 3. 后续点击流程
|
||||
|
||||
复制并修改模板:
|
||||
|
||||
```bash
|
||||
cp templates/android_flow.example.json templates/android_flow.local.json
|
||||
python3 scripts/invoice_tool.py android-run-flow --flow templates/android_flow.local.json
|
||||
```
|
||||
|
||||
模板支持这些动作:
|
||||
|
||||
| action | 用途 |
|
||||
|---|---|
|
||||
| `click_text` | 按完整文字点击 |
|
||||
| `click_contains` | 按包含文字点击 |
|
||||
| `tap` | 按坐标点击 |
|
||||
| `input_id8_for_visible_masked_name` | 从当前页面识别 `张*岩` 并输入身份证后 8 位 |
|
||||
| `input_config` | 输入 `config.local.json` 中的公司抬头、税号、邮箱 |
|
||||
| `input_text` | 输入固定文本 |
|
||||
| `sleep` | 等待 |
|
||||
| `keyevent` | 发送 Android keyevent |
|
||||
|
||||
真实 12306 App 页面可能不暴露全部控件文字。遇到找不到控件时,用 `android-dump` 看文字;如果没有文字,就改用 `tap` 坐标。
|
||||
|
||||
### 4. 单步状态机
|
||||
|
||||
当前页面已校准后,优先用单步命令。它每次只做一步动作,默认等待 `10 秒`,未出现目标状态再等 `20 秒`,仍不成功就暂停:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-invoice-step
|
||||
```
|
||||
|
||||
如果当前在“发票开具成功”页,默认会点 `继续开票` 回列表。需要先发邮件时才加:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-invoice-step --send-email
|
||||
```
|
||||
|
||||
### 5. 扫码列表页批处理队列
|
||||
|
||||
扫码开票单列表页不要用固定坐标。先抓当前可见的一页,按脱敏姓名和名单生成队列:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-build-page-queue
|
||||
```
|
||||
|
||||
队列文件:
|
||||
|
||||
```text
|
||||
work/android_page_queue.csv
|
||||
```
|
||||
|
||||
然后反复跑:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-page-step
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
- 当前在扫码列表页:按本页队列点击下一个可匹配人员的 `开具`。
|
||||
- 当前在核验/发票管理/发票信息/成功页:执行普通单步状态机。
|
||||
- 本页没有可处理人员:输出 `page_done`,不自动乱滑。
|
||||
|
||||
本页处理完后上滑到下一页并重建队列:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-page-next --rebuild
|
||||
```
|
||||
|
||||
也可以让 `android-page-step` 在本页完成后自动上滑:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py android-page-step --auto-next
|
||||
```
|
||||
|
||||
## 网页辅助开票
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py rpa-plan
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
output/rpa_helper.html
|
||||
```
|
||||
|
||||
先手动登录 12306 并完成核验,再逐条打开官方页面,复制公司抬头、税号和邮箱提交。
|
||||
|
||||
## 邮件和附件归集
|
||||
|
||||
如果有 `.eml` 邮件文件:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py collect-mail
|
||||
```
|
||||
|
||||
如果已经把 PDF/OFD/XML 附件下载到 `data/attachments/`:
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py collect-attachments
|
||||
```
|
||||
|
||||
归集后的发票在:
|
||||
|
||||
```text
|
||||
output/invoices/
|
||||
```
|
||||
|
||||
## 汇总和查漏
|
||||
|
||||
```bash
|
||||
python3 scripts/invoice_tool.py export-summary
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```text
|
||||
output/发票汇总.xlsx
|
||||
```
|
||||
|
||||
工作簿包含:
|
||||
|
||||
- `发票汇总`
|
||||
- `缺票清单`
|
||||
- `合规检查`
|
||||
|
||||
## 状态含义
|
||||
|
||||
| 状态 | 含义 |
|
||||
|---|---|
|
||||
| `待补码` | 未能自动识别二维码,需要人工扫码补录 |
|
||||
| `待人工扫码` | 码不是网页链接,通常需要 12306 App 扫码 |
|
||||
| `待网页开票` | 可以从辅助页打开官方页面处理 |
|
||||
| `已收票` | 已匹配到发票附件 |
|
||||
2576
scripts/invoice_tool.py
Executable file
2576
scripts/invoice_tool.py
Executable file
File diff suppressed because it is too large
Load Diff
152
scripts/record_step.py
Normal file
152
scripts/record_step.py
Normal file
@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""录制一次"用户操作"前后的手机状态。
|
||||
|
||||
用法:
|
||||
python3 scripts/record_step.py "label" # 记一次状态,自动序号
|
||||
python3 scripts/record_step.py --new "first label" # 开新 session,清重置
|
||||
python3 scripts/record_step.py --list # 列出当前 session 已记的步骤
|
||||
|
||||
每次调用会在 work/replay/<session_ts>/<NN_label>/ 下产出:
|
||||
- ui.xml adb uiautomator dump
|
||||
- screen.png adb 截屏
|
||||
- meta.json 时间戳 + label + 屏幕分辨率
|
||||
- texts.txt 从 xml 抽出的所有可见 text(后续 diff 用)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPLAY_ROOT = PROJECT_ROOT / "work" / "replay"
|
||||
CURRENT_SESSION_POINTER = REPLAY_ROOT / "_current_session"
|
||||
|
||||
|
||||
def latest_session() -> Path | None:
|
||||
if CURRENT_SESSION_POINTER.exists():
|
||||
target = Path(CURRENT_SESSION_POINTER.read_text().strip())
|
||||
if target.exists():
|
||||
return target
|
||||
return None
|
||||
|
||||
|
||||
def new_session() -> Path:
|
||||
REPLAY_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
session = REPLAY_ROOT / ts
|
||||
session.mkdir(parents=True, exist_ok=False)
|
||||
CURRENT_SESSION_POINTER.write_text(str(session))
|
||||
return session
|
||||
|
||||
|
||||
def next_step_index(session: Path) -> int:
|
||||
existing = sorted(p.name for p in session.iterdir() if p.is_dir())
|
||||
n = 0
|
||||
for name in existing:
|
||||
m = re.match(r"(\d+)_", name)
|
||||
if m:
|
||||
n = max(n, int(m.group(1)))
|
||||
return n + 1
|
||||
|
||||
|
||||
def slugify(label: str) -> str:
|
||||
label = re.sub(r"\s+", "_", label.strip())
|
||||
return re.sub(r"[^a-zA-Z0-9_一-鿿-]+", "", label)[:60] or "step"
|
||||
|
||||
|
||||
def dump_ui_xml(target: Path) -> None:
|
||||
subprocess.run(["adb", "shell", "uiautomator", "dump", "/sdcard/_record.xml"], check=True, capture_output=True)
|
||||
subprocess.run(["adb", "pull", "/sdcard/_record.xml", str(target)], check=True, capture_output=True)
|
||||
|
||||
|
||||
def take_screenshot(target: Path) -> None:
|
||||
result = subprocess.run(["adb", "exec-out", "screencap", "-p"], capture_output=True, check=True)
|
||||
target.write_bytes(result.stdout)
|
||||
|
||||
|
||||
def extract_texts(xml_path: Path) -> list[str]:
|
||||
xml = xml_path.read_text(encoding="utf-8", errors="ignore")
|
||||
return [t for t in re.findall(r'text="([^"]+)"', xml) if t.strip()]
|
||||
|
||||
|
||||
def png_dimensions(path: Path) -> tuple[int, int]:
|
||||
data = path.read_bytes()
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
|
||||
return 0, 0
|
||||
|
||||
|
||||
def record(session: Path, label: str) -> Path:
|
||||
idx = next_step_index(session)
|
||||
folder = session / f"{idx:02d}_{slugify(label)}"
|
||||
folder.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
xml_path = folder / "ui.xml"
|
||||
png_path = folder / "screen.png"
|
||||
dump_ui_xml(xml_path)
|
||||
take_screenshot(png_path)
|
||||
|
||||
texts = extract_texts(xml_path)
|
||||
(folder / "texts.txt").write_text("\n".join(texts), encoding="utf-8")
|
||||
|
||||
w, h = png_dimensions(png_path)
|
||||
meta = {
|
||||
"label": label,
|
||||
"index": idx,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"screen_w": w,
|
||||
"screen_h": h,
|
||||
"text_count": len(texts),
|
||||
}
|
||||
(folder / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
def cmd_list(session: Path) -> None:
|
||||
for folder in sorted(session.iterdir()):
|
||||
if folder.is_dir():
|
||||
meta_path = folder / "meta.json"
|
||||
if meta_path.exists():
|
||||
meta = json.loads(meta_path.read_text())
|
||||
print(f" {folder.name:30s} {meta.get('timestamp','')} text_count={meta.get('text_count')}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="录制手机操作前后状态")
|
||||
parser.add_argument("label", nargs="?", help="本步骤的描述,作为目录名一部分")
|
||||
parser.add_argument("--new", dest="new", action="store_true", help="开新 session")
|
||||
parser.add_argument("--list", dest="list_only", action="store_true", help="列出当前 session 已记的步骤")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.list_only:
|
||||
session = latest_session()
|
||||
if not session:
|
||||
print("no_session")
|
||||
return 1
|
||||
print(f"session={session}")
|
||||
cmd_list(session)
|
||||
return 0
|
||||
|
||||
if args.new or latest_session() is None:
|
||||
session = new_session()
|
||||
print(f"new_session={session}")
|
||||
else:
|
||||
session = latest_session()
|
||||
|
||||
if not args.label:
|
||||
print("error: label required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
folder = record(session, args.label)
|
||||
print(f"recorded={folder.name} session={session.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
259
scripts/vision_fallback.py
Normal file
259
scripts/vision_fallback.py
Normal file
@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""视觉 fallback: 截屏 + 调豆包视觉 API,做"当前是哪一页 / 找哪个按钮"判断."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_LOCAL = PROJECT_ROOT / "config.local.json"
|
||||
WORK_DIR = PROJECT_ROOT / "work"
|
||||
|
||||
|
||||
def load_vision_config() -> dict:
|
||||
if not CONFIG_LOCAL.exists():
|
||||
raise RuntimeError(f"配置文件不存在: {CONFIG_LOCAL}")
|
||||
cfg = json.loads(CONFIG_LOCAL.read_text(encoding="utf-8"))
|
||||
vision = cfg.get("vision")
|
||||
if not vision:
|
||||
raise RuntimeError("config.local.json 缺 vision 段")
|
||||
for key in ("base_url", "api_key", "model"):
|
||||
if not vision.get(key):
|
||||
raise RuntimeError(f"vision.{key} 未配置")
|
||||
return vision
|
||||
|
||||
|
||||
def adb_screenshot(output: Path) -> Path:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = subprocess.run(
|
||||
["adb", "exec-out", "screencap", "-p"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"adb screencap 失败: {result.stderr.decode('utf-8', 'ignore')}")
|
||||
output.write_bytes(result.stdout)
|
||||
return output
|
||||
|
||||
|
||||
def call_vision(image_path: Path, prompt: str, vision_cfg: dict, timeout: int = 60) -> dict:
|
||||
image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
|
||||
payload = {
|
||||
"model": vision_cfg["model"],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {vision_cfg['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = vision_cfg["base_url"].rstrip("/") + "/chat/completions"
|
||||
start = time.time()
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=timeout)
|
||||
elapsed = time.time() - start
|
||||
if response.status_code >= 300:
|
||||
raise RuntimeError(f"vision_api status={response.status_code} body={response.text[:500]}")
|
||||
data = response.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
usage = data.get("usage", {})
|
||||
return {
|
||||
"content": content,
|
||||
"usage": usage,
|
||||
"elapsed_seconds": round(elapsed, 2),
|
||||
"raw": data,
|
||||
}
|
||||
|
||||
|
||||
PAGE_DETECT_PROMPT = """这是手机 12306 App 的一张截图。请只用一个 JSON 对象回答(不要 markdown 代码块),字段如下:
|
||||
|
||||
{
|
||||
"page": "<one of: list / verify / invoice_info / invoice_confirm / success / email_confirm / mail_sent / no_ticket / launcher / other>",
|
||||
"summary": "<简短描述当前看到的内容,不超过 30 字>",
|
||||
"actionable_buttons": ["<可点击的关键按钮文字,如 开具/确认/提交/继续开票/确定/发送至邮箱>"],
|
||||
"scroll_position": "<top / middle / bottom / unknown>"
|
||||
}
|
||||
|
||||
页面对应关系:
|
||||
- list = 扫码开票单列表(显示多个 X*Y 脱敏姓名 + 开具按钮)
|
||||
- verify = 请输入乘车人 + 证件号码后8位的核验弹窗
|
||||
- invoice_info = 发票信息页(有抬头/税号/金额/提交按钮)
|
||||
- invoice_confirm = 发票信息确认弹窗
|
||||
- success = 发票开具成功页(有 继续开票 / 发送至邮箱)
|
||||
- email_confirm = 邮箱地址确认
|
||||
- mail_sent = 邮件发送完成提示
|
||||
- no_ticket = 发票管理页 + 您没有可开具电子发票的客票
|
||||
- launcher = 桌面 / 不在 12306 内
|
||||
- other = 其他识别不出的页面
|
||||
"""
|
||||
|
||||
|
||||
LIST_ROWS_PROMPT_TEMPLATE = """这是手机 12306 App 扫码开票单列表页的一张截图。
|
||||
请自上而下识别屏幕上所有"乘客行"(每行有一个脱敏姓名如 张*岩、王*平,行尾右侧有"开具"按钮)。
|
||||
**坐标系约定**:所有 bbox 用 0-1000 归一化坐标返回(x 用 [0,1000] 横向占比,y 用 [0,1000] 纵向占比),不要返回原始像素。
|
||||
|
||||
只用一个 JSON 对象回答(不要 markdown 代码块、不要解释),字段如下:
|
||||
{{
|
||||
"rows": [
|
||||
{{
|
||||
"masked_name": "<脱敏姓名,如 张*岩;只用一个 ASCII * 号,不要 *>",
|
||||
"id_first": "<身份证首位,看不见就空串>",
|
||||
"id_last": "<身份证末位,看不见就空串>",
|
||||
"button_bbox": [x1, y1, x2, y2] // 该行"开具"按钮的 0-1000 归一化 bbox,整数
|
||||
}}
|
||||
],
|
||||
"scroll_position": "<top / middle / bottom>", // bottom = 看到列表底部边缘或"没有更多了"提示
|
||||
"page_ok": <true/false> // 当前是不是扫码开票单列表页;不是请置 false 并把 rows 留空
|
||||
}}
|
||||
|
||||
注意:
|
||||
- 只识别完整可见的行;只露出半截的行不要加进 rows
|
||||
- masked_name 用半角 * 号(不要全角 *)
|
||||
- 没看到"开具"按钮的行不算"乘客行",跳过
|
||||
- bbox 数值范围 [0, 1000],整数
|
||||
"""
|
||||
|
||||
|
||||
def screenshot_size(image_path: Path) -> tuple[int, int]:
|
||||
data = image_path.read_bytes()
|
||||
if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
width = int.from_bytes(data[16:20], "big")
|
||||
height = int.from_bytes(data[20:24], "big")
|
||||
return width, height
|
||||
return 0, 0
|
||||
|
||||
|
||||
def parse_json_lenient(content: str) -> dict:
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if text.startswith("json"):
|
||||
text = text[4:]
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start == -1 or end == -1:
|
||||
raise RuntimeError(f"无法从模型输出里抽出 JSON: {content[:300]}")
|
||||
return json.loads(text[start:end + 1])
|
||||
|
||||
|
||||
def denormalize_bboxes(parsed: dict, width: int, height: int) -> dict:
|
||||
"""把模型返回的 0-1000 归一化 bbox 换算成截图绝对像素。
|
||||
|
||||
判定规则:
|
||||
- max(bbox) 明显小于 min(image_w, image_h) 的一半,几乎不可能是绝对像素 -> 归一化
|
||||
- max(bbox) <= 1100(允许 Qwen-VL rounding 飘到 1000-1030)且图比 1100 大 -> 归一化
|
||||
- 否则按绝对像素原样保留
|
||||
**就地**修改 parsed 并返回。
|
||||
"""
|
||||
rows = parsed.get("rows") or []
|
||||
if not rows or width <= 0 or height <= 0:
|
||||
return parsed
|
||||
flat = []
|
||||
for row in rows:
|
||||
bbox = row.get("button_bbox") or []
|
||||
if len(bbox) == 4:
|
||||
flat.extend(bbox)
|
||||
if not flat:
|
||||
return parsed
|
||||
max_v = max(flat)
|
||||
img_min = min(width, height)
|
||||
is_normalized = max_v <= 1100 and img_min > 1100
|
||||
if not is_normalized:
|
||||
return parsed
|
||||
for row in rows:
|
||||
bbox = row.get("button_bbox") or []
|
||||
if len(bbox) != 4:
|
||||
continue
|
||||
x1, y1, x2, y2 = bbox
|
||||
row["button_bbox"] = [
|
||||
int(round(x1 * width / 1000)),
|
||||
int(round(y1 * height / 1000)),
|
||||
int(round(x2 * width / 1000)),
|
||||
int(round(y2 * height / 1000)),
|
||||
]
|
||||
cx = (row["button_bbox"][0] + row["button_bbox"][2]) // 2
|
||||
cy = (row["button_bbox"][1] + row["button_bbox"][3]) // 2
|
||||
row["tap_point"] = [cx, cy]
|
||||
parsed["bbox_denormalized"] = True
|
||||
return parsed
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="vision fallback 测试入口")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_ping = sub.add_parser("ping", help="不截屏,只发一张本地图测试 API 通路")
|
||||
p_ping.add_argument("--image", required=True, help="本地图片路径")
|
||||
p_ping.add_argument("--prompt", default="一句话描述这张图")
|
||||
|
||||
p_detect = sub.add_parser("detect-page", help="adb 截屏 + 让豆包判断当前是哪一页")
|
||||
p_detect.add_argument("--save", default="work/last_screenshot.png", help="截屏保存路径")
|
||||
|
||||
p_ask = sub.add_parser("ask", help="adb 截屏 + 自定义 prompt")
|
||||
p_ask.add_argument("--prompt", required=True)
|
||||
p_ask.add_argument("--save", default="work/last_screenshot.png")
|
||||
|
||||
p_rows = sub.add_parser("list-rows", help="adb 截屏 + 让豆包识别列表页所有可见乘客行")
|
||||
p_rows.add_argument("--save", default="work/last_screenshot.png", help="截屏保存路径")
|
||||
p_rows.add_argument("--image", default=None, help="指定已有图片,跳过 adb 截屏(调试用)")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
vision_cfg = load_vision_config()
|
||||
|
||||
if args.cmd == "ping":
|
||||
image = Path(args.image).expanduser()
|
||||
if not image.is_absolute():
|
||||
image = PROJECT_ROOT / image
|
||||
result = call_vision(image, args.prompt, vision_cfg)
|
||||
elif args.cmd == "list-rows":
|
||||
if args.image:
|
||||
save_path = Path(args.image)
|
||||
if not save_path.is_absolute():
|
||||
save_path = PROJECT_ROOT / save_path
|
||||
else:
|
||||
save_path = Path(args.save)
|
||||
if not save_path.is_absolute():
|
||||
save_path = PROJECT_ROOT / save_path
|
||||
adb_screenshot(save_path)
|
||||
width, height = screenshot_size(save_path)
|
||||
prompt = LIST_ROWS_PROMPT_TEMPLATE.format(width=width, height=height)
|
||||
result = call_vision(save_path, prompt, vision_cfg)
|
||||
result["screenshot"] = str(save_path)
|
||||
result["image_size"] = {"width": width, "height": height}
|
||||
try:
|
||||
parsed = parse_json_lenient(result["content"])
|
||||
denormalize_bboxes(parsed, width, height)
|
||||
result["parsed"] = parsed
|
||||
except Exception as exc:
|
||||
result["parse_error"] = str(exc)
|
||||
else:
|
||||
save_path = Path(args.save)
|
||||
if not save_path.is_absolute():
|
||||
save_path = PROJECT_ROOT / save_path
|
||||
adb_screenshot(save_path)
|
||||
prompt = PAGE_DETECT_PROMPT if args.cmd == "detect-page" else args.prompt
|
||||
result = call_vision(save_path, prompt, vision_cfg)
|
||||
result["screenshot"] = str(save_path)
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1025
scripts/web_ui.py
Normal file
1025
scripts/web_ui.py
Normal file
File diff suppressed because it is too large
Load Diff
54
templates/android_flow.example.json
Normal file
54
templates/android_flow.example.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"description": "12306 App 开票页面示例流程。先用 android-dump 校准真实按钮文字或坐标后再复制修改。",
|
||||
"steps": [
|
||||
{
|
||||
"action": "click_contains",
|
||||
"text": "证件",
|
||||
"after": 0.5
|
||||
},
|
||||
{
|
||||
"action": "input_id8_for_visible_masked_name",
|
||||
"after": 0.8
|
||||
},
|
||||
{
|
||||
"action": "click_contains",
|
||||
"text": "下一步",
|
||||
"after": 1.2
|
||||
},
|
||||
{
|
||||
"action": "click_contains",
|
||||
"text": "抬头",
|
||||
"after": 0.5
|
||||
},
|
||||
{
|
||||
"action": "input_config",
|
||||
"field": "company_title",
|
||||
"after": 0.5
|
||||
},
|
||||
{
|
||||
"action": "click_contains",
|
||||
"text": "税号",
|
||||
"after": 0.5
|
||||
},
|
||||
{
|
||||
"action": "input_config",
|
||||
"field": "tax_id",
|
||||
"after": 0.5
|
||||
},
|
||||
{
|
||||
"action": "click_contains",
|
||||
"text": "邮箱",
|
||||
"after": 0.5
|
||||
},
|
||||
{
|
||||
"action": "input_config",
|
||||
"field": "receiver_email",
|
||||
"after": 0.5
|
||||
},
|
||||
{
|
||||
"action": "click_contains",
|
||||
"text": "开具",
|
||||
"after": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user