mirror of
https://github.com/Cp0204/quark-auto-save.git
synced 2026-01-12 15:20:44 +08:00
- 将 app/douban_service.py 移动到 app/sdk/douban_service.py - 更新 app/run.py 中的导入路径为 sdk.douban_service - 优化代码组织结构,统一 SDK 模块管理
515 lines
20 KiB
Python
515 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
豆瓣API服务模块
|
||
用于获取豆瓣影视榜单数据
|
||
"""
|
||
|
||
import requests
|
||
from typing import Dict, Optional, Any
|
||
|
||
|
||
class DoubanService:
|
||
"""豆瓣API服务类"""
|
||
|
||
def __init__(self):
|
||
self.base_url = "https://m.douban.com/rexxar/api/v2"
|
||
self.headers = {
|
||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1',
|
||
'Referer': 'https://m.douban.com/',
|
||
'Accept': 'application/json, text/plain, */*',
|
||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||
'Accept-Encoding': 'gzip, deflate', # 移除br,避免Brotli压缩
|
||
'Connection': 'keep-alive',
|
||
'Sec-Fetch-Dest': 'empty',
|
||
'Sec-Fetch-Mode': 'cors',
|
||
'Sec-Fetch-Site': 'same-origin'
|
||
}
|
||
|
||
# 电影榜单配置 - 4个大类,每个大类下有5个小类
|
||
self.movie_categories = {
|
||
'热门电影': {
|
||
'全部': {'category': '热门', 'type': '全部'},
|
||
'华语': {'category': '热门', 'type': '华语'},
|
||
'欧美': {'category': '热门', 'type': '欧美'},
|
||
'韩国': {'category': '热门', 'type': '韩国'},
|
||
'日本': {'category': '热门', 'type': '日本'}
|
||
},
|
||
'最新电影': {
|
||
'全部': {'category': '最新', 'type': '全部'},
|
||
'华语': {'category': '最新', 'type': '华语'},
|
||
'欧美': {'category': '最新', 'type': '欧美'},
|
||
'韩国': {'category': '最新', 'type': '韩国'},
|
||
'日本': {'category': '最新', 'type': '日本'}
|
||
},
|
||
'豆瓣高分': {
|
||
'全部': {'category': '豆瓣高分', 'type': '全部'},
|
||
'华语': {'category': '豆瓣高分', 'type': '华语'},
|
||
'欧美': {'category': '豆瓣高分', 'type': '欧美'},
|
||
'韩国': {'category': '豆瓣高分', 'type': '韩国'},
|
||
'日本': {'category': '豆瓣高分', 'type': '日本'}
|
||
},
|
||
'冷门佳片': {
|
||
'全部': {'category': '冷门佳片', 'type': '全部'},
|
||
'华语': {'category': '冷门佳片', 'type': '华语'},
|
||
'欧美': {'category': '冷门佳片', 'type': '欧美'},
|
||
'韩国': {'category': '冷门佳片', 'type': '韩国'},
|
||
'日本': {'category': '冷门佳片', 'type': '日本'}
|
||
}
|
||
}
|
||
|
||
# 剧集榜单配置 - 2个大类
|
||
self.tv_categories = {
|
||
'最近热门剧集': {
|
||
'综合': {'category': 'tv', 'type': 'tv'},
|
||
'国产剧': {'category': 'tv', 'type': 'tv_domestic'},
|
||
'欧美剧': {'category': 'tv', 'type': 'tv_american'},
|
||
'日剧': {'category': 'tv', 'type': 'tv_japanese'},
|
||
'韩剧': {'category': 'tv', 'type': 'tv_korean'},
|
||
'动画': {'category': 'tv', 'type': 'tv_animation'},
|
||
'纪录片': {'category': 'tv', 'type': 'tv_documentary'}
|
||
},
|
||
'最近热门综艺': {
|
||
'综合': {'category': 'show', 'type': 'show'},
|
||
'国内': {'category': 'show', 'type': 'show_domestic'},
|
||
'国外': {'category': 'show', 'type': 'show_foreign'}
|
||
}
|
||
}
|
||
|
||
|
||
def get_list_data(self, main_category: str, sub_category: str, limit: int = 20, start: int = 0) -> Dict[str, Any]:
|
||
"""
|
||
获取榜单数据
|
||
|
||
Args:
|
||
main_category: 主分类 (movie_hot, movie_latest, etc.)
|
||
sub_category: 子分类 (全部, 华语, 欧美, etc.)
|
||
limit: 返回数量限制
|
||
start: 起始位置
|
||
|
||
Returns:
|
||
包含榜单数据的字典
|
||
"""
|
||
try:
|
||
# 判断是电影还是电视剧
|
||
if main_category.startswith('movie_'):
|
||
return self._get_movie_ranking(main_category, sub_category, start, limit)
|
||
elif main_category.startswith('tv_'):
|
||
return self._get_tv_ranking(main_category, sub_category, start, limit)
|
||
else:
|
||
return {
|
||
'success': False,
|
||
'message': f'不支持的主分类: {main_category}',
|
||
'data': {'items': []}
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
'success': False,
|
||
'message': f'获取榜单数据失败: {str(e)}',
|
||
'data': {'items': []}
|
||
}
|
||
|
||
def _get_movie_ranking(self, main_category: str, sub_category: str, start: int = 0, limit: int = 20) -> Dict[str, Any]:
|
||
"""获取电影榜单数据"""
|
||
try:
|
||
|
||
# 映射主分类到豆瓣分类
|
||
category_mapping = {
|
||
'movie_hot': '热门电影',
|
||
'movie_latest': '最新电影',
|
||
'movie_top': '豆瓣高分',
|
||
'movie_underrated': '冷门佳片'
|
||
}
|
||
|
||
douban_main_category = category_mapping.get(main_category, '热门电影')
|
||
|
||
# 获取对应的category和type
|
||
if douban_main_category not in self.movie_categories:
|
||
# 使用模拟数据
|
||
mock_data = self._get_mock_movie_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': '不支持的分类,使用模拟数据'
|
||
}
|
||
}
|
||
|
||
category_config = self.movie_categories[douban_main_category].get(sub_category)
|
||
if not category_config:
|
||
# 使用模拟数据
|
||
mock_data = self._get_mock_movie_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': '不支持的子分类,使用模拟数据'
|
||
}
|
||
}
|
||
|
||
# 构建请求参数 - 按照参考项目的逻辑处理分类参数
|
||
params = {'start': start, 'limit': limit}
|
||
|
||
# 根据分类映射到豆瓣API的参数
|
||
douban_category = category_config.get('category', '热门')
|
||
douban_type = category_config.get('type', '全部')
|
||
|
||
# 根据不同的category和type添加特定参数(完全按照参考项目的逻辑)
|
||
if douban_category != '热门' or douban_type != '全部':
|
||
if douban_type != '全部':
|
||
params['type'] = douban_type
|
||
if douban_category != '热门':
|
||
params['category'] = douban_category
|
||
|
||
# 尝试调用豆瓣API
|
||
try:
|
||
url = f"{self.base_url}/subject/recent_hot/movie"
|
||
|
||
# 创建session来自动处理gzip解压
|
||
session = requests.Session()
|
||
session.headers.update(self.headers)
|
||
|
||
response = session.get(url, params=params, timeout=30)
|
||
response.raise_for_status()
|
||
|
||
# 检查响应内容是否为空
|
||
if not response.text.strip():
|
||
raise ValueError("API返回空响应")
|
||
|
||
data = response.json()
|
||
|
||
# 处理豆瓣移动端API的响应格式
|
||
items = data.get('items', []) or data.get('subjects', [])
|
||
|
||
if not items:
|
||
mock_data = self._get_mock_movie_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': 'API返回空数据'
|
||
}
|
||
}
|
||
|
||
# 处理返回的数据
|
||
processed_items = []
|
||
for item in items[:limit]:
|
||
processed_item = self._process_item(item)
|
||
if processed_item:
|
||
processed_items.append(processed_item)
|
||
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功',
|
||
'data': {
|
||
'items': processed_items,
|
||
'total': data.get('total', len(processed_items))
|
||
}
|
||
}
|
||
|
||
except Exception:
|
||
mock_data = self._get_mock_movie_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': 'API调用失败'
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
mock_data = self._get_mock_movie_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': f'获取失败: {str(e)}'
|
||
}
|
||
}
|
||
|
||
def _get_tv_ranking(self, main_category: str, sub_category: str, start: int = 0, limit: int = 20) -> Dict[str, Any]:
|
||
"""获取电视剧榜单数据"""
|
||
try:
|
||
|
||
# 映射主分类到豆瓣分类
|
||
category_mapping = {
|
||
'tv_drama': '最近热门剧集',
|
||
'tv_variety': '最近热门综艺'
|
||
}
|
||
|
||
douban_main_category = category_mapping.get(main_category, '最近热门剧集')
|
||
|
||
# 获取对应的category和type
|
||
if douban_main_category not in self.tv_categories:
|
||
# 使用模拟数据
|
||
mock_data = self._get_mock_tv_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': '不支持的分类,使用模拟数据'
|
||
}
|
||
}
|
||
|
||
category_config = self.tv_categories[douban_main_category].get(sub_category)
|
||
if not category_config:
|
||
# 使用模拟数据
|
||
mock_data = self._get_mock_tv_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': '不支持的子分类,使用模拟数据'
|
||
}
|
||
}
|
||
|
||
# 构建请求参数 - 按照参考项目的逻辑处理分类参数
|
||
params = {'start': start, 'limit': limit}
|
||
|
||
# 根据分类映射到豆瓣API的参数
|
||
douban_category = category_config.get('category', 'tv')
|
||
douban_type = category_config.get('type', 'tv')
|
||
|
||
# 根据不同的category和type添加特定参数(完全按照参考项目的逻辑)
|
||
if douban_category != 'tv' or douban_type != 'tv':
|
||
if douban_type != 'tv':
|
||
params['type'] = douban_type
|
||
if douban_category != 'tv':
|
||
params['category'] = douban_category
|
||
|
||
# 尝试调用豆瓣API
|
||
try:
|
||
url = f"{self.base_url}/subject/recent_hot/tv"
|
||
|
||
# 创建session来自动处理gzip解压
|
||
session = requests.Session()
|
||
session.headers.update(self.headers)
|
||
|
||
response = session.get(url, params=params, timeout=30)
|
||
response.raise_for_status()
|
||
|
||
# 检查响应内容是否为空
|
||
if not response.text.strip():
|
||
raise ValueError("TV API返回空响应")
|
||
|
||
data = response.json()
|
||
|
||
# 处理豆瓣移动端API的响应格式
|
||
items = data.get('items', []) or data.get('subjects', [])
|
||
|
||
if not items:
|
||
mock_data = self._get_mock_tv_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': 'API返回空数据'
|
||
}
|
||
}
|
||
|
||
# 处理返回的数据
|
||
processed_items = []
|
||
for item in items[:limit]:
|
||
processed_item = self._process_item(item)
|
||
if processed_item:
|
||
processed_items.append(processed_item)
|
||
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功',
|
||
'data': {
|
||
'items': processed_items,
|
||
'total': data.get('total', len(processed_items))
|
||
}
|
||
}
|
||
|
||
except Exception:
|
||
mock_data = self._get_mock_tv_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': 'API调用失败'
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
mock_data = self._get_mock_tv_data()
|
||
return {
|
||
'success': True,
|
||
'message': '获取成功(模拟数据)',
|
||
'data': {
|
||
'items': mock_data['items'][:limit],
|
||
'total': len(mock_data['items']),
|
||
'is_mock_data': True,
|
||
'mock_reason': f'获取失败: {str(e)}'
|
||
}
|
||
}
|
||
|
||
def _process_item(self, item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
处理单个影片数据项
|
||
|
||
Args:
|
||
item: 原始数据项
|
||
|
||
Returns:
|
||
处理后的数据项
|
||
"""
|
||
try:
|
||
# 处理图片URL
|
||
pic_data = item.get('pic', {})
|
||
pic_url = ''
|
||
if pic_data:
|
||
# 优先使用normal尺寸的图片
|
||
pic_url = pic_data.get('normal', '') or pic_data.get('large', '')
|
||
|
||
# 处理评分数据
|
||
rating_data = item.get('rating', {})
|
||
rating = None
|
||
if rating_data and rating_data.get('value'):
|
||
rating = {'value': rating_data.get('value')}
|
||
|
||
# 处理URL - 将douban://协议转换为标准HTTP链接
|
||
original_url = item.get('url', '') or item.get('uri', '')
|
||
processed_url = ''
|
||
if original_url:
|
||
if original_url.startswith('douban://douban.com/'):
|
||
# 将 douban://douban.com/movie/123 转换为 https://movie.douban.com/subject/123/
|
||
if '/movie/' in original_url:
|
||
# 提取ID部分
|
||
movie_id = original_url.split('/movie/')[-1]
|
||
processed_url = f'https://movie.douban.com/subject/{movie_id}/'
|
||
elif '/tv/' in original_url:
|
||
# 提取ID部分
|
||
tv_id = original_url.split('/tv/')[-1]
|
||
processed_url = f'https://movie.douban.com/subject/{tv_id}/'
|
||
else:
|
||
processed_url = original_url
|
||
else:
|
||
processed_url = original_url
|
||
|
||
processed = {
|
||
'id': item.get('id', ''),
|
||
'title': item.get('title', ''),
|
||
'year': item.get('year', ''),
|
||
'url': processed_url,
|
||
'pic': {
|
||
'normal': pic_url
|
||
},
|
||
'rating': rating,
|
||
'card_subtitle': item.get('card_subtitle', '')
|
||
}
|
||
|
||
# 确保必要字段存在
|
||
if not processed['title']:
|
||
return None
|
||
|
||
return processed
|
||
|
||
except Exception:
|
||
return None
|
||
|
||
def _get_mock_movie_data(self) -> Dict[str, Any]:
|
||
"""获取模拟电影数据"""
|
||
return {
|
||
'notice': "⚠️ 这是模拟数据,非豆瓣实时数据",
|
||
'items': [
|
||
{
|
||
'id': "1292052",
|
||
'title': "肖申克的救赎",
|
||
'rating': {'value': 9.7},
|
||
'year': "1994",
|
||
'url': "https://movie.douban.com/subject/1292052/",
|
||
'pic': {'normal': ""},
|
||
'card_subtitle': "1994 / 美国 / 剧情 犯罪 / 弗兰克·德拉邦特 / 蒂姆·罗宾斯 摩根·弗里曼"
|
||
},
|
||
{
|
||
'id': "1291546",
|
||
'title': "霸王别姬",
|
||
'rating': {'value': 9.6},
|
||
'year': "1993",
|
||
'url': "https://movie.douban.com/subject/1291546/",
|
||
'pic': {'normal': ""},
|
||
'card_subtitle': "1993 / 中国大陆 香港 / 剧情 爱情 同性 / 陈凯歌 / 张国荣 张丰毅"
|
||
},
|
||
{
|
||
'id': "1295644",
|
||
'title': "阿甘正传",
|
||
'rating': {'value': 9.5},
|
||
'year': "1994",
|
||
'url': "https://movie.douban.com/subject/1295644/",
|
||
'pic': {'normal': ""},
|
||
'card_subtitle': "1994 / 美国 / 剧情 爱情 / 罗伯特·泽米吉斯 / 汤姆·汉克斯 罗宾·怀特"
|
||
}
|
||
],
|
||
'total': 3
|
||
}
|
||
|
||
def _get_mock_tv_data(self) -> Dict[str, Any]:
|
||
"""获取模拟电视剧数据"""
|
||
return {
|
||
'notice': "⚠️ 这是模拟数据,非豆瓣实时数据",
|
||
'items': [
|
||
{
|
||
'id': "26794435",
|
||
'title': "请回答1988",
|
||
'rating': {'value': 9.7},
|
||
'year': "2015",
|
||
'url': "https://movie.douban.com/subject/26794435/",
|
||
'pic': {'normal': ""},
|
||
'card_subtitle': "2015 / 韩国 / 剧情 喜剧 家庭 / 申源浩 / 李惠利 朴宝剑"
|
||
},
|
||
{
|
||
'id': "1309163",
|
||
'title': "大明王朝1566",
|
||
'rating': {'value': 9.7},
|
||
'year': "2007",
|
||
'url': "https://movie.douban.com/subject/1309163/",
|
||
'pic': {'normal': ""},
|
||
'card_subtitle': "2007 / 中国大陆 / 剧情 历史 / 张黎 / 陈宝国 黄志忠"
|
||
},
|
||
{
|
||
'id': "1309169",
|
||
'title': "亮剑",
|
||
'rating': {'value': 9.3},
|
||
'year': "2005",
|
||
'url': "https://movie.douban.com/subject/1309169/",
|
||
'pic': {'normal': ""},
|
||
'card_subtitle': "2005 / 中国大陆 / 剧情 战争 / 陈健 张前 / 李幼斌 何政军"
|
||
}
|
||
],
|
||
'total': 3
|
||
}
|
||
|
||
|
||
# 创建全局实例
|
||
douban_service = DoubanService()
|