353 lines
11 KiB
Python
353 lines
11 KiB
Python
#!/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())
|