fix: accept invoice output args in vision run

This commit is contained in:
xinxin6623 2026-06-11 13:41:06 +08:00
parent 2648440f22
commit 81997121b3

View File

@ -865,6 +865,138 @@ def vision_detect_list_page() -> tuple[bool, str]:
return parsed.get("page") == "list", parsed.get("summary", "") return parsed.get("page") == "list", parsed.get("summary", "")
def vision_detect_page() -> dict:
"""截图 + 视觉判断当前页面类型,用于 XML 文本未稳定时兜底。"""
out = call_vision_subcommand(["detect-page"])
content = out.get("content", "")
try:
parsed = json.loads(content)
except Exception:
match = re.search(r"\{.*\}", content, re.S)
if not match:
return {"page": "other", "summary": content[:120], "raw": content}
try:
parsed = json.loads(match.group(0))
except Exception:
return {"page": "other", "summary": content[:120], "raw": content}
if not isinstance(parsed, dict):
return {"page": "other", "summary": str(parsed)[:120]}
return parsed
def xml_page_kind(nodes: list[dict[str, str]] | None = None) -> str:
"""用 uiautomator 文本快速判断页面,比视觉便宜,作为主判据。"""
if nodes is None:
nodes = adb_dump_nodes()
page_text = "\n".join(adb_visible_texts(nodes))
if (
"扫码开票单" in page_text
and "开票流水号" in page_text
and "开具" in page_text
and "请输入乘车人" not in page_text
and "发票开具成功" not in page_text
):
return "list"
if "请输入乘车人" in page_text and "证件号码后8位" in page_text:
return "verify"
if "发票信息确认" in page_text:
return "invoice_confirm"
if "发票信息" in page_text and ("提交" in page_text or "确认" in page_text):
return "invoice_info"
if "发票开具成功" in page_text:
return "success"
if "邮箱地址确认" in page_text:
return "email_confirm"
if "邮件发送完成,请注意查收。" in page_text:
return "mail_sent"
if "您没有可开具" in page_text or ("发票管理" in page_text and "客票" in page_text):
return "no_ticket"
if "发票开具中" in page_text or "发票正在开具中" in page_text:
return "loading"
if "业务须知" in page_text and "国家税务总局" in page_text:
return "instructions"
if "查询车票" in page_text or ("火车票" in page_text and "飞机票" in page_text):
return "home"
return "other"
def wait_for_page_transition(
args: argparse.Namespace,
expected_pages: set[str],
*,
label: str,
network_sensitive: bool = False,
) -> str:
"""等待页面切到目标状态;先高频 XML 轮询,必要时截图视觉兜底。
network_sensitive=True 的步骤通常触发 12306 后端请求,先给短等待,若仍是 loading/other
就自动延长,避免固定把每步都 sleep 很久
"""
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
vision_interval = float(getattr(args, "smart_wait_vision_interval", 1.2))
deadline = time.perf_counter() + max(quick_timeout, 0.1)
network_deadline = deadline + max(network_timeout, 0.0)
last_vision_at = 0.0
last_kind = "other"
phase = "quick"
while True:
nodes = adb_dump_nodes()
kind = xml_page_kind(nodes)
last_kind = kind
if kind in expected_pages:
print(f"wait_page_hit label={label} via=xml page={kind} phase={phase}")
return kind
now = time.perf_counter()
if getattr(args, "screenshot_wait", False) and now - last_vision_at >= vision_interval:
last_vision_at = now
try:
detected = vision_detect_page()
v_page = str(detected.get("page") or "other")
summary = str(detected.get("summary") or "")
if v_page == "success" and ("开具中" in summary or "正在开具" in summary):
v_page = "loading"
print(f"wait_page_probe label={label} via=vision page={v_page} summary={summary}")
if v_page in expected_pages:
return v_page
if v_page not in {"other", "launcher"}:
last_kind = v_page
except Exception as exc:
print(f"wait_page_vision_error label={label}: {exc}")
if now >= deadline:
if network_sensitive and now < network_deadline:
if phase != "network":
print(f"wait_page_more label={label} reason=network_or_loading last={last_kind} extra={network_timeout}s")
phase = "network"
time.sleep(0.5)
continue
print(f"wait_page_timeout label={label} expected={sorted(expected_pages)} last={last_kind}")
return last_kind
time.sleep(0.25)
def recover_to_h5_list(args: argparse.Namespace, row_label: str, *, reason: str) -> bool:
"""子流程断档时回列表;back 不够就扫码重进,让下次循环靠进度表续接。"""
print(f"recover_start row={row_label} reason={reason}")
if is_on_list_page():
print(f"recover_ok row={row_label} already_list")
return True
recovered = safe_back_until_list(max_back=getattr(args, "recover_max_back", 4))
if recovered:
print(f"recover_ok row={row_label} via=back")
return True
if is_on_instructions_page() or getattr(args, "recover_auto_scan", True):
print(f"recover_try_auto_scan row={row_label}")
if auto_scan_qr_to_list():
print(f"recover_ok row={row_label} via=auto_scan")
return True
print(f"recover_failed row={row_label}")
return False
def vision_list_rows() -> dict: def vision_list_rows() -> dict:
"""截屏 + 视觉识别列表页所有可见乘客行,返回 parsed dict(已反归一到绝对像素)。 """截屏 + 视觉识别列表页所有可见乘客行,返回 parsed dict(已反归一到绝对像素)。
@ -1330,13 +1462,7 @@ def drive_invoice_subflow(args: argparse.Namespace, row_label: str) -> str:
mark_clicked_as_runtime_error() mark_clicked_as_runtime_error()
consecutive_errors += 1 consecutive_errors += 1
if consecutive_errors >= 2: if consecutive_errors >= 2:
recovered = safe_back_until_list(max_back=4) recovered = recover_to_h5_list(args, row_label, reason="sub_step_errors")
if not recovered:
# 可能在法条说明页,尝试自动扫码回到列表
if is_on_instructions_page():
print(f"sub_recover via auto_scan row={row_label}")
if auto_scan_qr_to_list():
recovered = True
print(f"sub_abort row={row_label} consecutive_errors=2 recovered_to_list={recovered}") print(f"sub_abort row={row_label} consecutive_errors=2 recovered_to_list={recovered}")
return "error" return "error"
time.sleep(args.between_steps) time.sleep(args.between_steps)
@ -1692,7 +1818,12 @@ 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_short(args, ["请输入乘车人", "证件号码后8位", "发票管理", "您没有可开具"]) wait_for_page_transition(
args,
{"verify", "no_ticket", "invoice_info", "invoice_confirm", "success"},
label=f"after_list_open:{masked}",
network_sensitive=True,
)
result = drive_invoice_subflow(args, masked) result = drive_invoice_subflow(args, masked)
total_tapped += 1 total_tapped += 1
@ -1706,13 +1837,7 @@ def android_h5_page_run(args: argparse.Namespace) -> None:
elif p_row.get("masked_name") == masked and p_row.get("status") == "no_ticket": elif p_row.get("masked_name") == masked and p_row.get("status") == "no_ticket":
total_no_ticket += 1 total_no_ticket += 1
else: else:
recovered = safe_back_until_list(max_back=4) recovered = recover_to_h5_list(args, masked, reason=result)
if not recovered:
# 可能在法条说明页,尝试自动扫码
if is_on_instructions_page():
print(f"recover via auto_scan row={masked}")
if auto_scan_qr_to_list():
recovered = True
if not recovered: if not recovered:
print(f"STOP row={masked} sub_result={result} 无法回到列表页") print(f"STOP row={masked} sub_result={result} 无法回到列表页")
print(f"进度已保存,手机恢复后从 UI 重新启动即可续接") print(f"进度已保存,手机恢复后从 UI 重新启动即可续接")
@ -1722,6 +1847,9 @@ def android_h5_page_run(args: argparse.Namespace) -> None:
# back 后 scroll 重置到顶部,跳出 actions 循环重新截屏 # back 后 scroll 重置到顶部,跳出 actions 循环重新截屏
if idx < len(actions) - 1: if idx < len(actions) - 1:
print(f" (back 重置 scroll,重新截屏找下一个)") print(f" (back 重置 scroll,重新截屏找下一个)")
if args.max_actions and total_tapped >= args.max_actions:
print(f"stop reason=max_actions total_tapped={total_tapped}")
return
break break
# dry-run 模式下当前屏全部看完后自动滚动 # dry-run 模式下当前屏全部看完后自动滚动
@ -1925,18 +2053,18 @@ def android_invoice_step(args: argparse.Namespace) -> None:
if "邮箱地址确认" in page_text: if "邮箱地址确认" in page_text:
if not _xml_or_vision_click(nodes, "确认", "email_confirm", clickable=True): if not _xml_or_vision_click(nodes, "确认", "email_confirm", clickable=True):
raise RuntimeError("邮箱确认页未找到确认按钮") raise RuntimeError("邮箱确认页未找到确认按钮")
wait_short(args, ["邮件发送完成", "发票开具成功"]) wait_for_page_transition(args, {"mail_sent", "success"}, label="after_email_confirm", network_sensitive=True)
return return
if "发票信息确认" in page_text: if "发票信息确认" in page_text:
if not _xml_or_vision_click(nodes, "确认", "invoice_info_confirm", clickable=True): if not _xml_or_vision_click(nodes, "确认", "invoice_info_confirm", clickable=True):
raise RuntimeError("发票信息确认页未找到确认按钮") raise RuntimeError("发票信息确认页未找到确认按钮")
wait_long(args, ["发票开具成功"]) wait_for_page_transition(args, {"success", "no_ticket"}, label="after_invoice_confirm", network_sensitive=True)
return return
if "发票开具中" in page_text or "发票正在开具中" in page_text: if "发票开具中" in page_text or "发票正在开具中" in page_text:
print("invoice_opening_wait") print("invoice_opening_wait")
wait_and_probe(1, getattr(args, "wait_secondary", 10), ["发票开具成功", "邮件发送完成", "发票管理"]) wait_for_page_transition(args, {"success", "mail_sent", "no_ticket"}, label="invoice_opening", network_sensitive=True)
return return
if "发票开具成功" in page_text: if "发票开具成功" in page_text:
@ -1953,7 +2081,7 @@ def android_invoice_step(args: argparse.Namespace) -> None:
row = current_row row = current_row
update_progress(row.get("masked_name", ""), row.get("real_name", ""), row.get("id_last8", ""), "completed") update_progress(row.get("masked_name", ""), row.get("real_name", ""), row.get("id_last8", ""), "completed")
if _xml_or_vision_click(nodes, "发送至邮箱", "send_email"): if _xml_or_vision_click(nodes, "发送至邮箱", "send_email"):
wait_short(args, ["邮箱地址确认", "邮件发送完成"]) wait_for_page_transition(args, {"email_confirm", "mail_sent"}, label="after_send_email", network_sensitive=True)
return return
if success_action == "download" and in_progress: if success_action == "download" and in_progress:
local_path = click_invoice_download(args, nodes, row_label) local_path = click_invoice_download(args, nodes, row_label)
@ -1973,6 +2101,7 @@ def android_invoice_step(args: argparse.Namespace) -> None:
update_progress(row.get("masked_name", ""), row.get("real_name", ""), row.get("id_last8", ""), "completed") update_progress(row.get("masked_name", ""), row.get("real_name", ""), row.get("id_last8", ""), "completed")
if not _xml_or_vision_click(nodes, "继续开票", "continue_invoice"): if not _xml_or_vision_click(nodes, "继续开票", "continue_invoice"):
raise RuntimeError("成功页未找到继续开票按钮(XML+视觉均失败)") raise RuntimeError("成功页未找到继续开票按钮(XML+视觉均失败)")
wait_for_page_transition(args, {"list", "instructions", "no_ticket"}, label="after_continue_invoice", network_sensitive=False)
return return
if "请输入乘车人" in page_text and "证件号码后8位" in page_text: if "请输入乘车人" in page_text and "证件号码后8位" in page_text:
@ -2001,7 +2130,7 @@ def android_invoice_step(args: argparse.Namespace) -> None:
update_progress(masked_name, real_name, id8, "in_progress") update_progress(masked_name, real_name, id8, "in_progress")
if not _xml_or_vision_click(adb_dump_nodes(), "核验", "verify"): if not _xml_or_vision_click(adb_dump_nodes(), "核验", "verify"):
raise RuntimeError("未找到核验按钮(XML+视觉均失败)") raise RuntimeError("未找到核验按钮(XML+视觉均失败)")
wait_short(args, ["发票管理", "待开票", "发票信息"]) wait_for_page_transition(args, {"no_ticket", "invoice_info", "invoice_confirm", "success"}, label="after_verify", network_sensitive=True)
return return
if "身份核验" in page_text and "证件号码的后8位" in page_text: if "身份核验" in page_text and "证件号码的后8位" in page_text:
@ -2018,7 +2147,7 @@ def android_invoice_step(args: argparse.Namespace) -> None:
adb_input_text(matches[0]["身份证后8位"]) adb_input_text(matches[0]["身份证后8位"])
if not _xml_or_vision_click(adb_dump_nodes(), "开始核验", "agent_verify"): if not _xml_or_vision_click(adb_dump_nodes(), "开始核验", "agent_verify"):
raise RuntimeError("代办人身份核验页未找到开始核验按钮") raise RuntimeError("代办人身份核验页未找到开始核验按钮")
wait_short(args, ["扫码开票单", "开票流水号"]) wait_for_page_transition(args, {"list"}, label="after_agent_verify", network_sensitive=True)
return return
if "发票信息" in page_text and "提交" in page_text: if "发票信息" in page_text and "提交" in page_text:
@ -2028,7 +2157,7 @@ def android_invoice_step(args: argparse.Namespace) -> None:
raise RuntimeError("发票信息页未检测到默认抬头/税号,暂停,需手工确认") raise RuntimeError("发票信息页未检测到默认抬头/税号,暂停,需手工确认")
if not _xml_or_vision_click(nodes, "提交", "submit_invoice_info"): if not _xml_or_vision_click(nodes, "提交", "submit_invoice_info"):
raise RuntimeError("未找到提交按钮(XML+视觉均失败)") raise RuntimeError("未找到提交按钮(XML+视觉均失败)")
wait_short(args, ["发票信息确认"]) wait_for_page_transition(args, {"invoice_confirm"}, label="after_submit_invoice_info", network_sensitive=True)
return return
if "发票管理" in page_text and "您没有可开具电子发票的客票" in page_text: if "发票管理" in page_text and "您没有可开具电子发票的客票" in page_text:
@ -2052,13 +2181,13 @@ def android_invoice_step(args: argparse.Namespace) -> None:
pt = _vision_find_button("开具") pt = _vision_find_button("开具")
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])])
wait_short(args, ["发票信息"]) wait_for_page_transition(args, {"invoice_info", "invoice_confirm"}, label="after_ticket_open_vision", network_sensitive=True)
return return
raise RuntimeError("发票管理页未找到单张开具按钮(XML+视觉均失败)") raise RuntimeError("发票管理页未找到单张开具按钮(XML+视觉均失败)")
# Avoid bottom batch button; choose first visible normal button. # Avoid bottom batch button; choose first visible normal button.
node = sorted(open_nodes, key=node_center_y)[0] node = sorted(open_nodes, key=node_center_y)[0]
click_node(node, "ticket_open") click_node(node, "ticket_open")
wait_short(args, ["发票信息"]) wait_for_page_transition(args, {"invoice_info", "invoice_confirm"}, label="after_ticket_open", network_sensitive=True)
return return
if "扫码开票单" in page_text: if "扫码开票单" in page_text:
@ -2068,7 +2197,7 @@ def android_invoice_step(args: argparse.Namespace) -> None:
masked_name, row, button = passenger masked_name, row, button = passenger
print(f"passenger={masked_name} matched={row.get('姓名')} id_last8={row.get('身份证后8位')}") print(f"passenger={masked_name} matched={row.get('姓名')} id_last8={row.get('身份证后8位')}")
click_node(button, f"list_open:{masked_name}") click_node(button, f"list_open:{masked_name}")
wait_short(args, ["请输入乘车人", "证件号码后8位"]) wait_for_page_transition(args, {"verify", "no_ticket", "invoice_info"}, label=f"after_xml_list_open:{masked_name}", network_sensitive=True)
return return
if "com.bbk.launcher2" in page_text or "launcher" in page_text: if "com.bbk.launcher2" in page_text or "launcher" in page_text:
@ -2510,6 +2639,8 @@ def build_parser() -> argparse.ArgumentParser:
p_vision_run = subparsers.add_parser("android-vision-page-run", help="视觉驱动批量开票:截图识别 -> anchor 去重 -> 按页批次 tap -> 子流程 -> 滑动") p_vision_run = subparsers.add_parser("android-vision-page-run", help="视觉驱动批量开票:截图识别 -> anchor 去重 -> 按页批次 tap -> 子流程 -> 滑动")
p_vision_run.add_argument("--roster", default="work/passenger_roster.csv") p_vision_run.add_argument("--roster", default="work/passenger_roster.csv")
p_vision_run.add_argument("--send-email", action="store_true") p_vision_run.add_argument("--send-email", action="store_true")
p_vision_run.add_argument("--invoice-success-action", choices=["download", "email", "continue"], default="", help="成功页动作: download=下载发票,email=发送邮箱,continue=仅继续")
p_vision_run.add_argument("--invoice-output-dir", default="", help="PDF/OFD/XML 保存到电脑本地目录")
p_vision_run.add_argument("--fast", action="store_true") p_vision_run.add_argument("--fast", action="store_true")
p_vision_run.add_argument("--dry-run", action="store_true", help="只识别+匹配,不真 tap;用于验证视觉精度") p_vision_run.add_argument("--dry-run", action="store_true", help="只识别+匹配,不真 tap;用于验证视觉精度")
p_vision_run.add_argument("--no-precheck", dest="precheck", action="store_false", help="跳过开始前的视觉自检") p_vision_run.add_argument("--no-precheck", dest="precheck", action="store_false", help="跳过开始前的视觉自检")
@ -2543,6 +2674,12 @@ def build_parser() -> argparse.ArgumentParser:
p_h5_run.add_argument("--wait-secondary", type=int, default=10, help="扩展等待秒数") p_h5_run.add_argument("--wait-secondary", type=int, default=10, help="扩展等待秒数")
p_h5_run.add_argument("--short-wait", type=int, default=1, help="短等待秒数(页面切换)") p_h5_run.add_argument("--short-wait", type=int, default=1, help="短等待秒数(页面切换)")
p_h5_run.add_argument("--between-steps", type=float, default=0.3) p_h5_run.add_argument("--between-steps", type=float, default=0.3)
p_h5_run.add_argument("--screenshot-wait", action="store_true", help="等待页面跳转时用截图视觉判页兜底")
p_h5_run.add_argument("--smart-wait-quick", type=float, default=1.2, help="非网络跳转的快速等待秒数")
p_h5_run.add_argument("--smart-wait-network", type=float, default=12.0, help="疑似走网络时额外等待秒数")
p_h5_run.add_argument("--smart-wait-vision-interval", type=float, default=1.5, help="截图判页最小间隔秒数")
p_h5_run.add_argument("--recover-max-back", type=int, default=4, help="子流程断档时最多 back 次数")
p_h5_run.add_argument("--no-recover-auto-scan", dest="recover_auto_scan", action="store_false", help="回列表失败时不自动扫码重进")
p_h5_run.add_argument("--retry-failed-once", dest="retry_failed_once", type=int, default=1, help="当前运行内对可见 failed/error 人员最多重试次数") p_h5_run.add_argument("--retry-failed-once", dest="retry_failed_once", type=int, default=1, help="当前运行内对可见 failed/error 人员最多重试次数")
p_h5_run.add_argument("--no-resume-after-last-progress", dest="resume_after_last_progress", action="store_false", help="不按进度表最后处理人定位续跑") p_h5_run.add_argument("--no-resume-after-last-progress", dest="resume_after_last_progress", action="store_false", help="不按进度表最后处理人定位续跑")
# H5 滚动参数(页面内滚动,不是翻页) # H5 滚动参数(页面内滚动,不是翻页)