#!/usr/bin/env python3 """Full-page CeCe screenshot crawl (all reachable screens; paywall shot then back).""" 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") PKG = "com.xxwolo.cc5" ACT = f"{PKG}/com.cece.app.MainActivity" OUT = ROOT / ".tmp" / "cece-validation" / time.strftime("%Y%m%d") / "all" 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", "") ) MORE_ITEMS = [ "星盘报告", "测试", "倾诉", "心情小镇", "幸运地图", "星宿", "爱情树", "灵魂伴侣", "聊天分析", "关系网", "商城", "解读", "沙盘", "缘分合盘", "星座", "生辰", "紫微", "政余", "灵数", "生肖", "玛雅图腾", "人类图", "星盘", "生辰历", "紫微历", "政余历", "星骰", "智慧卡", "日历", ] HOME_GRID = [ "I人E人", "星座", "星盘", "生辰", "缘分合盘", "紫微", "陪伴小星", "倾诉", "智慧卡", "星盘报告", "生辰历", "灵魂伴侣", "AI玩法广场", "更多", ] MINE_ITEMS = [ "设置", "订单", "优惠券", "会员", "档案", "测试", "报告", "收藏", "帮助", "关于", "钱包", "客服", "消息通知", "签到", "邀请", "客服与帮助", ] CHART_TABS = ["天象", "本命", "行运", "三限", "次限", "日返", "月返", "法达", "推运", "合盘"] 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 or "")[:300]} def fg_pkg() -> str: r = adb("shell", "dumpsys", "window") m = re.search(r"mCurrentFocus=Window\{[^ ]+ u0 ([^/\}]+)", r.stdout or "") return m.group(1) if m else "" def ensure_app(): if PKG not in fg_pkg(): print("RELAUNCH", fg_pkg(), flush=True) adb("shell", "am", "start", "-n", ACT) time.sleep(5) def dump(): return tool("ui", "dump") def lab(e): return (e.get("contentDesc") or e.get("text") or "").strip() def labels(data=None): data = data or dump() return [lab(e) for e in (data.get("elements") or []) if lab(e)] def blob(data=None): return "\n".join(labels(data)) def area(e): b = e.get("bounds") or {} return int(b.get("width") or 0) * int(b.get("height") or 0) def center(e): c = e.get("center") if isinstance(c, dict): return int(c["x"]), int(c["y"]) return None def tap_xy(x, y, wait=1.5): 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(0.7) def safe_name(s: str) -> str: return re.sub(r"[^\w\u4e00-\u9fff\-]+", "_", s)[:48] def shot(rel: str, note: str = ""): ensure_app() path = OUT / rel path.parent.mkdir(parents=True, exist_ok=True) data = tool("screenshot", "--out", str(path)) labs = labels() (OUT / (rel.replace(".png", ".labels.txt"))).write_text("\n".join(labs), encoding="utf-8") print(f"SHOT {rel} bytes={data.get('bytes')} note={note}", flush=True) with NOTES.open("a", encoding="utf-8") as f: f.write(f"- `{rel}` {note}\n") return path def tap_exact(name: str, ymin=0, ymax=9999, prefer_small=True) -> bool: data = dump() hits = [] for e in data.get("elements") or []: t = lab(e) first = t.split("\n", 1)[0] if first != name and t != name: continue if not e.get("clickable"): continue xy = center(e) if not xy: continue if xy[1] < ymin or xy[1] > ymax: continue if area(e) > 1080 * 700: continue hits.append(e) if not hits: return False hits.sort(key=lambda e: area(e) if prefer_small else -area(e)) xy = center(hits[0]) print(f" TAP '{name}' {xy}", flush=True) tap_xy(*xy) return True def tap_contains(needle: str, ymin=0, ymax=9999) -> bool: data = dump() hits = [] for e in data.get("elements") or []: t = lab(e) if needle not in t: continue if not e.get("clickable"): continue xy = center(e) if not xy or xy[1] < ymin or xy[1] > ymax: continue if area(e) > 1080 * 700: continue hits.append(e) if not hits: return False hits.sort(key=area) xy = center(hits[0]) print(f" TAP~ '{needle}' {xy}", flush=True) tap_xy(*xy) return True def tap_tab(name: str, fb): if not tap_exact(name, ymin=2180) and not tap_contains(name, ymin=2180): print(f" tab fallback {name} {fb}", flush=True) tap_xy(*fb) def escape_modals(max_rounds=12) -> bool: for i in range(max_rounds): ensure_app() b = blob() labs = labels() print(f" escape[{i}] {labs[:4]}", flush=True) if any(x.split("\n", 1)[0] == "首页" for x in labs) and not any( k in b for k in ( "隐私政策更新", "温馨提示", "立即领取", "打开通知", "开通SVIP", "向TA提问", "确认并支付", "专享价购买", "未成年人模式", ) ): return True if "同意" in labs and any(k in b for k in ("隐私", "温馨提示")): tap_exact("同意") continue if "不再提醒" in labs: tap_exact("不再提醒") continue if "我知道了" in labs: tap_exact("我知道了") continue if "取消" in labs and "通知" in b: tap_exact("取消") continue if "立即领取" in labs: for y in (1680, 1720, 1760, 1800): tap_xy(540, y, wait=0.25) continue # paywall / consult / membership — capture once then back if any(k in b for k in ("开通SVIP", "向TA提问", "确认并支付", "专享价购买", "微信支付", "支付宝支付")): back() continue if "关闭" in labs: # avoid full-screen close; try small data = dump() for e in data.get("elements") or []: if lab(e) == "关闭" and area(e) < 200 * 200: tap_xy(*center(e)) break else: back() continue back() return False def go_home(): escape_modals() tap_tab("首页", (108, 2255)) time.sleep(1) escape_modals() def capture_page(rel, note=""): """Shot current page; if cashier, still save as *-cashier then back.""" b = blob() if any(k in b for k in ("确认并支付", "专享价购买", "微信支付", "支付宝支付")): shot(rel.replace(".png", "-cashier.png"), note + " [cashier]") back(2) escape_modals() return "cashier" shot(rel, note) return "ok" def crawl_tabs(): tabs = [ ("首页", (108, 2255), "tabs/01-home"), ("消息", (324, 2255), "tabs/02-message"), ("问", (540, 2255), "tabs/03-ask"), ("在线", (756, 2255), "tabs/04-online"), ("我的", (972, 2255), "tabs/05-mine"), ] for name, fb, base in tabs: print("=== TAB", name, flush=True) go_home() if name == "首页" else None escape_modals() tap_tab(name, fb) time.sleep(2) if name == "在线": for _ in range(4): b = blob() if "我知道了" in b: tap_exact("我知道了") continue if "向TA提问" in b: shot(f"{base}-ask-ta.png", "online->向TA提问") back() continue break capture_page(f"{base}.png", f"tab {name}") if name == "消息": if tap_contains("系统通知", ymax=900): time.sleep(1.5) capture_page(f"{base}-系统通知.png", "系统通知") back() if tap_contains("小星", ymax=1200): time.sleep(1.5) capture_page(f"{base}-小星.png", "小星") back() if name == "问": for sub in ("测测AI", "真人1v1"): if tap_contains(sub, ymax=500): time.sleep(1.5) capture_page(f"{base}-{safe_name(sub)}.png", sub) # tools sheet if tap_exact("工具", ymin=1800) or tap_contains("工具", ymin=1800): time.sleep(1.5) capture_page(f"{base}-工具sheet.png", "ask tools") # tap a few tool chips if present for t in ("星盘", "智慧卡", "合盘", "星骰"): if tap_exact(t) or tap_contains(t): time.sleep(1.5) capture_page(f"{base}-工具-{t}.png", f"ask tool {t}") back() time.sleep(0.8) back() for btn in ("语音通话", "深度解读", "灵魂伴侣"): if tap_exact(btn, ymin=1800) or tap_contains(btn, ymin=1800): time.sleep(1.8) capture_page(f"{base}-{btn}.png", btn) escape_modals() back() if name == "在线": adb("shell", "input", "swipe", "540", "1800", "540", "900", "350") time.sleep(1.2) capture_page(f"{base}-scroll.png", "online scroll") # open filters for f in ("综合排序", "全部工具", "筛选"): if tap_exact(f) or tap_contains(f): time.sleep(1.2) capture_page(f"{base}-{f}.png", f) back() if name == "我的": adb("shell", "input", "swipe", "540", "1800", "540", "900", "350") time.sleep(1.2) capture_page(f"{base}-scroll.png", "mine scroll") def crawl_home_self_and_search(): print("=== HOME extras", flush=True) go_home() if tap_contains("了解TA") or tap_contains("输入生日"): time.sleep(1.5) capture_page("home/了解TA.png", "了解TA") back() if tap_exact("签到") or tap_contains("签到"): time.sleep(1.5) capture_page("home/签到.png", "签到") escape_modals() back() # search bar approx top center tap_xy(540, 180) time.sleep(1.5) capture_page("home/搜索.png", "search") back() # self card more if tap_exact("更多", ymax=800): time.sleep(1.5) capture_page("home/自己-更多.png", "自己更多") # profile tabs if present for t in ("沙盘", "星座", "生辰", "星宿", "紫微", "自己"): if tap_contains(t, ymax=600): time.sleep(1.2) capture_page(f"home/档案-{t}.png", f"档案 {t}") back(2) def crawl_grid_and_more(): for name in HOME_GRID: print("=== GRID", name, flush=True) go_home() ok = tap_exact(name, ymax=2100) or tap_contains(name, ymax=2100) if not ok: adb("shell", "input", "swipe", "900", "1050", "200", "1050", "280") time.sleep(0.8) ok = tap_exact(name, ymax=2100) or tap_contains(name, ymax=2100) if not ok: with NOTES.open("a", encoding="utf-8") as f: f.write(f"- MISS grid {name}\n") continue time.sleep(2) capture_page(f"tools/{safe_name(name)}.png", f"grid {name}") # chart internal tabs if name in ("星盘", "生辰", "紫微", "星座"): for t in CHART_TABS: if tap_contains(t, ymax=700): time.sleep(1.2) capture_page(f"tools/{safe_name(name)}-{t}.png", f"{name}/{t}") if name == "缘分合盘": for t in ("添加档案", "直接选择档案合盘", "示例"): if tap_contains(t): time.sleep(1.5) capture_page(f"tools/合盘-{safe_name(t)}.png", t) escape_modals() back() break if name == "更多": # scroll more page and open every known item seen = set() for round_i in range(4): data = dump() items = [] for e in data.get("elements") or []: t = lab(e) if not t or not e.get("clickable"): continue if area(e) == 0 or area(e) > 360 * 360: continue first = t.split("\n", 1)[0] if first in ("更多", "首页", "消息", "在线", "我的", "推荐", "工具"): continue if first in seen: continue items.append(first) # prefer known list order ordered = [x for x in MORE_ITEMS if x in items or any(x == i for i in items)] ordered += [i for i in items if i not in ordered] for t in ordered: if t in seen: continue seen.add(t) print(" more>", t, flush=True) if not (tap_exact(t, ymax=2200) or tap_contains(t, ymax=2200)): continue time.sleep(2) capture_page(f"tools/more/{safe_name(t)}.png", f"more>{t}") escape_modals() # if left more page, reopen if not any(k in blob() for k in MORE_ITEMS[:5] + ["更多", "心情小镇", "沙盘"]): go_home() tap_exact("更多", ymax=2100) or tap_contains("更多", ymax=2100) time.sleep(1.2) adb("shell", "input", "swipe", "540", "1900", "540", "900", "350") time.sleep(1.0) else: escape_modals() back() def crawl_mine_children(): print("=== MINE children", flush=True) escape_modals() tap_tab("我的", (972, 2255)) time.sleep(1.5) capture_page("mine/00-root.png", "mine root") # collect clickable rows for round_i in range(3): data = dump() rows = [] for e in data.get("elements") or []: t = lab(e) if not t or not e.get("clickable"): continue xy = center(e) if not xy or xy[1] < 350 or xy[1] > 2100: continue first = t.split("\n", 1)[0] if first in ("首页", "消息", "在线", "我的", "问"): continue if area(e) > 1080 * 400: continue rows.append(first) # unique uniq = [] for r in rows: if r not in uniq: uniq.append(r) targets = [x for x in MINE_ITEMS if any(x in u for u in uniq)] targets += [u for u in uniq if u not in targets][:25] for t in targets: print(" mine>", t, flush=True) escape_modals() tap_tab("我的", (972, 2255)) time.sleep(1) if not (tap_contains(t, ymin=350, ymax=2100) or tap_exact(t, ymin=350, ymax=2100)): adb("shell", "input", "swipe", "540", "1800", "540", "900", "300") time.sleep(0.8) if not (tap_contains(t, ymin=350, ymax=2100) or tap_exact(t, ymin=350, ymax=2100)): continue time.sleep(2) capture_page(f"mine/{safe_name(t)}.png", f"mine>{t}") # membership sub tabs if "会员" in t or t in ("VIP", "SVIP"): for sub in ("VIP", "SVIP", "权益", "记录"): if tap_exact(sub, ymax=600) or tap_contains(sub, ymax=600): time.sleep(1.2) capture_page(f"mine/会员-{sub}.png", f"会员/{sub}") escape_modals() back() adb("shell", "input", "swipe", "540", "1800", "540", "900", "350") time.sleep(1) def write_manifest(): pngs = sorted(str(p.relative_to(OUT)) for p in OUT.rglob("*.png")) (OUT / "manifest.json").write_text( json.dumps({"count": len(pngs), "screenshots": pngs}, ensure_ascii=False, indent=2), encoding="utf-8", ) print("DONE", len(pngs), flush=True) def main(): OUT.mkdir(parents=True, exist_ok=True) NOTES.write_text(f"# full crawl {time.strftime('%F %T')}\n\n", encoding="utf-8") adb("shell", "input", "keyevent", "KEYCODE_WAKEUP") ensure_app() escape_modals() crawl_tabs() crawl_home_self_and_search() crawl_grid_and_more() crawl_mine_children() write_manifest() if __name__ == "__main__": main()