H5 视觉兜底优化 + scrcpy 非阻塞启动 + 自动校准/重连脚本

- wait_for_page_transition: XML 持续返回 other 超过 quick_timeout 后自动启用视觉兜底
- _xml_or_vision_click: 优先视觉 API 找按钮(H5 WebView XML 不可见)
- 自动处理媒体访问权限弹窗
- scrcpy 改为非阻塞 Popen 启动,绑定当前设备 serial 避免多设备冲突
- 新增 auto_calibrate_from_recording.py / reconnect-phone.sh
- postJson 前端异常捕获
This commit is contained in:
xinxin6623 2026-07-24 09:46:32 +08:00
parent 9eb6778a6f
commit 65d2719afb
6 changed files with 364 additions and 41 deletions

View File

@ -1,27 +1,28 @@
<!-- 本文件由 /wrap-up 自动生成并覆盖,勿手工编辑;生成器见 wrap-up skill references/codemap.md --> <!-- 本文件由 /wrap-up 自动生成并覆盖,勿手工编辑;生成器见 wrap-up skill references/codemap.md -->
# 铁路系统发票 · 代码图谱索引 (CODEMAP) # 铁路系统发票 · 代码图谱索引 (CODEMAP)
> 由 codebase-memory-mcp 于 2026-07-11 生成 · 图谱 566 节点 / 1799 边 · 活文件,每次 /wrap-up 覆盖 > 由 codebase-memory-mcp 于 2026-07-17 生成 · 图谱 1088 节点 / 3812 边 · 活文件,每次 /wrap-up 覆盖
## 一、给能连 MCP 的 agent首选 ## 一、给能连 MCP 的 agent首选
本项目已索引project 名 `Users-qoragufimo390gmail.com-Documents-works-autotrain`。直接查图谱代替 grep省 ~120x token 本项目已索引project 名 `Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain`。直接查图谱代替 grep省 ~120x token
- 架构总览:`get_architecture(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", aspects=["all"])` - 架构总览:`get_architecture(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", aspects=["all"])`
- 查函数/类:`search_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", name_pattern=".*X.*")` - 查函数/类:`search_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", name_pattern=".*X.*")`
- 调用链:`trace_path(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", function_name="X", direction="both")` - 调用链:`trace_path(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", function_name="X", direction="both")`
- 死代码:`search_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", max_degree=0, exclude_entry_points=true)` - 死代码:`search_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", max_degree=0, exclude_entry_points=true)`
- Cypher`query_graph(project="Users-qoragufimo390gmail.com-Documents-works-autotrain", query="MATCH ...")` - Cypher`query_graph(project="Users-qoragufimo390gmail.com-Documents-_e99d9ee9a1b9e79bae-works-autotrain", query="MATCH ...")`
未连 MCPCLI 等价:`~/.local/bin/codebase-memory-mcp cli <tool> '<json>'` 未连 MCPCLI 等价:`~/.local/bin/codebase-memory-mcp cli <tool> '<json>'`
## 二、结构画像快照(连不上 MCP 时读这段就够) ## 二、结构画像快照(连不上 MCP 时读这段就够)
- **规模**566 节点 / 1799 边 / 10 Python+Bash 文件 - **规模**1088 节点 / 3812 边 / 36 Python+Bash 文件
- **语言**Python 7 · Bash 3 - **语言**Python 32 · Bash 4
- **节点构成**Section 203 · Function 192 · Variable 92 · File 23 · Module 23 · Method 17 · Class 7 · Folder 6 - **节点构成**Function 350 · Section 296 · Variable 225 · File/Module 64 · Method 53 · Class 20 · Folder 13
- **包/目录**`scripts/`204 节点,唯一逻辑包)— 全部逻辑在独立 Python 文件里,彼此通过 CSV/配置文件耦合IMPORTS 边仅 2 条印证) - **包/目录**`scripts/`250 节点,主逻辑)· `nextgen/`149 节点,重构中的新一代)· `tests/`12 节点)— 旧 scripts 彼此通过 CSV/配置文件耦合IMPORTS 边 55 条,含 nextgen 内聚)
- **入口**6 个 main`invoice_tool.py`(主 CLI ~3200 行)/ `web_ui.py`HTTP 服务 :8765/ `vision_fallback.py`(视觉层)/ `record_step.py`(录制)/ `add_invoice_passenger_name.py`PDF 加姓名)/ `annotate_roster_invoices.py`(名单标状态) - **入口**9 个 main`invoice_tool.py`(主 CLI ~3200 行)/ `web_ui.py`HTTP 服务 :8765/ `vision_fallback.py`(视觉层)/ `record_step.py`(录制)/ `record_android_flow.py`(流录制)/ `add_invoice_passenger_name.py`PDF 加姓名)/ `annotate_roster_invoices.py`(名单标状态)/ `device_calibrate.py`(设备校准)/ `nextgen/scripts/autotrain_next.py`(新一代入口)
- **热点函数**by fan-in`run_adb`(23) · `project_path`(21) · `adb_dump_nodes`(17) · `adb_visible_texts`(14) · `match_roster`(13) · `read_progress`(12) - **热点函数**by fan-in`dict.get`(155) · `len`(62) · `print`(53) · `list.append`(49) · `run_adb`(24, invoice_tool) · `project_path`(21) · `adb_dump_nodes`(17) · `device.run`(15, nextgen) · `adb_visible_texts`(15)
- **边类型**CALLS 748 · DEFINES 606 · USAGE 162 · SEMANTICALLY_RELATED 131 · WRITES 81 · SIMILAR_TO 26 · FILE_CHANGES_WITH 3 · IMPORTS 2 - **边类型**DEFINES 1380 · CALLS 1297 · USAGE 445 · WRITES 223 · SEMANTICALLY_RELATED 177 · CONTAINS_FILE 64 · IMPORTS 55 · DEFINES_METHOD 48 · RAISES 34 · SIMILAR_TO 33 · TESTS 29 · FILE_CHANGES_WITH 10
- **架构层**`scripts` = entry layer有入口、只外调其余 builtins 为 core layer - **架构层**`scripts` / `nextgen` = entry layer有入口、只外调builtinsdict/str/list/len/int/print= core layer
- **Leiden 社区**10 个 cluster最大 58 成员do_POST/main/do_GET Web UI 层、55 成员android_invoice_step/android_vision_page_run 状态机、44 成员open_next/download_and_continue 开票子流程、39 成员device_runtime 设备层)

