fix: 批量下载可靠性修复(点击滚动解耦+动态行检测+边缘行/底部漏下+收尾崩溃)

- clear_current_screen 点击与滚动解耦:当前屏全部下完才前进,结构上不漏
- detect_card_rows 动态行检测 + navigate/screenshot/tap_until_activity 稳定性修复
- 滚动步长 1900→1000 缩为 1800→1150,加大重叠,修中段边缘行漏
- 到底判定 MIN_SCROLL_PX 250→80 + 连续3次强力重试,修底部一整段误判到底漏下
- wait_for_downloads 入口归一 set,修 set-list TypeError 收尾崩溃

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yuming
2026-09-20 21:50:24 +08:00
parent 8a934c8813
commit 09246db013
+270 -210
View File
@@ -17,8 +17,9 @@ PKG = "com.videogo"
# 屏幕坐标(Pixel 6 模拟器 1080x2400,2026-07-24 App 更新到 7.8.1 后重新实测校准) # 屏幕坐标(Pixel 6 模拟器 1080x2400,2026-07-24 App 更新到 7.8.1 后重新实测校准)
COORD = { COORD = {
"home_camera_card": (540, 500), # 首页摄像头卡片 "home_camera_card": (540, 500), # 首页摄像头卡片
"live_video_btn": (270, 1340), # 摄像头详情页-实时视频按钮 "live_video_btn": (390, 1362), # 设备主页-实时视频按钮
"record_tab": (540, 2290), # 底部录像 Tab "record_tab": (600, 2256), # 底部录像 Tab(实时视频|录像|消息 三 Tab 中"录像"偏右位置,
# 2026-09-19 实测:旧坐标 540,2200 会点进两 Tab 间空隙,导致停留实时视频页)
"prev_day_arrow": (77, 1124), # < 上一天箭头 "prev_day_arrow": (77, 1124), # < 上一天箭头
"next_day_arrow": (370, 1124), # > 下一天箭头(用于归位到今日) "next_day_arrow": (370, 1124), # > 下一天箭头(用于归位到今日)
"download_btn": (406, 935), # 录像页工具栏-下载按钮 "download_btn": (406, 935), # 录像页工具栏-下载按钮
@@ -63,10 +64,35 @@ ROW_PITCH = 204 # 相邻卡片行的竖直间距(用于粘连带
ROW_EDGE_MARGIN = 12 # 纹理带触及扫描区上/下边缘视为被截断的半行,丢弃 ROW_EDGE_MARGIN = 12 # 纹理带触及扫描区上/下边缘视为被截断的半行,丢弃
THUMB_LOAD_WAIT = 1.0 # 每轮截图前等待缩略图从"灰占位"加载出真实画面,避免假间隙漏检 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): # 滚动真实位移测量参数(用像素行剖面互相关,替代不可靠的 detect_card_rows 中位数)。
return subprocess.run([ADB, "-s", DEVICE] + list(args), # detect_card_rows 每次检出的行集合、缩略图加载状态都在变,中位数乱跳,会把"滚了几行"
capture_output=True, text=True) # 误判成"没动"→触发多余的二次滚动→滚过头,中段整片漏下(实测屏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): 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+)\]"', r'text="' + re.escape(target_text) + r'"[^<>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
) )
for i in range(retries): 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") adb("pull", "/sdcard/uib.xml", "/tmp/uib.xml")
try: try:
with open("/tmp/uib.xml") as f: 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): def tap_until_activity(x, y, expected_activity_keyword, max_tries=6, after_tap_wait=6.0):
"""点击 (x,y),通过 dumpsys 检查 Activity 是否切换到含 keyword 的页面。 """点击 (x,y),通过 dumpsys 检查 Activity 是否切换到含 keyword 的页面。
每次尝试前都检查 ANR,存在则先点 Wait。间隔放宽到 6s 给模拟器+萤石喘息时间。 每次尝试前都检查 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): for trial in range(max_tries):
dismiss_anr_if_any() dismiss_anr_if_any()
adb("shell", "input", "tap", str(x), str(y)) adb("shell", "input", "tap", str(x), str(y))
time.sleep(after_tap_wait) time.sleep(after_tap_wait)
cur = current_activity() 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}'") print(f" ✓ tap ({x},{y}) 后进入 '{cur}'")
return True 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) # 失败时给 App 更长的喘息时间(避免连环 ANR)
time.sleep(5) time.sleep(5)
raise RuntimeError( raise RuntimeError(
@@ -215,9 +245,17 @@ def sample_pixel_rgb(x, y, png_path="/tmp/_pixchk.png"):
def screenshot(path="/tmp/ezviz_screen.png"): def screenshot(path="/tmp/ezviz_screen.png"):
# 用 exec-out 直接把 PNG 输出到本地,省掉"先写 /sdcard 再 pull"的两步往返, # 用 exec-out 直接把 PNG 输出到本地,省掉"先写 /sdcard 再 pull"的两步往返,
# 每次截图快约 0.5~1s(批量下载循环每轮要截多次图,累计提速明显)。 # 每次截图快约 0.5~1s(批量下载循环每轮要截多次图,累计提速明显)。
with open(path, "wb") as f: # 关键:加超时。App/模拟器卡住时 screencap 会永久挂起,而截图是循环里调用
subprocess.run([ADB, "-s", DEVICE, "exec-out", "screencap", "-p"], # 最频繁的命令,无超时就会把整个脚本死锁(曾多次踩坑)。超时则重试一次。
stdout=f) 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 return path
@@ -334,6 +372,51 @@ def detect_card_rows(png_path):
return sorted(rows) 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): def get_checked_cells_dynamic(png_path, row_centers):
"""对动态检测出的每一行 × 固定 3 列,判断是否已打勾(下载完成)。 """对动态检测出的每一行 × 固定 3 列,判断是否已打勾(下载完成)。
返回 {(gx, cy): True/False},key 的 cy 是动态行中心。判据同 count_blue_pixels。 返回 {(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 撞车)。 Tab(萤石/对话/商城/社区/我的,"商城"中心 x=540 跟录像 Tab 撞车)。
""" """
print("→ 点击首页摄像头卡片(用 Activity 切换验证,避免频繁 dump 触发 ANR)") print("→ 点击首页摄像头卡片(用 Activity 切换验证,避免频繁 dump 触发 ANR)")
# 详情页是 RN 实现的 EZReactBizActivity tap_until_activity(*COORD["home_camera_card"],
tap_until_activity(*COORD["home_camera_card"], expected_activity_keyword="EZReactBizActivity") expected_activity_keyword=("EZReactBizActivity", "VideoPlayActivityNewVersion"))
print("→ 点击实时视频(详情页有文字,可以 dump 定位)") print("→ 点击实时视频按钮(设备主页)")
tap_text("实时视频", after_delay=5) tap(*COORD["live_video_btn"], delay=8) # 等实时视频页加载
time.sleep(3)
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 文字。 # 实时视频页全自绘,dump 不到底部 Tab 文字。
# 但当前 Activity = VideoPlayActivityNewVersion,跟首页 MainTabActivity 不同。 # 但当前 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 不再空滚。 实测:底部 6 个漏下卡片被清扫补下(相册 +6),到底判定位移=0 正确 break 不再空滚。
""" """
print("→ 先滚动到列表顶部(起始滚动位置继承自上次播放位置,不一定是最新记录)") print("→ 先滚动到列表顶部(起始滚动位置继承自上次播放位置,不一定是最新记录)")
# 第十二次教训:批量下载列表是【懒加载】——快速上滑时"更新的傍晚/夜间片段"还没
# 从网络拉进来,相邻两帧画面会暂时一样。旧逻辑"一次哈希不变就判到顶"会在半途
# (如 18 点)误判到顶,把它上面一整段最新录像漏在操作起点之上(实测漏掉当天
# 18:30–22:33 整段傍晚/夜间录像,用户在回放列表里看到一大片没打勾)。
# 修复:①每次上滑后多等一会儿(2s)让懒加载补齐更新片段;②要求【连续 2 次】画面
# 都无变化才确认真到顶(抗单次懒加载迟到的误判);③上限从 15 提到 40,避免从
# "上次播放位置"到当天最新之间列表过长时半途停下。
top_hash = None top_hash = None
for i in range(15): stable = 0
for i in range(40):
adb("shell", "input", "swipe", "540", "1150", "540", "2350", "600") adb("shell", "input", "swipe", "540", "1150", "540", "2350", "600")
time.sleep(1.3) time.sleep(2.0) # 等懒加载把更新的片段拉进来,避免"新内容没到→哈希暂时不变→误判到顶"
cur_hash = screenshot_hash() cur_hash = screenshot_hash()
if cur_hash == top_hash: if cur_hash == top_hash:
print(f" 已到顶部(第 {i+1} 次滑动后画面不再变化)") stable += 1
break if stable >= 2:
print(f" 已到顶部(第 {i+1} 次滑动,连续 2 次画面不再变化)")
break
else:
stable = 0
top_hash = cur_hash top_hash = cur_hash
else: else:
print(" ⚠ 滑动 15 次仍未稳定,按当前位置继续(可能列表过长)") print(" ⚠ 滑动 40 次仍未稳定,按当前位置继续(可能列表过长)")
print(f"→ 开始批量下载循环(最多 {max_rounds} 轮)") print(f"→ 开始批量下载循环(最多 {max_rounds} 轮)")
dialog_dismissed = False dialog_dismissed = False
for r in range(max_rounds): def clear_current_screen(round_no):
# 点击前先确认 App 没有卡死:卡死时截图读到的是冻结前的旧画面, """把当前屏所有"真卡片且未下载"的格子分批(每批≤9 防超并发)点击并等打勾,
# 打勾识别会失真,是"重复下载"的真正根因(见 ensure_not_anr 文档字符串)。 直到本屏没有未下载卡片(或达批次上限)。返回 (本屏点击总数, 是否检出过卡片行)。
ensure_not_anr()
# 截图前等一下,让"灰色占位"缩略图加载出真实画面——否则纯色占位 std≈0 【本次重构的核心】点击与滚动必须解耦:旧版每轮只点 3 个(≈1 行)却往下滚
# 会被 detect_card_rows 当成间隙,导致整行漏检(见 THUMB_LOAD_WAIT 说明)。 1200px(≈6 行),中间 5 行整片漏掉(实测昨日 100+ 段只下 53 段)。改为"当前屏
time.sleep(THUMB_LOAD_WAIT) 全部下完才前进",结构上不可能漏。attempted 记录本屏已点过的格子,避免个别
下载慢/失败的卡片一直不打勾被反复重点(重试点击是重复下载的根源,一并去掉)。"""
# 每屏动态实测卡片行中心 y(消除整点分组标签平移造成的错位,这是"重复下载" nonlocal dialog_dismissed
# 的总根源),再对每行 × 固定 3 列逐格判打勾,只点还没下载完的。 attempted = set()
grid_shot = screenshot("/tmp/_gridchk.png") screen_tapped = 0
full_rows = detect_card_rows(grid_shot) had_rows = False
# 检测不到行时重试最多3次:缩略图加载慢、ANR刚恢复、画面切换中都会导致 for b in range(8): # 一屏可见 6~7 行,8 批足够;防意外死循环
# 临时检测不到行。之前只重试1次就放弃,会误判"全部已打勾"直接滚动, ensure_not_anr()
# 漏掉当前屏所有卡片(这是漏下载的重要原因之一)。 # 截图前等一下,让"灰色占位"缩略图加载出真实画面——否则纯色占位 std≈0
retry = 0 # 会被 detect_card_rows 当成间隙,导致整行漏检(见 THUMB_LOAD_WAIT 说明)。
while not full_rows and retry < 3: time.sleep(THUMB_LOAD_WAIT)
retry += 1 shot = screenshot("/tmp/_gridchk.png")
time.sleep(2.0 + retry * 1.0) # 逐次加长等待,给缩略图加载留时间 rows = detect_card_rows(shot)
grid_shot = screenshot("/tmp/_gridchk.png") # 检测不到行时重试:缩略图加载慢、ANR 刚恢复、画面切换中都会临时检测不到。
full_rows = detect_card_rows(grid_shot) retry = 0
if not full_rows: while not rows and retry < 3:
print(f" 轮 {r+1}: ⚠ 连续 {retry+1} 次未检出卡片行,跳过本轮滚动(不盲点)") retry += 1
continue time.sleep(2.0 + retry)
# 每轮最多处理最上面 3 行(9 格)。萤石同时下载上限约 10 个,一屏可见 5~6 行, shot = screenshot("/tmp/_gridchk.png")
# 若把检出的所有行都点了(12~14 个)会超并发:多点的卡片不进下载队列、滚上来 rows = detect_card_rows(shot)
# 仍未打勾,被反复重点,实测会让"跳过已打勾"退化成 0、重复率回升。多出的行 if not rows:
# 下一轮滚上来再处理(滚动量≈2 行,正好衔接),不会漏。full_rows 保留全部行, break
# 供到底判定和到底清扫复用(省一次滚动前的重复截图+检测)。 had_rows = True
row_centers = full_rows # 处理所有检测到的行,下面按未打勾数截断到9个 # 每屏动态实测卡片行中心 y(消除整点分组标签平移错位),逐格判打勾。
checked = get_checked_cells_dynamic(grid_shot, row_centers) 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)))
# 跳过已经打勾(下载完成)的卡片。滚动留的 1~2 行重叠滚上来时必然已是打勾 # 稀疏布局下同一行不一定 3 列都有卡片,必须用 cell_has_card 过滤掉空列,
# 状态(本轮末尾"等全部点击的卡片打勾再滚动"保证),所以重叠行天然被跳过, # 否则点空→永远不打勾→等打勾白熬满超时。
# 不会被当成新卡片重复触发下载——现在行中心是动态对准的,这个跳过才可靠。 empty_skipped = sum(1 for cy in rows for gx in THUMB_X
badge_skipped = sum(1 for cy in row_centers for gx in THUMB_X if checked.get((gx, cy))) if not chk.get((gx, cy)) and not cell_has_card(shot, gx, cy))
# 收集所有未打勾格子(从上到下),最多9个避免超并发 # 收集"真卡片 且 未打勾 且 本屏还没点过"的格子(从上到下)。
todo_cells = [(gx, cy) for cy in row_centers for gx in THUMB_X if not checked.get((gx, cy))] todo = [(gx, cy) for cy in rows for gx in THUMB_X
tapped_cells = todo_cells[:3] # 每轮只点3个,避免队列堆积 if not chk.get((gx, cy)) and cell_has_card(shot, gx, cy)
for (gx, cy) in tapped_cells: and (gx, cy) not in attempted]
adb("shell", "input", "tap", str(gx), str(cy)) if not todo:
time.sleep(0.5) if b == 0:
if not dialog_dismissed and dismiss_dialog_if_any(): print(f" 轮 {round_no}: 本屏 {len(rows)} 行,已打勾 {badge_skipped},空列 {empty_skipped},无待下载")
dialog_dismissed = True 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)) adb("shell", "input", "tap", str(gx), str(cy))
time.sleep(0.5) time.sleep(0.5)
# 首次点击会弹"使用流量下载"确认框,点掉后补点一次该卡片。
print(f" 轮 {r+1}: 检出 {len(row_centers)} 行,跳过已打勾 {badge_skipped} 个,点击待下载 {len(tapped_cells)} 个") if not dialog_dismissed and dismiss_dialog_if_any():
dialog_dismissed = True
if tapped_cells: adb("shell", "input", "tap", str(gx), str(cy))
# 关键:等这一轮点击的卡片全部显示打勾(下载完成)再滚动。这样滚动 time.sleep(0.5)
# 留下的重叠行必然是打勾状态,下一轮开头 get_checked_cells 会跳过它们, attempted.add((gx, cy))
# 从根上消除"重叠行还没下完就被重复点击"这个唯一的重复来源。 screen_tapped += len(batch)
baseline_checked = count_checked_badges(grid_shot) print(f" 轮 {round_no} 批{b+1}: 检出 {len(rows)} 行,跳过已打勾 {badge_skipped},空列 {empty_skipped},点击 {len(batch)} 个")
target_checked = baseline_checked + len(tapped_cells) # 等这批全部打勾(下载完成)再进入下一批/滚动。整屏打勾数达到"基线+本批数"
print(f" 轮 {r+1}: 等待刚点击的 {len(tapped_cells)} 个卡片全部打勾(基线 {baseline_checked},目标 {target_checked})...") # 即完成。不再重试点击(重试是重复下载的根源),超时未完成的靠本地/下游去重。
target = baseline_checked + len(batch)
waited = 0 waited = 0
done = 0 cur = baseline_checked
while waited < max_wait_per_round: while waited < max_wait_per_round:
time.sleep(poll_interval) time.sleep(poll_interval)
waited += poll_interval waited += poll_interval
ensure_not_anr() ensure_not_anr()
wait_shot = screenshot("/tmp/_waitchk.png") cur = count_checked_badges(screenshot("/tmp/_waitchk.png"))
current_checked = count_checked_badges(wait_shot) if cur >= target:
done = current_checked - baseline_checked
if done >= len(tapped_cells):
break break
pending = len(tapped_cells) - done got = cur - baseline_checked
total = get_remote_new_file_count(baseline) if got < len(batch):
if pending: print(f" 轮 {round_no} 批{b+1}: 等 {waited}s 后 {got}/{len(batch)} 打勾,未完成的靠去重兜底,继续")
# 第二轮等待:不重试点击,让正在下载的完成
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}: 全部打勾完成")
else: else:
print(f" 轮 {r+1}: {len(tapped_cells)} 个全部打勾完成(等待 {waited}s),累计新下载 {total} 个,开始滚动") print(f" 轮 {round_no} 批{b+1}: {len(batch)} 个全部打勾({waited}s)")
else: return screen_tapped, had_rows
print(f" 轮 {r+1}: 这一屏全部已打勾,无需等待,直接滚动")
# 滚动前的行位置直接复用本轮开头检出的 full_rows(点击+等打勾期间列表没滚动, for r in range(max_rounds):
# 行位置不变),省掉一次滚动前的重复截图+检测。 # 先把当前屏所有未下载卡片全部下完(点击与滚动解耦,见 clear_current_screen)。
# 到底检测不能用整屏像素哈希:列表底部有未下载的"灰色占位图",滚动时会 tapped, had_rows = clear_current_screen(r + 1)
# 微微重渲染/回弹,像素哈希每次都不同,永远判不出"滚不动",导致一直空滚
# (实测跑到 216 轮还在底部空转)。改用"卡片行有没有整体平移":真滚动了行会
# 整体位移几百像素,滚不动(到底)时行位置几乎不变。
pre_rows = full_rows
# 向下滚动 2 行(约 406 像素,保留 1 行重叠避免漏跳) # 滚动 + 到底判定。用像素行剖面互相关精确测真实滚动位移(detect_card_rows
adb("shell", "input", "swipe", "540", "1900", "540", "700", "500") # 中位数不可靠,会把"滚了几行"误判成"没动"→二次滚动→滚过头漏行,这是中段
time.sleep(1.0) # 整片漏下的根因)。
# 第十三次教训·滑多了漏边缘行:滚动手势原为 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") if shift < MIN_SCROLL_PX:
post_rows = detect_card_rows(post_shot) # 疑似到底。但本列表在下载队列堵/卡顿时单次滚动手势常失效或只滚一点点
# (实测首次位移 8~176px 都出现过),不能凭一两次小位移就判到底——那样会把
def _median(vals): # 06:00~00:00 一整段没滚到就误判结束(实测轮15二次滚了156px其实还在滚,却因
return sorted(vals)[len(vals) // 2] if vals else -1 # 旧阈值250被当到底提前退出)。改为连续【3 次】重试滚动位移都<阈值才确认真到底;
def _moved(a, b): # 其间任一次滚动生效(>=阈值)就跳出重试、继续正常循环。
return bool(a and b and abs(_median(a) - _median(b)) >= 40) confirmed_bottom = True
shifts = [shift]
if not _moved(pre_rows, post_rows): for retry in range(3):
# 疑似到底,但一次没滚动也可能是偶发滚动手势失效 / 下载中大文件卡顿吸收了 time.sleep(2)
# 手势(实测 26、27 号就因单次误判、提前结束、漏下几百个)。再滚一次确认: b2 = screenshot("/tmp/_prescroll2.png")
# 只有连续两次都没推进才判真到底。 adb("shell", "input", "swipe", "540", "1800", "540", "1150", "500")
time.sleep(2) time.sleep(2.0)
adb("shell", "input", "swipe", "540", "1900", "540", "700", "500") a2 = screenshot("/tmp/_postscroll2.png")
time.sleep(1.5) s2, _ = measure_scroll_shift(b2, a2)
post2_rows = detect_card_rows(screenshot("/tmp/_postscroll2.png")) shifts.append(s2)
if _moved(pre_rows, post2_rows): if s2 >= MIN_SCROLL_PX:
print(f" 轮 {r+1}: 首次滚动疑似卡顿未生效,二次滚动已推进,继续") print(f" 轮 {r+1}: 滚动未生效(位移{shift}px),第{retry+1}次重试已推进({s2}px),继续")
continue confirmed_bottom = False
# 连续两次滚动行位置都未变,确认到底。此时当前屏可能还有被"每轮只处理前
# 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} 次未检出卡片行,视为底部已无内容")
break break
schk = get_checked_cells_dynamic(sshot, srows) print(f" 轮 {r+1}: 到底确认中,第{retry+1}/3次重试位移{s2}px(<{MIN_SCROLL_PX})")
todo = [(gx, cy) for cy in srows for gx in THUMB_X if not schk.get((gx, cy))] if confirmed_bottom:
if not todo: print(f" 轮 {r+1}: 连续多次强力滚动均滚不动{shifts},确认到底,结束")
print(" → 底部已无未下载卡片,清扫完成") break
break continue
batch = todo[:9]
print(f" → 清扫第 {sweep+1} 批:点击 {len(batch)} 个未下载卡片") if shift > MAX_SCROLL_PX:
for (gx, cy) in batch: # 滚过头,重叠不足可能漏被截断的半行,向上回滚补偿(回滚到已下载区是安全的:
adb("shell", "input", "tap", str(gx), str(cy)) # 多回=多重叠,重叠行已打勾会被跳过,不重复也不漏)。
time.sleep(0.5) back = shift - 600 # 回滚到净推进约 600px(≈3行) 的位置,与新步长一致
if not dialog_dismissed and dismiss_dialog_if_any(): print(f" 轮 {r+1}: 滚动过头({shift}px),向上回滚约{back}px补偿")
dialog_dismissed = True adb("shell", "input", "swipe", "540", "1000", "540", str(1000 + int(back)), "500")
adb("shell", "input", "tap", str(gx), str(cy)) time.sleep(2.0)
time.sleep(0.5)
# 等这批全部打勾(同主循环机制) print(f" 轮 {r+1}: 本屏已清空(点击 {tapped} 个),滚动 {shift}px(≈{shift/204:.1f}行),继续")
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("→ 批量下载循环结束") print("→ 批量下载循环结束")
@@ -907,6 +932,9 @@ def wait_for_downloads(baseline_files, timeout=600):
文件名使用下载时刻时间戳,固定存到今天的目录。 文件名使用下载时刻时间戳,固定存到今天的目录。
baseline_files 为下载前已有的文件集合,用于计算新增数量。 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") today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}" remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
print(f"→ 等待下载完成(目录 {today},起始已有 {len(baseline_files)} 个)...") print(f"→ 等待下载完成(目录 {today},起始已有 {len(baseline_files)} 个)...")
@@ -938,6 +966,33 @@ def wait_for_downloads(baseline_files, timeout=600):
return list(all_files - baseline_files) 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): def pull_files(remote_files, date_str=None):
"""把下载好的文件 adb pull 到本地。 """把下载好的文件 adb pull 到本地。
date_str 为录像日期(YYYYMMDD),归档到 recordings/BH5455783/{date_str}/。 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)) adb("pull", remote_path, str(local_path))
print(f" 已拉取: {fname}") print(f" 已拉取: {fname}")
# 拉取完成后做一次内容去重,清掉二次下载产生的重复副本(文件名去重拦不住的那种)
_dedup_local_dir(local_dir)
print(f"→ 完成,文件保存在 {local_dir}") print(f"→ 完成,文件保存在 {local_dir}")
@@ -980,7 +1037,10 @@ def get_all_remote_files():
""" """
today = datetime.now().strftime("%Y%m%d") today = datetime.now().strftime("%Y%m%d")
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}" 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) return set(l for l in result.stdout.strip().splitlines() if l)