Python 自动控制下载工具下载指定软件到手机

刚学完 Python 全栈自动化的培训课程,我迫不及待地想运用到公司的项目上,于是自己写代码,想实现当有新的软件发布就可以自动下载软件到手机,之后启动自动化测试的功能,从而实现无人值守,第一时间就可以自动点检测试新软件的功能。要实现这样的功能,关键的一步是实现自动下载指定软件到手机,通过 Python+Pywin32+Winspy 即可解决:Pywin32 提供了 Python 操作 windows 程序的接口,而 Winspy(或 Spy++)则是用于查看 windows 窗口控件相关属性的辅助工具。

分析:

  1. 下载指定的软件版本,这里包含了项目名称、主软件和 Perso 文件,既然是无人值守,当然需要先把对应项目的手机先连接到电脑上,保证电脑可以识别到手机(由于下载工具的限制,只能同时下载一台手机,因而一台电脑只能连接一台手机);
  2. 下载工具需要选择软件所在的服务器,这个下载工具也需要用户名和密码来登陆,登陆后,要选择项目名称,再加载主软件和 perso 文件,然后才能下载到手机;

image.png

  1. 加载软件时,默认会打开该项目所在服务器的项目根目录,需要逐步打开主软件所在的目录,之后打开对应的软件版本目录,再打开 originfiles 目录,然后打开 userdebug 目录,最后才是选择 partition 文件进行加载(这样下载的软件就是 userdebug 版本的,手机下载完软件后无需人工干预,电脑就能识别到手机的 ADB 口);主软件加载后才能加载 Perso 文件,加载 Perso 文件时,也需要从根目录开始,先打开 perso 所在的目录,再打开对应的软件版本目录,然后打开对应 Perso 名称的目录,最后是加载对应的 Perso 文件。例如下载 3D48+UE80 这样的软件到手机,主软件为 3D48,Perso 为 UE80,对应的主软件目录为 appli/v3D48/originfiles/userdebug,Perso 文件目录为 perso/3D48/UE。
    image.png

  2. 可循环监测服务器上是否有新的主软件和 Perso(也可通过接收固定格式内容的邮件来作为触发条件),只有新的主软件和 Perso 都同步到服务器后,才会触发自动化下载软件的程序来启动下载的命令,这一步可通过 Jenkins 来实现(通过定时查看服务器上的文件来判断,或者判断收件箱中收到新的固定格式内容的邮件,本次代码未包含这部分)。

遇到的问题:

  1. 下载工具加载软件时,对应的文件或目录是在 SysListView32 控件中展示的,由于没有在 win32api 中找到对应的接口,我是通过访问项目软件所在的网站,通过 Selenium 遍历网站目录找到对应的主软件和 Perso,从而得到主软件和 perso 文件在 SysListView32 控件中的相对位置,记录各个步骤对应的顺序,再通过相对定位,控制鼠标点击操作下载工具来加载软件的对应的软件版本和 Perso 的。
    image.png

  2. 项目选择的问题,在 win32api 中未找到读取项目下拉列表组合框当前选择的项目名称的接口,于是翻阅 win32api,找到了另外一个解决办法:先遍历所有的下拉列表项,比较下拉列表项是否完全匹配对应的项目名称,取得匹配项目名称的索引,再更新下拉列表的索引为找到索引,这样下拉列表选择的就是我们需要的项目名称了。之前考虑过把下拉列表中所有的项目作为一个列表,然后操作鼠标点击下拉列表,通过模拟键盘的上下方向键来选择第几个项目来实现项目的选择,但由于下载工具会更新,更新后往往有新增的项目,项目的顺序就会有细微的差别,因而没有采取这个解决方案。
    image.png

Python 编写的控制下载工具自动登陆、选择项目、加载主软件和 Perso、执行下载操作的所有代码如下:


from selenium import webdriver

#=========== 通用模块,windows系统操作==========>>
import win32gui, win32con, os, win32api
import time, win32ui

def move_to(position):
    '''移动鼠标到某个位置'''
    win32api.SetCursorPos(position)
    print("Move to:", position)

def one_click(position):
    '''鼠标单击某个位置'''
    move_to(position)
    print('Click position:', position)
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    time.sleep(0.05)
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
    time.sleep(0.05)
    time.sleep(1)