View File

@ -9,15 +9,7 @@
> 只保留最新一条,下一轮 wrap-up 直接覆盖。历史接力沉淀到 obwiki。 > 只保留最新一条,下一轮 wrap-up 直接覆盖。历史接力沉淀到 obwiki。
### 概述 ### 概述
**02 批次收尾完成(去重+儿童票标注+清一轮按钮Web UI 改动需重启验证,敬曦翔未开票待确认。** **无未尽事项。** 上一轮07-11 02 批次收尾)的待办已随本轮 scrcpy 修复 + web_ui 重启验证完毕。下批开票前用「清一轮开票记录」按钮清空本轮数据即可。
### 明细
2026-07-1102 批次开票跑完,做了三件收尾事。① 按发票号去重:删了主目录 3 份重复张辰铭 `_invoice.pdf`(同一发票号 26119121152005549789手工目录 4 份确认无重。② 儿童票标注:`annotate_roster_invoices.py` 新增 `is_child_ticket()` + `--child-suffix` 参数,读 PDF 正文检测「孩」字,状态列输出「已开票-孩」。7 位儿童票:陈露萱/胡初然/邱瑞泽/邱景媛/胡云辰/高振瑜/王楚渃。③ Web UI 加「清一轮开票记录」按钮:`reset_current_round()` + `/api/reset-round` + `confirm()` 二次确认,归档到 `archive/round-resets/<ts>/`,保留 roster/tasks/agent_id_last8。另把 `invoice-wrapup` skill 从 `.opencode/skills/` 移到 `skills/invoice-wrapup/`
**待办**
1. `./scripts/restart_web_ui.sh` 重启 Web UI验证「清一轮开票记录」按钮功能
2. `敬曦翔` 名单有但无发票(未开票),人工确认是否漏开
3. 下一批开票前用「清一轮开票记录」按钮清空本轮数据
## 项目结构 ## 项目结构
@ -43,6 +35,7 @@
│ ├── web_ui_watchdog.sh │ ├── web_ui_watchdog.sh
│ ├── vision_fallback.py │ ├── vision_fallback.py
│ └── record_step.py │ └── record_step.py
├── nextgen/ # 新一代跨平台、原子工具编排分支工程(自带三件套)
├── templates/ ├── templates/
│ └── android_flow.example.json │ └── android_flow.example.json
└── work/ └── work/
@ -50,7 +43,7 @@
## 子模块导航 ## 子模块导航
(本项目暂无子目录) | [nextgen/](./nextgen/INDEX.md) | 跨平台设备档案、原子工具编排与受控 LLM 任务循环 | 子项目(自带三件套) |
## 常用操作 ## 常用操作

View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""从录制快照自动灌入校准样图并分析。"""
import shutil, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[0]))
import calibrate, device_runtime
RECORDING = Path("work/replay/20260712-214511_manual")
CALIB = calibrate.CALIBRATION_DIR
# 录制 page_hint → 校准 step_id 映射,取每个页面第一次出现的最完整截图
MAPPING = {
"album_qr": "album_qr",
"list": "list_page",
"invoice_info": "invoice_info",
"invoice_confirm": "invoice_confirm",
"success": "success_page",
}
# 按录制时间线顺序每个页面类型取第一个UI 最稳定)
seen = set()
copied = []
missing = []
for snap in sorted(RECORDING.glob("*_*"), key=lambda p: p.name):
if not snap.is_dir():
continue
meta_file = snap / "meta.json"
if not meta_file.exists():
continue
import json
meta = json.loads(meta_file.read_text())
hint = meta.get("page_hint", "other")
step_id = MAPPING.get(hint)
if not step_id or step_id in seen:
continue
seen.add(step_id)
screen = snap / "screen.png"
if not screen.exists():
print(f"WARN: {snap.name} 没有 screen.png跳过")
continue
dest = CALIB / f"{step_id}.png"
CALIB.mkdir(parents=True, exist_ok=True)
shutil.copy2(screen, dest)
copied.append(step_id)
print(f" {step_id}{snap.name}")
for step in calibrate.CALIBRATION_STEPS:
if step["id"] not in seen:
missing.append(step["id"])
print(f"\n复制了 {len(copied)} 张样图: {copied}")
if missing:
print(f"缺失: {missing}(可在真机运行时补录)")
# 分析所有已有样图
print("\n分析中...")
results = calibrate.analyze_all()
for sid, r in results.items():
if r.get("skipped"):
print(f" {sid}: 无截图,跳过")
elif r.get("error"):
print(f" {sid}: 错误 - {r['error']}")
else:
page_ok = "" if r.get("page_ok") else "⚠️"
buttons = r.get("buttons", {})
btn_status = ", ".join(f"{k}:{'' if v.get('found') else ''}" for k, v in buttons.items())
print(f" {sid}: {page_ok} page={r.get('page_detected', '?')} | buttons: {btn_status}")
# 保存设备配置
print("\n保存设备配置...")
profile = calibrate.save_device_profile(results)
print(f"状态: {profile.get('status', '?')}")
print("完成!")

View File

@ -1257,6 +1257,9 @@ def wait_for_page_transition(
network_sensitive=True 的步骤通常触发 12306 后端请求,先给短等待,若仍是 loading/other network_sensitive=True 的步骤通常触发 12306 后端请求,先给短等待,若仍是 loading/other
就自动延长,避免固定把每步都 sleep 很久 就自动延长,避免固定把每步都 sleep 很久
H5 WebView 页面(如核验页)的文本在 uiautomator dump XML 中不可见,
XML 持续返回 other 超过 quick_timeout 后自动启用视觉兜底,无需手动开 --screenshot-wait
""" """
quick_timeout = float(getattr(args, "smart_wait_quick", 2.0)) quick_timeout = float(getattr(args, "smart_wait_quick", 2.0))
network_timeout = float(getattr(args, "smart_wait_network", 12.0)) if network_sensitive else 0.0 network_timeout = float(getattr(args, "smart_wait_network", 12.0)) if network_sensitive else 0.0
@ -1266,18 +1269,49 @@ def wait_for_page_transition(
last_vision_at = 0.0 last_vision_at = 0.0
last_kind = "other" last_kind = "other"
phase = "quick" phase = "quick"
other_start: float | None = None # XML 首次返回 other 的时间,用于触发自动视觉兜底
while True: while True:
nodes = adb_dump_nodes() nodes = adb_dump_nodes()
page_text = "\n".join(adb_visible_texts(nodes))
kind = xml_page_kind(nodes) kind = xml_page_kind(nodes)
last_kind = kind last_kind = kind
# 自动处理系统权限弹窗(媒体访问等),避免阻塞页面检测
if "是否允许" in page_text and "访问媒体" in page_text and "允许" in page_text:
if _xml_or_vision_click(nodes, "允许", "allow_media_permission_wait", clickable=True):
print(f"wait_page_dismiss_permission label={label}")
time.sleep(0.5)
continue
if "访问媒体" in page_text and "允许" in page_text:
if _xml_or_vision_click(nodes, "允许", "allow_media_permission_wait_v2", clickable=True):
print(f"wait_page_dismiss_permission label={label}")
time.sleep(0.5)
continue
if kind in expected_pages: if kind in expected_pages:
print(f"wait_page_hit label={label} via=xml page={kind} phase={phase}") print(f"wait_page_hit label={label} via=xml page={kind} phase={phase}")
return kind return kind
now = time.perf_counter() now = time.perf_counter()
if getattr(args, "screenshot_wait", False) and now - last_vision_at >= vision_interval:
# 跟踪 XML 持续返回 other 的时间
if kind == "other":
if other_start is None:
other_start = now
else:
other_start = None
# 自动视觉兜底:XML 持续返回 other 超过 quick_timeout 时启用
# (screenshot_wait 显式开启时也启用)
xml_stuck_on_other = other_start is not None and (now - other_start) >= quick_timeout
vision_enabled = getattr(args, "screenshot_wait", False) or xml_stuck_on_other
if vision_enabled and now - last_vision_at >= vision_interval:
last_vision_at = now last_vision_at = now
if xml_stuck_on_other and not getattr(args, "screenshot_wait", False):
print(f"wait_page_auto_vision label={label} reason=xml_stuck_on_other")
try: try:
detected = vision_detect_page() detected = vision_detect_page()
v_page = str(detected.get("page") or "other") v_page = str(detected.get("page") or "other")
@ -1340,19 +1374,22 @@ def vision_list_rows() -> dict:
def _xml_or_vision_click(nodes: list[dict], text: str, label: str, *, clickable: bool | None = None, contains: bool = False) -> bool: def _xml_or_vision_click(nodes: list[dict], text: str, label: str, *, clickable: bool | None = None, contains: bool = False) -> bool:
"""先 XML 找按钮点击,找不到则视觉 fallback。返回 True=已点击, False=都找不到。""" """优先用视觉 API 找按钮点击WebView 优先),找不到才试 XML。返回 True=已点击, False=都找不到。"""
matches = find_android_nodes(nodes, text, clickable=clickable, contains=contains) # 优先视觉(因为 H5 WebView 页面的按钮在 XML 里看不到!)
visible = [n for n in matches if n.get("bounds", "[0,0][0,0]") != "[0,0][0,0]"] print(f"{label}: trying vision first for 「{text}」...")
if visible:
click_node(visible[0], label)
return True
# 视觉 fallback
print(f"XML 未找到「{text}」按钮,视觉 fallback...")
pt = _vision_find_button(text) pt = _vision_find_button(text)
if pt: if pt:
run_adb(["shell", "input", "tap", str(pt[0]), str(pt[1])]) run_adb(["shell", "input", "tap", str(pt[0]), str(pt[1])])
print(f"vision_click {label} at {pt}") print(f"vision_click {label} at {pt}")
return True return True
# 视觉找不到,试 XML fallback
print(f"vision 未找到「{text}」按钮,XML fallback...")
matches = find_android_nodes(nodes, text, clickable=clickable, contains=contains)
visible = [n for n in matches if n.get("bounds", "[0,0][0,0]") != "[0,0][0,0]"]
if visible:
click_node(visible[0], label)
return True
print(f"both vision and XML failed to find 「{text}」 button")
return False return False
@ -2380,13 +2417,21 @@ def android_h5_page_run(args: argparse.Namespace) -> None:
print(f"retry_failed_attempt {masked}") print(f"retry_failed_attempt {masked}")
update_progress(masked, real_name, id8, "in_progress") update_progress(masked, real_name, id8, "in_progress")
run_adb(["shell", "input", "tap", str(tap_point[0]), str(tap_point[1])]) run_adb(["shell", "input", "tap", str(tap_point[0]), str(tap_point[1])])
wait_for_page_transition( detected_page = wait_for_page_transition(
args, args,
{"verify", "no_ticket", "invoice_info", "invoice_confirm", "success"}, {"verify", "no_ticket", "invoice_info", "invoice_confirm", "success"},
label=f"after_list_open:{masked}", label=f"after_list_open:{masked}",
network_sensitive=True, network_sensitive=True,
) )
# 核验页/待开票页在 H5 WebView 中,XML 文本可能看不到!拿到就立即处理
print(f"h5_after_list_open detected_page={detected_page}")
# 不管什么 detected_page直接调用 android_invoice_step 处理!
try:
android_invoice_step(args)
except RuntimeError as exc:
print(f"h5_after_list_open_step_error row={masked}: {exc}")
# 继续走子流程(待开票页/发票信息/确认/成功等)
result = drive_invoice_subflow(args, masked) result = drive_invoice_subflow(args, masked)
total_tapped += 1 total_tapped += 1
@ -2677,6 +2722,9 @@ def android_invoice_step(args: argparse.Namespace) -> None:
nodes = adb_dump_nodes() nodes = adb_dump_nodes()
texts = adb_visible_texts(nodes) texts = adb_visible_texts(nodes)
page_text = "\n".join(texts) page_text = "\n".join(texts)
# DEBUG: 输出当前页面 XML 文本,排查 H5 WebView 页面检测问题
xml_kind = xml_page_kind(nodes)
print(f"step_page_debug kind={xml_kind} texts={texts[:15]}")
roster = read_roster(project_path(args.roster)) roster = read_roster(project_path(args.roster))
if "是否允许“铁路12306”访问媒体" in page_text and "允许" in page_text: if "是否允许“铁路12306”访问媒体" in page_text and "允许" in page_text:
@ -2910,6 +2958,137 @@ def android_invoice_step(args: argparse.Namespace) -> None:
wait_for_page_transition(args, {"invoice_info", "invoice_confirm"}, label="after_ticket_open", network_sensitive=True) wait_for_page_transition(args, {"invoice_info", "invoice_confirm"}, label="after_ticket_open", network_sensitive=True)
return return
# ========== 核心新增:「待开票页」处理(多张票) ==========
# 当视觉检测到 "待开票" 页(显示多张票的页面)时,逐张处理!
# ==========
try:
detected = vision_detect_page()
v_page = str(detected.get("page") or "other")
summary = str(detected.get("summary") or "")
# 判断是否为「待开票」页(有多个「开具」按钮、「批量开具」、「待开票」标题)
is_ticket_list_page = ("待开票" in summary or "待开票" in page_text or
v_page in ["invoice_info", "list"] and "开具" in summary) or "待开票" in page_text
if is_ticket_list_page or ("开具" in summary and "批量" not in summary):
print(f"vision_ticket_list_page detected summary={summary}")
# ========== 处理待开票页:逐张点击「开具」 ==========
ticket_processed = 0
# 尝试多次点击「开具」直到所有票处理完或没有按钮为止
for attempt in range(10):
# 用视觉找「开具」按钮
print(f"ticket_list trying to find 开具 button (attempt {attempt+1})")
open_pt = _vision_find_button("开具")
if not open_pt:
# 没有按钮了,所有票处理完!
print(f"ticket_list no more 开具 buttons! tickets processed={ticket_processed}")
# 点「继续开票」回到主列表
if _xml_or_vision_click(adb_dump_nodes(), "继续开票", "ticket_list_continue"):
print(f"ticket_list clicking continue")
wait_for_page_transition(args, {"list", "no_ticket"}, label="after_ticket_list_continue", network_sensitive=False)
# 更新进度为 completed多票
in_progress = [row for row in read_progress() if row.get("status") == "in_progress"]
if in_progress:
row = in_progress[-1]
complete_progress_invoice(row, f"{ticket_processed} 张票均已开具完成")
# 更新 ticket_count
update_progress(row.get("masked_name", ""), row.get("real_name", ""),
row.get("id_last8", ""), "completed",
reason=f"{ticket_processed} 张票均已开具完成",
ticket_count=ticket_processed)
return
# 如果找不到「继续开票」,直接返回
print(f"ticket_list no continue button found, returning")
return
# 找到按钮,点击!
print(f"ticket_list clicking 开具 at {open_pt}")
run_adb(["shell", "input", "tap", str(open_pt[0]), str(open_pt[1])])
ticket_processed += 1
# ========== 等待并处理单票流程 ==========
wait_for_page_transition(args, {"invoice_info", "invoice_confirm", "success", "loading", "verify"},
label=f"after_ticket_list_open_{ticket_processed}",
network_sensitive=True)
print(f"ticket_list ticket {ticket_processed}: in sub-flow")
# 让下一轮 android_invoice_step 处理单票流程(发票信息 -> 确认 -> 成功 -> 继续)
time.sleep(0.5)
# 检查是否回到待开票页,或者完成了单票流程
# 返回主流程后继续循环处理下一张票!
# 这里不做 wait直接返回让主循环继续
return
except Exception as exc:
print(f"vision_ticket_list_page_error: {exc}")
# H5 WebView 页面(核验/发票信息等)的文本在 XML 中不可见,用视觉兜底
non_webview_terms = ["发票管理", "发票信息", "发票开具", "邮箱", "邮件", "扫码开票单", "请输入乘车人", "证件号不匹配", "发票预览", "发票批量", "发票下载"]
if not any(t in page_text for t in non_webview_terms):
try:
detected = vision_detect_page()
v_page = str(detected.get("page") or "other")
if v_page == "verify":
print(f"vision_step_verify via visual fallback")
in_progress = [row for row in read_progress() if row.get("status") == "in_progress"]
if not in_progress:
raise RuntimeError("视觉检测到核验页但无 in_progress 乘客")
row = in_progress[-1]
masked_name = row.get("masked_name", "")
id8 = row.get("id_last8", "")
real_name = row.get("real_name", "")
# 视觉找输入框和核验按钮
input_pt = _vision_find_button("证件号码")
if not input_pt:
# fallback: 屏幕中上方区域
w, h = get_device_screen_size()
input_pt = (w // 2, int(h * 0.38))
print(f"vision_verify_input_fallback pos={input_pt}")
run_adb(["shell", "input", "tap", str(input_pt[0]), str(input_pt[1])])
time.sleep(0.5)
adb_input_text(id8)
update_progress(masked_name, real_name, id8, "in_progress")
if not _xml_or_vision_click(adb_dump_nodes(), "核验", "verify_vision"):
raise RuntimeError("视觉核验页未找到核验按钮")
wait_for_page_transition(args, {"no_ticket", "invoice_info", "invoice_confirm", "success"}, label="after_verify_vision", network_sensitive=True)
return
if v_page == "invoice_info":
print(f"vision_step_invoice_info via visual fallback")
# 用视觉找提交按钮
if not _xml_or_vision_click(adb_dump_nodes(), "提交", "submit_invoice_info_vision"):
raise RuntimeError("视觉发票信息页未找到提交按钮")
wait_for_page_transition(args, {"invoice_confirm"}, label="after_submit_invoice_info_vision", network_sensitive=True)
return
if v_page == "invoice_confirm":
print(f"vision_step_invoice_confirm via visual fallback")
if not _xml_or_vision_click(adb_dump_nodes(), "确认", "invoice_confirm_vision"):
raise RuntimeError("视觉发票确认页未找到确认按钮")
wait_for_page_transition(args, {"success", "no_ticket"}, label="after_invoice_confirm_vision", network_sensitive=True)
return
if v_page == "loading":
print(f"vision_step_loading via visual fallback")
wait_for_page_transition(args, {"success", "mail_sent", "no_ticket"}, label="invoice_opening_vision", network_sensitive=True)
return
if v_page == "success":
print(f"vision_step_success via visual fallback")
# 如果在成功页,点击「继续开票」回到列表或待开票页
in_progress = [row for row in read_progress() if row.get("status") == "in_progress"]
if in_progress:
# 先更新当前这张票的进度(但可能是多票中的一张,暂时不标记 completed 直到全部完成)
# 点「继续开票」
if _xml_or_vision_click(adb_dump_nodes(), "继续开票", "success_continue"):
print(f"vision_success clicking continue")
wait_for_page_transition(args, {"list", "invoice_info", "verify"}, label="after_success_continue", network_sensitive=False)
return
# 如果「继续开票」找不到,可能已经在回列表的路上了
return
if v_page == "no_ticket":
print(f"vision_step_no_ticket via visual fallback")
in_progress = [row for row in read_progress() if row.get("status") == "in_progress"]
if in_progress:
update_progress(in_progress[-1].get("masked_name", ""), in_progress[-1].get("real_name", ""), in_progress[-1].get("id_last8", ""), "no_ticket", "12306 未查询到可开发票客票")
run_adb(["shell", "input", "keyevent", "4"])
time.sleep(2)
return
print(f"vision_step_unknown page={v_page} summary={detected.get('summary', '')}")
except Exception as exc:
print(f"vision_step_fallback_error: {exc}")
if "扫码开票单" in page_text: if "扫码开票单" in page_text:
passenger = first_matched_visible_passenger(nodes, roster, progress_names({"completed", "in_progress", "no_ticket"})) passenger = first_matched_visible_passenger(nodes, roster, progress_names({"completed", "in_progress", "no_ticket"}))
if not passenger: if not passenger:

47
scripts/reconnect-phone.sh Executable file
View File

@ -0,0 +1,47 @@
#!/bin/bash
# 华为手机快速重连脚本
ADB="/opt/homebrew/share/android-commandlinetools/platform-tools/adb"
PHONE_IP="192.168.1.206"
PHONE_PORT="5555"
echo "=== 华为手机快速重连 ==="
echo ""
# 1. 先断开所有连接
echo "1. 断开所有连接..."
$ADB disconnect
sleep 1
# 2. 尝试无线连接
echo "2. 尝试连接 $PHONE_IP:$PHONE_PORT..."
$ADB connect $PHONE_IP:$PHONE_PORT
sleep 2
# 3. 检查设备状态
echo ""
echo "3. 检查设备状态..."
devices_output="$($ADB devices)"
echo "$devices_output"
# 4. 检查是否成功连接
if echo "$devices_output" | grep -q "$PHONE_IP:$PHONE_PORT.*device" >/dev/null; then
echo ""
echo "✅ 无线连接成功!"
echo ""
echo "启动屏幕控制..."
scrcpy -s $PHONE_IP:$PHONE_PORT --window-title "手机屏幕控制Ctrl+Q 退出)" --max-size 800 --stay-awake --always-on-top
else
echo ""
echo "❌ 无线连接失败!"
echo ""
echo "请检查:"
echo " 1. 手机和电脑在同一 WiFi"
echo " 2. 手机已解锁"
echo " 3. 开发者选项里的开关都开着"
echo " 4. 手机 IP 是否正确(当前:$PHONE_IP"
echo ""
echo "如果以上都没问题,请先用 USB 线连接,然后运行:"
echo " $ADB tcpip 5555"
echo " $ADB connect $PHONE_IP:$PHONE_PORT"
fi

View File

@ -32,6 +32,7 @@ RUNNER_STOPPED_BY_USER: dict[str, object] | None = None
RECORDER: subprocess.Popen[str] | None = None RECORDER: subprocess.Popen[str] | None = None
RECORDER_SESSION: Path | None = None RECORDER_SESSION: Path | None = None
RECORDER_LOG = PROJECT_ROOT / "work" / "phone_recording.log" RECORDER_LOG = PROJECT_ROOT / "work" / "phone_recording.log"
SCRCPY: subprocess.Popen[str] | None = None
UPLOAD_DIR = PROJECT_ROOT / "data" / "input" UPLOAD_DIR = PROJECT_ROOT / "data" / "input"
@ -1021,9 +1022,14 @@ INDEX_HTML = r"""<!doctype html>
const stdout = (data && data.stdout) || ''; const stdout = (data && data.stdout) || '';
const m = stdout.match(/at_top iterations=(\d+)/); const m = stdout.match(/at_top iterations=(\d+)/);
if (m) return `已回到顶部 (滑了 ${m[1]} )`; if (m) return `已回到顶部 (滑了 ${m[1]} )`;
if (stdout.includes('max_iterations_reached')) return '已尽量回顶,但未稳定 请人工检查'; if (stdout.includes('max_iterations_reached')) return '已尽量回顶,但未稳定 - 请人工检查';
return '回顶部失败:' + ((data && data.stderr || '').split('\n').pop() || '未知'); return '回顶部失败:' + ((data && data.stderr || '').split('\n').pop() || '未知');
} }
if (path === '/api/scrcpy') {
if (data && data.started) return `屏幕控制已启动 (PID ${data.pid})`;
if (data && data.reason === 'already_running') return '屏幕控制已在运行中';
return '屏幕控制启动失败:' + ((data && (data.stderr || data.stdout)) || '未知错误');
}
if (path === '/api/upload-roster') { if (path === '/api/upload-roster') {
const stdout = (data && data.stdout) || ''; const stdout = (data && data.stdout) || '';
const rowsMatch = stdout.match(/roster_rows=(\d+)/); const rowsMatch = stdout.match(/roster_rows=(\d+)/);
@ -1035,9 +1041,13 @@ INDEX_HTML = r"""<!doctype html>
return '操作完成'; return '操作完成';
} }
async function postJson(path, body={}) { async function postJson(path, body={}) {
const data = await api(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }); try {
$('message').textContent = summarize(path, data); const data = await api(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
await refresh(); $('message').textContent = summarize(path, data);
await refresh();
} catch (err) {
$('message').textContent = '操作失败:' + (err.message || '网络错误');
}
} }
async function uploadRoster(file) { async function uploadRoster(file) {
if (!file) return; if (!file) return;
@ -1468,8 +1478,27 @@ class Handler(BaseHTTPRequestHandler):
result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500)
elif parsed.path == "/api/scrcpy": elif parsed.path == "/api/scrcpy":
result = subprocess.run(["scrcpy", "--window-title", "手机屏幕控制Ctrl+Q 退出)", "--max-size", "800", "--stay-awake", "--always-on-top"], cwd=PROJECT_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False) with RUNNER_LOCK:
write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) global SCRCPY
if SCRCPY is not None and SCRCPY.poll() is None:
write_json(self, {"return_code": 0, "started": False, "reason": "already_running", "stdout": "", "stderr": ""}, 200)
else:
try:
# 绑定当前连接的设备 serial避免换网络后旧 IP 残留成 offline 触发 scrcpy “多设备” 报错
device = device_runtime.require_single_device()
# cwd 回退:项目目录改名后旧进程的 PROJECT_ROOT 可能指向已不存在的路径,
# Popen(cwd=不存在的目录) 会抛 FileNotFoundError回退到进程当前 cwd 保命。
scrcpy_cwd = PROJECT_ROOT if PROJECT_ROOT.exists() else os.getcwd()
# 非阻塞启动 scrcpy!
SCRCPY = subprocess.Popen(
["scrcpy", "-s", device["serial"], "--window-title", "手机屏幕控制Ctrl+Q 退出)", "--max-size", "800", "--stay-awake", "--always-on-top"],
cwd=scrcpy_cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
write_json(self, {"return_code": 0, "started": True, "pid": SCRCPY.pid, "serial": device["serial"], "stdout": "", "stderr": ""}, 200)
except device_runtime.DeviceError as exc:
write_json(self, {"return_code": 1, "started": False, "stdout": "", "stderr": str(exc)}, 500)
except Exception as exc:
write_json(self, {"return_code": 1, "started": False, "stdout": "", "stderr": str(exc)}, 500)
elif parsed.path == "/api/recording": elif parsed.path == "/api/recording":
body = self.read_json() body = self.read_json()
if str(body.get("action", "start")) == "stop": if str(body.get("action", "start")) == "stop":