dp-cli 0.6.2__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {dp_cli-0.6.2 → dp_cli-0.6.3}/PKG-INFO +1 -1
  2. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/keyboard.py +42 -12
  3. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/misc.py +2 -0
  4. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/network.py +45 -39
  5. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/record.py +14 -23
  6. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/storage.py +37 -3
  7. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/recorder.py +1 -3
  8. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/a11y.py +33 -14
  9. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/js_scripts.py +15 -3
  10. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/PKG-INFO +1 -1
  11. {dp_cli-0.6.2 → dp_cli-0.6.3}/pyproject.toml +1 -1
  12. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_a11y.py +85 -1
  13. dp_cli-0.6.3/tests/test_commands.py +889 -0
  14. dp_cli-0.6.2/tests/test_commands.py +0 -389
  15. {dp_cli-0.6.2 → dp_cli-0.6.3}/README.md +0 -0
  16. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/__init__.py +0 -0
  17. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/bridge.py +0 -0
  18. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/bridge_manager.py +0 -0
  19. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/__init__.py +0 -0
  20. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/_utils.py +0 -0
  21. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/browser.py +0 -0
  22. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/element.py +0 -0
  23. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/page.py +0 -0
  24. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/snapshot_cmd.py +0 -0
  25. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/commands/tab.py +0 -0
  26. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/locators/__init__.py +0 -0
  27. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/locators/playwright.py +0 -0
  28. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/locators/pw_js.py +0 -0
  29. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/main.py +0 -0
  30. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/output.py +0 -0
  31. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/session.py +0 -0
  32. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/__init__.py +0 -0
  33. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/clickable.py +0 -0
  34. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/clickable_js.py +0 -0
  35. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/extract.py +0 -0
  36. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/snapshot/utils.py +0 -0
  37. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli/stealth.py +0 -0
  38. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/SOURCES.txt +0 -0
  39. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/dependency_links.txt +0 -0
  40. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/entry_points.txt +0 -0
  41. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/requires.txt +0 -0
  42. {dp_cli-0.6.2 → dp_cli-0.6.3}/dp_cli.egg-info/top_level.txt +0 -0
  43. {dp_cli-0.6.2 → dp_cli-0.6.3}/setup.cfg +0 -0
  44. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_bridge_integration.py +0 -0
  45. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_bridge_manager.py +0 -0
  46. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_clickable.py +0 -0
  47. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_pw_locator.py +0 -0
  48. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_recorder.py +0 -0
  49. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_resolve_locator.py +0 -0
  50. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_session.py +0 -0
  51. {dp_cli-0.6.2 → dp_cli-0.6.3}/tests/test_snapshot_small.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dp-cli
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: A powerful CLI for DrissionPage — browser automation, structured data extraction, network listening and more.
5
5
  License: BSD-3-Clause
6
6
  Project-URL: Homepage, https://github.com/mofanx/dp-cli
@@ -34,13 +34,17 @@ def register(cli):
34
34
 
35
35
  \b
36
36
  支持的按键: Enter, Tab, Escape, Space, Backspace,
37
- ArrowUp/Down/Left/Right, F1-F12,
38
- Control+A, Shift+Enter, Alt+F4 等组合键。
37
+ ArrowUp/Down/Left/Right, F1-F12, Home, End,
38
+ PageUp/PageDown, Insert, Delete 等。
39
+ 组合键: Control+A, Shift+Enter, Alt+F4,
40
+ 支持多修饰键如 Control+Shift+Delete。
39
41
 
40
42
  \b
41
43
  示例:
42
44
  dp press Enter
43
45
  dp press "Control+A"
46
+ dp press "Control+Shift+Delete"
47
+ dp press F5
44
48
  dp press Escape
