Files
jackyu66gitandCursor bd22d9dddd feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:37:53 +08:00

402 lines
12 KiB
Python

#!/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()