#!/usr/bin/env python3 """ 萤石录像自动下载脚本 通过 ADB 自动化模拟器中的萤石 App,下载前一天的所有报警录像。 """ import subprocess, time, os, shutil, struct, zlib from datetime import datetime, timedelta from pathlib import Path PYTHON = "/usr/local/bin/python3.12" 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": (270, 1340), # 摄像头详情页-实时视频按钮 "record_tab": (540, 2290), # 底部录像 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, 2300 # 列表纵向扫描范围(避开顶部状态栏/预览/标题栏、底部手势条) ROW_STD_X0, ROW_STD_W = 350, 700 # 横向 std 采样区(避开左右圆角/阴影) ROW_STD_COLS = 175 # 降采样列数(一次 magick 折成每行 175 个样本算 std,避免逐行上百次 magick) ROW_STD_LOW = 28 # std 阈值:>= 判为卡片纹理,< 判为间隙/标签/占位(实测卡片≥46、非卡片=0,余量足) ROW_MIN_H = 90 # 纹理带最小高度(滤掉分组标签、半截卡片、噪声) ROW_MAX_H = 260 # 纹理带最大高度(超过视为两行粘连,按行距二次切分) ROW_PITCH = 204 # 相邻卡片行的竖直间距(用于粘连带二次切分) ROW_EDGE_MARGIN = 12 # 纹理带触及扫描区上/下边缘视为被截断的半行,丢弃 THUMB_LOAD_WAIT = 1.0 # 每轮截图前等待缩略图从"灰占位"加载出真实画面,避免假间隙漏检 def adb(*args): return subprocess.run([ADB, "-s", DEVICE] + list(args), capture_output=True, text=True) 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 # 用 [^<>]*? 限制在同一 标签内,并跨过含 / 的属性 # (如 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") 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 给模拟器+萤石喘息时间。 """ 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 expected_activity_keyword in cur: print(f" ✓ tap ({x},{y}) 后进入 '{cur}'") return True print(f" ! tap ({x},{y}) 后 Activity='{cur}' 不含 '{expected_activity_keyword}',重试 {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。需要系统装了 ImageMagick (`magick`)。""" import re adb("shell", "screencap", "-p", "/sdcard/_pixchk.png") adb("pull", "/sdcard/_pixchk.png", png_path) out = subprocess.run( ["magick", png_path, "-format", "%[pixel:p{" + str(x) + "," + str(y) + "}]", "info:"], capture_output=True, text=True, ) m = re.match(r"srgba?\((\d+),(\d+),(\d+)", out.stdout.strip()) if not m: return (0, 0, 0) return tuple(map(int, m.groups())) def screenshot(path="/tmp/ezviz_screen.png"): # 用 exec-out 直接把 PNG 输出到本地,省掉"先写 /sdcard 再 pull"的两步往返, # 每次截图快约 0.5~1s(批量下载循环每轮要截多次图,累计提速明显)。 with open(path, "wb") as f: subprocess.run([ADB, "-s", DEVICE, "exec-out", "screencap", "-p"], stdout=f) return path def sample_rgb_from_file(png_path, x, y): """从已经拉取到本地的截图文件采样指定坐标 RGB(不重新截图,供批量取点复用)。""" import re out = subprocess.run( ["magick", png_path, "-format", "%[pixel:p{" + str(x) + "," + str(y) + "}]", "info:"], capture_output=True, text=True, ) m = re.match(r"srgba?\((\d+),(\d+),(\d+)", out.stdout.strip()) if not m: return (0, 0, 0) return tuple(map(int, m.groups())) def count_blue_pixels(png_path, cx, cy): """统计以 (cx,cy) 为中心、大小 BLUE_WIN 的窗口内"强蓝"像素数量。 强蓝判据:b>150 且 b-r>80(蓝勾角标的高饱和蓝,灰/黑/彩色画面几乎不出现)。 用窗口统计而非单点采样,是为了吸收卡片垂直位置的漂移(见 BADGE_OFFSET 说明)。""" import re w, h = BLUE_WIN x0, y0 = cx - w // 2, cy - h // 2 out = subprocess.run( ["magick", png_path, "-crop", f"{w}x{h}+{x0}+{y0}", "txt:-"], capture_output=True, text=True, ) cnt = 0 for m in re.finditer(r"srgba?\((\d+),(\d+),(\d+)", out.stdout): r, g, b = map(int, m.groups()) if b > 150 and b - r > 80: cnt += 1 return cnt 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。据此可把"卡片行" 从"间隙/标签/占位"里分出来。 性能:不逐行调 magick(一屏上百次太慢),而是用一次 magick 把 ROW_STD_W 宽的 采样区灰度图横向降采样到 ROW_STD_COLS 列(每列是原若干像素的平均),导出像素文本, 再在 Python 里按行算这 ROW_STD_COLS 个样本的标准差。实测降采样法的低谷/高峰位置 与逐行 %[fx:standard_deviation] 完全一致,且对比度更大(间隙 0 vs 卡片 60+)。""" import re, math h = ROW_Y1 - ROW_Y0 out = subprocess.run( ["magick", png_path, "-crop", f"{ROW_STD_W}x{h}+{ROW_STD_X0}+{ROW_Y0}", "+repage", "-colorspace", "Gray", "-resize", f"{ROW_STD_COLS}x{h}!", "txt:-"], capture_output=True, text=True, ) rows = {} # txt 行形如 "x,y: (gray,alpha) ...",灰度值取第一个数 for m in re.finditer(r"^\d+,(\d+): \((\d+)", out.stdout, re.M): rr, val = int(m.group(1)), int(m.group(2)) rows.setdefault(rr, []).append(val) prof = [] for rr in sorted(rows): vals = rows[rr] mean = sum(vals) / len(vals) var = sum((v - mean) ** 2 for v in vals) / len(vals) prof.append((ROW_Y0 + rr, math.sqrt(var))) return prof 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 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 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) crop_path = png_path.replace(".png", "_crop.png") subprocess.run(["magick", png_path, "-crop", "400x90+40+1080", crop_path], capture_output=True) with open(crop_path, "rb") as f: return hashlib.md5(f.read()).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(): """导航到昨天的 SD 卡批量下载模式。 关键节点用 tap_text 通过 UI dump 定位,避免坐标错位时误触首页底部 Tab(萤石/对话/商城/社区/我的,"商城"中心 x=540 跟录像 Tab 撞车)。 """ print("→ 点击首页摄像头卡片(用 Activity 切换验证,避免频繁 dump 触发 ANR)") # 详情页是 RN 实现的 EZReactBizActivity tap_until_activity(*COORD["home_camera_card"], expected_activity_keyword="EZReactBizActivity") print("→ 点击实时视频(详情页有文字,可以 dump 定位)") tap_text("实时视频", after_delay=5) for attempt in range(3): if not is_error_page(): print(" 实时视频加载成功") break print(f" 检测到加载失败,重试 {attempt+1}/3 ...") tap(*COORD["reload_btn"], delay=6) else: print(" 三次失败,返回重试") adb("shell", "input", "keyevent", "KEYCODE_BACK") time.sleep(2) tap(*COORD["home_camera_card"], delay=3) tap_text("实时视频", after_delay=8) # 实时视频页全自绘,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("→ 从今日退一天到昨天") if not tap_and_check_date_changed(COORD["prev_day_arrow"]): print(" ! 首次未检测到日期变化,重试一次") if not tap_and_check_date_changed(COORD["prev_day_arrow"]): raise RuntimeError("点击'上一天'箭头后日期未变化,无法切换到昨天") # 等日期切换后的录像列表稳定加载 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) crop_path = png_path.replace(".png", "_crop.png") # 缩略图网格区域:y=1000 到屏幕底部,避开上方标题/预览/状态栏(Pixel 6 模拟器 1080x2400) subprocess.run(["magick", png_path, "-crop", "1080x1400+0+1000", crop_path], capture_output=True) with open(crop_path, "rb") as f: return hashlib.md5(f.read()).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=30): """循环:点击固定网格 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("→ 先滚动到列表顶部(起始滚动位置继承自上次播放位置,不一定是最新记录)") top_hash = None for i in range(15): adb("shell", "input", "swipe", "540", "1150", "540", "2350", "600") time.sleep(1.3) cur_hash = screenshot_hash() if cur_hash == top_hash: print(f" 已到顶部(第 {i+1} 次滑动后画面不再变化)") break top_hash = cur_hash else: print(" ⚠ 滑动 15 次仍未稳定,按当前位置继续(可能列表过长)") print(f"→ 开始批量下载循环(最多 {max_rounds} 轮)") dialog_dismissed = False for r in range(max_rounds): # 点击前先确认 App 没有卡死:卡死时截图读到的是冻结前的旧画面, # 打勾识别会失真,是"重复下载"的真正根因(见 ensure_not_anr 文档字符串)。 ensure_not_anr() # 截图前等一下,让"灰色占位"缩略图加载出真实画面——否则纯色占位 std≈0 # 会被 detect_card_rows 当成间隙,导致整行漏检(见 THUMB_LOAD_WAIT 说明)。 time.sleep(THUMB_LOAD_WAIT) # 每屏动态实测卡片行中心 y(消除整点分组标签平移造成的错位,这是"重复下载" # 的总根源),再对每行 × 固定 3 列逐格判打勾,只点还没下载完的。 grid_shot = screenshot("/tmp/_gridchk.png") full_rows = detect_card_rows(grid_shot) if not full_rows: # 偶发一张都没检出(仍在加载/异常画面),重拍一次 time.sleep(1.5) grid_shot = screenshot("/tmp/_gridchk.png") full_rows = detect_card_rows(grid_shot) # 每轮最多处理最上面 3 行(9 格)。萤石同时下载上限约 10 个,一屏可见 5~6 行, # 若把检出的所有行都点了(12~14 个)会超并发:多点的卡片不进下载队列、滚上来 # 仍未打勾,被反复重点,实测会让"跳过已打勾"退化成 0、重复率回升。多出的行 # 下一轮滚上来再处理(滚动量≈2 行,正好衔接),不会漏。full_rows 保留全部行, # 供到底判定和到底清扫复用(省一次滚动前的重复截图+检测)。 row_centers = full_rows[:3] checked = get_checked_cells_dynamic(grid_shot, row_centers) # 跳过已经打勾(下载完成)的卡片。滚动留的 1~2 行重叠滚上来时必然已是打勾 # 状态(本轮末尾"等全部点击的卡片打勾再滚动"保证),所以重叠行天然被跳过, # 不会被当成新卡片重复触发下载——现在行中心是动态对准的,这个跳过才可靠。 badge_skipped = sum(1 for cy in row_centers for gx in THUMB_X if checked.get((gx, cy))) tapped_cells = [] for cy in row_centers: for gx in THUMB_X: if checked.get((gx, cy)): continue tapped_cells.append((gx, cy)) 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) print(f" 轮 {r+1}: 检出 {len(row_centers)} 行,跳过已打勾 {badge_skipped} 个,点击待下载 {len(tapped_cells)} 个") if tapped_cells: # 关键:等这一轮点击的卡片全部显示打勾(下载完成)再滚动。这样滚动 # 留下的重叠行必然是打勾状态,下一轮开头 get_checked_cells 会跳过它们, # 从根上消除"重叠行还没下完就被重复点击"这个唯一的重复来源。 print(f" 轮 {r+1}: 等待刚点击的 {len(tapped_cells)} 个卡片全部打勾(最多 {max_wait_per_round}s)...") waited = 0 pending = list(tapped_cells) while waited < max_wait_per_round: time.sleep(poll_interval) waited += poll_interval ensure_not_anr() # 等待期间 App 也可能卡死,同样要检测 wait_shot = screenshot("/tmp/_waitchk.png") # 等待期间未滚动,行位置不变,复用本轮 row_centers 复检这些格子 chk = get_checked_cells_dynamic(wait_shot, row_centers) pending = [c for c in tapped_cells if not chk.get(c)] if not pending: break done = len(tapped_cells) - len(pending) total = get_remote_new_file_count(baseline) if pending: # 个别卡片下载失败/超时一直不打勾,不能无限等,照常滚动。这几张 # 下一轮可能被重复点一次,靠下游 analyze.py 的 MD5 查重兜底。 print(f" 轮 {r+1}: 等待 {waited}s 超时,{done}/{len(tapped_cells)} 个已打勾," f"仍有 {len(pending)} 个未完成,照常滚动(这几张可能重复,靠下游去重兜底)") else: print(f" 轮 {r+1}: {len(tapped_cells)} 个全部打勾完成(等待 {waited}s),累计新下载 {total} 个,开始滚动") else: print(f" 轮 {r+1}: 这一屏全部已打勾,无需等待,直接滚动") # 滚动前的行位置直接复用本轮开头检出的 full_rows(点击+等打勾期间列表没滚动, # 行位置不变),省掉一次滚动前的重复截图+检测。 # 到底检测不能用整屏像素哈希:列表底部有未下载的"灰色占位图",滚动时会 # 微微重渲染/回弹,像素哈希每次都不同,永远判不出"滚不动",导致一直空滚 # (实测跑到 216 轮还在底部空转)。改用"卡片行有没有整体平移":真滚动了行会 # 整体位移几百像素,滚不动(到底)时行位置几乎不变。 pre_rows = full_rows # 向下滚动 2 行(约 406 像素,保留 1 行重叠避免漏跳) adb("shell", "input", "swipe", "540", "1820", "540", "1414", "400") time.sleep(1.0) post_shot = screenshot("/tmp/_postscroll.png") post_rows = detect_card_rows(post_shot) def _median(vals): return sorted(vals)[len(vals) // 2] if vals else -1 scrolled = not (pre_rows and post_rows and abs(_median(pre_rows) - _median(post_rows)) < 40) if not scrolled: # 到底了(滚不动)。此时当前屏可能还有被"每轮只处理前 3 行"限制漏下的 # 底部未下载卡片(它们本该靠继续滚动移到前 3 行处理,但到底滚不动了)。 # 清扫:对当前屏【所有】检出行判打勾,分批(每批≤9 不超并发)点未下载的、 # 等打勾,直到当前屏没有未下载卡片,再结束循环。 print(f" 轮 {r+1}: 滚动后行位置未变,已到达列表底部,清扫当前屏剩余未下载卡片") for sweep in range(6): # 最多 6 批,防意外死循环 ensure_not_anr() time.sleep(THUMB_LOAD_WAIT) sshot = screenshot("/tmp/_sweep.png") srows = detect_card_rows(sshot) schk = get_checked_cells_dynamic(sshot, srows) todo = [(gx, cy) for cy in srows for gx in THUMB_X if not schk.get((gx, cy))] if not todo: print(" → 底部已无未下载卡片,清扫完成") break batch = todo[:9] print(f" → 清扫第 {sweep+1} 批:点击 {len(batch)} 个未下载卡片") for (gx, cy) in batch: 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) # 等这批全部打勾(同主循环机制) waited = 0 pend = list(batch) while waited < max_wait_per_round: time.sleep(poll_interval) waited += poll_interval ensure_not_anr() wshot = screenshot("/tmp/_sweepwait.png") wchk = get_checked_cells_dynamic(wshot, srows) pend = [c for c in batch if not wchk.get(c)] if not pend: break if pend: print(f" → 清扫批 {sweep+1} 有 {len(pend)} 个超时未完成,靠下游去重兜底") break print("→ 批量下载循环结束") def wait_for_downloads(baseline_files, timeout=600): """等待下载目录新增文件稳定 文件名使用下载时刻时间戳,固定存到今天的目录。 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 pull_files(remote_files): """把下载好的文件 adb pull 到本地""" yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") # 本地目录按录像日期(昨天)命名,方便归档 local_dir = OUTPUT_DIR / "BH5455783" / yesterday 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}") 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}" result = adb("shell", "find", remote_dir, "-name", "*_BH*", "-type", "f") 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) # 默认 max_rounds=300,跑到滚到底为止 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("===== 同步完成 =====") if __name__ == "__main__": sync()