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.
- {dp_cli-0.5.0 → dp_cli-0.6.2}/PKG-INFO +27 -18
- {dp_cli-0.5.0 → dp_cli-0.6.2}/README.md +21 -17
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/bridge.py +23 -5
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/bridge_manager.py +93 -15
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/_utils.py +1 -1
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/browser.py +33 -13
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/keyboard.py +31 -47
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/snapshot_cmd.py +40 -20
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/output.py +4 -37
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/recorder.py +61 -97
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/session.py +60 -5
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/a11y.py +297 -63
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/clickable.py +13 -5
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/extract.py +2 -2
- dp_cli-0.6.2/dp_cli/snapshot/utils.py +70 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/PKG-INFO +27 -18
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/SOURCES.txt +6 -1
- dp_cli-0.6.2/dp_cli.egg-info/requires.txt +11 -0
- dp_cli-0.6.2/pyproject.toml +88 -0
- dp_cli-0.6.2/tests/test_a11y.py +661 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_bridge_manager.py +9 -6
- dp_cli-0.6.2/tests/test_commands.py +389 -0
- dp_cli-0.6.2/tests/test_recorder.py +473 -0
- dp_cli-0.6.2/tests/test_session.py +804 -0
- dp_cli-0.6.2/tests/test_snapshot_small.py +454 -0
- dp_cli-0.5.0/dp_cli/snapshot/utils.py +0 -43
- dp_cli-0.5.0/dp_cli.egg-info/requires.txt +0 -5
- dp_cli-0.5.0/pyproject.toml +0 -37
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/__init__.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/__init__.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/element.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/misc.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/network.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/page.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/record.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/storage.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/commands/tab.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/locators/__init__.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/locators/playwright.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/locators/pw_js.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/main.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/__init__.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/clickable_js.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/snapshot/js_scripts.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli/stealth.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/dependency_links.txt +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/entry_points.txt +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/dp_cli.egg-info/top_level.txt +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/setup.cfg +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_bridge_integration.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_clickable.py +0 -0
- {dp_cli-0.5.0 → dp_cli-0.6.2}/tests/test_pw_locator.py +0 -0
- {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.
|
|
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 #
|
|
79
|
-
dp open --auto-connect --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
|
|
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
|
|
136
|
-
dp snapshot
|
|
137
|
-
dp snapshot
|
|
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
|
|
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
|
|
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 #
|
|
58
|
-
dp open --auto-connect --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
|
|
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
|
|
115
|
-
dp snapshot
|
|
116
|
-
dp snapshot
|
|
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
|
|
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
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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):
|
|
12
|
-
|
|
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
|
-
#
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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'
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
except (ProcessLookupError, PermissionError):
|
|
254
|
+
|
|
255
|
+
if IS_WINDOWS:
|
|
256
|
+
# 1) 优雅: 给整个进程组发 CTRL_BREAK_EVENT
|
|
194
257
|
try:
|
|
195
|
-
os.kill(pid, signal.
|
|
258
|
+
os.kill(pid, signal.CTRL_BREAK_EVENT)
|
|
196
259
|
except Exception:
|
|
197
260
|
pass
|
|
198
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|