Files
snowy-yang 68e071b166 feat(sdk): kg/kw/tx/wy 补齐专辑、歌单与 parse 能力
四个主流平台从「搜索 + 取流」补到能力对齐,新增 playlist/parse
(tx、wy 另加 userPlaylist),顺带修掉酷我直链的一个真实缺陷。

- kw:酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3…`),
  `$` 非 URL 标准分隔符,播放器与解析器普遍不认,导致拿到直链也放不出声;
  统一还原成 `=` 再对外返回
- wy:补齐 album/playlist/parse/userPlaylist,与 kg/tx 能力对齐
- kg/kw/tx:接入 parse(跨平台精确匹配的输入源)与歌单

为什么单独一次提交:这批改动不动架构,只做能力补齐与 URL 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
2026-10-03 00:26:51 +08:00

183 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 酷我音乐 URL 获取(直连)
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际拿到的音质,请求档位缺失时沿降级链回退)
//
// 取流通道(按优先级):
// 1) http://mobi.kuwo.cn/mobi.s convert_url_with_sign(主通道)
// source=kwplayer_ar_8.5.5.0_apk_keluze.apk 指纹 + user=0 匿名即可。
// 2026-10-01 实测(含整段 MPEG 帧逐帧校验,非仅看开头):
// - 付费/VIP 歌匿名放行真音频:晴天(rid 228908) 拿到 2000k flac 52.83MB(269.7s,fLaC
// STREAMINFO 校验)与 320k mp3 10.29MB(10329 帧全程 320kbps/44100Hz 对齐),
// 均与搜索接口 N_MINFO 元数据的官方大小逐字节吻合;
// - hi-res 也可用:起风了(rid 82239887) br=4000kflac → 88.2kHz flac 109.12MB;
// - 请求档位不存在时会静默降级(2000kflac→48k aac、4000kflac→128k mp3)甚至升级到
// mgg/mflac 加密格式,因此必须严格校验「返回 bitrate == 请求档位 且 格式为明文音频」,
// 不匹配视为未命中、沿降级链换档重试;
// - 此前「mobi.s 吐 11 秒蜜罐假 MP3」的结论仅适用于其它 type/source 参数组合,
// 本组合已验证为真音频。
// - 返回 url 的 query 用 `$` 顶替 `=`,统一还原后对外返回(见 normalizeUrl)。
// 2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
// 签名:GET https://www.kuwo.cn/ 首页拿动态 cookie(Hm_Iuvt_*,名字与值均随机下发),
// 以「cookie 值为消息、cookie 名为密钥」跑异或哈希得 Secret 头(酷我 web 端算法,
// 参考 qyhqiu/kuwoMusicApi 的 secret.js)。token 会话级缓存,失效自动刷新重试一次。
// 限制:服务器无视 br 参数,匿名恒返回 128k MP3;VIP 歌明确报「该歌曲为付费内容」,
// 不吐假音频(作为 mobi 通道网络故障时的兜底)。
// - 写死 token(含旧 kw_token cookie)必报 "The request is illegal!",token 必须动态获取。
import { randomUUID } from 'crypto'
import { httpFetch } from '../common/request.ts'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const MOBI_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36'
// 音质 → mobi.s 请求档位(bitrate 为响应必须精确命中的数值)
const qualityTiers: Record<string, { br: string, bitrate: number }> = {
'128k': { br: '128kmp3', bitrate: 128 },
'320k': { br: '320kmp3', bitrate: 320 },
'flac': { br: '2000kflac', bitrate: 2000 },
'flac24bit': { br: '4000kflac', bitrate: 4000 },
}
// 档位缺失时的降级链(请求档位 → 逐级尝试)
const fallbackChain: Record<string, string[]> = {
'flac24bit': ['flac', '320k', '128k'],
'flac': ['320k', '128k'],
'320k': ['128k'],
'128k': [],
}
// 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播
const plainFormats = new Set(['mp3', 'flac', 'ogg', 'aac', 'm4a'])
// 酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`,实为酷我客户端
// 自有写法,签名 token 在 path 里、query 无 sign 参数),`$` 非标准分隔符,播放器与 URL
// 解析器普遍不认,统一还原成 `=` 再对外返回
const normalizeUrl = (url: string) => url.replace(/\$/g, '=')
// 匿名通道实际可用音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = ['128k', '320k', 'flac', 'flac24bit']
/**
* mobi.s convert_url_with_sign 取流
* 未命中(无 url / bitrate 不匹配 / 加密格式)resolve null,由调用方沿降级链换档
*/
const getUrlByMobi = (songmid: string, tier: { br: string, bitrate: number }): Promise<{ url: string } | null> => {
const requestObj = httpFetch(`http://mobi.kuwo.cn/mobi.s?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk&type=convert_url_with_sign&br=${tier.br}&rid=${songmid}`, {
method: 'get',
headers: {
'User-Agent': MOBI_UA,
'Accept-Language': 'zh-CN,zh;q=0.9',
},
timeout: 15000,
})
return requestObj.promise.then(({ statusCode, body }) => {
const data = body?.data
if (statusCode != 200 || !data?.url) return null
if (Number(data.bitrate) != tier.bitrate || !plainFormats.has(String(data.format))) return null
return { url: normalizeUrl(data.url) }
}).catch(() => null)
}
interface KwToken { name: string, value: string, secret: string }
let token: KwToken | null = null
let tokenPromise: Promise<KwToken> | null = null
/**
* 酷我 web 端 Secret 头哈希:t 为消息(cookie 值),e 为密钥(cookie 名)
*/
const kwSecret = (t: string, e: string): string => {
let digits = ''
for (let i = 0; i < e.length; i++) digits += e.charCodeAt(i).toString()
const r = Math.floor(digits.length / 5)
const o = parseInt(digits.charAt(r) + digits.charAt(2 * r) + digits.charAt(3 * r) + digits.charAt(4 * r) + digits.charAt(5 * r))
const l = Math.ceil(e.length / 2)
const c = 2 ** 31 - 1
if (o < 2) throw new Error('酷我 Secret 哈希失败')
let d = Math.round(1e9 * Math.random()) % 1e8
digits += d
while (digits.length > 10) digits = (parseInt(digits.substring(0, 10)) + parseInt(digits.substring(10, digits.length))).toString()
let n = (o * parseInt(digits) + l) % c
let out = ''
for (let i = 0; i < t.length; i++) {
const x = t.charCodeAt(i) ^ Math.floor(n / c * 255)
out += x < 16 ? `0${x.toString(16)}` : x.toString(16)
n = (o * n + l) % c
}
let hex = d.toString(16)
while (hex.length < 8) hex = `0${hex}`
return out + hex
}
/**
* 获取会话 token(访问首页,解析动态下发的 Hm_Iuvt_* cookie 名与值)
*/
const getToken = (retryNum = 0): Promise<KwToken> => {
if (token) return Promise.resolve(token)
if (tokenPromise) return tokenPromise
tokenPromise = httpFetch('https://www.kuwo.cn/', {
method: 'get',
headers: { 'User-Agent': UA },
timeout: 15000,
}).promise.then(({ headers }) => {
tokenPromise = null
const cookies = ((headers['set-cookie'] as string[]) || []).map(c => {
const i = c.indexOf('=')
return { name: c.slice(0, i).trim(), value: i >= 0 ? c.slice(i + 1).split(';')[0] : '' }
}).filter(c => /^Hm_Iuvt_\w+$/.test(c.name) && c.value)
if (!cookies.length) throw new Error('获取酷我token失败')
token = { name: cookies[0].name, value: cookies[0].value, secret: kwSecret(cookies[0].value, cookies[0].name) }
return token
}).catch(err => {
tokenPromise = null
if (retryNum > 2) throw err
return getToken(++retryNum)
})
return tokenPromise
}
/**
* 通过官方 playUrl 接口获取(需 Secret 签名;匿名恒 128k)
* @param t 本次请求使用的 token(显式传入,避免与并发刷新竞态)
*/
const getUrlByPlayUrl = (songmid: string, t: KwToken, retryNum = 0) => {
const requestObj = { cancelHttp() {}, promise: null as any }
requestObj.promise = httpFetch(`https://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId=${randomUUID()}`, {
method: 'get',
headers: {
Cookie: `${t.name}=${t.value}`,
Secret: t.secret,
Referer: 'https://www.kuwo.cn/',
'User-Agent': UA,
},
timeout: 15000,
}).promise.then(({ statusCode, body }) => {
const msg: string = body?.msg || body?.message || ''
if (statusCode != 200 || !body?.success || !body.data?.url) {
// 签名/token 失效:刷新后重试一次(VIP 拒绝等其他错误不重试)
if (retryNum == 0 && /illegal/i.test(msg)) {
token = null
return getToken().then(fresh => getUrlByPlayUrl(songmid, fresh, ++retryNum).promise)
}
return Promise.reject(new Error(msg || '获取失败'))
}
// 服务器无视 br,匿名实际只回 128k,如实上报
return { type: '128k', url: normalizeUrl(body.data.url) }
})
return requestObj
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const start = qualityTiers[type] ? type : '128k'
const chain = [start, ...fallbackChain[start]]
// 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
requestObj.promise = chain.reduce(
(p, tierType) => p.then(res => res ?? getUrlByMobi(songInfo.songmid, qualityTiers[tierType]).then(hit => hit && { type: tierType, url: hit.url })),
Promise.resolve(null as any),
).then(res => res ?? getToken().then(t => getUrlByPlayUrl(songInfo.songmid, t).promise))
return requestObj
}
export default { getMusicUrl }