quark-auto-save/app/sdk/douban_service.py
x1ao4 2896b801d6 将 douban_service.py 移动到 sdk 目录
- 将 app/douban_service.py 移动到 app/sdk/douban_service.py
- 更新 app/run.py 中的导入路径为 sdk.douban_service
- 优化代码组织结构,统一 SDK 模块管理
2025-07-14 00:12:25 +08:00

515 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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()