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.
Files changed (39) hide show
  1. {dp_cli-0.3.0 → dp_cli-0.3.1}/PKG-INFO +8 -3
  2. {dp_cli-0.3.0 → dp_cli-0.3.1}/README.md +7 -2
  3. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/snapshot_cmd.py +12 -24
  4. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/a11y.py +10 -25
  5. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/clickable.py +28 -15
  6. dp_cli-0.3.1/dp_cli/snapshot/clickable_js.py +381 -0
  7. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/PKG-INFO +8 -3
  8. {dp_cli-0.3.0 → dp_cli-0.3.1}/pyproject.toml +1 -1
  9. {dp_cli-0.3.0 → dp_cli-0.3.1}/tests/test_clickable.py +60 -16
  10. dp_cli-0.3.0/dp_cli/snapshot/clickable_js.py +0 -273
  11. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/__init__.py +0 -0
  12. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/bridge.py +0 -0
  13. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/bridge_manager.py +0 -0
  14. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/__init__.py +0 -0
  15. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/_utils.py +0 -0
  16. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/browser.py +0 -0
  17. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/element.py +0 -0
  18. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/keyboard.py +0 -0
  19. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/misc.py +0 -0
  20. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/network.py +0 -0
  21. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/page.py +0 -0
  22. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/storage.py +0 -0
  23. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/commands/tab.py +0 -0
  24. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/main.py +0 -0
  25. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/output.py +0 -0
  26. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/session.py +0 -0
  27. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/__init__.py +0 -0
  28. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/extract.py +0 -0
  29. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/js_scripts.py +0 -0
  30. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/snapshot/utils.py +0 -0
  31. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli/stealth.py +0 -0
  32. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/SOURCES.txt +0 -0
  33. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/dependency_links.txt +0 -0
  34. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/entry_points.txt +0 -0
  35. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/requires.txt +0 -0
  36. {dp_cli-0.3.0 → dp_cli-0.3.1}/dp_cli.egg-info/top_level.txt +0 -0
  37. {dp_cli-0.3.0 → dp_cli-0.3.1}/setup.cfg +0 -0
  38. {dp_cli-0.3.0 → dp_cli-0.3.1}/tests/test_bridge_integration.py +0 -0
  39. {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.0
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 match (`btn` / `click` / `toggle` / …) |
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 match (`btn` / `click` / `toggle` / …) |
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
- def scan(session, viewport_only, confidence, max_elements, fmt, filename):
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('- 用 ref:N 引用元素(⚡ = medium, ? = low)')
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
- marker = marker_map.get(rec.get('confidence'), '')
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 CONFIDENCE_MARKER
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' ref:N 引用')
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
- marker = CONFIDENCE_MARKER.get(rec.get('confidence'), '')
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': loc,
262
- 'role': f'clickable/{tag}',
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
- [42] button "Sign in" (role=button, pos=120,340, 80x32) → #signin
175
- [43] ? span "•••" (cursor:pointer, pos=200,150, 24x24) → .more-icon
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
- text = rec.get('text') or ''
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 text:
186
- parts.append(f'"{text[:80]}"')
187
- rect_str = ''
188
- if rect:
189
- rect_str = f'{rect.get("w", 0)}x{rect.get("h", 0)}@{rect.get("x", 0)},{rect.get("y", 0)}'
190
- meta = f'({reason}'
191
- if rect_str:
192
- meta += f', {rect_str}'
193
- meta += ')'
194
- parts.append(meta)
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.0
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 match (`btn` / `click` / `toggle` / …) |
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.0"
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
- # LOW
66
- assert "cursor:pointer" in body.lower() or "'pointer'" in body
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 'button' in s # reason
98
- assert '80x32' in s
118
+ assert '@top-right' in s
99
119
  assert '→ #signin' in s
100
- # high 不带标记
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 test_format_clickable_record_medium_has_marker():
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': 'onclick-attr',
110
- 'text': 'Open',
111
- 'rect': {'x': 0, 'y': 0, 'w': 24, 'h': 24},
112
- 'locator': '.open-btn',
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 'onclick-attr' in s
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
- 'rect': {'x': 0, 'y': 0, 'w': 16, 'h': 16},
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 'cursor:pointer' in s
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