dp-cli 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {dp_cli-0.4.0 → dp_cli-0.5.0}/PKG-INFO +1 -1
  2. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/__init__.py +2 -2
  3. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/_utils.py +14 -3
  4. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/browser.py +1 -1
  5. dp_cli-0.5.0/dp_cli/commands/keyboard.py +405 -0
  6. dp_cli-0.5.0/dp_cli/commands/record.py +204 -0
  7. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/snapshot_cmd.py +1 -1
  8. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/tab.py +6 -0
  9. dp_cli-0.5.0/dp_cli/recorder.py +799 -0
  10. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli.egg-info/PKG-INFO +1 -1
  11. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli.egg-info/SOURCES.txt +2 -0
  12. {dp_cli-0.4.0 → dp_cli-0.5.0}/pyproject.toml +1 -1
  13. dp_cli-0.4.0/dp_cli/commands/keyboard.py +0 -225
  14. {dp_cli-0.4.0 → dp_cli-0.5.0}/README.md +0 -0
  15. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/__init__.py +0 -0
  16. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/bridge.py +0 -0
  17. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/bridge_manager.py +0 -0
  18. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/element.py +0 -0
  19. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/misc.py +0 -0
  20. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/network.py +0 -0
  21. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/page.py +0 -0
  22. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/commands/storage.py +0 -0
  23. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/locators/__init__.py +0 -0
  24. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/locators/playwright.py +0 -0
  25. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/locators/pw_js.py +0 -0
  26. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/main.py +0 -0
  27. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/output.py +0 -0
  28. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/session.py +0 -0
  29. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/snapshot/__init__.py +0 -0
  30. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/snapshot/a11y.py +0 -0
  31. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/snapshot/clickable.py +0 -0
  32. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/snapshot/clickable_js.py +0 -0
  33. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/snapshot/extract.py +0 -0
  34. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/snapshot/js_scripts.py +0 -0
  35. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/snapshot/utils.py +0 -0
  36. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli/stealth.py +0 -0
  37. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli.egg-info/dependency_links.txt +0 -0
  38. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli.egg-info/entry_points.txt +0 -0
  39. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli.egg-info/requires.txt +0 -0
  40. {dp_cli-0.4.0 → dp_cli-0.5.0}/dp_cli.egg-info/top_level.txt +0 -0
  41. {dp_cli-0.4.0 → dp_cli-0.5.0}/setup.cfg +0 -0
  42. {dp_cli-0.4.0 → dp_cli-0.5.0}/tests/test_bridge_integration.py +0 -0
  43. {dp_cli-0.4.0 → dp_cli-0.5.0}/tests/test_bridge_manager.py +0 -0
  44. {dp_cli-0.4.0 → dp_cli-0.5.0}/tests/test_clickable.py +0 -0
  45. {dp_cli-0.4.0 → dp_cli-0.5.0}/tests/test_pw_locator.py +0 -0
  46. {dp_cli-0.4.0 → dp_cli-0.5.0}/tests/test_resolve_locator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dp-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A powerful CLI for DrissionPage — browser automation, structured data extraction, network listening and more.
5
5
  License: BSD-3-Clause
6
6
  Project-URL: Homepage, https://github.com/mofanx/dp-cli
@@ -1,10 +1,10 @@
1
1
  # -*- coding:utf-8 -*-
2
2
  from dp_cli.commands import (
3
3
  browser, snapshot_cmd, element, keyboard,
4
- page, tab, storage, network, misc,
4
+ page, tab, storage, network, record, misc,
5
5
  )
6
6
 
7
- _MODULES = [browser, snapshot_cmd, element, keyboard, page, tab, storage, network, misc]
7
+ _MODULES = [browser, snapshot_cmd, element, keyboard, page, tab, storage, network, record, misc]
8
8
 
9
9
 
10
10
  def register_all(cli):
@@ -25,11 +25,12 @@ def session_option(f):
25
25
  help='会话名称,默认 default', show_default=True)(f)
26
26
 
27
27
 
