dp-cli 0.3.0__tar.gz → 0.3.1__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.0 → dp_cli-0.3.1}/PKG-INFO +8 -3
- {dp_cli-0.3.0 → dp_cli-0.3.1}/README.md +7 -2
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/snapshot_cmd.py +12 -24
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/a11y.py +10 -25
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/clickable.py +28 -15
- dp_cli-0.3.1/dp_cli/snapshot/clickable_js.py +381 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/PKG-INFO +8 -3
- {dp_cli-0.3.0 → dp_cli-0.3.1}/pyproject.toml +1 -1
- {dp_cli-0.3.0 → dp_cli-0.3.1}/tests/test_clickable.py +60 -16
- dp_cli-0.3.0/dp_cli/snapshot/clickable_js.py +0 -273
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/__init__.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/bridge.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/bridge_manager.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/__init__.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/_utils.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/browser.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/element.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/keyboard.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/misc.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/network.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/page.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/storage.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/tab.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/main.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/output.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/session.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/__init__.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/extract.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/js_scripts.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/utils.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/stealth.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/SOURCES.txt +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/dependency_links.txt +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/entry_points.txt +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/requires.txt +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/top_level.txt +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/setup.cfg +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/tests/test_bridge_integration.py +0 -0
- {dp_cli-0.3.0 → dp_cli-0.3.1}/tests/test_bridge_manager.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.1
|
|
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
|
|
@@ -119,8 +119,13 @@ Results are deduplicated by `backendNodeId` and rendered with confidence markers
|
|
|
119
119
|
| Marker | Confidence | Triggers |
|
|
120
120
|
|--------|-----------|----------|
|
|
121
121
|
| none | **high** | `<a href>`, `<button>`, `<input>`, `role=button/link/...`, `contenteditable` |
|
|
122
|
-
| `⚡` | **medium** | `onclick` / `jsaction` / `tabindex>=0` / `aria-selected` / `<audio>/<video
|
|
123
|
-
| `?` | **low** | `cursor:pointer` / class keyword
|
|
122
|
+
| `⚡` | **medium** | `onclick` / `jsaction` / `tabindex>=0` / `aria-selected` / `<audio>/<video>`, or `cursor:pointer` + heuristic (aria-label / icon child / small square / class keyword) — **catches most React/Vue icon buttons** |
|
|
123
|
+
| `?` | **low** | bare `cursor:pointer` / class keyword only (no other signals); hidden unless `--include-low` |
|
|
124
|
+
|
|
125
|
+
Output includes helpful context:
|
|
126
|
+
- `@top-left`, `@top-right`, `@center`, `@bottom` … — position in the 9-region viewport grid
|
|
127
|
+
- `(icon)` — icon-only button (no visible label, has `<svg>` / `<img>` child)
|
|
128
|
+
- Shadow DOM is traversed automatically (open shadow roots)
|
|
124
129
|
|
|
125
130
|
Every element gets an `[N]` ref usable in any command: `dp click "ref:5"`.
|
|
126
131
|
|
|
@@ -98,8 +98,13 @@ Results are deduplicated by `backendNodeId` and rendered with confidence markers
|
|
|
98
98
|
| Marker | Confidence | Triggers |
|
|
99
99
|
|--------|-----------|----------|
|
|
100
100
|
| none | **high** | `<a href>`, `<button>`, `<input>`, `role=button/link/...`, `contenteditable` |
|
|
101
|
-
| `⚡` | **medium** | `onclick` / `jsaction` / `tabindex>=0` / `aria-selected` / `<audio>/<video
|
|
102
|
-
| `?` | **low** | `cursor:pointer` / class keyword
|
|
101
|
+
| `⚡` | **medium** | `onclick` / `jsaction` / `tabindex>=0` / `aria-selected` / `<audio>/<video>`, or `cursor:pointer` + heuristic (aria-label / icon child / small square / class keyword) — **catches most React/Vue icon buttons** |
|
|
102
|
+
| `?` | **low** | bare `cursor:pointer` / class keyword only (no other signals); hidden unless `--include-low` |
|
|
103
|
+
|
|
104
|
+
Output includes helpful context:
|
|
105
|
+
- `@top-left`, `@top-right`, `@center`, `@bottom` … — position in the 9-region viewport grid
|
|
106
|
+
- `(icon)` — icon-only button (no visible label, has `<svg>` / `<img>` child)
|
|
107
|
+
- Shadow DOM is traversed automatically (open shadow roots)
|
|
103
108
|
|
|
104
109
|
Every element gets an `[N]` ref usable in any command: `dp click "ref:5"`.
|
|
105
110
|
|
|
@@ -105,7 +105,9 @@ def register(cli):
|
|
|
105
105
|
@click.option('--format', 'fmt', type=click.Choice(['text', 'json']),
|
|
106
106
|
default='text', show_default=True, help='输出格式')
|
|
107
107
|
@click.option('--filename', default=None, help='保存到文件路径')
|
|
108
|
-
|
|
108
|
+
@click.option('--verbose', '-v', is_flag=True, default=False,
|
|
109
|
+
help='显示 detection reason 和像素尺寸(调试用)')
|
|
110
|
+
def scan(session, viewport_only, confidence, max_elements, fmt, filename, verbose):
|
|
109
111
|
"""Vimium 风格扫描当前页面的可交互元素(纯 DOM 遍历,不依赖 a11y tree)。
|
|
110
112
|
|
|
111
113
|
\b
|
|
@@ -188,40 +190,26 @@ def register(cli):
|
|
|
188
190
|
f'{len(filtered)} after filter'
|
|
189
191
|
+ (' (truncated)' if data.get('truncated') else '')
|
|
190
192
|
)
|
|
191
|
-
rendered_lines.append(
|
|
193
|
+
rendered_lines.append(
|
|
194
|
+
'- ⚡ = medium, ? = low;@zone = 位置区域(top-left/top-right/… 9 宫格);'
|
|
195
|
+
'(icon) = 仅图标按钮;用 ref:N 引用'
|
|
196
|
+
)
|
|
192
197
|
rendered_lines.append('')
|
|
193
198
|
|
|
194
|
-
|
|
195
|
-
marker_map = {'high': '', 'medium': '⚡ ', 'low': '? '}
|
|
199
|
+
from ..snapshot.clickable import format_clickable_record
|
|
196
200
|
|
|
197
201
|
for i, rec in enumerate(filtered, start=1):
|
|
198
202
|
refs[str(i)] = {
|
|
199
203
|
'locator': rec.get('locator') or '',
|
|
200
204
|
'role': f"clickable/{rec.get('tag', '')}",
|
|
201
|
-
'name': (rec.get('text') or '')[:100],
|
|
205
|
+
'name': (rec.get('label') or rec.get('text') or '')[:100],
|
|
202
206
|
'backendNodeId': rec.get('backendNodeId'),
|
|
203
207
|
'confidence': rec.get('confidence'),
|
|
204
208
|
'reason': rec.get('reason'),
|
|
209
|
+
'zone': rec.get('zone'),
|
|
210
|
+
'iconOnly': bool(rec.get('iconOnly')),
|
|
205
211
|
}
|
|
206
|
-
|
|
207
|
-
tag = rec.get('tag', '')
|
|
208
|
-
text = (rec.get('text') or '').strip()
|
|
209
|
-
reason = rec.get('reason') or ''
|
|
210
|
-
rect = rec.get('rect') or {}
|
|
211
|
-
loc = rec.get('locator') or ''
|
|
212
|
-
|
|
213
|
-
parts = [f'- [{i}] {marker}{tag}']
|
|
214
|
-
if text:
|
|
215
|
-
disp = text[:80] + '…' if len(text) > 80 else text
|
|
216
|
-
parts.append(f'"{disp}"')
|
|
217
|
-
meta_bits = [reason]
|
|
218
|
-
if rect.get('w'):
|
|
219
|
-
meta_bits.append(f'{rect["w"]}x{rect["h"]}')
|
|
220
|
-
meta_bits.append(f'@{rect.get("x", 0)},{rect.get("y", 0)}')
|
|
221
|
-
parts.append(f'({", ".join(meta_bits)})')
|
|
222
|
-
if loc:
|
|
223
|
-
parts.append(f'→ {loc}')
|
|
224
|
-
rendered_lines.append(' '.join(parts))
|
|
212
|
+
rendered_lines.append('- ' + format_clickable_record(rec, i, verbose=verbose))
|
|
225
213
|
|
|
226
214
|
# 保存 refs 到 session
|
|
227
215
|
if refs:
|
|
@@ -219,7 +219,7 @@ def render_a11y_text(snapshot: dict, verbose: bool = False,
|
|
|
219
219
|
# ── 追加 clickable_extras(a11y tree 漏掉的可交互元素)──
|
|
220
220
|
extras = snapshot.get('clickable_extras') or []
|
|
221
221
|
if extras:
|
|
222
|
-
from .clickable import
|
|
222
|
+
from .clickable import format_clickable_record
|
|
223
223
|
lines.append('')
|
|
224
224
|
meta = snapshot.get('clickable_meta') or {}
|
|
225
225
|
header_suffix = []
|
|
@@ -232,38 +232,23 @@ def render_a11y_text(snapshot: dict, verbose: bool = False,
|
|
|
232
232
|
lines.append(f'### Additional Interactive Elements'
|
|
233
233
|
f' (Vimium-style, not in a11y tree){suffix_str}')
|
|
234
234
|
lines.append(f'- 共 {len(extras)} 个;⚡ = medium 置信, ? = low 置信;'
|
|
235
|
-
f'
|
|
235
|
+
f'@zone=位置区域(top-left/top-right/center/… 9 宫格);'
|
|
236
|
+
f'(icon)=仅图标;用 ref:N 引用')
|
|
236
237
|
lines.append('')
|
|
237
238
|
for rec in extras:
|
|
238
239
|
ctx['counter'] += 1
|
|
239
240
|
rid = ctx['counter']
|
|
240
|
-
|
|
241
|
-
tag = rec.get('tag', '')
|
|
242
|
-
text = (rec.get('text') or '').strip()
|
|
243
|
-
reason = rec.get('reason') or ''
|
|
244
|
-
loc = rec.get('locator') or ''
|
|
245
|
-
rect = rec.get('rect') or {}
|
|
246
|
-
|
|
247
|
-
parts = [f'- [{rid}] {marker}{tag}']
|
|
248
|
-
if text:
|
|
249
|
-
display_text = text[:80] + '…' if len(text) > 80 else text
|
|
250
|
-
parts.append(f'"{display_text}"')
|
|
251
|
-
meta_parts = [reason]
|
|
252
|
-
if rect.get('w'):
|
|
253
|
-
meta_parts.append(f'{rect["w"]}x{rect["h"]}')
|
|
254
|
-
parts.append(f'({", ".join(meta_parts)})')
|
|
255
|
-
if loc:
|
|
256
|
-
parts.append(f'→ {loc}')
|
|
257
|
-
lines.append(' '.join(parts))
|
|
258
|
-
|
|
241
|
+
lines.append('- ' + format_clickable_record(rec, rid))
|
|
259
242
|
# 记入 refs 以便 click/fill 引用
|
|
260
243
|
ctx['refs'][str(rid)] = {
|
|
261
|
-
'locator':
|
|
262
|
-
'role': f
|
|
263
|
-
'name': text[:100],
|
|
244
|
+
'locator': rec.get('locator') or '',
|
|
245
|
+
'role': f"clickable/{rec.get('tag', '')}",
|
|
246
|
+
'name': (rec.get('label') or rec.get('text') or '')[:100],
|
|
264
247
|
'backendNodeId': rec.get('backendNodeId'),
|
|
265
248
|
'confidence': rec.get('confidence'),
|
|
266
|
-
'reason': reason,
|
|
249
|
+
'reason': rec.get('reason'),
|
|
250
|
+
'zone': rec.get('zone'),
|
|
251
|
+
'iconOnly': bool(rec.get('iconOnly')),
|
|
267
252
|
}
|
|
268
253
|
|
|
269
254
|
if snapshot.get('clickable_warning'):
|
|
@@ -106,6 +106,9 @@ def detect_clickables(page,
|
|
|
106
106
|
'confidence': e.get('confidence'),
|
|
107
107
|
'reason': e.get('reason'),
|
|
108
108
|
'text': e.get('text'),
|
|
109
|
+
'label': e.get('label'),
|
|
110
|
+
'iconOnly': bool(e.get('iconOnly')),
|
|
111
|
+
'zone': e.get('zone'),
|
|
109
112
|
'rect': e.get('rect'),
|
|
110
113
|
'inViewport': e.get('inViewport'),
|
|
111
114
|
'backendNodeId': backend_node_id,
|
|
@@ -167,31 +170,41 @@ CONFIDENCE_MARKER = {
|
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
|
|
170
|
-
def format_clickable_record(rec: dict, ref_id: int) -> str:
|
|
173
|
+
def format_clickable_record(rec: dict, ref_id: int, verbose: bool = False) -> str:
|
|
171
174
|
"""把一条 ClickableRecord 渲染成一行文本。
|
|
172
175
|
|
|
173
|
-
|
|
174
|
-
[
|
|
175
|
-
[
|
|
176
|
+
默认紧凑格式(给 AI / 用户看,强调有用信息):
|
|
177
|
+
[5] button "Sign in" @top-right → #signin
|
|
178
|
+
[8] ⚡ div "User profile" @top-left (icon) → .user-menu-btn
|
|
179
|
+
[9] ⚡ svg @top-right (icon) → xpath://header//svg[3]
|
|
180
|
+
[12] ? span "see more" @bottom → css:.footer > span:nth-child(3)
|
|
181
|
+
|
|
182
|
+
verbose=True 时追加 reason + 尺寸,用于调试:
|
|
183
|
+
[5] button "Sign in" @top-right (button, 80x32) → #signin
|
|
176
184
|
"""
|
|
177
185
|
marker = CONFIDENCE_MARKER.get(rec.get('confidence'), '')
|
|
178
186
|
tag = rec.get('tag', '')
|
|
179
|
-
|
|
187
|
+
label = (rec.get('label') or rec.get('text') or '').strip()
|
|
188
|
+
zone = rec.get('zone') or ''
|
|
189
|
+
icon_only = bool(rec.get('iconOnly'))
|
|
180
190
|
reason = rec.get('reason') or ''
|
|
181
191
|
rect = rec.get('rect') or {}
|
|
182
192
|
loc = rec.get('locator') or ''
|
|
183
193
|
|
|
184
194
|
parts = [f'[{ref_id}]', f'{marker}{tag}']
|
|
185
|
-
if
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
if label:
|
|
196
|
+
display = label[:80] + '…' if len(label) > 80 else label
|
|
197
|
+
parts.append(f'"{display}"')
|
|
198
|
+
elif icon_only:
|
|
199
|
+
# 无 label 但有 icon:标注 (icon) 提示这是图标按钮
|
|
200
|
+
parts.append('(icon)')
|
|
201
|
+
if zone:
|
|
202
|
+
parts.append(f'@{zone}')
|
|
203
|
+
if verbose:
|
|
204
|
+
meta_bits = [reason]
|
|
205
|
+
if rect.get('w'):
|
|
206
|
+
meta_bits.append(f'{rect["w"]}x{rect["h"]}')
|
|
207
|
+
parts.append(f'({", ".join(meta_bits)})')
|
|
195
208
|
if loc:
|
|
196
209
|
parts.append(f'→ {loc}')
|
|
197
210
|
return ' '.join(parts)
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Vimium-C 风格的可点击元素探测 JS 脚本(v2)
|
|
4
|
+
|
|
5
|
+
改进点(相比 v1):
|
|
6
|
+
1. 递归遍历 Shadow DOM(open shadow root),React/Vue 组件和 Web Components 都能看见
|
|
7
|
+
2. cursor:pointer 启发式升级:
|
|
8
|
+
满足下列任一 → MEDIUM(默认显示,不再必须 --include-low):
|
|
9
|
+
· 有 aria-label / title / data-tooltip 等 label 属性
|
|
10
|
+
· 包含 svg / img / i / [class*=icon] 子元素(图标按钮特征)
|
|
11
|
+
· 本身就是 svg / i 标签
|
|
12
|
+
· rect 是小方形(12-80px、宽高差 ≤16)—— 图标按钮尺寸特征
|
|
13
|
+
· class 名匹配 btn/click/toggle/menu... 关键词
|
|
14
|
+
都不满足的普通 cursor:pointer 仍为 LOW
|
|
15
|
+
3. 父子 cursor:pointer 去重更严:子元素覆盖父元素 ≥50% 面积时,父元素让位
|
|
16
|
+
4. 新增字段:
|
|
17
|
+
· label: aria-label / title / svg <title> 等无障碍名(优先于 innerText)
|
|
18
|
+
· zone: 位置区域(top-left / top-right / center / bottom 等 9 宫格)
|
|
19
|
+
· iconOnly: 布尔值,无文字但有图标子元素
|
|
20
|
+
5. 更强的 label 回退:svg > title、子 img alt、data-tooltip、data-tippy-content
|
|
21
|
+
6. 识别更多框架 click 属性:@click(Vue)、v-on:click、(click)(Angular)
|
|
22
|
+
|
|
23
|
+
注意:DrissionPage.run_js(code) 会把 code 包成函数体,只有顶层 return 才能
|
|
24
|
+
得到返回值;IIFE 返回值会被丢弃,所以用 `const __r = ...; return __r;`。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DETECT_CLICKABLES_JS = r"""
|
|
28
|
+
const __dp_detect_result = (function() {
|
|
29
|
+
const VIEWPORT_ONLY = %(viewport_only)s;
|
|
30
|
+
const MAX_ELEMENTS = %(max_elements)d;
|
|
31
|
+
const INCLUDE_LOW = %(include_low)s;
|
|
32
|
+
|
|
33
|
+
const CLICKABLE_ROLES = /^(button|link|checkbox|radio|combobox|menu|menuitem|menuitemcheckbox|menuitemradio|tab|option|switch|slider|spinbutton|searchbox|textbox|treeitem|row|cell|gridcell|listbox|listitem|article|tooltip)$/i;
|
|
34
|
+
|
|
35
|
+
const CLASS_PATTERN = /(?:^|[\s_-])(btn|button|click|action|select|link|menu|toggle|tab|close|open|expand|collapse|dropdown|trigger|hoverable|selectable|clickable|chip|pill|tag|card|avatar|icon|ico)(?:$|[\s_-])/i;
|
|
36
|
+
|
|
37
|
+
const ICON_CLASS_PATTERN = /(?:^|[\s_-])(icon|ico|fa|fa-|glyphicon|material-icons|anticon|lucide|svg)/i;
|
|
38
|
+
|
|
39
|
+
function isElementVisible(el) {
|
|
40
|
+
const rects = el.getClientRects();
|
|
41
|
+
if (!rects.length) return null;
|
|
42
|
+
const rect = el.getBoundingClientRect();
|
|
43
|
+
if (rect.width < 2 || rect.height < 2) return null;
|
|
44
|
+
let style;
|
|
45
|
+
try { style = getComputedStyle(el); } catch (e) { return null; }
|
|
46
|
+
if (style.display === 'none' || style.visibility === 'hidden') return null;
|
|
47
|
+
const op = parseFloat(style.opacity);
|
|
48
|
+
if (op < 0.05) return null;
|
|
49
|
+
return { rect: rect, style: style };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function inViewport(rect) {
|
|
53
|
+
return rect.top < window.innerHeight && rect.bottom > 0
|
|
54
|
+
&& rect.left < window.innerWidth && rect.right > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function classNameString(el) {
|
|
58
|
+
const cn = el.className;
|
|
59
|
+
if (typeof cn === 'string') return cn;
|
|
60
|
+
if (cn && typeof cn.baseVal === 'string') return cn.baseVal; // SVGAnimatedString
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 计算 9 宫格区域名(基于元素中心点和视口)
|
|
65
|
+
function computeZone(rect) {
|
|
66
|
+
const cx = rect.left + rect.width / 2;
|
|
67
|
+
const cy = rect.top + rect.height / 2;
|
|
68
|
+
const W = window.innerWidth || 1, H = window.innerHeight || 1;
|
|
69
|
+
const xPart = cx < W / 3 ? 'left' : cx > W * 2 / 3 ? 'right' : 'center';
|
|
70
|
+
const yPart = cy < H / 3 ? 'top' : cy > H * 2 / 3 ? 'bottom' : 'middle';
|
|
71
|
+
if (yPart === 'middle' && xPart === 'center') return 'center';
|
|
72
|
+
if (yPart === 'middle') return xPart;
|
|
73
|
+
if (xPart === 'center') return yPart;
|
|
74
|
+
return yPart + '-' + xPart;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 父元素是否也 cursor:pointer —— 若是则本元素应让位给父(保留最外层 pointer)
|
|
78
|
+
// 这样能避免 <div style="cursor:pointer"><svg><use/></svg></div> 里 svg/use 被重复登记
|
|
79
|
+
function parentIsPointer(el) {
|
|
80
|
+
const p = el.parentElement;
|
|
81
|
+
if (!p) return false;
|
|
82
|
+
try { return getComputedStyle(p).cursor === 'pointer'; } catch (e) { return false; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 获取元素的"无障碍名"——优先级 aria-label > aria-labelledby > 控件专属 > innerText > title/alt > 图标兜底
|
|
86
|
+
function getAccessibleText(el) {
|
|
87
|
+
let t = el.getAttribute && el.getAttribute('aria-label');
|
|
88
|
+
if (t && t.trim()) return t.trim();
|
|
89
|
+
|
|
90
|
+
const lbi = el.getAttribute && el.getAttribute('aria-labelledby');
|
|
91
|
+
if (lbi) {
|
|
92
|
+
const ids = lbi.split(/\s+/);
|
|
93
|
+
const texts = [];
|
|
94
|
+
for (const id of ids) {
|
|
95
|
+
const ref = document.getElementById(id);
|
|
96
|
+
if (ref) texts.push((ref.innerText || ref.textContent || '').trim());
|
|
97
|
+
}
|
|
98
|
+
const joined = texts.filter(Boolean).join(' ').trim();
|
|
99
|
+
if (joined) return joined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tag = el.tagName.toLowerCase();
|
|
103
|
+
if (tag === 'input') {
|
|
104
|
+
const type = (el.type || 'text').toLowerCase();
|
|
105
|
+
if (type === 'submit' || type === 'button') return (el.value || '').trim();
|
|
106
|
+
return (el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || '').trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// innerText(优先取,很多图标按钮实际上有 sr-only 文字)
|
|
110
|
+
const it = ((el.innerText || el.textContent) || '').trim().replace(/\s+/g, ' ');
|
|
111
|
+
if (it && it.length <= 120) return it;
|
|
112
|
+
if (it) return it.slice(0, 120);
|
|
113
|
+
|
|
114
|
+
// 各种 tooltip / title 属性
|
|
115
|
+
t = el.getAttribute('title')
|
|
116
|
+
|| el.getAttribute('alt')
|
|
117
|
+
|| el.getAttribute('data-tooltip')
|
|
118
|
+
|| el.getAttribute('data-tippy-content')
|
|
119
|
+
|| el.getAttribute('data-original-title')
|
|
120
|
+
|| el.getAttribute('data-bs-original-title')
|
|
121
|
+
|| el.getAttribute('data-title');
|
|
122
|
+
if (t && t.trim()) return t.trim();
|
|
123
|
+
|
|
124
|
+
// svg > title 子节点
|
|
125
|
+
try {
|
|
126
|
+
const svgTitle = el.querySelector('svg > title, svg > desc');
|
|
127
|
+
if (svgTitle) {
|
|
128
|
+
const st = (svgTitle.textContent || '').trim();
|
|
129
|
+
if (st) return st;
|
|
130
|
+
}
|
|
131
|
+
// 子节点 aria-label
|
|
132
|
+
const innerLabeled = el.querySelector('[aria-label]');
|
|
133
|
+
if (innerLabeled) {
|
|
134
|
+
const al = innerLabeled.getAttribute('aria-label');
|
|
135
|
+
if (al && al.trim()) return al.trim();
|
|
136
|
+
}
|
|
137
|
+
// 子 img alt
|
|
138
|
+
const imgAlt = el.querySelector('img[alt]');
|
|
139
|
+
if (imgAlt) {
|
|
140
|
+
const alt = imgAlt.getAttribute('alt');
|
|
141
|
+
if (alt && alt.trim()) return alt.trim();
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {}
|
|
144
|
+
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 判断是否是"仅图标"的按钮(无有效文字,但包含图标子元素)
|
|
149
|
+
function detectIconOnly(el, accessibleText) {
|
|
150
|
+
if (accessibleText) return false;
|
|
151
|
+
try {
|
|
152
|
+
if (el.querySelector('svg, img, :scope > i, :scope > [class*="icon"], :scope > [class*="Icon"]')) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 核心分类
|
|
160
|
+
function classify(el, style, rect) {
|
|
161
|
+
const tag = el.tagName.toLowerCase();
|
|
162
|
+
|
|
163
|
+
// HIGH 级:原生交互标签
|
|
164
|
+
if (tag === 'a') {
|
|
165
|
+
if (!el.hasAttribute('href') && !el.hasAttribute('onclick')) return null;
|
|
166
|
+
return { confidence: 'high', reason: 'a' };
|
|
167
|
+
}
|
|
168
|
+
if (tag === 'button' && !el.disabled) return { confidence: 'high', reason: 'button' };
|
|
169
|
+
if (tag === 'select' && !el.disabled) return { confidence: 'high', reason: 'select' };
|
|
170
|
+
if (tag === 'textarea' && !el.disabled) {
|
|
171
|
+
return { confidence: 'high', reason: el.readOnly ? 'textarea[ro]' : 'textarea' };
|
|
172
|
+
}
|
|
173
|
+
if (tag === 'input' && !el.disabled) {
|
|
174
|
+
const type = (el.type || 'text').toLowerCase();
|
|
175
|
+
if (type === 'hidden') return null;
|
|
176
|
+
return { confidence: 'high', reason: 'input[' + type + ']' };
|
|
177
|
+
}
|
|
178
|
+
if (tag === 'summary' || tag === 'details') {
|
|
179
|
+
return { confidence: 'high', reason: tag };
|
|
180
|
+
}
|
|
181
|
+
if (tag === 'label') {
|
|
182
|
+
if (el.htmlFor || el.control) return { confidence: 'high', reason: 'label' };
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (tag === 'audio' || tag === 'video') {
|
|
186
|
+
return { confidence: 'medium', reason: tag };
|
|
187
|
+
}
|
|
188
|
+
if (tag === 'iframe' || tag === 'frame') {
|
|
189
|
+
return { confidence: 'low', reason: tag };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// contenteditable
|
|
193
|
+
const ce = el.getAttribute('contenteditable');
|
|
194
|
+
if (ce !== null && ce !== 'false' && ce !== 'inherit') {
|
|
195
|
+
return { confidence: 'high', reason: 'contenteditable' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// role 白名单
|
|
199
|
+
const role = el.getAttribute('role');
|
|
200
|
+
if (role && CLICKABLE_ROLES.test(role.trim())) {
|
|
201
|
+
return { confidence: 'high', reason: 'role=' + role };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// MEDIUM 级:显式事件 / 框架 click / tabindex / aria-selected
|
|
205
|
+
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('onpointerdown')) {
|
|
206
|
+
return { confidence: 'medium', reason: 'onclick-attr' };
|
|
207
|
+
}
|
|
208
|
+
if (el.hasAttribute('jsaction')) {
|
|
209
|
+
return { confidence: 'medium', reason: 'jsaction' };
|
|
210
|
+
}
|
|
211
|
+
if (el.hasAttribute('ng-click') || el.hasAttribute('(click)')
|
|
212
|
+
|| el.hasAttribute('@click') || el.hasAttribute('v-on:click')) {
|
|
213
|
+
return { confidence: 'medium', reason: 'fw-click' };
|
|
214
|
+
}
|
|
215
|
+
const ti = el.getAttribute('tabindex');
|
|
216
|
+
if (ti !== null) {
|
|
217
|
+
const tiNum = parseInt(ti, 10);
|
|
218
|
+
if (!isNaN(tiNum) && tiNum >= 0) {
|
|
219
|
+
return { confidence: 'medium', reason: 'tabindex=' + tiNum };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (el.hasAttribute('aria-selected') || el.hasAttribute('aria-checked')) {
|
|
223
|
+
return { confidence: 'medium', reason: 'aria-state' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// cursor:pointer + 启发式 —— 这是 React/Vue 图标按钮的主要特征
|
|
227
|
+
const isPointer = style.cursor === 'pointer';
|
|
228
|
+
if (isPointer) {
|
|
229
|
+
// 父元素也是 cursor:pointer → 让位给父(保留最外层,Vimium 同策略)
|
|
230
|
+
if (parentIsPointer(el)) return null;
|
|
231
|
+
|
|
232
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
233
|
+
const title = el.getAttribute('title');
|
|
234
|
+
const tooltip = el.getAttribute('data-tooltip')
|
|
235
|
+
|| el.getAttribute('data-tippy-content')
|
|
236
|
+
|| el.getAttribute('data-original-title');
|
|
237
|
+
const hasLabel = !!(ariaLabel || title || tooltip);
|
|
238
|
+
|
|
239
|
+
let hasIconChild = false;
|
|
240
|
+
try {
|
|
241
|
+
hasIconChild = !!el.querySelector('svg, img, i.fa, i[class*="icon"], [class*="icon"]:not(body)');
|
|
242
|
+
} catch (e) {}
|
|
243
|
+
|
|
244
|
+
const isIconTag = tag === 'svg' || tag === 'i';
|
|
245
|
+
const widthOk = rect.width >= 12 && rect.width <= 80;
|
|
246
|
+
const heightOk = rect.height >= 12 && rect.height <= 80;
|
|
247
|
+
const aspectOk = Math.abs(rect.width - rect.height) <= 24;
|
|
248
|
+
const smallSquare = widthOk && heightOk && aspectOk;
|
|
249
|
+
|
|
250
|
+
const cn = classNameString(el);
|
|
251
|
+
const hasClassHint = cn && (CLASS_PATTERN.test(cn) || ICON_CLASS_PATTERN.test(cn));
|
|
252
|
+
|
|
253
|
+
if (hasLabel) return { confidence: 'medium', reason: 'cursor+label' };
|
|
254
|
+
if (hasIconChild && smallSquare) return { confidence: 'medium', reason: 'cursor+icon' };
|
|
255
|
+
if (hasIconChild) return { confidence: 'medium', reason: 'cursor+icon-child' };
|
|
256
|
+
if (isIconTag) return { confidence: 'medium', reason: 'cursor+' + tag };
|
|
257
|
+
if (smallSquare) return { confidence: 'medium', reason: 'cursor+square' };
|
|
258
|
+
if (hasClassHint) return { confidence: 'medium', reason: 'cursor+class' };
|
|
259
|
+
|
|
260
|
+
// 兜底:普通 cursor:pointer(文字链接类样式) → LOW
|
|
261
|
+
if (!INCLUDE_LOW) return null;
|
|
262
|
+
return { confidence: 'low', reason: 'cursor:pointer' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// LOW 级:class 名关键词匹配(无 cursor:pointer)
|
|
266
|
+
if (!INCLUDE_LOW) return null;
|
|
267
|
+
const cn = classNameString(el);
|
|
268
|
+
if (cn && CLASS_PATTERN.test(cn)) {
|
|
269
|
+
const p = el.parentElement;
|
|
270
|
+
if (!p || !CLASS_PATTERN.test(classNameString(p))) {
|
|
271
|
+
return { confidence: 'low', reason: 'class-pattern' };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 遍历所有元素(含 open Shadow DOM)
|
|
279
|
+
function collectAll() {
|
|
280
|
+
const out = [];
|
|
281
|
+
function walk(root) {
|
|
282
|
+
let nodes;
|
|
283
|
+
try { nodes = root.querySelectorAll('*'); } catch (e) { return; }
|
|
284
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
285
|
+
const el = nodes[i];
|
|
286
|
+
out.push(el);
|
|
287
|
+
if (el.shadowRoot) {
|
|
288
|
+
try { walk(el.shadowRoot); } catch (e) {}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
walk(document);
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 清理上次残留
|
|
297
|
+
try {
|
|
298
|
+
document.querySelectorAll('[data-dp-scan-id]').forEach(el => el.removeAttribute('data-dp-scan-id'));
|
|
299
|
+
} catch (e) {}
|
|
300
|
+
|
|
301
|
+
const results = [];
|
|
302
|
+
let counter = 0;
|
|
303
|
+
let truncated = false;
|
|
304
|
+
|
|
305
|
+
const all = collectAll();
|
|
306
|
+
for (let i = 0; i < all.length; i++) {
|
|
307
|
+
if (results.length >= MAX_ELEMENTS) { truncated = true; break; }
|
|
308
|
+
const el = all[i];
|
|
309
|
+
|
|
310
|
+
const vis = isElementVisible(el);
|
|
311
|
+
if (!vis) continue;
|
|
312
|
+
const rect = vis.rect;
|
|
313
|
+
const style = vis.style;
|
|
314
|
+
|
|
315
|
+
if (VIEWPORT_ONLY && !inViewport(rect)) continue;
|
|
316
|
+
|
|
317
|
+
const cls = classify(el, style, rect);
|
|
318
|
+
if (!cls) continue;
|
|
319
|
+
|
|
320
|
+
counter++;
|
|
321
|
+
try { el.setAttribute('data-dp-scan-id', String(counter)); } catch (e) { continue; }
|
|
322
|
+
|
|
323
|
+
const text = getAccessibleText(el).slice(0, 150);
|
|
324
|
+
const iconOnly = detectIconOnly(el, text);
|
|
325
|
+
const zone = computeZone(rect);
|
|
326
|
+
|
|
327
|
+
results.push({
|
|
328
|
+
scanId: counter,
|
|
329
|
+
tag: el.tagName.toLowerCase(),
|
|
330
|
+
confidence: cls.confidence,
|
|
331
|
+
reason: cls.reason,
|
|
332
|
+
text: text,
|
|
333
|
+
label: text,
|
|
334
|
+
iconOnly: iconOnly,
|
|
335
|
+
zone: zone,
|
|
336
|
+
rect: {
|
|
337
|
+
x: Math.round(rect.left),
|
|
338
|
+
y: Math.round(rect.top),
|
|
339
|
+
w: Math.round(rect.width),
|
|
340
|
+
h: Math.round(rect.height)
|
|
341
|
+
},
|
|
342
|
+
inViewport: inViewport(rect)
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { elements: results, total: results.length, truncated: truncated };
|
|
347
|
+
})();
|
|
348
|
+
return __dp_detect_result;
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
CLEANUP_CLICKABLES_JS = r"""
|
|
352
|
+
const __dp_cleanup_result = (function() {
|
|
353
|
+
let n = 0;
|
|
354
|
+
function walk(root) {
|
|
355
|
+
let nodes;
|
|
356
|
+
try { nodes = root.querySelectorAll('[data-dp-scan-id]'); } catch (e) { return; }
|
|
357
|
+
nodes.forEach(el => { el.removeAttribute('data-dp-scan-id'); n++; });
|
|
358
|
+
// 清理 shadow DOM 里的
|
|
359
|
+
try {
|
|
360
|
+
const all = root.querySelectorAll('*');
|
|
361
|
+
for (let i = 0; i < all.length; i++) {
|
|
362
|
+
if (all[i].shadowRoot) walk(all[i].shadowRoot);
|
|
363
|
+
}
|
|
364
|
+
} catch (e) {}
|
|
365
|
+
}
|
|
366
|
+
walk(document);
|
|
367
|
+
return { cleaned: n };
|
|
368
|
+
})();
|
|
369
|
+
return __dp_cleanup_result;
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def build_detect_js(viewport_only: bool = False,
|
|
374
|
+
max_elements: int = 1000,
|
|
375
|
+
include_low: bool = False) -> str:
|
|
376
|
+
"""按参数填充模板,返回可注入的 JS 代码。"""
|
|
377
|
+
return DETECT_CLICKABLES_JS % {
|
|
378
|
+
'viewport_only': 'true' if viewport_only else 'false',
|
|
379
|
+
'max_elements': int(max_elements),
|
|
380
|
+
'include_low': 'true' if include_low else 'false',
|
|
381
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dp-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -119,8 +119,13 @@ Results are deduplicated by `backendNodeId` and rendered with confidence markers
|
|
|
119
119
|
| Marker | Confidence | Triggers |
|
|
120
120
|
|--------|-----------|----------|
|
|
121
121
|
| none | **high** | `<a href>`, `<button>`, `<input>`, `role=button/link/...`, `contenteditable` |
|
|
122
|
-
| `⚡` | **medium** | `onclick` / `jsaction` / `tabindex>=0` / `aria-selected` / `<audio>/<video
|
|
123
|
-
| `?` | **low** | `cursor:pointer` / class keyword
|
|
122
|
+
| `⚡` | **medium** | `onclick` / `jsaction` / `tabindex>=0` / `aria-selected` / `<audio>/<video>`, or `cursor:pointer` + heuristic (aria-label / icon child / small square / class keyword) — **catches most React/Vue icon buttons** |
|
|
123
|
+
| `?` | **low** | bare `cursor:pointer` / class keyword only (no other signals); hidden unless `--include-low` |
|
|
124
|
+
|
|
125
|
+
Output includes helpful context:
|
|
126
|
+
- `@top-left`, `@top-right`, `@center`, `@bottom` … — position in the 9-region viewport grid
|
|
127
|
+
- `(icon)` — icon-only button (no visible label, has `<svg>` / `<img>` child)
|
|
128
|
+
- Shadow DOM is traversed automatically (open shadow roots)
|
|
124
129
|
|
|
125
130
|
Every element gets an `[N]` ref usable in any command: `dp click "ref:5"`.
|
|
126
131
|
|
|
@@ -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.1"
|
|
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"
|
|
@@ -50,21 +50,30 @@ def test_detect_js_is_raw_string_no_percent_collision():
|
|
|
50
50
|
def test_detect_js_covers_vimium_rules():
|
|
51
51
|
"""快速核查关键规则都在脚本里,避免被误删。"""
|
|
52
52
|
body = DETECT_CLICKABLES_JS
|
|
53
|
-
# HIGH
|
|
53
|
+
# HIGH 级
|
|
54
54
|
assert "tag === 'a'" in body
|
|
55
55
|
assert "tag === 'button'" in body
|
|
56
56
|
assert "tag === 'input'" in body
|
|
57
57
|
assert "tag === 'select'" in body
|
|
58
58
|
assert "contenteditable" in body
|
|
59
59
|
assert "CLICKABLE_ROLES.test" in body
|
|
60
|
-
# MEDIUM
|
|
60
|
+
# MEDIUM 级
|
|
61
61
|
assert "'onclick'" in body
|
|
62
62
|
assert "'jsaction'" in body
|
|
63
63
|
assert "'tabindex'" in body
|
|
64
64
|
assert "'aria-selected'" in body
|
|
65
|
-
#
|
|
66
|
-
assert "
|
|
65
|
+
# 框架 click 属性(Vue / Angular)
|
|
66
|
+
assert "@click" in body
|
|
67
|
+
assert "v-on:click" in body
|
|
68
|
+
# cursor:pointer 启发式
|
|
69
|
+
assert "isPointer" in body
|
|
67
70
|
assert "CLASS_PATTERN.test" in body
|
|
71
|
+
assert "ICON_CLASS_PATTERN" in body
|
|
72
|
+
assert "parentIsPointer" in body
|
|
73
|
+
# 新字段:zone / iconOnly / shadow DOM
|
|
74
|
+
assert "computeZone" in body
|
|
75
|
+
assert "detectIconOnly" in body
|
|
76
|
+
assert "shadowRoot" in body
|
|
68
77
|
# 可见性
|
|
69
78
|
assert "getClientRects" in body
|
|
70
79
|
assert "getBoundingClientRect" in body
|
|
@@ -72,6 +81,15 @@ def test_detect_js_covers_vimium_rules():
|
|
|
72
81
|
assert "data-dp-scan-id" in body
|
|
73
82
|
|
|
74
83
|
|
|
84
|
+
def test_detect_js_shadow_dom_traversal():
|
|
85
|
+
"""Shadow DOM 递归:collectAll 必须检查 shadowRoot。"""
|
|
86
|
+
body = DETECT_CLICKABLES_JS
|
|
87
|
+
assert "collectAll" in body
|
|
88
|
+
assert "shadowRoot" in body
|
|
89
|
+
# 清理脚本也要覆盖 shadow DOM
|
|
90
|
+
assert "shadowRoot" in CLEANUP_CLICKABLES_JS
|
|
91
|
+
|
|
92
|
+
|
|
75
93
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
76
94
|
# 置信度标记 & 渲染
|
|
77
95
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -88,32 +106,54 @@ def test_format_clickable_record_high():
|
|
|
88
106
|
'confidence': 'high',
|
|
89
107
|
'reason': 'button',
|
|
90
108
|
'text': 'Sign in',
|
|
109
|
+
'label': 'Sign in',
|
|
110
|
+
'zone': 'top-right',
|
|
111
|
+
'iconOnly': False,
|
|
91
112
|
'rect': {'x': 10, 'y': 20, 'w': 80, 'h': 32},
|
|
92
113
|
'locator': '#signin',
|
|
93
114
|
}
|
|
94
115
|
s = format_clickable_record(rec, ref_id=5)
|
|
95
116
|
assert s.startswith('[5] button')
|
|
96
117
|
assert '"Sign in"' in s
|
|
97
|
-
assert '
|
|
98
|
-
assert '80x32' in s
|
|
118
|
+
assert '@top-right' in s
|
|
99
119
|
assert '→ #signin' in s
|
|
100
|
-
#
|
|
120
|
+
# 默认紧凑格式不含 reason / 尺寸
|
|
121
|
+
assert 'button,' not in s
|
|
122
|
+
assert '80x32' not in s
|
|
123
|
+
# high 不带置信度标记
|
|
101
124
|
assert '⚡' not in s
|
|
102
125
|
assert '?' not in s
|
|
103
126
|
|
|
104
127
|
|
|
105
|
-
def
|
|
128
|
+
def test_format_clickable_record_verbose_shows_reason_and_size():
|
|
129
|
+
rec = {
|
|
130
|
+
'tag': 'button', 'confidence': 'high', 'reason': 'button',
|
|
131
|
+
'label': 'OK', 'zone': 'center', 'iconOnly': False,
|
|
132
|
+
'rect': {'w': 80, 'h': 32}, 'locator': '#ok',
|
|
133
|
+
}
|
|
134
|
+
s = format_clickable_record(rec, ref_id=1, verbose=True)
|
|
135
|
+
assert '(button, 80x32)' in s
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_format_clickable_record_icon_only_no_label():
|
|
106
139
|
rec = {
|
|
107
140
|
'tag': 'div',
|
|
108
141
|
'confidence': 'medium',
|
|
109
|
-
'reason': '
|
|
110
|
-
'text': '
|
|
111
|
-
'
|
|
112
|
-
'
|
|
142
|
+
'reason': 'cursor+icon',
|
|
143
|
+
'text': '',
|
|
144
|
+
'label': '',
|
|
145
|
+
'zone': 'top-left',
|
|
146
|
+
'iconOnly': True,
|
|
147
|
+
'rect': {'w': 32, 'h': 32},
|
|
148
|
+
'locator': '.sidebar-toggle',
|
|
113
149
|
}
|
|
114
150
|
s = format_clickable_record(rec, ref_id=3)
|
|
115
151
|
assert '⚡' in s
|
|
116
|
-
assert '
|
|
152
|
+
assert '(icon)' in s
|
|
153
|
+
assert '@top-left' in s
|
|
154
|
+
assert '→ .sidebar-toggle' in s
|
|
155
|
+
# 无 label 不应出现空引号
|
|
156
|
+
assert '""' not in s
|
|
117
157
|
|
|
118
158
|
|
|
119
159
|
def test_format_clickable_record_low_has_marker():
|
|
@@ -121,10 +161,14 @@ def test_format_clickable_record_low_has_marker():
|
|
|
121
161
|
'tag': 'span',
|
|
122
162
|
'confidence': 'low',
|
|
123
163
|
'reason': 'cursor:pointer',
|
|
124
|
-
'text': '
|
|
125
|
-
'
|
|
164
|
+
'text': 'See more',
|
|
165
|
+
'label': 'See more',
|
|
166
|
+
'zone': 'bottom',
|
|
167
|
+
'iconOnly': False,
|
|
168
|
+
'rect': {'w': 60, 'h': 16},
|
|
126
169
|
'locator': '.more',
|
|
127
170
|
}
|
|
128
171
|
s = format_clickable_record(rec, ref_id=9)
|
|
129
172
|
assert '?' in s
|
|
130
|
-
assert '
|
|
173
|
+
assert '"See more"' in s
|
|
174
|
+
assert '@bottom' in s
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
# -*- coding:utf-8 -*-
|
|
2
|
-
"""
|
|
3
|
-
Vimium-C 风格的可点击元素探测 JS 脚本
|
|
4
|
-
|
|
5
|
-
运行在浏览器内通过 CDP Runtime.evaluate;遍历可见 DOM 节点,
|
|
6
|
-
按多级规则识别可交互元素并打上临时属性 `data-dp-scan-id`,便于
|
|
7
|
-
Python 端通过 CDP DOM 树将其映射到 backendNodeId。
|
|
8
|
-
|
|
9
|
-
三级置信度:
|
|
10
|
-
high — 明确可点击(<a href>, <button>, role=button 等)
|
|
11
|
-
medium — 很可能可点击(onclick/jsaction/tabindex>=0/aria-selected)
|
|
12
|
-
low — 启发式(cursor:pointer 或 class 名匹配 btn/click/… 规则)
|
|
13
|
-
|
|
14
|
-
规则参考:vimium-c/content/local_links.ts
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
# 探测脚本:返回 {elements: [...], total: N, truncated: bool}
|
|
18
|
-
# 模板占位符:%(viewport_only)s, %(max_elements)d, %(include_low)s
|
|
19
|
-
#
|
|
20
|
-
# 注意:DrissionPage.run_js(code) 会把 code 包成函数体,只有顶层 return 才能
|
|
21
|
-
# 获得返回值;单纯 IIFE `(()=>{...})()` 的返回值会被丢弃。所以下面用
|
|
22
|
-
# `const __r = (function(){...})(); return __r;` 的形式。
|
|
23
|
-
DETECT_CLICKABLES_JS = r"""
|
|
24
|
-
const __dp_detect_result = (function() {
|
|
25
|
-
const VIEWPORT_ONLY = %(viewport_only)s;
|
|
26
|
-
const MAX_ELEMENTS = %(max_elements)d;
|
|
27
|
-
const INCLUDE_LOW = %(include_low)s;
|
|
28
|
-
|
|
29
|
-
const CLICKABLE_ROLES = /^(button|link|checkbox|radio|combobox|menu|menuitem|menuitemcheckbox|menuitemradio|tab|option|switch|slider|spinbutton|searchbox|textbox|treeitem|row|cell|gridcell|listbox|listitem)$/i;
|
|
30
|
-
|
|
31
|
-
// 类名关键词正则:btn/button/click/action/link/menu/toggle/tab/close/open/expand/collapse/dropdown/trigger
|
|
32
|
-
const CLASS_PATTERN = /(?:^|[\s_-])(btn|button|click|action|select|link|menu|toggle|tab|close|open|expand|collapse|dropdown|trigger|hoverable|selectable|clickable)(?:$|[\s_-])/i;
|
|
33
|
-
|
|
34
|
-
// 非可编辑 input 类型
|
|
35
|
-
const UNEDITABLE_INPUT = new Set(['hidden', 'submit', 'reset', 'button', 'checkbox', 'radio', 'image', 'file', 'color', 'range']);
|
|
36
|
-
|
|
37
|
-
function isElementVisible(el) {
|
|
38
|
-
const rects = el.getClientRects();
|
|
39
|
-
if (!rects.length) return null;
|
|
40
|
-
const rect = el.getBoundingClientRect();
|
|
41
|
-
if (rect.width < 2 || rect.height < 2) return null;
|
|
42
|
-
const style = getComputedStyle(el);
|
|
43
|
-
if (style.display === 'none' || style.visibility === 'hidden') return null;
|
|
44
|
-
const op = parseFloat(style.opacity);
|
|
45
|
-
if (op < 0.05) return null;
|
|
46
|
-
return rect;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function inViewport(rect) {
|
|
50
|
-
return rect.top < window.innerHeight && rect.bottom > 0
|
|
51
|
-
&& rect.left < window.innerWidth && rect.right > 0;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function classNameString(el) {
|
|
55
|
-
const cn = el.className;
|
|
56
|
-
if (typeof cn === 'string') return cn;
|
|
57
|
-
// SVGAnimatedString
|
|
58
|
-
if (cn && typeof cn.baseVal === 'string') return cn.baseVal;
|
|
59
|
-
return '';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function parentHasMatchingCursor(el) {
|
|
63
|
-
const p = el.parentElement;
|
|
64
|
-
if (!p) return false;
|
|
65
|
-
try { return getComputedStyle(p).cursor === 'pointer'; } catch (e) { return false; }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function parentHasMatchingClass(el) {
|
|
69
|
-
const p = el.parentElement;
|
|
70
|
-
if (!p) return false;
|
|
71
|
-
return CLASS_PATTERN.test(classNameString(p));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 判断一个元素是否可交互,返回 {confidence, reason} 或 null
|
|
75
|
-
function classify(el) {
|
|
76
|
-
const tag = el.tagName.toLowerCase();
|
|
77
|
-
|
|
78
|
-
// HIGH
|
|
79
|
-
if (tag === 'a') {
|
|
80
|
-
if (!el.hasAttribute('href') && !el.hasAttribute('onclick')) return null;
|
|
81
|
-
return { confidence: 'high', reason: 'a' };
|
|
82
|
-
}
|
|
83
|
-
if (tag === 'button') {
|
|
84
|
-
if (el.disabled) return null;
|
|
85
|
-
return { confidence: 'high', reason: 'button' };
|
|
86
|
-
}
|
|
87
|
-
if (tag === 'select') {
|
|
88
|
-
if (el.disabled) return null;
|
|
89
|
-
return { confidence: 'high', reason: 'select' };
|
|
90
|
-
}
|
|
91
|
-
if (tag === 'textarea') {
|
|
92
|
-
if (el.disabled) return null;
|
|
93
|
-
return { confidence: 'high', reason: el.readOnly ? 'textarea[readonly]' : 'textarea' };
|
|
94
|
-
}
|
|
95
|
-
if (tag === 'input') {
|
|
96
|
-
if (el.disabled) return null;
|
|
97
|
-
const type = (el.type || 'text').toLowerCase();
|
|
98
|
-
if (type === 'hidden') return null;
|
|
99
|
-
return { confidence: 'high', reason: 'input[' + type + ']' };
|
|
100
|
-
}
|
|
101
|
-
if (tag === 'summary' || tag === 'details') {
|
|
102
|
-
return { confidence: 'high', reason: tag };
|
|
103
|
-
}
|
|
104
|
-
if (tag === 'label') {
|
|
105
|
-
// 仅当关联到实际控件时,<label> 本身可点击触发控件
|
|
106
|
-
if (el.htmlFor || el.control) return { confidence: 'high', reason: 'label' };
|
|
107
|
-
// 没有关联的 label 不登记(点击无效)
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
if (tag === 'audio' || tag === 'video') {
|
|
111
|
-
return { confidence: 'medium', reason: tag };
|
|
112
|
-
}
|
|
113
|
-
if (tag === 'iframe' || tag === 'frame') {
|
|
114
|
-
return { confidence: 'low', reason: tag }; // 一般不直接点击
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// contenteditable
|
|
118
|
-
const ce = el.getAttribute('contenteditable');
|
|
119
|
-
if (ce !== null && ce !== 'false' && ce !== 'inherit') {
|
|
120
|
-
return { confidence: 'high', reason: 'contenteditable' };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// role 白名单
|
|
124
|
-
const role = el.getAttribute('role');
|
|
125
|
-
if (role && CLICKABLE_ROLES.test(role.trim())) {
|
|
126
|
-
return { confidence: 'high', reason: 'role=' + role };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// onclick / onmousedown / jsaction / ng-click
|
|
130
|
-
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown')) {
|
|
131
|
-
return { confidence: 'medium', reason: 'onclick-attr' };
|
|
132
|
-
}
|
|
133
|
-
if (el.hasAttribute('jsaction')) {
|
|
134
|
-
return { confidence: 'medium', reason: 'jsaction' };
|
|
135
|
-
}
|
|
136
|
-
if (el.hasAttribute('ng-click') || el.hasAttribute('(click)')) {
|
|
137
|
-
return { confidence: 'medium', reason: 'framework-click' };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// tabindex >= 0
|
|
141
|
-
const ti = el.getAttribute('tabindex');
|
|
142
|
-
if (ti !== null) {
|
|
143
|
-
const tiNum = parseInt(ti, 10);
|
|
144
|
-
if (!isNaN(tiNum) && tiNum >= 0) {
|
|
145
|
-
return { confidence: 'medium', reason: 'tabindex=' + tiNum };
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// aria-selected / aria-checked
|
|
150
|
-
if (el.hasAttribute('aria-selected') || el.hasAttribute('aria-checked')) {
|
|
151
|
-
return { confidence: 'medium', reason: 'aria-selected' };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// LOW 级需要 include_low 开关
|
|
155
|
-
if (!INCLUDE_LOW) return null;
|
|
156
|
-
|
|
157
|
-
// cursor:pointer(父元素也 pointer 则跳过避免冗余)
|
|
158
|
-
let style;
|
|
159
|
-
try { style = getComputedStyle(el); } catch (e) { return null; }
|
|
160
|
-
if (style.cursor === 'pointer' && !parentHasMatchingCursor(el)) {
|
|
161
|
-
return { confidence: 'low', reason: 'cursor:pointer' };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// class 名关键词
|
|
165
|
-
const cn = classNameString(el);
|
|
166
|
-
if (cn && CLASS_PATTERN.test(cn) && !parentHasMatchingClass(el)) {
|
|
167
|
-
return { confidence: 'low', reason: 'class-pattern' };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// SVG 且 cursor:pointer(上面已处理,但 SVG 有时 cursor 继承)
|
|
171
|
-
if (tag === 'svg' && style.cursor === 'pointer') {
|
|
172
|
-
return { confidence: 'low', reason: 'svg-cursor' };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function getAccessibleText(el) {
|
|
179
|
-
// aria-label 优先
|
|
180
|
-
let t = el.getAttribute('aria-label');
|
|
181
|
-
if (t) return t.trim();
|
|
182
|
-
// aria-labelledby
|
|
183
|
-
const lbi = el.getAttribute('aria-labelledby');
|
|
184
|
-
if (lbi) {
|
|
185
|
-
const ids = lbi.split(/\s+/);
|
|
186
|
-
const texts = [];
|
|
187
|
-
for (const id of ids) {
|
|
188
|
-
const ref = document.getElementById(id);
|
|
189
|
-
if (ref) texts.push((ref.innerText || ref.textContent || '').trim());
|
|
190
|
-
}
|
|
191
|
-
const joined = texts.filter(Boolean).join(' ').trim();
|
|
192
|
-
if (joined) return joined;
|
|
193
|
-
}
|
|
194
|
-
// input 的 value / placeholder / type
|
|
195
|
-
const tag = el.tagName.toLowerCase();
|
|
196
|
-
if (tag === 'input') {
|
|
197
|
-
const type = (el.type || 'text').toLowerCase();
|
|
198
|
-
if (type === 'submit' || type === 'button') return (el.value || '').trim();
|
|
199
|
-
return (el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || '').trim();
|
|
200
|
-
}
|
|
201
|
-
// innerText(截断)
|
|
202
|
-
const it = (el.innerText || '').trim().replace(/\s+/g, ' ');
|
|
203
|
-
if (it) return it.slice(0, 120);
|
|
204
|
-
// title / alt
|
|
205
|
-
return (el.getAttribute('title') || el.getAttribute('alt') || '').trim();
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// 去掉之前可能残留的 data-dp-scan-id(比如上次调用异常中断)
|
|
209
|
-
document.querySelectorAll('[data-dp-scan-id]').forEach(el => el.removeAttribute('data-dp-scan-id'));
|
|
210
|
-
|
|
211
|
-
const results = [];
|
|
212
|
-
let counter = 0;
|
|
213
|
-
let truncated = false;
|
|
214
|
-
|
|
215
|
-
// 遍历所有元素
|
|
216
|
-
const all = document.querySelectorAll('*');
|
|
217
|
-
for (let i = 0; i < all.length; i++) {
|
|
218
|
-
if (results.length >= MAX_ELEMENTS) { truncated = true; break; }
|
|
219
|
-
const el = all[i];
|
|
220
|
-
|
|
221
|
-
const cls = classify(el);
|
|
222
|
-
if (!cls) continue;
|
|
223
|
-
|
|
224
|
-
const rect = isElementVisible(el);
|
|
225
|
-
if (!rect) continue;
|
|
226
|
-
|
|
227
|
-
if (VIEWPORT_ONLY && !inViewport(rect)) continue;
|
|
228
|
-
|
|
229
|
-
counter++;
|
|
230
|
-
try { el.setAttribute('data-dp-scan-id', String(counter)); } catch (e) { continue; }
|
|
231
|
-
|
|
232
|
-
results.push({
|
|
233
|
-
scanId: counter,
|
|
234
|
-
tag: el.tagName.toLowerCase(),
|
|
235
|
-
confidence: cls.confidence,
|
|
236
|
-
reason: cls.reason,
|
|
237
|
-
text: getAccessibleText(el).slice(0, 150),
|
|
238
|
-
rect: {
|
|
239
|
-
x: Math.round(rect.left),
|
|
240
|
-
y: Math.round(rect.top),
|
|
241
|
-
w: Math.round(rect.width),
|
|
242
|
-
h: Math.round(rect.height)
|
|
243
|
-
},
|
|
244
|
-
inViewport: inViewport(rect)
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return { elements: results, total: results.length, truncated: truncated };
|
|
249
|
-
})();
|
|
250
|
-
return __dp_detect_result;
|
|
251
|
-
"""
|
|
252
|
-
|
|
253
|
-
# 清理脚本:移除所有 data-dp-scan-id 属性
|
|
254
|
-
CLEANUP_CLICKABLES_JS = r"""
|
|
255
|
-
const __dp_cleanup_result = (function() {
|
|
256
|
-
const nodes = document.querySelectorAll('[data-dp-scan-id]');
|
|
257
|
-
let n = 0;
|
|
258
|
-
nodes.forEach(el => { el.removeAttribute('data-dp-scan-id'); n++; });
|
|
259
|
-
return { cleaned: n };
|
|
260
|
-
})();
|
|
261
|
-
return __dp_cleanup_result;
|
|
262
|
-
"""
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def build_detect_js(viewport_only: bool = False,
|
|
266
|
-
max_elements: int = 1000,
|
|
267
|
-
include_low: bool = False) -> str:
|
|
268
|
-
"""按参数填充模板,返回可注入的 JS 代码。"""
|
|
269
|
-
return DETECT_CLICKABLES_JS % {
|
|
270
|
-
'viewport_only': 'true' if viewport_only else 'false',
|
|
271
|
-
'max_elements': int(max_elements),
|
|
272
|
-
'include_low': 'true' if include_low else 'false',
|
|
273
|
-
}
|
|
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
|