# -*- encoding=utf8 -*- import os import sys import time import logging from typing import Tuple from airtest.core.api import * from airtest.core.android import * from poco.drivers.android.uiautomation import AndroidUiautomationPoco from poco.exceptions import PocoTargetTimeout, PocoNoSuchNodeException sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, sys.path[0].rsplit('\\', 3)[0]) from adb_helper import ADBHelper from config_loader import load_config as load_autool_config from result_codes import DownloadError from utils_android.device_config import build_airtest_android_uri, ensure_android_wireless_connected, get_android_device_serial logger = logging.getLogger(__name__) class GooglePlayDownloader: GOOGLE_PLAY_PACKAGE = "com.android.vending" APP_INCOMPATIBLE_TEXTS = ( "This app is available only for your other devices", "Your device isn't compatible with this version.", ) ITEM_NOT_FOUND_TEXT = "Item not found" ITEM_NOT_FOUND_IMAGE = "item_not_found.png" ITEM_NOT_FOUND_IMAGE_THRESHOLD = 0.7 TRY_AGAIN_TEXT = "Try again" GOOGLE_PLAY_WEB_NOT_FOUND_PATTERNS = ( "item not found", "requested url was not found", "the requested url was not found", "url was not found on this server", ) PRIMARY_ACTION_TEXTS = ("Install", "Update", "Open", "Play") GOOGLE_PLAY_PRECHECK_TIMEOUT = 60 GOOGLE_PLAY_PRECHECK_REQUEST_TIMEOUT = 10 GOOGLE_PLAY_PRECHECK_RETRY_INTERVAL = 2 CURL_HTTP_CODE_MARKER = "__CURL_HTTP_CODE__:" GOOGLE_PLAY_OPEN_TIMEOUT = 60 GOOGLE_PLAY_ACTION_TIMEOUT = 30 ERROR_MAP = { 'app not available': DownloadError.REGION_RESTRICTED, 'account banned': DownloadError.ACCOUNT_BANNED, 'app not found': DownloadError.APP_NOT_FOUND, 'app incompatible': DownloadError.INCOMPATIBLE, 'install overtime': DownloadError.DOWNLOAD_TIMEOUT, 'jump timeout': DownloadError.DOWNLOAD_TIMEOUT, 'timeout': DownloadError.DOWNLOAD_TIMEOUT, 'jump failed': DownloadError.NETWORK_ERROR, 'app page load failed': DownloadError.NETWORK_ERROR, 'click download button failed': DownloadError.INSTALL_FAILED, 'install success': None, 'app already installed': None, } def __init__(self): self.poco = None self.adb_helper = None self._device_curl_available = None def _map_error(self, state: str) -> Tuple[bool, DownloadError]: """将字符串状态映射为 DownloadError""" if state in ('install success', 'app already installed'): return True, None error = self.ERROR_MAP.get(state, DownloadError.OTHER) return False, error def _ensure_poco(self): if self.poco is None: try: config = load_autool_config() serial = get_android_device_serial(config) ensure_android_wireless_connected(config, serial) auto_setup( __file__, devices=[ build_airtest_android_uri( config, serial=serial, cap_method="ADBCAP", ori_method="ADBORI", ) ] ) # Start poco service if needed start_app("com.netease.open.pocoservice") sleep(2) self.poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False) except Exception as e: logger.error(f"Poco initialization failed: {e}") def _ensure_adb_helper(self): if self.adb_helper is None: self.adb_helper = ADBHelper() return self.adb_helper def is_account_banned(self): """检测账号是否被封禁""" ban_texts = [ "Authentication is required. You need to sign in to your Google Account.", ] for text in ban_texts: if self.poco(text=text).exists(): logger.warning(f"APP Downloader: Account ban detected - '{text}'") return True return False def is_app_incompatible(self, package=None): """检测应用是否不兼容当前设备""" for incompatible_text in self.APP_INCOMPATIBLE_TEXTS: query = {"text": incompatible_text} if package: query["package"] = package if self.poco(**query).exists(): logger.warning( "APP Downloader: App incompatible with current device: '%s'", incompatible_text, ) return True return False def is_try_again_page_load_failed(self, package=None): query = {"text": self.TRY_AGAIN_TEXT} if package: query["package"] = package if self.poco(**query).exists(): logger.warning("APP Downloader: Try again detected, treating as page load failed") return True return False def is_app_not_available_by_error_image(self): picture_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), self.ITEM_NOT_FOUND_IMAGE, ) tpl = Template( picture_path, threshold=self.ITEM_NOT_FOUND_IMAGE_THRESHOLD, rgb=True, ) if exists(tpl): logger.warning( "APP Downloader: Google Play region-restricted error illustration detected" ) return True return False def is_item_not_found(self, package=None): query = {"text": self.ITEM_NOT_FOUND_TEXT} if package: query["package"] = package if self.poco(**query).exists(): logger.warning("APP Downloader: Item not found detected") return True return False def _get_control_y_position(self, control): try: position = control.get_position() except Exception: return None if not position or len(position) < 2: return None return position[1] def _build_google_play_url(self, package_name, country=None): url = f"https://play.google.com/store/apps/details?id={package_name}&hl=en" if country: url += f"&gl={country}" return url def _run_device_curl_precheck(self, url): output = self._ensure_adb_helper().shell( [ "curl", "-L", "--max-time", str(self.GOOGLE_PLAY_PRECHECK_REQUEST_TIMEOUT), "-A", ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "-H", "Accept-Language: en-US,en;q=0.9", "-sS", url, "-w", f"\n{self.CURL_HTTP_CODE_MARKER}%{{http_code}}\n", ] ) return str(output or "") def _has_device_curl(self): if self._device_curl_available is not None: return self._device_curl_available output = self._ensure_adb_helper().shell(["sh", "-c", "command -v curl || which curl || true"]) self._device_curl_available = bool(str(output or "").strip()) if not self._device_curl_available: logger.warning( "APP Downloader: Device has no curl; skipping Google Play webpage precheck" ) return self._device_curl_available def _parse_device_curl_output(self, output): http_code = "" body_lines = [] for line in str(output or "").splitlines(): if line.startswith(self.CURL_HTTP_CODE_MARKER): http_code = line[len(self.CURL_HTTP_CODE_MARKER):].strip() continue body_lines.append(line) return http_code, "\n".join(body_lines) def precheck_google_play_webpage( self, package_name, country=None, timeout=GOOGLE_PLAY_PRECHECK_TIMEOUT, ): """在拉起浏览器/Google Play 前,使用设备侧 curl 阻塞式检查网页是否可访问或为 not found 页面""" url = self._build_google_play_url(package_name, country) if not self._has_device_curl(): return True, "precheck skipped: device curl unavailable" deadline = time.time() + timeout last_error = "device curl precheck returned empty output" while time.time() < deadline: try: logger.info(f"APP Downloader: Prechecking Google Play webpage {url}") output = self._run_device_curl_precheck(url) except Exception as exc: last_error = exc logger.warning( "APP Downloader: Device-side Google Play precheck attempt failed, will retry until timeout: %s", exc, ) if time.time() >= deadline: break sleep(self.GOOGLE_PLAY_PRECHECK_RETRY_INTERVAL) continue http_code, page_text = self._parse_device_curl_output(output) page_text_lower = page_text.lower() if http_code == "404" or any( pattern in page_text_lower for pattern in self.GOOGLE_PLAY_WEB_NOT_FOUND_PATTERNS ): logger.info("APP Downloader: Google Play webpage reports app not found") return False, "app not found" if http_code: logger.info( "APP Downloader: Device-side Google Play webpage precheck passed with status %s", http_code or "200", ) return True, "precheck ok" last_error = output or "device curl returned no HTTP status marker" logger.warning( "APP Downloader: Device-side Google Play precheck attempt failed with http=%s; " "will retry until timeout", http_code or "unknown", ) if time.time() >= deadline: break sleep(self.GOOGLE_PLAY_PRECHECK_RETRY_INTERVAL) logger.error( "APP Downloader: Device-side Google Play webpage precheck timed out after %ss: %s", timeout, last_error, ) return False, "timeout" def _get_primary_action_button(self, action_names=None): best_candidate = None best_sort_key = None for action_index, action_name in enumerate(action_names or self.PRIMARY_ACTION_TEXTS): controls = self.poco(text=action_name, package=self.GOOGLE_PLAY_PACKAGE) if not controls.exists(): continue try: candidates = list(controls) except TypeError: candidates = [controls] for control in candidates: y_pos = self._get_control_y_position(control) sort_key = ( y_pos if y_pos is not None else float("inf"), action_index, ) if best_sort_key is None or sort_key < best_sort_key: best_sort_key = sort_key best_candidate = (action_name, control) if best_candidate is not None: return best_candidate return None, None def _wait_for_google_play_ready( self, timeout=GOOGLE_PLAY_ACTION_TIMEOUT, ): deadline = time.time() + timeout while time.time() < deadline: if self.poco(text="This item isn't available in your country.", package=self.GOOGLE_PLAY_PACKAGE).exists(): logger.info("APP Downloader: This item isn't available in your country!") return False, 'app not available' if self.is_app_not_available_by_error_image(): return False, 'app not available' if self.is_item_not_found(package=self.GOOGLE_PLAY_PACKAGE) or self.is_item_not_found(): return False, 'app not found' if self.is_try_again_page_load_failed(package=self.GOOGLE_PLAY_PACKAGE) or self.is_try_again_page_load_failed(): return False, 'app page load failed' if self.is_app_incompatible(package=self.GOOGLE_PLAY_PACKAGE) or self.is_app_incompatible(): return False, 'app incompatible' action_name, action_button = self._get_primary_action_button() if action_button is not None: logger.info( "APP Downloader: Google Play detail page ready with %s text", action_name, ) return True, action_name if self.is_account_banned(): return False, 'account banned' sleep(1) logger.warning( "APP Downloader: Google Play opened but no primary action text appeared before timeout" ) return False, 'app page load failed' def click_download_button(self): """点击下载按钮,只处理下载逻辑,不处理账号切换""" # Check regional availability if self.poco(text="This item isn't available in your country.", package=self.GOOGLE_PLAY_PACKAGE).exists(): logger.info("APP Downloader: This item isn't available in your country!") return False, 'app not available' if self.is_app_not_available_by_error_image(): return False, 'app not available' # Check if account is banned if self.is_account_banned(): logger.warning("APP Downloader: Account banned") return False, 'account banned' # Wait for buttons try: logger.info("APP Downloader: Waiting for app page to load...") ready, state = self._wait_for_google_play_ready( timeout=self.GOOGLE_PLAY_ACTION_TIMEOUT, ) if not ready: return False, state if self.is_app_incompatible(package=self.GOOGLE_PLAY_PACKAGE) or self.is_app_incompatible(): return False, 'app incompatible' action_name, action_button = self._get_primary_action_button(("Open", "Play")) if action_button is not None or self.poco(text="Uninstall", package=self.GOOGLE_PLAY_PACKAGE).exists(): if self.is_account_banned(): logger.warning("APP Downloader: Account banned before installed-state success") return False, 'account banned' return True, 'app already installed' action_name, action_button = self._get_primary_action_button(("Update", "Install")) if action_button is None: return False, 'click download button failed' if action_name == "Update": logger.info("APP Downloader: Found Update button, clicking...") action_button.click() sleep(3) self.deal_exception() return self.wait_for_download() if action_name == "Install": action_button.click() sleep(3) self.deal_exception() return self.wait_for_download() except Exception as e: logger.error(f"Error checking buttons: {e}") return False, 'app page load failed' return False, 'click download button failed' def deal_exception(self): """Handle common popups""" ins_text_list = ['Skip','No thanks','Not now','Continue','Accept','Got it','No','Yes, it\'s me'] for ins_text in ins_text_list: if self.poco(text=ins_text).exists(): logger.info(f"APP Downloader: Handling popup: {ins_text}") self.poco(text=ins_text).click() sleep(3) def wait_for_download(self): """Wait for the download to complete""" retry_times_max = 60 # 10 minutes for _ in range(retry_times_max): # Check if still downloading (Cancel button exists) if self.poco(name='Cancel', package=self.GOOGLE_PLAY_PACKAGE).exists(): logger.info("APP Downloader: Still downloading...") sleep(10) continue if self.is_app_not_available_by_error_image(): return False, 'app not available' if self.is_item_not_found(package=self.GOOGLE_PLAY_PACKAGE) or self.is_item_not_found(): return False, 'app not found' if self.is_try_again_page_load_failed(package=self.GOOGLE_PLAY_PACKAGE) or self.is_try_again_page_load_failed(): return False, 'app page load failed' if self.is_app_incompatible(package=self.GOOGLE_PLAY_PACKAGE) or self.is_app_incompatible(): return False, 'app incompatible' # Check for success indicators action_name, action_button = self._get_primary_action_button(("Open", "Play")) if action_button is not None: if self.is_account_banned(): logger.warning("APP Downloader: Account banned before download-complete success") return False, 'account banned' logger.info("APP Downloader: Download complete") return True, 'install success' sleep(10) self.deal_exception() return False, 'install overtime' def intent_jump_to_google_play(self, package_name, country=None): """Open Google Play via Intent""" url = self._build_google_play_url(package_name, country) logger.info(f"APP Downloader: Opening Google Play via intent: {url}") shell(f'am start -a android.intent.action.VIEW -d "{url}"') sleep(3) deadline = time.time() + self.GOOGLE_PLAY_OPEN_TIMEOUT while time.time() < deadline: top_app = shell("dumpsys window | grep mCurrentFocus") if self.GOOGLE_PLAY_PACKAGE in str(top_app): logger.info("APP Downloader: Switched to Google Play") remaining_timeout = max(1, deadline - time.time()) return self._wait_for_google_play_ready( timeout=remaining_timeout, ) sleep(1) return False, "jump timeout" def start(self, package_name, country=None, max_retry=3, target_account=None, **kwargs): """ Main entry point for Google Play Downloader :param max_retry: 最大重试次数(账号被封时切换账号重试) :param target_account: 目标账号邮箱(可选),如果提供则切换到指定账号 """ self._ensure_poco() logger.info(f"Starting Google Play download for {package_name}") retry_count = 0 while retry_count < max_retry: # 1. 打开 Google Play 页面 jump_result = self.intent_jump_to_google_play(package_name, country) if isinstance(jump_result, tuple): success, error_state = jump_result if not success: logger.error(f"Failed to jump to Google Play: {error_state}") return False, error_state # 2. 点击下载 success, state = self.click_download_button() # 3. 处理结果 if success: # 下载成功 stop_app(self.GOOGLE_PLAY_PACKAGE) return True, state if state == 'account banned': # 账号被封,尝试切换 logger.warning(f"APP Downloader: Account banned, switching account (retry {retry_count + 1}/{max_retry})") switch_success, switch_msg = self.switch_google_account(target_account=target_account) if not switch_success: logger.error(f"APP Downloader: Failed to switch account: {switch_msg}") stop_app(self.GOOGLE_PLAY_PACKAGE) return False, f'account banned, switch failed: {switch_msg}' # 切换成功,关闭应用准备重试 logger.info("APP Downloader: Account switched, will retry...") stop_app(self.GOOGLE_PLAY_PACKAGE) sleep(2) retry_count += 1 continue # 其他错误(非账号问题),直接返回 stop_app(self.GOOGLE_PLAY_PACKAGE) return False, state # 超过最大重试次数 stop_app(self.GOOGLE_PLAY_PACKAGE) return False, 'max retry exceeded' def switch_google_account(self, target_account=None): """ 切换谷歌账号 :param target_account: 目标账号邮箱(可选),如果提供则切换到指定账号,否则随机选择一个 :return: (bool, str) - (是否成功, 状态信息) """ try: self._ensure_poco() logger.info("APP Downloader: Starting Google account switch...") # 1. 点击右上角账号头像按钮 touch((486, 42)) sleep(3) # 2. 点击 "Switch account" if not self.poco(text="Switch account").exists(): return False, "Switch account button not found" self.poco(text="Switch account").click() sleep(2) # 3. 获取所有账号 accounts = self.poco(textMatches=".*@gmail\\.com.*") if not accounts.exists(): return False, "No accounts found" account_list = accounts.get_text() if isinstance(account_list, str): account_list = [account_list] logger.info(f"APP Downloader: Available accounts: {account_list}") if len(account_list) == 0: return False, "Empty account list" # 4. 选择账号 if target_account: # 指定了账号:匹配并点击 target = None for acc in account_list: if target_account.lower() in acc.lower(): target = acc break if not target: return False, f"Account '{target_account}' not found" else: # 未指定:随机选择(排除第一个) import random if len(account_list) > 1: target = random.choice(account_list[1:]) else: target = account_list[0] # 5. 点击选中的账号 self.poco(text=target).click() logger.info(f"APP Downloader: Switched to {target}") sleep(3) return True, f"switched to {target}" except Exception as e: logger.error(f"APP Downloader: Account switch failed: {e}") return False, f"account switch failed: {str(e)}" def dump_ui_tree(self, save_path=None): """ 打印当前所有 UI 控件,用于调试 :param save_path: 可选,保存到文件路径 """ try: self._ensure_poco() logger.info("=" * 60) logger.info("APP Downloader: Dumping UI tree...") logger.info("=" * 60) # 获取所有控件 all_nodes = self.poco() output_lines = [] for node in all_nodes: try: # 获取控件属性 - attr() 需要传入属性名 name = node.attr('name') or '' text = node.attr('text') or '' desc = node.attr('content-desc') or node.attr('desc') or '' node_type = node.attr('type') or '' pos = node.attr('pos') or '' line = f"[{node_type}] name={name}, text='{text}', desc='{desc}', pos={pos}" output_lines.append(line) logger.info(line) except Exception as node_e: logger.warning(f"APP Downloader: Failed to get node info: {node_e}") logger.info("=" * 60) logger.info(f"APP Downloader: Total nodes: {len(output_lines)}") logger.info("=" * 60) # 保存到文件 if save_path: with open(save_path, 'w', encoding='utf-8') as f: f.write('\n'.join(output_lines)) logger.info(f"APP Downloader: UI tree saved to {save_path}") return output_lines except Exception as e: logger.error(f"APP Downloader: Failed to dump UI tree: {e}") return [] def stop(self): """Stop the poco instance""" if self.poco: try: self.poco.stop_running() logger.info("Google Play Downloader: Poco stopped") except Exception as e: logger.error(f"Error stopping poco: {e}") finally: self.poco = None if __name__ == "__main__": pass