fix(bili): 取流 CDN 分级选址——backupUrl 按 host 分级探测,根治 PCDN 点歌卡死

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 分级选址)」软断言项。
This commit is contained in:
snowy-yang committed 2026-10-04 17:29:28 +08:00
1 parent e1357dd412
commit cec63f864e
5 files changed
+186 -41

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
"typecheck": "tsc", "typecheck": "tsc",
"start": "node src/server.ts", "start": "node src/server.ts",
"dev": "node scripts/dev.mjs", "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": { "dependencies": {
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
+114 -37
View File
@@ -4,9 +4,14 @@
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点) // fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底; // 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。 // 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
// 302:httpFetch 不跟随重定向,这里用 `GET + Range: bytes=0-0` 手动跟跳最多 3 跳(HEAD 会被 CDN 404,不能用), //
// 失败时回退原始地址,保证客户端仍能自行跟跳。 // CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
// 参考 Referer/UA 必须携带,否则 CDN 可能 403。 // 迅雷 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 { httpFetch } from '../common/request.ts'
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts' import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
@@ -22,6 +27,11 @@ const QUALITY_LABEL: Record<number, string> = {
30251: 'flac24bit', 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 let vipCache: { value: boolean, at: number } | null = null
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */ /** 登录态清空/更新后调用,丢弃大会员判断缓存 */
@@ -47,34 +57,72 @@ export const isVipAccount = async(cookie: string): Promise<boolean> => {
} }
} }
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<string>()
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 dash = data?.dash ?? {}
const flac = dash?.flac?.audio 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 dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
const standards: any[] = (dash?.audio ?? []) const standards: any[] = (dash?.audio ?? [])
.filter((a: any) => a?.baseUrl) .filter((a: any) => a?.baseUrl)
.sort((a: any, b: any) => Number(b.id) - Number(a.id)) .sort((a: any, b: any) => Number(b.id) - Number(a.id))
let node: any = null
let label = ''
if (type === '64k') { if (type === '64k') {
const low = standards.find(a => Number(a.id) === 30216) 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 → 最高标准档 // 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
if (hires) return { ...hires, label: QUALITY_LABEL[30251] } if (!node && hires) { node = hires; label = QUALITY_LABEL[30251] }
if (dolby) return { id: 30250, url: String(dolby.baseUrl), label: QUALITY_LABEL[30250] } if (!node && dolby) { node = dolby; label = QUALITY_LABEL[30250] }
if (standards.length) { if (!node && standards.length) {
const best = standards[0] node = standards[0]
return { id: Number(best.id), url: String(best.baseUrl), label: QUALITY_LABEL[Number(best.id)] ?? `audio-${best.id}` } label = QUALITY_LABEL[Number(standards[0].id)] ?? `audio-${standards[0].id}`
} }
const durl = data?.durl?.[0]?.url if (node) return { id: Number(node.id) || 0, label, urls: rankCandidates(nodeUrls(node)) }
if (durl) return { id: 0, url: String(durl), label: 'durl' }
const durlNode = data?.durl?.[0]
if (durlNode?.url) return { id: 0, label: 'durl', urls: rankCandidates(nodeUrls(durlNode)) }
return null return null
} }
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, url };不做 CDN 跟跳) */ /** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, urls };urls 已按 CDN 分级排序,[0] 为最优) */
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, url: string }> => { export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, urls: string[] }> => {
const cookie = await getCookie() const cookie = await getCookie()
const needFlac = type === 'flac' || type === 'flac24bit' const needFlac = type === 'flac' || type === 'flac24bit'
const vip = await isVipAccount(cookie) 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 (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}` : ''})`) if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
const picked = pickAudio(body.data, type) const picked = pickAudio(body.data, type)
if (!picked) throw new Error('B站未返回可用音频流') if (!picked || !picked.urls.length) throw new Error('B站未返回可用音频流')
return { type: picked.label, url: picked.url } return { type: picked.label, urls: picked.urls }
} }
/** 手动跟随 CDN 302(GET + Range 只读首字节;失败回退当前地址) */ /**
const followRedirects = async(url: string, cookie: string, tracker?: { current: any }): Promise<string> => { * 探测单个候选并跟随 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<string> => {
let current = String(url) let current = String(url)
for (let hop = 0; hop < 3; hop++) { 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 { try {
const request = httpFetch(current, { resp = await fetch(current, {
method: 'get', method: 'get',
headers: { ...biliHeaders(cookie), Range: 'bytes=0-0' }, headers: { ...biliHeaders(), Range: 'bytes=0-0' },
timeout: 15000, redirect: 'manual',
signal: controller.signal,
}) })
if (tracker) tracker.current = request } catch (err: any) {
response = await request.promise if (tracker.cancelled) { err.canceled = true; throw err }
} catch { throw err
return current } finally {
clearTimeout(timer)
} }
const statusCode = Number(response?.statusCode) || 0 // 拿到响应头即断开,不消费 body
const location = response?.headers?.location await resp.body?.cancel().catch(() => {})
if (statusCode >= 300 && statusCode < 400 && location) { const status = resp.status
const location = resp.headers.get('location')
if (status >= 300 && status < 400 && location) {
try { try {
current = new URL(String(location), current).href current = new URL(location, current).href
continue continue
} catch { } catch {
return current return current
} }
} }
if (status >= 200 && status < 300) return current
if (status >= 400) throw new Error(`CDN 返回 HTTP ${status}`)
return current return current
} }
return current return current
@@ -123,13 +188,15 @@ const followRedirects = async(url: string, cookie: string, tracker?: { current:
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp } * 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位) * promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。 * songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
* CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
*/ */
export const getMusicUrl = (songInfo: any, type: string) => { export const getMusicUrl = (songInfo: any, type: string) => {
const tracker: { current: any } = { current: null } const tracker: { current: any, cancelled?: boolean } = { current: null }
const requestObj: any = { const requestObj: any = {
cancelHttp() { cancelHttp() {
tracker.cancelled = true
try { try {
tracker.current?.cancelHttp?.() tracker.current?.abort?.(new Error('Canceled'))
} catch { } catch {
// ignore // ignore
} }
@@ -147,9 +214,19 @@ export const getMusicUrl = (songInfo: any, type: string) => {
} }
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)') if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
const info = await getAudioUrl(bvid, realCid, type) const info = await getAudioUrl(bvid, realCid, type)
const cookie = await getCookie()
const finalUrl = await followRedirects(info.url, cookie, tracker) const deadline = Date.now() + PROBE_TOTAL_BUDGET
return { type: info.type, url: finalUrl } 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 return requestObj
+3 -3
View File
@@ -51,12 +51,12 @@ export const parseSong = async(link: string) => {
page, page,
}) })
// 上游 Parse 会立即填充音频地址(失败忽略) // 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if (cid) { if (cid) {
try { try {
const audio = await getAudioUrl(song.bvid, cid) const audio = await getAudioUrl(song.bvid, cid)
if (audio?.url) { if (audio?.urls?.[0]) {
song.url = audio.url song.url = audio.urls[0]
song.type = audio.type song.type = audio.type
} }
} catch { } catch {
+57
View File
@@ -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<any>) => {
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)
+11
View File
@@ -555,6 +555,17 @@ await test('URL 5sing(档位 sq/hq/lq)', async() => {
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}` 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() => { await test('歌词 mg', async() => {
const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`) const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
const song = r.data?.list?.[0] const song = r.data?.list?.[0]