213 lines
7.9 KiB
Python
213 lines
7.9 KiB
Python
#!/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 为 None,failure_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,
|
||
}
|