├── .gitignore
├── .idea
└── vcs.xml
├── LICENSE
├── MANIFEST.in
├── README.md
├── __init__.py
├── adbui
├── __init__.py
├── adb_ext.py
├── get_ui.py
├── ocr.py
├── static
│ └── adbui
├── tango.py
└── util.py
├── docs
├── image
│ ├── ocr01.png
│ └── xpath01.png
└── pypi.txt
├── sample.py
├── setup.py
└── tests
├── res
├── dump.jpg
└── dump.xml
├── tango.py
├── test_adb_ext.py
└── test_util.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 |
57 | # Flask stuff:
58 | instance/
59 | .webassets-cache
60 |
61 | # Scrapy stuff:
62 | .scrapy
63 |
64 | # Sphinx documentation
65 | docs/_build/
66 |
67 | # PyBuilder
68 | target/
69 |
70 | # Jupyter Notebook
71 | .ipynb_checkpoints
72 |
73 | # pyenv
74 | .python-version
75 |
76 | # celery beat schedule file
77 | celerybeat-schedule
78 |
79 | # SageMath parsed files
80 | *.sage.py
81 |
82 | # dotenv
83 | .env
84 |
85 | # virtualenv
86 | .venv
87 | venv/
88 | ENV/
89 |
90 | # Spyder project settings
91 | .spyderproject
92 | .spyproject
93 |
94 | # Rope project settings
95 | .ropeproject
96 |
97 | # mkdocs documentation
98 | /site
99 |
100 | # mypy
101 | .mypy_cache/
102 | tests/tango.py
103 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 hao1032
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include adbui/static *
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 常见问题
2 | - adbui 交流微信群,加 hao1032,备注 adbui,会拉入微信群
3 | - 使用 ocr 提示出错,请在该页面最后查看使用 ocr 示例
4 |
5 |
6 | # adbui
7 | adbui 所有的功能都是通过 adb 命令,adbui 的特色是可以通过 xpath,ocr 获取 ui 元素。
8 |
9 | ## 安装
10 | pip install adbui
11 |
12 | ## 要求
13 | - 在命令中可以使用 adb 命令,即adb已经配置到环境变量
14 | - adb 的版本最好是 >= 1.0.39,用老版本的 adb 可能会有一些奇怪的问题
15 | - 依赖的库:lxml 解析 xml,requests 发 ocr 请求,pillow 图片处理
16 |
17 | ## 说明
18 | - adbui 当前还在完善,bug 和建议请直接在 github 反馈
19 | - 主要在 win7,python3 环境使用,其他环境可能有问题
20 |
21 |
22 | ## import and init
23 | from adbui import Device
24 |
25 | d = Device('123abc') # 手机的sn号,如果只有一个手机可以不写
26 |
27 |
28 | ## adbui 可以分为 3 个部分
29 | **util 负责执行完整的命令**
30 |
31 | - **cmd** 用来执行系统命令
32 |
33 | d.util.cmd('adb -s 123abc reboot')
34 | out = d.util.cmd('ping 127.0.0.1')
35 |
36 | - **adb** 用来执行 adb 命令
37 |
38 | d.util.adb('install xxx.apk')
39 | d.util.adb('uninstall com.tencent.mtt')
40 |
41 | - **shell** 用来执行 shell 命令
42 |
43 | d.util.shell('pm clear com.tencent.mtt')
44 | d.util.shell('am force-stop com.tencent.mtt')
45 |
46 | **adb_ext 对常用 adb 命令的封装,下面列出部分操作(可在 adbui/adb_ext.py 文件自行增加需要的操作)**
47 |
48 | - **screenshot**
49 |
50 | d.adb_ext.screenshot() # 截图保存到系统临时目录,也可指定目录
51 |
52 | - **click**
53 |
54 | d.adb_ext.click(10, 32) # 执行一个点击事件
55 |
56 | - **input**
57 |
58 | d.adb_ext.input('adbui') # 输入文本
59 |
60 | - **back**
61 |
62 | d.adb_ext.back() # 发出 back 指令
63 |
64 |
65 | **get_ui 可以通过多种方式获取 UI**
66 | - **by attr** 通过在 uiautomator 里面看到的属性来获取
67 |
68 | ui = d.get_ui_by_attr(text='设置', desc='设置') # 支持多个属性同时查找
69 |
70 | ui = d.get_ui_by_attr(text='设', is_contains=True) # 支持模糊查找
71 |
72 | ui = d.get_ui_by_attr(text='设置', is_update=False) # 如果需要在一个界面上获取多个 UI, 再次查找时可以设置不更新xml文件和截图,节省时间
73 |
74 | ui = d.get_ui_by_attr(class_='android.widget.TextView') # class 在 python 中是关键字,因此使用 class_ 代替
75 |
76 | ui = d.get_ui_by_attr(desc='fffffff') # 如果没有找到,返回 None;如果找到多个返回第一个
77 |
78 | ui = d.get_uis_by_attr(desc='fffffff') # 如果是 get uis 没有找到,返回空的 list
79 |
80 | - **by xpath** 使用 xpath 来获取
81 | 
82 |
83 | mic_btn = d.get_ui_by_xpath('.//FrameLayout/LinearLayout/RelativeLayout/ImageView[2]') # 获取麦克风按钮
84 | mic_btn.click() # 点击麦克风按钮
85 |
86 | # adbui 使用 lxml 解析 xml 文件,因此 by xpath 理论上支持任何标准的 xpth 路径。
87 | # 这里有一篇 xpath 使用的文章:https://cuiqingcai.com/2621.html
88 |
89 | # 另外获取的 ui 对象实际是一个自定义的 UI 实类,ui 有一个 element 的属性,element 就是 lxml 里面的 Element 对象,
90 | # 因此可以对 ui.element 执行 lxml 的相关操作。
91 | # lxml element 对象的文档:http://lxml.de/api/lxml.etree._Element-class.html
92 |
93 | scan_element = ui.element.getprevious() # 获取麦克风的上一个 element,即扫一扫按钮
94 | scan_btn = d.get_ui_by_element(scan_element) # 使用 element 实例化 UI
95 | scan_btn.click() # 点击扫一扫按钮
96 |
97 | - **by ocr** 使用腾讯的OCR技术来获取
98 | 
99 |
100 | d.init_ocr('10126986', 'AKIDT1Ws34B98MgtvmqRIC4oQr7CBzhEPvCL', 'AAyb3KQL5d1DE4jIMF2f6PYWJvLaeXEk')
101 | # 使用 ocr 功能前,必须要使用自己的开发密钥初始化,上面的密钥是我申请的公共测试密钥,要稳定使用请自行申请
102 | # 腾讯的 ocr 功能是免费使用的,需要自己到 http://open.youtu.qq.com/#/develop/new-join 申请自己的开发密钥
103 |
104 | btn = d.get_ui_by_ocr(text='爱拍') # 找到爱拍文字的位置
105 | btn.click() # 点击爱拍
106 |
107 | ## Change Log
108 | 20210425 version 4.5.0
109 | - screenshot 和 dump xml 优先使用 adbui,预期速度有很大的提升
110 | - 删除 Pillow 依赖
111 |
112 | 20210425 version 4.0.0
113 | - screenshot 参数有变化,升级请谨慎
114 | - 尝试尽量使用 minicap 截图
115 | - 尝试不使用 pillow 功能
116 |
117 | 20210418 version 3.5.2
118 | - 增加 minicap 截图
119 |
120 | 20210325 version 2.6
121 | - dump xml 优先使用 --compressed 模式
122 |
123 | 20210325 version 2.4
124 | - 修复python3.8以上版本找控件报错 RuntimeError: dictionary keys changed during iteration
125 |
126 | 20200402 version 1.0
127 | - 修改screenshot 参数情况
128 | - 去掉 cmd out save 函数
129 | - init ocr支持keys传入多个key
130 |
131 | 20200328 version 0.40.1
132 | - 修改 push pull 方法等参数
133 | - 使用 timeout 库控制超时
134 | - get ui by orc 去掉 min hit 参数,增加 is contains 参数
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # @Time : 2020/4/3 9:55 下午
4 | # @Author : tangonian
5 | # @Site :
6 | # @File : __init__.py
7 | # @Software: PyCharm
8 | from .adbui import Device
9 | from .adbui import Tango
10 | from .adbui import Util
11 |
--------------------------------------------------------------------------------
/adbui/__init__.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | from .get_ui import GetUI
3 | from .util import Util
4 | from .adb_ext import AdbExt
5 | from .tango import Tango
6 |
7 |
8 | class Device(GetUI):
9 | def __init__(self, sn=None):
10 | self.util = Util(sn)
11 | self.adb_ext = AdbExt(self.util)
12 | GetUI.__init__(self, self.adb_ext)
13 |
--------------------------------------------------------------------------------
/adbui/adb_ext.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | import os
3 | import re
4 | import time
5 | import base64
6 | import logging
7 | import tempfile
8 |
9 |
10 | class AdbExt(object):
11 | def __init__(self, util):
12 | self.util = util
13 | self.is_helper_ready = False
14 | self.width, self.height = None, None
15 | self.dir_path = os.path.dirname(os.path.abspath(__file__)) # 当前文件所在的目录绝对路径
16 | self.temp_device_dir_path = '/data/local/tmp'
17 |
18 | def init_device_size(self):
19 | if self.width and self.height:
20 | return
21 | out = self.util.shell('wm size') # out like 'Physical size: 1080x1920'
22 | out = re.findall(r'\d+', out)
23 | self.width = int(out[0])
24 | self.height = int(out[1])
25 |
26 | def dump(self):
27 | for i in range(5):
28 | xml_str = self.__dump_xml()
29 | if xml_str:
30 | return xml_str
31 | time.sleep(1)
32 | raise NameError('dump xml fail!')
33 |
34 | def __dump_xml(self):
35 | # 使用 helper 获取 xml
36 | xml_str = self.run_helper_cmd('layout')
37 |
38 | # 使用压缩模式
39 | if not xml_str:
40 | xml_str = self.util.adb('exec-out uiautomator dump --compressed /dev/tty', encoding='')
41 |
42 | # 使用非压缩模式
43 | if not xml_str:
44 | xml_str = self.util.adb('exec-out uiautomator dump /dev/tty', encoding='')
45 |
46 | if isinstance(xml_str, bytes):
47 | xml_str = xml_str.decode('utf-8')
48 |
49 | if 'hierarchy' in xml_str:
50 | start = xml_str.find('') + 1
52 | xml_str = xml_str[start: end].strip()
53 | return xml_str
54 |
55 | def run_helper_cmd(self, cmd):
56 | """
57 | 执行 helper 的命令,当前 helper 支持 dump xml 和 screenshot
58 | :param cmd:
59 | :return:
60 | """
61 | if not self.is_helper_ready:
62 | file_names = self.util.shell('ls {}'.format(self.temp_device_dir_path))
63 | if 'adbui' not in file_names:
64 | helper_path = os.path.join(self.dir_path, 'static', 'adbui')
65 | self.push(helper_path, self.temp_device_dir_path)
66 | self.is_helper_ready = True
67 | arg = 'app_process -Djava.class.path=/data/local/tmp/adbui /data/local/tmp com.ysbing.yadb.Main -{}'.format(cmd)
68 | return self.util.shell(arg)
69 |
70 | def delete_from_device(self, path):
71 | self.util.shell('rm -rf {}'.format(path))
72 |
73 | def screenshot(self, pc_path=None):
74 | out = self.run_helper_cmd('screenshot')
75 | if out and len(out) > 50:
76 | out = base64.b64decode(out)
77 | else: # helper 截图失败,使用 screencap 截图
78 | logging.warning('helper 截图失败')
79 | arg = 'exec-out screencap -p'.format(self.util.sn)
80 | out = self.util.adb(arg, encoding=None) # 这里是 png bytes string
81 |
82 | # 保存截图
83 | if pc_path:
84 | if self.util.is_py2:
85 | pc_path = pc_path.decode('utf-8')
86 | if os.path.exists(pc_path): # 删除电脑文件
87 | os.remove(pc_path)
88 | with open(pc_path, 'wb') as f:
89 | f.write(out)
90 | return pc_path
91 |
92 | return out
93 |
94 | def pull(self, device_path=None, pc_path=None):
95 | return self.util.adb('pull "{}" "{}"'.format(device_path, pc_path))
96 |
97 | def push(self, pc_path=None, device_path=None):
98 | return self.util.adb('push "{}" "{}"'.format(pc_path, device_path))
99 |
100 | def click(self, x, y):
101 | self.util.shell('input tap {} {}'.format(x, y))
102 |
103 | def long_click(self, x, y, duration=''):
104 | """
105 | 长按
106 | :param x: x 坐标
107 | :param y: y 坐标
108 | :param duration: 长按的时间(ms)
109 | :return:
110 | """
111 | self.util.shell('input touchscreen swipe {} {} {} {} {}'.format(x, y, x, y, duration))
112 |
113 | def start(self, pkg):
114 | """
115 | 使用monkey,只需给出包名即可启动一个应用
116 | :param pkg:
117 | :return:
118 | """
119 | self.util.shell('monkey -p {} 1'.format(pkg))
120 |
121 | def stop(self, pkg):
122 | self.util.shell('am force-stop {}'.format(pkg))
123 |
124 | def input(self, text):
125 | self.util.shell('input text "{}"'.format(text.replace('&', '\&')))
126 |
127 | def back(self, times=1):
128 | while times:
129 | self.util.shell('input keyevent 4')
130 | times -= 1
131 |
132 | def home(self):
133 | self.util.shell('input keyevent 3')
134 |
135 | def enter(self, times=1):
136 | while times:
137 | self.util.shell('input keyevent 66')
138 | times -= 1
139 |
140 | def swipe(self, e1=None, e2=None, start_x=None, start_y=None, end_x=None, end_y=None, duration=" "):
141 | """
142 | 滑动事件,Android 4.4以上可选duration(ms)
143 | usage: swipe(e1, e2)
144 | swipe(e1, end_x=200, end_y=500)
145 | swipe(start_x=0.5, start_y=0.5, e2)
146 | """
147 | self.init_device_size()
148 | if e1 is not None:
149 | start_x = e1[0]
150 | start_y = e1[1]
151 | if e2 is not None:
152 | end_x = e2[0]
153 | end_y = e2[1]
154 | if 0 < start_x < 1:
155 | start_x = start_x * self.width
156 | if 0 < start_y < 1:
157 | start_y = start_y * self.height
158 | if 0 < end_x < 1:
159 | end_x = end_x * self.width
160 | if 0 < end_y < 1:
161 | end_y = end_y * self.height
162 |
163 | self.util.shell('input swipe %s %s %s %s %s' % (str(start_x), str(start_y), str(end_x), str(end_y), str(duration)))
164 |
165 | def clear(self, pkg):
166 | """
167 | 重置应用
168 | :param pkg:
169 | :return:
170 | """
171 | self.util.shell('pm clear {}'.format(pkg))
172 |
173 | def wake_up(self):
174 | """
175 | 点亮屏幕
176 | :return:
177 | """
178 | self.util.shell('input keyevent KEYCODE_WAKEUP')
179 |
180 | def unlock(self):
181 | """
182 | 解锁屏幕
183 | :return:
184 | """
185 | self.util.shell('input keyevent 82')
186 |
187 | def grant(self, pkg, permission):
188 | """
189 | 给app赋权限,类似 adb shell pm grant [PACKAGE_NAME] android.permission.PACKAGE_USAGE_STATS
190 | :return:
191 | """
192 | self.util.shell('pm grant {} {}'.format(pkg, permission))
193 |
194 | def install(self, apk_path, with_g=True, with_r=False, user=None):
195 | """
196 | 安装包
197 | :param apk_path:
198 | :param with_g: -g 在一些设备上可以自动授权,默认 true
199 | :param with_r: -r 覆盖安装,默认 false
200 | :param user:
201 | :return:
202 | """
203 | arg = 'install'
204 | if user:
205 | arg = arg + ' -user {}'.format(user)
206 | if with_g:
207 | arg = arg + ' -g'
208 | if with_r:
209 | arg = arg + ' -r'
210 | self.util.adb('{} "{}"'.format(arg, apk_path), timeout=60 * 5) # 安装较大的包可能比较耗时
211 |
212 | def uninstall(self, pkg):
213 | """
214 | 卸载包
215 | :param pkg:
216 | :return:
217 | """
218 | self.util.adb('uninstall {}'.format(pkg))
219 |
220 | def get_name(self, remove_blank=False):
221 | name = self.util.shell('getprop ro.config.marketing_name').strip()
222 | if not name:
223 | name = self.util.shell('getprop ro.product.nickname').strip()
224 | if remove_blank:
225 | name = name.replace(' ', '')
226 | return name
227 |
228 | def switch_user(self, user_id, wait_time=5):
229 | self.util.shell('am switch-user {}'.format(user_id))
230 | time.sleep(wait_time)
231 |
232 | def list_packages(self, system=False):
233 | """
234 | 返回手机中安装的包
235 | :param system: 是否包含系统包
236 | :return:
237 | """
238 | with_system = '' if system else '-3'
239 | return self.util.shell('pm list packages {}'.format(with_system))
240 |
--------------------------------------------------------------------------------
/adbui/get_ui.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | import sys
3 | import re
4 | import logging
5 | import traceback
6 | from lxml import etree
7 | from .ocr import Ocr, TencentCloudOcr
8 | from lxml.etree import tostring
9 |
10 | short_keys = {'id': 'resource-id', 'class_': 'class', 'klass': 'class', 'desc': 'content-desc'}
11 |
12 |
13 | class GetUI(object):
14 | def __init__(self, adb_ext):
15 | self.adb_ext = adb_ext
16 | self.keys = None
17 | self.xml = None
18 | self.ocr = None
19 | self.init_ocr()
20 | self.image = None
21 |
22 | def init_ocr(self, app_id=None, secret_id=None, secret_key=None, keys=None,
23 | ten_secret_id=None, ten_secret_key=None):
24 | """
25 | 初始化ocr对象,当前支持腾讯云和优图的ocr服务,优先使用腾讯云的ocr服务,优图的已经不再维护
26 | :param app_id:
27 | :param secret_id:
28 | :param secret_key:
29 | :param keys:
30 | :param ten_secret_id: 腾讯云的 secret_id
31 | :param ten_secret_key: 腾讯云的 secret_key
32 | :return:
33 | """
34 | if ten_secret_id and ten_secret_key: # 优先使用腾讯云的 ocr
35 | self.ocr = TencentCloudOcr(ten_secret_id, ten_secret_key)
36 | return
37 |
38 | if keys is None:
39 | keys = []
40 | if app_id is None and secret_id is None and secret_key is None:
41 | # 以下为测试账号,任何人可用,但是随时都会不可用,建议自行去腾讯优图申请专属账号
42 | app_id = '10126986'
43 | secret_id = 'AKIDT1Ws34B98MgtvmqRIC4oQr7CBzhEPvCL'
44 | secret_key = 'AAyb3KQL5d1DE4jIMF2f6PYWJvLaeXEk'
45 | keys.append({'app_id': app_id, 'secret_id': secret_id, 'secret_key': secret_key})
46 | self.ocr = Ocr(keys)
47 |
48 | def get_ui_by_attr(self, is_contains=True, is_update=True, try_count=1, **kwargs):
49 | """
50 | 通过节点的属性获取节点
51 | :param is_contains:
52 | :param is_update:
53 | :param try_count:
54 | :param kwargs:
55 | :return:
56 | """
57 | uis = self.get_uis_by_attr(is_contains=is_contains, is_update=is_update, try_count=try_count, **kwargs)
58 | return uis[0] if uis else None
59 |
60 | def get_uis_by_attr(self, is_contains=True, is_update=True, try_count=1, **kwargs):
61 | """
62 | 通过节点的属性获取节点
63 | :param try_count:
64 | :param is_contains: 是否使用模糊查找
65 | :param is_update:
66 | :param kwargs:
67 | :return:
68 | """
69 | for key in list(kwargs):
70 | if key in short_keys:
71 | kwargs[short_keys[key]] = kwargs.pop(key)
72 | if is_contains:
73 | s = list(map(lambda x: "contains(@{}, '{}')".format(x, kwargs[x]), kwargs))
74 | xpath = './/*[{}]'.format(' and '.join(s))
75 | else:
76 | s = list(map(lambda key: "[@{}='{}']".format(key, kwargs[key]), kwargs))
77 | xpath = './/*{}'.format(''.join(s))
78 | uis = self.get_uis_by_xpath(xpath, is_update=is_update, try_count=try_count)
79 | return uis
80 |
81 | def get_ui_by_xpath(self, xpath, is_update=True, try_count=1):
82 | uis = self.get_uis_by_xpath(xpath, is_update, try_count=try_count)
83 | return uis[0] if uis else None
84 |
85 | def get_uis_by_xpath(self, xpath, is_update=True, try_count=1):
86 | """
87 | 通过xpath查找节点
88 | :param try_count:
89 | :param xpath:
90 | :param is_update:
91 | :return:
92 | """
93 | elements = []
94 | for index in range(try_count):
95 | if is_update:
96 | xml_str = self.adb_ext.dump() # 获取xml文件
97 | self.__init_xml(xml_str)
98 |
99 | xpath = xpath.decode('utf-8') if sys.version_info[0] < 3 else xpath
100 | elements = self.xml.xpath(xpath)
101 | if elements:
102 | elements = [self.get_ui_by_element(x) for x in elements]
103 | break
104 |
105 | return elements
106 |
107 | def get_ui_by_element(self, element):
108 | bounds = element.get('bounds')
109 | x1, y1, x2, y2 = re.compile(r"-?\d+").findall(bounds)
110 | ui = UI(self.adb_ext, x1, y1, x2, y2)
111 | ui.element = element
112 | text = element.get('text')
113 | if not text:
114 | text = element.get('content-desc')
115 | ui.text = text.encode('utf-8') if self.adb_ext.util.is_py2 and not isinstance(text, str) else text
116 | return ui
117 |
118 | def get_ui_by_ocr(self, text, is_contains=True, is_update=True, try_count=1):
119 | uis = self.get_uis_by_ocr(text, is_contains, is_update, try_count)
120 | return uis[0] if uis else None
121 |
122 | def get_uis_by_ocr(self, text, is_contains=True, is_update=True, try_count=1):
123 | """
124 | 通过ocr识别获取节点
125 | :param try_count:
126 | :param is_contains:
127 | :param text: 查找的文本
128 | :param is_update: 是否重新获取截图
129 | :return:
130 | """
131 | assert self.ocr, 'ocr 功能没有初始化.请到 adbui 页面查看如何使用。https://github.com/hao1032/adbui'
132 | uis = []
133 |
134 | for index in range(try_count):
135 | if is_update:
136 | self.image = self.adb_ext.screenshot() # 获取截图
137 |
138 | ocr_result = self.ocr.get_result(self.image)
139 | text = text.decode('utf-8') if self.adb_ext.util.is_py2 and isinstance(text, str) else text
140 | for item in ocr_result['items']:
141 | item_string = item['itemstring']
142 | item_string = item_string.decode('utf-8') if self.adb_ext.util.is_py2 and isinstance(item_string, str) else item_string
143 |
144 | if (is_contains and text in item_string) or (not is_contains and text == item_string):
145 | item_coord = item['itemcoord']
146 | ui = UI(self.adb_ext, item_coord['x'], item_coord['y'],
147 | item_coord['x'] + item_coord['width'], item_coord['y'] + item_coord['height'])
148 | ui.text = item_string
149 | uis.append(ui)
150 |
151 | if uis: # 知道了要找的元素,退出循环
152 | break
153 | return uis
154 |
155 | def __init_xml(self, xml_str):
156 | parser = etree.XMLParser(huge_tree=True)
157 | self.xml = etree.fromstring(xml_str, parser=parser)
158 | for element in self.xml.findall('.//node'):
159 | class_name = element.get('class').split('.')[-1].replace('$', '')
160 | if class_name[0].isdigit():
161 | continue # tag 不能数字开头
162 | element.tag = class_name # 将每个node的name替换为class值,和 uiautomator 里显示的一致
163 |
164 |
165 | class UI:
166 | def __init__(self, adb_ext, x1, y1, x2, y2):
167 | self.__adb_ext = adb_ext
168 | self.x1 = int(x1) # 左上角 x
169 | self.y1 = int(y1) # 左上角 y
170 | self.x2 = int(x2) # 右下角 x
171 | self.y2 = int(y2) # 右下角 y
172 | self.width = self.x2 - self.x1 # 元素宽
173 | self.height = self.y2 - self.y1 # 元素高
174 | self.x = self.x1 + int(self.width / 2)
175 | self.y = self.y1 + int(self.height / 2)
176 | self.text = None # 元素文本
177 | self.element = None # 元素对应的 lxml element,ocr无效
178 |
179 | def get_element_str(self):
180 | return tostring(self.element)
181 |
182 | def get_value(self, key):
183 | # 返回 lxml element 属性对应的值
184 | if key in short_keys:
185 | key = short_keys[key]
186 | return self.element.get(key)
187 |
188 | def click(self):
189 | # 点击元素的中心点
190 | self.__adb_ext.click(self.x, self.y)
191 |
--------------------------------------------------------------------------------
/adbui/ocr.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | import logging
3 | import os
4 | import time
5 | import random
6 | import traceback
7 | import requests
8 | import json
9 | import hmac
10 | import hashlib
11 | import binascii
12 | import base64
13 |
14 | from tencentcloud.common import credential
15 | from tencentcloud.common.profile.client_profile import ClientProfile
16 | from tencentcloud.common.profile.http_profile import HttpProfile
17 | from tencentcloud.ocr.v20181119 import ocr_client, models
18 |
19 |
20 | class Ocr(object):
21 | def __init__(self, keys):
22 | self.keys = keys
23 | self.app_id = None
24 | self.secret_id = None
25 | self.secret_key = None
26 | self.result = None
27 | os.environ['NO_PROXY'] = 'api.youtu.qq.com' # 防止代理影响结果
28 | if len(keys) == 0:
29 | raise NameError('ocr appid or secret_id or secret_key is None')
30 |
31 | def __app_sign(self):
32 | now = int(time.time())
33 | expired = now + 2592000
34 | rdm = random.randint(0, 999999999)
35 | plain_text = 'a={}&k={}&e={}&t={}&r={}&u=xx&f='.format(self.app_id, self.secret_id, expired, now, rdm)
36 | b = hmac.new(self.secret_key.encode(), plain_text.encode(), hashlib.sha1)
37 | s = binascii.unhexlify(b.hexdigest()) + plain_text.encode('ascii')
38 | signature = base64.b64encode(s).rstrip() # 生成签名
39 | return signature
40 |
41 | def __get_headers(self):
42 | sign = self.__app_sign()
43 | headers = {'Authorization': sign, 'Content-Type': 'text/json'}
44 | return headers
45 |
46 | def get_result_path(self, image_path):
47 | if len(image_path) == 0:
48 | return {'errormsg': 'IMAGE_PATH_EMPTY'}
49 |
50 | filepath = os.path.abspath(image_path)
51 | if not os.path.exists(filepath):
52 | return {'errormsg': 'IMAGE_FILE_NOT_EXISTS'}
53 |
54 | out = open(filepath, 'rb').read()
55 | return self.get_result(out)
56 |
57 | def get_result(self, image):
58 | self.result = None
59 | image = base64.b64encode(image)
60 | image = image.rstrip().decode('utf-8')
61 |
62 | for key in self.keys: # 使用多个优图账号尝试,防止某个账号频率限制
63 | if 'error' not in key:
64 | key['error'] = 0 # 初始化碰到限制的次数
65 | if key['error'] > 3:
66 | continue # 经常遇到频率限制的账号不用了
67 | self.app_id = key['app_id']
68 | self.secret_id = key['secret_id']
69 | self.secret_key = key['secret_key']
70 | headers = self.__get_headers()
71 | url = 'http://api.youtu.qq.com/youtu/ocrapi/generalocr'
72 | data = {"app_id": key['app_id'], "session_id": '', "image": image}
73 |
74 | try:
75 | r = requests.post(url, headers=headers, data=json.dumps(data))
76 | if r.status_code == 200:
77 | r.encoding = 'utf-8'
78 | self.result = r.json()
79 | break
80 | else:
81 | key['error'] = key['error'] + 1
82 | logging.info('keys: {}'.format(self.keys))
83 | logging.error('ocr请求返回异常:code {}, app_id {}'.format(r.status_code, key['app_id']))
84 | except Exception as e:
85 | traceback.print_exc()
86 | logging.error('error: {}'.format(traceback.format_exc()))
87 |
88 | if self.result and 'items' in self.result:
89 | return self.result
90 | else:
91 | logging.info('result:{}'.format(self.result))
92 | raise NameError('OCR 请求异常')
93 |
94 |
95 | class TencentCloudOcr(object):
96 | def __init__(self, secret_id, secret_key):
97 | cred = credential.Credential(secret_id, secret_key)
98 | httpProfile = HttpProfile()
99 | httpProfile.endpoint = "ocr.tencentcloudapi.com"
100 |
101 | clientProfile = ClientProfile()
102 | clientProfile.httpProfile = httpProfile
103 | self.client = ocr_client.OcrClient(cred, "ap-guangzhou", clientProfile)
104 |
105 | def get_text_base_info(self, image):
106 | """
107 | 通过图片的内容,获取图片上的文字内容
108 | :param image:
109 | :return:
110 | """
111 | req = models.GeneralFastOCRRequest()
112 | req.ImageBase64 = base64.b64encode(image).decode('utf-8')
113 | resp = self.client.GeneralFastOCR(req)
114 | logging.info('resp: {}'.format(resp.to_json_string()))
115 | return resp
116 |
117 | def get_result(self, image):
118 | """
119 | 将腾讯云结果封装为优图结果,方便统一使用
120 | :param image:
121 | :return:
122 | """
123 | items = []
124 | resp = self.get_text_base_info(image)
125 | for info in resp.TextDetections:
126 | x = info.Polygon[0].X
127 | y = info.Polygon[0].Y
128 | width = info.Polygon[1].X - info.Polygon[0].X
129 | height = info.Polygon[3].Y - info.Polygon[0].Y
130 | box = {'x': x, 'y': y, 'width': width, 'height': height}
131 | items.append({'itemstring': info.DetectedText, 'itemcoord': box})
132 | return {'items': items}
133 |
--------------------------------------------------------------------------------
/adbui/static/adbui:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hao1032/adbui/e4c2fade1ded09d7009b4380c220b1e8353e54c0/adbui/static/adbui
--------------------------------------------------------------------------------
/adbui/tango.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # @Time : 2020/3/29 3:55 下午
4 | # @Author : tangonian
5 | # @Site :
6 | # @File : tango.py
7 | # @Software: PyCharm
8 | import os
9 | import logging
10 | import time
11 |
12 |
13 | class Tango:
14 | @staticmethod
15 | def set_log(log_path, level=logging.INFO, remove_exist=False):
16 | if remove_exist and os.path.isfile(log_path):
17 | os.remove(log_path)
18 |
19 | handler1 = logging.StreamHandler()
20 | handler2 = logging.FileHandler(filename=log_path, encoding='utf-8')
21 |
22 | format_str = '{}%(asctime)s-%(levelname)s-%(lineno)d:%(message)s{}'
23 | handler1.setFormatter(logging.Formatter(format_str.format('', '')))
24 | handler2.setFormatter(logging.Formatter(format_str.format('', '')))
25 |
26 | logging.root.level = level
27 | logging.root.handlers = [handler1, handler2]
28 |
29 | @staticmethod
30 | def get_time_str(fmt='%Y%m%d-%H%M%S'):
31 | return time.strftime(fmt, time.localtime())
32 |
33 | @staticmethod
34 | def list_to_chunks(lst, n):
35 | """Yield successive n-sized chunks from lst."""
36 | for i in range(0, len(lst), n):
37 | yield lst[i:i + n]
--------------------------------------------------------------------------------
/adbui/util.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | import re
3 | import sys
4 | import subprocess
5 | import logging
6 | import platform
7 | import time
8 | import traceback
9 |
10 | from func_timeout import func_timeout, FunctionTimedOut
11 |
12 |
13 | class Util(object):
14 | def __init__(self, sn):
15 | self.is_win = 'window' in platform.system().lower()
16 | self.is_wsl = 'linux' in platform.system().lower() and 'microsoft' in platform.release().lower() # 判断当前是不是WSL环境
17 | self.is_py2 = sys.version_info < (3, 0)
18 | self.sn = sn
19 | self.adb_path = None
20 | self.debug = False
21 |
22 | @staticmethod
23 | def __get_cmd_process(arg):
24 | logging.debug(arg)
25 | p = subprocess.Popen(arg, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # 将错误信息也使用stdout输出
26 | return p
27 |
28 | @staticmethod
29 | def __get_cmd_out(process):
30 | out, err = process.communicate()
31 | if err.strip():
32 | logging.error('命令 {} 有错误输出:\n{}'.format(process.args, err))
33 |
34 | return out
35 |
36 | @staticmethod
37 | def __run_cmd(arg, is_wait=True, encoding='utf-8'):
38 | p = Util.__get_cmd_process(arg)
39 | if is_wait:
40 | out, err = p.communicate()
41 | else:
42 | return p # 如果不等待,直接返回
43 |
44 | if err:
45 | logging.error('err: {}, arg: {}'.format(err.strip(), arg))
46 |
47 | if encoding and isinstance(out, bytes):
48 | out = out.decode(encoding)
49 | err = err.decode(encoding)
50 |
51 | try:
52 | logging.debug('out[: 100]: {}'.format(out[: 100].strip()))
53 | except Exception as e:
54 | error_str = traceback.format_exc().strip()
55 | logging.debug('out log error: {}'.format(error_str))
56 |
57 | return out, err
58 |
59 | @staticmethod
60 | def cmd(arg, timeout=30, is_wait=True, encoding='utf-8'):
61 | """
62 | 执行命令,并返回命令的输出,有超时可以设置
63 | :param arg:
64 | :param timeout:
65 | :param is_wait:
66 | :param encoding:
67 | :return:
68 | """
69 | try:
70 | return func_timeout(timeout, Util.__run_cmd, args=(arg, is_wait, encoding))
71 | except FunctionTimedOut:
72 | print('执行命令超时 {}s: {}'.format(timeout, arg))
73 |
74 | def adb(self, arg, timeout=30, encoding='utf-8'):
75 | self.adb_path = self.adb_path if self.adb_path and self.adb_path != 'adb' else 'adb'
76 |
77 | if not self.sn:
78 | self.sn = self.get_first_sn()
79 |
80 | arg = '{} -s {} {}'.format(self.adb_path, self.sn, arg)
81 | for index in range(3):
82 | result = self.cmd(arg, timeout, encoding=encoding)
83 |
84 | if result is not None and len(result) == 2:
85 | out, err = result
86 | else:
87 | logging.error('执行 cmd 返回结果异常:{}'.format(result))
88 | continue
89 |
90 | if err: # 错误处理
91 | if isinstance(err, bytes):
92 | err = err.decode('utf-8')
93 |
94 | # 处理设备连接错误
95 | is_device_not_found = 'device' in err and 'not found' in err
96 | is_device_offline = 'device offline' in err
97 | if is_device_not_found or is_device_offline:
98 | if index == 2:
99 | raise NameError('设备无法使用: {}'.format(self.sn))
100 | self.connect_sn() # 尝试重新连接网络设备
101 |
102 | else: # 只处理某些错误
103 | return out
104 | else: # 没有错误,返回命令的结果
105 | return out
106 | assert False, 'adb run error: {}'.format(arg)
107 |
108 | def shell(self, arg, timeout=30, encoding='utf-8'):
109 | arg = 'shell {}'.format(arg)
110 | return self.adb(arg, timeout, encoding=encoding)
111 |
112 | def connect_sn(self):
113 | if self.sn.count('.') != 3:
114 | return # 非网络设备不处理
115 | self.cmd('adb disconnect {}'.format(self.sn)) # 首先断开连接,排除该 sn 当前是 offline 状态
116 | time.sleep(1) # 等待断开
117 | self.cmd('adb connect {}'.format(self.sn))
118 | time.sleep(1) # 等待连接
119 |
120 | info = self.get_sn_info()
121 | logging.info('连接后的设备列表:{}'.format(info))
122 |
123 | def get_first_sn(self):
124 | sn_info = self.get_sn_info()
125 | for sn in sn_info:
126 | if sn_info[sn] == 'device':
127 | return sn
128 | raise NameError('没有可以使用的设备: {}'.format(sn_info))
129 |
130 | def get_sn_info(self):
131 | sn_info = {}
132 | out, err = self.cmd('adb devices')
133 | lines = re.split(r'[\r\n]+', out.strip())
134 | for line in lines[1:]:
135 | if not line.strip():
136 | continue
137 | sn, status = re.split(r'\s+', line, maxsplit=1)
138 | sn_info[sn] = status
139 | return sn_info
140 |
--------------------------------------------------------------------------------
/docs/image/ocr01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hao1032/adbui/e4c2fade1ded09d7009b4380c220b1e8353e54c0/docs/image/ocr01.png
--------------------------------------------------------------------------------
/docs/image/xpath01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hao1032/adbui/e4c2fade1ded09d7009b4380c220b1e8353e54c0/docs/image/xpath01.png
--------------------------------------------------------------------------------
/docs/pypi.txt:
--------------------------------------------------------------------------------
1 | https://wxnacy.com/2018/07/13/python-create-pip/
2 |
3 | 1.pip3 install twine -i https://mirrors.tencent.com/pypi/simple/
4 | 2.python setup.py sdist
5 | 3.twine upload dist/*
6 |
--------------------------------------------------------------------------------
/sample.py:
--------------------------------------------------------------------------------
1 | from adbui import Device, Util
2 | import time
3 |
4 | d = Device()
5 |
6 | ui = d.get_ui_by_ocr('阅读')
7 | print(ui)
8 |
9 | ui = d.get_ui_by_attr(text='设置', is_contains=True) # 需要手机当前桌面显示 设置 应用
10 | ui.click()
11 |
12 | time.sleep(1)
13 | ui = d.get_ui_by_ocr(text='显示')
14 | ui.click()
15 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import io
4 | from setuptools import setup, find_packages
5 |
6 | VERSION = '4.5.22'
7 |
8 | with io.open('README.md', 'r', encoding='utf-8') as fp:
9 | long_description = fp.read()
10 |
11 | requires = [
12 | 'lxml',
13 | 'requests',
14 | 'func_timeout',
15 | 'tencentcloud-sdk-python>=3.0.0'
16 | ]
17 |
18 | setup(
19 | name='adbui',
20 | version=VERSION,
21 | description='adbui 所有的功能都是通过 adb 命令,adbui 的特色是可以通过 xpath,ocr 获取 ui 元素。',
22 | long_description=long_description,
23 | long_description_content_type="text/markdown",
24 | author='Tango Nian',
25 | author_email='hao1032@gmail.com',
26 | url='https://github.com/hao1032/adbui',
27 | keywords='testing android uiautomator ocr minicap',
28 | install_requires=requires,
29 | packages=find_packages(),
30 | include_package_data=True,
31 | license='MIT',
32 | platforms='any',
33 | classifiers=(
34 | 'Development Status :: 4 - Beta',
35 | 'License :: OSI Approved :: MIT License',
36 | 'Programming Language :: Python',
37 | 'Programming Language :: Python :: 2',
38 | 'Programming Language :: Python :: 3',
39 | 'Topic :: Software Development :: Testing'
40 | )
41 | )
--------------------------------------------------------------------------------
/tests/res/dump.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hao1032/adbui/e4c2fade1ded09d7009b4380c220b1e8353e54c0/tests/res/dump.jpg
--------------------------------------------------------------------------------
/tests/res/dump.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/tango.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # @Time : 2020/3/29 3:58 上午
4 | # @Author : tangonian
5 | # @Site :
6 | # @File : tango.py
7 | # @Software: PyCharm
8 | import subprocess
9 | from lxml import etree
10 | from adbui import Device
11 | from adbui import Util
12 | import logging
13 | logging.basicConfig(level=logging.DEBUG)
14 |
15 | d = Device()
16 |
17 | ui = d.get_uis_by_ocr('')
18 | print(ui)
19 |
--------------------------------------------------------------------------------
/tests/test_adb_ext.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | import unittest
3 | from adbui.util import Util
4 | from adbui.adb_ext import AdbExt
5 | from unittest.mock import MagicMock
6 |
7 |
8 | class TestAdbExt(unittest.TestCase):
9 | def setUp(self):
10 | self.sn = '123abc'
11 | self.util = Util(self.sn)
12 | self.util.cmd = MagicMock()
13 | self.util.cmd.return_value = ''
14 | self.adb_ext = AdbExt(self.util)
15 |
16 | def tearDown(self):
17 | pass
18 |
19 | def test_dump(self):
20 | self.util.cmd.side_effect = NameError('dump xml fail!')
21 | self.adb_ext.dump()
22 | print(self.util.cmd.call_args_list)
23 |
24 |
--------------------------------------------------------------------------------
/tests/test_util.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | import os
3 | import unittest
4 | import warnings
5 | import tempfile
6 | from adbui.util import Util
7 | from unittest.mock import MagicMock
8 |
9 |
10 | class TestUtil(unittest.TestCase):
11 | def setUp(self):
12 | self.sn = '123abc'
13 | self.util = Util(self.sn)
14 | self.path = os.path.join(tempfile.gettempdir(), 'temp.txt')
15 | if os.path.exists(self.path): os.remove(self.path)
16 |
17 | def tearDown(self):
18 | if os.path.exists(self.path): os.remove(self.path)
19 |
20 | def test_cmd(self):
21 | warnings.simplefilter("ignore")
22 | self.assertEqual(self.util.cmd('echo adbui'), 'adbui')
23 |
24 | def test_cmd_out_save(self):
25 | self.util.cmd_out_save('echo adbui', self.path)
26 | self.assertTrue(os.path.exists(self.path))
27 | self.assertFalse(not os.path.exists(self.path))
28 |
29 | def test_adb(self):
30 | self.util.cmd = MagicMock()
31 | self.util.cmd.return_value = None
32 | self.util.adb('adbui')
33 | self.util.cmd.assert_called_once_with('adb -s 123abc adbui')
34 |
35 | def test_shell(self):
36 | self.util.cmd = MagicMock()
37 | self.util.cmd.return_value = None
38 | self.util.shell('adbui')
39 | self.util.cmd.assert_called_once_with('adb -s 123abc shell adbui')
40 |
41 |
--------------------------------------------------------------------------------