dp-cli 0.6.2__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.2 → dp_cli-0.6.3}/PKG-INFO +1 -1
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/keyboard.py +42 -12
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/misc.py +2 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/network.py +45 -39
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/record.py +14 -23
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/storage.py +37 -3
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/recorder.py +1 -3
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/a11y.py +33 -14
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/js_scripts.py +15 -3
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/PKG-INFO +1 -1
- {dp_cli-0.6.2 → dp_cli-0.6.3}/pyproject.toml +1 -1
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_a11y.py +85 -1
- dp_cli-0.6.3/tests/test_commands.py +889 -0
- dp_cli-0.6.2/tests/test_commands.py +0 -389
- {dp_cli-0.6.2 → dp_cli-0.6.3}/README.md +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/__init__.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/bridge.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/bridge_manager.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/__init__.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/_utils.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/browser.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/element.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/page.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/snapshot_cmd.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/tab.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/locators/__init__.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/locators/playwright.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/locators/pw_js.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/main.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/output.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/session.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/__init__.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/clickable.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/clickable_js.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/extract.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/utils.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/stealth.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/SOURCES.txt +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/dependency_links.txt +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/entry_points.txt +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/requires.txt +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/top_level.txt +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/setup.cfg +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_bridge_integration.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_bridge_manager.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_clickable.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_pw_locator.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_recorder.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_resolve_locator.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_session.py +0 -0
- {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_snapshot_small.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
|
|
@@ -34,13 +34,17 @@ def register(cli):
|
|
|
34
34
|
|
|
35
35
|
\b
|
|
36
36
|
支持的按键: Enter, Tab, Escape, Space, Backspace,
|
|
37
|
-
ArrowUp/Down/Left/Right, F1-F12,
|
|
38
|
-
|
|
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。
|
|
39
41
|
|
|
40
42
|
\b
|
|
41
43
|
示例:
|
|
42
44
|
dp press Enter
|
|
43
45
|
dp press "Control+A"
|
|
46
|
+
dp press "Control+Shift+Delete"
|
|
47
|
+
dp press F5
|
|
44
48
|
dp press Escape
|
|
45
49
|
"""
|
|
46
50
|
page = _get_page(session)
|
|
@@ -52,20 +56,46 @@ def register(cli):
|
|
|
52
56
|
'arrowup': '\ue013', 'arrowdown': '\ue015',
|
|
53
57
|
'arrowleft': '\ue012', 'arrowright': '\ue014',
|
|
54
58
|
}
|
|
55
|
-
|
|
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
|
+
|
|
56
82
|
if '+' in key:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if
|
|
63
|
-
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)
|
|
64
95
|
else:
|
|
65
96
|
page.actions.type(key)
|
|
66
97
|
else:
|
|
67
|
-
|
|
68
|
-
page.actions.type(actual_key)
|
|
98
|
+
page.actions.type(_key_token(key))
|
|
69
99
|
ok({'key': key}, msg='按键成功')
|
|
70
100
|
except Exception as e:
|
|
71
101
|
error(f'按键失败: {key}', code='KEY_FAILED', detail=str(e))
|
|
@@ -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))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# -*- coding:utf-8 -*-
|
|
2
2
|
"""操作录制命令: record-start / record-stop / record-show / record-clear"""
|
|
3
|
+
import sys
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import click
|
|
@@ -16,20 +17,22 @@ def _get_active_page_for_record(session):
|
|
|
16
17
|
"""获取浏览器当前激活的标签页用于录制(忽略 session 中的 active_tab 绑定)"""
|
|
17
18
|
from dp_cli.session import get_browser
|
|
18
19
|
page = get_browser(session)
|
|
19
|
-
|
|
20
|
-
# 使用 CDP
|
|
20
|
+
|
|
21
|
+
# 使用 CDP 找到当前真正激活的标签页,直接按 targetId 取 tab
|
|
22
|
+
# (DrissionPage 的 tab_id 即 CDP targetId,无需为每个 target 遍历全部 tab)
|
|
21
23
|
try:
|
|
22
24
|
result = page.run_cdp('Target.getTargets')
|
|
23
25
|
for target in result.get('targetInfos', []):
|
|
24
26
|
if target.get('attached') and target.get('type') == 'page':
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
try:
|
|
28
|
+
tab = page.get_tab(target.get('targetId'))
|
|
29
|
+
except Exception:
|
|
30
|
+
continue
|
|
31
|
+
if tab:
|
|
32
|
+
return tab
|
|
30
33
|
except Exception as e:
|
|
31
|
-
print(f'[record] Failed to get active tab via CDP: {e}', file=
|
|
32
|
-
|
|
34
|
+
print(f'[record] Failed to get active tab via CDP: {e}', file=sys.stderr)
|
|
35
|
+
|
|
33
36
|
# 回退到 latest_tab
|
|
34
37
|
try:
|
|
35
38
|
return page.latest_tab or page
|
|
@@ -37,19 +40,6 @@ def _get_active_page_for_record(session):
|
|
|
37
40
|
return page
|
|
38
41
|
|
|
39
42
|
|
|
40
|
-
def _get_all_tab_ids(page):
|
|
41
|
-
"""获取所有标签页的 ID 列表"""
|
|
42
|
-
try:
|
|
43
|
-
# 使用 CDP 获取所有目标
|
|
44
|
-
result = page.run_cdp('Target.getTargets')
|
|
45
|
-
return [
|
|
46
|
-
t.get('targetId') for t in result.get('targetInfos', [])
|
|
47
|
-
if t.get('type') == 'page'
|
|
48
|
-
]
|
|
49
|
-
except Exception:
|
|
50
|
-
return []
|
|
51
|
-
|
|
52
|
-
|
|
53
43
|
def _get_recording_page(session):
|
|
54
44
|
page = _get_page(session, raw=True, inject_recording=False)
|
|
55
45
|
sess = load_session(session) or {}
|
|
@@ -98,7 +88,8 @@ def register(cli):
|
|
|
98
88
|
save_session(session, sess)
|
|
99
89
|
data = dict(status or {})
|
|
100
90
|
data['tab'] = _page_info(page)
|
|
101
|
-
|
|
91
|
+
tab_url = (data['tab']['url'] or '')[:40]
|
|
92
|
+
msg = (f'操作录制已开始,已绑定标签页: {tab_url}...\n'
|
|
102
93
|
f' 提示: 如果绑定不正确,请先执行 "dp tab-select <序号>" 选择正确标签,再录制')
|
|
103
94
|
ok(data, msg=msg)
|
|
104
95
|
except Exception as e:
|
|
@@ -26,10 +26,32 @@ def register(cli):
|
|
|
26
26
|
"""
|
|
27
27
|
page = _get_page(session)
|
|
28
28
|
try:
|
|
29
|
-
|
|
29
|
+
# all_info=True 保留 domain/path 等完整字段,供 --domain/--url 过滤
|
|
30
|
+
cookies = page.cookies(all_domains=True, all_info=True)
|
|
31
|
+
# --url:取 host 做 cookie domain 匹配(.example.com 匹配 sub.example.com)
|
|
32
|
+
host = None
|
|
33
|
+
if url:
|
|
34
|
+
host = url.split('://', 1)[-1].split('/')[0].split(':')[0].lower()
|
|
30
35
|
cookie_list_data = []
|
|
31
|
-
for
|
|
32
|
-
|
|
36
|
+
for c in cookies:
|
|
37
|
+
c_domain = (c.get('domain') or '')
|
|
38
|
+
if domain:
|
|
39
|
+
# 域级匹配(标签边界):example.com 命中 .example.com /
|
|
40
|
+
# sub.example.com 的 cookie,不命中 notexample.com
|
|
41
|
+
# (子串匹配会误中)
|
|
42
|
+
fd = domain.lower().lstrip('.')
|
|
43
|
+
cd = c_domain.lower().lstrip('.')
|
|
44
|
+
if not (cd == fd or cd.endswith('.' + fd)
|
|
45
|
+
or fd.endswith('.' + cd)):
|
|
46
|
+
continue
|
|
47
|
+
if host:
|
|
48
|
+
d = c_domain.lstrip('.')
|
|
49
|
+
if not (host == d or host.endswith('.' + d)):
|
|
50
|
+
continue
|
|
51
|
+
cookie_list_data.append({'name': c.get('name'),
|
|
52
|
+
'value': c.get('value'),
|
|
53
|
+
'domain': c_domain,
|
|
54
|
+
'path': c.get('path', '/')})
|
|
33
55
|
ok({'cookies': cookie_list_data, 'count': len(cookie_list_data)})
|
|
34
56
|
except Exception as e:
|
|
35
57
|
error(f'获取 Cookie 失败', code='COOKIE_FAILED', detail=str(e))
|
|
@@ -210,6 +232,18 @@ def register(cli):
|
|
|
210
232
|
except Exception as e:
|
|
211
233
|
error(f'设置 sessionStorage 失败', code='STORAGE_FAILED', detail=str(e))
|
|
212
234
|
|
|
235
|
+
@cli.command('sessionstorage-delete')
|
|
236
|
+
@click.argument('key')
|
|
237
|
+
@session_option
|
|
238
|
+
def sessionstorage_delete(key, session):
|
|
239
|
+
"""删除 sessionStorage 指定键。"""
|
|
240
|
+
page = _get_page(session)
|
|
241
|
+
try:
|
|
242
|
+
page.run_js(f'sessionStorage.removeItem({json.dumps(key)})', as_expr=True)
|
|
243
|
+
ok({'key': key}, msg='sessionStorage 键已删除')
|
|
244
|
+
except Exception as e:
|
|
245
|
+
error(f'删除 sessionStorage 失败', code='STORAGE_FAILED', detail=str(e))
|
|
246
|
+
|
|
213
247
|
@cli.command('sessionstorage-clear')
|
|
214
248
|
@session_option
|
|
215
249
|
def sessionstorage_clear(session):
|
|
@@ -266,7 +266,7 @@ _RECORDER_JS = r"""
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
function findScrollable(el) {
|
|
269
|
-
let cur = el && el.nodeType === 1 ? el : el.parentElement;
|
|
269
|
+
let cur = (el && el.nodeType === 1) ? el : (el && el.parentElement);
|
|
270
270
|
while (cur && cur !== document.body && cur !== document.documentElement) {
|
|
271
271
|
const st = getComputedStyle(cur);
|
|
272
272
|
const canY = /(auto|scroll|overlay)/.test(st.overflowY) && cur.scrollHeight > cur.clientHeight + 1;
|
|
@@ -362,8 +362,6 @@ _RECORDER_JS = r"""
|
|
|
362
362
|
document.addEventListener('keydown', onKeyDown, true);
|
|
363
363
|
document.addEventListener('wheel', onWheel, true);
|
|
364
364
|
|
|
365
|
-
console.log('[dp-recorder] initialized, version:', VERSION);
|
|
366
|
-
|
|
367
365
|
window.__dpRecorder = {
|
|
368
366
|
version: VERSION,
|
|
369
367
|
start: function () { setRecording(true); },
|
|
@@ -13,6 +13,8 @@ a11y tree — 浏览器原生无障碍树获取与渲染
|
|
|
13
13
|
5. render_a11y_text() 渲染为可读文本
|
|
14
14
|
6. CDP 失败时降级到 JS fallback
|
|
15
15
|
"""
|
|
16
|
+
import json
|
|
17
|
+
|
|
16
18
|
from .utils import suggest_locator
|
|
17
19
|
|
|
18
20
|
# 交互角色列表:需要生成定位器的角色
|
|
@@ -368,9 +370,11 @@ def _find_subtree_by_selector(page, tree: dict, all_nodes: list,
|
|
|
368
370
|
xpath_selector = normalized[6:]
|
|
369
371
|
# 使用 Runtime.evaluate 执行 document.evaluate 支持 xpath
|
|
370
372
|
# ponytail: 不使用 returnByValue,直接获取 objectId
|
|
373
|
+
# xpath 经 json.dumps 转义——xpath 常含双引号(如 //div[@class="x"]),
|
|
374
|
+
# 直接拼进 JS 字符串会语法错误,甚至有注入风险
|
|
371
375
|
js_code = f'''
|
|
372
376
|
(function() {{
|
|
373
|
-
const result = document.evaluate(
|
|
377
|
+
const result = document.evaluate({json.dumps(xpath_selector)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
374
378
|
return result.singleNodeValue;
|
|
375
379
|
}})()
|
|
376
380
|
'''
|
|
@@ -397,10 +401,9 @@ def _find_subtree_by_selector(page, tree: dict, all_nodes: list,
|
|
|
397
401
|
|
|
398
402
|
# ponytail: iframe 特殊处理 - 获取 iframe 的 frameId 并返回该 frame 的快照
|
|
399
403
|
if node_name == 'iframe':
|
|
400
|
-
#
|
|
404
|
+
# 上面的 describeNode 结果已含 frameId,无需重复 CDP 调用
|
|
401
405
|
try:
|
|
402
|
-
|
|
403
|
-
frame_id = frame_info['node'].get('frameId')
|
|
406
|
+
frame_id = node_info.get('frameId')
|
|
404
407
|
if frame_id:
|
|
405
408
|
# 重新获取该 frame 的 a11y tree
|
|
406
409
|
flat_nodes = _get_full_tree_cdp(page, max_depth=None, frame_id=frame_id)
|
|
@@ -502,7 +505,19 @@ def _build_tree(flat_nodes: list) -> dict:
|
|
|
502
505
|
roots = [n for n in flat_nodes
|
|
503
506
|
if not n.get('parentId') or n['parentId'] not in node_map]
|
|
504
507
|
|
|
505
|
-
|
|
508
|
+
if not roots:
|
|
509
|
+
return flat_nodes[0] if flat_nodes else {}
|
|
510
|
+
if len(roots) == 1:
|
|
511
|
+
return roots[0]
|
|
512
|
+
# 多根节点(如 iframe):用合成容器包裹,避免丢失子树
|
|
513
|
+
return {
|
|
514
|
+
'nodeId': '__root__',
|
|
515
|
+
'role': 'generic',
|
|
516
|
+
'name': '',
|
|
517
|
+
'children': roots,
|
|
518
|
+
'backendNodeId': None,
|
|
519
|
+
'locator': None,
|
|
520
|
+
}
|
|
506
521
|
|
|
507
522
|
|
|
508
523
|
def _compute_stats(nodes: list) -> dict:
|
|
@@ -553,7 +568,6 @@ def _generate_locators_batch(page, interactive_nodes: list, frame_id: str = None
|
|
|
553
568
|
attrs_map = {}
|
|
554
569
|
try:
|
|
555
570
|
# 如果没有指定自定义优先级,使用默认优先级
|
|
556
|
-
from .utils import suggest_locator
|
|
557
571
|
default_priority = [
|
|
558
572
|
'data-testid', 'data-test', 'data-test-id',
|
|
559
573
|
'data-qa', 'data-cy', 'id', 'aria-label', 'name', 'placeholder'
|
|
@@ -565,15 +579,16 @@ def _generate_locators_batch(page, interactive_nodes: list, frame_id: str = None
|
|
|
565
579
|
combined_selector = ','.join(selectors)
|
|
566
580
|
|
|
567
581
|
# 使用 JS 一次性获取所有元素的属性
|
|
582
|
+
# selector 与 attr 列表经 json.dumps 转义,避免引号破坏 JS / 注入
|
|
568
583
|
js = f"""
|
|
569
584
|
(function() {{
|
|
570
585
|
const result = {{}};
|
|
571
|
-
const elements = document.querySelectorAll(
|
|
586
|
+
const elements = document.querySelectorAll({json.dumps(combined_selector)});
|
|
572
587
|
elements.forEach(el => {{
|
|
573
588
|
const text = el.textContent.trim().substring(0, 50);
|
|
574
589
|
if (text && !result[text]) {{
|
|
575
590
|
const attrs = {{}};
|
|
576
|
-
for (const attr of {attrs_to_get}) {{
|
|
591
|
+
for (const attr of {json.dumps(attrs_to_get)}) {{
|
|
577
592
|
if (el.hasAttribute(attr)) {{
|
|
578
593
|
attrs[attr] = el.getAttribute(attr);
|
|
579
594
|
}}
|
|
@@ -588,7 +603,6 @@ def _generate_locators_batch(page, interactive_nodes: list, frame_id: str = None
|
|
|
588
603
|
"""
|
|
589
604
|
result = page.run_cdp('Runtime.evaluate', expression=js, returnByValue=True)
|
|
590
605
|
if result.get('result', {}).get('value'):
|
|
591
|
-
import json
|
|
592
606
|
attrs_map = json.loads(result['result']['value'])
|
|
593
607
|
except Exception:
|
|
594
608
|
pass
|
|
@@ -708,7 +722,7 @@ def _build_dom_bid_map(page, frame_id: str = None) -> dict:
|
|
|
708
722
|
:param frame_id: 可选,限制获取特定 frame 的 DOM 树
|
|
709
723
|
"""
|
|
710
724
|
try:
|
|
711
|
-
kwargs = {'depth': -1}
|
|
725
|
+
kwargs = {'depth': -1, 'pierce': True}
|
|
712
726
|
if frame_id:
|
|
713
727
|
kwargs['frameId'] = frame_id
|
|
714
728
|
doc = page.run_cdp('DOM.getDocument', **kwargs)
|
|
@@ -763,7 +777,8 @@ def _render_node(node: dict, lines: list, depth: int = 0,
|
|
|
763
777
|
"""递归渲染单个 a11y 节点为文本行
|
|
764
778
|
|
|
765
779
|
:param parent_text: 父节点已显示的文本,用于消除子节点冗余
|
|
766
|
-
:param brief: True
|
|
780
|
+
:param brief: True 时跳过独立文本节点并截断内容节点长文本(>80 字),
|
|
781
|
+
保留结构 + 交互元素概览,省 token
|
|
767
782
|
:param ctx: 渲染上下文 {'counter': int, 'refs': dict},用于分配 [N] 编号
|
|
768
783
|
"""
|
|
769
784
|
role = node.get('role', '')
|
|
@@ -771,10 +786,14 @@ def _render_node(node: dict, lines: list, depth: int = 0,
|
|
|
771
786
|
ignored = node.get('ignored', False)
|
|
772
787
|
children = node.get('children', [])
|
|
773
788
|
|
|
774
|
-
# brief
|
|
775
|
-
|
|
789
|
+
# brief 模式:跳过非关键内容节点(cell/listitem/caption 等),
|
|
790
|
+
# 但保留 _REF_CONTENT_ROLES(paragraph/heading/code/blockquote),由后续逻辑截断;
|
|
791
|
+
# 跳过时仍递归子节点,避免丢失嵌套的交互元素
|
|
792
|
+
if brief and role in _CONTENT_ROLES and role not in _REF_CONTENT_ROLES:
|
|
793
|
+
for child in children:
|
|
794
|
+
_render_node(child, lines, depth, verbose=verbose,
|
|
795
|
+
parent_text=parent_text, brief=brief, ctx=ctx)
|
|
776
796
|
return
|
|
777
|
-
|
|
778
797
|
# 跳过 ignored 节点(除非 verbose),但仍然渲染子节点
|
|
779
798
|
if ignored and not verbose:
|
|
780
799
|
for child in children:
|
|
@@ -105,6 +105,15 @@ _JS_A11Y_FALLBACK = """
|
|
|
105
105
|
const cn = buildNode(child, depth + 1);
|
|
106
106
|
if (cn) children.push(cn);
|
|
107
107
|
}
|
|
108
|
+
// 穿透 open shadow DOM
|
|
109
|
+
if (el.shadowRoot) {
|
|
110
|
+
try {
|
|
111
|
+
for (const child of el.shadowRoot.children) {
|
|
112
|
+
const cn = buildNode(child, depth + 1);
|
|
113
|
+
if (cn) children.push(cn);
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {}
|
|
116
|
+
}
|
|
108
117
|
|
|
109
118
|
// 跳过无意义容器(无 role、无 name、只有一个子节点)
|
|
110
119
|
if (!role && !name && children.length === 1) {
|
|
@@ -140,9 +149,12 @@ _JS_A11Y_FALLBACK = """
|
|
|
140
149
|
const v = el.getAttribute(attr);
|
|
141
150
|
if (v) return '@' + attr + '=' + v;
|
|
142
151
|
}
|
|
143
|
-
const cls = el.className;
|
|
144
|
-
if (
|
|
145
|
-
|
|
152
|
+
const cls = typeof el.className === 'string' ? el.className : (el.className && el.className.baseVal) || '';
|
|
153
|
+
if (cls.trim()) {
|
|
154
|
+
const parts = cls.trim().split(/\\s+/).filter(function(c) {
|
|
155
|
+
return c && !/^[a-z0-9]{6,}$/i.test(c) && !/^[_-]/.test(c);
|
|
156
|
+
});
|
|
157
|
+
if (parts.length) return '.' + parts[0];
|
|
146
158
|
}
|
|
147
159
|
const text = (el.textContent || '').trim();
|
|
148
160
|
if (text && text.length <= 30) return 'text:' + text;
|
|
@@ -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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dp-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.3"
|
|
8
8
|
description = "A powerful CLI for DrissionPage — browser automation, structured data extraction, network listening and more."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -658,4 +658,88 @@ def test_generate_locators_iframe_frame():
|
|
|
658
658
|
# 没有 id 的使用 class
|
|
659
659
|
assert interactive_nodes[1]['locator'] == '.btn-export' or 'btn-export' in interactive_nodes[1]['locator']
|
|
660
660
|
# 查找失败的回退到 text:
|
|
661
|
-
assert interactive_nodes[2]['locator'] == 'text:搜索'
|
|
661
|
+
assert interactive_nodes[2]['locator'] == 'text:搜索'
|
|
662
|
+
|
|
663
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
664
|
+
# t3 增强回归:JS 转义 / iframe describeNode 去重
|
|
665
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
@pytest.mark.unit
|
|
668
|
+
def test_find_subtree_xpath_quotes_escaped():
|
|
669
|
+
"""xpath 含双引号(如 //div[@class="x"])时必须转义后再拼进 JS,
|
|
670
|
+
否则 Runtime.evaluate 语法错误;断言传入的 expression 已被 json 转义。"""
|
|
671
|
+
exprs = []
|
|
672
|
+
|
|
673
|
+
def rt(**kw):
|
|
674
|
+
exprs.append(kw.get('expression', ''))
|
|
675
|
+
return {'result': {'type': 'object', 'objectId': 'obj1'}}
|
|
676
|
+
|
|
677
|
+
page = FakePage(cdp_map={
|
|
678
|
+
'DOM.getDocument': {'root': {'nodeId': 1}},
|
|
679
|
+
'Runtime.evaluate': rt,
|
|
680
|
+
'DOM.requestNode': {'nodeId': 100},
|
|
681
|
+
'DOM.describeNode': {'node': {'backendNodeId': 200, 'nodeName': 'DIV'}},
|
|
682
|
+
})
|
|
683
|
+
tree = node(role='root', backendNodeId=1, children=[
|
|
684
|
+
node(role='generic', backendNodeId=200)
|
|
685
|
+
])
|
|
686
|
+
result, warning, frame_id = a11y._find_subtree_by_selector(
|
|
687
|
+
page, tree, [], 'xpath://div[@class="x"]')
|
|
688
|
+
|
|
689
|
+
assert exprs, 'Runtime.evaluate 未被调用'
|
|
690
|
+
# xpath 应作为 JSON 字符串字面量出现(双引号被 \" 转义)
|
|
691
|
+
assert '//div[@class=\\"x\\"]' in exprs[0]
|
|
692
|
+
assert warning is None
|
|
693
|
+
assert result['backendNodeId'] == 200
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@pytest.mark.unit
|
|
697
|
+
def test_find_subtree_iframe_describe_node_once():
|
|
698
|
+
"""iframe 分支复用首次 describeNode 的 frameId,整个流程只调 1 次。"""
|
|
699
|
+
calls = []
|
|
700
|
+
|
|
701
|
+
def describe(**kw):
|
|
702
|
+
calls.append(kw)
|
|
703
|
+
return {'node': {'backendNodeId': 200, 'nodeName': 'IFRAME',
|
|
704
|
+
'frameId': 'F1'}}
|
|
705
|
+
|
|
706
|
+
page = FakePage(cdp_map={
|
|
707
|
+
'DOM.getDocument': {'root': {'nodeId': 1}},
|
|
708
|
+
'DOM.querySelector': {'nodeId': 100},
|
|
709
|
+
'DOM.describeNode': describe,
|
|
710
|
+
'Accessibility.getFullAXTree': {
|
|
711
|
+
'nodes': [{'nodeId': '1', 'role': {'value': 'WebArea'},
|
|
712
|
+
'backendDOMNodeId': 1, 'childIds': []}]
|
|
713
|
+
},
|
|
714
|
+
})
|
|
715
|
+
tree = node(role='root', backendNodeId=1)
|
|
716
|
+
result, warning, frame_id = a11y._find_subtree_by_selector(
|
|
717
|
+
page, tree, [], 'iframe')
|
|
718
|
+
|
|
719
|
+
assert frame_id == 'F1'
|
|
720
|
+
assert '已切换到 iframe frame' in warning
|
|
721
|
+
assert len(calls) == 1
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@pytest.mark.unit
|
|
725
|
+
def test_generate_locators_escapes_attr_selector():
|
|
726
|
+
"""attr_priority 含引号时,拼进 JS 的 selector / attr 列表必须经
|
|
727
|
+
json.dumps 转义,不产生非法 JS 或注入。"""
|
|
728
|
+
exprs = []
|
|
729
|
+
|
|
730
|
+
def rt(**kw):
|
|
731
|
+
exprs.append(kw.get('expression', ''))
|
|
732
|
+
return {'result': {'value': '{}'}}
|
|
733
|
+
|
|
734
|
+
page = FakePage(cdp_map={
|
|
735
|
+
'DOM.getDocument': {'root': {'backendNodeId': 1, 'nodeName': 'DIV',
|
|
736
|
+
'attributes': [], 'children': []}},
|
|
737
|
+
'Runtime.evaluate': rt,
|
|
738
|
+
})
|
|
739
|
+
nodes = [node(role='button', backendNodeId=1, name='x')]
|
|
740
|
+
a11y._generate_locators_batch(page, nodes, attr_priority=["data-fo'o"])
|
|
741
|
+
|
|
742
|
+
assert exprs, 'Runtime.evaluate 未被调用'
|
|
743
|
+
# 属性名以 JSON 字符串形式出现(双引号包裹,单引号原样保留)
|
|
744
|
+
assert '"[data-fo\'o]"' in exprs[0]
|
|
745
|
+
assert '["data-fo\'o"]' in exprs[0]
|