28
- def _get_page(session: str, raw: bool = False):
28
+ def _get_page(session: str, raw: bool = False, inject_recording: bool = True):
29
29
  """获取页面对象,失败则 error 退出。
30
30
 
31
31
  :param raw: True 时始终返回 ChromiumPage(用于浏览器级操作如标签页管理)。
32
- False 时返回绑定的标签页 ChromiumTab(如有),否则返回 ChromiumPage。
32
+ False 时返回绑定的标签页 ChromiumTab(如有),否则返回当前激活标签页。
33
+ :param inject_recording: session 录制中时是否自动注入录制器。
33
34
  """
34
35
  try:
35
36
  page = get_browser(session)
@@ -53,7 +54,10 @@ def _get_page(session: str, raw: bool = False):
53
54
  save_session(session, sess)
54
55
  target = page
55
56
  else:
56
- target = page
57
+ try:
58
+ target = page.latest_tab or page
59
+ except Exception:
60
+ target = page
57
61
 
58
62
  # 自动重新应用 stealth:CDP init_js 绑定到 CDP session,每个 dp 命令是独立
59
63
  # Python 进程/独立 session,必须重新注册才能让下一次 navigation 生效。
@@ -71,6 +75,13 @@ def _get_page(session: str, raw: bool = False):
71
75
  except Exception:
72
76
  pass # 不能让 stealth 失败阻塞常规命令
73
77
 
78
+ if inject_recording and sess.get('recording'):
79
+ try:
80
+ from dp_cli.recorder import inject_recorder
81
+ inject_recorder(target)
82
+ except Exception:
83
+ pass
84
+
74
85
  return target
75
86
 
76
87
 
@@ -23,7 +23,7 @@ def register(cli):
23
23
  @click.option('--profile', 'user_data_dir', default=None, help='用户数据目录')
24
24
  @click.option('--proxy', default=None, help='代理服务器,如 http://127.0.0.1:7890')
25
25
  @click.option('--port', type=int, default=None, help='连接指定端口的已有浏览器实例')
