dp-cli 0.5.0__tar.gz → 0.6.2__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.5.0 → dp_cli-0.6.2}/PKG-INFO +27 -18
  2. {dp_cli-0.5.0 → dp_cli-0.6.2}/README.md +21 -17
  3. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/bridge.py +23 -5
  4. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/bridge_manager.py +93 -15
  5. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/_utils.py +1 -1
  6. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/browser.py +33 -13
  7. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/keyboard.py +31 -47
  8. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/snapshot_cmd.py +40 -20
  9. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/output.py +4 -37
  10. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/recorder.py +61 -97
  11. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/session.py +60 -5
  12. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/a11y.py +297 -63
  13. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/clickable.py +13 -5
  14. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/extract.py +2 -2
  15. dp_cli-0.6.2/dp_cli/snapshot/utils.py +70 -0
  16. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/PKG-INFO +27 -18
  17. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/SOURCES.txt +6 -1
  18. dp_cli-0.6.2/dp_cli.egg-info/requires.txt +11 -0
  19. dp_cli-0.6.2/pyproject.toml +88 -0
  20. dp_cli-0.6.2/tests/test_a11y.py +661 -0
  21. {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_bridge_manager.py +9 -6
  22. dp_cli-0.6.2/tests/test_commands.py +389 -0
  23. dp_cli-0.6.2/tests/test_recorder.py +473 -0
  24. dp_cli-0.6.2/tests/test_session.py +804 -0
  25. dp_cli-0.6.2/tests/test_snapshot_small.py +454 -0
  26. dp_cli-0.5.0/dp_cli/snapshot/utils.py +0 -43
  27. dp_cli-0.5.0/dp_cli.egg-info/requires.txt +0 -5
  28. dp_cli-0.5.0/pyproject.toml +0 -37
  29. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/__init__.py +0 -0
  30. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/__init__.py +0 -0
  31. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/element.py +0 -0
  32. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/misc.py +0 -0
  33. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/network.py +0 -0
  34. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/page.py +0 -0
  35. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/record.py +0 -0
  36. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/storage.py +0 -0
  37. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/tab.py +0 -0
  38. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/locators/__init__.py +0 -0
  39. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/locators/playwright.py +0 -0
  40. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/locators/pw_js.py +0 -0
  41. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/main.py +0 -0
  42. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/__init__.py +0 -0
  43. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/clickable_js.py +0 -0
  44. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/js_scripts.py +0 -0
  45. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/stealth.py +0 -0
  46. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/dependency_links.txt +0 -0
  47. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/entry_points.txt +0 -0
  48. {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/top_level.txt +0 -0
  49. {dp_cli-0.5.0 → dp_cli-0.6.2}/setup.cfg +0 -0
  50. {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_bridge_integration.py +0 -0
  51. {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_clickable.py +0 -0
  52. {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_pw_locator.py +0 -0
  53. {dp_cli-0.5.0 → dp_cli-0.6.2}/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.5.0
3
+ Version: 0.6.2
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
 
@@ -26,7 +31,7 @@ A powerful CLI for [DrissionPage](https://github.com/g1879/DrissionPage) — bro
26
31
  ## Features
27
32
 
28
33
  - **Anti-detection by default** — not based on webdriver, `navigator.webdriver` is `false`
29
- - **Reuse your own browser** — `--auto-connect` (Chrome 144+, no CLI flag needed) or `--port`
34
+ - **Reuse your own browser** — `--auto-connect` (Chrome/Edge 144+, no CLI flag needed) or `--port`
30
35
  - **Hybrid snapshot** — a11y tree + Vimium-style clickable detection, catches icon-only buttons
31
36
  and custom menu items the a11y tree misses; every element gets an `[N]` ref with
32
37
  confidence markers (`⚡` medium, `?` low)
@@ -64,25 +69,26 @@ dp open https://example.com --port 9222
64
69
  dp snapshot
65
70
  ```
66
71
 
67
- ## Connect to a Normally-Launched Chrome (Chrome 144+)
72
+ ## Connect to a Normally-Launched Chrome/Edge (Chrome/Edge 144+)
68
73
 
69
- No `--remote-debugging-port` required. Chrome 144+ exposes opt-in remote debugging
70
- via `chrome://inspect`:
74
+ No `--remote-debugging-port` required. Chrome/Edge 144+ exposes opt-in remote debugging
75
+ via `chrome://inspect` (Chrome) or `edge://inspect/#devices` (Edge):
71
76
 
72
- 1. Open your Chrome as usual (no special flags)
73
- 2. Visit `chrome://inspect/#remote-debugging`
77
+ 1. Open your Chrome or Edge as usual (no special flags)
78
+ 2. Visit `chrome://inspect/#remote-debugging` (Chrome) or `edge://inspect/#devices` (Edge)
74
79
  3. Check **"Allow remote debugging for this browser instance"**
75
80
  4. Run `dp open --auto-connect`
76
81
 
77
82
  ```bash
78
- dp open --auto-connect # stable channel, default profile
79
- dp open --auto-connect --channel beta # pick a different channel
83
+ dp open --auto-connect # auto channel: sniffs Chrome stable → Edge stable
84
+ dp open --auto-connect --channel edge # force Edge stable
85
+ dp open --auto-connect --channel beta # Chrome beta
80
86
  dp open --auto-connect --probe-dir ~/my-profile # custom user-data-dir
81
87
  ```
82
88
 
83
89
  ### How it works
84
90
 
85
- Chrome 144+ in this mode exposes **only** a browser-level WebSocket and omits the HTTP
91
+ Chrome/Edge 144+ in this mode exposes **only** a browser-level WebSocket and omits the HTTP
86
92
  REST API (`/json`, `/json/version`, ...) that DrissionPage / puppeteer / Playwright
87
93
  depend on. `dp-cli` transparently handles this:
88
94
 
@@ -95,14 +101,14 @@ depend on. `dp-cli` transparently handles this:
95
101
  4. Points DrissionPage at the bridge. Subsequent `dp` commands reuse the same bridge.
96
102
 
97
103
  The bridge subprocess and its port are tracked in the session file; `dp close` stops
98
- the bridge automatically and never quits your Chrome (it's your browser, not dp's).
104
+ the bridge automatically and never quits your browser (it's your browser, not dp's).
99
105
 
100
106
  ### Caveats
101
107
 
102
- - Chrome always shows an **"Allow remote debugging"** dialog per new WebSocket client.
108
+ - Chrome/Edge always shows an **"Allow remote debugging"** dialog per new WebSocket client.
103
109
  Since bridge maintains one WebSocket and dp commands share it, you confirm at most
104
110
  once per `dp open --auto-connect`.
105
- - Works with whatever profile Chrome is actually using — same cookies, logins, history.
111
+ - Works with whatever profile Chrome/Edge is actually using — same cookies, logins, history.
106
112
  - Classic `--remote-debugging-port=9222` mode still works unchanged via `dp open --port 9222`.
107
113
 
108
114
  ## Hybrid Snapshot (a11y + Vimium-style)
@@ -132,9 +138,12 @@ Every element gets an `[N]` ref usable in any command: `dp click "ref:5"`.
132
138
 
133
139
  ```bash
134
140
  dp snapshot # a11y + clickable (default); high + medium markers
135
- dp snapshot --viewport-only # clickable probe limited to viewport (faster)
136
- dp snapshot --include-low # also surface `?` low-confidence heuristics
137
- 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
138
147
  ```
139
148
 
140
149
  ### `dp scan` — fast clickable-only listing
@@ -238,7 +247,7 @@ GPU or Xvfb environment.
238
247
 
239
248
  ```bash
240
249
  # 1. Discover CSS class names via noise-filtered content tree
241
- dp snapshot --mode content --max-text 40
250
+ dp snapshot -i -s ".main"
242
251
 
243
252
  # 2. Verify field selectors
244
253
  dp query "css:.item-title" --fields "text,loc"
@@ -249,7 +258,7 @@ dp extract "css:.item-card" \
249
258
  "price":"css:.item-price",
250
259
  "tags":{"selector":"css:.tag","multi":true},
251
260
  "url":{"selector":"css:a","attr":"href"}}' \
252
- --limit 100 --output csv --filename result.csv
261
+ --limit 100 -o csv -f result.csv
253
262
  ```
254
263
 
255
264
  ## Project Structure
@@ -5,7 +5,7 @@ A powerful CLI for [DrissionPage](https://github.com/g1879/DrissionPage) — bro
5
5
  ## Features
6
6
 
7
7
  - **Anti-detection by default** — not based on webdriver, `navigator.webdriver` is `false`
8
- - **Reuse your own browser** — `--auto-connect` (Chrome 144+, no CLI flag needed) or `--port`
8
+ - **Reuse your own browser** — `--auto-connect` (Chrome/Edge 144+, no CLI flag needed) or `--port`
9
9
  - **Hybrid snapshot** — a11y tree + Vimium-style clickable detection, catches icon-only buttons
10
10
  and custom menu items the a11y tree misses; every element gets an `[N]` ref with
11
11
  confidence markers (`⚡` medium, `?` low)
@@ -43,25 +43,26 @@ dp open https://example.com --port 9222
43
43
  dp snapshot
44
44
  ```
45
45
 
46
- ## Connect to a Normally-Launched Chrome (Chrome 144+)
46
+ ## Connect to a Normally-Launched Chrome/Edge (Chrome/Edge 144+)
47
47
 
48
- No `--remote-debugging-port` required. Chrome 144+ exposes opt-in remote debugging
49
- via `chrome://inspect`:
48
+ No `--remote-debugging-port` required. Chrome/Edge 144+ exposes opt-in remote debugging
49
+ via `chrome://inspect` (Chrome) or `edge://inspect/#devices` (Edge):
50
50
 
51
- 1. Open your Chrome as usual (no special flags)
52
- 2. Visit `chrome://inspect/#remote-debugging`
51
+ 1. Open your Chrome or Edge as usual (no special flags)
52
+ 2. Visit `chrome://inspect/#remote-debugging` (Chrome) or `edge://inspect/#devices` (Edge)
53
53
  3. Check **"Allow remote debugging for this browser instance"**
54
54
  4. Run `dp open --auto-connect`
55
55
 
56
56
  ```bash
57
- dp open --auto-connect # stable channel, default profile
58
- dp open --auto-connect --channel beta # pick a different channel
57
+ dp open --auto-connect # auto channel: sniffs Chrome stable → Edge stable
58
+ dp open --auto-connect --channel edge # force Edge stable
59
+ dp open --auto-connect --channel beta # Chrome beta
59
60
  dp open --auto-connect --probe-dir ~/my-profile # custom user-data-dir
60
61
  ```
61
62
 
62
63
  ### How it works
63
64
 
64
- Chrome 144+ in this mode exposes **only** a browser-level WebSocket and omits the HTTP
65
+ Chrome/Edge 144+ in this mode exposes **only** a browser-level WebSocket and omits the HTTP
65
66
  REST API (`/json`, `/json/version`, ...) that DrissionPage / puppeteer / Playwright
66
67
  depend on. `dp-cli` transparently handles this:
67
68
 
@@ -74,14 +75,14 @@ depend on. `dp-cli` transparently handles this:
74
75
  4. Points DrissionPage at the bridge. Subsequent `dp` commands reuse the same bridge.
75
76
 
76
77
  The bridge subprocess and its port are tracked in the session file; `dp close` stops
77
- the bridge automatically and never quits your Chrome (it's your browser, not dp's).
78
+ the bridge automatically and never quits your browser (it's your browser, not dp's).
78
79
 
79
80
  ### Caveats
80
81
 
81
- - Chrome always shows an **"Allow remote debugging"** dialog per new WebSocket client.
82
+ - Chrome/Edge always shows an **"Allow remote debugging"** dialog per new WebSocket client.
82
83
  Since bridge maintains one WebSocket and dp commands share it, you confirm at most
83
84
  once per `dp open --auto-connect`.
84
- - Works with whatever profile Chrome is actually using — same cookies, logins, history.
85
+ - Works with whatever profile Chrome/Edge is actually using — same cookies, logins, history.
85
86
  - Classic `--remote-debugging-port=9222` mode still works unchanged via `dp open --port 9222`.
86
87
 
87
88
  ## Hybrid Snapshot (a11y + Vimium-style)
@@ -111,9 +112,12 @@ Every element gets an `[N]` ref usable in any command: `dp click "ref:5"`.
111
112
 
112
113
  ```bash
113
114
  dp snapshot # a11y + clickable (default); high + medium markers
114
- dp snapshot --viewport-only # clickable probe limited to viewport (faster)
115
- dp snapshot --include-low # also surface `?` low-confidence heuristics
116
- 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
117
121
  ```
118
122
 
119
123
  ### `dp scan` — fast clickable-only listing
@@ -217,7 +221,7 @@ GPU or Xvfb environment.
217
221
 
218
222
  ```bash
219
223
  # 1. Discover CSS class names via noise-filtered content tree
220
- dp snapshot --mode content --max-text 40
224
+ dp snapshot -i -s ".main"
221
225
 
222
226
  # 2. Verify field selectors
223
227
  dp query "css:.item-title" --fields "text,loc"
@@ -228,7 +232,7 @@ dp extract "css:.item-card" \
228
232
  "price":"css:.item-price",
229
233
  "tags":{"selector":"css:.tag","multi":true},
230
234
  "url":{"selector":"css:a","attr":"href"}}' \
231
- --limit 100 --output csv --filename result.csv
235
+ --limit 100 -o csv -f result.csv
232
236
  ```
233
237
 
234
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,21 +137,23 @@ 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 弹出授权框请点击。
96
152
  # 写 stderr 避免污染 stdout 的 JSON 输出。
97
- print('[dp] 正在启动 bridge 并连接 Chrome', file=sys.stderr, flush=True)
98
- print('[dp] 💡 若 Chrome 弹出 "Allow remote debugging" 对话框,'
99
- '请切到 Chrome 窗口点击 "Allow"(后续命令会自动复用连接)',
153
+ print('[dp] 正在启动 bridge 并连接浏览器(Chrome/Edge)…',
154
+ file=sys.stderr, flush=True)
155
+ print('[dp] 💡 若浏览器弹出 "Allow remote debugging" 对话框,'
156
+ '请切到浏览器窗口点击 "Allow"(后续命令会自动复用连接)',
100
157
  file=sys.stderr, flush=True)
101
158
 
102
159
  # 异步读 stderr,方便 timeout 时回显给用户
@@ -143,7 +200,7 @@ def start_bridge(user_data_dir: str | os.PathLike,
143
200
  proc.terminate()
144
201
  raise RuntimeError(
145
202
  f'bridge 启动超时 ({ready_timeout}s)。\n'
146
- f'若是首次连接:请把 Chrome 窗口切到前台,点击弹出的'
203
+ f'若是首次连接:请把浏览器窗口(Chrome/Edge)切到前台,点击弹出的'
147
204
  f' "Allow remote debugging for this browser instance" 对话框。\n'
148
205
  f'当前 bridge stderr:\n{_collect_stderr()}'
149
206
  )
@@ -172,6 +229,8 @@ def start_bridge(user_data_dir: str | os.PathLike,
172
229
  def is_bridge_alive(pid: int) -> bool:
173
230
  if pid <= 0:
174
231
  return False
232
+ if IS_WINDOWS:
233
+ return _win_pid_alive(pid)
175
234
  try:
176
235
  os.kill(pid, 0)
177
236
  return True
@@ -184,18 +243,37 @@ def is_bridge_alive(pid: int) -> bool:
184
243
 
185
244
 
186
245
  def stop_bridge(pid: int, timeout: float = 2.0) -> bool:
187
- """停止 bridge 子进程。返回是否成功终止。"""
246
+ """停止 bridge 子进程。返回是否成功终止。
247
+
248
+ POSIX: SIGTERM 整个进程组 → 等待 → SIGKILL。
249
+ Windows: CTRL_BREAK_EVENT(依赖 spawn 时的 CREATE_NEW_PROCESS_GROUP)
250
+ → 等待 → taskkill /F /T 终止进程树。
251
+ """
188
252
  if not is_bridge_alive(pid):
189
253
  return True
190
- # 先 SIGTERM 整个进程组(start_new_session 让 bridge 自成组)
191
- try:
192
- os.killpg(pid, signal.SIGTERM)
193
- except (ProcessLookupError, PermissionError):
254
+
255
+ if IS_WINDOWS:
256
+ # 1) 优雅: 给整个进程组发 CTRL_BREAK_EVENT
194
257
  try:
195
- os.kill(pid, signal.SIGTERM)
258
+ os.kill(pid, signal.CTRL_BREAK_EVENT)
196
259
  except Exception:
197
260
  pass
198
- 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):
199
277
  try:
200
278
  os.kill(pid, signal.SIGTERM)
201
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
 
@@ -7,7 +7,8 @@ import click
7
7
  from dp_cli.session import (get_browser, close_browser, list_sessions,
8
8
  delete_session, load_session, save_session,
9
9
  discover_port_from_profile,
10
- default_user_data_dir_for_channel)
10
+ default_user_data_dir_for_channel,
11
+ sniff_auto_user_data_dir)
11
12
  from dp_cli.output import ok, error, format_page_info
12
13
  from dp_cli.commands._utils import session_option, _get_page, normalize_url
13
14
  from dp_cli.stealth import apply_stealth, PRESETS, DEFAULT_UA
@@ -24,11 +25,14 @@ def register(cli):
24
25
  @click.option('--proxy', default=None, help='代理服务器,如 http://127.0.0.1:7890')
25
26
  @click.option('--port', type=int, default=None, help='连接指定端口的已有浏览器实例')
26
27
  @click.option('--auto-connect', '-a', is_flag=True,
27
- help='从用户常规启动的 Chrome 读取 DevToolsActivePort 自动发现端口'
28
- '(需 Chrome 144+,用户在 chrome://inspect/#remote-debugging 启用)')
29
- @click.option('--channel', type=click.Choice(['stable', 'beta', 'dev', 'canary', 'chromium']),
30
- default='stable', show_default=True,
31
- help='配合 --auto-connect 使用,定位默认 user-data-dir')
28
+ help='从用户常规启动的 Chrome/Edge 读取 DevToolsActivePort 自动发现端口'
29
+ '(需 Chrome/Edge 144+,用户在 chrome://inspect/#remote-debugging '
30
+ 'edge://inspect/#devices 启用)')
31
+ @click.option('--channel',
32
+ type=click.Choice(['auto', 'stable', 'beta', 'dev', 'canary', 'chromium', 'edge']),
33
+ default='auto', show_default=True,
34
+ help='配合 --auto-connect 使用,定位默认 user-data-dir;'
35
+ 'auto = 依次嗅探 chrome stable → edge stable,取首个含 DevToolsActivePort 的目录')
32
36
  @click.option('--probe-dir', 'probe_dir', default=None,
33
37
  help='配合 --auto-connect 使用,显式指定要探测的 user-data-dir '
34
38
  '(覆盖 --channel 的默认路径)')
@@ -46,14 +50,16 @@ def register(cli):
46
50
  dp open --port 9222
47
51
 
48
52
  \b
49
- 【复用用户自己的浏览器 - 方式 B:--auto-connect(Chrome 144+ 推荐)】
50
- 无需特殊启动参数,正常打开 Chrome 即可:
51
- 1. 打开 Chrome,访问 chrome://inspect/#remote-debugging
53
+ 【复用用户自己的浏览器 - 方式 B:--auto-connect(Chrome/Edge 144+ 推荐)】
54
+ 无需特殊启动参数,正常打开 Chrome 或 Edge 即可:
55
+ 1. 打开浏览器:
56
+ - Chrome:访问 chrome://inspect/#remote-debugging
57
+ - Edge: 访问 edge://inspect/#devices
52
58
  2. 勾选 "Allow remote debugging for this browser instance"
53
- 3. dp open --auto-connect
54
- dp 会从 Chrome user-data-dir 自动读取 DevToolsActivePort 拿到端口。
55
- 指定非 stable 渠道:dp open --auto-connect --channel beta
56
- 指定自定义 profile:dp open --auto-connect --probe-dir ~/my-chrome-profile
59
+ 3. dp open --auto-connect # 默认 auto:先嗅探 Chrome,再 Edge
60
+ dp open --auto-connect --channel edge # 强制使用 Edge stable
61
+ dp open --auto-connect --channel beta # 使用 Chrome beta
62
+ dp open --auto-connect --probe-dir ~/my-profile # 自定义 profile
57
63
 
58
64
  \b
59
65
  【dp 自动管理浏览器】
@@ -83,6 +89,20 @@ def register(cli):
83
89
  return
84
90
  if probe_dir:
85
91
  dir_to_probe = Path(probe_dir).expanduser()
92
+ elif channel == 'auto':
93
+ # 自动嗅探:chrome stable → edge stable,取首个含 DevToolsActivePort 的目录
94
+ hit, diag = sniff_auto_user_data_dir()
95
+ if hit is None:
96
+ detail = '\n'.join(f' - {ch:7s} {p} [{reason}]'
97
+ for ch, p, reason in diag)
98
+ error('自动嗅探未找到含 DevToolsActivePort 的 user-data-dir。\n'
99
+ '请先在 Chrome (chrome://inspect/#remote-debugging) 或 '
100
+ 'Edge (edge://inspect/#devices) 启用远程调试,\n'
101
+ '或用 --channel/--probe-dir 显式指定。\n'
102
+ f'嗅探记录:\n{detail}',
103
+ code='PROFILE_NOT_FOUND')
104
+ return
105
+ dir_to_probe = hit
86
106
  else:
87
107
  dir_to_probe = default_user_data_dir_for_channel(channel)
88
108
  if not dir_to_probe:
@@ -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
 
@@ -142,32 +156,22 @@ def register(cli):
142
156
 
143
157
  if mouse_x is not None and mouse_y is not None:
144
158
  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
- }
159
+ f"""{_FIND_SCROLLABLE_JS}
156
160
  const start = document.elementFromPoint(arguments[2], arguments[3]);
157
161
  const target = findScrollable(start);
158
- const before = {scrollTop: target.scrollTop, scrollLeft: target.scrollLeft};
159
- if (arguments[4]) {
162
+ const before = {{scrollTop: target.scrollTop, scrollLeft: target.scrollLeft}};
163
+ if (arguments[4]) {{
160
164
  target.scrollTop = 0;
161
- } else if (arguments[5]) {
165
+ }} else if (arguments[5]) {{
162
166
  target.scrollTop = target.scrollHeight;
163
- } else {
167
+ }} else {{
164
168
  target.scrollTop += arguments[1];
165
169
  target.scrollLeft += arguments[0];
166
- }
167
- target.dispatchEvent(new Event('scroll', {bubbles: true}));
168
- return {
170
+ }}
171
+ target.dispatchEvent(new Event('scroll', {{bubbles: true}}));
172
+ return {{
169
173
  before,
170
- after: {scrollTop: target.scrollTop, scrollLeft: target.scrollLeft},
174
+ after: {{scrollTop: target.scrollTop, scrollLeft: target.scrollLeft}},
171
175
  scrollHeight: target.scrollHeight,
172
176
  clientHeight: target.clientHeight,
173
177
  scrollWidth: target.scrollWidth,
@@ -176,8 +180,8 @@ def register(cli):
176
180
  id: target.id || '',
177
181
  className: target.className || '',
178
182
  mode: 'mouse'
179
- };
180
- """,
183
+ }};
184
+ }}""",
181
185
  x, y, mouse_x, mouse_y, top, bottom,
182
186
  )
183
187
  ok({'x': x, 'y': y, 'mouse': {'x': mouse_x, 'y': mouse_y},
@@ -292,19 +296,9 @@ def register(cli):
292
296
  return int(target.run_js('return this.scrollHeight'))
293
297
  if use_mouse_container:
294
298
  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
- }
299
+ f"""{_FIND_SCROLLABLE_JS}
305
300
  const target = findScrollable(document.elementFromPoint(arguments[0], arguments[1]));
306
- return target.scrollHeight;
307
- """,
301
+ return target.scrollHeight;""",
308
302
  mouse_x, mouse_y,
309
303
  ))
310
304
  return int(page.run_js('return document.documentElement.scrollHeight'))
@@ -329,22 +323,13 @@ def register(cli):
329
323
  )
330
324
  elif use_mouse_container:
331
325
  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
- }
326
+ f"""{_FIND_SCROLLABLE_JS}
342
327
  const target = findScrollable(document.elementFromPoint(arguments[0], arguments[1]));
343
328
  const before = target.scrollTop;
344
329
  const delta = arguments[2] > 0 ? arguments[2] : Math.max(300, Math.floor(target.clientHeight * arguments[3]));
345
330
  target.scrollTop += delta;
346
- target.dispatchEvent(new Event('scroll', {bubbles: true}));
347
- return {
331
+ target.dispatchEvent(new Event('scroll', {{bubbles: true}}));
332
+ return {{
348
333
  before,
349
334
  after: target.scrollTop,
350
335
  delta,
@@ -354,8 +339,7 @@ def register(cli):
354
339
  id: target.id || '',
355
340
  className: target.className || '',
356
341
  mode: 'mouse'
357
- };
358
- """,
342
+ }};""",
359
343
  mouse_x, mouse_y, step, 3 if fast else 0.9,
360
344
  )
361
345
  else: