#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 美国下载PC定时脚本 每 N 分钟从 MinIO 拉取下载任务列表,批量下载 → 导出APK → 上传到 MinIO, 并写入下载结果供中控读取。下载失败的包记录详细原因,汇总为报告定期上传。 多设备下载策略: - 第一次在模拟器A(默认 Google Play)上尝试下载 - 如果失败,自动回退到模拟器B(Aurora Store)再次尝试 - 如果仍然失败,最终回退到 APKPure HTTP 直连下载 用法: python us_download_worker.py python us_download_worker.py --export-dir D:\\mumu_apk_export --interval 600 python us_download_worker.py --once """ from __future__ import annotations import json import logging import os import shutil import subprocess import sys import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from typing import Any, Dict, List, Optional PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) from config import ( APK_EMULATOR_CLEANUP_ENABLED, APK_US_DEVICE_A_SERIAL, APK_US_DEVICE_A_VM_INDEX, APK_US_DEVICE_B_SERIAL, APK_US_DEVICE_B_VM_INDEX, APK_US_EXPORT_DIR, MUMU_MANAGER_PATH, ) from apk_cloud.storage import MinioStorage from apk_cloud.android_google_play_downloader import ( GooglePlayDownloader, STORE_GOOGLE_PLAY, STORE_AURORA, ) from apk_cloud.apkpure_downloader import download_from_apkpure, DOWNLOAD_DELAY_SECONDS logger = logging.getLogger("us_download_worker") _DEFAULT_POLL_SECONDS = 60 _EMULATOR_CLEANUP_INTERVAL = 3600 _FAILURE_REPORT_KEY = "download-reports/failures.json" _HISTORY_FILE = "download_history.json" _MAX_PIPELINE_WORKERS = 8 _APKPURE_MAX_CONCURRENT = 3 _device_a_lock = threading.Lock() _device_b_lock = threading.Lock() _apkpure_semaphore = threading.Semaphore(_APKPURE_MAX_CONCURRENT) _history_lock = threading.Lock() US_DEVICE_A_SERIAL = APK_US_DEVICE_A_SERIAL US_DEVICE_B_SERIAL = APK_US_DEVICE_B_SERIAL # 设备串号 → MuMu VM 编号 _US_SERIAL_VM_MAP = { US_DEVICE_A_SERIAL: APK_US_DEVICE_A_VM_INDEX, US_DEVICE_B_SERIAL: APK_US_DEVICE_B_VM_INDEX, } _US_EMULATOR_RESTART_LOCK = threading.Lock() _US_EMULATOR_RESTART_COOLDOWN: Dict[str, float] = {} def _is_adb_device_offline_error(error_text: str) -> bool: if not error_text: return False lowered = error_text.lower() return "device" in lowered and "not found" in lowered def _restart_emulator_if_needed(serial: str, reason: str) -> bool: vm_index = _US_SERIAL_VM_MAP.get(serial) if not vm_index: return False if not _is_adb_device_offline_error(reason): return False now = time.time() with _US_EMULATOR_RESTART_LOCK: last = _US_EMULATOR_RESTART_COOLDOWN.get(serial, 0) if now - last < 120: logger.info("[Emulator] %s 在冷却期内(%.0fs前刚重启),跳过本次重启", serial, now - last) return False _US_EMULATOR_RESTART_COOLDOWN[serial] = now logger.info("[Emulator] 检测到 ADB 断链 (%s),正在重启模拟器 VM-%d ...", serial, vm_index) manager = MUMU_MANAGER_PATH.strip() if not os.path.isfile(manager): logger.warning("[Emulator] MuMuManager.exe 不存在: %s", manager) return False try: subprocess.run( [manager, "control", "-v", str(vm_index), "shutdown"], capture_output=True, timeout=30, ) time.sleep(10) subprocess.run( [manager, "control", "-v", str(vm_index), "restart"], capture_output=True, timeout=30, ) time.sleep(30) subprocess.run(["adb", "disconnect", serial.split(":")[0]], capture_output=True, timeout=10) time.sleep(5) subprocess.run(["adb", "connect", serial], capture_output=True, timeout=15) time.sleep(10) logger.info("[Emulator] VM-%d 重启完成,ADB 已重连", vm_index) return True except subprocess.TimeoutExpired as exc: logger.warning("[Emulator] VM-%d 重启命令超时: %s", vm_index, exc) return False except Exception as exc: logger.warning("[Emulator] VM-%d 重启失败: %s", vm_index, exc) return False # ── Download History ──────────────────────────────────────────────── def _load_history(export_dir: str) -> Dict[str, Dict[str, str]]: path = os.path.join(export_dir, _HISTORY_FILE) if not os.path.isfile(path): return {} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else {} except (json.JSONDecodeError, IOError): return {} def _save_history(export_dir: str, history: Dict[str, Dict[str, str]]) -> None: path = os.path.join(export_dir, _HISTORY_FILE) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) os.replace(tmp, path) def _is_history_fresh(history: Dict[str, Dict[str, str]], package_name: str, last_updated: str) -> bool: entry = history.get(package_name) if not entry: return False hist_updated = str(entry.get("last_updated") or "") hist_date = str(entry.get("download_date") or "") if not hist_date: return False if hist_updated != last_updated: return False return True def _record_history(history: Dict[str, Dict[str, str]], package_name: str, last_updated: str, download_date: str = "", version_name: str = "") -> None: history[package_name] = { "last_updated": last_updated, "download_date": download_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "version_name": version_name or "", } def download_and_export(package_name: str, export_dir: str, downloader: GooglePlayDownloader) -> tuple: """返回 (result_dict_or_None, failure_reason_or_None)。""" try: if downloader.is_installed(package_name): logger.info("Already installed, skip download: %s", package_name) return _export_only(package_name, export_dir, downloader), None logger.info("Downloading: %s (device=%s)", package_name, downloader._serial or "default") success, message = downloader.start(package_name) if not success: reason = str(message or "unknown download error").strip() logger.warning("Download failed: %s reason=%s", package_name, reason) return None, reason logger.info("Download succeeded: %s", package_name) result = _export_only(package_name, export_dir, downloader) if result is None: return None, "export failed after download" return result, None finally: # 每次下载任务完成后(无论成功失败)重置设备状态 # 清理商店页面、系统弹窗等残留,确保不影响下一个包的下载 try: downloader.reset_state() except Exception as exc: logger.warning("reset_state failed: %s", exc) def download_with_fallback(package_name: str, export_dir: str) -> tuple: """多设备下载 pipeline:先尝试模拟器A (Google Play),失败则回退模拟器B (Aurora Store), 最终回退到 APKPure HTTP 直连下载。 各阶段通过 device lock 协调并发: - 一个任务释放 Device A → 下一个任务立即抢占 Device A - Device A / Device B / APKPure 可同时处理互不干扰 返回 (result_dict_or_None, failure_reason_or_None, source_details_dict), 其中 result_dict 包含 source_details 记录每个来源的尝试结果。 """ source_details = {} # ── 第一级:Google Play (Device A) ── logger.info("[Device A / Google Play] Trying: %s", package_name) with _device_a_lock: device_a = GooglePlayDownloader(serial=US_DEVICE_A_SERIAL, store_type=STORE_GOOGLE_PLAY) result, reason = download_and_export(package_name, export_dir, device_a) if result: source_details["us_play"] = { "status": "success", "error": None, "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } source_details["us_aurora"] = { "status": "not_attempted", "error": None, "date": None, } source_details["apkpure"] = { "status": "not_attempted", "error": None, "date": None, } result["source_details"] = source_details return result, None, source_details source_details["us_play"] = { "status": "failed", "error": reason, "date": None, } logger.warning("[Device A] Failed (%s), falling back to Device B / Aurora Store: %s", reason, package_name) if _restart_emulator_if_needed(US_DEVICE_A_SERIAL, reason): logger.info("[Device A] 模拟器已重启,重试一次: %s", package_name) with _device_a_lock: device_a = GooglePlayDownloader(serial=US_DEVICE_A_SERIAL, store_type=STORE_GOOGLE_PLAY) result, reason = download_and_export(package_name, export_dir, device_a) if result: source_details["us_play"] = { "status": "success", "error": None, "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } source_details["us_aurora"] = {"status": "not_attempted", "error": None, "date": None} source_details["apkpure"] = {"status": "not_attempted", "error": None, "date": None} result["source_details"] = source_details return result, None, source_details # ── 第二级:Aurora Store (Device B) ── with _device_b_lock: device_b = GooglePlayDownloader(serial=US_DEVICE_B_SERIAL, store_type=STORE_AURORA) result2, reason2 = download_and_export(package_name, export_dir, device_b) if result2: source_details["us_aurora"] = { "status": "success", "error": None, "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } source_details["apkpure"] = { "status": "not_attempted", "error": None, "date": None, } result2["source_details"] = source_details return result2, None, source_details source_details["us_aurora"] = { "status": "failed", "error": reason2, "date": None, } logger.warning( "[Device A+B] Both failed (A=%s, B=%s), falling back to APKPure: %s", reason, reason2, package_name, ) if _restart_emulator_if_needed(US_DEVICE_B_SERIAL, reason2): logger.info("[Device B] 模拟器已重启,重试一次: %s", package_name) with _device_b_lock: device_b = GooglePlayDownloader(serial=US_DEVICE_B_SERIAL, store_type=STORE_AURORA) result2, reason2 = download_and_export(package_name, export_dir, device_b) if result2: source_details["us_aurora"] = { "status": "success", "error": None, "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } source_details["apkpure"] = {"status": "not_attempted", "error": None, "date": None} result2["source_details"] = source_details return result2, None, source_details # ── 第三级(最终回退):APKPure HTTP 直连下载 ── with _apkpure_semaphore: result3, reason3 = download_from_apkpure(package_name, export_dir) if result3: source_details["apkpure"] = { "status": "success", "error": None, "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } result3["source_details"] = source_details return result3, None, source_details source_details["apkpure"] = { "status": "failed", "error": reason3, "date": None, } return ( None, f"all sources failed: Play={reason}, Aurora={reason2}, APKPure={reason3}", source_details, ) def _export_only(package_name: str, export_dir: str, downloader: GooglePlayDownloader, uninstall_after: bool = True) -> Optional[Dict[str, Any]]: version = downloader.get_apk_version(package_name) pkg_export_dir = os.path.join(export_dir, package_name) for old_file in os.listdir(pkg_export_dir) if os.path.isdir(pkg_export_dir) else []: if old_file.endswith(('.apk', '.xapk', '.apkm')): try: os.remove(os.path.join(pkg_export_dir, old_file)) except OSError: pass os.makedirs(pkg_export_dir, exist_ok=True) apk_files = downloader.export_apk(package_name, pkg_export_dir) if not apk_files: logger.warning("APK export empty: %s", package_name) return None if uninstall_after and APK_EMULATOR_CLEANUP_ENABLED: try: downloader._adb.run( ["shell", "am", "force-stop", package_name], check=False, timeout=10, ) except Exception: pass try: downloader._adb.run( ["shell", "pm", "clear", package_name], check=False, timeout=15, ) except Exception: pass try: downloader._adb.run( ["shell", "pm", "uninstall", "--user", "0", package_name], check=True, timeout=30, ) logger.info("Uninstalled after export: %s", package_name) except Exception as exc: logger.warning("Uninstall failed: %s: %s", package_name, exc) try: downloader._adb.run( ["shell", "pm", "uninstall", package_name], check=False, timeout=15, ) except Exception: pass download_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") file_infos = [] for fp in apk_files: size = os.path.getsize(fp) if os.path.isfile(fp) else 0 file_infos.append({ "filename": os.path.basename(fp), "size": size, }) return { "package_name": package_name, "download_date": download_date, "version_name": version, "files": file_infos, "local_dir": export_dir, } def _process_single_task(task: Dict[str, str], export_dir: str, storage: MinioStorage, history: Dict[str, Dict[str, str]]) -> Dict[str, Any]: """处理单个下载任务:pipeline 下载 → 上传 MinIO → 记录结果。 返回 {"success": bool, "failures": [...]} 供 run_cycle 聚合统计。 """ package_name = str(task.get("package_name") or "").strip() last_updated = str(task.get("last_updated") or "").strip() if not package_name: return {"success": False, "failures": []} try: result, fail_reason, source_details = download_with_fallback(package_name, export_dir) except Exception as exc: logger.exception("download_with_fallback panicked: %s", package_name) fail_info = { "package_name": package_name, "reason": f"pipeline error: {exc}", "failed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } fallback = { "status": "failed", "package_name": package_name, "download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "reason": str(exc), "source_details": {}, } try: storage.write_download_result(package_name, fallback) except Exception: pass with _history_lock: _record_history(history, package_name, last_updated, download_date="none", version_name="") _save_history(export_dir, history) return {"success": False, "failures": [fail_info]} if result: result["status"] = "ok" pkg_dir = os.path.join(export_dir, package_name) try: version_code = result.get("version_name", "").replace(".", "_") or "v0" manifest = storage.upload_apk( package_name, export_dir, download_date=result["download_date"][:10], version_code=version_code, ) result["uploaded_files"] = manifest.get("files", []) result["download_date"] = manifest.get("download_date", result.get("download_date", "")) result["version_code"] = manifest.get("version_code", "") storage.cleanup_old_versions(package_name) with _history_lock: _record_history(history, package_name, last_updated, download_date=result.get("download_date", ""), version_name=result.get("version_name", "")) _save_history(export_dir, history) except Exception as exc: logger.error("Upload failed for %s: %s", package_name, exc) result["status"] = "download_ok_upload_failed" result["upload_error"] = str(exc) try: storage.write_download_result(package_name, result) except Exception: pass return { "success": False, "failures": [{ "package_name": package_name, "reason": f"upload failed: {exc}", "failed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), }], } finally: if os.path.isdir(pkg_dir): shutil.rmtree(pkg_dir, ignore_errors=True) logger.info("[Upload] 已清理本地文件: %s", pkg_dir) storage.write_download_result(package_name, result) return {"success": True, "failures": []} else: reason = fail_reason or "download_or_export_failed" result = { "status": "failed", "package_name": package_name, "download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "reason": reason, "source_details": source_details, } storage.write_download_result(package_name, result) with _history_lock: _record_history(history, package_name, last_updated, download_date="none", version_name="") _save_history(export_dir, history) return { "success": False, "failures": [{ "package_name": package_name, "reason": reason, "failed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), }], } def run_cycle(storage: MinioStorage, export_dir: str, history: Dict[str, Dict[str, str]] = None) -> Dict[str, Any]: history = history if history is not None else {} tasks = storage.pull_download_queue() if not tasks: logger.info("No tasks in download queue, sleeping") return {"processed": 0, "uploaded": 0, "failures": []} original_count = len(tasks) filtered = [] skipped = 0 for task in tasks: package_name = str(task.get("package_name") or "").strip() last_updated = str(task.get("last_updated") or "").strip() if not package_name or ' ' in package_name or '&&' in package_name: logger.warning("Skipping invalid task entry: %s", task) continue if not task.get("force") and _is_history_fresh(history, package_name, last_updated): skipped += 1 continue filtered.append(task) if skipped: logger.info("History filter: %d/%d tasks skipped (already downloaded)", skipped, original_count) tasks = filtered logger.info("%d tasks to process (max_workers=%d, priority ordered)", len(tasks), _MAX_PIPELINE_WORKERS) processed = 0 uploaded = 0 failures: List[Dict[str, Any]] = [] if not tasks: return {"processed": 0, "uploaded": 0, "failures": []} max_workers = min(_MAX_PIPELINE_WORKERS, max(len(tasks), 1)) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {} for task in tasks: future = executor.submit(_process_single_task, task, export_dir, storage, history) future_map[future] = task for future in as_completed(future_map): task_result = future.result() if task_result.get("success"): processed += 1 uploaded += 1 failures.extend(task_result.get("failures", [])) return {"processed": processed, "uploaded": uploaded, "failures": failures} def upload_failure_report(storage: MinioStorage, failures: List[Dict[str, Any]]) -> None: if not failures: return try: existing = storage.download_json(_FAILURE_REPORT_KEY) or {} except Exception: existing = {} existing.setdefault("failures", []) existing_failures = existing.get("failures", []) if not isinstance(existing_failures, list): existing_failures = [] existing["failures"] = existing_failures existing_failures.extend(failures) existing["generated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") existing["worker"] = "us-downloader" existing["total_failures"] = len(existing_failures) try: storage.upload_json(existing, _FAILURE_REPORT_KEY) logger.info("Failure report uploaded: %d total failures", len(existing_failures)) except Exception as exc: logger.warning("Failed to upload failure report: %s", exc) def cleanup_emulator_third_party_apps(downloader: GooglePlayDownloader) -> int: result = downloader._adb.run( ["shell", "pm", "list", "packages", "-3"], check=False, timeout=30, ) if result.returncode != 0: return 0 packages = [] for line in (result.stdout or "").splitlines(): line = line.strip() if line.startswith("package:"): pkg = line.split(":", 1)[1].strip() if pkg and pkg not in ("com.android.vending", "com.aurora.store"): packages.append(pkg) uninstalled = 0 for pkg in packages: try: r = downloader._adb.run( ["shell", "pm", "uninstall", pkg], check=False, timeout=15, ) if r.returncode == 0: uninstalled += 1 logger.info("Emulator cleanup uninstalled: %s", pkg) except Exception: pass return uninstalled def main_loop(export_dir: str, poll_seconds: int = _DEFAULT_POLL_SECONDS): storage = MinioStorage() downloader_for_cleanup = GooglePlayDownloader(serial=US_DEVICE_B_SERIAL, store_type=STORE_AURORA) last_emulator_cleanup = time.time() accumulated_failures: List[Dict[str, Any]] = [] history = _load_history(export_dir) history_count = len(history) logger.info("US Download Worker started, poll_interval=%ds, export_dir=%s", poll_seconds, export_dir) logger.info("Device A (Google Play): %s, Device B (Aurora): %s", US_DEVICE_A_SERIAL, US_DEVICE_B_SERIAL) logger.info("MinIO: %s / %s", storage.endpoint, storage.bucket) logger.info("History: %d previously downloaded packages", history_count) while True: start_time = time.time() try: stats = run_cycle(storage, export_dir, history) elapsed = time.time() - start_time logger.info("Cycle done: processed=%d uploaded=%d failed=%d elapsed=%.1fs", stats["processed"], stats["uploaded"], len(stats.get("failures", [])), elapsed) failures = stats.get("failures", []) if failures: accumulated_failures.extend(failures) upload_failure_report(storage, accumulated_failures) if stats["uploaded"] > 0: try: limit_result = storage.enforce_storage_limit() if limit_result.get("deleted_versions"): logger.info( "MinIO storage limit: %.1fGB -> %.1fGB (deleted %d versions)", limit_result["before_bytes"] / (1024 ** 3), limit_result["after_bytes"] / (1024 ** 3), limit_result["deleted_versions"], ) except Exception as exc: logger.warning("MinIO storage limit check failed: %s", exc) if APK_EMULATOR_CLEANUP_ENABLED and (time.time() - last_emulator_cleanup >= _EMULATOR_CLEANUP_INTERVAL): try: count = cleanup_emulator_third_party_apps(downloader_for_cleanup) if count > 0: logger.info("Emulator cleanup: uninstalled %d apps", count) except Exception as exc: logger.warning("Emulator cleanup failed: %s", exc) last_emulator_cleanup = time.time() except Exception: logger.exception("Cycle error") sleep_left = max(0, poll_seconds - (time.time() - start_time)) if sleep_left > 0: logger.info("Sleeping %.0fs until next poll...", sleep_left) time.sleep(sleep_left) def main() -> int: import argparse parser = argparse.ArgumentParser(description="US Download Worker") parser.add_argument("--export-dir", default=APK_US_EXPORT_DIR, help=f"APK导出目录 (default: {APK_US_EXPORT_DIR})") parser.add_argument("--interval", type=int, default=_DEFAULT_POLL_SECONDS, help=f"轮询间隔秒数 (default: {_DEFAULT_POLL_SECONDS})") parser.add_argument("--once", action="store_true", help="只执行一次循环后退出") args = parser.parse_args() logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) export_dir = str(args.export_dir or "").strip() or APK_US_EXPORT_DIR os.makedirs(export_dir, exist_ok=True) if args.once: storage = MinioStorage() history = _load_history(export_dir) stats = run_cycle(storage, export_dir, history) failures = stats.get("failures", []) if failures: upload_failure_report(storage, failures) print(f"done: processed={stats['processed']} uploaded={stats['uploaded']} failed={len(failures)}") return 0 main_loop(export_dir, poll_seconds=args.interval) return 0 if __name__ == "__main__": raise SystemExit(main())