fix: 动态定位卡片行根治萤石批量下载重复问题
根因:批量下载列表是自绘 UI,原用写死的网格坐标 THUMB_Y 定位卡片;但列表 每隔一段插入"整点分组标签"行,滚动跨过标签会使所有卡片整行错位(实测漂移 达 174px),导致点击落空、打勾检测采样错位、已下载卡片被误判重下,重复率约 30%。 修复与优化: - 改用亮度标准差(std)垂直剖面每屏动态实测卡片行真实中心,替代写死的 THUMB_Y - 每轮最多点前 3 行(9 格),避免超萤石约 10 个的并发下载上限 - 到底判定改用"卡片行有没有整体平移",抗底部灰占位抖动(原像素哈希会一直空滚) - 到底时清扫当前屏剩余未下载卡片,补下被限 3 行漏掉的底部几个 - 截图改 exec-out 一步式 + 复用检测结果 + 缩短固定等待,单轮 ~12s 降到 ~7s 实测重复率 30% → 0%,端到端跑通(启动→下载→到底清扫→拉取到本地→同步完成)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+552
-114
@@ -14,23 +14,54 @@ DEVICE = "emulator-5554"
|
|||||||
OUTPUT_DIR = Path("./recordings")
|
OUTPUT_DIR = Path("./recordings")
|
||||||
PKG = "com.videogo"
|
PKG = "com.videogo"
|
||||||
|
|
||||||
# 屏幕坐标(Pixel 6 模拟器 1080x2400,2026-06-15 实测校准)
|
# 屏幕坐标(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": (270, 1340), # 摄像头详情页-实时视频按钮
|
||||||
"record_tab": (540, 2290), # 底部录像 Tab
|
"record_tab": (540, 2290), # 底部录像 Tab
|
||||||
"prev_day_arrow": (80, 1130), # < 昨天箭头
|
"prev_day_arrow": (77, 1124), # < 上一天箭头
|
||||||
"download_btn": (426, 880), # 录像页工具栏-下载按钮
|
"next_day_arrow": (370, 1124), # > 下一天箭头(用于归位到今日)
|
||||||
"batch_download": (540, 1904), # 弹出菜单"批量下载"(UIAutomator dump 实测)
|
"download_btn": (406, 935), # 录像页工具栏-下载按钮
|
||||||
|
"batch_download": (540, 1904), # 弹出菜单"批量下载"
|
||||||
"reload_btn": (540, 1960), # 加载失败页的"重新加载"按钮
|
"reload_btn": (540, 1960), # 加载失败页的"重新加载"按钮
|
||||||
|
"today_thumb": (198, 1388), # "今日"默认列表第一张缩略图(用于切换工具栏到下载模式)
|
||||||
}
|
}
|
||||||
|
|
||||||
# 批量下载界面缩略图布局(UIAutomator dump 实测)
|
# 批量下载界面缩略图布局(7.8.1 实测;此版本自绘 UI,uiautomator dump 拿不到 bounds,
|
||||||
# 3 列 x 中心:201, 540, 878
|
# 改用固定网格坐标点击)
|
||||||
# 行高 203 像素;y 中心从 1240 开始
|
# 3 列 x 中心:201, 540, 878;3 行 y 中心:1148, 1351, 1554
|
||||||
THUMB_X = [201, 540, 878]
|
THUMB_X = [201, 540, 878]
|
||||||
THUMB_ROW_H = 203
|
THUMB_Y = [1148, 1351, 1554]
|
||||||
# 每次点 3 整行 = 9 个(不触发 10 并发限制),然后滚动 3 行
|
# 每次点 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):
|
def adb(*args):
|
||||||
@@ -109,6 +140,27 @@ def dismiss_anr_if_any():
|
|||||||
return False
|
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):
|
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 给模拟器+萤石喘息时间。
|
||||||
@@ -168,29 +220,142 @@ def sample_pixel_rgb(x, y, png_path="/tmp/_pixchk.png"):
|
|||||||
return tuple(map(int, m.groups()))
|
return tuple(map(int, m.groups()))
|
||||||
|
|
||||||
|
|
||||||
def tap_until_pixel_changed(x, y, sample_x, sample_y, condition_fn,
|
|
||||||
max_tries=5, timeout_per_try=8, interval=1.0):
|
|
||||||
"""点 (x,y),然后轮询 (sample_x, sample_y) 像素,直到 condition_fn(rgb) 为真。
|
|
||||||
超时则重试点击。用于无文字可定位、但有视觉信号的场景(如切日期后工具栏变化)。
|
|
||||||
"""
|
|
||||||
for trial in range(max_tries):
|
|
||||||
adb("shell", "input", "tap", str(x), str(y))
|
|
||||||
for sec in range(int(timeout_per_try / interval)):
|
|
||||||
time.sleep(interval)
|
|
||||||
rgb = sample_pixel_rgb(sample_x, sample_y)
|
|
||||||
if condition_fn(rgb):
|
|
||||||
print(f" ✓ tap ({x},{y}) 生效,{sec+1}s 后检测到像素变化 rgb={rgb}")
|
|
||||||
return True
|
|
||||||
print(f" ! tap ({x},{y}) 后像素无变化({timeout_per_try}s),重试 {trial+1}/{max_tries}")
|
|
||||||
raise RuntimeError(f"点击 ({x},{y}) 重试 {max_tries} 次后像素仍未变化")
|
|
||||||
|
|
||||||
|
|
||||||
def screenshot(path="/tmp/ezviz_screen.png"):
|
def screenshot(path="/tmp/ezviz_screen.png"):
|
||||||
adb("shell", "screencap", "-p", "/sdcard/ezviz_tmp.png")
|
# 用 exec-out 直接把 PNG 输出到本地,省掉"先写 /sdcard 再 pull"的两步往返,
|
||||||
adb("pull", "/sdcard/ezviz_tmp.png", path)
|
# 每次截图快约 0.5~1s(批量下载循环每轮要截多次图,累计提速明显)。
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
subprocess.run([ADB, "-s", DEVICE, "exec-out", "screencap", "-p"],
|
||||||
|
stdout=f)
|
||||||
return path
|
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():
|
def launch_app():
|
||||||
"""从头启动 App,等待 618 开屏广告自动关闭后回到首页。
|
"""从头启动 App,等待 618 开屏广告自动关闭后回到首页。
|
||||||
用 am start -S 强制重启主 Activity(比 monkey 更可靠:避免之前残留的
|
用 am start -S 强制重启主 Activity(比 monkey 更可靠:避免之前残留的
|
||||||
@@ -242,6 +407,34 @@ def is_error_page():
|
|||||||
return False # 解析失败时不做干预
|
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():
|
def navigate_to_yesterday_batch():
|
||||||
"""导航到昨天的 SD 卡批量下载模式。
|
"""导航到昨天的 SD 卡批量下载模式。
|
||||||
关键节点用 tap_text 通过 UI dump 定位,避免坐标错位时误触首页底部
|
关键节点用 tap_text 通过 UI dump 定位,避免坐标错位时误触首页底部
|
||||||
@@ -274,29 +467,30 @@ def navigate_to_yesterday_batch():
|
|||||||
tap_safe(*COORD["record_tab"], expect_activity_keyword="VideoPlayActivity",
|
tap_safe(*COORD["record_tab"], expect_activity_keyword="VideoPlayActivity",
|
||||||
hint="未进入实时视频页,可能上一步点击实时视频按钮失败")
|
hint="未进入实时视频页,可能上一步点击实时视频按钮失败")
|
||||||
|
|
||||||
print("→ 切换到昨天(像素验证:切换后日期标签 (180,1130) 出现深色文字)")
|
print("→ 点击今日缩略图,切换工具栏到下载模式(新装/重启后默认是延时摄影等非下载工具栏)")
|
||||||
|
# 这个工具栏是自绘 UI,find_by_text 拿不到"下载"文字节点,不做强校验;
|
||||||
|
# 后面点下载按钮那步本身有"未弹出菜单则重试"的兜底。
|
||||||
assert_activity("VideoPlayActivity", "未在实时视频/录像页")
|
assert_activity("VideoPlayActivity", "未在实时视频/录像页")
|
||||||
# "今日" 状态下 (180,1130) 是浅蓝灰背景 (244,246,252)
|
adb("shell", "input", "tap", str(COORD["today_thumb"][0]), str(COORD["today_thumb"][1]))
|
||||||
# "06-XX" 状态下该位置是数字深色文字(R~44)
|
time.sleep(2)
|
||||||
tap_until_pixel_changed(
|
|
||||||
*COORD["prev_day_arrow"],
|
print("→ 归位到今日(App 重启后录像页会停留在上次浏览的日期,不一定是今日,"
|
||||||
sample_x=180, sample_y=1130,
|
"所以先连续点'下一天'直到到头,再退一天到昨天)")
|
||||||
condition_fn=lambda rgb: rgb[0] < 100,
|
assert_activity("VideoPlayActivity", "未在实时视频/录像页")
|
||||||
)
|
for i in range(8):
|
||||||
# 日期切换后工具栏不会自动切换。需要点一个缩略图把"录像选中状态"打开,
|
if not tap_and_check_date_changed(COORD["next_day_arrow"]):
|
||||||
# 工具栏才从"延时摄影/清晰度/对讲/..." 切到"收藏/下载/剪辑/..."
|
print(f" 第 {i+1} 次点'下一天'后日期未变化,已到今日")
|
||||||
# 工具栏第 3 位 (540,900) 由浅蓝(对讲圆圈)变白即为切换完成
|
|
||||||
print("→ 点一个缩略图触发工具栏切换")
|
|
||||||
adb("shell", "input", "tap", "200", "1400") # 第一行第一列缩略图
|
|
||||||
for _ in range(15):
|
|
||||||
time.sleep(1)
|
|
||||||
rgb = sample_pixel_rgb(540, 900)
|
|
||||||
if rgb[0] > 240 and rgb[1] > 240 and rgb[2] > 240:
|
|
||||||
print(f" ✓ 工具栏已切换到含'下载'按钮(rgb={rgb})")
|
|
||||||
break
|
break
|
||||||
|
print(f" 第 {i+1} 次点'下一天',日期已翻页")
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("点缩略图后工具栏未切换到下载模式")
|
print(" ⚠ 连续 8 次翻页仍在变化,按当前位置继续")
|
||||||
# 等视频开始稳定播放再操作(否则 tap 会被加载过程吸收)
|
|
||||||
|
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)
|
time.sleep(3)
|
||||||
|
|
||||||
# 下载按钮可能因为视频还在加载而被吸收,循环重点击直到菜单弹出
|
# 下载按钮可能因为视频还在加载而被吸收,循环重点击直到菜单弹出
|
||||||
@@ -317,27 +511,24 @@ def navigate_to_yesterday_batch():
|
|||||||
raise RuntimeError("反复点下载按钮仍未弹出批量下载菜单")
|
raise RuntimeError("反复点下载按钮仍未弹出批量下载菜单")
|
||||||
|
|
||||||
|
|
||||||
def dump_thumbnail_bounds():
|
def screenshot_hash(png_path="/tmp/_scrollchk.png"):
|
||||||
"""通过 uiautomator dump 拿到当前批量下载界面可见缩略图的 bounds 列表。
|
"""截图并返回缩略图网格区域内容的 md5,用于判断滚动前后画面是否变化(到顶/到底检测)。
|
||||||
返回 [(x1,y1,x2,y2), ...] 按 y 升序。
|
|
||||||
过滤:宽 > 300,高 > 150,y1 在 [1000, 2300] 区间。
|
不能对整屏截图直接哈希:顶部状态栏时钟每分钟跳动、视频预览区域的"正在下载"提示文字
|
||||||
|
等都会随时间变化,跟"列表是否真的滚动了"无关,会导致哈希永远对不上,滚到底/滚到顶
|
||||||
|
后无法被正确识别,造成无限重复滚动+点击同一批卡片(实测触发过这个问题)。
|
||||||
|
改成只裁剪缩略图网格区域(避开顶部状态栏+视频预览+标题栏),滚不动时该区域内容才会
|
||||||
|
真正保持一致。
|
||||||
"""
|
"""
|
||||||
import re
|
import hashlib
|
||||||
adb("shell", "uiautomator", "dump", "/sdcard/uib.xml")
|
adb("shell", "screencap", "-p", "/sdcard/_scrollchk.png")
|
||||||
adb("pull", "/sdcard/uib.xml", "/tmp/uib.xml")
|
adb("pull", "/sdcard/_scrollchk.png", png_path)
|
||||||
try:
|
crop_path = png_path.replace(".png", "_crop.png")
|
||||||
with open("/tmp/uib.xml") as f:
|
# 缩略图网格区域:y=1000 到屏幕底部,避开上方标题/预览/状态栏(Pixel 6 模拟器 1080x2400)
|
||||||
content = f.read()
|
subprocess.run(["magick", png_path, "-crop", "1080x1400+0+1000", crop_path],
|
||||||
except FileNotFoundError:
|
capture_output=True)
|
||||||
return []
|
with open(crop_path, "rb") as f:
|
||||||
pat = re.compile(r'clickable="true"[^/]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
return hashlib.md5(f.read()).hexdigest()
|
||||||
out = []
|
|
||||||
for m in pat.finditer(content):
|
|
||||||
x1, y1, x2, y2 = map(int, m.groups())
|
|
||||||
if 1000 <= y1 <= 2300 and (x2 - x1) > 300 and (y2 - y1) > 150:
|
|
||||||
out.append((x1, y1, x2, y2))
|
|
||||||
out.sort(key=lambda b: (b[1], b[0]))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def dismiss_dialog_if_any():
|
def dismiss_dialog_if_any():
|
||||||
@@ -353,53 +544,282 @@ def dismiss_dialog_if_any():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def tap_all_thumbnails(max_rounds=100, batch_sleep=35):
|
def get_remote_new_file_count(baseline_files):
|
||||||
"""循环:dump → 点击 9 个 → 等下载 → 滚动 3 行。
|
"""统计远程 SD 卡目录里相对 baseline 新增的文件数,用于判断这一批是否真的下载完。"""
|
||||||
每轮点 3 整行(9 个)避免触发 10 并发限制。
|
today = datetime.now().strftime("%Y%m%d")
|
||||||
滚动距离 = 3 行 = ~609 像素,保证下轮可见行与本轮不重叠。
|
remote_dir = f"/sdcard/Android/data/{PKG}/files/{today}"
|
||||||
检测到底:连续 2 轮 bounds 列表相同则结束。
|
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} 轮)")
|
print(f"→ 开始批量下载循环(最多 {max_rounds} 轮)")
|
||||||
# 进入批量模式后会有"使用手机网络流量"弹窗,先关掉
|
dialog_dismissed = False
|
||||||
dismiss_dialog_if_any()
|
|
||||||
last_signature = None
|
|
||||||
same_count = 0
|
|
||||||
|
|
||||||
for r in range(max_rounds):
|
for r in range(max_rounds):
|
||||||
bounds = dump_thumbnail_bounds()
|
# 点击前先确认 App 没有卡死:卡死时截图读到的是冻结前的旧画面,
|
||||||
if not bounds:
|
# 打勾识别会失真,是"重复下载"的真正根因(见 ensure_not_anr 文档字符串)。
|
||||||
print(f" 轮 {r+1}: 没拿到缩略图 bounds,结束")
|
ensure_not_anr()
|
||||||
break
|
|
||||||
|
|
||||||
# 取完整可见行(高度 > 195)
|
# 截图前等一下,让"灰色占位"缩略图加载出真实画面——否则纯色占位 std≈0
|
||||||
full = [b for b in bounds if (b[3] - b[1]) > 195]
|
# 会被 detect_card_rows 当成间隙,导致整行漏检(见 THUMB_LOAD_WAIT 说明)。
|
||||||
if len(full) < 3:
|
time.sleep(THUMB_LOAD_WAIT)
|
||||||
print(f" 轮 {r+1}: 完整行不足 ({len(full)} < 3),结束")
|
|
||||||
break
|
|
||||||
|
|
||||||
# 检测到底:bounds 内容跟上次相同
|
# 每屏动态实测卡片行中心 y(消除整点分组标签平移造成的错位,这是"重复下载"
|
||||||
signature = tuple(full[:9])
|
# 的总根源),再对每行 × 固定 3 列逐格判打勾,只点还没下载完的。
|
||||||
if signature == last_signature:
|
grid_shot = screenshot("/tmp/_gridchk.png")
|
||||||
same_count += 1
|
full_rows = detect_card_rows(grid_shot)
|
||||||
if same_count >= 2:
|
if not full_rows:
|
||||||
print(f" 轮 {r+1}: 连续 2 轮内容相同,已到底,结束")
|
# 偶发一张都没检出(仍在加载/异常画面),重拍一次
|
||||||
break
|
|
||||||
else:
|
|
||||||
same_count = 0
|
|
||||||
last_signature = signature
|
|
||||||
|
|
||||||
to_click = full[:9]
|
|
||||||
for x1, y1, x2, y2 in to_click:
|
|
||||||
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
|
|
||||||
adb("shell", "input", "tap", str(cx), str(cy))
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
print(f" 轮 {r+1}: 点击 {len(to_click)} 个,等 {batch_sleep}s 下载...")
|
|
||||||
time.sleep(batch_sleep)
|
|
||||||
|
|
||||||
# 向下滚动 3 行(约 609 像素)
|
|
||||||
adb("shell", "input", "swipe", "540", "1820", "540", "1211", "400")
|
|
||||||
time.sleep(1.5)
|
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("→ 批量下载循环结束")
|
print("→ 批量下载循环结束")
|
||||||
|
|
||||||
@@ -468,19 +888,37 @@ def get_baseline_files():
|
|||||||
return set(l for l in result.stdout.strip().splitlines() if l)
|
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():
|
def sync():
|
||||||
print("===== 萤石录像自动同步开始 =====")
|
print("===== 萤石录像自动同步开始 =====")
|
||||||
launch_app()
|
launch_app()
|
||||||
# 记录下载前的基准文件集合
|
# baseline 只用于判断"这一轮点击触发的下载是否已经稳定",不再限制最终拉取范围
|
||||||
baseline = get_baseline_files()
|
baseline = get_baseline_files()
|
||||||
print(f"→ 基准文件数: {len(baseline)}")
|
print(f"→ 基准文件数: {len(baseline)}")
|
||||||
navigate_to_yesterday_batch()
|
navigate_to_yesterday_batch()
|
||||||
tap_all_thumbnails() # 默认 max_rounds=100,跑到滚到底为止
|
tap_all_thumbnails(baseline) # 默认 max_rounds=300,跑到滚到底为止
|
||||||
files = wait_for_downloads(baseline)
|
wait_for_downloads(baseline)
|
||||||
if files:
|
|
||||||
pull_files(files)
|
all_files = get_all_remote_files()
|
||||||
|
print(f"→ 设备上共有 {len(all_files)} 个录像文件(含之前跑崩残留的历史文件),开始全部拉取")
|
||||||
|
if all_files:
|
||||||
|
pull_files(list(all_files))
|
||||||
else:
|
else:
|
||||||
print("未找到新下载文件")
|
print("未找到任何录像文件")
|
||||||
print("===== 同步完成 =====")
|
print("===== 同步完成 =====")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user