dp-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # -*- coding:utf-8 -*-
@@ -0,0 +1,12 @@
1
+ # -*- coding:utf-8 -*-
2
+ from dp_cli.commands import (
3
+ browser, snapshot_cmd, element, keyboard,
4
+ page, tab, storage, network, misc,
5
+ )
6
+
7
+ _MODULES = [browser, snapshot_cmd, element, keyboard, page, tab, storage, network, misc]
8
+
9
+
10
+ def register_all(cli):
11
+ for mod in _MODULES:
12
+ mod.register(cli)
@@ -0,0 +1,107 @@
1
+ # -*- coding:utf-8 -*-
2
+ """所有命令模块共享的工具函数和装饰器"""
3
+ import io
4
+ import csv
5
+ import click
6
+
7
+ from dp_cli.session import get_browser, load_refs, load_session, save_session
8
+ from dp_cli.output import error
9
+
10
+
11
+ def normalize_url(url: str) -> str:
12
+ """补全 URL scheme,支持省略 http:// / https://"""
13
+ if not url:
14
+ return url
15
+ if not url.startswith(('http://', 'https://', 'file://')):
16
+ return 'https://' + url
17
+ return url
18
+
19
+
20
+ def session_option(f):
21
+ return click.option('-s', '--session', default='default',
22
+ help='会话名称,默认 default', show_default=True)(f)
23
+
24
+
25
+ def _get_page(session: str, raw: bool = False):
26
+ """获取页面对象,失败则 error 退出。
27
+
28
+ :param raw: True 时始终返回 ChromiumPage(用于浏览器级操作如标签页管理)。
29
+ False 时返回绑定的标签页 ChromiumTab(如有),否则返回 ChromiumPage。
30
+ """
31
+ try:
32
+ page = get_browser(session)
33
+ except Exception as e:
34
+ error(f'无法连接浏览器会话 [{session}],请先执行 dp open',
35
+ code='SESSION_NOT_FOUND', detail=str(e))
36
+ return
37
+
38
+ if raw:
39
+ return page
40
+
41
+ # 检查是否有绑定的标签页
42
+ sess = load_session(session)
43
+ tab_id = sess.get('active_tab')
44
+ if tab_id:
45
+ try:
46
+ tab = page.get_tab(tab_id)
47
+ return tab
48
+ except Exception:
49
+ # 标签页可能已关闭,清除绑定
50
+ sess.pop('active_tab', None)
51
+ save_session(session, sess)
52
+
53
+ return page
54
+
55
+
56
+ def resolve_locator(locator: str, session: str = 'default') -> str:
57
+ """解析定位器,支持 ref:N 语法。
58
+
59
+ 如果 locator 以 'ref:' 开头,从 session 的 refs 映射中查找真实定位器。
60
+ 否则原样返回。
61
+ """
62
+ if not locator.startswith('ref:'):
63
+ return locator
64
+
65
+ ref_id = locator[4:]
66
+ refs = load_refs(session)
67
+ if not refs:
68
+ error(f'没有可用的 ref 映射,请先执行 dp snapshot',
69
+ code='NO_REFS')
70
+ raise SystemExit(1)
71
+
72
+ ref_data = refs.get(ref_id)
73
+ if not ref_data:
74
+ available = sorted(refs.keys(), key=lambda x: int(x) if x.isdigit() else 0)
75
+ hint = f"可用范围: ref:1 ~ ref:{available[-1]}" if available else ""
76
+ error(f'ref:{ref_id} 不存在。{hint}',
77
+ code='REF_NOT_FOUND')
78
+ raise SystemExit(1)
79
+
80
+ real_loc = ref_data.get('locator')
81
+ if real_loc and not real_loc.startswith('t:'):
82
+ return real_loc
83
+
84
+ # locator 不可用时(如 t:p),尝试用 name 作为 text 定位器
85
+ name = ref_data.get('name', '')
86
+ if name and len(name) <= 50:
87
+ return f'text:{name}'
88
+
89
+ error(f'ref:{ref_id} 无法解析为有效定位器 (role={ref_data.get("role")})',
90
+ code='REF_UNRESOLVABLE')
91
+ raise SystemExit(1)
92
+
93
+
94
+ def records_to_csv(records: list) -> str:
95
+ """将记录列表转为 CSV 字符串(含 BOM,Excel 直接打开不乱码)"""
96
+ if not records:
97
+ return ''
98
+ fields = list(records[0].keys())
99
+ buf = io.StringIO()
100
+ writer = csv.DictWriter(buf, fieldnames=fields, extrasaction='ignore',
101
+ lineterminator='\n')
102
+ writer.writeheader()
103
+ for row in records:
104
+ clean = {k: ('|'.join(str(i) for i in v) if isinstance(v, list) else v)
105
+ for k, v in row.items()}
106
+ writer.writerow(clean)
107
+ return buf.getvalue()
@@ -0,0 +1,159 @@
1
+ # -*- coding:utf-8 -*-
2
+ """浏览器生命周期命令: open / goto / reload / go-back / go-forward / close / close-all / list / delete-data"""
3
+ import click
4
+
5
+ from dp_cli.session import (get_browser, close_browser, list_sessions,
6
+ delete_session, load_session, save_session)
7
+ from dp_cli.output import ok, error, format_page_info
8
+ from dp_cli.commands._utils import session_option, _get_page, normalize_url
9
+
10
+
11
+ def register(cli):
12
+
13
+ @cli.command('open')
14
+ @click.argument('url', required=False)
15
+ @session_option
16
+ @click.option('--headless', is_flag=True, help='无头模式')
17
+ @click.option('--browser', 'browser_path', default=None, help='浏览器可执行文件路径')
18
+ @click.option('--profile', 'user_data_dir', default=None, help='用户数据目录')
19
+ @click.option('--proxy', default=None, help='代理服务器,如 http://127.0.0.1:7890')
20
+ @click.option('--port', type=int, default=None, help='连接指定端口的已有浏览器实例')
21
+ @click.option('--new', is_flag=True, help='强制创建新实例(不复用已有会话)')
22
+ def cmd_open(url, session, headless, browser_path, user_data_dir, proxy, port, new):
23
+ """打开浏览器并可选导航到 URL。
24
+
25
+ \b
26
+ 【复用用户自己的浏览器】(最常见场景,保留登录状态/Cookie/历史)
27
+ 第一步:用调试模式启动你自己的 Chrome/Chromium:
28
+ google-chrome --remote-debugging-port=9222
29
+ 第二步:用 dp 接管:
30
+ dp open --port 9222
31
+ dp open https://example.com --port 9222
32
+ 第三步:后续命令无需再指定 --port(会话自动记住端口):
33
+ dp snapshot
34
+ dp click "text:登录"
35
+
36
+ \b
37
+ 【dp 自动管理浏览器】
38
+ dp open
39
+ dp open https://example.com
40
+ dp open https://example.com --headless
41
+ dp -s work open https://github.com
42
+ """
43
+ if new:
44
+ delete_session(session)
45
+ try:
46
+ page = get_browser(session, headless=headless, browser_path=browser_path,
47
+ user_data_dir=user_data_dir, proxy=proxy, port=port)
48
+ except Exception as e:
49
+ error(f'启动浏览器失败: {e}', code='BROWSER_START_FAILED', detail=str(e))
50
+ return
51
+ if url:
52
+ try:
53
+ page.get(normalize_url(url))
54
+ except Exception as e:
55
+ error(f'导航失败: {e}', code='NAVIGATE_FAILED', detail=str(e))
56
+ return
57
+ ok(format_page_info(page), msg='浏览器已就绪')
58
+
59
+ @cli.command()
60
+ @click.argument('url')
61
+ @session_option
62
+ @click.option('--timeout', default=30, help='超时秒数', show_default=True)
63
+ @click.option('--retry', default=3, help='重试次数', show_default=True)
64
+ def goto(url, session, timeout, retry):
65
+ """导航到指定 URL。
66
+
67
+ \b
68
+ 示例:
69
+ dp goto https://example.com
70
+ dp goto example.com
71
+ dp goto example.com --timeout 60
72
+ """
73
+ page = _get_page(session)
74
+ try:
75
+ page.get(normalize_url(url), timeout=timeout, retry=retry)
76
+ ok(format_page_info(page))
77
+ except Exception as e:
78
+ error(f'导航到 {url} 失败', code='NAVIGATE_FAILED', detail=str(e))
79
+
80
+ @cli.command()
81
+ @session_option
82
+ def reload(session):
83
+ """刷新当前页面。"""
84
+ page = _get_page(session)
85
+ try:
86
+ page.get(page.url)
87
+ ok(format_page_info(page))
88
+ except Exception as e:
89
+ error(f'刷新失败', code='RELOAD_FAILED', detail=str(e))
90
+
91
+ @cli.command('go-back')
92
+ @session_option
93
+ def go_back(session):
94
+ """浏览器后退。"""
95
+ page = _get_page(session)
96
+ try:
97
+ page.back()
98
+ ok(format_page_info(page))
99
+ except Exception as e:
100
+ error('后退失败', code='NAVIGATE_FAILED', detail=str(e))
101
+
102
+ @cli.command('go-forward')
103
+ @session_option
104
+ def go_forward(session):
105
+ """浏览器前进。"""
106
+ page = _get_page(session)
107
+ try:
108
+ page.forward()
109
+ ok(format_page_info(page))
110
+ except Exception as e:
111
+ error('前进失败', code='NAVIGATE_FAILED', detail=str(e))
112
+
113
+ @cli.command('close')
114
+ @session_option
115
+ @click.option('--del-data', is_flag=True, help='同时删除用户数据目录')
116
+ @click.option('--force', is_flag=True, help='强制关闭浏览器(user_connected 模式下默认只断开连接)')
117
+ def cmd_close(session, del_data, force):
118
+ """关闭浏览器会话。
119
+
120
+ 如果是通过 --port 连接的用户自己的浏览器,默认只断开连接不关闭浏览器。
121
+ 用 --force 才会真正关闭浏览器进程。
122
+ """
123
+ sess = load_session(session)
124
+ if not sess:
125
+ error(f'会话 [{session}] 不存在', code='SESSION_NOT_FOUND')
126
+ return
127
+ user_connected = sess.get('user_connected', False)
128
+ if user_connected and not force:
129
+ delete_session(session)
130
+ ok(msg=f'已断开与 [{session}] 的连接(浏览器仍运行)。用 --force 关闭浏览器。')
131
+ else:
132
+ result = close_browser(session, del_data=del_data)
133
+ if result:
134
+ ok(msg=f'会话 [{session}] 已关闭')
135
+ else:
136
+ error(f'关闭失败', code='CLOSE_FAILED')
137
+
138
+ @cli.command('close-all')
139
+ def close_all():
140
+ """关闭所有会话。"""
141
+ sessions = list_sessions()
142
+ closed = []
143
+ for s in sessions:
144
+ close_browser(s['name'])
145
+ closed.append(s['name'])
146
+ ok({'closed': closed}, msg=f'已关闭 {len(closed)} 个会话')
147
+
148
+ @cli.command('list')
149
+ def cmd_list():
150
+ """列出所有活跃会话。"""
151
+ sessions = list_sessions()
152
+ ok({'sessions': sessions, 'count': len(sessions)})
153
+
154
+ @cli.command('delete-data')
155
+ @session_option
156
+ def delete_data(session):
157
+ """删除会话的用户数据目录。"""
158
+ close_browser(session, del_data=True)
159
+ ok(msg=f'会话 [{session}] 数据已删除')
@@ -0,0 +1,259 @@
1
+ # -*- coding:utf-8 -*-
2
+ """元素交互命令: click / dblclick / fill / clear / select / hover / drag / check / upload"""
3
+ import click
4
+
5
+ from dp_cli.output import ok, error
6
+ from dp_cli.commands._utils import session_option, _get_page, resolve_locator
7
+
8
+
9
+ def register(cli):
10
+
11
+ @cli.command('click')
12
+ @click.argument('locator')
13
+ @session_option
14
+ @click.option('--index', default=1, help='第几个匹配元素', show_default=True)
15
+ @click.option('--by-js', is_flag=True, help='使用 JavaScript 点击')
16
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
17
+ def cmd_click(locator, session, index, by_js, timeout):
18
+ """点击元素。
19
+
20
+ \b
21
+ 示例:
22
+ dp click "text:登录"
23
+ dp click "#submit-btn"
24
+ dp click "ref:5" # 使用快照编号
25
+ dp click "css:.btn-primary" --by-js
26
+ dp click "css:li" --index 3
27
+ """
28
+ locator = resolve_locator(locator, session)
29
+ page = _get_page(session)
30
+ try:
31
+ ele = page.ele(locator, index=index, timeout=timeout)
32
+ if not ele or ele.__class__.__name__ == 'NoneElement':
33
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
34
+ return
35
+ if by_js:
36
+ ele.click.js()
37
+ else:
38
+ ele.click()
39
+ ok({'locator': locator}, msg='点击成功')
40
+ except Exception as e:
41
+ error(f'点击失败: {locator}', code='CLICK_FAILED', detail=str(e))
42
+
43
+ @cli.command('dblclick')
44
+ @click.argument('locator')
45
+ @session_option
46
+ @click.option('--index', default=1, help='第几个匹配元素', show_default=True)
47
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
48
+ def dblclick(locator, session, index, timeout):
49
+ """双击元素。
50
+
51
+ \b
52
+ 示例:
53
+ dp dblclick "#editable-cell"
54
+ dp dblclick "ref:5"
55
+ """
56
+ locator = resolve_locator(locator, session)
57
+ page = _get_page(session)
58
+ try:
59
+ ele = page.ele(locator, index=index, timeout=timeout)
60
+ if not ele or ele.__class__.__name__ == 'NoneElement':
61
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
62
+ return
63
+ ele.click(count=2)
64
+ ok({'locator': locator}, msg='双击成功')
65
+ except Exception as e:
66
+ error(f'双击失败: {locator}', code='CLICK_FAILED', detail=str(e))
67
+
68
+ @cli.command('fill')
69
+ @click.argument('locator')
70
+ @click.argument('value')
71
+ @session_option
72
+ @click.option('--index', default=1, help='第几个匹配元素', show_default=True)
73
+ @click.option('--clear', is_flag=True, default=True, help='填入前清空(默认开启)')
74
+ @click.option('--by-js', is_flag=True, help='使用 JavaScript 设置值')
75
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
76
+ def fill(locator, value, session, index, clear, by_js, timeout):
77
+ """向输入框填入文本。
78
+
79
+ \b
80
+ 示例:
81
+ dp fill "@name=username" admin
82
+ dp fill "ref:15" "Python" # 使用快照编号
83
+ dp fill "css:textarea" "多行\\n文本"
84
+ """
85
+ locator = resolve_locator(locator, session)
86
+ page = _get_page(session)
87
+ try:
88
+ ele = page.ele(locator, index=index, timeout=timeout)
89
+ if not ele or ele.__class__.__name__ == 'NoneElement':
90
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
91
+ return
92
+ ele.input(value, clear=clear, by_js=by_js)
93
+ ok({'locator': locator, 'value': value}, msg='填入成功')
94
+ except Exception as e:
95
+ error(f'填入失败: {locator}', code='FILL_FAILED', detail=str(e))
96
+
97
+ @cli.command('clear')
98
+ @click.argument('locator')
99
+ @session_option
100
+ @click.option('--index', default=1, help='第几个匹配元素', show_default=True)
101
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
102
+ def cmd_clear(locator, session, index, timeout):
103
+ """清空输入框内容。"""
104
+ locator = resolve_locator(locator, session)
105
+ page = _get_page(session)
106
+ try:
107
+ ele = page.ele(locator, index=index, timeout=timeout)
108
+ if not ele or ele.__class__.__name__ == 'NoneElement':
109
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
110
+ return
111
+ ele.clear()
112
+ ok({'locator': locator}, msg='清空成功')
113
+ except Exception as e:
114
+ error(f'清空失败: {locator}', code='CLEAR_FAILED', detail=str(e))
115
+
116
+ @cli.command('select')
117
+ @click.argument('locator')
118
+ @click.argument('value')
119
+ @session_option
120
+ @click.option('--index', default=1, help='第几个匹配 select 元素', show_default=True)
121
+ @click.option('--by-text', is_flag=True, help='按文本选择(默认按 value)')
122
+ @click.option('--by-index', 'sel_by_index', default=None, type=int, help='按位置索引选择(从1开始)')
123
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
124
+ def cmd_select(locator, value, session, index, by_text, sel_by_index, timeout):
125
+ """选择下拉框选项。
126
+
127
+ \b
128
+ 示例:
129
+ dp select "@name=city" beijing
130
+ dp select "ref:12" admin # 用快照编号
131
+ dp select "#size" "" --by-index 2
132
+ """
133
+ locator = resolve_locator(locator, session)
134
+ page = _get_page(session)
135
+ try:
136
+ ele = page.ele(locator, index=index, timeout=timeout)
137
+ if not ele or ele.__class__.__name__ == 'NoneElement':
138
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
139
+ return
140
+ if sel_by_index is not None:
141
+ ele.select.by_index(sel_by_index)
142
+ elif by_text:
143
+ ele.select.by_text(value)
144
+ else:
145
+ ele.select.by_value(value)
146
+ ok({'locator': locator, 'value': value}, msg='选择成功')
147
+ except Exception as e:
148
+ error(f'选择失败: {locator}', code='SELECT_FAILED', detail=str(e))
149
+
150
+ @cli.command('hover')
151
+ @click.argument('locator')
152
+ @session_option
153
+ @click.option('--index', default=1, help='第几个匹配元素', show_default=True)
154
+ @click.option('--offset-x', default=None, type=int, help='X 偏移量(像素)')
155
+ @click.option('--offset-y', default=None, type=int, help='Y 偏移量(像素)')
156
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
157
+ def hover(locator, session, index, offset_x, offset_y, timeout):
158
+ """悬停鼠标到元素。
159
+
160
+ \b
161
+ 示例:
162
+ dp hover "css:.menu-item"
163
+ dp hover "ref:8" # 用快照编号
164
+ """
165
+ locator = resolve_locator(locator, session)
166
+ page = _get_page(session)
167
+ try:
168
+ ele = page.ele(locator, index=index, timeout=timeout)
169
+ if not ele or ele.__class__.__name__ == 'NoneElement':
170
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
171
+ return
172
+ ele.hover(offset_x=offset_x, offset_y=offset_y)
173
+ ok({'locator': locator}, msg='悬停成功')
174
+ except Exception as e:
175
+ error(f'悬停失败: {locator}', code='HOVER_FAILED', detail=str(e))
176
+
177
+ @cli.command('drag')
178
+ @click.argument('from_locator')
179
+ @click.argument('to_locator')
180
+ @session_option
181
+ @click.option('--duration', default=0.5, help='拖拽持续时间(秒)', show_default=True)
182
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
183
+ def drag(from_locator, to_locator, session, duration, timeout):
184
+ """拖拽元素到另一个元素。
185
+
186
+ \b
187
+ 示例:
188
+ dp drag "#draggable" "#droptarget"
189
+ dp drag "ref:3" "ref:7" # 用快照编号
190
+ """
191
+ from_locator = resolve_locator(from_locator, session)
192
+ to_locator = resolve_locator(to_locator, session)
193
+ page = _get_page(session)
194
+ try:
195
+ src = page.ele(from_locator, timeout=timeout)
196
+ dst = page.ele(to_locator, timeout=timeout)
197
+ if not src or src.__class__.__name__ == 'NoneElement':
198
+ error(f'未找到源元素: {from_locator}', code='ELEMENT_NOT_FOUND')
199
+ return
200
+ if not dst or dst.__class__.__name__ == 'NoneElement':
201
+ error(f'未找到目标元素: {to_locator}', code='ELEMENT_NOT_FOUND')
202
+ return
203
+ src.drag_to(dst, duration=duration)
204
+ ok({'from': from_locator, 'to': to_locator}, msg='拖拽成功')
205
+ except Exception as e:
206
+ error(f'拖拽失败', code='DRAG_FAILED', detail=str(e))
207
+
208
+ @cli.command('check')
209
+ @click.argument('locator')
210
+ @session_option
211
+ @click.option('--check/--uncheck', default=True, help='选中/取消选中')
212
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
213
+ def cmd_check(locator, session, check, timeout):
214
+ """勾选或取消勾选 checkbox/radio。
215
+
216
+ \b
217
+ 示例:
218
+ dp check "#agree-terms"
219
+ dp check "ref:10" # 用快照编号
220
+ dp check "@name=remember" --uncheck
221
+ """
222
+ locator = resolve_locator(locator, session)
223
+ page = _get_page(session)
224
+ try:
225
+ ele = page.ele(locator, timeout=timeout)
226
+ if not ele or ele.__class__.__name__ == 'NoneElement':
227
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
228
+ return
229
+ current = ele.states.is_checked
230
+ if (check and not current) or (not check and current):
231
+ ele.click()
232
+ ok({'locator': locator, 'checked': check}, msg='操作成功')
233
+ except Exception as e:
234
+ error(f'checkbox 操作失败: {locator}', code='CHECK_FAILED', detail=str(e))
235
+
236
+ @cli.command('upload')
237
+ @click.argument('locator')
238
+ @click.argument('file_path')
239
+ @session_option
240
+ @click.option('--timeout', default=10, help='等待超时秒数', show_default=True)
241
+ def upload(locator, file_path, session, timeout):
242
+ """上传文件到 input[type=file] 元素。
243
+
244
+ \b
245
+ 示例:
246
+ dp upload "@name=avatar" /path/to/image.png
247
+ dp upload "ref:15" ./document.pdf # 用快照编号
248
+ """
249
+ locator = resolve_locator(locator, session)
250
+ page = _get_page(session)
251
+ try:
252
+ ele = page.ele(locator, timeout=timeout)
253
+ if not ele or ele.__class__.__name__ == 'NoneElement':
254
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
255
+ return
256
+ ele.input(file_path)
257
+ ok({'locator': locator, 'file': file_path}, msg='文件上传成功')
258
+ except Exception as e:
259
+ error(f'文件上传失败', code='UPLOAD_FAILED', detail=str(e))
@@ -0,0 +1,126 @@
1
+ # -*- coding:utf-8 -*-
2
+ """键盘与滚动命令: press / type / scroll / scroll-to"""
3
+ import click
4
+
5
+ from dp_cli.output import ok, error
6
+ from dp_cli.commands._utils import session_option, _get_page, resolve_locator
7
+
8
+
9
+ def register(cli):
10
+
11
+ @cli.command('press')
12
+ @click.argument('key')
13
+ @session_option
14
+ def cmd_press(key, session):
15
+ """模拟键盘按键。
16
+
17
+ \b
18
+ 支持的按键: Enter, Tab, Escape, Space, Backspace,
19
+ ArrowUp/Down/Left/Right, F1-F12,
20
+ Control+A, Shift+Enter, Alt+F4 等组合键。
21
+
22
+ \b
23
+ 示例:
24
+ dp press Enter
25
+ dp press "Control+A"
26
+ dp press Escape
27
+ """
28
+ page = _get_page(session)
29
+ try:
30
+ from DrissionPage._functions.keys import Keys
31
+ key_map = {
32
+ 'enter': '\ue007', 'tab': '\ue004', 'escape': '\ue00c', 'esc': '\ue00c',
33
+ 'backspace': '\ue003', 'delete': '\ue017', 'space': ' ',
34
+ 'arrowup': '\ue013', 'arrowdown': '\ue015',
35
+ 'arrowleft': '\ue012', 'arrowright': '\ue014',
36
+ }
37
+ k = key.lower()
38
+ if '+' in key:
39
+ parts = key.split('+')
40
+ modifier = parts[0].lower()
41
+ main_key = parts[1]
42
+ mod_map = {'control': Keys.CTRL, 'ctrl': Keys.CTRL,
43
+ 'shift': Keys.SHIFT, 'alt': Keys.ALT}
44
+ if modifier in mod_map:
45
+ page.actions.key_down(mod_map[modifier]).type(main_key).key_up(mod_map[modifier])
46
+ else:
47
+ page.actions.type(key)
48
+ else:
49
+ actual_key = key_map.get(k, key)
50
+ page.actions.type(actual_key)
51
+ ok({'key': key}, msg='按键成功')
52
+ except Exception as e:
53
+ error(f'按键失败: {key}', code='KEY_FAILED', detail=str(e))
54
+
55
+ @cli.command('type')
56
+ @click.argument('text')
57
+ @session_option
58
+ def cmd_type(text, session):
59
+ """输入文本(当前焦点元素)。
60
+
61
+ \b
62
+ 示例:
63
+ dp type "hello world"
64
+ dp type "search query"
65
+ """
66
+ page = _get_page(session)
67
+ try:
68
+ page.actions.type(text)
69
+ ok({'text': text}, msg='输入成功')
70
+ except Exception as e:
71
+ error(f'输入失败', code='TYPE_FAILED', detail=str(e))
72
+
73
+ @cli.command('scroll')
74
+ @click.option('--x', default=0, type=int, help='水平滚动像素')
75
+ @click.option('--y', default=300, type=int, help='垂直滚动像素')
76
+ @click.option('--locator', default=None, help='滚动特定元素(而非页面)')
77
+ @session_option
78
+ def cmd_scroll(x, y, locator, session):
79
+ """滚动页面或元素。
80
+
81
+ \b
82
+ 示例:
83
+ dp scroll --y 300
84
+ dp scroll --y -200
85
+ dp scroll --locator "css:.scroll-container" --y 100
86
+ """
87
+ page = _get_page(session)
88
+ try:
89
+ if locator:
90
+ ele = page.ele(locator)
91
+ ele.scroll.down(y) if y > 0 else ele.scroll.up(abs(y))
92
+ else:
93
+ if y > 0:
94
+ page.scroll.down(y)
95
+ elif y < 0:
96
+ page.scroll.up(abs(y))
97
+ if x > 0:
98
+ page.scroll.right(x)
99
+ elif x < 0:
100
+ page.scroll.left(abs(x))
101
+ ok({'x': x, 'y': y}, msg='滚动成功')
102
+ except Exception as e:
103
+ error(f'滚动失败', code='SCROLL_FAILED', detail=str(e))
104
+
105
+ @cli.command('scroll-to')
106
+ @click.argument('locator')
107
+ @session_option
108
+ def scroll_to(locator, session):
109
+ """滚动页面直到元素可见。
110
+
111
+ \b
112
+ 示例:
113
+ dp scroll-to "#footer"
114
+ dp scroll-to "ref:20"
115
+ """
116
+ locator = resolve_locator(locator, session)
117
+ page = _get_page(session)
118
+ try:
119
+ ele = page.ele(locator)
120
+ if not ele or ele.__class__.__name__ == 'NoneElement':
121
+ error(f'未找到元素: {locator}', code='ELEMENT_NOT_FOUND')
122
+ return
123
+ ele.scroll.to_see()
124
+ ok({'locator': locator}, msg='已滚动到元素')
125
+ except Exception as e:
126
+ error(f'滚动失败', code='SCROLL_FAILED', detail=str(e))