安卓app自动化操作方案实现

PC群控实机方案

准备

python uiautomator2 实现自动化操作
使用weditor实现元素解析
使用adb+wifi实现手机与PC的通信

uiautomator2代码例子

class FlightWarning(object):
    def __init__(self):
        self.app_name = "com.umetrip.android.msky.app"
        self.phone_ip = phone_ip
        self.d = None

    # 随机等待
    @staticmethod
    def human_delay(min=1.0, max=3.0):
        time.sleep(random.uniform(min, max))

    # 登录
    def login(self):
        res = {'flag': 'end', 'msg': '支付失败!', 'img': None, 'is_pay': False}
        try:
            # 通过设备序列号连接
            d = u2.connect(self.phone_ip)  # 192.168.90.93:15552
            self.d = d
            # d.debug = True  # 启动内置调试器 http://localhost:7912
            # d.app_start(app_name, stop=False)
            # 通过包名启动
            d.app_start(self.app_name)
            # xpath定位
            self.human_delay(min=3.0, max=5.0)
            d.xpath(
                '//*[@resource-id="com.umetrip.android.msky.app:id/home_bottom_bar"]/android.widget.LinearLayout[5]/android.widget.RelativeLayout[1]/android.widget.ImageView[1]').click()
            self.human_delay()
            d.xpath('//*[@content-desc="点击登录"]').click()
            # 使用ADB命令输入文本
            self.human_delay()
            d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/regist_name_edit"]').click()
            self.human_delay()
            d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/regist_name_edit"]').set_text("")
            d.shell(f"input text 账号")
            self.human_delay()
            d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/regist_pass_edit"]').click()
            d.shell(f"input text 密码")
            self.human_delay()
            d.press("back")
            d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/iv_agreement_image"]').click()
            self.human_delay()
            d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/login_button"]').click()
            self.human_delay(min=3.0, max=5.0)
            element = d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/strict_login_get_code"]')
            if element.exists:
                logger.info("需要短信验证码")
                d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/strict_login_get_code"]').click()
                time.sleep(3)  # 等待短信验证码
                code = '短信验证码'
                d.shell(f"input text {code}")
                self.human_delay()
                d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/strict_login_done"]').click()
                self.human_delay(min=3.0, max=5.0)

                element = d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/regist_pass_edit"]')
                if element.exists:
                    logger.info("再次输入密码")
                    d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/regist_pass_edit"]').click()
                    d.shell(f"input text 密码")
                    d.press("back")
                    d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/iv_agreement_image"]').click()
                    self.human_delay()
                    d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/login_button"]').click()
            else:
                logger.info("不需要短信验证码")

            if d.xpath('//*[@content-desc="我的订单"]').wait(timeout=10):  # 等待最多15秒
                res['flag'] = 'success'
                res['msg'] = '登录成功!'
                logger.info(res['msg'])
            else:
                res['msg'] = '登录失败!'
                logger.info(res['msg'])
                file_path = f"登录.png"
                d.screenshot(file_path)

            return res
        except Exception as e:
            res['msg'] = f'登录-运行异常:{e}'
            logger.error(f'登录-异常:{traceback.format_exc()}')
            return res
        # finally:
        #     try:
        #         d.app_stop(self.app_name)  # 关闭app
        #     except Exception:
        #         pass

    # 登出
    def logout(self):
        res = {'flag': 'end', 'msg': '支付失败!', 'img': None, 'is_pay': False}
        try:
            if not self.d.xpath('//*[@content-desc="我的订单"]').wait(timeout=4.5):
                self.d.xpath(
                    '//*[@resource-id="com.umetrip.android.msky.app:id/home_bottom_bar"]/android.widget.LinearLayout[5]/android.widget.RelativeLayout[1]/android.widget.ImageView[1]').click()
            self.d.xpath(
                '//*[@resource-id="com.umetrip.android.msky.app:id/fl_weex_container"]/android.widget.FrameLayout[1]/android.widget.FrameLayout[2]/android.widget.FrameLayout[2]/android.widget.ImageView[2]').click()
            self.d.xpath(
                '//*[@resource-id="com.umetrip.android.msky.app:id/fl_weex_container"]/android.widget.FrameLayout[1]/android.widget.FrameLayout[1]/android.widget.FrameLayout[2]').click()

            if self.d.xpath('//*[@resource-id="com.umetrip.android.msky.app:id/tv_loginAuthBtn"]').wait(
                    timeout=10):  # 等待最多15秒
                res['flag'] = 'success'
                res['msg'] = '登出成功!'
                logger.info(res['msg'])
            else:
                res['msg'] = '登出失败!'
                logger.info(res['msg'])
                file_path = f"登出.png"
                self.d.screenshot(file_path)
            return res
        except Exception as e:
            res['msg'] = f'登出-运行异常:{e}'
            logger.error(f'登出-异常:{traceback.format_exc()}')
            return res

    # 查询
    def query(self, depart, arrive, airline, phone_ip):
        res = {'flag': 'end', 'msg': '查询预警-初始信息', 'flight_data': [], 'depart': depart, 'arrive': arrive,
               'airline': airline}
        try:
            logger.info(f'查询预警-开始:{depart}->{arrive}->{airline}->{phone_ip}')
            airline_cn = airline_schedule.get(airline)
            if not airline_cn:
                res['msg'] = f'查询预警-不支持航空公司:{airline}!'
                return res
            # 方法2:直接连接
            try:
                self.d = u2.connect(phone_ip)
            except  Exception:
                # 如果失败,尝试重新初始化
                self.d = u2.connect(phone_ip)
            # d.debug = True  # 启动内置调试器 http://localhost:7912
            # d.app_start(app_name, stop=False)
            # 通过包名启动
            try:
                self.d.screen_on()
                self.d.swipe_ext("up")
            except:
                pass
            self.human_delay(min=3.0, max=3.0)
            try:
                self.d.app_stop(self.app_name)
            except Exception:
                pass
            self.human_delay(min=1.0, max=2.0)
            self.d.app_start(self.app_name)
            # xpath定位
            self.human_delay(min=3.0, max=5.0)
            self.d.xpath(
                '//*[@resource-id="com.umetrip.android.msky.app:id/home_bottom_bar"]/android.widget.LinearLayout[4]/android.widget.RelativeLayout[1]/android.widget.ImageView[1]').click()
            self.human_delay()
            self.d.xpath('//*[@content-desc="按起降地"]').click()
            self.human_delay(min=1.0, max=3.0)
            # 输入出发机场
            self.d.click(0.181, 0.24)
            self.human_delay(min=3.0, max=4.0)
            self.d.xpath('//*[@text="输入城市或机场中文/英文/三字码"]').click()
            self.human_delay(min=12.0, max=15.0)
            s_code = f"input text {depart}"
            self.d.shell(s_code)
            # self.d.send_keys(depart)
            self.human_delay(min=4.0, max=5.0)
            self.d.click(0.075, 0.158)
            self.human_delay(min=2.0, max=3.0)
            # 输入到达机场
            self.d.click(0.838, 0.24)
            self.human_delay(min=3.0, max=4.0)
            self.d.xpath('//*[@text="输入城市或机场中文/英文/三字码"]').click()
            self.human_delay(min=12.0, max=15.0)
            s_code = f"input text {arrive}"
            self.d.shell(s_code)
            self.d.click(0.397, 0.097)
            # self.d.shell(f"input text PVG")
            # self.d.send_keys(arrive)
            self.human_delay(min=4.0, max=5.0)
            self.d.click(0.371, 0.161)
            self.human_delay(min=3.0, max=4.0)
            # 点击查询
            self.d.click(0.498, 0.481)
            # self.human_delay(min=4.0, max=5.0)
            logger.info(f'输入出发到达完成时间:{time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())}')
            # 筛选zh航班 //*[@content-desc="筛选航班"] SZX
            self.d.xpath('//*[@content-desc="筛选航班"]').click()
            zh_flag = False
            for _ in range(5):
                # if self.d.xpath('//*[@content-desc="深圳航空"]').wait(timeout=3):
                ele = self.d.xpath('//*[@content-desc="%s"]' % airline_cn)
                if ele.exists:
                    logger.info(f"出现{airline_cn}")
                    zh_flag = True
                    break
                else:
                    logger.info(f"没有出现{airline_cn}")
                    self.d.swipe_ext("up")
                    continue
            if not zh_flag:
                logger.info(f"没有出现{airline_cn}")
                res['msg'] = f'查询预警-没有出现{airline_cn},预警信息!'
                return res
            # self.d.swipe_ext("up")  # 上滑找到 深圳航空
            self.d.xpath('//*[@content-desc="深圳航空"]').click()
            self.d.xpath('//*[@content-desc="查看结果"]').click()
            logger.info(f'筛选所需航班完成时间:{time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())}')

            # 航班数据 - 简化版本
            flight_data = []
            previous_bottom_hash = None

            for i in range(4):
                ele2 = self.d.xpath('//*[@content-desc="预警"]')
                if ele2.exists:
                    for i2 in range(5):
                        result_ces = self.capture_element_screenshot(
                            f'//android.widget.ScrollView/android.widget.FrameLayout[1]/android.widget.FrameLayout[{i2 + 1}]/android.widget.FrameLayout[1]',
                            f'预警{str(i) + str(i2)}.jpg')

                        if result_ces['flag'] == 'error':
                            break
                        if result_ces['flag'] == 'success':
                            logger.info(f"{depart + '->' + arrive + '->' + airline}截图成功:{result_ces['img']}")
                            flight_data.append({"raw_text": '', 'data': {}, 'img': result_ces['img']})

                # 滑动前获取底部区域哈希
                current_hash = self.get_bottom_hash()

                # 截图航班信息
                self.d.swipe_ext("up")
                self.human_delay(min=1.0, max=2.0)

                # 如果是第一次循环,只保存哈希值不比较
                if i == 0:
                    previous_bottom_hash = current_hash
                    continue

                # 比较滑动前后的底部区域哈希
                if current_hash == previous_bottom_hash:
                    logger.info(f"第{i + 1}次滑动后内容无变化,终止循环")
                    break

                # 更新前一次的哈希值为当前哈希值
                previous_bottom_hash = current_hash

            if flight_data:
                # 使用图片路径进行去重
                seen_imgs = set()
                unique_flight_data = []

                for f in flight_data:
                    if f['img'] not in seen_imgs:
                        seen_imgs.add(f['img'])
                        unique_flight_data.append(f)

                flight_data = unique_flight_data

                for f in flight_data:
                    result_uo = self.umi_ocr(f['img'])
                    f['raw_text'] = result_uo['data']
                    result_pfd = self.parse_flight_data(f['raw_text'], depart, arrive)
                    if result_pfd['flag'] == 'success':
                        f['data'] = result_pfd['data']
                res['flight_data'] = flight_data
            res['flag'] = 'success'
            res['msg'] = '查询预警-查询成功!'
            logger.info(f'查询预警-返回数据:{res}')
            return res
        except Exception:
            logger.info(f"查询预警-运行异常:{traceback.format_exc()}")
            return res
        finally:
            try:
                self.d.app_stop(self.app_name)
            except Exception:
                pass

    def get_bottom_hash(self):
        """
        获取屏幕底部区域的哈希值
        """
        try:
            screenshot = self.d.screenshot()
            width, height = screenshot.size

            # 截取屏幕底部20%的区域
            bottom_region = screenshot.crop((0, int(height * 0.7), width, height))
            buffer = BytesIO()
            bottom_region.save(buffer, format='PNG')
            return hashlib.md5(buffer.getvalue()).hexdigest()
        except Exception as e:
            logger.error(f"获取底部哈希时出错: {e}")
            return None

    # 解析数据
    def parse_flight_data(self, flight_str, depart, arrive):
        res = {'flag': 'end', 'msg': '解析数据-初始信息', "data": {}}
        try:
            flight_info = {
                '航班号': '',
                '出发机场': depart,
                '到达机场': arrive,
                '前序航班预计到达': '',
                '前序航班已到达': '',
                '航班状态': '预警',
                '预计出发': '',
                '预计到达': '',
                '计划出发': '',
                '计划到达': '',
            }
            # '深圳航空ZH18941实际承运CA1894 11:15宝安T3 预计11:57 前序航班已于09:14到达 13:55浦东T2 预计13:43 预警'
            flight_num = 'ZH' + re.findall(r'深圳航空ZH(.{4})', flight_str)[0]
            flight_info['航班号'] = 'ZH' + flight_num
            if '前序航班' in flight_str:
                if '前序航班已于' in flight_str:
                    previous_flight = re.findall(r'前序航班已于(\d{1,2}:\d{2})到达', flight_str)[0]
                    flight_info['前序航班已到达'] = previous_flight

                    exp = r'前序航班已于\d{1,2}:\d{2}到达 ' + r'(\d{1,2}:\d{2})'
                    flight_info['计划到达'] = re.findall(exp, flight_str)[0]

                else:
                    previous_flight = re.findall(r'前序航班预计(\d{1,2}:\d{2})', flight_str)[0]
                    flight_info['前序航班预计到达'] = previous_flight

                    exp = f'前序航班预计{flight_info["前序航班预计到达"]}到达 ' + r'(\d{1,2}:\d{2})'
                    flight_info['计划到达'] = re.findall(exp, flight_str)[0]

            else:
                logger.info("没有前序航班!")

            try:
                exp = '深圳航空' + flight_num + ' ' + r'(\d{1,2}:\d{2})'
                flight_info['计划出发'] = re.findall(exp, flight_str)[0]
            except:
                exp = r' (\d{1,2}:\d{2})'
                flight_info['计划出发'] = re.findall(exp, flight_str)[0]

            flight_info['预计出发'] = re.findall(r' 预计(\d{1,2}:\d{2})', flight_str)[0]
            flight_info['预计到达'] = re.findall(r' 预计(\d{1,2}:\d{2})', flight_str)[1]
            logger.info(flight_info)

            res['flag'] = 'success'
            res['data'] = flight_info
            res['msg'] = '解析数据-成功!'
            logger.info(f"{res['msg']}-{flight_info}")
            return res
        except Exception as e:
            res['msg'] = f'解析数据-异常:{e}'
            logger.info(f'解析数据-异常:{traceback.format_exc()}')
            return res

    # 捕捉元素截图
    def capture_element_screenshot(self, xpath_expression, save_path) -> dict:
        """
        对指定XPath元素进行截图

        Args:
            d: uiautomator2 设备实例
            xpath_expression: XPath表达式
            save_path: 截图保存路径

        Returns:
            bool: 是否成功截图
        """
        res = {'flag': 'end', 'msg': '截图失败!', 'img': None}
        try:
            # 查找元素
            element = self.d.xpath(xpath_expression).get()
            if not element:
                res['msg'] = f"截图失败-未找到元素: {xpath_expression}"
                logger.info(res['msg'])
                return res

            # 获取元素的边界信息
            bounds = element.info['bounds']
            left = bounds['left']
            top = bounds['top']
            right = bounds['right']
            bottom = bounds['bottom']

            if (bottom - top) not in [254, 344]:
                res['msg'] = "截图失败-元素高度不是标准高度跳过"
                logger.info(res['msg'])
                return res

            # 获取整个屏幕的截图
            screenshot = self.d.screenshot()
            # 处理不同版本的返回值
            if hasattr(screenshot, 'save'):  # 已经是 PIL Image 对象
                img = screenshot
            else:  # 是字节数据
                img = Image.open(io.BytesIO(screenshot))

            # 裁剪出元素的区域
            element_img = img.crop((left, top, right, bottom))
            # 将PIL Image保存到内存缓冲区(二进制数据)
            img_byte = io.BytesIO()
            element_img.save(img_byte, format='PNG')
            img_byte_arr = img_byte.getvalue()

            # 从二进制数据解码为OpenCV图像
            image_array = np.frombuffer(img_byte_arr, dtype=np.uint8)
            result_coe = self.check_order_exists(image_array,
                                                 r'yj_template.jpg')
            if result_coe:
                # 保存元素截图
                element_img.save(save_path)
                res['msg'] = f"截图保留成功:{save_path}"
                res['img'] = base64.b64encode(img_byte_arr).decode('utf-8')
                res['flag'] = 'success'
                logger.info(res['msg'])
                return res
            else:
                res['msg'] = "截图失败-不是预警航班!"
                logger.info(res['msg'])
                return res

        except Exception as e:
            logger.info(f"截图失败-运行异常: {traceback.format_exc()}")
            res['msg'] = f"截图失败-运行异常:{e}"
            res['flag'] = "error"
            return res

    @staticmethod
    def check_order_exists(target, template_path, threshold=0.9):
        # 读取图片(灰度模式)
        # target = cv2.imread(target_path, 0)
        target = cv2.imdecode(target, cv2.IMREAD_COLOR)
        template = cv2.imread(template_path, cv2.IMREAD_COLOR)
        logger.info(f"目标图片尺寸: {target.shape}")
        logger.info(f"模板图片尺寸: {template.shape}")
        # 模板匹配
        res = cv2.matchTemplate(target, template, cv2.TM_CCOEFF_NORMED)
        _, max_val, _, _ = cv2.minMaxLoc(res)

        # 判断匹配度是否超过阈值
        return max_val > threshold

    @staticmethod
    def image_to_base64(image_path):
        # 打开图像文件
        img = Image.open(image_path)
        # 创建一个字节流缓冲区
        buffered = BytesIO()
        # 将图像保存到字节流中,格式为PNG
        img.save(buffered, format="PNG")
        # 获取字节流中的字节数据
        img_bytes = buffered.getvalue()
        # 将字节数据编码为Base64字符串
        img_base64 = base64.b64encode(img_bytes).decode('utf-8')
        return img_base64

    # 识别图片信息
    def umi_ocr(self, img_base64):
        res = {'flag': 'end', 'msg': '识别失败!', 'data': None}
        try:
            url = 'http://127.0.0.1:1224/api/ocr'
            # 使用示例
            data = {
                "base64": img_base64,
                "options": {
                    "ocr.language": "models/config_chinese.txt",
                    "ocr.cls": True,
                    "ocr.limit_side_len": 4320,
                    "tbpu.parser": "multi_none",
                    "data.format": "text"
                }
            }
            response = session.post(url, json=data, timeout=10)
            # {'code': 100, 'data': '深圳航空ZH1894 1实际承运CA1894 11:15宝安T3 实际12:40 13:55浦东T2 实际14:39 到达', 'score': 0.9312354241098676, 'time': 0.08330988883972168, 'timestamp': 1758506588.0610397}
            if response.json().get('code') != 100:
                res['msg'] = f'识别失败:{response.json().get("data")}!'
                return res
            res['data'] = response.json()['data']
            res['flag'] = 'success'
            res['msg'] = '识别成功!'
            return res
        except Exception as e:
            res['msg'] = f'识别异常:{e}'
            return res

