autool-dispatcher/apk_cloud/batch_download.py
2026-06-17 19:50:39 +08:00

353 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())