diff --git a/automate.py b/automate.py index 316870c..bb2fa43 100644 --- a/automate.py +++ b/automate.py @@ -17,8 +17,9 @@ 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 + "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), # 录像页工具栏-下载按钮 @@ -63,10 +64,35 @@ 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 >= 判为有卡片,< 判为空列(无卡片,跳过不点) -def adb(*args): - return subprocess.run([ADB, "-s", DEVICE] + list(args), - capture_output=True, text=True) +# 滚动真实位移测量参数(用像素行剖面互相关,替代不可靠的 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 # 单次滚动真实位移 > 此值视为滚过头,向上回滚补偿(保险,实测罕见) + + +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): @@ -85,7 +111,7 @@ def find_by_text(target_text, retries=4, delay=2.0): 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("shell", "uiautomator", "dump", "/sdcard/uib.xml", timeout=15) adb("pull", "/sdcard/uib.xml", "/tmp/uib.xml") try: with open("/tmp/uib.xml") as f: @@ -164,16 +190,20 @@ def ensure_not_anr(max_wait=90): 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 expected_activity_keyword in cur: + 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}' 不含 '{expected_activity_keyword}',重试 {trial+1}/{max_tries}") + print(f" ! tap ({x},{y}) 后 Activity='{cur}' 不含 {kws},重试 {trial+1}/{max_tries}") # 失败时给 App 更长的喘息时间(避免连环 ANR) time.sleep(5) raise RuntimeError( @@ -215,9 +245,17 @@ def sample_pixel_rgb(x, y, png_path="/tmp/_pixchk.png"): 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) + # 关键:加超时。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 @@ -334,6 +372,51 @@ def detect_card_rows(png_path): 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 高;空列是纯色背景 → std≈0。 + 见 CELL_STD_WIN/CELL_STD_LOW 说明。出错时保守返回 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) + return math.sqrt(var) >= CELL_STD_LOW + except Exception: + return True + + def get_checked_cells_dynamic(png_path, row_centers): """对动态检测出的每一行 × 固定 3 列,判断是否已打勾(下载完成)。 返回 {(gx, cy): True/False},key 的 cy 是动态行中心。判据同 count_blue_pixels。 @@ -463,24 +546,12 @@ def navigate_to_yesterday_batch(days_back=1): Tab(萤石/对话/商城/社区/我的,"商城"中心 x=540 跟录像 Tab 撞车)。 """ print("→ 点击首页摄像头卡片(用 Activity 切换验证,避免频繁 dump 触发 ANR)") - # 详情页是 RN 实现的 EZReactBizActivity - tap_until_activity(*COORD["home_camera_card"], expected_activity_keyword="EZReactBizActivity") + tap_until_activity(*COORD["home_camera_card"], + expected_activity_keyword=("EZReactBizActivity", "VideoPlayActivityNewVersion")) - 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) + print("→ 点击实时视频按钮(设备主页)") + tap(*COORD["live_video_btn"], delay=8) # 等实时视频页加载 + time.sleep(3) # 实时视频页全自绘,dump 不到底部 Tab 文字。 # 但当前 Activity = VideoPlayActivityNewVersion,跟首页 MainTabActivity 不同。 @@ -693,211 +764,165 @@ def tap_all_thumbnails(baseline, max_rounds=300, poll_interval=4, max_wait_per_r 实测:底部 6 个漏下卡片被清扫补下(相册 +6),到底判定位移=0 正确 break 不再空滚。 """ print("→ 先滚动到列表顶部(起始滚动位置继承自上次播放位置,不一定是最新记录)") + # 第十二次教训:批量下载列表是【懒加载】——快速上滑时"更新的傍晚/夜间片段"还没 + # 从网络拉进来,相邻两帧画面会暂时一样。旧逻辑"一次哈希不变就判到顶"会在半途 + # (如 18 点)误判到顶,把它上面一整段最新录像漏在操作起点之上(实测漏掉当天 + # 18:30–22:33 整段傍晚/夜间录像,用户在回放列表里看到一大片没打勾)。 + # 修复:①每次上滑后多等一会儿(2s)让懒加载补齐更新片段;②要求【连续 2 次】画面 + # 都无变化才确认真到顶(抗单次懒加载迟到的误判);③上限从 15 提到 40,避免从 + # "上次播放位置"到当天最新之间列表过长时半途停下。 top_hash = None - for i in range(15): + stable = 0 + for i in range(40): adb("shell", "input", "swipe", "540", "1150", "540", "2350", "600") - time.sleep(1.3) + time.sleep(2.0) # 等懒加载把更新的片段拉进来,避免"新内容没到→哈希暂时不变→误判到顶" cur_hash = screenshot_hash() if cur_hash == top_hash: - print(f" 已到顶部(第 {i+1} 次滑动后画面不再变化)") - break + stable += 1 + if stable >= 2: + print(f" 已到顶部(第 {i+1} 次滑动,连续 2 次画面不再变化)") + break + else: + stable = 0 top_hash = cur_hash else: - print(" ⚠ 滑动 15 次仍未稳定,按当前位置继续(可能列表过长)") + print(" ⚠ 滑动 40 次仍未稳定,按当前位置继续(可能列表过长)") print(f"→ 开始批量下载循环(最多 {max_rounds} 轮)") dialog_dismissed = False - for r in range(max_rounds): - # 点击前先确认 App 没有卡死:卡死时截图读到的是冻结前的旧画面, - # 打勾识别会失真,是"重复下载"的真正根因(见 ensure_not_anr 文档字符串)。 - ensure_not_anr() + def clear_current_screen(round_no): + """把当前屏所有"真卡片且未下载"的格子分批(每批≤9 防超并发)点击并等打勾, + 直到本屏没有未下载卡片(或达批次上限)。返回 (本屏点击总数, 是否检出过卡片行)。 - # 截图前等一下,让"灰色占位"缩略图加载出真实画面——否则纯色占位 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) - # 检测不到行时重试最多3次:缩略图加载慢、ANR刚恢复、画面切换中都会导致 - # 临时检测不到行。之前只重试1次就放弃,会误判"全部已打勾"直接滚动, - # 漏掉当前屏所有卡片(这是漏下载的重要原因之一)。 - retry = 0 - while not full_rows and retry < 3: - retry += 1 - time.sleep(2.0 + retry * 1.0) # 逐次加长等待,给缩略图加载留时间 - grid_shot = screenshot("/tmp/_gridchk.png") - full_rows = detect_card_rows(grid_shot) - if not full_rows: - print(f" 轮 {r+1}: ⚠ 连续 {retry+1} 次未检出卡片行,跳过本轮滚动(不盲点)") - continue - # 每轮最多处理最上面 3 行(9 格)。萤石同时下载上限约 10 个,一屏可见 5~6 行, - # 若把检出的所有行都点了(12~14 个)会超并发:多点的卡片不进下载队列、滚上来 - # 仍未打勾,被反复重点,实测会让"跳过已打勾"退化成 0、重复率回升。多出的行 - # 下一轮滚上来再处理(滚动量≈2 行,正好衔接),不会漏。full_rows 保留全部行, - # 供到底判定和到底清扫复用(省一次滚动前的重复截图+检测)。 - row_centers = full_rows # 处理所有检测到的行,下面按未打勾数截断到9个 - 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))) - # 收集所有未打勾格子(从上到下),最多9个避免超并发 - todo_cells = [(gx, cy) for cy in row_centers for gx in THUMB_X if not checked.get((gx, cy))] - tapped_cells = todo_cells[:3] # 每轮只点3个,避免队列堆积 - for (gx, cy) in tapped_cells: - adb("shell", "input", "tap", str(gx), str(cy)) - time.sleep(0.5) - if not dialog_dismissed and dismiss_dialog_if_any(): - dialog_dismissed = True + 【本次重构的核心】点击与滚动必须解耦:旧版每轮只点 3 个(≈1 行)却往下滚 + 1200px(≈6 行),中间 5 行整片漏掉(实测昨日 100+ 段只下 53 段)。改为"当前屏 + 全部下完才前进",结构上不可能漏。attempted 记录本屏已点过的格子,避免个别 + 下载慢/失败的卡片一直不打勾被反复重点(重试点击是重复下载的根源,一并去掉)。""" + nonlocal dialog_dismissed + attempted = set() + screen_tapped = 0 + had_rows = False + for b in range(8): # 一屏可见 6~7 行,8 批足够;防意外死循环 + ensure_not_anr() + # 截图前等一下,让"灰色占位"缩略图加载出真实画面——否则纯色占位 std≈0 + # 会被 detect_card_rows 当成间隙,导致整行漏检(见 THUMB_LOAD_WAIT 说明)。 + time.sleep(THUMB_LOAD_WAIT) + shot = screenshot("/tmp/_gridchk.png") + 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 + # 每屏动态实测卡片行中心 y(消除整点分组标签平移错位),逐格判打勾。 + chk = get_checked_cells_dynamic(shot, rows) + badge_skipped = sum(1 for cy in rows for gx in THUMB_X if chk.get((gx, cy))) + # 稀疏布局下同一行不一定 3 列都有卡片,必须用 cell_has_card 过滤掉空列, + # 否则点空→永远不打勾→等打勾白熬满超时。 + empty_skipped = sum(1 for cy in rows for gx in THUMB_X + if not chk.get((gx, cy)) and not cell_has_card(shot, gx, cy)) + # 收集"真卡片 且 未打勾 且 本屏还没点过"的格子(从上到下)。 + todo = [(gx, cy) for cy in rows for gx in THUMB_X + if not chk.get((gx, cy)) and cell_has_card(shot, gx, cy) + and (gx, cy) not in attempted] + if not todo: + if b == 0: + print(f" 轮 {round_no}: 本屏 {len(rows)} 行,已打勾 {badge_skipped},空列 {empty_skipped},无待下载") + break + batch = todo[:3] # 每批≤3:CB60 是 4G 电池机,回放下载串行且慢, + # 一次点太多(曾用9)会把下载队列压垮→大量 0 字节失败, + # 还会让 App 卡死触发 uiautomator dump 挂起。实测 3 个稳妥。 + baseline_checked = count_checked_badges(shot) + for (gx, cy) in batch: 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 会跳过它们, - # 从根上消除"重叠行还没下完就被重复点击"这个唯一的重复来源。 - baseline_checked = count_checked_badges(grid_shot) - target_checked = baseline_checked + len(tapped_cells) - print(f" 轮 {r+1}: 等待刚点击的 {len(tapped_cells)} 个卡片全部打勾(基线 {baseline_checked},目标 {target_checked})...") + # 首次点击会弹"使用流量下载"确认框,点掉后补点一次该卡片。 + if not dialog_dismissed and dismiss_dialog_if_any(): + dialog_dismissed = True + adb("shell", "input", "tap", str(gx), str(cy)) + time.sleep(0.5) + attempted.add((gx, cy)) + screen_tapped += len(batch) + print(f" 轮 {round_no} 批{b+1}: 检出 {len(rows)} 行,跳过已打勾 {badge_skipped},空列 {empty_skipped},点击 {len(batch)} 个") + # 等这批全部打勾(下载完成)再进入下一批/滚动。整屏打勾数达到"基线+本批数" + # 即完成。不再重试点击(重试是重复下载的根源),超时未完成的靠本地/下游去重。 + target = baseline_checked + len(batch) waited = 0 - done = 0 + cur = baseline_checked while waited < max_wait_per_round: time.sleep(poll_interval) waited += poll_interval ensure_not_anr() - wait_shot = screenshot("/tmp/_waitchk.png") - current_checked = count_checked_badges(wait_shot) - done = current_checked - baseline_checked - if done >= len(tapped_cells): + cur = count_checked_badges(screenshot("/tmp/_waitchk.png")) + if cur >= target: break - pending = len(tapped_cells) - done - total = get_remote_new_file_count(baseline) - if pending: - # 第二轮等待:不重试点击,让正在下载的完成 - print(f" 轮 {r+1}: 等待 {waited}s 后 {done}/{len(tapped_cells)} 个已打勾,继续等待...") - wait2_waited = 0 - while wait2_waited < max_wait_per_round: - time.sleep(poll_interval) - wait2_waited += poll_interval - ensure_not_anr() - w2_shot = screenshot("/tmp/_wait2.png") - current2 = count_checked_badges(w2_shot) - done = current2 - baseline_checked - if done >= len(tapped_cells): - break - pending = len(tapped_cells) - done - if pending: - # 两轮等待后仍未完成,重试点击所有未完成的卡片 - print(f" 轮 {r+1}: 再等 {wait2_waited}s 后仍有 {pending} 个未完成,重试点击...") - for (gx, cy) in tapped_cells: - adb("shell", "input", "tap", str(gx), str(cy)) - time.sleep(0.5) - retry_waited = 0 - while retry_waited < max_wait_per_round: - time.sleep(poll_interval) - retry_waited += poll_interval - ensure_not_anr() - retry_shot = screenshot("/tmp/_retrywait.png") - current3 = count_checked_badges(retry_shot) - done = current3 - baseline_checked - if done >= len(tapped_cells): - break - pending = len(tapped_cells) - done - if pending: - print(f" 轮 {r+1}: 三轮等待后仍有 {pending} 个未完成,照常滚动(靠下游去重兜底)") - else: - print(f" 轮 {r+1}: 全部打勾完成") + got = cur - baseline_checked + if got < len(batch): + print(f" 轮 {round_no} 批{b+1}: 等 {waited}s 后 {got}/{len(batch)} 打勾,未完成的靠去重兜底,继续") else: - print(f" 轮 {r+1}: {len(tapped_cells)} 个全部打勾完成(等待 {waited}s),累计新下载 {total} 个,开始滚动") - else: - print(f" 轮 {r+1}: 这一屏全部已打勾,无需等待,直接滚动") + print(f" 轮 {round_no} 批{b+1}: {len(batch)} 个全部打勾({waited}s)") + return screen_tapped, had_rows - # 滚动前的行位置直接复用本轮开头检出的 full_rows(点击+等打勾期间列表没滚动, - # 行位置不变),省掉一次滚动前的重复截图+检测。 - # 到底检测不能用整屏像素哈希:列表底部有未下载的"灰色占位图",滚动时会 - # 微微重渲染/回弹,像素哈希每次都不同,永远判不出"滚不动",导致一直空滚 - # (实测跑到 216 轮还在底部空转)。改用"卡片行有没有整体平移":真滚动了行会 - # 整体位移几百像素,滚不动(到底)时行位置几乎不变。 - pre_rows = full_rows + for r in range(max_rounds): + # 先把当前屏所有未下载卡片全部下完(点击与滚动解耦,见 clear_current_screen)。 + tapped, had_rows = clear_current_screen(r + 1) - # 向下滚动 2 行(约 406 像素,保留 1 行重叠避免漏跳) - adb("shell", "input", "swipe", "540", "1900", "540", "700", "500") - time.sleep(1.0) + # 滚动 + 到底判定。用像素行剖面互相关精确测真实滚动位移(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) - 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 - def _moved(a, b): - return bool(a and b and abs(_median(a) - _median(b)) >= 40) - - if not _moved(pre_rows, post_rows): - # 疑似到底,但一次没滚动也可能是偶发滚动手势失效 / 下载中大文件卡顿吸收了 - # 手势(实测 26、27 号就因单次误判、提前结束、漏下几百个)。再滚一次确认: - # 只有连续两次都没推进才判真到底。 - time.sleep(2) - adb("shell", "input", "swipe", "540", "1900", "540", "700", "500") - time.sleep(1.5) - post2_rows = detect_card_rows(screenshot("/tmp/_postscroll2.png")) - if _moved(pre_rows, post2_rows): - print(f" 轮 {r+1}: 首次滚动疑似卡顿未生效,二次滚动已推进,继续") - continue - # 连续两次滚动行位置都未变,确认到底。此时当前屏可能还有被"每轮只处理前 - # 3 行"限制漏下的底部未下载卡片,清扫:对当前屏所有检出行分批点未下载的、 - # 等打勾,直到当前屏没有未下载卡片,再结束循环。 - 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) - # 清扫时检测不到行要重试:底部缩略图可能还在加载,直接判"无未下载" - # 会提前结束、漏掉底部最后几个卡片。 - sweep_retry = 0 - while not srows and sweep_retry < 3: - sweep_retry += 1 - time.sleep(2.0) - sshot = screenshot("/tmp/_sweep.png") - srows = detect_card_rows(sshot) - if not srows: - print(f" → 清扫第 {sweep+1} 批:连续 {sweep_retry+1} 次未检出卡片行,视为底部已无内容") + if shift < MIN_SCROLL_PX: + # 疑似到底。但本列表在下载队列堵/卡顿时单次滚动手势常失效或只滚一点点 + # (实测首次位移 8~176px 都出现过),不能凭一两次小位移就判到底——那样会把 + # 06:00~00:00 一整段没滚到就误判结束(实测轮15二次滚了156px其实还在滚,却因 + # 旧阈值250被当到底提前退出)。改为连续【3 次】重试滚动位移都<阈值才确认真到底; + # 其间任一次滚动生效(>=阈值)就跳出重试、继续正常循环。 + confirmed_bottom = True + shifts = [shift] + for retry in range(3): + time.sleep(2) + 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 - 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(f" 轮 {r+1}: 到底确认中,第{retry+1}/3次重试位移{s2}px(<{MIN_SCROLL_PX})") + if confirmed_bottom: + print(f" 轮 {r+1}: 连续多次强力滚动均滚不动{shifts},确认到底,结束") + 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("→ 批量下载循环结束") @@ -907,6 +932,9 @@ 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)} 个)...") @@ -938,6 +966,33 @@ def wait_for_downloads(baseline_files, timeout=600): 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}/。 @@ -958,6 +1013,8 @@ def pull_files(remote_files, date_str=None): adb("pull", remote_path, str(local_path)) print(f" 已拉取: {fname}") + # 拉取完成后做一次内容去重,清掉二次下载产生的重复副本(文件名去重拦不住的那种) + _dedup_local_dir(local_dir) print(f"→ 完成,文件保存在 {local_dir}") @@ -980,7 +1037,10 @@ def get_all_remote_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") + # 排除 .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)