def double_click(position):
    '''鼠标双击某个位置'''
    move_to(position)
    print('Double click position:', position)
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    time.sleep(0.01)
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
    time.sleep(0.05)
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    time.sleep(0.05)
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
    time.sleep(0.05)
    time.sleep(1)

def multi_click(times, position):
    '''点击某位置多次(例如滚动条向下的位置,可以实现listview控件向下滚动times行)'''

    print('Multi click position ',times, ' times: ', position)
    move_to(position)
    for i in range(times):
        win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
        time.sleep(0.05)
        win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
        time.sleep(0.05)
    time.sleep(2)

def findWindow(*args):
    '''args为窗口标题包含的内容,可以是多个字符串'''
    findHandle = None
    findTitle = None
    hWndList = []
    win32gui.EnumWindows(lambda hWnd, param: param.append(hWnd), hWndList)
    for hWnd in hWndList:
        if hWnd:
            title = win32gui.GetWindowText(hWnd)
            flag = []
            i = 0
            for a in args:
                i = i + 1
                if(a in title):
                    flag.append(True)
                else:
                    flag.append(False)
            if False not in flag:
                findHandle = hWnd
                findTitle = title
    if findTitle is not None:
        print('Find Window:', findTitle)
    return findHandle

def findWindow2(className, title_text):
    '''根据class和title查找窗口'''
    findHandle = []
    findTitle = None
    hWndList = []
    win32gui.EnumWindows(lambda hWnd, param: param.append(hWnd), hWndList)
    for hWnd in hWndList:
        if hWnd:
            title = win32gui.GetWindowText(hWnd)
            class_name = win32gui.GetClassName(hWnd)
            if title_text in title and class_name == className:
                findHandle.append(hWnd)
    if len(findHandle) >= 1:
        return findHandle[-1]
    else:
        return None

def waitHandle(title,seconds=30):
    '''根据title检索handle,默认循环检测30秒,每秒检测一次,当打开新窗口句柄时,可以使用此函数'''
    handle = None
    i = 0
    while handle is None:
        i = i + 1
        handle = findWindow(title)
        if handle is None:
            time.sleep(1)  # 找到窗口句柄
        else:
            break
        if i >= seconds:
            print('找不到对应的窗口句柄')
            break
    return handle

def key_press(text):
    '''根据传入的文本模拟相应的按键'''
    key_map = {'a': 65, 'b': 66, 'c': 67, 'd': 68, 'e': 69, 'f': 70, 'g': 71, 'h': 72, 'i': 73, 'j': 74, 'k': 75,
               'l': 76, 'm': 77, 'n': 78, 'o': 79, 'p': 80, 'q': 81, 'r': 82, 's': 83, 't': 84, 'u': 85, 'v': 86,
               'w': 87, 'x': 88, 'y': 89, 'z': 90, '0': 48, '1': 49, '2': 50, '3': 51, '4': 52, '5': 53, '6': 54,
               '7': 55, '8': 56, '9': 57, 'Star': 106, '!': 33, '@': 64, '#': 35, '$': 36, 'Enter': 13,
               'Backspace': 8,
               'Tab': 9, 'Clear': 12, 'Shift': 16, 'Control': 17, 'Alt': 18, 'Cap Lock': 20, 'Esc': 27,
               'Spacebar': 32,
               'Page Up': 33, 'Page Down': 34, 'End': 35, 'Home': 36, 'Left Arrow': 37, 'Up Arrow': 38,
               'Right Arrow': 39, 'Down Arrow': 40, 'Insert': 45, 'Delete': 46, 'Num Lock': 144}
    if type(text) == list:
        for item in text:
            if item in key_map.keys():
                code = key_map[item]
                win32api.keybd_event(code, 0, 0, 0)  # key down
                time.sleep(0.01)
                win32api.keybd_event(code, 0, win32con.KEYEVENTF_KEYUP, 0)  # key up
                time.sleep(0.01)
            elif item in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":  # upper
                shift = key_map['Shift']
                code = key_map[item.lower()]
                win32api.keybd_event(shift, 0, 0, 0)  # key down
                time.sleep(0.01)
                win32api.keybd_event(code, 0, 0, 0)  # key down
                time.sleep(0.01)
                win32api.keybd_event(code, 0, win32con.KEYEVENTF_KEYUP, 0)  # key up
                time.sleep(0.01)
                win32api.keybd_event(shift, 0, win32con.KEYEVENTF_KEYUP, 0)  # key up
                time.sleep(0.01)
    elif text in key_map.keys():
        code = key_map[text]
        win32api.keybd_event(code, 0, 0, 0)  # key down
        time.sleep(0.01)
        win32api.keybd_event(code, 0, win32con.KEYEVENTF_KEYUP, 0)  # key up
        time.sleep(0.01)
    else:
        print('无法识别出入的参数:', text)
        raise Exception  #抛出异常

