dp-cli 0.6.0__tar.gz → 0.6.3__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 (53) hide show
  1. {dp_cli-0.6.0 → dp_cli-0.6.3}/PKG-INFO +14 -6
  2. {dp_cli-0.6.0 → dp_cli-0.6.3}/README.md +8 -5
  3. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/bridge.py +23 -5
  4. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/bridge_manager.py +88 -11
  5. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/_utils.py +1 -1
  6. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/keyboard.py +73 -59
  7. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/misc.py +2 -0
  8. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/network.py +45 -39
  9. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/record.py +14 -23
  10. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/snapshot_cmd.py +40 -20
  11. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/storage.py +37 -3
  12. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/output.py +4 -37
  13. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/recorder.py +62 -100
  14. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/a11y.py +317 -64
  15. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/clickable.py +13 -5
  16. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/extract.py +2 -2
  17. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/js_scripts.py +15 -3
  18. dp_cli-0.6.3/dp_cli/snapshot/utils.py +70 -0
  19. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/PKG-INFO +14 -6
  20. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/SOURCES.txt +6 -1
  21. dp_cli-0.6.3/dp_cli.egg-info/requires.txt +11 -0
  22. dp_cli-0.6.3/pyproject.toml +88 -0
  23. dp_cli-0.6.3/tests/test_a11y.py +745 -0
  24. {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_bridge_manager.py +9 -6
  25. dp_cli-0.6.3/tests/test_commands.py +889 -0
  26. dp_cli-0.6.3/tests/test_recorder.py +473 -0
  27. dp_cli-0.6.3/tests/test_session.py +804 -0
  28. dp_cli-0.6.3/tests/test_snapshot_small.py +454 -0
  29. dp_cli-0.6.0/dp_cli/snapshot/utils.py +0 -43
  30. dp_cli-0.6.0/dp_cli.egg-info/requires.txt +0 -5
  31. dp_cli-0.6.0/pyproject.toml +0 -37
  32. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/__init__.py +0 -0
  33. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/__init__.py +0 -0
  34. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/browser.py +0 -0
  35. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/element.py +0 -0
  36. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/page.py +0 -0
  37. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/tab.py +0 -0
  38. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/locators/__init__.py +0 -0
  39. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/locators/playwright.py +0 -0
  40. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/locators/pw_js.py +0 -0
  41. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/main.py +0 -0
  42. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/session.py +0 -0
  43. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/__init__.py +0 -0
  44. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/clickable_js.py +0 -0
  45. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/stealth.py +0 -0
  46. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/dependency_links.txt +0 -0
  47. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/entry_points.txt +0 -0
  48. {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/top_level.txt +0 -0
  49. {dp_cli-0.6.0 → dp_cli-0.6.3}/setup.cfg +0 -0
  50. {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_bridge_integration.py +0 -0
  51. {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_clickable.py +0 -0
  52. {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_pw_locator.py +0 -0
  53. {dp_cli-0.6.0 → dp_cli-0.6.3}/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.6.0
3
+ Version: 0.6.3
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
@@ -18,6 +18,11 @@ Requires-Dist: click>=8.0
18
18
  Requires-Dist: aiohttp>=3.9
19
19
  Requires-Dist: websockets>=12
20
20
  Requires-Dist: requests>=2.28
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7.4.0; extra == "test"
23
+ Requires-Dist: pytest-cov>=4.1.0; extra == "test"
24
+ Requires-Dist: pytest-timeout>=2.1.0; extra == "test"
25
+ Requires-Dist: pytest-mock>=3.11.0; extra == "test"
21
26
 
22
27
  # dp-cli
23
28
 
@@ -133,9 +138,12 @@ Every element gets an `[N]` ref usable in any command: `dp click "ref:5"`.
133
138
 
134
139
  ```bash
135
140
  dp snapshot # a11y + clickable (default); high + medium markers
136
- dp snapshot --viewport-only # clickable probe limited to viewport (faster)
137
- dp snapshot --include-low # also surface `?` low-confidence heuristics
138
- dp snapshot --no-clickables # a11y tree only (legacy behavior)
141
+ dp snapshot -i # interactive mode: only interactive elements
142
+ dp snapshot -s ".main" # limit to specific area
143
+ dp snapshot -p "data-test" # custom locator priority
144
+ dp scan --viewport # only elements currently in viewport
145
+ dp scan --confidence all # include low-confidence heuristics
146
+ dp scan --confidence high # only the sure-thing clickables
139
147
  ```
140
148
 
141
149
  ### `dp scan` — fast clickable-only listing
@@ -239,7 +247,7 @@ GPU or Xvfb environment.
239
247
 
240
248
  ```bash
241
249
  # 1. Discover CSS class names via noise-filtered content tree
242
- dp snapshot --mode content --max-text 40
250
+ dp snapshot -i -s ".main"
243
251
 
244
252
  # 2. Verify field selectors
245
253
  dp query "css:.item-title" --fields "text,loc"
@@ -250,7 +258,7 @@ dp extract "css:.item-card" \
250
258
  "price":"css:.item-price",
251
259
  "tags":{"selector":"css:.tag","multi":true},
252
260
  "url":{"selector":"css:a","attr":"href"}}' \
253
- --limit 100 --output csv --filename result.csv
261
+ --limit 100 -o csv -f result.csv
254
262
  ```
255
263
 
256
264
  ## Project Structure
@@ -112,9 +112,12 @@ Every element gets an `[N]` ref usable in any command: `dp click "ref:5"`.
112
112
 
113
113
  ```bash
114
114
  dp snapshot # a11y + clickable (default); high + medium markers
115
- dp snapshot --viewport-only # clickable probe limited to viewport (faster)
116
- dp snapshot --include-low # also surface `?` low-confidence heuristics
117
- dp snapshot --no-clickables # a11y tree only (legacy behavior)
115
+ dp snapshot -i # interactive mode: only interactive elements
116
+ dp snapshot -s ".main" # limit to specific area
117
+ dp snapshot -p "data-test" # custom locator priority
118
+ dp scan --viewport # only elements currently in viewport
119
+ dp scan --confidence all # include low-confidence heuristics
120
+ dp scan --confidence high # only the sure-thing clickables
118
121
  ```
119
122
 
120
123
  ### `dp scan` — fast clickable-only listing
@@ -218,7 +221,7 @@ GPU or Xvfb environment.
218
221
 
219
222
  ```bash
220
223
  # 1. Discover CSS class names via noise-filtered content tree
221
- dp snapshot --mode content --max-text 40
224
+ dp snapshot -i -s ".main"
222
225
 
223
226
  # 2. Verify field selectors
224
227
  dp query "css:.item-title" --fields "text,loc"
@@ -229,7 +232,7 @@ dp extract "css:.item-card" \
229
232
  "price":"css:.item-price",
230
233
  "tags":{"selector":"css:.tag","multi":true},
231
234
  "url":{"selector":"css:a","attr":"href"}}' \
232
- --limit 100 --output csv --filename result.csv
235
+ --limit 100 -o csv -f result.csv
233
236
  ```
234
237
 
235
238
  ## Project Structure
@@ -464,11 +464,29 @@ async def main_async(user_data_dir: Path, host: str, port: int) -> None:
464
464
  # 等待终止信号
465
465
  stop_evt = asyncio.Event()
466
466
  loop = asyncio.get_running_loop()
467
- for sig in (signal.SIGINT, signal.SIGTERM):
468
- try:
469
- loop.add_signal_handler(sig, stop_evt.set)
470
- except NotImplementedError:
471
- pass
467
+
468
+ if sys.platform == 'win32':
469
+ # Windows ProactorEventLoop 不支持 add_signal_handler。
470
+ # 改用 signal.signal 拦截 SIGINT / SIGBREAK(来自 CTRL_BREAK_EVENT)。
471
+ # signal handler 跑在主线程的信号上下文中,必须用 call_soon_threadsafe
472
+ # 通知 event loop。
473
+ def _win_signal_handler(signum, frame):
474
+ loop.call_soon_threadsafe(stop_evt.set)
475
+
476
+ for sig_name in ('SIGINT', 'SIGBREAK', 'SIGTERM'):
477
+ sig = getattr(signal, sig_name, None)
478
+ if sig is None:
479
+ continue
480
+ try:
481
+ signal.signal(sig, _win_signal_handler)
482
+ except (ValueError, OSError):
483
+ pass
484
+ else:
485
+ for sig in (signal.SIGINT, signal.SIGTERM):
486
+ try:
487
+ loop.add_signal_handler(sig, stop_evt.set)
488
+ except NotImplementedError:
489
+ pass
472
490
  try:
473
491
  await stop_evt.wait()
474
492
  finally:
@@ -8,8 +8,10 @@ chrome://inspect 桥接进程生命周期管理
8
8
  - start_bridge(user_data_dir): spawn `python -m dp_cli.bridge` 子进程,
9
9
  等待其向 stdout 打印 "BRIDGE_READY host=... port=..." 标记后返回 (pid, port)。
10
10
 
11
- - stop_bridge(pid): 向子进程发 SIGTERM;如 2 秒未退出再 SIGKILL。
12
- - is_bridge_alive(pid): OS 级存在性检查。
11
+ - stop_bridge(pid): 向子进程发终止信号;如 2 秒未退出再强杀。
12
+ POSIX: SIGTERM → SIGKILL(针对整个进程组)。
13
+ Windows: CTRL_BREAK_EVENT → taskkill /F /T。
14
+ - is_bridge_alive(pid): OS 级存在性检查(Windows 走 OpenProcess)。
13
15
  """
14
16
 
15
17
  from __future__ import annotations
@@ -22,6 +24,59 @@ import sys
22
24
  import time
23
25
  from pathlib import Path
24
26
 
27
+ IS_WINDOWS = sys.platform == 'win32'
28
+
29
+
30
+ def _detach_spawn_kwargs() -> dict:
31
+ """让 bridge 子进程脱离父进程的信号/控制台分组。
32
+
33
+ POSIX: ``start_new_session=True`` → setsid,使 bridge 自成进程组,
34
+ 父进程 Ctrl-C 不会传递过来。
35
+ Windows: ``CREATE_NEW_PROCESS_GROUP`` 让我们后续可以发 CTRL_BREAK_EVENT;
36
+ ``CREATE_NO_WINDOW`` 避免在 GUI/服务环境弹出黑色控制台窗口。
37
+ """
38
+ if IS_WINDOWS:
39
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
40
+ CREATE_NO_WINDOW = 0x08000000
41
+ return {'creationflags': CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW}
42
+ return {'start_new_session': True}
43
+
44
+
45
+ def _win_pid_alive(pid: int) -> bool:
46
+ """Windows: 用 OpenProcess + GetExitCodeProcess 判断进程是否存活。"""
47
+ import ctypes
48
+ from ctypes import wintypes
49
+
50
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
51
+ STILL_ACTIVE = 259
52
+ kernel32 = ctypes.windll.kernel32
53
+ kernel32.OpenProcess.restype = wintypes.HANDLE
54
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
55
+ h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
56
+ if not h:
57
+ return False
58
+ try:
59
+ code = wintypes.DWORD()
60
+ if not kernel32.GetExitCodeProcess(h, ctypes.byref(code)):
61
+ return False
62
+ return code.value == STILL_ACTIVE
63
+ finally:
64
+ kernel32.CloseHandle(h)
65
+
66
+
67
+ def _win_terminate(pid: int) -> None:
68
+ """Windows: taskkill /F /T 强杀进程及其子进程树。"""
69
+ try:
70
+ subprocess.run(
71
+ ['taskkill', '/F', '/T', '/PID', str(pid)],
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ check=False,
75
+ timeout=5,
76
+ )
77
+ except Exception:
78
+ pass
79
+
25
80
 
26
81
  _READY_RE = re.compile(r'^BRIDGE_READY host=(?P<host>\S+) port=(?P<port>\d+)\s*$')
27
82
 
@@ -82,14 +137,15 @@ def start_bridge(user_data_dir: str | os.PathLike,
82
137
  '--listen', str(listen_port),
83
138
  '-v',
84
139
  ]
85
- # start_new_session 让 bridge 成为独立进程组,防止父进程 SIGINT 误杀
140
+ # 让 bridge 成为独立进程组,防止父进程 SIGINT/Ctrl-C 误杀。
141
+ # POSIX 走 start_new_session;Windows 走 CREATE_NEW_PROCESS_GROUP。
86
142
  proc = subprocess.Popen(
87
143
  cmd,
88
144
  stdout=subprocess.PIPE,
89
145
  stderr=subprocess.PIPE,
90
146
  text=True,
91
- start_new_session=True,
92
147
  bufsize=1, # 行缓冲
148
+ **_detach_spawn_kwargs(),
93
149
  )
94
150
 
95
151
  # 立即提示用户:bridge 正在连接;若 Chrome 弹出授权框请点击。
@@ -173,6 +229,8 @@ def start_bridge(user_data_dir: str | os.PathLike,
173
229
  def is_bridge_alive(pid: int) -> bool:
174
230
  if pid <= 0:
175
231
  return False
232
+ if IS_WINDOWS:
233
+ return _win_pid_alive(pid)
176
234
  try:
177
235
  os.kill(pid, 0)
178
236
  return True
@@ -185,18 +243,37 @@ def is_bridge_alive(pid: int) -> bool:
185
243
 
186
244
 
187
245
  def stop_bridge(pid: int, timeout: float = 2.0) -> bool:
188
- """停止 bridge 子进程。返回是否成功终止。"""
246
+ """停止 bridge 子进程。返回是否成功终止。
247
+
248
+ POSIX: SIGTERM 整个进程组 → 等待 → SIGKILL。
249
+ Windows: CTRL_BREAK_EVENT(依赖 spawn 时的 CREATE_NEW_PROCESS_GROUP)
250
+ → 等待 → taskkill /F /T 终止进程树。
251
+ """
189
252
  if not is_bridge_alive(pid):
190
253
  return True
191
- # 先 SIGTERM 整个进程组(start_new_session 让 bridge 自成组)
192
- try:
193
- os.killpg(pid, signal.SIGTERM)
194
- except (ProcessLookupError, PermissionError):
254
+
255
+ if IS_WINDOWS:
256
+ # 1) 优雅: 给整个进程组发 CTRL_BREAK_EVENT
195
257
  try:
196
- os.kill(pid, signal.SIGTERM)
258
+ os.kill(pid, signal.CTRL_BREAK_EVENT)
197
259
  except Exception:
198
260
  pass
199
- except Exception:
261
+
262
+ deadline = time.monotonic() + timeout
263
+ while time.monotonic() < deadline:
264
+ if not is_bridge_alive(pid):
265
+ return True
266
+ time.sleep(0.05)
267
+
268
+ # 2) 强杀: taskkill /F /T 终止进程树
269
+ _win_terminate(pid)
270
+ time.sleep(0.1)
271
+ return not is_bridge_alive(pid)
272
+
273
+ # POSIX: 先 SIGTERM 整个进程组(start_new_session 让 bridge 自成组)
274
+ try:
275
+ os.killpg(pid, signal.SIGTERM)
276
+ except (ProcessLookupError, PermissionError, OSError):
200
277
  try:
201
278
  os.kill(pid, signal.SIGTERM)
202
279
  except Exception:
@@ -21,7 +21,7 @@ def normalize_url(url: str) -> str:
21
21
 
22
22
 
23
23
  def session_option(f):
24
- return click.option('-s', '--session', default='default',
24
+ return click.option('-S', '--session', default='default',
25
25
  help='会话名称,默认 default', show_default=True)(f)
26
26
 
27
27
 
@@ -9,6 +9,20 @@ from dp_cli.commands._utils import (
9
9
  session_option, _get_page, resolve_locator, wait_network_idle,
10
10
  )
11
11
 
12
+ # ponytail: 提取重复的 findScrollable JS 函数
13
+ _FIND_SCROLLABLE_JS = """
14
+ function findScrollable(el) {
15
+ while (el && el !== document.body && el !== document.documentElement) {
16
+ const st = getComputedStyle(el);
17
+ const canY = /(auto|scroll|overlay)/.test(st.overflowY) && el.scrollHeight > el.clientHeight + 1;
18
+ const canX = /(auto|scroll|overlay)/.test(st.overflowX) && el.scrollWidth > el.clientWidth + 1;
19
+ if (canY || canX) return el;
20
+ el = el.parentElement;
21
+ }
22
+ return document.scrollingElement || document.documentElement;
23
+ }
24
+ """
25
+
12
26
 
13
27
  def register(cli):
14
28
 
@@ -20,13 +34,17 @@ def register(cli):
20
34
 
21
35
  \b
22
36
  支持的按键: Enter, Tab, Escape, Space, Backspace,
23
- ArrowUp/Down/Left/Right, F1-F12,
24
- Control+A, Shift+Enter, Alt+F4 等组合键。
37
+ ArrowUp/Down/Left/Right, F1-F12, Home, End,
38
+ PageUp/PageDown, Insert, Delete 等。
39
+ 组合键: Control+A, Shift+Enter, Alt+F4,
40
+ 支持多修饰键如 Control+Shift+Delete。
25
41
 
26
42
  \b
27
43
  示例:
28
44
  dp press Enter
29
45
  dp press "Control+A"
46
+ dp press "Control+Shift+Delete"
47
+ dp press F5
30
48
  dp press Escape
31
49
  """
32
50
  page = _get_page(session)
@@ -38,20 +56,46 @@ def register(cli):
38
56
  'arrowup': '\ue013', 'arrowdown': '\ue015',
39
57
  'arrowleft': '\ue012', 'arrowright': '\ue014',
40
58
  }
41
- k = key.lower()
59
+ mod_map = {'control': Keys.CTRL, 'ctrl': Keys.CTRL,
60
+ 'shift': Keys.SHIFT, 'alt': Keys.ALT,
61
+ 'meta': Keys.META, 'cmd': Keys.COMMAND,
62
+ 'command': Keys.COMMAND}
63
+ # 常见键名别名 → Keys 枚举名(枚举用下划线风格,如 PAGE_UP;
64
+ # 映射失败会回退为字面输入,带修饰键时会误触浏览器快捷键,
65
+ # 所以别名必须覆盖常用名)
66
+ key_alias = {
67
+ 'pageup': 'PAGE_UP', 'pgup': 'PAGE_UP',
68
+ 'pagedown': 'PAGE_DOWN', 'pgdn': 'PAGE_DOWN',
69
+ 'del': 'DEL', 'esc': 'ESCAPE', 'win': 'META',
70
+ }
71
+
72
+ def _key_token(name):
73
+ """键名 → Keys 枚举/特殊键码;未知键按字面输入。"""
74
+ low = name.strip().lower()
75
+ if low in key_map:
76
+ return key_map[low]
77
+ attr = key_alias.get(
78
+ low, low.upper().replace('-', '_').replace(' ', '_'))
79
+ k = getattr(Keys, attr, None)
80
+ return k if k is not None else name.strip()
81
+
42
82
  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])
83
+ # 前缀修饰键逐个出栈,支持 Control+Shift+Delete 多修饰组合
84
+ parts = [p for p in key.split('+') if p.strip()]
85
+ mods = []
86
+ while len(parts) > 1 and parts[0].strip().lower() in mod_map:
87
+ mods.append(mod_map[parts.pop(0).strip().lower()])
88
+ if mods and parts:
89
+ act = page.actions
90
+ for m in mods:
91
+ act = act.key_down(m)
92
+ act = act.type(_key_token('+'.join(parts)))
93
+ for m in reversed(mods):
94
+ act = act.key_up(m)
50
95
  else:
51
96
  page.actions.type(key)
52
97
  else:
53
- actual_key = key_map.get(k, key)
54
- page.actions.type(actual_key)
98
+ page.actions.type(_key_token(key))
55
99
  ok({'key': key}, msg='按键成功')
56
100
  except Exception as e:
57
101
  error(f'按键失败: {key}', code='KEY_FAILED', detail=str(e))
@@ -142,32 +186,22 @@ def register(cli):
142
186
 
143
187
  if mouse_x is not None and mouse_y is not None:
144
188
  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
- }
189
+ f"""{_FIND_SCROLLABLE_JS}
156
190
  const start = document.elementFromPoint(arguments[2], arguments[3]);
157
191
  const target = findScrollable(start);
158
- const before = {scrollTop: target.scrollTop, scrollLeft: target.scrollLeft};
159
- if (arguments[4]) {
192
+ const before = {{scrollTop: target.scrollTop, scrollLeft: target.scrollLeft}};
193
+ if (arguments[4]) {{
160
194
  target.scrollTop = 0;
161
- } else if (arguments[5]) {
195
+ }} else if (arguments[5]) {{
162
196
  target.scrollTop = target.scrollHeight;
163
- } else {
197
+ }} else {{
164
198
  target.scrollTop += arguments[1];
165
199
  target.scrollLeft += arguments[0];
166
- }
167
- target.dispatchEvent(new Event('scroll', {bubbles: true}));
168
- return {
200
+ }}
201
+ target.dispatchEvent(new Event('scroll', {{bubbles: true}}));
202
+ return {{
169
203
  before,
170
- after: {scrollTop: target.scrollTop, scrollLeft: target.scrollLeft},
204
+ after: {{scrollTop: target.scrollTop, scrollLeft: target.scrollLeft}},
171
205
  scrollHeight: target.scrollHeight,
172
206
  clientHeight: target.clientHeight,
173
207
  scrollWidth: target.scrollWidth,
@@ -176,8 +210,8 @@ def register(cli):
176
210
  id: target.id || '',
177
211
  className: target.className || '',
178
212
  mode: 'mouse'
179
- };
180
- """,
213
+ }};
214
+ }}""",
181
215
  x, y, mouse_x, mouse_y, top, bottom,
182
216
  )
183
217
  ok({'x': x, 'y': y, 'mouse': {'x': mouse_x, 'y': mouse_y},
@@ -292,19 +326,9 @@ def register(cli):
292
326
  return int(target.run_js('return this.scrollHeight'))
293
327
  if use_mouse_container:
294
328
  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
- }
329
+ f"""{_FIND_SCROLLABLE_JS}
305
330
  const target = findScrollable(document.elementFromPoint(arguments[0], arguments[1]));
