豆瓣数据源增加备用源

This commit is contained in:
mtvpls
2026-04-09 21:30:07 +08:00
parent 204eec782f
commit 769dbf8310
11 changed files with 778 additions and 202 deletions
+244 -98
View File
@@ -88,6 +88,36 @@ interface DoubanDetailApiResponse {
[key: string]: any; // 允许其他字段
}
type DoubanProxyType =
| 'direct'
| 'cors-proxy-zwei'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'cors-anywhere'
| 'custom';
function normalizeDoubanProxyConfig(
proxyType: DoubanProxyType,
proxyUrl: string
): {
proxyType: DoubanProxyType;
proxyUrl: string;
} {
const normalizedProxyUrl = proxyUrl.trim();
if (proxyType === 'custom' && !normalizedProxyUrl) {
return {
proxyType: 'direct',
proxyUrl: '',
};
}
return {
proxyType,
proxyUrl: normalizedProxyUrl,
};
}
/**
* 带超时的 fetch 请求
*/
@@ -135,6 +165,14 @@ function getDoubanProxyConfig(): {
| 'cors-anywhere'
| 'custom';
proxyUrl: string;
backupProxyType:
| 'direct'
| 'cors-proxy-zwei'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'cors-anywhere'
| 'custom';
backupProxyUrl: string;
} {
const doubanProxyType =
localStorage.getItem('doubanDataSource') ||
@@ -144,12 +182,115 @@ function getDoubanProxyConfig(): {
localStorage.getItem('doubanProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
'';
const doubanProxyBackupType =
(localStorage.getItem('doubanDataSourceBackup') as DoubanProxyType | null) ||
'direct';
const doubanProxyBackupUrl =
localStorage.getItem('doubanProxyUrlBackup') || '';
const primaryConfig = normalizeDoubanProxyConfig(doubanProxyType, doubanProxy);
const backupConfig = normalizeDoubanProxyConfig(
doubanProxyBackupType,
doubanProxyBackupUrl
);
return {
proxyType: doubanProxyType,
proxyUrl: doubanProxy,
proxyType: primaryConfig.proxyType,
proxyUrl: primaryConfig.proxyUrl,
backupProxyType: backupConfig.proxyType,
backupProxyUrl: backupConfig.proxyUrl,
};
}
function buildDoubanRequester(
proxyType: DoubanProxyType,
proxyUrl: string
): {
useDirectApi: boolean;
requestProxyUrl: string;
useTencentCDN: boolean;
useAliCDN: boolean;
} {
switch (proxyType) {
case 'cors-proxy-zwei':
return {
useDirectApi: false,
requestProxyUrl: 'https://ciao-cors.is-an.org/',
useTencentCDN: false,
useAliCDN: false,
};
case 'cmliussss-cdn-tencent':
return {
useDirectApi: false,
requestProxyUrl: '',
useTencentCDN: true,
useAliCDN: false,
};
case 'cmliussss-cdn-ali':
return {
useDirectApi: false,
requestProxyUrl: '',
useTencentCDN: false,
useAliCDN: true,
};
case 'cors-anywhere':
return {
useDirectApi: false,
requestProxyUrl: 'https://cors-anywhere.com/',
useTencentCDN: false,
useAliCDN: false,
};
case 'custom':
return {
useDirectApi: false,
requestProxyUrl: proxyUrl,
useTencentCDN: false,
useAliCDN: false,
};
case 'direct':
default:
return {
useDirectApi: true,
requestProxyUrl: '',
useTencentCDN: false,
useAliCDN: false,
};
}
}
async function requestDoubanWithFallback<T>(
primary: { proxyType: DoubanProxyType; proxyUrl: string },
backup: { proxyType: DoubanProxyType; proxyUrl: string },
runner: (requester: ReturnType<typeof buildDoubanRequester>) => Promise<T>
): Promise<T> {
const primaryRequester = buildDoubanRequester(primary.proxyType, primary.proxyUrl);
const backupRequester = buildDoubanRequester(backup.proxyType, backup.proxyUrl);
try {
return await runner(primaryRequester);
} catch (primaryError) {
const sameStrategy =
primary.proxyType === backup.proxyType && primary.proxyUrl === backup.proxyUrl;
if (sameStrategy) {
throw primaryError;
}
console.warn(
`[Douban] 主渠道失败,切换备用渠道: ${primary.proxyType} -> ${backup.proxyType}`,
primaryError
);
return runner(backupRequester);
}
}
function dispatchDoubanGlobalError(message: string) {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message },
})
);
}
}
/**
* 浏览器端豆瓣分类数据获取函数
*/
@@ -211,14 +352,6 @@ export async function fetchDoubanCategories(
list: list,
};
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣分类数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
@@ -230,25 +363,34 @@ 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}`
);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return response.json();
return fetchDoubanCategories(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣分类数据失败');
throw error;
}
}
@@ -263,25 +405,34 @@ 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}`
);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return response.json();
return fetchDoubanList(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣列表数据失败');
throw error;
}
}
@@ -343,14 +494,6 @@ export async function fetchDoubanList(
list: list,
};
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣列表数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
@@ -383,25 +526,34 @@ export async function getDoubanRecommends(
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}&region=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}&region=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return response.json();
return fetchDoubanRecommends(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣推荐数据失败');
throw error;
}
}
@@ -544,14 +696,6 @@ export async function fetchDoubanDetail(
const doubanData: DoubanDetailApiResponse = await response.json();
return doubanData;
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣详情数据失败' },
})
);
}
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
}
}
@@ -562,24 +706,26 @@ export async function fetchDoubanDetail(
export async function getDoubanDetail(
id: string
): Promise<DoubanDetailApiResponse> {
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanDetail(id, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanDetail(id, '', false, true);
case 'cors-anywhere':
return fetchDoubanDetail(id, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanDetail(id, proxyUrl);
case 'direct':
default:
const response = await fetch(`/api/douban/detail?id=${id}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(`/api/douban/detail?id=${id}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN);
}
return response.json();
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣详情数据失败');
throw error;
}
}
+121 -28
View File
@@ -3,8 +3,7 @@ import bs58 from 'bs58';
import he from 'he';
import Hls from 'hls.js';
function getDoubanImageProxyConfig(): {
proxyType:
export type DoubanImageProxyType =
| 'direct'
| 'server'
| 'img3'
@@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): {
| 'cmliussss-cdn-ali'
| 'baidu'
| 'custom';
function normalizeDoubanImageProxyConfig(
proxyType: DoubanImageProxyType,
proxyUrl: string
): {
proxyType: DoubanImageProxyType;
proxyUrl: string;
} {
const normalizedProxyUrl = proxyUrl.trim();
if (proxyType === 'custom' && !normalizedProxyUrl) {
return {
proxyType: 'server',
proxyUrl: '',
};
}
return {
proxyType,
proxyUrl: normalizedProxyUrl,
};
}
function buildDoubanImageUrl(
originalUrl: string,
proxyType: DoubanImageProxyType,
proxyUrl: string
): string {
switch (proxyType) {
case 'server':
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
case 'img3':
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
case 'cmliussss-cdn-tencent':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.net'
);
case 'cmliussss-cdn-ali':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.com'
);
case 'baidu':
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
case 'custom':
return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
case 'direct':
default:
return originalUrl;
}
}
function getDoubanImageProxyConfig(): {
proxyType: DoubanImageProxyType;
proxyUrl: string;
backupProxyType: DoubanImageProxyType;
backupProxyUrl: string;
} {
// 确保在浏览器环境中执行
if (typeof window === 'undefined') {
return {
proxyType: 'cmliussss-cdn-tencent',
proxyUrl: '',
backupProxyType: 'server',
backupProxyUrl: '',
};
}
@@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): {
localStorage.getItem('doubanImageProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
'';
const doubanImageProxyBackupType =
(localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
'server';
const doubanImageProxyBackupUrl =
localStorage.getItem('doubanImageProxyUrlBackup') || '';
const primaryConfig = normalizeDoubanImageProxyConfig(
doubanImageProxyType,
doubanImageProxy
);
const backupConfig = normalizeDoubanImageProxyConfig(
doubanImageProxyBackupType,
doubanImageProxyBackupUrl
);
return {
proxyType: doubanImageProxyType,
proxyUrl: doubanImageProxy,
proxyType: primaryConfig.proxyType,
proxyUrl: primaryConfig.proxyUrl,
backupProxyType: backupConfig.proxyType,
backupProxyUrl: backupConfig.proxyUrl,
};
}
export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
return null;
}
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanImageProxyConfig();
const primaryUrl = buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
const backupUrl = buildDoubanImageUrl(
originalUrl,
backupProxyType,
backupProxyUrl
);
if (backupUrl === primaryUrl) {
return null;
}
return backupUrl;
}
export function tryApplyDoubanImageFallback(
target: HTMLImageElement,
originalUrl: string
): boolean {
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
return false;
}
if (target.dataset.doubanBackupTried === 'true') {
return false;
}
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
return false;
}
target.dataset.doubanBackupTried = 'true';
target.src = fallbackUrl;
return true;
}
/**
* 处理图片 URL,根据用户设置使用相应的代理
*/
@@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string {
}
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
switch (proxyType) {
case 'server':
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
case 'img3':
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
case 'cmliussss-cdn-tencent':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.net'
);
case 'cmliussss-cdn-ali':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.com'
);
case 'baidu':
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
case 'custom':
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
case 'direct':
default:
return originalUrl;
}
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
}
/**
@@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string {
// 在 Node.js 环境中使用 Buffer
return Buffer.from(bytes).toString('utf-8');
}