def set_index(cbHandle, full_text):
    '''根据文本内容设置下拉列表框选中该文本内容项'''
    #获取下拉列表索引数量
    total = win32gui.SendMessage(cbHandle, win32con.CB_GETCOUNT, 0, 0)
    index = -1
    for i in range(total):
        #遍历索引,按文本强匹配查找对应的索引
        index = win32gui.SendMessage(cbHandle,win32con.CB_FINDSTRINGEXACT,0,full_text)
        if index < total:
            #找到索引,更新下拉列表的索因为找到的索因
            win32gui.SendMessage(cbHandle, win32con.CB_SETCURSEL, index, 0)
            print('已选择:',full_text)
            return True
    if index not in range(total):
        #没有找到匹配的索因
        print('找不到匹配内容的索引')
        return False

#<<=========== 通用模块,windows系统操作



class AutoDownloadSW:
    """
    自动通过下载工具下载指定项目的主软件和perso到手机
    """

    def __init__(self,maincode, perso, project, download_tool_path=None):
        """
        下载操作初始化
        :param maincode: 主软件名称,默认为4到5个字符
        :param perso: Perso名称和编号,默认为4个字符
        :param project: 项目名称,可能包含有空格
        :param download_tool_path: 下载工具路径,有默认版本的路径,不指定时,用默认版本,新增的项目只有新版本工具才可以下载
        """
        self.maincode = maincode
        self.perso = perso
        self.project = project
        self.download_tool_path = download_tool_path
        if download_tool_path is None:
            self.download_tool_path = r"C:\Program Files (x86)\TeleWeb QCT_SP 3.8.2\TelewebS.exe"

    def start_download(self):
        '''下载操作'''
        # 开始,提示
        print('程序开始自动执行,请不要动鼠标或执行其它操作,以免影响自动执行!')
        time.sleep(5)
        # 打开下载工具
        os.popen(self.download_tool_path)
        # 获取Teleweb工具的句柄
        handle0 = waitHandle('Login TeleWeb')
        # 判断是否弹出了更新版本的提示,如果有,点击取消
        #此处代码省略
        # 找到OK按钮,点击OK按钮(默认已输入用户名和密码,如果没有输入过,需要输入一次,此处省略了用户名和密码的处理)
        ok_btn = win32gui.FindWindowEx(handle0, 0, 'Button', 'OK')
        win32gui.SendMessage(handle0, win32con.WM_COMMAND, 1, ok_btn)
        print("点击OK")
        time.sleep(2)
        errorHandle = findWindow2("#32770", 'TeleWeb')
        if errorHandle is not None:
            msg = 'Wrong user mode, user name or password, please try again! (Error code = 401)'
            w = win32ui.FindWindow('#32770', 'TeleWeb')
            if w.GetDlgItemText(0xFFFF) == msg:
                print('发现异常,请手动输入用户名和密码,选择好site,下次会自动记录')
                raise Exception
        # 查找Teleweb窗口句柄
        handle1 = waitHandle('TeleWeb QCT_SP 3.8.2 - Shenzhen')  # Teleweb主窗口
        handle2 = win32gui.FindWindowEx(handle1, 0, "#32770", None)
        handle3 = win32gui.FindWindowEx(handle2, 0, "SysTabControl32", None)  # Download Tab窗口
        handle4 = win32gui.FindWindowEx(handle3, 0, "#32770", None)
        # 遍历窗口子控件
        hwndChildList = []
        win32gui.EnumChildWindows(handle4, lambda hwnd, param: param.append(hwnd), hwndChildList)
        cb = []
        for h in hwndChildList:
            if win32gui.GetClassName(h) == 'ComboBox':
                # print(win32gui.GetWindowRect(h))
                cb.append(h)
        prjCombobox = cb[-1]  #项目下拉列表,此处发现另有一个隐藏的下拉列表,所以只能通过这个方法得到项目下拉列表控件
        if not set_index(prjCombobox, self.project.replace(' ','')):  #设置下拉列表索引为该项目,项目需要去掉空格
            raise Exception #设置失败抛出异常
        #获取项目软件加载操作步骤顺序
        steps = self.get_download_steps()
        # 点击partition按钮,打开软件加载对话框
        p_btn = win32gui.FindWindowEx(handle4, 0, "Button", "...")
        left, top, right, bottom = win32gui.GetWindowRect(p_btn) #获得按钮的位置信息
        p=(int((left+right)/2), int((top+bottom)/2)) #按钮左上的位置稍偏右下
        time.sleep(2)
        one_click(p)
        print("点击Partition按钮")
        time.sleep(2)
        #选择软件版本
        self.select_file(steps[0])
        time.sleep(5)
        #获取文件加载列表控件
        list2 = win32gui.FindWindowEx(handle4,0,'SysListView32','List2')  #List2控件
        left, top, right, bottom = win32gui.GetWindowRect(list2)
        #文件2的位置
        p = (left+20, top + 50)
        #点击2开头的文件
        print('click 2 file')
        one_click(p)
        time.sleep(2)
        #选择perso
        self.select_file(steps[1])
        time.sleep(5)

        #点击download开始下载
        dl_btn = win32gui.FindWindowEx(handle4,0,'Button','Download')
        left, top, right, bottom = win32gui.GetWindowRect(dl_btn)
        # 文件2的位置
        p = (left + 10, top + 10)
        one_click(p)
        time.sleep(5)
        #判断是否出现异常,例如手机未连接
        errorHandle = findWindow2("#32770", 'TeleWeb')
        if errorHandle is not None:
            print('发现异常,请确认手机是否已连接')
            raise Exception
        finish = findWindow2("#32770", 'TeleWeb')
        i = 0
        while finish is None:
            time.sleep(5)
            i = i + 5
            finish = findWindow2("#32770", 'TeleWeb')
            if finish is not None:
                w = win32ui.FindWindow('#32770', 'TeleWeb')
                if w.GetDlgItemText(0xFFFF) == 'Operate Finished':
                    print('操作已完成')
                w.SendMessage(win32con.WM_CLOSE)
            else:
                print('未弹出完成窗口,请确认操作是否正确')
                raise Exception
            if i > 1200:
                print('已等待20分钟,下载仍未完成,请确认是否下载正常')
                raise Exception

    def get_download_steps(self):
        '''根据maincode和perso,获取相应的步骤(顺序编号)'''
        maincode = self.maincode
        perso = self.perso
        project = self.project
        #软件目录
        url = 'https://10.128.161.96/0_Shenzhen/'
        #打开浏览器,默认用IE打开,需要提前把对应的IE驱动放入python的根目录中
        self.driver = webdriver.Ie()
        self.driver.get(url)
        time.sleep(5)
        #点击继续浏览此网站
        self.driver.find_element_by_partial_link_text("继续浏览此网站").click()
        time.sleep(2)
        for i in range(3):  # 按3次向上箭头,定位到用户名输入框
            key_press('Up Arrow')
        # 输入用户名
        user = 'xxxxxxxx'  #此处为软件服务器登录的用户名
        key_press(list(user))
        time.sleep(1)
        # Tab键
        key_press('Tab')
        # 密码
        pwd = "xxxxxxxx" #此处为软件服务器登录的密码
        key_press(list(pwd))
        time.sleep(1)
        # 输入回车,登录
        key_press('Enter')
        time.sleep(2)
        # 打开项目目录
        project = project.upper() #转为全部大写
        project = project.replace(' ', '')  # 去掉空格
        #打开项目对应的链接
        self.driver.find_element_by_partial_link_text(project).click()
        time.sleep(1)
        list0 = []
        #list0存放主软件相对位置顺序
        list0.append(self.get_step('appli/'))
        list0.append(self.get_step(self.maincode[-4:] + '/')) # 软件版本末4位加上/与目录的末5位字符匹配
        # 判断是否找到了主软件
        if list0[-1] == -1:  # 没有找到主软件,需要从tmp目录下找
            list0 = []
            # 返回上一级目录
            self.driver.find_element_by_partial_link_text('Parent Directory').click()
            time.sleep(2)
            # 找到tmp目录
            list0.append(self.get_step('appli/'))
            list0.append(self.get_step(self.maincode[-4:] + '/'))  # 软件版本末4位加上/与目录的末5位字符匹配
            #判断是否找到
            if list0[-1] == -1:
                # 仍然找不到,直接返回None
                print("找不到指定的软件版本,请确认版本名称是否正确,注意一定要匹配,区分大小写:", maincode)
                self.driver.close()
                raise Exception  #抛出异常
        # 找到originfiles的顺序
        list0.append(self.get_step('originfiles/'))
        # 找到userdebug的顺序
        list0.append(self.get_step('userdebug/'))
        # 找到P开头的partition文件的顺序
        list0.append(self.get_step('P' + maincode[-4:]))
        # 获得软件目录路径
        swpath = self.driver.current_url
        # 返回主目录
        for i in range(1, len(list0)):
            self.driver.find_element_by_partial_link_text('Parent Directory').click()
            time.sleep(2)
        list1=[]
        # 找到perso文件夹的顺序
        list1.append(self.get_step('perso/'))
        # 找到主软件文件夹的顺序
        list1.append(self.get_step(maincode[-4:] + '/'))
        # 找到Perso名文件夹的顺序
        list1.append(self.get_step(perso[:2] + '/'))
        # 找到Perso 2开头的文件的顺序
        list1.append(self.get_step('2' + maincode[-4:-1].lower() + perso.lower()))
        time.sleep(2)
        #记录perso文件路径
        persopath = self.driver.current_url
        #关闭浏览器
        self.driver.close()
        rmpath = url + project
        swpath = swpath.replace(rmpath, '') #移除路径前缀
        persopath = persopath.replace(rmpath, '') #移除路径前缀
        return [list0, list1, swpath, persopath] #返回主软件顺序位置、perso文件顺序位置、主软件目录、perso文件目录,返回的列表是为自动操作下载工具加载软件准备的

    def get_step(self, text):
        '''根据文本查询对应链接的位置,注意前4个链接是标题,第5个链接是返回上级目录,而teleweb所以返回的位置需要减去5'''
        sw = self.driver.find_elements_by_tag_name('a')
        # 找到Perso 2开头的文件的顺序
        n = 0
        for i in sw:
            n = n + 1
            if text[-1] == "/": #对应为目录,需要右侧匹配
                if text in i.text[-len(text):]:
                    i.click() #打开目录
                    time.sleep(2)
                    return n - 5
            else: #对应为文件,只需要包含即可
                if text in i.text:
                    return n-5
        #没有找到
        return -1

    def select_file(self, steps):
        '''根据传入的各个步骤的文件顺序操作鼠标自动选择软件版本(Userdebug)'''
        """
        steps: 一个数值列表,代表每一步的编号
        startPosition: List1控件第一行中间的位置(x, y)
        step: 每一行的高度(像素)
        """
        time.sleep(5)
        file_handle = findWindow('Remote File Open Dialog')
        back = win32gui.FindWindowEx(file_handle, 0, 'Button', '/')
        left, top, right, bottom = win32gui.GetWindowRect(back)  # 获得按钮的位置信息
        bp = (int((left + right) / 2), int((top + bottom) / 2))  # 按钮中心
        print('Click /')
        one_click(bp)
        time.sleep(2)
        list1 = win32gui.FindWindowEx(file_handle, 0, 'SysListView32', 'List1')  # List1控件
        left, top, right, bottom = win32gui.GetWindowRect(list1)
        startPosition = (left + 30, top + 32)
        lastPosition = (startPosition[0], bottom - 10)
        downPosition = (right - 10, bottom - 10)
        for i in steps:

            print('steps:', i)
            if i <= 17:
                # 直接移动鼠标到目标位置
                position = (left + 30, startPosition[1] + i * 17)
                # 双击打开
                double_click(position)
            else:
                j = i - 17
                # 滚动到条目可见
                multi_click(j, downPosition)
                # 双击打开
                double_click(lastPosition)


AutoDownloadSW('3D48','UE80','U50A PLUS VZW').start_download()
4 回帖
请输入回帖内容 ...
  • HappyEarth

    大佬 666,请问一下这个 pywin32 的库适用于 64 位的应用程序吗?

  • 其他回帖
  • steve

    没试过,可以试一试,我电脑是 64 位系统的

  • ta_qian

    有联系方式没 加一下

  • steve

    本来录屏了自动操作过程演示为视频的,因涉及公司机密,现去掉视频链接。