diff --git a/automate.py b/automate.py index 612991e..93443eb 100644 --- a/automate.py +++ b/automate.py @@ -72,6 +72,7 @@ THUMB_LOAD_WAIT = 1.0 # 每轮截图前等待缩略图从"灰占位"加 # 空白列纯色 std≈0。 CELL_STD_WIN = (200, 120) # 判断格子有无卡片的采样窗口(取卡片主体,避开左下角蓝勾角标) CELL_STD_LOW = 12 # 窗口灰度 std >= 判为有卡片,< 判为空列(无卡片,跳过不点) +CELL_BRIGHT_HIGH = 220 # 窗口平均亮度 < 此值也判为有卡:灰色未加载占位卡(实测≈143)比纯白空隙(255)暗 # 滚动真实位移测量参数(用像素行剖面互相关,替代不可靠的 detect_card_rows 中位数)。 # detect_card_rows 每次检出的行集合、缩略图加载状态都在变,中位数乱跳,会把"滚了几行" @@ -83,6 +84,11 @@ MIN_SCROLL_PX = 80 # 单次滚动真实位移 < 此值才视为"没滚 # 提前退出→底部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 卡死时) @@ -400,8 +406,12 @@ def measure_scroll_shift(before_png, after_png): def cell_has_card(png_path, gx, cy): """判断 (gx,cy) 这个格子处是否真有一张卡片缩略图(区分稀疏行里的空列)。 - 卡片缩略图有画面纹理 → 局部窗口灰度 std 高;空列是纯色背景 → std≈0。 - 见 CELL_STD_WIN/CELL_STD_LOW 说明。出错时保守返回 True(宁可点也不漏下载)。""" + 两条判据并联,满足其一即认为有卡: + ① 有画面纹理 → 局部窗口灰度 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: @@ -412,7 +422,8 @@ def cell_has_card(png_path, gx, cy): 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 + std = math.sqrt(var) + return std >= CELL_STD_LOW or mean < CELL_BRIGHT_HIGH except Exception: return True @@ -790,88 +801,119 @@ def tap_all_thumbnails(baseline, max_rounds=300, poll_interval=4, max_wait_per_r print(f"→ 开始批量下载循环(最多 {max_rounds} 轮)") dialog_dismissed = False + total_tapped = 0 # 本趟(一次完整 top→bottom)累计点击的未打勾卡片数,供多趟循环判完整 def clear_current_screen(round_no): - """把当前屏所有"真卡片且未下载"的格子分批(每批≤9 防超并发)点击并等打勾, - 直到本屏没有未下载卡片(或达批次上限)。返回 (本屏点击总数, 是否检出过卡片行)。 + """把当前屏所有"真卡片且未下载"的格子下完,直到整屏全部打勾才允许滚动。 + 返回 (本屏点击总数, 是否检出过卡片行)。 - 【本次重构的核心】点击与滚动必须解耦:旧版每轮只点 3 个(≈1 行)却往下滚 - 1200px(≈6 行),中间 5 行整片漏掉(实测昨日 100+ 段只下 53 段)。改为"当前屏 - 全部下完才前进",结构上不可能漏。attempted 记录本屏已点过的格子,避免个别 - 下载慢/失败的卡片一直不打勾被反复重点(重试点击是重复下载的根源,一并去掉)。""" + 【设计要点·串行下载版】 + ① 点击与滚动解耦——整屏未下载卡片不全部打勾就绝不滚动,结构上零漏行。 + ② 水位泵:维持在途下载数≤WATERLINE。App 实为串行下载(一次只真下一个),泵只为 + 填满其队列、消除"整批下完→截图滚动"的空转间隙。在途按"已点但尚未打勾的格子数" + 逐格精确统计。 + ③ 【点完就滚 + 去重兜底】滚动的前置条件是"整屏每张卡都至少点过一次",不要求 + 真下完。最好的退出是整屏全打勾(重叠行都是勾,下屏天然跳过,零重复);若 + max_wait 内一直没有新卡打勾(个别卡慢/失败),就把没点过的强制补点一遍后 + 直接滚动,未下完的靠下游 md5 去重、下次同步再补——绝不为个别慢卡把整屏乃至 + 整个列表卡死(这是"整屏全打勾才滚"卡在第 1 屏的教训)。 + ④ 本屏不滚动,故首帧锁定行中心 locked_rows,之后每轮都按同一批 y 采样打勾, + 不受 detect_card_rows 帧间抖动影响。tapped 记录已点格子、点过不再点(重复点击 + 是重复下载的根源)。""" nonlocal dialog_dismissed - attempted = set() screen_tapped = 0 had_rows = False - for b in range(8): # 一屏可见 6~7 行,8 批足够;防意外死循环 + targets = None # 本屏所有"真卡片"格子 [(gx,cy)];None=尚未成功检出 + locked_rows = None # 首帧锁定的行中心 y(本屏不滚动,后续都按此采样打勾) + tapped = set() # 已点过的格子(点过不再点) + prev_checked = -1 # 上轮已打勾格子数,用于识别停滞 + stall = 0 # 距上次有新格子打勾的秒数 + while True: ensure_not_anr() - # 截图前等一下,让"灰色占位"缩略图加载出真实画面——否则纯色占位 std≈0 - # 会被 detect_card_rows 当成间隙,导致整行漏检(见 THUMB_LOAD_WAIT 说明)。 + # 截图前等缩略图从灰占位加载出真实画面,否则 std≈0 被当间隙漏检。 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") + # 首次成功检出时锁定本屏行中心与全部真卡片格子(含已打勾的)。 + if targets is None: rows = detect_card_rows(shot) - if not rows: + # 检测不到行时重试:缩略图慢/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 - 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},无待下载") + # 有新卡片打勾 → 下载在推进,重置停滞计时。 + 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 - batch = todo[:3] # 每批≤3:CB60 是 4G 电池机,回放下载串行且慢, - # 一次点太多(曾用9)会把下载队列压垮→大量 0 字节失败, - # 还会让 App 卡死触发 uiautomator dump 挂起。实测 3 个稳妥。 - baseline_checked = count_checked_badges(shot) - for (gx, cy) in batch: + # 在途 = 已点但还没打勾;未点 = 还没点过的待下载格子。 + 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) - 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 - cur = baseline_checked - while waited < max_wait_per_round: - time.sleep(poll_interval) - waited += poll_interval - ensure_not_anr() - cur = count_checked_badges(screenshot("/tmp/_waitchk.png")) - if cur >= target: - break - got = cur - baseline_checked - if got < len(batch): - print(f" 轮 {round_no} 批{b+1}: 等 {waited}s 后 {got}/{len(batch)} 打勾,未完成的靠去重兜底,继续") - else: - print(f" 轮 {round_no} 批{b+1}: {len(batch)} 个全部打勾({waited}s)") + 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 # 中位数不可靠,会把"滚了几行"误判成"没动"→二次滚动→滚过头漏行,这是中段 @@ -889,15 +931,16 @@ def tap_all_thumbnails(baseline, max_rounds=300, poll_interval=4, max_wait_per_r shift, sad = measure_scroll_shift(before_scroll, after_scroll) if shift < MIN_SCROLL_PX: - # 疑似到底。但本列表在下载队列堵/卡顿时单次滚动手势常失效或只滚一点点 - # (实测首次位移 8~176px 都出现过),不能凭一两次小位移就判到底——那样会把 - # 06:00~00:00 一整段没滚到就误判结束(实测轮15二次滚了156px其实还在滚,却因 - # 旧阈值250被当到底提前退出)。改为连续【3 次】重试滚动位移都<阈值才确认真到底; - # 其间任一次滚动生效(>=阈值)就跳出重试、继续正常循环。 + # 疑似到底。但本列表【分页懒加载】:滚到已加载页末尾时,下一页(如 06:4x→00:00) + # 还要经 3G 现拉,那一刻滑不动、位移=0,并非真到底(实测轮17就是这样把整段 + # 06:4x→00:00 切掉漏下)。也可能是队列堵/卡顿导致单次手势只滚一点点。 + # 改法A·加耐心:连续【6 次】重试、每次滑动前多等 7 秒给下一页加载时间, + # 全部 <阈值 才确认真到底;其间任一次滚动生效(>=阈值)就跳出、继续正常循环。 + # 真到底只多花几十秒(每次跑一次),却能救回被误切的底部整段。 confirmed_bottom = True shifts = [shift] - for retry in range(3): - time.sleep(2) + 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) @@ -908,13 +951,19 @@ def tap_all_thumbnails(baseline, max_rounds=300, poll_interval=4, max_wait_per_r print(f" 轮 {r+1}: 滚动未生效(位移{shift}px),第{retry+1}次重试已推进({s2}px),继续") confirmed_bottom = False break - print(f" 轮 {r+1}: 到底确认中,第{retry+1}/3次重试位移{s2}px(<{MIN_SCROLL_PX})") + print(f" 轮 {r+1}: 到底确认中,第{retry+1}/6次重试位移{s2}px(<{MIN_SCROLL_PX})") if confirmed_bottom: - # 到底确认后,那 3 次重试滚动虽位移<阈值,但累积的微小移动会把最底 + # 到底确认后,那 6 次重试滚动虽位移<阈值,但累积的微小移动会把最底 # 一两行完整露出来(实测到底后 06:19~06:30 两行已显示却从未被点、漏下)—— # 这最终画面从没被 clear 过。break 前对最终屏补扫一次,确保最后几行不漏。 print(f" 轮 {r+1}: 连续多次强力滚动均滚不动{shifts},已到底,补扫最终屏") - clear_current_screen(r + 1) + 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 @@ -929,7 +978,63 @@ def tap_all_thumbnails(baseline, max_rounds=300, poll_interval=4, max_wait_per_r print(f" 轮 {r+1}: 本屏已清空(点击 {tapped} 个),滚动 {shift}px(≈{shift/204:.1f}行),继续") - print("→ 批量下载循环结束") + 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): @@ -1056,7 +1161,7 @@ def sync(): baseline = get_baseline_files() print(f"→ 基准文件数: {len(baseline)}") navigate_to_yesterday_batch() - tap_all_thumbnails(baseline) # 默认 max_rounds=300,跑到滚到底为止 + tap_all_thumbnails(baseline) wait_for_downloads(baseline) all_files = get_all_remote_files() @@ -1102,8 +1207,9 @@ def sync_recent_days(n_days=7): launch_app() clear_device_recordings() # 下载前清空,保证有空间、且设备只剩这天的 navigate_to_yesterday_batch(days_back=d) - tap_all_thumbnails(set()) - wait_for_downloads(set()) + # 多趟循环下载:反复回顶重扫补下漏点卡,直到某趟零点击=完整(或达上限兜底)。 + # 内部已含趟间 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)