├── .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 | ![xpath](docs/image/xpath01.png) 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 | ![xpath](docs/image/ocr01.png) 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 | --------------------------------------------------------------------------------