From cec63f864e5da9e13d8ef33fc3d075c8ac03490c Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:29:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(bili):=20=E5=8F=96=E6=B5=81=20CDN=20?= =?UTF-8?q?=E5=88=86=E7=BA=A7=E9=80=89=E5=9D=80=E2=80=94=E2=80=94backupUrl?= =?UTF-8?q?=20=E6=8C=89=20host=20=E5=88=86=E7=BA=A7=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=EF=BC=8C=E6=A0=B9=E6=B2=BB=20PCDN=20=E7=82=B9=E6=AD=8C?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit playurl 的 baseUrl 大概率落在 PCDN(京东云无线宝 mcdn.bilivideo.cn、迅雷 mountaintoys 等),旧代码只取 baseUrl(backupUrl 0 处使用)且探测失败仍 原样返回坏地址,点歌端连 PCDN 挂死。现候选 = baseUrl+backupUrl 按 host 分级(upos/Akamai 正规 CDN > cn-* 等 bilivideo.com 系 > PCDN),探测改为 fetch 只读响应头即断开(不等 body,CDN 忽略 Range 回 200 全量时不再整文件 缓冲),单跳 8s / 总预算 20s / 至多 3 候选,全败回退最优候选(fail-soft); CDN 探测不再携带 Cookie(直链自带签名,避免登录态泄给第三方 PCDN 域名)。 getAudioUrl 返回 urls[],parse 改取 urls[0]。 新增 test/bili-cdn-test.ts(SDK 层 PCDN 硬断言,并入 pnpm test)与 smoke 「URL bili(CDN 分级选址)」软断言项。 --- package.json | 2 +- sdk/apis/bili.ts | 151 +++++++++++++++++++++++++++++++----------- sdk/bili/parse.ts | 6 +- test/bili-cdn-test.ts | 57 ++++++++++++++++ test/smoke-test.ts | 11 +++ 5 files changed, 186 insertions(+), 41 deletions(-) create mode 100644 test/bili-cdn-test.ts diff --git a/package.json b/package.json index 501aa78..0af2c6a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc", "start": "node src/server.ts", "dev": "node scripts/dev.mjs", - "test": "pnpm run typecheck && node test/source-simple-test.ts && node test/smoke-test.ts" + "test": "pnpm run typecheck && node test/source-simple-test.ts && node test/bili-cdn-test.ts && node test/smoke-test.ts" }, "dependencies": { "@fastify/cors": "^11.3.0", diff --git a/sdk/apis/bili.ts b/sdk/apis/bili.ts index b5dfe8a..2570c6b 100644 --- a/sdk/apis/bili.ts +++ b/sdk/apis/bili.ts @@ -4,9 +4,14 @@ // fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点) // 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底; // 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。 -// 302:httpFetch 不跟随重定向,这里用 `GET + Range: bytes=0-0` 手动跟跳最多 3 跳(HEAD 会被 CDN 404,不能用), -// 失败时回退原始地址,保证客户端仍能自行跟跳。 -// 参考 Referer/UA 必须携带,否则 CDN 可能 403。 +// +// CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、 +// 迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源; +// 正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外), +// cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。 +// 因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN), +// 逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。 +// 参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。 import { httpFetch } from '../common/request.ts' import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts' @@ -22,6 +27,11 @@ const QUALITY_LABEL: Record = { 30251: 'flac24bit', } +// CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等) +const PROBE_HOP_TIMEOUT = 8000 +const PROBE_TOTAL_BUDGET = 20000 +const PROBE_MAX_CANDIDATES = 3 + let vipCache: { value: boolean, at: number } | null = null /** 登录态清空/更新后调用,丢弃大会员判断缓存 */ @@ -47,34 +57,72 @@ export const isVipAccount = async(cookie: string): Promise => { } } -const pickAudio = (data: any, type: string): { id: number, url: string, label: string } | null => { +/** CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / upos-*.akamaized.net,多地节点) + * 1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方) */ +const hostRank = (url: string): number => { + let host = '' + try { host = new URL(url).hostname.toLowerCase() } catch { return 2 } + if (host.endsWith('.akamaized.net')) return 0 + if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1 + return 2 +} + +/** 节点地址收集:baseUrl + backupUrl(去重) */ +const nodeUrls = (node: any): string[] => { + const backups = [ + ...(Array.isArray(node?.backupUrl) ? node.backupUrl : []), + ...(Array.isArray(node?.backup_url) ? node.backup_url : []), + ] + return [node?.baseUrl, ...backups].filter(Boolean).map(String) +} + +/** 候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先) */ +const rankCandidates = (urls: string[]): string[] => { + const seen = new Set() + const uniq: string[] = [] + for (const url of urls) { + const u = String(url || '') + if (!u || seen.has(u)) continue + seen.add(u) + uniq.push(u) + } + return uniq + .map((u, i) => ({ u, i, rank: hostRank(u) })) + .sort((a, b) => a.rank - b.rank || a.i - b.i) + .map(x => x.u) +} + +const pickAudio = (data: any, type: string): { id: number, label: string, urls: string[] } | null => { const dash = data?.dash ?? {} const flac = dash?.flac?.audio - const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? { id: 30251, url: String(flac.baseUrl) } : null + const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? flac : null const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl) const standards: any[] = (dash?.audio ?? []) .filter((a: any) => a?.baseUrl) .sort((a: any, b: any) => Number(b.id) - Number(a.id)) + let node: any = null + let label = '' if (type === '64k') { const low = standards.find(a => Number(a.id) === 30216) - if (low) return { id: 30216, url: String(low.baseUrl), label: QUALITY_LABEL[30216] } + if (low) { node = low; label = QUALITY_LABEL[30216] } } - // 上游优先级:Hi-Res FLAC → Dolby → 最高标准档 - if (hires) return { ...hires, label: QUALITY_LABEL[30251] } - if (dolby) return { id: 30250, url: String(dolby.baseUrl), label: QUALITY_LABEL[30250] } - if (standards.length) { - const best = standards[0] - return { id: Number(best.id), url: String(best.baseUrl), label: QUALITY_LABEL[Number(best.id)] ?? `audio-${best.id}` } + if (!node && hires) { node = hires; label = QUALITY_LABEL[30251] } + if (!node && dolby) { node = dolby; label = QUALITY_LABEL[30250] } + if (!node && standards.length) { + node = standards[0] + label = QUALITY_LABEL[Number(standards[0].id)] ?? `audio-${standards[0].id}` } - const durl = data?.durl?.[0]?.url - if (durl) return { id: 0, url: String(durl), label: 'durl' } + if (node) return { id: Number(node.id) || 0, label, urls: rankCandidates(nodeUrls(node)) } + + const durlNode = data?.durl?.[0] + if (durlNode?.url) return { id: 0, label: 'durl', urls: rankCandidates(nodeUrls(durlNode)) } return null } -/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, url };不做 CDN 跟跳) */ -export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, url: string }> => { +/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, urls };urls 已按 CDN 分级排序,[0] 为最优) */ +export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, urls: string[] }> => { const cookie = await getCookie() const needFlac = type === 'flac' || type === 'flac24bit' const vip = await isVipAccount(cookie) @@ -84,36 +132,53 @@ export const getAudioUrl = async(bvid: string, cid: string | number, type = '128 if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`) if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`) const picked = pickAudio(body.data, type) - if (!picked) throw new Error('B站未返回可用音频流') - return { type: picked.label, url: picked.url } + if (!picked || !picked.urls.length) throw new Error('B站未返回可用音频流') + return { type: picked.label, urls: picked.urls } } -/** 手动跟随 CDN 302(GET + Range 只读首字节;失败回退当前地址) */ -const followRedirects = async(url: string, cookie: string, tracker?: { current: any }): Promise => { +/** + * 探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开 body。 + * HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时, + * 会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。 + * 命中 2xx/206 返回该地址;4xx/5xx/超时/网络错误抛错,由调用方换下一候选。 + */ +const resolveUrl = async(url: string, tracker: { current: any, cancelled?: boolean }, deadline: number): Promise => { let current = String(url) for (let hop = 0; hop < 3; hop++) { - let response: any + const remaining = deadline - Date.now() + if (remaining <= 0) throw new Error('CDN 探测超时(总预算耗尽)') + const controller = new AbortController() + tracker.current = controller + const timer = setTimeout(() => controller.abort(new Error('CDN 探测超时')), Math.min(PROBE_HOP_TIMEOUT, remaining)) + timer.unref?.() + let resp: Response try { - const request = httpFetch(current, { + resp = await fetch(current, { method: 'get', - headers: { ...biliHeaders(cookie), Range: 'bytes=0-0' }, - timeout: 15000, + headers: { ...biliHeaders(), Range: 'bytes=0-0' }, + redirect: 'manual', + signal: controller.signal, }) - if (tracker) tracker.current = request - response = await request.promise - } catch { - return current + } catch (err: any) { + if (tracker.cancelled) { err.canceled = true; throw err } + throw err + } finally { + clearTimeout(timer) } - const statusCode = Number(response?.statusCode) || 0 - const location = response?.headers?.location - if (statusCode >= 300 && statusCode < 400 && location) { + // 拿到响应头即断开,不消费 body + await resp.body?.cancel().catch(() => {}) + const status = resp.status + const location = resp.headers.get('location') + if (status >= 300 && status < 400 && location) { try { - current = new URL(String(location), current).href + current = new URL(location, current).href continue } catch { return current } } + if (status >= 200 && status < 300) return current + if (status >= 400) throw new Error(`CDN 返回 HTTP ${status}`) return current } return current @@ -123,13 +188,15 @@ const followRedirects = async(url: string, cookie: string, tracker?: { current: * 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp } * promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位) * songInfo.songmid 约定 `|`;仅给 bvid 时回源 view 取首P cid。 + * CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。 */ export const getMusicUrl = (songInfo: any, type: string) => { - const tracker: { current: any } = { current: null } + const tracker: { current: any, cancelled?: boolean } = { current: null } const requestObj: any = { cancelHttp() { + tracker.cancelled = true try { - tracker.current?.cancelHttp?.() + tracker.current?.abort?.(new Error('Canceled')) } catch { // ignore } @@ -147,9 +214,19 @@ export const getMusicUrl = (songInfo: any, type: string) => { } if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)') const info = await getAudioUrl(bvid, realCid, type) - const cookie = await getCookie() - const finalUrl = await followRedirects(info.url, cookie, tracker) - return { type: info.type, url: finalUrl } + + const deadline = Date.now() + PROBE_TOTAL_BUDGET + for (const candidate of info.urls.slice(0, PROBE_MAX_CANDIDATES)) { + try { + const finalUrl = await resolveUrl(candidate, tracker, deadline) + return { type: info.type, url: finalUrl } + } catch (err: any) { + if (err?.canceled || tracker.cancelled) throw new Error('canceled') + if (Date.now() >= deadline) break + } + } + // 探测全败(或预算耗尽):回退分级排序后的最优候选 + return { type: info.type, url: info.urls[0] } })() return requestObj diff --git a/sdk/bili/parse.ts b/sdk/bili/parse.ts index fc8f951..cf5cf2e 100644 --- a/sdk/bili/parse.ts +++ b/sdk/bili/parse.ts @@ -51,12 +51,12 @@ export const parseSong = async(link: string) => { page, }) - // 上游 Parse 会立即填充音频地址(失败忽略) + // 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选 if (cid) { try { const audio = await getAudioUrl(song.bvid, cid) - if (audio?.url) { - song.url = audio.url + if (audio?.urls?.[0]) { + song.url = audio.urls[0] song.type = audio.type } } catch { diff --git a/test/bili-cdn-test.ts b/test/bili-cdn-test.ts new file mode 100644 index 0000000..8ca5436 --- /dev/null +++ b/test/bili-cdn-test.ts @@ -0,0 +1,57 @@ +// bili 取流 CDN 分级选址回归 —— SDK 层直调(不依赖服务器实例,含真实 B 站上游请求) +// 运行:node test/bili-cdn-test.ts(已并入 pnpm test) +// +// 覆盖:getMusicUrl 返回地址不落在 PCDN(mcdn 无线宝/mountaintoys 迅雷/szbdyd/nexusedgeio), +// parse 路径填充的 url 同样校验。 +// 判定:命中 PCDN 即失败——fail-soft 兜底意味着 upos/cn 候选 20s 探测全败,是真实的选址退化信号 +// (smoke 的「URL bili」项走 HTTP 层、只软警告;本文件是 SDK 层的硬断言)。 + +import biliApi from '../sdk/apis/bili.ts' +import { parseSong } from '../sdk/bili/parse.ts' + +/** 与 sdk/apis/bili.ts 的 hostRank 同构:0=正规 CDN 1=B站系 2=PCDN */ +const hostRank = (url: string): number => { + let host = '' + try { host = new URL(url).hostname.toLowerCase() } catch { return 2 } + if (host.endsWith('.akamaized.net')) return 0 + if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1 + return 2 +} +const LABEL = ['正规CDN', 'B站系', 'PCDN(坏)'] + +const results: { name: string; ok: boolean; detail?: any }[] = [] +const test = async(name: string, fn: () => Promise) => { + try { + const result = await fn() + results.push({ name, ok: true, detail: result }) + console.log(`✅ ${name}${result ? `: ${result}` : ''}`) + } catch (err: any) { + results.push({ name, ok: false, detail: err.message }) + console.log(`❌ ${name}: ${err.message}`) + } +} + +// 固定样本(2026-10-04 实测均有多分P候选与 upos 备源);换样本时优先选多分P、热度中等的稿件 +const CASES = ['BV1BZbSzZEGT', 'BV1GJ411x7h7', 'BV1d4411N7zD', 'BV1EC41147U1', 'BV19F5k6VEEp'] + +for (const bvid of CASES) { + await test(`取流 ${bvid} 落正规 CDN`, async() => { + const { promise } = biliApi.getMusicUrl({ songmid: bvid }, '128k') + const res = await promise + const rank = hostRank(res.url) + if (rank === 2) throw new Error(`命中 PCDN:${new URL(res.url).host}(backupUrl 探测疑似全败)`) + return `${LABEL[rank]} ${new URL(res.url).host}` + }) +} + +await test('parse 填充 url 落正规 CDN', async() => { + const song = await parseSong('https://www.bilibili.com/video/BV1GJ411x7h7') + if (!song.url) throw new Error('parse 未填充 url') + const rank = hostRank(String(song.url)) + if (rank === 2) throw new Error(`命中 PCDN:${new URL(String(song.url)).host}`) + return `${LABEL[rank]} ${new URL(String(song.url)).host}` +}) + +const failed = results.filter(r => !r.ok).length +console.log(`\n===== 结果: ${results.length - failed}/${results.length} 通过 =====`) +process.exit(failed ? 1 : 0) diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 35cc52b..3ca2852 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -555,6 +555,17 @@ await test('URL 5sing(档位 sq/hq/lq)', async() => { return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}` }) +await test('URL bili(CDN 分级选址)', async() => { + const r = await get(`/search/bili/${encodeURIComponent('周杰伦')}?limit=3`) + if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流') + const u = await get(`/url/bili/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`) + if (u.code != 0) throw new Error(u.msg) + // 返回地址应落在正规 CDN(upos-*.bilivideo.com 等);命中 PCDN 说明备源探测全挂,软警告不判失败 + const host = new URL(String(u.data.url)).hostname + const pcdn = /\.(mcdn\.bilivideo\.cn|mountaintoys\.cn|szbdyd\.com|nexusedgeio\.com)$/.test(host) + return `实际 ${u.data.type},${host}${pcdn ? '(警告:命中 PCDN,请检查 backupUrl 探测)' : ''}` +}) + await test('歌词 mg', async() => { const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`) const song = r.data?.list?.[0]