对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。 Co-authored-by: Cursor <cursoragent@cursor.com>
1959 lines
67 KiB
Python
Executable File
1959 lines
67 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Module-whitelist deep crawl for CeCe (depth 3–4).
|
||
|
||
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 Compose:dump 过稀或仍像首页时,只信 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
|