autool/utils_android/download_app/google_play_downloader.py
2026-06-17 19:44:18 +08:00

649 lines
25 KiB
Python

# -*- 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