对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。 Co-authored-by: Cursor <cursoragent@cursor.com>
248 lines
7.3 KiB
Python
248 lines
7.3 KiB
Python
#!/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()
|