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()
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overnight free-page capture for CeCe after login. Avoid paywalls."""
|
||||
|
||||
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"
|
||||
ACTIVITY = f"{PKG}/com.cece.app.MainActivity"
|
||||
DATE = time.strftime("%Y%m%d")
|
||||
OUT = ROOT / ".tmp" / "cece-validation" / DATE / "free"
|
||||
NOTES = OUT / "CAPTURE_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", "")
|
||||
)
|
||||
|
||||
# Strong paywall / cashier signals only (avoid false positive on feed prices)
|
||||
PAYWALL_STRONG = (
|
||||
"确认支付",
|
||||
"微信支付",
|
||||
"支付宝支付",
|
||||
"苹果支付",
|
||||
"立即支付",
|
||||
"连续包月",
|
||||
"确认协议并支付",
|
||||
)
|
||||
|
||||
# Home tool entries to open (free teaser pages OK)
|
||||
HOME_TOOLS = [
|
||||
"I人E人",
|
||||
"星座",
|
||||
"星盘",
|
||||
"生辰",
|
||||
"缘分合盘",
|
||||
"紫微",
|
||||
"陪伴小星",
|
||||
"倾诉",
|
||||
"智慧卡",
|
||||
"星盘报告",
|
||||
"生辰历",
|
||||
"灵魂伴侣",
|
||||
"AI玩法广场",
|
||||
"更多",
|
||||
]
|
||||
|
||||
|
||||
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 device_ready():
|
||||
return bool(re.search(r"emulator-\d+\s+device", adb("devices").stdout or ""))
|
||||
|
||||
|
||||
def ensure_device():
|
||||
if device_ready():
|
||||
return
|
||||
print("waiting for device…")
|
||||
for _ in range(60):
|
||||
if device_ready():
|
||||
return
|
||||
time.sleep(2)
|
||||
raise SystemExit("no device")
|
||||
|
||||
|
||||
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 shot(rel):
|
||||
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 save_dump(rel, data=None):
|
||||
path = OUT / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = data or dump()
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
|
||||
def blob(data):
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
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):
|
||||
adb("shell", "input", "tap", str(x), str(y))
|
||||
time.sleep(1.8)
|
||||
|
||||
|
||||
def tap_el(e):
|
||||
xy = center(e)
|
||||
if not xy:
|
||||
return False
|
||||
print(f" TAP {xy} {label(e)[:60]!r}")
|
||||
tap_xy(*xy)
|
||||
return True
|
||||
|
||||
|
||||
def find_exact(data, name, clickable_only=True):
|
||||
hits = []
|
||||
for e in elements(data):
|
||||
lab = label(e)
|
||||
# tab badges: "消息\n6", "在线\n限免"
|
||||
first = lab.split("\n", 1)[0].strip()
|
||||
if lab != name and first != name:
|
||||
continue
|
||||
if clickable_only and not e.get("clickable"):
|
||||
continue
|
||||
if area(e) > 1080 * 900:
|
||||
continue
|
||||
hits.append(e)
|
||||
hits.sort(key=area)
|
||||
return hits
|
||||
|
||||
|
||||
def find_contains(data, needle, clickable_only=True):
|
||||
hits = []
|
||||
for e in elements(data):
|
||||
lab = label(e)
|
||||
if needle not in lab and needle not in lab.split("\n", 1)[0]:
|
||||
continue
|
||||
if clickable_only and not e.get("clickable"):
|
||||
continue
|
||||
if area(e) > 1080 * 900:
|
||||
continue
|
||||
hits.append(e)
|
||||
hits.sort(key=area)
|
||||
return hits
|
||||
|
||||
|
||||
def tap_text(name, exact=True):
|
||||
data = dump()
|
||||
hits = find_exact(data, name) if exact else find_contains(data, name)
|
||||
if not hits:
|
||||
print(" MISS", name)
|
||||
return False
|
||||
return tap_el(hits[0])
|
||||
|
||||
|
||||
def back(n=1):
|
||||
for _ in range(n):
|
||||
adb("shell", "input", "keyevent", "4")
|
||||
time.sleep(1.2)
|
||||
|
||||
|
||||
def is_paywall(data=None):
|
||||
data = data or dump()
|
||||
b = blob(data)
|
||||
if any(h in b for h in PAYWALL_STRONG):
|
||||
return True
|
||||
# membership cashier sheet
|
||||
if ("开通会员" in b or "升级会员" in b) and ("立即开通" in b or "立即购买" in b):
|
||||
return True
|
||||
if "立即解锁" in b and ("¥" in b or "元" in b):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def dismiss_overlays(max_rounds=8):
|
||||
for i in range(max_rounds):
|
||||
data = dump()
|
||||
b = blob(data)
|
||||
acted = False
|
||||
# Daily login / return gift modal — close via X under sheet (not claim)
|
||||
if "立即领取" in b and ("每日登录" in b or "回归福利" in b or "头像挂件" in b or "缘分报告" in b):
|
||||
print("dismiss daily-reward via X taps")
|
||||
for y in (1680, 1720, 1760, 1800, 1640, 1600):
|
||||
tap_xy(540, y)
|
||||
data = dump()
|
||||
b = blob(data)
|
||||
if "立即领取" not in b:
|
||||
acted = True
|
||||
break
|
||||
if "立即领取" in b:
|
||||
# last resort: claim free reward to clear
|
||||
print("dismiss daily-reward via claim")
|
||||
tap_text("立即领取", exact=True)
|
||||
time.sleep(2)
|
||||
acted = True
|
||||
if acted:
|
||||
continue
|
||||
for name in (
|
||||
"不再提醒",
|
||||
"我知道了",
|
||||
"知道了",
|
||||
"暂不开启",
|
||||
"以后再说",
|
||||
"取消",
|
||||
"跳过",
|
||||
"允许",
|
||||
"使用时允许",
|
||||
"仅在使用中允许",
|
||||
"仅限这一次",
|
||||
"同意",
|
||||
):
|
||||
hits = find_exact(data, name) or find_contains(data, name)
|
||||
hits = [e for e in hits if area(e) < 1080 * 400]
|
||||
if hits:
|
||||
if name == "同意" and "温馨提示" not in b:
|
||||
continue
|
||||
print("dismiss", name)
|
||||
tap_el(hits[0])
|
||||
acted = True
|
||||
break
|
||||
if not acted:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def go_home():
|
||||
dismiss_overlays()
|
||||
data = dump()
|
||||
if any(label(e).split("\n", 1)[0] == "首页" and (center(e) or (0, 0))[1] >= 2200 for e in elements(data)):
|
||||
tap_bottom_tab("首页", (108, 2255))
|
||||
else:
|
||||
adb("shell", "am", "start", "-n", ACTIVITY)
|
||||
time.sleep(4)
|
||||
dismiss_overlays()
|
||||
tap_bottom_tab("首页", (108, 2255))
|
||||
time.sleep(1.5)
|
||||
dismiss_overlays()
|
||||
|
||||
|
||||
def capture_screen(rel, note=""):
|
||||
dismiss_overlays()
|
||||
data = dump()
|
||||
if is_paywall(data):
|
||||
print("PAYWALL at", rel, "— back out")
|
||||
shot(rel.replace(".png", "-paywall.png"))
|
||||
save_dump(rel.replace(".png", "-paywall.json"), data)
|
||||
back()
|
||||
return "paywall"
|
||||
shot(rel)
|
||||
save_dump(rel.replace(".png", ".json"), data)
|
||||
if note:
|
||||
with NOTES.open("a", encoding="utf-8") as f:
|
||||
f.write(f"- `{rel}` {note}\n")
|
||||
labels = [label(e) for e in elements(data) if label(e)]
|
||||
(OUT / rel.replace(".png", ".labels.txt")).write_text(
|
||||
"\n".join(labels), encoding="utf-8"
|
||||
)
|
||||
return "ok"
|
||||
|
||||
|
||||
def tap_bottom_tab(name: str, fb: tuple[int, int]) -> None:
|
||||
"""Only tap elements in the bottom tab bar (y>=2200)."""
|
||||
data = dump()
|
||||
hits = []
|
||||
for e in elements(data):
|
||||
lab = label(e)
|
||||
first = lab.split("\n", 1)[0].strip()
|
||||
if first != name and name not in first:
|
||||
continue
|
||||
xy = center(e)
|
||||
if not xy or xy[1] < 2200:
|
||||
continue
|
||||
if area(e) > 400 * 400:
|
||||
continue
|
||||
hits.append(e)
|
||||
if hits:
|
||||
hits.sort(key=lambda e: -(center(e) or (0, 0))[1])
|
||||
tap_el(hits[0])
|
||||
else:
|
||||
print(" tab fallback", name, fb)
|
||||
tap_xy(*fb)
|
||||
|
||||
|
||||
def capture_tabs():
|
||||
tabs = [
|
||||
("首页", "tabs/01-home.png", (108, 2255)),
|
||||
("消息", "tabs/02-message.png", (324, 2255)),
|
||||
("问", "tabs/03-ask.png", (540, 2255)), # may miss label
|
||||
("在线", "tabs/04-online.png", (756, 2255)),
|
||||
("我的", "tabs/05-mine.png", (972, 2255)),
|
||||
]
|
||||
for name, rel, fb in tabs:
|
||||
print("=== TAB", name)
|
||||
if name == "首页":
|
||||
go_home()
|
||||
# always leave nested pages first
|
||||
back(1)
|
||||
dismiss_overlays()
|
||||
tap_bottom_tab(name, fb)
|
||||
time.sleep(2.2)
|
||||
# ask may open sub UI
|
||||
if name == "问":
|
||||
capture_screen(rel, "ask tab root")
|
||||
for sub in ("测测AI", "真人1v1"):
|
||||
d = dump()
|
||||
if find_contains(d, sub):
|
||||
# prefer header tab (y < 400)
|
||||
hits = [
|
||||
e
|
||||
for e in find_contains(d, sub)
|
||||
if (center(e) or (0, 9999))[1] < 500
|
||||
]
|
||||
if hits:
|
||||
tap_el(hits[0])
|
||||
else:
|
||||
tap_text(sub, exact=False)
|
||||
time.sleep(2)
|
||||
capture_screen(f"tabs/03-ask-{sub}.png", f"ask sub {sub}")
|
||||
# tools sheet on ask
|
||||
d = dump()
|
||||
if find_exact(d, "工具") or find_contains(d, "工具"):
|
||||
hits = [e for e in find_contains(d, "工具") if (center(e) or (0, 0))[1] > 1800]
|
||||
if hits:
|
||||
tap_el(hits[0])
|
||||
time.sleep(2)
|
||||
if not is_paywall():
|
||||
capture_screen("ask/tools-sheet.png", "ask tools sheet")
|
||||
back()
|
||||
elif name == "消息":
|
||||
capture_screen(rel, "message tab")
|
||||
# open first conversation if free list item
|
||||
d = dump()
|
||||
for e in elements(d):
|
||||
lab = label(e)
|
||||
if lab and e.get("clickable") and (center(e) or (0, 0))[1] < 2000:
|
||||
if any(x in lab for x in ("系统", "通知", "客服", "互动", "赞")):
|
||||
tap_el(e)
|
||||
time.sleep(2)
|
||||
if capture_screen("tabs/02-message-detail.png", lab[:40]) != "paywall":
|
||||
back()
|
||||
break
|
||||
elif name == "在线":
|
||||
# 限免可能直达「向TA提问」付费页:关掉券弹层并退回列表
|
||||
for _ in range(4):
|
||||
d = dump()
|
||||
b = blob(d)
|
||||
if "我知道了" in b:
|
||||
tap_text("我知道了", exact=True)
|
||||
time.sleep(1)
|
||||
continue
|
||||
if "向TA提问" in b or "专享价购买" in b:
|
||||
print(" online landed on consult — back")
|
||||
back()
|
||||
continue
|
||||
break
|
||||
capture_screen(rel, "online tab")
|
||||
adb("shell", "input", "swipe", "540", "1800", "540", "900", "350")
|
||||
time.sleep(1.5)
|
||||
capture_screen("tabs/04-online-scroll.png", "online scrolled")
|
||||
elif name == "我的":
|
||||
capture_screen(rel, "mine tab")
|
||||
adb("shell", "input", "swipe", "540", "1800", "540", "900", "350")
|
||||
time.sleep(1.5)
|
||||
capture_screen("tabs/05-mine-scroll.png", "mine scrolled")
|
||||
# open free settings-like items
|
||||
for item in (
|
||||
"设置",
|
||||
"我的订单",
|
||||
"优惠券",
|
||||
"我的档案",
|
||||
"生命档案",
|
||||
"我的测试",
|
||||
"我的报告",
|
||||
"收藏",
|
||||
"帮助",
|
||||
"关于",
|
||||
"钱包",
|
||||
"会员中心",
|
||||
):
|
||||
go_tab_mine()
|
||||
d = dump()
|
||||
if find_contains(d, item):
|
||||
tap_text(item, exact=False)
|
||||
time.sleep(2)
|
||||
st = capture_screen(f"mine/{item}.png", item)
|
||||
if st == "paywall":
|
||||
continue
|
||||
back()
|
||||
else:
|
||||
capture_screen(rel, "home tab")
|
||||
|
||||
|
||||
def go_tab_mine():
|
||||
dismiss_overlays()
|
||||
tap_bottom_tab("我的", (972, 2255))
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
def capture_home_tools():
|
||||
for name in HOME_TOOLS:
|
||||
print("=== TOOL", name)
|
||||
go_home()
|
||||
time.sleep(1)
|
||||
data = dump()
|
||||
hits = find_contains(data, name) if name != "更多" else find_exact(data, "更多")
|
||||
if not hits:
|
||||
# scroll home a bit to reveal
|
||||
adb("shell", "input", "swipe", "540", "1600", "540", "900", "300")
|
||||
time.sleep(1)
|
||||
data = dump()
|
||||
hits = find_contains(data, name) or find_exact(data, name)
|
||||
if not hits:
|
||||
print(" skip missing", name)
|
||||
with NOTES.open("a", encoding="utf-8") as f:
|
||||
f.write(f"- MISSING tool entry: {name}\n")
|
||||
continue
|
||||
# prefer smaller icon cell
|
||||
hits.sort(key=area)
|
||||
tap_el(hits[0])
|
||||
time.sleep(2.5)
|
||||
safe = re.sub(r"[^\w\u4e00-\u9fff\-]+", "_", name)[:40]
|
||||
st = capture_screen(f"tools/{safe}.png", f"tool {name}")
|
||||
if st == "paywall":
|
||||
back()
|
||||
continue
|
||||
# one level deeper free exploration for 更多
|
||||
if name == "更多":
|
||||
d = dump()
|
||||
labels = []
|
||||
for e in elements(d):
|
||||
lab = label(e)
|
||||
if lab and e.get("clickable") and 0 < area(e) < 400 * 400:
|
||||
labels.append(lab)
|
||||
# unique preserve order
|
||||
seen = set()
|
||||
uniq = []
|
||||
for lab in labels:
|
||||
if lab in seen or lab in ("更多", "首页", "消息", "在线", "我的", "问"):
|
||||
continue
|
||||
seen.add(lab)
|
||||
uniq.append(lab)
|
||||
for lab in uniq[:25]:
|
||||
print(" more-item", lab)
|
||||
if not tap_text(lab, exact=True):
|
||||
if not tap_text(lab, exact=False):
|
||||
continue
|
||||
time.sleep(2)
|
||||
safe2 = re.sub(r"[^\w\u4e00-\u9fff\-]+", "_", lab)[:40]
|
||||
st2 = capture_screen(f"tools/more/{safe2}.png", f"more>{lab}")
|
||||
back()
|
||||
time.sleep(1)
|
||||
# ensure still on more page
|
||||
d2 = dump()
|
||||
if not find_contains(d2, "更多") and not any(
|
||||
x in blob(d2) for x in uniq[:5]
|
||||
):
|
||||
# re-open more
|
||||
go_home()
|
||||
tap_text("更多")
|
||||
time.sleep(2)
|
||||
else:
|
||||
# optional: capture one sub-tab if present without paying
|
||||
d = dump()
|
||||
for sub in ("今日", "本周", "概览", "免费", "详情", "解读"):
|
||||
if find_exact(d, sub) or find_contains(d, sub):
|
||||
# avoid 立即解锁
|
||||
if is_paywall(d):
|
||||
break
|
||||
tap_text(sub, exact=False)
|
||||
time.sleep(1.5)
|
||||
if is_paywall():
|
||||
back()
|
||||
break
|
||||
capture_screen(f"tools/{safe}-{sub}.png", f"{name}>{sub}")
|
||||
break
|
||||
back()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def capture_ask_deep():
|
||||
print("=== ASK deep")
|
||||
go_home()
|
||||
tap_xy(540, 2255)
|
||||
time.sleep(2)
|
||||
capture_screen("ask/01-root.png", "ask root")
|
||||
d = dump()
|
||||
for name in ("测测AI", "真人1v1", "历史", "新对话", "换一个"):
|
||||
if find_contains(d, name) or find_exact(d, name):
|
||||
tap_text(name, exact=False)
|
||||
time.sleep(2)
|
||||
if is_paywall():
|
||||
shot(f"ask/{name}-paywall.png")
|
||||
back()
|
||||
continue
|
||||
capture_screen(f"ask/{name}.png", name)
|
||||
d = dump()
|
||||
|
||||
|
||||
def capture_home_feed():
|
||||
print("=== HOME feed scroll")
|
||||
go_home()
|
||||
capture_screen("home/01-top.png", "home top")
|
||||
for i in range(1, 4):
|
||||
adb("shell", "input", "swipe", "540", "1900", "540", "800", "400")
|
||||
time.sleep(1.5)
|
||||
capture_screen(f"home/0{i+1}-scroll.png", f"home scroll {i}")
|
||||
|
||||
|
||||
def write_index():
|
||||
pngs = sorted(str(p.relative_to(OUT)) for p in OUT.rglob("*.png"))
|
||||
manifest = {
|
||||
"date": DATE,
|
||||
"package": PKG,
|
||||
"scope": "free-pages-only",
|
||||
"count": len(pngs),
|
||||
"screenshots": pngs,
|
||||
}
|
||||
(OUT / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print("TOTAL PNG", len(pngs))
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
NOTES.write_text(
|
||||
f"# CeCe free capture\n\nStarted: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ensure_device()
|
||||
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
|
||||
dismiss_overlays()
|
||||
# prefer 不再提醒 if minor mode
|
||||
tap_text("不再提醒")
|
||||
dismiss_overlays()
|
||||
capture_tabs()
|
||||
capture_home_feed()
|
||||
capture_home_tools()
|
||||
capture_ask_deep()
|
||||
write_index()
|
||||
with NOTES.open("a", encoding="utf-8") as f:
|
||||
f.write(f"\nFinished: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
print("DONE", OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user