Files
digital-psychology/tools/cece_capture_deep.py
T
jackyu66gitandCursor 53eb4577b3 feat: 按测测功能重构 H5 首页与各子页,并修正顶栏「+」添加档案菜单
对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:32:35 +08:00

1959 lines
67 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Module-whitelist deep crawl for CeCe (depth 34).
Shallow full crawl (cece_capture_all.py) only hits L1/L2.
This script walks known product modules with explicit child labels,
shots paywalls then backs out, and skips UGC/feed noise.
"""
from __future__ import annotations
import hashlib
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"
STAMP = time.strftime("%Y%m%d-%H%M%S")
OUT = ROOT / ".tmp" / "cece-validation" / time.strftime("%Y%m%d") / f"deep-{STAMP}"
NOTES = OUT / "NOTES.md"
MANIFEST = OUT / "manifest.json"
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", "")
)
# ─── module whitelist ───────────────────────────────────────────────
# entry: ("home"|"more"|"mine"|"tab", label)
# children: labels allowed at every depth (exact first-line match preferred)
# tabs: always try as horizontal/top chips when visible
# max_depth: L1=entry shot, L2=children, L3/L4=deeper
COMMON_DEEP = [
"开始", "开始测试", "立即开始", "立即体验", "免费体验", "继续", "下一步",
"查看详情", "深度解读", "解锁", "示例", "示例报告",
"添加档案", "选择档案", "新建档案", "编辑档案", "直接选择档案合盘",
"今日", "本周", "本月", "日运", "周运", "月运",
"概览", "详情", "解读",
]
ZODIAC = [
"白羊座", "金牛座", "双子座", "巨蟹座", "狮子座", "处女座",
"天秤座", "天蝎座", "射手座", "摩羯座", "水瓶座", "双鱼座",
]
CHART_TABS = ["天象", "本命", "行运", "三限", "次限", "日返", "月返", "法达", "推运", "合盘"]
NATAL_INNER = ["概览", "行星", "宫位", "相位", "解读", "深度解读"]
SYNASTRY_INNER = [
"添加档案", "直接选择档案合盘", "示例", "恋爱", "友情", "亲子",
"比较盘", "组合盘", "组合中点", "时空中点", "组合星座", "推运",
"查看报告", "深度报告", "邀请合盘",
]
MODULES = [
{
"id": "xingpan",
"entry": ("home", "星盘"),
"max_depth": 4,
"tabs": CHART_TABS,
"children": CHART_TABS + NATAL_INNER + COMMON_DEEP + [
"档案", "自己", "基础解读", "Deepseek解读", "本命盘", "现代", "参数",
"日", "周", "月", "测测达人在线解读星盘",
],
},
{
"id": "xingzuo",
"entry": ("home", "星座"),
"max_depth": 4,
# Hub 解读页:无障碍常泄漏成首页;勿用首页心情分「爱情/事业」当 Tab
"tabs": ["查看星盘", "简单模式"],
"fallback_xy": {
"查看星盘": (920, 420),
"简单模式": (200, 420),
},
"children": ["星座密码", "整体分析一下我的星图", "...全文"] + ZODIAC[:4] + COMMON_DEEP,
},
{
"id": "shengchen",
"entry": ("home", "生辰"),
"max_depth": 4,
# Hub Compose 页 a11y 常空/泄漏首页;用实测坐标深挖
"tabs": ["查看生辰历", "Ai 解读", "校正"],
"fallback_xy": {
"查看生辰历": (920, 420),
"Ai 解读": (930, 700),
"校正": (280, 1080),
},
"children": [
"合盘", "流盘", "参数", "设置", "开始校正", "直接选择类型", "...全文",
] + COMMON_DEEP,
},
{
"id": "hepan",
"entry": ("home", "缘分合盘"),
"max_depth": 4,
"tabs": [],
"children": SYNASTRY_INNER + COMMON_DEEP + ZODIAC[:4],
},
{
"id": "ziwei",
"entry": ("home", "紫微"),
"max_depth": 4,
"tabs": ["身宫", "命宫", "来因宫", "官禄宫", "迁移宫", "财帛宫", "夫妻宫"],
"fallback_xy": {
"查看紫微历": (920, 480),
"身宫": (200, 720),
"命宫": (540, 720),
"来因宫": (880, 720),
"官禄宫": (200, 980),
"迁移宫": (420, 980),
"财帛宫": (660, 980),
"夫妻宫": (880, 980),
},
"children": ["查看紫微历", "...全文"] + COMMON_DEEP,
},
{
"id": "iren",
"entry": ("home", "I人E人"),
"max_depth": 3,
"tabs": [],
"children": COMMON_DEEP + ["开始测试", "重新测试"],
},
{
"id": "xiaoxing",
"entry": ("home", "陪伴小星"),
"max_depth": 3,
"tabs": ["聊天", "日记", "成长"],
"children": COMMON_DEEP + ["聊天", "日记", "成长", "设置", "语音"],
},
{
"id": "qingsu",
"entry": ("home", "倾诉"),
"max_depth": 3,
"tabs": [],
"children": COMMON_DEEP + ["开始倾诉", "匹配达人", "语音通话", "文字"],
},
{
"id": "zhihuika",
"entry": ("home", "智慧卡"),
"max_depth": 3,
"tabs": ["智慧卡", "三张牌", "深度解读"],
"children": COMMON_DEEP + ["智慧卡", "三张牌", "深度解读", "灵魂伴侣", "抽卡", "再抽一次"],
},
{
"id": "xingpan_report",
"entry": ("home", "星盘报告"),
"max_depth": 3,
"tabs": ["全部", "解读", "情感", "财富", "自我探索"],
"children": COMMON_DEEP + ["全部", "解读", "情感", "财富", "自我探索", "购买", "示例", "目录"],
},
{
"id": "shengchenli",
"entry": ("home", "生辰历"),
"max_depth": 3,
"tabs": ["生辰", "合盘", "流盘"],
"children": COMMON_DEEP + ["生辰", "合盘", "流盘", "参数", "设置", "星盘", "紫微", "政余"],
},
{
"id": "linghun",
"entry": ("home", "灵魂伴侣"),
"max_depth": 3,
"tabs": ["男生", "女生", "不限"],
"children": COMMON_DEEP + ["男生", "女生", "不限", "开始匹配", "查看报告"],
},
{
"id": "ai_plaza",
"entry": ("home", "AI玩法广场"),
"max_depth": 3,
"tabs": ["热门", "上新", "测试", "心动站", "工具"],
"children": ["搜索", "七宗罪与七美德", "DIY水晶手串", "印盘", "人生说明书", "正缘画像"] + COMMON_DEEP,
},
# from 更多
{
"id": "ceshi",
"entry": ("more", "测试"),
"max_depth": 4,
"tabs": ["全部", "情感", "职场", "自我", "健康", "趣味"],
"children": COMMON_DEEP + [
"全部", "情感", "职场", "自我", "健康", "趣味",
"MBTI", "九型人格", "开始测试", "继续答题", "提交",
],
},
{
"id": "shapan",
"entry": ("more", "沙盘"),
"max_depth": 3,
# Unity Game view:无障碍几乎为空,靠底部工具栏坐标
"tabs": ["完成", "道具", "保存"],
"fallback_xy": {
"完成": (180, 2200),
"道具": (400, 2200),
"保存": (620, 2200),
"解读": (840, 2200),
},
"children": COMMON_DEEP + ["完成", "道具", "保存", "解读"],
},
{
"id": "moodtown",
"entry": ("more", "心情小镇"),
"max_depth": 3,
# 「和我聊聊」按钮无障碍缺失;按角色名 Y+230 实测
"tabs": ["甄心", "志远", "明朗"],
"fallback_xy": {
"甄心": (900, 1118),
"志远": (900, 1665),
"明朗": (900, 2210),
},
"children": [],
},
{
"id": "xingxiu",
"entry": ("more", "星宿"),
"max_depth": 3,
"tabs": ["本命星宿", "值日星宿", "星宿关系", "全部"],
"children": COMMON_DEEP + ["本命星宿", "值日星宿", "星宿关系", "全部", "...全文"],
},
{
"id": "aiqingshu",
"entry": ("more", "爱情树"),
"max_depth": 3,
"tabs": ["开始测试", "重新测试"],
"children": COMMON_DEEP + [
"开始测试", "重新测试", "伴侣相处阻力", "成人依恋类型", "家谱图",
"依恋类型", "完成测试题目后即可查看详细解读",
],
},
{
"id": "liaotian",
"entry": ("more", "聊天分析"),
"max_depth": 3,
"tabs": [],
"children": COMMON_DEEP + ["上传", "开始分析", "示例"],
},
{
"id": "guanxiwang",
"entry": ("more", "关系网"),
"max_depth": 3,
"tabs": ["添加", "编辑"],
"fallback_xy": {
"添加": (980, 200),
"编辑": (880, 200),
},
"children": COMMON_DEEP + ["添加", "编辑"],
},
{
"id": "jiedu",
# 「更多」里的「解读」已变成分区标题不可点;达人解读实际在底栏「在线」
"entry": ("tab", "在线"),
"max_depth": 3,
"tabs": ["关注", "全部", "私人专线", "综合排序", "筛选", "甄选达人"],
"fallback_xy": {
"关注": (101, 205),
"全部": (274, 205),
"私人专线": (487, 205),
"综合排序": (125, 309),
"筛选": (990, 309),
"甄选达人": (154, 400),
},
"children": [],
},
{
"id": "xingzhi",
"entry": ("more", "星骰"),
"max_depth": 3,
"tabs": [],
"children": COMMON_DEEP + ["投掷", "再来一次"],
},
{
"id": "rishu",
"entry": ("more", "灵数"),
"max_depth": 3,
"tabs": ["财富", "忠诚", "远见", "挑剔"],
"children": COMMON_DEEP + ["财富", "忠诚", "远见", "挑剔", "...全文"],
},
{
"id": "shengxiao",
"entry": ("more", "生肖"),
"max_depth": 3,
"tabs": [],
"children": COMMON_DEEP + [
"鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊", "猴", "鸡", "狗", "猪",
],
},
{
"id": "mayan",
"entry": ("more", "玛雅图腾"),
"max_depth": 3,
"tabs": [],
"children": COMMON_DEEP,
},
{
"id": "renleitu",
"entry": ("more", "人类图"),
"max_depth": 3,
"tabs": ["设计"],
"children": COMMON_DEEP + ["设计", "...全文"],
},
{
"id": "ziweili",
"entry": ("more", "紫微历"),
"max_depth": 3,
"tabs": ["大限", "参数", "设置"],
"children": COMMON_DEEP + ["大限", "参数", "设置", "4~13岁", "14~23岁", "24~33岁"],
},
{
"id": "rili",
"entry": ("more", "日历"),
"max_depth": 3,
# 点一个日期进详情
"tabs": ["立秋", "2", "3", "7"],
"children": COMMON_DEEP + ["立秋"],
},
# 我的关键子树
{
"id": "mine_dangan",
"entry": ("mine", "档案"),
"max_depth": 3,
"tabs": [],
"children": COMMON_DEEP + ["添加", "新建", "编辑", "删除", "自己"],
},
{
"id": "mine_huiyuan",
"entry": ("mine", "VIP会员陪伴中"),
"max_depth": 4,
"tabs": ["VIP", "SVIP", "记录"],
"children": [
"VIP", "SVIP", "记录", "开通", "续费", "活动须知", "遇到问题?",
"连续包月", "连续包季", "连续包年", "12个月",
"工具内容解读权益", "确认并支付", "首次开通送",
"新用户限时特惠", "开启一段更懂你的陪伴", "升级SVIP",
] + COMMON_DEEP,
"entry_alts": [
"VIP会员陪伴中", "升级SVIP", "¥9.9开启陪伴", "新用户限时特惠",
"开通会员", "VIP会员", "测测会员", "会员",
],
},
{
"id": "mine_duihuan",
"entry": ("mine", "兑换码"),
"max_depth": 2,
"tabs": [],
"children": ["兑换", "确定", "取消"] + COMMON_DEEP,
},
{
"id": "mine_dingdan",
"entry": ("mine_mall_order", "全部订单"),
"max_depth": 3,
"tabs": ["全部", "待付款", "待收货", "退款售后"],
"fallback_xy": {
"全部": (150, 280),
"待付款": (380, 280),
"待收货": (620, 280),
"退款售后": (900, 280),
},
"children": ["全部", "待付款", "待收货", "退款售后", "全部订单", "商城订单", "详情"] + COMMON_DEEP,
},
{
"id": "mine_shezhi",
"entry": ("mine_gear", "设置"),
"max_depth": 3,
"tabs": [],
"children": [
"个人资料", "账号安全", "免密支付", "通知设置", "隐私设置", "语言设置",
"智能体设置", "黑名单", "未成年人模式", "夜间模式", "震动提醒", "推荐给好友",
],
},
{
"id": "mine_baogao",
"entry": ("mine", "报告与测试"),
"max_depth": 3,
"tabs": ["已购买", "我的报告", "我的测试"],
"children": ["已购买", "我的报告", "我的测试", "购买须知"] + COMMON_DEEP,
"entry_alts": ["报告与测试", "报告", "我的报告", "0\n报告与测试"],
},
{
"id": "mine_ceshi",
"entry": ("mine", "测试"),
"max_depth": 3,
"tabs": ["我的测试", "我的报告", "已购买"],
"children": ["我的测试", "我的报告", "已购买", "继续", "重新测试"] + COMMON_DEEP,
"entry_alts": ["我的测试", "报告与测试"],
},
]
# 底栏 Tab(补齐)
MODULES = [
{
"id": "tab_message",
"entry": ("tab", "消息"),
"max_depth": 2,
"tabs": [],
# 不进小星/互动聊天,只截系统通知
"children": ["系统通知"],
},
{
"id": "tab_ask",
"entry": ("tab", "问"),
"max_depth": 2,
# 不截真人1v1
"tabs": ["测测AI"],
"children": ["测测AI", "工具", "深度解读"],
},
{
"id": "tab_online",
"entry": ("tab", "在线"),
"max_depth": 2,
"tabs": [],
"children": ["综合排序", "筛选", "全部工具"],
},
] + MODULES
BOTTOM_TABS = {"首页", "消息", "在线", "我的", "问"}
TAB_FALLBACK = {
"首页": (108, 2255),
"消息": (324, 2255),
"问": (540, 2255),
"在线": (756, 2255),
"我的": (972, 2255),
}
PAYWALL_KEYS = (
"确认并支付", "专享价购买", "微信支付", "支付宝支付", "苹果支付",
"立即支付", "连续包月", "确认协议并支付", "开通SVIP", "向TA提问",
)
BLACKLIST_EXACT = {
"首页", "消息", "在线", "我的", "问", "推荐", "工具", "更多",
"点赞", "评论", "关注", "粉丝", "私信", "直播",
"最热", "最新", "全部评论",
}
BLACKLIST_RE = re.compile(
r"^(\d+|U\d+|第\s*\d+|$|"
r".{0,2}穿搭|梨形|财神爷|米咪嘟|竹木鑫|赵莹|天使|"
r"广告|下载|打开测测|"
r"全部评论|条评论|写评论|查看评论)"
)
MAX_CHILDREN_PER_PAGE = 16
MAX_SHOTS = 600
# Long screenshot: finger swipe UP (往上滑) to reveal content below, then stitch
LONG_MAX_SCROLLS = 6 # extra frames after the first viewport
LONG_BOTTOM_CROP = 260 # drop bottom tab bar from stitch frames
LONG_TOP_CROP_CONT = 160 # drop status/sticky header on continuation frames
LONG_MIN_OVERLAP = 60
LONG_MAX_OVERLAP = 520
LONG_SCROLL_BACK = 5 # after stitch, pull page back to top (finger down)
# 上滑长图时折叠块入口(点开后再截)
EXPAND_LABELS = (
"展开更多",
"展开全部",
"查看更多",
"点击展开",
"显示更多",
"展开",
)
# ─── adb / ui helpers ───────────────────────────────────────────────
def sh(args, timeout=120):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
print(f" TIMEOUT {' '.join(str(a) for a in args[:4])}", flush=True)
return subprocess.CompletedProcess(args, 124, "", "timeout")
def adb(*args, timeout=30):
return sh(["adb", *args], timeout=timeout)
def tool(*args, timeout=25):
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", timeout=15)
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, timeout=15)
time.sleep(5)
def dump():
"""UI dump with short timeout; never hang the crawl."""
try:
data = tool("ui", "dump", timeout=18)
if isinstance(data, dict) and data.get("elements") is not None:
return data
except Exception as ex:
print(f" dump err: {ex}", flush=True)
return {"elements": []}
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 first_line(t: str) -> str:
return t.split("\n", 1)[0].strip()
def safe_name(s: str) -> str:
return re.sub(r"[^\w\u4e00-\u9fff\-]+", "_", s)[:40]
def tap_xy(x, y, wait=1.4):
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.65)
if PKG not in fg_pkg():
print(" back exited app → relaunch", flush=True)
ensure_app()
break
def swipe_up():
"""手指往上滑 → 页面内容上移,露出下方。
起点避开底部手势条(过低易触发 Home / 返回)。
"""
adb("shell", "input", "swipe", "540", "1680", "540", "720", "380")
time.sleep(0.95)
def swipe_down():
"""手指往下滑 → 回到上方(仅用于长图后复位)。"""
adb("shell", "input", "swipe", "540", "750", "540", "1850", "380")
time.sleep(0.7)
def scroll_page_top(n: int = 3):
"""After long shot, gently return toward top (fewer swipes = less stall)."""
for _ in range(n):
swipe_down()
def expand_more_if_present(max_taps: int = 3) -> int:
"""上滑过程中若出现「展开更多」等按钮,点开再继续截。返回点击次数。"""
taps = 0
for _ in range(max_taps):
data = dump()
blob_txt = "\n".join(lab(e) for e in (data.get("elements") or []) if lab(e))
if not any(k in blob_txt for k in ("展开更多", "展开全部", "查看更多", "显示更多", "点击展开")) and "展开" not in blob_txt:
break
hit = None
for e in data.get("elements") or []:
t = lab(e)
if not t:
continue
fl = first_line(t)
if fl in ("收起", "收起更多", "收起全部"):
continue
matched = fl in EXPAND_LABELS or any(
k in fl for k in ("展开更多", "展开全部", "查看更多", "显示更多", "点击展开")
)
if not matched and fl == "展开":
matched = True
if not matched:
continue
xy = center(e)
if not xy or xy[1] < 200 or xy[1] > 2150:
continue
hit = (fl, xy)
break
if not hit:
break
name, xy = hit
print(f" long: 展开 '{name}' {xy}", flush=True)
tap_xy(*xy, wait=1.2)
taps += 1
time.sleep(0.35)
return taps
def swipe_left_mid():
adb("shell", "input", "swipe", "900", "1050", "200", "1050", "280")
time.sleep(0.8)
def swipe_right_mid():
"""Reset home tool carousel to first page (I人E人 side)."""
adb("shell", "input", "swipe", "200", "1050", "900", "1050", "280")
time.sleep(0.8)
def fingerprint(labs: list[str]) -> str:
# include head + mid content so tab chips alone don't collapse distinct pages
fls = [first_line(x) for x in labs]
key = "|".join(fls[:18] + fls[18:36])
return hashlib.md5(key.encode()).hexdigest()[:12]
def is_blacklisted(name: str) -> bool:
if name in BLACKLIST_EXACT or name in BOTTOM_TABS:
return True
if BLACKLIST_RE.search(name):
return True
if len(name) > 16 and not any(k in name for k in ("报告", "测试", "解读", "档案", "合盘")):
return True
return False
def allowed(name: str, allow: set[str]) -> bool:
"""Strict whitelist: exact first-line match only (no fuzzy substring)."""
if is_blacklisted(name):
return False
return name in allow
# ─── gestures / nav ─────────────────────────────────────────────────
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)
fl = first_line(t)
if fl != name and t != name:
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=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 = TAB_FALLBACK.get(name, (540, 2255))
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=10) -> None:
for i in range(max_rounds):
ensure_app()
labs = labels()
b = "\n".join(labs)
fls = [first_line(x) for x in labs]
print(f" escape[{i}] {fls[:4]}", flush=True)
if "首页" in fls and not any(
k in b
for k in (
"隐私政策更新", "温馨提示", "立即领取", "打开通知",
"开通SVIP", "向TA提问", "确认并支付", "专享价购买", "未成年人模式",
)
):
return
if "同意" in fls and any(k in b for k in ("隐私", "温馨提示")):
tap_exact("同意")
continue
if "不再提醒" in fls:
tap_exact("不再提醒")
continue
if "我知道了" in fls:
tap_exact("我知道了")
continue
if "取消" in fls and "通知" in b:
tap_exact("取消")
continue
if "立即领取" in fls:
for y in (1680, 1720, 1760, 1800):
tap_xy(540, y, wait=0.25)
continue
if any(k in b for k in PAYWALL_KEYS):
# leave to caller to shot; just stop escaping into back-loop
return
if "关闭" in fls:
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
# stuck overlay
if i >= 3:
back()
continue
return
def scroll_home_top():
"""Pull homepage content back to the tool grid (avoid feed / mid-scroll)."""
for _ in range(3):
adb("shell", "input", "swipe", "540", "700", "540", "1900", "280")
time.sleep(0.45)
def reset_home_tool_row():
"""Horizontal carousel often sits on page 2 — swipe right back to I人E人."""
for _ in range(2):
swipe_right_mid()
def is_more_wall(labs=None) -> bool:
"""「更多」工具墙:顶栏「更多/推荐」+ 墙内专属入口,避免首页信息流误判。"""
fls = [first_line(x) for x in (labs or labels())]
if "更多" in fls and "推荐" in fls and (
"心情小镇" in fls or "幸运地图" in fls or "聊天分析" in fls or "关系网" in fls
):
return True
return False
def is_home_grid(labs=None) -> bool:
fls = [first_line(x) for x in (labs or labels())]
# inside chart chrome → not home
if "天象" in fls and "本命" in fls and "行运" in fls:
return False
# 「更多」工具墙也有星座/生辰/紫微,不能当首页
if is_more_wall(labs):
return False
# 玩法广场 hub
if "玩法广场" in fls and "热门" in fls:
return False
# 首页宫格:完整第一屏,或横向滑过后的工具行
if "I人E人" in fls:
return True
if "星座" in fls and "星盘" in fls and "生辰" in fls:
return True
if "紫微" in fls and ("恋爱能力" in fls or "星盘报告" in fls or "生辰历" in fls or "倾诉" in fls):
return True
if "AI玩法广场" in fls and ("星盘报告" in fls or "灵魂伴侣" in fls or "智慧卡" in fls):
return True
# 底栏在首页 + 可见档案「自己」工具带
if "首页" in fls and "自己" in fls and "示例" in fls and ("紫微" in fls or "星座" in fls or "星盘" in fls):
return True
return False
def tap_top_right_close() -> bool:
"""在线房间等全屏页:右上角 × 关闭(BACK/底栏常无效)。"""
try:
data = dump()
except Exception as ex:
print(f" close-X dump fail: {ex}", flush=True)
data = {}
hits = []
for e in data.get("elements") or []:
if not e.get("clickable"):
continue
xy = center(e)
if not xy:
continue
x, y = xy
if y > 300 or x < 820:
continue
if area(e) > 60000:
continue
t = (lab(e) or "").strip()
fl = first_line(t)
score = 0
if fl in ("×", "X", "x", "关闭"):
score += 10
if "关闭" in t:
score += 5
if not fl:
score += 2
hits.append((score, area(e), xy))
hits.sort(key=lambda h: (-h[0], h[1]))
if hits and hits[0][0] > 0:
xy = hits[0][2]
print(f" TAP-XY close-X {xy}", flush=True)
tap_xy(*xy, wait=1.2)
return True
for xy in ((1002, 215), (990, 160), (1010, 180)):
print(f" TAP-XY close-X fallback {xy}", flush=True)
tap_xy(*xy, wait=1.0)
try:
fls = [first_line(x) for x in labels()]
if not any(k in fls for k in ("说点什么...", "说点什么", "公开连麦", "麦序")):
return True
except Exception:
return True
return False
def go_home():
"""Leave subpages via BACK, then land on 首页 tool grid."""
try:
escape_modals(max_rounds=3)
except Exception:
pass
prev_sig = None
stuck = 0
for i in range(10):
try:
labs = labels()
except Exception as ex:
print(f" go_home dump fail: {ex}", flush=True)
back()
continue
fls = [first_line(x) for x in labs]
b = "\n".join(labs)
print(f" go_home[{i}] {fls[:5]}", flush=True)
sig = tuple(fls[:4])
if sig == prev_sig:
stuck += 1
else:
stuck = 0
prev_sig = sig
if stuck >= 3:
print(" go_home: stuck → force-stop relaunch", flush=True)
adb("shell", "am", "force-stop", "com.xxwolo.cc5", timeout=15)
time.sleep(1.2)
adb("shell", "am", "start", "-n", ACT, timeout=15)
time.sleep(3.5)
tap_tab("首页")
time.sleep(1)
break
# Unity / WebView 沙盘等:无原生控件,BACK 无效 → 强杀重开
# 注意:空 dump 不能当成 Game view(否则会误强杀死循环)
if fls == ["Game view"] or (len(fls) == 1 and fls[0] == "Game view"):
print(" go_home: Game view → force-stop relaunch", flush=True)
adb("shell", "am", "force-stop", "com.xxwolo.cc5", timeout=15)
time.sleep(1)
adb("shell", "am", "start", "-n", ACT, timeout=15)
time.sleep(3.5)
tap_tab("首页")
time.sleep(1)
break
if not fls:
print(" go_home: empty dump → BACK + 首页", flush=True)
back()
time.sleep(0.5)
tap_tab("首页")
time.sleep(0.8)
if i >= 2:
adb("shell", "am", "start", "-n", ACT, timeout=15)
time.sleep(2.5)
tap_tab("首页")
break
continue
if is_home_grid(labs):
break
# 测测AI 聊天浮层:必须先关 ×(点首页无效)
if "测测AI" in fls and any(
k in b
for k in (
"让我来解答你的问题吧",
"内容由AI生成",
"正在生成",
"已思考",
"真人1v1",
)
) and (
"让我来解答你的问题吧" in b
or "内容由AI生成" in b
or "正在生成" in b
or "已思考" in fls
or any(len(first_line(x)) > 36 for x in labs[:10])
):
print(" go_home: 测测AI sheet → close-X", flush=True)
tap_top_right_close()
time.sleep(0.8)
continue
# 向TA提问 / 达人咨询收银:BACK
if "向TA提问" in fls or (
"文字沟通" in fls and "语音沟通" in fls and any(
k in b for k in ("请输入问题", "支付方式", "选择档案", "VIP专享价")
)
):
print(" go_home: 向TA提问 → BACK", flush=True)
back()
time.sleep(0.7)
continue
# 达人主页(文字/语音沟通入口)
if "文字沟通" in fls and "语音沟通" in fls:
print(" go_home: 达人主页 → BACK", flush=True)
back()
time.sleep(0.7)
continue
# 在线直播间 / 达人房间:点右上角 ×
if any(k in fls for k in ("说点什么...", "说点什么", "公开连麦", "麦序", "更多在线")):
print(" go_home: live room → close-X", flush=True)
tap_top_right_close()
time.sleep(0.8)
continue
# 「我的」个人主页:BACK 无效,直接切首页 Tab
if "VIP会员陪伴中" in fls or (
"ESTP" in fls and any(k in fls for k in ("兑换码", "订单", "设置", "报告与测试"))
) or (
any(k in fls for k in ("兑换码", "我的订单", "设置")) and "首页" in fls and "我的" in fls
):
print(" go_home: on 我的 → tap 首页", flush=True)
tap_tab("首页")
time.sleep(1.0)
continue
# 消息 / 问 / 在线 底栏页:BACK 停在 Tab 内
if ("系统通知" in fls and "小星" in fls) or (
"消息" in fls and any(k in fls for k in ("系统通知", "互动消息", "去开启"))
):
print(" go_home: on 消息 → tap 首页", flush=True)
tap_tab("首页")
time.sleep(1.0)
continue
# 在线列表须先于泛化的「测测AI」判断
if "综合排序" in fls and ("筛选" in fls or "私人专线" in fls or "全部工具" in fls):
print(" go_home: on 在线 → tap 首页", flush=True)
tap_tab("首页")
time.sleep(1.0)
continue
if "测测AI" in fls or "真人1v1" in fls:
print(" go_home: on 问 → tap 首页", flush=True)
tap_tab("首页")
time.sleep(1.0)
continue
if is_comment_feed(labs):
print(" go_home: leave comment feed", flush=True)
back()
time.sleep(0.7)
continue
# 档案列表也在我的树下
if any(k in fls for k in ("档案列表", "按关系排序", "按昵称排序", "添加档案")):
print(" go_home: leave 档案 → 首页", flush=True)
back()
time.sleep(0.5)
tap_tab("首页")
time.sleep(0.8)
continue
if any(
k in fls
for k in (
"天象", "本命", "行运", "三限", "基础解读", "Deepseek解读",
)
):
back()
time.sleep(0.7)
continue
if any(k in b for k in PAYWALL_KEYS):
back()
time.sleep(0.5)
continue
# Prefer BACK over tab tap when unsure (tab tap often no-ops / hangs dumps)
back()
time.sleep(0.6)
if i >= 3:
tap_tab("首页")
time.sleep(0.8)
try:
escape_modals(max_rounds=2)
except Exception:
pass
scroll_home_top()
try:
labs = labels()
if is_home_grid(labs) and "I人E人" not in [first_line(x) for x in labs]:
reset_home_tool_row()
labs = labels()
if not is_home_grid(labs):
print(" go_home: relaunch main", flush=True)
adb("shell", "am", "force-stop", "com.xxwolo.cc5", timeout=15)
time.sleep(1)
adb("shell", "am", "start", "-n", ACT, timeout=15)
time.sleep(3.5)
tap_tab("首页")
time.sleep(1)
scroll_home_top()
reset_home_tool_row()
except Exception as ex:
print(f" go_home final: {ex}", flush=True)
adb("shell", "am", "force-stop", "com.xxwolo.cc5", timeout=15)
time.sleep(1)
adb("shell", "am", "start", "-n", ACT, timeout=15)
time.sleep(3.5)
def open_more():
"""Open 首页宫格「更多」——常在第二屏,需左右滑。
禁止 tap_contains('更多'):信息流大卡片文案里常含「更多」,会点到 (540,642) 假入口。
"""
go_home()
scroll_home_top()
reset_home_tool_row()
for _ in range(5):
try:
# 只认宫格小图标:文案精确为「更多」,且在工具带 Y 范围
if tap_exact("更多", ymin=900, ymax=1700, prefer_small=True):
time.sleep(1.6)
if is_more_wall():
return True
print(" open_more: tap 更多 but not wall, back", flush=True)
back()
time.sleep(0.6)
except Exception as ex:
print(f" open_more tap err: {ex}", flush=True)
swipe_left_mid()
# 坐标兜底(Pixel7 模拟器第二屏右下宫格)
print(" open_more TAP-XY (984,1366)", flush=True)
swipe_left_mid()
tap_xy(984, 1366, wait=1.8)
try:
if is_more_wall():
return True
b = blob()
if any(k in b for k in ("心情小镇", "幸运地图", "星骰")):
return True
except Exception:
pass
return False
def find_and_tap_in_scroll(name: str, rounds=5, ymin=300, ymax=2100) -> bool:
for i in range(rounds):
if tap_exact(name, ymin=ymin, ymax=ymax):
return True
if tap_force(name, ymin=ymin, ymax=ymax):
return True
# 短文案禁用 contains,避免误点信息流
if len(name) >= 3 and tap_contains(name, ymin=ymin, ymax=ymax):
return True
if i < rounds - 1:
# 更多墙内轻滑,避免整页上滑把墙滑走
adb("shell", "input", "swipe", "540", "1700", "540", "1100", "280")
time.sleep(0.45)
return False
def tap_home_tool(label: str) -> bool:
"""Tap a home-grid tool; prefer exact match, then known coordinates."""
# Known Pixel-7-ish grid centers (from successful captures)
COORDS = {
"I人E人": (150, 974),
"星座": (320, 974),
"星盘": (490, 974),
"生辰": (660, 974),
"缘分合盘": (830, 974),
"紫微": (1000, 974),
"陪伴小星": (150, 1200),
"倾诉": (320, 1200),
"智慧卡": (490, 1200),
"星盘报告": (660, 1200),
"生辰历": (830, 1200),
"灵魂伴侣": (1000, 1200),
"更多": (984, 1366),
"AI玩法广场": (274, 1583),
}
scroll_home_top()
reset_home_tool_row()
for attempt in range(3):
try:
if tap_exact(label, ymin=350, ymax=1700):
return True
except Exception as ex:
print(f" tap_exact err: {ex}", flush=True)
if attempt == 0:
scroll_home_top()
elif attempt == 1:
swipe_left_mid()
# last resort: fixed coordinates (avoids another failing dump loop)
if label in COORDS:
x, y = COORDS[label]
print(f" TAP-XY '{label}' ({x},{y})", flush=True)
if label == "更多":
swipe_left_mid()
tap_xy(x, y, wait=1.8)
return True
try:
return tap_contains(label, ymin=350, ymax=1700)
except Exception:
return False
# ─── capture state ──────────────────────────────────────────────────
class State:
def __init__(self):
self.shots = 0
self.seen_fps: set[str] = set()
self.log: list[dict] = []
STATE = State()
def _pil_open(path: Path):
from PIL import Image
return Image.open(path).convert("RGB")
def _strip_mse(a, b) -> float:
"""Subsampled mean squared error between two same-size RGB images."""
wa, ha = a.size
wb, hb = b.size
if (wa, ha) != (wb, hb) or wa == 0 or ha == 0:
return 1e18
pa, pb = a.load(), b.load()
err = 0.0
n = 0
step = 6
for y in range(0, ha, step):
for x in range(0, wa, step):
ca, cb = pa[x, y], pb[x, y]
err += (ca[0] - cb[0]) ** 2 + (ca[1] - cb[1]) ** 2 + (ca[2] - cb[2]) ** 2
n += 1
return err / max(n, 1)
def _frame_sig(img) -> str:
"""Cheap content signature to detect end-of-scroll (no movement)."""
w, h = img.size
band = img.crop((0, h // 3, w, (2 * h) // 3)).resize((64, 32))
return hashlib.md5(band.tobytes()).hexdigest()[:16]
def find_overlap_height(prev, curr, min_ov=LONG_MIN_OVERLAP, max_ov=LONG_MAX_OVERLAP) -> int:
"""How many top pixels of curr overlap the bottom of prev — coarse + fast."""
w, h = prev.size
max_ov = min(max_ov, h - 20, curr.size[1] - 20)
min_ov = min(min_ov, max_ov)
# fixed-step search only (avoid per-pixel refine — was hanging the crawl)
best_ov, best_err = min_ov, 1e18
for ov in range(min_ov, max_ov + 1, 24):
prev_strip = prev.crop((0, h - ov, w, h)).resize((w // 8, max(1, ov // 8)))
curr_strip = curr.crop((0, 0, w, ov)).resize((w // 8, max(1, ov // 8)))
err = _strip_mse(prev_strip, curr_strip)
if err < best_err:
best_err, best_ov = err, ov
return best_ov
def stitch_long(frames: list[Path], out_path: Path) -> Path | None:
"""Vertical stitch with overlap detection; crops chrome on continuation frames."""
if not frames:
return None
from PIL import Image
imgs = [_pil_open(p) for p in frames]
w, h0 = imgs[0].size
# first frame: keep full width, drop bottom tab for cleaner long page
pieces = [imgs[0].crop((0, 0, w, max(1, h0 - LONG_BOTTOM_CROP)))]
for im in imgs[1:]:
w2, h2 = im.size
top = min(LONG_TOP_CROP_CONT, h2 // 5)
bottom = max(top + 1, h2 - LONG_BOTTOM_CROP)
body = im.crop((0, top, w2, bottom))
if body.size[0] != pieces[-1].size[0]:
body = body.resize((pieces[-1].size[0], body.size[1]))
ov = find_overlap_height(pieces[-1], body)
pieces.append(body.crop((0, ov, body.size[0], body.size[1])))
total_h = sum(p.size[1] for p in pieces)
canvas = Image.new("RGB", (pieces[0].size[0], total_h), (255, 255, 255))
y = 0
for p in pieces:
canvas.paste(p, (0, y))
y += p.size[1]
out_path.parent.mkdir(parents=True, exist_ok=True)
canvas.save(out_path, "PNG", optimize=True)
return out_path
def screencap_to(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
data = tool("screenshot", "--out", str(path))
if path.exists() and path.stat().st_size >= 1000:
return path
# binary fallback (avoid text-mode corruption)
with path.open("wb") as f:
subprocess.run(
["adb", "exec-out", "screencap", "-p"],
stdout=f,
timeout=60,
check=False,
)
if not path.exists() or path.stat().st_size < 1000:
raise RuntimeError(f"screencap failed: {path} tool={data}")
return path
def shot(rel: str, note: str = "", long: bool = True) -> Path:
"""Capture viewport; optionally scroll-stitch a *-long.png and scroll back to top."""
ensure_app()
path = OUT / rel
path.parent.mkdir(parents=True, exist_ok=True)
print(f" shot:begin {rel}", flush=True)
# screencap first so we always have a file even if dump hangs
screencap_to(path)
STATE.shots += 1
print(f"SHOT [{STATE.shots}] {rel} bytes={path.stat().st_size} note={note}", flush=True)
try:
labs = labels()
(OUT / (rel.replace(".png", ".labels.txt"))).write_text("\n".join(labs), encoding="utf-8")
except Exception as ex:
print(f" labels fail: {ex}", flush=True)
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- `{rel}` {note}\n")
STATE.log.append({"path": rel, "note": note, "bytes": path.stat().st_size})
if long and not rel.endswith("-cashier.png"):
try:
long_path = shot_long_from(path, rel, note)
if long_path:
STATE.log.append({"path": str(long_path.relative_to(OUT)), "note": note + " [long]"})
except Exception as ex:
print(f" long-shot fail: {ex}", flush=True)
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- LONG_FAIL `{rel}` {ex}\n")
finally:
# 只复位滚动,不再 dump(dump 是卡死主因之一)
try:
scroll_page_top(2)
except Exception as ex:
print(f" scroll_page_top fail: {ex}", flush=True)
return path
def shot_long_from(first: Path, rel: str, note: str):
"""往上滑多屏截取并拼成 *-long.png。
策略:先滑再截(保证能动);每隔一帧再尝试点「展开更多」,dump 超时则跳过。
"""
parts_dir = first.parent / (first.stem + "_parts")
parts_dir.mkdir(parents=True, exist_ok=True)
frames = [first]
part0 = parts_dir / "0.png"
if not part0.exists():
part0.write_bytes(first.read_bytes())
prev_sig = _frame_sig(_pil_open(frames[0]))
for i in range(1, LONG_MAX_SCROLLS + 1):
print(f" long: 往上滑 frame={i}", flush=True)
try:
(OUT / ".heartbeat").write_text(f"{time.strftime('%T')} {rel} f{i}\n", encoding="utf-8")
except Exception:
pass
swipe_up()
# expand 先关掉:ui dump 在长图循环里会偶发把进程卡死/杀掉
# (单独冒烟正常,全量爬里 frame=1 就挂)。需要时再开 LONG_EXPAND=1
if os.environ.get("LONG_EXPAND") == "1" and i % 2 == 1:
try:
n_exp = expand_more_if_present(max_taps=2)
if n_exp:
print(f" long: expanded x{n_exp}", flush=True)
time.sleep(0.4)
except Exception as ex:
print(f" long: expand skip {ex}", flush=True)
part = parts_dir / f"{i}.png"
try:
screencap_to(part)
except Exception as ex:
print(f" long: screencap fail {ex}", flush=True)
break
try:
sig = _frame_sig(_pil_open(part))
except Exception as ex:
print(f" long: sig fail {ex}", flush=True)
break
if sig == prev_sig:
print(f" long: 已到底 frame={i}", flush=True)
part.unlink(missing_ok=True)
break
prev_sig = sig
frames.append(part)
if len(frames) <= 1:
print(" long: 单屏无需拼接", flush=True)
return None
long_rel = rel.replace(".png", "-long.png")
out = OUT / long_rel
print(f" long: stitching {len(frames)} frames…", flush=True)
try:
stitch_long(frames, out)
except Exception as ex:
print(f" long: stitch fail {ex}, fallback concat", flush=True)
# emergency: simple vertical concat with fixed crop
from PIL import Image
imgs = [_pil_open(p) for p in frames]
w, h = imgs[0].size
crop_b = LONG_BOTTOM_CROP
pieces = [imgs[0].crop((0, 0, w, h - crop_b))]
for im in imgs[1:]:
body = im.crop((0, LONG_TOP_CROP_CONT, w, h - crop_b))
ov = min(280, body.size[1] // 4)
pieces.append(body.crop((0, ov, w, body.size[1])))
th = sum(p.size[1] for p in pieces)
canvas = Image.new("RGB", (w, th), (255, 255, 255))
y = 0
for p in pieces:
canvas.paste(p, (0, y))
y += p.size[1]
canvas.save(out, "PNG")
STATE.shots += 1
print(
f"SHOT-LONG [{STATE.shots}] {long_rel} frames={len(frames)} "
f"bytes={out.stat().st_size} note={note}",
flush=True,
)
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- `{long_rel}` {note} [long x{len(frames)} 往上滑+展开]\n")
return out
def is_comment_feed(labs=None) -> bool:
"""UGC 评论流:不截图,应直接返回。"""
b = "\n".join(labs or labels())
return ("全部评论" in b) or ("写评论" in b) or ("条评论" in b and "最热" in b)
def is_result_card(labs=None) -> bool:
"""结果图 / 分享海报页:不重复截。"""
b = "\n".join(labs or labels())
keys = (
"结果图", "生成结果图", "分享结果", "分享卡片", "生成卡片",
"保存到相册", "保存图片", "生成海报", "长图分享",
)
return any(k in b for k in keys)
def capture_here(rel: str, note: str = "", long: bool = True) -> str:
"""Shot current (viewport + optional long); cashier → shot then stop-deeper."""
try:
labs = labels()
b = "\n".join(labs)
except Exception as ex:
print(f" blob fail: {ex}", flush=True)
labs, b = [], ""
if is_comment_feed(labs):
print(f" skip comment feed: {note}", flush=True)
back()
return "skip"
if is_result_card(labs):
print(f" skip result card: {note}", flush=True)
back()
return "skip"
if any(k in b for k in PAYWALL_KEYS):
shot(rel.replace(".png", "-cashier.png"), note + " [cashier]", long=False)
return "cashier"
shot(rel, note, long=long)
return "ok"
def collect_candidates(allow: set[str], tabs: list[str], ymin=280, ymax=2100) -> list[str]:
data = dump()
found: list[str] = []
seen: set[str] = set()
# prefer tabs first
for t in tabs:
for e in data.get("elements") or []:
fl = first_line(lab(e))
if fl == t or t in lab(e):
if fl not in seen and e.get("clickable"):
seen.add(fl if fl == t else t)
found.append(t)
break
for e in data.get("elements") or []:
if not e.get("clickable"):
continue
t = lab(e)
if not t:
continue
fl = first_line(t)
xy = center(e)
if not xy or xy[1] < ymin or xy[1] > ymax:
continue
if area(e) == 0 or area(e) > 900 * 500:
continue
if fl in seen:
continue
if not allowed(fl, allow):
continue
seen.add(fl)
found.append(fl)
if len(found) >= MAX_CHILDREN_PER_PAGE:
break
return found
# ─── module exploration (flat, not deep recursive DFS) ──────────────
# 递归 dive + 每层长图 + 密集 ui dump 会导致进程在 candidates 后静默死亡。
# 改为:入口长图 → 每个 tab 各截一页(可长图)→ 少量白名单按钮各截一页。
SKIP_ACTIONS = {
"测测达人在线解读星盘", "现代", "参数", "日", "周", "月",
"分享", "保存", "生成卡片", "历史", "记录", "帮助", "说明",
"评论", "全部评论", "写评论", "点赞", "最热", "最新",
# 结果图 / 分享海报:不重复截
"结果图", "查看结果", "生成结果图", "分享结果", "分享卡片",
"保存图片", "保存到相册", "海报", "长图分享", "生成海报",
# 倾诉 / 真人1v1 / 聊天类:不点进去
"倾诉", "开始倾诉", "匹配达人",
"真人1v1", "真人1V1", "语音通话", "文字",
"聊天", "小星", "陪伴小星", "聊天分析", "互动消息",
}
# 整模块跳过(不进入)
SKIP_MODULES = {
"qingsu", # 倾诉
"liaotian", # 聊天分析
"xiaoxing", # 陪伴小星(聊天)
"tab_online", # 在线达人(真人咨询)
}
def tap_force(name: str, ymin=0, ymax=9999) -> bool:
"""Tap by first-line text even when node is not marked clickable (settings rows etc.)."""
try:
data = dump()
except Exception as ex:
print(f" tap_force dump fail: {ex}", flush=True)
return False
hits = []
for e in data.get("elements") or []:
fl = first_line(lab(e))
if fl != name:
continue
xy = center(e)
if not xy or xy[1] < ymin or xy[1] > ymax:
continue
if area(e) > 1080 * 900:
continue
hits.append((0 if e.get("clickable") else 1, area(e), xy))
if not hits:
return False
hits.sort()
xy = hits[0][2]
print(f" TAP! '{name}' {xy}", flush=True)
tap_xy(*xy)
return True
def tap_label(name: str, ymin=180, ymax=2150) -> bool:
"""Exact / force tap only — never tap_contains (feed cards false-hit e.g. 540,642)."""
print(f" try tap '{name}'", flush=True)
try:
if tap_exact(name, ymin=ymin, ymax=ymax):
return True
if tap_force(name, ymin=ymin, ymax=ymax):
return True
except Exception as ex:
print(f" tap fail '{name}': {ex}", flush=True)
return False
def visible_allow_hits(allow: set[str], ymin=160, ymax=2050) -> list[tuple[str, tuple[int, int]]]:
"""One dump → (label, xy) for whitelist items on screen, top-to-bottom."""
try:
data = dump()
except Exception as ex:
print(f" visible dump fail: {ex}", flush=True)
return []
hits = []
seen = set()
for e in data.get("elements") or []:
fl = first_line(lab(e))
if not fl or fl in seen or fl not in allow:
continue
if fl in BLACKLIST_EXACT or fl in BOTTOM_TABS or fl in SKIP_ACTIONS:
continue
xy = center(e)
if not xy or xy[1] < ymin or xy[1] > ymax:
continue
if area(e) > 1080 * 850:
continue
seen.add(fl)
hits.append((xy[1], fl, xy))
hits.sort()
return [(fl, xy) for _, fl, xy in hits]
def is_home_leak(data=None) -> bool:
"""判断 dump 是否仍是首页(而非当前模块页)。
注意:AI玩法广场等页卡片标题也可能含「七宗罪与七美德」,不能单靠该文案判定。
"""
try:
labs = labels(data) if data is not None else labels()
except Exception:
return True
fls = [first_line(x) for x in labs]
b = "\n".join(labs)
# 首页宫格特征:底栏 + 工具行 + 示例/自己切换
tool_row = ("I人E人" in fls and "星盘" in fls and "生辰" in fls) or (
"倾诉" in fls and "智慧卡" in fls and "星盘报告" in fls
)
home_chrome = "首页" in fls and "我的" in fls
if "今日心情" in b and tool_row and home_chrome:
return True
if tool_row and home_chrome and "示例" in fls and "自己" in fls:
return True
if "部分标题由AI生成,仅供参考" in b and "AI玩法广场" in fls and tool_row and home_chrome:
return True
return False
def leave_detail(prefer_close: bool = False) -> None:
"""离开详情:直播间/测测AI 用右上角 ×,其余 BACK。"""
try:
labs = labels()
fls = [first_line(x) for x in labs]
b = "\n".join(labs)
if prefer_close or any(
k in b
for k in ("测测AI", "让我来解答你的问题吧", "说点什么...", "公开连麦", "麦序")
):
tap_top_right_close()
elif any(k in fls for k in ("说点什么...", "公开连麦", "麦序")):
tap_top_right_close()
else:
back()
time.sleep(0.55)
except Exception:
ensure_app()
def explore_module(mod: dict) -> None:
mid = mod["id"]
tabs = list(mod.get("tabs") or [])
children = [c for c in (mod.get("children") or []) if c not in SKIP_ACTIONS]
allow: set[str] = set(tabs) | set(children)
fb: dict[str, tuple[int, int]] = dict(mod.get("fallback_xy") or {})
print(f" explore flat tabs={tabs[:10]} children={len(children)} fb={list(fb)[:6]}", flush=True)
# 先截 L1;dump 放在截图后重试,避免入口动画导致空 dump
st = capture_here(f"{mid}/L1.png", mid, long=True)
if st == "cashier":
print(" cashier noted — still try on-screen targets", flush=True)
for _ in range(3):
adb("shell", "input", "swipe", "540", "700", "540", "1700", "260")
time.sleep(0.35)
leak = is_home_leak()
if not leak:
onscreen = visible_allow_hits(allow)
if not onscreen:
time.sleep(1.0)
onscreen = visible_allow_hits(allow)
leak = is_home_leak()
else:
onscreen = []
# Hub Composedump 过稀或仍像首页时,只信 fallback,避免点到「示例」等首页节点
if fb and (leak or len(onscreen) <= 2 or is_home_leak()):
if not leak or len(onscreen) <= 2:
print(
f" a11y weak/home-leak (onscreen={len(onscreen)}) → fallback XY only",
flush=True,
)
leak = True
onscreen = []
print(f" onscreen: {[fl for fl, _ in onscreen[:14]]}", flush=True)
on_names = {fl for fl, _ in onscreen}
ordered: list[tuple[str, tuple[int, int] | None]] = []
for t in tabs:
xy = next((xy for fl, xy in onscreen if fl == t), None)
if xy is None and t in fb:
xy = fb[t]
ordered.append((t, xy))
if not leak:
for fl, xy in onscreen:
if fl not in tabs:
ordered.append((fl, xy))
extra_search = 0
for c in children:
if c in on_names or c in tabs:
continue
ordered.append((c, None))
extra_search += 1
if extra_search >= 4:
break
# fallback 里声明但未进 tabs 的入口也补上
for name, xy in fb.items():
if name not in {n for n, _ in ordered}:
ordered.append((name, xy))
targets = ordered[:14]
print(f" targets({len(targets)}): {[n for n, _ in targets[:12]]}", flush=True)
for name, xy in targets:
if STATE.shots >= MAX_SHOTS:
break
for _ in range(2):
adb("shell", "input", "swipe", "540", "700", "540", "1700", "240")
time.sleep(0.25)
if xy is None and name in tabs and not leak:
refreshed = visible_allow_hits({name}, ymin=150, ymax=1200)
if refreshed:
xy = refreshed[0][1]
if xy is None and name in fb:
xy = fb[name]
if xy is not None:
print(f" TAP-XY '{name}' {xy}", flush=True)
tap_xy(*xy, wait=1.3)
elif name in tabs or name in fb:
# 声明的 tabs 即使 dump 弱也要再试一次精确点击
if name in fb:
print(f" TAP-XY '{name}' {fb[name]}", flush=True)
tap_xy(*fb[name], wait=1.3)
elif not tap_label(name, ymin=120, ymax=2100):
print(f" skip tab miss '{name}'", flush=True)
continue
else:
print(f" skip offscreen '{name}'", flush=True)
continue
try:
escape_modals(max_rounds=2)
except Exception:
pass
if name in tabs or name in fb:
rel = f"{mid}/{safe_name(name)}/L2.png"
do_long = True
else:
rel = f"{mid}/act_{safe_name(name)}/L2.png"
do_long = False
capture_here(rel, f"{mid}>{name}", long=do_long)
leave_detail(prefer_close=(name in ("Ai 解读", "测测AI")))
def dive(module_id: str, path_parts: list[str], depth: int, max_depth: int,
allow: set[str], tabs: list[str]) -> str:
"""Deprecated recursive path — kept as thin wrapper to flat explore for safety."""
print(" dive: redirected to flat explore", flush=True)
return "ok"
# ─── module entry ───────────────────────────────────────────────────
def enter_module(mod: dict) -> bool:
kind, label = mod["entry"]
print(f"\n=== MODULE {mod['id']} entry={kind}/{label}", flush=True)
escape_modals()
if kind == "home":
go_home()
ok = tap_home_tool(label)
if not ok:
# try 更多 as fallback
if open_more() and find_and_tap_in_scroll(label, ymin=200, ymax=2200):
time.sleep(1.8)
return True
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- MISS entry home/{label}\n")
return False
time.sleep(1.8)
return True
if kind == "more":
if not open_more():
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- MISS open 更多 for {label}\n")
return False
if not find_and_tap_in_scroll(label, rounds=8, ymin=150, ymax=2350):
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- MISS entry more/{label}\n")
return False
time.sleep(1.8)
return True
if kind == "mine":
# 离开评论流再进我的
try:
if is_comment_feed():
back()
time.sleep(0.6)
except Exception:
pass
tap_tab("我的")
time.sleep(1.2)
escape_modals(max_rounds=3)
if not find_and_tap_in_scroll(label, rounds=6, ymin=300, ymax=2200):
alts = list(mod.get("entry_alts") or [])
alts += {
"会员": ["VIP会员", "开通会员", "我的会员", "VIP会员陪伴中", "升级SVIP"],
"VIP会员陪伴中": ["升级SVIP", "¥9.9开启陪伴", "新用户限时特惠", "VIP会员"],
"¥9.9开启陪伴": ["VIP会员陪伴中", "升级SVIP", "新用户限时特惠"],
"报告": ["报告与测试", "我的报告", "报告中心"],
"报告与测试": ["报告", "我的报告"],
"测试": ["我的测试", "报告与测试"],
"订单": ["我的订单"],
"设置": ["设置", "通用"],
}.get(label, [])
hit = False
for a in alts:
if find_and_tap_in_scroll(a, rounds=4, ymin=200, ymax=2200):
hit = True
break
if not hit:
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- MISS entry mine/{label}\n")
return False
time.sleep(1.8)
return True
if kind == "mine_gear":
# 设置齿轮无障碍文案,固定点右上角
try:
if is_comment_feed():
back()
time.sleep(0.6)
except Exception:
pass
tap_tab("我的")
time.sleep(1.2)
escape_modals(max_rounds=3)
print(" TAP-XY mine gear (1010,180)", flush=True)
tap_xy(1010, 180, wait=1.5)
try:
fls = [first_line(x) for x in labels()]
if "设置" in fls or "个人资料" in fls or "账号安全" in fls:
return True
except Exception:
pass
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- MISS entry mine_gear/设置\n")
return False
if kind == "mine_mall_order":
# 订单入口在测测商城 → 个人中心(H5,BACK/底栏常无效)
try:
fls0 = [first_line(x) for x in labels()]
b0 = "\n".join(labels())
if any(k in b0 for k in ("订单列表", "测测商城", "个人中心", "weimob")):
print(" leave mall H5 → force-stop", flush=True)
adb("shell", "am", "force-stop", "com.xxwolo.cc5", timeout=15)
time.sleep(1)
adb("shell", "am", "start", "-n", ACT, timeout=15)
time.sleep(3.5)
except Exception:
pass
try:
if is_comment_feed():
back()
time.sleep(0.6)
except Exception:
pass
tap_tab("我的")
time.sleep(1.2)
escape_modals(max_rounds=3)
# 「商城」入口 Y 会随头部运营位变化,放宽范围
if not (tap_exact("商城", ymin=700, ymax=1600) or tap_contains("商城", ymin=700, ymax=1600)):
# 动态找中心,失败再用兜底
xy = None
try:
for e in dump().get("elements") or []:
if first_line(lab(e)) == "商城" and e.get("clickable"):
xy = center(e)
break
except Exception:
pass
if xy:
print(f" TAP-XY 商城 {xy}", flush=True)
tap_xy(*xy, wait=2.0)
else:
print(" TAP-XY 商城 fallback (782,1250)", flush=True)
tap_xy(782, 1250, wait=2.0)
time.sleep(2.5)
ok_mall = False
for _ in range(4):
try:
fls = [first_line(x) for x in labels()]
if "测测商城" in fls or "分类" in fls or "购物车" in fls or "个人中心" in fls:
ok_mall = True
break
except Exception:
pass
time.sleep(1.0)
if not ok_mall:
# 仍继续:部分机型 WebView dump 延迟,坐标进个人中心再校验订单
print(" warn: mall labels not confirmed, continue XY", flush=True)
# 商城底栏「我的」常不可 clickable
print(" TAP-XY mall 我的 (946,2280)", flush=True)
tap_xy(946, 2280, wait=1.8)
# 点全部订单 / 商城订单
if not (
tap_exact("全部订单", ymin=700, ymax=1400)
or tap_contains("全部订单", ymin=700, ymax=1400)
or tap_exact("商城订单", ymin=700, ymax=1400)
or tap_contains("商城订单", ymin=700, ymax=1400)
):
print(" TAP-XY 全部订单 (926,884)", flush=True)
tap_xy(926, 884, wait=1.5)
time.sleep(1.2)
try:
b = "\n".join(labels())
if any(k in b for k in ("订单", "待付款", "待收货", "退款", "订单列表")):
return True
except Exception:
pass
with NOTES.open("a", encoding="utf-8") as f:
f.write("- MISS entry mine_mall_order/订单\n")
return False
if kind == "tab":
tap_tab(label)
time.sleep(1.5)
return True
return False
def run_module(mod: dict):
if STATE.shots >= MAX_SHOTS:
return
if not enter_module(mod):
return
try:
explore_module(mod)
except Exception as ex:
import traceback
print(f" explore ERR: {ex}", flush=True)
traceback.print_exc()
# leave module — 底栏 Tab / WebView 上 BACK 常无效,直接回首页
try:
tap_tab("首页")
time.sleep(0.8)
go_home()
except Exception as ex:
print(f" leave module fail: {ex}", flush=True)
ensure_app()
def write_manifest():
pngs = sorted(str(p.relative_to(OUT)) for p in OUT.rglob("*.png"))
MANIFEST.write_text(
json.dumps(
{
"count": len(pngs),
"shots_logged": STATE.shots,
"modules": [m["id"] for m in MODULES],
"screenshots": pngs,
"log": STATE.log,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
print("DONE", len(pngs), "pngs", flush=True)
def main():
import faulthandler
faulthandler.enable()
OUT.mkdir(parents=True, exist_ok=True)
NOTES.write_text(
f"# deep whitelist crawl {time.strftime('%F %T')}\n\n"
f"modules={len(MODULES)} mode=flat max_shots={MAX_SHOTS}\n\n",
encoding="utf-8",
)
print(f"START out={OUT} mode=flat", flush=True)
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
ensure_app()
escape_modals(max_rounds=4)
only = set(sys.argv[1:]) if len(sys.argv) > 1 else None
for mod in MODULES:
if only and mod["id"] not in only:
continue
if mod["id"] in SKIP_MODULES:
print(f"\n>>> skip {mod['id']} (SKIP_MODULES)", flush=True)
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- SKIP module {mod['id']}\n")
continue
try:
print(f"\n>>> begin {mod['id']} shots={STATE.shots}", flush=True)
run_module(mod)
print(f"<<< end {mod['id']} shots={STATE.shots}", flush=True)
except Exception as ex:
import traceback
print(f"ERR {mod['id']}: {ex}", flush=True)
traceback.print_exc()
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- ERR {mod['id']}: {ex}\n")
ensure_app()
try:
go_home()
except Exception:
ensure_app()
write_manifest()
if __name__ == "__main__":
try:
main()
except Exception:
import traceback
traceback.print_exc()
raise