Compare commits

..
2 Commits
Author SHA1 Message Date
snowy-yang e796e622f2 docs(readme): 补齐 qs 平台与双通道后的过期内容
- 平台列表/接口表补 qs(抖音扫码登录、/stream/qs 解密流代理)与取流链说明
- 歌曲 ID 约定补 qs,删掉已失效的 kg 风控告示(2026-10-01 /v5/url 已修复,
  VIP 歌改为需登录的准确表述)
- 外部音源章节说明候选链只覆盖 kw/kg/tx/wy、失败回落 SDK 与酷我兜底
- 已知限制重写:kg 登录取流、kw 严格档位校验与降级链、qs 试听/解密代理
- 项目结构注释补 qs 目录与 auth 续期描述
2026-10-01 22:54:05 +08:00
snowy-yang b3e1cc5f67 feat(url): 取流链兜底——外部音源→SDK 直连→酷我跨平台匹配
/url/:source/:id 顺序尝试:外部音源脚本(SOURCE_ENABLED 启用时)→
SDK 官方直连→都失败时用 findMusic 仅在 kw 内按歌名/歌手/时长匹配
同一首歌后走 kw 通道(匿名可拿无损)。kg 的 hash 自动反查歌曲信息,
其它平台可传 name/singer/interval 查询参数启用兜底;
kw 自身不再二次兜底。响应新增 via 字段标记实际命中通道
(script/平台 id/kw,直接命中不返回),type 改为实际音质(降级链生效);
全部失败返回聚合各阶段错误的 502。findMusic/searchMusic 增加 only 参数。
2026-10-01 22:54:00 +08:00
5 changed files with 106 additions and 38 deletions

No files matched your search

