Compare commits
7
Commits
e796e622f2
...
1fec828547
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fec828547 | ||
|
|
6fb866e343 | ||
|
|
83d3eb3b49 | ||
|
|
020e9bbb2e | ||
|
|
5a9915b74c | ||
|
|
44852864e2 | ||
|
|
3b40ba4c8f |
No files matched your search
@@ -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 进程内直载)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// 懒人听书 URL 获取(web 通道 getPlayPath,需登录)
|
||||||
|
// 接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1§ions=[<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§ions=[${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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
@@ -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' })
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
@@ -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
@@ -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 }),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user