45
49
  """
46
50
  page = _get_page(session)
@@ -52,20 +56,46 @@ def register(cli):
52
56
  'arrowup': '\ue013', 'arrowdown': '\ue015',
53
57
  'arrowleft': '\ue012', 'arrowright': '\ue014',
54
58
  }
55
- k = key.lower()
59
+ mod_map = {'control': Keys.CTRL, 'ctrl': Keys.CTRL,
60
+ 'shift': Keys.SHIFT, 'alt': Keys.ALT,
61
+ 'meta': Keys.META, 'cmd': Keys.COMMAND,
62
+ 'command': Keys.COMMAND}
63
+ # 常见键名别名 → Keys 枚举名(枚举用下划线风格,如 PAGE_UP;
64
+ # 映射失败会回退为字面输入,带修饰键时会误触浏览器快捷键,
65
+ # 所以别名必须覆盖常用名)
66
+ key_alias = {
67
+ 'pageup': 'PAGE_UP', 'pgup': 'PAGE_UP',
68
+ 'pagedown': 'PAGE_DOWN', 'pgdn': 'PAGE_DOWN',
69
+ 'del': 'DEL', 'esc': 'ESCAPE', 'win': 'META',
70
+ }
71
+
72
+ def _key_token(name):
73
+ """键名 → Keys 枚举/特殊键码;未知键按字面输入。"""
74
+ low = name.strip().lower()
75
+ if low in key_map:
76
+ return key_map[low]
77
+ attr = key_alias.get(
78
+ low, low.upper().replace('-', '_').replace(' ', '_'))
79
+ k = getattr(Keys, attr, None)
80
+ return k if k is not None else name.strip()
81
+
56
82
  if '+' in key:
57
- parts = key.split('+')
58
- modifier = parts[0].lower()
59
- main_key = parts[1]
60
- mod_map = {'control': Keys.CTRL, 'ctrl': Keys.CTRL,
61
- 'shift': Keys.SHIFT, 'alt': Keys.ALT}
62
- if modifier in mod_map:
63
- page.actions.key_down(mod_map[modifier]).type(main_key).key_up(mod_map[modifier])
83
+ # 前缀修饰键逐个出栈,支持 Control+Shift+Delete 多修饰组合
84
+ parts = [p for p in key.split('+') if p.strip()]
85
+ mods = []
86
+ while len(parts) > 1 and parts[0].strip().lower() in mod_map:
87
+ mods.append(mod_map[parts.pop(0).strip().lower()])
88
+ if mods and parts:
89
+ act = page.actions
90
+ for m in mods:
91
+ act = act.key_down(m)
92
+ act = act.type(_key_token('+'.join(parts)))
93
+ for m in reversed(mods):
94
+ act = act.key_up(m)
64
95
  else:
65
96
  page.actions.type(key)
66
97
  else:
67
- actual_key = key_map.get(k, key)
68
- page.actions.type(actual_key)
98
+ page.actions.type(_key_token(key))
69
99
  ok({'key': key}, msg='按键成功')
70
100
  except Exception as e:
71
101
  error(f'按键失败: {key}', code='KEY_FAILED', detail=str(e))
@@ -134,3 +134,5 @@ def register(cli):
134
134
  co.set_user_data_path(user_path)
135
135
  co.save()
136
136
  ok(msg='配置已保存')
137
+ elif not copy_config:
138
+ click.echo('请至少指定一个选项。使用 --help 查看可用参数。')
@@ -9,13 +9,46 @@ from dp_cli.output import ok, error
9
9
  from dp_cli.commands._utils import session_option, _get_page
10
10
 
11
11
 
12
+ def _emit_response(page, output):
13
+ """构造 http-get/http-post 的输出。
14
+
15
+ --output 指定时把原始响应字节写入文件(兼容二进制),
16
+ 否则把 body 内联到结果(JSON 优先,文本截断 3000 字)。
17
+ """
18
+ resp = page.response
19
+ result = {
20
+ 'url': page.url,
21
+ 'status_code': resp.status_code if resp is not None else None,
22
+ }
23
+ if output:
24
+ # response 为 None(请求失败未拿到响应)时写空文件,避免 AttributeError
25
+ Path(output).write_bytes(resp.content if resp is not None else b'')
26
+ result['saved_to'] = output
27
+ if resp is None:
28
+ result['warning'] = '无响应内容(response is None),已写入空文件'
29
+ return result
30
+ if resp is None:
31
+ result['body'] = None
32
+ return result
33
+ try:
34
+ result['body'] = resp.json()
35
+ except Exception:
36
+ try:
37
+ result['body'] = resp.text[:3000]
38
+ except Exception:
39
+ result['body'] = '<binary>'
40
+ return result
41
+
42
+
12
43
  def register(cli):
13
44
 
14
45
  @cli.command('listen')
15
46
  @session_option
16
47
  @click.option('--filter', 'url_filter', default=None, help='URL 过滤关键字,如 "api/user"')
17
- @click.option('--count', default=10, help='最多捕获请求数', show_default=True)
18
- @click.option('--timeout', default=30, help='监听超时秒数', show_default=True)
48
+ # --count/--timeout 对 listen.start 无意义(作用于 listen-stop 的 wait),
49
+ # 保留参数兼容性但从 help 隐藏,避免误导
50
+ @click.option('--count', default=10, hidden=True)
51
+ @click.option('--timeout', default=30, hidden=True)
19
52
  @click.option('--method', default=None, help='过滤请求方法,如 POST')
20
53
  def listen(session, url_filter, count, timeout, method):
21
54
  """监听网络请求(抓包)。先 listen,再执行触发操作,最后 listen-stop 读取结果。
