Files
yingshiyun/automate.py
T
yuming 548ab3b2f1 feat: 多趟循环下载直到无新增唯一录像,补齐单趟遗漏
- tap_all_thumbnails 返回本趟点击的未打勾卡片数
- 新增 tap_all_thumbnails_multipass:同一 App 会话内反复回顶重扫、
  只补漏点卡,用"设备唯一内容(md5)增量=0"判完整——对蓝勾约4.5%
  假阴性、死录像均免疫("零点击"判据因假阴性永远达不到,已弃用)
- sync_recent_days 接入多趟循环,替代单趟 tap+wait
- 配套:cell_has_card 增亮度判据识别灰色占位卡;到底重试加耐心(6次/7s)

实测 09-19:清理存储后单趟 120→281,合并归档达 285 完整;
多趟复验第 1 趟 21 次假阴性补点、新增唯一 0,正确判完整并 1 趟退出。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 18:00:52 +08:00

1238 lines
70 KiB
Python
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
"""
萤石录像自动下载脚本
通过 ADB 自动化模拟器中的萤石 App,下载前一天的所有报警录像。
"""
import subprocess, time, os, shutil, struct, zlib, io, hashlib, math
from datetime import datetime, timedelta
from pathlib import Path
from PIL import Image
ADB = os.path.expanduser("~/Library/Android/sdk/platform-tools/adb")
DEVICE = "emulator-5554"
OUTPUT_DIR = Path("./recordings")
PKG = "com.videogo"
# 屏幕坐标(Pixel 6 模拟器 1080x2400,2026-07-24 App 更新到 7.8.1 后重新实测校准)
COORD = {
"home_camera_card": (540, 500), # 首页摄像头卡片
"live_video_btn": (390, 1362), # 设备主页-实时视频按钮
"record_tab": (600, 2256), # 底部录像 Tab(实时视频|录像|消息 三 Tab 中"录像"偏右位置,
# 2026-09-19 实测:旧坐标 540,2200 会点进两 Tab 间空隙,导致停留实时视频页)
"prev_day_arrow": (77, 1124), # < 上一天箭头
"next_day_arrow": (370, 1124), # > 下一天箭头(用于归位到今日)
"download_btn": (406, 935), # 录像页工具栏-下载按钮
"batch_download": (540, 1904), # 弹出菜单"批量下载"
"reload_btn": (540, 1960), # 加载失败页的"重新加载"按钮
"today_thumb": (198, 1388), # "今日"默认列表第一张缩略图(用于切换工具栏到下载模式)
}
# 批量下载界面缩略图布局(7.8.1 实测;此版本自绘 UI,uiautomator dump 拿不到 bounds,
# 改用固定网格坐标点击)
# 3 列 x 中心:201, 540, 878;3 行 y 中心:1148, 1351, 1554
THUMB_X = [201, 540, 878]
THUMB_Y = [1148, 1351, 1554]
# 每次点 3 整行 = 9 个(不触发 10 并发限制),然后滚动 3 行(609 像素)
# 打勾(已下载完成)蓝色角标的检测参数。已打勾时卡片左下角有一个高饱和蓝色圆角
# 方块(约 RGB 100,143,252);未下载/下载中没有。
# 关键教训:这个下载列表滚动时【不会 snap 对齐到固定行】,同一张卡片在不同滚动
# 状态下会停在不同的垂直位置(实测两张截图里蓝勾 y 偏移分别是 +62 和 +94,漂移
# 达 32px)。所以绝不能用"固定单点采样"判打勾——单点极易错过漂移后的蓝勾,导致
# 打勾永远检测不到、每屏 9 格全部重点,这才是"重复下载"的真正总根源。
# 正确做法:在卡片左下角开一个足够大的窗口(96x100),统计其中"强蓝"像素的数量,
# 数量超过阈值即判已打勾。窗口大到能覆盖 y 漂移;强蓝在灰/黑/彩色画面里几乎不出现,
# 所以打勾格实测蓝像素≈2500、未勾格≈0,区分极清晰。
# BADGE_OFFSET 现在表示"窗口中心"相对九宫格点击中心的偏移。
BADGE_OFFSET = (-121, 78)
BLUE_WIN = (96, 100) # 蓝勾搜索窗口宽、高
BLUE_THRESHOLD = 500 # 窗口内强蓝像素数 >= 此值判为已打勾(打勾≈2500,未勾≈0,余量充足)
# 动态行定位参数(替代写死的 THUMB_Y)。原理见 detect_card_rows 文档字符串。
# 列表滚动时卡片会因"整点分组标签"整行平移(实测漂移达 174px),固定 THUMB_Y 追不上,
# 导致点击/打勾检测全部错位、把已下载卡片误判为未下载重复点击。改为每屏用亮度标准差
# (std) 垂直剖面实测出真实的卡片行中心 Y:卡片缩略图有纹理 std 高,卡片间隙/分组标签/
# 灰占位是纯色 std≈0,据此切出"纹理带",每个带的竖直中心就是一行卡片的点击中心。
ROW_Y0, ROW_Y1 = 1000, 2380 # 列表纵向扫描范围(避开顶部状态栏/预览/标题栏、底部手势条)
ROW_STD_X0, ROW_STD_W = 50, 980 # 横向 std 采样区(避开左右圆角/阴影)
ROW_STD_COLS = 200 # 降采样列数(Pillow 一次折成每行 175 个样本算 std,避免逐行上百次采样)
ROW_STD_LOW = 18 # std 阈值:>= 判为卡片纹理,< 判为间隙/标签/占位(实测卡片≥46、非卡片=0,余量足)
ROW_MIN_H = 90 # 纹理带最小高度(滤掉分组标签、半截卡片、噪声)
ROW_MAX_H = 260 # 纹理带最大高度(超过视为两行粘连,按行距二次切分)
ROW_PITCH = 204 # 相邻卡片行的竖直间距(用于粘连带二次切分)
ROW_EDGE_MARGIN = 12 # 纹理带触及扫描区上/下边缘视为被截断的半行,丢弃
THUMB_LOAD_WAIT = 1.0 # 每轮截图前等待缩略图从"灰占位"加载出真实画面,避免假间隙漏检
# 单格"有无卡片"判据(7.7.5 新版列表结构变化后新增)。detect_card_rows 只定位卡片
# "行",但新版是"整点分组、每组卡片数不固定"的稀疏布局:同一行的 3 列不一定都有卡片
# (如某组只有 1 个卡片,右侧第 2、3 列是空白)。若仍按固定 3 列点击,空列会点在空白
# 上、永远不打勾,导致"等打勾"白熬满超时(实测只下 1 个就卡死 540 秒)。据此在收集
# 待点击格子前,先用局部窗口灰度 std 判断该格子是否真有缩略图:有画面纹理 std 高、
# 空白列纯色 std≈0。
CELL_STD_WIN = (200, 120) # 判断格子有无卡片的采样窗口(取卡片主体,避开左下角蓝勾角标)
CELL_STD_LOW = 12 # 窗口灰度 std >= 判为有卡片,< 判为空列(无卡片,跳过不点)
CELL_BRIGHT_HIGH = 220 # 窗口平均亮度 < 此值也判为有卡:灰色未加载占位卡(实测≈143)比纯白空隙(255)暗
# 滚动真实位移测量参数(用像素行剖面互相关,替代不可靠的 detect_card_rows 中位数)。
# detect_card_rows 每次检出的行集合、缩略图加载状态都在变,中位数乱跳,会把"滚了几行"
# 误判成"没动"→触发多余的二次滚动→滚过头,中段整片漏下(实测屏6~9全漏)。改用行平均
# 亮度剖面做 SAD 互相关,能稳定测出真实滚动了多少像素。
SCROLL_PROFILE_STEP = 4 # 行剖面降采样步长(每4行压成1个均值,加速SAD互相关)
MIN_SCROLL_PX = 80 # 单次滚动真实位移 < 此值才视为"没滚动"(约0.4行)。原为250过大:
# 列表在下载队列堵/卡顿时单次只滚一点点(实测156px)也被当"到底"
# 提前退出→底部06:00~00:00一大段没下。80>底部占位回弹抖动(实测<40px)
MAX_SCROLL_PX = 1100 # 单次滚动真实位移 > 此值视为滚过头,向上回滚补偿(保险,实测罕见)
WATERLINE = 4 # 水位泵:任意时刻最多几个在途下载。CB60 是 4G 电池机、回放下载
# 串行且慢,4 个相对稳健——比旧版"每批死等3个"略高一档以填满流水线
# 提速,又不至于压垮下载队列产生 0 字节失败。在途按"已点但未打勾
# 的格子数"逐格精确统计(见 clear_current_screen)。
def adb(*args, timeout=60):
"""执行 adb 命令。带超时防止个别命令(尤其 uiautomator dump 在 App 卡死时)
永久挂起把整个脚本拖死。超时返回一个 returncode!=0 的空结果,调用方按失败处理。"""
try:
return subprocess.run([ADB, "-s", DEVICE] + list(args),
capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
print(f" ⚠ adb 命令超时({timeout}s):{' '.join(args)[:40]}")
return subprocess.CompletedProcess(args, 1, stdout="", stderr="timeout")
def tap(x, y, delay=0.8):
adb("shell", "input", "tap", str(x), str(y))
time.sleep(delay)
def find_by_text(target_text, retries=4, delay=2.0):
"""通过 uiautomator dump 查找指定 text 节点的中心坐标。
返回 (cx, cy) 或 None。重试次数受限,每次间隔 >= 2s,避免 ANR。
"""
import re
# 用 [^<>]*? 限制在同一 <node /> 标签内,并跨过含 / 的属性
# (如 resource-id="com.videogo:id/tv_download_all")
pat = re.compile(
r'text="' + re.escape(target_text) + r'"[^<>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
)
for i in range(retries):
adb("shell", "uiautomator", "dump", "/sdcard/uib.xml", timeout=15)
adb("pull", "/sdcard/uib.xml", "/tmp/uib.xml")
try:
with open("/tmp/uib.xml") as f:
content = f.read()
except FileNotFoundError:
content = ""
m = pat.search(content)
if m:
x1, y1, x2, y2 = map(int, m.groups())
return ((x1 + x2) // 2, (y1 + y2) // 2)
if i < retries - 1:
time.sleep(delay)
return None
def tap_text(text, after_delay=1.5, retries=8):
"""查找文字元素并点击。找不到抛 RuntimeError,避免误触别的页面。"""
pos = find_by_text(text, retries=retries)
if pos is None:
raise RuntimeError(f"找不到目标文字:'{text}',停止以避免误触")
cx, cy = pos
print(f" → tap '{text}' at ({cx}, {cy})")
adb("shell", "input", "tap", str(cx), str(cy))
time.sleep(after_delay)
def tap_until_text(x, y, expected_text, max_tries=4, after_tap_wait=4.0):
"""点击 (x,y) 并等待 expected_text 出现,验证页面切换。
没切换则重试点击。tap 后等 4 秒再 dump,避免 ANR(dump 太密集会让 App 卡死)。
"""
for trial in range(max_tries):
adb("shell", "input", "tap", str(x), str(y))
time.sleep(after_tap_wait)
if find_by_text(expected_text, retries=1):
print(f" ✓ tap ({x},{y}) 后看到 '{expected_text}'")
return True
print(f" ! tap ({x},{y}) 后未出现 '{expected_text}',重试 {trial+1}/{max_tries}")
# ANR 风险点:连续失败时给 App 喘息时间
time.sleep(2)
raise RuntimeError(f"点击 ({x},{y}) 重试 {max_tries} 次仍未跳转到含 '{expected_text}' 的页面")
def dismiss_anr_if_any():
"""检测系统 ANR 弹窗,存在则点 Wait 让 App 继续运行(不杀进程)。"""
result = adb("shell", "dumpsys", "activity", "activities")
if "isn't responding" in result.stdout or "AppErrorDialog" in result.stdout:
print(" ⚠ 检测到 ANR 弹窗,点 Wait")
# Wait 按钮 bounds=[70,1299][1010,1425],中心 (540,1362)
adb("shell", "input", "tap", "540", "1362")
time.sleep(4)
return True
return False
def ensure_not_anr(max_wait=90):
"""确保 App 当前没有卡死(ANR),反复检测+点 Wait 直到恢复正常。
根本原因(实测复现):批量下载循环里长时间连续点击/截图会让 App 偶尔卡死,
卡死期间截图读到的是冻结前的旧画面,打勾识别会把"其实已经点过/下载中"的卡片
误判为"未下载",导致反复重新触发下载——单日 220 个下载里只有 11 种真实内容,
最严重的一组被重复下载了 28 次,就是这个机制造成的。
之前 dismiss_anr_if_any() 只在导航阶段被调用,批量下载循环里完全没有调用,
是这个问题的直接缺口。
卡死超过 max_wait 仍未恢复则直接报错停止,而不是继续在冻结画面上盲点,
避免制造更多重复下载。
"""
waited = 0
while dismiss_anr_if_any():
waited += 4
if waited >= max_wait:
raise RuntimeError(f"App 卡死(ANR)超过 {max_wait}s 仍未恢复,停止自动化,避免继续误触造成更多重复下载")
return waited
def tap_until_activity(x, y, expected_activity_keyword, max_tries=6, after_tap_wait=6.0):
"""点击 (x,y),通过 dumpsys 检查 Activity 是否切换到含 keyword 的页面。
每次尝试前都检查 ANR,存在则先点 Wait。间隔放宽到 6s 给模拟器+萤石喘息时间。
expected_activity_keyword 支持传多个候选(元组/列表,命中任一即算成功)——
实测同一入口(首页摄像头卡片)有时进 React 页(EZReactBizActivity)、有时直接
进原生播放页(VideoPlayActivityNewVersion),取决于 App 栈状态,两种都算导航成功。
"""
kws = (expected_activity_keyword,) if isinstance(expected_activity_keyword, str) else tuple(expected_activity_keyword)
for trial in range(max_tries):
dismiss_anr_if_any()
adb("shell", "input", "tap", str(x), str(y))
time.sleep(after_tap_wait)
cur = current_activity()
if cur and any(k in cur for k in kws):
print(f" ✓ tap ({x},{y}) 后进入 '{cur}'")
return True
print(f" ! tap ({x},{y}) 后 Activity='{cur}' 不含 {kws},重试 {trial+1}/{max_tries}")
# 失败时给 App 更长的喘息时间(避免连环 ANR)
time.sleep(5)
raise RuntimeError(
f"点击 ({x},{y}) 重试 {max_tries} 次仍未跳到含 '{expected_activity_keyword}' 的 Activity"
)
def current_activity():
"""返回当前 topResumedActivity 的简短名(如 'MainTabActivity')。"""
import re
result = adb("shell", "dumpsys", "activity", "activities")
m = re.search(r'topResumedActivity=ActivityRecord\{[^}]*\s+com\.videogo/\.?([^\s}]+)', result.stdout)
return m.group(1) if m else None
def assert_activity(keyword, hint=""):
"""断言当前 Activity 名含 keyword。不含则报错。避免在错页面盲点。"""
cur = current_activity()
if not cur or keyword not in cur:
raise RuntimeError(f"期望 Activity 含 '{keyword}',实际为 '{cur}'。{hint}")
return cur
def tap_safe(x, y, expect_activity_keyword, hint="", delay=1.5):
"""先断言当前 Activity 匹配 keyword,再点击 (x,y),避免误触别的页面。"""
cur = assert_activity(expect_activity_keyword, hint)
print(f" → tap ({x},{y}) [当前 Activity: {cur}]")
adb("shell", "input", "tap", str(x), str(y))
time.sleep(delay)
def sample_pixel_rgb(x, y, png_path="/tmp/_pixchk.png"):
"""截图并采样指定坐标 RGB。用 Pillow 实现,不依赖系统 ImageMagick。"""
adb("shell", "screencap", "-p", "/sdcard/_pixchk.png")
adb("pull", "/sdcard/_pixchk.png", png_path)
return sample_rgb_from_file(png_path, x, y)
def screenshot(path="/tmp/ezviz_screen.png"):
# 用 exec-out 直接把 PNG 输出到本地,省掉"先写 /sdcard 再 pull"的两步往返,
# 每次截图快约 0.5~1s(批量下载循环每轮要截多次图,累计提速明显)。
# 关键:加超时。App/模拟器卡住时 screencap 会永久挂起,而截图是循环里调用
# 最频繁的命令,无超时就会把整个脚本死锁(曾多次踩坑)。超时则重试一次。
for attempt in range(2):
try:
with open(path, "wb") as f:
subprocess.run([ADB, "-s", DEVICE, "exec-out", "screencap", "-p"],
stdout=f, timeout=30)
return path
except subprocess.TimeoutExpired:
print(f" ⚠ 截图超时(30s),重试 {attempt + 1}/2")
time.sleep(2)
return path
def sample_rgb_from_file(png_path, x, y):
"""从已经拉取到本地的截图文件采样指定坐标 RGB(不重新截图,供批量取点复用)。
用 Pillow 实现,不依赖系统 ImageMagick。"""
try:
with Image.open(png_path) as img:
img = img.convert("RGB")
r, g, b = img.getpixel((x, y))
return (r, g, b)
except Exception:
return (0, 0, 0)
def count_blue_pixels(png_path, cx, cy):
"""统计以 (cx,cy) 为中心、大小 BLUE_WIN 的窗口内"强蓝"像素数量。
强蓝判据:b>150 且 b-r>80(蓝勾角标的高饱和蓝,灰/黑/彩色画面几乎不出现)。
用窗口统计而非单点采样,是为了吸收卡片垂直位置的漂移(见 BADGE_OFFSET 说明)。
用 Pillow 实现,不依赖系统 ImageMagick。"""
w, h = BLUE_WIN
x0, y0 = cx - w // 2, cy - h // 2
try:
with Image.open(png_path) as img:
img = img.convert("RGB")
crop = img.crop((x0, y0, x0 + w, y0 + h))
cnt = 0
for r, g, b in crop.getdata():
if b > 150 and b - r > 80:
cnt += 1
return cnt
except Exception:
return 0
def get_checked_cells(png_path):
"""【已废弃,仅保留备查】基于写死 THUMB_Y 的打勾检测。分组标签平移会使采样错位,
现由 get_checked_cells_dynamic 取代。"""
checked = {}
for gy in THUMB_Y:
for gx in THUMB_X:
cx, cy = gx + BADGE_OFFSET[0], gy + BADGE_OFFSET[1]
checked[(gx, gy)] = count_blue_pixels(png_path, cx, cy) >= BLUE_THRESHOLD
return checked
def std_profile(png_path):
"""计算截图列表区每一行的"亮度标准差"垂直剖面,返回 [(y, std), ...](y 从 ROW_Y0 起)。
卡片缩略图有画面纹理 → 一行内各像素亮度差异大 → std 高(实测 ≥46);
卡片间水平间隙、整点分组标签、灰色占位图都是纯色 → std≈0。据此可把"卡片行"
从"间隙/标签/占位"里分出来。
性能:不逐行采样(一屏上百次太慢),而是用 Pillow 把 ROW_STD_W 宽的采样区灰度图
横向降采样到 ROW_STD_COLS 列(每列是原若干像素的平均),再在 Python 里按行算这
ROW_STD_COLS 个样本的标准差。用 Pillow 实现,不依赖系统 ImageMagick。"""
h = ROW_Y1 - ROW_Y0
try:
with Image.open(png_path) as img:
img = img.convert("L")
crop = img.crop((ROW_STD_X0, ROW_Y0, ROW_STD_X0 + ROW_STD_W, ROW_Y0 + h))
small = crop.resize((ROW_STD_COLS, h), Image.Resampling.NEAREST)
pixels = list(small.getdata())
prof = []
for rr in range(h):
row_vals = pixels[rr * ROW_STD_COLS:(rr + 1) * ROW_STD_COLS]
if not row_vals:
continue
mean = sum(row_vals) / len(row_vals)
var = sum((v - mean) ** 2 for v in row_vals) / len(row_vals)
prof.append((ROW_Y0 + rr, math.sqrt(var)))
return prof
except Exception:
return []
def detect_card_rows(png_path):
"""动态检测当前屏可见的完整卡片行,返回各行中心 y 的升序列表。
列表滚动时卡片会因整点分组标签整行平移,写死的 THUMB_Y 追不上(这是"重复下载"的
总根源)。本函数每屏用 std_profile 实测卡片行的真实位置:把 std >= ROW_STD_LOW 的
连续区间当作一条"卡片纹理带",过滤掉太矮(标签/半截/噪声)、太高(两行粘连,按
ROW_PITCH 二次切分)、以及触及扫描区上下边缘(被屏幕截断的半行)的带,剩下每条带的
竖直中心就是一行卡片的点击中心 y。配合固定的 3 列 THUMB_X 即可定位 9 宫格。
实测(3 张不同滚动位置的真实截图):检出的行中心精准落在卡片竖直中心,分组标签处
不产生行——彻底消除固定网格遇标签错位的问题。"""
prof = std_profile(png_path)
textured = [(y, s >= ROW_STD_LOW) for y, s in prof]
runs = []
i, n = 0, len(textured)
while i < n:
if textured[i][1]:
j = i
while j < n and textured[j][1]:
j += 1
runs.append((textured[i][0], textured[j - 1][0]))
i = j
else:
i += 1
rows = []
for top, bot in runs:
height = bot - top
if height < ROW_MIN_H:
continue # 分组标签、半截卡片、纹理噪声
if top <= ROW_Y0 + ROW_EDGE_MARGIN or bot >= ROW_Y1 - ROW_EDGE_MARGIN:
continue # 触边=被屏幕上/下缘截断的半行(相邻屏会完整出现,不漏)
if height > ROW_MAX_H:
k = max(1, round(height / ROW_PITCH)) # 两行粘连,按行距切分
for m in range(k):
rows.append(top + int(height * (m + 0.5) / k))
else:
rows.append((top + bot) // 2)
return sorted(rows)
def _row_profile(png_path):
"""列表区每行的平均亮度(降采样成 1 列),用于互相关测滚动位移。"""
with Image.open(png_path) as img:
img = img.convert("L")
crop = img.crop((ROW_STD_X0, ROW_Y0, ROW_STD_X0 + ROW_STD_W, ROW_Y1))
col = crop.resize((1, (ROW_Y1 - ROW_Y0) // SCROLL_PROFILE_STEP))
return list(col.getdata())
def measure_scroll_shift(before_png, after_png):
"""用行剖面 SAD 互相关测量向下滚动的真实像素位移(after 相对 before)。
比 detect_card_rows 的中位数稳定得多——后者会把"滚了几行"误判成"没动",触发
多余的二次滚动导致滚过头漏行(中段整片漏下的根因)。返回 (位移px, 匹配SAD)。"""
a = _row_profile(before_png)
b = _row_profile(after_png)
n = len(a)
overlap_min = n // 3 # 至少保留 1/3 重叠才算有效匹配
best_s, best_sad = 0, 1e18
for s in range(0, n - overlap_min): # 向下滚 s: b[i] ≈ a[i+s]
cnt = n - s
sad = sum(abs(a[i + s] - b[i]) for i in range(cnt)) / cnt
if sad < best_sad:
best_sad, best_s = sad, s
return best_s * SCROLL_PROFILE_STEP, best_sad
def cell_has_card(png_path, gx, cy):
"""判断 (gx,cy) 这个格子处是否真有一张卡片缩略图(区分稀疏行里的空列)。
两条判据并联,满足其一即认为有卡:
① 有画面纹理 → 局部窗口灰度 std 高(已加载的彩色缩略图);
② 整体够暗 → 平均亮度 < CELL_BRIGHT_HIGH(灰色未加载占位卡,实测≈143,
明显暗于纯白空隙≈255)。这条专治"缩略图没加载完的老录像被当空列漏点"。
空列是纯白背景(亮≈255、std≈0),两条都不满足 → 判为空。
见 CELL_STD_WIN/CELL_STD_LOW/CELL_BRIGHT_HIGH 说明。出错时保守返回 True(宁可点也不漏下载)。"""
w, h = CELL_STD_WIN
x0, y0 = gx - w // 2, cy - h // 2
try:
with Image.open(png_path) as img:
img = img.convert("L")
crop = img.crop((x0, y0, x0 + w, y0 + h))
small = crop.resize((60, 40), Image.Resampling.NEAREST)
vals = list(small.getdata())
mean = sum(vals) / len(vals)
var = sum((v - mean) ** 2 for v in vals) / len(vals)
std = math.sqrt(var)
return std >= CELL_STD_LOW or mean < CELL_BRIGHT_HIGH
except Exception:
return True
def get_checked_cells_dynamic(png_path, row_centers):
"""对动态检测出的每一行 × 固定 3 列,判断是否已打勾(下载完成)。
返回 {(gx, cy): True/False},key 的 cy 是动态行中心。判据同 count_blue_pixels。
横向 3 列 THUMB_X 不随滚动漂移,故列坐标仍固定;只有行 y 是动态的。"""
checked = {}
for cy in row_centers:
for gx in THUMB_X:
bx, by = gx + BADGE_OFFSET[0], cy + BADGE_OFFSET[1]
checked[(gx, cy)] = count_blue_pixels(png_path, bx, by) >= BLUE_THRESHOLD
return checked
def is_queue_busy(png_path):
"""检测右上角是否有红色感叹号(下载队列忙)。
有红点说明队列已满/有后台任务,此时不应再点击新卡片,
否则新点击会被拒绝且永远不会打勾。"""
try:
with Image.open(png_path) as img:
img = img.convert("RGB")
w, h = img.size
# 右上角下载图标区域
x0, y0 = int(w*0.92), int(h*0.37)
x1, y1 = int(w*0.99), int(h*0.40)
region = img.crop((x0, y0, x1, y1))
pixels = list(region.getdata())
red = sum(1 for r,g,b in pixels if r>180 and g<80 and b<80)
return red > 30
except Exception:
return False
def count_checked_badges(png_path):
"""统计整屏已打勾(蓝色角标)的卡片数量。
先用 detect_card_rows 检测实际行位置,再逐行逐列检测打勾,
避免固定步长与实际行位置不匹配导致漏检。"""
try:
rows = detect_card_rows(png_path)
checked = get_checked_cells_dynamic(png_path, rows)
return sum(1 for v in checked.values() if v)
except Exception:
return 0
def launch_app():
"""从头启动 App,等待 618 开屏广告自动关闭后回到首页。
用 am start -S 强制重启主 Activity(比 monkey 更可靠:避免之前残留的
RN 业务页占据栈顶导致 monkey 不进入首页)。
"""
adb("shell", "am", "force-stop", PKG)
time.sleep(2)
adb("shell", "am", "start", "-S", "-n", f"{PKG}/.LauncherActivity")
# 等开屏广告(618 全屏广告自动播放 5~8 秒)+ App 启动稳定
time.sleep(15)
def is_error_page():
"""检测当前是否为'加载失败'白屏页面
取屏幕中央像素,白色/浅灰 = 错误页,深色 = 有视频内容。
"""
tmp = "/tmp/ezviz_check.png"
adb("shell", "screencap", "-p", "/sdcard/ezviz_chk.png")
adb("pull", "/sdcard/ezviz_chk.png", tmp)
try:
with open(tmp, "rb") as f:
data = f.read()
# 解析 PNG IDAT 得到原始像素
pos, idat = 8, b""
width = height = 0
while pos < len(data):
length = struct.unpack(">I", data[pos:pos+4])[0]
ctype = data[pos+4:pos+8]
cdata = data[pos+8:pos+8+length]
if ctype == b"IHDR":
width, height = struct.unpack(">II", cdata[:8])
elif ctype == b"IDAT":
idat += cdata
elif ctype == b"IEND":
break
pos += 12 + length
raw = zlib.decompress(idat)
# screencap 输出 RGBA,每行有 1 字节 filter 前缀
bpp = 4
stride = width * bpp + 1
cx, cy = width // 2, height // 3 # 取屏幕上 1/3 处中央(视频区域)
row_start = cy * stride + 1
r = raw[row_start + cx * bpp]
g = raw[row_start + cx * bpp + 1]
b = raw[row_start + cx * bpp + 2]
# 亮度 > 220 认为是白屏(错误页)
return (r + g + b) // 3 > 220
except Exception:
return False # 解析失败时不做干预
def date_label_hash(png_path="/tmp/_datechk.png"):
"""截图并裁剪出日期条区域("< 07-23 筛选 >"整条),返回内容 md5。
只框住日期条,避开上方一直在播放的视频画面,避免视频帧变化造成误判。
"""
import hashlib
adb("shell", "screencap", "-p", "/sdcard/_datechk.png")
adb("pull", "/sdcard/_datechk.png", png_path)
with Image.open(png_path) as img:
crop = img.crop((40, 1080, 440, 1170)) # 400x90+40+1080
buf = io.BytesIO()
crop.save(buf, format="PNG")
return hashlib.md5(buf.getvalue()).hexdigest()
def tap_and_check_date_changed(arrow_coord, timeout=12, interval=1.0):
"""点一次日期箭头,轮询日期条区域的截图 md5 判断是否变化。
返回 True=翻页成功,False=日期条未变化(已到边界,如已在今日不能再往后翻)。
"""
baseline = date_label_hash()
adb("shell", "input", "tap", str(arrow_coord[0]), str(arrow_coord[1]))
for _ in range(int(timeout / interval)):
time.sleep(interval)
cur = date_label_hash()
if cur != baseline:
return True
return False
def navigate_to_yesterday_batch(days_back=1):
"""导航到"今天往前第 days_back 天"的 SD 卡批量下载模式。
days_back=0 是今天,1 是昨天,2 是前天……(默认 1 保持原行为)。
关键节点用 tap_text 通过 UI dump 定位,避免坐标错位时误触首页底部
Tab(萤石/对话/商城/社区/我的,"商城"中心 x=540 跟录像 Tab 撞车)。
"""
print("→ 点击首页摄像头卡片(用 Activity 切换验证,避免频繁 dump 触发 ANR)")
tap_until_activity(*COORD["home_camera_card"],
expected_activity_keyword=("EZReactBizActivity", "VideoPlayActivityNewVersion"))
print("→ 点击实时视频按钮(设备主页)")
tap(*COORD["live_video_btn"], delay=8) # 等实时视频页加载
time.sleep(3)
# 实时视频页全自绘,dump 不到底部 Tab 文字。
# 但当前 Activity = VideoPlayActivityNewVersion,跟首页 MainTabActivity 不同。
# 用 Activity 校验保护坐标点击,避免误触首页商城。
print("→ 切换到录像 Tab(先校验在实时视频页)")
tap_safe(*COORD["record_tab"], expect_activity_keyword="VideoPlayActivity",
hint="未进入实时视频页,可能上一步点击实时视频按钮失败")
print("→ 点击今日缩略图,切换工具栏到下载模式(新装/重启后默认是延时摄影等非下载工具栏)")
# 这个工具栏是自绘 UI,find_by_text 拿不到"下载"文字节点,不做强校验;
# 后面点下载按钮那步本身有"未弹出菜单则重试"的兜底。
assert_activity("VideoPlayActivity", "未在实时视频/录像页")
adb("shell", "input", "tap", str(COORD["today_thumb"][0]), str(COORD["today_thumb"][1]))
time.sleep(2)
print("→ 归位到今日(App 重启后录像页会停留在上次浏览的日期,不一定是今日,"
"所以先连续点'下一天'直到到头,再退一天到昨天)")
assert_activity("VideoPlayActivity", "未在实时视频/录像页")
for i in range(8):
if not tap_and_check_date_changed(COORD["next_day_arrow"]):
print(f" 第 {i+1} 次点'下一天'后日期未变化,已到今日")
break
print(f" 第 {i+1} 次点'下一天',日期已翻页")
else:
print(" ⚠ 连续 8 次翻页仍在变化,按当前位置继续")
print(f"→ 从今日往前退 {days_back} 天")
for step in range(days_back):
if not tap_and_check_date_changed(COORD["prev_day_arrow"]):
print(f" ! 第 {step+1} 次点'上一天'未检测到日期变化,重试一次")
if not tap_and_check_date_changed(COORD["prev_day_arrow"]):
raise RuntimeError(f"点击'上一天'箭头后日期未变化,无法退到第 {days_back} 天")
# 等日期切换后的录像列表稳定加载
time.sleep(3)
# 下载按钮可能因为视频还在加载而被吸收,循环重点击直到菜单弹出
print("→ 点击下载按钮 + 等批量下载菜单出现(最多 4 次重试)")
assert_activity("VideoPlayActivity", "未在录像页")
for trial in range(4):
adb("shell", "input", "tap", str(COORD["download_btn"][0]), str(COORD["download_btn"][1]))
time.sleep(3)
pos = find_by_text("批量下载", retries=3, delay=1.5)
if pos:
print(f" ✓ 菜单弹出,'批量下载' at {pos}")
cx, cy = pos
adb("shell", "input", "tap", str(cx), str(cy))
time.sleep(3)
break
print(f" ! 第 {trial+1} 次点下载未弹出菜单,重试")
else:
raise RuntimeError("反复点下载按钮仍未弹出批量下载菜单")
def screenshot_hash(png_path="/tmp/_scrollchk.png"):
"""截图并返回缩略图网格区域内容的 md5,用于判断滚动前后画面是否变化(到顶/到底检测)。
不能对整屏截图直接哈希:顶部状态栏时钟每分钟跳动、视频预览区域的"正在下载"提示文字
等都会随时间变化,跟"列表是否真的滚动了"无关,会导致哈希永远对不上,滚到底/滚到顶
后无法被正确识别,造成无限重复滚动+点击同一批卡片(实测触发过这个问题)。
改成只裁剪缩略图网格区域(避开顶部状态栏+视频预览+标题栏),滚不动时该区域内容才会
真正保持一致。
"""
import hashlib
adb("shell", "screencap", "-p", "/sdcard/_scrollchk.png")
adb("pull", "/sdcard/_scrollchk.png", png_path)
with Image.open(png_path) as img:
crop = img.crop((0, 1000, 1080, 2400)) # 1080x1400+0+1000
buf = io.BytesIO()
crop.save(buf, format="PNG")
return hashlib.md5(buf.getvalue()).hexdigest()
def dismiss_dialog_if_any():
"""检测并关闭"使用手机网络下载将消耗流量"等萤石弹窗。
点击 text="确定" 按钮。
"""
pos = find_by_text("确定", retries=1)
if pos:
print(f" ⚠ 检测到弹窗,点击'确定' at {pos}")
adb("shell", "input", "tap", str(pos[0]), str(pos[1]))
time.sleep(2)
return True
return False
def get_remote_new_file_count(baseline_files):
"""统计远程 SD 卡目录里相对 baseline 新增的文件数,用于判断这一批是否真的下载完。"""
today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
result = adb("shell", "find", remote_dir, "-name", "*_BH*", "-type", "f")
all_files = set(l for l in result.stdout.strip().splitlines() if l)
return len(all_files - baseline_files)
def tap_all_thumbnails(baseline, max_rounds=300, poll_interval=4, max_wait_per_round=180):
"""循环:点击固定网格 9 个卡片(点击即下载)→ 轮询确认下载数量稳定 → 滚动 2 行。
新版 UI(7.8.1)自绘,dump 拿不到缩略图 bounds,改用固定网格坐标 THUMB_X x THUMB_Y。
首次点击卡片会弹"使用手机网络下载将消耗流量"确认框,点确定后补点一次该卡片。
之前用固定 sleep(20s) 判断"这批下载完了",如果视频较大/网速慢,20s 内没下完,
下一轮又新点 9 个,同时在跑的下载数就可能超过 App 的并发上限(10个),超出的点击
会被静默忽略;等脚本继续往下滚,这些"点了但没真下载"的卡片就再也够不到了。
现在改成轮询远程目录里新增文件数直到连续两次不再增长,确认上一批真的落盘完成/
趋于稳定后再滚动,从根上避免超并发。max_wait_per_round 是保险上限,避免个别
文件异常卡住导致整个流程无限等待。
滚动距离也从"整 3 行"改成"2 行",故意留 1 行重叠:即使滑动手势的实际像素
距离和行高没有精确对齐,重叠的这一行也保证不会有卡片"从没出现在网格里就被划走"。
第二次教训(比上一版更根本):不能跨"轮"比较截图来判断是否到底。因为点击本身会把
卡片从"未下载"变成"已下载(打勾)",这个状态变化本身就会让下一轮开始时的截图跟上一轮
不一样——即使列表根本没有真的往下滚。正确做法是只在同一轮内、紧贴着"滚动"这一个
动作的前后各截一次图来判断是否到底,避免掺杂点击造成的状态变化。
第三次教训(比前两次更根本,本质原因):即使改成"同轮内滚动前后比较",只要画面里有
一个仍在真实下载的大文件(如几百分钟的超大录像),它的下载进度环/百分比会持续跳动,
导致任意两次截图之间必然存在像素差异——"是否到底"的信号被这张大文件自身的进度跳动
淹没,永远检测不到"画面无变化",脚本因此不断在同一屏重新点击,包括点到已经打勾✓
完成的卡片,把它们的下载重新触发一遍,造成"重复下载"。
根本修复:不能只依赖"整屏/局部截图是否变化"这一间接信号,必须直接识别每张卡片
本身是否已经打勾(已下载完成),点击前逐个跳过已打勾的卡片,只点真正还没下载完的。
这样无论"是否到底"这个滚动前后哈希判断能不能被大文件干扰失效,都不会再对已完成的
卡片重复触发下载——滚动哈希判断继续保留作为"提前结束循环"的优化,但不再是防止
重复下载的唯一防线。
第四次教训:打勾检测本身也有盲区——它只能识别"已经 100% 完成"的卡片,识别不了
"已经点过、正在下载但还没下完"的卡片(这两种状态在打勾位置的像素上是一样的,都不是
蓝色打勾)。而滚动距离是故意设计成"2 行"、留 1 行重叠的(见上面的说明),这意味着
上一轮网格里的第 3 行(第 7、8、9 个卡片)滚动后必然变成这一轮的第 1 行——如果这几张
卡片是大文件、90 秒轮询等不到它下完,打勾检测看到的仍是"未完成",会被当成全新卡片
重新点击一次,导致刚点过还在下载中的卡片被重复触发(实测复现:"第一次下载了 9 个,
上滑后又从第 7 个重新下载一遍")。
这个重叠关系是滚动距离决定的确定性事实,不需要靠像素判断去猜——所以改成无条件跳过
每一轮的第 1 行(除了最开始的第 1 轮),因为它就是上一轮已经点过的第 3 行,不管有没有
下完都不需要再点第二次。
第五次教训:以上几种修复都假设"打勾检测读到的是当前真实画面",但长时间连续点击/
截图会偶尔把 App 拖到卡死(ANR),卡死期间截图读到的是冻结前的旧画面——打勾检测会把
"其实已经点过/正在下载"的卡片误判为"未下载",导致反复重新触发下载。这不是某一行
的重叠问题,是全局性的:实测单日 220 个下载里只有 11 种真实内容,最严重一组被重复
下载了 28 次。dismiss_anr_if_any() 之前只在导航阶段被调用,批量下载循环里完全没
调用,是这个问题的直接缺口。修复:在每轮点击前、以及轮询下载数量期间都调用
ensure_not_anr(),确认 App 没卡死再继续;卡死超时仍未恢复则直接报错停止,而不是
在冻结画面上继续盲点制造更多重复。
第六次教训:第四次教训里"无条件跳过每轮第1行"这个修复本身还有个漏洞——它假设
"每一行永远是等高的3张卡片",但实际列表里穿插着"18:00""19:00"这类整点分组标签,
不属于卡片网格、却会额外占竖直空间。一旦某次滚动跨越了这种标签,实际卡片内容
相对固定网格坐标就会错位,"这一轮第1行 = 上一轮第3行"这个前提就不成立了——但
脚本还是死板地把第1行整行跳过,结果是把一整行从没点过的新卡片当成重叠行漏掉
(实测复现:截图里一整行卡片既没打勾也没在下载)。
根本修复:不能再靠"位置固定跳过第1行"这种盲猜,改成 cell_fingerprint() 逐张
卡片内容比对——只有指纹跟上一轮保存的最后一行指纹完全一致的卡片才是真重叠,
跳过;不一致就说明是错位带来的新内容,必须正常处理(判断打勾/点击),不能再
整行无脑跳过。
第七次教训:第六次教训里指纹比对方案刚实现时,取"上一轮最后一行"指纹用的是
本轮点击前的截图(grid_shot),而不是点击+等待下载稳定之后的截图。同一张卡片
点击前是"未下载"的鲜艳缩略图,点击后会变成"下载中"的暗色调,两种状态的指纹
永远对不上,导致"跳过真重叠"实测恒为 0(round 12~31 全部是 0),等于指纹比对
完全没生效,每轮都把上一轮点过的最后一行又点了一遍,比修复前更频繁地重复下载。
修复:把指纹计算挪到本轮点击+轮询结束、滚动前的那一刻,用 pre_scroll_hash 已经
截好的同一张图算指纹,确保两轮比较的都是同一种视觉状态(都是"这一刻画面",
而不是"点击前 vs 点击后"两种状态)。
第八次教训:第六/七次教训修好时序后仍有残留重复——实测相册里65个新文件有8对
(16个)内容重复,重复文件的时间间隔都稳定在34~45秒(正好是"一轮点击+等待轮询"
的周期),说明还是"重叠行"没识别出来。根因:指纹比对只查"这一轮第1行"是否
等于"上一轮最后一行",隐含假设"每次滚动固定重叠1行";但 input swipe 模拟的
是惯性滚动,实际滚动距离会随惯性、是否跨过整点分组标签而浮动,重叠有时是
1行、有时是2行。于是又改成本轮全部9格跟上一轮全部9格做指纹比对——但这版依然
"跳过真重叠恒为0",实测53个新文件仍有13对重复。
第九次教训(推翻第六~八次的整条指纹路线,最终方案):像素指纹从根上就走不通。
真正原因是下载列表里卡片缩略图的视觉状态会变:未下载时显示的是"灰色占位图",
下载完成后才替换成"彩色实际画面"(截图实证)。所以同一张卡片"滚动前看到是灰
占位、滚动回来时已下完变成彩色",缩略图整块都不同,任何基于像素的 MD5 指纹都
必然对不上 → 恒为0。左上角时间戳文字虽稳定,但白字压在亮背景上 OCR 不可靠,且
"同分钟同时长"不唯一。
彻底换思路:重复的唯一来源,是滚动留的重叠行滚上来时"上一轮刚点的卡片还没下完、
没显示打勾",导致 badge 检测(本能跳过打勾卡片)拦不住、被当新卡片重点。那就
从源头堵:每轮点击后,等到"这一轮点击的卡片全部显示打勾(下载完成)"再滚动。
这样滚动后的重叠行必然是打勾状态,下一轮开头 get_checked_cells 天然跳过它们——
不需要指纹、不需要 OCR,纯确定性。个别卡片下载失败/超时一直不打勾,则设上限
超时后照常滚动(那几张可能重复一次,靠下游 analyze.py 的 MD5 查重兜底)。
第十次教训(找到贯穿始终的总根源,本次最终方案):第九次"等打勾再滚"实测重复率
仍约 30%,且用户亲眼看到"已打勾的又下一遍"。深挖发现:打勾检测和点击都建立在
写死的网格坐标 THUMB_Y=[1148,1351,1554] 上,但这个列表滚动时【不 snap 对齐】,
而且每隔一段插入"整点分组标签"行(如"21:00"),它占竖直空间却不属于卡片网格。
一旦滚动跨过标签,所有卡片相对固定 THUMB_Y 整行平移(实测漂移达 174px,接近一整
行)。平移后:点击落在标签/错位卡片上、蓝勾采样点对不准(把已下载误读成未下载),
于是重复点击。前面 ANR/指纹/时机/等打勾所有修复,都建在这个"坐标会错位"的地基
上打转,治标不治本。
根本修复:不再写死行坐标,改成每屏用亮度标准差(std)垂直剖面【动态实测】卡片行的
真实中心 y(detect_card_rows)——卡片缩略图有纹理 std 高、间隙/分组标签/灰占位是
纯色 std≈0,据此切出纹理带,带中心即行中心。横向 3 列 THUMB_X 不漂移予以保留。
坐标动态对准后,第九次教训的"等打勾跳重叠"机制才真正生效。三张不同滚动位置的真实
截图验证:检出行中心精准落在卡片中心、分组标签处不产生行。THUMB_Y 与旧
get_checked_cells 就此废弃(仅保留备查)。
第十一次教训(第十次动态定位落地后暴露的两个到底部边界 bug):
(1) 每轮限点前 3 行以避免超并发,但列表【到底】时滚不动,被限制漏在第 4+ 行的
底部未下载卡片就永远轮不到处理,漏下最后几个(实测底部 00:xx 那几个未下载)。
(2) 到底检测原用整屏像素哈希,但底部未下载卡片是"灰色占位图",滚动时会微微
重渲染/回弹,哈希每次都不同,永远判不出"滚不动",一直空滚(实测空转到 216 轮)。
修复:到底检测改用"卡片行有没有整体平移"(滚不动=行中位数位移<40px,抗底部占位
抖动,比像素哈希可靠);判到底后不直接结束,先【清扫】当前屏所有检出行、分批
(每批≤9 不超并发)点未下载的并等打勾,把被限 3 行漏下的底部卡片补下完再 break。
实测:底部 6 个漏下卡片被清扫补下(相册 +6),到底判定位移=0 正确 break 不再空滚。
"""
print("→ 先滚动到列表顶部(起始滚动位置继承自上次播放位置,不一定是最新记录)")
# 第十二次教训:批量下载列表是【懒加载】——快速上滑时"更新的傍晚/夜间片段"还没
# 从网络拉进来,相邻两帧画面会暂时一样。旧逻辑"一次哈希不变就判到顶"会在半途
# (如 18 点)误判到顶,把它上面一整段最新录像漏在操作起点之上(实测漏掉当天
# 18:30–22:33 整段傍晚/夜间录像,用户在回放列表里看到一大片没打勾)。
# 修复:①每次上滑后多等一会儿(2s)让懒加载补齐更新片段;②要求【连续 2 次】画面
# 都无变化才确认真到顶(抗单次懒加载迟到的误判);③上限从 15 提到 40,避免从
# "上次播放位置"到当天最新之间列表过长时半途停下。
top_hash = None
stable = 0
for i in range(40):
adb("shell", "input", "swipe", "540", "1150", "540", "2350", "600")
time.sleep(2.0) # 等懒加载把更新的片段拉进来,避免"新内容没到→哈希暂时不变→误判到顶"
cur_hash = screenshot_hash()
if cur_hash == top_hash:
stable += 1
if stable >= 2:
print(f" 已到顶部(第 {i+1} 次滑动,连续 2 次画面不再变化)")
break
else:
stable = 0
top_hash = cur_hash
else:
print(" ⚠ 滑动 40 次仍未稳定,按当前位置继续(可能列表过长)")
print(f"→ 开始批量下载循环(最多 {max_rounds} 轮)")
dialog_dismissed = False
total_tapped = 0 # 本趟(一次完整 top→bottom)累计点击的未打勾卡片数,供多趟循环判完整
def clear_current_screen(round_no):
"""把当前屏所有"真卡片且未下载"的格子下完,直到整屏全部打勾才允许滚动。
返回 (本屏点击总数, 是否检出过卡片行)。
【设计要点·串行下载版】
① 点击与滚动解耦——整屏未下载卡片不全部打勾就绝不滚动,结构上零漏行。
② 水位泵:维持在途下载数≤WATERLINE。App 实为串行下载(一次只真下一个),泵只为
填满其队列、消除"整批下完→截图滚动"的空转间隙。在途按"已点但尚未打勾的格子数"
逐格精确统计。
③ 【点完就滚 + 去重兜底】滚动的前置条件是"整屏每张卡都至少点过一次",不要求
真下完。最好的退出是整屏全打勾(重叠行都是勾,下屏天然跳过,零重复);若
max_wait 内一直没有新卡打勾(个别卡慢/失败),就把没点过的强制补点一遍后
直接滚动,未下完的靠下游 md5 去重、下次同步再补——绝不为个别慢卡把整屏乃至
整个列表卡死(这是"整屏全打勾才滚"卡在第 1 屏的教训)。
④ 本屏不滚动,故首帧锁定行中心 locked_rows,之后每轮都按同一批 y 采样打勾,
不受 detect_card_rows 帧间抖动影响。tapped 记录已点格子、点过不再点(重复点击
是重复下载的根源)。"""
nonlocal dialog_dismissed
screen_tapped = 0
had_rows = False
targets = None # 本屏所有"真卡片"格子 [(gx,cy)];None=尚未成功检出
locked_rows = None # 首帧锁定的行中心 y(本屏不滚动,后续都按此采样打勾)
tapped = set() # 已点过的格子(点过不再点)
prev_checked = -1 # 上轮已打勾格子数,用于识别停滞
stall = 0 # 距上次有新格子打勾的秒数
while True:
ensure_not_anr()
# 截图前等缩略图从灰占位加载出真实画面,否则 std≈0 被当间隙漏检。
time.sleep(THUMB_LOAD_WAIT)
shot = screenshot("/tmp/_gridchk.png")
# 首次成功检出时锁定本屏行中心与全部真卡片格子(含已打勾的)。
if targets is None:
rows = detect_card_rows(shot)
# 检测不到行时重试:缩略图慢/ANR 刚恢复/画面切换中会临时检测不到。
retry = 0
while not rows and retry < 3:
retry += 1
time.sleep(2.0 + retry)
shot = screenshot("/tmp/_gridchk.png")
rows = detect_card_rows(shot)
if not rows:
break
had_rows = True
locked_rows = rows
chk = get_checked_cells_dynamic(shot, locked_rows)
# 稀疏布局下同一行不一定 3 列都有卡片,用 cell_has_card 过滤空列,
# 否则点空→永远不打勾→白熬满超时。
targets = [(gx, cy) for cy in locked_rows for gx in THUMB_X
if cell_has_card(shot, gx, cy)]
already = sum(1 for t in targets if chk.get(t))
print(f" 轮 {round_no}: 本屏 {len(locked_rows)} 行,卡片 {len(targets)},"
f"已打勾 {already},待下载 {len(targets) - already}")
if not targets:
break
else:
chk = get_checked_cells_dynamic(shot, locked_rows)
# 本屏尚未打勾的目标格子。
remaining = [t for t in targets if not chk.get(t)]
n_checked = len(targets) - len(remaining)
# 最好的退出:整屏全部打勾(重叠行都是勾,下屏天然跳过,零重复)。
if not remaining:
print(f" 轮 {round_no}: 本屏 {len(targets)} 卡全部打勾,可滚动")
break
# 有新卡片打勾 → 下载在推进,重置停滞计时。
if n_checked > prev_checked:
prev_checked = n_checked
stall = 0
# 超时兜底:max_wait 内没有任何新卡片打勾(个别卡慢/失败),认定不必再等。
# 先把还没点过的强制补点一遍(保证每张卡至少被尝试过一次,绝不漏点),
# 然后直接 break 去滚动——未下完的靠下游 md5 去重、下次同步再补。
# 这样绝不会为个别慢卡把整屏乃至整个列表卡死。
if stall >= max_wait_per_round:
untapped_now = [t for t in remaining if t not in tapped]
for gx, cy in untapped_now:
adb("shell", "input", "tap", str(gx), str(cy))
time.sleep(0.5)
if not dialog_dismissed and dismiss_dialog_if_any():
dialog_dismissed = True
adb("shell", "input", "tap", str(gx), str(cy))
time.sleep(0.5)
tapped.add((gx, cy))
screen_tapped += 1
print(f" 轮 {round_no}: 等 {stall}s 无新完成,剩 {len(remaining)} 未下完"
f"已全部点过,靠去重兜底,继续")
break
# 在途 = 已点但还没打勾;未点 = 还没点过的待下载格子。
untapped = [t for t in remaining if t not in tapped]
inflight = len(remaining) - len(untapped)
# 水位泵:在途未满水位、且还有未点卡片,就逐个补点填满队列。
while inflight < WATERLINE and untapped:
gx, cy = untapped.pop(0)
adb("shell", "input", "tap", str(gx), str(cy))
time.sleep(0.5)
# 首次点击弹"使用流量下载"确认框,点掉后补点一次该卡片。
if not dialog_dismissed and dismiss_dialog_if_any():
dialog_dismissed = True
adb("shell", "input", "tap", str(gx), str(cy))
time.sleep(0.5)
tapped.add((gx, cy))
screen_tapped += 1
inflight += 1
print(f" 轮 {round_no}: 补点 ({gx},{cy}),在途 {inflight}/{WATERLINE},"
f"未点剩 {len(untapped)}")
time.sleep(poll_interval)
stall += poll_interval
return screen_tapped, had_rows
for r in range(max_rounds):
# 先把当前屏所有未下载卡片全部下完(点击与滚动解耦,见 clear_current_screen)。
tapped, had_rows = clear_current_screen(r + 1)
total_tapped += tapped
# 滚动 + 到底判定。用像素行剖面互相关精确测真实滚动位移(detect_card_rows
# 中位数不可靠,会把"滚了几行"误判成"没动"→二次滚动→滚过头漏行,这是中段
# 整片漏下的根因)。
# 第十三次教训·滑多了漏边缘行:滚动手势原为 1900→1000(净推进≈4.4 行、重叠仅
# ≈2.4 行)。屏幕最顶/最底被裁切的半行 detect_card_rows 切不出完整纹理带→不被
# 点击;重叠不足时,这半行可能"上一屏在底部没检出→这一屏被滚过头到顶部边缘又没
# 检出",两屏都没完整落进可视区中部→整行漏下(实测顶部一整行 07:xx 未下载)。
# 修复:手势缩小到 1800→1150(净推进≈3 行、重叠≈4 行),任何边缘漏检行下一屏
# 必然完整落在可视区中部被检出处理。代价是轮数变多、略慢,对 4G 慢速机可接受。
before_scroll = screenshot("/tmp/_prescroll.png")
adb("shell", "input", "swipe", "540", "1800", "540", "1150", "500")
time.sleep(2.0) # 等惯性滚动完全停止再截图测位移
after_scroll = screenshot("/tmp/_postscroll.png")
shift, sad = measure_scroll_shift(before_scroll, after_scroll)
if shift < MIN_SCROLL_PX:
# 疑似到底。但本列表【分页懒加载】:滚到已加载页末尾时,下一页(如 06:4x→00:00)
# 还要经 3G 现拉,那一刻滑不动、位移=0,并非真到底(实测轮17就是这样把整段
# 06:4x→00:00 切掉漏下)。也可能是队列堵/卡顿导致单次手势只滚一点点。
# 改法A·加耐心:连续【6 次】重试、每次滑动前多等 7 秒给下一页加载时间,
# 全部 <阈值 才确认真到底;其间任一次滚动生效(>=阈值)就跳出、继续正常循环。
# 真到底只多花几十秒(每次跑一次),却能救回被误切的底部整段。
confirmed_bottom = True
shifts = [shift]
for retry in range(6):
time.sleep(7) # 关键:给懒加载的下一页足够时间经 3G 拉出来
b2 = screenshot("/tmp/_prescroll2.png")
adb("shell", "input", "swipe", "540", "1800", "540", "1150", "500")
time.sleep(2.0)
a2 = screenshot("/tmp/_postscroll2.png")
s2, _ = measure_scroll_shift(b2, a2)
shifts.append(s2)
if s2 >= MIN_SCROLL_PX:
print(f" 轮 {r+1}: 滚动未生效(位移{shift}px),第{retry+1}次重试已推进({s2}px),继续")
confirmed_bottom = False
break
print(f" 轮 {r+1}: 到底确认中,第{retry+1}/6次重试位移{s2}px(<{MIN_SCROLL_PX})")
if confirmed_bottom:
# 到底确认后,那 6 次重试滚动虽位移<阈值,但累积的微小移动会把最底
# 一两行完整露出来(实测到底后 06:19~06:30 两行已显示却从未被点、漏下)——
# 这最终画面从没被 clear 过。break 前对最终屏补扫一次,确保最后几行不漏。
print(f" 轮 {r+1}: 连续多次强力滚动均滚不动{shifts},已到底,补扫最终屏")
final_tapped, final_had_rows = clear_current_screen(r + 1)
total_tapped += final_tapped
if not final_had_rows and r > 0:
# 一行都检不出来:可能整页还在"加载中"(并非真到底)。已连续 6 次滚不动
# 才走到这里,通常就是真到底/空页;仅打一条提示,不再判死。个别因加载慢
# 漏掉的片段靠 daily 重跑 + md5 去重下次补齐。
print(f" 轮 {r+1}: ⚠ 到底判定时未检出卡片行(可能列表加载慢),按到底结束")
print(f" 轮 {r+1}: 确认到底,结束")
break
continue
if shift > MAX_SCROLL_PX:
# 滚过头,重叠不足可能漏被截断的半行,向上回滚补偿(回滚到已下载区是安全的:
# 多回=多重叠,重叠行已打勾会被跳过,不重复也不漏)。
back = shift - 600 # 回滚到净推进约 600px(≈3行) 的位置,与新步长一致
print(f" 轮 {r+1}: 滚动过头({shift}px),向上回滚约{back}px补偿")
adb("shell", "input", "swipe", "540", "1000", "540", str(1000 + int(back)), "500")
time.sleep(2.0)
print(f" 轮 {r+1}: 本屏已清空(点击 {tapped} 个),滚动 {shift}px(≈{shift/204:.1f}行),继续")
print(f"→ 批量下载循环结束(本趟累计点击 {total_tapped} 个未打勾卡片)")
return total_tapped
def _device_unique_count():
"""统计今日下载目录里【唯一内容】的数量(按整文件 md5 去重,与本地 _dedup 同口径)。
多趟循环用它判"这一趟有没有真的补到新录像"。用设备端 md5sum 计算,避免拉回本地。"""
today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
r = adb("shell", "md5sum", f"{remote_dir}/*_BH*")
digests = set()
for line in r.stdout.strip().splitlines():
line = line.strip()
if not line or "No such file" in line:
continue
digests.add(line.split()[0])
return len(digests)
def tap_all_thumbnails_multipass(baseline, max_passes=5, **kw):
"""多趟循环下载,直到"某一趟没有新增任何唯一录像"=补全完整,或达 max_passes 上限兜底。
设计依据:tap_all_thumbnails 本身就是一次完整的 top→bottom 下载——开头先滚到顶,
每屏用蓝勾检测跳过已下载卡、只点未打勾卡,滚到底才结束。它不重启 App,所以反复
调用就是"在同一 App 会话内、回顶重扫、补下漏点卡",天然满足"同会话保住蓝勾"。
完整性判据——【本趟新增唯一内容数】而非"零点击":
蓝勾检测有约 4.5% 假阴性(把已下载卡误判成未下载),会让"零点击"永远达不到、
循环白跑满上限并狂造重复。改用"设备唯一内容(md5)增量"判完整:真遗漏被补下=新增
唯一内容;假阴性重复点=只造重复、不增唯一内容;死录像=下不动、不增。三种情况
里只有"真补到新录像"才让增量>0,故"某趟增量=0"就是"再没有可补的了"=完整。
这个判据对假阴性和死录像都免疫。
退出条件:
① 成功——某趟结束后唯一内容数没有增长(gained==0),确认无更多可补,完整。
② 兜底——达 max_passes 趟仍在新增(gained>0):极少见(说明每趟都还在捞到新录像,
可能列表极长或网络反复失败),记录警告后停止,避免无限循环。
趟间必须 wait_for_downloads:本趟点的卡此刻可能还没下完,等队列消化后再统计唯一数,
才能准确反映"这一趟到底补到了多少新录像"。
"""
prev_unique = _device_unique_count()
print(f"→ 多趟循环开始,设备当前唯一录像 {prev_unique} 个")
for p in range(max_passes):
print(f"\n===== 第 {p+1}/{max_passes} 趟下载开始 =====")
tapped = tap_all_thumbnails(baseline, **kw)
# 趟间等下载队列消化,让本趟点的卡都下完,唯一数统计才准确。
wait_for_downloads(baseline)
now_unique = _device_unique_count()
gained = now_unique - prev_unique
print(f"===== 第 {p+1} 趟结束:点击 {tapped} 个未打勾卡,"
f"新增唯一录像 {gained} 个(累计 {now_unique})=====")
if gained <= 0:
print(f"✓ 第 {p+1} 趟未补到新录像,确认已完整下载,结束多趟循环")
return
prev_unique = now_unique
print(f"⚠ 已达 {max_passes} 趟上限仍在新增录像,可能列表过长或网络反复失败,停止多趟循环")
def wait_for_downloads(baseline_files, timeout=600):
"""等待下载目录新增文件稳定
文件名使用下载时刻时间戳,固定存到今天的目录。
baseline_files 为下载前已有的文件集合,用于计算新增数量。
"""
# 兼容调用方传 list:下面要做 set 差集(all_files - baseline_files),
# 若传进来是 list 会 TypeError: unsupported operand type(s) for -: 'set' and 'list'。
baseline_files = set(baseline_files)
today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
print(f"→ 等待下载完成(目录 {today},起始已有 {len(baseline_files)} 个)...")
last_new = -1
stable_rounds = 0
deadline = time.time() + timeout
while time.time() < deadline:
result = adb("shell", "find", remote_dir, "-name", "*_BH*", "-type", "f")
all_files = set(l for l in result.stdout.strip().splitlines() if l)
new_files = all_files - baseline_files
count = len(new_files)
print(f" 新下载 {count} 个文件...")
if count > 0 and count == last_new:
stable_rounds += 1
if stable_rounds >= 3:
print(f"→ 下载稳定,共新增 {count} 个文件")
return list(new_files)
else:
stable_rounds = 0
last_new = count
time.sleep(10)
print("警告:等待超时")
# 返回已下载的新文件(即便未完全稳定)
result = adb("shell", "find", remote_dir, "-name", "*_BH*", "-type", "f")
all_files = set(l for l in result.stdout.strip().splitlines() if l)
return list(all_files - baseline_files)
def _dedup_local_dir(local_dir):
"""按内容(md5)对本地目录去重。
同一段录像若在一次运行里被二次下载(首轮下载超时、角标未出现、滚动重叠时被再点一次),
会得到一个以"下载时刻"为前缀的全新文件名,pull_files 的文件名去重拦不住它,
于是本地留下两份内容完全相同的文件。这里按内容哈希分组,每组只保留文件名最小
(= 最早下载)的一个,删除多余副本,让"未完成的靠去重兜底"真正生效。"""
by_hash = {}
removed = 0
for f in sorted(local_dir.glob("*.mp4")): # sorted 保证每组保留最早下载的那个
h = hashlib.md5()
try:
with open(f, "rb") as fp:
for chunk in iter(lambda: fp.read(1 << 20), b""):
h.update(chunk)
except OSError:
continue
digest = h.hexdigest()
if digest in by_hash:
f.unlink()
removed += 1
print(f" 去重删除(内容与 {by_hash[digest]} 相同): {f.name}")
else:
by_hash[digest] = f.name
if removed:
print(f"→ 内容去重:删除 {removed} 个重复副本,剩余 {len(by_hash)} 个唯一录像")
def pull_files(remote_files, date_str=None):
"""把下载好的文件 adb pull 到本地。
date_str 为录像日期(YYYYMMDD),归档到 recordings/BH5455783/{date_str}/。
不传则默认昨天(保持原行为)。"""
if date_str is None:
date_str = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
# 本地目录按录像日期命名,方便归档
local_dir = OUTPUT_DIR / "BH5455783" / date_str
local_dir.mkdir(parents=True, exist_ok=True)
print(f"→ 拉取 {len(remote_files)} 个文件到 {local_dir}")
for remote_path in remote_files:
fname = Path(remote_path).name + ".mp4"
local_path = local_dir / fname
if local_path.exists():
print(f" 跳过(已存在): {fname}")
continue
adb("pull", remote_path, str(local_path))
print(f" 已拉取: {fname}")
# 拉取完成后做一次内容去重,清掉二次下载产生的重复副本(文件名去重拦不住的那种)
_dedup_local_dir(local_dir)
print(f"→ 完成,文件保存在 {local_dir}")
def get_baseline_files():
"""获取下载开始前已有的文件列表"""
today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
result = adb("shell", "find", remote_dir, "-name", "*_BH*", "-type", "f")
return set(l for l in result.stdout.strip().splitlines() if l)
def get_all_remote_files():
"""获取设备上今天下载目录里的全部录像文件(不做 baseline 差集)。
最终拉取阶段必须用这个函数,不能只拉"相对 baseline 新增"的文件:如果之前
某次运行中途跑崩/被 kill,它已经下载到设备本地的文件会被下一次运行的新
baseline 当成"运行前就存在",永远不会被视为新增,导致这些早就下载好的文件
永远拉不回 Mac。pull_files() 本身已经有本地文件存在则跳过的去重逻辑,
所以直接拉全部远程文件是安全的,不会重复拉取已经在本地的文件。
"""
today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
# 排除 .tmp 半成品(中途被 kill 留下的不完整下载)和 0 字节失败文件,
# 避免把坏文件拉回本地。
result = adb("shell", "find", remote_dir, "-name", "*_BH*", "-type", "f",
"!", "-name", "*.tmp", "!", "-size", "0")
return set(l for l in result.stdout.strip().splitlines() if l)
def sync():
print("===== 萤石录像自动同步开始 =====")
launch_app()
# baseline 只用于判断"这一轮点击触发的下载是否已经稳定",不再限制最终拉取范围
baseline = get_baseline_files()
print(f"→ 基准文件数: {len(baseline)}")
navigate_to_yesterday_batch()
tap_all_thumbnails(baseline)
wait_for_downloads(baseline)
all_files = get_all_remote_files()
print(f"→ 设备上共有 {len(all_files)} 个录像文件(含之前跑崩残留的历史文件),开始全部拉取")
if all_files:
pull_files(list(all_files))
else:
print("未找到任何录像文件")
print("===== 同步完成 =====")
def clear_device_recordings():
"""清空模拟器上萤石下载的录像文件 + 相册里的录像副本,释放存储空间。
只删录像(*_BH* 下载文件、YS 相册 *.mp4),不碰 App 的日志/播放器资源等其它文件。
多天连续下载时必须每天清,否则录像(含相册双份)会很快撑满模拟器 10G 存储。"""
today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
adb("shell", f"find {remote_dir} -name '*_BH*' -type f -delete")
adb("shell", "find /sdcard/DCIM/YS -iname '*.mp4' -type f -delete")
def sync_recent_days(n_days=7):
"""下载最近 n_days 天的录像(今天、昨天……往前 n_days-1 天),按录像日期归档。
每天独立重启 App 导航(比"退出批量下载页再切日期"稳妥)。关键:每天下载【前】
先清空模拟器上的录像+相册,让这一天从零开始——这样:①模拟器不会被多天累积撑满
(10G 存储,含相册双份,几天就满);②下完后设备上就是这一天的全部录像,直接
全量拉取到 recordings/{录像日期}/,不用再靠前后差集区分。拉完再清一次即时释放。
某天若无录像(点下载弹不出菜单)则跳过。
"""
print(f"===== 开始下载最近 {n_days} 天录像 =====")
for d in range(n_days):
rec_date = (datetime.now() - timedelta(days=d)).strftime("%Y%m%d")
print(f"\n===== [{d+1}/{n_days}] 录像日期 {rec_date}(今天往前第 {d} 天)=====")
for attempt in range(2): # 每天最多重试1次(共2次尝试),避免偶发ANR/网络问题导致整天漏下载
try:
# 清空本地该天旧目录:重下的文件名是新的下载时刻,和旧文件名不同,
# 不清会产生"内容相同、文件名不同"的重复副本。SD 卡是真源,重下可恢复。
day_dir = OUTPUT_DIR / "BH5455783" / rec_date
if day_dir.exists():
shutil.rmtree(day_dir)
print(f" 已清空本地旧目录 {day_dir}(覆盖式重下)")
launch_app()
clear_device_recordings() # 下载前清空,保证有空间、且设备只剩这天的
navigate_to_yesterday_batch(days_back=d)
# 多趟循环下载:反复回顶重扫补下漏点卡,直到某趟零点击=完整(或达上限兜底)。
# 内部已含趟间 wait_for_downloads,故不再单独调用。
tap_all_thumbnails_multipass(set())
day_files = get_all_remote_files() # 这一天下载的全部录像
if day_files:
pull_files(list(day_files), date_str=rec_date)
else:
print(f"→ {rec_date} 没有下载到文件")
clear_device_recordings() # 拉完即时清空,释放空间给下一天
break # 成功则跳出重试循环
except Exception as e:
print(f"⚠ {rec_date} 第 {attempt+1} 次尝试失败:{e}")
if attempt == 0:
print(f" 重试一次...")
time.sleep(5)
else:
print(f" {rec_date} 两次尝试均失败,跳过该天")
print(f"\n===== 最近 {n_days} 天下载完成 =====")
if __name__ == "__main__":
import sys
# 用法:python automate.py → 只下昨天(原行为)
# python automate.py 7 → 下最近 7 天
if len(sys.argv) > 1:
sync_recent_days(int(sys.argv[1]))
else:
sync()