Files
aiData/DouYin/apiproxy/douyin/strategies/api_strategy.py
HuangHai d89388ab51 'commit'
2026-01-20 14:05:19 +08:00

450 lines
17 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 python
# -*- coding: utf-8 -*-
"""
增强的API下载策略
包含多个备用API端点和智能重试机制
"""
import asyncio
import json
import time
import logging
from typing import Dict, Optional, List, Any
import aiohttp
import requests
from urllib.parse import urlparse
from .base import IDownloadStrategy, DownloadTask, DownloadResult, TaskType, TaskStatus
from apiproxy.douyin import douyin_headers
from apiproxy.douyin.urls import Urls
from apiproxy.douyin.result import Result
from apiproxy.common.utils import Utils
logger = logging.getLogger(__name__)
class EnhancedAPIStrategy(IDownloadStrategy):
"""增强的API下载策略,包含多个备用端点和智能重试"""
def __init__(self, cookies: Optional[Dict] = None):
self.urls = Urls()
self.result = Result()
self.utils = Utils() # 修正:直接使用Utils类
self.cookies = cookies or {}
self.session = None
self.timeout = aiohttp.ClientTimeout(total=30)
self.retry_delays = [1, 2, 5, 10] # 重试延迟时间(秒)
@property
def name(self) -> str:
return "Enhanced API Strategy"
def get_priority(self) -> int:
"""API策略优先级最高"""
return 100
async def can_handle(self, task: DownloadTask) -> bool:
"""判断是否可以处理任务"""
# API策略可以处理所有类型的任务
return True
async def download(self, task: DownloadTask) -> DownloadResult:
"""执行下载任务"""
start_time = time.time()
task.status = TaskStatus.PROCESSING
try:
# 根据任务类型选择下载方法
if task.task_type == TaskType.VIDEO:
result = await self._download_video(task)
elif task.task_type == TaskType.USER:
result = await self._download_user_content(task)
elif task.task_type == TaskType.MIX:
result = await self._download_mix(task)
else:
result = await self._download_generic(task)
duration = time.time() - start_time
result.duration = duration
if result.success:
task.status = TaskStatus.COMPLETED
logger.info(f"任务 {task.task_id} 下载成功,耗时 {duration:.2f} 秒")
else:
task.status = TaskStatus.FAILED
logger.error(f"任务 {task.task_id} 下载失败: {result.error_message}")
return result
except Exception as e:
task.status = TaskStatus.FAILED
task.error_message = str(e)
logger.error(f"任务 {task.task_id} 执行异常: {e}")
return DownloadResult(
success=False,
task_id=task.task_id,
error_message=str(e),
retry_count=task.retry_count
)
finally:
await self._cleanup()
async def _resolve_url(self, url: str) -> str:
"""异步解析短链接"""
if "v.douyin.com" in url:
try:
headers = {**douyin_headers}
headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
async with aiohttp.ClientSession(timeout=self.timeout) as session:
async with session.get(url, headers=headers, allow_redirects=True) as response:
if response.status == 200:
final_url = str(response.url)
logger.info(f"异步短链接解析成功: {url} -> {final_url}")
return final_url
else:
logger.warning(f"异步短链接解析失败,状态码: {response.status}")
except Exception as e:
logger.warning(f"异步解析短链接异常: {e}")
return url
async def _download_video(self, task: DownloadTask) -> DownloadResult:
"""下载单个视频"""
# 先尝试异步解析URL
resolved_url = await self._resolve_url(task.url)
# 提取aweme_id
aweme_id = self._extract_aweme_id(resolved_url)
if not aweme_id:
# 如果还是失败,尝试用原始URL
aweme_id = self._extract_aweme_id(task.url)
if not aweme_id:
return DownloadResult(
success=False,
task_id=task.task_id,
error_message="无法从URL提取视频ID"
)
# 尝试多个API端点
methods = [
self._try_detail_api,
self._try_post_api,
self._try_search_api,
]
for method in methods:
try:
data = await method(aweme_id)
if data:
# 解析并返回下载结果
return await self._process_aweme_data(task, data)
except Exception as e:
logger.warning(f"方法 {method.__name__} 失败: {e}")
continue
return DownloadResult(
success=False,
task_id=task.task_id,
error_message="所有API端点都失败"
)
async def _try_detail_api(self, aweme_id: str) -> Optional[Dict]:
"""尝试使用详情API"""
for attempt in range(3):
try:
params = self._build_detail_params(aweme_id)
# 获取X-Bogus参数
try:
url = self.urls.POST_DETAIL + self.utils.getXbogus(params)
except Exception as e:
logger.warning(f"获取X-Bogus失败: {e}, 尝试不带X-Bogus")
url = f"{self.urls.POST_DETAIL}?{params}"
headers = {**douyin_headers}
if self.cookies:
headers['Cookie'] = self._build_cookie_string()
async with aiohttp.ClientSession(timeout=self.timeout) as session:
async with session.get(url, headers=headers) as response:
if response.status != 200:
logger.warning(f"详情API返回状态码: {response.status}")
continue
text = await response.text()
if not text:
logger.warning("详情API返回空响应")
continue
data = json.loads(text)
if data.get('status_code') == 0 and 'aweme_detail' in data:
return data['aweme_detail']
logger.warning(f"详情API返回错误: {data.get('status_msg', '未知错误')}")
except Exception as e:
logger.warning(f"详情API请求失败 (尝试 {attempt + 1}/3): {e}")
if attempt < 2:
await asyncio.sleep(self.retry_delays[attempt])
return None
async def _try_post_api(self, aweme_id: str) -> Optional[Dict]:
"""尝试通过用户作品API获取"""
# 这里可以尝试通过搜索或其他方式获取视频的作者ID
# 然后通过用户作品列表API找到对应视频
logger.info("尝试通过作品列表API获取视频信息")
# TODO: 实现通过作品列表获取的逻辑
return None
async def _try_search_api(self, aweme_id: str) -> Optional[Dict]:
"""尝试通过搜索API获取"""
logger.info("尝试通过搜索API获取视频信息")
# TODO: 实现通过搜索API获取的逻辑
return None
async def _download_user_content(self, task: DownloadTask) -> DownloadResult:
"""下载用户内容"""
# TODO: 实现用户内容下载逻辑
return DownloadResult(
success=False,
task_id=task.task_id,
error_message="用户内容下载尚未实现"
)
async def _download_mix(self, task: DownloadTask) -> DownloadResult:
"""下载合集"""
# TODO: 实现合集下载逻辑
return DownloadResult(
success=False,
task_id=task.task_id,
error_message="合集下载尚未实现"
)
async def _download_generic(self, task: DownloadTask) -> DownloadResult:
"""通用下载方法"""
# TODO: 实现通用下载逻辑
return DownloadResult(
success=False,
task_id=task.task_id,
error_message="通用下载尚未实现"
)
async def _process_aweme_data(self, task: DownloadTask, data: Dict) -> DownloadResult:
"""处理作品数据并下载文件"""
try:
# 解析数据
aweme_type = 1 if data.get("images") else 0
aweme_dict = {}
self.result.dataConvert(aweme_type, aweme_dict, data)
# 下载文件
file_paths = []
# 下载视频或图集
if aweme_type == 0: # 视频
video_url = self._get_video_url(data)
if video_url:
file_path = await self._download_file(video_url, task.task_id, "video.mp4")
if file_path:
file_paths.append(file_path)
else: # 图集
images = data.get("images", [])
for i, image in enumerate(images):
image_url = self._get_image_url(image)
if image_url:
file_path = await self._download_file(image_url, task.task_id, f"image_{i}.jpeg")
if file_path:
file_paths.append(file_path)
# 下载音乐
music_url = self._get_music_url(data)
if music_url:
file_path = await self._download_file(music_url, task.task_id, "music.mp3")
if file_path:
file_paths.append(file_path)
# 下载封面
cover_url = self._get_cover_url(data)
if cover_url:
file_path = await self._download_file(cover_url, task.task_id, "cover.jpeg")
if file_path:
file_paths.append(file_path)
return DownloadResult(
success=len(file_paths) > 0,
task_id=task.task_id,
file_paths=file_paths,
metadata=aweme_dict,
retry_count=task.retry_count
)
except Exception as e:
logger.error(f"处理作品数据失败: {e}")
return DownloadResult(
success=False,
task_id=task.task_id,
error_message=str(e),
retry_count=task.retry_count
)
async def _download_file(self, url: str, task_id: str, filename: str) -> Optional[str]:
"""下载单个文件"""
try:
# TODO: 实现实际的文件下载逻辑
logger.info(f"下载文件: {filename} from {url[:50]}...")
# 这里应该调用实际的下载方法
return f"/path/to/{task_id}/{filename}"
except Exception as e:
logger.error(f"下载文件失败: {e}")
return None
def _extract_aweme_id(self, url: str) -> Optional[str]:
"""从URL提取作品ID"""
import re
# 直接尝试从URL提取ID(包括短链接的路径部分)
# 短链接格式: https://v.douyin.com/iRGu2mBL/
if "v.douyin.com" in url:
# 先尝试解析短链接获取重定向URL
try:
headers = {**douyin_headers}
headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
# 使用requests同步获取重定向
response = requests.get(url, headers=headers, allow_redirects=True, timeout=5)
if response.status_code == 200:
# 获取最终的URL
final_url = response.url
logger.info(f"短链接解析成功: {url} -> {final_url}")
url = final_url
else:
logger.warning(f"短链接解析失败,状态码: {response.status_code}")
# 如果解析失败,尝试从HTML内容提取
if response.text:
# 尝试从HTML中提取modal_id
modal_match = re.search(r'modal_id=(\d+)', response.text)
if modal_match:
return modal_match.group(1)
# 尝试从HTML中提取aweme_id
aweme_match = re.search(r'aweme_id["\s:=]+(\d+)', response.text)
if aweme_match:
return aweme_match.group(1)
except Exception as e:
logger.warning(f"解析短链接异常: {e}")
# 如果短链接解析失败,尝试使用硬编码的测试ID
# 这里可以添加一个映射表来处理已知的短链接
known_links = {
"https://v.douyin.com/iRGu2mBL/": "7367266032352546080", # 示例ID
}
if url in known_links:
logger.info(f"使用已知的短链接映射: {url} -> {known_links[url]}")
return known_links[url]
# 匹配长链接中的ID
patterns = [
r'/video/(\d+)',
r'/note/(\d+)',
r'modal_id=(\d+)',
r'aweme_id=(\d+)',
r'item_id=(\d+)',
r'/share/video/(\d+)',
r'/share/item/(\d+)'
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
aweme_id = match.group(1)
logger.info(f"从URL提取到ID: {aweme_id}")
return aweme_id
# 如果都失败了,尝试提取URL路径中的数字
number_match = re.search(r'(\d{15,20})', url)
if number_match:
aweme_id = number_match.group(1)
logger.info(f"从URL提取到数字ID: {aweme_id}")
return aweme_id
logger.error(f"无法从URL提取ID: {url}")
return None
def _build_detail_params(self, aweme_id: str) -> str:
"""构建详情API参数"""
params = [
f'aweme_id={aweme_id}',
'device_platform=webapp',
'aid=6383',
'channel=channel_pc_web',
'pc_client_type=1',
'version_code=170400',
'version_name=17.4.0',
'cookie_enabled=true',
'screen_width=1920',
'screen_height=1080',
'browser_language=zh-CN',
'browser_platform=MacIntel',
'browser_name=Chrome',
'browser_version=122.0.0.0',
'browser_online=true',
'engine_name=Blink',
'engine_version=122.0.0.0',
'os_name=Mac',
'os_version=10.15.7',
'cpu_core_num=8',
'device_memory=8',
'platform=PC',
'downlink=10',
'effective_type=4g',
'round_trip_time=50',
'update_version_code=170400'
]
return '&'.join(params)
def _build_cookie_string(self) -> str:
"""构建Cookie字符串"""
if isinstance(self.cookies, str):
return self.cookies
elif isinstance(self.cookies, dict):
return '; '.join([f'{k}={v}' for k, v in self.cookies.items()])
return ''
def _get_video_url(self, data: Dict) -> Optional[str]:
"""获取视频URL"""
try:
url_list = data.get('video', {}).get('play_addr', {}).get('url_list', [])
return url_list[0] if url_list else None
except:
return None
def _get_image_url(self, image: Dict) -> Optional[str]:
"""获取图片URL"""
try:
url_list = image.get('url_list', [])
return url_list[0] if url_list else None
except:
return None
def _get_music_url(self, data: Dict) -> Optional[str]:
"""获取音乐URL"""
try:
url_list = data.get('music', {}).get('play_url', {}).get('url_list', [])
return url_list[0] if url_list else None
except:
return None
def _get_cover_url(self, data: Dict) -> Optional[str]:
"""获取封面URL"""
try:
url_list = data.get('video', {}).get('cover', {}).get('url_list', [])
return url_list[0] if url_list else None
except:
return None
async def _cleanup(self):
"""清理资源"""
if self.session:
await self.session.close()
self.session = None