Compare commits

...
7 Commits
36 changed files with 2101 additions and 37 deletions

No files matched your search

+14 -5
View File
@@ -4,7 +4,7 @@
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs) - **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)+ 有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr)(有声平台不参与聚合搜索)
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单 - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单
@@ -26,9 +26,9 @@ pnpm typecheck # 仅类型检查,不运行
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) | | `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有音乐平台,xm 不参与聚合需显式指定) |
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | | `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) | | `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
| `GET /stream/qs/:id` | 汽水音乐解密流代理(登录态取流地址,服务端解密回明文音频) | | `GET /stream/qs/:id` | 汽水音乐解密流代理(登录态取流地址,服务端解密回明文音频) |
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) | | `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
| `GET /pic/:source/:id` | 获取封面 | | `GET /pic/:source/:id` | 获取封面 |
@@ -39,15 +39,20 @@ pnpm typecheck # 仅类型检查,不运行
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) | | `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
| `GET /singer/:source/:id/songs?page=1&limit=30` | 歌手歌曲列表(kg/tx/wy) | | `GET /singer/:source/:id/songs?page=1&limit=30` | 歌手歌曲列表(kg/tx/wy) |
| `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) | | `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) |
| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx) | | `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx/xm) |
| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) | | `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) | | `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
| `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) | | `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) |
| `POST /login/kg/captcha` → `POST /login/kg/cellphone` | 酷狗手机验证码登录 | | `POST /login/kg/captcha` → `POST /login/kg/cellphone` | 酷狗手机验证码登录 |
| `POST /login/kg/password` | 酷狗账号密码登录(触发 20028 风控时返回 needVerify 挑战,配合 `/login/kg/verify` 提交图形/短信验证后重试) |
| `POST /login/kg/verify` | 提交酷狗风控验证结果(verifycode 为图形验证凭证或短信码) |
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) | | `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) | | `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) | | `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
| `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(**抖音 App** 扫码,登录后解锁完整曲目与无损取流) | | `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(**抖音 App** 扫码,登录后解锁完整曲目与无损取流) |
| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) |
| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) |
| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) |
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
@@ -62,6 +67,10 @@ pnpm typecheck # 仅类型检查,不运行
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
- **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流 - **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
- **qs**:track id(搜索结果的 `songmid` 字段);匿名仅 30s 试听,`/login/qs/qr` 登录后走解密流代理取完整曲目 - **qs**:track id(搜索结果的 `songmid` 字段);匿名仅 30s 试听,`/login/qs/qr` 登录后走解密流代理取完整曲目
- **xm**:track id(搜索/专辑结果的 `songmid` 字段);匿名可取免费曲目(64k/128k 有声档位),付费/VIP 内容 `POST /login/xm` 绑定 cookie 后取流
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。 `/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
@@ -94,7 +103,7 @@ export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码) src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码)
server.ts HTTP 服务器入口(pnpm start 实际启动文件) server.ts HTTP 服务器入口(pnpm start 实际启动文件)
routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据) routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据)
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/token、tx 扫码/cookie、qs 抖音扫码) routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 抖音扫码、xm/me cookie 绑定)
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK) source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK)
source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱) source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱)
source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载) source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载)
+41
View File
@@ -0,0 +1,41 @@
// 懒人听书 URL 获取(web 通道 getPlayPath,需登录)
// 接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1&sections=[<section>]&type=0
// 实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
// 成功响应 list[0].path 为音频直链。
// songmid 为复合 id "<bookId>:<section>"(/album/lr 章节列表产出);纯 bookId 视为第一章
import { httpFetch } from '../request.ts'
import { getLrCookie } from '../lr/auth.ts'
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
const PLAY_URL = 'https://m.lrts.me/ajax/getPlayPath'
export const supportQualitys = ['64k', '128k']
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
const [bookId, section = '1'] = songmid.split(':')
requestObj.promise = (async() => {
if (!bookId || !/^\d+$/.test(bookId)) throw new Error('懒人 id 需为 <bookId>[:<section>](见 /album/lr 章节列表)')
const cookie = getLrCookie()
if (!cookie) throw new Error('懒人听书取流需要登录(POST /login/lr 绑定账号密码)')
const { statusCode, body } = await httpFetch(
`${PLAY_URL}?entityId=${bookId}&entityType=3&opType=1&sections=[${section}]&type=0`,
{ headers: { 'User-Agent': UA_MI, Referer: 'https://m.lrts.me/', Cookie: cookie }, timeout: 15000 },
).promise as any
const b = body ?? {}
if (statusCode != 200) throw new Error(`取流失败(HTTP ${statusCode})`)
const path = b?.list?.[0]?.path
if (!path) {
if (b.status === 2) throw new Error(b.msg || '收费章节未购买(需账号已购/会员)')
if (String(b.msg || '').includes('登录')) throw new Error('登录态已失效,请重新 POST /login/lr')
throw new Error(b.msg || '取流失败')
}
return { type: type === '128k' ? '128k' : '64k', url: String(path) }
})()
return requestObj
}
export default { getMusicUrl }
+39
View File
@@ -0,0 +1,39 @@
// 猫耳FM URL 获取(App API 通道)
// 链路:HMAC-SHA256(appsecret) 签名 → app.missevan.com/sound/sound → 免签名明文直链
// soundurl/soundurl_64 = 原文件(码率视源,常见 192k AAC)
// soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
// 实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
// 付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带
// 统一接口约定:apis('me').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { getSoundInfo } from '../me/musicInfo.ts'
// 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
const QUALITY_FIELDS: Record<string, string[]> = {
'128k': ['soundurl_128', 'soundurl', 'soundurl_64', 'soundurl_32'],
'64k': ['soundurl_64', 'soundurl', 'soundurl_128', 'soundurl_32'],
}
export const supportQualitys = ['64k', '128k']
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const soundId = String(songInfo.songmid ?? '')
requestObj.promise = (async() => {
const info = await getSoundInfo(soundId)
const fields = QUALITY_FIELDS[type] ?? QUALITY_FIELDS['128k']
let url = ''
for (const field of fields) {
const v = info?.[field]
if (v && String(v).startsWith('http')) { url = String(v); break }
}
if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)')
// -128k 变体归 128k 桶,原文件按 64k 桶展示(原始码率不固定,多为 192k)
const hit128 = url.includes('-128k') || type === '128k'
return { type: hit128 ? '128k' : '64k', url }
})()
return requestObj
}
export default { getMusicUrl }
+34
View File
@@ -0,0 +1,34 @@
// 蜻蜓FM URL 获取(点播 audiostream 签名直链)
// 链路(官网 main.js 逆向,2026-10-02 实测):
// GET https://audio.qtfm.cn/audiostream/redirect/<channel>/<program>?access_token=&device_id=MOBILESITE&qingting_id=&t=<ms>&sign=<hex>
// sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外)
// 匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3)
// 直播电台另有 /live/<cid>/64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn)
// songmid 为复合 id "<channelId>:<programId>"(/album/qt 列表产出)
import crypto from 'node:crypto'
const SIGN_KEY = '7l8CZ)SgZgM_bkrw'
const AUDIO_HOST = 'https://audio.qtfm.cn'
export const supportQualitys = ['64k', '128k']
export const buildProgramUrl = (channelId: string, programId: string) => {
const s = `/audiostream/redirect/${channelId}/${programId}?access_token=&device_id=MOBILESITE&qingting_id=&t=${Date.now()}`
const sign = crypto.createHmac('md5', SIGN_KEY).update(s).digest('hex')
return `${AUDIO_HOST}${s}&sign=${sign}`
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
const [channelId, programId] = songmid.split(':')
requestObj.promise = (async() => {
if (!channelId || !programId) {
throw new Error('蜻蜓 id 需为 <channelId>:<programId>(见 /album/qt 节目列表)')
}
return { type: type === '128k' ? '128k' : '64k', url: buildProgramUrl(channelId, programId) }
})()
return requestObj
}
export default { getMusicUrl, buildProgramUrl }
+21 -20
View File
@@ -2,40 +2,41 @@
// 接口: https://u.y.qq.com/cgi-bin/musicu.fcg // 接口: https://u.y.qq.com/cgi-bin/musicu.fcg
// 方法: vkey.GetVkeyServer.CgiGetVkey // 方法: vkey.GetVkeyServer.CgiGetVkey
// 取流方案: // 取流方案:
// 按请求音质组 filename 链一次请求(M500{media_mid}.mp3 / M800{media_mid}.mp3 / F000{media_mid}.flac), // 按请求音质组 filename 候选链一次请求(命中第一个有 purl 的档位即最高可用音质);
// 响应 midurlinfo 与 filename 一一对应,取第一个有 purl 的(即最高可用音质);
// 始终附带 C400{songmid}.m4a 保底(匿名可取的 128k)。 // 始终附带 C400{songmid}.m4a 保底(匿名可取的 128k)。
// 登录 cookie(uin + qm_keyst,见 /login/tx)可解锁 320k/无损。 // 档位前缀(社区资料 2026-10 核对):RS01 Hi-Res FLAC > Q000/AI00 大师/臻品母带 FLAC >
// F000 无损 FLAC > M800 320k MP3 > M500 128k MP3 > C400 128k AAC;
// VIP 档 filename 用 media_mid(非 songmid)。
// 登录 cookie(uin + qm_keyst,见 /login/tx)可解锁 320k/无损/Hi-Res。
// 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中的音质,可能低于请求值) // promise resolve → { type, url }(type 为实际命中的音质,可能低于请求值)
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { getTxAuth } from '../auth/store.ts' import { getTxAuth } from '../auth/store.ts'
// 音质 → [文件前缀, 扩展名](flac24bit 暂走 flac 通道,命中即返回) // 音质 → 候选档位链([prefix, ext, 命中后展示的 type],从高到低逐级回退)
const prefixMap = { const candidateMap: Record<string, Array<[string, string, string]>> = {
'128k': ['M500', 'mp3'], '128k': [['M500', 'mp3', '128k']],
'320k': ['M800', 'mp3'], '320k': [['M800', 'mp3', '320k'], ['M500', 'mp3', '128k']],
flac: ['F000', 'flac'], flac: [['F000', 'flac', 'flac'], ['M800', 'mp3', '320k'], ['M500', 'mp3', '128k']],
flac24bit: ['F000', 'flac'], flac24bit: [
['RS01', 'flac', 'flac24bit'],
['Q000', 'flac', 'flac24bit'],
['AI00', 'flac', 'flac24bit'],
['F000', 'flac', 'flac'],
['M800', 'mp3', '320k'],
['M500', 'mp3', '128k'],
],
} }
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality) // 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = Object.keys(prefixMap) export const supportQualitys = Object.keys(candidateMap)
// 降级链:高音质无版权/VIP 受限时逐级回退
const chainMap = {
'128k': ['128k'],
'320k': ['320k', '128k'],
flac: ['flac', '320k', '128k'],
flac24bit: ['flac', '320k', '128k'],
}
export const getMusicUrl = (songInfo, type) => { export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any } const requestObj = { cancelHttp() {}, promise: null as any }
const mediaMid = songInfo.strMediaMid || songInfo.songmid const mediaMid = songInfo.strMediaMid || songInfo.songmid
const entries: any[] = [] const entries: any[] = []
for (const t of chainMap[type] || chainMap['128k']) { for (const [prefix, ext, hitType] of candidateMap[type] || candidateMap['128k']) {
const [prefix, ext] = prefixMap[t] entries.push({ filename: `${prefix}${mediaMid}.${ext}`, type: hitType })
entries.push({ filename: `${prefix}${mediaMid}.${ext}`, type: t })
} }
entries.push({ filename: `C400${songInfo.songmid}.m4a`, type: '128k' }) entries.push({ filename: `C400${songInfo.songmid}.m4a`, type: '128k' })
+52
View File
@@ -0,0 +1,52 @@
// 喜马拉雅 URL 获取(PC 桌面端 baseInfo 通道)
// 链路:makeXmSign(hdaa 上报) → baseInfo device=win(带 PC 设备 Cookie) → playUrlList
// → decodeWinUrl(AES-ECB, 客户端 4.0.14 asar 密钥) → a.xmcdn.com 带签名直链
// 实测(2026-10-02)匿名免费曲目 HTTP 200 audio/mp4 可播;付费/VIP 曲 ret=1001(需登录,暂未接)
// 档位命名如 M4A_64/MP3_64/M4A_24/MP3_32/AAC_24(音乐内容另有 MP3_128/M4A_128);
// 喜马拉雅以有声内容为主,码率低于音乐平台,音质映射到 '64k'/'128k' 桶
// 统一接口约定:apis('xm').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { getTrackInfo } from '../xm/musicInfo.ts'
import { decodeWinUrl } from '../xm/decoder.ts'
// 优先 M4A(同码率体积更小),128 档优先 MP3_128
const QUALITY_PREFERENCE: Record<string, string[]> = {
'128k': ['MP3_128', 'M4A_128', 'M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'],
'64k': ['M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'],
}
export const supportQualitys = ['64k', '128k']
const bitrateOf = (type: string): number => Number(/(\d+)/.exec(type)?.[1] ?? 0) || 0
const bucketOf = (bitrate: number): string => (bitrate >= 128 ? '128k' : '64k')
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const trackId = String(songInfo.songmid ?? '')
requestObj.promise = (async() => {
const trackInfo = await getTrackInfo(trackId)
const playList: any[] = trackInfo.playUrlList ?? []
if (!playList.length) throw new Error('无可用播放资源(付费/版权内容需登录)')
const preference = QUALITY_PREFERENCE[type] ?? QUALITY_PREFERENCE['128k']
let picked: { type: string, url: string } | null = null
for (const want of preference) {
const hit = playList.find(item => item?.type === want && item.url)
if (hit) {
picked = { type: hit.type, url: hit.url.startsWith('http') ? hit.url : decodeWinUrl(hit.url) }
break
}
}
if (!picked) {
// 兜底:未知档位命名时取码率最高的一条
const best = [...playList].filter(item => item?.url).sort((a, b) => bitrateOf(b.type) - bitrateOf(a.type))[0]
if (!best) throw new Error('无可用播放资源')
picked = { type: best.type, url: best.url.startsWith('http') ? best.url : decodeWinUrl(best.url) }
}
return { type: bucketOf(bitrateOf(picked.type)), url: picked.url }
})()
return requestObj
}
export default { getMusicUrl }
+245 -3
View File
@@ -1,10 +1,13 @@
// 酷狗登录 —— token 绑定 / 扫码 / 短信验证码(与 MakcRe/KuGouMusicApi 协议对齐) // 酷狗登录 —— token 绑定 / 扫码 / 短信验证码 / 账号密码(与 MakcRe/KuGouMusicApi 协议对齐)
// 扫码走 login-user.kugou.com 的 web 签名通道;短信走 android 签名 + AES/RSA; // 扫码走 login-user.kugou.com 的 web 签名通道;短信走 android 签名 + AES/RSA;
// 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 +
// sid/edt 行为指纹,指纹由 kgSimulate.ts 服务端生成,无需浏览器 WASM);
// token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定 // token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from './store.ts' import { getCredentials, setCredentials } from './store.ts'
import { generateSimulate } from './kgSimulate.ts'
import type { KgCredentials } from './store.ts' import type { KgCredentials } from './store.ts'
const md5 = s => crypto.createHash('md5').update(s).digest('hex') const md5 = s => crypto.createHash('md5').update(s).digest('hex')
@@ -34,6 +37,82 @@ export const androidSign = (params, data = '') => {
return md5(SALT_SIGN + s + data + SALT_SIGN) return md5(SALT_SIGN + s + data + SALT_SIGN)
} }
/** RSA PKCS1 v1.5 加密(register_dev 专用;与登录的 RSA 裸填充不同) */
const rsaEncryptPkcs1Hex = (data: object) =>
crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(JSON.stringify(data), 'utf8')).toString('hex')
// ---- 设备 dfid 正式注册(userservice.kugou.com /risk/v2/r_register_dev)----
// 随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。
// 协议(参考 KuGouMusicApi register_dev.js):设备画像 → AES-128-CBC(随机 6 位 key,
// key=md5(key)[0:16]、iv=md5(key)[16:32]) → base64 作 body;p = RSA-PKCS1({aes:key, uid, token})。
// 响应为原始二进制密文(非 JSON),按 base64 解码后同密钥 AES 解密得 {status, data:{dfid}}。
const playlistAesEncrypt = (data: object) => {
const key = randomString(6)
const keyHex = md5(key)
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8'))
return {
key,
str: Buffer.concat([cipher.update(JSON.stringify(data), 'utf8'), cipher.final()]).toString('base64'),
}
}
const playlistAesDecrypt = (rawBase64: string, key: string) => {
const keyHex = md5(key)
const d = crypto.createDecipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8'))
const out = Buffer.concat([d.update(Buffer.from(rawBase64, 'base64')), d.final()]).toString('utf8')
try { return JSON.parse(out) } catch { return out }
}
/** 注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍) */
export const registerDfid = async() => {
const dev = getDevice()
if ((dev as any).dfidRegistered) return dev
const cred = getCredentials('kg') as KgCredentials | null
const userid = Number(cred?.userid || 0)
const deviceMap = {
availableRamSize: 4983533568, availableRomSize: 48114719, availableSDSize: 48114717,
basebandVer: '', batteryLevel: 100, batteryStatus: 3,
brand: 'Redmi', buildSerial: 'unknown', device: 'marble',
imei: dev.guid, imsi: '', manufacturer: 'Xiaomi', uuid: dev.guid,
accelerometer: false, accelerometerValue: '', gravity: false, gravityValue: '',
gyroscope: false, gyroscopeValue: '', light: false, lightValue: '',
magnetic: false, magneticValue: '', orientation: false, orientationValue: '',
pressure: false, pressureValue: '', step_counter: false, step_counterValue: '',
temperature: false, temperatureValue: '',
}
const enc = playlistAesEncrypt(deviceMap)
const p = rsaEncryptPkcs1Hex({ aes: enc.key, uid: userid, token: cred?.token || '' })
const params: Record<string, any> = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
part: 1, platid: 1, p,
}
params.signature = androidSign(params, enc.str)
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://userservice.kugou.com/risk/v2/r_register_dev?${qs}`, {
method: 'post',
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
body: enc.str,
timeout: 15000,
}).promise as any
// 响应为二进制密文:raw base64 → AES 解密
const raw = r.raw ? Buffer.from(r.raw).toString('base64') : Buffer.from(JSON.stringify(r.body ?? '')).toString('base64')
const body = playlistAesDecrypt(raw, enc.key)
if (body?.status !== 1 || !body.data?.dfid) {
throw new Error(`dfid 注册失败(status ${body?.status ?? '未知'} ${body?.error ?? ''})`.trim())
}
const updated = { ...dev, dfid: String(body.data.dfid), dfidRegistered: true }
setCredentials('_kg_device', updated)
return updated
}
/** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */ /** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */
export const getDevice = () => { export const getDevice = () => {
const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) } const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) }
@@ -44,13 +123,14 @@ export const getDevice = () => {
return dev return dev
} }
/** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域) */ /** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域);withHeaders 时返回 { body, headers } */
const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {} }: { const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {}, withHeaders = false }: {
params?: Record<string, any> params?: Record<string, any>
data?: any data?: any
router?: string router?: string
host?: string host?: string
headers?: Record<string, string> headers?: Record<string, string>
withHeaders?: boolean
}) => { }) => {
const dev = getDevice() const dev = getDevice()
const params = { const params = {
@@ -72,6 +152,7 @@ const gatewayRequest = async(url, { params: extraParams, data, router, host = 'h
body: data ?? undefined, body: data ?? undefined,
timeout: 15000, timeout: 15000,
}).promise }).promise
if (withHeaders) return { body: r.body ?? {}, headers: (r as any).headers ?? {} }
return r.body ?? {} return r.body ?? {}
} }
@@ -334,6 +415,167 @@ export const refreshLogin = async() => {
return { refreshed: true } return { refreshed: true }
} }
// ---- 账号密码登录(/v9/login_by_pwd)+ 20028 验证码挑战 ----
// 参考 KuGouMusicApi login.js / get_verify_info.js / verify_user_info.js 与 issue #206:
// 密码与验证码走 AES 随机密钥加密(params/pk);风控触发时响应 error_code=20028,
// 事件 id 在响应头 ssa-code(gz_tx_event_xxx);/verifyservice/v3/get_verify_info
// 按 data.url/v_type 区分验证类型(23 腾讯滑块、32 手机验证码、22 活体、34 人脸…);
// 验证结果经 /v4/verify_user_info 提交(verifycode 格式 KGCodeXX|{json} 或短信码),
// sid/edt 为行为指纹(kgSimulate 服务端生成),随后重试登录(短信码经 code 字段随密文上送)。
// login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值)
const PWD_T1 = '562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c03a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5adcdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac'
const PWD_T2 = '31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294aeebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db1400fdf49b7cf7e26be2e5af4da7830cc3be4'
const PWD_T3 = 'MCwwLDAsMCwwLDAsMCwwLDA='
const PWD_CLIENTVER = 11440
// 验证码类型(get_verify_info 的 data.url 前缀 / v_type)
const VERIFY_VTYPES: Record<string, string> = {
16: 'QQ 绑定验证', 18: '微信绑定验证', 22: '活体认证', 23: '滑块/图形验证码', 32: '手机短信验证码', 34: '人脸核身',
}
const VERIFY_URL_TYPES: Record<string, string> = {
KGCodeGT: '极验', KGCodeTX: '腾讯', KGCodeKG: '酷狗旋转', KGCodeKG2: '酷狗滑块', KGCodeSM: '数美', KGCodeYD: '易盾',
}
export interface VerifyChallenge {
eventid: string
v_type: number
vTypeLabel: string
url?: string
provider?: string
txappid?: string
sessionid?: string
sid: string
edt: string
}
/** 查询验证事件详情(/verifyservice/v3/get_verify_info) */
export const getVerifyInfo = async(eventid: string) => {
const body = await gatewayRequest('/verifyservice/v3/get_verify_info', {
data: {
eventid, userid: Number(getCredentials('kg')?.userid ?? 0), platid: 2, rtype: 1,
wasm: 1, i: '', sid: '', edt: '',
},
})
if (body.status !== 1 && body.error_code !== 0) {
throw new Error(body.error ?? `查询验证信息失败(code ${body.error_code ?? '未知'})`)
}
return body.data ?? {}
}
/** 提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码) */
export const verifyUserInfo = async({ eventid, v_type, verifycode, sid, edt }: {
eventid: string
v_type: number
verifycode: string
sid?: string
edt?: string
}) => {
const dev = getDevice()
const userid = Number(getCredentials('kg')?.userid ?? 0)
const fp = sid && edt ? { sid, edt } : generateSimulate(dev.mid, userid, dev.dfid)
const dataMap: Record<string, any> = {
eventid, userid, platid: 2, v_type: Number(v_type) || 23, wasm: 1, i: '',
sid: fp.sid, edt: fp.edt,
}
if (dataMap.v_type === 32) {
// 手机短信验证码:明文 code + 密文 params 均携带
const enc = aesEncryptRandom({ code: verifycode })
Object.assign(dataMap, {
code: verifycode, verifycode,
pk: rsaEncryptHex({ key: enc.key }), params: enc.str,
})
} else {
// 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送
const enc = aesEncryptRandom({})
Object.assign(dataMap, {
verifycode,
pk: rsaEncryptHex({ key: enc.key }), params: enc.str,
})
}
const body = await gatewayRequest('/v4/verify_user_info', {
host: 'https://verifyservice.kugou.com',
params: { clientver: 11510 },
data: dataMap,
})
if (body.status !== 1) {
const err = new Error(body.error ?? `验证提交失败(code ${body.error_code ?? '未知'})`)
;(err as any).status = 401
throw err
}
return { verified: true }
}
/**
* 账号密码登录(/v9/login_by_pwd)
* 成功返回账号资料;命中 20028 风控返回 VerifyChallenge(先 /login/kg/verify 提交验证,
* 短信码场景再带 code 重试本接口)
*/
export const passwordLogin = async({ username, password, code }: { username: string, password: string, code?: string }) => {
// 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试)
await registerDfid().catch(() => {})
const clienttimeMs = Date.now()
const enc = aesEncryptRandom({ pwd: String(password), code: String(code || ''), clienttime_ms: clienttimeMs })
const { body, headers } = await gatewayRequest('/v9/login_by_pwd', {
router: 'login.user.kugou.com',
params: { clientver: PWD_CLIENTVER },
data: {
plat: 1,
support_multi: 1,
clienttime_ms: clienttimeMs,
t1: PWD_T1,
t2: PWD_T2,
t3: PWD_T3,
dev: 'SDY-AN00',
username: String(username),
params: enc.str,
pk: rsaEncryptHex({ clienttime_ms: clienttimeMs, key: enc.key }).toUpperCase(),
},
headers: {
'User-Agent': `Android16-1070-${PWD_CLIENTVER}-130-0-LOGIN-wifi`,
'support-calm': '1',
},
withHeaders: true,
}) as { body: any, headers: Record<string, any> }
if (body.status === 1 && body.data?.secu_params) {
const decrypted = aesDecryptHex(body.data.secu_params, enc.key)
const token = typeof decrypted === 'object' ? decrypted?.token : decrypted
if (!token) throw new Error('登录响应解密失败')
return applyKgLogin({ token, userid: decrypted?.userid ?? body.data?.userid ?? 0 })
}
if (body.error_code === 20028) {
const eventid = String(headers?.['ssa-code'] ?? headers?.['SSA-CODE'] ?? body.ssaCode ?? '')
if (!eventid) {
throw new Error('触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录')
}
const dev = getDevice()
const userid = Number(getCredentials('kg')?.userid ?? 0)
const { sid, edt } = generateSimulate(dev.mid, userid, dev.dfid)
const info = await getVerifyInfo(eventid).catch(() => ({}) as any)
const vType = Number(info.v_type ?? 23)
const url: string = String(info.url ?? '')
const provider = Object.keys(VERIFY_URL_TYPES).find(k => url.includes(k))
const challenge: VerifyChallenge = {
eventid,
v_type: vType,
vTypeLabel: VERIFY_VTYPES[vType] ?? `未知类型 ${vType}`,
url: url || undefined,
provider: provider ? VERIFY_URL_TYPES[provider] : undefined,
txappid: info.txappid,
sessionid: info.sessionid,
sid,
edt,
}
return challenge
}
const err = new Error(body.error ?? `登录失败(code ${body.error_code ?? '未知'})`)
;(err as any).status = 401
throw err
}
/** 当前 kg 登录资料(未登录返回 null) */ /** 当前 kg 登录资料(未登录返回 null) */
export const status = () => { export const status = () => {
const cred = getCredentials('kg') const cred = getCredentials('kg')
+116
View File
@@ -0,0 +1,116 @@
// 酷狗行为指纹模拟(sid/edt 生成)——移植 MakcRe/KuGouMusicApi util/generate_simulate.js
// 用途:20028 二次验证时替代浏览器 WASM 上报行为数据。
// 协议:明文 = mid=..;userid=..;dfid=..;webgl=..;webdriver=0;ts=..;data=<事件流>
// edt = AES-128-CBC(明文, key=md5(rand16)[0:16], iv='kugousecurity123') → Base64
// sid = RSA-OAEP-SHA256(key) → Base64(公钥取自酷狗 WASM 二进制)
// 事件流:贝塞尔曲线鼠标轨迹(type 3) + 滚动(type 5) + 窗口(type 6),条目间以 : 分隔,
// 每条事件后跟一条哨兵记录(时间差字段替换为接近 0xFFFFFFFF 的随机值)。
import crypto from 'node:crypto'
const md5 = (s: string) => crypto.createHash('md5').update(s).digest('hex')
const SIM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF
lFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/
W64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA
2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW
PmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc
56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa
rQIDAQAB
-----END PUBLIC KEY-----`
const SIM_IV = 'kugousecurity123'
const ri = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
const randomString = (n: number) => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
let sentinel = 0xffffffff - ri(0, 20)
const f3 = (t: number | string, i: number, x: number, y: number) => `3,${t},${i},${x},${y}`
const f5 = (t: number | string, i: number) => `5,${t},${i}`
const f6 = (t: number | string, i: number, x: number, y: number) => `6,${t},${i},${x},${y}`
// 三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛
const bezierPath = (sx: number, sy: number, ex: number, ey: number, n: number) => {
const c1x = sx + (ex - sx) * 0.3 + ri(-80, 80)
const c1y = sy + (ey - sy) * 0.2 + ri(-60, 60)
const c2x = sx + (ex - sx) * 0.7 + ri(-60, 60)
const c2y = sy + (ey - sy) * 0.8 + ri(-40, 40)
const pts: Array<{ x: number, y: number }> = []
for (let i = 0; i <= n; i++) {
const t = i / n
const u = 1 - t
const x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex
const y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey
const jitter = Math.max(0.5, 3 - t * 2.5)
pts.push({ x: x + (Math.random() - 0.5) * jitter, y: y + (Math.random() - 0.5) * jitter })
}
return pts
}
const generateEdtData = ({ startX, startY, endX, endY, mousePoints }: {
startX: number, startY: number, endX: number, endY: number, mousePoints: number
}) => {
const entries: string[] = []
let ts = 0
let ei = 0
entries.push(f5(0, 0), f5(sentinel, 0), f5(0, 0), f5(sentinel, 0))
ts += ri(5, 20)
entries.push(f6(ts, ei, 750, 500), f6(sentinel, ei, 750, 500))
ei++
for (let i = 0; i < 3; i++) {
ts += ri(80, 600)
entries.push(f5(ts, ei), f5(sentinel, ei))
ei++
}
const path = bezierPath(startX, startY, endX, endY, mousePoints)
let si = 0
for (let i = 0; i < path.length; i++) {
const { x, y } = path[i]
ts += ri(8, 50)
const rx = Math.round(x)
const ry = Math.round(y)
entries.push(f3(ts, si, rx, ry), f3(sentinel, si, rx, ry))
if (i > 0 && i % 12 === 0) {
ts += ri(20, 60)
entries.push(f5(ts, ei), f5(sentinel, ei))
ei++
}
si = (si + 1) % 2
}
ts += ri(5, 30)
entries.push(f3(ts, 1, Math.round(endX + ri(-5, 5)), Math.round(endY + ri(-5, 5))), f3(sentinel, 1, Math.round(endX), Math.round(endY)))
return entries.join(':')
}
/** 生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送 */
export const generateSimulate = (mid: string | number, userid: string | number, dfid: string, webglHash?: string) => {
sentinel = 0xffffffff - ri(0, 20)
const key = md5(randomString(16)).slice(0, 16)
const points = ri(30, 60)
const startX = ri(200, 600)
const startY = ri(200, 500)
const endX = ri(500, 700)
const endY = ri(80, 150)
const webgl = webglHash || md5(randomString(32))
const plaintext = `mid=${mid || 0};userid=${userid || 0};dfid=${dfid || 0};webgl=${webgl};webdriver=0;ts=${Date.now()};data=${generateEdtData({ startX, startY, endX, endY, mousePoints: points })}`
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(key, 'utf8'), Buffer.from(SIM_IV, 'utf8'))
const edt = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]).toString('base64')
const sid = crypto.publicEncrypt(
{ key: SIM_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
Buffer.from(key, 'utf8'),
).toString('base64')
return { edt, sid }
}
export default { generateSimulate }
+22
View File
@@ -45,6 +45,28 @@ export interface TxCredentials {
updatedAt?: string updatedAt?: string
} }
export interface XmCredentials {
// www.ximalaya.com 登录 Cookie(须含 1&_token,从已登录浏览器请求头复制)
// baseInfo 付费内容授权依赖它;无法主动续期,失效后需重新复制绑定
cookie: string
updatedAt?: string
}
export interface MeCredentials {
// App 登录 Cookie(须含 token=,从猫耳 App 抓包或已登录 m.missevan.com 浏览器复制)
// App API 付费声音授权依赖它;无法主动续期
cookie: string
updatedAt?: string
}
export interface LrCredentials {
// m.lrts.me 登录会话 Cookie(/login/lr 账号密码登录后由服务端保存)
// 取流接口必须携带(免费章节也要求登录态)
cookie: string
phone?: string
updatedAt?: string
}
let cache: Record<string, any> = {} let cache: Record<string, any> = {}
let loaded = false let loaded = false
+21 -2
View File
@@ -3,11 +3,19 @@ import kg from './kg/index.ts'
import tx from './tx/index.ts' import tx from './tx/index.ts'
import wy from './wy/index.ts' import wy from './wy/index.ts'
import qs from './qs/index.ts' import qs from './qs/index.ts'
import xm from './xm/index.ts'
import me from './me/index.ts'
import qt from './qt/index.ts'
import lr from './lr/index.ts'
import { supportQualitys as kwQualitys } from './apis/kw.ts' import { supportQualitys as kwQualitys } from './apis/kw.ts'
import { supportQualitys as kgQualitys } from './apis/kg.ts' import { supportQualitys as kgQualitys } from './apis/kg.ts'
import { supportQualitys as txQualitys } from './apis/tx.ts' import { supportQualitys as txQualitys } from './apis/tx.ts'
import { supportQualitys as wyQualitys } from './apis/wy.ts' import { supportQualitys as wyQualitys } from './apis/wy.ts'
import { supportQualitys as qsQualitys } from './apis/qs.ts' import { supportQualitys as qsQualitys } from './apis/qs.ts'
import { supportQualitys as xmQualitys } from './apis/xm.ts'
import { supportQualitys as meQualitys } from './apis/me.ts'
import { supportQualitys as qtQualitys } from './apis/qt.ts'
import { supportQualitys as lrQualitys } from './apis/lr.ts'
import type { PlatformModule, SearchResult, SongInfo } from './types.ts' import type { PlatformModule, SearchResult, SongInfo } from './types.ts'
import { import {
sizeFormate, sizeFormate,
@@ -22,17 +30,22 @@ import {
export { sizeFormate, formatPlayTime, dateFormat, dateFormat2, formatPlayCount, decodeName } export { sizeFormate, formatPlayTime, dateFormat, dateFormat2, formatPlayCount, decodeName }
const SINGERS_SEPARATOR = /、|&|;|;|\/|,|,|\||\]|\[|!|!/g const SINGERS_SEPARATOR = /、|&|;|;|\/|,|,|\||\]|\[|!|!/g
const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModule }> = [ // noAggregate:有声/播客类平台不参与聚合搜索与跨平台匹配(xm/me),仅支持 source 直查
const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModule; noAggregate?: boolean }> = [
{ id: 'kw', name: '酷我音乐', module: kw }, { id: 'kw', name: '酷我音乐', module: kw },
{ id: 'kg', name: '酷狗音乐', module: kg }, { id: 'kg', name: '酷狗音乐', module: kg },
{ id: 'tx', name: 'QQ音乐', module: tx }, { id: 'tx', name: 'QQ音乐', module: tx },
{ id: 'wy', name: '网易音乐', module: wy }, { id: 'wy', name: '网易音乐', module: wy },
{ id: 'qs', name: '汽水音乐', module: qs }, { id: 'qs', name: '汽水音乐', module: qs },
{ id: 'xm', name: '喜马拉雅', module: xm, noAggregate: true },
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
{ id: 'lr', name: '懒人听书', module: lr, noAggregate: true },
] ]
const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module])) const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
export const sources = SUPPORTED_SOURCES.map(({ id, name }) => ({ id, name })) export const sources = SUPPORTED_SOURCES.map(({ id, name, noAggregate }) => ({ id, name, ...(noAggregate ? { noAggregate: true } : {}) }))
export const init = (): Promise<unknown[]> => export const init = (): Promise<unknown[]> =>
Promise.all(SUPPORTED_SOURCES.map((s) => s.module.init?.()).filter(Boolean)) Promise.all(SUPPORTED_SOURCES.map((s) => s.module.init?.()).filter(Boolean))
@@ -45,6 +58,10 @@ export const supportQuality: Record<string, string[]> = {
tx: txQualitys, tx: txQualitys,
wy: wyQualitys, wy: wyQualitys,
qs: qsQualitys, qs: qsQualitys,
xm: xmQualitys,
me: meQualitys,
qt: qtQualitys,
lr: lrQualitys,
} }
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string) const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
@@ -96,6 +113,8 @@ export const searchMusic = async (
const tasks: Array<Promise<SearchResult | null>> = [] const tasks: Array<Promise<SearchResult | null>> = []
for (const source of SUPPORTED_SOURCES) { for (const source of SUPPORTED_SOURCES) {
if (!source.module.musicSearch) continue if (!source.module.musicSearch) continue
// 有声平台(noAggregate)仅在显式 only 指定时参与搜索
if (source.noAggregate && source.id !== only) continue
if (only ? source.id !== only : source.id === excludeSource) continue if (only ? source.id !== only : source.id === excludeSource) continue
tasks.push( tasks.push(
source.module.musicSearch source.module.musicSearch
+5
View File
@@ -8,8 +8,13 @@ import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.ts' import singer from './singer.ts'
import album from './album.ts' import album from './album.ts'
import { registerDfid } from '../auth/kg.ts'
const kg = { const kg = {
// 启动时注册正式 dfid(幂等;随机 dfid 匿名取流易撞 20028 验证,注册后显著降低)
async init() {
await registerDfid().catch((err: any) => console.log(`[kg/init] dfid 注册跳过: ${err.message}`))
},
leaderboard, leaderboard,
songList, songList,
musicSearch, musicSearch,
+35
View File
@@ -0,0 +1,35 @@
// 懒人听书 App API(dapis.mting.info)—— 搜索通道(游客 token 匿名可用)
// 签名(移植 loyalflower/lrts_download download.go):
// sc = md5(urlPath + "?" + <按 key 排序的 query(不含sc) 拼接> + secret),secret = "iJ0DgxmdC83#I&j@iwg"
// 头:UA Android13/yyting/... / Referer yytingting.com / ClientVersion 6.3.4.0
// 端点:/yyting/search/searchBatch.action(q=2150)
// 注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.ts)
import crypto from 'node:crypto'
import { httpFetch } from '../request.ts'
const DOMAIN = 'http://dapis.mting.info'
const SECRET = 'iJ0DgxmdC83#I&j@iwg'
const GUEST_TOKEN = 'OqzlvCxt2i_P1SZKF6GjFg**_lK0uCQpm5tN-P6XdFZYawCDKSgeC4anU'
const GUEST_IMEI = 'MDI6MDA6MDA6MDA6MDA6DDA='
const APP_HEADERS = {
'User-Agent': 'Android13/yyting/vivo/V2133A/ch_uc_beta/167/Android',
Referer: 'yytingting.com',
ClientVersion: '6.3.4.0',
}
const signed = (path: string, params: Record<string, string>) => {
const q: Record<string, string> = { token: GUEST_TOKEN, imei: GUEST_IMEI, nwt: '1', ...params }
const qs = Object.keys(q).sort().map(k => `${k}=${q[k]}`).join('&')
q.sc = crypto.createHash('md5').update(path + '?' + qs + SECRET).digest('hex')
return `${DOMAIN}${path}?${Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}`
}
/** App API GET(游客态,仅搜索可用;章节/取流会 482 需登录) */
export const appApiGet = async(path: string, params: Record<string, string>): Promise<any> => {
const { statusCode, body } = await httpFetch(signed(path, params), { headers: APP_HEADERS, timeout: 15000 }).promise as any
if (statusCode != 200) throw new Error(`懒人 App API HTTP ${statusCode}`)
return body ?? {}
}
export default { appApiGet }
+51
View File
@@ -0,0 +1,51 @@
// 懒人听书登录(web 通道 m.lrts.me/ajax/logon)
// 参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
// 成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
// (免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
import { httpFetch } from '../request.ts'
import { getCredentials, setCredentials } from '../auth/store.ts'
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
const LOGON_URL = 'https://m.lrts.me/ajax/logon'
/** 账号密码登录(成功保存会话 Cookie) */
export const login = async({ account, password }: { account: string, password: string }) => {
const form = `account=${encodeURIComponent(account)}&pwd=${Buffer.from(String(password), 'utf8').toString('base64')}`
const { statusCode, body, headers } = await httpFetch(LOGON_URL, {
method: 'post',
headers: {
'User-Agent': UA_MI,
Referer: 'https://m.lrts.me/',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: form,
timeout: 15000,
}).promise as any
const b = body ?? {}
if (statusCode != 200 || !b.phone) {
const err = new Error(b.msg || '登录失败(账号或密码错误)')
;(err as any).status = 401
throw err
}
const setCookies = (headers as any)?.['set-cookie'] ?? []
const cookie = (Array.isArray(setCookies) ? setCookies : [setCookies])
.map((c: string) => String(c).split(';')[0].trim())
.filter(Boolean)
.join('; ')
if (!cookie) throw new Error('登录成功但未返回会话 Cookie')
setCredentials('lr', { cookie, phone: String(b.phone) })
return { logged: true, phone: String(b.phone) }
}
/** 登录会话 Cookie(未登录返回空串) */
export const getLrCookie = (): string => (getCredentials('lr') as any)?.cookie || ''
/** 登录状态 */
export const status = () => {
const cred = getCredentials('lr') as { cookie?: string, phone?: string, updatedAt?: string } | null
return cred?.cookie
? { logged: true, phone: cred.phone, updatedAt: cred.updatedAt }
: { logged: false }
}
export default { login, getLrCookie, status }
+54
View File
@@ -0,0 +1,54 @@
// 懒人听书(lrts.me)平台模块
// 匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。
// 取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。
// 章节 songmid 为复合 id "<bookId>:<section>";有声内容不参与聚合(noAggregate)。
import lrApi from '../apis/lr.ts'
import musicSearch from './musicSearch.ts'
import { getBookMenu } from './menu.ts'
import { getLrCookie } from './auth.ts'
const lr = {
musicSearch,
getMusicUrl(songInfo, type) {
return lrApi.getMusicUrl(songInfo, type)
},
getPic(songInfo) {
const bookId = String(songInfo.songmid ?? '').split(':')[0]
// 书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回空由调用方回退)
return Promise.resolve(null)
},
album: {
async getAlbumDetail(bookId: string, page = 1, limit = 30) {
const { total, list } = await getBookMenu(bookId, page, limit)
return {
info: { name: `书籍 ${bookId}`, total },
list: list.slice(0, limit).map((item: any) => ({
songmid: `${bookId}:${item.section}`,
name: item.name || `第${item.section}节`,
singer: '',
albumId: String(bookId),
albumName: '',
interval: 0,
payPlay: String(item.listenPrice) !== '0',
img: null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
total,
limit,
allPage: Math.ceil(total / 30) || page,
source: 'lr' as const,
}
},
},
getMusicDetailPageUrl(songInfo) {
const bookId = String(songInfo.songmid ?? '').split(':')[0]
return `https://m.lrts.me/book/${bookId}`
},
}
export { status } from './auth.ts'
export default lr
+21
View File
@@ -0,0 +1,21 @@
// 懒人听书章节列表(web 通道,匿名免签)
// 接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0
// 实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state}
// 注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)
import { httpFetch } from '../request.ts'
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
const MENU_URL = 'https://m.lrts.me/ajax/getBookMenu'
export const getBookMenu = async(bookId: string, page = 1, limit = 30) => {
const { statusCode, body } = await httpFetch(`${MENU_URL}?bookId=${bookId}&pageNum=${page}&pageSize=${Math.max(30, limit)}&sortType=0`, {
headers: { 'User-Agent': UA_MI, Referer: 'https://m.lrts.me/' },
timeout: 15000,
}).promise as any
if (statusCode != 200) throw new Error(`获取章节失败(HTTP ${statusCode})`)
const b = body ?? {}
if (b.status === 1 || !b.list) throw new Error(b.msg || '获取章节失败')
return { total: Number(b.sections ?? 0), list: b.list }
}
export default { getBookMenu }
+41
View File
@@ -0,0 +1,41 @@
// 懒人听书搜索(App 通道,游客匿名)
// 接口: /yyting/search/searchBatch.action?keyWord=&searchOption=1&pageSize=&type=0&q=2150
// 实测(2026-10-02)匿名可用;结果分 data.bookResult/albumResult/folderResult/...
// 书籍条目字段:id(baseEntityId 同值)/name/announcer/cover
import { appApiGet } from './appApi.ts'
export const search = async(keyword: string, page = 1, limit = 20) => {
const body = await appApiGet('/yyting/search/searchBatch.action', {
keyWord: keyword,
searchOption: '1',
pageSize: String(limit),
type: String(page - 1),
q: '2150',
})
if (body.apiStatus != null && body.apiStatus !== 0) throw new Error(body.msg || `搜索失败(code ${body.apiStatus})`)
const books = body?.data?.bookResult?.list ?? []
const total = Number(body?.data?.bookResult?.count ?? books.length)
return {
allPage: Math.ceil(total / limit) || page,
total,
limit,
source: 'lr' as const,
list: books.map((item: any) => ({
songmid: String(item.id ?? item.baseEntityId),
name: item.name || '',
singer: item.announcer || item.author || '',
albumId: '',
albumName: '',
interval: 0,
payPlay: false,
img: item.cover || null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
}
}
export default { search }
+52
View File
@@ -0,0 +1,52 @@
// 猫耳FM 广播剧集列表(web 通道,匿名免签)
// 接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize=
// 结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name
// 注意:getdrama(不带 episodes)必须带 type=2 且 seasons 只含各季首集,列表用本接口
import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.ts'
const EPISODES_URL = 'https://www.missevan.com/dramaapi/getdramaepisodes'
export const getAlbumDetail = async(dramaId: string, page = 1, limit = 30) => {
const { statusCode, body } = await httpFetch(
`${EPISODES_URL}?drama_id=${dramaId}&season=1&page=${page}&order=0&pageSize=${Math.max(30, limit)}`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
Referer: `https://www.missevan.com/mdrama/drama/${dramaId}`,
Accept: 'application/json',
},
timeout: 15000,
},
).promise as any
if (statusCode != 200 || body?.success === false) {
throw new Error(body?.info || `获取剧集失败(HTTP ${statusCode})`)
}
const info = body?.info ?? {}
const drama = info.drama ?? {}
const episodes = info.episodes?.episode ?? []
return {
info: { name: drama.name || String(dramaId), total: episodes.length },
list: episodes.slice(0, limit).map((item: any) => ({
songmid: String(item.sound_id),
name: item.name || '',
singer: drama.name || '',
albumId: String(dramaId),
albumName: drama.name || '',
interval: item.duration ? formatPlayTime(Math.round(Number(item.duration) / 1000)) : 0,
payPlay: Number(item.pay_type) > 0,
img: drama.cover || null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
total: episodes.length,
limit,
allPage: 1,
source: 'me' as const,
}
}
export default { getAlbumDetail }
+35
View File
@@ -0,0 +1,35 @@
// 猫耳FM(MissEvan,ACG 广播剧/音声平台)平台模块
// 匿名可用面(2026-10-02 实测):搜索 / 剧集列表 / 免费声音明文直链 / 封面。
// 取流走 App API 签名通道(app.missevan.com,HMAC-SHA256(appsecret)),
// 返回 maoercdn 免签名直链——网页版 hls.m3u8 是 Widevine 加密流,勿用 web soundurl。
// 付费声音需 POST /login/me 绑定含 token 的 cookie。
// 有声内容不参与聚合搜索与跨平台匹配(noAggregate),仅按 source=me 直查。
import meApi from '../apis/me.ts'
import musicSearch from './musicSearch.ts'
import { getSoundInfo } from './musicInfo.ts'
import { getAlbumDetail } from './album.ts'
import { getCredentials } from '../auth/store.ts'
const me = {
musicSearch,
getMusicUrl(songInfo, type) {
return meApi.getMusicUrl(songInfo, type)
},
getPic(songInfo) {
return getSoundInfo(songInfo.songmid).then((info: any) => info?.front_cover || null)
},
album: { getAlbumDetail },
getMusicDetailPageUrl(songInfo) {
return `https://www.missevan.com/sound/${songInfo.songmid}`
},
}
/** 登录状态(凭据存在性判断) */
export const status = () => {
const cred = getCredentials('me') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie
? { logged: true, updatedAt: cred.updatedAt }
: { logged: false }
}
export default me
+10
View File
@@ -0,0 +1,10 @@
// 猫耳FM 曲目信息(App API 通道)
// 接口: GET https://app.missevan.com/sound/sound?sound_id=
// 返回 info 直接是 sound 对象(非 web 版的 info.sound 包装):
// soundstr/duration(毫秒)/front_cover/soundurl(原文件)/soundurl_64(=原文件)/
// soundurl_128/-192k 变体,均为 maoercdn.com 免签名明文直链
import { appApiGet } from './sign.ts'
export const getSoundInfo = async(soundId: string): Promise<any> => appApiGet('/sound/sound', `sound_id=${soundId}`)
export default { getSoundInfo }
+44
View File
@@ -0,0 +1,44 @@
// 猫耳FM 搜索(web 通道,匿名免签)
// 接口: https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=30&sort=2
// 结果在 data.info.Datas[](soundstr/username/id/pay_type/front_cover/duration 毫秒)
import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.ts'
const SEARCH_URL = 'https://www.missevan.com/sound/getsearch'
export const search = (keyword: string, page = 1, limit = 20) =>
httpFetch(`${SEARCH_URL}?s=${encodeURIComponent(keyword)}&p=${page}&type=3&page_size=${limit}&sort=2`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
Referer: 'https://www.missevan.com/',
Accept: 'application/json',
},
timeout: 15000,
}).promise.then(({ statusCode, body }: any) => {
if (statusCode != 200 || body?.success === false) throw new Error(body?.info || '搜索失败')
const docs = body?.info?.Datas ?? []
const total = Number(body?.info?.pagination?.count ?? docs.length)
return {
allPage: Math.ceil(total / limit) || page,
total,
limit,
source: 'me' as const,
list: docs.map((item: any) => ({
songmid: String(item.id),
name: item.soundstr || '',
singer: item.username || '',
albumId: '',
albumName: '',
interval: item.duration ? formatPlayTime(Math.round(Number(item.duration) / 1000)) : 0,
payPlay: Number(item.pay_type) > 0,
img: item.front_cover || null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
}
})
export default { search }
+68
View File
@@ -0,0 +1,68 @@
// 猫耳FM(MissEvan)App API 签名 —— app.missevan.com 域鉴权
// 算法(知乎 p/359692208 + zunley/gofm pkg/missevan/utils.go,2026-10-02 实测可用):
// authorization = "MissEvan " + base64(HMAC-SHA256(appsecret, DATA))
// DATA = METHOD\n + url(冒号转义%3A)\n + querystring\n + equip_id:<id>\n [+ token:<t>\n]
// + x-m-date:<UTC毫秒ISO>\n + x-m-nonce:<uuid>\n [+ base64(sha256(body))\n]
// 头:User-Agent MissEvanApp、x-m-date、x-m-nonce、Cookie(equip_id[+token])
// 该通道返回的是免签名 CDN 明文直链(sound-ali*-cdn-cn.maoercdn.com,m4a 原文件/-128k 变体);
// 网页版 hls.m3u8 为 Widevine 加密,与此无关。
import crypto from 'node:crypto'
import { httpFetch } from '../request.ts'
import { getCredentials } from '../auth/store.ts'
const APP_SECRET = 'f8Yw1M3u0e5MV7z34PcbY9wgP56YwJ'
const APP_UA = 'MissEvanApp/4.7.4 (iOS;15.1;iPhone11,6)'
const EQUIP_ID = '52e76e9a-7592-43af-ae72-8ec755094e41'
const utcMillis = () => new Date().toISOString().replace(/\.\d+Z$/, '.000Z')
/** 构造 authorization 签名(GET 便捷封装;POST 需补 body 摘要行) */
const buildAuth = (method: string, url: string, query: string, { equipId, token, date, nonce }: {
equipId: string, token?: string, date: string, nonce: string
}) => {
const lines = [
method,
url.replace(/:/g, '%3A'),
query,
`equip_id:${equipId}`,
...(token ? [`token:${token}`] : []),
`x-m-date:${date}`,
`x-m-nonce:${nonce}`,
]
return 'MissEvan ' + crypto.createHmac('sha256', APP_SECRET).update(lines.join('\n') + '\n').digest('base64')
}
/** 登录 token(/login/me 绑定的 cookie 中提取;未登录为空) */
export const getMeToken = (): string => {
const cookie = (getCredentials('me') as any)?.cookie || ''
return cookie.match(/(?:^|;\s*)token=([^;\s]+)/)?.[1] || ''
}
/** App API GET 请求(自动携带登录态 token) */
export const appApiGet = async(path: string, query = ''): Promise<any> => {
const full = `https://app.missevan.com${path}`
const date = utcMillis()
const nonce = crypto.randomUUID()
const token = getMeToken()
const { statusCode, body } = await httpFetch(query ? `${full}?${query}` : full, {
headers: {
'User-Agent': APP_UA,
Accept: 'application/json',
'Accept-Language': 'zh-Hans-CN;q=1',
'x-m-date': date,
'x-m-nonce': nonce,
Cookie: `equip_id=${EQUIP_ID}${token ? `; token=${token}` : ''}`,
Authorization: buildAuth('GET', full, query, { equipId: EQUIP_ID, token, date, nonce }),
},
timeout: 15000,
}).promise as any
if (statusCode != 200) throw new Error(`猫耳 App API HTTP ${statusCode}`)
const b = body ?? {}
if (b.success === false || (b.code != null && b.code !== 0)) {
throw new Error(b.info || `猫耳 App API code ${b.code}`)
}
return b.info ?? b
}
export const ME_UA = APP_UA
export default { appApiGet, getMeToken, ME_UA }
+48
View File
@@ -0,0 +1,48 @@
// 蜻蜓FM 频道节目列表(官网 SSR 页解析)
// 接口: https://www.qingting.fm/channels/<id>(302 → www.qtfm.cn,见 http.ts)
// 实测(2026-10-02)SSR 输出含节目表格:a.pTitle href=/channels/<cid>/programs/<pid> + p[title] + span.col2 时长
// 节目条目的 songmid 采用复合 id "<channelId>:<programId>"(取流签名需要两者)
import { ssrGet } from './http.ts'
const decodeEntities = (s: string) => s
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'")
const fetchChannelHtml = async(channelId: string) => {
const { statusCode, html } = await ssrGet(`https://www.qingting.fm/channels/${channelId}`)
if (statusCode != 200) throw new Error(`获取频道失败(HTTP ${statusCode})`)
return html
}
export const getChannelPrograms = async(channelId: string) => {
const html = await fetchChannelHtml(channelId)
const name = decodeEntities(html.match(/<title>([^<]*)</)?.[1]?.split('-')[0]?.trim() || channelId)
const rows = [...html.matchAll(/href="\/channels\/(\d+)\/programs\/(\d+)"[^>]*>\s*<p[^>]*title="([^"]*)"/g)]
// 标题可能为空(p[title] 空串),用行内文本兜底;时长在后续 col2
const list = rows.map(m => ({
songmid: `${m[1]}:${m[2]}`,
name: decodeEntities(m[3]) || `节目 ${m[2]}`,
singer: name,
albumId: String(channelId),
albumName: name,
interval: 0,
payPlay: false,
img: null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
}))
return { name, list }
}
/** 频道封面(SSR 页 og:image) */
export const getChannelOgImage = async(channelId: string): Promise<string | null> => {
const html = await fetchChannelHtml(channelId)
const og = html.match(/property="og:image"\s+content="([^"]+)"/)?.[1] || html.match(/content="([^"]+)"\s+property="og:image"/)?.[1]
return og ? og.replace('http://', 'https://') : null
}
export default { getChannelPrograms }
+39
View File
@@ -0,0 +1,39 @@
// 蜻蜓FM SSR 请求助手 —— 域名迁移(www.qingting.fm → www.qtfm.cn)伴随华为 WAF 会话,
// 302 时需带上 Set-Cookie 的 HWWAFSESID/HWWAFSESTIME 再请求 Location,否则可能被 WAF 拦
import { httpFetch } from '../request.ts'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
/** GET 并跟随最多 3 跳重定向(累计收集 Cookie 回带),返回最终响应 */
export const ssrGet = async(url: string): Promise<{ statusCode: number, html: string }> => {
let current = url
let cookie = ''
for (let hop = 0; hop < 4; hop++) {
const { statusCode, headers, raw, body } = await httpFetch(current, {
headers: {
'User-Agent': UA,
Referer: 'https://www.qtfm.cn/',
Accept: 'text/html,application/xhtml+xml',
...(cookie ? { Cookie: cookie } : {}),
},
timeout: 15000,
}).promise as any
const setCookies = (headers as any)?.['set-cookie'] ?? []
const pairs = (Array.isArray(setCookies) ? setCookies : [setCookies])
.map((c: string) => String(c).split(';')[0].trim())
.filter(Boolean)
if (pairs.length) cookie = [...cookie.split('; ').filter(Boolean), ...pairs].join('; ')
if (statusCode >= 300 && statusCode < 400) {
const loc = String((headers as any)?.location || '')
if (loc) {
current = loc.startsWith('http') ? loc : new URL(loc, current).href
continue
}
}
return { statusCode, html: raw ? raw.toString('utf8') : String(body ?? '') }
}
throw new Error('重定向次数过多')
}
export default { ssrGet }
+39
View File
@@ -0,0 +1,39 @@
// 蜻蜓FM 平台模块
// 匿名可用面(2026-10-02 实测):搜索(官网 SSR 解析)/ 频道节目列表(SSR)/
// 免费节目点播直链(audiostream 签名 302 到华为云 CDN)。
// 搜索结果为频道(专辑)级条目,songmid=channelId;节目条目 songmid="<channelId>:<programId>"。
// 付费节目与直播电台签名(Lwrpu$K5oP)暂未接登录。有声内容不参与聚合(noAggregate)。
import qtApi from '../apis/qt.ts'
import musicSearch from './musicSearch.ts'
import { getChannelPrograms } from './album.ts'
const qt = {
musicSearch,
getMusicUrl(songInfo, type) {
return qtApi.getMusicUrl(songInfo, type)
},
async getPic(songInfo) {
// 搜索结果已带封面;频道详情页无独立封面接口,降级用 SSR 页 og:image
const { getChannelOgImage } = await import('./album.ts')
return getChannelOgImage(songInfo.songmid.split(':')[0])
},
album: {
async getAlbumDetail(channelId: string) {
const { name, list } = await getChannelPrograms(channelId)
return {
info: { name, total: list.length },
list,
total: list.length,
limit: list.length,
allPage: 1,
source: 'qt' as const,
}
},
},
getMusicDetailPageUrl(songInfo) {
const [cid, pid] = String(songInfo.songmid).split(':')
return pid ? `https://www.qingting.fm/channels/${cid}/programs/${pid}` : `https://www.qingting.fm/channels/${cid}`
},
}
export default qt
+39
View File
@@ -0,0 +1,39 @@
// 蜻蜓FM 搜索(官网 SSR 频道结果页解析)
// 接口: https://www.qingting.fm/search/channel_ondemand/<keyword>(302 → www.qtfm.cn,见 http.ts)
// 实测(2026-10-02)SSR 渲染且与关键词强相关;?keyword= 混合页的结果区为客户端渲染
// (服务端只吐热门推荐),JSON 搜索端点(webapi/webbff/searchapi)均 404——本页是唯一匿名搜索通道
import { ssrGet } from './http.ts'
export const search = async(keyword: string, page = 1, limit = 20) => {
const { statusCode, html } = await ssrGet(`https://www.qingting.fm/search/channel_ondemand/${encodeURIComponent(keyword)}?page=${page}`)
if (statusCode != 200) throw new Error(`搜索失败(HTTP ${statusCode})`)
// 结果项:href=/channels/<id> … <span title="<名称>">,封面在随后的 img src
const items = [...html.matchAll(/href="\/channels\/(\d+)"[\s\S]{0,400}?<span title="([^"]*)">/g)]
const list = items.map(m => {
const after = html.slice(m.index ?? 0, (m.index ?? 0) + 700)
const img = after.match(/<img src="(http[^"]+)"/)?.[1]
return {
songmid: m[1],
name: m[2],
singer: '',
albumId: '',
albumName: '',
interval: 0,
img: img ? img.replace('http://', 'https://') : null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
}
}).filter((item): item is NonNullable<typeof item> => Boolean(item.name)).slice(0, limit)
return {
allPage: page + (list.length >= 10 ? 1 : 0),
total: list.length,
limit,
source: 'qt' as const,
list,
}
}
export default { search }
+1 -1
View File
@@ -2,7 +2,7 @@
// 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。 // 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。
/** 音乐平台标识 */ /** 音乐平台标识 */
export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' | 'xm' | 'me' | 'qt' | 'lr'
/** 音质标识(wav 仅部分接口声明使用) */ /** 音质标识(wav 仅部分接口声明使用) */
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav' export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
+58
View File
@@ -0,0 +1,58 @@
// 喜马拉雅专辑曲目列表(免签匿名接口)
// 接口: https://www.ximalaya.com/revision/album/getTracksList?albumId=&pageNum=&sort=0
// 实测(2026-10-02)匿名可翻全部页(固定每页 30 条);仅曲目元信息(trackId/title/isPaid),
// 播放地址需另走 baseInfo。注意 v1 版接口(/revision/album/v1/getTracksList)需要 webtk 签名且有风控。
import { httpFetch } from '../request.ts'
import { XM_UA } from './sign.ts'
const TRACKS_URL = 'https://www.ximalaya.com/revision/album/getTracksList'
const PAGE_SIZE = 30
/** 抓取专辑第 pageNum 页曲目(返回 { total, tracks }) */
export const getTracksPage = async(albumId: string, pageNum: number) => {
const { statusCode, body } = await httpFetch(`${TRACKS_URL}?albumId=${albumId}&pageNum=${pageNum}&sort=0`, {
headers: { 'User-Agent': XM_UA, Referer: `https://www.ximalaya.com/album/${albumId}` },
timeout: 15000,
}).promise as any
if (statusCode != 200 || body?.ret != 200 || !body?.data) {
throw new Error(body?.msg || `获取专辑曲目失败(ret ${body?.ret ?? statusCode})`)
}
return {
total: Number(body.data.trackTotalCount ?? 0),
tracks: body.data.tracks ?? [],
}
}
/** 专辑详情 + 指定页曲目(/album 路由适配:{ info, list },接口固定每页 30 条) */
export const getAlbumDetail = async(albumId: string, page = 1, limit = 30) => {
const { total, tracks } = await getTracksPage(albumId, page)
let albumName = ''
const list = tracks.slice(0, limit).map((item: any) => {
if (!albumName && item.albumTitle) albumName = String(item.albumTitle)
return {
songmid: String(item.trackId),
name: item.title || String(item.trackId),
singer: item.albumTitle || '',
albumId: String(albumId),
albumName: item.albumTitle || '',
interval: 0,
payPlay: Boolean(item.isPaid),
img: null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
}
})
return {
info: { name: albumName || String(albumId), total },
list,
total,
limit,
allPage: Math.ceil(total / PAGE_SIZE) || page,
source: 'xm',
}
}
export default { getTracksPage, getAlbumDetail }
+71
View File
@@ -0,0 +1,71 @@
// 喜马拉雅 www2 播放地址解码 —— 移植 Ximalaya-Downloader-Next adapters/decoder.py
// 算法:URL-safe Base64 → 尾 16 字节为 IV,正文先查 S-Box 置换表,
// 再按 16 字节块与 IV 异或、按 32 字节块与固定密钥异或,还原明文 URL。
// PC 桌面端(device=win)另有 AES-ECB 变体(密钥取自官方客户端 4.0.14 asar)。
import crypto from 'node:crypto'
// S-Box 置换表(www2/mweb2 设备类型)
const PERMUTATION_TABLE_O = [
183, 174, 108, 16, 131, 159, 250, 5, 239, 110, 193, 202, 153, 137, 251, 176,
119, 150, 47, 204, 97, 237, 1, 71, 177, 42, 88, 218, 166, 82, 87, 94,
14, 195, 69, 127, 215, 240, 225, 197, 238, 142, 123, 44, 219, 50, 190, 29,
181, 186, 169, 98, 139, 185, 152, 13, 141, 76, 6, 157, 200, 132, 182, 49,
20, 116, 136, 43, 155, 194, 101, 231, 162, 242, 151, 213, 53, 60, 26, 134,
211, 56, 28, 223, 107, 161, 199, 15, 229, 61, 96, 41, 66, 158, 254, 21,
165, 253, 103, 89, 3, 168, 40, 246, 81, 95, 58, 31, 172, 78, 99, 45,
148, 187, 222, 124, 55, 203, 235, 64, 68, 149, 180, 35, 113, 207, 118, 111,
91, 38, 247, 214, 7, 212, 209, 189, 241, 18, 115, 173, 25, 236, 121, 249,
75, 57, 216, 10, 175, 112, 234, 164, 70, 206, 198, 255, 140, 230, 12, 32,
83, 46, 245, 0, 62, 227, 72, 191, 156, 138, 248, 114, 220, 90, 84, 170,
128, 19, 24, 122, 146, 80, 39, 37, 8, 34, 22, 11, 93, 130, 63, 154,
244, 160, 144, 79, 23, 133, 92, 54, 102, 210, 65, 67, 27, 196, 201, 106,
143, 52, 74, 100, 217, 179, 48, 233, 126, 117, 184, 226, 85, 171, 167, 86,
2, 147, 17, 135, 228, 252, 105, 30, 192, 129, 178, 120, 36, 145, 51, 163,
77, 205, 73, 4, 188, 125, 232, 33, 243, 109, 224, 104, 208, 221, 59, 9,
]
const XOR_KEY_A = [
204, 53, 135, 197, 39, 73, 58, 160, 79, 24, 12, 83, 180, 250, 101, 60,
206, 30, 10, 227, 36, 95, 161, 16, 135, 150, 235, 116, 242, 116, 165, 171,
]
// PC 桌面端 playUrlList 的 AES-ECB 密钥(客户端 4.0.14 asar Gt 函数提取)
const WIN_AES_KEY = Buffer.from('aaad3e4fd540b0f79dca95606e72bf93', 'hex')
const b64urlToBuf = (s: string) => {
const cleaned = s.replace(/_/g, '/').replace(/-/g, '+')
return Buffer.from(cleaned + '='.repeat((4 - cleaned.length % 4) % 4), 'base64')
}
/** www2/mweb2 加密 URL → 明文 URL */
export const decodeWww2Url = (encrypted: string): string => {
if (!encrypted) return ''
if (encrypted.startsWith('http')) return encrypted
const decoded = b64urlToBuf(encrypted)
if (decoded.length < 16) throw new Error('密文长度不足(缺少 IV)')
const body = Uint8Array.from(decoded.subarray(0, decoded.length - 16))
const iv = decoded.subarray(decoded.length - 16)
for (let i = 0; i < body.length; i++) body[i] = PERMUTATION_TABLE_O[body[i]]
for (let i = 0; i < body.length; i += 16) {
const n = Math.min(16, body.length - i)
for (let j = 0; j < n; j++) body[i + j] ^= iv[j]
}
for (let i = 0; i < body.length; i += 32) {
const n = Math.min(32, body.length - i)
for (let j = 0; j < n; j++) body[i + j] ^= XOR_KEY_A[j]
}
return Buffer.from(body).toString('utf8')
}
/** PC 桌面端(device=win)AES-ECB 加密 URL → 明文 URL(解出的是带签名付费地址) */
export const decodeWinUrl = (encrypted: string): string => {
if (!encrypted) return ''
if (encrypted.startsWith('http')) return encrypted
const d = crypto.createDecipheriv('aes-128-ecb', WIN_AES_KEY, null)
const plain = Buffer.concat([d.update(b64urlToBuf(encrypted)), d.final()])
return plain.toString('utf8')
}
export default { decodeWww2Url, decodeWinUrl }
+329
View File
@@ -0,0 +1,329 @@
// 喜马拉雅 du_web_sdk 设备指纹模板(内置公共指纹,与 Ximalaya-Downloader-Next 的 device_info_default.json 同源)
// Zf5 时间戳每次上报前刷新;UA 字段 yV2 与请求 UA 保持一致
export const DEVICE_INFO = {
"Zf5": 0,
"GF9": "2.0.0",
"HW5": "t6pfoml9679z52kqw93uqu75eflqdg1bykhl",
"uS7": "",
"KFp": "h5_goyxvzyohd",
"ew1": {
"Wg7": "Mozilla",
"lV1": "Google Inc.",
"Xt4": "Netscape",
"yV2": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"KY1": "Win32",
"Le3": "Tmxhcm9vei81LjAgKERybXdsZGggTUcgMTAuMDsgRHJtNjQ7IGM2NCkgWmtrb3ZEdnlQcmcvNTM3LjM2IChQU0dOTywgb3JwdiBUdnhwbCkgWHNpbG52LzEyNS4wLjAuMCBIenV6aXIvNTM3LjM2",
"kH1": 900,
"ad5": 1440,
"Ua9": 24,
"TQ6": 900,
"kC7": 1440,
"me8": 24,
"eY9": true,
"Kn2": false,
"OM3": true,
"sw8": false,
"uW3": -1,
"iO8": "https://www.ximalaya.com/",
"By1": "www.ximalaya.com",
"Gv4": "/",
"ef2": "",
"tZ2": "https:",
"OG4": true,
"kx1": true,
"VD6": true,
"Ov6": false,
"lq3": "zh-CN",
"ef5": [
"zh-CN"
],
"OK3": 1,
"Fg5": true,
"qS2": [
1440,
900,
1440,
900
],
"Fc5": "light"
},
"HK3": {
"iI1": {
"NF1": -1,
"cA1": -1,
"NK5": -1,
"VP4": "-1.00",
"RX5": -1,
"VP6": -1,
"tJ4": -1
},
"ti4": {
"xm9": true,
"is3": 1
},
"AV9": 8,
"aK8": "Google Inc. (0x00001D17)",
"df6": "ANGLE (0x00001D17, ZX C-960 (0x00003A04) Direct3D11 vs_5_0 ps_5_0, D3D11)",
"WB9": "8ba05e37b4b3f57bddb4904eb8f40204",
"pD7": "d41d8cd98f00b204e9800998ecf8427e",
"da2": "124.04347527516074",
"dt2": 16,
"Sy6": -480,
"MS3": "Asia/Shanghai",
"pi9": false,
"Ao1": [],
"BH5": 0,
"UG4": [
"Arial",
"Arial Black",
"Arial Narrow",
"Calibri",
"Cambria",
"Cambria Math",
"Comic Sans MS",
"Consolas",
"Courier",
"Courier New",
"Georgia",
"Helvetica",
"Impact",
"Lucida Console",
"Lucida Sans Unicode",
"Microsoft Sans Serif",
"MS Gothic",
"MS PGothic",
"MS Sans Serif",
"MS Serif",
"Palatino Linotype",
"Segoe Print",
"Segoe Script",
"Segoe UI",
"Segoe UI Light",
"Segoe UI Semibold",
"Segoe UI Symbol",
"Tahoma",
"Times",
"Times New Roman",
"Trebuchet MS",
"Verdana",
"Wingdings"
]
},
"fc9": {
"cx4": "4g",
"zY8": -1,
"yj6": 1.5,
"dV4": 200,
"yX4": false
},
"adi": "070BF8:016D89:6BDE99:1600",
"acd": "D2t6yNNzRqtSbN4GtZw4eGLn8tfc0Y1EYzqzMOCd9GiGMX17",
"bdi": null,
"bcd": null,
"fd2": {
"Pf5": 1783179255284,
"Ja5": "070BF8:016D89:6BDE99:1600",
"xz7": "2ccd7426-e999-efba-78c5-bbea1fc69729",
"av1": "D2t6yNNzRqtSbN4GtZw4eGLn8tfc0Y1EYzqzMOCd9GiGMX17",
"cp9": 0
},
"exts": "",
"startime": 1783179258568,
"Zn6": {
"oe2": "0",
"EV9": "true",
"xu2": "1441A790908125E9682A828824A003E99783D166409BBC4DD782F461C2955BA7",
"CY8": "",
"nE4": 928,
"Tw1": [
5799.099999904633,
6727.399999856949
],
"Sb1": false
},
"jm9": 2,
"dla": "",
"swp": "",
"ecm": "",
"emm": "",
"asu": "",
"asu1": 0,
"GJ2": "31b33ada-785b-efc7-cbad-c985880ba125-fcs011",
"slw": "",
"bds": "",
"MT7": "33-00000-0000-1111111-000000-0011-000000-0000-00000-0",
"bnd": "-0",
"kec": "000000",
"BG5": false,
"Fd8": "1",
"iq7": false,
"DP5": "1441A7909C087DBBE7CE59881B9DF8B9",
"lL1": "63649ded5da25cce1ae7d5f2a950c18a",
"uT8": "-1",
"sV5": 2,
"Vo6": "",
"infoF": {
"lof": false,
"baf": false,
"auf": false,
"iif": false
},
"infoCallback": null,
"url_host": "hdaa.shuzilm.cn",
"_cidStorage": {
"_key": "cmci9xde",
"_value": "U2FsdGVkX19G9YbXMDClJDhN2IT4GtXDHuuLUlzrmuZDqisRyfuqlB9Im8cJ3oWqqi8rKpuwj6i0PFh2Ym6gmg==",
"_saveObject": true
},
"_pkgStorage": {
"_key": "pmck9xge",
"_value": "U2FsdGVkX19GQAJ5RgJtjxT5ljFskRYgi6bFYacB4yM=",
"_saveObject": true
},
"_checkextensions": {
"_deviceInfoCollector": {
"Zf5": -1,
"GF9": "2.0.0",
"HW5": "",
"uS7": "",
"KFp": "",
"ew1": {
"Wg7": "",
"lV1": "",
"Xt4": "",
"yV2": "",
"KY1": "",
"Le3": "",
"kH1": -1,
"ad5": -1,
"Ua9": -1,
"TQ6": -1,
"kC7": -1,
"me8": -1,
"eY9": null,
"OM3": null,
"Kn2": null,
"OG4": null,
"kx1": null,
"VD6": null,
"Ov6": null,
"sw8": null,
"uW3": -1,
"lq3": "",
"ef5": "",
"iO8": "",
"By1": "",
"Gv4": "",
"ef2": "",
"tZ2": "",
"OK3": -1,
"Fg5": false,
"qS2": [],
"Fc5": ""
},
"HK3": {
"iI1": {
"NF1": -1,
"cA1": -1,
"NK5": -1,
"VP4": -1,
"RX5": -1,
"VP6": -1,
"tJ4": -1
},
"ti4": {
"xm9": null,
"is3": -1
},
"AV9": -1,
"aK8": "",
"df6": "",
"WB9": "",
"pD7": "",
"da2": -1,
"dt2": -1,
"Sy6": -1,
"MS3": "",
"pi9": null,
"Ao1": [],
"BH5": -1,
"UG4": []
},
"fc9": {
"cx4": null,
"ui9": null,
"zY8": -1,
"yj6": -1,
"dV4": -1,
"eN7": "",
"yX4": false
},
"adi": "",
"acd": "",
"bdi": "",
"bcd": "",
"fd2": {},
"exts": "",
"startime": 0,
"Zn6": {
"oe2": "",
"EV9": "",
"xu2": "",
"CY8": "",
"nE4": -1,
"Tw1": [],
"Sb1": false
},
"jm9": "",
"dla": "",
"swp": "",
"ecm": "",
"emm": "",
"asu": "",
"asu1": 0,
"GJ2": "",
"slw": "",
"bds": "",
"MT7": "",
"bnd": "",
"kec": "",
"BG5": "",
"Fd8": "0",
"iq7": false,
"DP5": "",
"lL1": "",
"uT8": "-1",
"sV5": 1,
"Vo6": "",
"infoF": {
"lof": false,
"baf": false,
"auf": false,
"iif": false
},
"infoCallback": null,
"url_host": "hdaa.shuzilm.cn"
}
},
"_idstor": {
"_value": null
},
"_caddStorage": {
"_key": "vmce9xdq",
"_value": null,
"_saveObject": true
},
"_ipfStorage": {
"_key": "assva6",
"_value": "U2FsdGVkX18v3Du2ZHJsNvvtzU+I7D8WBNBlFpRxpNw=",
"_saveObject": true
},
"_ipflagStorage": {
"_key": "assva5",
"_value": "U2FsdGVkX1+PrpJkbR2dIWLeY/i1/2dGcYLQLh4eDtHqVRt/LkERQlZiSdem69zrRZOIHhv7pHPh3xL4SOxCsw==",
"_saveObject": true
},
"_checkintacts": {},
"_checkdetects": {},
"_getmousetest": {}
} as Record<string, any>
+34
View File
@@ -0,0 +1,34 @@
// 喜马拉雅(有声书/播客)平台模块
// 匿名可用面(2026-10-02 实测):搜索 / 专辑曲目 / 免费曲目取流(PC baseInfo 通道 + 解密直链)/ 封面。
// 付费与 VIP 内容:绑定登录 Cookie(POST /login/xm,须含 1&_token)后 baseInfo 授权放行;
// 超清(XimiUhq)需会员。有声内容不参与聚合搜索与跨平台匹配(sdk/index.ts 标记 noAggregate),
// 仅按 source=xm 直查。
import xmApi from '../apis/xm.ts'
import musicSearch from './musicSearch.ts'
import { getTrackInfo, coverUrl } from './musicInfo.ts'
import { getAlbumDetail } from './album.ts'
import { getCredentials } from '../auth/store.ts'
const xm = {
musicSearch,
getMusicUrl(songInfo, type) {
return xmApi.getMusicUrl(songInfo, type)
},
getPic(songInfo) {
return getTrackInfo(songInfo.songmid).then(coverUrl)
},
album: { getAlbumDetail },
getMusicDetailPageUrl(songInfo) {
return `https://www.ximalaya.com/sound/${songInfo.songmid}`
},
}
/** 登录状态(凭据存在性判断,无在线校验接口) */
export const status = () => {
const cred = getCredentials('xm') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie
? { logged: true, updatedAt: cred.updatedAt }
: { logged: false }
}
export default xm
+64
View File
@@ -0,0 +1,64 @@
// 喜马拉雅曲目信息(PC 桌面端 baseInfo 通道)
// 接口: https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/{ms_ts}?device=win&trackId=&trackQualityLevel=1
// 实测(2026-10-02)匿名免费曲目可用,但必须同时满足:
// 1) 携带 PC 设备 Cookie(install_id 与 1&_device 同一 uuid,channel=99&100001,客户端 4.0.14 抓包格式)
// 2) 携带 du_web_sdk 真实 xm-sign(hdaa 上报取 cadd/sid)
// 缺 Cookie 或换 device=www2 均返回 ret=1001 系统繁忙。
// 响应 trackInfo 顶层挂载(playUrlList/title/cover*/duration/isPaid/isAuthorized/isXimiUhqTrack),
// playUrlList[].url 为 AES-ECB 加密地址(key 取自官方客户端 4.0.14 asar),见 decoder.ts
import crypto from 'node:crypto'
import { httpFetch } from '../request.ts'
import { makeXmSign, XM_UA } from './sign.ts'
import { getCredentials } from '../auth/store.ts'
const BASE_INFO_URL = 'https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/'
// 进程级设备标识(install_id 与 1&_device 共用同一 uuid,稳定复用)
const deviceUuid = crypto.randomUUID()
const PC_DEVICE_COOKIE = `install_id=${deviceUuid}; channel=99&100001; 1&_device=win32&${deviceUuid}&4.0.14`
/** 登录态 Cookie(/login/xm 绑定,须含 1&_token);与 PC 设备 Cookie 合并后上送 */
export const buildRequestCookie = (): string => {
const loginCookie = (getCredentials('xm') as any)?.cookie || ''
return loginCookie ? `${loginCookie}; ${PC_DEVICE_COOKIE}` : PC_DEVICE_COOKIE
}
export const coverUrl = (trackInfo: any): string | null => {
const path = trackInfo?.coverMiddle || trackInfo?.coverLarge || trackInfo?.coverSmall
if (!path) return null
const s = String(path)
return s.startsWith('http') ? s : `https://imagev2.xmcdn.com/${s}`
}
/** 拉取曲目播放信息(免登免费曲可用;付费/VIP 曲 ret=1001 或 isAuthorized=false) */
export const getTrackInfo = async(trackId: string, trackQualityLevel = 1): Promise<any> => {
const xmSign = await makeXmSign()
const { statusCode, body } = await httpFetch(
`${BASE_INFO_URL}${Date.now()}?device=win&trackId=${trackId}&trackQualityLevel=${trackQualityLevel}`,
{
headers: {
'User-Agent': XM_UA,
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
Referer: `https://www.ximalaya.com/sound/${trackId}`,
Origin: 'https://www.ximalaya.com',
Cookie: buildRequestCookie(),
'xm-sign': xmSign,
},
timeout: 20000,
},
).promise as any
if (statusCode != 200) throw new Error(`baseInfo HTTP ${statusCode}`)
const b = body ?? {}
if (b.ret != 0) {
// 1001:匿名访问付费内容与风控共用;未登录时优先提示绑定登录
const logged = Boolean((getCredentials('xm') as any)?.cookie)
if (b.ret == 1001 && !logged) throw new Error('该内容需要登录(POST /login/xm 绑定 cookie 后重试)')
throw new Error(b.msg || `baseInfo ret ${b.ret}`)
}
const trackInfo = b.trackInfo ?? b.data?.trackInfo
if (!trackInfo) throw new Error('baseInfo 未返回 trackInfo')
return trackInfo
}
export default { getTrackInfo, coverUrl }
+56
View File
@@ -0,0 +1,56 @@
// 喜马拉雅搜索(web 通道)
// 接口: https://www.ximalaya.com/revision/search?core=track&kw=&page=&rows=&spellchecker=true&condition=relation&device=web
// 实测(2026-10-02)匿名可用(注意:同参数的 /revision/search/main 与 /search/main 会命中
// risk invalid 风控,本端点 + condition=relation&device=web 不受影响)
// 返回 data.result.response.docs[],字段 id/title/duration(秒)/nickname/album_id/album_title/
// album_cover_path/is_paid/is_trailer
import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.ts'
import { XM_UA } from './sign.ts'
const SEARCH_URL = 'https://www.ximalaya.com/revision/search'
const normalizeCover = (path: unknown): string | null => {
if (!path) return null
const s = String(path)
return s.startsWith('http') ? s : `https://imagev2.xmcdn.com/${s}`
}
export const search = (keyword: string, page = 1, limit = 20) => {
const params = new URLSearchParams({
core: 'track', kw: keyword, page: String(page), rows: String(limit),
spellchecker: 'true', condition: 'relation', device: 'web',
})
return httpFetch(`${SEARCH_URL}?${params.toString()}`, {
headers: { 'User-Agent': XM_UA, Accept: 'application/json, text/plain, */*', Referer: 'https://www.ximalaya.com/' },
timeout: 15000,
}).promise.then(({ statusCode, body }: any) => {
if (statusCode != 200 || body?.ret != 200) throw new Error(body?.msg || '搜索失败')
const docs = body?.data?.result?.response?.docs ?? []
const total = Number(body?.data?.result?.response?.numFound ?? docs.length)
const allPage = Math.ceil(total / limit) || page
return {
allPage,
total,
limit,
source: 'xm' as const,
list: docs.map((item: any) => ({
songmid: String(item.id),
name: item.title || '',
singer: item.nickname || '',
albumId: item.album_id ? String(item.album_id) : '',
albumName: item.album_title || '',
interval: item.duration ? formatPlayTime(Number(item.duration)) : 0,
payPlay: Boolean(item.is_paid),
img: normalizeCover(item.album_cover_path),
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
}
})
}
export default { search }
+53
View File
@@ -0,0 +1,53 @@
// 喜马拉雅 xm-sign 生成 —— 移植 Ximalaya-Downloader-Next / easy-sign 的 du_web_sdk 签名链
// 协议:device_info → JSON → URL 编码字节 → zlib(level 6) → AES-128-ECB(PKCS7, KEY) →
// POST https://hdaa.shuzilm.cn/report?v=1.2.0&e=1&c=1&r=<uuid>(octet-stream)→
// Base64 响应 → AES-ECB 解密 → JSON 取 cadd/sid → xm-sign = "{cadd}&&{sid}"
// KEY 为 du_web_sdk _getDeviceKey(0) 的硬编码密钥;每次签名都重新上报(不缓存)。
// 模板为纯 ASCII(Base64/十六进制字段),latin1 取字节与参考实现的 charCode 语义一致。
import zlib from 'node:zlib'
import crypto from 'node:crypto'
import { httpFetch } from '../request.ts'
import { DEVICE_INFO } from './deviceInfo.ts'
const SIGN_KEY = 'm9ZtRrz:qujT8@da'
const REPORT_URL = 'https://hdaa.shuzilm.cn/report?v=1.2.0&e=1&c=1&r='
// 与 device_info.ew1.yV2(Chrome 125 模板)保持一致
export const XM_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
const aesEcb = (data: Buffer, decrypt: boolean) => {
const key = Buffer.from(SIGN_KEY, 'utf8')
const cipher = decrypt
? crypto.createDecipheriv('aes-128-ecb', key, null)
: crypto.createCipheriv('aes-128-ecb', key, null)
return Buffer.concat([cipher.update(data), cipher.final()])
}
/** 生成 xm-sign(形如 "cadd&&sid");每次调用重新上报设备指纹 */
export const makeXmSign = async(): Promise<string> => {
const info = { ...DEVICE_INFO, Zf5: Date.now() }
const compressed = zlib.deflateSync(Buffer.from(JSON.stringify(info), 'latin1'), { level: 6 })
const payload = aesEcb(compressed, false)
const { statusCode, body, raw } = await httpFetch(`${REPORT_URL}${crypto.randomUUID()}`, {
method: 'post',
headers: {
'Content-Type': 'application/octet-stream',
'User-Agent': XM_UA,
Host: 'hdaa.shuzilm.cn',
},
body: payload,
timeout: 15000,
}).promise as any
const respText = raw ? raw.toString('utf8') : String(body ?? '')
if (statusCode != 200) throw new Error(`xm-sign 上报失败: HTTP ${statusCode}`)
const decrypted = aesEcb(Buffer.from(respText, 'base64'), true).toString('utf8')
const obj = JSON.parse(decrypted)
const cadd = String(obj.cadd || '')
const sid = String(obj.sid || '')
if (!cadd || !sid) throw new Error('xm-sign 上报响应缺少 cadd/sid')
return `${cadd}&&${sid}`
}
export default { makeXmSign, XM_UA }
+120 -1
View File
@@ -5,6 +5,9 @@ import * as wyAuth from '../sdk/auth/wy.ts'
import * as kgAuth from '../sdk/auth/kg.ts' import * as kgAuth from '../sdk/auth/kg.ts'
import * as txAuth from '../sdk/auth/tx.ts' import * as txAuth from '../sdk/auth/tx.ts'
import * as qsPassport from '../sdk/qs/passport.ts' import * as qsPassport from '../sdk/qs/passport.ts'
import { status as xmStatus } from '../sdk/xm/index.ts'
import { status as meStatus } from '../sdk/me/index.ts'
import * as lrAuth from '../sdk/lr/auth.ts'
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts' import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
import { refreshAll } from './refresh.ts' import { refreshAll } from './refresh.ts'
@@ -159,6 +162,59 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/kg/password', {
schema: {
summary: '酷狗账号密码登录',
description: '触发 20028 风控时返回 needVerify 挑战(含 eventid、验证类型与 sid/edt 指纹):' +
'滑块类需浏览器完成图形验证(返回的 url/txappid 供前端加载腾讯验证码)后调 /login/kg/verify,' +
'再重试本接口;短信类(v_type=32)将收到的短信码先提交 /login/kg/verify,随后带 code 重试本接口完成登录。' +
'风控较严,频繁失败建议改用扫码或短信登录。',
tags: ['登录'],
body: {
type: 'object',
properties: {
username: { type: 'string' },
password: { type: 'string' },
code: { type: 'string', description: '短信验证码(20028 短信验证通过后重试登录时携带)' },
},
required: ['username', 'password'],
},
},
}, async(request, reply) => {
try {
return await kgAuth.passwordLogin(request.body)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/kg/verify', {
schema: {
summary: '提交酷狗风控验证结果',
description: '配合 /login/kg/password 的 needVerify 挑战使用:verifycode 为图形验证码凭证' +
'(格式 KGCodeXX|{ticket,randstr,txappid})或短信验证码。sid/edt 省略时服务端自动生成行为指纹。' +
'验证通过后按挑战类型重试登录(短信码带 code 重试 /login/kg/password)。',
tags: ['登录'],
body: {
type: 'object',
properties: {
eventid: { type: 'string' },
v_type: { type: 'integer', description: '验证类型(needVerify 返回的 v_type)' },
verifycode: { type: 'string' },
sid: { type: 'string' },
edt: { type: 'string' },
},
required: ['eventid', 'v_type', 'verifycode'],
},
},
}, async(request, reply) => {
try {
return await kgAuth.verifyUserInfo(request.body)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/kg', { fastify.delete('/login/kg', {
schema: { summary: '退出酷狗登录', tags: ['登录'] }, schema: { summary: '退出酷狗登录', tags: ['登录'] },
}, async() => { }, async() => {
@@ -266,6 +322,66 @@ export const loginRoutes = async(fastify) => {
return { logged: false } return { logged: false }
}) })
// ---------- 喜马拉雅 ----------
fastify.post('/login/xm', {
schema: {
summary: '绑定喜马拉雅 cookie',
description: 'cookie 从已登录的 www.ximalaya.com 浏览器请求头复制(须含 1&_token 字段),' +
'用于解锁 xm 付费/已购内容取流。无法自动续期,失效后需重新复制绑定。',
tags: ['登录'],
body: {
type: 'object',
properties: { cookie: { type: 'string' } },
required: ['cookie'],
},
},
}, async(request, reply) => {
const cookie = String(request.body.cookie || '')
if (!/1&_token=\S+/.test(cookie)) {
return reply.code(422).send({ code: 422, msg: 'cookie 需包含 1&_token 字段' })
}
setCredentials('xm', { cookie })
return { logged: true }
})
fastify.delete('/login/xm', {
schema: { summary: '退出喜马拉雅登录', tags: ['登录'] },
}, async() => {
clearCredentials('xm')
return { logged: false }
})
// ---------- 猫耳FM ----------
fastify.post('/login/me', {
schema: {
summary: '绑定猫耳FM token',
description: 'cookie 从已登录的 m.missevan.com 浏览器请求头或猫耳 App 抓包复制(须含 token= 字段),' +
'用于解锁 me 付费声音取流(App API 签名通道会自动携带)。无法自动续期,失效后需重新绑定。',
tags: ['登录'],
body: {
type: 'object',
properties: { cookie: { type: 'string' } },
required: ['cookie'],
},
},
}, async(request, reply) => {
const cookie = String(request.body.cookie || '')
if (!/(?:^|;\s*)token=\S+/.test(cookie)) {
return reply.code(422).send({ code: 422, msg: 'cookie 需包含 token 字段' })
}
setCredentials('me', { cookie })
return { logged: true }
})
fastify.delete('/login/me', {
schema: { summary: '退出猫耳FM登录', tags: ['登录'] },
}, async() => {
clearCredentials('me')
return { logged: false }
})
// ---------- 通用 ---------- // ---------- 通用 ----------
fastify.post('/login/refresh', { fastify.post('/login/refresh', {
@@ -280,7 +396,7 @@ export const loginRoutes = async(fastify) => {
fastify.get('/login/status', { fastify.get('/login/status', {
schema: { schema: {
summary: '查看登录状态', summary: '查看登录状态',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间)。', description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间),qs/xm/me/lr 为已存凭据。',
tags: ['登录'], tags: ['登录'],
}, },
}, async() => { }, async() => {
@@ -293,6 +409,9 @@ export const loginRoutes = async(fastify) => {
kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false }, kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false },
tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false }, tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false },
qs: qsPassport.status(), qs: qsPassport.status(),
xm: xmStatus(),
me: meStatus(),
lr: lrAuth.status(),
} }
}) })
} }
+5 -5
View File
@@ -6,7 +6,7 @@
import { notFound, upstream } from './http/api-error.ts' import { notFound, upstream } from './http/api-error.ts'
import { getMusicInfo } from '../sdk/kg/musicInfo.ts' import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit'] const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit']
/** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */ /** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */
const unwrap = (result: any): any => ( const unwrap = (result: any): any => (
@@ -65,7 +65,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/search', { fastify.get('/search', {
schema: { schema: {
summary: '搜索歌曲', summary: '搜索歌曲',
description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。', description: 'source 省略时聚合全部平台(排除 xm/me/qt/lr 有声平台)搜索,失败的平台会被跳过。',
tags: ['搜索'], tags: ['搜索'],
querystring: qs({ querystring: qs({
name: { type: 'string', description: '歌名', example: '稻香' }, name: { type: 'string', description: '歌名', example: '稻香' },
@@ -94,7 +94,7 @@ export const musicRoutes = async(fastify: any) => {
const lists = await Promise.all( const lists = await Promise.all(
musicSdk.sources musicSdk.sources
.filter(s => musicSdk[s.id]?.musicSearch) .filter(s => musicSdk[s.id]?.musicSearch && !(s as any).noAggregate)
.map(s => doSearch(s.id).catch(() => [])), .map(s => doSearch(s.id).catch(() => [])),
) )
return { source: 'all', list: lists.flat(), page, limit } return { source: 'all', list: lists.flat(), page, limit }
@@ -188,7 +188,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/url/:source/:id', { fastify.get('/url/:source/:id', {
schema: { schema: {
summary: '获取播放地址', summary: '获取播放地址',
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs 用 track id。' + description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs/xm/me 用声音 id,qt 为 <channelId>:<programId>(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。' +
'取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' + '取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' +
'酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' + '酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' +
'其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。', '其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。',
@@ -408,7 +408,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/album/:source/:id', { fastify.get('/album/:source/:id', {
schema: { schema: {
summary: '获取专辑详情与曲目列表', summary: '获取专辑详情与曲目列表',
description: '支持 kg/kw/mg(见 /sources 的 album 能力位)。kw 单页固定 1000 条、limit 不生效。', description: '支持 kg/kw/tx/xm/me(见 /sources 的 album 能力位)。kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。',
tags: ['歌手专辑'], tags: ['歌手专辑'],
params: params({ source: pSource('album'), id: { type: 'string', description: '平台专辑 id(搜索结果中的 albumId)' } }), params: params({ source: pSource('album'), id: { type: 'string', description: '平台专辑 id(搜索结果中的 albumId)' } }),
querystring: qs({ page: pPage, limit: pLimit }), querystring: qs({ page: pPage, limit: pLimit }),
+124
View File
@@ -194,6 +194,76 @@ await test('URL qs 试听 128k', async() => {
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
}) })
// xm 匿名免费曲直链(12096669 = 苏荷酒吧我爱的人,免费,2026-10-02 实测可用)
await test('URL xm 免费曲', async() => {
const r = await get('/url/xm/12096669?quality=128k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url || !/xmcdn\.com/.test(r.data.url)) throw new Error('非 xmcdn 直链')
return `${r.data.type} ${r.data.url.slice(0, 60)}`
})
// me 匿名免费声音明文直链(1557130 = 穿堂惊掠琵琶声·总预告,App API 签名通道,2026-10-02 实测可用)
await test('URL me 免费声音', async() => {
const r = await get('/url/me/1557130?quality=128k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url || !/maoercdn\.com/.test(r.data.url)) throw new Error('非 maoercdn 直链')
return `${r.data.type} ${r.data.url.slice(0, 60)}`
})
// qt 匿名节目直链(304214:12302057 = 诡吹灯续·片花,audiostream 签名通道,2026-10-02 实测可用)
await test('URL qt 免费节目', async() => {
const r = await get('/url/qt/304214:12302057?quality=64k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url || !/audiostream\/redirect/.test(r.data.url)) throw new Error('非 audiostream 签名直链')
return `${r.data.type} ${r.data.url.slice(0, 70)}`
})
// qt/lr 直查 + 聚合排除
await test('搜索 qt 直查', async() => {
const r = await get(`/search?source=qt&name=${encodeURIComponent('郭德纲')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
await test('搜索 lr 直查', async() => {
const r = await get(`/search?source=lr&name=${encodeURIComponent('三体')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
// lr 未登录取流应返回明确错误(502 上游)
await test('URL lr 未登录 → 明确错误', async() => {
const r = await getRaw('/url/lr/50678018:1?quality=64k')
if (r.status !== 502) throw new Error(`期望 502,实际 ${r.status}`)
if (!JSON.parse(r.body).msg.includes('/login/lr')) throw new Error(`错误信息应引导登录,实际 ${JSON.parse(r.body).msg}`)
return JSON.parse(r.body).msg.slice(0, 40)
})
// me 有声内容:直查可用、聚合排除
await test('搜索 me 直查', async() => {
const r = await get(`/search?source=me&name=${encodeURIComponent('广播剧')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
// xm 有声内容:直查可用、聚合排除
await test('搜索 xm 直查', async() => {
const r = await get(`/search?source=xm&name=${encodeURIComponent('郭德纲')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
await test('聚合搜索排除 xm', async() => {
const r = await get(`/search?name=${encodeURIComponent('郭德纲')}&limit=2`)
if (r.code != 0) throw new Error(r.msg)
if (r.data?.list?.some((item: any) => ['xm', 'me', 'qt', 'lr'].includes(item.source))) throw new Error('聚合结果不应包含有声平台')
return 'ok'
})
// 取流链兜底:平台直连失败后按 name/singer 匹配酷我(via=kw 标记实际命中通道) // 取流链兜底:平台直连失败后按 name/singer 匹配酷我(via=kw 标记实际命中通道)
await test('URL wy 坏 id → 酷我兜底(via=kw)', async() => { await test('URL wy 坏 id → 酷我兜底(via=kw)', async() => {
const r = await get('/url/wy/not-exist-id?quality=128k&name=晴天&singer=周杰伦&interval=04:29') const r = await get('/url/wy/not-exist-id?quality=128k&name=晴天&singer=周杰伦&interval=04:29')
@@ -357,6 +427,38 @@ await test('专辑详情 /album/tx', async() => {
return `${r.data.info.name}(${r.data.list.length} 首)` return `${r.data.info.name}(${r.data.list.length} 首)`
}) })
// xm 专辑(3595841 = 苏荷酒吧专辑,2026-10-02 实测可用)
await test('专辑详情 /album/xm', async() => {
const r = await get('/album/xm/3595841')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.info.total} 集)`
})
// me 剧集(25276 = 穿堂惊掠琵琶声,2026-10-02 实测可用)
await test('专辑详情 /album/me', async() => {
const r = await get('/album/me/25276')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.info.total} 集)`
})
// qt 频道(304214 = 诡吹灯续,2026-10-02 实测可用)
await test('专辑详情 /album/qt', async() => {
const r = await get('/album/qt/304214')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length || !String(r.data.list[0].songmid).includes(':')) throw new Error('响应不完整或缺少复合 id')
return `${r.data.list.length} 节目,首条 ${r.data.list[0].name}`
})
// lr 书籍章节(50678018 = 三体广播剧,2026-10-02 实测可用)
await test('专辑详情 /album/lr', async() => {
const r = await get('/album/lr/50678018')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length || !String(r.data.list[0].songmid).includes(':')) throw new Error('响应不完整或缺少复合 id')
return `${r.data.info.total} 节`
})
await test('参数校验 422(缺少 name)', async() => { await test('参数校验 422(缺少 name)', async() => {
const r = await getRaw('/search') const r = await getRaw('/search')
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
@@ -473,6 +575,28 @@ await test('kg 假 token 拒绝 POST /login/kg', async() => {
return r.body?.msg return r.body?.msg
}) })
// xm cookie 绑定校验(缺 1&_token 拒绝)
await test('xm 坏 cookie 拒绝 POST /login/xm', async() => {
const r = await postJson('/login/xm', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
// me cookie 绑定校验(缺 token 拒绝)
await test('me 坏 cookie 拒绝 POST /login/me', async() => {
const r = await postJson('/login/me', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
// xm 登录状态展示
await test('登录状态含 xm 字段', async() => {
const r = await get('/login/status')
if (r.code != 0) throw new Error(r.msg)
if (!('xm' in (r.data ?? {})) || !('logged' in r.data.xm)) throw new Error('xm 登录状态缺失')
return `xm.logged=${r.data.xm.logged}`
})
// 汇总 // 汇总
const passed = results.filter(r => r.ok).length const passed = results.filter(r => r.ok).length
const failed = results.filter(r => !r.ok) const failed = results.filter(r => !r.ok)