This commit is contained in:
HuangHai
2026-01-20 14:05:19 +08:00
parent 646f3acd6d
commit d89388ab51
89 changed files with 12526 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
抖音下载策略模块
包含多种下载策略的实现
"""
from .base import IDownloadStrategy, DownloadTask, DownloadResult, TaskType, TaskStatus
from .api_strategy import EnhancedAPIStrategy
from .browser_strategy import BrowserDownloadStrategy as BrowserStrategy
from .retry_strategy import RetryStrategy
__all__ = [
'IDownloadStrategy',
'DownloadTask',
'DownloadResult',
'TaskType',
'TaskStatus',
'EnhancedAPIStrategy',
'BrowserStrategy',
'RetryStrategy'
]

View File

@@ -0,0 +1,450 @@
#!/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

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
下载策略基础类和接口定义
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from enum import Enum
import time
class TaskType(Enum):
"""任务类型枚举"""
VIDEO = "video"
IMAGE = "image"
MUSIC = "music"
USER = "user"
MIX = "mix"
LIVE = "live"
class TaskStatus(Enum):
"""任务状态枚举"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
RETRYING = "retrying"
@dataclass
class DownloadTask:
"""下载任务数据类"""
task_id: str
url: str
task_type: TaskType
priority: int = 0
retry_count: int = 0
max_retries: int = 3
status: TaskStatus = TaskStatus.PENDING
metadata: Dict[str, Any] = field(default_factory=dict)
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
error_message: Optional[str] = None
def increment_retry(self) -> bool:
"""增加重试次数,返回是否还能重试"""
self.retry_count += 1
self.updated_at = time.time()
return self.retry_count < self.max_retries
def to_dict(self) -> Dict:
"""转换为字典"""
return {
'task_id': self.task_id,
'url': self.url,
'task_type': self.task_type.value,
'priority': self.priority,
'retry_count': self.retry_count,
'max_retries': self.max_retries,
'status': self.status.value,
'metadata': self.metadata,
'created_at': self.created_at,
'updated_at': self.updated_at,
'error_message': self.error_message
}
@dataclass
class DownloadResult:
"""下载结果数据类"""
success: bool
task_id: str
file_paths: List[str] = field(default_factory=list)
error_message: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
duration: float = 0.0
retry_count: int = 0
def to_dict(self) -> Dict:
"""转换为字典"""
return {
'success': self.success,
'task_id': self.task_id,
'file_paths': self.file_paths,
'error_message': self.error_message,
'metadata': self.metadata,
'duration': self.duration,
'retry_count': self.retry_count
}
class IDownloadStrategy(ABC):
"""下载策略抽象基类"""
@abstractmethod
async def can_handle(self, task: DownloadTask) -> bool:
"""判断是否可以处理该任务"""
pass
@abstractmethod
async def download(self, task: DownloadTask) -> DownloadResult:
"""执行下载任务"""
pass
@abstractmethod
def get_priority(self) -> int:
"""获取策略优先级,数值越大优先级越高"""
pass
@property
@abstractmethod
def name(self) -> str:
"""策略名称"""
pass
def __str__(self) -> str:
return f"{self.name} (Priority: {self.get_priority()})"

View File

