#!/usr/bin/env python3 """Local web UI for railway invoice automation.""" from __future__ import annotations import base64 import cgi import csv import json import os import re import sys import shutil import subprocess import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse import invoice_tool import calibrate import device_runtime PROJECT_ROOT = invoice_tool.PROJECT_ROOT PYTHON = sys.executable RUNNER: subprocess.Popen[str] | None = None RUNNER_LOCK = threading.RLock() RUNNER_LOG = PROJECT_ROOT / "work" / "web_runner.log" RUNNER_STOPPED_BY_USER: dict[str, object] | None = None RECORDER: subprocess.Popen[str] | None = None RECORDER_SESSION: Path | None = None RECORDER_LOG = PROJECT_ROOT / "work" / "phone_recording.log" UPLOAD_DIR = PROJECT_ROOT / "data" / "input" def clear_runner_log(reason: str = "") -> None: RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True) header = f"[log cleared] {reason}\n" if reason else "" RUNNER_LOG.write_text(header, encoding="utf-8") def csv_rows(path: Path) -> list[dict[str, str]]: if not path.exists(): return [] with path.open("r", encoding="utf-8-sig", newline="") as fh: return list(csv.DictReader(fh)) def backup_runtime_tables(label: str = "start") -> dict[str, object]: """启动批处理前备份运行期三张表。""" stamp = time.strftime("%Y%m%d-%H%M%S") backup_dir = PROJECT_ROOT / "archive" / "runtime-backups" / f"{stamp}-{label}" copied: list[str] = [] for path in [invoice_tool.PROGRESS_CSV, invoice_tool.QR_ORDER_CSV, invoice_tool.PAGE_QUEUE_CSV]: if not path.exists(): continue backup_dir.mkdir(parents=True, exist_ok=True) dest = backup_dir / path.name shutil.copy2(path, dest) copied.append(str(dest)) return {"dir": str(backup_dir), "files": copied} def reset_current_round() -> dict[str, object]: """归档本轮开票记录 + output 交付目录,然后清空以便下一轮从头开始。 - roster / tasks / agent_id_last8 不动 - runner 若在跑则拒绝 - 归档到 archive/round-resets//,同时保存被清的清单 """ with RUNNER_LOCK: if RUNNER is not None and RUNNER.poll() is None: return {"reset": False, "reason": "runner_running", "message": "运行中不能清理,请先停止"} stamp = time.strftime("%Y%m%d-%H%M%S") archive_dir = PROJECT_ROOT / "archive" / "round-resets" / stamp archived: list[str] = [] removed: list[str] = [] runtime_paths = [ invoice_tool.PROGRESS_CSV, invoice_tool.PAGE_QUEUE_CSV, invoice_tool.QR_ORDER_CSV, invoice_tool.LAST_QR_URL, invoice_tool.LAST_QR_IMAGE, RUNNER_LOG, ] for path in runtime_paths: if not path.exists(): continue archive_dir.mkdir(parents=True, exist_ok=True) dest = archive_dir / path.name shutil.copy2(path, dest) archived.append(str(dest)) try: path.unlink() removed.append(str(path)) except OSError: pass config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH) output_dir = invoice_tool.project_path(config.invoice_output_dir) output_archived = "" if output_dir.exists() and any(output_dir.iterdir()): archive_dir.mkdir(parents=True, exist_ok=True) dest = archive_dir / f"output-{output_dir.name}" shutil.move(str(output_dir), str(dest)) output_dir.mkdir(parents=True, exist_ok=True) output_archived = str(dest) removed.append(str(output_dir)) clear_runner_log(f"round reset -> {archive_dir}") return { "reset": True, "archive_dir": str(archive_dir) if archived or output_archived else "", "archived_files": archived, "output_archived": output_archived, "removed": removed, } def write_json(handler: BaseHTTPRequestHandler, data: object, status: int = 200) -> None: body = json.dumps(data, ensure_ascii=False).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) def device_status() -> dict[str, object]: profile_status = calibrate.get_device_status() if profile_status.get("status") == "unavailable": return { "connected": False, "state": "unknown", "current_package": "", "current_activity": "", "in_12306": False, "message": str(profile_status.get("error") or "未检测到手机"), "profile": profile_status, } state = invoice_tool.run_adb(["get-state"], check=False) connected = state.returncode == 0 and state.stdout.strip() == "device" status: dict[str, object] = { "connected": connected, "state": state.stdout.strip() or "unknown", "current_package": "", "current_activity": "", "in_12306": False, "message": "手机已连接" if connected else (state.stderr.strip() or state.stdout.strip() or "未检测到手机"), "profile": profile_status, } if not connected: return status window = invoice_tool.run_adb(["shell", "dumpsys", "window"], check=False) focus_lines = [line.strip() for line in window.stdout.splitlines() if "mCurrentFocus=" in line or "mFocusedApp=" in line] focus_text = " ".join(focus_lines) match = re.search(r"u0\s+([^/\s]+)/([^\s}]+)", focus_text) if match: status["current_package"] = match.group(1) status["current_activity"] = match.group(2) status["in_12306"] = match.group(1) == "com.MobileTicket" status["message"] = "当前在 12306" if status["in_12306"] else f"当前在 {match.group(1)}" else: status["message"] = "手机已连接,未识别当前前台 App" return status def connect_wireless_device(host: str, port: object) -> dict[str, object]: result = device_runtime.connect_wireless(host, port) result["device"] = device_status() if not result["connected"]: result["message"] = result["stderr"] or result["stdout"] or "未连接成功:请确认手机与电脑同一 Wi‑Fi,并已开启网络 ADB 调试。" return result PROGRESS_LABELS = { "completed": {"text": "已开票", "tone": "ok", "reason": ""}, "no_ticket": {"text": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"}, "in_progress": {"text": "处理中", "tone": "info", "reason": ""}, "failed": {"text": "失败", "tone": "bad", "reason": "上次运行未完成"}, "error": {"text": "运行错误", "tone": "bad", "reason": "脚本异常"}, "pending": {"text": "未操作", "tone": "pending", "reason": ""}, } MATCH_REASON = { "no_match": "名单里没有这个脱敏名", "ambiguous": "同名多人,无法唯一匹配", "no_button": "未识别到「开具」按钮", } def label_for_queue_row(row: dict[str, str], progress_by_key: dict, progress_by_masked: dict) -> dict[str, str]: masked = row.get("masked_name", "") real = row.get("real_name", "") id8 = row.get("id_last8", "") queue_status = row.get("queue_status", "") match_status = row.get("match_status", "") progress_row: dict[str, str] | None = None if real and id8: progress_row = progress_by_key.get((real, id8)) if progress_row is None and masked: progress_row = progress_by_masked.get(masked) pstatus = progress_row.get("status", "") if progress_row else "" progress_reason = progress_row.get("reason", "") if progress_row else "" if pstatus == "completed": return {"display": "已开票", "tone": "ok", "reason": progress_reason or "总进度表已完成"} if pstatus == "no_ticket": return {"display": "无可开票", "tone": "muted", "reason": progress_reason or "12306 未查询到可开发票客票"} if pstatus == "in_progress" or queue_status == "clicked": return {"display": "处理中", "tone": "info", "reason": progress_reason or "脚本正在处理这行"} if pstatus == "failed": return {"display": "失败", "tone": "bad", "reason": progress_reason or "上次运行未完成"} if pstatus == "error": return {"display": "运行错误", "tone": "bad", "reason": progress_reason or "脚本异常"} if match_status in MATCH_REASON: return {"display": "识别问题", "tone": "warn", "reason": MATCH_REASON[match_status]} if match_status == "runtime_error": return {"display": "运行错误", "tone": "bad", "reason": "上次处理异常"} if match_status == "skip_progress": return {"display": "已处理", "tone": "muted", "reason": "进度表已记录,跳过"} if match_status == "resume_in_progress": return {"display": "继续中", "tone": "info", "reason": "上次未完成,本次继续"} if queue_status == "pending": return {"display": "待处理", "tone": "pending", "reason": ""} if queue_status == "skipped": return {"display": "跳过", "tone": "muted", "reason": match_status or ""} return {"display": queue_status or "未知", "tone": "muted", "reason": match_status or ""} def label_for_progress_status(status: str) -> dict[str, str]: return PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""}) def status_payload() -> dict[str, object]: roster = csv_rows(invoice_tool.ROSTER_CSV) progress = csv_rows(invoice_tool.PROGRESS_CSV) queue_raw = csv_rows(invoice_tool.PAGE_QUEUE_CSV) qr_order_raw = csv_rows(invoice_tool.QR_ORDER_CSV) by_person_key = { (row.get("real_name", ""), row.get("id_last8", "")): row for row in progress if row.get("real_name") or row.get("id_last8") } by_real_name = {row.get("real_name", ""): row for row in progress if row.get("real_name")} by_masked = {row.get("masked_name", ""): row for row in progress if row.get("masked_name")} roster_status = [] counts = {"total": len(roster), "completed": 0, "failed": 0, "in_progress": 0, "pending": 0} queue = [] for row in queue_raw: label = label_for_queue_row(row, by_person_key, by_masked) item = dict(row) item["display_status"] = label["display"] item["display_tone"] = label["tone"] item["display_reason"] = label["reason"] matched_progress = by_person_key.get((row.get("real_name", ""), row.get("id_last8", ""))) or by_masked.get(row.get("masked_name", "")) item["ticket_count"] = matched_progress.get("ticket_count", "") if matched_progress else "" queue.append(item) qr_order = [] for row in qr_order_raw: real = row.get("real_name", "") id8 = row.get("id_last8", "") masked = row.get("masked_name", "") progress_row = by_person_key.get((real, id8)) if (real or id8) else None if progress_row is None and masked: progress_row = by_masked.get(masked) status = progress_row.get("status", "pending") if progress_row else "pending" if progress_row is None and not (real or id8): label = {"text": "名单未匹配", "tone": "warn", "reason": ""} status = "no_match" else: label = label_for_progress_status(status) item = dict(row) item["status"] = status item["display_status"] = label["text"] item["display_tone"] = label["tone"] item["display_reason"] = progress_row.get("reason", label.get("reason", "")) if progress_row else label.get("reason", "") item["updated_at"] = progress_row.get("updated_at", "") if progress_row else "" item["ticket_count"] = progress_row.get("ticket_count", "") if progress_row else "" qr_order.append(item) qr_seq_by_person_key = { (row.get("real_name", ""), row.get("id_last8", "")): int(row.get("seq", 0) or 0) for row in qr_order_raw if row.get("real_name") and row.get("id_last8") } qr_seq_by_masked = { row.get("masked_name", ""): int(row.get("seq", 0) or 0) for row in qr_order_raw if row.get("masked_name") } counts["no_ticket"] = 0 for idx, person in enumerate(roster): person_key = (person.get("姓名", ""), person.get("身份证后8位", "")) progress_row = by_person_key.get(person_key) or by_real_name.get(person.get("姓名", "")) status = progress_row.get("status", "pending") if progress_row else "pending" label = PROGRESS_LABELS.get(status, {"text": status or "未操作", "tone": "pending", "reason": ""}) if status == "completed": counts["completed"] += 1 elif status == "no_ticket": counts["no_ticket"] += 1 elif status in {"failed", "error"}: counts["failed"] += 1 elif status == "in_progress": counts["in_progress"] += 1 else: counts["pending"] += 1 masked = progress_row.get("masked_name", "") if progress_row else "" qr_seq = qr_seq_by_person_key.get(person_key) if qr_seq is None and masked: qr_seq = qr_seq_by_masked.get(masked) if qr_seq is None: qr_seq = 999999 roster_status.append( { "seq": qr_seq if qr_seq != 999999 else "", "name": person.get("姓名", ""), "id_last8": person.get("身份证后8位", ""), "status": status, "display_status": label["text"], "display_tone": label["tone"], "masked_name": masked, "ticket_count": progress_row.get("ticket_count", "") if progress_row else "", "updated_at": progress_row.get("updated_at", "") if progress_row else "", "reason": progress_row.get("reason", "") if progress_row else label.get("reason", ""), "_qr_seq": qr_seq, "_roster_idx": idx, } ) roster_status.sort(key=lambda r: (r.get("_qr_seq", 999999), r.get("_roster_idx", 0))) for r in roster_status: r.pop("_qr_seq", None) r.pop("_roster_idx", None) with RUNNER_LOCK: running = RUNNER is not None and RUNNER.poll() is None return_code = None if RUNNER is None else RUNNER.poll() stopped_by_user = bool( RUNNER_STOPPED_BY_USER and RUNNER is not None and RUNNER_STOPPED_BY_USER.get("pid") == RUNNER.pid ) log_tail = "" if RUNNER_LOG.exists(): log_tail = "\n".join(RUNNER_LOG.read_text(encoding="utf-8", errors="ignore").splitlines()[-80:]) runner_status = "running" if running else "stopped" if (not running) and return_code not in (None, 0) and not stopped_by_user: runner_status = "failed" config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH) return { "counts": counts, "roster": roster_status, "queue": queue, "qr_order": qr_order, "progress": progress, "runner": {"running": running, "status": runner_status, "return_code": return_code, "stopped_by_user": stopped_by_user, "log_tail": log_tail}, "device": device_status(), "config": { "company_title": config.company_title, "tax_id": config.tax_id, "receiver_email": config.receiver_email, "invoice_output_dir": config.invoice_output_dir, "invoice_success_action": config.invoice_success_action, "invoice_output_absolute_dir": str(invoice_tool.project_path(config.invoice_output_dir)), }, "files": { "roster": str(invoice_tool.ROSTER_CSV), "progress": str(invoice_tool.PROGRESS_CSV), "queue": str(invoice_tool.PAGE_QUEUE_CSV), "qr_order": str(invoice_tool.QR_ORDER_CSV), }, } def save_invoice_settings(invoice_success_action: str, invoice_output_dir: str) -> dict[str, object]: allowed_actions = {"download", "email", "continue"} action = invoice_success_action if invoice_success_action in allowed_actions else "download" output_dir = invoice_output_dir.strip() or "output/invoices" config_path = invoice_tool.CONFIG_LOCAL_PATH data: dict[str, object] = {} if config_path.exists(): data = json.loads(config_path.read_text(encoding="utf-8")) data["invoice_success_action"] = action data["invoice_output_dir"] = output_dir config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") project_dir = invoice_tool.project_path(output_dir) project_dir.mkdir(parents=True, exist_ok=True) return {"saved": True, "invoice_success_action": action, "invoice_output_dir": output_dir, "absolute_dir": str(project_dir)} def start_runner(max_batches: int, max_actions: int, send_email: bool, fast: bool, dry_run: bool = False, scroll_top_first: bool = True, mode: str = "h5", invoice_success_action: str = "", invoice_output_dir: str = "", final_retry_pass: bool = True) -> dict[str, object]: global RUNNER, RUNNER_STOPPED_BY_USER with RUNNER_LOCK: if RUNNER is not None and RUNNER.poll() is None: return {"started": False, "reason": "runner_already_running"} if RECORDER is not None and RECORDER.poll() is None: return {"started": False, "reason": "recording_running", "message": "正在录制人工演示,请先停止录制再启动批处理"} profile = calibrate.get_device_status() if not dry_run and profile.get("status") != "verified": return { "started": False, "reason": "device_not_verified", "message": "当前手机尚未完成校准,请先进入“设备校准”完成全部页面验证。", "profile": profile, } backup = backup_runtime_tables("start") RUNNER_STOPPED_BY_USER = None RUNNER_LOG.parent.mkdir(parents=True, exist_ok=True) RUNNER_LOG.write_text("", encoding="utf-8") log_fh = RUNNER_LOG.open("a", encoding="utf-8") if backup["files"]: log_fh.write(f"[prelude] runtime_backup dir={backup['dir']} files={len(backup['files'])}\n") log_fh.flush() prelude: list[str] = [] if scroll_top_first: top_result = subprocess.run( [PYTHON, "scripts/invoice_tool.py", "android-page-top"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False, ) log_fh.write(f"[prelude] android-page-top rc={top_result.returncode}\n{top_result.stdout}{top_result.stderr}\n") log_fh.flush() prelude.append(f"scroll_top: {top_result.stdout.strip().splitlines()[-1] if top_result.stdout.strip() else 'no_output'}") if mode == "h5": sub_cmd = "android-h5-page-run" else: sub_cmd = "android-vision-page-run" cmd = [PYTHON, "-u", "scripts/invoice_tool.py", sub_cmd, "--max-batches", str(max_batches)] if mode == "h5": cmd.append("--no-resume-after-last-progress") if max_actions > 0: cmd += ["--max-actions", str(max_actions)] if send_email: cmd.append("--send-email") if invoice_success_action: cmd += ["--invoice-success-action", invoice_success_action] if invoice_output_dir: cmd += ["--invoice-output-dir", invoice_output_dir] if fast: cmd.append("--fast") if dry_run: cmd.append("--dry-run") if not final_retry_pass: cmd.append("--no-final-retry-pass") RUNNER = subprocess.Popen(cmd, cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True) return {"started": True, "pid": RUNNER.pid, "cmd": cmd, "prelude": prelude, "mode": mode, "backup": backup} def stop_runner() -> dict[str, object]: global RUNNER, RUNNER_STOPPED_BY_USER with RUNNER_LOCK: if RUNNER is None or RUNNER.poll() is not None: return {"stopped": False, "reason": "runner_not_running"} RUNNER_STOPPED_BY_USER = {"pid": RUNNER.pid, "at": time.strftime("%Y-%m-%d %H:%M:%S")} RUNNER.terminate() for _ in range(10): if RUNNER.poll() is not None: return {"stopped": True, "stopped_by_user": True} time.sleep(0.2) RUNNER.kill() return {"stopped": True, "killed": True, "stopped_by_user": True} def start_automatic_calibration(masked_name: str) -> dict[str, object]: """对用户在当前列表页明确选择的一人执行一次真实全流程校准。""" global RUNNER, RUNNER_STOPPED_BY_USER masked_name = masked_name.strip() if not masked_name: return {"started": False, "reason": "missing_masked_name"} with RUNNER_LOCK: if RUNNER is not None and RUNNER.poll() is None: return {"started": False, "reason": "runner_already_running"} if RECORDER is not None and RECORDER.poll() is None: return {"started": False, "reason": "recording_running", "message": "正在录制人工演示,请先停止录制"} backup = backup_runtime_tables("device-calibration") RUNNER_STOPPED_BY_USER = None log_path = PROJECT_ROOT / "work" / "device_calibration.log" log_fh = log_path.open("w", encoding="utf-8") cmd = [PYTHON, "-u", "scripts/device_calibrate.py", "--masked-name", masked_name] RUNNER = subprocess.Popen(cmd, cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True) return {"started": True, "pid": RUNNER.pid, "cmd": cmd, "backup": backup, "log": str(log_path)} def recording_status() -> dict[str, object]: with RUNNER_LOCK: running = RECORDER is not None and RECORDER.poll() is None return { "running": running, "pid": RECORDER.pid if running and RECORDER is not None else None, "session": str(RECORDER_SESSION.relative_to(PROJECT_ROOT)) if RECORDER_SESSION else "", "return_code": None if RECORDER is None else RECORDER.poll(), } def start_phone_recording() -> dict[str, object]: """开始被动录制,用户可以通过手机或 scrcpy 自行完成演示。""" global RECORDER, RECORDER_SESSION with RUNNER_LOCK: if RECORDER is not None and RECORDER.poll() is None: return {"started": False, "reason": "recording_already_running", **recording_status()} if RUNNER is not None and RUNNER.poll() is None: return {"started": False, "reason": "runner_running", "message": "批处理运行中,不能同时录制演示"} try: device_runtime.require_single_device() except device_runtime.DeviceError as exc: return {"started": False, "reason": "device_unavailable", "message": str(exc)} stamp = time.strftime("%Y%m%d-%H%M%S") RECORDER_SESSION = PROJECT_ROOT / "work" / "replay" / f"{stamp}_manual" RECORDER_LOG.parent.mkdir(parents=True, exist_ok=True) log_fh = RECORDER_LOG.open("w", encoding="utf-8") RECORDER = subprocess.Popen( [PYTHON, "-u", "scripts/record_android_flow.py", "--output", str(RECORDER_SESSION)], cwd=PROJECT_ROOT, stdout=log_fh, stderr=subprocess.STDOUT, text=True, ) return {"started": True, **recording_status()} def stop_phone_recording() -> dict[str, object]: global RECORDER with RUNNER_LOCK: if RECORDER is None or RECORDER.poll() is not None: return {"stopped": False, "reason": "recording_not_running", **recording_status()} RECORDER.terminate() try: RECORDER.wait(timeout=5) except subprocess.TimeoutExpired: RECORDER.kill() return {"stopped": True, **recording_status()} def build_vision_page_queue() -> dict[str, object]: """用视觉 API 截屏识别当前页乘客,匹配名单+进度,返回队列数据。""" try: parsed = invoice_tool.vision_list_rows() except Exception as exc: return {"error": str(exc), "rows": [], "meta": f"视觉识别失败: {exc}"} if not parsed.get("page_ok"): return {"error": "not_on_list_page", "rows": [], "meta": "当前不在扫码开票单列表页"} rows = parsed.get("rows") or [] roster = invoice_tool.read_roster() skip_names = invoice_tool.progress_names({"completed", "no_ticket", "failed", "in_progress"}) queue = [] for row in rows: masked = row.get("masked_name", "") id_first = row.get("id_first", "") or "" id_last = row.get("id_last", "") or "" real = "" id8 = "" if masked in skip_names: status, tone, reason = "已处理", "muted", "进度表已完成或跳过" else: matches = invoice_tool.match_roster(masked, roster, id_first, id_last) if not matches and (id_first or id_last): matches = invoice_tool.match_roster(masked, roster) if len(matches) == 1: status, tone, reason = "待处理", "pending", f"匹配: {matches[0].get('姓名', '')}" real = matches[0].get("姓名", "") id8 = matches[0].get("身份证后8位", "") elif len(matches) > 1: status, tone, reason = "识别问题", "warn", f"同名 {len(matches)} 人" else: status, tone, reason = "识别问题", "warn", "不在名单中" queue.append({ "masked_name": masked, "real_name": real, "id_last8": id8, "id_first": id_first, "id_last": id_last, "display_status": status, "display_tone": tone, "display_reason": reason, }) # 同步写入 page_queue CSV,防止定时 refresh() 覆盖 now = invoice_tool.datetime.now().strftime("%Y-%m-%d %H:%M:%S") csv_rows = [] for q in queue: csv_rows.append({ "page_id": f"vision_{now}", "masked_name": q["masked_name"], "id_first": q.get("id_first", ""), "id_last": q.get("id_last", ""), "real_name": q.get("real_name", ""), "id_last8": q.get("id_last8", ""), "button_bounds": "", "match_status": "matched" if q["display_tone"] == "pending" else "skip_progress", "queue_status": "pending" if q["display_tone"] == "pending" else "skipped", "attempts": "0", "updated_at": now, }) invoice_tool.write_page_queue(csv_rows) return { "rows": queue, "meta": f"本页 {len(rows)} 人 · scroll={parsed.get('scroll_position', '?')} · pending={sum(1 for q in queue if q['display_tone'] == 'pending')}", "scroll_position": parsed.get("scroll_position"), } def launch_railway_app() -> dict[str, object]: result = invoice_tool.run_adb( ["shell", "monkey", "-p", "com.MobileTicket", "-c", "android.intent.category.LAUNCHER", "1"], check=False, ) if result.returncode == 0: time.sleep(1) return { "return_code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "package": "com.MobileTicket", "device": device_status(), } INDEX_HTML = r""" 铁路系统发票批量开票

