mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-05 19:17:30 +08:00
first commit
This commit is contained in:
479
src/lib/douban.client.ts
Normal file
479
src/lib/douban.client.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,no-case-declarations */
|
||||
|
||||
import { DoubanItem, DoubanResult } from './types';
|
||||
|
||||
interface DoubanCategoriesParams {
|
||||
kind: 'tv' | 'movie';
|
||||
category: string;
|
||||
type: string;
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
}
|
||||
|
||||
interface DoubanCategoryApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DoubanListApiResponse {
|
||||
total: number;
|
||||
subjects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DoubanRecommendApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
year: string;
|
||||
type: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时的 fetch 请求
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
proxyUrl: string
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 检查是否使用代理
|
||||
const finalUrl =
|
||||
proxyUrl === 'https://cors-anywhere.com/'
|
||||
? `${proxyUrl}${url}`
|
||||
: proxyUrl
|
||||
? `${proxyUrl}${encodeURIComponent(url)}`
|
||||
: url;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(finalUrl, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getDoubanProxyConfig(): {
|
||||
proxyType:
|
||||
| 'direct'
|
||||
| 'cors-proxy-zwei'
|
||||
| 'cmliussss-cdn-tencent'
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'cors-anywhere'
|
||||
| 'custom';
|
||||
proxyUrl: string;
|
||||
} {
|
||||
const doubanProxyType =
|
||||
localStorage.getItem('doubanDataSource') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
|
||||
'direct';
|
||||
const doubanProxy =
|
||||
localStorage.getItem('doubanProxyUrl') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
|
||||
'';
|
||||
return {
|
||||
proxyType: doubanProxyType,
|
||||
proxyUrl: doubanProxy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器端豆瓣分类数据获取函数
|
||||
*/
|
||||
export async function fetchDoubanCategories(
|
||||
params: DoubanCategoriesParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
|
||||
// 验证参数
|
||||
if (!['tv', 'movie'].includes(kind)) {
|
||||
throw new Error('kind 参数必须是 tv 或 movie');
|
||||
}
|
||||
|
||||
if (!category || !type) {
|
||||
throw new Error('category 和 type 参数不能为空');
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
throw new Error('pageLimit 必须在 1-100 之间');
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
throw new Error('pageStart 不能小于 0');
|
||||
}
|
||||
|
||||
const target = useTencentCDN
|
||||
? `https://m.douban.cmliussss.net/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
|
||||
: useAliCDN
|
||||
? `https://m.douban.cmliussss.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
|
||||
: `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanCategoryApiResponse = await response.json();
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.pic?.normal || item.pic?.large || '',
|
||||
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
|
||||
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣分类数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
|
||||
*/
|
||||
export async function getDoubanCategories(
|
||||
params: DoubanCategoriesParams
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanCategories(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanCategories(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanCategories(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
interface DoubanListParams {
|
||||
tag: string;
|
||||
type: string;
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
}
|
||||
|
||||
export async function getDoubanList(
|
||||
params: DoubanListParams
|
||||
): Promise<DoubanResult> {
|
||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanList(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanList(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanList(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanList(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDoubanList(
|
||||
params: DoubanListParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
|
||||
// 验证参数
|
||||
if (!tag || !type) {
|
||||
throw new Error('tag 和 type 参数不能为空');
|
||||
}
|
||||
|
||||
if (!['tv', 'movie'].includes(type)) {
|
||||
throw new Error('type 参数必须是 tv 或 movie');
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
throw new Error('pageLimit 必须在 1-100 之间');
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
throw new Error('pageStart 不能小于 0');
|
||||
}
|
||||
|
||||
const target = useTencentCDN
|
||||
? `https://movie.douban.cmliussss.net/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
|
||||
: useAliCDN
|
||||
? `https://movie.douban.cmliussss.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
|
||||
: `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanListApiResponse = await response.json();
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.cover,
|
||||
rate: item.rate,
|
||||
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣列表数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface DoubanRecommendsParams {
|
||||
kind: 'tv' | 'movie';
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
category?: string;
|
||||
format?: string;
|
||||
label?: string;
|
||||
region?: string;
|
||||
year?: string;
|
||||
platform?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export async function getDoubanRecommends(
|
||||
params: DoubanRecommendsParams
|
||||
): Promise<DoubanResult> {
|
||||
const {
|
||||
kind,
|
||||
pageLimit = 20,
|
||||
pageStart = 0,
|
||||
category,
|
||||
format,
|
||||
label,
|
||||
region,
|
||||
year,
|
||||
platform,
|
||||
sort,
|
||||
} = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanRecommends(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanRecommends(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanRecommends(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDoubanRecommends(
|
||||
params: DoubanRecommendsParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, pageLimit = 20, pageStart = 0 } = params;
|
||||
let { category, format, region, year, platform, sort, label } = params;
|
||||
if (category === 'all') {
|
||||
category = '';
|
||||
}
|
||||
if (format === 'all') {
|
||||
format = '';
|
||||
}
|
||||
if (label === 'all') {
|
||||
label = '';
|
||||
}
|
||||
if (region === 'all') {
|
||||
region = '';
|
||||
}
|
||||
if (year === 'all') {
|
||||
year = '';
|
||||
}
|
||||
if (platform === 'all') {
|
||||
platform = '';
|
||||
}
|
||||
if (sort === 'T') {
|
||||
sort = '';
|
||||
}
|
||||
|
||||
const selectedCategories = { 类型: category } as any;
|
||||
if (format) {
|
||||
selectedCategories['形式'] = format;
|
||||
}
|
||||
if (region) {
|
||||
selectedCategories['地区'] = region;
|
||||
}
|
||||
|
||||
const tags = [] as Array<string>;
|
||||
if (category) {
|
||||
tags.push(category);
|
||||
}
|
||||
if (!category && format) {
|
||||
tags.push(format);
|
||||
}
|
||||
if (label) {
|
||||
tags.push(label);
|
||||
}
|
||||
if (region) {
|
||||
tags.push(region);
|
||||
}
|
||||
if (year) {
|
||||
tags.push(year);
|
||||
}
|
||||
if (platform) {
|
||||
tags.push(platform);
|
||||
}
|
||||
|
||||
const baseUrl = useTencentCDN
|
||||
? `https://m.douban.cmliussss.net/rexxar/api/v2/${kind}/recommend`
|
||||
: useAliCDN
|
||||
? `https://m.douban.cmliussss.com/rexxar/api/v2/${kind}/recommend`
|
||||
: `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
|
||||
const reqParams = new URLSearchParams();
|
||||
reqParams.append('refresh', '0');
|
||||
reqParams.append('start', pageStart.toString());
|
||||
reqParams.append('count', pageLimit.toString());
|
||||
reqParams.append('selected_categories', JSON.stringify(selectedCategories));
|
||||
reqParams.append('uncollect', 'false');
|
||||
reqParams.append('score_range', '0,10');
|
||||
reqParams.append('tags', tags.join(','));
|
||||
if (sort) {
|
||||
reqParams.append('sort', sort);
|
||||
}
|
||||
const target = `${baseUrl}?${reqParams.toString()}`;
|
||||
console.log(target);
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanRecommendApiResponse = await response.json();
|
||||
const list: DoubanItem[] = doubanData.items
|
||||
.filter((item) => item.type == 'movie' || item.type == 'tv')
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.pic?.normal || item.pic?.large || '',
|
||||
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
|
||||
year: item.year,
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`获取豆瓣推荐数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user