- web_ui.py: major refactor with improved runner, mode selection, progress tracking - scripts: add device_runtime, device_calibrate, calibration_trace, record_android_flow - docs: add phone-control-manual, update device-adaptation and windows-migration - invoice_tool: enhance H5 automation flow and visual fallback - calibrate.py: expand calibration capabilities - add skills/ and tests/ directories - remove obsolete .opencode/skills/invoice-wrapup Co-Authored-By: Claude <noreply@anthropic.com>
178 lines
6.6 KiB
Python
178 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
||
"""连续录制人工手机演示中的页面状态变化。
|
||
|
||
用户通过手机或 scrcpy 手动完成流程;本脚本轮询 Android XML,页面结构发生
|
||
变化时自动落盘截图、XML、可见文字和时间线。它不执行 tap / 输入等任何操作。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import signal
|
||
import time
|
||
import xml.etree.ElementTree as ET
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import device_runtime
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
REPLAY_ROOT = PROJECT_ROOT / "work" / "replay"
|
||
REMOTE_XML = "/sdcard/autotrain_manual_record.xml"
|
||
RUNNING = True
|
||
|
||
|
||
def _stop(_signum: int, _frame: object) -> None:
|
||
global RUNNING
|
||
RUNNING = False
|
||
|
||
|
||
def dump_xml() -> str:
|
||
device_runtime.run_adb(["shell", "uiautomator", "dump", REMOTE_XML], check=False)
|
||
result = device_runtime.run_adb_bytes(["exec-out", "cat", REMOTE_XML], check=False)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(result.stderr.decode("utf-8", "ignore").strip() or "读取 UI XML 失败")
|
||
return result.stdout.decode("utf-8", "ignore")
|
||
|
||
|
||
def visible_texts(xml_text: str) -> list[str]:
|
||
try:
|
||
root = ET.fromstring(xml_text)
|
||
except ET.ParseError:
|
||
return []
|
||
texts: list[str] = []
|
||
for node in root.iter("node"):
|
||
if node.attrib.get("package") == "com.android.systemui":
|
||
continue
|
||
for key in ("text", "content-desc"):
|
||
value = (node.attrib.get(key) or "").strip()
|
||
if value and value not in texts:
|
||
texts.append(value)
|
||
return texts
|
||
|
||
|
||
def ui_signature(xml_text: str) -> str:
|
||
"""忽略系统状态栏;保留文本、控件属性与 bounds,以识别弹窗和滚动。"""
|
||
try:
|
||
root = ET.fromstring(xml_text)
|
||
except ET.ParseError:
|
||
return hashlib.sha256(xml_text.encode("utf-8", "ignore")).hexdigest()
|
||
rows: list[str] = []
|
||
keys = ("class", "resource-id", "text", "content-desc", "bounds", "clickable", "enabled", "checked")
|
||
for node in root.iter("node"):
|
||
attrs = node.attrib
|
||
if attrs.get("package") == "com.android.systemui":
|
||
continue
|
||
rows.append("\x1f".join(attrs.get(key, "") for key in keys))
|
||
return hashlib.sha256("\n".join(rows).encode("utf-8", "ignore")).hexdigest()
|
||
|
||
|
||
def page_hint(texts: list[str]) -> str:
|
||
text = "\n".join(texts)
|
||
if "证件号码后8位" in text or "请输入乘车人" in text:
|
||
return "verify"
|
||
if "发票信息确认" in text:
|
||
return "invoice_confirm"
|
||
if "发票开具成功" in text:
|
||
return "success"
|
||
if "发票信息" in text and ("提交" in text or "确认" in text):
|
||
return "invoice_info"
|
||
if "没有可开具电子发票" in text:
|
||
return "no_ticket"
|
||
if "开具" in text and "*" in text:
|
||
return "list"
|
||
if "扫码" in text and "相册" in text:
|
||
return "album_qr"
|
||
return "other"
|
||
|
||
|
||
def slug(value: str) -> str:
|
||
return re.sub(r"[^a-zA-Z0-9_-]+", "_", value)[:40] or "other"
|
||
|
||
|
||
def save_snapshot(session: Path, index: int, xml_text: str, reason: str, started_at: float) -> dict:
|
||
texts = visible_texts(xml_text)
|
||
hint = page_hint(texts)
|
||
folder = session / f"{index:03d}_{slug(hint)}"
|
||
folder.mkdir(parents=True, exist_ok=False)
|
||
(folder / "ui.xml").write_text(xml_text, encoding="utf-8")
|
||
(folder / "texts.txt").write_text("\n".join(texts), encoding="utf-8")
|
||
screenshot = device_runtime.run_adb_bytes(["exec-out", "screencap", "-p"], check=False)
|
||
if screenshot.returncode == 0:
|
||
(folder / "screen.png").write_bytes(screenshot.stdout)
|
||
metadata = {
|
||
"index": index,
|
||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"elapsed_seconds": round(time.monotonic() - started_at, 2),
|
||
"page_hint": hint,
|
||
"reason": reason,
|
||
"text_count": len(texts),
|
||
"ui_signature": ui_signature(xml_text),
|
||
}
|
||
(folder / "meta.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
return {"folder": folder.name, **metadata}
|
||
|
||
|
||
def record(output: Path, interval: float) -> None:
|
||
output.mkdir(parents=True, exist_ok=False)
|
||
started_at = time.monotonic()
|
||
fingerprint = device_runtime.device_fingerprint()
|
||
(output / "session.json").write_text(json.dumps({
|
||
"started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"device": fingerprint,
|
||
"interval_seconds": interval,
|
||
"mode": "manual_demonstration",
|
||
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
print(f"session={output}", flush=True)
|
||
timeline: list[dict] = []
|
||
previous_signature = ""
|
||
index = 0
|
||
last_warning = ""
|
||
last_warning_at = 0.0
|
||
while RUNNING:
|
||
try:
|
||
xml_text = dump_xml()
|
||
signature = ui_signature(xml_text)
|
||
if signature != previous_signature:
|
||
index += 1
|
||
reason = "initial" if not previous_signature else "ui_changed"
|
||
entry = save_snapshot(output, index, xml_text, reason, started_at)
|
||
timeline.append(entry)
|
||
previous_signature = signature
|
||
print(f"snapshot={entry['folder']} page={entry['page_hint']} reason={reason}", flush=True)
|
||
except Exception as exc:
|
||
warning = str(exc)
|
||
now = time.monotonic()
|
||
if warning != last_warning or now - last_warning_at >= 10:
|
||
print(f"record_warning={warning}", flush=True)
|
||
last_warning = warning
|
||
last_warning_at = now
|
||
time.sleep(max(interval, 2.0))
|
||
continue
|
||
time.sleep(max(interval, 0.25))
|
||
(output / "timeline.json").write_text(json.dumps({
|
||
"stopped_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"snapshots": timeline,
|
||
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
print(f"finished snapshots={len(timeline)}", flush=True)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="连续录制人工 Android 操作的页面变化")
|
||
parser.add_argument("--output", default="", help="录制目录;默认 work/replay/<时间>_manual")
|
||
parser.add_argument("--interval", type=float, default=0.75, help="轮询间隔秒数")
|
||
args = parser.parse_args()
|
||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
output = Path(args.output) if args.output else REPLAY_ROOT / f"{stamp}_manual"
|
||
signal.signal(signal.SIGINT, _stop)
|
||
signal.signal(signal.SIGTERM, _stop)
|
||
record(output, args.interval)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|