From 3bf72e4b6676144dc6d114078a66bfee6a297b62 Mon Sep 17 00:00:00 2001 From: can4hou6joeng4 Date: Fri, 31 Jul 2026 18:32:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(douban):=20=E5=9B=BE=E7=89=87=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=8C=89=E9=95=9C=E5=83=8F=E5=9B=9E=E9=80=80,?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A2=83=E5=A4=96=E9=83=A8=E7=BD=B2=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5=E5=A4=A7=E9=87=8F=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imgN.doubanio.com 互为镜像但解析到不同线路:img1 当前解析到国内 IP 180.97.198.41,在境外主机上不可达。此时 fetch 是直接抛异常而不是返回 状态码,于是走进 catch 一律返回 500,首页会有相当比例的海报加载失败。 改为在 imgN.doubanio.com 之间按 img9 -> img3 -> img2 顺序回退,同一 路径换个镜像即可取到。同时保留最后一次的真实状态码,不再把上游的 403 或 404 也统一伪装成 500。 --- app/api/douban/image/route.ts | 84 +++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/app/api/douban/image/route.ts b/app/api/douban/image/route.ts index a51deef..2a8bc70 100644 --- a/app/api/douban/image/route.ts +++ b/app/api/douban/image/route.ts @@ -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 }); }