'commit'
This commit is contained in:
20
DouYin/apiproxy/douyin/__init__.py
Normal file
20
DouYin/apiproxy/douyin/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import apiproxy
|
||||
from apiproxy.common import utils
|
||||
|
||||
douyin_headers = {
|
||||
'User-Agent': apiproxy.ua,
|
||||
'referer': 'https://www.douyin.com/',
|
||||
'accept': 'application/json, text/plain, */*',
|
||||
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'accept-encoding': 'gzip, deflate, br',
|
||||
'sec-ch-ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-origin'
|
||||
# Cookie字段将在运行时动态设置
|
||||
}
|
||||
592
DouYin/apiproxy/douyin/auth/cookie_manager.py
Normal file
592
DouYin/apiproxy/douyin/auth/cookie_manager.py
Normal file
@@ -0,0 +1,592 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Cookie自动管理器
|
||||
自动获取、刷新和管理抖音Cookies
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import pickle
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
logger.warning("Playwright未安装,自动Cookie管理不可用")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CookieInfo:
|
||||
"""Cookie信息"""
|
||||
cookies: List[Dict[str, Any]]
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_used: float = field(default_factory=time.time)
|
||||
login_method: str = "manual" # manual, qrcode, phone
|
||||
is_valid: bool = True
|
||||
|
||||
def is_expired(self, max_age_hours: int = 24) -> bool:
|
||||
"""检查Cookie是否过期"""
|
||||
age = time.time() - self.created_at
|
||||
return age > max_age_hours * 3600
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""转换为字典格式"""
|
||||
return {
|
||||
'cookies': self.cookies,
|
||||
'created_at': self.created_at,
|
||||
'last_used': self.last_used,
|
||||
'login_method': self.login_method,
|
||||
'is_valid': self.is_valid
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> 'CookieInfo':
|
||||
"""从字典创建"""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class AutoCookieManager:
|
||||
"""自动Cookie管理器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookie_file: str = "cookies.pkl",
|
||||
auto_refresh: bool = True,
|
||||
refresh_interval: int = 3600,
|
||||
headless: bool = False
|
||||
):
|
||||
"""
|
||||
初始化Cookie管理器
|
||||
|
||||
Args:
|
||||
cookie_file: Cookie保存文件
|
||||
auto_refresh: 是否自动刷新
|
||||
refresh_interval: 刷新间隔(秒)
|
||||
headless: 浏览器是否无头模式
|
||||
"""
|
||||
self.cookie_file = Path(cookie_file)
|
||||
self.auto_refresh = auto_refresh
|
||||
self.refresh_interval = refresh_interval
|
||||
self.headless = headless
|
||||
|
||||
self.current_cookies: Optional[CookieInfo] = None
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
self.playwright = None
|
||||
|
||||
self._refresh_task = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# 加载已保存的Cookies
|
||||
self._load_cookies()
|
||||
|
||||
def _load_cookies(self):
|
||||
"""从文件加载Cookies"""
|
||||
if self.cookie_file.exists():
|
||||
try:
|
||||
with open(self.cookie_file, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
self.current_cookies = CookieInfo.from_dict(data)
|
||||
logger.info(f"已加载保存的Cookies (创建时间: {datetime.fromtimestamp(self.current_cookies.created_at)})")
|
||||
except Exception as e:
|
||||
logger.error(f"加载Cookies失败: {e}")
|
||||
self.current_cookies = None
|
||||
|
||||
def _save_cookies(self):
|
||||
"""保存Cookies到文件"""
|
||||
if self.current_cookies:
|
||||
try:
|
||||
with open(self.cookie_file, 'wb') as f:
|
||||
pickle.dump(self.current_cookies.to_dict(), f)
|
||||
logger.info("Cookies已保存")
|
||||
except Exception as e:
|
||||
logger.error(f"保存Cookies失败: {e}")
|
||||
|
||||
async def get_cookies(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取有效的Cookies
|
||||
|
||||
Returns:
|
||||
Cookie列表
|
||||
"""
|
||||
async with self._lock:
|
||||
# 检查是否需要刷新
|
||||
if self._need_refresh():
|
||||
await self._refresh_cookies()
|
||||
|
||||
if self.current_cookies and self.current_cookies.is_valid:
|
||||
self.current_cookies.last_used = time.time()
|
||||
return self.current_cookies.cookies
|
||||
|
||||
return None
|
||||
|
||||
def _need_refresh(self) -> bool:
|
||||
"""判断是否需要刷新Cookies"""
|
||||
if not self.current_cookies:
|
||||
return True
|
||||
|
||||
# 检查是否过期
|
||||
if self.current_cookies.is_expired(max_age_hours=24):
|
||||
logger.info("Cookies已过期,需要刷新")
|
||||
return True
|
||||
|
||||
# 检查是否长时间未使用
|
||||
idle_time = time.time() - self.current_cookies.last_used
|
||||
if idle_time > self.refresh_interval:
|
||||
logger.info(f"Cookies已闲置 {idle_time/3600:.1f} 小时,需要刷新")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _refresh_cookies(self):
|
||||
"""登录并获取新的Cookies"""
|
||||
logger.info("需要重新登录获取Cookies")
|
||||
|
||||
try:
|
||||
browser = await self._get_browser()
|
||||
page = await browser.new_page()
|
||||
|
||||
# 访问抖音,放宽等待条件
|
||||
try:
|
||||
await page.goto("https://www.douyin.com", wait_until='domcontentloaded', timeout=120000)
|
||||
# 额外等待页面稳定,给验证码页面加载留出时间
|
||||
await asyncio.sleep(10)
|
||||
except Exception as e:
|
||||
logger.warning(f"页面加载超时,继续尝试: {e}")
|
||||
# 即使超时也继续尝试
|
||||
|
||||
# 检查是否需要登录
|
||||
is_logged_in = await self._check_login_status(page)
|
||||
|
||||
if not is_logged_in:
|
||||
# 执行登录流程
|
||||
login_method = await self._perform_login(page)
|
||||
|
||||
if not login_method:
|
||||
logger.error("登录失败")
|
||||
await page.close()
|
||||
return
|
||||
else:
|
||||
login_method = "already_logged_in"
|
||||
|
||||
# 获取Cookies
|
||||
cookies = await page.context.cookies()
|
||||
|
||||
# 过滤必要的Cookies
|
||||
filtered_cookies = self._filter_cookies(cookies)
|
||||
|
||||
self.current_cookies = CookieInfo(
|
||||
cookies=filtered_cookies,
|
||||
login_method=login_method
|
||||
)
|
||||
|
||||
self._save_cookies()
|
||||
logger.info(f"成功获取Cookies (登录方式: {login_method})")
|
||||
|
||||
await page.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"登录获取Cookies失败: {e}")
|
||||
|
||||
async def _try_refresh_existing(self) -> bool:
|
||||
"""尝试刷新现有Cookies"""
|
||||
try:
|
||||
browser = await self._get_browser()
|
||||
page = await browser.new_page()
|
||||
|
||||
# 设置现有Cookies
|
||||
await page.context.add_cookies(self.current_cookies.cookies)
|
||||
|
||||
# 访问抖音主页
|
||||
await page.goto("https://www.douyin.com", wait_until='networkidle')
|
||||
|
||||
# 检查是否仍然登录
|
||||
is_logged_in = await self._check_login_status(page)
|
||||
|
||||
if is_logged_in:
|
||||
# 获取更新后的Cookies
|
||||
cookies = await page.context.cookies()
|
||||
self.current_cookies = CookieInfo(
|
||||
cookies=cookies,
|
||||
login_method="refresh"
|
||||
)
|
||||
self._save_cookies()
|
||||
logger.info("Cookies刷新成功")
|
||||
await page.close()
|
||||
return True
|
||||
|
||||
await page.close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"刷新Cookies失败: {e}")
|
||||
return False
|
||||
|
||||
async def _login_and_get_cookies(self):
|
||||
"""登录并获取新的Cookies"""
|
||||
logger.info("需要重新登录获取Cookies")
|
||||
|
||||
try:
|
||||
browser = await self._get_browser()
|
||||
page = await browser.new_page()
|
||||
|
||||
# 访问抖音
|
||||
await page.goto("https://www.douyin.com", wait_until='networkidle')
|
||||
|
||||
# 检查是否需要登录
|
||||
is_logged_in = await self._check_login_status(page)
|
||||
|
||||
if not is_logged_in:
|
||||
# 执行登录流程
|
||||
login_method = await self._perform_login(page)
|
||||
|
||||
if not login_method:
|
||||
logger.error("登录失败")
|
||||
await page.close()
|
||||
return
|
||||
else:
|
||||
login_method = "already_logged_in"
|
||||
|
||||
# 获取Cookies
|
||||
cookies = await page.context.cookies()
|
||||
|
||||
# 过滤必要的Cookies
|
||||
filtered_cookies = self._filter_cookies(cookies)
|
||||
|
||||
self.current_cookies = CookieInfo(
|
||||
cookies=filtered_cookies,
|
||||
login_method=login_method
|
||||
)
|
||||
|
||||
self._save_cookies()
|
||||
logger.info(f"成功获取Cookies (登录方式: {login_method})")
|
||||
|
||||
await page.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"登录获取Cookies失败: {e}")
|
||||
|
||||
async def _check_login_status(self, page: 'Page') -> bool:
|
||||
"""检查登录状态"""
|
||||
try:
|
||||
# 查找用户头像或其他登录标识
|
||||
selectors = [
|
||||
'[data-e2e="user-avatar"]',
|
||||
'.user-avatar',
|
||||
'[class*="avatar"]',
|
||||
'.login-success',
|
||||
'[class*="user"]',
|
||||
'[class*="profile"]',
|
||||
'img[alt*="头像"]',
|
||||
'img[alt*="avatar"]',
|
||||
'[data-e2e="profile"]',
|
||||
'.profile-info'
|
||||
]
|
||||
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = await page.wait_for_selector(selector, timeout=5000)
|
||||
if element:
|
||||
logger.info("检测到已登录")
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
|
||||
# 额外检查:查找登录按钮,如果找不到说明可能已登录
|
||||
try:
|
||||
login_indicators = [
|
||||
'[data-e2e="login-button"]',
|
||||
'.login-button',
|
||||
'button:has-text("登录")',
|
||||
'a:has-text("登录")'
|
||||
]
|
||||
|
||||
for indicator in login_indicators:
|
||||
try:
|
||||
element = await page.wait_for_selector(indicator, timeout=2000)
|
||||
if element:
|
||||
logger.info("检测到登录按钮,未登录")
|
||||
return False
|
||||
except:
|
||||
continue
|
||||
|
||||
# 如果找不到登录按钮,可能已登录
|
||||
logger.info("未找到登录按钮,可能已登录")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"检查登录状态失败: {e}")
|
||||
return False
|
||||
|
||||
async def _perform_login(self, page: 'Page') -> Optional[str]:
|
||||
"""执行登录流程"""
|
||||
logger.info("开始登录流程...")
|
||||
|
||||
# 首先尝试二维码登录
|
||||
login_method = await self._qrcode_login(page)
|
||||
|
||||
if not login_method:
|
||||
# 如果二维码登录失败,尝试其他方式
|
||||
login_method = await self._manual_login(page)
|
||||
|
||||
return login_method
|
||||
|
||||
async def _qrcode_login(self, page: Page) -> Optional[str]:
|
||||
"""二维码登录"""
|
||||
try:
|
||||
logger.info("尝试二维码登录...")
|
||||
|
||||
# 查找并点击登录按钮
|
||||
login_button_selectors = [
|
||||
'[data-e2e="login-button"]',
|
||||
'.login-button',
|
||||
'button:has-text("登录")',
|
||||
'a:has-text("登录")',
|
||||
'[class*="login"]',
|
||||
'button:has-text("登入")',
|
||||
'a:has-text("登入")'
|
||||
]
|
||||
|
||||
for selector in login_button_selectors:
|
||||
try:
|
||||
button = await page.wait_for_selector(selector, timeout=15000)
|
||||
if button:
|
||||
await button.click()
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
# 等待登录弹窗
|
||||
await asyncio.sleep(8)
|
||||
|
||||
# 选择二维码登录
|
||||
qr_selectors = [
|
||||
'[data-e2e="qrcode-tab"]',
|
||||
'.qrcode-login',
|
||||
'text=扫码登录',
|
||||
'text=二维码登录',
|
||||
'[class*="qrcode"]',
|
||||
'text=二维码',
|
||||
'text=扫码'
|
||||
]
|
||||
|
||||
for selector in qr_selectors:
|
||||
try:
|
||||
qr_tab = await page.wait_for_selector(selector, timeout=15000)
|
||||
if qr_tab:
|
||||
await qr_tab.click()
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
# 等待二维码出现
|
||||
qr_img_selectors = [
|
||||
'.qrcode-img',
|
||||
'[class*="qrcode"] img',
|
||||
'canvas',
|
||||
'[class*="qr"] img',
|
||||
'img[alt*="二维码"]',
|
||||
'img[alt*="QR"]'
|
||||
]
|
||||
|
||||
qr_found = False
|
||||
for selector in qr_img_selectors:
|
||||
try:
|
||||
await page.wait_for_selector(selector, timeout=20000)
|
||||
qr_found = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not qr_found:
|
||||
logger.warning("未找到二维码,尝试继续等待...")
|
||||
# 即使没找到二维码也继续等待,可能页面还在加载
|
||||
|
||||
if not self.headless:
|
||||
print("\n" + "="*60)
|
||||
print("请使用抖音APP扫描二维码登录")
|
||||
print("如果出现验证码,请完成验证码验证")
|
||||
print("等待登录中...")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# 等待用户扫码(最多等待300秒,给验证码验证留出时间)
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 300:
|
||||
is_logged_in = await self._check_login_status(page)
|
||||
if is_logged_in:
|
||||
logger.info("二维码登录成功")
|
||||
return "qrcode"
|
||||
await asyncio.sleep(8)
|
||||
|
||||
logger.warning("二维码登录超时")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"二维码登录失败: {e}")
|
||||
return None
|
||||
|
||||
async def _manual_login(self, page: Page) -> Optional[str]:
|
||||
"""手动登录(等待用户操作)"""
|
||||
if self.headless:
|
||||
logger.error("无头模式下无法进行手动登录")
|
||||
return None
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("请在浏览器中手动完成登录")
|
||||
print("如果出现验证码,请完成验证码验证")
|
||||
print("登录成功后将自动继续...")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# 等待用户手动登录(最多等待600秒,给验证码验证留出充足时间)
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 600:
|
||||
is_logged_in = await self._check_login_status(page)
|
||||
if is_logged_in:
|
||||
logger.info("手动登录成功")
|
||||
return "manual"
|
||||
await asyncio.sleep(8)
|
||||
|
||||
logger.warning("手动登录超时")
|
||||
return None
|
||||
|
||||
def _filter_cookies(self, cookies: List[Dict]) -> List[Dict]:
|
||||
"""过滤必要的Cookies"""
|
||||
# 必要的Cookie名称
|
||||
required_names = [
|
||||
'msToken',
|
||||
'ttwid',
|
||||
'odin_tt',
|
||||
'passport_csrf_token',
|
||||
'sid_guard',
|
||||
'uid_tt',
|
||||
'sessionid',
|
||||
'sid_tt'
|
||||
]
|
||||
|
||||
filtered = []
|
||||
for cookie in cookies:
|
||||
# 保留必要的Cookie或抖音域名下的所有Cookie
|
||||
if cookie['name'] in required_names or '.douyin.com' in cookie.get('domain', ''):
|
||||
filtered.append(cookie)
|
||||
|
||||
logger.info(f"过滤后保留 {len(filtered)} 个Cookies")
|
||||
return filtered
|
||||
|
||||
async def _get_browser(self) -> Browser:
|
||||
"""获取浏览器实例"""
|
||||
if not self.browser:
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
raise ImportError("Playwright未安装")
|
||||
|
||||
self.playwright = await async_playwright().start()
|
||||
self.browser = await self.playwright.chromium.launch(
|
||||
headless=self.headless,
|
||||
args=[
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
'--no-sandbox'
|
||||
]
|
||||
)
|
||||
|
||||
self.context = await self.browser.new_context(
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
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',
|
||||
locale='zh-CN'
|
||||
)
|
||||
|
||||
return self.context
|
||||
|
||||
async def start_auto_refresh(self):
|
||||
"""启动自动刷新任务"""
|
||||
if self.auto_refresh and not self._refresh_task:
|
||||
self._refresh_task = asyncio.create_task(self._auto_refresh_loop())
|
||||
logger.info("自动Cookie刷新已启动")
|
||||
|
||||
async def stop_auto_refresh(self):
|
||||
"""停止自动刷新任务"""
|
||||
if self._refresh_task:
|
||||
self._refresh_task.cancel()
|
||||
try:
|
||||
await self._refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._refresh_task = None
|
||||
logger.info("自动Cookie刷新已停止")
|
||||
|
||||
async def _auto_refresh_loop(self):
|
||||
"""自动刷新循环"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.refresh_interval)
|
||||
|
||||
if self._need_refresh():
|
||||
logger.info("触发自动Cookie刷新")
|
||||
await self._refresh_cookies()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"自动刷新异常: {e}")
|
||||
await asyncio.sleep(60) # 出错后等待1分钟再试
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理资源"""
|
||||
await self.stop_auto_refresh()
|
||||
|
||||
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
|
||||
|
||||
logger.info("Cookie管理器资源已清理")
|
||||
|
||||
def get_cookie_dict(self) -> Optional[Dict[str, str]]:
|
||||
"""获取Cookie字典格式"""
|
||||
if not self.current_cookies:
|
||||
return None
|
||||
|
||||
cookie_dict = {}
|
||||
for cookie in self.current_cookies.cookies:
|
||||
cookie_dict[cookie['name']] = cookie['value']
|
||||
|
||||
return cookie_dict
|
||||
|
||||
def get_cookie_string(self) -> Optional[str]:
|
||||
"""获取Cookie字符串格式"""
|
||||
cookie_dict = self.get_cookie_dict()
|
||||
if not cookie_dict:
|
||||
return None
|
||||
|
||||
return '; '.join([f'{k}={v}' for k, v in cookie_dict.items()])
|
||||
|
||||
async def __aenter__(self):
|
||||
"""异步上下文管理器入口"""
|
||||
await self.start_auto_refresh()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""异步上下文管理器出口"""
|
||||
await self.cleanup()
|
||||
421
DouYin/apiproxy/douyin/core/orchestrator.py
Normal file
421
DouYin/apiproxy/douyin/core/orchestrator.py
Normal file
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
下载编排器
|
||||
协调多种下载策略,实现智能降级和任务管理
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from apiproxy.douyin.strategies.base import (
|
||||
IDownloadStrategy,
|
||||
DownloadTask,
|
||||
DownloadResult,
|
||||
TaskType,
|
||||
TaskStatus
|
||||
)
|
||||
from apiproxy.douyin.strategies.api_strategy import EnhancedAPIStrategy
|
||||
from apiproxy.douyin.strategies.retry_strategy import RetryStrategy
|
||||
from .rate_limiter import AdaptiveRateLimiter, RateLimitConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrchestratorConfig:
|
||||
"""编排器配置"""
|
||||
def __init__(
|
||||
self,
|
||||
max_concurrent: int = 5,
|
||||
enable_retry: bool = True,
|
||||
enable_rate_limit: bool = True,
|
||||
rate_limit_config: Optional[RateLimitConfig] = None,
|
||||
priority_queue: bool = True,
|
||||
save_progress: bool = True
|
||||
):
|
||||
self.max_concurrent = max_concurrent
|
||||
self.enable_retry = enable_retry
|
||||
self.enable_rate_limit = enable_rate_limit
|
||||
self.rate_limit_config = rate_limit_config or RateLimitConfig()
|
||||
self.priority_queue = priority_queue
|
||||
self.save_progress = save_progress
|
||||
|
||||
|
||||
class DownloadOrchestrator:
|
||||
"""下载任务编排器"""
|
||||
|
||||
def __init__(self, config: Optional[OrchestratorConfig] = None):
|
||||
"""
|
||||
初始化编排器
|
||||
|
||||
Args:
|
||||
config: 编排器配置
|
||||
"""
|
||||
self.config = config or OrchestratorConfig()
|
||||
self.strategies: List[IDownloadStrategy] = []
|
||||
self.rate_limiter = AdaptiveRateLimiter(self.config.rate_limit_config) if self.config.enable_rate_limit else None
|
||||
|
||||
# 任务队列
|
||||
self.pending_queue = asyncio.Queue()
|
||||
self.priority_tasks: List[DownloadTask] = []
|
||||
self.active_tasks: Dict[str, DownloadTask] = {}
|
||||
self.completed_tasks: List[DownloadTask] = []
|
||||
self.failed_tasks: List[DownloadTask] = []
|
||||
|
||||
# 工作线程
|
||||
self.workers: List[asyncio.Task] = []
|
||||
self.running = False
|
||||
|
||||
# 统计信息
|
||||
self.stats = {
|
||||
'total_tasks': 0,
|
||||
'completed_tasks': 0,
|
||||
'failed_tasks': 0,
|
||||
'retried_tasks': 0,
|
||||
'average_duration': 0.0,
|
||||
'success_rate': 0.0
|
||||
}
|
||||
|
||||
# 初始化默认策略
|
||||
self._init_default_strategies()
|
||||
|
||||
def _init_default_strategies(self):
|
||||
"""初始化默认策略"""
|
||||
# API策略
|
||||
api_strategy = EnhancedAPIStrategy()
|
||||
|
||||
# 如果启用重试,包装策略
|
||||
if self.config.enable_retry:
|
||||
api_strategy = RetryStrategy(api_strategy)
|
||||
|
||||
self.register_strategy(api_strategy)
|
||||
|
||||
def register_strategy(self, strategy: IDownloadStrategy):
|
||||
"""
|
||||
注册下载策略
|
||||
|
||||
Args:
|
||||
strategy: 下载策略实例
|
||||
"""
|
||||
self.strategies.append(strategy)
|
||||
# 按优先级排序
|
||||
self.strategies.sort(key=lambda s: s.get_priority(), reverse=True)
|
||||
logger.info(f"注册策略: {strategy.name} (优先级: {strategy.get_priority()})")
|
||||
|
||||
async def add_task(self, url: str, task_type: Optional[TaskType] = None, priority: int = 0) -> str:
|
||||
"""
|
||||
添加下载任务
|
||||
|
||||
Args:
|
||||
url: 下载URL
|
||||
task_type: 任务类型
|
||||
priority: 优先级(数值越大优先级越高)
|
||||
|
||||
Returns:
|
||||
任务ID
|
||||
"""
|
||||
# 自动识别任务类型
|
||||
if task_type is None:
|
||||
task_type = self._detect_task_type(url)
|
||||
|
||||
# 创建任务
|
||||
task = DownloadTask(
|
||||
task_id=str(uuid.uuid4()),
|
||||
url=url,
|
||||
task_type=task_type,
|
||||
priority=priority
|
||||
)
|
||||
|
||||
# 添加到队列
|
||||
if self.config.priority_queue and priority > 0:
|
||||
self.priority_tasks.append(task)
|
||||
self.priority_tasks.sort(key=lambda t: t.priority, reverse=True)
|
||||
else:
|
||||
await self.pending_queue.put(task)
|
||||
|
||||
self.stats['total_tasks'] += 1
|
||||
logger.info(f"添加任务: {task.task_id} ({task_type.value}) 优先级: {priority}")
|
||||
|
||||
return task.task_id
|
||||
|
||||
async def add_batch(self, urls: List[str], task_type: Optional[TaskType] = None) -> List[str]:
|
||||
"""
|
||||
批量添加任务
|
||||
|
||||
Args:
|
||||
urls: URL列表
|
||||
task_type: 任务类型
|
||||
|
||||
Returns:
|
||||
任务ID列表
|
||||
"""
|
||||
task_ids = []
|
||||
for i, url in enumerate(urls):
|
||||
# 批量任务使用递减优先级
|
||||
priority = len(urls) - i
|
||||
task_id = await self.add_task(url, task_type, priority)
|
||||
task_ids.append(task_id)
|
||||
|
||||
return task_ids
|
||||
|
||||
async def start(self):
|
||||
"""启动编排器"""
|
||||
if self.running:
|
||||
logger.warning("编排器已在运行")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
logger.info(f"启动编排器,最大并发数: {self.config.max_concurrent}")
|
||||
|
||||
# 创建工作线程
|
||||
for i in range(self.config.max_concurrent):
|
||||
worker = asyncio.create_task(self._worker(i))
|
||||
self.workers.append(worker)
|
||||
|
||||
async def stop(self):
|
||||
"""停止编排器"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("停止编排器...")
|
||||
self.running = False
|
||||
|
||||
# 取消所有工作线程
|
||||
for worker in self.workers:
|
||||
worker.cancel()
|
||||
|
||||
# 等待工作线程结束
|
||||
await asyncio.gather(*self.workers, return_exceptions=True)
|
||||
self.workers.clear()
|
||||
|
||||
logger.info("编排器已停止")
|
||||
|
||||
async def wait_completion(self, timeout: Optional[float] = None):
|
||||
"""
|
||||
等待所有任务完成
|
||||
|
||||
Args:
|
||||
timeout: 超时时间(秒)
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while self.running:
|
||||
# 检查是否所有任务都完成
|
||||
if (self.pending_queue.empty() and
|
||||
not self.priority_tasks and
|
||||
not self.active_tasks):
|
||||
logger.info("所有任务已完成")
|
||||
break
|
||||
|
||||
# 检查超时
|
||||
if timeout and (time.time() - start_time) > timeout:
|
||||
logger.warning(f"等待超时 ({timeout} 秒)")
|
||||
break
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 计算统计信息
|
||||
self._calculate_stats()
|
||||
|
||||
async def _worker(self, worker_id: int):
|
||||
"""
|
||||
工作线程
|
||||
|
||||
Args:
|
||||
worker_id: 工作线程ID
|
||||
"""
|
||||
logger.info(f"工作线程 {worker_id} 启动")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# 获取任务
|
||||
task = await self._get_next_task()
|
||||
if task is None:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
# 标记为活动任务
|
||||
self.active_tasks[task.task_id] = task
|
||||
|
||||
# 限速控制
|
||||
if self.rate_limiter:
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
# 执行任务
|
||||
logger.info(f"工作线程 {worker_id} 开始处理任务: {task.task_id}")
|
||||
result = await self._execute_task(task)
|
||||
|
||||
# 移除活动任务
|
||||
del self.active_tasks[task.task_id]
|
||||
|
||||
# 处理结果
|
||||
if result.success:
|
||||
self.completed_tasks.append(task)
|
||||
self.stats['completed_tasks'] += 1
|
||||
logger.info(f"任务 {task.task_id} 完成")
|
||||
else:
|
||||
# 检查是否需要重试
|
||||
if task.increment_retry():
|
||||
logger.warning(f"任务 {task.task_id} 失败,准备重试 ({task.retry_count}/{task.max_retries})")
|
||||
await self.pending_queue.put(task)
|
||||
self.stats['retried_tasks'] += 1
|
||||
else:
|
||||
self.failed_tasks.append(task)
|
||||
self.stats['failed_tasks'] += 1
|
||||
logger.error(f"任务 {task.task_id} 最终失败: {result.error_message}")
|
||||
|
||||
# 保存进度
|
||||
if self.config.save_progress:
|
||||
await self._save_progress()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"工作线程 {worker_id} 被取消")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"工作线程 {worker_id} 异常: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.info(f"工作线程 {worker_id} 结束")
|
||||
|
||||
async def _get_next_task(self) -> Optional[DownloadTask]:
|
||||
"""获取下一个任务"""
|
||||
# 优先处理高优先级任务
|
||||
if self.priority_tasks:
|
||||
return self.priority_tasks.pop(0)
|
||||
|
||||
# 从普通队列获取
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self.pending_queue.get(),
|
||||
timeout=0.1
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
async def _execute_task(self, task: DownloadTask) -> DownloadResult:
|
||||
"""
|
||||
执行任务,尝试所有策略
|
||||
|
||||
Args:
|
||||
task: 下载任务
|
||||
|
||||
Returns:
|
||||
下载结果
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for strategy in self.strategies:
|
||||
try:
|
||||
# 检查策略是否能处理任务
|
||||
if not await strategy.can_handle(task):
|
||||
continue
|
||||
|
||||
logger.info(f"使用策略 {strategy.name} 处理任务 {task.task_id}")
|
||||
|
||||
# 执行下载
|
||||
result = await strategy.download(task)
|
||||
|
||||
if result.success:
|
||||
return result
|
||||
|
||||
last_error = result.error_message
|
||||
logger.warning(f"策略 {strategy.name} 失败: {last_error}")
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
logger.error(f"策略 {strategy.name} 异常: {e}")
|
||||
|
||||
# 所有策略都失败
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
task_id=task.task_id,
|
||||
error_message=f"所有策略都失败: {last_error}",
|
||||
retry_count=task.retry_count
|
||||
)
|
||||
|
||||
def _detect_task_type(self, url: str) -> TaskType:
|
||||
"""
|
||||
自动检测任务类型
|
||||
|
||||
Args:
|
||||
url: URL
|
||||
|
||||
Returns:
|
||||
任务类型
|
||||
"""
|
||||
url_lower = url.lower()
|
||||
|
||||
if '/user/' in url_lower:
|
||||
return TaskType.USER
|
||||
elif '/video/' in url_lower or '/note/' in url_lower:
|
||||
return TaskType.VIDEO
|
||||
elif '/music/' in url_lower:
|
||||
return TaskType.MUSIC
|
||||
elif '/mix/' in url_lower or '/collection/' in url_lower:
|
||||
return TaskType.MIX
|
||||
elif 'live.douyin.com' in url_lower:
|
||||
return TaskType.LIVE
|
||||
else:
|
||||
return TaskType.VIDEO # 默认为视频
|
||||
|
||||
def _calculate_stats(self):
|
||||
"""计算统计信息"""
|
||||
total = self.stats['total_tasks']
|
||||
if total > 0:
|
||||
self.stats['success_rate'] = self.stats['completed_tasks'] / total * 100
|
||||
|
||||
# 计算平均时长
|
||||
durations = []
|
||||
for task in self.completed_tasks:
|
||||
if hasattr(task, 'duration'):
|
||||
durations.append(task.duration)
|
||||
|
||||
if durations:
|
||||
self.stats['average_duration'] = sum(durations) / len(durations)
|
||||
|
||||
async def _save_progress(self):
|
||||
"""保存进度(可扩展为持久化到文件或数据库)"""
|
||||
# TODO: 实现进度保存逻辑
|
||||
pass
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
self._calculate_stats()
|
||||
return self.stats.copy()
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[TaskStatus]:
|
||||
"""
|
||||
获取任务状态
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
|
||||
Returns:
|
||||
任务状态
|
||||
"""
|
||||
# 检查活动任务
|
||||
if task_id in self.active_tasks:
|
||||
return self.active_tasks[task_id].status
|
||||
|
||||
# 检查完成任务
|
||||
for task in self.completed_tasks:
|
||||
if task.task_id == task_id:
|
||||
return TaskStatus.COMPLETED
|
||||
|
||||
# 检查失败任务
|
||||
for task in self.failed_tasks:
|
||||
if task.task_id == task_id:
|
||||
return TaskStatus.FAILED
|
||||
|
||||
# 检查待处理任务
|
||||
for task in self.priority_tasks:
|
||||
if task.task_id == task_id:
|
||||
return TaskStatus.PENDING
|
||||
|
||||
return None
|
||||
503
DouYin/apiproxy/douyin/core/progress_tracker.py
Normal file
503
DouYin/apiproxy/douyin/core/progress_tracker.py
Normal file
@@ -0,0 +1,503 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
实时进度跟踪系统
|
||||
支持WebSocket推送和进度监控
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 动态导入WebSocket支持
|
||||
try:
|
||||
import websockets
|
||||
from websockets.server import WebSocketServerProtocol
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
logger.warning("websockets未安装,WebSocket功能不可用")
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""事件类型"""
|
||||
TASK_ADDED = "task_added"
|
||||
TASK_STARTED = "task_started"
|
||||
TASK_PROGRESS = "task_progress"
|
||||
TASK_COMPLETED = "task_completed"
|
||||
TASK_FAILED = "task_failed"
|
||||
TASK_RETRYING = "task_retrying"
|
||||
STATS_UPDATE = "stats_update"
|
||||
SPEED_UPDATE = "speed_update"
|
||||
ERROR = "error"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressEvent:
|
||||
"""进度事件"""
|
||||
event_type: EventType
|
||||
task_id: Optional[str] = None
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'event_type': self.event_type.value,
|
||||
'task_id': self.task_id,
|
||||
'data': self.data,
|
||||
'timestamp': self.timestamp
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""转换为JSON"""
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskProgress:
|
||||
"""任务进度信息"""
|
||||
task_id: str
|
||||
url: str
|
||||
status: str
|
||||
progress: float = 0.0 # 0-100
|
||||
downloaded_bytes: int = 0
|
||||
total_bytes: int = 0
|
||||
speed: float = 0.0 # bytes/second
|
||||
eta: float = 0.0 # seconds
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def get_duration(self) -> float:
|
||||
"""获取耗时"""
|
||||
if self.end_time:
|
||||
return self.end_time - self.start_time
|
||||
return time.time() - self.start_time
|
||||
|
||||
def update_progress(self, downloaded: int, total: int):
|
||||
"""更新进度"""
|
||||
self.downloaded_bytes = downloaded
|
||||
self.total_bytes = total
|
||||
|
||||
if total > 0:
|
||||
self.progress = (downloaded / total) * 100
|
||||
|
||||
# 计算速度
|
||||
duration = self.get_duration()
|
||||
if duration > 0:
|
||||
self.speed = downloaded / duration
|
||||
|
||||
# 计算剩余时间
|
||||
if self.speed > 0 and total > downloaded:
|
||||
self.eta = (total - downloaded) / self.speed
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'task_id': self.task_id,
|
||||
'url': self.url,
|
||||
'status': self.status,
|
||||
'progress': round(self.progress, 2),
|
||||
'downloaded_bytes': self.downloaded_bytes,
|
||||
'total_bytes': self.total_bytes,
|
||||
'speed': round(self.speed, 2),
|
||||
'eta': round(self.eta, 2),
|
||||
'duration': round(self.get_duration(), 2),
|
||||
'error_message': self.error_message
|
||||
}
|
||||
|
||||
|
||||
class ProgressTracker:
|
||||
"""进度跟踪器"""
|
||||
|
||||
def __init__(self, enable_websocket: bool = True, ws_port: int = 8765):
|
||||
"""
|
||||
初始化进度跟踪器
|
||||
|
||||
Args:
|
||||
enable_websocket: 是否启用WebSocket
|
||||
ws_port: WebSocket端口
|
||||
"""
|
||||
self.enable_websocket = enable_websocket and WEBSOCKET_AVAILABLE
|
||||
self.ws_port = ws_port
|
||||
|
||||
# 任务进度
|
||||
self.tasks: Dict[str, TaskProgress] = {}
|
||||
|
||||
# 事件监听器
|
||||
self.listeners: List[Callable[[ProgressEvent], None]] = []
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket_clients: List[WebSocketServerProtocol] = []
|
||||
self.websocket_server = None
|
||||
self.ws_task = None
|
||||
|
||||
# 统计信息
|
||||
self.stats = {
|
||||
'total_tasks': 0,
|
||||
'active_tasks': 0,
|
||||
'completed_tasks': 0,
|
||||
'failed_tasks': 0,
|
||||
'total_downloaded': 0,
|
||||
'average_speed': 0.0,
|
||||
'success_rate': 0.0
|
||||
}
|
||||
|
||||
# 速度历史(用于计算平均速度)
|
||||
self.speed_history = []
|
||||
self.max_speed_history = 100
|
||||
|
||||
def add_listener(self, listener: Callable[[ProgressEvent], None]):
|
||||
"""添加事件监听器"""
|
||||
self.listeners.append(listener)
|
||||
logger.debug(f"添加事件监听器: {listener}")
|
||||
|
||||
def remove_listener(self, listener: Callable[[ProgressEvent], None]):
|
||||
"""移除事件监听器"""
|
||||
if listener in self.listeners:
|
||||
self.listeners.remove(listener)
|
||||
|
||||
async def emit_event(self, event: ProgressEvent):
|
||||
"""触发事件"""
|
||||
# 通知监听器
|
||||
for listener in self.listeners:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(listener):
|
||||
await listener(event)
|
||||
else:
|
||||
listener(event)
|
||||
except Exception as e:
|
||||
logger.error(f"事件监听器执行失败: {e}")
|
||||
|
||||
# 推送到WebSocket客户端
|
||||
if self.websocket_clients:
|
||||
await self._broadcast_websocket(event.to_json())
|
||||
|
||||
async def add_task(self, task_id: str, url: str):
|
||||
"""添加任务"""
|
||||
self.tasks[task_id] = TaskProgress(
|
||||
task_id=task_id,
|
||||
url=url,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
self.stats['total_tasks'] += 1
|
||||
|
||||
await self.emit_event(ProgressEvent(
|
||||
event_type=EventType.TASK_ADDED,
|
||||
task_id=task_id,
|
||||
data={'url': url}
|
||||
))
|
||||
|
||||
async def start_task(self, task_id: str):
|
||||
"""开始任务"""
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = "processing"
|
||||
self.tasks[task_id].start_time = time.time()
|
||||
|
||||
self.stats['active_tasks'] += 1
|
||||
|
||||
await self.emit_event(ProgressEvent(
|
||||
event_type=EventType.TASK_STARTED,
|
||||
task_id=task_id
|
||||
))
|
||||
|
||||
async def update_progress(
|
||||
self,
|
||||
task_id: str,
|
||||
downloaded: int,
|
||||
total: int,
|
||||
extra_data: Optional[Dict] = None
|
||||
):
|
||||
"""更新任务进度"""
|
||||
if task_id not in self.tasks:
|
||||
return
|
||||
|
||||
task = self.tasks[task_id]
|
||||
task.update_progress(downloaded, total)
|
||||
|
||||
# 更新速度历史
|
||||
if task.speed > 0:
|
||||
self.speed_history.append(task.speed)
|
||||
if len(self.speed_history) > self.max_speed_history:
|
||||
self.speed_history.pop(0)
|
||||
|
||||
# 计算平均速度
|
||||
self.stats['average_speed'] = sum(self.speed_history) / len(self.speed_history)
|
||||
|
||||
event_data = task.to_dict()
|
||||
if extra_data:
|
||||
event_data.update(extra_data)
|
||||
|
||||
await self.emit_event(ProgressEvent(
|
||||
event_type=EventType.TASK_PROGRESS,
|
||||
task_id=task_id,
|
||||
data=event_data
|
||||
))
|
||||
|
||||
async def complete_task(self, task_id: str, success: bool = True, error: Optional[str] = None):
|
||||
"""完成任务"""
|
||||
if task_id not in self.tasks:
|
||||
return
|
||||
|
||||
task = self.tasks[task_id]
|
||||
task.end_time = time.time()
|
||||
|
||||
if success:
|
||||
task.status = "completed"
|
||||
task.progress = 100.0
|
||||
self.stats['completed_tasks'] += 1
|
||||
event_type = EventType.TASK_COMPLETED
|
||||
else:
|
||||
task.status = "failed"
|
||||
task.error_message = error
|
||||
self.stats['failed_tasks'] += 1
|
||||
event_type = EventType.TASK_FAILED
|
||||
|
||||
self.stats['active_tasks'] = max(0, self.stats['active_tasks'] - 1)
|
||||
|
||||
# 更新成功率
|
||||
total_finished = self.stats['completed_tasks'] + self.stats['failed_tasks']
|
||||
if total_finished > 0:
|
||||
self.stats['success_rate'] = (self.stats['completed_tasks'] / total_finished) * 100
|
||||
|
||||
await self.emit_event(ProgressEvent(
|
||||
event_type=event_type,
|
||||
task_id=task_id,
|
||||
data=task.to_dict()
|
||||
))
|
||||
|
||||
async def retry_task(self, task_id: str, retry_count: int):
|
||||
"""重试任务"""
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = "retrying"
|
||||
|
||||
await self.emit_event(ProgressEvent(
|
||||
event_type=EventType.TASK_RETRYING,
|
||||
task_id=task_id,
|
||||
data={'retry_count': retry_count}
|
||||
))
|
||||
|
||||
async def update_stats(self):
|
||||
"""更新统计信息"""
|
||||
await self.emit_event(ProgressEvent(
|
||||
event_type=EventType.STATS_UPDATE,
|
||||
data=self.stats.copy()
|
||||
))
|
||||
|
||||
def get_task_progress(self, task_id: str) -> Optional[TaskProgress]:
|
||||
"""获取任务进度"""
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
def get_active_tasks(self) -> List[TaskProgress]:
|
||||
"""获取活动任务"""
|
||||
return [
|
||||
task for task in self.tasks.values()
|
||||
if task.status in ["processing", "retrying"]
|
||||
]
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
return self.stats.copy()
|
||||
|
||||
def clear_completed_tasks(self):
|
||||
"""清理已完成的任务"""
|
||||
completed_ids = [
|
||||
task_id for task_id, task in self.tasks.items()
|
||||
if task.status in ["completed", "failed"]
|
||||
]
|
||||
|
||||
for task_id in completed_ids:
|
||||
del self.tasks[task_id]
|
||||
|
||||
logger.info(f"清理了 {len(completed_ids)} 个已完成任务")
|
||||
|
||||
# WebSocket相关功能
|
||||
async def start_websocket_server(self):
|
||||
"""启动WebSocket服务器"""
|
||||
if not self.enable_websocket:
|
||||
logger.info("WebSocket功能未启用")
|
||||
return
|
||||
|
||||
try:
|
||||
self.websocket_server = await websockets.serve(
|
||||
self._handle_websocket,
|
||||
"localhost",
|
||||
self.ws_port
|
||||
)
|
||||
|
||||
logger.info(f"WebSocket服务器启动在 ws://localhost:{self.ws_port}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动WebSocket服务器失败: {e}")
|
||||
|
||||
async def stop_websocket_server(self):
|
||||
"""停止WebSocket服务器"""
|
||||
if self.websocket_server:
|
||||
self.websocket_server.close()
|
||||
await self.websocket_server.wait_closed()
|
||||
self.websocket_server = None
|
||||
logger.info("WebSocket服务器已停止")
|
||||
|
||||
async def _handle_websocket(self, websocket: WebSocketServerProtocol, path: str):
|
||||
"""处理WebSocket连接"""
|
||||
logger.info(f"新的WebSocket连接: {websocket.remote_address}")
|
||||
|
||||
# 添加客户端
|
||||
self.websocket_clients.append(websocket)
|
||||
|
||||
try:
|
||||
# 发送当前状态
|
||||
await websocket.send(json.dumps({
|
||||
'type': 'init',
|
||||
'data': {
|
||||
'tasks': {
|
||||
task_id: task.to_dict()
|
||||
for task_id, task in self.tasks.items()
|
||||
},
|
||||
'stats': self.stats
|
||||
}
|
||||
}))
|
||||
|
||||
# 保持连接
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await self._handle_ws_message(websocket, data)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"无效的WebSocket消息: {message}")
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"WebSocket连接关闭: {websocket.remote_address}")
|
||||
finally:
|
||||
# 移除客户端
|
||||
if websocket in self.websocket_clients:
|
||||
self.websocket_clients.remove(websocket)
|
||||
|
||||
async def _handle_ws_message(self, websocket: WebSocketServerProtocol, data: Dict):
|
||||
"""处理WebSocket消息"""
|
||||
msg_type = data.get('type')
|
||||
|
||||
if msg_type == 'ping':
|
||||
await websocket.send(json.dumps({'type': 'pong'}))
|
||||
elif msg_type == 'get_stats':
|
||||
await websocket.send(json.dumps({
|
||||
'type': 'stats',
|
||||
'data': self.stats
|
||||
}))
|
||||
elif msg_type == 'get_tasks':
|
||||
await websocket.send(json.dumps({
|
||||
'type': 'tasks',
|
||||
'data': {
|
||||
task_id: task.to_dict()
|
||||
for task_id, task in self.tasks.items()
|
||||
}
|
||||
}))
|
||||
|
||||
async def _broadcast_websocket(self, message: str):
|
||||
"""广播WebSocket消息"""
|
||||
if not self.websocket_clients:
|
||||
return
|
||||
|
||||
# 并发发送给所有客户端
|
||||
disconnected = []
|
||||
|
||||
for client in self.websocket_clients:
|
||||
try:
|
||||
await client.send(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
disconnected.append(client)
|
||||
|
||||
# 清理断开的连接
|
||||
for client in disconnected:
|
||||
self.websocket_clients.remove(client)
|
||||
|
||||
# 上下文管理器
|
||||
async def __aenter__(self):
|
||||
"""异步上下文管理器入口"""
|
||||
await self.start_websocket_server()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""异步上下文管理器出口"""
|
||||
await self.stop_websocket_server()
|
||||
|
||||
|
||||
def create_console_listener(use_rich: bool = True):
|
||||
"""
|
||||
创建控制台进度监听器
|
||||
|
||||
Args:
|
||||
use_rich: 是否使用Rich库美化输出
|
||||
|
||||
Returns:
|
||||
监听器函数
|
||||
"""
|
||||
if use_rich:
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
)
|
||||
|
||||
task_map = {}
|
||||
|
||||
def rich_listener(event: ProgressEvent):
|
||||
if event.event_type == EventType.TASK_ADDED:
|
||||
task_id = event.task_id
|
||||
if task_id not in task_map:
|
||||
task_map[task_id] = progress.add_task(
|
||||
f"[cyan]{event.data.get('url', 'Unknown')}",
|
||||
total=100
|
||||
)
|
||||
|
||||
elif event.event_type == EventType.TASK_PROGRESS:
|
||||
task_id = event.task_id
|
||||
if task_id in task_map:
|
||||
progress.update(
|
||||
task_map[task_id],
|
||||
completed=event.data.get('progress', 0)
|
||||
)
|
||||
|
||||
elif event.event_type == EventType.TASK_COMPLETED:
|
||||
console.print(f"[green]✓ 任务完成: {event.task_id}")
|
||||
|
||||
elif event.event_type == EventType.TASK_FAILED:
|
||||
console.print(f"[red]✗ 任务失败: {event.task_id} - {event.data.get('error_message', 'Unknown error')}")
|
||||
|
||||
return rich_listener
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 默认简单监听器
|
||||
def simple_listener(event: ProgressEvent):
|
||||
timestamp = datetime.fromtimestamp(event.timestamp).strftime('%H:%M:%S')
|
||||
|
||||
if event.event_type == EventType.TASK_PROGRESS:
|
||||
progress = event.data.get('progress', 0)
|
||||
speed = event.data.get('speed', 0)
|
||||
print(f"[{timestamp}] 任务 {event.task_id}: {progress:.1f}% ({speed/1024/1024:.2f} MB/s)")
|
||||
elif event.event_type == EventType.TASK_COMPLETED:
|
||||
print(f"[{timestamp}] ✓ 任务完成: {event.task_id}")
|
||||
elif event.event_type == EventType.TASK_FAILED:
|
||||
print(f"[{timestamp}] ✗ 任务失败: {event.task_id}")
|
||||
|
||||
return simple_listener
|
||||
494
DouYin/apiproxy/douyin/core/queue_manager.py
Normal file
494
DouYin/apiproxy/douyin/core/queue_manager.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
队列管理器
|
||||
支持任务持久化和断点恢复
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
import logging
|
||||
import pickle
|
||||
from typing import List, Dict, Optional, Any
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
|
||||
from apiproxy.douyin.strategies.base import DownloadTask, TaskStatus, TaskType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PersistentQueue:
|
||||
"""持久化队列管理器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str = "download_queue.db",
|
||||
max_size: int = 10000,
|
||||
checkpoint_interval: int = 60
|
||||
):
|
||||
"""
|
||||
初始化队列管理器
|
||||
|
||||
Args:
|
||||
db_path: 数据库文件路径
|
||||
max_size: 队列最大容量
|
||||
checkpoint_interval: 检查点保存间隔(秒)
|
||||
"""
|
||||
self.db_path = Path(db_path)
|
||||
self.max_size = max_size
|
||||
self.checkpoint_interval = checkpoint_interval
|
||||
|
||||
self.conn: Optional[sqlite3.Connection] = None
|
||||
self.queue = asyncio.Queue(maxsize=max_size)
|
||||
self._checkpoint_task = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# 初始化数据库
|
||||
self._init_database()
|
||||
|
||||
# 恢复未完成的任务
|
||||
self._restore_tasks()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库"""
|
||||
self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# 创建任务表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
priority INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
max_retries INTEGER DEFAULT 3,
|
||||
metadata TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
completed_at REAL,
|
||||
error_message TEXT,
|
||||
result TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON tasks(status)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_priority ON tasks(priority DESC)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_created_at ON tasks(created_at)')
|
||||
|
||||
# 创建进度表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS progress (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
total_tasks INTEGER,
|
||||
pending_tasks INTEGER,
|
||||
active_tasks INTEGER,
|
||||
completed_tasks INTEGER,
|
||||
failed_tasks INTEGER,
|
||||
success_rate REAL,
|
||||
average_duration REAL
|
||||
)
|
||||
''')
|
||||
|
||||
self.conn.commit()
|
||||
logger.info(f"数据库初始化完成: {self.db_path}")
|
||||
|
||||
def _restore_tasks(self):
|
||||
"""从数据库恢复未完成的任务"""
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# 将所有PROCESSING状态的任务重置为PENDING
|
||||
cursor.execute('''
|
||||
UPDATE tasks
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE status = ?
|
||||
''', (TaskStatus.PENDING.value, time.time(), TaskStatus.PROCESSING.value))
|
||||
|
||||
# 获取所有待处理的任务
|
||||
cursor.execute('''
|
||||
SELECT task_id, url, task_type, priority, retry_count, max_retries, metadata, created_at
|
||||
FROM tasks
|
||||
WHERE status IN (?, ?)
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
''', (TaskStatus.PENDING.value, TaskStatus.RETRYING.value))
|
||||
|
||||
restored_count = 0
|
||||
for row in cursor.fetchall():
|
||||
task = self._row_to_task(row)
|
||||
if task:
|
||||
try:
|
||||
self.queue.put_nowait(task)
|
||||
restored_count += 1
|
||||
except asyncio.QueueFull:
|
||||
break
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
if restored_count > 0:
|
||||
logger.info(f"从数据库恢复了 {restored_count} 个未完成任务")
|
||||
|
||||
def _row_to_task(self, row: tuple) -> Optional[DownloadTask]:
|
||||
"""将数据库行转换为任务对象"""
|
||||
try:
|
||||
task_id, url, task_type, priority, retry_count, max_retries, metadata_str, created_at = row
|
||||
|
||||
metadata = {}
|
||||
if metadata_str:
|
||||
try:
|
||||
metadata = json.loads(metadata_str)
|
||||
except:
|
||||
pass
|
||||
|
||||
return DownloadTask(
|
||||
task_id=task_id,
|
||||
url=url,
|
||||
task_type=TaskType(task_type),
|
||||
priority=priority,
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
metadata=metadata,
|
||||
created_at=created_at
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"转换任务失败: {e}")
|
||||
return None
|
||||
|
||||
async def add_task(self, task: DownloadTask) -> bool:
|
||||
"""
|
||||
添加任务到队列
|
||||
|
||||
Args:
|
||||
task: 下载任务
|
||||
|
||||
Returns:
|
||||
是否成功添加
|
||||
"""
|
||||
async with self._lock:
|
||||
try:
|
||||
# 保存到数据库
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO tasks (
|
||||
task_id, url, task_type, priority, status,
|
||||
retry_count, max_retries, metadata,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
task.task_id,
|
||||
task.url,
|
||||
task.task_type.value,
|
||||
task.priority,
|
||||
task.status.value,
|
||||
task.retry_count,
|
||||
task.max_retries,
|
||||
json.dumps(task.metadata),
|
||||
task.created_at,
|
||||
task.updated_at
|
||||
))
|
||||
self.conn.commit()
|
||||
|
||||
# 添加到内存队列
|
||||
await self.queue.put(task)
|
||||
|
||||
logger.debug(f"任务 {task.task_id} 已添加到队列")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"添加任务失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_task(self, timeout: float = 1.0) -> Optional[DownloadTask]:
|
||||
"""
|
||||
从队列获取任务
|
||||
|
||||
Args:
|
||||
timeout: 超时时间
|
||||
|
||||
Returns:
|
||||
下载任务
|
||||
"""
|
||||
try:
|
||||
task = await asyncio.wait_for(self.queue.get(), timeout=timeout)
|
||||
|
||||
# 更新数据库状态
|
||||
await self.update_task_status(task.task_id, TaskStatus.PROCESSING)
|
||||
|
||||
return task
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
status: TaskStatus,
|
||||
error_message: Optional[str] = None,
|
||||
result: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
更新任务状态
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
status: 新状态
|
||||
error_message: 错误信息
|
||||
result: 执行结果
|
||||
"""
|
||||
async with self._lock:
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
update_fields = {
|
||||
'status': status.value,
|
||||
'updated_at': time.time()
|
||||
}
|
||||
|
||||
if error_message:
|
||||
update_fields['error_message'] = error_message
|
||||
|
||||
if result:
|
||||
update_fields['result'] = json.dumps(result)
|
||||
|
||||
if status == TaskStatus.COMPLETED:
|
||||
update_fields['completed_at'] = time.time()
|
||||
|
||||
# 构建UPDATE语句
|
||||
set_clause = ', '.join([f'{k} = ?' for k in update_fields.keys()])
|
||||
values = list(update_fields.values()) + [task_id]
|
||||
|
||||
cursor.execute(
|
||||
f'UPDATE tasks SET {set_clause} WHERE task_id = ?',
|
||||
values
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
async def requeue_task(self, task: DownloadTask):
|
||||
"""
|
||||
将任务重新加入队列
|
||||
|
||||
Args:
|
||||
task: 下载任务
|
||||
"""
|
||||
task.retry_count += 1
|
||||
task.status = TaskStatus.RETRYING
|
||||
task.updated_at = time.time()
|
||||
|
||||
await self.add_task(task)
|
||||
logger.info(f"任务 {task.task_id} 重新加入队列 (重试 {task.retry_count}/{task.max_retries})")
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取队列统计信息"""
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# 统计各状态任务数
|
||||
cursor.execute('''
|
||||
SELECT status, COUNT(*)
|
||||
FROM tasks
|
||||
GROUP BY status
|
||||
''')
|
||||
|
||||
status_counts = {}
|
||||
for status, count in cursor.fetchall():
|
||||
status_counts[status] = count
|
||||
|
||||
# 计算成功率
|
||||
total = sum(status_counts.values())
|
||||
completed = status_counts.get(TaskStatus.COMPLETED.value, 0)
|
||||
success_rate = (completed / total * 100) if total > 0 else 0
|
||||
|
||||
# 计算平均耗时
|
||||
cursor.execute('''
|
||||
SELECT AVG(completed_at - created_at)
|
||||
FROM tasks
|
||||
WHERE status = ? AND completed_at IS NOT NULL
|
||||
''', (TaskStatus.COMPLETED.value,))
|
||||
|
||||
avg_duration = cursor.fetchone()[0] or 0
|
||||
|
||||
stats = {
|
||||
'total_tasks': total,
|
||||
'pending_tasks': status_counts.get(TaskStatus.PENDING.value, 0),
|
||||
'processing_tasks': status_counts.get(TaskStatus.PROCESSING.value, 0),
|
||||
'completed_tasks': completed,
|
||||
'failed_tasks': status_counts.get(TaskStatus.FAILED.value, 0),
|
||||
'retrying_tasks': status_counts.get(TaskStatus.RETRYING.value, 0),
|
||||
'success_rate': success_rate,
|
||||
'average_duration': avg_duration,
|
||||
'queue_size': self.queue.qsize()
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
async def save_progress(self):
|
||||
"""保存进度到数据库"""
|
||||
stats = self.get_statistics()
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO progress (
|
||||
timestamp, total_tasks, pending_tasks, active_tasks,
|
||||
completed_tasks, failed_tasks, success_rate, average_duration
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
time.time(),
|
||||
stats['total_tasks'],
|
||||
stats['pending_tasks'],
|
||||
stats['processing_tasks'],
|
||||
stats['completed_tasks'],
|
||||
stats['failed_tasks'],
|
||||
stats['success_rate'],
|
||||
stats['average_duration']
|
||||
))
|
||||
self.conn.commit()
|
||||
|
||||
logger.debug("进度已保存")
|
||||
|
||||
async def start_checkpoint(self):
|
||||
"""启动检查点保存任务"""
|
||||
if not self._checkpoint_task:
|
||||
self._checkpoint_task = asyncio.create_task(self._checkpoint_loop())
|
||||
logger.info("检查点保存任务已启动")
|
||||
|
||||
async def stop_checkpoint(self):
|
||||
"""停止检查点保存任务"""
|
||||
if self._checkpoint_task:
|
||||
self._checkpoint_task.cancel()
|
||||
try:
|
||||
await self._checkpoint_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._checkpoint_task = None
|
||||
logger.info("检查点保存任务已停止")
|
||||
|
||||
async def _checkpoint_loop(self):
|
||||
"""检查点保存循环"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.checkpoint_interval)
|
||||
await self.save_progress()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"保存检查点失败: {e}")
|
||||
|
||||
def get_recent_progress(self, hours: int = 24) -> List[Dict]:
|
||||
"""
|
||||
获取最近的进度记录
|
||||
|
||||
Args:
|
||||
hours: 获取最近多少小时的记录
|
||||
|
||||
Returns:
|
||||
进度记录列表
|
||||
"""
|
||||
cursor = self.conn.cursor()
|
||||
since = time.time() - hours * 3600
|
||||
|
||||
cursor.execute('''
|
||||
SELECT timestamp, total_tasks, completed_tasks, failed_tasks, success_rate
|
||||
FROM progress
|
||||
WHERE timestamp > ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 100
|
||||
''', (since,))
|
||||
|
||||
records = []
|
||||
for row in cursor.fetchall():
|
||||
records.append({
|
||||
'timestamp': row[0],
|
||||
'total_tasks': row[1],
|
||||
'completed_tasks': row[2],
|
||||
'failed_tasks': row[3],
|
||||
'success_rate': row[4]
|
||||
})
|
||||
|
||||
return records
|
||||
|
||||
def cleanup_old_tasks(self, days: int = 7):
|
||||
"""
|
||||
清理旧任务记录
|
||||
|
||||
Args:
|
||||
days: 保留最近多少天的记录
|
||||
"""
|
||||
cursor = self.conn.cursor()
|
||||
cutoff = time.time() - days * 86400
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM tasks
|
||||
WHERE status IN (?, ?) AND updated_at < ?
|
||||
''', (TaskStatus.COMPLETED.value, TaskStatus.FAILED.value, cutoff))
|
||||
|
||||
deleted = cursor.rowcount
|
||||
self.conn.commit()
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"清理了 {deleted} 条旧任务记录")
|
||||
|
||||
def export_tasks(self, status: Optional[TaskStatus] = None) -> List[Dict]:
|
||||
"""
|
||||
导出任务列表
|
||||
|
||||
Args:
|
||||
status: 筛选状态
|
||||
|
||||
Returns:
|
||||
任务列表
|
||||
"""
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
if status:
|
||||
cursor.execute('''
|
||||
SELECT * FROM tasks WHERE status = ?
|
||||
ORDER BY created_at DESC
|
||||
''', (status.value,))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT * FROM tasks
|
||||
ORDER BY created_at DESC
|
||||
''')
|
||||
|
||||
tasks = []
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
|
||||
for row in cursor.fetchall():
|
||||
task_dict = dict(zip(columns, row))
|
||||
# 解析JSON字段
|
||||
if task_dict.get('metadata'):
|
||||
try:
|
||||
task_dict['metadata'] = json.loads(task_dict['metadata'])
|
||||
except:
|
||||
pass
|
||||
if task_dict.get('result'):
|
||||
try:
|
||||
task_dict['result'] = json.loads(task_dict['result'])
|
||||
except:
|
||||
pass
|
||||
tasks.append(task_dict)
|
||||
|
||||
return tasks
|
||||
|
||||
def close(self):
|
||||
"""关闭数据库连接"""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
logger.info("数据库连接已关闭")
|
||||
|
||||
async def __aenter__(self):
|
||||
"""异步上下文管理器入口"""
|
||||
await self.start_checkpoint()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""异步上下文管理器出口"""
|
||||
await self.stop_checkpoint()
|
||||
await self.save_progress()
|
||||
self.close()
|
||||
308
DouYin/apiproxy/douyin/core/rate_limiter.py
Normal file
308
DouYin/apiproxy/douyin/core/rate_limiter.py
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
智能限速器
|
||||
防止请求过快导致被封禁
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimitStrategy(Enum):
|
||||
"""限速策略枚举"""
|
||||
FIXED = "fixed" # 固定速率
|
||||
ADAPTIVE = "adaptive" # 自适应速率
|
||||
BURST = "burst" # 突发模式
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""限速配置"""
|
||||
max_per_second: int = 2
|
||||
max_per_minute: int = 30
|
||||
max_per_hour: int = 1000
|
||||
burst_size: int = 5
|
||||
strategy: RateLimitStrategy = RateLimitStrategy.ADAPTIVE
|
||||
cooldown_time: int = 60 # 触发限制后的冷却时间(秒)
|
||||
|
||||
|
||||
class AdaptiveRateLimiter:
|
||||
"""自适应限速器"""
|
||||
|
||||
def __init__(self, config: Optional[RateLimitConfig] = None):
|
||||
"""
|
||||
初始化限速器
|
||||
|
||||
Args:
|
||||
config: 限速配置
|
||||
"""
|
||||
self.config = config or RateLimitConfig()
|
||||
self.requests = deque()
|
||||
self.failures = deque()
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
# 当前限制值(可动态调整)
|
||||
self.current_max_per_second = self.config.max_per_second
|
||||
self.current_max_per_minute = self.config.max_per_minute
|
||||
self.current_max_per_hour = self.config.max_per_hour
|
||||
|
||||
# 统计信息
|
||||
self.stats = {
|
||||
'total_requests': 0,
|
||||
'blocked_requests': 0,
|
||||
'rate_adjustments': 0,
|
||||
'current_rate': self.current_max_per_second,
|
||||
'failure_rate': 0.0
|
||||
}
|
||||
|
||||
# 冷却状态
|
||||
self.cooldown_until = 0
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
"""
|
||||
获取请求许可
|
||||
|
||||
Returns:
|
||||
是否获得许可
|
||||
"""
|
||||
async with self.lock:
|
||||
now = time.time()
|
||||
|
||||
# 检查是否在冷却期
|
||||
if self.cooldown_until > now:
|
||||
remaining = self.cooldown_until - now
|
||||
logger.warning(f"限速器处于冷却期,还需等待 {remaining:.1f} 秒")
|
||||
await asyncio.sleep(remaining)
|
||||
self.cooldown_until = 0
|
||||
|
||||
# 清理过期记录
|
||||
self._clean_old_records(now)
|
||||
|
||||
# 检查速率限制
|
||||
while not self._can_proceed(now):
|
||||
# 计算需要等待的时间
|
||||
wait_time = self._calculate_wait_time(now)
|
||||
if wait_time > 0:
|
||||
logger.debug(f"速率限制,等待 {wait_time:.2f} 秒")
|
||||
await asyncio.sleep(wait_time)
|
||||
now = time.time()
|
||||
self._clean_old_records(now)
|
||||
else:
|
||||
# 无法继续,记录被阻塞的请求
|
||||
self.stats['blocked_requests'] += 1
|
||||
return False
|
||||
|
||||
# 记录请求
|
||||
self.requests.append(now)
|
||||
self.stats['total_requests'] += 1
|
||||
|
||||
# 自适应调整
|
||||
if self.config.strategy == RateLimitStrategy.ADAPTIVE:
|
||||
self._adjust_rate()
|
||||
|
||||
return True
|
||||
|
||||
async def __aenter__(self):
|
||||
"""异步上下文管理器入口"""
|
||||
await self.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""异步上下文管理器出口"""
|
||||
if exc_type is not None:
|
||||
# 发生异常,记录失败
|
||||
self.record_failure()
|
||||
|
||||
def record_failure(self):
|
||||
"""记录失败请求"""
|
||||
now = time.time()
|
||||
self.failures.append(now)
|
||||
|
||||
# 自适应调整
|
||||
if self.config.strategy == RateLimitStrategy.ADAPTIVE:
|
||||
self._handle_failure()
|
||||
|
||||
def _can_proceed(self, now: float) -> bool:
|
||||
"""检查是否可以继续请求"""
|
||||
# 检查每秒限制
|
||||
recent_second = [r for r in self.requests if now - r < 1]
|
||||
if len(recent_second) >= self.current_max_per_second:
|
||||
return False
|
||||
|
||||
# 检查每分钟限制
|
||||
recent_minute = [r for r in self.requests if now - r < 60]
|
||||
if len(recent_minute) >= self.current_max_per_minute:
|
||||
return False
|
||||
|
||||
# 检查每小时限制
|
||||
recent_hour = [r for r in self.requests if now - r < 3600]
|
||||
if len(recent_hour) >= self.current_max_per_hour:
|
||||
return False
|
||||
|
||||
# 突发模式检查
|
||||
if self.config.strategy == RateLimitStrategy.BURST:
|
||||
recent_burst = [r for r in self.requests if now - r < 0.1]
|
||||
if len(recent_burst) >= self.config.burst_size:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _calculate_wait_time(self, now: float) -> float:
|
||||
"""计算需要等待的时间"""
|
||||
wait_times = []
|
||||
|
||||
# 计算每秒限制的等待时间
|
||||
recent_second = [r for r in self.requests if now - r < 1]
|
||||
if len(recent_second) >= self.current_max_per_second:
|
||||
oldest_in_second = min(recent_second)
|
||||
wait_times.append(1 - (now - oldest_in_second))
|
||||
|
||||
# 计算每分钟限制的等待时间
|
||||
recent_minute = [r for r in self.requests if now - r < 60]
|
||||
if len(recent_minute) >= self.current_max_per_minute:
|
||||
oldest_in_minute = min(recent_minute)
|
||||
wait_times.append(60 - (now - oldest_in_minute))
|
||||
|
||||
# 返回最小等待时间
|
||||
return min(wait_times) if wait_times else 0.1
|
||||
|
||||
def _clean_old_records(self, now: float):
|
||||
"""清理过期记录"""
|
||||
# 保留最近1小时的请求记录
|
||||
while self.requests and now - self.requests[0] > 3600:
|
||||
self.requests.popleft()
|
||||
|
||||
# 保留最近10分钟的失败记录
|
||||
while self.failures and now - self.failures[0] > 600:
|
||||
self.failures.popleft()
|
||||
|
||||
def _adjust_rate(self):
|
||||
"""自适应调整速率"""
|
||||
now = time.time()
|
||||
|
||||
# 计算失败率
|
||||
recent_failures = [f for f in self.failures if now - f < 60]
|
||||
recent_requests = [r for r in self.requests if now - r < 60]
|
||||
|
||||
if len(recent_requests) > 10:
|
||||
failure_rate = len(recent_failures) / len(recent_requests)
|
||||
self.stats['failure_rate'] = failure_rate
|
||||
|
||||
if failure_rate > 0.3:
|
||||
# 失败率过高,降低速率
|
||||
self._decrease_rate()
|
||||
elif failure_rate < 0.05 and len(recent_requests) > 20:
|
||||
# 失败率很低,尝试提高速率
|
||||
self._increase_rate()
|
||||
|
||||
def _handle_failure(self):
|
||||
"""处理失败,调整限速策略"""
|
||||
now = time.time()
|
||||
recent_failures = [f for f in self.failures if now - f < 10]
|
||||
|
||||
# 如果短时间内失败次数过多,触发冷却
|
||||
if len(recent_failures) >= 5:
|
||||
logger.warning(f"检测到频繁失败,进入冷却期 {self.config.cooldown_time} 秒")
|
||||
self.cooldown_until = now + self.config.cooldown_time
|
||||
self._decrease_rate()
|
||||
|
||||
def _decrease_rate(self):
|
||||
"""降低请求速率"""
|
||||
old_rate = self.current_max_per_second
|
||||
|
||||
self.current_max_per_second = max(1, int(self.current_max_per_second * 0.7))
|
||||
self.current_max_per_minute = max(10, int(self.current_max_per_minute * 0.7))
|
||||
self.current_max_per_hour = max(100, int(self.current_max_per_hour * 0.7))
|
||||
|
||||
if old_rate != self.current_max_per_second:
|
||||
self.stats['rate_adjustments'] += 1
|
||||
self.stats['current_rate'] = self.current_max_per_second
|
||||
logger.info(f"降低请求速率: {old_rate}/s -> {self.current_max_per_second}/s")
|
||||
|
||||
def _increase_rate(self):
|
||||
"""提高请求速率"""
|
||||
old_rate = self.current_max_per_second
|
||||
|
||||
# 不超过配置的最大值
|
||||
self.current_max_per_second = min(
|
||||
self.config.max_per_second,
|
||||
int(self.current_max_per_second * 1.2)
|
||||
)
|
||||
self.current_max_per_minute = min(
|
||||
self.config.max_per_minute,
|
||||
int(self.current_max_per_minute * 1.2)
|
||||
)
|
||||
self.current_max_per_hour = min(
|
||||
self.config.max_per_hour,
|
||||
int(self.current_max_per_hour * 1.2)
|
||||
)
|
||||
|
||||
if old_rate != self.current_max_per_second:
|
||||
self.stats['rate_adjustments'] += 1
|
||||
self.stats['current_rate'] = self.current_max_per_second
|
||||
logger.info(f"提高请求速率: {old_rate}/s -> {self.current_max_per_second}/s")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
return self.stats.copy()
|
||||
|
||||
def reset_stats(self):
|
||||
"""重置统计信息"""
|
||||
self.stats = {
|
||||
'total_requests': 0,
|
||||
'blocked_requests': 0,
|
||||
'rate_adjustments': 0,
|
||||
'current_rate': self.current_max_per_second,
|
||||
'failure_rate': 0.0
|
||||
}
|
||||
|
||||
def set_cooldown(self, seconds: int):
|
||||
"""手动设置冷却时间"""
|
||||
self.cooldown_until = time.time() + seconds
|
||||
logger.info(f"手动设置冷却期 {seconds} 秒")
|
||||
|
||||
|
||||
class SimpleRateLimiter:
|
||||
"""简单限速器(固定速率)"""
|
||||
|
||||
def __init__(self, requests_per_second: float = 1.0):
|
||||
"""
|
||||
初始化简单限速器
|
||||
|
||||
Args:
|
||||
requests_per_second: 每秒允许的请求数
|
||||
"""
|
||||
self.requests_per_second = requests_per_second
|
||||
self.min_interval = 1.0 / requests_per_second
|
||||
self.last_request_time = 0
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self):
|
||||
"""获取请求许可"""
|
||||
async with self.lock:
|
||||
now = time.time()
|
||||
time_since_last = now - self.last_request_time
|
||||
|
||||
if time_since_last < self.min_interval:
|
||||
sleep_time = self.min_interval - time_since_last
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
async def __aenter__(self):
|
||||
"""异步上下文管理器入口"""
|
||||
await self.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""异步上下文管理器出口"""
|
||||
pass
|
||||
157
DouYin/apiproxy/douyin/database.py
Normal file
157
DouYin/apiproxy/douyin/database.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
|
||||
class DataBase(object):
|
||||
def __init__(self):
|
||||
self.conn = sqlite3.connect('data.db')
|
||||
self.cursor = self.conn.cursor()
|
||||
self.create_user_post_table()
|
||||
self.create_user_like_table()
|
||||
self.create_mix_table()
|
||||
self.create_music_table()
|
||||
|
||||
def create_user_post_table(self):
|
||||
sql = """CREATE TABLE if not exists t_user_post (
|
||||
id integer primary key autoincrement,
|
||||
sec_uid varchar(200),
|
||||
aweme_id integer unique,
|
||||
rawdata json
|
||||
);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def get_user_post(self, sec_uid: str, aweme_id: int):
|
||||
sql = """select id, sec_uid, aweme_id, rawdata from t_user_post where sec_uid=? and aweme_id=?;"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql, (sec_uid, aweme_id))
|
||||
self.conn.commit()
|
||||
res = self.cursor.fetchone()
|
||||
return res
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def insert_user_post(self, sec_uid: str, aweme_id: int, data: dict):
|
||||
insertsql = """insert into t_user_post (sec_uid, aweme_id, rawdata) values(?,?,?);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(insertsql, (sec_uid, aweme_id, json.dumps(data)))
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def create_user_like_table(self):
|
||||
sql = """CREATE TABLE if not exists t_user_like (
|
||||
id integer primary key autoincrement,
|
||||
sec_uid varchar(200),
|
||||
aweme_id integer unique,
|
||||
rawdata json
|
||||
);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def get_user_like(self, sec_uid: str, aweme_id: int):
|
||||
sql = """select id, sec_uid, aweme_id, rawdata from t_user_like where sec_uid=? and aweme_id=?;"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql, (sec_uid, aweme_id))
|
||||
self.conn.commit()
|
||||
res = self.cursor.fetchone()
|
||||
return res
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def insert_user_like(self, sec_uid: str, aweme_id: int, data: dict):
|
||||
insertsql = """insert into t_user_like (sec_uid, aweme_id, rawdata) values(?,?,?);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(insertsql, (sec_uid, aweme_id, json.dumps(data)))
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def create_mix_table(self):
|
||||
sql = """CREATE TABLE if not exists t_mix (
|
||||
id integer primary key autoincrement,
|
||||
sec_uid varchar(200),
|
||||
mix_id varchar(200),
|
||||
aweme_id integer,
|
||||
rawdata json
|
||||
);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def get_mix(self, sec_uid: str, mix_id: str, aweme_id: int):
|
||||
sql = """select id, sec_uid, mix_id, aweme_id, rawdata from t_mix where sec_uid=? and mix_id=? and aweme_id=?;"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql, (sec_uid, mix_id, aweme_id))
|
||||
self.conn.commit()
|
||||
res = self.cursor.fetchone()
|
||||
return res
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def insert_mix(self, sec_uid: str, mix_id: str, aweme_id: int, data: dict):
|
||||
insertsql = """insert into t_mix (sec_uid, mix_id, aweme_id, rawdata) values(?,?,?,?);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(insertsql, (sec_uid, mix_id, aweme_id, json.dumps(data)))
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def create_music_table(self):
|
||||
sql = """CREATE TABLE if not exists t_music (
|
||||
id integer primary key autoincrement,
|
||||
music_id varchar(200),
|
||||
aweme_id integer unique,
|
||||
rawdata json
|
||||
);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def get_music(self, music_id: str, aweme_id: int):
|
||||
sql = """select id, music_id, aweme_id, rawdata from t_music where music_id=? and aweme_id=?;"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(sql, (music_id, aweme_id))
|
||||
self.conn.commit()
|
||||
res = self.cursor.fetchone()
|
||||
return res
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def insert_music(self, music_id: str, aweme_id: int, data: dict):
|
||||
insertsql = """insert into t_music (music_id, aweme_id, rawdata) values(?,?,?);"""
|
||||
|
||||
try:
|
||||
self.cursor.execute(insertsql, (music_id, aweme_id, json.dumps(data)))
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
889
DouYin/apiproxy/douyin/douyin.py
Normal file
889
DouYin/apiproxy/douyin/douyin.py
Normal file
@@ -0,0 +1,889 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import re
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import copy
|
||||
# from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from typing import Tuple, Optional
|
||||
from requests.exceptions import RequestException
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn
|
||||
from rich.console import Console
|
||||
|
||||
from apiproxy.douyin import douyin_headers
|
||||
from apiproxy.douyin.urls import Urls
|
||||
from apiproxy.douyin.result import Result
|
||||
from apiproxy.douyin.database import DataBase
|
||||
from apiproxy.common import utils
|
||||
import sys
|
||||
import os
|
||||
# 添加项目根目录到系统路径,确保可以正确导入utils模块
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from utils.logger import logger
|
||||
|
||||
# 创建全局console实例
|
||||
console = Console()
|
||||
|
||||
class Douyin(object):
|
||||
|
||||
def __init__(self, database=False):
|
||||
self.urls = Urls()
|
||||
self.result = Result()
|
||||
self.database = database
|
||||
if database:
|
||||
self.db = DataBase()
|
||||
# 用于设置重复请求某个接口的最大时间
|
||||
self.timeout = 10
|
||||
self.console = Console() # 也可以在实例中创建console
|
||||
|
||||
# 从分享链接中提取网址
|
||||
def getShareLink(self, string):
|
||||
# findall() 查找匹配正则表达式的字符串
|
||||
return re.findall('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', string)[0]
|
||||
|
||||
# 得到 作品id 或者 用户id
|
||||
# 传入 url 支持 https://www.iesdouyin.com 与 https://v.douyin.com
|
||||
def getKey(self, url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""获取资源标识
|
||||
Args:
|
||||
url: 抖音分享链接或网页URL
|
||||
Returns:
|
||||
(资源类型, 资源ID)
|
||||
"""
|
||||
key = None
|
||||
key_type = None
|
||||
|
||||
try:
|
||||
r = requests.get(url=url, headers=douyin_headers)
|
||||
except Exception as e:
|
||||
print('[ 错误 ]:输入链接有误!\r')
|
||||
return key_type, key
|
||||
|
||||
# 抖音把图集更新为note
|
||||
# 作品 第一步解析出来的链接是share/video/{aweme_id}
|
||||
# https://www.iesdouyin.com/share/video/7037827546599263488/?region=CN&mid=6939809470193126152&u_code=j8a5173b&did=MS4wLjABAAAA1DICF9-A9M_CiGqAJZdsnig5TInVeIyPdc2QQdGrq58xUgD2w6BqCHovtqdIDs2i&iid=MS4wLjABAAAAomGWi4n2T0H9Ab9x96cUZoJXaILk4qXOJlJMZFiK6b_aJbuHkjN_f0mBzfy91DX1&with_sec_did=1&titleType=title&schema_type=37&from_ssr=1&utm_source=copy&utm_campaign=client_share&utm_medium=android&app=aweme
|
||||
# 用户 第一步解析出来的链接是share/user/{sec_uid}
|
||||
# https://www.iesdouyin.com/share/user/MS4wLjABAAAA06y3Ctu8QmuefqvUSU7vr0c_ZQnCqB0eaglgkelLTek?did=MS4wLjABAAAA1DICF9-A9M_CiGqAJZdsnig5TInVeIyPdc2QQdGrq58xUgD2w6BqCHovtqdIDs2i&iid=MS4wLjABAAAAomGWi4n2T0H9Ab9x96cUZoJXaILk4qXOJlJMZFiK6b_aJbuHkjN_f0mBzfy91DX1&with_sec_did=1&sec_uid=MS4wLjABAAAA06y3Ctu8QmuefqvUSU7vr0c_ZQnCqB0eaglgkelLTek&from_ssr=1&u_code=j8a5173b×tamp=1674540164&ecom_share_track_params=%7B%22is_ec_shopping%22%3A%221%22%2C%22secuid%22%3A%22MS4wLjABAAAA-jD2lukp--I21BF8VQsmYUqJDbj3FmU-kGQTHl2y1Cw%22%2C%22enter_from%22%3A%22others_homepage%22%2C%22share_previous_page%22%3A%22others_homepage%22%7D&utm_source=copy&utm_campaign=client_share&utm_medium=android&app=aweme
|
||||
# 合集
|
||||
# https://www.douyin.com/collection/7093490319085307918
|
||||
urlstr = str(r.request.path_url)
|
||||
|
||||
if "/user/" in urlstr:
|
||||
# 获取用户 sec_uid
|
||||
if '?' in r.request.path_url:
|
||||
for one in re.finditer(r'user\/([\d\D]*)([?])', str(r.request.path_url)):
|
||||
key = one.group(1)
|
||||
else:
|
||||
for one in re.finditer(r'user\/([\d\D]*)', str(r.request.path_url)):
|
||||
key = one.group(1)
|
||||
key_type = "user"
|
||||
elif "/video/" in urlstr:
|
||||
# 获取作品 aweme_id
|
||||
key = re.findall('video/(\d+)?', urlstr)[0]
|
||||
key_type = "aweme"
|
||||
elif "/note/" in urlstr:
|
||||
# 获取note aweme_id
|
||||
key = re.findall('note/(\d+)?', urlstr)[0]
|
||||
key_type = "aweme"
|
||||
elif "/mix/detail/" in urlstr:
|
||||
# 获取合集 id
|
||||
key = re.findall('/mix/detail/(\d+)?', urlstr)[0]
|
||||
key_type = "mix"
|
||||
elif "/collection/" in urlstr:
|
||||
# 获取合集 id
|
||||
key = re.findall('/collection/(\d+)?', urlstr)[0]
|
||||
key_type = "mix"
|
||||
elif "/music/" in urlstr:
|
||||
# 获取原声 id
|
||||
key = re.findall('music/(\d+)?', urlstr)[0]
|
||||
key_type = "music"
|
||||
elif "/webcast/reflow/" in urlstr:
|
||||
key1 = re.findall('reflow/(\d+)?', urlstr)[0]
|
||||
url = self.urls.LIVE2 + utils.getXbogus(
|
||||
f'live_id=1&room_id={key1}&app_id=1128')
|
||||
res = requests.get(url, headers=douyin_headers)
|
||||
resjson = json.loads(res.text)
|
||||
key = resjson['data']['room']['owner']['web_rid']
|
||||
key_type = "live"
|
||||
elif "live.douyin.com" in r.url:
|
||||
key = r.url.replace('https://live.douyin.com/', '')
|
||||
key_type = "live"
|
||||
|
||||
if key is None or key_type is None:
|
||||
print('[ 错误 ]:输入链接有误!无法获取 id\r')
|
||||
return key_type, key
|
||||
|
||||
return key_type, key
|
||||
|
||||
# 暂时注释掉装饰器
|
||||
# @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
|
||||
def getAwemeInfo(self, aweme_id: str) -> dict:
|
||||
"""获取作品信息(带重试机制)
|
||||
|
||||
由于抖音单个视频接口经常返回空响应,这里实现一个备用方案:
|
||||
1. 首先尝试原有的单个视频接口
|
||||
2. 如果失败,尝试通过搜索接口获取视频信息
|
||||
3. 如果还是失败,返回空字典
|
||||
"""
|
||||
retries = 3
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
logger.info(f'[ 提示 ]:正在请求的作品 id = {aweme_id}')
|
||||
if aweme_id is None:
|
||||
return {}
|
||||
|
||||
# 方法1: 尝试原有的单个视频接口
|
||||
result = self._try_detail_api(aweme_id)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 方法2: 如果单个视频接口失败,尝试备用方案
|
||||
logger.warning("单个视频接口失败,尝试备用方案...")
|
||||
result = self._try_alternative_method(aweme_id)
|
||||
if result:
|
||||
return result
|
||||
|
||||
logger.warning(f"所有方法都失败了,尝试 {attempt+1}/{retries}")
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"请求失败(尝试 {attempt+1}/{retries}): {str(e)}")
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
logger.error(f"无法获取视频 {aweme_id} 的信息")
|
||||
return {}
|
||||
|
||||
def _try_detail_api(self, aweme_id: str) -> dict:
|
||||
"""尝试使用原有的单个视频接口"""
|
||||
try:
|
||||
start = time.time()
|
||||
while True:
|
||||
try:
|
||||
# 单作品接口返回 'aweme_detail'
|
||||
# 主页作品接口返回 'aweme_list'->['aweme_detail']
|
||||
# 更新API参数以适应最新接口要求
|
||||
detail_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'
|
||||
jx_url = self.urls.POST_DETAIL + utils.getXbogus(detail_params)
|
||||
|
||||
response = requests.get(url=jx_url, headers=douyin_headers, timeout=10)
|
||||
|
||||
# 检查响应是否为空
|
||||
if len(response.text) == 0:
|
||||
logger.warning("单个视频接口返回空响应")
|
||||
return {}
|
||||
|
||||
datadict = json.loads(response.text)
|
||||
|
||||
# 添加调试信息
|
||||
logger.info(f"单个视频API响应状态: {datadict.get('status_code') if datadict else 'None'}")
|
||||
if datadict and datadict.get("status_code") != 0:
|
||||
logger.warning(f"单个视频API错误: {datadict.get('status_msg', '未知错误')}")
|
||||
return {}
|
||||
|
||||
if datadict is not None and datadict.get("status_code") == 0:
|
||||
# 检查是否有aweme_detail字段
|
||||
if "aweme_detail" not in datadict:
|
||||
logger.error(f"响应中缺少aweme_detail字段,可用字段: {list(datadict.keys())}")
|
||||
return {}
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time()
|
||||
if end - start > self.timeout:
|
||||
logger.warning(f"重复请求该接口{self.timeout}s, 仍然未获取到数据")
|
||||
return {}
|
||||
|
||||
# 清空self.awemeDict
|
||||
self.result.clearDict(self.result.awemeDict)
|
||||
|
||||
# 默认为视频
|
||||
awemeType = 0
|
||||
try:
|
||||
# datadict['aweme_detail']["images"] 不为 None 说明是图集
|
||||
if datadict['aweme_detail']["images"] is not None:
|
||||
awemeType = 1
|
||||
except Exception as e:
|
||||
logger.warning("接口中未找到 images")
|
||||
|
||||
# 转换成我们自己的格式
|
||||
self.result.dataConvert(awemeType, self.result.awemeDict, datadict['aweme_detail'])
|
||||
|
||||
return self.result.awemeDict
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"单个视频接口异常: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _try_alternative_method(self, aweme_id: str) -> dict:
|
||||
"""备用方案:通过其他方式获取视频信息
|
||||
|
||||
这里可以实现:
|
||||
1. 通过搜索接口查找视频
|
||||
2. 通过用户主页接口查找视频
|
||||
3. 其他可能的方法
|
||||
"""
|
||||
logger.info("尝试备用方案获取视频信息...")
|
||||
|
||||
# 目前返回空字典,表示备用方案暂未实现
|
||||
# 可以在这里添加其他获取视频信息的方法
|
||||
logger.warning("备用方案暂未实现")
|
||||
return {}
|
||||
|
||||
# 传入 url 支持 https://www.iesdouyin.com 与 https://v.douyin.com
|
||||
# mode : post | like 模式选择 like为用户点赞 post为用户发布
|
||||
def getUserInfo(self, sec_uid, mode="post", count=35, number=0, increase=False, start_time="", end_time=""):
|
||||
"""获取用户信息
|
||||
Args:
|
||||
sec_uid: 用户ID
|
||||
mode: 模式(post:发布/like:点赞)
|
||||
count: 每页数量
|
||||
number: 限制下载数量(0表示无限制)
|
||||
increase: 是否增量更新
|
||||
start_time: 开始时间,格式:YYYY-MM-DD
|
||||
end_time: 结束时间,格式:YYYY-MM-DD
|
||||
"""
|
||||
if sec_uid is None:
|
||||
return None
|
||||
|
||||
# 处理时间范围
|
||||
if end_time == "now":
|
||||
end_time = time.strftime("%Y-%m-%d")
|
||||
|
||||
if not start_time:
|
||||
start_time = "1970-01-01"
|
||||
if not end_time:
|
||||
end_time = "2099-12-31"
|
||||
|
||||
self.console.print(f"[cyan]🕒 时间范围: {start_time} 至 {end_time}[/]")
|
||||
|
||||
max_cursor = 0
|
||||
awemeList = []
|
||||
total_fetched = 0
|
||||
filtered_count = 0
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeRemainingColumn(),
|
||||
console=self.console,
|
||||
transient=True
|
||||
) as progress:
|
||||
fetch_task = progress.add_task(
|
||||
f"[cyan]📥 正在获取{mode}作品列表...",
|
||||
total=None # 总数未知,使用无限进度条
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 构建请求URL - 添加更多必需参数
|
||||
base_params = f'sec_user_id={sec_uid}&count={count}&max_cursor={max_cursor}&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'
|
||||
|
||||
if mode == "post":
|
||||
url = self.urls.USER_POST + utils.getXbogus(base_params)
|
||||
elif mode == "like":
|
||||
# 尝试备用like接口
|
||||
try:
|
||||
url = self.urls.USER_FAVORITE_A + utils.getXbogus(base_params)
|
||||
except:
|
||||
# 如果主接口失败,尝试备用接口
|
||||
url = self.urls.USER_FAVORITE_B + utils.getXbogus(base_params)
|
||||
else:
|
||||
self.console.print("[red]❌ 模式选择错误,仅支持post、like[/]")
|
||||
return None
|
||||
|
||||
# 发送请求
|
||||
res = requests.get(url=url, headers=douyin_headers, timeout=10)
|
||||
|
||||
# 检查HTTP状态码
|
||||
if res.status_code != 200:
|
||||
self.console.print(f"[red]❌ HTTP请求失败: {res.status_code}[/]")
|
||||
break
|
||||
|
||||
try:
|
||||
datadict = json.loads(res.text)
|
||||
except json.JSONDecodeError as e:
|
||||
self.console.print(f"[red]❌ JSON解析失败: {str(e)}[/]")
|
||||
self.console.print(f"[yellow]🔍 响应内容: {res.text[:500]}...[/]")
|
||||
self.console.print(f"[yellow]🔍 请求URL: {url}[/]")
|
||||
self.console.print(f"[yellow]🔍 模式: {mode}[/]")
|
||||
|
||||
# 检查是否是空响应或权限问题
|
||||
if not res.text.strip():
|
||||
self.console.print(f"[yellow]💡 提示: {mode}模式可能需要特殊权限或该用户的{mode}列表不公开[/]")
|
||||
elif "登录" in res.text or "login" in res.text.lower():
|
||||
self.console.print(f"[yellow]💡 提示: {mode}模式需要登录状态[/]")
|
||||
elif "权限" in res.text or "permission" in res.text.lower():
|
||||
self.console.print(f"[yellow]💡 提示: {mode}模式权限不足[/]")
|
||||
break
|
||||
|
||||
# 处理返回数据
|
||||
if not datadict or datadict.get("status_code") != 0:
|
||||
self.console.print(f"[red]❌ API请求失败: {datadict.get('status_msg', '未知错误')}[/]")
|
||||
# 打印详细的响应信息用于调试
|
||||
self.console.print(f"[yellow]🔍 响应状态码: {datadict.get('status_code') if datadict else 'None'}[/]")
|
||||
self.console.print(f"[yellow]🔍 响应内容: {str(datadict)[:200]}...[/]")
|
||||
break
|
||||
|
||||
# 检查aweme_list字段是否存在
|
||||
if "aweme_list" not in datadict:
|
||||
self.console.print(f"[red]❌ 响应中缺少aweme_list字段[/]")
|
||||
self.console.print(f"[yellow]🔍 可用字段: {list(datadict.keys())}[/]")
|
||||
break
|
||||
|
||||
current_count = len(datadict["aweme_list"])
|
||||
total_fetched += current_count
|
||||
|
||||
# 更新进度显示
|
||||
progress.update(
|
||||
fetch_task,
|
||||
description=f"[cyan]📥 已获取: {total_fetched}个作品"
|
||||
)
|
||||
|
||||
# 在处理作品时添加时间过滤
|
||||
for aweme in datadict["aweme_list"]:
|
||||
create_time = time.strftime(
|
||||
"%Y-%m-%d",
|
||||
time.localtime(int(aweme.get("create_time", 0)))
|
||||
)
|
||||
|
||||
# 时间过滤
|
||||
if not (start_time <= create_time <= end_time):
|
||||
filtered_count += 1
|
||||
continue
|
||||
|
||||
# 数量限制检查
|
||||
if number > 0 and len(awemeList) >= number:
|
||||
self.console.print(f"[green]✅ 已达到限制数量: {number}[/]")
|
||||
return awemeList
|
||||
|
||||
# 增量更新检查
|
||||
if self.database:
|
||||
if mode == "post":
|
||||
if self.db.get_user_post(sec_uid=sec_uid, aweme_id=aweme['aweme_id']):
|
||||
if increase and aweme['is_top'] == 0:
|
||||
self.console.print("[green]✅ 增量更新完成[/]")
|
||||
return awemeList
|
||||
else:
|
||||
self.db.insert_user_post(sec_uid=sec_uid, aweme_id=aweme['aweme_id'], data=aweme)
|
||||
elif mode == "like":
|
||||
if self.db.get_user_like(sec_uid=sec_uid, aweme_id=aweme['aweme_id']):
|
||||
if increase and aweme['is_top'] == 0:
|
||||
self.console.print("[green]✅ 增量更新完成[/]")
|
||||
return awemeList
|
||||
else:
|
||||
self.console.print("[red]❌ 模式选择错误,仅支持post、like[/]")
|
||||
return None
|
||||
|
||||
# 转换数据格式
|
||||
aweme_data = self._convert_aweme_data(aweme)
|
||||
if aweme_data:
|
||||
awemeList.append(aweme_data)
|
||||
|
||||
# 检查是否还有更多数据
|
||||
if not datadict["has_more"]:
|
||||
self.console.print(f"[green]✅ 已获取全部作品: {total_fetched}个[/]")
|
||||
break
|
||||
|
||||
# 更新游标
|
||||
max_cursor = datadict["max_cursor"]
|
||||
|
||||
except Exception as e:
|
||||
self.console.print(f"[red]❌ 获取作品列表出错: {str(e)}[/]")
|
||||
break
|
||||
|
||||
return awemeList
|
||||
|
||||
def _convert_aweme_data(self, aweme):
|
||||
"""转换作品数据格式"""
|
||||
try:
|
||||
self.result.clearDict(self.result.awemeDict)
|
||||
aweme_type = 1 if aweme.get("images") else 0
|
||||
self.result.dataConvert(aweme_type, self.result.awemeDict, aweme)
|
||||
return copy.deepcopy(self.result.awemeDict)
|
||||
except Exception as e:
|
||||
logger.error(f"数据转换错误: {str(e)}")
|
||||
return None
|
||||
|
||||
def getLiveInfo(self, web_rid: str):
|
||||
print('[ 提示 ]:正在请求的直播间 id = %s\r\n' % web_rid)
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
# 接口不稳定, 有时服务器不返回数据, 需要重新获取
|
||||
try:
|
||||
live_params = f'aid=6383&device_platform=web&web_rid={web_rid}&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'
|
||||
live_api = self.urls.LIVE + utils.getXbogus(live_params)
|
||||
|
||||
response = requests.get(live_api, headers=douyin_headers)
|
||||
live_json = json.loads(response.text)
|
||||
if live_json != {} and live_json['status_code'] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
print("[ 提示 ]:重复请求该接口" + str(self.timeout) + "s, 仍然未获取到数据")
|
||||
return {}
|
||||
|
||||
# 清空字典
|
||||
self.result.clearDict(self.result.liveDict)
|
||||
|
||||
# 类型
|
||||
self.result.liveDict["awemeType"] = 2
|
||||
# 是否在播
|
||||
self.result.liveDict["status"] = live_json['data']['data'][0]['status']
|
||||
|
||||
if self.result.liveDict["status"] == 4:
|
||||
print('[ 📺 ]:当前直播已结束,正在退出')
|
||||
return self.result.liveDict
|
||||
|
||||
# 直播标题
|
||||
self.result.liveDict["title"] = live_json['data']['data'][0]['title']
|
||||
|
||||
# 直播cover
|
||||
self.result.liveDict["cover"] = live_json['data']['data'][0]['cover']['url_list'][0]
|
||||
|
||||
# 头像
|
||||
self.result.liveDict["avatar"] = live_json['data']['data'][0]['owner']['avatar_thumb']['url_list'][0].replace(
|
||||
"100x100", "1080x1080")
|
||||
|
||||
# 观看人数
|
||||
self.result.liveDict["user_count"] = live_json['data']['data'][0]['user_count_str']
|
||||
|
||||
# 昵称
|
||||
self.result.liveDict["nickname"] = live_json['data']['data'][0]['owner']['nickname']
|
||||
|
||||
# sec_uid
|
||||
self.result.liveDict["sec_uid"] = live_json['data']['data'][0]['owner']['sec_uid']
|
||||
|
||||
# 直播间观看状态
|
||||
self.result.liveDict["display_long"] = live_json['data']['data'][0]['room_view_stats']['display_long']
|
||||
|
||||
# 推流
|
||||
self.result.liveDict["flv_pull_url"] = live_json['data']['data'][0]['stream_url']['flv_pull_url']
|
||||
|
||||
try:
|
||||
# 分区
|
||||
self.result.liveDict["partition"] = live_json['data']['partition_road_map']['partition']['title']
|
||||
self.result.liveDict["sub_partition"] = \
|
||||
live_json['data']['partition_road_map']['sub_partition']['partition']['title']
|
||||
except Exception as e:
|
||||
self.result.liveDict["partition"] = '无'
|
||||
self.result.liveDict["sub_partition"] = '无'
|
||||
|
||||
info = '[ 💻 ]:直播间:%s 当前%s 主播:%s 分区:%s-%s\r' % (
|
||||
self.result.liveDict["title"], self.result.liveDict["display_long"], self.result.liveDict["nickname"],
|
||||
self.result.liveDict["partition"], self.result.liveDict["sub_partition"])
|
||||
print(info)
|
||||
|
||||
flv = []
|
||||
print('[ 🎦 ]:直播间清晰度')
|
||||
for i, f in enumerate(self.result.liveDict["flv_pull_url"].keys()):
|
||||
print('[ %s ]: %s' % (i, f))
|
||||
flv.append(f)
|
||||
|
||||
rate = int(input('[ 🎬 ]输入数字选择推流清晰度:'))
|
||||
|
||||
self.result.liveDict["flv_pull_url0"] = self.result.liveDict["flv_pull_url"][flv[rate]]
|
||||
|
||||
# 显示清晰度列表
|
||||
print('[ %s ]:%s' % (flv[rate], self.result.liveDict["flv_pull_url"][flv[rate]]))
|
||||
print('[ 📺 ]:复制链接使用下载工具下载')
|
||||
return self.result.liveDict
|
||||
|
||||
def getMixInfo(self, mix_id, count=35, number=0, increase=False, sec_uid="", start_time="", end_time=""):
|
||||
"""获取合集信息"""
|
||||
if mix_id is None:
|
||||
return None
|
||||
|
||||
# 处理时间范围
|
||||
if end_time == "now":
|
||||
end_time = time.strftime("%Y-%m-%d")
|
||||
|
||||
if not start_time:
|
||||
start_time = "1970-01-01"
|
||||
if not end_time:
|
||||
end_time = "2099-12-31"
|
||||
|
||||
self.console.print(f"[cyan]🕒 时间范围: {start_time} 至 {end_time}[/]")
|
||||
|
||||
cursor = 0
|
||||
awemeList = []
|
||||
total_fetched = 0
|
||||
filtered_count = 0
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeRemainingColumn(),
|
||||
console=self.console,
|
||||
transient=True
|
||||
) as progress:
|
||||
fetch_task = progress.add_task(
|
||||
"[cyan]📥 正在获取合集作品...",
|
||||
total=None
|
||||
)
|
||||
|
||||
while True: # 外层循环
|
||||
try:
|
||||
mix_params = f'mix_id={mix_id}&cursor={cursor}&count={count}&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'
|
||||
url = self.urls.USER_MIX + utils.getXbogus(mix_params)
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers, timeout=10)
|
||||
|
||||
# 检查HTTP状态码
|
||||
if res.status_code != 200:
|
||||
self.console.print(f"[red]❌ 合集HTTP请求失败: {res.status_code}[/]")
|
||||
break
|
||||
|
||||
try:
|
||||
datadict = json.loads(res.text)
|
||||
except json.JSONDecodeError as e:
|
||||
self.console.print(f"[red]❌ 合集JSON解析失败: {str(e)}[/]")
|
||||
self.console.print(f"[yellow]🔍 响应内容: {res.text[:500]}...[/]")
|
||||
break
|
||||
|
||||
if not datadict:
|
||||
self.console.print("[red]❌ 获取合集数据失败[/]")
|
||||
break
|
||||
|
||||
if datadict.get("status_code") != 0:
|
||||
self.console.print(f"[red]❌ 合集API请求失败: {datadict.get('status_msg', '未知错误')}[/]")
|
||||
break
|
||||
|
||||
if "aweme_list" not in datadict:
|
||||
self.console.print(f"[red]❌ 合集响应中缺少aweme_list字段[/]")
|
||||
self.console.print(f"[yellow]🔍 可用字段: {list(datadict.keys())}[/]")
|
||||
break
|
||||
|
||||
for aweme in datadict["aweme_list"]:
|
||||
create_time = time.strftime(
|
||||
"%Y-%m-%d",
|
||||
time.localtime(int(aweme.get("create_time", 0)))
|
||||
)
|
||||
|
||||
# 时间过滤
|
||||
if not (start_time <= create_time <= end_time):
|
||||
filtered_count += 1
|
||||
continue
|
||||
|
||||
# 数量限制检查
|
||||
if number > 0 and len(awemeList) >= number:
|
||||
return awemeList # 使用return替代break
|
||||
|
||||
# 增量更新检查
|
||||
if self.database:
|
||||
if self.db.get_mix(sec_uid=sec_uid, mix_id=mix_id, aweme_id=aweme['aweme_id']):
|
||||
if increase and aweme['is_top'] == 0:
|
||||
return awemeList # 使用return替代break
|
||||
else:
|
||||
self.db.insert_mix(sec_uid=sec_uid, mix_id=mix_id, aweme_id=aweme['aweme_id'], data=aweme)
|
||||
|
||||
# 转换数据
|
||||
aweme_data = self._convert_aweme_data(aweme)
|
||||
if aweme_data:
|
||||
awemeList.append(aweme_data)
|
||||
|
||||
# 检查是否还有更多数据
|
||||
if not datadict.get("has_more"):
|
||||
self.console.print(f"[green]✅ 已获取全部作品[/]")
|
||||
break
|
||||
|
||||
# 更新游标
|
||||
cursor = datadict.get("cursor", 0)
|
||||
total_fetched += len(datadict["aweme_list"])
|
||||
progress.update(fetch_task, description=f"[cyan]📥 已获取: {total_fetched}个作品")
|
||||
|
||||
except Exception as e:
|
||||
self.console.print(f"[red]❌ 获取作品列表出错: {str(e)}[/]")
|
||||
# 添加更详细的错误信息
|
||||
if 'datadict' in locals():
|
||||
self.console.print(f"[yellow]🔍 最后一次响应: {str(datadict)[:300]}...[/]")
|
||||
break
|
||||
|
||||
if filtered_count > 0:
|
||||
self.console.print(f"[yellow]⚠️ 已过滤 {filtered_count} 个不在时间范围内的作品[/]")
|
||||
|
||||
return awemeList
|
||||
|
||||
def getUserAllMixInfo(self, sec_uid, count=35, number=0):
|
||||
print('[ 提示 ]:正在请求的用户 id = %s\r\n' % sec_uid)
|
||||
if sec_uid is None:
|
||||
return None
|
||||
if number <= 0:
|
||||
numflag = False
|
||||
else:
|
||||
numflag = True
|
||||
|
||||
cursor = 0
|
||||
mixIdNameDict = {}
|
||||
|
||||
print("[ 提示 ]:正在获取主页下所有合集 id 数据请稍后...\r")
|
||||
print("[ 提示 ]:会进行多次请求,等待时间较长...\r\n")
|
||||
times = 0
|
||||
while True:
|
||||
times = times + 1
|
||||
print("[ 提示 ]:正在对 [合集列表] 进行第 " + str(times) + " 次请求...\r")
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
# 接口不稳定, 有时服务器不返回数据, 需要重新获取
|
||||
try:
|
||||
mix_list_params = f'sec_user_id={sec_uid}&count={count}&cursor={cursor}&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'
|
||||
url = self.urls.USER_MIX_LIST + utils.getXbogus(mix_list_params)
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers, timeout=10)
|
||||
|
||||
# 检查HTTP状态码
|
||||
if res.status_code != 200:
|
||||
self.console.print(f"[red]❌ 合集列表HTTP请求失败: {res.status_code}[/]")
|
||||
break
|
||||
|
||||
try:
|
||||
# 尝试直接解析,如果失败则检查是否为压缩格式
|
||||
try:
|
||||
datadict = json.loads(res.text)
|
||||
except json.JSONDecodeError:
|
||||
# 可能是压缩响应,尝试手动解压
|
||||
content_encoding = res.headers.get('content-encoding', '').lower()
|
||||
if content_encoding == 'gzip':
|
||||
import gzip
|
||||
content = gzip.decompress(res.content).decode('utf-8')
|
||||
datadict = json.loads(content)
|
||||
elif content_encoding == 'br':
|
||||
try:
|
||||
import brotli
|
||||
content = brotli.decompress(res.content).decode('utf-8')
|
||||
datadict = json.loads(content)
|
||||
except ImportError:
|
||||
self.console.print("[red]❌ 需要安装brotli库来处理br压缩: pip install brotli[/]")
|
||||
raise
|
||||
else:
|
||||
raise # 重新抛出原始异常
|
||||
except json.JSONDecodeError as e:
|
||||
self.console.print(f"[red]❌ 合集列表JSON解析失败: {str(e)}[/]")
|
||||
self.console.print(f"[yellow]🔍 响应内容: {res.text[:500]}...[/]")
|
||||
self.console.print(f"[yellow]🔍 响应头: {dict(res.headers)}[/]")
|
||||
break
|
||||
|
||||
# 检查响应结构
|
||||
if not datadict:
|
||||
self.console.print("[red]❌ 获取合集列表数据失败[/]")
|
||||
break
|
||||
|
||||
if datadict.get("status_code") != 0:
|
||||
self.console.print(f"[red]❌ 合集列表API请求失败: {datadict.get('status_msg', '未知错误')}[/]")
|
||||
break
|
||||
|
||||
if "mix_infos" not in datadict:
|
||||
self.console.print(f"[red]❌ 响应中缺少mix_infos字段[/]")
|
||||
self.console.print(f"[yellow]🔍 可用字段: {list(datadict.keys())}[/]")
|
||||
break
|
||||
|
||||
print('[ 提示 ]:本次请求返回 ' + str(len(datadict["mix_infos"])) + ' 条数据\r')
|
||||
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
print("[ 提示 ]:重复请求该接口" + str(self.timeout) + "s, 仍然未获取到数据")
|
||||
return mixIdNameDict
|
||||
|
||||
# 检查datadict是否成功获取
|
||||
if 'datadict' not in locals() or not datadict:
|
||||
print("[ 提示 ]:未能获取到有效的合集列表数据")
|
||||
return mixIdNameDict
|
||||
|
||||
|
||||
for mix in datadict["mix_infos"]:
|
||||
mixIdNameDict[mix["mix_id"]] = mix["mix_name"]
|
||||
if numflag:
|
||||
number -= 1
|
||||
if number == 0:
|
||||
break
|
||||
if numflag and number == 0:
|
||||
print("\r\n[ 提示 ]:[合集列表] 下指定数量合集数据获取完成...\r\n")
|
||||
break
|
||||
|
||||
# 更新 max_cursor
|
||||
cursor = datadict["cursor"]
|
||||
|
||||
# 退出条件
|
||||
if datadict["has_more"] == 0 or datadict["has_more"] == False:
|
||||
print("[ 提示 ]:[合集列表] 下所有合集 id 数据获取完成...\r\n")
|
||||
break
|
||||
else:
|
||||
print("\r\n[ 提示 ]:[合集列表] 第 " + str(times) + " 次请求成功...\r\n")
|
||||
|
||||
return mixIdNameDict
|
||||
|
||||
def getMusicInfo(self, music_id: str, count=35, number=0, increase=False):
|
||||
print('[ 提示 ]:正在请求的音乐集合 id = %s\r\n' % music_id)
|
||||
if music_id is None:
|
||||
return None
|
||||
if number <= 0:
|
||||
numflag = False
|
||||
else:
|
||||
numflag = True
|
||||
|
||||
cursor = 0
|
||||
awemeList = []
|
||||
increaseflag = False
|
||||
numberis0 = False
|
||||
|
||||
print("[ 提示 ]:正在获取音乐集合下的所有作品数据请稍后...\r")
|
||||
print("[ 提示 ]:会进行多次请求,等待时间较长...\r\n")
|
||||
times = 0
|
||||
while True:
|
||||
times = times + 1
|
||||
print("[ 提示 ]:正在对 [音乐集合] 进行第 " + str(times) + " 次请求...\r")
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
# 接口不稳定, 有时服务器不返回数据, 需要重新获取
|
||||
try:
|
||||
music_params = f'music_id={music_id}&cursor={cursor}&count={count}&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'
|
||||
url = self.urls.MUSIC + utils.getXbogus(music_params)
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers, timeout=10)
|
||||
|
||||
# 检查HTTP状态码
|
||||
if res.status_code != 200:
|
||||
self.console.print(f"[red]❌ 音乐HTTP请求失败: {res.status_code}[/]")
|
||||
break
|
||||
|
||||
try:
|
||||
datadict = json.loads(res.text)
|
||||
except json.JSONDecodeError as e:
|
||||
self.console.print(f"[red]❌ 音乐JSON解析失败: {str(e)}[/]")
|
||||
self.console.print(f"[yellow]🔍 响应内容: {res.text[:500]}...[/]")
|
||||
break
|
||||
|
||||
if not datadict:
|
||||
self.console.print("[red]❌ 获取音乐数据失败[/]")
|
||||
break
|
||||
|
||||
if datadict.get("status_code") != 0:
|
||||
self.console.print(f"[red]❌ 音乐API请求失败: {datadict.get('status_msg', '未知错误')}[/]")
|
||||
break
|
||||
|
||||
if "aweme_list" not in datadict:
|
||||
self.console.print(f"[red]❌ 音乐响应中缺少aweme_list字段[/]")
|
||||
self.console.print(f"[yellow]🔍 可用字段: {list(datadict.keys())}[/]")
|
||||
break
|
||||
|
||||
print('[ 提示 ]:本次请求返回 ' + str(len(datadict["aweme_list"])) + ' 条数据\r')
|
||||
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
print("[ 提示 ]:重复请求该接口" + str(self.timeout) + "s, 仍然未获取到数据")
|
||||
return awemeList
|
||||
|
||||
|
||||
for aweme in datadict["aweme_list"]:
|
||||
if self.database:
|
||||
# 退出条件
|
||||
if increase is False and numflag and numberis0:
|
||||
break
|
||||
if increase and numflag and numberis0 and increaseflag:
|
||||
break
|
||||
# 增量更新, 找到非置顶的最新的作品发布时间
|
||||
if self.db.get_music(music_id=music_id, aweme_id=aweme['aweme_id']) is not None:
|
||||
if increase and aweme['is_top'] == 0:
|
||||
increaseflag = True
|
||||
else:
|
||||
self.db.insert_music(music_id=music_id, aweme_id=aweme['aweme_id'], data=aweme)
|
||||
|
||||
# 退出条件
|
||||
if increase and numflag is False and increaseflag:
|
||||
break
|
||||
if increase and numflag and numberis0 and increaseflag:
|
||||
break
|
||||
else:
|
||||
if numflag and numberis0:
|
||||
break
|
||||
|
||||
if numflag:
|
||||
number -= 1
|
||||
if number == 0:
|
||||
numberis0 = True
|
||||
|
||||
# 清空self.awemeDict
|
||||
self.result.clearDict(self.result.awemeDict)
|
||||
|
||||
# 默认为视频
|
||||
awemeType = 0
|
||||
try:
|
||||
if aweme["images"] is not None:
|
||||
awemeType = 1
|
||||
except Exception as e:
|
||||
print("[ 警告 ]:接口中未找到 images\r")
|
||||
|
||||
# 转换成我们自己的格式
|
||||
self.result.dataConvert(awemeType, self.result.awemeDict, aweme)
|
||||
|
||||
if self.result.awemeDict is not None and self.result.awemeDict != {}:
|
||||
awemeList.append(copy.deepcopy(self.result.awemeDict))
|
||||
|
||||
if self.database:
|
||||
if increase and numflag is False and increaseflag:
|
||||
print("\r\n[ 提示 ]: [音乐集合] 下作品增量更新数据获取完成...\r\n")
|
||||
break
|
||||
elif increase is False and numflag and numberis0:
|
||||
print("\r\n[ 提示 ]: [音乐集合] 下指定数量作品数据获取完成...\r\n")
|
||||
break
|
||||
elif increase and numflag and numberis0 and increaseflag:
|
||||
print("\r\n[ 提示 ]: [音乐集合] 下指定数量作品数据获取完成, 增量更新数据获取完成...\r\n")
|
||||
break
|
||||
else:
|
||||
if numflag and numberis0:
|
||||
print("\r\n[ 提示 ]: [音乐集合] 下指定数量作品数据获取完成...\r\n")
|
||||
break
|
||||
|
||||
# 更新 cursor
|
||||
cursor = datadict["cursor"]
|
||||
|
||||
# 退出条件
|
||||
if datadict["has_more"] == 0 or datadict["has_more"] == False:
|
||||
print("\r\n[ 提示 ]:[音乐集合] 下所有作品数据获取完成...\r\n")
|
||||
break
|
||||
else:
|
||||
print("\r\n[ 提示 ]:[音乐集合] 第 " + str(times) + " 次请求成功...\r\n")
|
||||
|
||||
return awemeList
|
||||
|
||||
def getUserDetailInfo(self, sec_uid):
|
||||
if sec_uid is None:
|
||||
return None
|
||||
|
||||
datadict = {}
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
# 接口不稳定, 有时服务器不返回数据, 需要重新获取
|
||||
try:
|
||||
user_detail_params = f'sec_user_id={sec_uid}&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'
|
||||
url = self.urls.USER_DETAIL + utils.getXbogus(user_detail_params)
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers)
|
||||
datadict = json.loads(res.text)
|
||||
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
return datadict
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
print("[ 提示 ]:重复请求该接口" + str(self.timeout) + "s, 仍然未获取到数据")
|
||||
return datadict
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
383
DouYin/apiproxy/douyin/douyinapi.py
Normal file
383
DouYin/apiproxy/douyin/douyinapi.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import re
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import copy
|
||||
|
||||
from apiproxy.douyin import douyin_headers
|
||||
from apiproxy.douyin.urls import Urls
|
||||
from apiproxy.douyin.result import Result
|
||||
from apiproxy.common import utils
|
||||
|
||||
class DouyinApi(object):
|
||||
def __init__(self):
|
||||
self.urls = Urls()
|
||||
self.result = Result()
|
||||
# 用于设置重复请求某个接口的最大时间
|
||||
self.timeout = 10
|
||||
|
||||
# 从分享链接中提取网址
|
||||
def getShareLink(self, string):
|
||||
# findall() 查找匹配正则表达式的字符串
|
||||
return re.findall('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', string)[0]
|
||||
|
||||
# 得到 作品id 或者 用户id
|
||||
# 传入 url 支持 https://www.iesdouyin.com 与 https://v.douyin.com
|
||||
def getKey(self, url):
|
||||
key = None
|
||||
key_type = None
|
||||
|
||||
try:
|
||||
r = requests.get(url=url, headers=douyin_headers)
|
||||
except Exception as e:
|
||||
print('[ 错误 ]:输入链接有误!\r')
|
||||
return key_type, key
|
||||
|
||||
# 抖音把图集更新为note
|
||||
# 作品 第一步解析出来的链接是share/video/{aweme_id}
|
||||
# https://www.iesdouyin.com/share/video/7037827546599263488/?region=CN&mid=6939809470193126152&u_code=j8a5173b&did=MS4wLjABAAAA1DICF9-A9M_CiGqAJZdsnig5TInVeIyPdc2QQdGrq58xUgD2w6BqCHovtqdIDs2i&iid=MS4wLjABAAAAomGWi4n2T0H9Ab9x96cUZoJXaILk4qXOJlJMZFiK6b_aJbuHkjN_f0mBzfy91DX1&with_sec_did=1&titleType=title&schema_type=37&from_ssr=1&utm_source=copy&utm_campaign=client_share&utm_medium=android&app=aweme
|
||||
# 用户 第一步解析出来的链接是share/user/{sec_uid}
|
||||
# https://www.iesdouyin.com/share/user/MS4wLjABAAAA06y3Ctu8QmuefqvUSU7vr0c_ZQnCqB0eaglgkelLTek?did=MS4wLjABAAAA1DICF9-A9M_CiGqAJZdsnig5TInVeIyPdc2QQdGrq58xUgD2w6BqCHovtqdIDs2i&iid=MS4wLjABAAAAomGWi4n2T0H9Ab9x96cUZoJXaILk4qXOJlJMZFiK6b_aJbuHkjN_f0mBzfy91DX1&with_sec_did=1&sec_uid=MS4wLjABAAAA06y3Ctu8QmuefqvUSU7vr0c_ZQnCqB0eaglgkelLTek&from_ssr=1&u_code=j8a5173b×tamp=1674540164&ecom_share_track_params=%7B%22is_ec_shopping%22%3A%221%22%2C%22secuid%22%3A%22MS4wLjABAAAA-jD2lukp--I21BF8VQsmYUqJDbj3FmU-kGQTHl2y1Cw%22%2C%22enter_from%22%3A%22others_homepage%22%2C%22share_previous_page%22%3A%22others_homepage%22%7D&utm_source=copy&utm_campaign=client_share&utm_medium=android&app=aweme
|
||||
# 合集
|
||||
# https://www.douyin.com/collection/7093490319085307918
|
||||
urlstr = str(r.request.path_url)
|
||||
|
||||
if "/user/" in urlstr:
|
||||
# 获取用户 sec_uid
|
||||
if '?' in r.request.path_url:
|
||||
for one in re.finditer(r'user\/([\d\D]*)([?])', str(r.request.path_url)):
|
||||
key = one.group(1)
|
||||
else:
|
||||
for one in re.finditer(r'user\/([\d\D]*)', str(r.request.path_url)):
|
||||
key = one.group(1)
|
||||
key_type = "user"
|
||||
elif "/video/" in urlstr:
|
||||
# 获取作品 aweme_id
|
||||
key = re.findall('video/(\d+)?', urlstr)[0]
|
||||
key_type = "aweme"
|
||||
elif "/note/" in urlstr:
|
||||
# 获取note aweme_id
|
||||
key = re.findall('note/(\d+)?', urlstr)[0]
|
||||
key_type = "aweme"
|
||||
elif "/mix/detail/" in urlstr:
|
||||
# 获取合集 id
|
||||
key = re.findall('/mix/detail/(\d+)?', urlstr)[0]
|
||||
key_type = "mix"
|
||||
elif "/collection/" in urlstr:
|
||||
# 获取合集 id
|
||||
key = re.findall('/collection/(\d+)?', urlstr)[0]
|
||||
key_type = "mix"
|
||||
elif "/music/" in urlstr:
|
||||
# 获取原声 id
|
||||
key = re.findall('music/(\d+)?', urlstr)[0]
|
||||
key_type = "music"
|
||||
elif "/webcast/reflow/" in urlstr:
|
||||
key1 = re.findall('reflow/(\d+)?', urlstr)[0]
|
||||
url = self.urls.LIVE2 + utils.getXbogus(
|
||||
f'live_id=1&room_id={key1}&app_id=1128')
|
||||
res = requests.get(url, headers=douyin_headers)
|
||||
resjson = json.loads(res.text)
|
||||
key = resjson['data']['room']['owner']['web_rid']
|
||||
key_type = "live"
|
||||
elif "live.douyin.com" in r.url:
|
||||
key = r.url.replace('https://live.douyin.com/', '')
|
||||
key_type = "live"
|
||||
|
||||
if key is None or key_type is None:
|
||||
print('[ 错误 ]:输入链接有误!无法获取 id\r')
|
||||
return key_type, key
|
||||
|
||||
return key_type, key
|
||||
|
||||
def getAwemeInfoApi(self, aweme_id):
|
||||
if aweme_id is None:
|
||||
return None
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
try:
|
||||
jx_url = self.urls.POST_DETAIL + utils.getXbogus(
|
||||
f'aweme_id={aweme_id}&device_platform=webapp&aid=6383')
|
||||
|
||||
raw = requests.get(url=jx_url, headers=douyin_headers).text
|
||||
datadict = json.loads(raw)
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
return None
|
||||
|
||||
# 清空self.awemeDict
|
||||
self.result.clearDict(self.result.awemeDict)
|
||||
|
||||
# 默认为视频
|
||||
awemeType = 0
|
||||
try:
|
||||
if datadict['aweme_detail']["images"] is not None:
|
||||
awemeType = 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 转换成我们自己的格式
|
||||
self.result.dataConvert(awemeType, self.result.awemeDict, datadict['aweme_detail'])
|
||||
|
||||
return self.result.awemeDict, datadict
|
||||
|
||||
def getUserInfoApi(self, sec_uid, mode="post", count=35, max_cursor=0):
|
||||
if sec_uid is None:
|
||||
return None
|
||||
|
||||
awemeList = []
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
try:
|
||||
if mode == "post":
|
||||
url = self.urls.USER_POST + utils.getXbogus(
|
||||
f'sec_user_id={sec_uid}&count={count}&max_cursor={max_cursor}&device_platform=webapp&aid=6383')
|
||||
elif mode == "like":
|
||||
url = self.urls.USER_FAVORITE_A + utils.getXbogus(
|
||||
f'sec_user_id={sec_uid}&count={count}&max_cursor={max_cursor}&device_platform=webapp&aid=6383')
|
||||
else:
|
||||
return None
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers)
|
||||
datadict = json.loads(res.text)
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
return None
|
||||
|
||||
for aweme in datadict["aweme_list"]:
|
||||
# 清空self.awemeDict
|
||||
self.result.clearDict(self.result.awemeDict)
|
||||
|
||||
# 默认为视频
|
||||
awemeType = 0
|
||||
try:
|
||||
if aweme["images"] is not None:
|
||||
awemeType = 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 转换成我们自己的格式
|
||||
self.result.dataConvert(awemeType, self.result.awemeDict, aweme)
|
||||
|
||||
if self.result.awemeDict is not None and self.result.awemeDict != {}:
|
||||
awemeList.append(copy.deepcopy(self.result.awemeDict))
|
||||
|
||||
return awemeList, datadict, datadict["max_cursor"], datadict["has_more"]
|
||||
|
||||
def getLiveInfoApi(self, web_rid: str):
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
try:
|
||||
live_api = self.urls.LIVE + utils.getXbogus(
|
||||
f'aid=6383&device_platform=web&web_rid={web_rid}')
|
||||
|
||||
response = requests.get(live_api, headers=douyin_headers)
|
||||
live_json = json.loads(response.text)
|
||||
if live_json != {} and live_json['status_code'] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
return None
|
||||
|
||||
# 清空字典
|
||||
self.result.clearDict(self.result.liveDict)
|
||||
|
||||
# 类型
|
||||
self.result.liveDict["awemeType"] = 2
|
||||
# 是否在播
|
||||
self.result.liveDict["status"] = live_json['data']['data'][0]['status']
|
||||
|
||||
if self.result.liveDict["status"] == 4:
|
||||
return self.result.liveDict, live_json
|
||||
|
||||
# 直播标题
|
||||
self.result.liveDict["title"] = live_json['data']['data'][0]['title']
|
||||
|
||||
# 直播cover
|
||||
self.result.liveDict["cover"] = live_json['data']['data'][0]['cover']['url_list'][0]
|
||||
|
||||
# 头像
|
||||
self.result.liveDict["avatar"] = live_json['data']['data'][0]['owner']['avatar_thumb']['url_list'][0].replace(
|
||||
"100x100", "1080x1080")
|
||||
|
||||
# 观看人数
|
||||
self.result.liveDict["user_count"] = live_json['data']['data'][0]['user_count_str']
|
||||
|
||||
# 昵称
|
||||
self.result.liveDict["nickname"] = live_json['data']['data'][0]['owner']['nickname']
|
||||
|
||||
# sec_uid
|
||||
self.result.liveDict["sec_uid"] = live_json['data']['data'][0]['owner']['sec_uid']
|
||||
|
||||
# 直播间观看状态
|
||||
self.result.liveDict["display_long"] = live_json['data']['data'][0]['room_view_stats']['display_long']
|
||||
|
||||
# 推流
|
||||
self.result.liveDict["flv_pull_url"] = live_json['data']['data'][0]['stream_url']['flv_pull_url']
|
||||
|
||||
try:
|
||||
# 分区
|
||||
self.result.liveDict["partition"] = live_json['data']['partition_road_map']['partition']['title']
|
||||
self.result.liveDict["sub_partition"] = \
|
||||
live_json['data']['partition_road_map']['sub_partition']['partition']['title']
|
||||
except Exception as e:
|
||||
self.result.liveDict["partition"] = '无'
|
||||
self.result.liveDict["sub_partition"] = '无'
|
||||
|
||||
flv = []
|
||||
|
||||
for i, f in enumerate(self.result.liveDict["flv_pull_url"].keys()):
|
||||
flv.append(f)
|
||||
|
||||
self.result.liveDict["flv_pull_url0"] = self.result.liveDict["flv_pull_url"][flv[0]]
|
||||
|
||||
return self.result.liveDict, live_json
|
||||
|
||||
def getMixInfoApi(self, mix_id: str, count=35, cursor=0):
|
||||
if mix_id is None:
|
||||
return None
|
||||
|
||||
awemeList = []
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
try:
|
||||
url = self.urls.USER_MIX + utils.getXbogus(
|
||||
f'mix_id={mix_id}&cursor={cursor}&count={count}&device_platform=webapp&aid=6383')
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers)
|
||||
datadict = json.loads(res.text)
|
||||
if datadict is not None:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
return None
|
||||
|
||||
for aweme in datadict["aweme_list"]:
|
||||
|
||||
# 清空self.awemeDict
|
||||
self.result.clearDict(self.result.awemeDict)
|
||||
|
||||
# 默认为视频
|
||||
awemeType = 0
|
||||
try:
|
||||
if aweme["images"] is not None:
|
||||
awemeType = 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 转换成我们自己的格式
|
||||
self.result.dataConvert(awemeType, self.result.awemeDict, aweme)
|
||||
|
||||
if self.result.awemeDict is not None and self.result.awemeDict != {}:
|
||||
awemeList.append(copy.deepcopy(self.result.awemeDict))
|
||||
|
||||
return awemeList, datadict, datadict["cursor"], datadict["has_more"]
|
||||
|
||||
def getUserAllMixInfoApi(self, sec_uid, count=35, cursor=0):
|
||||
|
||||
if sec_uid is None:
|
||||
return None
|
||||
|
||||
mixIdlist = []
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
try:
|
||||
url = self.urls.USER_MIX_LIST + utils.getXbogus(
|
||||
f'sec_user_id={sec_uid}&count={count}&cursor={cursor}&device_platform=webapp&aid=6383')
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers)
|
||||
datadict = json.loads(res.text)
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
return None
|
||||
|
||||
for mix in datadict["mix_infos"]:
|
||||
mixIdNameDict = {}
|
||||
mixIdNameDict["https://www.douyin.com/collection/" + mix["mix_id"]] = mix["mix_name"]
|
||||
mixIdlist.append(mixIdNameDict)
|
||||
|
||||
return mixIdlist, datadict, datadict["cursor"], datadict["has_more"]
|
||||
|
||||
def getMusicInfoApi(self, music_id: str, count=35, cursor=0):
|
||||
if music_id is None:
|
||||
return None
|
||||
|
||||
awemeList = []
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
try:
|
||||
url = self.urls.MUSIC + utils.getXbogus(
|
||||
f'music_id={music_id}&cursor={cursor}&count={count}&device_platform=webapp&aid=6383')
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers)
|
||||
datadict = json.loads(res.text)
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
return None
|
||||
|
||||
for aweme in datadict["aweme_list"]:
|
||||
# 清空self.awemeDict
|
||||
self.result.clearDict(self.result.awemeDict)
|
||||
|
||||
# 默认为视频
|
||||
awemeType = 0
|
||||
try:
|
||||
if aweme["images"] is not None:
|
||||
awemeType = 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 转换成我们自己的格式
|
||||
self.result.dataConvert(awemeType, self.result.awemeDict, aweme)
|
||||
|
||||
if self.result.awemeDict is not None and self.result.awemeDict != {}:
|
||||
awemeList.append(copy.deepcopy(self.result.awemeDict))
|
||||
|
||||
return awemeList, datadict, datadict["cursor"], datadict["has_more"]
|
||||
|
||||
def getUserDetailInfoApi(self, sec_uid):
|
||||
if sec_uid is None:
|
||||
return None
|
||||
|
||||
start = time.time() # 开始时间
|
||||
while True:
|
||||
# 接口不稳定, 有时服务器不返回数据, 需要重新获取
|
||||
try:
|
||||
url = self.urls.USER_DETAIL + utils.getXbogus(
|
||||
f'sec_user_id={sec_uid}&device_platform=webapp&aid=6383')
|
||||
|
||||
res = requests.get(url=url, headers=douyin_headers)
|
||||
datadict = json.loads(res.text)
|
||||
|
||||
if datadict is not None and datadict["status_code"] == 0:
|
||||
return datadict
|
||||
except Exception as e:
|
||||
end = time.time() # 结束时间
|
||||
if end - start > self.timeout:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
283
DouYin/apiproxy/douyin/download.py
Normal file
283
DouYin/apiproxy/douyin/download.py
Normal file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
from concurrent.futures import ThreadPoolExecutor, wait, ALL_COMPLETED
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
# import asyncio # 暂时注释掉
|
||||
# import aiohttp # 暂时注释掉
|
||||
import logging
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich import print as rprint
|
||||
|
||||
from apiproxy.douyin import douyin_headers
|
||||
from apiproxy.common import utils
|
||||
|
||||
logger = logging.getLogger("douyin_downloader")
|
||||
console = Console()
|
||||
|
||||
class Download(object):
|
||||
def __init__(self, thread=5, music=True, cover=True, avatar=True, resjson=True, folderstyle=True):
|
||||
self.thread = thread
|
||||
self.music = music
|
||||
self.cover = cover
|
||||
self.avatar = avatar
|
||||
self.resjson = resjson
|
||||
self.folderstyle = folderstyle
|
||||
self.console = Console()
|
||||
self.progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeRemainingColumn(),
|
||||
transient=True # 添加这个参数,进度条完成后自动消失
|
||||
)
|
||||
self.retry_times = 3
|
||||
self.chunk_size = 8192
|
||||
self.timeout = 30
|
||||
|
||||
def _download_media(self, url: str, path: Path, desc: str) -> bool:
|
||||
"""通用下载方法,处理所有类型的媒体下载"""
|
||||
if path.exists():
|
||||
self.console.print(f"[cyan]⏭️ 跳过已存在: {desc}[/]")
|
||||
return True
|
||||
|
||||
# 使用新的断点续传下载方法替换原有的下载逻辑
|
||||
return self.download_with_resume(url, path, desc)
|
||||
|
||||
def _get_first_url(self, url_list: list) -> str:
|
||||
"""安全地获取URL列表中的第一个URL"""
|
||||
if isinstance(url_list, list) and len(url_list) > 0:
|
||||
return url_list[0]
|
||||
return None
|
||||
|
||||
def _download_media_files(self, aweme: dict, path: Path, name: str, desc: str) -> None:
|
||||
"""下载所有媒体文件"""
|
||||
try:
|
||||
# 下载视频或图集
|
||||
if aweme["awemeType"] == 0: # 视频
|
||||
video_path = path / f"{name}_video.mp4"
|
||||
url_list = aweme.get("video", {}).get("play_addr", {}).get("url_list", [])
|
||||
if url := self._get_first_url(url_list):
|
||||
if not self._download_media(url, video_path, f"[视频]{desc}"):
|
||||
raise Exception("视频下载失败")
|
||||
else:
|
||||
logger.warning(f"视频URL为空: {desc}")
|
||||
|
||||
elif aweme["awemeType"] == 1: # 图集
|
||||
for i, image in enumerate(aweme.get("images", [])):
|
||||
url_list = image.get("url_list", [])
|
||||
if url := self._get_first_url(url_list):
|
||||
image_path = path / f"{name}_image_{i}.jpeg"
|
||||
if not self._download_media(url, image_path, f"[图集{i+1}]{desc}"):
|
||||
raise Exception(f"图片{i+1}下载失败")
|
||||
else:
|
||||
logger.warning(f"图片{i+1} URL为空: {desc}")
|
||||
|
||||
# 下载音乐
|
||||
if self.music:
|
||||
url_list = aweme.get("music", {}).get("play_url", {}).get("url_list", [])
|
||||
if url := self._get_first_url(url_list):
|
||||
music_name = utils.replaceStr(aweme["music"]["title"])
|
||||
music_path = path / f"{name}_music_{music_name}.mp3"
|
||||
if not self._download_media(url, music_path, f"[音乐]{desc}"):
|
||||
self.console.print(f"[yellow]⚠️ 音乐下载失败: {desc}[/]")
|
||||
|
||||
# 下载封面
|
||||
if self.cover and aweme["awemeType"] == 0:
|
||||
url_list = aweme.get("video", {}).get("cover", {}).get("url_list", [])
|
||||
if url := self._get_first_url(url_list):
|
||||
cover_path = path / f"{name}_cover.jpeg"
|
||||
if not self._download_media(url, cover_path, f"[封面]{desc}"):
|
||||
self.console.print(f"[yellow]⚠️ 封面下载失败: {desc}[/]")
|
||||
|
||||
# 下载头像
|
||||
if self.avatar:
|
||||
url_list = aweme.get("author", {}).get("avatar", {}).get("url_list", [])
|
||||
if url := self._get_first_url(url_list):
|
||||
avatar_path = path / f"{name}_avatar.jpeg"
|
||||
if not self._download_media(url, avatar_path, f"[头像]{desc}"):
|
||||
self.console.print(f"[yellow]⚠️ 头像下载失败: {desc}[/]")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"下载失败: {str(e)}")
|
||||
|
||||
def awemeDownload(self, awemeDict: dict, savePath: Path) -> None:
|
||||
"""下载单个作品的所有内容"""
|
||||
if not awemeDict:
|
||||
logger.warning("无效的作品数据")
|
||||
return
|
||||
|
||||
try:
|
||||
# 创建保存目录
|
||||
save_path = Path(savePath)
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 构建文件名
|
||||
file_name = f"{awemeDict['create_time']}_{utils.replaceStr(awemeDict['desc'])}"
|
||||
aweme_path = save_path / file_name if self.folderstyle else save_path
|
||||
aweme_path.mkdir(exist_ok=True)
|
||||
|
||||
# 保存JSON数据
|
||||
if self.resjson:
|
||||
self._save_json(aweme_path / f"{file_name}_result.json", awemeDict)
|
||||
|
||||
# 下载媒体文件
|
||||
desc = file_name[:30]
|
||||
self._download_media_files(awemeDict, aweme_path, file_name, desc)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理作品时出错: {str(e)}")
|
||||
|
||||
def _save_json(self, path: Path, data: dict) -> None:
|
||||
"""保存JSON数据"""
|
||||
try:
|
||||
with open(path, "w", encoding='utf-8') as f:
|
||||
json.dump(data, ensure_ascii=False, indent=2, fp=f)
|
||||
except Exception as e:
|
||||
logger.error(f"保存JSON失败: {path}, 错误: {str(e)}")
|
||||
|
||||
def userDownload(self, awemeList: List[dict], savePath: Path):
|
||||
if not awemeList:
|
||||
self.console.print("[yellow]⚠️ 没有找到可下载的内容[/]")
|
||||
return
|
||||
|
||||
save_path = Path(savePath)
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
start_time = time.time()
|
||||
total_count = len(awemeList)
|
||||
success_count = 0
|
||||
|
||||
# 显示下载信息面板
|
||||
self.console.print(Panel(
|
||||
Text.assemble(
|
||||
("下载配置\n", "bold cyan"),
|
||||
(f"总数: {total_count} 个作品\n", "cyan"),
|
||||
(f"线程: {self.thread}\n", "cyan"),
|
||||
(f"保存路径: {save_path}\n", "cyan"),
|
||||
),
|
||||
title="抖音下载器",
|
||||
border_style="cyan"
|
||||
))
|
||||
|
||||
with self.progress:
|
||||
download_task = self.progress.add_task(
|
||||
"[cyan]📥 批量下载进度",
|
||||
total=total_count
|
||||
)
|
||||
|
||||
for aweme in awemeList:
|
||||
try:
|
||||
self.awemeDownload(awemeDict=aweme, savePath=save_path)
|
||||
success_count += 1
|
||||
self.progress.update(download_task, advance=1)
|
||||
except Exception as e:
|
||||
self.console.print(f"[red]❌ 下载失败: {str(e)}[/]")
|
||||
|
||||
# 显示下载完成统计
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
minutes = int(duration // 60)
|
||||
seconds = int(duration % 60)
|
||||
|
||||
self.console.print(Panel(
|
||||
Text.assemble(
|
||||
("下载完成\n", "bold green"),
|
||||
(f"成功: {success_count}/{total_count}\n", "green"),
|
||||
(f"用时: {minutes}分{seconds}秒\n", "green"),
|
||||
(f"保存位置: {save_path}\n", "green"),
|
||||
),
|
||||
title="下载统计",
|
||||
border_style="green"
|
||||
))
|
||||
|
||||
def download_with_resume(self, url: str, filepath: Path, desc: str) -> bool:
|
||||
"""支持断点续传的下载方法"""
|
||||
file_size = filepath.stat().st_size if filepath.exists() else 0
|
||||
headers = {'Range': f'bytes={file_size}-'} if file_size > 0 else {}
|
||||
|
||||
for attempt in range(self.retry_times):
|
||||
try:
|
||||
response = requests.get(url, headers={**douyin_headers, **headers},
|
||||
stream=True, timeout=self.timeout)
|
||||
|
||||
if response.status_code not in (200, 206):
|
||||
raise Exception(f"HTTP {response.status_code}")
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0)) + file_size
|
||||
mode = 'ab' if file_size > 0 else 'wb'
|
||||
|
||||
with self.progress:
|
||||
task = self.progress.add_task(f"[cyan]⬇️ {desc}", total=total_size)
|
||||
self.progress.update(task, completed=file_size) # 更新断点续传的进度
|
||||
|
||||
with open(filepath, mode) as f:
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=self.chunk_size):
|
||||
if chunk:
|
||||
size = f.write(chunk)
|
||||
self.progress.update(task, advance=size)
|
||||
except (requests.exceptions.ConnectionError,
|
||||
requests.exceptions.ChunkedEncodingError,
|
||||
Exception) as chunk_error:
|
||||
# 网络中断,记录当前文件大小,下次从这里继续
|
||||
current_size = filepath.stat().st_size if filepath.exists() else 0
|
||||
logger.warning(f"下载中断,已下载 {current_size} 字节: {str(chunk_error)}")
|
||||
raise chunk_error
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# 计算重试等待时间(指数退避)
|
||||
wait_time = min(2 ** attempt, 10) # 最多等待10秒
|
||||
logger.warning(f"下载失败 (尝试 {attempt + 1}/{self.retry_times}): {str(e)}")
|
||||
|
||||
if attempt == self.retry_times - 1:
|
||||
self.console.print(f"[red]❌ 下载失败: {desc}\n {str(e)}[/]")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"等待 {wait_time} 秒后重试...")
|
||||
time.sleep(wait_time)
|
||||
# 重新计算文件大小,准备断点续传
|
||||
file_size = filepath.stat().st_size if filepath.exists() else 0
|
||||
headers = {'Range': f'bytes={file_size}-'} if file_size > 0 else {}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
def __init__(self, max_workers=3):
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
|
||||
def download_with_resume(self, url, filepath, callback=None):
|
||||
# 检查是否存在部分下载的文件
|
||||
file_size = os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
||||
|
||||
headers = {'Range': f'bytes={file_size}-'}
|
||||
|
||||
response = requests.get(url, headers=headers, stream=True)
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
|
||||
mode = 'ab' if file_size > 0 else 'wb'
|
||||
|
||||
with open(filepath, mode) as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
if callback:
|
||||
callback(len(chunk))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
300
DouYin/apiproxy/douyin/result.py
Normal file
300
DouYin/apiproxy/douyin/result.py
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import time
|
||||
import copy
|
||||
|
||||
|
||||
class Result(object):
|
||||
def __init__(self):
|
||||
# 作者信息
|
||||
self.authorDict = {
|
||||
"avatar_thumb": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"avatar": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"cover_url": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
# 喜欢的作品数
|
||||
"favoriting_count": "",
|
||||
# 粉丝数
|
||||
"follower_count": "",
|
||||
# 关注数
|
||||
"following_count": "",
|
||||
# 昵称
|
||||
"nickname": "",
|
||||
# 是否允许下载
|
||||
"prevent_download": "",
|
||||
# 用户 url id
|
||||
"sec_uid": "",
|
||||
# 是否私密账号
|
||||
"secret": "",
|
||||
# 短id
|
||||
"short_id": "",
|
||||
# 签名
|
||||
"signature": "",
|
||||
# 总获赞数
|
||||
"total_favorited": "",
|
||||
# 用户id
|
||||
"uid": "",
|
||||
# 用户自定义唯一id 抖音号
|
||||
"unique_id": "",
|
||||
# 年龄
|
||||
"user_age": "",
|
||||
|
||||
}
|
||||
# 图片信息
|
||||
self.picDict = {
|
||||
"height": "",
|
||||
"mask_url_list": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
}
|
||||
# 音乐信息
|
||||
self.musicDict = {
|
||||
"cover_hd": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"cover_large": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"cover_medium": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"cover_thumb": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
# 音乐作者抖音号
|
||||
"owner_handle": "",
|
||||
# 音乐作者id
|
||||
"owner_id": "",
|
||||
# 音乐作者昵称
|
||||
"owner_nickname": "",
|
||||
"play_url": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_key": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
# 音乐名字
|
||||
"title": "",
|
||||
}
|
||||
# 视频信息
|
||||
self.videoDict = {
|
||||
"play_addr": {
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
},
|
||||
"cover_original_scale": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"dynamic_cover": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"origin_cover": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
},
|
||||
"cover": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": ""
|
||||
}
|
||||
}
|
||||
# mix信息
|
||||
self.mixInfo = {
|
||||
"cover_url": {
|
||||
"height": "",
|
||||
"uri": "",
|
||||
"url_list": [],
|
||||
"width": 720
|
||||
},
|
||||
"ids": "",
|
||||
"is_serial_mix": "",
|
||||
"mix_id": "",
|
||||
"mix_name": "",
|
||||
"mix_pic_type": "",
|
||||
"mix_type": "",
|
||||
"statis": {
|
||||
"current_episode": "",
|
||||
"updated_to_episode": ""
|
||||
}
|
||||
}
|
||||
# 作品信息
|
||||
self.awemeDict = {
|
||||
# 作品创建时间
|
||||
"create_time": "",
|
||||
# awemeType=0 视频, awemeType=1 图集, awemeType=2 直播
|
||||
"awemeType": "",
|
||||
# 作品 id
|
||||
"aweme_id": "",
|
||||
# 作者信息
|
||||
"author": self.authorDict,
|
||||
# 作品描述
|
||||
"desc": "",
|
||||
# 图片
|
||||
"images": [],
|
||||
# 音乐
|
||||
"music": self.musicDict,
|
||||
# 合集
|
||||
"mix_info": self.mixInfo,
|
||||
# 视频
|
||||
"video": self.videoDict,
|
||||
# 作品信息统计
|
||||
"statistics": {
|
||||
"admire_count": "",
|
||||
"collect_count": "",
|
||||
"comment_count": "",
|
||||
"digg_count": "",
|
||||
"play_count": "",
|
||||
"share_count": ""
|
||||
}
|
||||
}
|
||||
# 用户作品信息
|
||||
self.awemeList = []
|
||||
# 直播信息
|
||||
self.liveDict = {
|
||||
# awemeType=0 视频, awemeType=1 图集, awemeType=2 直播
|
||||
"awemeType": "",
|
||||
# 是否在播
|
||||
"status": "",
|
||||
# 直播标题
|
||||
"title": "",
|
||||
# 直播cover
|
||||
"cover": "",
|
||||
# 头像
|
||||
"avatar": "",
|
||||
# 观看人数
|
||||
"user_count": "",
|
||||
# 昵称
|
||||
"nickname": "",
|
||||
# sec_uid
|
||||
"sec_uid": "",
|
||||
# 直播间观看状态
|
||||
"display_long": "",
|
||||
# 推流
|
||||
"flv_pull_url": "",
|
||||
# 分区
|
||||
"partition": "",
|
||||
"sub_partition": "",
|
||||
# 最清晰的地址
|
||||
"flv_pull_url0": "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
# 将得到的json数据(dataRaw)精简成自己定义的数据(dataNew)
|
||||
# 转换得到的数据
|
||||
def dataConvert(self, awemeType, dataNew, dataRaw):
|
||||
for item in dataNew:
|
||||
try:
|
||||
# 作品创建时间
|
||||
if item == "create_time":
|
||||
dataNew['create_time'] = time.strftime(
|
||||
"%Y-%m-%d %H.%M.%S", time.localtime(dataRaw['create_time']))
|
||||
continue
|
||||
# 设置 awemeType
|
||||
if item == "awemeType":
|
||||
dataNew["awemeType"] = awemeType
|
||||
continue
|
||||
# 当 解析的链接 是图片时
|
||||
if item == "images":
|
||||
if awemeType == 1:
|
||||
for image in dataRaw[item]:
|
||||
for i in image:
|
||||
self.picDict[i] = image[i]
|
||||
# 字典要深拷贝
|
||||
self.awemeDict["images"].append(copy.deepcopy(self.picDict))
|
||||
continue
|
||||
# 当 解析的链接 是视频时
|
||||
if item == "video":
|
||||
if awemeType == 0:
|
||||
self.dataConvert(awemeType, dataNew[item], dataRaw[item])
|
||||
continue
|
||||
# 将小头像放大
|
||||
if item == "avatar":
|
||||
for i in dataNew[item]:
|
||||
if i == "url_list":
|
||||
for j in self.awemeDict["author"]["avatar_thumb"]["url_list"]:
|
||||
dataNew[item][i].append(j.replace("100x100", "1080x1080"))
|
||||
elif i == "uri":
|
||||
dataNew[item][i] = self.awemeDict["author"]["avatar_thumb"][i].replace("100x100",
|
||||
"1080x1080")
|
||||
else:
|
||||
dataNew[item][i] = self.awemeDict["author"]["avatar_thumb"][i]
|
||||
continue
|
||||
|
||||
# 原来的json是[{}] 而我们的是 {}
|
||||
if item == "cover_url":
|
||||
self.dataConvert(awemeType, dataNew[item], dataRaw[item][0])
|
||||
continue
|
||||
|
||||
# 根据 uri 获取 1080p 视频
|
||||
if item == "play_addr":
|
||||
dataNew[item]["uri"] = dataRaw["bit_rate"][0]["play_addr"]["uri"]
|
||||
# 使用 这个api 可以获得1080p
|
||||
# dataNew[item]["url_list"] = "https://aweme.snssdk.com/aweme/v1/play/?video_id=%s&ratio=1080p&line=0" \
|
||||
# % dataNew[item]["uri"]
|
||||
dataNew[item]["url_list"] = copy.deepcopy(dataRaw["bit_rate"][0]["play_addr"]["url_list"])
|
||||
continue
|
||||
|
||||
# 常规 递归遍历 字典
|
||||
if isinstance(dataNew[item], dict):
|
||||
self.dataConvert(awemeType, dataNew[item], dataRaw[item])
|
||||
else:
|
||||
# 赋值
|
||||
dataNew[item] = dataRaw[item]
|
||||
except Exception as e:
|
||||
# 删除这个警告, 总是让人误会出错了
|
||||
# print("[ 警告 ]:转换数据时在接口中未找到 %s\r" % (item))
|
||||
pass
|
||||
|
||||
def clearDict(self, data):
|
||||
for item in data:
|
||||
# 常规 递归遍历 字典
|
||||
if isinstance(data[item], dict):
|
||||
self.clearDict(data[item])
|
||||
elif isinstance(data[item], list):
|
||||
data[item] = []
|
||||
else:
|
||||
data[item] = ""
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
23
DouYin/apiproxy/douyin/strategies/__init__.py
Normal file
23
DouYin/apiproxy/douyin/strategies/__init__.py
Normal 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'
|
||||
]
|
||||
450
DouYin/apiproxy/douyin/strategies/api_strategy.py
Normal file
450
DouYin/apiproxy/douyin/strategies/api_strategy.py
Normal 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
|
||||
121
DouYin/apiproxy/douyin/strategies/base.py
Normal file
121
DouYin/apiproxy/douyin/strategies/base.py
Normal 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()})"
|
||||
463
DouYin/apiproxy/douyin/strategies/browser_strategy.py
Normal file
463
DouYin/apiproxy/douyin/strategies/browser_strategy.py
Normal 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()
|
||||
244
DouYin/apiproxy/douyin/strategies/retry_strategy.py
Normal file
244
DouYin/apiproxy/douyin/strategies/retry_strategy.py
Normal 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
|
||||
64
DouYin/apiproxy/douyin/urls.py
Normal file
64
DouYin/apiproxy/douyin/urls.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
class Urls(object):
|
||||
def __init__(self):
|
||||
######################################### WEB #########################################
|
||||
# 首页推荐
|
||||
self.TAB_FEED = 'https://www.douyin.com/aweme/v1/web/tab/feed/?'
|
||||
|
||||
# 用户短信息(给多少个用户secid就返回多少的用户信息)
|
||||
self.USER_SHORT_INFO = 'https://www.douyin.com/aweme/v1/web/im/user/info/?'
|
||||
|
||||
# 用户详细信息
|
||||
self.USER_DETAIL = 'https://www.douyin.com/aweme/v1/web/user/profile/other/?'
|
||||
|
||||
# 用户作品
|
||||
self.USER_POST = 'https://www.douyin.com/aweme/v1/web/aweme/post/?'
|
||||
|
||||
# 作品信息
|
||||
self.POST_DETAIL = 'https://www.douyin.com/aweme/v1/web/aweme/detail/?'
|
||||
|
||||
# 用户喜欢A
|
||||
# 需要 odin_tt
|
||||
self.USER_FAVORITE_A = 'https://www.douyin.com/aweme/v1/web/aweme/favorite/?'
|
||||
|
||||
# 用户喜欢B
|
||||
self.USER_FAVORITE_B = 'https://www.iesdouyin.com/web/api/v2/aweme/like/?'
|
||||
|
||||
# 用户历史
|
||||
self.USER_HISTORY = 'https://www.douyin.com/aweme/v1/web/history/read/?'
|
||||
|
||||
# 用户收藏
|
||||
self.USER_COLLECTION = 'https://www.douyin.com/aweme/v1/web/aweme/listcollection/?'
|
||||
|
||||
# 用户评论
|
||||
self.COMMENT = 'https://www.douyin.com/aweme/v1/web/comment/list/?'
|
||||
|
||||
# 首页朋友作品
|
||||
self.FRIEND_FEED = 'https://www.douyin.com/aweme/v1/web/familiar/feed/?'
|
||||
|
||||
# 关注用户作品
|
||||
self.FOLLOW_FEED = 'https://www.douyin.com/aweme/v1/web/follow/feed/?'
|
||||
|
||||
# 合集下所有作品
|
||||
# 只需要X-Bogus
|
||||
self.USER_MIX = 'https://www.douyin.com/aweme/v1/web/mix/aweme/?'
|
||||
|
||||
# 用户所有合集列表
|
||||
# 需要 ttwid
|
||||
self.USER_MIX_LIST = 'https://www.douyin.com/aweme/v1/web/mix/list/?'
|
||||
|
||||
# 直播
|
||||
self.LIVE = 'https://live.douyin.com/webcast/room/web/enter/?'
|
||||
self.LIVE2 = 'https://webcast.amemv.com/webcast/room/reflow/info/?'
|
||||
|
||||
# 音乐
|
||||
self.MUSIC = 'https://www.douyin.com/aweme/v1/web/music/aweme/?'
|
||||
|
||||
#######################################################################################
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
Reference in New Issue
Block a user