306
- return target.scrollHeight;
307
- """,
331
+ return target.scrollHeight;""",
308
332
  mouse_x, mouse_y,
309
333
  ))
310
334
  return int(page.run_js('return document.documentElement.scrollHeight'))
@@ -329,22 +353,13 @@ def register(cli):
329
353
  )
330
354
  elif use_mouse_container:
331
355
  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
- }
356
+ f"""{_FIND_SCROLLABLE_JS}
342
357
  const target = findScrollable(document.elementFromPoint(arguments[0], arguments[1]));
343
358
  const before = target.scrollTop;
344
359
  const delta = arguments[2] > 0 ? arguments[2] : Math.max(300, Math.floor(target.clientHeight * arguments[3]));
345
360
  target.scrollTop += delta;
346
- target.dispatchEvent(new Event('scroll', {bubbles: true}));
347
- return {
361
+ target.dispatchEvent(new Event('scroll', {{bubbles: true}}));
362
+ return {{
348
363
  before,
349
364
  after: target.scrollTop,
350
365
  delta,
@@ -354,8 +369,7 @@ def register(cli):
354
369
  id: target.id || '',
355
370
  className: target.className || '',
356
371
  mode: 'mouse'
357
- };
358
- """,
372
+ }};""",
359
373
  mouse_x, mouse_y, step, 3 if fast else 0.9,
360
374
  )
361
375
  else:
@@ -134,3 +134,5 @@ def register(cli):
134
134
  co.set_user_data_path(user_path)
135
135
  co.save()
136
136
  ok(msg='配置已保存')
137
+ elif not copy_config:
138
+ click.echo('请至少指定一个选项。使用 --help 查看可用参数。')
@@ -9,13 +9,46 @@ from dp_cli.output import ok, error
9
9
  from dp_cli.commands._utils import session_option, _get_page
10
10
 
11
11
 
12
+ def _emit_response(page, output):
13
+ """构造 http-get/http-post 的输出。
14
+
15
+ --output 指定时把原始响应字节写入文件(兼容二进制),
16
+ 否则把 body 内联到结果(JSON 优先,文本截断 3000 字)。
17
+ """
18
+ resp = page.response
19
+ result = {
20
+ 'url': page.url,
21
+ 'status_code': resp.status_code if resp is not None else None,
22
+ }
23
+ if output:
24
+ # response 为 None(请求失败未拿到响应)时写空文件,避免 AttributeError
25
+ Path(output).write_bytes(resp.content if resp is not None else b'')
26
+ result['saved_to'] = output
27
+ if resp is None:
28
+ result['warning'] = '无响应内容(response is None),已写入空文件'
29
+ return result
30
+ if resp is None:
31
+ result['body'] = None
32
+ return result
33
+ try:
34
+ result['body'] = resp.json()
35
+ except Exception:
36
+ try:
37
+ result['body'] = resp.text[:3000]
38
+ except Exception:
39
+ result['body'] = '<binary>'
40
+ return result
41
+
42
+
12
43
  def register(cli):
13
44
 
14
45
  @cli.command('listen')
15
46
  @session_option
16
47
  @click.option('--filter', 'url_filter', default=None, help='URL 过滤关键字,如 "api/user"')
17
- @click.option('--count', default=10, help='最多捕获请求数', show_default=True)
18
- @click.option('--timeout', default=30, help='监听超时秒数', show_default=True)
48
+ # --count/--timeout 对 listen.start 无意义(作用于 listen-stop 的 wait),
49
+ # 保留参数兼容性但从 help 隐藏,避免误导
50
+ @click.option('--count', default=10, hidden=True)
51
+ @click.option('--timeout', default=30, hidden=True)
19
52
  @click.option('--method', default=None, help='过滤请求方法,如 POST')
20
53
  def listen(session, url_filter, count, timeout, method):
21
54
  """监听网络请求(抓包)。先 listen,再执行触发操作,最后 listen-stop 读取结果。