+18 -12
View File
@@ -4,7 +4,7 @@
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy) - **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单 - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单
@@ -28,7 +28,8 @@ 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 省略则聚合所有平台) |
| `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) | | `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
| `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` | 获取封面 |
| `GET /boards/:source` | 榜单列表 | | `GET /boards/:source` | 榜单列表 |
@@ -46,6 +47,7 @@ pnpm typecheck # 仅类型检查,不运行
| `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** 扫码,登录后解锁完整曲目与无损取流) |
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
@@ -58,11 +60,14 @@ pnpm typecheck # 仅类型检查,不运行
## 歌曲 ID 约定 ## 歌曲 ID 约定
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
- **kg**:`hash`(搜索结果的 `hash` 字段;当前环境 kg 取流接口被服务端限制,URL 获取可能失败) - **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
- **qs**:track id(搜索结果的 `songmid` 字段);匿名仅 30s 试听,`/login/qs/qr` 登录后走解密流代理取完整曲目
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
## 外部音源 ## 外部音源
设置 `SOURCE_ENABLED=1` 后取流切换到外部音源(默认未设置时走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。 设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。
**社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。 **社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。
@@ -89,8 +94,8 @@ 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) routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/token、tx 扫码/cookie、qs 抖音扫码)
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流重定向到 sources/ 脚本) 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 进程内直载)
http/ http/
@@ -98,15 +103,15 @@ src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源
openapi/ openapi/
components.ts OpenAPI 共享 schema(与路由校验同源) components.ts OpenAPI 共享 schema(与路由校验同源)
ui.ts ReDoc 页面模板 ui.ts ReDoc 页面模板
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy 四平台) sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy/qs 五平台)
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts) index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy) apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy/qs)
auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token) auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token/续期、tx.ts 扫码/cookie/续期)
common/ SDK 基础设施 common/ SDK 基础设施
request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压) request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等) utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
message.ts 请求错误消息 message.ts 请求错误消息
kg/ kw/ tx/ wy/ 平台模块(搜索/歌词/榜单/歌单等 + 各自的歌词解码器) kg/ kw/ tx/ wy/ qs/ 平台模块(搜索/歌词/榜单/歌单等;qs 含字节系参数签名与音频解密 crypto/)
test/ test/
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口) smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
source-simple-test.ts 简单 API 音源脚本加载器测试(离线) source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
@@ -116,6 +121,7 @@ test/
## 已知限制 ## 已知限制
- **kg 取流**:酷狗当前环境下 `wwwapi`/`trackercdn`/`m.kugou.com` 等取流接口均被服务端风控(err_code 30020/20010),播放地址获取可能失败;歌词、搜索、榜单正常。 - **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
- **kw flac**:`antiserver` 接口对 flac 返回 `refuse`,高音质会回退官方接口(需要 csrf token,token 失效时自动重试)。 - **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 抖音扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。 - 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
+7 -8
View File
@@ -86,19 +86,17 @@ interface MatchCandidate extends SongInfo {
* @param params.name 歌名 * @param params.name 歌名
* @param params.singer 歌手 * @param params.singer 歌手
* @param params.excludeSource 指定平台时,聚合结果会跳过该平台 * @param params.excludeSource 指定平台时,聚合结果会跳过该平台
* @param params.only 只搜该平台(与 excludeSource 互斥,优先生效)
* @param params.limit 单平台结果条数 * @param params.limit 单平台结果条数
*/ */
export const searchMusic = async ( export const searchMusic = async (
{ name, singer, source: excludeSource, limit = 25 }: { name: string; singer?: string; source?: string; limit?: number }, { name, singer, source: excludeSource, only, limit = 25 }: { name: string; singer?: string; source?: string; only?: string; limit?: number },
): Promise<Array<SearchResult | null>> => { ): Promise<Array<SearchResult | null>> => {
const musicName = trimStr(name) const musicName = trimStr(name)
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 ( if (!source.module.musicSearch) continue
source.id === excludeSource || if (only ? source.id !== only : source.id === excludeSource) continue
!source.module.musicSearch
)
continue
tasks.push( tasks.push(
source.module.musicSearch source.module.musicSearch
.search(`${musicName} ${singer || ''}`.trim(), 1, limit) .search(`${musicName} ${singer || ''}`.trim(), 1, limit)
@@ -120,11 +118,12 @@ const pickMatching = <T>(items: T[], predicate: (item: T) => boolean): { matched
/** /**
* 跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序) * 跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序)
* @param params.only 只在该平台内匹配(跨平台兜底取流用)
*/ */
export const findMusic = async ( export const findMusic = async (
{ name, singer, albumName, interval, source: s }: { name: string; singer?: string; albumName?: string; interval?: string; source?: string }, { name, singer, albumName, interval, source: s, only }: { name: string; singer?: string; albumName?: string; interval?: string; source?: string; only?: string },
): Promise<MatchCandidate[]> => { ): Promise<MatchCandidate[]> => {
const lists = await searchMusic({ name, singer, source: s, limit: 25 }) const lists = await searchMusic({ name, singer, source: s, only, limit: 25 })
const fMusicName = filterStr(name).toLowerCase() const fMusicName = filterStr(name).toLowerCase()
const fSinger = filterStr(sortSingle(singer)).toLowerCase() const fSinger = filterStr(sortSingle(singer)).toLowerCase()
+2 -1
View File
@@ -67,8 +67,9 @@ export const COMPONENT_SCHEMAS = {
MusicUrlResult: { MusicUrlResult: {
type: 'object', type: 'object',
properties: { properties: {
type: { type: 'string', example: '320k' }, type: { type: 'string', example: '320k', description: '实际命中的音质(降级链可能低于请求音质)' },
url: { type: 'string', description: '播放地址' }, url: { type: 'string', description: '播放地址' },
via: { type: 'string', example: 'kw', description: '实际命中通道:script=外部音源脚本 / 平台 id / kw=酷我兜底;直接命中时不返回该字段' },
}, },
additionalProperties: true, additionalProperties: true,
}, },
+60 -17
View File
@@ -4,6 +4,7 @@
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码 // 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
import { notFound, upstream } from './http/api-error.ts' import { notFound, upstream } from './http/api-error.ts'
import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit'] const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
@@ -128,39 +129,81 @@ export const musicRoutes = async(fastify: any) => {
const urlHandler = async(request) => { const urlHandler = async(request) => {
const { source, id } = request.params const { source, id } = request.params
const { quality, albumId } = request.query const { quality, albumId, name, singer, interval } = request.query
const m = getSource(musicSdk, source, 'getMusicUrl') const m = getSource(musicSdk, source, 'getMusicUrl')
// 音源模式下服务端把取流重定向到外部音源脚本;未挂载时走 SDK 官方直连接口
const impl = fastify.sourceApi?.[source] ?? m
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} } const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
let result
try { // 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配)
result = unwrap(impl.getMusicUrl(songInfo, quality)) const finish = (res: any, via: string) => {
} catch (err: any) { if (!res?.url) throw new Error('获取播放地址失败')
throw upstream(err.message)
}
return result.then(res => {
if (!res?.url) throw upstream('获取播放地址失败')
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器) // qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器)
const url = String(res.url).startsWith('/') const url = String(res.url).startsWith('/')
? `${request.protocol}://${request.headers.host}${res.url}` ? `${request.protocol}://${request.headers.host}${res.url}`
: res.url : res.url
return { type: quality, url } // type 用实际音质(kw 等通道可能降级);via 标记兜底链中真正命中的通道
}).catch(err => { return { type: res.type || quality, url, ...(via !== source ? { via } : {}) }
throw err.status ? err : upstream(err.message) }
})
const attempts: Array<{ via: string, run: () => Promise<any> }> = []
if (fastify.sourceApi?.[source]) {
attempts.push({ via: 'script', run: () => unwrap(fastify.sourceApi[source].getMusicUrl(songInfo, quality)) })
}
attempts.push({ via: source, run: () => unwrap(m.getMusicUrl(songInfo, quality)) })
const errors: string[] = []
for (const attempt of attempts) {
try {
return finish(await attempt.run(), attempt.via)
} catch (err: any) {
errors.push(`${attempt.via}: ${err?.message || err}`)
}
}
// 酷我兜底:匿名可拿真无损的通道,按歌名/歌手/时长匹配同一首歌后取流
if (source !== 'kw') {
try {
let meta: { name?: string, singer?: string, interval?: string } | null = name ? { name, singer, interval } : null
if (!meta && source === 'kg') {
// kg 的 id 是 hash,可反查出歌名/歌手/时长
const info = await getMusicInfo(id)
if (info?.name) meta = { name: String(info.name), singer: info.singer ? String(info.singer) : undefined, interval: info.interval ? String(info.interval) : undefined }
}
if (meta?.name) {
const [best] = await musicSdk.findMusic({ ...meta, only: 'kw' })
if (best) {
const res = await unwrap(musicSdk.kw.getMusicUrl({ songmid: best.songmid, hash: best.songmid, _types: {} }, quality))
return finish(res, 'kw')
}
errors.push('kw 兜底: 未匹配到同名歌曲')
} else {
errors.push('kw 兜底: 缺少歌名信息(传 name/singer/interval 查询参数可启用)')
}
} catch (err: any) {
errors.push(`kw 兜底: ${err?.message || err}`)
}
}
throw upstream(`取流失败(${errors.join(';')})`)
} }
fastify.get('/url/:source/:id', { fastify.get('/url/:source/:id', {
schema: { schema: {
summary: '获取播放地址', summary: '获取播放地址',
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash。', description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs 用 track id。' +
'取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' +
'酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' +
'其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。',
tags: ['歌曲资源'], tags: ['歌曲资源'],
params: params({ params: params({
source: pSource('url'), source: pSource('url'),
id: pId, id: pId,
}), }),
querystring: qs({ quality: pQuality, albumId: { type: 'string', description: '专辑 id' } }), querystring: qs({
quality: pQuality,
albumId: { type: 'string', description: '专辑 id' },
name: { type: 'string', description: '歌名(酷我兜底匹配用)' },
singer: { type: 'string', description: '歌手名(酷我兜底匹配用)' },
interval: { type: 'string', description: '时长 mm:ss(酷我兜底匹配用)', example: '04:17' },
}),
response: responses('MusicUrlResult'), response: responses('MusicUrlResult'),
}, },
}, urlHandler) }, urlHandler)
+19
View File
@@ -152,6 +152,7 @@ await test('URL kw 128k 免费歌', async() => {
const r = await get('/url/kw/151826618?quality=128k') const r = await get('/url/kw/151826618?quality=128k')
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url') if (!r.data?.url) throw new Error('无 url')
if (r.data.via !== undefined) throw new Error(`直接命中不应有 via 字段,实际 ${r.data.via}`)
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
}) })
@@ -193,6 +194,24 @@ await test('URL qs 试听 128k', async() => {
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
}) })
// 取流链兜底:平台直连失败后按 name/singer 匹配酷我(via=kw 标记实际命中通道)
await test('URL wy 坏 id → 酷我兜底(via=kw)', async() => {
const r = await get('/url/wy/not-exist-id?quality=128k&name=晴天&singer=周杰伦&interval=04:29')
if (r.code != 0) throw new Error(r.msg)
if (r.data?.via !== 'kw') throw new Error(`期望 via=kw,实际 ${JSON.stringify(r.data)}`)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
// 坏 id 且未传歌名:kg hash 反查也失败时,应返回聚合各阶段错误的 502 而非裸错误
await test('URL kg 坏 hash 无歌名 → 502 聚合错误', async() => {
const r = await getRaw('/url/kg/00000000000000000000000000000000?quality=128k')
if (r.status !== 502) throw new Error(`期望 502,实际 ${r.status}`)
const msg = JSON.parse(r.body).msg
if (!msg.includes('取流失败')) throw new Error(`错误信息应为取流失败聚合,实际 ${msg}`)
return msg
})
// 歌词 // 歌词
await test('歌词 kw', async() => { await test('歌词 kw', async() => {
const r = await get('/lyric/kw/28423286') const r = await get('/lyric/kw/28423286')