dp-cli 0.3.1__tar.gz → 0.3.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.3.1 → dp_cli-0.3.2}/PKG-INFO +1 -1
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/_utils.py +64 -4
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/PKG-INFO +1 -1
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/SOURCES.txt +2 -1
- {dp_cli-0.3.1 → dp_cli-0.3.2}/pyproject.toml +1 -1
- dp_cli-0.3.2/tests/test_resolve_locator.py +141 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/README.md +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/__init__.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/bridge.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/bridge_manager.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/__init__.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/browser.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/element.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/keyboard.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/misc.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/network.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/page.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/snapshot_cmd.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/storage.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/tab.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/main.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/output.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/session.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/__init__.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/a11y.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/clickable.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/clickable_js.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/extract.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/js_scripts.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/utils.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/stealth.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/dependency_links.txt +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/entry_points.txt +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/requires.txt +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/top_level.txt +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/setup.cfg +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/tests/test_bridge_integration.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/tests/test_bridge_manager.py +0 -0
- {dp_cli-0.3.1 → dp_cli-0.3.2}/tests/test_clickable.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dp-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -106,11 +106,55 @@ def normalize_locator(loc: str) -> str:
|
|
|
106
106
|
return loc
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def
|
|
109
|
+
def _mark_element_by_backend_id(page, backend_node_id: int) -> str:
|
|
110
|
+
"""通过 CDP 给指定 backendNodeId 的元素打一个临时 data-dp-ref 属性,
|
|
111
|
+
返回 marker 字符串,调用方可用 @data-dp-ref=<marker> 精确定位。
|
|
112
|
+
|
|
113
|
+
这是 ref:N → Element 的最鲁棒通路:
|
|
114
|
+
- 绕开 CSS Modules / React 动态 class 命名
|
|
115
|
+
- 绕开 xpath 结构变化
|
|
116
|
+
- 只要 DOM 节点还在,backendNodeId 稳定
|
|
117
|
+
|
|
118
|
+
失败(节点已不存在、CDP 异常)返回 None。
|
|
119
|
+
"""
|
|
120
|
+
import uuid
|
|
121
|
+
marker = 'dp' + uuid.uuid4().hex[:12]
|
|
122
|
+
try:
|
|
123
|
+
res = page.run_cdp('DOM.resolveNode', backendNodeId=int(backend_node_id))
|
|
124
|
+
except Exception:
|
|
125
|
+
return None
|
|
126
|
+
if not isinstance(res, dict):
|
|
127
|
+
return None
|
|
128
|
+
obj_id = (res.get('object') or {}).get('objectId')
|
|
129
|
+
if not obj_id:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
page.run_cdp(
|
|
133
|
+
'Runtime.callFunctionOn',
|
|
134
|
+
objectId=obj_id,
|
|
135
|
+
functionDeclaration=(
|
|
136
|
+
'function(m){'
|
|
137
|
+
'try{this.setAttribute("data-dp-ref", m);}catch(e){}'
|
|
138
|
+
'}'
|
|
139
|
+
),
|
|
140
|
+
arguments=[{'value': marker}],
|
|
141
|
+
returnByValue=True,
|
|
142
|
+
)
|
|
143
|
+
except Exception:
|
|
144
|
+
return None
|
|
145
|
+
return marker
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_locator(locator: str, session: str = 'default', page=None) -> str:
|
|
110
149
|
"""解析定位器:ref:N 展开 + 智能前缀补全。
|
|
111
150
|
|
|
112
|
-
如果 locator 以 'ref:' 开头,从 session 的 refs
|
|
113
|
-
|
|
151
|
+
如果 locator 以 'ref:' 开头,从 session 的 refs 映射中查找。
|
|
152
|
+
- 有 backendNodeId 时:通过 CDP 现场打临时属性,返回 @data-dp-ref=<marker>
|
|
153
|
+
(最鲁棒,绕开 CSS Modules / 动态 class / xpath 变化)
|
|
154
|
+
- 无 backendNodeId 或打标失败时:回落到保存的 locator 字符串
|
|
155
|
+
- 再失败,用 name 作 text 定位器
|
|
156
|
+
|
|
157
|
+
:param page: 可选,传入避免内部再调用 _get_page;为 None 时按需懒加载。
|
|
114
158
|
"""
|
|
115
159
|
if not locator.startswith('ref:'):
|
|
116
160
|
return normalize_locator(locator)
|
|
@@ -130,11 +174,27 @@ def resolve_locator(locator: str, session: str = 'default') -> str:
|
|
|
130
174
|
code='REF_NOT_FOUND')
|
|
131
175
|
raise SystemExit(1)
|
|
132
176
|
|
|
177
|
+
# 1. 首选:backendNodeId 打标
|
|
178
|
+
bid = ref_data.get('backendNodeId')
|
|
179
|
+
if bid:
|
|
180
|
+
if page is None:
|
|
181
|
+
try:
|
|
182
|
+
page = _get_page(session)
|
|
183
|
+
except SystemExit:
|
|
184
|
+
page = None
|
|
185
|
+
except Exception:
|
|
186
|
+
page = None
|
|
187
|
+
if page is not None:
|
|
188
|
+
marker = _mark_element_by_backend_id(page, bid)
|
|
189
|
+
if marker:
|
|
190
|
+
return f'@data-dp-ref={marker}'
|
|
191
|
+
|
|
192
|
+
# 2. 回退:原保存的 locator 字符串
|
|
133
193
|
real_loc = ref_data.get('locator')
|
|
134
194
|
if real_loc and not real_loc.startswith('t:'):
|
|
135
195
|
return real_loc
|
|
136
196
|
|
|
137
|
-
#
|
|
197
|
+
# 3. 再回退:用 name 作文字定位
|
|
138
198
|
name = ref_data.get('name', '')
|
|
139
199
|
if name and len(name) <= 50:
|
|
140
200
|
return f'text:{name}'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dp-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dp-cli"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
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"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""resolve_locator 的鲁棒解析测试(不依赖真实浏览器)。"""
|
|
3
|
+
import json
|
|
4
|
+
import pytest
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
|
|
7
|
+
from dp_cli.commands import _utils
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
# _mark_element_by_backend_id:CDP 现场打标
|
|
12
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
def test_mark_element_success_returns_marker():
|
|
15
|
+
"""正常路径:DOM.resolveNode → objectId → setAttribute 成功,返回 marker。"""
|
|
16
|
+
page = MagicMock()
|
|
17
|
+
page.run_cdp.side_effect = [
|
|
18
|
+
{'object': {'objectId': 'obj-123'}}, # DOM.resolveNode
|
|
19
|
+
{'result': {'type': 'undefined'}}, # Runtime.callFunctionOn
|
|
20
|
+
]
|
|
21
|
+
marker = _utils._mark_element_by_backend_id(page, 42)
|
|
22
|
+
assert marker is not None
|
|
23
|
+
assert marker.startswith('dp')
|
|
24
|
+
assert len(marker) >= 10
|
|
25
|
+
|
|
26
|
+
# 确认调了两个 CDP 命令
|
|
27
|
+
assert page.run_cdp.call_count == 2
|
|
28
|
+
# 第一个必须是 DOM.resolveNode + backendNodeId=42
|
|
29
|
+
first = page.run_cdp.call_args_list[0]
|
|
30
|
+
assert first.args[0] == 'DOM.resolveNode'
|
|
31
|
+
assert first.kwargs.get('backendNodeId') == 42
|
|
32
|
+
# 第二个必须是 Runtime.callFunctionOn + objectId=obj-123
|
|
33
|
+
second = page.run_cdp.call_args_list[1]
|
|
34
|
+
assert second.args[0] == 'Runtime.callFunctionOn'
|
|
35
|
+
assert second.kwargs.get('objectId') == 'obj-123'
|
|
36
|
+
# setAttribute 调用里的 marker 值必须跟返回的一致
|
|
37
|
+
args_arg = second.kwargs.get('arguments', [])
|
|
38
|
+
assert args_arg and args_arg[0].get('value') == marker
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_mark_element_resolve_node_failure_returns_none():
|
|
42
|
+
"""DOM.resolveNode 抛异常 → 返回 None(上层会走 fallback)。"""
|
|
43
|
+
page = MagicMock()
|
|
44
|
+
page.run_cdp.side_effect = RuntimeError('node not found')
|
|
45
|
+
assert _utils._mark_element_by_backend_id(page, 42) is None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_mark_element_no_objectid_returns_none():
|
|
49
|
+
"""resolveNode 返回里没 objectId(已 GC)→ 返回 None。"""
|
|
50
|
+
page = MagicMock()
|
|
51
|
+
page.run_cdp.return_value = {'object': {}}
|
|
52
|
+
assert _utils._mark_element_by_backend_id(page, 42) is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_mark_element_setattr_failure_returns_none():
|
|
56
|
+
"""setAttribute 抛异常 → 返回 None。"""
|
|
57
|
+
page = MagicMock()
|
|
58
|
+
page.run_cdp.side_effect = [
|
|
59
|
+
{'object': {'objectId': 'obj-1'}},
|
|
60
|
+
RuntimeError('runtime disconnected'),
|
|
61
|
+
]
|
|
62
|
+
assert _utils._mark_element_by_backend_id(page, 42) is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_mark_element_markers_are_unique():
|
|
66
|
+
"""多次调用产生不同 marker,避免冲突。"""
|
|
67
|
+
page = MagicMock()
|
|
68
|
+
page.run_cdp.side_effect = [
|
|
69
|
+
{'object': {'objectId': 'o1'}}, {},
|
|
70
|
+
{'object': {'objectId': 'o2'}}, {},
|
|
71
|
+
]
|
|
72
|
+
m1 = _utils._mark_element_by_backend_id(page, 1)
|
|
73
|
+
m2 = _utils._mark_element_by_backend_id(page, 2)
|
|
74
|
+
assert m1 and m2 and m1 != m2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
# resolve_locator:ref 解析流程
|
|
79
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def test_resolve_locator_non_ref_passes_through():
|
|
82
|
+
"""非 ref: 直接走 normalize_locator,不查 refs。"""
|
|
83
|
+
with patch.object(_utils, 'load_refs') as mock_load:
|
|
84
|
+
assert _utils.resolve_locator('#submit') == 'css:#submit'
|
|
85
|
+
assert _utils.resolve_locator('css:.btn') == 'css:.btn'
|
|
86
|
+
assert _utils.resolve_locator('text:Login') == 'text:Login'
|
|
87
|
+
mock_load.assert_not_called()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_resolve_locator_ref_uses_backend_id_when_available(tmp_path):
|
|
91
|
+
"""ref 有 backendNodeId → 优先走 CDP 打标,返回 @data-dp-ref=<marker>。"""
|
|
92
|
+
refs = {'7': {'locator': '.stale-class', 'backendNodeId': 99,
|
|
93
|
+
'name': 'Hi'}}
|
|
94
|
+
page = MagicMock()
|
|
95
|
+
page.run_cdp.side_effect = [
|
|
96
|
+
{'object': {'objectId': 'x'}},
|
|
97
|
+
{},
|
|
98
|
+
]
|
|
99
|
+
with patch.object(_utils, 'load_refs', return_value=refs):
|
|
100
|
+
result = _utils.resolve_locator('ref:7', session='x', page=page)
|
|
101
|
+
assert result.startswith('@data-dp-ref=dp')
|
|
102
|
+
# 没有回落到陈旧的 .stale-class
|
|
103
|
+
assert '.stale-class' not in result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_resolve_locator_ref_falls_back_to_locator_when_marking_fails():
|
|
107
|
+
"""CDP 打标失败 → 回落到保存的 locator 字符串。"""
|
|
108
|
+
refs = {'3': {'locator': 'css:#voice-input-button', 'backendNodeId': 42}}
|
|
109
|
+
page = MagicMock()
|
|
110
|
+
page.run_cdp.side_effect = RuntimeError('resolve failed')
|
|
111
|
+
with patch.object(_utils, 'load_refs', return_value=refs):
|
|
112
|
+
result = _utils.resolve_locator('ref:3', session='x', page=page)
|
|
113
|
+
assert result == 'css:#voice-input-button'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_resolve_locator_ref_falls_back_to_name_when_no_locator():
|
|
117
|
+
"""既没 backendNodeId、locator 也不可用 → 用 name 走 text: 定位。"""
|
|
118
|
+
refs = {'5': {'locator': 't:p', 'name': 'Submit', 'backendNodeId': None}}
|
|
119
|
+
with patch.object(_utils, 'load_refs', return_value=refs):
|
|
120
|
+
result = _utils.resolve_locator('ref:5', session='x', page=None)
|
|
121
|
+
assert result == 'text:Submit'
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_resolve_locator_ref_not_found_exits():
|
|
125
|
+
refs = {'1': {'locator': '#a', 'backendNodeId': None}}
|
|
126
|
+
with patch.object(_utils, 'load_refs', return_value=refs), \
|
|
127
|
+
patch.object(_utils, 'error') as mock_err:
|
|
128
|
+
mock_err.side_effect = SystemExit(1)
|
|
129
|
+
with pytest.raises(SystemExit):
|
|
130
|
+
_utils.resolve_locator('ref:999', session='x', page=None)
|
|
131
|
+
mock_err.assert_called_once()
|
|
132
|
+
assert 'REF_NOT_FOUND' in str(mock_err.call_args)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_resolve_locator_no_refs_file_exits():
|
|
136
|
+
with patch.object(_utils, 'load_refs', return_value={}), \
|
|
137
|
+
patch.object(_utils, 'error') as mock_err:
|
|
138
|
+
mock_err.side_effect = SystemExit(1)
|
|
139
|
+
with pytest.raises(SystemExit):
|
|
140
|
+
_utils.resolve_locator('ref:1', session='x', page=None)
|
|
141
|
+
assert 'NO_REFS' in str(mock_err.call_args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|