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.
Files changed (39) hide show
  1. {dp_cli-0.3.1 → dp_cli-0.3.2}/PKG-INFO +1 -1
  2. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/_utils.py +64 -4
  3. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/PKG-INFO +1 -1
  4. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/SOURCES.txt +2 -1
  5. {dp_cli-0.3.1 → dp_cli-0.3.2}/pyproject.toml +1 -1
  6. dp_cli-0.3.2/tests/test_resolve_locator.py +141 -0
  7. {dp_cli-0.3.1 → dp_cli-0.3.2}/README.md +0 -0
  8. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/__init__.py +0 -0
  9. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/bridge.py +0 -0
  10. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/bridge_manager.py +0 -0
  11. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/__init__.py +0 -0
  12. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/browser.py +0 -0
  13. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/element.py +0 -0
  14. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/keyboard.py +0 -0
  15. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/misc.py +0 -0
  16. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/network.py +0 -0
  17. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/page.py +0 -0
  18. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/snapshot_cmd.py +0 -0
  19. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/storage.py +0 -0
  20. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/commands/tab.py +0 -0
  21. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/main.py +0 -0
  22. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/output.py +0 -0
  23. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/session.py +0 -0
  24. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/__init__.py +0 -0
  25. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/a11y.py +0 -0
  26. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/clickable.py +0 -0
  27. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/clickable_js.py +0 -0
  28. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/extract.py +0 -0
  29. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/js_scripts.py +0 -0
  30. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/snapshot/utils.py +0 -0
  31. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli/stealth.py +0 -0
  32. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/dependency_links.txt +0 -0
  33. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/entry_points.txt +0 -0
  34. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/requires.txt +0 -0
  35. {dp_cli-0.3.1 → dp_cli-0.3.2}/dp_cli.egg-info/top_level.txt +0 -0
  36. {dp_cli-0.3.1 → dp_cli-0.3.2}/setup.cfg +0 -0
  37. {dp_cli-0.3.1 → dp_cli-0.3.2}/tests/test_bridge_integration.py +0 -0
  38. {dp_cli-0.3.1 → dp_cli-0.3.2}/tests/test_bridge_manager.py +0 -0
  39. {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.1
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 resolve_locator(locator: str, session: str = 'default') -> str:
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
- 否则尝试智能补全 css:/xpath: 前缀。
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
- # locator 不可用时(如 t:p),尝试用 name 作为 text 定位器
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.1
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
@@ -33,4 +33,5 @@ dp_cli/snapshot/js_scripts.py
33
33
  dp_cli/snapshot/utils.py
34
34
  tests/test_bridge_integration.py
35
35
  tests/test_bridge_manager.py
36
- tests/test_clickable.py
36
+ tests/test_clickable.py
37
+ tests/test_resolve_locator.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dp-cli"
7
- version = "0.3.1"
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