铁路系统发票批量开票

名单导入 → 手机页面识别 → 自动开票 → 进度记录

读取中 手机状态读取中 当前 App 读取中 📱 设备校准

运行控制台

只调整本地 UI 布局,所有按钮和接口保持原功能。

① 名单 Roster
📶 无线连接手机
手机和电脑需同一 Wi‑Fi,并开启网络 ADB 调试。
② 参数与执行 Run
③ 二维码 QR
📋 粘贴二维码截图
每组换新二维码时,先粘贴截图保存到手机,再点“重新进入开票页”

保存设置

配置成功后的处理方式和本地保存目录。

填写说明保存目录可填相对项目路径或绝对路径。

0名单人数
0已开票
0无可开票
0失败
0处理中
0未操作

当前页队列

状态原因脱敏名姓名后8位

运行日志


      

本二维码核查表 下载本表

按当前二维码列表出现顺序展示,方便跑完后人工核查失败项。

#状态票数脱敏名姓名身份证后8位更新时间

总进度 下载进度表

#状态票数姓名脱敏名身份证后8位更新时间
""" class Handler(BaseHTTPRequestHandler): def log_message(self, fmt: str, *args: object) -> None: return def do_HEAD(self) -> None: parsed = urlparse(self.path) if parsed.path == "/": body = INDEX_HTML.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") self.send_header("Pragma", "no-cache") self.send_header("Expires", "0") self.send_header("Content-Length", str(len(body))) self.end_headers() elif parsed.path == "/api/progress-csv" and invoice_tool.PROGRESS_CSV.exists(): self.send_response(200) self.send_header("Content-Type", "text/csv; charset=utf-8") self.send_header("Content-Disposition", f'attachment; filename="{invoice_tool.PROGRESS_CSV.name}"') self.send_header("Content-Length", str(invoice_tool.PROGRESS_CSV.stat().st_size)) self.end_headers() elif parsed.path == "/api/qr-order-csv" and invoice_tool.QR_ORDER_CSV.exists(): self.send_response(200) self.send_header("Content-Type", "text/csv; charset=utf-8") self.send_header("Content-Disposition", f'attachment; filename="{invoice_tool.QR_ORDER_CSV.name}"') self.send_header("Content-Length", str(invoice_tool.QR_ORDER_CSV.stat().st_size)) self.end_headers() elif parsed.path == "/api/status": self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() elif parsed.path in {"/api/decode-qr", "/api/save-qr-image", "/api/open-on-phone", "/api/reopen-qr", "/api/vision-page-queue", "/api/mark-completed"}: self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() else: self.send_response(404) self.end_headers() def do_GET(self) -> None: parsed = urlparse(self.path) if parsed.path == "/": body = INDEX_HTML.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) elif parsed.path == "/calibrate": body = CALIBRATE_HTML.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) elif parsed.path == "/api/calibrate/steps": write_json(self, calibrate.get_steps()) elif parsed.path == "/api/calibrate/screen": w, h = calibrate.get_screen_size() write_json(self, {"width": w, "height": h}) elif parsed.path == "/api/calibrate/capture-status": write_json(self, calibrate.get_capture_status()) elif parsed.path == "/api/calibrate/profile": write_json(self, calibrate.get_device_status()) elif parsed.path == "/api/calibrate/candidates": queue = build_vision_page_queue() rows = [row for row in queue.get("rows", []) if row.get("display_status") == "待处理"] write_json(self, {"rows": rows, "error": queue.get("error", ""), "meta": queue.get("meta", "")}) elif parsed.path.startswith("/api/calibrate/screenshot"): from urllib.parse import parse_qs qs = parse_qs(parsed.query) sid = qs.get("id", [""])[0] if sid.endswith("_sample"): img = calibrate.trusted_sample_path(sid.removesuffix("_sample")) else: img = calibrate.CALIBRATION_DIR / f"{sid}.png" if img and img.exists(): data = img.read_bytes() self.send_response(200) self.send_header("Content-Type", "image/png") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) else: write_json(self, {"error": "not_found"}, 404) elif parsed.path == "/api/status": write_json(self, status_payload()) elif parsed.path == "/api/device-status": write_json(self, device_status()) elif parsed.path == "/api/recording": write_json(self, recording_status()) elif parsed.path == "/api/progress-csv": self.send_file(invoice_tool.PROGRESS_CSV, "text/csv; charset=utf-8") elif parsed.path == "/api/qr-order-csv": self.send_file(invoice_tool.QR_ORDER_CSV, "text/csv; charset=utf-8") else: write_json(self, {"error": "not_found"}, 404) def do_POST(self) -> None: parsed = urlparse(self.path) try: if parsed.path == "/api/upload-roster": self.handle_upload_roster() elif parsed.path == "/api/start": body = self.read_json() write_json(self, start_runner( int(body.get("max_batches", 200)), int(body.get("max_actions", 0)), bool(body.get("send_email", True)), bool(body.get("fast", True)), bool(body.get("dry_run", False)), bool(body.get("scroll_top_first", True)), str(body.get("mode", "h5")), str(body.get("invoice_success_action", "")), str(body.get("invoice_output_dir", "")), bool(body.get("final_retry_pass", True)), )) elif parsed.path == "/api/stop": write_json(self, stop_runner()) elif parsed.path == "/api/reset-round": write_json(self, reset_current_round()) elif parsed.path == "/api/settings": body = self.read_json() write_json(self, save_invoice_settings( str(body.get("invoice_success_action", "download")), str(body.get("invoice_output_dir", "output/invoices")), )) elif parsed.path == "/api/launch-12306": result = launch_railway_app() write_json(self, result, 200 if result["return_code"] == 0 else 500) elif parsed.path == "/api/connect-device": body = self.read_json() result = connect_wireless_device(str(body.get("host", "")), body.get("port", 5555)) write_json(self, result, 200 if result["connected"] else 400) elif parsed.path == "/api/build-queue": result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-build-page-queue"], 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) elif parsed.path == "/api/next-page": result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "android-page-next", "--rebuild"], 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) elif parsed.path == "/api/scroll-top": 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) 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) write_json(self, {"return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) elif parsed.path == "/api/recording": body = self.read_json() if str(body.get("action", "start")) == "stop": write_json(self, stop_phone_recording()) else: write_json(self, start_phone_recording()) elif parsed.path == "/api/decode-qr": self.handle_decode_qr() elif parsed.path == "/api/save-qr-image": self.handle_save_qr_image() elif parsed.path == "/api/open-on-phone": self.handle_open_on_phone() elif parsed.path == "/api/reopen-qr": clear_runner_log("reopen qr page") body = self.read_json() qr_url = str(body.get("qr_url", "")).strip() if qr_url: invoice_tool.save_qr_url(qr_url) if body.get("agent_id_last8"): saved = invoice_tool.save_agent_id_last8(str(body.get("agent_id_last8", ""))) print(f"[web-ui] saved agent_id_last8={saved}", flush=True) result = invoice_tool.reopen_qr_url() write_json(self, { "reopened": result, "error": "自动扫码失败:请确认已粘贴二维码截图,手机在 12306 首页或电子发票相关页面" if not result else "", "device": device_status(), }) elif parsed.path == "/api/vision-page-queue": write_json(self, build_vision_page_queue()) elif parsed.path == "/api/mark-completed": self.handle_mark_completed() elif parsed.path == "/api/calibrate": self.handle_calibrate() else: write_json(self, {"error": "not_found"}, 404) except Exception as exc: write_json(self, {"error": str(exc)}, 500) def read_json(self) -> dict[str, object]: length = int(self.headers.get("Content-Length", "0")) if not length: return {} return json.loads(self.rfile.read(length).decode("utf-8")) def send_file(self, path: Path, content_type: str) -> None: if not path.exists(): write_json(self, {"error": "file_not_found", "file": str(path)}, 404) return body = path.read_bytes() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Disposition", f'attachment; filename="{path.name}"') self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def handle_upload_roster(self) -> None: form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers.get("Content-Type", "")}) item = form["file"] filename = Path(item.filename or "roster.xlsx").name UPLOAD_DIR.mkdir(parents=True, exist_ok=True) dest = UPLOAD_DIR / filename with dest.open("wb") as fh: shutil.copyfileobj(item.file, fh) result = subprocess.run([PYTHON, "scripts/invoice_tool.py", "import-roster", "--input", str(dest)], cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) write_json(self, {"file": str(dest), "return_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}, 200 if result.returncode == 0 else 500) def handle_decode_qr(self) -> None: body = self.read_json() image_b64 = str(body.get("image", "")).strip() if not image_b64: write_json(self, {"results": [], "error": "未收到图片数据"}, 400) return # 去掉 data URL 前缀 (data:image/png;base64,...) if "," in image_b64 and image_b64.startswith("data:"): image_b64 = image_b64.split(",", 1)[1] try: image_bytes = base64.b64decode(image_b64) except Exception: write_json(self, {"results": [], "error": "图片数据解码失败,请重试"}, 400) return invoice_tool.save_qr_image_bytes(image_bytes) result = invoice_tool.decode_qr_image(image_bytes) # 保存解码结果,供脚本自动重进使用 if result.get("results"): invoice_tool.save_qr_url(result["results"][0]) result["qr_pushed"] = invoice_tool.generate_and_push_qr() result["saved"] = True else: result["image_saved"] = True write_json(self, dict(result)) def handle_save_qr_image(self) -> None: body = self.read_json() image_b64 = str(body.get("image", "")).strip() if not image_b64: write_json(self, {"saved": False, "error": "未收到图片数据"}, 400) return if "," in image_b64 and image_b64.startswith("data:"): image_b64 = image_b64.split(",", 1)[1] try: image_bytes = base64.b64decode(image_b64) except Exception: write_json(self, {"saved": False, "error": "图片数据解码失败,请重试"}, 400) return path = invoice_tool.save_qr_image_bytes(image_bytes) clear_runner_log("new qr image pasted") write_json(self, {"saved": True, "image_saved": True, "path": str(path), "size": path.stat().st_size}) def handle_mark_completed(self) -> None: body = self.read_json() rows = body.get("rows") or [] marked = 0 for item in rows: masked = item.get("masked", "") real = item.get("real", "") id8 = item.get("id8", "") if masked or real: invoice_tool.update_progress(masked, real, id8, "completed") marked += 1 write_json(self, {"marked": marked}) def handle_open_on_phone(self) -> None: body = self.read_json() url = str(body.get("url", "")).strip() if not url: write_json(self, {"return_code": -1, "stdout": "", "stderr": "未提供 URL", "device": device_status()}, 400) return adb_result = invoice_tool.run_adb( ["shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url], check=False, ) time.sleep(0.5) write_json(self, { "return_code": adb_result.returncode, "stdout": adb_result.stdout.strip(), "stderr": adb_result.stderr.strip(), "device": device_status(), }, 200 if adb_result.returncode == 0 else 500) def handle_calibrate(self) -> None: body = self.read_json() action = str(body.get("action", "")) if action == "capture": step_id = str(body.get("step_id", "")) write_json(self, calibrate.capture_screenshot(step_id)) elif action == "analyze": step_id = str(body.get("step_id", "")) if step_id == "*": write_json(self, calibrate.analyze_all()) else: write_json(self, calibrate.analyze_step(step_id)) elif action == "save": write_json(self, calibrate.save_device_profile(calibrate.analyze_all())) elif action == "auto-run": write_json(self, start_automatic_calibration(str(body.get("masked_name", "")))) elif action == "approve-sample": write_json(self, calibrate.approve_current_as_sample(str(body.get("step_id", "")))) else: write_json(self, {"error": "unknown_action"}, 400) CALIBRATE_HTML = r""" 设备校准 · 铁路发票

设备校准

新手机先完成一次全流程验证,后续批量开票将自动匹配此设备配置。

读取设备中← 返回主界面
选择左侧步骤后显示样图
← 点击左侧步骤开始校准
""" def main() -> None: print(f"UI starting: cwd={Path.cwd()} pid={os.getpid()} python={sys.executable}", flush=True) invoice_tool.ensure_dirs() server = ThreadingHTTPServer(("127.0.0.1", 8765), Handler) print("UI: http://127.0.0.1:8765", flush=True) server.serve_forever() if __name__ == "__main__": main()