@@ -26,7 +59,7 @@ def register(cli):
26
59
  \b
27
60
  示例:
28
61
  dp listen --filter "api/login"
29
- dp listen --count 5 --timeout 10
62
+ dp listen --method POST
30
63
  """
31
64
  page = _get_page(session)
32
65
  try:
@@ -44,7 +77,11 @@ def register(cli):
44
77
  """停止监听并获取捕获的网络请求数据。"""
45
78
  page = _get_page(session)
46
79
  try:
47
- packets = page.listen.wait(count=count, timeout=timeout, fit_count=False)
80
+ # finally 中 stop:wait 超时/出错也必须停止监听,否则监听器残留
81
+ try:
82
+ packets = page.listen.wait(count=count, timeout=timeout, fit_count=False)
83
+ finally:
84
+ page.listen.stop()
48
85
  results = []
49
86
  if packets:
50
87
  pkts = packets if isinstance(packets, list) else [packets]
@@ -63,7 +100,6 @@ def register(cli):
63
100
  results.append(item)
64
101
  except Exception:
65
102
  continue
66
- page.listen.stop()
67
103
  ok({'packets': results, 'count': len(results)})
68
104
  except Exception as e:
69
105
  error(f'获取监听数据失败', code='LISTEN_FAILED', detail=str(e))
@@ -95,25 +131,7 @@ def register(cli):
95
131
 
96
132
  page = SessionPage(session_or_options=so)
97
133
  page.get(url, timeout=timeout)
98
-
99
- result = {
100
- 'url': page.url,
101
- 'status_code': page.response.status_code if page.response else None,
102
- }
103
- try:
104
- result['body'] = page.response.json()
105
- except Exception:
106
- try:
107
- body_text = page.response.text[:3000]
108
- except Exception:
109
- body_text = '<binary>'
110
- if output:
111
- Path(output).write_text(page.response.text, encoding='utf-8')
112
- result['saved_to'] = output
113
- else:
114
- result['body'] = body_text
115
-
116
- ok(result)
134
+ ok(_emit_response(page, output))
117
135
  except Exception as e:
118
136
  error(f'HTTP GET 失败', code='HTTP_FAILED', detail=str(e))
119
137
 
@@ -124,7 +142,8 @@ def register(cli):
124
142
  @click.option('--headers', default=None, help='JSON 格式的 Headers')
125
143
  @click.option('--proxy', default=None, help='代理地址')
126
144
  @click.option('--timeout', default=30, help='超时秒数', show_default=True)
127
- def http_post(url, data, form, headers, proxy, timeout):
145
+ @click.option('--output', default=None, help='响应体保存路径')
146
+ def http_post(url, data, form, headers, proxy, timeout, output):
128
147
  """发送 HTTP POST 请求(不启动浏览器)。
129
148
 
130
149
  \b
@@ -151,19 +170,6 @@ def register(cli):
151
170
  kwargs['data'] = json.loads(form)
152
171
 
153
172
  page.post(url, **kwargs)
154
-
155
- result = {
156
- 'url': page.url,
157
- 'status_code': page.response.status_code if page.response else None,
158
- }
159
- try:
160
- result['body'] = page.response.json()
161
- except Exception:
162
- try:
163
- result['body'] = page.response.text[:3000]
164
- except Exception:
165
- result['body'] = '<binary>'
166
-
167
- ok(result)
173
+ ok(_emit_response(page, output))
168
174
  except Exception as e:
