fix(douban): 图片代理按镜像回退,修复境外部署海报大量 500

imgN.doubanio.com 互为镜像但解析到不同线路:img1 当前解析到国内 IP
180.97.198.41,在境外主机上不可达。此时 fetch 是直接抛异常而不是返回
状态码,于是走进 catch 一律返回 500,首页会有相当比例的海报加载失败。

改为在 imgN.doubanio.com 之间按 img9 -> img3 -> img2 顺序回退,同一
路径换个镜像即可取到。同时保留最后一次的真实状态码,不再把上游的 403
或 404 也统一伪装成 500。
This commit is contained in:
can4hou6joeng4
2026-07-31 18:32:34 +08:00
parent 0675c28413
commit 3bf72e4b66
+55 -29
View File
@@ -2,6 +2,40 @@ import { NextResponse } from 'next/server';
export const runtime = 'edge';
const REQUEST_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',
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
Referer: 'https://movie.douban.com/',
};
// 豆瓣的 imgN.doubanio.com 互为镜像,但各自解析到不同线路。部分线路(例如 img1
// 的国内 IP)在境外主机上不可达,fetch 会直接抛错而不是返回状态码,海报因此变成
// 500。同一路径换个镜像通常就能取到,所以失败时按顺序回退。
const DOUBAN_IMAGE_HOSTS = ['img9.doubanio.com', 'img3.doubanio.com', 'img2.doubanio.com'];
function buildCandidates(rawUrl: string): string[] {
const candidates = [rawUrl];
try {
const parsed = new URL(rawUrl);
if (!/^img\d+\.doubanio\.com$/.test(parsed.hostname)) {
return candidates;
}
for (const host of DOUBAN_IMAGE_HOSTS) {
if (host === parsed.hostname) continue;
const alternate = new URL(parsed.toString());
alternate.hostname = host;
candidates.push(alternate.toString());
}
} catch {
// 非法 URL 留给 fetch 去报错
}
return candidates;
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get('url');
@@ -10,51 +44,43 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
}
try {
const imageResponse = await fetch(imageUrl, {
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',
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
Referer: 'https://movie.douban.com/',
},
});
let lastStatus = 502;
let lastError = 'Error fetching image';
for (const candidate of buildCandidates(imageUrl)) {
let imageResponse: Response;
try {
imageResponse = await fetch(candidate, { headers: REQUEST_HEADERS });
} catch {
// 线路不可达,换下一个镜像
continue;
}
if (!imageResponse.ok) {
return NextResponse.json(
{ error: imageResponse.statusText },
{ status: imageResponse.status }
);
lastStatus = imageResponse.status;
lastError = imageResponse.statusText || 'Error fetching image';
continue;
}
const contentType = imageResponse.headers.get('content-type');
if (!imageResponse.body) {
return NextResponse.json(
{ error: 'Image response has no body' },
{ status: 500 }
);
lastStatus = 500;
lastError = 'Image response has no body';
continue;
}
// 创建响应头
const headers = new Headers();
const contentType = imageResponse.headers.get('content-type');
if (contentType) {
headers.set('Content-Type', contentType);
}
// 设置缓存头
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000');
// 直接返回图片流
// @ts-ignore
return new Response(imageResponse.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json(
{ error: 'Error fetching image' },
{ status: 500 }
);
}
return NextResponse.json({ error: lastError }, { status: lastStatus });
}