refactor: 架构整固——分层单向化、全量 TypeScript、直接运行
- 移除 bd/mg 两平台及磁盘残留产物;清理 api-test 死脚本、musicSdk/message+options 死模块、infSign vendor - musicSdk/ 改名 sdk/;src/common、src/auth、src/apis 整体移入 sdk/,依赖方向定为 src → sdk 严格单向 - src/ 手写 JS 全量转 .ts,删除 JS 类型垫片 modules.d.ts;源码 import 统一 .ts 后缀 - Node 原生类型剥离直接运行 .ts(engines >=22.18),取消 tsc 发射产物,tsc 仅类型检查(pnpm typecheck) - 删除 kwdes 备用实现与 src/index.ts 转发垫片;测试移至 test/smoke-test.ts - common/request.ts 补显式导出类型;修复 set-cookie 单值形态、ApiError 类字段等隐患 验证:tsc 零错误,smoke-test 41/41
This commit is contained in:
1 parent
c8c939aa32
commit
354df2e739
126 files changed
+413
-2942
No files matched your search
@@ -1,37 +0,0 @@
|
||||
// 百度音乐 URL 获取(直连)
|
||||
// 接口: https://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.getSongLink
|
||||
// 返回: result.songinfo.song_link
|
||||
// 统一接口约定:apis('bd').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../common/request.js'
|
||||
|
||||
const getSongLink = (songmid, retryNum = 0) => {
|
||||
const url = `https://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.getSongLink&format=json&from=bmpc&version=1.0.0&version_d=11.1.6.0&songid=${songmid}&type=1&res=1&s_protocol=1&aac=2&project=tpass`
|
||||
return httpFetch(url, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.error_code != 22000 || !body.result?.songinfo) {
|
||||
if (retryNum < 2) return getSongLink(songmid, ++retryNum)
|
||||
return Promise.reject(new Error('获取失败'))
|
||||
}
|
||||
return body.result.songinfo
|
||||
})
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
requestObj.promise = getSongLink(songInfo.songmid).then(info => {
|
||||
// song_link 为试听地址(128k),按音质选择字段
|
||||
const url = info.song_link || info.file_link
|
||||
if (!url) return Promise.reject(new Error('获取失败'))
|
||||
return { type, url }
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
@@ -1,17 +0,0 @@
|
||||
// 各平台 URL 直连接口汇总
|
||||
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
|
||||
// 外部聚合音源(sources/ 目录脚本)不走这里,由 musicSdk/api-source.ts 直接接入 source.js
|
||||
import kw from './kw.js';
|
||||
import kg from './kg.js';
|
||||
import tx from './tx.js';
|
||||
import wy from './wy.js';
|
||||
import mg from './mg.js';
|
||||
import bd from './bd.js';
|
||||
export default {
|
||||
kw: kw,
|
||||
kg: kg,
|
||||
tx: tx,
|
||||
wy: wy,
|
||||
mg: mg,
|
||||
bd: bd,
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
// 各平台 URL 直连接口汇总
|
||||
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
|
||||
// 外部聚合音源(sources/ 目录脚本)不走这里,由 musicSdk/api-source.ts 直接接入 source.js
|
||||
|
||||
import kw from './kw.js'
|
||||
import kg from './kg.js'
|
||||
import tx from './tx.js'
|
||||
import wy from './wy.js'
|
||||
import mg from './mg.js'
|
||||
import bd from './bd.js'
|
||||
|
||||
interface SongInfo {
|
||||
songmid?: string
|
||||
hash?: string
|
||||
copyrightId?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface MusicUrlResult {
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface ApiModule {
|
||||
getMusicUrl?(songInfo: SongInfo, type: string): { promise: Promise<MusicUrlResult>; cancelHttp: () => void }
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export default {
|
||||
kw: kw as ApiModule,
|
||||
kg: kg as ApiModule,
|
||||
tx: tx as ApiModule,
|
||||
wy: wy as ApiModule,
|
||||
mg: mg as ApiModule,
|
||||
bd: bd as ApiModule,
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// 酷狗音乐 URL 获取(手机客户端通道)
|
||||
// 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名)
|
||||
// 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源,
|
||||
// VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high
|
||||
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { getKgAuth } from '../auth/store.js'
|
||||
import { androidSign, getDevice } from '../auth/kg.js'
|
||||
|
||||
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
|
||||
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
|
||||
const APPID = 1005
|
||||
const CLIENTVER = 11430
|
||||
|
||||
const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
const hash = (songInfo._types?.[type]?.hash || songInfo.hash || '').toLowerCase()
|
||||
if (!hash) {
|
||||
requestObj.promise = Promise.reject(new Error('缺少 hash'))
|
||||
return requestObj
|
||||
}
|
||||
|
||||
requestObj.cancelHttp = () => {} // 单次 GET,无需取消
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
const dev = getDevice()
|
||||
const auth = getKgAuth()
|
||||
const userid = Number(auth?.userid || 0)
|
||||
|
||||
const params = {
|
||||
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
||||
clienttime: Math.floor(Date.now() / 1000),
|
||||
album_id: Number(songInfo.albumId ?? 0),
|
||||
area_code: 1,
|
||||
hash,
|
||||
ssa_flag: 'is_fromtrack',
|
||||
version: CLIENTVER,
|
||||
page_id: 151369488,
|
||||
quality: qualityMap[type] ?? '128',
|
||||
album_audio_id: Number(songInfo.songmid ?? 0),
|
||||
behavior: 'play',
|
||||
pid: 2,
|
||||
cmd: 26,
|
||||
pidversion: 3001,
|
||||
IsFreePart: 0,
|
||||
ppage_id: '463467626,350369493,788954147',
|
||||
cdnBackup: 1,
|
||||
module: '',
|
||||
}
|
||||
if (auth) {
|
||||
params.token = auth.token
|
||||
params.userid = userid
|
||||
}
|
||||
params.key = md5(`${hash}${SALT_KEY}${APPID}${dev.mid}${userid}`)
|
||||
params.signature = androidSign(params)
|
||||
|
||||
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
|
||||
const r = await httpFetch(`https://gateway.kugou.com/v5/url?${qs}`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
|
||||
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
|
||||
'x-router': 'trackercdn.kugou.com',
|
||||
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
|
||||
const body = r.body && typeof r.body === 'object' ? r.body : {}
|
||||
if (body.status !== 1 || !Array.isArray(body.data)) {
|
||||
throw new Error(body.error || (auth ? '获取失败' : '获取失败(该资源需要酷狗登录)'))
|
||||
}
|
||||
const item = body.data.find(d => d.url) ?? body.data[0]
|
||||
const url = item?.url || item?.fallback
|
||||
if (!url) throw new Error('获取失败')
|
||||
return { type, url }
|
||||
})()
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
// 酷我音乐 URL 获取(直连)
|
||||
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
//
|
||||
// 取流方案:
|
||||
// 首选 antiserver.kuwo.cn/anti.s(convert_url3 老接口,无需 token,128k/320k 有效)
|
||||
// flac 及以上音质该接口返回 refuse,回退 www.kuwo.cn/api/v1/www/music/playUrl
|
||||
// (需要 csrf token,token 失效时自动重新获取重试一次)
|
||||
|
||||
import { httpFetch } from '../common/request.js'
|
||||
|
||||
// 音质映射:128k → 128kmp3, 320k → 320kmp3, flac → flac(antiserver 用 mp3/flac 格式)
|
||||
const qualityMap = {
|
||||
'128k': { br: '128kmp3', format: 'mp3' },
|
||||
'320k': { br: '320kmp3', format: 'mp3' },
|
||||
flac: { br: 'flac', format: 'flac' },
|
||||
flac24bit: { br: 'flac', format: 'flac' },
|
||||
}
|
||||
|
||||
let token = ''
|
||||
let tokenPromise = null
|
||||
|
||||
/**
|
||||
* 获取 kw csrf token(访问首页拿 cookie)
|
||||
*/
|
||||
const getToken = (retryNum = 0) => {
|
||||
if (token) return Promise.resolve(token)
|
||||
if (tokenPromise) return tokenPromise
|
||||
tokenPromise = httpFetch('http://www.kuwo.cn/', {
|
||||
method: 'get',
|
||||
timeout: 15000,
|
||||
}).promise.then(({ headers }) => {
|
||||
tokenPromise = null
|
||||
const match = (headers['set-cookie'] || []).join(';').match(/kw_token=(\w+)/)
|
||||
if (!match) throw new Error('获取token失败')
|
||||
token = match[1]
|
||||
return token
|
||||
}).catch(err => {
|
||||
tokenPromise = null
|
||||
if (retryNum > 2) throw err
|
||||
return getToken(++retryNum)
|
||||
})
|
||||
return tokenPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 antiserver 老接口获取(无需 token)
|
||||
* @param {string} rid MUSIC_xxx
|
||||
* @param {string} br 128kmp3 / 320kmp3 / flac
|
||||
* @param {string} format mp3 / flac
|
||||
*/
|
||||
const getUrlByAntiServer = (rid, br, format) => {
|
||||
return httpFetch(`http://antiserver.kuwo.cn/anti.s?type=convert_url3&rid=${rid}&format=${format}&response=url&br=${br}`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || typeof body != 'object' || body.code != 200 || !body.url) {
|
||||
return Promise.reject(new Error(body?.msg || '获取失败'))
|
||||
}
|
||||
return body.url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过官方 playUrl 接口获取(需要 csrf token)
|
||||
*/
|
||||
const getUrlByPlayUrl = (songmid, br, retryNum = 0) => {
|
||||
const requestObj = httpFetch(`http://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&br=${br}`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
csrf: token,
|
||||
cookie: `kw_token=${token}`,
|
||||
Referer: 'http://www.kuwo.cn/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.code != 200) {
|
||||
// token 失效时重新获取再试一次
|
||||
if (token && /token|403|401/.test(body?.msg || '') && retryNum == 0) {
|
||||
token = ''
|
||||
return getUrlByPlayUrl(songmid, br, ++retryNum).promise
|
||||
}
|
||||
return Promise.reject(new Error(body?.msg || '获取失败'))
|
||||
}
|
||||
if (!body.data?.url) return Promise.reject(new Error('获取失败'))
|
||||
return body.data.url
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
const { br, format } = qualityMap[type] || qualityMap['128k']
|
||||
const rid = `MUSIC_${songInfo.songmid}`
|
||||
|
||||
requestObj.promise = getUrlByAntiServer(rid, br, format).catch(() => {
|
||||
// antiserver 无此音质(flac 常被拒)时回退官方接口
|
||||
return getToken().then(() => getUrlByPlayUrl(songInfo.songmid, br).promise)
|
||||
}).then(url => ({ type, url }))
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
@@ -1,84 +0,0 @@
|
||||
// 咪咕音乐 URL 获取(直连)
|
||||
// 流程: resourceinfo.do(拿 contentId,需签名)→ listenSong.do(302 跳转到 CDN 音频地址)
|
||||
// 统一接口约定:apis('mg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { toMD5 } from '../common/utils.js'
|
||||
|
||||
const rateMap = {
|
||||
'128k': 'PQ',
|
||||
'320k': 'HQ',
|
||||
flac: 'SQ',
|
||||
flac24bit: 'ZQ24',
|
||||
}
|
||||
|
||||
const deviceId = '963B7AA0D21511ED807EE5846EC87D20'
|
||||
const signatureMd5 = '6cdc72a439cef99a3418d2a78aa28c73'
|
||||
|
||||
const createSignature = (time, str) => {
|
||||
const sign = toMD5(`${str}${signatureMd5}yyapp2d16148780a1dcc7408e06336b98cfd50${deviceId}${time}`)
|
||||
return { sign, deviceId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 songId 获取歌曲内容信息(contentId)
|
||||
*/
|
||||
const getResourceInfo = songmid => {
|
||||
const time = Date.now().toString()
|
||||
const signData = createSignature(time, songmid)
|
||||
return httpFetch(`http://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?resourceId=${songmid}&resourceType=2`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36',
|
||||
timestamp: time,
|
||||
sign: signData.sign,
|
||||
deviceId: signData.deviceId,
|
||||
appId: 'yyapp2',
|
||||
channel: '0146921',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.code != '000000' || !body.resource?.length) {
|
||||
return Promise.reject(new Error('获取失败'))
|
||||
}
|
||||
return body.resource[0]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取播放地址(302 跳转)
|
||||
* @param {string} contentId
|
||||
* @param {string} copyrightId
|
||||
* @param {string} rateType PQ / HQ / SQ
|
||||
*/
|
||||
const getSongUrl = (contentId, copyrightId, rateType, retryNum = 0) => {
|
||||
if (retryNum > 2) return Promise.reject(new Error('获取失败'))
|
||||
const url = `https://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/sub/listenSong.do?contentId=${contentId}©rightId=${copyrightId}&resourceType=2&rateType=${rateType}&channel=0146921&toneFlag=${rateType}&netType=00&version=5.0.1`
|
||||
return httpFetch(url, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36',
|
||||
Referer: 'http://music.migu.cn/',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise.then(({ statusCode, headers }) => {
|
||||
if (statusCode == 302 && headers.location) return headers.location
|
||||
if (statusCode == 302 && !headers.location) {
|
||||
// 手动跟随
|
||||
return Promise.reject(new Error('获取失败'))
|
||||
}
|
||||
return getSongUrl(contentId, copyrightId, rateType, ++retryNum)
|
||||
})
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
const rateType = rateMap[type] || 'PQ'
|
||||
requestObj.promise = getResourceInfo(songInfo.songmid || songInfo.copyrightId)
|
||||
.then(resource => getSongUrl(resource.contentId, resource.copyrightId, rateType))
|
||||
.then(url => ({ type, url }))
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
@@ -1,642 +0,0 @@
|
||||
// 外部聚合音源加载器(isolated-vm 硬隔离执行第三方脚本)
|
||||
//
|
||||
// 这是外部音源的唯一接入方式:在 isolated-vm 硬隔离沙箱中运行原始混淆脚本,
|
||||
// 脚本的所有能力都经由本模块显式桥接的通道:
|
||||
// - http:默认只放行脚本头部 SERVER_SCRIPT_CONFIG.apiUrl 同源请求,其余一律阻止
|
||||
// - crypto / zlib / buffer:Node 侧实现,日志只记操作与长度,不记录密钥内容
|
||||
// - 定时器 / console:桥接到 Node,随 isolate 销毁回收
|
||||
// 对外接口:getMusicUrl(songInfo, type) → { promise, cancelHttp },与其它平台适配器一致。
|
||||
// 脚本升版时只需替换 sources/ 目录下的脚本文件,无需改代码。
|
||||
//
|
||||
// 安全模型:
|
||||
// - isolate 内存上限(memoryLimit),无 process/require/fs/网络直连能力
|
||||
// - HTTP 出站默认拒绝(deny-all),白名单来自脚本自身声明的 apiUrl,可被 options 收紧或放宽
|
||||
// - 每个音源 API 调用(on/send/request/utils/dispatch)都有日志,请求含耗时与状态
|
||||
//
|
||||
// 依赖:isolated-vm(未安装时工厂函数抛出明确错误)
|
||||
// 用法:
|
||||
// import { createIvmSource } from './source-ivm.js'
|
||||
// const source = await createIvmSource({ scriptPath }) // scriptPath 必填(sources/ 扫描逻辑见 source.js)
|
||||
// const source = await createIvmSource({ scriptPath, http }) // http 可注入 mock 做离线测试
|
||||
// await source.kw.getMusicUrl({ songmid: '123' }, '320k').promise
|
||||
// await source.destroy()
|
||||
//
|
||||
// 已知边界(相对桌面端 preload 的差异):
|
||||
// - source.request 回调的 resp 不含 raw/bytes(Buffer 无法跨隔离边界,实测脚本未使用)
|
||||
// - formData 选项不支持(httpFetch 不支持 multipart)
|
||||
// - updateAlert 事件仅记录到日志,不弹窗
|
||||
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import zlib from 'node:zlib'
|
||||
import { httpFetch } from '../common/request.js'
|
||||
|
||||
// 从脚本头部的 SERVER_SCRIPT_CONFIG 提取 apiUrl 声明,作为 HTTP 白名单缺省值。
|
||||
// 兼容裸键(apiUrl:)与 JSON 风格("apiUrl":),并处理 \/ 转义
|
||||
const extractAllowedOrigins = (script) => {
|
||||
const origins = []
|
||||
const re = /["']?apiUrl["']?\s*:\s*["'`]([^"'`\s]+)["'`]/g
|
||||
let m
|
||||
while ((m = re.exec(script))) {
|
||||
try { origins.push(new URL(m[1].replace(/\\\//g, '/')).origin) } catch {}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
const brief = (v, max = 72) => {
|
||||
const s = typeof v === 'string' ? v : JSON.stringify(v) ?? String(v)
|
||||
return s.length > max ? `${s.slice(0, max)}…(${s.length})` : s
|
||||
}
|
||||
|
||||
// 从脚本头部注释解析 @name/@version 等(对齐音源脚本 currentScriptInfo)
|
||||
const parseScriptInfo = (script, scriptPath) => {
|
||||
const head = script.slice(0, 2000)
|
||||
const pick = (key) => {
|
||||
const m = new RegExp(`@${key}\\s+(.+)`).exec(head)
|
||||
return m ? m[1].trim() : ''
|
||||
}
|
||||
return {
|
||||
name: pick('name') || '未命名音源',
|
||||
description: pick('description'),
|
||||
version: pick('version') || '0',
|
||||
author: pick('author'),
|
||||
homepage: pick('homepage'),
|
||||
rawScript: script,
|
||||
path: scriptPath,
|
||||
}
|
||||
}
|
||||
|
||||
// 注入隔离环境的胶水层:提供音源脚本 API(globalThis.lx 为脚本契约名)、定时器、console、Bytes 包装。
|
||||
// 与 Node 的数据交换一律走 JSON 字符串 / hex 字符串,避免跨隔离传输复杂对象。
|
||||
const GLUE = `
|
||||
'use strict'
|
||||
var __currentDispatch = 0
|
||||
var __pendingZlib = {}
|
||||
var __timers = {}
|
||||
|
||||
// ---- Bytes 轻量包装(对齐 Buffer 的最小用法面)----
|
||||
function __utf8Encode(str) {
|
||||
var out = []
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var c = str.charCodeAt(i)
|
||||
if (c >= 0xD800 && c <= 0xDBFF && i + 1 < str.length) {
|
||||
var c2 = str.charCodeAt(i + 1)
|
||||
if (c2 >= 0xDC00 && c2 <= 0xDFFF) { c = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); i++ }
|
||||
}
|
||||
if (c < 0x80) out.push(c)
|
||||
else if (c < 0x800) out.push(0xC0 | (c >> 6), 0x80 | (c & 63))
|
||||
else if (c < 0x10000) out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
|
||||
else out.push(0xF0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
|
||||
}
|
||||
return out
|
||||
}
|
||||
function __utf8Decode(bytes) {
|
||||
var out = ''
|
||||
for (var i = 0; i < bytes.length;) {
|
||||
var b = bytes[i], cp
|
||||
if (b < 0x80) { cp = b; i += 1 }
|
||||
else if (b < 0xE0) { cp = ((b & 31) << 6) | (bytes[i + 1] & 63); i += 2 }
|
||||
else if (b < 0xF0) { cp = ((b & 15) << 12) | ((bytes[i + 1] & 63) << 6) | (bytes[i + 2] & 63); i += 3 }
|
||||
else { cp = ((b & 7) << 18) | ((bytes[i + 1] & 63) << 12) | ((bytes[i + 2] & 63) << 6) | (bytes[i + 3] & 63); i += 4 }
|
||||
if (cp > 0xFFFF) { cp -= 0x10000; out += String.fromCharCode(0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF)) }
|
||||
else out += String.fromCharCode(cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
function __bytesToBase64(bytes) {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', out = ''
|
||||
for (var i = 0; i < bytes.length; i += 3) {
|
||||
var b0 = bytes[i], b1 = bytes[i + 1], b2 = bytes[i + 2]
|
||||
out += chars[b0 >> 2]
|
||||
out += chars[((b0 & 3) << 4) | ((b1 === undefined ? 0 : b1) >> 4)]
|
||||
out += b1 === undefined ? '=' : chars[((b1 & 15) << 2) | ((b2 === undefined ? 0 : b2) >> 6)]
|
||||
out += b2 === undefined ? '=' : chars[b2 & 63]
|
||||
}
|
||||
return out
|
||||
}
|
||||
function __base64ToBytes(b64) {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', out = []
|
||||
b64 = String(b64).replace(/[^A-Za-z0-9+/]/g, '')
|
||||
for (var i = 0; i < b64.length; i += 4) {
|
||||
var n = (chars.indexOf(b64[i]) << 18) | (chars.indexOf(b64[i + 1]) << 12) |
|
||||
((chars.indexOf(b64[i + 2]) & 63) << 6) | (chars.indexOf(b64[i + 3]) & 63)
|
||||
out.push((n >> 16) & 255)
|
||||
if (b64[i + 2] && b64[i + 2] !== '=') out.push((n >> 8) & 255)
|
||||
if (b64[i + 3] && b64[i + 3] !== '=') out.push(n & 255)
|
||||
}
|
||||
return out
|
||||
}
|
||||
function __hexToBytes(hex) {
|
||||
var out = []
|
||||
for (var i = 0; i + 1 < hex.length; i += 2) out.push(parseInt(hex.substr(i, 2), 16))
|
||||
return out
|
||||
}
|
||||
function __bytesToHex(bytes) {
|
||||
var out = ''
|
||||
for (var i = 0; i < bytes.length; i++) out += ((bytes[i] & 255) < 16 ? '0' : '') + (bytes[i] & 255).toString(16)
|
||||
return out
|
||||
}
|
||||
globalThis.__Bytes = function (bytes) { this._b = bytes }
|
||||
globalThis.__Bytes.prototype.length = 0
|
||||
Object.defineProperty(globalThis.__Bytes.prototype, 'length', {
|
||||
get: function () { return this._b.length },
|
||||
})
|
||||
globalThis.__Bytes.prototype.toString = function (enc) {
|
||||
enc = String(enc || 'utf8').toLowerCase()
|
||||
if (enc === 'hex') return __bytesToHex(this._b)
|
||||
if (enc === 'base64') return __bytesToBase64(this._b)
|
||||
if (enc === 'binary' || enc === 'latin1') {
|
||||
var s = ''
|
||||
for (var i = 0; i < this._b.length; i++) s += String.fromCharCode(this._b[i] & 255)
|
||||
return s
|
||||
}
|
||||
return __utf8Decode(this._b)
|
||||
}
|
||||
globalThis.__Bytes.prototype.slice = function (start, end) {
|
||||
return new __Bytes(this._b.slice(start || 0, end === undefined || end === null ? undefined : end))
|
||||
}
|
||||
globalThis.__Bytes.prototype.toJSON = function () { return this.toString('utf8') }
|
||||
|
||||
function __toHex(data) {
|
||||
if (data instanceof __Bytes) return data.toString('hex')
|
||||
if (typeof data === 'string') return __bytesToHex(__utf8Encode(data))
|
||||
if (data && typeof data.length === 'number') {
|
||||
var out = ''
|
||||
for (var i = 0; i < data.length; i++) out += ((data[i] & 255) < 16 ? '0' : '') + (data[i] & 255).toString(16)
|
||||
return out
|
||||
}
|
||||
return ''
|
||||
}
|
||||
function __fromAny(data, enc) {
|
||||
if (data instanceof __Bytes) return new __Bytes(data._b.slice())
|
||||
if (typeof data === 'string') {
|
||||
enc = String(enc || 'utf8').toLowerCase()
|
||||
if (enc === 'hex') return new __Bytes(__hexToBytes(data))
|
||||
if (enc === 'base64') return new __Bytes(__base64ToBytes(data))
|
||||
if (enc === 'binary' || enc === 'latin1') {
|
||||
var out = []
|
||||
for (var i = 0; i < data.length; i++) out.push(data.charCodeAt(i) & 255)
|
||||
return new __Bytes(out)
|
||||
}
|
||||
return new __Bytes(__utf8Encode(data))
|
||||
}
|
||||
return new __Bytes(Array.prototype.slice.call(data || []))
|
||||
}
|
||||
|
||||
// ---- console / 定时器桥接 ----
|
||||
globalThis.console = {
|
||||
log: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
|
||||
info: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
|
||||
warn: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
|
||||
error: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
|
||||
debug: function () {},
|
||||
}
|
||||
globalThis.setTimeout = function (fn, ms) {
|
||||
var id = __newTimer(fn, Array.prototype.slice.call(arguments, 2), false)
|
||||
__startTimer(id, Number(ms) || 0, false)
|
||||
return id
|
||||
}
|
||||
globalThis.clearTimeout = function (id) { __clearTimer(id) }
|
||||
globalThis.setInterval = function (fn, ms) {
|
||||
var id = __newTimer(fn, [], true)
|
||||
__startTimer(id, Number(ms) || 1, true)
|
||||
return id
|
||||
}
|
||||
globalThis.clearInterval = function (id) { __clearTimer(id) }
|
||||
function __newTimer(fn, args, interval) {
|
||||
var id = Date.now() % 100000000 + Math.floor(Math.random() * 100000000)
|
||||
__timers[id] = { fn: fn, args: args, interval: interval }
|
||||
return id
|
||||
}
|
||||
function __clearTimer(id) {
|
||||
if (__timers[id]) { delete __timers[id]; __stopTimer(id) }
|
||||
}
|
||||
globalThis.__fireTimer = function (id) {
|
||||
var t = __timers[id]
|
||||
if (!t) return
|
||||
if (!t.interval) delete __timers[id]
|
||||
try { t.fn.apply(undefined, t.args) } catch (e) { __log('timer error: ' + (e && e.message)) }
|
||||
}
|
||||
|
||||
// ---- 音源脚本 preload API ----
|
||||
// 注意:globalThis.lx 是第三方脚本依赖的 API 契约名(脚本内部调用 lx.on/source.request 等),
|
||||
// 属于对外兼容接口而非命名残留,不可改动,否则脚本初始化直接失败。
|
||||
var __EVENT_NAMES = { request: 'request', inited: 'inited', updateAlert: 'updateAlert' }
|
||||
var __handlers = {}
|
||||
globalThis.lx = {
|
||||
EVENT_NAMES: __EVENT_NAMES,
|
||||
version: '2.0.0',
|
||||
env: 'desktop',
|
||||
currentScriptInfo: __scriptInfo,
|
||||
on: function (name, handler) {
|
||||
if (name !== __EVENT_NAMES.request) return Promise.reject(new Error('The event is not supported: ' + name))
|
||||
__handlers[name] = handler
|
||||
__on(String(name))
|
||||
return Promise.resolve()
|
||||
},
|
||||
send: function (name, data) {
|
||||
if (name !== __EVENT_NAMES.inited && name !== __EVENT_NAMES.updateAlert) {
|
||||
return Promise.reject(new Error('The event is not supported: ' + name))
|
||||
}
|
||||
var err = __send(String(name), JSON.stringify(data === undefined ? null : data))
|
||||
return err ? Promise.reject(new Error(err)) : Promise.resolve()
|
||||
},
|
||||
request: function (url, options, callback) { return __sourceRequest(url, options, callback, __currentDispatch) },
|
||||
utils: {
|
||||
crypto: {
|
||||
md5: function (str) { return __md5(str) },
|
||||
randomBytes: function (size) { return new __Bytes(__hexToBytes(__randomBytesHex(size))) },
|
||||
aesEncrypt: function (buffer, mode, key, iv) {
|
||||
return new __Bytes(__hexToBytes(__aesEncryptHex(__toHex(buffer), String(mode), __toHex(key), __toHex(iv))))
|
||||
},
|
||||
rsaEncrypt: function (buffer, key) {
|
||||
return new __Bytes(__hexToBytes(__rsaEncryptHex(__toHex(buffer), String(key))))
|
||||
},
|
||||
},
|
||||
buffer: {
|
||||
from: function (data, enc) {
|
||||
__log('source.utils.buffer.from(' + (data instanceof __Bytes ? 'Bytes(' + data.length + ')' : typeof data + (data && data.length !== undefined ? '[' + data.length + ']' : '')) + (enc ? ',' + enc : '') + ')')
|
||||
return __fromAny(data, enc)
|
||||
},
|
||||
bufToString: function (buf, format) {
|
||||
__log('source.utils.buffer.bufToString(' + (buf && buf.length !== undefined ? buf.length + 'B' : typeof buf) + ',' + (format || 'utf8') + ')')
|
||||
return buf.toString(format || 'utf8')
|
||||
},
|
||||
},
|
||||
zlib: {
|
||||
inflate: function (buf) { return __zlibAsync('inflate', buf) },
|
||||
deflate: function (data) { return __zlibAsync('deflate', data) },
|
||||
},
|
||||
},
|
||||
}
|
||||
// 隔离环境内不依赖 ivm 全局:回调统一走「隔离内注册表 + 自增 id」,
|
||||
// Node 侧通过 jail.get 拿到 __completeHttp 等入口反调
|
||||
function __sourceRequest(url, options, callback, dispatchId) {
|
||||
options = options || {}
|
||||
var payload = {
|
||||
method: options.method || 'get',
|
||||
timeout: options.timeout,
|
||||
headers: options.headers || null,
|
||||
body: options.body === undefined || options.body === null ? null :
|
||||
(typeof options.body === 'string' ? options.body : JSON.stringify(options.body)),
|
||||
form: options.form ? JSON.stringify(options.form) : null,
|
||||
}
|
||||
var cbId = ++__seq
|
||||
__pendingCbs[cbId] = callback
|
||||
var reqId = __http(dispatchId | 0, String(url), JSON.stringify(payload), cbId)
|
||||
return function () { __cancelHttp(reqId) }
|
||||
}
|
||||
var __pendingCbs = {}
|
||||
globalThis.__completeHttp = function (cbId, errMsg, respJson, bodyJson) {
|
||||
var cb = __pendingCbs[cbId]
|
||||
if (!cb) return
|
||||
delete __pendingCbs[cbId]
|
||||
try {
|
||||
if (errMsg) cb.call(lx, new Error(errMsg), null, null)
|
||||
else cb.call(lx, null, respJson === null ? null : JSON.parse(respJson), bodyJson === null ? null : JSON.parse(bodyJson))
|
||||
} catch (e) { __log('request callback error: ' + (e && e.message)) }
|
||||
}
|
||||
function __zlibAsync(op, data) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var opId = ++__seqCounter()
|
||||
__pendingZlib[opId] = { resolve: resolve, reject: reject }
|
||||
__zlib(opId, op, __toHex(data))
|
||||
})
|
||||
}
|
||||
var __seq = 0
|
||||
function __seqCounter() { return ++__seq }
|
||||
globalThis.__zlibDone = function (opId, errMsg, valHex) {
|
||||
var p = __pendingZlib[opId]
|
||||
if (!p) return
|
||||
delete __pendingZlib[opId]
|
||||
if (errMsg) p.reject(new Error(errMsg))
|
||||
else p.resolve(new __Bytes(__hexToBytes(valHex)))
|
||||
}
|
||||
|
||||
// ---- 请求分发(Node 侧调用)----
|
||||
// 注意:本函数必须不返回值(结果经 __dispatchResult 回传),
|
||||
// 否则 apply 会尝试克隆隔离内 Promise 而报 "could not be cloned"
|
||||
globalThis.__dispatch = function (dispatchId, payloadJson) {
|
||||
var payload = JSON.parse(payloadJson)
|
||||
__currentDispatch = dispatchId | 0
|
||||
var p
|
||||
try {
|
||||
var handler = __handlers[__EVENT_NAMES.request]
|
||||
if (!handler) p = Promise.reject(new Error('request handler 未注册'))
|
||||
else p = Promise.resolve().then(function () { return handler(payload) })
|
||||
} catch (e) {
|
||||
p = Promise.reject(e)
|
||||
}
|
||||
__currentDispatch = 0
|
||||
p.then(function (result) {
|
||||
if (payload.action === 'musicUrl') return JSON.stringify({ ok: true, url: result === undefined ? null : String(result) })
|
||||
return JSON.stringify({ ok: true, data: result === undefined ? null : result })
|
||||
}, function (err) {
|
||||
return JSON.stringify({ ok: false, message: err && err.message ? String(err.message) : String(err) })
|
||||
}).then(function (resultJson) {
|
||||
__dispatchResult(dispatchId | 0, resultJson)
|
||||
}, function (err) {
|
||||
__dispatchResult(dispatchId | 0, JSON.stringify({ ok: false, message: String(err && err.message || err) }))
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* 创建隔离执行的外部音源
|
||||
* @param {object} [options]
|
||||
* @param {string} options.scriptPath 音源脚本路径(必填,sources/ 目录扫描逻辑见 source.js)
|
||||
* @param {Function} [options.http] http 实现,默认项目统一 httpFetch;测试可注入 mock
|
||||
* @param {number} [options.memoryLimit] 隔离内存上限 MB,默认 256
|
||||
* @param {number} [options.initTimeout] 等待脚本上报 inited 的超时 ms,默认 10000
|
||||
* @param {number} [options.requestTimeout] 单次取流超时 ms,默认 15000
|
||||
* @param {boolean|Function} [options.log] 日志开关;true(默认)输出到 console,可传自定义 (msg)=>void,false 关闭
|
||||
* @param {string[]} [options.allowedOrigins] HTTP 白名单 origin;缺省取脚本 SERVER_SCRIPT_CONFIG.apiUrl 同源
|
||||
* @param {Function} [options.httpFilter] 自定义放行判定 (url) => boolean;提供时优先于 origin 白名单
|
||||
* @param {boolean} [options.allowAllHttp] 放行全部 http 请求(调试用,默认 false)
|
||||
* @returns {{ env: string, scriptInfo: object, inited: object|null, apis: object, allowedOrigins: string[], destroy: Function }}
|
||||
*/
|
||||
export const createIvmSource = async({
|
||||
scriptPath,
|
||||
http: httpImpl = httpFetch,
|
||||
memoryLimit = 256,
|
||||
initTimeout = 10000,
|
||||
requestTimeout = 15000,
|
||||
log: logOption = true,
|
||||
allowedOrigins,
|
||||
httpFilter,
|
||||
allowAllHttp = false,
|
||||
} = {}) => {
|
||||
let ivm
|
||||
try {
|
||||
ivm = (await import('isolated-vm')).default
|
||||
} catch {
|
||||
throw new Error('未安装 isolated-vm,无法启用隔离执行音源:pnpm add isolated-vm')
|
||||
}
|
||||
if (!scriptPath) throw new Error('缺少音源脚本路径 scriptPath(sources/ 目录扫描逻辑见 source.js)')
|
||||
|
||||
const script = fs.readFileSync(scriptPath, 'utf8')
|
||||
const scriptInfo = parseScriptInfo(script, scriptPath)
|
||||
|
||||
// ---- 日志与 HTTP 白名单 ----
|
||||
const log = typeof logOption === 'function'
|
||||
? logOption
|
||||
: logOption === false
|
||||
? () => {}
|
||||
: (msg) => console.log('[source-ivm]', msg)
|
||||
const scriptOrigins = extractAllowedOrigins(script)
|
||||
const origins = (allowedOrigins ?? scriptOrigins).map(o => String(o).replace(/\/+$/, ''))
|
||||
const httpAllowed = (urlStr) => {
|
||||
if (allowAllHttp) return true
|
||||
if (httpFilter) return !!httpFilter(urlStr)
|
||||
try { return origins.includes(new URL(urlStr).origin) } catch { return false }
|
||||
}
|
||||
log(`加载脚本 ${scriptPath}(http 白名单: ${origins.join(', ') || '(空, 全部阻止)'}${allowAllHttp ? ' + allowAllHttp' : ''}${httpFilter ? ' + httpFilter' : ''})`)
|
||||
|
||||
const isolate = new ivm.Isolate({ memoryLimit })
|
||||
// afterEvaluate:每次外部调用返回后推进隔离内的微任务队列,否则脚本内的 Promise 链永远不会执行
|
||||
const context = await isolate.createContext({ microtaskMode: 'afterEvaluate' })
|
||||
const jail = context.global
|
||||
|
||||
// ---- Node 侧状态 ----
|
||||
const state = {
|
||||
inited: null,
|
||||
updateAlerts: [],
|
||||
onRegistered: null,
|
||||
initWaiters: [],
|
||||
}
|
||||
const pendingDispatch = new Map() // dispatchId -> { resolve, reject }
|
||||
const httpRequests = new Map() // reqId -> requestObj
|
||||
const dispatchHttp = new Map() // dispatchId -> Set<requestObj>
|
||||
const nodeTimers = new Map() // timerId -> timeout对象
|
||||
let httpSeq = 0
|
||||
let dispatchSeq = 0
|
||||
let disposed = false
|
||||
|
||||
const forgetHttp = (reqId, dispatchId, requestObj) => {
|
||||
httpRequests.delete(reqId)
|
||||
const set = dispatchHttp.get(dispatchId)
|
||||
if (set) { set.delete(requestObj); if (!set.size) dispatchHttp.delete(dispatchId) }
|
||||
}
|
||||
|
||||
// jail.get 拿到的是 ivm.Function:apply 为同步调用(async 选项无效),
|
||||
// 隔离内异步结果一律经回调(__dispatchResult/__completeHttp 等)传回
|
||||
const safeApply = (fnRef, args) => {
|
||||
if (disposed) return
|
||||
try {
|
||||
fnRef.apply(undefined, args)
|
||||
} catch (err) {
|
||||
log(`isolate 调用失败: ${err?.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 桥接回调(Node ← isolate)----
|
||||
await jail.set('__log', new ivm.Callback((msg) => log(String(msg))))
|
||||
await jail.set('__on', new ivm.Callback((name) => {
|
||||
state.onRegistered = name
|
||||
log(`source.on(${String(name)})`)
|
||||
}))
|
||||
await jail.set('__send', new ivm.Callback((name, dataJson) => {
|
||||
let data = null
|
||||
try { data = JSON.parse(dataJson) } catch {}
|
||||
log(`source.send(${String(name)}) ${brief(dataJson ?? '')}`)
|
||||
if (name === 'inited') {
|
||||
state.inited = data
|
||||
const waiters = state.initWaiters.splice(0)
|
||||
waiters.forEach(w => w.resolve(data))
|
||||
} else if (name === 'updateAlert') {
|
||||
state.updateAlerts.push(data)
|
||||
}
|
||||
return '' // 非空字符串会以异常形式抛回 isolate
|
||||
}))
|
||||
await jail.set('__dispatchResult', new ivm.Callback((dispatchId, resultJson) => {
|
||||
const p = pendingDispatch.get(dispatchId)
|
||||
if (!p) return
|
||||
pendingDispatch.delete(dispatchId)
|
||||
let parsed
|
||||
try { parsed = JSON.parse(resultJson) } catch (err) { p.reject(err); return }
|
||||
if (parsed.ok) p.resolve(parsed)
|
||||
else p.reject(new Error(parsed.message || '获取失败'))
|
||||
}))
|
||||
await jail.set('__md5', new ivm.Callback((str) => {
|
||||
log(`source.utils.crypto.md5(${brief(str)})`)
|
||||
return crypto.createHash('md5').update(String(str)).digest('hex')
|
||||
}))
|
||||
await jail.set('__randomBytesHex', new ivm.Callback((size) => {
|
||||
log(`source.utils.crypto.randomBytes(${size})`)
|
||||
return crypto.randomBytes(size).toString('hex')
|
||||
}))
|
||||
await jail.set('__aesEncryptHex', new ivm.Callback((hexBuf, mode, hexKey, hexIv) => {
|
||||
log(`source.utils.crypto.aesEncrypt(${Buffer.from(hexBuf, 'hex').length}B, ${mode})`)
|
||||
const cipher = crypto.createCipheriv(mode, Buffer.from(hexKey, 'hex'), Buffer.from(hexIv, 'hex'))
|
||||
return Buffer.concat([cipher.update(Buffer.from(hexBuf, 'hex')), cipher.final()]).toString('hex')
|
||||
}))
|
||||
await jail.set('__rsaEncryptHex', new ivm.Callback((hexBuf, pemKey) => {
|
||||
log(`source.utils.crypto.rsaEncrypt(${Buffer.from(hexBuf, 'hex').length}B, key=${brief(pemKey, 24)})`)
|
||||
let buf = Buffer.from(hexBuf, 'hex')
|
||||
buf = Buffer.concat([Buffer.alloc(128 - buf.length), buf])
|
||||
return crypto.publicEncrypt({ key: pemKey, padding: crypto.constants.RSA_NO_PADDING }, buf).toString('hex')
|
||||
}))
|
||||
await jail.set('__zlib', new ivm.Callback((opId, op, hex) => {
|
||||
log(`source.utils.zlib.${op}(${Buffer.from(hex, 'hex').length}B)`)
|
||||
const buf = Buffer.from(hex, 'hex')
|
||||
zlib[op](buf, (err, out) => {
|
||||
safeApply(zlibDoneRef, [opId, err ? String(err.message) : null, err ? '' : out.toString('hex')])
|
||||
})
|
||||
}))
|
||||
await jail.set('__http', new ivm.Callback((dispatchId, url, optionsJson, cbId) => {
|
||||
const reqId = ++httpSeq
|
||||
const opts = JSON.parse(optionsJson)
|
||||
const method = String(opts.method || 'get').toUpperCase()
|
||||
if (!httpAllowed(url)) {
|
||||
log(`✋ source.request ${method} ${url} → 已阻止(不在白名单)`)
|
||||
safeApply(completeHttpRef, [cbId, `http blocked by allowlist: ${url}`, null, null])
|
||||
return reqId
|
||||
}
|
||||
log(`→ source.request ${method} ${brief(url, 120)}`)
|
||||
const startedAt = Date.now()
|
||||
const options = {
|
||||
method: opts.method || 'get',
|
||||
timeout: typeof opts.timeout === 'number' && opts.timeout > 0 ? opts.timeout : 15000,
|
||||
}
|
||||
if (opts.headers) options.headers = opts.headers
|
||||
if (opts.body) options.body = opts.body
|
||||
if (opts.form) options.form = JSON.parse(opts.form)
|
||||
const requestObj = httpImpl(String(url), options)
|
||||
httpRequests.set(reqId, requestObj)
|
||||
if (!dispatchHttp.has(dispatchId)) dispatchHttp.set(dispatchId, new Set())
|
||||
dispatchHttp.get(dispatchId).add(requestObj)
|
||||
requestObj.promise.then(({ statusCode, statusMessage, headers, body }) => {
|
||||
log(`← source.request ${method} ${brief(url, 80)} → ${statusCode}(${Date.now() - startedAt}ms)`)
|
||||
forgetHttp(reqId, dispatchId, requestObj)
|
||||
const resp = { statusCode, statusMessage: statusMessage || '', headers: headers || {}, body }
|
||||
return safeApply(completeHttpRef, [cbId, null, JSON.stringify(resp), JSON.stringify(body === undefined ? null : body)])
|
||||
}).catch(err => {
|
||||
log(`← source.request ${method} ${brief(url, 80)} → 失败(${Date.now() - startedAt}ms)${err?.message ? ' ' + err.message : ''}`)
|
||||
forgetHttp(reqId, dispatchId, requestObj)
|
||||
return safeApply(completeHttpRef, [cbId, String(err && err.message || err), null, null])
|
||||
})
|
||||
return reqId
|
||||
}))
|
||||
await jail.set('__cancelHttp', new ivm.Callback((reqId) => {
|
||||
const requestObj = httpRequests.get(reqId)
|
||||
if (requestObj) requestObj.cancelHttp()
|
||||
}))
|
||||
await jail.set('__startTimer', new ivm.Callback((id, ms, interval) => {
|
||||
const timerRef = interval
|
||||
? setInterval(() => safeApply(fireTimerRef, [id]), Math.max(1, ms))
|
||||
: setTimeout(() => safeApply(fireTimerRef, [id]), Math.max(0, ms))
|
||||
if (interval && typeof timerRef.unref === 'function') timerRef.unref()
|
||||
nodeTimers.set(id, timerRef)
|
||||
}))
|
||||
await jail.set('__stopTimer', new ivm.Callback((id) => {
|
||||
const t = nodeTimers.get(id)
|
||||
if (t) { clearTimeout(t); clearInterval(t); nodeTimers.delete(id) }
|
||||
}))
|
||||
|
||||
// ---- 注入胶水层与脚本元信息,再加载目标脚本 ----
|
||||
await jail.set('__scriptInfo', new ivm.ExternalCopy(scriptInfo).copyInto())
|
||||
await jail.set('SCRIPT_MD5', crypto.createHash('md5').update(script).digest('hex'))
|
||||
await context.eval(GLUE)
|
||||
const fireTimerRef = await jail.get('__fireTimer')
|
||||
const zlibDoneRef = await jail.get('__zlibDone')
|
||||
const completeHttpRef = await jail.get('__completeHttp')
|
||||
const dispatchRef = await jail.get('__dispatch')
|
||||
await context.eval(script, { async: true, timeout: 15000 })
|
||||
|
||||
// ---- 对外 dispatch ----
|
||||
const dispatch = (payload) => {
|
||||
if (disposed) return Promise.reject(new Error('isolate 已销毁'))
|
||||
const id = ++dispatchSeq
|
||||
return {
|
||||
id,
|
||||
promise: new Promise((resolve, reject) => {
|
||||
pendingDispatch.set(id, { resolve, reject })
|
||||
try {
|
||||
dispatchRef.apply(undefined, [id, JSON.stringify(payload)])
|
||||
} catch (err) {
|
||||
if (pendingDispatch.delete(id)) reject(err)
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const getMusicUrlFor = (source) => (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
const id = songInfo?.hash || songInfo?.songmid || '?'
|
||||
const dispatched = dispatch({ source, action: 'musicUrl', info: { type, musicInfo: songInfo } })
|
||||
const startedAt = Date.now()
|
||||
log(`▶ getMusicUrl ${source}/${type} id=${id}(dispatch #${dispatched.id})`)
|
||||
requestObj.cancelHttp = () => {
|
||||
const set = dispatchHttp.get(dispatched.id)
|
||||
if (set) [...set].forEach(r => r.cancelHttp())
|
||||
}
|
||||
requestObj.promise = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
requestObj.cancelHttp()
|
||||
pendingDispatch.delete(dispatched.id)
|
||||
reject(new Error(`请求超时 (${requestTimeout}ms)`))
|
||||
}, requestTimeout)
|
||||
timer.unref?.()
|
||||
dispatched.promise.then(result => {
|
||||
clearTimeout(timer)
|
||||
log(`✔ getMusicUrl ${source}/${type} 完成(${Date.now() - startedAt}ms)`)
|
||||
if (!result.url) reject(new Error('响应无播放地址'))
|
||||
else resolve({ type, url: result.url })
|
||||
}, err => {
|
||||
clearTimeout(timer)
|
||||
log(`✖ getMusicUrl ${source}/${type} 失败(${Date.now() - startedAt}ms)${err?.message || ''}`)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
// ---- 等待脚本 inited ----
|
||||
const inited = await new Promise((resolve, reject) => {
|
||||
if (state.inited) return resolve(state.inited)
|
||||
const waiter = { resolve, reject }
|
||||
state.initWaiters.push(waiter)
|
||||
const t = setTimeout(() => {
|
||||
const i = state.initWaiters.indexOf(waiter)
|
||||
if (i >= 0) state.initWaiters.splice(i, 1)
|
||||
reject(new Error(`脚本 ${initTimeout}ms 内未上报 inited(handler 注册: ${state.onRegistered || '无'})`))
|
||||
}, initTimeout)
|
||||
t.unref?.()
|
||||
}).catch(async err => {
|
||||
await destroy()
|
||||
throw err
|
||||
})
|
||||
|
||||
// 按脚本上报的 sources 生成分平台接口(过滤 local 这类服务器侧无意义条目仍保留键,但 local 无 qualitys)
|
||||
const reportedSources = inited?.sources && typeof inited.sources === 'object' ? Object.keys(inited.sources) : []
|
||||
const sources = reportedSources.length ? reportedSources : ['kw', 'kg', 'tx', 'wy', 'mg']
|
||||
const apis = {}
|
||||
for (const s of sources) apis[s] = { getMusicUrl: getMusicUrlFor(s) }
|
||||
|
||||
function destroy() {
|
||||
if (disposed) return Promise.resolve()
|
||||
disposed = true
|
||||
for (const t of nodeTimers.values()) { clearTimeout(t); clearInterval(t) }
|
||||
nodeTimers.clear()
|
||||
for (const r of httpRequests.values()) { try { r.cancelHttp() } catch {} }
|
||||
httpRequests.clear()
|
||||
dispatchHttp.clear()
|
||||
for (const p of pendingDispatch.values()) p.reject(new Error('isolate 已销毁'))
|
||||
pendingDispatch.clear()
|
||||
for (const w of state.initWaiters.splice(0)) w.reject(new Error('isolate 已销毁'))
|
||||
try { isolate.dispose() } catch {}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return {
|
||||
env: 'isolated-vm',
|
||||
scriptInfo: { name: scriptInfo.name, version: scriptInfo.version, author: scriptInfo.author, path: scriptPath },
|
||||
inited,
|
||||
apis,
|
||||
allowedOrigins: origins,
|
||||
...apis,
|
||||
destroy,
|
||||
}
|
||||
}
|
||||
|
||||
export default { createIvmSource }
|
||||
@@ -1,76 +0,0 @@
|
||||
// 外部聚合音源统一入口(isolated-vm 懒加载封装)
|
||||
// 内部委托 source-ivm.js 在硬隔离沙箱中执行音源脚本。
|
||||
// 首次调用 getMusicUrl 时才加载并初始化隔离环境,后续复用同一实例。
|
||||
// 统一接口约定:source.kw.getMusicUrl(songInfo, type) → { promise, cancelHttp },resolve → { type, url }
|
||||
//
|
||||
// 脚本来源(优先级):
|
||||
// 1. 环境变量 SOURCE_SCRIPT_PATH 指定的脚本文件
|
||||
// 2. sources/ 目录下按文件名排序的第一个 .js(脚本文件不入库,见 .gitignore)
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createIvmSource } from './source-ivm.js'
|
||||
|
||||
export const SOURCES = ['kw', 'kg', 'tx', 'wy', 'mg']
|
||||
|
||||
const SOURCES_DIR = fileURLToPath(new URL('../../sources/', import.meta.url))
|
||||
|
||||
const resolveScriptPath = () => {
|
||||
const custom = process.env.SOURCE_SCRIPT_PATH
|
||||
if (custom) return custom
|
||||
if (!fs.existsSync(SOURCES_DIR)) {
|
||||
throw new Error(`音源目录不存在: ${SOURCES_DIR}(将脚本放入 sources/ 目录,或用 SOURCE_SCRIPT_PATH 指定)`)
|
||||
}
|
||||
const files = fs.readdirSync(SOURCES_DIR).filter(f => f.toLowerCase().endsWith('.js')).sort()
|
||||
if (!files.length) {
|
||||
throw new Error('sources/ 目录下没有 .js 音源脚本(将脚本放入该目录,或用 SOURCE_SCRIPT_PATH 指定)')
|
||||
}
|
||||
if (files.length > 1) {
|
||||
console.log(`[source] sources/ 下有 ${files.length} 个脚本,使用 ${files[0]}(可用 SOURCE_SCRIPT_PATH 指定)`)
|
||||
}
|
||||
return path.join(SOURCES_DIR, files[0])
|
||||
}
|
||||
|
||||
let sharedPromise = null
|
||||
|
||||
const getShared = () => {
|
||||
if (!sharedPromise) {
|
||||
// 配置可经环境变量调整,与项目其它适配器保持一致风格
|
||||
sharedPromise = createIvmSource({
|
||||
scriptPath: resolveScriptPath(),
|
||||
log: process.env.SOURCE_LOG !== '0',
|
||||
allowAllHttp: process.env.SOURCE_ALLOW_ALL_HTTP === '1',
|
||||
}).catch(err => {
|
||||
sharedPromise = null // 失败允许下次重试
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return sharedPromise
|
||||
}
|
||||
|
||||
const apiFor = (source) => ({
|
||||
getMusicUrl(songInfo, type) {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
let inner = null
|
||||
requestObj.cancelHttp = () => inner?.cancelHttp()
|
||||
requestObj.promise = (async () => {
|
||||
const api = await getShared()
|
||||
if (!api[source]) throw new Error(`脚本不支持的平台: ${source}`)
|
||||
inner = api[source].getMusicUrl(songInfo, type)
|
||||
return inner.promise
|
||||
})()
|
||||
return requestObj
|
||||
},
|
||||
})
|
||||
|
||||
// 兼容导出:musicSdk/api-source.js 以 createApi(source) 方式接入聚合音源
|
||||
export const createApi = apiFor
|
||||
|
||||
const sourceApi = {}
|
||||
for (const s of SOURCES) sourceApi[s] = apiFor(s)
|
||||
|
||||
// 预热:提前触发脚本加载与初始化(可选调用)
|
||||
sourceApi.init = getShared
|
||||
|
||||
export default sourceApi
|
||||
@@ -1,55 +0,0 @@
|
||||
// QQ音乐 URL 获取(直连)
|
||||
// 接口: https://u.y.qq.com/cgi-bin/musicu.fcg
|
||||
// 方法: vkey.GetVkeyServer.CgiGetVkey
|
||||
// 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../common/request.js'
|
||||
|
||||
const buildData = songmid => ({
|
||||
comm: {
|
||||
ct: '19',
|
||||
cv: '1859',
|
||||
uin: '0',
|
||||
},
|
||||
req: {
|
||||
module: 'vkey.GetVkeyServer',
|
||||
method: 'CgiGetVkey',
|
||||
param: {
|
||||
guid: '7342827269',
|
||||
songmid: [songmid],
|
||||
songtype: [0],
|
||||
uin: '0',
|
||||
loginflag: 1,
|
||||
platform: '20',
|
||||
needNewCode: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
requestObj.promise = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
|
||||
Referer: 'https://y.qq.com/',
|
||||
},
|
||||
body: buildData(songInfo.songmid),
|
||||
timeout: 15000,
|
||||
}).promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.code != 0 || body.req?.code != 0) {
|
||||
return Promise.reject(new Error('获取失败'))
|
||||
}
|
||||
const data = body.req.data
|
||||
const sip = data.sip?.[0] || 'http://aqqmusic.tc.qq.com/'
|
||||
// 格式: C400{media_mid}.m4a?guid=&vkey=&uin=&fromtag=
|
||||
const params = data.testfile2g?.split('?')[1] || ''
|
||||
const url = `${sip}C400${songInfo.songmid}.m4a?${params}`
|
||||
if (!params) return Promise.reject(new Error('获取失败'))
|
||||
return { type, url }
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
@@ -1,47 +0,0 @@
|
||||
// 网易云音乐 URL 获取(直连)
|
||||
// 接口: https://interface3.music.163.com/eapi/song/enhance/player/url/v1(eapi 加密请求,响应为明文 JSON)
|
||||
// 音质: 128k → standard, 320k → exhigh, flac → lossless
|
||||
// 统一接口约定:apis('wy').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { eapi } from '../../musicSdk/wy/utils/crypto.ts'
|
||||
import { getWyCookie } from '../auth/store.js'
|
||||
|
||||
const levelMap = {
|
||||
'128k': 'standard',
|
||||
'320k': 'exhigh',
|
||||
flac: 'lossless',
|
||||
flac24bit: 'hires',
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
const data = {
|
||||
ids: `[${songInfo.songmid}]`,
|
||||
level: levelMap[type] || 'standard',
|
||||
encodeType: 'flac',
|
||||
}
|
||||
const cookie = getWyCookie()
|
||||
const request = httpFetch('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: eapi('/api/song/enhance/player/url/v1', data),
|
||||
timeout: 15000,
|
||||
})
|
||||
requestObj.promise = request.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.code != 200) {
|
||||
return Promise.reject(new Error('获取失败'))
|
||||
}
|
||||
const item = body.data?.[0]
|
||||
if (!item?.url) return Promise.reject(new Error('获取失败'))
|
||||
return { type, url: item.url }
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐)
|
||||
// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展;
|
||||
// token 可从酷狗 App 抓包获取(login.token)
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { getCredentials, setCredentials } from './store.js'
|
||||
|
||||
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
|
||||
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
|
||||
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
|
||||
const APPID = 1005
|
||||
const CLIENTVER = 11430
|
||||
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
const calculateMid = (guid) => BigInt(`0x${md5(guid)}`).toString()
|
||||
const randomString = (n) => {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
}
|
||||
|
||||
export const androidSign = (params, data = '') => {
|
||||
const s = Object.keys(params).sort()
|
||||
.map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`)
|
||||
.join('')
|
||||
return md5(SALT_SIGN + s + data + SALT_SIGN)
|
||||
}
|
||||
|
||||
/** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */
|
||||
export const getDevice = () => {
|
||||
const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) }
|
||||
if (!dev.mid) {
|
||||
dev.mid = calculateMid(dev.guid)
|
||||
setCredentials('_kg_device', dev)
|
||||
}
|
||||
return dev
|
||||
}
|
||||
|
||||
/** 网关移动端签名请求(gateway.kugou.com + x-router) */
|
||||
const gatewayRequest = async(url, { params: extraParams, data, router }) => {
|
||||
const dev = getDevice()
|
||||
const params = {
|
||||
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
||||
clienttime: Math.floor(Date.now() / 1000),
|
||||
...extraParams,
|
||||
}
|
||||
const signature = androidSign(params, data ? JSON.stringify(data) : '')
|
||||
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
|
||||
const r = await httpFetch(`https://gateway.kugou.com${url}?${qs}&signature=${signature}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
|
||||
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
|
||||
'x-router': router,
|
||||
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
|
||||
},
|
||||
body: data ?? undefined,
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
return r.body ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 token 并返回账号资料(usercenter /v3/get_my_info)
|
||||
* p = RSA 原始加密(零填充 128 字节){ token, clienttime }
|
||||
*/
|
||||
export const verifyToken = async({ token, userid }) => {
|
||||
const unixSec = Math.floor(Date.now() / 1000)
|
||||
const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec }))
|
||||
const padded = Buffer.concat([Buffer.alloc(128 - payload.length), payload])
|
||||
const p = crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex').toUpperCase()
|
||||
|
||||
const body = await gatewayRequest('/v3/get_my_info', {
|
||||
router: 'usercenter.kugou.com',
|
||||
params: { plat: 1 },
|
||||
data: { visit_time: unixSec, usertype: 1, p, userid: Number(userid) || 0 },
|
||||
})
|
||||
if (body.status !== 1 || !body.data) {
|
||||
const err = new Error(body.error ?? 'token 无效或已过期')
|
||||
err.status = 401
|
||||
throw err
|
||||
}
|
||||
return {
|
||||
userid: body.data.userid ?? Number(userid),
|
||||
nickname: body.data.nickname ?? body.data.username ?? null,
|
||||
vip: body.data.vip ? Boolean(body.data.vip) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定并验证 kg token */
|
||||
export const loginByToken = async({ token, userid }) => {
|
||||
const profile = await verifyToken({ token, userid })
|
||||
setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname })
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 当前 kg 登录资料(未登录返回 null) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('kg')
|
||||
return cred?.token ? { userid: cred.userid, nickname: cred.nickname ?? null } : null
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: {...}, kg: {...}, _kg_device: {...} }
|
||||
// 注意:本文件经 tsc 就地发射 store.js 供纯 JS 模块引用(与 src/apis/index.ts 同约定)
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json');
|
||||
let cache = {};
|
||||
let loaded = false;
|
||||
const load = () => {
|
||||
if (loaded)
|
||||
return cache;
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {};
|
||||
}
|
||||
catch {
|
||||
cache = {};
|
||||
}
|
||||
loaded = true;
|
||||
return cache;
|
||||
};
|
||||
const save = (data) => {
|
||||
cache = data;
|
||||
loaded = true;
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true });
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
||||
};
|
||||
export const getCredentials = (platform) => load()[platform] ?? null;
|
||||
export const setCredentials = (platform, credentials) => {
|
||||
const data = load();
|
||||
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() };
|
||||
save(data);
|
||||
};
|
||||
export const clearCredentials = (platform) => {
|
||||
const data = load();
|
||||
delete data[platform];
|
||||
save(data);
|
||||
};
|
||||
/** wy 登录 cookie 字符串(未登录返回空串) */
|
||||
export const getWyCookie = () => getCredentials('wy')?.cookie || '';
|
||||
/** kg 登录态(未登录返回 null) */
|
||||
export const getKgAuth = () => {
|
||||
const cred = getCredentials('kg');
|
||||
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null;
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: {...}, kg: {...}, _kg_device: {...} }
|
||||
// 注意:本文件经 tsc 就地发射 store.js 供纯 JS 模块引用(与 src/apis/index.ts 同约定)
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
|
||||
|
||||
export interface WyCredentials {
|
||||
cookie: string
|
||||
musicU: string
|
||||
userid?: number
|
||||
nickname?: string | null
|
||||
avatar?: string | null
|
||||
vipType?: number | null
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface KgCredentials {
|
||||
token: string
|
||||
userid: number | string
|
||||
nickname?: string | null
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface KgDevice {
|
||||
guid: string
|
||||
mid: string
|
||||
dfid: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
let cache: Record<string, any> = {}
|
||||
let loaded = false
|
||||
|
||||
const load = (): Record<string, any> => {
|
||||
if (loaded) return cache
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
|
||||
} catch {
|
||||
cache = {}
|
||||
}
|
||||
loaded = true
|
||||
return cache
|
||||
}
|
||||
|
||||
const save = (data: Record<string, any>): void => {
|
||||
cache = data
|
||||
loaded = true
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true })
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export const getCredentials = (platform: string): any => load()[platform] ?? null
|
||||
|
||||
export const setCredentials = (platform: string, credentials: Record<string, any>): void => {
|
||||
const data = load()
|
||||
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
|
||||
save(data)
|
||||
}
|
||||
|
||||
export const clearCredentials = (platform: string): void => {
|
||||
const data = load()
|
||||
delete data[platform]
|
||||
save(data)
|
||||
}
|
||||
|
||||
/** wy 登录 cookie 字符串(未登录返回空串) */
|
||||
export const getWyCookie = (): string => (getCredentials('wy') as WyCredentials | null)?.cookie || ''
|
||||
|
||||
/** kg 登录态(未登录返回 null) */
|
||||
export const getKgAuth = (): { token: string; userid: number | string } | null => {
|
||||
const cred = getCredentials('kg') as KgCredentials | null
|
||||
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
// 网易云登录 —— 扫码 / 手机号,weapi 加密请求
|
||||
// 参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { weapi } from '../../musicSdk/wy/utils/crypto.ts'
|
||||
import { getWyCookie, setCredentials } from './store.js'
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
|
||||
const requestWeapi = async(path, data, cookie = getWyCookie()) => {
|
||||
const r = await httpFetch(`https://music.163.com/weapi${path}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
origin: 'https://music.163.com',
|
||||
Referer: 'https://music.163.com/',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi(data),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (r.statusCode !== 200) throw new Error('网易请求失败')
|
||||
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
|
||||
}
|
||||
|
||||
/** 明文 api 请求(二维码接口只提供该形态,weapi 形态已 404) */
|
||||
const requestApi = async(path, data) => {
|
||||
const r = await httpFetch(`https://music.163.com${path}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
origin: 'https://music.163.com',
|
||||
Referer: 'https://music.163.com/',
|
||||
},
|
||||
form: data,
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (r.statusCode !== 200) throw new Error('网易请求失败')
|
||||
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
|
||||
}
|
||||
|
||||
/** 从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U */
|
||||
const extractLoginCookie = (setCookie) => {
|
||||
const joined = setCookie.map(c => c.split(';')[0]).filter(Boolean).join('; ')
|
||||
const musicU = /MUSIC_U=([^;]+)/.exec(joined)?.[1]
|
||||
return { cookie: joined, musicU }
|
||||
}
|
||||
|
||||
/** 登录成功后拉取账号资料 */
|
||||
const fetchProfile = async() => {
|
||||
const { body } = await requestWeapi('/api/w/nuser/account/get', {})
|
||||
return {
|
||||
userid: body?.account?.id ?? null,
|
||||
nickname: body?.profile?.nickname ?? null,
|
||||
avatar: body?.profile?.avatarUrl ?? null,
|
||||
vipType: body?.profile?.vipType ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用登录 cookie(成功后调用) */
|
||||
const applyLogin = async(setCookie) => {
|
||||
const { cookie, musicU } = extractLoginCookie(setCookie)
|
||||
if (!musicU) throw new Error('登录响应中缺少 MUSIC_U')
|
||||
const profile = await fetchProfile()
|
||||
setCredentials('wy', { cookie, musicU, ...profile })
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 创建扫码登录 key 与二维码内容 */
|
||||
export const qrKey = async() => {
|
||||
const { body } = await requestApi('/api/login/qrcode/unikey', { type: 3 })
|
||||
if (body?.code !== 200 || !body.unikey) throw new Error('获取二维码 key 失败')
|
||||
const key = body.unikey
|
||||
const qrurl = `https://music.163.com/login?codekey=${key}`
|
||||
const qrimg = await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '')
|
||||
return { key, qrurl, qrimg }
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询扫码状态
|
||||
* @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)
|
||||
*/
|
||||
export const qrCheck = async(key) => {
|
||||
const { body, setCookie } = await requestApi('/api/login/qrcode/client/login', { key, type: 3 })
|
||||
if (body.code === 803) {
|
||||
const profile = await applyLogin(setCookie)
|
||||
return { code: 803, message: '登录成功', profile }
|
||||
}
|
||||
const messages = { 800: '二维码已过期', 801: '等待扫码', 802: '已扫码,待确认' }
|
||||
return { code: body.code, message: messages[body.code] ?? body.message ?? '未知状态' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号登录(密码为明文,传输前本地 MD5;或直接传验证码 captcha)
|
||||
*/
|
||||
export const cellphoneLogin = async({ phone, password, captcha, countrycode = '86' }) => {
|
||||
const data = {
|
||||
type: '1',
|
||||
https: 'true',
|
||||
phone,
|
||||
countrycode,
|
||||
remember: 'true',
|
||||
[captcha ? 'captcha' : 'password']: captcha ?? crypto.createHash('md5').update(password ?? '').digest('hex'),
|
||||
}
|
||||
const { body, setCookie } = await requestWeapi('/api/w/login/cellphone', data)
|
||||
if (body.code !== 200) {
|
||||
const err = new Error(body.message ?? body.msg ?? '登录失败')
|
||||
err.status = 401
|
||||
throw err
|
||||
}
|
||||
const profile = await applyLogin(setCookie)
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 当前账号资料(未登录返回 null) */
|
||||
export const status = async() => {
|
||||
if (!getWyCookie()) return null
|
||||
return fetchProfile()
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// IPC 事件名
|
||||
|
||||
export const WIN_MAIN_RENDERER_EVENT_NAME = {
|
||||
handle_kw_decode_lyric: 'handle_kw_decode_lyric',
|
||||
handle_tx_decode_lyric: 'handle_tx_decode_lyric',
|
||||
}
|
||||
|
||||
export default { WIN_MAIN_RENDERER_EVENT_NAME }
|
||||
@@ -1,66 +0,0 @@
|
||||
// 歌词解码(原主进程 IPC 处理器的纯 JS 实现)
|
||||
// - handle_kw_decode_lyric: 酷我歌词(inflate + yeelion XOR + gb18030)
|
||||
// 算法来源: kw 桌面端歌词解码实现
|
||||
// - handle_tx_decode_lyric: QQ音乐歌词(qrc 解码:hex → XOR 偏移 → deflate)
|
||||
// qrc 算法来源: 开源 qrc 解码器(ZhuYiQing/qrcDecode 纯 JS 版)
|
||||
|
||||
import zlib from 'node:zlib'
|
||||
import iconv from 'iconv-lite'
|
||||
|
||||
// ==================== 酷我 ====================
|
||||
|
||||
const handleInflate = data => new Promise((resolve, reject) => {
|
||||
zlib.inflate(data, (err, result) => {
|
||||
if (err) return reject(err)
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
|
||||
const buf_key = Buffer.from('yeelion')
|
||||
const buf_key_len = buf_key.length
|
||||
|
||||
const decodeKwLyric = async(buf, isGetLyricx) => {
|
||||
if (buf.toString('utf8', 0, 10) != 'tp=content') return ''
|
||||
const lrcData = await handleInflate(buf.subarray(buf.indexOf('\r\n\r\n') + 4))
|
||||
|
||||
if (!isGetLyricx) return iconv.decode(lrcData, 'gb18030')
|
||||
|
||||
const buf_str = Buffer.from(lrcData.toString(), 'base64')
|
||||
const buf_str_len = buf_str.length
|
||||
const output = new Uint8Array(buf_str_len)
|
||||
let i = 0
|
||||
while (i < buf_str_len) {
|
||||
let j = 0
|
||||
while (j < buf_key_len && i < buf_str_len) {
|
||||
output[i] = buf_str[i] ^ buf_key[j]
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
|
||||
return iconv.decode(Buffer.from(output), 'gb18030')
|
||||
}
|
||||
|
||||
/**
|
||||
* 酷我歌词解码
|
||||
* @param {{ lrcBase64: string, isGetLyricx: boolean }} params
|
||||
* @returns {Promise<string>} 解码后歌词的 base64
|
||||
*/
|
||||
export const handleKwDecodeLyric = async({ lrcBase64, isGetLyricx }) => {
|
||||
const lrc = await decodeKwLyric(Buffer.from(lrcBase64, 'base64'), isGetLyricx)
|
||||
return Buffer.from(lrc).toString('base64')
|
||||
}
|
||||
|
||||
// ==================== QQ音乐(qrc) ====================
|
||||
|
||||
import { decodeQrc } from './qrcDecode.js'
|
||||
|
||||
/**
|
||||
* QQ音乐歌词解码
|
||||
* @param {{ lrc: string, tlrc: string, rlrc: string }} params 均为十六进制字符串
|
||||
* @returns {Promise<{lyric: string, tlyric: string, rlyric: string}>}
|
||||
*/
|
||||
export const handleTxDecodeLyric = async({ lrc, tlrc, rlrc }) => {
|
||||
const [lyric, tlyric, rlyric] = await Promise.all([decodeQrc(lrc), decodeQrc(tlrc), decodeQrc(rlrc)])
|
||||
return { lyric, tlyric, rlyric }
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// 酷狗 krc 歌词解码
|
||||
|
||||
import zlib from 'node:zlib'
|
||||
import { decodeName } from '../utils.js'
|
||||
|
||||
// 基于社区通用的 krc 解析思路
|
||||
const enc_key = Buffer.from([0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69], 'binary')
|
||||
|
||||
const decodeLyric = str => new Promise((resolve, reject) => {
|
||||
if (!str.length) return
|
||||
const buf_str = Buffer.from(str, 'base64').subarray(4)
|
||||
for (let i = 0, len = buf_str.length; i < len; i++) {
|
||||
buf_str[i] = buf_str[i] ^ enc_key[i % 16]
|
||||
}
|
||||
zlib.inflate(buf_str, (err, result) => {
|
||||
if (err) return reject(err)
|
||||
resolve(result.toString())
|
||||
})
|
||||
})
|
||||
|
||||
const headExp = /^.*\[id:\$\w+\]\n/
|
||||
|
||||
const parseLyric = str => {
|
||||
str = str.replace(/\r/g, '')
|
||||
if (headExp.test(str)) str = str.replace(headExp, '')
|
||||
let trans = str.match(/\[language:([\w=\\/+]+)\]/)
|
||||
let lyric
|
||||
let rlyric
|
||||
let tlyric
|
||||
if (trans) {
|
||||
str = str.replace(/\[language:[\w=\\/+]+\]\n/, '')
|
||||
let json = JSON.parse(Buffer.from(trans[1], 'base64').toString())
|
||||
for (const item of json.content) {
|
||||
switch (item.type) {
|
||||
case 0:
|
||||
rlyric = item.lyricContent
|
||||
break
|
||||
case 1:
|
||||
tlyric = item.lyricContent
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = 0
|
||||
let yrc = str.replace(/\[((\d+),\d+)\].*/g, str => {
|
||||
let result = str.match(/\[((\d+),\d+)\].*/)
|
||||
let time = parseInt(result[2])
|
||||
let ms = time % 1000
|
||||
time /= 1000
|
||||
let m = parseInt(time / 60).toString().padStart(2, '0')
|
||||
time %= 60
|
||||
let s = parseInt(time).toString().padStart(2, '0')
|
||||
time = `${m}:${s}.${ms}`
|
||||
if (rlyric) rlyric[i] = `[${time}]${rlyric[i]?.join('') ?? ''}`
|
||||
if (tlyric) tlyric[i] = `[${time}]${tlyric[i]?.join('') ?? ''}`
|
||||
i++
|
||||
return str.replace(result[1], time)
|
||||
})
|
||||
rlyric = rlyric ? rlyric.join('\n') : ''
|
||||
tlyric = tlyric ? tlyric.join('\n') : ''
|
||||
yrc = yrc.replace(/<(\d+,\d+),\d+>/g, '<$1>')
|
||||
yrc = decodeName(yrc)
|
||||
lyric = yrc.replace(/<\d+,\d+>/g, '')
|
||||
rlyric = decodeName(rlyric)
|
||||
tlyric = decodeName(tlyric)
|
||||
return {
|
||||
lyric,
|
||||
tlyric,
|
||||
rlyric,
|
||||
yrc,
|
||||
}
|
||||
}
|
||||
|
||||
export const decodeKrc = async data => {
|
||||
return decodeLyric(data).then(parseLyric)
|
||||
}
|
||||
|
||||
export default { decodeKrc }
|
||||
@@ -1,10 +0,0 @@
|
||||
// 请求提示消息
|
||||
|
||||
export const requestMsg = {
|
||||
cancelRequest: '请求已取消',
|
||||
fail: '请求失败',
|
||||
timeout: '请求超时',
|
||||
tooManyRequests: '请求过多',
|
||||
notConnectNetwork: '无法连接到服务器',
|
||||
unachievable: '无法访问',
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { inflate, constants } from 'zlib'
|
||||
|
||||
/**
|
||||
* QRC 歌词解密(3DES-ECB + zlib inflate)
|
||||
*
|
||||
* 由原 C++ native 插件(qrc_decode.node)1:1 移植为纯 JS 实现。
|
||||
* 加密算法为标准 3DES(DES-EDE3,ECB 模式、无填充),密钥固定。
|
||||
* 数据流程:hex 字符串 -> 字节 -> 3DES 解密 -> zlib inflate -> UTF-8 文本。
|
||||
*/
|
||||
|
||||
const DES_ENCRYPT = 1
|
||||
const DES_DECRYPT = 0
|
||||
|
||||
// DES S-box。注意:QRC 使用的是非标准 DES 变体,
|
||||
// 其中 S2[23]=15、S4[53]=10 两处与标准 DES 不同(标准为 14、1)。
|
||||
// 这两个值与原 native 插件(qrc_decode.node)二进制完全一致,不可改为标准值,
|
||||
// 否则解密结果错误。下表逐字节对应原 C++ 实现。
|
||||
const sbox = [
|
||||
[
|
||||
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
|
||||
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13,
|
||||
],
|
||||
[
|
||||
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10, 6, 9, 11, 5,
|
||||
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9,
|
||||
],
|
||||
[
|
||||
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
|
||||
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12,
|
||||
],
|
||||
[
|
||||
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
|
||||
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14,
|
||||
],
|
||||
[
|
||||
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
|
||||
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3,
|
||||
],
|
||||
[
|
||||
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
|
||||
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13,
|
||||
],
|
||||
[
|
||||
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
|
||||
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12,
|
||||
],
|
||||
[
|
||||
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
|
||||
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11,
|
||||
],
|
||||
]
|
||||
|
||||
const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
|
||||
const key_perm_c = [
|
||||
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35,
|
||||
]
|
||||
const key_perm_d = [
|
||||
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3,
|
||||
]
|
||||
const key_compression = [
|
||||
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51, 30, 36, 46,
|
||||
54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31,
|
||||
]
|
||||
|
||||
// 全部以无符号 32 位处理,>>> 0 保证溢出行为与 C++ uint32_t 一致
|
||||
const bitnum = (a, b, c) => (((a[((b / 32) | 0) * 4 + 3 - (((b % 32) / 8) | 0)] >>> (7 - (b % 8))) & 1) << c) >>> 0
|
||||
|
||||
const bitnum_intr = (a, b, c) => (((a >>> (31 - b)) & 1) << c) >>> 0
|
||||
|
||||
const bitnum_intl = (a, b, c) => (((((a << b) >>> 0) & 0x80000000) >>> 0) >>> c) >>> 0
|
||||
|
||||
const sbox_bit = a => (a & 32) | ((a & 31) >>> 1) | ((a & 1) << 4)
|
||||
|
||||
// 初始置换:返回 [s0, s1]
|
||||
const initial_permutation = (input) => {
|
||||
const s0 = (
|
||||
bitnum(input, 57, 31) | bitnum(input, 49, 30) | bitnum(input, 41, 29) | bitnum(input, 33, 28) |
|
||||
bitnum(input, 25, 27) | bitnum(input, 17, 26) | bitnum(input, 9, 25) | bitnum(input, 1, 24) |
|
||||
bitnum(input, 59, 23) | bitnum(input, 51, 22) | bitnum(input, 43, 21) | bitnum(input, 35, 20) |
|
||||
bitnum(input, 27, 19) | bitnum(input, 19, 18) | bitnum(input, 11, 17) | bitnum(input, 3, 16) |
|
||||
bitnum(input, 61, 15) | bitnum(input, 53, 14) | bitnum(input, 45, 13) | bitnum(input, 37, 12) |
|
||||
bitnum(input, 29, 11) | bitnum(input, 21, 10) | bitnum(input, 13, 9) | bitnum(input, 5, 8) |
|
||||
bitnum(input, 63, 7) | bitnum(input, 55, 6) | bitnum(input, 47, 5) | bitnum(input, 39, 4) |
|
||||
bitnum(input, 31, 3) | bitnum(input, 23, 2) | bitnum(input, 15, 1) | bitnum(input, 7, 0)
|
||||
) >>> 0
|
||||
const s1 = (
|
||||
bitnum(input, 56, 31) | bitnum(input, 48, 30) | bitnum(input, 40, 29) | bitnum(input, 32, 28) |
|
||||
bitnum(input, 24, 27) | bitnum(input, 16, 26) | bitnum(input, 8, 25) | bitnum(input, 0, 24) |
|
||||
bitnum(input, 58, 23) | bitnum(input, 50, 22) | bitnum(input, 42, 21) | bitnum(input, 34, 20) |
|
||||
bitnum(input, 26, 19) | bitnum(input, 18, 18) | bitnum(input, 10, 17) | bitnum(input, 2, 16) |
|
||||
bitnum(input, 60, 15) | bitnum(input, 52, 14) | bitnum(input, 44, 13) | bitnum(input, 36, 12) |
|
||||
bitnum(input, 28, 11) | bitnum(input, 20, 10) | bitnum(input, 12, 9) | bitnum(input, 4, 8) |
|
||||
bitnum(input, 62, 7) | bitnum(input, 54, 6) | bitnum(input, 46, 5) | bitnum(input, 38, 4) |
|
||||
bitnum(input, 30, 3) | bitnum(input, 22, 2) | bitnum(input, 14, 1) | bitnum(input, 6, 0)
|
||||
) >>> 0
|
||||
return [s0, s1]
|
||||
}
|
||||
|
||||
// 逆置换:将 s0/s1 写回 8 字节 out
|
||||
const inverse_permutation = (s0, s1, out) => {
|
||||
out[3] = (bitnum_intr(s1, 7, 7) | bitnum_intr(s0, 7, 6) | bitnum_intr(s1, 15, 5) | bitnum_intr(s0, 15, 4) | bitnum_intr(s1, 23, 3) | bitnum_intr(s0, 23, 2) | bitnum_intr(s1, 31, 1) | bitnum_intr(s0, 31, 0)) & 0xff
|
||||
out[2] = (bitnum_intr(s1, 6, 7) | bitnum_intr(s0, 6, 6) | bitnum_intr(s1, 14, 5) | bitnum_intr(s0, 14, 4) | bitnum_intr(s1, 22, 3) | bitnum_intr(s0, 22, 2) | bitnum_intr(s1, 30, 1) | bitnum_intr(s0, 30, 0)) & 0xff
|
||||
out[1] = (bitnum_intr(s1, 5, 7) | bitnum_intr(s0, 5, 6) | bitnum_intr(s1, 13, 5) | bitnum_intr(s0, 13, 4) | bitnum_intr(s1, 21, 3) | bitnum_intr(s0, 21, 2) | bitnum_intr(s1, 29, 1) | bitnum_intr(s0, 29, 0)) & 0xff
|
||||
out[0] = (bitnum_intr(s1, 4, 7) | bitnum_intr(s0, 4, 6) | bitnum_intr(s1, 12, 5) | bitnum_intr(s0, 12, 4) | bitnum_intr(s1, 20, 3) | bitnum_intr(s0, 20, 2) | bitnum_intr(s1, 28, 1) | bitnum_intr(s0, 28, 0)) & 0xff
|
||||
out[7] = (bitnum_intr(s1, 3, 7) | bitnum_intr(s0, 3, 6) | bitnum_intr(s1, 11, 5) | bitnum_intr(s0, 11, 4) | bitnum_intr(s1, 19, 3) | bitnum_intr(s0, 19, 2) | bitnum_intr(s1, 27, 1) | bitnum_intr(s0, 27, 0)) & 0xff
|
||||
out[6] = (bitnum_intr(s1, 2, 7) | bitnum_intr(s0, 2, 6) | bitnum_intr(s1, 10, 5) | bitnum_intr(s0, 10, 4) | bitnum_intr(s1, 18, 3) | bitnum_intr(s0, 18, 2) | bitnum_intr(s1, 26, 1) | bitnum_intr(s0, 26, 0)) & 0xff
|
||||
out[5] = (bitnum_intr(s1, 1, 7) | bitnum_intr(s0, 1, 6) | bitnum_intr(s1, 9, 5) | bitnum_intr(s0, 9, 4) | bitnum_intr(s1, 17, 3) | bitnum_intr(s0, 17, 2) | bitnum_intr(s1, 25, 1) | bitnum_intr(s0, 25, 0)) & 0xff
|
||||
out[4] = (bitnum_intr(s1, 0, 7) | bitnum_intr(s0, 0, 6) | bitnum_intr(s1, 8, 5) | bitnum_intr(s0, 8, 4) | bitnum_intr(s1, 16, 3) | bitnum_intr(s0, 16, 2) | bitnum_intr(s1, 24, 1) | bitnum_intr(s0, 24, 0)) & 0xff
|
||||
}
|
||||
|
||||
// Feistel 轮函数 f(state, key)
|
||||
const des_f = (state, key) => {
|
||||
const t1 = (
|
||||
bitnum_intl(state, 31, 0) | (((state & 0xF0000000) >>> 0) >>> 1) | bitnum_intl(state, 4, 5) |
|
||||
bitnum_intl(state, 3, 6) | (((state & 0x0F000000) >>> 0) >>> 3) | bitnum_intl(state, 8, 11) |
|
||||
bitnum_intl(state, 7, 12) | (((state & 0x00F00000) >>> 0) >>> 5) | bitnum_intl(state, 12, 17) |
|
||||
bitnum_intl(state, 11, 18) | (((state & 0x000F0000) >>> 0) >>> 7) | bitnum_intl(state, 16, 23)
|
||||
) >>> 0
|
||||
const t2 = (
|
||||
bitnum_intl(state, 15, 0) | (((state & 0x0000F000) << 15) >>> 0) | bitnum_intl(state, 20, 5) |
|
||||
bitnum_intl(state, 19, 6) | (((state & 0x00000F00) << 13) >>> 0) | bitnum_intl(state, 24, 11) |
|
||||
bitnum_intl(state, 23, 12) | (((state & 0x000000F0) << 11) >>> 0) | bitnum_intl(state, 28, 17) |
|
||||
bitnum_intl(state, 27, 18) | (((state & 0x0000000F) << 9) >>> 0) | bitnum_intl(state, 0, 23)
|
||||
) >>> 0
|
||||
|
||||
const lrgstate = [
|
||||
(t1 >>> 24) & 0xFF, (t1 >>> 16) & 0xFF, (t1 >>> 8) & 0xFF,
|
||||
(t2 >>> 24) & 0xFF, (t2 >>> 16) & 0xFF, (t2 >>> 8) & 0xFF,
|
||||
]
|
||||
for (let i = 0; i < 6; i++) lrgstate[i] ^= key[i]
|
||||
|
||||
let s = (
|
||||
(sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) |
|
||||
(sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) |
|
||||
(sbox[2][sbox_bit(((lrgstate[1] & 0x0F) << 2) | (lrgstate[2] >>> 6))] << 20) |
|
||||
(sbox[3][sbox_bit(lrgstate[2] & 0x3F)] << 16) |
|
||||
(sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) |
|
||||
(sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) |
|
||||
(sbox[6][sbox_bit(((lrgstate[4] & 0x0F) << 2) | (lrgstate[5] >>> 6))] << 4) |
|
||||
sbox[7][sbox_bit(lrgstate[5] & 0x3F)]
|
||||
) >>> 0
|
||||
|
||||
return (
|
||||
bitnum_intl(s, 15, 0) | bitnum_intl(s, 6, 1) | bitnum_intl(s, 19, 2) | bitnum_intl(s, 20, 3) |
|
||||
bitnum_intl(s, 28, 4) | bitnum_intl(s, 11, 5) | bitnum_intl(s, 27, 6) | bitnum_intl(s, 16, 7) |
|
||||
bitnum_intl(s, 0, 8) | bitnum_intl(s, 14, 9) | bitnum_intl(s, 22, 10) | bitnum_intl(s, 25, 11) |
|
||||
bitnum_intl(s, 4, 12) | bitnum_intl(s, 17, 13) | bitnum_intl(s, 30, 14) | bitnum_intl(s, 9, 15) |
|
||||
bitnum_intl(s, 1, 16) | bitnum_intl(s, 7, 17) | bitnum_intl(s, 23, 18) | bitnum_intl(s, 13, 19) |
|
||||
bitnum_intl(s, 31, 20) | bitnum_intl(s, 26, 21) | bitnum_intl(s, 2, 22) | bitnum_intl(s, 8, 23) |
|
||||
bitnum_intl(s, 18, 24) | bitnum_intl(s, 12, 25) | bitnum_intl(s, 29, 26) | bitnum_intl(s, 5, 27) |
|
||||
bitnum_intl(s, 21, 28) | bitnum_intl(s, 10, 29) | bitnum_intl(s, 3, 30) | bitnum_intl(s, 24, 31)
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
// 单次 DES 加/解密一个 8 字节分组(轮密钥 schedule 为 16x6)
|
||||
const des_crypt = (input, schedule, output) => {
|
||||
let [s0, s1] = initial_permutation(input)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const prev = s1
|
||||
s1 = (des_f(s1, schedule[i]) ^ s0) >>> 0
|
||||
s0 = prev
|
||||
}
|
||||
s0 = (des_f(s1, schedule[15]) ^ s0) >>> 0
|
||||
inverse_permutation(s0, s1, output)
|
||||
}
|
||||
|
||||
// 生成 16 轮子密钥
|
||||
const key_schedule = (key, mode) => {
|
||||
const schedule = Array.from({ length: 16 }, () => new Uint8Array(6))
|
||||
let c = 0
|
||||
let d = 0
|
||||
for (let i = 0; i < 28; i++) {
|
||||
c = (c | bitnum(key, key_perm_c[i], 31 - i)) >>> 0
|
||||
d = (d | bitnum(key, key_perm_d[i], 31 - i)) >>> 0
|
||||
}
|
||||
for (let i = 0; i < 16; i++) {
|
||||
c = ((((c << key_rnd_shift[i]) >>> 0) | (c >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0
|
||||
d = ((((d << key_rnd_shift[i]) >>> 0) | (d >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0
|
||||
const togen = mode === DES_DECRYPT ? 15 - i : i
|
||||
for (let j = 0; j < 24; j++) {
|
||||
schedule[togen][(j / 8) | 0] |= bitnum_intr(c, key_compression[j], 7 - (j % 8))
|
||||
}
|
||||
for (let j = 24; j < 48; j++) {
|
||||
schedule[togen][(j / 8) | 0] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8))
|
||||
}
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
|
||||
// 3DES(EDE3)密钥编排,返回 3 组 schedule
|
||||
const tripledes_key_setup = (key, mode) => {
|
||||
if (mode === DES_ENCRYPT) {
|
||||
return [
|
||||
key_schedule(key.subarray(0, 8), DES_ENCRYPT),
|
||||
key_schedule(key.subarray(8, 16), DES_DECRYPT),
|
||||
key_schedule(key.subarray(16, 24), DES_ENCRYPT),
|
||||
]
|
||||
}
|
||||
return [
|
||||
key_schedule(key.subarray(16, 24), DES_DECRYPT),
|
||||
key_schedule(key.subarray(8, 16), DES_ENCRYPT),
|
||||
key_schedule(key.subarray(0, 8), DES_DECRYPT),
|
||||
]
|
||||
}
|
||||
|
||||
// 3DES 加/解密一个 8 字节分组
|
||||
const tripledes_crypt = (input, schedule, output) => {
|
||||
const buf = new Uint8Array(8)
|
||||
des_crypt(input, schedule[0], buf)
|
||||
des_crypt(buf, schedule[1], output)
|
||||
des_crypt(output, schedule[2], buf)
|
||||
output.set(buf)
|
||||
}
|
||||
|
||||
// QRC 固定密钥:!@#)(*$%123ZXC!@!@#)(NHL
|
||||
const QRC_KEY = Buffer.from([
|
||||
0x21, 0x40, 0x23, 0x29, 0x28, 0x2a, 0x24, 0x25, 0x31, 0x32, 0x33, 0x5a,
|
||||
0x58, 0x43, 0x21, 0x40, 0x21, 0x40, 0x23, 0x29, 0x28, 0x4e, 0x48, 0x4c,
|
||||
])
|
||||
|
||||
const handleInflate = (data) => new Promise((resolve, reject) => {
|
||||
// Z_SYNC_FLUSH:容忍尾部不完整的 zlib 流(对应原实现忽略 Z_BUF_ERROR 的行为)
|
||||
inflate(data, { finishFlush: constants.Z_SYNC_FLUSH }, (err, result) => {
|
||||
if (err) reject(err)
|
||||
else resolve(result)
|
||||
})
|
||||
})
|
||||
/**
|
||||
* 解密腾讯 QRC 歌词。
|
||||
* @param {string} hexData 服务端返回的十六进制字符串
|
||||
* @returns {Promise<string>} 解密并解压后的歌词文本(解析失败时返回空字符串)
|
||||
*/
|
||||
export const decodeQrc = async(hexData) => {
|
||||
if (!hexData || hexData.length % 2 !== 0) return ''
|
||||
const encrypted = Buffer.from(hexData, 'hex')
|
||||
if (encrypted.length === 0) return ''
|
||||
|
||||
const schedule = tripledes_key_setup(QRC_KEY, DES_DECRYPT)
|
||||
|
||||
const block = new Uint8Array(8)
|
||||
for (let i = 0; i + 8 <= encrypted.length; i += 8) {
|
||||
tripledes_crypt(encrypted.subarray(i, i + 8), schedule, block)
|
||||
encrypted.set(block, i)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleInflate(encrypted)
|
||||
return result.toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// 渲染器 IPC 桥(替代 Electron ipcRenderer.invoke)
|
||||
// 服务器环境下 IPC 调用直接映射到本地实现:
|
||||
// handle_kw_decode_lyric → 酷我歌词解码(见 lyricDecode.js)
|
||||
// handle_tx_decode_lyric → QQ音乐歌词解码(见 lyricDecode.js)
|
||||
|
||||
import { handleKwDecodeLyric, handleTxDecodeLyric } from './lyricDecode.js'
|
||||
|
||||
const localHandlers = {
|
||||
[WIN_MAIN_RENDERER_EVENT_NAME.handle_kw_decode_lyric]: handleKwDecodeLyric,
|
||||
[WIN_MAIN_RENDERER_EVENT_NAME.handle_tx_decode_lyric]: handleTxDecodeLyric,
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用主进程能力(本地直调)
|
||||
* @param {string} name 事件名
|
||||
* @param {*} params 参数
|
||||
*/
|
||||
export const rendererInvoke = async(name, params) => {
|
||||
const handler = localHandlers[name]
|
||||
if (!handler) throw new Error(`Unsupported invoke: ${name}`)
|
||||
return handler(params)
|
||||
}
|
||||
|
||||
export const rendererSend = () => {}
|
||||
export const rendererOn = () => {}
|
||||
export const rendererOnce = () => {}
|
||||
export const rendererOff = () => {}
|
||||
export const rendererOffAll = () => {}
|
||||
export const rendererSendSync = () => {}
|
||||
|
||||
import { WIN_MAIN_RENDERER_EVENT_NAME } from './ipcNames.js'
|
||||
|
||||
export default { rendererInvoke, rendererSend, rendererOn, rendererOnce, rendererOff, rendererOffAll, rendererSendSync }
|
||||
@@ -1,174 +0,0 @@
|
||||
// 网络请求模块
|
||||
// 接口与原版保持一致:
|
||||
// httpFetch(url, { method, headers, body, form, timeout, lookup, family })
|
||||
// → { promise, cancelHttp }
|
||||
// promise resolve → { statusCode, body, headers, raw }
|
||||
// - body:JSON 已解析对象,或原始字符串(HTML/文本)
|
||||
// - raw:响应体 Buffer
|
||||
// promise reject → 错误(timeout 请求超时 / canceled 主动取消)
|
||||
// 注意:promise 与 cancelHttp 均为普通可赋值属性(SDK 多处会重写 promise)
|
||||
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import zlib from 'node:zlib'
|
||||
import { URL } from 'node:url'
|
||||
|
||||
const MAX_BUFFER_SIZE = 64 * 1024 * 1024 // 64MB
|
||||
|
||||
const defaultHeaders = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
|
||||
}
|
||||
|
||||
const parseBody = (raw) => {
|
||||
if (!raw || !raw.length) return null
|
||||
const text = raw.toString('utf8')
|
||||
// 与原版一致:无论 content-type 都尝试 JSON 解析,失败则返回原始文本
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起请求(返回带 promise 与 cancelHttp 的对象)
|
||||
*/
|
||||
const buildHttpPromise = (url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const obj = {
|
||||
isCancelled: false,
|
||||
cancelHttp: () => {
|
||||
if (!obj.requestObj) {
|
||||
obj.isCancelled = true
|
||||
return
|
||||
}
|
||||
obj.requestObj.destroy(new Error('Canceled'))
|
||||
obj.requestObj = null
|
||||
obj.promise = obj.cancelHttp = null
|
||||
obj.cancelFn?.(new Error('Canceled'))
|
||||
obj.cancelFn = null
|
||||
},
|
||||
}
|
||||
obj.promise = new Promise((resolve, reject) => {
|
||||
obj.cancelFn = reject
|
||||
|
||||
let parsedUrl
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
const isHttps = parsedUrl.protocol === 'https:'
|
||||
const client = isHttps ? https : http
|
||||
|
||||
// 数据体构造:body(对象/字符串/Buffer)与 form(对象 → urlencoded)
|
||||
let body = null
|
||||
let isForm = false
|
||||
if (options.form) {
|
||||
const formBody = new URLSearchParams()
|
||||
for (const key of Object.keys(options.form)) {
|
||||
const value = options.form[key]
|
||||
if (value == null) continue
|
||||
formBody.append(key, typeof value === 'object' ? JSON.stringify(value) : value)
|
||||
}
|
||||
body = formBody.toString()
|
||||
isForm = true
|
||||
} else if (options.body) {
|
||||
body = typeof options.body === 'object' && !Buffer.isBuffer(options.body) ? JSON.stringify(options.body) : options.body
|
||||
}
|
||||
|
||||
const headers = { ...defaultHeaders, ...(options.headers || {}) }
|
||||
if (body && !headers['content-type'] && !headers['Content-Type']) {
|
||||
headers['content-type'] = isForm ? 'application/x-www-form-urlencoded' : 'application/json'
|
||||
}
|
||||
if (body) headers['content-length'] = Buffer.byteLength(body)
|
||||
|
||||
const requestOptions = {
|
||||
method,
|
||||
headers,
|
||||
family: options.family || undefined,
|
||||
}
|
||||
if (options.lookup) requestOptions.lookup = options.lookup
|
||||
|
||||
const req = client.request(parsedUrl, requestOptions, res => {
|
||||
obj.requestObj = null
|
||||
obj.cancelFn = null
|
||||
const statusCode = res.statusCode
|
||||
|
||||
let stream = res
|
||||
switch (res.headers['content-encoding']) {
|
||||
case 'gzip': stream = res.pipe(zlib.createGunzip()); break
|
||||
case 'deflate': stream = res.pipe(zlib.createInflate()); break
|
||||
case 'br': stream = res.pipe(zlib.createBrotliDecompress()); break
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
let size = 0
|
||||
stream.on('data', chunk => {
|
||||
size += chunk.length
|
||||
if (size > MAX_BUFFER_SIZE) {
|
||||
stream.destroy(new Error('Response too large'))
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
stream.on('error', err => reject(err))
|
||||
stream.on('end', () => {
|
||||
const raw = Buffer.concat(chunks)
|
||||
resolve({
|
||||
statusCode,
|
||||
headers: res.headers,
|
||||
body: parseBody(raw),
|
||||
raw,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
obj.requestObj = req
|
||||
|
||||
req.on('error', err => {
|
||||
if (obj.isCancelled || err.canceled) err.canceled = true
|
||||
reject(err)
|
||||
})
|
||||
|
||||
if (options.timeout) {
|
||||
req.setTimeout(options.timeout, () => {
|
||||
const err = new Error('Request timeout')
|
||||
err.timeout = true
|
||||
req.destroy(err)
|
||||
})
|
||||
}
|
||||
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求超时/断网错误统一处理(与原版行为一致)
|
||||
*/
|
||||
export const httpFetch = (url, options = { method: 'get' }) => {
|
||||
const requestObj = buildHttpPromise(url, options)
|
||||
requestObj.promise = requestObj.promise.catch(err => {
|
||||
if (err.canceled) return Promise.reject(err)
|
||||
switch (err.code) {
|
||||
case 'ETIMEDOUT':
|
||||
case 'ESOCKETTIMEDOUT':
|
||||
return Promise.reject(new Error(requestMsg.timeout))
|
||||
case 'ENOTFOUND':
|
||||
return Promise.reject(new Error(requestMsg.notConnectNetwork))
|
||||
case 'ECONNRESET':
|
||||
return Promise.reject(new Error(requestMsg.fail))
|
||||
default:
|
||||
return Promise.reject(err)
|
||||
}
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
import { requestMsg } from './message.js'
|
||||
|
||||
export default { httpFetch }
|
||||
@@ -1,101 +0,0 @@
|
||||
// 通用工具函数
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
/** MD5 十六进制串 */
|
||||
export const toMD5 = str => crypto.createHash('md5').update(str).digest('hex')
|
||||
|
||||
/**
|
||||
* 将大小格式化为可读字符串
|
||||
* @param {number} size 字节数
|
||||
*/
|
||||
export const sizeFormate = size => {
|
||||
// https://gist.github.com/thomseddon/3511330
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const number = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return `${(size / Math.pow(1024, Math.floor(number))).toFixed(2)} ${units[number]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将秒数格式化为 mm:ss
|
||||
* @param {number} time 秒
|
||||
*/
|
||||
export const formatPlayTime = time => {
|
||||
if (Number.isNaN(time) || time < 0) time = 0
|
||||
const m = Math.trunc(time / 60)
|
||||
const s = Math.trunc(time % 60)
|
||||
return m == 0 && s == 0 ? '--/--' : `${m < 10 ? '0' + m : m}:${s < 10 ? '0' + s : s}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间对象/时间戳转成日期字符串
|
||||
* @param {Date|number|string} _date 时间
|
||||
* @param {string} [format='Y-M-D h:m:s'] 格式模板(Y/M/D/h/m/s)
|
||||
*/
|
||||
export const dateFormat = (_date, format = 'Y-M-D h:m:s') => {
|
||||
if (!_date) return ''
|
||||
let date
|
||||
switch (typeof _date) {
|
||||
case 'string':
|
||||
if (!_date.includes('T')) _date = _date.split('.')[0].replace(/-/g, '/')
|
||||
// fallthrough
|
||||
case 'number':
|
||||
date = new Date(_date)
|
||||
break
|
||||
case 'object':
|
||||
date = _date
|
||||
break
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
const numFix = n => n < 10 ? '0' + n : String(n)
|
||||
return format
|
||||
.replace('Y', date.getFullYear().toString())
|
||||
.replace('M', numFix(date.getMonth() + 1))
|
||||
.replace('D', numFix(date.getDate()))
|
||||
.replace('h', numFix(date.getHours()))
|
||||
.replace('m', numFix(date.getMinutes()))
|
||||
.replace('s', numFix(date.getSeconds()))
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对时间格式化(用于评论时间)
|
||||
* @param {number} time 毫秒时间戳
|
||||
*/
|
||||
export const dateFormat2 = time => {
|
||||
const differ = Math.trunc((Date.now() - time) / 1000)
|
||||
if (differ < 60) return `${differ}秒前`
|
||||
if (differ < 3600) return `${Math.trunc(differ / 60)}分钟前`
|
||||
if (differ < 86400) return `${Math.trunc(differ / 3600)}小时前`
|
||||
if (differ < 2592000) return `${Math.trunc(differ / 86400)}天前`
|
||||
return dateFormat(time)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化播放量
|
||||
* @param {number} num
|
||||
*/
|
||||
export const formatPlayCount = num => {
|
||||
if (num > 100000000) return `${Math.trunc(num / 10000000) / 10}亿`
|
||||
if (num > 10000) return `${Math.trunc(num / 1000) / 10}万`
|
||||
return String(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 HTML 实体
|
||||
* @param {string} str
|
||||
*/
|
||||
export const decodeName = (str = '') => {
|
||||
if (!str) return ''
|
||||
if (typeof str !== 'string') str = String(str)
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&#(\d+);/g, (match, num) => String.fromCharCode(parseInt(num)))
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// API 业务错误 —— 路由处理中抛出,由服务器统一转换为对应 HTTP 状态码的 JSON 响应
|
||||
|
||||
export class ApiError extends Error {
|
||||
/**
|
||||
* @param {number} status HTTP 状态码(同时作为响应体中的 code)
|
||||
* @param {string} msg 错误描述
|
||||
* @param {unknown} [detail] 可选的结构化错误详情
|
||||
*/
|
||||
constructor(status, msg, detail) {
|
||||
super(msg)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
/** 资源不存在(未知路由 / 不支持的平台) */
|
||||
export const notFound = (msg, detail) => new ApiError(404, msg, detail)
|
||||
|
||||
/** 上游平台请求失败 */
|
||||
export const upstream = (msg, detail) => new ApiError(502, msg, detail)
|
||||
@@ -0,0 +1,24 @@
|
||||
// API 业务错误 —— 路由处理中抛出,由服务器统一转换为对应 HTTP 状态码的 JSON 响应
|
||||
|
||||
export class ApiError extends Error {
|
||||
/** HTTP 状态码(同时作为响应体中的 code) */
|
||||
status: number
|
||||
/** 与 status 同值,历史别名 */
|
||||
code: number
|
||||
/** 可选的结构化错误详情 */
|
||||
detail?: unknown
|
||||
|
||||
constructor(status: number, msg: string, detail?: unknown) {
|
||||
super(msg)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
/** 资源不存在(未知路由 / 不支持的平台) */
|
||||
export const notFound = (msg: string, detail?: unknown) => new ApiError(404, msg, detail)
|
||||
|
||||
/** 上游平台请求失败 */
|
||||
export const upstream = (msg: string, detail?: unknown) => new ApiError(502, msg, detail)
|
||||
@@ -1,11 +0,0 @@
|
||||
// SDK 根模块 —— musicSdk 内 `import xxx from '../../index.js'` 的解析目标
|
||||
// 聚合导出通用工具函数
|
||||
|
||||
export {
|
||||
sizeFormate,
|
||||
formatPlayTime,
|
||||
dateFormat,
|
||||
dateFormat2,
|
||||
formatPlayCount,
|
||||
decodeName,
|
||||
} from './common/utils.js'
|
||||
@@ -33,7 +33,7 @@ export const COMPONENT_SCHEMAS = {
|
||||
properties: {
|
||||
songmid: { type: 'string', description: '平台歌曲 id(kg 平台为 hash)' },
|
||||
hash: { type: 'string', description: 'kg 平台的歌曲 hash' },
|
||||
copyrightId: { type: 'string', description: 'mg 平台的版权 id' },
|
||||
copyrightId: { type: 'string', description: '平台版权 id(原 mg 平台使用)' },
|
||||
albumId: { type: 'string' },
|
||||
name: { type: 'string', description: '歌名' },
|
||||
singer: { type: 'string', description: '歌手' },
|
||||
File renamed without changes.
@@ -1,15 +0,0 @@
|
||||
// 替代原渲染器的 @renderer/store 模块(保留导入别名所需的接口形态)
|
||||
// 服务器环境默认使用内置直连接口(apiSource.value === 'internal'),
|
||||
// 设置 SOURCE_ENABLED 环境变量(任意非 0/false 值)即启用外部聚合音源。
|
||||
|
||||
const enabled = process.env.SOURCE_ENABLED
|
||||
|
||||
export const apiSource = {
|
||||
value: enabled && enabled !== '0' && enabled !== 'false' ? 'source' : 'internal',
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
apis: {},
|
||||
}
|
||||
|
||||
export default { apiSource, userApi }
|
||||
@@ -1,9 +1,9 @@
|
||||
// 登录路由 —— wy 扫码/手机号登录,kg token 绑定,凭据持久化于 data/auth.json
|
||||
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
|
||||
|
||||
import * as wyAuth from './auth/wy.js'
|
||||
import * as kgAuth from './auth/kg.js'
|
||||
import { clearCredentials, getCredentials } from './auth/store.js'
|
||||
import * as wyAuth from '../sdk/auth/wy.ts'
|
||||
import * as kgAuth from '../sdk/auth/kg.ts'
|
||||
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
|
||||
|
||||
const fail = (reply, err) => {
|
||||
const status = err.status ?? 502
|
||||
@@ -102,19 +102,53 @@ export const loginRoutes = async(fastify) => {
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- QQ音乐 ----------
|
||||
|
||||
fastify.post('/login/tx', {
|
||||
schema: {
|
||||
summary: '绑定 QQ 音乐 cookie',
|
||||
description: 'cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),用于解锁 tx 取流的 320k/无损音质。',
|
||||
tags: ['登录'],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cookie: { type: 'string' },
|
||||
},
|
||||
required: ['cookie'],
|
||||
},
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
const cookie = request.body.cookie
|
||||
const uin = cookie.match(/uin=(\d+)/)?.[1]
|
||||
if (!uin || !/qm_keyst=\S+/.test(cookie)) {
|
||||
return reply.code(422).send({ code: 422, msg: 'cookie 需包含 uin 与 qm_keyst 字段' })
|
||||
}
|
||||
setCredentials('tx', { cookie })
|
||||
return { logged: true, uin }
|
||||
})
|
||||
|
||||
fastify.delete('/login/tx', {
|
||||
schema: { summary: '退出 QQ 音乐登录', tags: ['登录'] },
|
||||
}, async() => {
|
||||
clearCredentials('tx')
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 通用 ----------
|
||||
|
||||
fastify.get('/login/status', {
|
||||
schema: {
|
||||
summary: '查看登录状态',
|
||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg 为已存凭据。',
|
||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async() => {
|
||||
const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())])
|
||||
const tx = getTxAuth()
|
||||
return {
|
||||
wy: wy ? { logged: true, ...wy } : { logged: false },
|
||||
kg: kg ? { logged: true, ...kg } : { logged: false },
|
||||
tx: tx ? { logged: true, uin: tx.uin } : { logged: false },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,18 +3,18 @@
|
||||
// 处理函数返回普通对象 → 由服务器的 preSerialization 钩子包成 { code: 0, data }
|
||||
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
|
||||
|
||||
import { notFound, upstream } from './http/api-error.js'
|
||||
import { notFound, upstream } from './http/api-error.ts'
|
||||
|
||||
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
|
||||
|
||||
/** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */
|
||||
const unwrap = (result) => (
|
||||
const unwrap = (result: any): any => (
|
||||
result && typeof result.promise !== 'undefined' && typeof result.cancelHttp === 'function'
|
||||
? result.promise
|
||||
: result
|
||||
)
|
||||
|
||||
const envelope = (dataRef) => ({
|
||||
const envelope = (dataRef?: string) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'integer', description: '0 表示成功', example: 0 },
|
||||
@@ -24,7 +24,7 @@ const envelope = (dataRef) => ({
|
||||
})
|
||||
|
||||
/** 组装路由的 schema.response:200 信封 + 通用错误码文档 */
|
||||
const responses = (dataRef, { notFoundDoc = true, upstreamDoc = true } = {}) => ({
|
||||
const responses = (dataRef: any, { notFoundDoc = true, upstreamDoc = true }: { notFoundDoc?: boolean; upstreamDoc?: boolean } = {}) => ({
|
||||
'200': envelope(dataRef),
|
||||
'422': { $ref: 'ErrorEnvelope#' },
|
||||
...(notFoundDoc ? { '404': { $ref: 'ErrorEnvelope#' } } : {}),
|
||||
@@ -43,10 +43,10 @@ const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, descrip
|
||||
const pQuality = { type: 'string', enum: QUALITIES, default: '128k', description: '音质' }
|
||||
const pId = { type: 'string', description: '平台歌曲 id', example: '28423286' }
|
||||
|
||||
const qs = (properties, required = []) => ({ type: 'object', properties, required })
|
||||
const params = (properties, required) => qs(properties, required ?? Object.keys(properties))
|
||||
const qs = (properties: any, required: string[] = []) => ({ type: 'object', properties, required })
|
||||
const params = (properties: any, required?: string[]) => qs(properties, required ?? Object.keys(properties))
|
||||
|
||||
const getSource = (musicSdk, name, capability) => {
|
||||
const getSource = (musicSdk: any, name: string, capability?: string) => {
|
||||
const m = musicSdk[name]
|
||||
if (!m || (capability && !m[capability])) throw notFound(`不支持的平台: ${name}`)
|
||||
return m
|
||||
@@ -56,7 +56,7 @@ const getSource = (musicSdk, name, capability) => {
|
||||
* 注册全部业务路由
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
*/
|
||||
export const musicRoutes = async(fastify) => {
|
||||
export const musicRoutes = async(fastify: any) => {
|
||||
const musicSdk = fastify.musicSdk
|
||||
|
||||
// ---------- 搜索 ----------
|
||||
@@ -86,7 +86,7 @@ export const musicRoutes = async(fastify) => {
|
||||
getSource(musicSdk, source, 'musicSearch')
|
||||
try {
|
||||
return { source, list: await doSearch(source), page, limit }
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export const musicRoutes = async(fastify) => {
|
||||
return {
|
||||
list: await musicSdk.findMusic({ name, singer, albumName, interval, source: excludeSource }),
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
@@ -134,7 +134,7 @@ export const musicRoutes = async(fastify) => {
|
||||
let result
|
||||
try {
|
||||
result = unwrap(m.getMusicUrl(songInfo, quality))
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
return result.then(res => {
|
||||
@@ -148,7 +148,7 @@ export const musicRoutes = async(fastify) => {
|
||||
fastify.get('/url/:source/:id', {
|
||||
schema: {
|
||||
summary: '获取播放地址',
|
||||
description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。',
|
||||
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash。',
|
||||
tags: ['歌曲资源'],
|
||||
params: params({
|
||||
source: pSource('url'),
|
||||
@@ -166,7 +166,7 @@ export const musicRoutes = async(fastify) => {
|
||||
let result
|
||||
try {
|
||||
result = unwrap(m.getLyric({ songmid: id, hash: id, copyrightId: id, name: name || '', interval: interval || '' }))
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
return result.then(res => (res || { lyric: '', tlyric: '' })).catch(err => {
|
||||
@@ -194,7 +194,7 @@ export const musicRoutes = async(fastify) => {
|
||||
let result
|
||||
try {
|
||||
result = unwrap(m.getPic({ songmid: id, hash: id, copyrightId: id, albumId: albumId || '' }))
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
return result.then(url => ({ url })).catch(err => {
|
||||
@@ -346,7 +346,7 @@ export const musicRoutes = async(fastify) => {
|
||||
const { source, id } = request.params
|
||||
const { page, limit } = request.query
|
||||
const album = getSource(musicSdk, source, 'album').album
|
||||
// 各平台适配器签名不同:kg/mg 为 getAlbumDetail(id, page, limit),
|
||||
// 各平台适配器签名不同:kg 为 getAlbumDetail(id, page, limit),
|
||||
// kw 为 getAlbumListDetail(id, page)——第三参数是 retryNum,不能透传 limit
|
||||
if (album.getAlbumDetail) return album.getAlbumDetail(id, page, limit).catch(err => { throw upstream(err.message) })
|
||||
return album.getAlbumListDetail(id, page).catch(err => { throw upstream(err.message) })
|
||||
@@ -378,7 +378,7 @@ export const musicRoutes = async(fastify) => {
|
||||
const m = musicSdk[id] || {}
|
||||
const qualities = [...new Set(
|
||||
Object.values(musicSdk.supportQuality || {})
|
||||
.map(q => q[id])
|
||||
.map((q: any) => q[id])
|
||||
.filter(Boolean)
|
||||
.flat(),
|
||||
)]
|
||||
@@ -13,7 +13,7 @@
|
||||
// GET /singer/:source/:id 歌手信息(kg/tx/wy)
|
||||
// GET /singer/:source/:id/songs 歌手歌曲(kg/tx/wy)
|
||||
// GET /singer/:source/:id/albums 歌手专辑(kg/tx/wy)
|
||||
// GET /album/:source/:id 专辑详情+曲目(kg/kw/mg)
|
||||
// GET /album/:source/:id 专辑详情+曲目(kg/kw)
|
||||
// GET /sources /health GET /docs /redoc /openapi.json
|
||||
//
|
||||
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
||||
@@ -26,19 +26,19 @@ import swaggerUi from '@fastify/swagger-ui'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import musicSdk from '../musicSdk/index.js'
|
||||
import { ApiError } from './http/api-error.js'
|
||||
import { musicRoutes } from './routes.js'
|
||||
import { loginRoutes } from './routes-login.js'
|
||||
import { COMPONENT_SCHEMAS } from './openapi/components.js'
|
||||
import { redocHtml } from './openapi/ui.js'
|
||||
import musicSdk from '../sdk/index.ts'
|
||||
import { ApiError } from './http/api-error.ts'
|
||||
import { musicRoutes } from './routes.ts'
|
||||
import { loginRoutes } from './routes-login.ts'
|
||||
import { COMPONENT_SCHEMAS } from './openapi/components.ts'
|
||||
import { redocHtml } from './openapi/ui.ts'
|
||||
|
||||
// 默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间
|
||||
const PORT = process.env.PORT || 16688
|
||||
const DOCS_PREFIX = '/docs'
|
||||
const API_TITLE = 'MusicServer 聚合音乐 API'
|
||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
|
||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)平台。'
|
||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)平台。'
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
||||
|
||||
@@ -57,7 +57,7 @@ for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) {
|
||||
// ---- 全局中间行为 ----
|
||||
|
||||
// 收集全部路由模式(含 :param 参数段),供 not-found 处理器做 405 判定
|
||||
const routePatterns = []
|
||||
const routePatterns: { method: any; regex: RegExp }[] = []
|
||||
fastify.addHook('onRoute', (routeOptions) => {
|
||||
if (!routeOptions.url || !routeOptions.method) return
|
||||
// :param 段 → [^/]+;通配符 * → .*(如 @fastify/cors 注册的 catch-all 路由);其余按字面量转义
|
||||
@@ -84,7 +84,7 @@ fastify.addHook('onResponse', async(request, reply) => {
|
||||
// 必须豁免:Swagger UI 强制从该地址加载规范,包了信封顶层就没有 openapi 字段,页面直接报错
|
||||
fastify.addHook('preSerialization', async(request, reply, payload) => {
|
||||
if (reply.statusCode !== 200) return payload
|
||||
if (request.routeOptions.config?.raw) return payload
|
||||
if ((request.routeOptions.config as any)?.raw) return payload
|
||||
if (request.routeOptions.url === `${DOCS_PREFIX}/json`) return payload
|
||||
if (payload === null || typeof payload === 'object') return { code: 0, data: payload }
|
||||
return payload
|
||||
@@ -92,9 +92,9 @@ fastify.addHook('preSerialization', async(request, reply, payload) => {
|
||||
|
||||
// ---- 错误处理 ----
|
||||
|
||||
fastify.setErrorHandler((err, request, reply) => {
|
||||
fastify.setErrorHandler((err: any, request, reply) => {
|
||||
if (err instanceof ApiError) {
|
||||
const payload = { code: err.status, msg: err.message }
|
||||
const payload: Record<string, any> = { code: err.status, msg: err.message }
|
||||
if (err.detail !== undefined) payload.detail = err.detail
|
||||
return reply.code(err.status).send(payload)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ fastify.setNotFoundHandler((request, reply) => {
|
||||
fastify.register(swagger, {
|
||||
// 组件按 addSchema 的 $id 命名(默认 def-1/def-2 不可读)
|
||||
refResolver: {
|
||||
buildLocalReference: (json, _baseUri, _fragment, i) => json.$id || `def-${i}`,
|
||||
buildLocalReference: (json: any, _baseUri: any, _fragment: any, i: number) => json.$id || `def-${i}`,
|
||||
},
|
||||
openapi: {
|
||||
openapi: '3.1.0',
|
||||
@@ -185,7 +185,7 @@ fastify.register(musicRoutes)
|
||||
fastify.register(loginRoutes)
|
||||
|
||||
export const startServer = async(port = PORT) => {
|
||||
await fastify.listen({ port, host: '0.0.0.0' })
|
||||
await fastify.listen({ port: Number(port), host: '0.0.0.0' })
|
||||
console.log(`音乐服务器已启动: http://localhost:${port}`)
|
||||
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
|
||||
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
|
||||
@@ -205,6 +205,6 @@ process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
|
||||
// 直接运行时启动
|
||||
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('src\\server.js'))) {
|
||||
if (process.argv[1] && (process.argv[1].endsWith('server.ts') || process.argv[1].endsWith('src\\server.ts'))) {
|
||||
startServer()
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
// 冒烟测试 —— 验证服务器各接口与各平台功能
|
||||
// 运行:node src/smoke-test.js
|
||||
//
|
||||
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
|
||||
// 跨平台匹配 /find、歌手/专辑、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、
|
||||
// 文档页、旧 query 路由移除回归
|
||||
|
||||
import http from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
// 本地默认 16688(避开 1024~15000 的动态端口冲突区),可用 PORT/BASE_URL 覆盖
|
||||
const PORT = process.env.PORT || 16688
|
||||
const BASE = process.env.BASE_URL || `http://localhost:${PORT}`
|
||||
const results = []
|
||||
|
||||
const get = (path) => new Promise((resolve, reject) => {
|
||||
http.get(`${BASE}${path}`, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)) } catch { reject(new Error(`JSON parse failed: ${data.slice(0, 100)}`)) }
|
||||
})
|
||||
}).on('error', reject)
|
||||
})
|
||||
|
||||
// 原始响应(不解析 JSON,用于 HTML 页与状态码断言)
|
||||
const getRaw = (path) => new Promise((resolve, reject) => {
|
||||
http.get(`${BASE}${path}`, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
|
||||
}).on('error', reject)
|
||||
})
|
||||
|
||||
const postRaw = (path) => new Promise((resolve, reject) => {
|
||||
const req = http.request(`${BASE}${path}`, { method: 'POST' }, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
|
||||
// JSON POST(登录等写入接口)
|
||||
const postJson = (path, body) => new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body)
|
||||
const req = http.request(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
||||
}, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => resolve({
|
||||
status: res.statusCode,
|
||||
body: (() => { try { return JSON.parse(data) } catch { return null } })(),
|
||||
}))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
|
||||
const test = async(name, fn) => {
|
||||
try {
|
||||
const result = await fn()
|
||||
results.push({ name, ok: true, detail: result })
|
||||
console.log(`✅ ${name}`)
|
||||
} catch (err) {
|
||||
results.push({ name, ok: false, detail: err.message })
|
||||
console.log(`❌ ${name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查本地服务器是否已在运行
|
||||
*/
|
||||
const isServerRunning = () => new Promise(resolve => {
|
||||
if (!BASE.startsWith('http://localhost:') && !BASE.startsWith('http://127.0.0.1:')) return resolve(true)
|
||||
const req = http.get(`${BASE}/health`, res => {
|
||||
res.resume()
|
||||
resolve(res.statusCode === 200)
|
||||
})
|
||||
req.on('error', () => resolve(false))
|
||||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
||||
})
|
||||
|
||||
/**
|
||||
* 启动本地服务器并返回关闭句柄
|
||||
*/
|
||||
const startLocalServer = () => new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, PORT: String(PORT) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
proc.stdout.on('data', d => { stdout += d })
|
||||
proc.stderr.on('data', d => { stdout += d })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error('启动本地服务器超时'))
|
||||
}, 10000)
|
||||
|
||||
const check = () => {
|
||||
http.get(`${BASE}/health`, res => {
|
||||
res.resume()
|
||||
if (res.statusCode === 200) {
|
||||
clearTimeout(timer)
|
||||
resolve(proc)
|
||||
} else {
|
||||
setTimeout(check, 200)
|
||||
}
|
||||
}).on('error', () => setTimeout(check, 200))
|
||||
}
|
||||
setTimeout(check, 300)
|
||||
})
|
||||
|
||||
let localServer = null
|
||||
const running = await isServerRunning()
|
||||
if (!running) {
|
||||
console.log('本地服务器未运行,正在自动启动...')
|
||||
localServer = await startLocalServer()
|
||||
console.log('本地服务器已启动\n')
|
||||
} else {
|
||||
console.log('使用已运行的服务器\n')
|
||||
}
|
||||
|
||||
// 搜索
|
||||
for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) {
|
||||
await test(`搜索 ${source}`, async() => {
|
||||
const r = await get(`/search?source=${source}&name=${encodeURIComponent('稻香')}&limit=2`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
|
||||
})
|
||||
}
|
||||
|
||||
await test('聚合搜索', async() => {
|
||||
const r = await get(`/search?name=${encodeURIComponent('稻香')}&limit=2`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 条结果`
|
||||
})
|
||||
|
||||
// URL(用已知歌曲 id,路径参数风格)
|
||||
await test('URL kw 320k', async() => {
|
||||
const r = await get('/url/kw/28423286?quality=320k')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
return r.data.url.slice(0, 60)
|
||||
})
|
||||
|
||||
await test('URL wy 128k', async() => {
|
||||
const r = await get('/url/wy/3357698666?quality=128k')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
return r.data.url.slice(0, 60)
|
||||
})
|
||||
|
||||
await test('URL tx 320k', async() => {
|
||||
const r = await get('/url/tx/003C9fBv2K4x8b?quality=320k')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
return r.data.url.slice(0, 60)
|
||||
})
|
||||
|
||||
// 歌词
|
||||
await test('歌词 kw', async() => {
|
||||
const r = await get('/lyric/kw/28423286')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.lyric) throw new Error('无歌词')
|
||||
return `${r.data.lyric.slice(0, 30)}...`
|
||||
})
|
||||
|
||||
// 封面
|
||||
await test('封面 kw', async() => {
|
||||
const r = await get('/pic/kw/28423286')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
return r.data.url.slice(0, 60)
|
||||
})
|
||||
|
||||
// 榜单 & 热搜
|
||||
for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) {
|
||||
await test(`榜单 ${source}`, async() => {
|
||||
const r = await get(`/boards/${source}`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 个榜单`
|
||||
})
|
||||
}
|
||||
|
||||
await test('热搜 kw', async() => {
|
||||
const r = await get('/hot-search/kw')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return r.data.list[0]
|
||||
})
|
||||
|
||||
// ---- 标准 HTTP 语义与文档 ----
|
||||
|
||||
await test('平台列表 /sources', async() => {
|
||||
const r = await get('/sources')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.platforms?.length) throw new Error('空平台列表')
|
||||
const kw = r.data.platforms.find(p => p.id === 'kw')
|
||||
if (!kw?.capabilities?.search) throw new Error('kw 缺少 search 能力')
|
||||
return `${r.data.platforms.length} 个平台`
|
||||
})
|
||||
|
||||
await test('榜单详情 路径风格 /boards/:source/:boardId', async() => {
|
||||
// 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid)
|
||||
const r = await get('/boards/kw/kw__93?page=1')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 首`
|
||||
})
|
||||
|
||||
await test('跨平台匹配 /find', async() => {
|
||||
const r = await get(`/find?name=${encodeURIComponent('稻香')}&singer=${encodeURIComponent('周杰伦')}`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
|
||||
})
|
||||
|
||||
await test('歌单 路径风格 /song-list/:source', async() => {
|
||||
const r = await get('/song-list/kg?page=1')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 个歌单`
|
||||
})
|
||||
|
||||
await test('歌单 默认排序 /song-list/kw', async() => {
|
||||
const r = await get('/song-list/kw?page=1')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表(sortId 默认值未适配平台)')
|
||||
return `${r.data.list.length} 个歌单`
|
||||
})
|
||||
|
||||
// ---- 歌手 & 专辑 ----
|
||||
|
||||
await test('歌手信息 /singer/kg', async() => {
|
||||
const r = await get('/singer/kg/3062')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.info?.name) throw new Error('无歌手名')
|
||||
return r.data.info.name
|
||||
})
|
||||
|
||||
await test('歌手信息 /singer/wy', async() => {
|
||||
const r = await get('/singer/wy/3684')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.info?.name) throw new Error('无歌手名')
|
||||
return r.data.info.name
|
||||
})
|
||||
|
||||
await test('歌手信息 /singer/tx', async() => {
|
||||
const r = await get('/singer/tx/0025NhlN2yWrP4')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.info?.name) throw new Error('无歌手名')
|
||||
return r.data.info.name
|
||||
})
|
||||
|
||||
await test('歌手歌曲 /singer/tx/:id/songs', async() => {
|
||||
const r = await get('/singer/tx/0025NhlN2yWrP4/songs?page=1&limit=3')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 首 / 共 ${r.data.total}`
|
||||
})
|
||||
|
||||
await test('歌手专辑 /singer/kg/:id/albums', async() => {
|
||||
const r = await get('/singer/kg/3062/albums?page=1&limit=3')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 张`
|
||||
})
|
||||
|
||||
await test('专辑详情 /album/kg', async() => {
|
||||
const r = await get('/album/kg/171089481')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.info?.name || !r.data?.list) throw new Error('响应不完整')
|
||||
return `${r.data.info.name}(${r.data.list.length} 首)`
|
||||
})
|
||||
|
||||
await test('专辑详情 /album/kw', async() => {
|
||||
const r = await get('/album/kw/29218')
|
||||
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.list.length} 首)`
|
||||
})
|
||||
|
||||
await test('专辑详情 /album/tx', async() => {
|
||||
const r = await get('/album/tx/000MkMni19ClKG')
|
||||
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.list.length} 首)`
|
||||
})
|
||||
|
||||
await test('专辑详情 /album/mg', async() => {
|
||||
const r = await get('/album/mg/25578')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.info?.name) throw new Error('无专辑名')
|
||||
return `${r.data.info.name}(${r.data.list?.length ?? 0} 首)`
|
||||
})
|
||||
|
||||
await test('参数校验 422(缺少 name)', async() => {
|
||||
const r = await getRaw('/search')
|
||||
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||||
const body = JSON.parse(r.body)
|
||||
if (body.code !== 422 || !body.msg) throw new Error('响应体格式错误')
|
||||
return body.msg
|
||||
})
|
||||
|
||||
await test('参数校验 422(非法音质)', async() => {
|
||||
const r = await getRaw('/url/kw/28423286?quality=999k')
|
||||
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||||
return JSON.parse(r.body).msg
|
||||
})
|
||||
|
||||
await test('404 未知接口', async() => {
|
||||
const r = await getRaw('/not-exist')
|
||||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||||
return JSON.parse(r.body).msg
|
||||
})
|
||||
|
||||
await test('旧 query 路由已移除(404)', async() => {
|
||||
const r = await getRaw('/url?source=kw&id=28423286')
|
||||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||||
const body = JSON.parse(r.body)
|
||||
if (!/接口不存在/.test(body.msg)) throw new Error(`响应异常: ${body.msg}`)
|
||||
return body.msg
|
||||
})
|
||||
|
||||
await test('405 方法不允许(静态路由)', async() => {
|
||||
const r = await postRaw('/search')
|
||||
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
|
||||
if (!r.headers.allow) throw new Error('缺少 Allow 头')
|
||||
return `Allow: ${r.headers.allow}`
|
||||
})
|
||||
|
||||
await test('405 方法不允许(参数路由)', async() => {
|
||||
const r = await postRaw('/url/kw/28423286')
|
||||
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
|
||||
if (!r.headers.allow || !r.headers.allow.includes('GET')) throw new Error(`Allow 头异常: ${r.headers.allow}`)
|
||||
return `Allow: ${r.headers.allow}`
|
||||
})
|
||||
|
||||
await test('根路径 302 → /docs', async() => {
|
||||
const r = await getRaw('/')
|
||||
if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`)
|
||||
if (r.headers.location !== '/docs') throw new Error(`期望 /docs,实际 ${r.headers.location}`)
|
||||
return r.headers.location
|
||||
})
|
||||
|
||||
await test('Swagger UI 文档页 /docs', async() => {
|
||||
const r = await getRaw('/docs')
|
||||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||||
if (!r.body.includes('swagger-ui')) throw new Error('页面内容异常')
|
||||
return 'Swagger UI'
|
||||
})
|
||||
|
||||
await test('OpenAPI 规范 /openapi.json', async() => {
|
||||
const r = await getRaw('/openapi.json')
|
||||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||||
const spec = JSON.parse(r.body)
|
||||
if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`)
|
||||
if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义')
|
||||
if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义')
|
||||
return `${Object.keys(spec.paths).length} 个路径`
|
||||
})
|
||||
|
||||
await test('Swagger UI 规范端点 /docs/json(不可包信封)', async() => {
|
||||
const r = await getRaw('/docs/json')
|
||||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||||
const spec = JSON.parse(r.body)
|
||||
// Swagger UI 强制从该地址加载规范:若被全局信封钩子包成 { code, data },顶层无 openapi 字段,文档页直接报错
|
||||
if (!spec.openapi?.startsWith('3.')) throw new Error(`顶层缺少 openapi 字段(疑似被信封包裹): ${r.body.slice(0, 80)}`)
|
||||
return spec.openapi
|
||||
})
|
||||
|
||||
// ---- 登录 ----
|
||||
|
||||
await test('登录状态 /login/status', async() => {
|
||||
const r = await get('/login/status')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (typeof r.data.wy?.logged !== 'boolean' || typeof r.data.kg?.logged !== 'boolean') throw new Error('响应不完整')
|
||||
return `wy:${r.data.wy.logged} kg:${r.data.kg.logged}`
|
||||
})
|
||||
|
||||
await test('扫码二维码创建 POST /login/wy/qr', async() => {
|
||||
const r = await postJson('/login/wy/qr', {})
|
||||
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
|
||||
if (!r.body.data?.key || !r.body.data?.qrurl || !r.body.data?.qrimg) throw new Error('响应不完整')
|
||||
return r.body.data.key.slice(0, 18) + '…'
|
||||
})
|
||||
|
||||
await test('kg 假 token 拒绝 POST /login/kg', async() => {
|
||||
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
|
||||
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
||||
return r.body?.msg
|
||||
})
|
||||
|
||||
// 汇总
|
||||
const passed = results.filter(r => r.ok).length
|
||||
const failed = results.filter(r => !r.ok)
|
||||
console.log(`\n===== 结果: ${passed}/${results.length} 通过 =====`)
|
||||
if (failed.length) {
|
||||
console.log('失败项:')
|
||||
failed.forEach(f => console.log(` - ${f.name}: ${f.detail}`))
|
||||
}
|
||||
|
||||
if (localServer) {
|
||||
console.log('\n正在关闭本地服务器...')
|
||||
localServer.kill()
|
||||
await new Promise(resolve => { localServer.on('exit', resolve) })
|
||||
}
|
||||
|
||||
if (failed.length) process.exit(1)
|
||||
Vendored
-56
@@ -1,56 +0,0 @@
|
||||
// 为项目中尚未迁移到 TypeScript 的 JavaScript 模块提供最小类型声明,
|
||||
// 让 source-loader / apis 等 TypeScript 文件可以正常导入。
|
||||
|
||||
declare module '../common/request.js' {
|
||||
export interface HttpFetchResponse {
|
||||
statusCode: number
|
||||
statusMessage?: string
|
||||
headers: Record<string, string | string[]>
|
||||
body: any
|
||||
}
|
||||
|
||||
export interface HttpRequestObject {
|
||||
promise: Promise<HttpFetchResponse>
|
||||
cancelHttp(): void
|
||||
}
|
||||
|
||||
export function httpFetch(url: string, options?: Record<string, any>): HttpRequestObject
|
||||
}
|
||||
|
||||
declare module '../../src/auth/store.js' {
|
||||
export function getCredentials(platform: string): any
|
||||
export function setCredentials(platform: string, credentials: object): void
|
||||
export function clearCredentials(platform: string): void
|
||||
export function getWyCookie(): string
|
||||
export function getKgAuth(): { token: string; userid: number | string } | null
|
||||
}
|
||||
|
||||
declare module './kw.js' {
|
||||
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
|
||||
export = mod
|
||||
}
|
||||
|
||||
declare module './kg.js' {
|
||||
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
|
||||
export = mod
|
||||
}
|
||||
|
||||
declare module './tx.js' {
|
||||
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
|
||||
export = mod
|
||||
}
|
||||
|
||||
declare module './wy.js' {
|
||||
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
|
||||
export = mod
|
||||
}
|
||||
|
||||
declare module './mg.js' {
|
||||
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
|
||||
export = mod
|
||||
}
|
||||
|
||||
declare module './bd.js' {
|
||||
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
|
||||
export = mod
|
||||
}
|
||||
Reference in new issue
Block a user