@@ -26,7 +59,7 @@ def register(cli):
26
59
  \b
27
60
  示例:
28
61
  dp listen --filter "api/login"
29
- dp listen --count 5 --timeout 10
62
+ dp listen --method POST
30
63
  """
31
64
  page = _get_page(session)
32
65
  try:
@@ -44,7 +77,11 @@ def register(cli):
44
77
  """停止监听并获取捕获的网络请求数据。"""
45
78
  page = _get_page(session)
46
79
  try:
47
- packets = page.listen.wait(count=count, timeout=timeout, fit_count=False)
80
+ # finally 中 stop:wait 超时/出错也必须停止监听,否则监听器残留
81
+ try:
82
+ packets = page.listen.wait(count=count, timeout=timeout, fit_count=False)
83
+ finally:
84
+ page.listen.stop()
48
85
  results = []
49
86
  if packets:
50
87
  pkts = packets if isinstance(packets, list) else [packets]
@@ -63,7 +100,6 @@ def register(cli):
63
100
  results.append(item)
64
101
  except Exception:
65
102
  continue
66
- page.listen.stop()
67
103
  ok({'packets': results, 'count': len(results)})
68
104
  except Exception as e:
69
105
  error(f'获取监听数据失败', code='LISTEN_FAILED', detail=str(e))
@@ -95,25 +131,7 @@ def register(cli):
95
131
 
96
132
  page = SessionPage(session_or_options=so)
97
133
  page.get(url, timeout=timeout)
98
-
99
- result = {
100
- 'url': page.url,
101
- 'status_code': page.response.status_code if page.response else None,
102
- }
103
- try:
104
- result['body'] = page.response.json()
105
- except Exception:
106
- try:
107
- body_text = page.response.text[:3000]
108
- except Exception:
109
- body_text = '<binary>'
110
- if output:
111
- Path(output).write_text(page.response.text, encoding='utf-8')
112
- result['saved_to'] = output
113
- else:
114
- result['body'] = body_text
115
-
116
- ok(result)
134
+ ok(_emit_response(page, output))
117
135
  except Exception as e:
118
136
  error(f'HTTP GET 失败', code='HTTP_FAILED', detail=str(e))
119
137
 
@@ -124,7 +142,8 @@ def register(cli):
124
142
  @click.option('--headers', default=None, help='JSON 格式的 Headers')
125
143
  @click.option('--proxy', default=None, help='代理地址')
126
144
  @click.option('--timeout', default=30, help='超时秒数', show_default=True)
127
- def http_post(url, data, form, headers, proxy, timeout):
145
+ @click.option('--output', default=None, help='响应体保存路径')
146
+ def http_post(url, data, form, headers, proxy, timeout, output):
128
147
  """发送 HTTP POST 请求(不启动浏览器)。
129
148
 
130
149
  \b
@@ -151,19 +170,6 @@ def register(cli):
151
170
  kwargs['data'] = json.loads(form)
152
171
 
153
172
  page.post(url, **kwargs)
154
-
155
- result = {
156
- 'url': page.url,
157
- 'status_code': page.response.status_code if page.response else None,
158
- }
159
- try:
160
- result['body'] = page.response.json()
161
- except Exception:
162
- try:
163
- result['body'] = page.response.text[:3000]
164
- except Exception:
165
- result['body'] = '<binary>'
166
-
167
- ok(result)
173
+ ok(_emit_response(page, output))
168
174
  except Exception as e:
169
175
  error(f'HTTP POST 失败', code='HTTP_FAILED', detail=str(e))