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

213 lines
7.9 KiB
Python
Raw Permalink 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 -*-
"""
APKPure 下载器
通过 APKPure 的直连 URL 下载应用安装包,作为 US Download Worker 的最终回退源。
当 Google Play 和 Aurora Store 都失败时使用此模块。
参考 scripts/download.py 中的下载逻辑,适配 us_download_worker 的返回格式。
"""
from __future__ import annotations
import logging
import os
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger("apkpure_downloader")
# APKPure 直连下载 URL 模板
_APKPURE_DOWNLOAD_URL = "https://d.apkpure.net/b/XAPK/{package_name}?version=latest"
# 请求头
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/108.0.0.0 Safari/537.36"
),
}
# 下载超时(秒)- 对 stream 下载的每个 chunk 读取生效
_DOWNLOAD_CONNECT_TIMEOUT = 15
_DOWNLOAD_READ_TIMEOUT = 30
_DOWNLOAD_PROGRESS_TIMEOUT = 200
# 单次下载最大重试次数
_MAX_RETRIES = 2
# 每次下载间的延迟(秒),避免被限流
DOWNLOAD_DELAY_SECONDS = 3
def _create_session(retries: int = 3, backoff_factor: float = 0.3,
pool_connections: int = 20, pool_maxsize: int = 20) -> requests.Session:
"""创建带自动重试和连接池的 requests Session。"""
session = requests.Session()
retry = Retry(
total=retries,
read=retries,
connect=retries,
backoff_factor=backoff_factor,
status_forcelist=(500, 502, 504),
)
adapter = HTTPAdapter(max_retries=retry, pool_connections=pool_connections,
pool_maxsize=pool_maxsize)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
# 模块级共享 session避免每次下载重复创建连接池
_shared_session: Optional[requests.Session] = None
_session_lock = __import__('threading').Lock()
def _get_shared_session() -> requests.Session:
global _shared_session
if _shared_session is None:
with _session_lock:
if _shared_session is None:
_shared_session = _create_session()
return _shared_session
def download_from_apkpure(
package_name: str,
export_dir: str,
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""从 APKPure 下载应用安装包。
Args:
package_name: 应用包名
export_dir: 导出目录APK 文件保存到 {export_dir}/{package_name}/
Returns:
(result_dict, failure_reason)
- 成功时 result_dict 包含 package_name, download_date, version_name, files, local_dir
- 失败时 result_dict 为 Nonefailure_reason 为失败原因
"""
download_url = _APKPURE_DOWNLOAD_URL.format(package_name=package_name)
pkg_dir = os.path.join(export_dir, package_name)
final_filename = os.path.join(pkg_dir, f"{package_name}.xapk")
if os.path.exists(final_filename) and os.path.getsize(final_filename) > 0:
logger.info("[APKPure] 文件已存在,跳过下载: %s", package_name)
return _build_result(package_name, final_filename, export_dir), None
for old_file in os.listdir(pkg_dir) if os.path.isdir(pkg_dir) else []:
if old_file.endswith(('.apk', '.xapk', '.apkm')):
try:
os.remove(os.path.join(pkg_dir, old_file))
except OSError:
pass
os.makedirs(pkg_dir, exist_ok=True)
partial_filename = final_filename + ".part"
headers = dict(_HEADERS)
headers["Referer"] = f"https://apkpure.net/1/{package_name}"
# 支持断点续传
resumable_size = 0
if os.path.exists(partial_filename):
resumable_size = os.path.getsize(partial_filename)
headers["Range"] = f"bytes={resumable_size}-"
try:
logger.info("[APKPure] 开始下载: %s (URL: %s)", package_name, download_url)
start_time = time.time()
with _get_shared_session().get(
download_url, headers=headers, stream=True,
timeout=(_DOWNLOAD_CONNECT_TIMEOUT, _DOWNLOAD_READ_TIMEOUT),
verify=False,
) as response:
# 检查 HTTP 状态
if response.status_code == 404:
return None, f"APKPure 404: 应用不存在或不可用"
if response.status_code == 403:
return None, f"APKPure 403: 访问被拒绝"
response.raise_for_status()
# 获取文件总大小
content_length = response.headers.get("Content-Length")
total_size = int(content_length) + resumable_size if content_length else 0
mode = "ab" if resumable_size > 0 else "wb"
with open(partial_filename, mode) as f:
downloaded = resumable_size
for chunk in response.iter_content(chunk_size=1024 * 1024):
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if time.time() - start_time > _DOWNLOAD_PROGRESS_TIMEOUT:
logger.warning("[APKPure] 下载总时长超限: %s (%.0fs)", package_name,
time.time() - start_time)
return None, f"下载总时长超限: {time.time() - start_time:.0f}s"
if total_size and downloaded % (10 * 1024 * 1024) == 0:
logger.debug("[APKPure] 下载进度: %s %.0f%%", package_name,
downloaded / total_size * 100)
# 验证文件完整性
actual_size = os.path.getsize(partial_filename)
if total_size > 0 and actual_size != total_size:
return None, f"文件下载不完整: 期望 {total_size} 字节, 实际 {actual_size} 字节"
if actual_size == 0:
# 空文件,清理并报告失败
try:
os.remove(partial_filename)
except OSError:
pass
return None, "下载文件为空"
# 重命名为最终文件
os.rename(partial_filename, final_filename)
logger.info("[APKPure] 下载完成: %s (%.1f MB)",
package_name, actual_size / (1024 * 1024))
return _build_result(package_name, final_filename, export_dir), None
except requests.exceptions.ConnectionError as e:
logger.warning("[APKPure] 连接错误: %s - %s", package_name, e)
return None, f"连接错误: {e}"
except requests.exceptions.ReadTimeout as e:
logger.warning("[APKPure] 读取超时(卡住): %s - %s", package_name, e)
return None, f"下载卡住超时: {e}"
except requests.exceptions.Timeout as e:
logger.warning("[APKPure] 超时: %s - %s", package_name, e)
return None, f"下载超时: {e}"
except requests.exceptions.HTTPError as e:
logger.warning("[APKPure] HTTP错误: %s - %s", package_name, e)
return None, f"HTTP错误: {e}"
except Exception as e:
logger.warning("[APKPure] 下载异常: %s - %s", package_name, e)
return None, f"下载异常: {e}"
def _build_result(
package_name: str, file_path: str, export_dir: str,
) -> Dict[str, Any]:
"""构建与 download_and_export 兼容的返回结果。"""
file_size = os.path.getsize(file_path) if os.path.isfile(file_path) else 0
download_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return {
"package_name": package_name,
"download_date": download_date,
"version_name": "", # APKPure 直连无法获取版本号
"files": [
{
"filename": os.path.basename(file_path),
"size": file_size,
}
],
"local_dir": export_dir,
}