169
175
  error(f'HTTP POST 失败', code='HTTP_FAILED', detail=str(e))
@@ -1,5 +1,6 @@
1
1
  # -*- coding:utf-8 -*-
2
2
  """操作录制命令: record-start / record-stop / record-show / record-clear"""
3
+ import sys
3
4
  from pathlib import Path
4
5
 
5
6
  import click
@@ -16,20 +17,22 @@ def _get_active_page_for_record(session):
16
17
  """获取浏览器当前激活的标签页用于录制(忽略 session 中的 active_tab 绑定)"""
17
18
  from dp_cli.session import get_browser
18
19
  page = get_browser(session)
19
-
20
- # 使用 CDP 找到当前真正激活的标签页
20
+
21
+ # 使用 CDP 找到当前真正激活的标签页,直接按 targetId 取 tab
22
+ # (DrissionPage 的 tab_id 即 CDP targetId,无需为每个 target 遍历全部 tab)
21
23
  try:
22
24
  result = page.run_cdp('Target.getTargets')
23
25
  for target in result.get('targetInfos', []):
24
26
  if target.get('attached') and target.get('type') == 'page':
25
- target_id = target.get('targetId')
26
- # 通过 targetId 获取对应的 tab
27
- for tab in [page.get_tab(tid) for tid in _get_all_tab_ids(page)]:
28
- if tab and tab.tab_id == target_id:
29
- return tab
27
+ try:
28
+ tab = page.get_tab(target.get('targetId'))
29
+ except Exception:
30
+ continue
31
+ if tab:
32
+ return tab
30
33
  except Exception as e:
31
- print(f'[record] Failed to get active tab via CDP: {e}', file=__import__('sys').stderr)
32
-
34
+ print(f'[record] Failed to get active tab via CDP: {e}', file=sys.stderr)
35
+
33
36
  # 回退到 latest_tab
34
37
  try:
35
38
  return page.latest_tab or page
@@ -37,19 +40,6 @@ def _get_active_page_for_record(session):
37
40
  return page
38
41
 
39
42
 
40
- def _get_all_tab_ids(page):
41
- """获取所有标签页的 ID 列表"""
42
- try:
43
- # 使用 CDP 获取所有目标
44
- result = page.run_cdp('Target.getTargets')
45
- return [
46
- t.get('targetId') for t in result.get('targetInfos', [])
47
- if t.get('type') == 'page'
48
- ]
49
- except Exception:
50
- return []
51
-
52
-
53
43
  def _get_recording_page(session):
54
44
  page = _get_page(session, raw=True, inject_recording=False)
55
45
  sess = load_session(session) or {}
@@ -98,7 +88,8 @@ def register(cli):
98
88
  save_session(session, sess)
99
89
  data = dict(status or {})
100
90
  data['tab'] = _page_info(page)
