feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user