优化动漫数据源图片降级策略

This commit is contained in:
mtvpls
2026-07-21 16:13:00 +08:00
parent d54cf65927
commit 08c2ff36f9
3 changed files with 173 additions and 49 deletions
+12 -12
View File
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
clearBangumiImageFallbackCacheIfFailed, clearBangumiImageFallbackCacheIfFailed,
ensureBangumiImagePrimaryProbed,
processImageUrl, processImageUrl,
tryApplyBangumiImageFallback, tryApplyBangumiImageFallback,
tryApplyDoubanImageFallback, tryApplyDoubanImageFallback,
@@ -38,22 +39,21 @@ const ProxyImage: React.FC<ProxyImageProps> = ({
setCurrentSrc(initialSrc); setCurrentSrc(initialSrc);
}, [initialSrc]); }, [initialSrc]);
// 主源图片域主页探测:失败则 sticky 走备源(替代原 5s complete 误判)
useEffect(() => { useEffect(() => {
if (displaySrc) return; if (displaySrc) return;
const timer = window.setTimeout(() => { let cancelled = false;
const img = imgRef.current; void (async () => {
if (!img || img.complete || img.dataset.bangumiBackupTried === 'true') { const reachable = await ensureBangumiImagePrimaryProbed();
return; if (cancelled || reachable) return;
} setCurrentSrc(processImageUrl(originalSrc));
})();
if (tryApplyBangumiImageFallback(img, originalSrc)) { return () => {
setCurrentSrc(img.src); cancelled = true;
} };
}, 5000); }, [displaySrc, originalSrc]);
return () => window.clearTimeout(timer);
}, [currentSrc, displaySrc, originalSrc]);
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => { const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
const img = e.currentTarget; const img = e.currentTarget;
+13 -37
View File
@@ -38,9 +38,9 @@ import { isNetdiskSource } from '@/lib/netdisk/source';
import { import {
base58Decode, base58Decode,
clearBangumiImageFallbackCacheIfFailed, clearBangumiImageFallbackCacheIfFailed,
ensureBangumiImagePrimaryProbed,
getBangumiImageFallbackUrl, getBangumiImageFallbackUrl,
getDoubanImageFallbackUrl, getDoubanImageFallbackUrl,
markBangumiImageFallbackActive,
processImageUrl, processImageUrl,
tryApplyBangumiImageFallback, tryApplyBangumiImageFallback,
tryApplyDoubanImageFallback, tryApplyDoubanImageFallback,
@@ -226,44 +226,24 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
setDisplayPoster(processedPoster); setDisplayPoster(processedPoster);
}, [processedPoster]); }, [processedPoster]);
const bangumiImageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( // 主源图片域主页探测:失败写 sticky 后切备源海报(替代原 5s 强制降级)
null
);
useEffect(() => { useEffect(() => {
if (bangumiImageTimeoutRef.current) {
clearTimeout(bangumiImageTimeoutRef.current);
bangumiImageTimeoutRef.current = null;
}
if (!actualPoster) return; if (!actualPoster) return;
const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster); let cancelled = false;
if (!bangumiFallbackPoster || displayPoster === bangumiFallbackPoster) { void (async () => {
return; const reachable = await ensureBangumiImagePrimaryProbed();
} if (cancelled || reachable) return;
const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster);
bangumiImageTimeoutRef.current = setTimeout(() => { if (bangumiFallbackPoster) {
markBangumiImageFallbackActive(); setDisplayPoster(bangumiFallbackPoster);
setDisplayPoster((current) => }
current === bangumiFallbackPoster ? current : bangumiFallbackPoster })();
);
}, 5000);
return () => { return () => {
if (bangumiImageTimeoutRef.current) { cancelled = true;
clearTimeout(bangumiImageTimeoutRef.current);
bangumiImageTimeoutRef.current = null;
}
}; };
}, [actualPoster, displayPoster]); }, [actualPoster]);
const clearBangumiImageTimeout = useCallback(() => {
if (bangumiImageTimeoutRef.current) {
clearTimeout(bangumiImageTimeoutRef.current);
bangumiImageTimeoutRef.current = null;
}
}, []);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
setEpisodes: (eps?: number) => setDynamicEpisodes(eps), setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
@@ -1017,7 +997,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
loading='lazy' loading='lazy'
onLoadingComplete={() => { onLoadingComplete={() => {
setIsLoading(true); setIsLoading(true);
clearBangumiImageTimeout();
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1031,7 +1010,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
doubanFallbackPoster && doubanFallbackPoster &&
tryApplyDoubanImageFallback(img, actualPoster) tryApplyDoubanImageFallback(img, actualPoster)
) { ) {
clearBangumiImageTimeout();
setDisplayPoster(doubanFallbackPoster); setDisplayPoster(doubanFallbackPoster);
return; return;
} }
@@ -1042,7 +1020,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
bangumiFallbackPoster && bangumiFallbackPoster &&
tryApplyBangumiImageFallback(img, actualPoster) tryApplyBangumiImageFallback(img, actualPoster)
) { ) {
clearBangumiImageTimeout();
setDisplayPoster(bangumiFallbackPoster); setDisplayPoster(bangumiFallbackPoster);
return; return;
} }
@@ -1050,7 +1027,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
if ( if (
clearBangumiImageFallbackCacheIfFailed(img, actualPoster) clearBangumiImageFallbackCacheIfFailed(img, actualPoster)
) { ) {
clearBangumiImageTimeout();
setDisplayPoster(processedPoster); setDisplayPoster(processedPoster);
return; return;
} }
+148
View File
@@ -159,6 +159,24 @@ type AnimeImageSource =
const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil'; const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil';
const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature'; const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature';
const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000; const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000;
/** 探测主源图片域主页超时(404 也算通) */
const BANGUMI_IMAGE_PROBE_TIMEOUT_MS = 5000;
/** 探测成功后的内存缓存,避免列表刷屏时重复探测 */
const BANGUMI_IMAGE_PROBE_OK_TTL_MS = 15 * 60 * 1000;
type BangumiImageProbeCache = {
signature: string;
reachable: boolean | null;
okUntil: number;
inflight: Promise<boolean> | null;
};
let bangumiImageProbeCache: BangumiImageProbeCache = {
signature: '',
reachable: null,
okUntil: 0,
inflight: null,
};
function normalizeBaseUrl(baseUrl: string): string { function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, ''); return baseUrl.trim().replace(/\/+$/, '');
@@ -239,10 +257,50 @@ function getBangumiImageFallbackSignature(): string {
}); });
} }
function getBangumiImageProbeSignature(): string {
return JSON.stringify({
primary: getPrimaryBangumiImageSource(),
imageBaseUrl: getBangumiImageBaseUrl(),
});
}
/** 当前主源图片域 originserver-proxy 无需探测返回 null */
function getBangumiImageProbeOrigin(): string | null {
const primary = getPrimaryBangumiImageSource();
switch (primary) {
case 'server-proxy':
return null;
case 'sakura':
return 'https://lain.bangumi.lol';
case 'custom-baseurl': {
const base = getBangumiImageBaseUrl();
if (!base) return 'https://lain.bgm.tv';
try {
return new URL(base).origin;
} catch {
return null;
}
}
case 'direct':
default:
return 'https://lain.bgm.tv';
}
}
export function clearBangumiImageProbeCache(): void {
bangumiImageProbeCache = {
signature: '',
reachable: null,
okUntil: 0,
inflight: null,
};
}
export function clearBangumiImageFallbackCache(): void { export function clearBangumiImageFallbackCache(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY); localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY);
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY); localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY);
clearBangumiImageProbeCache();
} }
export function markBangumiImageFallbackActive(): void { export function markBangumiImageFallbackActive(): void {
@@ -257,6 +315,92 @@ export function markBangumiImageFallbackActive(): void {
); );
} }
/**
* 探测主源图片域是否可达:请求 origin 主页(常见 404),5s 超时 + 防缓存。
* no-cors 下任意完成(含 404)视为可达;超时/DNS/断网视为不可达。
* 失败时写入 sticky 降级(若有备源)。全局单飞 + 成功缓存 15 分钟。
* @returns true=主源可用,false=应走备源
*/
export async function ensureBangumiImagePrimaryProbed(): Promise<boolean> {
if (typeof window === 'undefined') return true;
if (isBangumiImageFallbackActive()) {
return false;
}
const origin = getBangumiImageProbeOrigin();
if (!origin) {
return true;
}
const signature = getBangumiImageProbeSignature();
const now = Date.now();
if (
bangumiImageProbeCache.signature === signature &&
bangumiImageProbeCache.reachable === true &&
bangumiImageProbeCache.okUntil > now
) {
return true;
}
if (
bangumiImageProbeCache.signature === signature &&
bangumiImageProbeCache.inflight
) {
return bangumiImageProbeCache.inflight;
}
const probePromise = (async (): Promise<boolean> => {
const url = `${origin}/?_cb=${Date.now()}_${Math.random()
.toString(36)
.slice(2, 10)}`;
try {
await fetch(url, {
method: 'GET',
mode: 'no-cors',
cache: 'no-store',
credentials: 'omit',
signal: AbortSignal.timeout(BANGUMI_IMAGE_PROBE_TIMEOUT_MS),
});
bangumiImageProbeCache = {
signature,
reachable: true,
okUntil: Date.now() + BANGUMI_IMAGE_PROBE_OK_TTL_MS,
inflight: null,
};
return true;
} catch {
bangumiImageProbeCache = {
signature,
reachable: false,
okUntil: 0,
inflight: null,
};
const primary = getPrimaryBangumiImageSource();
if (getBackupBangumiImageSource(primary)) {
markBangumiImageFallbackActive();
}
return false;
}
})();
bangumiImageProbeCache = {
signature,
reachable: bangumiImageProbeCache.reachable,
okUntil: bangumiImageProbeCache.okUntil,
inflight: probePromise,
};
return probePromise;
}
/** 非阻塞触发主源图片域探测(processImageUrl / 首屏侧调用) */
export function scheduleBangumiImagePrimaryProbe(): void {
if (typeof window === 'undefined') return;
void ensureBangumiImagePrimaryProbed();
}
function normalizeComparableUrl(url: string): string { function normalizeComparableUrl(url: string): string {
if (typeof window === 'undefined') return url; if (typeof window === 'undefined') return url;
@@ -441,12 +585,16 @@ export function processImageUrl(originalUrl: string): string {
// 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片; // 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片;
// 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。 // 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。
// sticky 降级中走备源;否则用主源,并非阻塞探测主源图片域主页(5s,404 算通)。
if (isBangumiImageUrl(originalUrl)) { if (isBangumiImageUrl(originalUrl)) {
const primary = getPrimaryBangumiImageSource(); const primary = getPrimaryBangumiImageSource();
const backup = getBackupBangumiImageSource(primary); const backup = getBackupBangumiImageSource(primary);
if (backup && isBangumiImageFallbackActive()) { if (backup && isBangumiImageFallbackActive()) {
return buildBangumiImageUrl(originalUrl, backup); return buildBangumiImageUrl(originalUrl, backup);
} }
if (primary !== 'server-proxy' && backup) {
scheduleBangumiImagePrimaryProbe();
}
return buildBangumiImageUrl(originalUrl, primary); return buildBangumiImageUrl(originalUrl, primary);
} }