#!/usr/bin/env python3 """Capture CeCe homepage top-right 「+」→ add family/friend archive flow.""" from __future__ import annotations import json import os import re import subprocess 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") PKG = "com.xxwolo.cc5" ACT = f"{PKG}/com.cece.app.MainActivity" STAMP = time.strftime("%Y%m%d-%H%M%S") OUT = ROOT / ".tmp" / "cece-validation" / time.strftime("%Y%m%d") / f"home-plus-{STAMP}" NOTES = OUT / "NOTES.md" 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"/opt/homebrew/bin:/usr/bin:/bin:" + os.environ.get("PATH", "") ) def sh(args, timeout=120): return subprocess.run(args, capture_output=True, text=True, timeout=timeout) def adb(*args, timeout=120): return sh(["adb", *args], timeout=timeout) def tool(*args, timeout=120): r = sh([str(TOOLS), *args, "--json"], timeout=timeout) try: return json.loads(r.stdout or "{}") except Exception: return {"error": r.stderr, "raw": (r.stdout or "")[:500]} def label(e): return (e.get("text") or e.get("contentDesc") or e.get("contentDescription") or "").strip() def elements(data): return data.get("elements") or [] def dump(): return tool("ui", "dump") def center(e): c = e.get("center") if isinstance(c, dict): return int(c["x"]), int(c["y"]) b = e.get("bounds") or {} if isinstance(b, dict) and "width" in b: return int(b["x"] + b["width"] / 2), int(b["y"] + b["height"] / 2) return None def area(e): b = e.get("bounds") or {} return int(b.get("width") or 0) * int(b.get("height") or 0) def tap_xy(x, y, wait=1.6): adb("shell", "input", "tap", str(x), str(y)) time.sleep(wait) def back(n=1): for _ in range(n): adb("shell", "input", "keyevent", "4") time.sleep(1.0) def shot(rel: str, note: str = ""): path = OUT / rel path.parent.mkdir(parents=True, exist_ok=True) tool("screenshot", "--out", str(path)) labs = sorted({label(e).split("\n", 1)[0] for e in elements(dump()) if label(e)}) (OUT / rel.replace(".png", ".labels.txt")).write_text("\n".join(labs), encoding="utf-8") with NOTES.open("a", encoding="utf-8") as f: f.write(f"- `{rel}` {note}\n") f.write(f" labels: {', '.join(labs[:40])}\n") print("SHOT", rel, note, flush=True) return path def save_dump(rel): path = OUT / rel path.parent.mkdir(parents=True, exist_ok=True) data = dump() path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") return data def first_lines(data=None): data = data or dump() return [label(e).split("\n", 1)[0] for e in elements(data) if label(e)] def go_home(): adb("shell", "am", "start", "-n", ACT) time.sleep(2.0) # tap 首页 tab tap_xy(108, 2255, wait=1.2) # scroll top for _ in range(2): adb("shell", "input", "swipe", "540", "500", "540", "1600", "280") time.sleep(0.5) def find_plus_candidates(data): """Top-right + / 添加 / contentDesc.""" hits = [] for e in elements(data): lab = label(e) first = lab.split("\n", 1)[0] b = e.get("bounds") or {} y = int(b.get("y") or 0) x = int(b.get("x") or 0) w = int(b.get("width") or 0) h = int(b.get("height") or 0) if y > 280: continue if x < 700: continue # plus-like if first in ("+", "+", "添加", "新建", "添加档案") or "添加" in lab or lab in ("+", "+"): hits.append(e) continue cd = (e.get("contentDesc") or e.get("contentDescription") or "") if any(k in cd for k in ("添加", "新建", "plus", "Plus", "+")): hits.append(e) continue # small square top-right icon without text if e.get("clickable") and not lab and w < 120 and h < 120 and x > 850: hits.append(e) hits.sort(key=lambda e: (area(e), -(center(e) or (0, 0))[0])) return hits def tap_contains(needle, ymax=9999): data = dump() cands = [] for e in elements(data): lab = label(e) if needle not in lab: continue b = e.get("bounds") or {} if int(b.get("y") or 0) > ymax: continue if not e.get("clickable") and area(e) > 200 * 200: continue cands.append(e) cands.sort(key=area) if not cands: return False xy = center(cands[0]) if not xy: return False print(f" TAP contains {needle!r} @ {xy}", flush=True) tap_xy(*xy) return True def explore_add_flow(): OUT.mkdir(parents=True, exist_ok=True) NOTES.write_text(f"# home + archive capture {STAMP}\n\n", encoding="utf-8") if not re.search(r"emulator-\d+\s+device", adb("devices").stdout or ""): raise SystemExit("no emulator") go_home() shot("00-home.png", "home before +") data = save_dump("00-home.json") plus = find_plus_candidates(data) print("plus candidates:", len(plus), flush=True) for e in plus[:8]: print(" ", label(e) or "(empty)", center(e), e.get("bounds"), flush=True) opened = False if plus: xy = center(plus[0]) if xy: print(f"tap plus candidate {xy}", flush=True) tap_xy(*xy, wait=2.0) opened = True if not opened: # fallback top-right + for xy in ((1008, 168), (990, 155), (1020, 190), (960, 170)): print(f"fallback tap {xy}", flush=True) tap_xy(*xy, wait=1.8) fls = first_lines() if any( k in " ".join(fls) for k in ("添加档案", "新建档案", "家人", "朋友", "伴侣", "关系", "昵称", "生日", "档案") ): opened = True break back(1) time.sleep(0.6) shot("01-after-plus.png", "after tapping +") data = save_dump("01-after-plus.json") fls = first_lines(data) print("after-plus labels:", fls[:30], flush=True) # Dive relation type chips if present for lab in ("家人", "朋友", "伴侣", "同事", "其他", "添加档案", "新建档案", "创建档案", "立即添加"): if lab in fls or any(lab in x for x in fls): if tap_contains(lab, ymax=1800): time.sleep(1.2) shot(f"02-tap-{lab}.png", f"tap {lab}") save_dump(f"02-tap-{lab}.json") # Try fill-ish fields visibility shot("03-form.png", "add archive form state") save_dump("03-form.json") # Look for 关系 / 生日 / 保存 for lab in ("保存", "完成", "确定", "下一步", "生成"): if tap_contains(lab, ymax=2200): time.sleep(1.2) shot(f"04-{lab}.png", f"tap {lab}") break # Summary of labels for NOTES final = first_lines() with NOTES.open("a", encoding="utf-8") as f: f.write("\n## Summary labels after +\n") f.write("\n".join(f"- {x}" for x in final[:80])) f.write("\n") print("DONE", OUT, flush=True) if __name__ == "__main__": explore_add_flow()