26
- @click.option('--auto-connect', is_flag=True,
26
+ @click.option('--auto-connect', '-a', is_flag=True,
27
27
  help='从用户常规启动的 Chrome 读取 DevToolsActivePort 自动发现端口'
28
28
  '(需 Chrome 144+,用户在 chrome://inspect/#remote-debugging 启用)')
29
29
  @click.option('--channel', type=click.Choice(['stable', 'beta', 'dev', 'canary', 'chromium']),
@@ -0,0 +1,405 @@
1
+ # -*- coding:utf-8 -*-
2
+ """键盘与滚动命令: press / type / scroll / scroll-to / autoscroll"""
3
+ from time import sleep as _sleep
4
+
5
+ import click
6
+
7
+ from dp_cli.output import ok, error
8
+ from dp_cli.commands._utils import (
9
+ session_option, _get_page, resolve_locator, wait_network_idle,
10
+ )
11
+
12
+
13
+ def register(cli):
14
+
15
+ @cli.command('press')
16
+ @click.argument('key')
17
+ @session_option
18
+ def cmd_press(key, session):
19
+ """模拟键盘按键。
20
+
21
+ \b
22
+ 支持的按键: Enter, Tab, Escape, Space, Backspace,
23
+ ArrowUp/Down/Left/Right, F1-F12,
24
+ Control+A, Shift+Enter, Alt+F4 等组合键。
25
+
26
+ \b
27
+ 示例:
28
+ dp press Enter
29
+ dp press "Control+A"
30
+ dp press Escape
31
+ """
32
+ page = _get_page(session)
33
+ try:
34
+ from DrissionPage._functions.keys import Keys
35
+ key_map = {
36
+ 'enter': '\ue007', 'tab': '\ue004', 'escape': '\ue00c', 'esc': '\ue00c',
37
+ 'backspace': '\ue003', 'delete': '\ue017', 'space': ' ',
38
+ 'arrowup': '\ue013', 'arrowdown': '\ue015',
39
+ 'arrowleft': '\ue012', 'arrowright': '\ue014',
40
+ }
41
+ k = key.lower()
42
+ if '+' in key:
43
+ parts = key.split('+')
44
+ modifier = parts[0].lower()
45
+ main_key = parts[1]
46
+ mod_map = {'control': Keys.CTRL, 'ctrl': Keys.CTRL,
47
+ 'shift': Keys.SHIFT, 'alt': Keys.ALT}
48
+ if modifier in mod_map:
49
+ page.actions.key_down(mod_map[modifier]).type(main_key).key_up(mod_map[modifier])
50
+ else:
51
+ page.actions.type(key)
52
+ else:
53
+ actual_key = key_map.get(k, key)
54
+ page.actions.type(actual_key)
55
+ ok({'key': key}, msg='按键成功')
56
+ except Exception as e:
57
+ error(f'按键失败: {key}', code='KEY_FAILED', detail=str(e))
58
+
59
+ @cli.command('type')
60
+ @click.argument('text')
61
+ @session_option
62
+ def cmd_type(text, session):
63
+ """输入文本(当前焦点元素)。
64
+
65
+ \b
66
+ 示例:
67
+ dp type "hello world"
68
+ dp type "search query"
69
+ """
70
+ page = _get_page(session)
71
+ try:
72
+ page.actions.type(text)
73
+ ok({'text': text}, msg='输入成功')
74
+ except Exception as e:
75
+ error(f'输入失败', code='TYPE_FAILED', detail=str(e))
76
+
77
+ @cli.command('scroll')
78
+ @click.option('--x', default=0, type=int, help='水平滚动像素')
79
+ @click.option('--y', default=300, type=int, help='垂直滚动像素')
80
+ @click.option('--top', is_flag=True, help='滚动到顶部')
81
+ @click.option('--bottom', is_flag=True, help='滚动到底部')
82
+ @click.option('--locator', default=None, help='滚动特定元素(而非页面)')
83
+ @click.option('--mouse-x', default=None, type=int, help='locator 失效时按录制鼠标 X 坐标查找滚动容器')
84
+ @click.option('--mouse-y', default=None, type=int, help='locator 失效时按录制鼠标 Y 坐标查找滚动容器')
85
+ @session_option
86
+ def cmd_scroll(x, y, top, bottom, locator, mouse_x, mouse_y, session):
87
+ """滚动页面或元素。
88
+
89
+ \b
90
+ 示例:
91
+ dp scroll --y 300
92
+ dp scroll --y -200
93
+ dp scroll --top
94
+ dp scroll --bottom
95
+ dp scroll --locator "css:.scroll-container" --y 100
96
+ dp scroll --locator "css:.scroll-container" --bottom
97
+ dp scroll --y 100 --mouse-x 500 --mouse-y 600
98
+ """
99
+ locator = resolve_locator(locator, session) if locator else None
100
+ page = _get_page(session)
101
+ try:
102
+ if locator:
103
+ target = page.ele(locator)
104
+ if not target or target.__class__.__name__ == 'NoneElement':
105
+ if mouse_x is None or mouse_y is None:
106
+ error(f'未找到滚动元素: {locator}', code='ELEMENT_NOT_FOUND')
107
+ return
108
+ locator = None
109
+ target = None
110
+ if target:
111
+ if top:
112
+ result = target.run_js(
113
+ 'this.scrollTop = 0; return {scrollTop: this.scrollTop, scrollLeft: this.scrollLeft};'
114
+ )
115
+ ok({'locator': locator, 'position': result}, msg='已滚动到顶部')
116
+ elif bottom:
117
+ result = target.run_js(
118
+ 'this.scrollTop = this.scrollHeight; return {scrollTop: this.scrollTop, scrollLeft: this.scrollLeft};'
119
+ )
120
+ ok({'locator': locator, 'position': result}, msg='已滚动到底部')
121
+ else:
122
+ result = target.run_js(
123
+ """
124
+ const before = {scrollTop: this.scrollTop, scrollLeft: this.scrollLeft};
125
+ this.scrollTop += arguments[1];
126
+ this.scrollLeft += arguments[0];
127
+ this.dispatchEvent(new Event('scroll', {bubbles: true}));
128
+ return {
129
+ before,
130
+ after: {scrollTop: this.scrollTop, scrollLeft: this.scrollLeft},
131
+ scrollHeight: this.scrollHeight,
132
+ clientHeight: this.clientHeight,
133
+ scrollWidth: this.scrollWidth,
134
+ clientWidth: this.clientWidth,
135
+ mode: 'locator'
136
+ };
137
+ """,
138
+ x, y,
139
+ )
140
+ ok({'x': x, 'y': y, 'locator': locator, 'position': result}, msg='滚动成功')
141
+ return
142
+
143
+ if mouse_x is not None and mouse_y is not None:
144
+ result = page.run_js(
145
+ """
146
+ function findScrollable(el) {
147
+ while (el && el !== document.body && el !== document.documentElement) {
148
+ const st = getComputedStyle(el);
149
+ const canY = /(auto|scroll|overlay)/.test(st.overflowY) && el.scrollHeight > el.clientHeight + 1;
150
+ const canX = /(auto|scroll|overlay)/.test(st.overflowX) && el.scrollWidth > el.clientWidth + 1;
151
+ if (canY || canX) return el;
152
+ el = el.parentElement;
153
+ }
154
+ return document.scrollingElement || document.documentElement;
155
+ }
156
+ const start = document.elementFromPoint(arguments[2], arguments[3]);
157
+ const target = findScrollable(start);
158
+ const before = {scrollTop: target.scrollTop, scrollLeft: target.scrollLeft};
159
+ if (arguments[4]) {
160
+ target.scrollTop = 0;
161
+ } else if (arguments[5]) {
162
+ target.scrollTop = target.scrollHeight;
163
+ } else {
164
+ target.scrollTop += arguments[1];
165
+ target.scrollLeft += arguments[0];
166
+ }
167
+ target.dispatchEvent(new Event('scroll', {bubbles: true}));
168
+ return {
169
+ before,
170
+ after: {scrollTop: target.scrollTop, scrollLeft: target.scrollLeft},
171
+ scrollHeight: target.scrollHeight,
172
+ clientHeight: target.clientHeight,
173
+ scrollWidth: target.scrollWidth,
174
+ clientWidth: target.clientWidth,
175
+ tag: target.tagName,
176
+ id: target.id || '',
177
+ className: target.className || '',
178
+ mode: 'mouse'
179
+ };
180
+ """,
181
+ x, y, mouse_x, mouse_y, top, bottom,
182
+ )
183
+ ok({'x': x, 'y': y, 'mouse': {'x': mouse_x, 'y': mouse_y},
184
+ 'position': result}, msg='滚动成功')
185
+ return
186
+
187
+ if top:
188
+ result = page.run_js(
189
+ 'window.scrollTo(0, 0); return {scrollX: window.scrollX, scrollY: window.scrollY};'
190
+ )
191
+ ok({'position': result}, msg='已滚动到顶部')
192
+ elif bottom:
193
+ result = page.run_js(
194
+ 'window.scrollTo(0, document.scrollingElement.scrollHeight); return {scrollX: window.scrollX, scrollY: window.scrollY};'
195
+ )
196
+ ok({'position': result}, msg='已滚动到底部')
197
+ else:
198
+ result = page.run_js(
199
+ 'window.scrollBy(arguments[0], arguments[1]); return {scrollX: window.scrollX, scrollY: window.scrollY};',
200
+ x, y,
201
+ )
202
+ ok({'x': x, 'y': y, 'position': result}, msg='滚动成功')
203
+ except Exception as e:
204
+ error(f'滚动失败', code='SCROLL_FAILED', detail=str(e))
205
+
206
+ @cli.command('scroll-to')
207
+ @click.argument('locator')
208
+ @session_option
209
+ def scroll_to(locator, session):
210
+ """滚动页面直到元素可见。
211
+
212
+ \b
213
+ 示例:
214
+ dp scroll-to "#footer"
215
+ dp scroll-to "ref:20"
216
+ """
217
+ locator = resolve_locator(locator, session)
218
+ page = _get_page(session)
219
+ try:
220
+ ele = page.ele(locator)
221
+ if not ele or ele.__class__.__name__ == 'NoneElement':
222
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
223
+ return
224
+ ele.scroll.to_see()
225
+ ok({'locator': locator}, msg='已滚动到元素')
226
+ except Exception as e:
227
+ error(f'滚动失败', code='SCROLL_FAILED', detail=str(e))
228
+
229
+ @cli.command('autoscroll')
230
+ @click.option('--locator', default=None,
231
+ help='计数元素(如 ".item");省略则用页面高度判断')
232
+ @click.option('--container', default=None,
233
+ help='在指定容器内滚动(用于内部可滚动区域的 SPA)')
234
+ @click.option('--max', 'max_rounds', default=300, show_default=True, type=int,
235
+ help='最大滚动轮数')
236
+ @click.option('--stable', default=2, show_default=True, type=int,
237
+ help='连续 N 轮无增长视为到底')
238
+ @click.option('--idle', default=2.0, show_default=True, type=float,
239
+ help='每轮滚动后等待网络空闲秒数(0 则仅固定小延时)')
240
+ @click.option('--idle-timeout', default=10.0, show_default=True, type=float,
241
+ help='网络空闲等待超时(超时后视为本轮完成,不报错)')
242
+ @click.option('--step', default=0, show_default=True, type=int,
243
+ help='每轮滚动像素;0 表示按可视高度自动计算')
244
+ @click.option('--fast', is_flag=True, default=False,
245
+ help='快速滚动模式:使用更大步长和短延迟,适合无反爬场景')
246
+ @click.option('--fast-delay', default=0.05, show_default=True, type=float,
247
+ help='快速模式每轮固定等待秒数')
248
+ @click.option('--mouse-x', default=None, type=int,
249
+ help='container 失效时按鼠标 X 坐标查找滚动容器')
250
+ @click.option('--mouse-y', default=None, type=int,
251
+ help='container 失效时按鼠标 Y 坐标查找滚动容器')
252
+ @session_option
253
+ def cmd_autoscroll(locator, container, max_rounds, stable, idle, idle_timeout,
254
+ step, fast, fast_delay, mouse_x, mouse_y, session):
255
+ """循环滚动到底部,直到懒加载无新内容。
256
+
257
+ \b
258
+ 终止条件:
259
+ 1. 连续 --stable 轮 计数/高度 无增长
260
+ 2. 达到 --max 轮上限
261
+
262
+ \b
263
+ 示例:
264
+ dp autoscroll --locator ".item" # 按元素数量判断
265
+ dp autoscroll # 按页面高度判断
266
+ dp autoscroll --container "#feed" --idle 3 # 容器内滚动
267
+ dp autoscroll --step 800 --max 50 # 每轮滚动 800px
268
+ dp autoscroll --fast --max 100 # 速度优先,快速滚到底
269
+ dp autoscroll --mouse-x 500 --mouse-y 600 # 按坐标查找滚动容器
270
+ dp autoscroll --max 50 --stable 3 # 更耐心的配置
271
+ """
272
+ if locator:
273
+ locator = resolve_locator(locator, session)
274
+ if container:
275
+ container = resolve_locator(container, session)
276
+ page = _get_page(session)
277
+
278
+ try:
279
+ target = page.ele(container) if container else None
280
+ if container and (not target or target.__class__.__name__ == 'NoneElement'):
281
+ if mouse_x is None or mouse_y is None:
282
+ error(f'未找到容器: {container}', code='ELEMENT_NOT_FOUND')
283
+ return
284
+ target = None
285
+
286
+ use_mouse_container = target is None and mouse_x is not None and mouse_y is not None
287
+
288
+ def _measure():
289
+ if locator:
290
+ return len(page.eles(locator, timeout=0))
291
+ if container and target:
292
+ return int(target.run_js('return this.scrollHeight'))
293
+ if use_mouse_container:
294
+ return int(page.run_js(
295
+ """
296
+ function findScrollable(el) {
297
+ while (el && el !== document.body && el !== document.documentElement) {
298
+ const st = getComputedStyle(el);
299
+ const canY = /(auto|scroll|overlay)/.test(st.overflowY) && el.scrollHeight > el.clientHeight + 1;
300
+ if (canY) return el;
301
+ el = el.parentElement;
302
+ }
303
+ return document.scrollingElement || document.documentElement;
304
+ }
305
+ const target = findScrollable(document.elementFromPoint(arguments[0], arguments[1]));
306
+ return target.scrollHeight;
307
+ """,
308
+ mouse_x, mouse_y,
309
+ ))
310
+ return int(page.run_js('return document.documentElement.scrollHeight'))
311
+
312
+ metric = 'count' if locator else 'scrollHeight'
313
+ history = [_measure()]
314
+ positions = []
315
+ stable_count = 0
316
+ reason = 'max-reached'
317
+
318
+ for i in range(max_rounds):
319
+ if container and target:
320
+ position = target.run_js(
321
+ """
322
+ const before = this.scrollTop;
323
+ const delta = arguments[0] > 0 ? arguments[0] : Math.max(300, Math.floor(this.clientHeight * arguments[1]));
324
+ this.scrollTop += delta;
325
+ this.dispatchEvent(new Event('scroll', {bubbles: true}));
326
+ return {before, after: this.scrollTop, delta, scrollHeight: this.scrollHeight, clientHeight: this.clientHeight, mode: 'container'};
327
+ """,
328
+ step, 3 if fast else 0.9,
329
+ )
330
+ elif use_mouse_container:
331
+ position = page.run_js(
332
+ """
333
+ function findScrollable(el) {
334
+ while (el && el !== document.body && el !== document.documentElement) {
335
+ const st = getComputedStyle(el);
336
+ const canY = /(auto|scroll|overlay)/.test(st.overflowY) && el.scrollHeight > el.clientHeight + 1;
337
+ if (canY) return el;
338
+ el = el.parentElement;
339
+ }
340
+ return document.scrollingElement || document.documentElement;
341
+ }
342
+ const target = findScrollable(document.elementFromPoint(arguments[0], arguments[1]));
343
+ const before = target.scrollTop;
344
+ const delta = arguments[2] > 0 ? arguments[2] : Math.max(300, Math.floor(target.clientHeight * arguments[3]));
345
+ target.scrollTop += delta;
346
+ target.dispatchEvent(new Event('scroll', {bubbles: true}));
347
+ return {
348
+ before,
349
+ after: target.scrollTop,
350
+ delta,
351
+ scrollHeight: target.scrollHeight,
352
+ clientHeight: target.clientHeight,
353
+ tag: target.tagName,
354
+ id: target.id || '',
355
+ className: target.className || '',
356
+ mode: 'mouse'
357
+ };
358
+ """,
359
+ mouse_x, mouse_y, step, 3 if fast else 0.9,
360
+ )
361
+ else:
362
+ position = page.run_js(
363
+ """
364
+ const target = document.scrollingElement || document.documentElement;
365
+ const before = target.scrollTop;
366
+ const delta = arguments[0] > 0 ? arguments[0] : Math.max(300, Math.floor(window.innerHeight * arguments[1]));
367
+ window.scrollBy(0, delta);
368
+ return {before, after: window.scrollY || target.scrollTop, delta, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, mode: 'page'};
369
+ """,
370
+ step, 3 if fast else 0.9,
371
+ )
372
+ positions.append(position)
373
+ if fast:
374
+ _sleep(max(0, fast_delay))
375
+ elif idle > 0:
376
+ try:
377
+ wait_network_idle(page, idle_time=idle, timeout=idle_timeout)
378
+ except TimeoutError:
379
+ pass # 超时也继续判断,可能本来就没新请求
380
+ else:
381
+ _sleep(0.3)
382
+
383
+ curr = _measure()
384
+ history.append(curr)
385
+ moved = bool(position and position.get('after') != position.get('before'))
386
+ if curr <= history[-2] and not moved:
387
+ stable_count += 1
388
+ if stable_count >= stable:
389
+ reason = 'stable'
390
+ break
391
+ else:
392
+ stable_count = 0
393
+
394
+ ok({
395
+ 'rounds': len(history) - 1,
396
+ 'metric': metric,
397
+ 'initial': history[0],
398
+ 'final': history[-1],
399
+ 'growth': history[-1] - history[0],
400
+ 'history': history,
401
+ 'positions': positions,
402
+ 'reason': reason,
403
+ }, msg=f'autoscroll 完成 ({reason}):{metric} {history[0]} → {history[-1]}')
404
+ except Exception as e:
405
+ error(f'自动滚动失败', code='AUTOSCROLL_FAILED', detail=str(e))