#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 批量 Google Play 下载脚本 按名单逐个下载安装应用,下完一个自动换下一个。 从 CSV 文件读取包名列表,默认读取 config/streaming.csv, 解析 target 列作为包名,并根据 enabled 列过滤。 用法: python batch_download.py python batch_download.py --export-apk /path/to/apk/export """ from __future__ import annotations import csv import hashlib import json import logging import os import sys import time from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path from typing import List, Optional # 复用项目现有的下载器 from android_google_play_downloader import GooglePlayDownloader 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_LOCAL_STORAGE_DIR logger = logging.getLogger("batch_download") @dataclass class Config: """运行参数配置,直接在这里修改即可。""" # CSV 文件路径(相对于项目根目录或绝对路径) csv_path: str = "config/streaming.csv" # 结果输出 JSON 文件路径(留空则不保存) output_path: str = "" # 是否跳过已安装的应用 skip_installed: bool = True # 是否只下载 enabled=1 的行 only_enabled: bool = True # APK 导出目录(留空则跳过导出) export_apk_dir: str = "" # 下载成功后是否删除模拟器中的APK(释放空间) uninstall_after_export: bool = False @dataclass class DownloadResult: package: str success: bool message: str start_time: str = "" end_time: str = "" duration_sec: float = 0.0 exported_apks: List[str] = field(default_factory=list) version_name: str = "" @dataclass class ApkFileInfo: filename: str size: int md5: str @dataclass class DownloadManifest: package_name: str download_date: str version_name: str version_code: str files: List[ApkFileInfo] = field(default_factory=list) @dataclass class BatchReport: total: int = 0 success: int = 0 failed: int = 0 skipped: int = 0 results: List[DownloadResult] = field(default_factory=list) def load_packages_from_csv(path: str, only_enabled: bool = True) -> List[str]: """从 CSV 文件加载包名列表,读取 target 列。 CSV 表头预期: enabled,task_id,category,platform,target """ packages: List[str] = [] csv_file = Path(path) if not csv_file.exists(): print(f"错误: CSV 文件不存在: {path}", file=sys.stderr) return packages with csv_file.open("r", encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for row in reader: # 根据 enabled 过滤 if only_enabled: enabled = row.get("enabled", "").strip() if enabled not in ("1", "true", "True", "TRUE", "yes", "Yes"): continue target = row.get("target", "").strip() if target: packages.append(target) return packages def _file_md5(filepath: str) -> str: h = hashlib.md5() with open(filepath, "rb") as fh: for chunk in iter(lambda: fh.read(65536), b""): h.update(chunk) return h.hexdigest() def build_download_manifest( package_name: str, apk_files: List[str], version_name: str, download_date: str ) -> DownloadManifest: file_infos = [] for fp in apk_files: size = os.path.getsize(fp) if os.path.isfile(fp) else 0 md5 = _file_md5(fp) if os.path.isfile(fp) else "" file_infos.append(ApkFileInfo( filename=os.path.basename(fp), size=size, md5=md5, )) return DownloadManifest( package_name=package_name, download_date=download_date, version_name=version_name, version_code="", files=file_infos, ) def save_manifest(manifest: DownloadManifest, output_dir: str) -> str: os.makedirs(output_dir, exist_ok=True) path = os.path.join(output_dir, f"{manifest.package_name}.json") data = { "package_name": manifest.package_name, "download_date": manifest.download_date, "version_name": manifest.version_name, "version_code": manifest.version_code, "files": [asdict(f) for f in manifest.files], } with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) return path def run_batch( packages: List[str], output_path: Optional[str] = None, skip_installed: bool = True, export_apk_dir: str = "", uninstall_after_export: bool = False, ) -> BatchReport: """执行批量下载。""" report = BatchReport(total=len(packages)) downloader = GooglePlayDownloader() print(f"\n{'=' * 60}") print(f"批量下载开始,共 {len(packages)} 个应用") if export_apk_dir: print(f"APK导出目录: {export_apk_dir}") print(f"{'=' * 60}\n") for idx, package in enumerate(packages, start=1): result = DownloadResult(package=package, success=False, message="") result.start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") t0 = time.time() print(f"[{idx}/{len(packages)}] 正在处理: {package}") try: # 如果设置了跳过已安装,先检查一下 if skip_installed and downloader.is_installed(package): result.success = True result.message = "already installed, skipped" report.skipped += 1 print(f" -> 已安装,跳过") else: success, message = downloader.start(package) result.success = success result.message = message if success: report.success += 1 print(f" -> 成功: {message}") else: report.failed += 1 print(f" -> 失败: {message}") # 导出APK if result.success and export_apk_dir: version = downloader.get_apk_version(package) result.version_name = version # 导出到 export_apk_dir/package/ 子目录,与 registry.refresh_from_local_disk 期望的结构一致 pkg_export_dir = os.path.join(export_apk_dir, package) apk_files = downloader.export_apk(package, pkg_export_dir) if apk_files: result.exported_apks = apk_files download_date = datetime.now().strftime("%Y-%m-%d") manifest = build_download_manifest( package, apk_files, version, download_date, ) # manifest 也保存到 package 子目录下: export_apk_dir/package/package.json manifest_path = save_manifest(manifest, pkg_export_dir) print(f" -> 已导出APK: {len(apk_files)}个文件") print(f" -> 版本: {version or 'N/A'}") print(f" -> manifest: {manifest_path}") if uninstall_after_export: downloader._adb.run( ["shell", "pm", "uninstall", package], check=False, timeout=15, ) print(f" -> 已卸载") else: print(f" -> 警告: APK导出为空") except Exception as exc: result.success = False result.message = str(exc) report.failed += 1 print(f" -> 异常: {exc}") result.end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") result.duration_sec = round(time.time() - t0, 2) report.results.append(result) # 每个之间稍微停顿一下,避免操作过快 if idx < len(packages): print(" (等待 2 秒切换下一个...)\n") time.sleep(2) else: print() print(f"{'=' * 60}") print(f"批量下载结束") print(f" 总计: {report.total}") print(f" 成功: {report.success}") print(f" 失败: {report.failed}") print(f" 跳过(已安装): {report.skipped}") if export_apk_dir: exported_count = sum(1 for r in report.results if r.exported_apks) print(f" 已导出APK: {exported_count}") print(f"{'=' * 60}") # 保存结果到文件 if output_path: save_report(report, output_path) print(f"\n详细结果已保存到: {output_path}") return report def save_report(report: BatchReport, path: str) -> None: """将结果保存为 JSON。""" data = { "summary": { "total": report.total, "success": report.success, "failed": report.failed, "skipped": report.skipped, }, "results": [asdict(r) for r in report.results], } with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def main() -> int: import argparse parser = argparse.ArgumentParser(description="批量 Google Play 下载") parser.add_argument("--csv", default="", help="CSV文件路径") parser.add_argument("--output", default="", help="结果输出JSON路径") parser.add_argument("--skip-installed", action="store_true", default=True, help="跳过已安装的应用") parser.add_argument("--no-skip-installed", dest="skip_installed", action="store_false", help="不跳过已安装的应用") parser.add_argument("--export-apk", default="", help="APK导出目录") parser.add_argument("--uninstall-after-export", action="store_true", help="导出后卸载模拟器中的应用") parser.set_defaults(skip_installed=True) args = parser.parse_args() logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) config = Config( csv_path=args.csv or "config/streaming.csv", output_path=args.output, skip_installed=args.skip_installed, export_apk_dir=args.export_apk, uninstall_after_export=args.uninstall_after_export, ) # 从 CSV 加载包名列表 packages = load_packages_from_csv( config.csv_path, only_enabled=config.only_enabled, ) if not packages: print("错误: 没有加载到任何包名,请检查 CSV 文件路径和 enabled 配置。", file=sys.stderr) return 1 # 去重,保持顺序 seen = set() unique_packages: List[str] = [] for p in packages: if p not in seen: seen.add(p) unique_packages.append(p) run_batch( packages=unique_packages, output_path=config.output_path or None, skip_installed=config.skip_installed, export_apk_dir=config.export_apk_dir, uninstall_after_export=config.uninstall_after_export, ) return 0 if __name__ == "__main__": raise SystemExit(main())