From aa4f2eec67e45b81fe7d02e0c6e1b9755c52a801 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:43:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(kw):=20=E5=8F=96=E6=B5=81=E5=8F=8C?= =?UTF-8?q?=E9=80=9A=E9=81=93=E9=87=8D=E6=9E=84=E2=80=94=E2=80=94mobi.s=20?= =?UTF-8?q?convert=5Furl=5Fwith=5Fsign=20=E4=B8=BB=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主通道 mobi.s(keluze 指纹,user=0 匿名):VIP/付费歌放行真音频, 晴天 320k mp3/2000k flac 与 N_MINFO 元数据逐字节吻合,hi-res 可用; 档位缺失会静默降级甚至吐 mgg/mflac 加密,必须严格校验 「返回 bitrate==请求档位 且格式为明文」否则沿降级链换档重试。 回退通道 web playUrl(Hm_Iuvt cookie 异或哈希 Secret 签名):匿名恒 128k, VIP 歌明确拒绝,仅作 mobi 网络故障兜底。旧 antiserver 链移除。 --- sdk/apis/kw.ts | 203 +++++++++++++++++++++++++++++++------------------ 1 file changed, 130 insertions(+), 73 deletions(-) diff --git a/sdk/apis/kw.ts b/sdk/apis/kw.ts index 54713fb..bcfebac 100644 --- a/sdk/apis/kw.ts +++ b/sdk/apis/kw.ts @@ -1,50 +1,126 @@ // 酷我音乐 URL 获取(直连) // 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp } -// promise resolve → { type, url } +// promise resolve → { type, url }(type 为实际拿到的音质,请求档位缺失时沿降级链回退) // -// 取流方案(按序回退): -// 1. antiserver.kuwo.cn/anti.s(convert_url3 老接口,无需 token,128k/320k 有效, -// 未登录时部分资源只返回试听片段) -// 2. www.kuwo.cn/api/v1/www/music/playUrl(需 csrf token,token 失效自动重取重试一次) -// -// 2026-09-19 实测结论(勿轻易改链): -// - mobi.kuwo.cn/mobi.s(DES 加密 query)已进防盗链蜜罐: -// 任意 rid(含不存在的)都返回 200 与 MP3 地址,但内容是《防盗链打击音频》假文件, -// 且响应无法与真实响应区分,故不接入默认链路;DES 实现可参考 UnblockNeteaseMusic 上游 kwDES。 -// - playUrl 对新 token(Hm_Iuvt_* cookie)返回 "The request is illegal!"(要求签名), -// 首页也不再下发 kw_token,getToken 会失败——通道名存实亡,仅作最后回退。 -// - flac 及以上音质各匿名通道均要求 VIP 登录态;接入酷我登录 token 后可重估 mobi.s。 +// 取流通道(按优先级): +// 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 参数组合, +// 本组合已验证为真音频。 +// 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' -// 音质映射:128k → 128kmp3, 320k → 320kmp3, flac → flac(antiserver/mobi.s 用 mp3/flac 格式) -const qualityMap = { - '128k': { br: '128kmp3', format: 'mp3' }, - '320k': { br: '320kmp3', format: 'mp3' }, - flac: { br: 'flac', format: 'flac' }, - flac24bit: { br: 'flac', format: 'flac' }, +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 = { + '128k': { br: '128kmp3', bitrate: 128 }, + '320k': { br: '320kmp3', bitrate: 320 }, + 'flac': { br: '2000kflac', bitrate: 2000 }, + 'flac24bit': { br: '4000kflac', bitrate: 4000 }, } -// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality) -export const supportQualitys = Object.keys(qualityMap) +// 档位缺失时的降级链(请求档位 → 逐级尝试) +const fallbackChain: Record = { + 'flac24bit': ['flac', '320k', '128k'], + 'flac': ['320k', '128k'], + '320k': ['128k'], + '128k': [], +} -let token = '' -let tokenPromise: Promise | null = null +// 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播 +const plainFormats = new Set(['mp3', 'flac', 'ogg', 'aac', 'm4a']) + +// 匿名通道实际可用音质(sdk/index.ts 汇总为 supportQuality) +export const supportQualitys = ['128k', '320k', 'flac', 'flac24bit'] /** - * 获取 kw csrf token(访问首页拿 cookie) + * mobi.s convert_url_with_sign 取流 + * 未命中(无 url / bitrate 不匹配 / 加密格式)resolve null,由调用方沿降级链换档 */ -const getToken = (retryNum = 0) => { +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: data.url } + }).catch(() => null) +} + +interface KwToken { name: string, value: string, secret: string } + +let token: KwToken | null = null +let tokenPromise: Promise | 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 => { if (token) return Promise.resolve(token) if (tokenPromise) return tokenPromise - tokenPromise = httpFetch('http://www.kuwo.cn/', { + tokenPromise = httpFetch('https://www.kuwo.cn/', { method: 'get', + headers: { 'User-Agent': UA }, timeout: 15000, }).promise.then(({ headers }) => { tokenPromise = null - const match = ((headers['set-cookie'] as string[]) || []).join(';').match(/kw_token=(\w+)/) - if (!match) throw new Error('获取token失败') - token = match[1] + 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 @@ -55,64 +131,45 @@ const getToken = (retryNum = 0) => { } /** - * 通过 antiserver 老接口获取(无需 token) - * @param {string} rid MUSIC_xxx - * @param {string} br 128kmp3 / 320kmp3 / flac - * @param {string} format mp3 / flac + * 通过官方 playUrl 接口获取(需 Secret 签名;匿名恒 128k) + * @param t 本次请求使用的 token(显式传入,避免与并发刷新竞态) */ -const getUrlByAntiServer = (rid, br, format) => { - return httpFetch(`http://antiserver.kuwo.cn/anti.s?type=convert_url3&rid=${rid}&format=${format}&response=url&br=${br}`, { +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: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Cookie: `${t.name}=${t.value}`, + Secret: t.secret, + Referer: 'https://www.kuwo.cn/', + 'User-Agent': UA, }, timeout: 15000, }).promise.then(({ statusCode, body }) => { - if (statusCode != 200 || typeof body != 'object' || body.code != 200 || !body.url) { - return Promise.reject(new Error(body?.msg || '获取失败')) - } - return body.url - }) -} - -/** - * 通过官方 playUrl 接口获取(需要 csrf token) - */ -const getUrlByPlayUrl = (songmid, br, retryNum = 0) => { - const requestObj = httpFetch(`http://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&br=${br}`, { - method: 'get', - headers: { - csrf: token, - cookie: `kw_token=${token}`, - Referer: 'http://www.kuwo.cn/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - }, - timeout: 15000, - }) - requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { - if (statusCode != 200 || body.code != 200) { - // token 失效时重新获取再试一次 - if (token && /token|403|401/.test(body?.msg || '') && retryNum == 0) { - token = '' - return getUrlByPlayUrl(songmid, br, ++retryNum).promise + 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(body?.msg || '获取失败')) + return Promise.reject(new Error(msg || '获取失败')) } - if (!body.data?.url) return Promise.reject(new Error('获取失败')) - return body.data.url + // 服务器无视 br,匿名实际只回 128k,如实上报 + return { type: '128k', url: body.data.url } }) return requestObj } export const getMusicUrl = (songInfo, type) => { const requestObj = { cancelHttp() {}, promise: null as any } - const { br, format } = qualityMap[type] || qualityMap['128k'] - const rid = `MUSIC_${songInfo.songmid}` - - requestObj.promise = getUrlByAntiServer(rid, br, format).catch(() => { - // antiserver 无此音质(flac 常被拒)时回退官方接口 - return getToken().then(() => getUrlByPlayUrl(songInfo.songmid, br).promise) - }).then(url => ({ type, url })) + 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 }