#!/usr/bin/env python3 """Capture CeCe (测测) screenshots for complete-design validation.""" from __future__ import annotations import json import os import re import subprocess import sys import time from pathlib import Path ROOT = Path("/Users/jack/Project/digital-psychology") TOOLS = ROOT / "tools" / "android" ANDROID_HOME = Path("/opt/homebrew/share/android-commandlinetools") AVD = "YuXinGu_API34" PKG = "com.xxwolo.cc5" ACTIVITY = "com.xxwolo.cc5/com.cece.app.MainActivity" APK = Path("/Users/jack/Project/bird-xxdoc-android-release-xxwolo-10.50.0.apk") DATE = time.strftime("%Y%m%d") OUT = ROOT / ".tmp" / "cece-validation" / DATE os.environ["ANDROID_HOME"] = str(ANDROID_HOME) os.environ["ANDROID_SDK_ROOT"] = str(ANDROID_HOME) os.environ["PATH"] = ( f"{ANDROID_HOME}/emulator:{ANDROID_HOME}/platform-tools:" f"{ANDROID_HOME}/cmdline-tools/latest/bin:/opt/homebrew/bin:/usr/bin:/bin:" + os.environ.get("PATH", "") ) def sh(args: list[str], check: bool = False, timeout: int = 120) -> subprocess.CompletedProcess: return subprocess.run(args, capture_output=True, text=True, check=check, timeout=timeout) def adb(*args: str, timeout: int = 120) -> subprocess.CompletedProcess: return sh(["adb", *args], timeout=timeout) def tool(*args: str, timeout: int = 120) -> dict: r = sh([str(TOOLS), *args, "--json"], timeout=timeout) out = (r.stdout or "").strip() if not out: print("TOOL STDERR:", (r.stderr or "")[:500], file=sys.stderr) return {"error": r.stderr} try: return json.loads(out) except json.JSONDecodeError: print("TOOL RAW:", out[:500], file=sys.stderr) return {"raw": out} def device_ready() -> bool: r = adb("devices") return bool(re.search(r"emulator-\d+\s+device", r.stdout or "")) def ensure_emulator() -> None: if device_ready(): print("device already up") return print("starting emulator…") sh(["pkill", "-f", "qemu-system|emulator -avd YuXinGu"], check=False) time.sleep(2) subprocess.Popen( [ "emulator", "-avd", AVD, "-no-audio", "-gpu", "host", "-accel", "on", "-no-snapshot-load", ], stdout=open("/tmp/emulator-yuxingu.log", "a"), stderr=subprocess.STDOUT, start_new_session=True, ) for i in range(120): if device_ready(): break time.sleep(2) else: raise SystemExit("emulator failed to appear in adb") for i in range(90): boot = adb("shell", "getprop", "sys.boot_completed").stdout.strip().replace("\r", "") if boot == "1": print(f"boot_completed try={i}") break time.sleep(3) adb("shell", "input", "keyevent", "KEYCODE_WAKEUP") adb("shell", "wm", "dismiss-keyguard") for k, v in [ ("window_animation_scale", "0"), ("transition_animation_scale", "0"), ("animator_duration_scale", "0"), ]: adb("shell", "settings", "put", "global", k, v) def ensure_app() -> None: r = adb("shell", "pm", "path", PKG) if "package:" not in (r.stdout or ""): print("installing apk…") r = adb("install", "-r", str(APK), timeout=300) print(r.stdout, r.stderr) def shot(rel: str) -> Path: path = OUT / rel path.parent.mkdir(parents=True, exist_ok=True) data = tool("screenshot", "--out", str(path)) print("SHOT", rel, data.get("bytes"), data.get("error")) return path def dump(rel: str) -> dict: path = OUT / rel path.parent.mkdir(parents=True, exist_ok=True) data = tool("ui", "dump") path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") return data def elements(data: dict) -> list: return data.get("elements") or [] def el_blob(e: dict) -> str: return " ".join( str(e.get(k) or "") for k in ("text", "contentDescription", "contentDesc", "hint", "resourceId") ) def find_els(data: dict, *needles: str) -> list: hits = [] for e in elements(data): blob = el_blob(e) if all(n in blob for n in needles): hits.append(e) return hits def center_of(e: dict) -> tuple[int, int] | None: c = e.get("center") if isinstance(c, dict) and "x" in c and "y" in c: return int(c["x"]), int(c["y"]) if isinstance(c, (list, tuple)) and len(c) >= 2: return int(c[0]), int(c[1]) b = e.get("bounds") if isinstance(b, dict) and "x" in b and "width" in b: return int(b["x"] + b["width"] / 2), int(b["y"] + b["height"] / 2) m = re.findall(r"\[(\d+),(\d+)\]", str(b or "")) if len(m) >= 2: x = (int(m[0][0]) + int(m[1][0])) // 2 y = (int(m[0][1]) + int(m[1][1])) // 2 return x, y return None def tap_el(e: dict) -> bool: xy = center_of(e) if not xy: print("no coords", e) return False print( f"TAP {xy} text={e.get('text')!r} " f"desc={e.get('contentDescription') or e.get('contentDesc')!r}" ) adb("shell", "input", "tap", str(xy[0]), str(xy[1])) return True def label_of(e: dict) -> str: return (e.get("text") or e.get("contentDescription") or e.get("contentDesc") or "").strip() def tap_text(*candidates: str, exact: bool = False) -> bool: data = tool("ui", "dump") for label in candidates: hits = find_els(data, label) if exact: hits = [e for e in hits if label_of(e) == label] # prefer clickable + smaller bounds (avoid full-screen "关闭" overlays) def rank(e: dict): b = e.get("bounds") or {} area = int(b.get("width") or 0) * int(b.get("height") or 0) return (0 if e.get("clickable") else 1, area) hits = sorted(hits, key=rank) if hits: ok = tap_el(hits[0]) time.sleep(2.2) return ok print("MISS", candidates) return False def print_texts(data: dict, prefix: str = "") -> None: for e in elements(data): t = e.get("text") or e.get("contentDescription") or e.get("contentDesc") if t: print(prefix, repr(t)[:100], "click=", e.get("clickable"), "c=", e.get("center")) def launch() -> None: adb("shell", "am", "force-stop", PKG) time.sleep(1) adb("shell", "am", "start", "-n", ACTIVITY) time.sleep(7) def dismiss_onboarding() -> None: shot("onboarding/10-start.png") data = dump("onboarding/10-start.json") print_texts(data, "ONB") # Prefer guest mode for browse-capture; else agree if find_els(data, "温馨提示") or find_els(data, "同意") or find_els(data, "游客"): if not tap_text("不同意,使用游客模式", exact=True): if not tap_text("同意", exact=True): tap_text("同意") time.sleep(2.5) shot("onboarding/11-after-privacy.png") data = dump("onboarding/11-after-privacy.json") print_texts(data, "AFTER") # Drain permission / login / guide gates for i in range(8): data = tool("ui", "dump") blob = json.dumps(data, ensure_ascii=False) if "首页" in blob and ("消息" in blob or "我的" in blob or "在线" in blob): print("main tabs visible") return progressed = False for label in ( "不同意,使用游客模式", "游客模式", "跳过", "随便看看", "先逛逛", "立即体验", "进入测测", "开始体验", "允许", "仅在使用中允许", "使用时允许", "仅限这一次", "确定", "我知道了", "同意并继续", ): if label in blob: if tap_text(label, exact=(label in ("允许", "确定", "跳过"))): progressed = True break if not progressed: # checkbox + login continue heuristics if "我已阅读" in blob or "无法登录" in blob: # try tap lower primary button area if present via contentDesc for e in elements(data): lab = label_of(e) if lab in ("登录", "手机号登录", "一键登录", "微信登录"): # don't force login; look for skip nearby continue # coordinate: sometimes "游客" only after agree path tap_text("游客") progressed = True time.sleep(1.5) shot(f"onboarding/12-loop-{i}.png") if not device_ready(): ensure_emulator() launch() def tap_tab(name: str) -> bool: """Tap bottom tab by text/content-desc.""" data = tool("ui", "dump") hits = [] for e in find_els(data, name): xy = center_of(e) if not xy: continue b = e.get("bounds") or {} area = int(b.get("width") or 0) * int(b.get("height") or 0) if area > 1080 * 800: # skip full-screen overlays continue if label_of(e) != name and name not in label_of(e): continue hits.append((xy[1], area, e)) hits.sort(key=lambda t: (-t[0], t[1])) # bottom-most, then smaller if not hits: print("tab miss", name) print_texts(data, "TABSCAN") return False return tap_el(hits[0][2]) def capture_tabs() -> None: mapping = [ ("首页", "V-H01/home.png", "V-H01/home.json"), ("消息", "V-G01/message.png", "V-G01/message.json"), ("问", "V-A01/ask.png", "V-A01/ask.json"), ("在线", "V-L01/online.png", "V-L01/online.json"), ("我的", "V-U01/mine.png", "V-U01/mine.json"), ] # start from home tap_tab("首页") time.sleep(2) for tab, png, js in mapping: print("=== TAB", tab, "===") if not tap_tab(tab): # coordinate fallback for 5-tab bar on 1080x2400 # approx centers: 108, 324, 540, 756, 972 at y=2280 fallback = {"首页": 108, "消息": 324, "问": 540, "在线": 756, "我的": 972} x = fallback.get(tab) if x: print("fallback tap", tab, x) adb("shell", "input", "tap", str(x), "2280") time.sleep(2.5) else: time.sleep(2.5) shot(png) data = dump(js) print_texts(data, tab) def capture_home_details() -> None: tap_tab("首页") time.sleep(2) shot("V-H01/home-top.png") # scroll down for feed adb("shell", "input", "swipe", "540", "1800", "540", "700", "400") time.sleep(2) shot("V-H01/home-feed.png") dump("V-H01/home-feed.json") # scroll up, try 更多 adb("shell", "input", "swipe", "540", "700", "540", "1800", "400") time.sleep(1.5) if tap_text("更多"): time.sleep(2) shot("V-H01/tools-more.png") dump("V-H01/tools-more.json") adb("shell", "input", "keyevent", "4") # back time.sleep(1) def capture_tools() -> None: tap_tab("首页") time.sleep(1.5) for label, rel in [ ("星盘", "V-N01/natal-entry.png"), ("合盘", "V-S01/synastry-entry.png"), ("缘分合盘", "V-S01/synastry-entry2.png"), ("星座", "misc/zodiac-entry.png"), ("I人", "misc/mbti-entry.png"), ]: tap_tab("首页") time.sleep(1) if tap_text(label): time.sleep(3) shot(rel) dump(rel.replace(".png", ".json")) adb("shell", "input", "keyevent", "4") time.sleep(1.5) def write_manifest() -> None: files = sorted(str(p.relative_to(OUT)) for p in OUT.rglob("*.png")) manifest = { "date": DATE, "package": PKG, "version_hint": "10.50.0", "avd": AVD, "screenshots": files, } (OUT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print("manifest", len(files), "pngs") def main() -> None: OUT.mkdir(parents=True, exist_ok=True) ensure_emulator() ensure_app() launch() dismiss_onboarding() shot("misc/20-main-or-gate.png") dump("misc/20-main-or-gate.json") capture_tabs() capture_home_details() capture_tools() write_manifest() print("DONE", OUT) if __name__ == "__main__": main()