weditor进行页面分析

# 开启命令
python -m weditor

image-20251110141729666
image-20251110141843864
image-20251110141944608
image-20251110142034416

adb+wifi控制手机

编写的uiautomator2代码运行电脑和手机wifi必须为同一网络

# 查看手机ip

image-20251110142401873

# 先执行  adb 命令 开启端口
adb devices
    List of devices attached
    FA7A51A03802    device
adb -s FA7A51A03802 tcpip 15556
    restarting in TCP mode port: 15556
        
# 然后通过 
d = u2.connect('192.168.90.1:15556') # 就可以完成对手机的连接 

方案总结

需要自己编写控制调度程序,实机维护起来比较麻烦,对一些简单,执行次数较少的项目比较适用

脱离PC方案

准备

使用一些开源工具如: autojs等 完成对手机的自动化操作实现,及任务获取上传

我这里使用的是 hamibot
hamibot实现自动化操作  (注意 是基于autojs二开的项目,有人维护,但是使用要额外付费,源码需要存放在对方服务器) 
	其他库推荐: autox,auto6,autojs pro,EasyClick等
自己编写任务发布接收服务,用于发布和接收手机自动化结果

hamibot代码例子

// hamibot具体操作使用直接参考官网文档就行,写的挺详细的

function main_zh() {
    log('开始执行脚本main_zh')
    var new_task = {
        'tid': '',
        'airline': '',
        'depart': '',
        'arrive': '',
        'rawImg': [],
        'routeStatus': true,
        'msg': '查询失败!',
        'flag': 'fail',
        'robotName': hamibot.robotName
    };
    let key = '通信加密key';
    let packageName = "com.umetrip.android.msky.app"; // 替换为目标应用的包名
    var src, res, regionImg, img_b64, body_json;
    let specialAirport = ['PKX', 'SHA', 'CTU', 'WUX'];
    auto.waitFor();
// log('当前运行设备:' + hamibot.robotName);
// 返回结果
    function result_call(msg) {
        new_task['msg'] = msg;
        response = http.postJson('http://你的服务地址/submitResult', new_task, options = {
            headers: {
                'sing': hasher.hash(key + currentTime, 'md5'),
                "Content-Type": "application/json"
            },
        })
        if (response.statusCode !== 200) {
            log('请求失败: ' + response.statusCode + ' ' + response.statusMessage);
            return '提交结果失败'
        } else {
            log('请求成功: ' + response.statusCode + ' ' + response.statusMessage);
            return '提交结果成功'
        }
        // hamibot.exit(); // 没获得授权,退出
    }

    function closeApp(appName) {
        // 1. 获取应用包名
        var name = getPackageName(appName); // 尝试通过应用名称获取包名
        if (!name) {
            // 如果未找到包名
            if (getAppName(appName)) {
                // 检查参数是否为包名
                name = appName; // 确认参数是包名
            } else {
                return false; // 应用不存在返回失败
            }
        }
        // 2. 进入应用设置页
        app.openAppSetting(name); // 打开应用详情页
        text(app.getAppName(name)).waitFor(); // 等待应用名称出现确认界面跳转
        sleep(1500); // 界面加载延迟(可选)

        // 3. 查找关闭控件
        images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
        sleep(2000);
        res = ocr.recognizeText(images.read('/storage/emulated/0/fullScreenImg.png'));
        log("识别结果: " + res)
        if (res.indexOf('强行停止') !== -1) {
            log("识别结果: " + res)
            // 检查控件是否可用
            click(590, 503);
            sleep(2500);
            // 4. 处理确认弹窗
            textMatches(/(.*确.*|.*定.*)/).findOne().click(); // 确认关闭操作
            log(app.getAppName(name) + ' 已关闭');
            sleep(1000);
            back(); // 返回上级界面
        } else {
            log('无法关闭 ' + app.getAppName(name));
            back();
        }
    }

// 获取当前时间并格式化为 YYYYMMDDHH 格式
    function getCurrentTime() {
        const now = new Date();
        const year = now.getFullYear();
        // 月份要+1,因为getMonth()返回0-11
        const month = (now.getMonth() + 1).toString().padStart(2, '0');
        const day = now.getDate().toString().padStart(2, '0');
        const hours = now.getHours().toString().padStart(2, '0');
        // 组合成 YYYYMMDDHH 格式
        return year + month + day + hours;
    }

// 获取后台任务 航线状态
    const currentTime = getCurrentTime();
    var response = http.get('http://你的服务地址/getTask?robotName=' + hamibot.robotName, {
        headers: {
            'sing': hasher.hash(key + currentTime, 'md5'),
        },
    });
    if (response.statusCode !== 200) {
        log('请求任务失败: ' + response.statusCode + ' ' + response.statusMessage);
        // hamibot.exit(); // 没获得授权,退出
        return '获取任务失败'
    } else {
        body_json = response.body.json()
        log(body_json);
        if (body_json['flag'] === 'success') {
            new_task['airline'] = body_json['task']['航司']
            new_task['depart'] = body_json['task']['出发']
            new_task['arrive'] = body_json['task']['到达']
            new_task['tid'] = body_json['tid']
            log('获取任务成功: ' + body_json['tid']);
        } else {
            new_task['flag'] = 'fail';
            new_task['msg'] = '请求任务失败!';
            log('任务数据异常');
            log(response.body.json());
            // hamibot.exit(); // 没获得授权,退出
            return '任务数据异常!'
        }
    }
    if (!new_task['depart'] || !new_task['arrive']) {
        log('任务数据异常2: ' + response.body.json());
        // hamibot.exit(); // 没获得授权,退出
        return '任务数据异常2!'
    }

    if (app.launchPackage(packageName)) {
        log("应用启动成功");
    } else {
        log("应用启动失败");
        result_call('应用启动失败!')
//     hamibot.exit(); // 没获得授权,退出
    }
// 启用线程,点击截图确认按钮
    threads.start(function () {
        const check = () => {
            // OPPO ColorOS 适配
            click('立即开始');
            setTimeout(function () {
                check();
            }, 500);
        };
        check();
    });
    if (!requestScreenCapture()) {
        // hamibot.exit(); // 没获得授权,退出
    }
    threads.shutDownAll(); // 结束线程
    sleep(2000); // 可选,短暂延迟,避免截图包含确认框

// hamibot.exit(); // 没获得授权,退出
// // 针对第一次打开应用的一些列操作
    images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
    res = ocr.recognizeText(images.read('/storage/emulated/0/fullScreenImg.png'));
// log(res);
// 注册认证航旅纵横后
    if (res.indexOf('注册认证航旅纵横后') !== -1) {
        log("第一次APP");
        sleep(2000);
        click('同意');
        sleep(2000);
        click(349, 1086);
        sleep(5000);
        click(429, 771);
        sleep(5000);
        click('残忍拒绝');
    }

// 循环3次 检查动态
    var foundDynamic = false;
    for (let i = 0; i < 3; i++) {
        // 每次循环都重新获取截图,避免图片被回收
        sleep(1000);
        images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
        log('开始识别,尝试 ' + (i + 1));
        // 安全地裁剪图片
        sleep(1000);
        try {
            src = images.read('/storage/emulated/0/fullScreenImg.png');
            regionImg = images.clip(src, 465, 1242, 80, 36);
            images.save(regionImg, '/storage/emulated/0/regionImg.png', 'png', 100);
            if (!regionImg) {
                log("图片裁剪失败");
                sleep(1000);
                continue;
            }
        } catch (e) {
            log("图片裁剪错误: " + e);
            sleep(1000);
            continue;
        }
        // 识别文本 需要等图片保存好 不然识别为空
        sleep(2500);
        res = ocr.recognizeText(regionImg);
        log(res);
        // 检查是否包含"动态"
      	 if (res.indexOf('立即升级') !== -1) {
            log("点击立即升级");
            longClick(329, 843);
            sleep(5000);
        }
        if (res.indexOf('动态') !== -1) {
            log("点击动态");
            longClick(503, 1231);
            foundDynamic = true;
            break; // 跳出循环
        } else {
            log("动态按钮未加载,等待1秒后重试");
            sleep(1000); // 等待1秒
        }
        // 回收图片资源
        if (regionImg) {
            try {
                regionImg.recycle();
            } catch (e) {
                // 忽略回收错误
            }
        }
    }
    if (!foundDynamic) {
        log("4次尝试后动态按钮仍未加载");
        // 使用示例
        closeApp(packageName);
        result_call('4次尝试后动态按钮仍未加载!')
        // hamibot.exit(); // 没获得授权,退出
    }
    sleep(2000);
    log("点击起降地");
    longClick(358, 177);
    sleep(5000);
    log("点击出发");
    longClick(113, 306);
    sleep(5000);
    log("输入框");
    longClick(340, 128);
    sleep(5000);
    log("输入文本");
    setText(new_task['depart']);
    sleep(1000);
    longClick(583, 128);
    sleep(5000);
    log("点击第一个元素"); // 双机场要特殊处理
    if (specialAirport.includes(new_task['depart'])) {
        longClick(583, 289);
    } else {
        longClick(583, 209);
    }
    sleep(2500);
    log("点击降落");
    longClick(609, 302);
    sleep(2000);
    log("输入框");
    longClick(340, 128);
    sleep(3000);
    log("输入文本");
    setText(new_task['arrive']);
    sleep(1000);
    longClick(583, 134);
    sleep(5000);
    log("点击第一个元素");  // 双机场要特殊处理
    if (specialAirport.includes(new_task['arrive'])) {
        longClick(583, 289);
    } else {
        longClick(583, 209);
    }
    sleep(3000);
    log("点击查询");
    longClick(346, 614);
    sleep(2500);
// 判读设备是否被封
    images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
    src = images.read('/storage/emulated/0/fullScreenImg.png');
    sleep(1000);
    res = ocr.recognizeText(src);
// log('查看设备是否被封:', res);
// log(res);
    if (res.indexOf('超过限制') !== -1) {
        log("设备被封,请重试!");
        // hamibot.exit(); // 没获得授权,退出
        closeApp(packageName);
        result_call('设备被封,请重试!')
    }
    if (res.indexOf('残忍拒绝') !== -1) {
        log("设备被封,请重试!");
        log("设备被封,请重试!");
        click('残忍拒绝');
        sleep(1000)
        closeApp(packageName);
        result_call('点击残忍拒绝,请重试!')
    }
    sleep(1000);
    log("点击筛选");
    longClick(196, 1182);
    sleep(4500);
    images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
// 安全地裁剪图片
    sleep(1000);
    try {
        src = images.read('/storage/emulated/0/fullScreenImg.png');
        regionImg = images.clip(src, 465, 1242, 80, 36);
        images.save(regionImg, '/storage/emulated/0/regionImg.png', 'png', 100);
        if (!regionImg) {
            log("图片裁剪失败");
            sleep(1000);
        }
    } catch (e) {
        log("图片裁剪错误: " + e);
        sleep(1000);
    }
// 识别文本 需要等图片保存好 不然识别为空
    sleep(2500);
    res = ocr.recognizeText(regionImg);
    log(res);
// 检查是否包含"动态"
    if (res.indexOf('动态') !== -1) {
        log("还是出现动态");
        closeApp(packageName);
        new_task['routeStatus'] = true;
        images.save(captureScreen(), '/storage/emulated/0/输入出发到达机场失败.png', 'png', 100);
        result_call('输入出发到达机场失败!');
    }
// 滑动点击深圳航空
    var zh_flag = false;
    var templ = images.read('/storage/emulated/0/zh.png');
    for (let i = 0; i < 3; i++) {
        images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
        src = images.read('/storage/emulated/0/fullScreenImg.png');
        var p = findImage(src, templ);
        if (p) {
            log('找到啦:' + p);
            click(p.x + 5, p.y + 5);
            sleep(1000); // 可选,短暂延迟,避免截图包含确认框
            click(531, 1150);
            zh_flag = true;
            break; // 跳出循环
        } else {
            log('没找到滑动一页');
            swipe(413, 1065, 413, 614, 1000);
            sleep(1000);
        }

    }
    sleep(2500);
// 判断有没有 可用航班信息  计划 预计  无用航班信息 延误 到达 取消
    if (!zh_flag) {
        log("筛选没有ZH航班!");
        closeApp(packageName);
        result_call("筛选没有ZH航班!")
    }

// 循环6次 预计航班
    let templ_plan = images.read('/storage/emulated/0/预警模版.png');
    for (let i = 0; i < 4; i++) {
        // 每次循环都重新获取截图,避免图片被回收
        sleep(1000);
        images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
        log('开始识别,尝试 ' + (i + 1));
        // 安全地裁剪图片
        sleep(1000);
        try {
            src = images.read('/storage/emulated/0/fullScreenImg.png');
            regionImg = images.clip(src, 582, 331, 111, 795);
            images.save(regionImg, '/storage/emulated/0/regionImg.png', 'png', 100);
            if (!regionImg) {
                log("图片裁剪失败");
                sleep(1000);
                continue;
            }
        } catch (e) {
            log("图片裁剪错误: " + e);
            sleep(1000);
            continue;
        }
        // 识别文本 需要等图片保存好 不然识别为空
        sleep(2500);
        res = ocr.recognizeText(regionImg);
        log(res);
        // 检查是否包含计划 预警 计划
        if (res.indexOf('计划') !== -1 || res.indexOf('预警') !== -1) {
            new_task['routeStatus'] = true;
            log("存在计划航班,航线有效");
            // 单独处理预警航班信息
            if (res.indexOf('预警') !== -1) {
                // 测试 预警模版位置 截取此航班信息进行ocr识别 待处理
                images.save(captureScreen(), '/storage/emulated/0/fullScreenImg.png', 'png', 100);
                sleep(500);
                src = images.read('/storage/emulated/0/fullScreenImg.png');
                sleep(2500);
                var p2 = images.matchTemplate(src, templ_plan)
                // 修正部分:正确使用 MatchingResult 对象
                if (p2 != null && p2.points.length > 0) {
                    // 方法1:使用 points 属性遍历坐标点
                    p2.points.forEach(function (point, index) {
                        log('匹配点' + index + ': 坐标(' + point.x + ', ' + point.y + ')');
                        log('匹配点' + index + ': 坐标(' + point.x + ', ' + point.y + ')');
                        regionImg = images.clip(src, 0, point.y - 73, 681, 231);
                        images.save(regionImg, '/storage/emulated/0/预警' + i + index + '.png', 'png', 100);
                        img_b64 = images.toBase64(regionImg, "png", 100)
                        sleep(2000);
                        // 添加重复检查 - 方法1: 使用 includes 检查
                        if (!new_task['rawImg'].includes(img_b64)) {
                            new_task['rawImg'].push(img_b64);
//                         log(img_b64);
                            log('添加了新的图片,当前数量:' + new_task['rawImg'].length);
                        } else {
                            log('跳过重复图片');
                        }
                    });
                    // 计算坐标进行截图
                    // 处理完成进行翻页处理
                    log('处理完预计滑动一页');
                    swipe(343, 1128, 343, 668, 1000);
                    sleep(1000);
                } else {
                    log('没找到滑动一页');
                    swipe(343, 1128, 343, 668, 1000);
                    sleep(1000);
                }
            } else {
                log('当前页无预警滑动一页');
                swipe(343, 1128, 343, 668, 1000);
            }
        } else {
            log("未发现可用航班信息,滑动一页");
            break
        }
        // 回收图片资源
        if (regionImg) {
            try {
                regionImg.recycle();
            } catch (e) {
                // 忽略回收错误
            }
        }
    }
    if (!new_task['routeStatus']) {
        log("无可用航班信息,废除此航线!");
        closeApp(packageName);
        result_call("无可用航班信息,废除此航线!")
    }
    new_task['flag'] = 'success';
    log('任务id:' + new_task['tid'] + '查询完成!');
    closeApp(packageName)
    result_call('查询完成!');

};
main_zh();

任务调度原理

hamibot脚本预估执行时间,如1分钟执行完,在每次脚本开始,请求自己的任务发布服务(ip需要再公网可调用)获取任务,脚本执行完返回结果给自己服务,成任务的调用

方案总结

可以脱离对PC的依赖,通过自动化框架的app执行脚本,完成对任务的调度
缺点 hamibot源码在对方服务器保存,某些实现并不好用,某些功能可能要额外付费,可以尝试其他自动化框架进行替换
优点 有人维护项目比较稳定,文档比较详细清晰

此种方案可以在实机部署,也可以在云手机部署,灵活度更高,可以胜任更复杂的项目
posted @ 2025-11-10 15:03  郭楷丰  阅读(7)  评论(1)    收藏  举报
Live2D