@@ -0,0 +1,463 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
浏览器自动化下载策略
使用Playwright实现浏览器自动化下载
"""
import asyncio
import json
import logging
import time
import os
from typing import Dict, Optional, List, Any
from pathlib import Path
from .base import IDownloadStrategy, DownloadTask, DownloadResult, TaskType
logger = logging.getLogger(__name__)
# 动态导入Playwright,避免未安装时报错
try:
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
logger.warning("Playwright未安装,浏览器策略不可用。请运行: pip install playwright && playwright install chromium")
class BrowserDownloadStrategy(IDownloadStrategy):
"""浏览器自动化下载策略"""
def __init__(self, headless: bool = True, timeout: int = 30000):
"""
初始化浏览器策略
Args:
headless: 是否无头模式
timeout: 页面加载超时时间(毫秒)
"""
if not PLAYWRIGHT_AVAILABLE:
raise ImportError("Playwright未安装,请运行: pip install playwright && playwright install chromium")
self.headless = headless
self.timeout = timeout
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
self.playwright = None
self.initialized = False
# 浏览器配置
self.browser_args = [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--disable-web-security',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-accelerated-2d-canvas',
'--disable-gpu'
]
# User-Agent
self.user_agent = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
@property
def name(self) -> str:
return "Browser Automation Strategy"
def get_priority(self) -> int:
"""浏览器策略优先级中等"""
return 50
async def can_handle(self, task: DownloadTask) -> bool:
"""判断是否可以处理任务"""
# 浏览器策略可以处理视频和图集
return task.task_type in [TaskType.VIDEO, TaskType.IMAGE]
async def initialize(self):
"""初始化浏览器"""
if self.initialized:
return
try:
logger.info("初始化浏览器...")
self.playwright = await async_playwright().start()
# 启动浏览器
self.browser = await self.playwright.chromium.launch(
headless=self.headless,
args=self.browser_args
)
# 创建上下文
self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent=self.user_agent,
locale='zh-CN',
timezone_id='Asia/Shanghai'
)
# 添加反检测脚本
await self.context.add_init_script("""
// 隐藏webdriver特征
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
// 修改navigator.plugins
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5]
});
// 修改navigator.languages
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en']
});
// 修改权限查询
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
""")
self.initialized = True
logger.info("浏览器初始化完成")
except Exception as e:
logger.error(f"浏览器初始化失败: {e}")
await self.cleanup()
raise
async def download(self, task: DownloadTask) -> DownloadResult:
"""执行下载任务"""
start_time = time.time()
try:
# 初始化浏览器
await self.initialize()
# 创建新页面
page = await self.context.new_page()
try:
# 设置cookies(如果有)
if task.metadata.get('cookies'):
await self._set_cookies(page, task.metadata['cookies'])
# 访问页面
logger.info(f"浏览器访问: {task.url}")
await page.goto(task.url, wait_until='networkidle', timeout=self.timeout)
# 等待页面加载
await asyncio.sleep(2)
# 根据任务类型处理
if task.task_type == TaskType.VIDEO:
result = await self._download_video(page, task)
else:
result = await self._download_images(page, task)
result.duration = time.time() - start_time
return result
finally:
await page.close()
except Exception as e:
logger.error(f"浏览器下载失败: {e}")
return DownloadResult(
success=False,
task_id=task.task_id,
error_message=str(e),
duration=time.time() - start_time
)
async def _download_video(self, page: 'Page', task: DownloadTask) -> DownloadResult:
"""下载视频"""
try:
# 等待视频元素加载
video_selector = 'video'
await page.wait_for_selector(video_selector, timeout=10000)
# 获取视频信息
video_info = await page.evaluate("""
() => {
const video = document.querySelector('video');
if (!video) return null;
// 尝试多种方式获取视频URL
let videoUrl = video.src || video.currentSrc;
// 如果没有直接的src,尝试从source标签获取
if (!videoUrl) {
const source = video.querySelector('source');
if (source) {
videoUrl = source.src;
}
}
// 获取视频标题
let title = document.title;
const titleElement = document.querySelector('h1, .video-title, [class*="title"]');
if (titleElement) {
title = titleElement.innerText || title;
}
// 获取作者信息
let author = '';
const authorElement = document.querySelector('[class*="author"], [class*="nickname"]');
if (authorElement) {
author = authorElement.innerText;
}
return {
url: videoUrl,
title: title,
author: author,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight
};
}
""")
if not video_info or not video_info.get('url'):
# 尝试拦截网络请求获取视频URL
video_url = await self._intercept_video_url(page)
if not video_url:
return DownloadResult(
success=False,
task_id=task.task_id,
error_message="无法获取视频URL"
)
video_info = {'url': video_url}
# 获取其他媒体资源
media_urls = await self._extract_media_urls(page)
logger.info(f"获取到视频信息: {video_info}")
# 返回结果(实际下载由其他组件处理)
return DownloadResult(
success=True,
task_id=task.task_id,
file_paths=[], # 这里只返回URL,实际下载由Download类处理
metadata={
'video_url': video_info['url'],
'title': video_info.get('title', ''),
'author': video_info.get('author', ''),
'media_urls': media_urls,
'video_info': video_info
}
)
except Exception as e:
logger.error(f"视频下载失败: {e}")
return DownloadResult(
success=False,
task_id=task.task_id,
error_message=str(e)
)
async def _download_images(self, page: 'Page', task: DownloadTask) -> DownloadResult:
"""下载图集"""
try:
# 等待图片加载
await page.wait_for_selector('img', timeout=10000)
# 获取所有图片URL
image_urls = await page.evaluate("""
() => {
const images = [];
// 查找主要的图片容器
const selectors = [
'.swiper-slide img',
'[class*="image-list"] img',
'[class*="gallery"] img',
'img[class*="image"]'
];
for (const selector of selectors) {
const imgs = document.querySelectorAll(selector);
if (imgs.length > 0) {
imgs.forEach(img => {
const url = img.src || img.dataset.src;
if (url && !url.includes('avatar') && !url.includes('icon')) {
images.push(url);
}
});
break;
}
}
// 如果没找到,获取所有大图
if (images.length === 0) {
document.querySelectorAll('img').forEach(img => {
if (img.naturalWidth > 200 && img.naturalHeight > 200) {
const url = img.src || img.dataset.src;
if (url) images.push(url);
}
});
}
return [...new Set(images)]; // 去重
}
""")
if not image_urls:
return DownloadResult(
success=False,
task_id=task.task_id,
error_message="未找到图片"
)
logger.info(f"找到 {len(image_urls)} 张图片")
return DownloadResult(
success=True,
task_id=task.task_id,
file_paths=[],
metadata={
'image_urls': image_urls,
'count': len(image_urls)
}
)
except Exception as e:
logger.error(f"图集下载失败: {e}")
return DownloadResult(
success=False,
task_id=task.task_id,
error_message=str(e)
)
async def _intercept_video_url(self, page: 'Page') -> Optional[str]:
"""拦截网络请求获取视频URL"""
video_url = None
def handle_response(response):
nonlocal video_url
url = response.url
# 检查是否是视频请求
if any(ext in url for ext in ['.mp4', '.m3u8', '.flv', 'video', 'stream']):
if response.status == 200:
video_url = url
logger.info(f"拦截到视频URL: {url}")
# 监听响应
page.on('response', handle_response)
# 等待一段时间收集请求
await asyncio.sleep(3)
# 尝试触发视频加载
await page.evaluate("""
() => {
const video = document.querySelector('video');
if (video) {
video.play();
}
}
""")
await asyncio.sleep(2)
return video_url
async def _extract_media_urls(self, page: 'Page') -> Dict[str, str]:
"""提取页面中的媒体资源URL"""
media_urls = await page.evaluate("""
() => {
const urls = {};
// 获取音频URL
const audio = document.querySelector('audio');
if (audio) {
urls.audio = audio.src;
}
// 获取封面图
const meta_image = document.querySelector('meta[property="og:image"]');
if (meta_image) {
urls.cover = meta_image.content;
}
// 获取头像
const avatar = document.querySelector('[class*="avatar"] img');
if (avatar) {
urls.avatar = avatar.src;
}
return urls;
}
""")
return media_urls
async def _set_cookies(self, page: 'Page', cookies: Any):
"""设置cookies"""
try:
if isinstance(cookies, str):
# 解析cookie字符串
cookie_list = []
for item in cookies.split(';'):
if '=' in item:
key, value = item.strip().split('=', 1)
cookie_list.append({
'name': key,
'value': value,
'domain': '.douyin.com',
'path': '/'
})
await page.context.add_cookies(cookie_list)
elif isinstance(cookies, list):
await page.context.add_cookies(cookies)
elif isinstance(cookies, dict):
cookie_list = [
{'name': k, 'value': v, 'domain': '.douyin.com', 'path': '/'}
for k, v in cookies.items()
]
await page.context.add_cookies(cookie_list)
logger.info("Cookies设置成功")
except Exception as e:
logger.warning(f"设置Cookies失败: {e}")
async def cleanup(self):
"""清理资源"""
try:
if self.context:
await self.context.close()
self.context = None
if self.browser:
await self.browser.close()
self.browser = None
if self.playwright:
await self.playwright.stop()
self.playwright = None
self.initialized = False
logger.info("浏览器资源已清理")
except Exception as e:
logger.error(f"清理浏览器资源失败: {e}")
async def __aenter__(self):
"""异步上下文管理器入口"""
await self.initialize()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""异步上下文管理器出口"""
await self.cleanup()

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
智能重试策略
包装其他策略并提供智能重试机制
"""
import asyncio
import time
import logging
from typing import Optional, List
from functools import wraps
from .base import IDownloadStrategy, DownloadTask, DownloadResult, TaskStatus
logger = logging.getLogger(__name__)
class RetryStrategy(IDownloadStrategy):
"""智能重试策略,包装其他策略并提供重试机制"""
def __init__(
self,
strategy: IDownloadStrategy,
max_retries: int = 3,
retry_delays: Optional[List[float]] = None,
exponential_backoff: bool = True
):
"""
初始化重试策略
Args:
strategy: 被包装的策略
max_retries: 最大重试次数
retry_delays: 自定义重试延迟列表
exponential_backoff: 是否使用指数退避
"""
self.strategy = strategy
self.max_retries = max_retries
self.retry_delays = retry_delays or [1, 2, 5, 10, 30]
self.exponential_backoff = exponential_backoff
self.retry_stats = {
'total_retries': 0,
'successful_retries': 0,
'failed_retries': 0
}
@property
def name(self) -> str:
return f"Retry({self.strategy.name})"
def get_priority(self) -> int:
"""继承被包装策略的优先级"""
return self.strategy.get_priority()
async def can_handle(self, task: DownloadTask) -> bool:
"""判断是否可以处理任务"""
return await self.strategy.can_handle(task)
async def download(self, task: DownloadTask) -> DownloadResult:
"""执行下载任务,带重试机制"""
original_retry_count = task.retry_count
last_error = None
for attempt in range(self.max_retries):
try:
# 更新任务状态
if attempt > 0:
task.status = TaskStatus.RETRYING
logger.info(f"任务 {task.task_id} 第 {attempt + 1}/{self.max_retries} 次重试")
# 执行下载
result = await self.strategy.download(task)
if result.success:
if attempt > 0:
self.retry_stats['successful_retries'] += 1
logger.info(f"任务 {task.task_id} 重试成功 (第 {attempt + 1} 次)")
return result
# 下载失败,准备重试
last_error = result.error_message
# 检查是否应该重试
if not self._should_retry(result, attempt):
logger.warning(f"任务 {task.task_id} 不符合重试条件,停止重试")
return result
# 计算延迟时间
delay = self._calculate_delay(attempt)
logger.info(f"任务 {task.task_id} 将在 {delay} 秒后重试")
await asyncio.sleep(delay)
# 增加重试计数
task.retry_count += 1
self.retry_stats['total_retries'] += 1
except Exception as e:
last_error = str(e)
logger.error(f"任务 {task.task_id} 执行异常: {e}")
if attempt < self.max_retries - 1:
delay = self._calculate_delay(attempt)
logger.info(f"任务 {task.task_id} 将在 {delay} 秒后重试")
await asyncio.sleep(delay)
task.retry_count += 1
self.retry_stats['total_retries'] += 1
else:
self.retry_stats['failed_retries'] += 1
break
# 所有重试都失败
task.status = TaskStatus.FAILED
self.retry_stats['failed_retries'] += 1
return DownloadResult(
success=False,
task_id=task.task_id,
error_message=f"重试 {self.max_retries} 次后仍然失败: {last_error}",
retry_count=task.retry_count
)
def _should_retry(self, result: DownloadResult, attempt: int) -> bool:
"""判断是否应该重试"""
# 如果已经达到最大重试次数,不重试
if attempt >= self.max_retries - 1:
return False
# 如果没有错误消息,可能是未知错误,应该重试
if not result.error_message:
return True
# 检查是否是可重试的错误
retryable_errors = [
'timeout',
'connection',
'network',
'429', # Too Many Requests
'503', # Service Unavailable
'502', # Bad Gateway
'504', # Gateway Timeout
'空响应',
'返回空',
'empty response',
'temporary'
]
error_lower = result.error_message.lower()
for error in retryable_errors:
if error in error_lower:
return True
# 检查是否是不可重试的错误
non_retryable_errors = [
'404', # Not Found
'403', # Forbidden
'401', # Unauthorized
'invalid',
'not found',
'deleted',
'已删除',
'不存在'
]
for error in non_retryable_errors:
if error in error_lower:
return False
# 默认重试
return True
def _calculate_delay(self, attempt: int) -> float:
"""计算重试延迟时间"""
if self.exponential_backoff:
# 指数退避:2^attempt 秒,最大30秒
delay = min(2 ** attempt, 30)
else:
# 使用预定义的延迟列表
if attempt < len(self.retry_delays):
delay = self.retry_delays[attempt]
else:
delay = self.retry_delays[-1]
# 添加一些随机性以避免同时重试
import random
jitter = random.uniform(0, 0.3 * delay)
return delay + jitter
def get_stats(self) -> dict:
"""获取重试统计信息"""
return self.retry_stats.copy()
def reset_stats(self):
"""重置统计信息"""
self.retry_stats = {
'total_retries': 0,
'successful_retries': 0,
'failed_retries': 0
}
def with_retry(
max_retries: int = 3,
retry_delays: Optional[List[float]] = None,
exponential_backoff: bool = True
):
"""
装饰器:为异步函数添加重试机制
Usage:
@with_retry(max_retries=3)
async def download_file(url):
...
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
delays = retry_delays or [1, 2, 5, 10, 30]
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
if exponential_backoff:
delay = min(2 ** attempt, 30)
else:
delay = delays[attempt] if attempt < len(delays) else delays[-1]
logger.warning(f"函数 {func.__name__} 失败 (尝试 {attempt + 1}/{max_retries}): {e}")
logger.info(f"将在 {delay} 秒后重试")
await asyncio.sleep(delay)
else:
logger.error(f"函数 {func.__name__} 重试 {max_retries} 次后仍然失败")
raise last_exception
return wrapper
return decorator