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.
- {dp_cli-0.6.0 → dp_cli-0.6.3}/PKG-INFO +14 -6
- {dp_cli-0.6.0 → dp_cli-0.6.3}/README.md +8 -5
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/bridge.py +23 -5
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/bridge_manager.py +88 -11
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/_utils.py +1 -1
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/keyboard.py +73 -59
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/misc.py +2 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/network.py +45 -39
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/record.py +14 -23
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/snapshot_cmd.py +40 -20
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/storage.py +37 -3
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/output.py +4 -37
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/recorder.py +62 -100
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/a11y.py +317 -64
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/clickable.py +13 -5
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/extract.py +2 -2
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/js_scripts.py +15 -3
- dp_cli-0.6.3/dp_cli/snapshot/utils.py +70 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/PKG-INFO +14 -6
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/SOURCES.txt +6 -1
- dp_cli-0.6.3/dp_cli.egg-info/requires.txt +11 -0
- dp_cli-0.6.3/pyproject.toml +88 -0
- dp_cli-0.6.3/tests/test_a11y.py +745 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_bridge_manager.py +9 -6
- dp_cli-0.6.3/tests/test_commands.py +889 -0
- dp_cli-0.6.3/tests/test_recorder.py +473 -0
- dp_cli-0.6.3/tests/test_session.py +804 -0
- dp_cli-0.6.3/tests/test_snapshot_small.py +454 -0
- dp_cli-0.6.0/dp_cli/snapshot/utils.py +0 -43
- dp_cli-0.6.0/dp_cli.egg-info/requires.txt +0 -5
- dp_cli-0.6.0/pyproject.toml +0 -37
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/__init__.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/__init__.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/browser.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/element.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/page.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/commands/tab.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/locators/__init__.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/locators/playwright.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/locators/pw_js.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/main.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/session.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/__init__.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/snapshot/clickable_js.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli/stealth.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/dependency_links.txt +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/entry_points.txt +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/dp_cli.egg-info/top_level.txt +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/setup.cfg +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_bridge_integration.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_clickable.py +0 -0
- {dp_cli-0.6.0 → dp_cli-0.6.3}/tests/test_pw_locator.py +0 -0
- {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.
|
|
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
|
|
137
|
-
dp snapshot
|
|
138
|
-
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
|
|
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
|
|
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
|
|
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
|
|
116
|
-
dp snapshot
|
|
117
|
-
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,14 +137,15 @@ 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 弹出授权框请点击。
|
|
@@ -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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
except (ProcessLookupError, PermissionError):
|
|
254
|
+
|
|
255
|
+
if IS_WINDOWS:
|
|
256
|
+
# 1) 优雅: 给整个进程组发 CTRL_BREAK_EVENT
|
|
195
257
|
try:
|
|
196
|
-
os.kill(pid, signal.
|
|
258
|
+
os.kill(pid, signal.CTRL_BREAK_EVENT)
|
|
197
259
|
except Exception:
|
|
198
260
|
pass
|
|
199
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if
|
|
49
|
-
page.actions
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
18
|
-
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
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))
|