滚动与列表收集 深度解析
滚动与列表收集 这页聚焦 用 OCR 文本重叠判断列表是否真的移动。关键证据包括 src/phone_harness/helpers.py:45、src/phone_harness/mirror.py:91、src/phone_harness/ocr.py:19、src/phone_harness/admin.py:12,所有判断都回到真实源码行号。
导入链与契约
滚动子系统先限制 OCR 区域,避开状态栏和底部导航。src/phone_harness/helpers.py:153、src/phone_harness/helpers.py:156、src/phone_harness/helpers.py:159、src/phone_harness/helpers.py:167 把屏幕文本变成集合,后续不依赖业务解析器是否抽到新 item。
153def _content_texts(min_conf=0.4, top_frac=0.06, bottom_frac=0.92): 154 """OCR of the scrollable content area, excluding the volatile status bar 155 (clock/battery) at top and the nav/home strip at bottom.""" 156 path, win = mirror.capture() 157 top = win["y"] + win["h"] * top_frac 158 bot = win["y"] + win["h"] * bottom_frac 159 return [o for o in _ocr.recognize(path, win) 160 if top < o["y"] < bot and o["confidence"] >= min_conf] 161 162 163def _text_set(boxes): 164 return frozenset(o["text"].strip() for o in boxes if o["text"].strip()) 165 166 167def _overlap(a, b): 168 """Jaccard overlap of two text sets: ~1.0 = same screen, low = it moved.""" 169 if not a or not b: 170 return 0.0 171 return len(a & b) / len(a | b)
核心实现,逐段拆
▸单步滚动
scroll_screen 的核心是先取 before,再发 wheel scroll,然后等待 OCR 稳定。src/phone_harness/helpers.py:174、src/phone_harness/helpers.py:189、src/phone_harness/helpers.py:193、src/phone_harness/helpers.py:198、src/phone_harness/helpers.py:206 让“是否移动”变成可度量的 overlap,而不是凭感觉。
174def scroll_screen(direction="up", amount=0.6, settle=2.5, moved_thresh=0.6): 175 """One scroll gesture, then wait for the screen to settle (so a lazy-load 176 spinner resolves before we judge movement). 177 178 Returns {moved, overlap, before, after, boxes} — `boxes` is the settled 179 content OCR, ready to parse. `moved` is False when overlap >= moved_thresh: 180 the list didn't advance. The default 0.6 sits in the empirical gap between 181 real forward progress (overlap < ~0.45) and overscroll bounce at a boundary 182 (overlap > ~0.7), which springs the content and would otherwise read as 183 movement and defeat end-detection. 184 """ 185 w = mirror.ensure_window() 186 sign = {"up": -1, "down": 1}.get(direction) # 'up' reveals content below 187 if sign is None: 188 raise ValueError(f"direction must be 'up' or 'down', got {direction!r}") 189 before = _text_set(_content_texts()) 190 # Wheel scroll, not drag: a slow touch-drag barely moves an iOS list and 191 # bounces back, while wheel events advance it deterministically (proven on 192 # long lists). amount is a fraction of window height. 193 mirror.scroll_wheel(sign * int(w["h"] * amount), 194 w["x"] + w["w"] / 2, w["y"] + w["h"] / 2, steps=10) 195 time.sleep(0.4) 196 prev_boxes, prev = None, None 197 deadline = time.time() + settle 198 while time.time() < deadline: 199 boxes = _content_texts() 200 cur = _text_set(boxes) 201 if cur == prev: # two identical captures = settled 202 break 203 prev, prev_boxes = cur, boxes 204 time.sleep(0.35) 205 after = prev or frozenset() 206 return {"moved": _overlap(before, after) < moved_thresh, 207 "overlap": round(_overlap(before, after), 3), 208 "before": before, "after": after, "boxes": prev_boxes or []}
▸收集到终点
scroll_collect 对每屏提取结果去重,并用 end_after 连续不移动确认终点。src/phone_harness/helpers.py:239、src/phone_harness/helpers.py:258、src/phone_harness/helpers.py:271、src/phone_harness/helpers.py:276、src/phone_harness/helpers.py:280 这正是移动端长列表自动化最容易写错的部分。
239def scroll_collect(extract=None, key=None, direction="up", amount=0.6, 240 max_scrolls=400, end_after=3, settle=2.5, on_progress=None): 241 """Scroll a list top-to-bottom, extracting and de-duping items each screen, 242 until the list reaches its true end. 243 244 - extract(boxes) -> list of items for the current screen. Default returns 245 each content text line, so a bare scroll_collect() gathers all text. 246 - key(item) -> hashable de-dup key (default: the item itself). 247 - Stops after `end_after` consecutive non-moving scrolls (the settle window 248 already gave lazy-load a chance), or `max_scrolls`. 249 250 Returns {items, stop, scrolls}. `stop` is 'reached-end' or 'max-scrolls'. 251 Use amount < 1.0 so screens overlap and no row falls between captures. 252 """ 253 extract = extract or (lambda boxes: [o["text"].strip() for o in boxes 254 if o["text"].strip()]) 255 key = key or (lambda x: x) 256 seen, order = set(), [] 257 258 def ingest(boxes): 259 new = 0 260 for item in extract(boxes): 261 k = key(item) 262 if k in seen: 263 continue 264 seen.add(k) 265 order.append(item) 266 new += 1 267 return new 268 269 ingest(_content_texts()) 270 stale = 0 271 for i in range(1, max_scrolls + 1): 272 res = scroll_screen(direction, amount, settle) 273 new = ingest(res["boxes"]) 274 if on_progress: 275 on_progress(i, len(order), new, res["moved"], res["overlap"]) 276 if res["moved"]: 277 stale = 0 278 else: 279 stale += 1 280 if stale >= end_after: 281 return {"items": order, "stop": "reached-end", "scrolls": i} 282 time.sleep(0.8) # extra grace for a slow lazy-load 283 mirror.activate() 284 return {"items": order, "stop": "max-scrolls", "scrolls": max_scrolls}
关键流程
这个流程的关键是每一步都有可观察证据:src/phone_harness/mirror.py:84、src/phone_harness/ocr.py:48、src/phone_harness/helpers.py:73、src/phone_harness/helpers.py:316、src/phone_harness/admin.py:65 分别覆盖截图、OCR 坐标、screen_info、稳定等待和 doctor 结果。它不像浏览器自动化那样有 DOM,所以截图就是最终断言。
边界 / 失败模式
src/phone_harness/helpers.py:67遇到 blocked interstitial 时要求用户处理,防止 agent 越权。src/phone_harness/mirror.py:251对无法输入字符抛错,避免用户以为文本已发送。src/phone_harness/helpers.py:280需要连续不移动才结束列表,避免 lazy-load 误判。src/phone_harness/admin.py:55用截图字节数识别空 capture,提示重启终端让录屏权限生效。