101
- msg = (f'操作录制已开始,已绑定标签页: {data["tab"]["url"][:40]}...\n'
91
+ tab_url = (data['tab']['url'] or '')[:40]
92
+ msg = (f'操作录制已开始,已绑定标签页: {tab_url}...\n'
102
93
  f' 提示: 如果绑定不正确,请先执行 "dp tab-select <序号>" 选择正确标签,再录制')
103
94
  ok(data, msg=msg)
104
95
  except Exception as e:
@@ -26,10 +26,32 @@ def register(cli):
26
26
  """
27
27
  page = _get_page(session)
28
28
  try:
29
- cookies = page.cookies(all_domains=True).as_dict()
29
+ # all_info=True 保留 domain/path 等完整字段,供 --domain/--url 过滤
30
+ cookies = page.cookies(all_domains=True, all_info=True)
31
+ # --url:取 host 做 cookie domain 匹配(.example.com 匹配 sub.example.com)
32
+ host = None
33
+ if url:
34
+ host = url.split('://', 1)[-1].split('/')[0].split(':')[0].lower()
30
35
  cookie_list_data = []
31
- for name, value in cookies.items():
32
- cookie_list_data.append({'name': name, 'value': value})
36
+ for c in cookies:
37
+ c_domain = (c.get('domain') or '')
38
+ if domain:
39
+ # 域级匹配(标签边界):example.com 命中 .example.com /
40
+ # sub.example.com 的 cookie,不命中 notexample.com
41
+ # (子串匹配会误中)
42
+ fd = domain.lower().lstrip('.')
43
+ cd = c_domain.lower().lstrip('.')
44
+ if not (cd == fd or cd.endswith('.' + fd)
45
+ or fd.endswith('.' + cd)):
46
+ continue
47
+ if host:
48
+ d = c_domain.lstrip('.')
49
+ if not (host == d or host.endswith('.' + d)):
50
+ continue
51
+ cookie_list_data.append({'name': c.get('name'),
52
+ 'value': c.get('value'),
53
+ 'domain': c_domain,
54
+ 'path': c.get('path', '/')})
33
55
  ok({'cookies': cookie_list_data, 'count': len(cookie_list_data)})
34
56
  except Exception as e:
35
57
  error(f'获取 Cookie 失败', code='COOKIE_FAILED', detail=str(e))
@@ -210,6 +232,18 @@ def register(cli):
210
232
  except Exception as e:
211
233
  error(f'设置 sessionStorage 失败', code='STORAGE_FAILED', detail=str(e))
212
234
 
235
+ @cli.command('sessionstorage-delete')
236
+ @click.argument('key')
237
+ @session_option
238
+ def sessionstorage_delete(key, session):
239
+ """删除 sessionStorage 指定键。"""
240
+ page = _get_page(session)
241
+ try:
242
+ page.run_js(f'sessionStorage.removeItem({json.dumps(key)})', as_expr=True)
243
+ ok({'key': key}, msg='sessionStorage 键已删除')
244
+ except Exception as e:
245
+ error(f'删除 sessionStorage 失败', code='STORAGE_FAILED', detail=str(e))
246
+
213
247
  @cli.command('sessionstorage-clear')
214
248
  @session_option
215
249
  def sessionstorage_clear(session):
@@ -266,7 +266,7 @@ _RECORDER_JS = r"""
266
266
  }
267
267
 
