名单上传与运行
支持截图中的二维码自动识别,解码后可在手机打开
保存设置
保存目录可填相对项目路径或绝对路径。
当前页队列
| ✓ | 状态 | 原因 | 脱敏名 | 姓名 | 后8位 |
|---|
运行日志
总进度 下载进度表
| 状态 | 姓名 | 脱敏名 | 身份证后8位 | 更新时间 |
|---|
#!/usr/bin/env python3 """Local web UI for railway invoice automation.""" from __future__ import annotations import base64 import cgi import csv import json 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 PROJECT_ROOT = invoice_tool.PROJECT_ROOT PYTHON = sys.executable RUNNER: subprocess.Popen[str] | None = None RUNNER_LOCK = threading.Lock() RUNNER_LOG = PROJECT_ROOT / "work" / "web_runner.log" UPLOAD_DIR = PROJECT_ROOT / "data" / "input" 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 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]: 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 "未检测到手机"), } 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 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 "" if pstatus == "completed": return {"display": "已开票", "tone": "ok", "reason": "总进度表已完成"} if pstatus == "no_ticket": return {"display": "无可开票", "tone": "muted", "reason": "12306 未查询到可开发票客票"} if pstatus == "in_progress" or queue_status == "clicked": return {"display": "处理中", "tone": "info", "reason": "脚本正在处理这行"} if pstatus == "failed": return {"display": "失败", "tone": "bad", "reason": "上次运行未完成"} if pstatus == "error": return {"display": "运行错误", "tone": "bad", "reason": "脚本异常"} 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 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) 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"] queue.append(item) counts["no_ticket"] = 0 for person in 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 roster_status.append( { "name": person.get("姓名", ""), "id_last8": person.get("身份证后8位", ""), "status": status, "display_status": label["text"], "display_tone": label["tone"], "masked_name": progress_row.get("masked_name", "") if progress_row else "", "updated_at": progress_row.get("updated_at", "") if progress_row else "", } ) with RUNNER_LOCK: running = RUNNER is not None and RUNNER.poll() is None return_code = None if RUNNER is None else RUNNER.poll() 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): runner_status = "failed" config = invoice_tool.load_config(invoice_tool.CONFIG_LOCAL_PATH) return { "counts": counts, "roster": roster_status, "queue": queue, "progress": progress, "runner": {"running": running, "status": runner_status, "return_code": return_code, "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), }, } 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 = "native", invoice_success_action: str = "", invoice_output_dir: str = "") -> dict[str, object]: global RUNNER with RUNNER_LOCK: if RUNNER is not None and RUNNER.poll() is None: return {"started": False, "reason": "runner_already_running"} 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") 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 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") 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} def stop_runner() -> dict[str, object]: global RUNNER with RUNNER_LOCK: if RUNNER is None or RUNNER.poll() is not None: return {"stopped": False, "reason": "runner_not_running"} RUNNER.terminate() for _ in range(10): if RUNNER.poll() is not None: return {"stopped": True} time.sleep(0.2) RUNNER.kill() return {"stopped": True, "killed": True} 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"""
保存目录可填相对项目路径或绝对路径。
| ✓ | 状态 | 原因 | 脱敏名 | 姓名 | 后8位 |
|---|
| 状态 | 姓名 | 脱敏名 | 身份证后8位 | 更新时间 |
|---|