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 +1 -0
- dp_cli/commands/__init__.py +12 -0
- dp_cli/commands/_utils.py +107 -0
- dp_cli/commands/browser.py +159 -0
- dp_cli/commands/element.py +259 -0
- dp_cli/commands/keyboard.py +126 -0
- dp_cli/commands/misc.py +136 -0
- dp_cli/commands/network.py +169 -0
- dp_cli/commands/page.py +204 -0
- dp_cli/commands/snapshot_cmd.py +391 -0
- dp_cli/commands/storage.py +222 -0
- dp_cli/commands/tab.py +203 -0
- dp_cli/main.py +47 -0
- dp_cli/output.py +97 -0
- dp_cli/session.py +201 -0
- dp_cli/snapshot/__init__.py +23 -0
- dp_cli/snapshot/a11y.py +671 -0
- dp_cli/snapshot/extract.py +158 -0
- dp_cli/snapshot/js_scripts.py +155 -0
- dp_cli/snapshot/utils.py +43 -0
- dp_cli-0.1.0.dist-info/METADATA +103 -0
- dp_cli-0.1.0.dist-info/RECORD +25 -0
- dp_cli-0.1.0.dist-info/WHEEL +5 -0
- dp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dp_cli-0.1.0.dist-info/top_level.txt +1 -0
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))
|