268
268
  function findScrollable(el) {
269
- let cur = el && el.nodeType === 1 ? el : el.parentElement;
269
+ let cur = (el && el.nodeType === 1) ? el : (el && el.parentElement);
270
270
  while (cur && cur !== document.body && cur !== document.documentElement) {
271
271
  const st = getComputedStyle(cur);
272
272
  const canY = /(auto|scroll|overlay)/.test(st.overflowY) && cur.scrollHeight > cur.clientHeight + 1;
@@ -362,8 +362,6 @@ _RECORDER_JS = r"""
362
362
  document.addEventListener('keydown', onKeyDown, true);
363
363
  document.addEventListener('wheel', onWheel, true);
364
364
 
365
- console.log('[dp-recorder] initialized, version:', VERSION);
366
-
367
365
  window.__dpRecorder = {
368
366
  version: VERSION,
369
367
  start: function () { setRecording(true); },
@@ -13,6 +13,8 @@ a11y tree — 浏览器原生无障碍树获取与渲染
13
13
  5. render_a11y_text() 渲染为可读文本
14
14
  6. CDP 失败时降级到 JS fallback
15
15
  """
16
+ import json
17
+
16
18
  from .utils import suggest_locator
17
19
 
18
20
  # 交互角色列表:需要生成定位器的角色
@@ -368,9 +370,11 @@ def _find_subtree_by_selector(page, tree: dict, all_nodes: list,
368
370
  xpath_selector = normalized[6:]
369
371
  # 使用 Runtime.evaluate 执行 document.evaluate 支持 xpath
370
372
  # ponytail: 不使用 returnByValue,直接获取 objectId
373
+ # xpath 经 json.dumps 转义——xpath 常含双引号(如 //div[@class="x"]),
374
+ # 直接拼进 JS 字符串会语法错误,甚至有注入风险
371
375
  js_code = f'''
372
376
  (function() {{
373
- const result = document.evaluate("{xpath_selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
377
+ const result = document.evaluate({json.dumps(xpath_selector)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
374
378
  return result.singleNodeValue;
375
379
  }})()
376
380
  '''
@@ -397,10 +401,9 @@ def _find_subtree_by_selector(page, tree: dict, all_nodes: list,
397
401
 
398
402
  # ponytail: iframe 特殊处理 - 获取 iframe 的 frameId 并返回该 frame 的快照
399
403
  if node_name == 'iframe':
400
- # 获取 iframe 的 frameId
404
+ # 上面的 describeNode 结果已含 frameId,无需重复 CDP 调用
401
405
  try:
402
- frame_info = page.run_cdp('DOM.describeNode', nodeId=node_id)
403
- frame_id = frame_info['node'].get('frameId')
406
+ frame_id = node_info.get('frameId')
404
407
  if frame_id:
405
408
  # 重新获取该 frame 的 a11y tree
406
409
  flat_nodes = _get_full_tree_cdp(page, max_depth=None, frame_id=frame_id)
@@ -502,7 +505,19 @@ def _build_tree(flat_nodes: list) -> dict:
502
505
  roots = [n for n in flat_nodes
503
506
  if not n.get('parentId') or n['parentId'] not in node_map]
504
507
 
505
- return roots[0] if roots else flat_nodes[0] if flat_nodes else {}
508
+ if not roots:
509
+ return flat_nodes[0] if flat_nodes else {}
510
+ if len(roots) == 1:
511
+ return roots[0]
512
+ # 多根节点(如 iframe):用合成容器包裹,避免丢失子树
513
+ return {
514
+ 'nodeId': '__root__',
515
+ 'role': 'generic',
516
+ 'name': '',
517
+ 'children': roots,
518
+ 'backendNodeId': None,
519
+ 'locator': None,
520
+ }
506
521
 
507
522
 
508
523
  def _compute_stats(nodes: list) -> dict:
@@ -553,7 +568,6 @@ def _generate_locators_batch(page, interactive_nodes: list, frame_id: str = None
553
568
  attrs_map = {}
554
569
  try:
555
570
  # 如果没有指定自定义优先级,使用默认优先级
556
- from .utils import suggest_locator
557
571
  default_priority = [
558
572
  'data-testid', 'data-test', 'data-test-id',
559
573
  'data-qa', 'data-cy', 'id', 'aria-label', 'name', 'placeholder'
@@ -565,15 +579,16 @@ def _generate_locators_batch(page, interactive_nodes: list, frame_id: str = None
565
579
  combined_selector = ','.join(selectors)
566
580
 
567
581
  # 使用 JS 一次性获取所有元素的属性
582
+ # selector 与 attr 列表经 json.dumps 转义,避免引号破坏 JS / 注入
568
583
  js = f"""
569
584
  (function() {{
570
585
  const result = {{}};
571
- const elements = document.querySelectorAll('{combined_selector}');
586
+ const elements = document.querySelectorAll({json.dumps(combined_selector)});
572
587
  elements.forEach(el => {{
573
588
  const text = el.textContent.trim().substring(0, 50);
574
589
  if (text && !result[text]) {{
575
590
  const attrs = {{}};
576
- for (const attr of {attrs_to_get}) {{
591
+ for (const attr of {json.dumps(attrs_to_get)}) {{
577
592
  if (el.hasAttribute(attr)) {{
578
593
  attrs[attr] = el.getAttribute(attr);
579
594
  }}
@@ -588,7 +603,6 @@ def _generate_locators_batch(page, interactive_nodes: list, frame_id: str = None
588
603
  """
589
604
  result = page.run_cdp('Runtime.evaluate', expression=js, returnByValue=True)
590
605
  if result.get('result', {}).get('value'):
591
- import json
592
606
  attrs_map = json.loads(result['result']['value'])
593
607
  except Exception:
594
608
  pass
@@ -708,7 +722,7 @@ def _build_dom_bid_map(page, frame_id: str = None) -> dict:
708
722
  :param frame_id: 可选,限制获取特定 frame 的 DOM 树
709
723
  """
710
724
  try:
711
- kwargs = {'depth': -1}
725
+ kwargs = {'depth': -1, 'pierce': True}
712
726
  if frame_id:
713
727
  kwargs['frameId'] = frame_id
714
728
  doc = page.run_cdp('DOM.getDocument', **kwargs)
@@ -763,7 +777,8 @@ def _render_node(node: dict, lines: list, depth: int = 0,
763
777
  """递归渲染单个 a11y 节点为文本行
764
778
 
765
779
  :param parent_text: 父节点已显示的文本,用于消除子节点冗余
766
- :param brief: True 时跳过内容节点(paragraph/heading 等),只显示交互元素
780
+ :param brief: True 时跳过独立文本节点并截断内容节点长文本(>80 字),
781
+ 保留结构 + 交互元素概览,省 token
767
782
  :param ctx: 渲染上下文 {'counter': int, 'refs': dict},用于分配 [N] 编号
768
783
  """
769
784
  role = node.get('role', '')
@@ -771,10 +786,14 @@ def _render_node(node: dict, lines: list, depth: int = 0,
771
786
  ignored = node.get('ignored', False)
772
787
  children = node.get('children', [])
773
788
 
774
- # brief 模式:跳过内容节点(paragraph/heading 等),但保留交互元素和容器
775
- if brief and role in _CONTENT_ROLES:
789
+ # brief 模式:跳过非关键内容节点(cell/listitem/caption 等),
790
+ # 但保留 _REF_CONTENT_ROLES(paragraph/heading/code/blockquote),由后续逻辑截断;
791
+ # 跳过时仍递归子节点,避免丢失嵌套的交互元素
792
+ if brief and role in _CONTENT_ROLES and role not in _REF_CONTENT_ROLES:
793
+ for child in children:
794
+ _render_node(child, lines, depth, verbose=verbose,
795
+ parent_text=parent_text, brief=brief, ctx=ctx)
776
796
  return
777
-
778
797
  # 跳过 ignored 节点(除非 verbose),但仍然渲染子节点
779
798
  if ignored and not verbose:
780
799
  for child in children:
@@ -105,6 +105,15 @@ _JS_A11Y_FALLBACK = """
105
105
  const cn = buildNode(child, depth + 1);
106
106
  if (cn) children.push(cn);
107
107
  }
108
+ // 穿透 open shadow DOM
109
+ if (el.shadowRoot) {
110
+ try {
111
+ for (const child of el.shadowRoot.children) {
112
+ const cn = buildNode(child, depth + 1);
113
+ if (cn) children.push(cn);
114
+ }
115
+ } catch (e) {}
116
+ }
108
117
 
109
118
  // 跳过无意义容器(无 role、无 name、只有一个子节点)
110
119
  if (!role && !name && children.length === 1) {
@@ -140,9 +149,12 @@ _JS_A11Y_FALLBACK = """
140
149
  const v = el.getAttribute(attr);
141
150
  if (v) return '@' + attr + '=' + v;
142
151
  }
143
- const cls = el.className;
144
- if (typeof cls === 'string' && cls.trim()) {
145
- return '.' + cls.trim().split(/\\s+/)[0];
152
+ const cls = typeof el.className === 'string' ? el.className : (el.className && el.className.baseVal) || '';
153
+ if (cls.trim()) {
154
+ const parts = cls.trim().split(/\\s+/).filter(function(c) {
155
+ return c && !/^[a-z0-9]{6,}$/i.test(c) && !/^[_-]/.test(c);
156
+ });
157
+ if (parts.length) return '.' + parts[0];
146
158
  }
147
159
  const text = (el.textContent || '').trim();
148
160
  if (text && text.length <= 30) return 'text:' + text;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dp-cli
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: A powerful CLI for DrissionPage — browser automation, structured data extraction, network listening and more.
5
5
  License: BSD-3-Clause
6
6
  Project-URL: Homepage, https://github.com/mofanx/dp-cli
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dp-cli"
7
- version = "0.6.2"
7
+ version = "0.6.3"
8
8
  description = "A powerful CLI for DrissionPage — browser automation, structured data extraction, network listening and more."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -658,4 +658,88 @@ def test_generate_locators_iframe_frame():
658
658
  # 没有 id 的使用 class
659
659
  assert interactive_nodes[1]['locator'] == '.btn-export' or 'btn-export' in interactive_nodes[1]['locator']
660
660
  # 查找失败的回退到 text:
661
- assert interactive_nodes[2]['locator'] == 'text:搜索'
661
+ assert interactive_nodes[2]['locator'] == 'text:搜索'
662
+
663
+ # ─────────────────────────────────────────────────────────────────────────────
664
+ # t3 增强回归:JS 转义 / iframe describeNode 去重
665
+ # ─────────────────────────────────────────────────────────────────────────────
666
+
667
+ @pytest.mark.unit
668
+ def test_find_subtree_xpath_quotes_escaped():
669
+ """xpath 含双引号(如 //div[@class="x"])时必须转义后再拼进 JS,
670
+ 否则 Runtime.evaluate 语法错误;断言传入的 expression 已被 json 转义。"""
671
+ exprs = []
672
+
673
+ def rt(**kw):
674
+ exprs.append(kw.get('expression', ''))
675
+ return {'result': {'type': 'object', 'objectId': 'obj1'}}
676
+
677
+ page = FakePage(cdp_map={
678
+ 'DOM.getDocument': {'root': {'nodeId': 1}},
679
+ 'Runtime.evaluate': rt,
680
+ 'DOM.requestNode': {'nodeId': 100},
681
+ 'DOM.describeNode': {'node': {'backendNodeId': 200, 'nodeName': 'DIV'}},
682
+ })
683
+ tree = node(role='root', backendNodeId=1, children=[
684
+ node(role='generic', backendNodeId=200)
685
+ ])
686
+ result, warning, frame_id = a11y._find_subtree_by_selector(
687
+ page, tree, [], 'xpath://div[@class="x"]')
688
+
689
+ assert exprs, 'Runtime.evaluate 未被调用'
690
+ # xpath 应作为 JSON 字符串字面量出现(双引号被 \" 转义)
691
+ assert '//div[@class=\\"x\\"]' in exprs[0]
692
+ assert warning is None
693
+ assert result['backendNodeId'] == 200
694
+
695
+
696
+ @pytest.mark.unit
697
+ def test_find_subtree_iframe_describe_node_once():
698
+ """iframe 分支复用首次 describeNode 的 frameId,整个流程只调 1 次。"""
699
+ calls = []
700
+
701
+ def describe(**kw):
702
+ calls.append(kw)
703
+ return {'node': {'backendNodeId': 200, 'nodeName': 'IFRAME',
704
+ 'frameId': 'F1'}}
705
+
706
+ page = FakePage(cdp_map={
707
+ 'DOM.getDocument': {'root': {'nodeId': 1}},
708
+ 'DOM.querySelector': {'nodeId': 100},
709
+ 'DOM.describeNode': describe,
710
+ 'Accessibility.getFullAXTree': {
711
+ 'nodes': [{'nodeId': '1', 'role': {'value': 'WebArea'},
712
+ 'backendDOMNodeId': 1, 'childIds': []}]
713
+ },
714
+ })
715
+ tree = node(role='root', backendNodeId=1)
716
+ result, warning, frame_id = a11y._find_subtree_by_selector(
717
+ page, tree, [], 'iframe')
718
+
719
+ assert frame_id == 'F1'
720
+ assert '已切换到 iframe frame' in warning
721
+ assert len(calls) == 1
722
+
723
+
724
+ @pytest.mark.unit
725
+ def test_generate_locators_escapes_attr_selector():
726
+ """attr_priority 含引号时,拼进 JS 的 selector / attr 列表必须经
727
+ json.dumps 转义,不产生非法 JS 或注入。"""
728
+ exprs = []
729
+
730
+ def rt(**kw):
731
+ exprs.append(kw.get('expression', ''))
732
+ return {'result': {'value': '{}'}}
733
+
734
+ page = FakePage(cdp_map={
735
+ 'DOM.getDocument': {'root': {'backendNodeId': 1, 'nodeName': 'DIV',
736
+ 'attributes': [], 'children': []}},
737
+ 'Runtime.evaluate': rt,
738
+ })
739
+ nodes = [node(role='button', backendNodeId=1, name='x')]
740
+ a11y._generate_locators_batch(page, nodes, attr_priority=["data-fo'o"])
741
+
742
+ assert exprs, 'Runtime.evaluate 未被调用'
743
+ # 属性名以 JSON 字符串形式出现(双引号包裹,单引号原样保留)
744
+ assert '"[data-fo\'o]"' in exprs[0]
745
+ assert '["data-fo\'o"]' in exprs[0]