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
@@ -0,0 +1,36 @@
|
||||
// 接口源声明:id 与 api-source.ts 中 allApi 的 `<id>_<source>` 键对应
|
||||
// Support qualitys: 128k 320k flac wav
|
||||
|
||||
interface ApiSourceInfo {
|
||||
id: string
|
||||
name: string
|
||||
disabled: boolean
|
||||
supportQualitys: Partial<Record<'kw' | 'kg' | 'tx' | 'wy', string[]>>
|
||||
}
|
||||
|
||||
const sources: ApiSourceInfo[] = [
|
||||
// {
|
||||
// id: 'test',
|
||||
// name: '测试接口',
|
||||
// disabled: false,
|
||||
// supportQualitys: {
|
||||
// kw: ['128k'],
|
||||
// kg: ['128k'],
|
||||
// tx: ['128k'],
|
||||
// wy: ['128k'],
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 'source',
|
||||
name: '外部聚合音源',
|
||||
disabled: false,
|
||||
supportQualitys: {
|
||||
kw: ['128k', '320k', 'flac'],
|
||||
kg: ['128k', '320k', 'flac'],
|
||||
tx: ['128k', '320k', 'flac'],
|
||||
wy: ['128k', '320k', 'flac'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default sources
|
||||
@@ -0,0 +1,54 @@
|
||||
// 接口选择层:按 apiSource 配置把请求路由到外部聚合音源或服务器内置直连接口
|
||||
import apiSourceInfo from './api-source-info.ts'
|
||||
// 接口模式开关(替代原 lx-music 渲染进程 store 的服务器端替身):
|
||||
// 默认 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: {},
|
||||
}
|
||||
import internalApi from './apis/index.ts'
|
||||
import { createApi as createSourceApi } from './apis/source.ts'
|
||||
|
||||
interface SourceApi {
|
||||
getMusicUrl(songInfo: any, type: string): { promise: Promise<any>; cancelHttp(): void }
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const allApi: Record<string, SourceApi> = {
|
||||
source_kw: createSourceApi('kw'),
|
||||
source_kg: createSourceApi('kg'),
|
||||
source_tx: createSourceApi('tx'),
|
||||
source_wy: createSourceApi('wy'),
|
||||
}
|
||||
|
||||
const apiList: Record<string, SourceApi | undefined> = {}
|
||||
const supportQuality: Record<string, any> = {}
|
||||
|
||||
for (const api of apiSourceInfo) {
|
||||
supportQuality[api.id] = api.supportQualitys
|
||||
for (const source of Object.keys(api.supportQualitys)) {
|
||||
apiList[`${api.id}_api_${source}`] = allApi[`${api.id}_${source}`]
|
||||
}
|
||||
}
|
||||
|
||||
const getAPI = (source: string): SourceApi | undefined => apiList[`${apiSource.value}_api_${source}`]
|
||||
|
||||
export const apis = (source: string): SourceApi => {
|
||||
if (/^user_api/.test(apiSource.value)) return userApi.apis[source]
|
||||
// 服务器内置直连接口
|
||||
if (apiSource.value == 'internal') {
|
||||
const api = internalApi[source]
|
||||
if (api) return api
|
||||
throw new Error('Api is not found')
|
||||
}
|
||||
const api = getAPI(source)
|
||||
if (api) return api
|
||||
throw new Error('Api is not found')
|
||||
}
|
||||
|
||||
export { supportQuality }
|
||||
@@ -0,0 +1,33 @@
|
||||
// 各平台 URL 直连接口汇总
|
||||
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
|
||||
// 外部聚合音源(sources/ 目录脚本)不走这里,由 sdk/api-source.ts 直接接入 source.ts
|
||||
// 注意:index.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build
|
||||
|
||||
import kw from './kw.ts'
|
||||
import kg from './kg.ts'
|
||||
import tx from './tx.ts'
|
||||
import wy from './wy.ts'
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 酷狗音乐 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.ts'
|
||||
import { getKgAuth } from '../auth/store.ts'
|
||||
import { androidSign, getDevice } from '../auth/kg.ts'
|
||||
|
||||
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 as any }
|
||||
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: Record<string, any> = {
|
||||
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 }
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// 酷我音乐 URL 获取(直连)
|
||||
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
//
|
||||
// 取流方案(按序回退):
|
||||
// 1. antiserver.kuwo.cn/anti.s(convert_url3 老接口,无需 token,128k/320k 有效,
|
||||
// 未登录时部分资源只返回试听片段)
|
||||
// 2. www.kuwo.cn/api/v1/www/music/playUrl(需 csrf token,token 失效自动重取重试一次)
|
||||
//
|
||||
// 2026-09-19 实测结论(勿轻易改链):
|
||||
// - mobi.kuwo.cn/mobi.s(DES 加密 query)已进防盗链蜜罐:
|
||||
// 任意 rid(含不存在的)都返回 200 与 MP3 地址,但内容是《防盗链打击音频》假文件,
|
||||
// 且响应无法与真实响应区分,故不接入默认链路;DES 实现可参考 UnblockNeteaseMusic 上游 kwDES。
|
||||
// - playUrl 对新 token(Hm_Iuvt_* cookie)返回 "The request is illegal!"(要求签名),
|
||||
// 首页也不再下发 kw_token,getToken 会失败——通道名存实亡,仅作最后回退。
|
||||
// - flac 及以上音质各匿名通道均要求 VIP 登录态;接入酷我登录 token 后可重估 mobi.s。
|
||||
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
|
||||
// 音质映射:128k → 128kmp3, 320k → 320kmp3, flac → flac(antiserver/mobi.s 用 mp3/flac 格式)
|
||||
const qualityMap = {
|
||||
'128k': { br: '128kmp3', format: 'mp3' },
|
||||
'320k': { br: '320kmp3', format: 'mp3' },
|
||||
flac: { br: 'flac', format: 'flac' },
|
||||
flac24bit: { br: 'flac', format: 'flac' },
|
||||
}
|
||||
|
||||
let token = ''
|
||||
let tokenPromise: Promise<string> | null = 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'] as string[]) || []).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 as any }
|
||||
const { br, format } = qualityMap[type] || qualityMap['128k']
|
||||
const rid = `MUSIC_${songInfo.songmid}`
|
||||
|
||||
requestObj.promise = getUrlByAntiServer(rid, br, format).catch(() => {
|
||||
// antiserver 无此音质(flac 常被拒)时回退官方接口
|
||||
return getToken().then(() => getUrlByPlayUrl(songInfo.songmid, br).promise)
|
||||
}).then(url => ({ type, url }))
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
@@ -0,0 +1,642 @@
|
||||
// 外部聚合音源加载器(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.ts'
|
||||
// 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.ts'
|
||||
|
||||
// 从脚本头部的 SERVER_SCRIPT_CONFIG 提取 apiUrl 声明,作为 HTTP 白名单缺省值。
|
||||
// 兼容裸键(apiUrl:)与 JSON 风格("apiUrl":),并处理 \/ 转义
|
||||
const extractAllowedOrigins = (script) => {
|
||||
const origins: string[] = []
|
||||
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,
|
||||
}: Record<string, any> = {}) => {
|
||||
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: Record<string, any> = {
|
||||
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 as any)?.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: Record<string, any> = {
|
||||
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: any): { id: number; promise: Promise<any> } => {
|
||||
if (disposed) return Promise.reject(new Error('isolate 已销毁')) as any
|
||||
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 as any }
|
||||
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<any>((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']
|
||||
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 }
|
||||
@@ -0,0 +1,76 @@
|
||||
// 外部聚合音源统一入口(isolated-vm 懒加载封装)
|
||||
// 内部委托 source-ivm.ts 在硬隔离沙箱中执行音源脚本。
|
||||
// 首次调用 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.ts'
|
||||
|
||||
export const SOURCES = ['kw', 'kg', 'tx', 'wy']
|
||||
|
||||
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: Promise<any> | null = 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 as any }
|
||||
let inner: any = 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
|
||||
},
|
||||
})
|
||||
|
||||
// 兼容导出:sdk/api-source.ts 以 createApi(source) 方式接入聚合音源
|
||||
export const createApi = apiFor
|
||||
|
||||
const sourceApi: Record<string, any> = {}
|
||||
for (const s of SOURCES) sourceApi[s] = apiFor(s)
|
||||
|
||||
// 预热:提前触发脚本加载与初始化(可选调用)
|
||||
sourceApi.init = getShared
|
||||
|
||||
export default sourceApi
|
||||
@@ -0,0 +1,87 @@
|
||||
// QQ音乐 URL 获取(直连)
|
||||
// 接口: https://u.y.qq.com/cgi-bin/musicu.fcg
|
||||
// 方法: vkey.GetVkeyServer.CgiGetVkey
|
||||
// 取流方案:
|
||||
// 按请求音质组 filename 链一次请求(M500{media_mid}.mp3 / M800{media_mid}.mp3 / F000{media_mid}.flac),
|
||||
// 响应 midurlinfo 与 filename 一一对应,取第一个有 purl 的(即最高可用音质);
|
||||
// 始终附带 C400{songmid}.m4a 保底(匿名可取的 128k)。
|
||||
// 登录 cookie(uin + qm_keyst,见 /login/tx)可解锁 320k/无损。
|
||||
// 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }(type 为实际命中的音质,可能低于请求值)
|
||||
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { getTxAuth } from '../auth/store.ts'
|
||||
|
||||
// 音质 → [文件前缀, 扩展名](flac24bit 暂走 flac 通道,命中即返回)
|
||||
const prefixMap = {
|
||||
'128k': ['M500', 'mp3'],
|
||||
'320k': ['M800', 'mp3'],
|
||||
flac: ['F000', 'flac'],
|
||||
flac24bit: ['F000', 'flac'],
|
||||
}
|
||||
// 降级链:高音质无版权/VIP 受限时逐级回退
|
||||
const chainMap = {
|
||||
'128k': ['128k'],
|
||||
'320k': ['320k', '128k'],
|
||||
flac: ['flac', '320k', '128k'],
|
||||
flac24bit: ['flac', '320k', '128k'],
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
const mediaMid = songInfo.strMediaMid || songInfo.songmid
|
||||
const entries: any[] = []
|
||||
for (const t of chainMap[type] || chainMap['128k']) {
|
||||
const [prefix, ext] = prefixMap[t]
|
||||
entries.push({ filename: `${prefix}${mediaMid}.${ext}`, type: t })
|
||||
}
|
||||
entries.push({ filename: `C400${songInfo.songmid}.m4a`, type: '128k' })
|
||||
|
||||
const auth = getTxAuth()
|
||||
const uin = auth?.uin || '0'
|
||||
const data = {
|
||||
comm: { ct: '19', cv: '1859', uin },
|
||||
req: {
|
||||
module: 'vkey.GetVkeyServer',
|
||||
method: 'CgiGetVkey',
|
||||
param: {
|
||||
guid: '7342827269',
|
||||
songmid: [songInfo.songmid],
|
||||
filename: entries.map(e => e.filename),
|
||||
songtype: [0],
|
||||
uin,
|
||||
loginflag: 1,
|
||||
platform: '20',
|
||||
needNewCode: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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/',
|
||||
...(auth ? { cookie: auth.cookie } : {}),
|
||||
},
|
||||
body: data,
|
||||
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/'
|
||||
const infos = data.midurlinfo || []
|
||||
const idx = infos.findIndex(info => info.purl)
|
||||
if (idx >= 0) {
|
||||
return { type: entries[idx]?.type || '128k', url: `${sip}${infos[idx].purl}` }
|
||||
}
|
||||
// 全部受限(VIP 歌匿名)时回退试听地址(服务器返回的 128k m4a 试听文件)
|
||||
if (data.testfile2g) return { type: '128k', url: `${sip}${data.testfile2g}` }
|
||||
return Promise.reject(new Error('获取失败'))
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
@@ -0,0 +1,48 @@
|
||||
// 网易云音乐 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.ts'
|
||||
// crypto.js 为 tsc 就地发射产物(源 sdk/wy/utils/crypto.ts);统一走 .js,避免同进程出现 .ts/.js 双实例
|
||||
import { eapi } from '../wy/utils/crypto.ts'
|
||||
import { getWyCookie } from '../auth/store.ts'
|
||||
|
||||
const levelMap = {
|
||||
'128k': 'standard',
|
||||
'320k': 'exhigh',
|
||||
flac: 'lossless',
|
||||
flac24bit: 'hires',
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
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
@@ -0,0 +1,103 @@
|
||||
// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐)
|
||||
// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展;
|
||||
// token 可从酷狗 App 抓包获取(login.token)
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { getCredentials, setCredentials } from './store.ts'
|
||||
|
||||
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 as any).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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} }
|
||||
// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface TxCredentials {
|
||||
cookie: 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
|
||||
}
|
||||
|
||||
/** tx(QQ) 登录态:cookie 与其中的 uin(未登录返回 null) */
|
||||
export const getTxAuth = (): { cookie: string; uin: string } | null => {
|
||||
const cred = getCredentials('tx') as TxCredentials | null
|
||||
if (!cred?.cookie) return null
|
||||
const uin = cred.cookie.match(/uin=(\d+)/)?.[1] ?? '0'
|
||||
return { cookie: cred.cookie, uin }
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// 网易云登录 —— 扫码 / 手机号,weapi 加密请求
|
||||
// 参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { weapi } from '../wy/utils/crypto.ts'
|
||||
import { getWyCookie, setCredentials } from './store.ts'
|
||||
|
||||
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 as any).status = 401
|
||||
throw err
|
||||
}
|
||||
const profile = await applyLogin(setCookie)
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 当前账号资料(未登录返回 null) */
|
||||
export const status = async() => {
|
||||
if (!getWyCookie()) return null
|
||||
return fetchProfile()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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 }
|
||||
@@ -0,0 +1,66 @@
|
||||
// 歌词解码(原主进程 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: Buffer): Promise<Buffer> => 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.ts'
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 酷狗 krc 歌词解码
|
||||
|
||||
import zlib from 'node:zlib'
|
||||
import { decodeName } from '../utils.ts'
|
||||
|
||||
// 基于社区通用的 krc 解析思路
|
||||
const enc_key = Buffer.from([0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69])
|
||||
|
||||
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: any = parseInt(result[2])
|
||||
let ms = time % 1000
|
||||
time /= 1000
|
||||
let m = Math.trunc(time / 60).toString().padStart(2, '0')
|
||||
time %= 60
|
||||
let s = Math.trunc(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 }
|
||||
@@ -0,0 +1,10 @@
|
||||
// 请求提示消息
|
||||
|
||||
export const requestMsg = {
|
||||
cancelRequest: '请求已取消',
|
||||
fail: '请求失败',
|
||||
timeout: '请求超时',
|
||||
tooManyRequests: '请求过多',
|
||||
notConnectNetwork: '无法连接到服务器',
|
||||
unachievable: '无法访问',
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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: Buffer): Promise<Buffer> => 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 ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 渲染器 IPC 桥(替代 Electron ipcRenderer.invoke)
|
||||
// 服务器环境下 IPC 调用直接映射到本地实现:
|
||||
// handle_kw_decode_lyric → 酷我歌词解码(见 lyricDecode.js)
|
||||
// handle_tx_decode_lyric → QQ音乐歌词解码(见 lyricDecode.js)
|
||||
|
||||
import { handleKwDecodeLyric, handleTxDecodeLyric } from './lyricDecode.ts'
|
||||
|
||||
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.ts'
|
||||
|
||||
export default { rendererInvoke, rendererSend, rendererOn, rendererOnce, rendererOff, rendererOffAll, rendererSendSync }
|
||||
@@ -0,0 +1,204 @@
|
||||
// 网络请求模块
|
||||
// 接口与原版保持一致:
|
||||
// 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'
|
||||
import { requestMsg } from './message.ts'
|
||||
|
||||
const MAX_BUFFER_SIZE = 64 * 1024 * 1024 // 64MB
|
||||
|
||||
export interface HttpOptions {
|
||||
method?: string
|
||||
headers?: Record<string, any>
|
||||
body?: any
|
||||
form?: Record<string, any>
|
||||
timeout?: number
|
||||
lookup?: any
|
||||
family?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface HttpFetchResponse {
|
||||
statusCode: number
|
||||
statusMessage?: string
|
||||
headers: Record<string, any>
|
||||
body: any
|
||||
raw: Buffer
|
||||
}
|
||||
|
||||
export interface HttpRequestObject {
|
||||
promise: Promise<HttpFetchResponse>
|
||||
cancelHttp(): void
|
||||
isCancelled: boolean
|
||||
requestObj?: any
|
||||
cancelFn?: ((err: any) => void) | null
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
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: Buffer): any => {
|
||||
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: string, options: HttpOptions = {}): HttpRequestObject => {
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const obj: HttpRequestObject = {
|
||||
promise: null as any,
|
||||
isCancelled: false,
|
||||
cancelHttp: () => {
|
||||
if (!obj.requestObj) {
|
||||
obj.isCancelled = true
|
||||
return
|
||||
}
|
||||
obj.requestObj.destroy(new Error('Canceled'))
|
||||
obj.requestObj = null
|
||||
// 取消后置空是运行时事实,但公开类型保持非空(消费方总是先取 promise 再取消)
|
||||
;(obj as any).promise = (obj as any).cancelHttp = null
|
||||
obj.cancelFn?.(new Error('Canceled'))
|
||||
obj.cancelFn = null
|
||||
},
|
||||
}
|
||||
obj.promise = new Promise((resolve, reject) => {
|
||||
obj.cancelFn = reject
|
||||
|
||||
let parsedUrl: URL
|
||||
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: string | any = 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: Record<string, any> = { ...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: Record<string, any> = {
|
||||
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 ?? 0
|
||||
|
||||
let stream: any = 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: Buffer[] = []
|
||||
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 => {
|
||||
const e = err as any
|
||||
if (obj.isCancelled || e.canceled) e.canceled = true
|
||||
reject(e)
|
||||
})
|
||||
|
||||
if (options.timeout) {
|
||||
req.setTimeout(options.timeout, () => {
|
||||
const err = new Error('Request timeout') as any
|
||||
err.timeout = true
|
||||
req.destroy(err)
|
||||
})
|
||||
}
|
||||
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求超时/断网错误统一处理(与原版行为一致)
|
||||
*/
|
||||
export const httpFetch = (url: string, options: HttpOptions = { method: 'get' }): HttpRequestObject => {
|
||||
const requestObj = buildHttpPromise(url, options)
|
||||
requestObj.promise = requestObj.promise.catch((err: any) => {
|
||||
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
|
||||
}
|
||||
|
||||
export default { httpFetch }
|
||||
@@ -0,0 +1,101 @@
|
||||
// 通用工具函数
|
||||
|
||||
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)))
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import kw from './kw/index.ts'
|
||||
import kg from './kg/index.ts'
|
||||
import tx from './tx/index.ts'
|
||||
import wy from './wy/index.ts'
|
||||
import { supportQuality } from './api-source.ts'
|
||||
import type { PlatformModule, SearchResult, SongInfo } from './types.ts'
|
||||
import {
|
||||
sizeFormate,
|
||||
formatPlayTime,
|
||||
dateFormat,
|
||||
dateFormat2,
|
||||
formatPlayCount,
|
||||
decodeName,
|
||||
} from './common/utils.ts'
|
||||
|
||||
// 通用工具函数(SDK 内 `import { ... } from '../index.ts'` 的解析目标)
|
||||
export { sizeFormate, formatPlayTime, dateFormat, dateFormat2, formatPlayCount, decodeName }
|
||||
|
||||
const SINGERS_SEPARATOR = /、|&|;|;|\/|,|,|\||\]|\[|!|!/g
|
||||
const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModule }> = [
|
||||
{ id: 'kw', name: '酷我音乐', module: kw },
|
||||
{ id: 'kg', name: '酷狗音乐', module: kg },
|
||||
{ id: 'tx', name: 'QQ音乐', module: tx },
|
||||
{ id: 'wy', name: '网易音乐', module: wy },
|
||||
]
|
||||
|
||||
const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
|
||||
|
||||
export const sources = SUPPORTED_SOURCES.map(({ id, name }) => ({ id, name }))
|
||||
|
||||
export const init = (): Promise<unknown[]> =>
|
||||
Promise.all(SUPPORTED_SOURCES.map((s) => s.module.init?.()).filter(Boolean))
|
||||
|
||||
export { supportQuality }
|
||||
|
||||
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
|
||||
|
||||
const filterStr = (str: unknown): string =>
|
||||
typeof str === 'string'
|
||||
? str.replace(/\s|'|\.|,|,|&|"|、|\(|\)|(|)|`|~|-|<|>|\||\/|\]|\[|!|!/g, '')
|
||||
: String(str ?? '')
|
||||
|
||||
const getIntervalSeconds = (interval: unknown): number => {
|
||||
if (!interval) return 0
|
||||
const parts = String(interval).split(':')
|
||||
let seconds = 0
|
||||
let unit = 1
|
||||
while (parts.length) {
|
||||
seconds += parseInt(parts.pop()!) * unit
|
||||
unit *= 60
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
const sortSingle = (singer: unknown): string => {
|
||||
if (!singer) return ''
|
||||
const s = String(singer)
|
||||
return SINGERS_SEPARATOR.test(s)
|
||||
? s.split(SINGERS_SEPARATOR).sort((a, b) => a.localeCompare(b)).join('、')
|
||||
: s
|
||||
}
|
||||
|
||||
interface MatchCandidate extends SongInfo {
|
||||
fSinger?: string
|
||||
fMusicName?: string
|
||||
fAlbumName?: string
|
||||
fInterval?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 多平台聚合搜索
|
||||
* @param params.name 歌名
|
||||
* @param params.singer 歌手
|
||||
* @param params.excludeSource 指定平台时,聚合结果会跳过该平台
|
||||
* @param params.limit 单平台结果条数
|
||||
*/
|
||||
export const searchMusic = async (
|
||||
{ name, singer, source: excludeSource, limit = 25 }: { name: string; singer?: string; source?: string; limit?: number },
|
||||
): Promise<Array<SearchResult | null>> => {
|
||||
const musicName = trimStr(name)
|
||||
const tasks: Array<Promise<SearchResult | null>> = []
|
||||
for (const source of SUPPORTED_SOURCES) {
|
||||
if (
|
||||
source.id === excludeSource ||
|
||||
!source.module.musicSearch
|
||||
)
|
||||
continue
|
||||
tasks.push(
|
||||
source.module.musicSearch
|
||||
.search(`${musicName} ${singer || ''}`.trim(), 1, limit)
|
||||
.catch(() => null)
|
||||
)
|
||||
}
|
||||
return (await Promise.all(tasks)).filter(Boolean)
|
||||
}
|
||||
|
||||
const pickMatching = <T>(items: T[], predicate: (item: T) => boolean): { matched: T[]; rest: T[] } => {
|
||||
const matched: T[] = []
|
||||
const rest: T[] = []
|
||||
for (const item of items) {
|
||||
if (predicate(item)) matched.push(item)
|
||||
else rest.push(item)
|
||||
}
|
||||
return { matched, rest }
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序)
|
||||
*/
|
||||
export const findMusic = async (
|
||||
{ name, singer, albumName, interval, source: s }: { name: string; singer?: string; albumName?: string; interval?: string; source?: string },
|
||||
): Promise<MatchCandidate[]> => {
|
||||
const lists = await searchMusic({ name, singer, source: s, limit: 25 })
|
||||
|
||||
const fMusicName = filterStr(name).toLowerCase()
|
||||
const fSinger = filterStr(sortSingle(singer)).toLowerCase()
|
||||
const fAlbumName = filterStr(albumName).toLowerCase()
|
||||
const fInterval = getIntervalSeconds(interval)
|
||||
|
||||
const isEqualsInterval = (intv: number) =>
|
||||
Math.abs((fInterval || intv) - (intv || fInterval)) < 5
|
||||
const isIncludesName = (n: string) => fMusicName.includes(n) || n.includes(fMusicName)
|
||||
const isIncludesSinger = (sng: string) =>
|
||||
fSinger ? fSinger.includes(sng) || sng.includes(fSinger) : true
|
||||
const isEqualsAlbum = (alb: string) => (fAlbumName ? fAlbumName === alb : true)
|
||||
|
||||
let candidates: MatchCandidate[] = []
|
||||
for (const source of lists) {
|
||||
if (!source) continue
|
||||
for (const item of source.list) {
|
||||
const cand = item as MatchCandidate
|
||||
cand.name = trimStr(cand.name)
|
||||
cand.singer = trimStr(cand.singer)
|
||||
cand.fSinger = filterStr(sortSingle(cand.singer).toLowerCase())
|
||||
cand.fMusicName = filterStr(String(cand.name ?? '').toLowerCase())
|
||||
cand.fAlbumName = filterStr(String(cand.albumName ?? '').toLowerCase())
|
||||
cand.fInterval = getIntervalSeconds(cand.interval)
|
||||
if (!isEqualsInterval(cand.fInterval)) {
|
||||
cand.name = null
|
||||
continue
|
||||
}
|
||||
candidates.push(cand)
|
||||
}
|
||||
}
|
||||
|
||||
const result: MatchCandidate[] = []
|
||||
const take = (predicate: (item: MatchCandidate) => boolean) => {
|
||||
const { matched, rest } = pickMatching(candidates, predicate)
|
||||
result.push(...matched)
|
||||
candidates = rest
|
||||
}
|
||||
|
||||
take((item) => item.fSinger === fSinger && item.fMusicName === fMusicName && item.interval === interval)
|
||||
take((item) => item.fMusicName === fMusicName && item.fSinger === fSinger && item.fAlbumName === fAlbumName)
|
||||
take((item) => item.fSinger === fSinger && item.fMusicName === fMusicName)
|
||||
take((item) => item.fMusicName === fMusicName && item.interval === interval)
|
||||
take((item) => item.fSinger === fSinger && item.interval === interval)
|
||||
take((item) => item.interval === interval)
|
||||
take((item) => item.fMusicName === fMusicName)
|
||||
take((item) => item.fSinger === fSinger)
|
||||
take((item) => item.fAlbumName === fAlbumName)
|
||||
|
||||
for (const item of [...candidates, ...result]) {
|
||||
delete item.fSinger
|
||||
delete item.fMusicName
|
||||
delete item.fAlbumName
|
||||
delete item.fInterval
|
||||
}
|
||||
|
||||
result.push(...candidates)
|
||||
return result
|
||||
}
|
||||
|
||||
const musicSdk = {
|
||||
sources,
|
||||
...SOURCE_MODULES,
|
||||
init,
|
||||
supportQuality,
|
||||
searchMusic,
|
||||
findMusic,
|
||||
}
|
||||
|
||||
export default musicSdk
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getMusicInfosByList } from './musicInfo.ts'
|
||||
import { createHttpFetch } from './util.ts'
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 通过AlbumId获取专辑信息
|
||||
* @param {*} id
|
||||
*/
|
||||
async getAlbumInfo(id) {
|
||||
const albumInfoRequest = await createHttpFetch('http://kmrserviceretry.kugou.com/container/v1/album?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
appid: 1005,
|
||||
clienttime: 1681833686,
|
||||
clientver: 11589,
|
||||
data: [{ album_id: id }],
|
||||
fields: 'language,grade_count,intro,mix_intro,heat,category,sizable_cover,cover,album_name,type,quality,publish_company,grade,special_tag,author_name,publish_date,language_id,album_id,exclusive,is_publish,trans_param,authors,album_tag',
|
||||
isBuy: 0,
|
||||
key: 'e6f3306ff7e2afb494e89fbbda0becbf',
|
||||
mid: '22945702112173152889429073101964063697',
|
||||
show_album_tag: 0,
|
||||
},
|
||||
})
|
||||
if (!albumInfoRequest) return Promise.reject(new Error('get album info failed.'))
|
||||
const albumInfo = albumInfoRequest[0]
|
||||
|
||||
return {
|
||||
name: albumInfo.album_name,
|
||||
image: albumInfo.sizable_cover.replace('{size}', 240),
|
||||
desc: albumInfo.intro,
|
||||
authorName: albumInfo.author_name,
|
||||
// play_count: this.formatPlayCount(info.count),
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 通过AlbumId获取专辑
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
*/
|
||||
async getAlbumDetail(id, page = 1, limit = 200) {
|
||||
const albumList = await createHttpFetch(`http://mobiles.kugou.com/api/v3/album/song?version=9108&albumid=${id}&plat=0&pagesize=${limit}&area_code=0&page=${page}&with_res_tag=0`)
|
||||
if (!albumList.info) return Promise.reject(new Error('Get album list failed.'))
|
||||
|
||||
let result = await getMusicInfosByList(albumList.info)
|
||||
|
||||
const info = await this.getAlbumInfo(id)
|
||||
|
||||
return {
|
||||
list: result || [],
|
||||
page,
|
||||
limit,
|
||||
total: albumList.total,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: info.name,
|
||||
img: info.image,
|
||||
desc: info.desc,
|
||||
author: info.authorName,
|
||||
// play_count: this.formatPlayCount(info.count),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeName, dateFormat2 } from '../index.ts'
|
||||
import { signatureParams } from './util.ts'
|
||||
// import { getMusicInfoRaw } from './musicInfo.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
_requestObj2: null as HttpRequestObject | null,
|
||||
async getComment({ hash }, page = 1, limit = 20) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
|
||||
// const res_id = (await getMusicInfoRaw(hash)).classification?.[0]?.res_id
|
||||
// if (!res_id) throw new Error('获取评论失败')
|
||||
|
||||
let timestamp = Date.now()
|
||||
const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0`
|
||||
// const params = `appid=1005&clienttime=${timestamp}&clienttoken=0&clientver=11409&code=fc4be23b4e972707f36b8a828a93ba8a&dfid=0&extdata=${hash}&kugouid=0&mid=16249512204336365674023395779019&mixsongid=${res_id}&p=${page}&pagesize=${limit}&uuid=0&ver=10`
|
||||
const _requestObj = httpFetch(`http://m.comment.service.kugou.com/r/v1/rank/newest?${params}&signature=${signatureParams(params)}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
// console.log(body)
|
||||
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取评论失败')
|
||||
const total = body.count ?? 0
|
||||
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
|
||||
},
|
||||
async getHotComment({ hash }, page = 1, limit = 20) {
|
||||
// console.log(songmid)
|
||||
if (this._requestObj2) this._requestObj2.cancelHttp()
|
||||
let timestamp = Date.now()
|
||||
const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0`
|
||||
// https://github.com/GitHub-ZC/wp_MusicApi/blob/bf9307dd138dc8ac6c4f7de29361209d4f5b665f/routes/v1/kugou/comment.js#L53
|
||||
const _requestObj2 = httpFetch(`http://m.comment.service.kugou.com/r/v1/rank/topliked?${params}&signature=${signatureParams(params)}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj2.promise
|
||||
// console.log(body)
|
||||
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取热门评论失败')
|
||||
const total = body.count ?? 0
|
||||
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
|
||||
},
|
||||
async getReplyComment({ songmid, audioId }, replyId, page = 1, limit = 100) {
|
||||
if (this._requestObj2) this._requestObj2.cancelHttp()
|
||||
|
||||
songmid = songmid.length == 32 // 修复歌曲ID存储变更导致图片获取失败的问题
|
||||
? audioId.split('_')[0]
|
||||
: songmid
|
||||
|
||||
const _requestObj2 = httpFetch(`http://comment.service.kugou.com/index.php?r=commentsv2/getReplyWithLike&code=fc4be23b4e972707f36b8a828a93ba8a&p=${page}&pagesize=${limit}&ver=1.01&clientver=8373&kugouid=687373022&need_show_image=1&appid=1001&childrenid=${songmid}&tid=${replyId}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Android712-AndroidPhone-8983-18-0-COMMENT-wifi',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj2.promise
|
||||
// console.log(body)
|
||||
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取回复评论失败')
|
||||
return { source: 'kg', comments: this.filterComment(body.list || []) }
|
||||
},
|
||||
replaceAt(raw, atList) {
|
||||
atList.forEach((atobj) => {
|
||||
raw = raw.replaceAll(`[at=${atobj.id}]`, `@${atobj.name} `)
|
||||
})
|
||||
return raw
|
||||
},
|
||||
filterComment(rawList) {
|
||||
return rawList.map(item => {
|
||||
let data = {
|
||||
id: item.id,
|
||||
text: decodeName((item.atlist ? this.replaceAt(item.content, item.atlist) : item.content) || ''),
|
||||
images: item.images ? item.images.map(i => i.url) : [],
|
||||
location: item.location,
|
||||
time: item.addtime,
|
||||
timeStr: dateFormat2(new Date(item.addtime).getTime()),
|
||||
userName: item.user_name,
|
||||
avatar: item.user_pic,
|
||||
userId: item.user_id,
|
||||
likedCount: item.like.likenum,
|
||||
replyNum: item.reply_num,
|
||||
reply: [],
|
||||
}
|
||||
|
||||
return item.pcontent
|
||||
? {
|
||||
id: item.id,
|
||||
text: decodeName(item.pcontent),
|
||||
time: null,
|
||||
userName: item.puser,
|
||||
avatar: null,
|
||||
userId: item.puser_id,
|
||||
likedCount: null,
|
||||
replyNum: null,
|
||||
reply: [data],
|
||||
}
|
||||
: data
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeName } from '../index.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
async getList(retryNum = 0) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
const _requestObj = httpFetch('http://gateway.kugou.com/api/v3/search/hot_tab?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0', {
|
||||
method: 'get',
|
||||
headers: {
|
||||
dfid: '1ssiv93oVqMp27cirf2CvoF1',
|
||||
mid: '156798703528610303473757548878786007104',
|
||||
clienttime: '1584257267',
|
||||
'x-router': 'msearch.kugou.com',
|
||||
'user-agent': 'Android9-AndroidPhone-10020-130-0-searchrecommendprotocol-wifi',
|
||||
'kg-rc': '1',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
if (statusCode != 200 || body.errcode !== 0) throw new Error('获取热搜词失败')
|
||||
// console.log(body, statusCode)
|
||||
return { source: 'kg', list: this.filterList(body.data.list) }
|
||||
},
|
||||
filterList(rawList) {
|
||||
const list: string[] = []
|
||||
rawList.forEach(item => {
|
||||
item.keywords.map(k => list.push(decodeName(k.keyword)))
|
||||
})
|
||||
return list
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import leaderboard from './leaderboard.ts'
|
||||
import { apis } from '../api-source.ts'
|
||||
import songList from './songList.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import pic from './pic.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
import album from './album.ts'
|
||||
// import tipSearch from './tipSearch.ts'
|
||||
|
||||
const kg = {
|
||||
// tipSearch,
|
||||
leaderboard,
|
||||
songList,
|
||||
musicSearch,
|
||||
hotSearch,
|
||||
comment,
|
||||
singer,
|
||||
album,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return apis('kg').getMusicUrl!(songInfo, type)
|
||||
},
|
||||
getLyric(songInfo) {
|
||||
return lyric.getLyric(songInfo)
|
||||
},
|
||||
// getLyric(songInfo) {
|
||||
// return apis('kg').getLyric(songInfo)
|
||||
// },
|
||||
getPic(songInfo) {
|
||||
return pic.getPic(songInfo)
|
||||
},
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
return `https://www.kugou.com/song/#hash=${songInfo.hash}&album_id=${songInfo.albumId}`
|
||||
},
|
||||
// getPic(songInfo) {
|
||||
// return apis('kg').getPic(songInfo)
|
||||
// },
|
||||
}
|
||||
|
||||
export default kg
|
||||
@@ -0,0 +1,209 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import type { HttpRequestObject, SongType, QualityInfo } from '../types.ts'
|
||||
|
||||
let boardList = [{ id: 'kg__8888', name: 'TOP500', bangid: '8888' }, { id: 'kg__6666', name: '飙升榜', bangid: '6666' }, { id: 'kg__59703', name: '蜂鸟流行音乐榜', bangid: '59703' }, { id: 'kg__52144', name: '抖音热歌榜', bangid: '52144' }, { id: 'kg__52767', name: '快手热歌榜', bangid: '52767' }, { id: 'kg__24971', name: 'DJ热歌榜', bangid: '24971' }, { id: 'kg__23784', name: '网络红歌榜', bangid: '23784' }, { id: 'kg__44412', name: '说唱先锋榜', bangid: '44412' }, { id: 'kg__31308', name: '内地榜', bangid: '31308' }, { id: 'kg__33160', name: '电音榜', bangid: '33160' }, { id: 'kg__31313', name: '香港地区榜', bangid: '31313' }, { id: 'kg__51341', name: '民谣榜', bangid: '51341' }, { id: 'kg__54848', name: '台湾地区榜', bangid: '54848' }, { id: 'kg__31310', name: '欧美榜', bangid: '31310' }, { id: 'kg__33162', name: 'ACG新歌榜', bangid: '33162' }, { id: 'kg__31311', name: '韩国榜', bangid: '31311' }, { id: 'kg__31312', name: '日本榜', bangid: '31312' }, { id: 'kg__49225', name: '80后热歌榜', bangid: '49225' }, { id: 'kg__49223', name: '90后热歌榜', bangid: '49223' }, { id: 'kg__49224', name: '00后热歌榜', bangid: '49224' }, { id: 'kg__33165', name: '粤语金曲榜', bangid: '33165' }, { id: 'kg__33166', name: '欧美金曲榜', bangid: '33166' }, { id: 'kg__33163', name: '影视金曲榜', bangid: '33163' }, { id: 'kg__51340', name: '伤感榜', bangid: '51340' }, { id: 'kg__35811', name: '会员专享榜', bangid: '35811' }, { id: 'kg__37361', name: '雷达榜', bangid: '37361' }, { id: 'kg__21101', name: '分享榜', bangid: '21101' }, { id: 'kg__46910', name: '综艺新歌榜', bangid: '46910' }, { id: 'kg__30972', name: '酷狗音乐人原创榜', bangid: '30972' }, { id: 'kg__60170', name: '闽南语榜', bangid: '60170' }, { id: 'kg__65234', name: '儿歌榜', bangid: '65234' }, { id: 'kg__4681', name: '美国BillBoard榜', bangid: '4681' }, { id: 'kg__25028', name: 'Beatport电子舞曲榜', bangid: '25028' }, { id: 'kg__4680', name: '英国单曲榜', bangid: '4680' }, { id: 'kg__38623', name: '韩国Melon音乐榜', bangid: '38623' }, { id: 'kg__42807', name: 'joox本地热歌榜', bangid: '42807' }, { id: 'kg__36107', name: '小语种热歌榜', bangid: '36107' }, { id: 'kg__4673', name: '日本公信榜', bangid: '4673' }, { id: 'kg__46868', name: '日本SPACE SHOWER榜', bangid: '46868' }, { id: 'kg__42808', name: 'KKBOX风云榜', bangid: '42808' }, { id: 'kg__60171', name: '越南语榜', bangid: '60171' }, { id: 'kg__60172', name: '泰语榜', bangid: '60172' }, { id: 'kg__59895', name: 'R&B榜', bangid: '59895' }, { id: 'kg__59896', name: '摇滚榜', bangid: '59896' }, { id: 'kg__59897', name: '爵士榜', bangid: '59897' }, { id: 'kg__59898', name: '乡村音乐榜', bangid: '59898' }, { id: 'kg__59900', name: '纯音乐榜', bangid: '59900' }, { id: 'kg__59899', name: '古典榜', bangid: '59899' }, { id: 'kg__22603', name: '5sing音乐榜', bangid: '22603' }, { id: 'kg__21335', name: '繁星音乐榜', bangid: '21335' }, { id: 'kg__33161', name: '古风新歌榜', bangid: '33161' }]
|
||||
|
||||
export default {
|
||||
listDetailLimit: 100,
|
||||
list: [
|
||||
{
|
||||
id: 'kgtop500',
|
||||
name: 'TOP500',
|
||||
bangid: '8888',
|
||||
},
|
||||
{
|
||||
id: 'kgwlhgb',
|
||||
name: '网络榜',
|
||||
bangid: '23784',
|
||||
},
|
||||
{
|
||||
id: 'kgbsb',
|
||||
name: '飙升榜',
|
||||
bangid: '6666',
|
||||
},
|
||||
{
|
||||
id: 'kgfxb',
|
||||
name: '分享榜',
|
||||
bangid: '21101',
|
||||
},
|
||||
{
|
||||
id: 'kgcyyb',
|
||||
name: '纯音乐榜',
|
||||
bangid: '33164',
|
||||
},
|
||||
{
|
||||
id: 'kggfjqb',
|
||||
name: '古风榜',
|
||||
bangid: '33161',
|
||||
},
|
||||
{
|
||||
id: 'kgyyjqb',
|
||||
name: '粤语榜',
|
||||
bangid: '33165',
|
||||
},
|
||||
{
|
||||
id: 'kgomjqb',
|
||||
name: '欧美榜',
|
||||
bangid: '33166',
|
||||
},
|
||||
{
|
||||
id: 'kgdyrgb',
|
||||
name: '电音榜',
|
||||
bangid: '33160',
|
||||
},
|
||||
{
|
||||
id: 'kgjdrgb',
|
||||
name: 'DJ热歌榜',
|
||||
bangid: '24971',
|
||||
},
|
||||
{
|
||||
id: 'kghyxgb',
|
||||
name: '华语新歌榜',
|
||||
bangid: '31308',
|
||||
},
|
||||
],
|
||||
getUrl(p, id, limit) {
|
||||
return `http://mobilecdnbj.kugou.com/api/v3/rank/song?version=9108&ranktype=1&plat=0&pagesize=${limit}&area_code=1&page=${p}&rankid=${id}&with_res_tag=0&show_portrait_mv=1`
|
||||
},
|
||||
regExps: {
|
||||
total: /total: '(\d+)',/,
|
||||
page: /page: '(\d+)',/,
|
||||
limit: /pagesize: '(\d+)',/,
|
||||
listData: /global\.features = (\[.+\]);/,
|
||||
},
|
||||
_requestBoardsObj: null as HttpRequestObject | null,
|
||||
getBoardsData() {
|
||||
if (this._requestBoardsObj) this._requestBoardsObj.cancelHttp()
|
||||
this._requestBoardsObj = httpFetch('http://mobilecdnbj.kugou.com/api/v5/rank/list?version=9108&plat=0&showtype=2&parentid=0&apiver=6&area_code=1&withsong=1')
|
||||
return this._requestBoardsObj!.promise
|
||||
},
|
||||
getData(url) {
|
||||
const requestDataObj = httpFetch(url)
|
||||
return requestDataObj.promise
|
||||
},
|
||||
getSinger(singers) {
|
||||
let arr: string[] = []
|
||||
singers.forEach(singer => {
|
||||
arr.push(singer.author_name)
|
||||
})
|
||||
return arr.join('、')
|
||||
},
|
||||
filterData(rawList) {
|
||||
// console.log(rawList)
|
||||
return rawList.map(item => {
|
||||
const types: Array<SongType & { hash?: string }> = []
|
||||
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
||||
if (item.filesize !== 0) {
|
||||
let size = sizeFormate(item.filesize)
|
||||
types.push({ type: '128k', size, hash: item.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
hash: item.hash,
|
||||
}
|
||||
}
|
||||
if (item['320filesize'] !== 0) {
|
||||
let size = sizeFormate(item['320filesize'])
|
||||
types.push({ type: '320k', size, hash: item['320hash'] })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
hash: item['320hash'],
|
||||
}
|
||||
}
|
||||
if (item.sqfilesize !== 0) {
|
||||
let size = sizeFormate(item.sqfilesize)
|
||||
types.push({ type: 'flac', size, hash: item.sqhash })
|
||||
_types.flac = {
|
||||
size,
|
||||
hash: item.sqhash,
|
||||
}
|
||||
}
|
||||
if (item.filesize_high !== 0) {
|
||||
let size = sizeFormate(item.filesize_high)
|
||||
types.push({ type: 'flac24bit', size, hash: item.hash_high })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
hash: item.hash_high,
|
||||
}
|
||||
}
|
||||
return {
|
||||
singer: formatSingerName(item.authors, 'author_name'),
|
||||
name: decodeName(item.songname),
|
||||
albumName: decodeName(item.remark),
|
||||
albumId: item.album_id,
|
||||
songmid: item.audio_id,
|
||||
source: 'kg',
|
||||
interval: formatPlayTime(item.duration),
|
||||
img: null,
|
||||
lrc: null,
|
||||
hash: item.hash,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
filterBoardsData(rawList) {
|
||||
// console.log(rawList)
|
||||
let list: Array<{ id: string; name: string; bangid: string }> = []
|
||||
for (const board of rawList) {
|
||||
if (board.isvol != 1) continue
|
||||
list.push({
|
||||
id: 'kg__' + board.rankid,
|
||||
name: board.rankname,
|
||||
bangid: String(board.rankid),
|
||||
})
|
||||
}
|
||||
return list
|
||||
},
|
||||
async getBoards(retryNum = 0) {
|
||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
// let response
|
||||
// try {
|
||||
// response = await this.getBoardsData()
|
||||
// } catch (error) {
|
||||
// return this.getBoards(retryNum)
|
||||
// }
|
||||
// // console.log(response.body)
|
||||
// if (response.statusCode !== 200 || response.body.errcode !== 0) return this.getBoards(retryNum)
|
||||
// const list = this.filterBoardsData(response.body.data.info)
|
||||
// console.log(list)
|
||||
// // console.log(JSON.stringify(list))
|
||||
// this.list = list
|
||||
// return {
|
||||
// list,
|
||||
// source: 'kg',
|
||||
// }
|
||||
this.list = boardList
|
||||
return {
|
||||
list: boardList,
|
||||
source: 'kg',
|
||||
}
|
||||
},
|
||||
async getList(bangid, page, retryNum = 0) {
|
||||
if (++retryNum > 3) throw new Error('try max num')
|
||||
const { body } = await this.getData(this.getUrl(page, bangid, this.listDetailLimit))
|
||||
|
||||
if (body.errcode != 0) return this.getList(bangid, page, retryNum)
|
||||
|
||||
// console.log(body)
|
||||
let total = body.data.total
|
||||
let limit = 100
|
||||
let listData = this.filterData(body.data.info)
|
||||
// console.log(listData)
|
||||
return {
|
||||
total,
|
||||
list: listData,
|
||||
limit,
|
||||
page,
|
||||
source: 'kg',
|
||||
}
|
||||
},
|
||||
getDetailPageUrl(id) {
|
||||
if (typeof id == 'string') id = id.replace('kg__', '')
|
||||
return `https://www.kugou.com/yy/rank/home/1-${id}.html`
|
||||
},
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeKrc } from '../common/lyricUtils/kg.ts'
|
||||
|
||||
export default {
|
||||
getIntv(interval) {
|
||||
if (!interval) return 0
|
||||
let intvArr = interval.split(':')
|
||||
let intv = 0
|
||||
let unit = 1
|
||||
while (intvArr.length) {
|
||||
intv += (intvArr.pop()) * unit
|
||||
unit *= 60
|
||||
}
|
||||
return parseInt(intv as unknown as string)
|
||||
},
|
||||
// getLyric(songInfo, tryNum = 0) {
|
||||
// let requestObj = httpFetch(`http://m.kugou.com/app/i/krc.php?cmd=100&keyword=${encodeURIComponent(songInfo.name)}&hash=${songInfo.hash}&timelength=${songInfo._interval || this.getIntv(songInfo.interval)}&d=0.38664927426725626`, {
|
||||
// headers: {
|
||||
// 'KG-RC': 1,
|
||||
// 'KG-THash': 'expand_search_manager.cpp:852736169:451',
|
||||
// 'User-Agent': 'KuGou2012-9020-ExpandSearchManager',
|
||||
// },
|
||||
// })
|
||||
// requestObj.promise = requestObj.promise.then(({ body, statusCode }) => {
|
||||
// if (statusCode !== 200) {
|
||||
// if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
||||
// let tryRequestObj = this.getLyric(songInfo, ++tryNum)
|
||||
// requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
||||
// return tryRequestObj.promise
|
||||
// }
|
||||
// return {
|
||||
// lyric: body,
|
||||
// tlyric: '',
|
||||
// }
|
||||
// })
|
||||
// return requestObj
|
||||
// },
|
||||
searchLyric(name, hash, time, tryNum = 0) {
|
||||
let requestObj = httpFetch(`http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&keyword=${encodeURIComponent(name)}&hash=${hash}&timelength=${time}&lrctxt=1`, {
|
||||
headers: {
|
||||
'KG-RC': '1',
|
||||
'KG-THash': 'expand_search_manager.cpp:852736169:451',
|
||||
'User-Agent': 'KuGou2012-9020-ExpandSearchManager',
|
||||
},
|
||||
})
|
||||
requestObj.promise = (requestObj.promise as any).then(({ body, statusCode }) => {
|
||||
if (statusCode !== 200) {
|
||||
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
||||
let tryRequestObj = this.searchLyric(name, hash, time, ++tryNum)
|
||||
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
||||
return tryRequestObj.promise
|
||||
}
|
||||
if (body.candidates.length) {
|
||||
let info = body.candidates[0]
|
||||
return { id: info.id, accessKey: info.accesskey, fmt: (info.krctype == 1 && info.contenttype != 1) ? 'krc' : 'lrc' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
return requestObj
|
||||
},
|
||||
getLyricDownload(id, accessKey, fmt, tryNum = 0) {
|
||||
let requestObj = httpFetch(`http://lyrics.kugou.com/download?ver=1&client=pc&id=${id}&accesskey=${accessKey}&fmt=${fmt}&charset=utf8`, {
|
||||
headers: {
|
||||
'KG-RC': '1',
|
||||
'KG-THash': 'expand_search_manager.cpp:852736169:451',
|
||||
'User-Agent': 'KuGou2012-9020-ExpandSearchManager',
|
||||
},
|
||||
})
|
||||
requestObj.promise = (requestObj.promise as any).then(({ body, statusCode }) => {
|
||||
if (statusCode !== 200) {
|
||||
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
||||
let tryRequestObj = (this.getLyric as any)(id, accessKey, fmt, ++tryNum)
|
||||
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
||||
return tryRequestObj.promise
|
||||
}
|
||||
|
||||
switch (body.fmt) {
|
||||
case 'krc':
|
||||
return decodeKrc(body.content)
|
||||
case 'lrc':
|
||||
return {
|
||||
lyric: Buffer.from(body.content, 'base64').toString('utf-8'),
|
||||
tlyric: '',
|
||||
rlyric: '',
|
||||
yrc: '',
|
||||
}
|
||||
default:
|
||||
return Promise.reject(new Error(`未知歌词格式: ${body.fmt}`))
|
||||
}
|
||||
})
|
||||
|
||||
return requestObj
|
||||
},
|
||||
getLyric(songInfo, tryNum = 0) {
|
||||
let requestObj = this.searchLyric(songInfo.name, songInfo.hash, songInfo._interval || this.getIntv(songInfo.interval))
|
||||
|
||||
requestObj.promise = requestObj.promise.then(result => {
|
||||
if (!result) return Promise.reject(new Error('Get lyric failed'))
|
||||
|
||||
let requestObj2 = this.getLyricDownload((result as any).id, (result as any).accessKey, (result as any).fmt)
|
||||
|
||||
requestObj.cancelHttp = requestObj2.cancelHttp.bind(requestObj2)
|
||||
|
||||
return requestObj2.promise
|
||||
}) as any
|
||||
return requestObj
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { createHttpFetch } from './util.ts'
|
||||
import type { SongInfo, SongType, QualityInfo } from '../types.ts'
|
||||
|
||||
const createGetMusicInfosTask = (hashs: Array<{ hash: string }>): Array<Promise<any>> => {
|
||||
let data = {
|
||||
area_code: '1',
|
||||
show_privilege: 1,
|
||||
show_album_info: '1',
|
||||
is_publish: '',
|
||||
appid: 1005,
|
||||
clientver: 11451,
|
||||
mid: '1',
|
||||
dfid: '-',
|
||||
clienttime: Date.now(),
|
||||
key: 'OIlwieks28dk2k092lksi2UIkp',
|
||||
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname,classification',
|
||||
}
|
||||
let list = hashs
|
||||
let tasks: Array<{ data: Array<{ hash: string }> }> = []
|
||||
while (list.length) {
|
||||
tasks.push(Object.assign({ data: list.slice(0, 100) }, data))
|
||||
if (list.length < 100) break
|
||||
list = list.slice(100)
|
||||
}
|
||||
let url = 'http://gateway.kugou.com/v3/album_audio/audio'
|
||||
return tasks.map(task => createHttpFetch(url, {
|
||||
method: 'POST',
|
||||
body: task,
|
||||
headers: {
|
||||
'KG-THash': '13a3164',
|
||||
'KG-RC': '1',
|
||||
'KG-Fake': '0',
|
||||
'KG-RF': '00869891',
|
||||
'User-Agent': 'Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi',
|
||||
'x-router': 'kmr.service.kugou.com',
|
||||
},
|
||||
}).then(data => data.map(s => s[0])))
|
||||
}
|
||||
|
||||
export const filterMusicInfoList = (rawList: any[]): SongInfo[] => {
|
||||
// console.log(rawList)
|
||||
let ids = new Set()
|
||||
let list: SongInfo[] = []
|
||||
rawList.forEach(item => {
|
||||
if (!item) return
|
||||
if (ids.has(item.audio_info.audio_id)) return
|
||||
ids.add(item.audio_info.audio_id)
|
||||
const types: Array<SongType & { hash?: string }> = []
|
||||
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
||||
if (item.audio_info.filesize !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize))
|
||||
types.push({ type: '128k', size, hash: item.audio_info.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
hash: item.audio_info.hash,
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_320 !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_320))
|
||||
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
hash: item.audio_info.hash_320,
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_flac !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_flac))
|
||||
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
|
||||
_types.flac = {
|
||||
size,
|
||||
hash: item.audio_info.hash_flac,
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_high !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_high))
|
||||
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
hash: item.audio_info.hash_high,
|
||||
}
|
||||
}
|
||||
list.push({
|
||||
singer: decodeName(item.author_name),
|
||||
name: decodeName(item.songname),
|
||||
albumName: decodeName(item.album_info.album_name),
|
||||
albumId: item.album_info.album_id,
|
||||
songmid: item.audio_info.audio_id,
|
||||
source: 'kg',
|
||||
interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000),
|
||||
img: null,
|
||||
lrc: null,
|
||||
hash: item.audio_info.hash,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export const getMusicInfos = async(hashs: Array<{ hash: string }>): Promise<SongInfo[]> => {
|
||||
return filterMusicInfoList(await Promise.all(createGetMusicInfosTask(hashs)).then(data => data.flat()))
|
||||
}
|
||||
|
||||
export const getMusicInfoRaw = async(hash: string): Promise<any> => {
|
||||
return Promise.all(createGetMusicInfosTask([{ hash }])).then(data => data.flat()[0])
|
||||
}
|
||||
|
||||
export const getMusicInfo = async(hash: string): Promise<SongInfo | undefined> => {
|
||||
return getMusicInfos([{ hash }]).then(data => data[0])
|
||||
}
|
||||
|
||||
export const getMusicInfosByList = (list: any[]): Promise<SongInfo[]> => {
|
||||
return getMusicInfos(list.map(item => ({ hash: item.hash })))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import type { SongType, QualityInfo } from '../types.ts'
|
||||
|
||||
|
||||
export default {
|
||||
limit: 30,
|
||||
total: 0,
|
||||
page: 0,
|
||||
allPage: 1,
|
||||
musicSearch(str, page, limit) {
|
||||
const searchRequest = httpFetch(`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`)
|
||||
return searchRequest.promise.then(({ body }) => body)
|
||||
},
|
||||
filterData(rawData) {
|
||||
const types: Array<SongType & { hash?: string }> = []
|
||||
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
||||
if (rawData.FileSize !== 0) {
|
||||
let size = sizeFormate(rawData.FileSize)
|
||||
types.push({ type: '128k', size, hash: rawData.FileHash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
hash: rawData.FileHash,
|
||||
}
|
||||
}
|
||||
if (rawData.HQFileSize !== 0) {
|
||||
let size = sizeFormate(rawData.HQFileSize)
|
||||
types.push({ type: '320k', size, hash: rawData.HQFileHash })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
hash: rawData.HQFileHash,
|
||||
}
|
||||
}
|
||||
if (rawData.SQFileSize !== 0) {
|
||||
let size = sizeFormate(rawData.SQFileSize)
|
||||
types.push({ type: 'flac', size, hash: rawData.SQFileHash })
|
||||
_types.flac = {
|
||||
size,
|
||||
hash: rawData.SQFileHash,
|
||||
}
|
||||
}
|
||||
if (rawData.ResFileSize !== 0) {
|
||||
let size = sizeFormate(rawData.ResFileSize)
|
||||
types.push({ type: 'flac24bit', size, hash: rawData.ResFileHash })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
hash: rawData.ResFileHash,
|
||||
}
|
||||
}
|
||||
return {
|
||||
singer: decodeName(formatSingerName(rawData.Singers, 'name')),
|
||||
name: decodeName(rawData.SongName),
|
||||
albumName: decodeName(rawData.AlbumName),
|
||||
albumId: rawData.AlbumID,
|
||||
songmid: rawData.Audioid,
|
||||
source: 'kg',
|
||||
interval: formatPlayTime(rawData.Duration),
|
||||
_interval: rawData.Duration,
|
||||
img: null,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
hash: rawData.FileHash,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
},
|
||||
handleResult(rawData) {
|
||||
let ids = new Set()
|
||||
const list: any[] = []
|
||||
rawData.forEach(item => {
|
||||
const key = item.Audioid + item.FileHash
|
||||
if (ids.has(key)) return
|
||||
ids.add(key)
|
||||
list.push(this.filterData(item))
|
||||
for (const childItem of item.Grp) {
|
||||
const key = item.Audioid + item.FileHash
|
||||
if (ids.has(key)) continue
|
||||
ids.add(key)
|
||||
list.push(this.filterData(childItem))
|
||||
}
|
||||
})
|
||||
return list
|
||||
},
|
||||
search(str, page = 1, limit, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
if (limit == null) limit = this.limit
|
||||
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
|
||||
return this.musicSearch(str, page, limit).then(result => {
|
||||
if (!result || result.error_code !== 0) return this.search(str, page, limit, retryNum)
|
||||
let list = this.handleResult(result.data.lists)
|
||||
|
||||
if (list == null) return this.search(str, page, limit, retryNum)
|
||||
|
||||
this.total = result.data.total
|
||||
this.page = page
|
||||
this.allPage = Math.ceil(this.total / limit)
|
||||
|
||||
return Promise.resolve({
|
||||
list,
|
||||
allPage: this.allPage,
|
||||
limit,
|
||||
total: this.total,
|
||||
source: 'kg',
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
|
||||
export default {
|
||||
getPic(songInfo) {
|
||||
const requestObj = httpFetch(
|
||||
'http://media.store.kugou.com/v1/get_res_privilege',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'KG-RC': '1',
|
||||
'KG-THash': 'expand_search_manager.cpp:852736169:451',
|
||||
'User-Agent': 'KuGou2012-9020-ExpandSearchManager',
|
||||
},
|
||||
body: {
|
||||
appid: 1001,
|
||||
area_code: '1',
|
||||
behavior: 'play',
|
||||
clientver: '9020',
|
||||
need_hash_offset: 1,
|
||||
relate: 1,
|
||||
resource: [
|
||||
{
|
||||
album_audio_id:
|
||||
songInfo.songmid.length == 32 // 修复歌曲ID存储变更导致图片获取失败的问题
|
||||
? songInfo.audioId.split('_')[0]
|
||||
: songInfo.songmid,
|
||||
album_id: songInfo.albumId,
|
||||
hash: songInfo.hash,
|
||||
id: 0,
|
||||
name: `${songInfo.singer} - ${songInfo.name}.mp3`,
|
||||
type: 'audio',
|
||||
},
|
||||
],
|
||||
token: '',
|
||||
userid: 2626431536,
|
||||
vip: 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
if (body.error_code !== 0) return Promise.reject(new Error('图片获取失败'))
|
||||
let info = body.data[0].info
|
||||
const img = info.imgsize ? info.image.replace('{size}', info.imgsize[0]) : info.image
|
||||
if (!img) return Promise.reject(new Error('Pic get failed'))
|
||||
return img
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { getMusicInfosByList } from './musicInfo.ts'
|
||||
import { createHttpFetch } from './util.ts'
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 获取歌手信息
|
||||
* @param {*} id
|
||||
*/
|
||||
getInfo(id) {
|
||||
if (id == 0) throw new Error('歌手不存在') // kg源某些歌曲在歌手没被kg收录时返回的歌手id为0
|
||||
return createHttpFetch(`http://mobiles.kugou.com/api/v5/singer/info?singerid=${id}`).then(body => {
|
||||
if (!body) throw new Error('get singer info faild.')
|
||||
|
||||
return {
|
||||
source: 'kg',
|
||||
id: body.singerid,
|
||||
info: {
|
||||
name: body.singername,
|
||||
desc: body.intro,
|
||||
avatar: body.imgurl.replace('{size}', 480),
|
||||
gender: body.grade === 1 ? 'man' : 'woman',
|
||||
},
|
||||
count: {
|
||||
music: body.songcount,
|
||||
album: body.albumcount,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取歌手专辑列表
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
getAlbumList(id, page = 1, limit = 10) {
|
||||
if (id == 0) throw new Error('歌手不存在')
|
||||
return createHttpFetch(`http://mobiles.kugou.com/api/v5/singer/album?singerid=${id}&page=${page}&pagesize=${limit}`).then(body => {
|
||||
if (!body.info) throw new Error('get singer album list faild.')
|
||||
|
||||
const list = this.filterAlbumList(body.info)
|
||||
return {
|
||||
source: 'kg',
|
||||
list,
|
||||
limit,
|
||||
page,
|
||||
total: body.total,
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取歌手歌曲列表
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
async getSongList(id, page = 1, limit = 100) {
|
||||
if (id == 0) throw new Error('歌手不存在')
|
||||
const body = await createHttpFetch(`http://mobiles.kugou.com/api/v5/singer/song?singerid=${id}&page=${page}&pagesize=${limit}`)
|
||||
if (!body.info) throw new Error('get singer song list faild.')
|
||||
|
||||
const list = await getMusicInfosByList(body.info)
|
||||
return {
|
||||
source: 'kg',
|
||||
list,
|
||||
limit,
|
||||
page,
|
||||
total: body.total,
|
||||
}
|
||||
},
|
||||
filterAlbumList(raw) {
|
||||
return raw.map(item => {
|
||||
return {
|
||||
id: item.albumid,
|
||||
count: item.songcount,
|
||||
info: {
|
||||
name: item.albumname,
|
||||
author: item.singername,
|
||||
img: String(item.imgurl || item.img || '').replace('{size}', '480'),
|
||||
desc: item.intro,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
||||
import { toMD5 } from '../utils.ts'
|
||||
import { signatureParams } from './util.ts'
|
||||
import type { HttpRequestObject, SongInfo, SongType, QualityInfo } from '../types.ts'
|
||||
|
||||
// 原版使用 @renderer/utils/musicSdk/kg/vendors/infSign.min(浏览器签名库),
|
||||
// 其 H5 签名算法为:{srcappid, clientver, clienttime, mid, uuid, dfid} + 业务参数,
|
||||
// 按键排序拼接后以 key 包裹再取 MD5(与 signatureParams 同构)。
|
||||
const handleSignature = (id, page, limit) => {
|
||||
const params = {
|
||||
srcappid: '2919',
|
||||
clientver: '20000',
|
||||
clienttime: Date.now(),
|
||||
mid: '0',
|
||||
uuid: '0',
|
||||
dfid: '-',
|
||||
appid: 1058,
|
||||
type: 0,
|
||||
module: 'playlist',
|
||||
page,
|
||||
pagesize: limit,
|
||||
specialid: id,
|
||||
}
|
||||
const key = 'NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt'
|
||||
const sorted = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('')
|
||||
return Promise.resolve(toMD5(`${key}${sorted}${key}`))
|
||||
}
|
||||
|
||||
export default {
|
||||
_requestObj_tags: null as HttpRequestObject | null,
|
||||
_requestObj_listInfo: null as HttpRequestObject | null,
|
||||
_requestObj_list: null as HttpRequestObject | null,
|
||||
_requestObj_listRecommend: null as HttpRequestObject | null,
|
||||
listDetailLimit: 10000,
|
||||
currentTagInfo: {
|
||||
id: undefined,
|
||||
info: undefined,
|
||||
},
|
||||
sortList: [
|
||||
{
|
||||
name: '推荐',
|
||||
id: '5',
|
||||
},
|
||||
{
|
||||
name: '最热',
|
||||
id: '6',
|
||||
},
|
||||
{
|
||||
name: '最新',
|
||||
id: '7',
|
||||
},
|
||||
{
|
||||
name: '热藏',
|
||||
id: '3',
|
||||
},
|
||||
{
|
||||
name: '飙升',
|
||||
id: '8',
|
||||
},
|
||||
],
|
||||
cache: new Map(),
|
||||
regExps: {
|
||||
listData: /global\.data = (\[.+\]);/,
|
||||
listInfo: /global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};/,
|
||||
// https://www.kugou.com/yy/special/single/1067062.html
|
||||
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/,
|
||||
},
|
||||
// async getGlobalSpecialId(specialId) {
|
||||
// return httpFetch(`http://mobilecdnbj.kugou.com/api/v5/special/info?specialid=${specialId}`, {
|
||||
// headers: {
|
||||
// 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70',
|
||||
// },
|
||||
// }).promise.then(({ body }) => {
|
||||
// // console.log(body)
|
||||
// if (!body.data.global_specialid) Promise.reject(new Error('Failed to get global collection id.'))
|
||||
// return body.data.global_specialid
|
||||
// })
|
||||
// },
|
||||
// async getListInfoBySpecialId(special_id, retry = 0) {
|
||||
// if (++retry > 2) throw new Error('failed')
|
||||
// return httpFetch(`https://m.kugou.com/plist/list/${special_id}/?json=true`, {
|
||||
// headers: {
|
||||
// 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70',
|
||||
// },
|
||||
// follow_max: 2,
|
||||
// }).promise.then(({ body }) => {
|
||||
// // console.log(body)
|
||||
// if (!body.info.list) return this.getListInfoBySpecialId(special_id, retry)
|
||||
// let listinfo = body.info.list
|
||||
// return {
|
||||
// listInfo: {
|
||||
// name: listinfo.specialname,
|
||||
// image: listinfo.imgurl.replace('{size}', '150'),
|
||||
// intro: listinfo.intro,
|
||||
// author: listinfo.nickname,
|
||||
// playcount: listinfo.playcount,
|
||||
// total: listinfo.songcount,
|
||||
// },
|
||||
// globalSpecialId: listinfo.global_specialid,
|
||||
// }
|
||||
// })
|
||||
// },
|
||||
// async getSongListDetailByGlobalSpecialId(id, page, limit = 100, retry = 0) {
|
||||
// if (++retry > 2) throw new Error('failed')
|
||||
// console.log(id)
|
||||
// const params = `specialid=0&need_sort=1&module=CloudMusic&clientver=11409&pagesize=${limit}&global_collection_id=${id}&userid=0&page=${page}&type=1&area_code=1&appid=1005`
|
||||
// return httpFetch(`http://pubsongscdn.tx.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params)}`).promise.then(({ body }) => {
|
||||
// // console.log(body)
|
||||
// if (body.data?.info == null) return this.getSongListDetailByGlobalSpecialId(id, page, limit, retry)
|
||||
// return body.data.info
|
||||
// })
|
||||
// },
|
||||
parseHtmlDesc(html) {
|
||||
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
|
||||
let index = html.indexOf(prefix)
|
||||
if (index < 0) return null
|
||||
const afterStr = html.substring(index + prefix.length)
|
||||
index = afterStr.indexOf('</div>')
|
||||
if (index < 0) return null
|
||||
return decodeName(afterStr.substring(0, index))
|
||||
},
|
||||
async getListDetailBySpecialId(id, page?, tryNum = 0) {
|
||||
if (tryNum > 2) throw new Error('try max num')
|
||||
|
||||
const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise
|
||||
let listData = body.match(this.regExps.listData)
|
||||
let listInfo = body.match(this.regExps.listInfo)
|
||||
if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum)
|
||||
let list = await this.getMusicInfos(JSON.parse(listData[1]))
|
||||
// listData = this.filterData(JSON.parse(listData[1]))
|
||||
let name
|
||||
let pic
|
||||
if (listInfo) {
|
||||
name = listInfo[1]
|
||||
pic = listInfo[2]
|
||||
}
|
||||
let desc = this.parseHtmlDesc(body)
|
||||
|
||||
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name,
|
||||
img: pic,
|
||||
desc,
|
||||
// author: body.result.info.userinfo.username,
|
||||
// play_count: formatPlayCount(body.result.listen_num),
|
||||
},
|
||||
}
|
||||
|
||||
// const globalSpecialId = await this.getGlobalSpecialId(id)
|
||||
// const limit = 100
|
||||
// const listData = await this.getSongListDetailByGlobalSpecialId(globalSpecialId, page, limit)
|
||||
// if (!Array.isArray(listData))
|
||||
// return this.getUserListDetail2(globalSpecialId)
|
||||
// return {
|
||||
// list: this.filterDatav9(listData),
|
||||
// page,
|
||||
// limit,
|
||||
// total: listInfo.total,
|
||||
// source: 'kg',
|
||||
// info: {
|
||||
// name: listInfo.name,
|
||||
// img: listInfo.image,
|
||||
// desc: listInfo.intro,
|
||||
// author: listInfo.author,
|
||||
// play_count: formatPlayCount(listInfo.playcount),
|
||||
// },
|
||||
// }
|
||||
},
|
||||
getInfoUrl(tagId?) {
|
||||
return tagId
|
||||
? `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c=${tagId}`
|
||||
: 'http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&'
|
||||
},
|
||||
getSongListUrl(sortId, tagId, page) {
|
||||
if (tagId == null) tagId = ''
|
||||
return `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t=${sortId}&c=${tagId}&p=${page}`
|
||||
},
|
||||
getSongListDetailUrl(id) {
|
||||
return `http://www2.kugou.kugou.com/yueku/v9/special/single/${id}-5-9999.html`
|
||||
},
|
||||
|
||||
filterInfoHotTag(rawData) {
|
||||
const result: any[] = []
|
||||
if (rawData.status !== 1) return result
|
||||
for (const key of Object.keys(rawData.data)) {
|
||||
let tag = rawData.data[key]
|
||||
result.push({
|
||||
id: tag.special_id,
|
||||
name: tag.special_name,
|
||||
source: 'kg',
|
||||
})
|
||||
}
|
||||
return result
|
||||
},
|
||||
filterTagInfo(rawData) {
|
||||
const result: any[] = []
|
||||
for (const name of Object.keys(rawData)) {
|
||||
result.push({
|
||||
name,
|
||||
list: rawData[name].data.map(tag => ({
|
||||
parent_id: tag.parent_id,
|
||||
parent_name: tag.pname,
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
source: 'kg',
|
||||
})),
|
||||
})
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
getSongList(sortId, tagId, page, tryNum = 0) {
|
||||
if (this._requestObj_list) this._requestObj_list.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_list = httpFetch(
|
||||
this.getSongListUrl(sortId, tagId, page),
|
||||
)
|
||||
return this._requestObj_list!.promise.then(({ body }) => {
|
||||
if (!body || body.status !== 1) return this.getSongList(sortId, tagId, page, ++tryNum)
|
||||
return this.filterList(body.special_db)
|
||||
})
|
||||
},
|
||||
getSongListRecommend(tryNum = 0) {
|
||||
if (this._requestObj_listRecommend) this._requestObj_listRecommend.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_listRecommend = httpFetch(
|
||||
'http://everydayrec.service.kugou.com/guess_special_recommend',
|
||||
{
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'KuGou2012-8275-web_browser_event_handler',
|
||||
},
|
||||
body: {
|
||||
appid: 1001,
|
||||
clienttime: 1566798337219,
|
||||
clientver: 8275,
|
||||
key: 'f1f93580115bb106680d2375f8032d96',
|
||||
mid: '21511157a05844bd085308bc76ef3343',
|
||||
platform: 'pc',
|
||||
userid: '262643156',
|
||||
return_min: 6,
|
||||
return_max: 15,
|
||||
},
|
||||
},
|
||||
)
|
||||
return this._requestObj_listRecommend!.promise.then(({ body }) => {
|
||||
if (body.status !== 1) return this.getSongListRecommend(++tryNum)
|
||||
return this.filterList(body.data.special_list)
|
||||
})
|
||||
},
|
||||
filterList(rawData) {
|
||||
return rawData.map(item => ({
|
||||
play_count: item.total_play_count || formatPlayCount(item.play_count),
|
||||
id: 'id_' + item.specialid,
|
||||
author: item.nickname,
|
||||
name: item.specialname,
|
||||
time: dateFormat(item.publish_time || item.publishtime, 'Y-M-D'),
|
||||
img: item.img || item.imgurl,
|
||||
total: item.songcount,
|
||||
grade: item.grade,
|
||||
desc: item.intro,
|
||||
source: 'kg',
|
||||
}))
|
||||
},
|
||||
|
||||
async createHttp(url, options, retryNum = 0) {
|
||||
if (retryNum > 2) throw new Error('try max num')
|
||||
let result
|
||||
try {
|
||||
result = await httpFetch(url, options).promise
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return this.createHttp(url, options, ++retryNum)
|
||||
}
|
||||
// console.log(result.statusCode, result.body)
|
||||
if (result.statusCode !== 200 ||
|
||||
(
|
||||
(result.body.error_code !== undefined
|
||||
? result.body.error_code
|
||||
: result.body.errcode !== undefined
|
||||
? result.body.errcode
|
||||
: result.body.err_code
|
||||
) !== 0)
|
||||
) return this.createHttp(url, options, ++retryNum)
|
||||
if (result.body.data) return result.body.data
|
||||
if (Array.isArray(result.body.info)) return result.body
|
||||
return result.body.info
|
||||
},
|
||||
|
||||
createTask(hashs) {
|
||||
let data = {
|
||||
area_code: '1',
|
||||
show_privilege: 1,
|
||||
show_album_info: '1',
|
||||
is_publish: '',
|
||||
appid: 1005,
|
||||
clientver: 11451,
|
||||
mid: '1',
|
||||
dfid: '-',
|
||||
clienttime: Date.now(),
|
||||
key: 'OIlwieks28dk2k092lksi2UIkp',
|
||||
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname',
|
||||
}
|
||||
let list = hashs
|
||||
let tasks: any[] = []
|
||||
while (list.length) {
|
||||
tasks.push(Object.assign({ data: list.slice(0, 100) }, data))
|
||||
if (list.length < 100) break
|
||||
list = list.slice(100)
|
||||
}
|
||||
let url = 'http://gateway.kugou.com/v2/album_audio/audio'
|
||||
return tasks.map(task => this.createHttp(url, {
|
||||
method: 'POST',
|
||||
body: task,
|
||||
headers: {
|
||||
'KG-THash': '13a3164',
|
||||
'KG-RC': '1',
|
||||
'KG-Fake': '0',
|
||||
'KG-RF': '00869891',
|
||||
'User-Agent': 'Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi',
|
||||
'x-router': 'kmr.service.kugou.com',
|
||||
},
|
||||
}).then(data => data.map(s => s[0])))
|
||||
},
|
||||
async getMusicInfos(list) {
|
||||
return this.filterData2(
|
||||
await Promise.all(
|
||||
this.createTask(
|
||||
this.deDuplication(list)
|
||||
.map(item => ({ hash: item.hash })),
|
||||
))
|
||||
.then(([...datas]) => datas.flat()))
|
||||
},
|
||||
|
||||
async getUserListDetailByCode(id) {
|
||||
const songInfo = await this.createHttp('http://t.kugou.com/command/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'KG-RC': 1,
|
||||
'KG-THash': 'network_super_call.cpp:3676261689:379',
|
||||
'User-Agent': '',
|
||||
},
|
||||
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: id },
|
||||
})
|
||||
// console.log(songInfo)
|
||||
// type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列
|
||||
let songList
|
||||
let info = songInfo.info
|
||||
switch (info.type) {
|
||||
case 2:
|
||||
if (!info.global_collection_id) return this.getListDetailBySpecialId(info.id)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (info.global_collection_id) return this.getUserListDetail2(info.global_collection_id)
|
||||
if (info.userid != null) {
|
||||
songList = await this.createHttp('http://www2.kugou.kugou.com/apps/kucodeAndShare/app/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'KG-RC': 1,
|
||||
'KG-THash': 'network_super_call.cpp:3676261689:379',
|
||||
'User-Agent': '',
|
||||
},
|
||||
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: { id: info.id, type: 3, userid: info.userid, collect_type: 0, page: 1, pagesize: info.count } },
|
||||
})
|
||||
// console.log(songList)
|
||||
}
|
||||
let list = await this.getMusicInfos(songList || songInfo.list)
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: info.count,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: info.name,
|
||||
img: (info.img_size && info.img_size.replace('{size}', 240)) || info.img,
|
||||
// desc: body.result.info.list_desc,
|
||||
author: info.username,
|
||||
// play_count: formatPlayCount(info.count),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async getUserListDetail3(chain, page) {
|
||||
const songInfo = await this.createHttp(`http://m.kugou.com/schain/transfer?pagesize=${this.listDetailLimit}&chain=${chain}&su=1&page=${page}&n=0.7928855356604456`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
|
||||
},
|
||||
})
|
||||
if (!songInfo.list) {
|
||||
if (songInfo.global_collection_id) return this.getUserListDetail2(songInfo.global_collection_id)
|
||||
else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain))
|
||||
}
|
||||
let list = await this.getMusicInfos(songInfo.list)
|
||||
// console.log(info, songInfo)
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: this.listDetailLimit,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: songInfo.info.name,
|
||||
img: songInfo.info.img,
|
||||
// desc: body.result.info.list_desc,
|
||||
author: songInfo.info.username,
|
||||
// play_count: formatPlayCount(info.count),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
deDuplication(datas) {
|
||||
let ids = new Set()
|
||||
return datas.filter(({ hash }) => {
|
||||
if (ids.has(hash)) return false
|
||||
ids.add(hash)
|
||||
return true
|
||||
})
|
||||
},
|
||||
|
||||
async decodeGcid(gcid) {
|
||||
const params = 'dfid=-&appid=1005&mid=0&clientver=20109&clienttime=640612895&uuid=-'
|
||||
const body = {
|
||||
ret_info: 1,
|
||||
data: [
|
||||
{
|
||||
id: gcid,
|
||||
id_type: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = await this.createHttp(`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36',
|
||||
Referer: 'https://m.kugou.com/',
|
||||
},
|
||||
body,
|
||||
})
|
||||
return result.list[0].global_collection_id
|
||||
},
|
||||
|
||||
async getUserListDetailByLink({ info }, link) {
|
||||
let listInfo = info['0']
|
||||
let total = listInfo.count
|
||||
let tasks: any[] = []
|
||||
let page = 0
|
||||
while (total) {
|
||||
const limit = total > 90 ? 90 : total
|
||||
total -= limit
|
||||
page += 1
|
||||
tasks.push(this.createHttp(link.replace(/pagesize=\d+/, 'pagesize=' + limit).replace(/page=\d+/, 'page=' + page), {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
|
||||
Referer: link,
|
||||
},
|
||||
}).then(data => data.list.info))
|
||||
}
|
||||
let result = await Promise.all(tasks).then(([...datas]) => datas.flat())
|
||||
result = await this.getMusicInfos(result)
|
||||
// console.log(result)
|
||||
return {
|
||||
list: result,
|
||||
page,
|
||||
limit: this.listDetailLimit,
|
||||
total: result.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: listInfo.name,
|
||||
img: listInfo.pic && listInfo.pic.replace('{size}', 240),
|
||||
// desc: body.result.info.list_desc,
|
||||
author: listInfo.list_create_username,
|
||||
// play_count: formatPlayCount(listInfo.count),
|
||||
},
|
||||
}
|
||||
},
|
||||
createGetListDetail2Task(id, total) {
|
||||
let tasks: any[] = []
|
||||
let page = 0
|
||||
while (total) {
|
||||
const limit = total > 300 ? 300 : total
|
||||
total -= limit
|
||||
page += 1
|
||||
const params = 'appid=1058&global_specialid=' + id + '&specialid=0&plat=0&version=8000&page=' + page + '&pagesize=' + limit + '&srcappid=2919&clientver=20000&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-'
|
||||
tasks.push(this.createHttp(`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(params, 'web')}`, {
|
||||
headers: {
|
||||
mid: '1586163263991',
|
||||
Referer: 'https://m3ws.kugou.com/share/index.php',
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
|
||||
dfid: '-',
|
||||
clienttime: '1586163263991',
|
||||
},
|
||||
}).then(data => data.info))
|
||||
}
|
||||
return Promise.all(tasks).then(([...datas]) => datas.flat())
|
||||
},
|
||||
async getUserListDetail2(global_collection_id) {
|
||||
let id = global_collection_id
|
||||
if (id.length > 1000) throw new Error('get list error')
|
||||
const params = 'appid=1058&specialid=0&global_specialid=' + id + '&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-'
|
||||
let info = await this.createHttp(`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`, {
|
||||
headers: {
|
||||
mid: '1586163242519',
|
||||
Referer: 'https://m3ws.kugou.com/share/index.php',
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
|
||||
dfid: '-',
|
||||
clienttime: '1586163242519',
|
||||
},
|
||||
})
|
||||
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
|
||||
let list = await this.getMusicInfos(songInfo)
|
||||
// console.log(info, songInfo, list)
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: this.listDetailLimit,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: info.specialname,
|
||||
img: info.imgurl && info.imgurl.replace('{size}', 240),
|
||||
desc: info.intro,
|
||||
author: info.nickname,
|
||||
play_count: formatPlayCount(info.playcount),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async getListInfoByChain(chain) {
|
||||
if (this.cache.has(chain)) return this.cache.get(chain)
|
||||
const { body } = await httpFetch(`https://m.kugou.com/share/?chain=${chain}&id=${chain}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
}).promise
|
||||
let result = body.match(/var\sphpParam\s=\s({.+?});/)
|
||||
if (result) result = JSON.parse(result[1])
|
||||
this.cache.set(chain, result)
|
||||
return result
|
||||
},
|
||||
|
||||
async getUserListDetailByPcChain(chain) {
|
||||
let key = `${chain}_pc_list`
|
||||
if (this.cache.has(key)) return this.cache.get(key)
|
||||
const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
|
||||
},
|
||||
}).promise
|
||||
let result = body.match(/var\sdataFromSmarty\s=\s(\[.+?\])/)
|
||||
if (result) result = JSON.parse(result[1])
|
||||
this.cache.set(chain, result)
|
||||
result = await this.getMusicInfos(result)
|
||||
// console.log(info, songInfo)
|
||||
return result
|
||||
},
|
||||
|
||||
async getUserListDetail4(songInfo, chain, page) {
|
||||
const limit = 100
|
||||
const [listInfo, list] = await Promise.all([
|
||||
this.getListInfoByChain(chain),
|
||||
this.getUserListDetailById(songInfo.id, page, limit),
|
||||
])
|
||||
return {
|
||||
list: list || [],
|
||||
page,
|
||||
limit,
|
||||
total: list.length ?? 0,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: listInfo.specialname,
|
||||
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
|
||||
// desc: body.result.info.list_desc,
|
||||
author: listInfo.nickname,
|
||||
// play_count: formatPlayCount(info.count),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async getUserListDetail5(chain) {
|
||||
const [listInfo, list] = await Promise.all([
|
||||
this.getListInfoByChain(chain),
|
||||
this.getUserListDetailByPcChain(chain),
|
||||
])
|
||||
return {
|
||||
list: list || [],
|
||||
page: 1,
|
||||
limit: this.listDetailLimit,
|
||||
total: list.length ?? 0,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: listInfo.specialname,
|
||||
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
|
||||
// desc: body.result.info.list_desc,
|
||||
author: listInfo.nickname,
|
||||
// play_count: formatPlayCount(info.count),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async getUserListDetailById(id, page, limit) {
|
||||
const signature = await handleSignature(id, page, limit)
|
||||
let info = await this.createHttp(`https://pubsongscdn.kugou.com/v2/get_other_list_file?srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist&page=${page}&pagesize=${limit}&specialid=${id}&signature=${signature}`, {
|
||||
headers: {
|
||||
Referer: 'https://m3ws.kugou.com/share/index.php',
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
|
||||
dfid: '-',
|
||||
},
|
||||
})
|
||||
|
||||
// console.log(info)
|
||||
let result = await this.getMusicInfos(info.info)
|
||||
// console.log(info, songInfo)
|
||||
return result
|
||||
},
|
||||
|
||||
async getUserListDetail(link, page, retryNum = 0) {
|
||||
if (retryNum > 3) return Promise.reject(new Error('link try max num'))
|
||||
if (link.includes('#')) link = link.replace(/#.*$/, '')
|
||||
if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
|
||||
if (link.includes('gcid_')) {
|
||||
let gcid = link.match(/gcid_\w+/)?.[0]
|
||||
if (gcid) {
|
||||
const global_collection_id = await this.decodeGcid(gcid)
|
||||
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
|
||||
}
|
||||
}
|
||||
if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
|
||||
if (link.includes('.html')) {
|
||||
if (link.includes('zlist.html')) {
|
||||
link = link.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
|
||||
if (link.includes('pagesize')) {
|
||||
link = link.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page)
|
||||
} else {
|
||||
link += `&pagesize=${this.listDetailLimit}&page=${page}`
|
||||
}
|
||||
} else if (!link.includes('song.html')) return this.getUserListDetail3(link.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page)
|
||||
}
|
||||
|
||||
const requestObj_listDetailLink = httpFetch(link, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
|
||||
Referer: link,
|
||||
},
|
||||
})
|
||||
const { headers: { location }, statusCode, body } = await requestObj_listDetailLink.promise
|
||||
const loc = location as string | undefined
|
||||
// console.log(body, location)
|
||||
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
|
||||
if (loc) {
|
||||
// console.log(location)
|
||||
if (loc.includes('global_collection_id')) return this.getUserListDetail2(loc.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
|
||||
if (loc.includes('gcid_')) {
|
||||
let gcid = link.match(/gcid_\w+/)?.[0]
|
||||
if (gcid) {
|
||||
const global_collection_id = await this.decodeGcid(gcid)
|
||||
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
|
||||
}
|
||||
}
|
||||
if (loc.includes('chain=')) return this.getUserListDetail3(loc.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
|
||||
if (loc.includes('.html')) {
|
||||
if (loc.includes('zlist.html')) {
|
||||
let link = loc.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
|
||||
if (link.includes('pagesize')) {
|
||||
link = link.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page)
|
||||
} else {
|
||||
link += `&pagesize=${this.listDetailLimit}&page=${page}`
|
||||
}
|
||||
return this.getUserListDetail(link, page, ++retryNum)
|
||||
} else return this.getUserListDetail3(loc.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page)
|
||||
}
|
||||
// console.log('location', location)
|
||||
return this.getUserListDetail(loc, page, ++retryNum)
|
||||
}
|
||||
if (typeof body == 'string') {
|
||||
let global_collection_id = body.match(/"global_collection_id":"(\w+)"/)?.[1]
|
||||
if (!global_collection_id) {
|
||||
let gcid = body.match(/"encode_gic":"(\w+)"/)?.[1]
|
||||
if (!gcid) gcid = body.match(/"encode_src_gid":"(\w+)"/)?.[1]
|
||||
if (gcid) global_collection_id = await this.decodeGcid(gcid)
|
||||
}
|
||||
if (!global_collection_id) throw new Error('get list error')
|
||||
return this.getUserListDetail2(global_collection_id)
|
||||
}
|
||||
if (body.errcode !== 0) return this.getUserListDetail(link, page, ++retryNum)
|
||||
return this.getUserListDetailByLink(body, link)
|
||||
},
|
||||
|
||||
async getListDetail(id, page) { // 获取歌曲列表内的音乐
|
||||
id = id.toString()
|
||||
if (id.includes('special/single/')) {
|
||||
id = id.replace(this.regExps.listDetailLink, '$1')
|
||||
} else if (/https?:/.test(id)) {
|
||||
// fix https://www.kugou.com/songlist/xxx/?uid=xxx&chl=qq_client&cover=http%3A%2F%2Fimge.kugou.com%xxx.jpg&iszlist=1
|
||||
return this.getUserListDetail(id.replace(/^.*?http/, 'http'), page)
|
||||
} else if (/^\d+$/.test(id)) {
|
||||
return this.getUserListDetailByCode(id)
|
||||
} else if (id.startsWith('id_')) {
|
||||
id = id.replace('id_', '')
|
||||
}
|
||||
// if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1')
|
||||
|
||||
return this.getListDetailBySpecialId(id, page)
|
||||
},
|
||||
filterData(rawList) {
|
||||
// console.log(rawList)
|
||||
return rawList.map(item => {
|
||||
const types: Array<{ type: string; size: string; hash?: string }> = []
|
||||
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
||||
if (item.filesize !== 0) {
|
||||
let size = sizeFormate(item.filesize)
|
||||
types.push({ type: '128k', size, hash: item.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
hash: item.hash,
|
||||
}
|
||||
}
|
||||
if (item.filesize_320 !== 0) {
|
||||
let size = sizeFormate(item.filesize_320)
|
||||
types.push({ type: '320k', size, hash: item.hash_320 })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
hash: item.hash_320,
|
||||
}
|
||||
}
|
||||
if (item.filesize_ape !== 0) {
|
||||
let size = sizeFormate(item.filesize_ape)
|
||||
types.push({ type: 'ape', size, hash: item.hash_ape })
|
||||
_types.ape = {
|
||||
size,
|
||||
hash: item.hash_ape,
|
||||
}
|
||||
}
|
||||
if (item.filesize_flac !== 0) {
|
||||
let size = sizeFormate(item.filesize_flac)
|
||||
types.push({ type: 'flac', size, hash: item.hash_flac })
|
||||
_types.flac = {
|
||||
size,
|
||||
hash: item.hash_flac,
|
||||
}
|
||||
}
|
||||
return {
|
||||
singer: decodeName(item.singername),
|
||||
name: decodeName(item.songname),
|
||||
albumName: decodeName(item.album_name),
|
||||
albumId: item.album_id,
|
||||
songmid: item.audio_id,
|
||||
source: 'kg',
|
||||
interval: formatPlayTime(item.duration / 1000),
|
||||
img: null,
|
||||
lrc: null,
|
||||
hash: item.hash,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
// getSinger(singers) {
|
||||
// let arr = []
|
||||
// singers?.forEach(singer => {
|
||||
// arr.push(singer.name)
|
||||
// })
|
||||
// return arr.join('、')
|
||||
// },
|
||||
// v9 API
|
||||
// filterDatav9(rawList) {
|
||||
// console.log(rawList)
|
||||
// return rawList.map(item => {
|
||||
// const types = []
|
||||
// const _types = {}
|
||||
// item.relate_goods.forEach(qualityObj => {
|
||||
// if (qualityObj.level === 2) {
|
||||
// let size = sizeFormate(qualityObj.size)
|
||||
// types.push({ type: '128k', size, hash: qualityObj.hash })
|
||||
// _types['128k'] = {
|
||||
// size,
|
||||
// hash: qualityObj.hash,
|
||||
// }
|
||||
// } else if (qualityObj.level === 4) {
|
||||
// let size = sizeFormate(qualityObj.size)
|
||||
// types.push({ type: '320k', size, hash: qualityObj.hash })
|
||||
// _types['320k'] = {
|
||||
// size,
|
||||
// hash: qualityObj.hash,
|
||||
// }
|
||||
// } else if (qualityObj.level === 5) {
|
||||
// let size = sizeFormate(qualityObj.size)
|
||||
// types.push({ type: 'flac', size, hash: qualityObj.hash })
|
||||
// _types.flac = {
|
||||
// size,
|
||||
// hash: qualityObj.hash,
|
||||
// }
|
||||
// } else if (qualityObj.level === 6) {
|
||||
// let size = sizeFormate(qualityObj.size)
|
||||
// types.push({ type: 'flac24bit', size, hash: qualityObj.hash })
|
||||
// _types.flac24bit = {
|
||||
// size,
|
||||
// hash: qualityObj.hash,
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// const nameInfo = item.name.split(' - ')
|
||||
// return {
|
||||
// singer: this.getSinger(item.singerinfo),
|
||||
// name: decodeName((nameInfo[1] ?? nameInfo[0]).trim()),
|
||||
// albumName: decodeName(item.albuminfo.name),
|
||||
// albumId: item.albuminfo.id,
|
||||
// songmid: item.audio_id,
|
||||
// source: 'kg',
|
||||
// interval: formatPlayTime(item.timelen / 1000),
|
||||
// img: null,
|
||||
// lrc: null,
|
||||
// hash: item.hash,
|
||||
// types,
|
||||
// _types,
|
||||
// typeUrl: {},
|
||||
// }
|
||||
// })
|
||||
// },
|
||||
|
||||
// hash list filter
|
||||
filterData2(rawList) {
|
||||
// console.log(rawList)
|
||||
let ids = new Set()
|
||||
let list: SongInfo[] = []
|
||||
rawList.forEach(item => {
|
||||
if (!item) return
|
||||
if (ids.has(item.audio_info.audio_id)) return
|
||||
ids.add(item.audio_info.audio_id)
|
||||
const types: Array<SongType & { hash?: string }> = []
|
||||
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
||||
if (item.audio_info.filesize !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize))
|
||||
types.push({ type: '128k', size, hash: item.audio_info.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
hash: item.audio_info.hash,
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_320 !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_320))
|
||||
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
hash: item.audio_info.hash_320,
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_flac !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_flac))
|
||||
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
|
||||
_types.flac = {
|
||||
size,
|
||||
hash: item.audio_info.hash_flac,
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_high !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_high))
|
||||
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
hash: item.audio_info.hash_high,
|
||||
}
|
||||
}
|
||||
list.push({
|
||||
singer: decodeName(item.author_name),
|
||||
name: decodeName(item.songname),
|
||||
albumName: decodeName(item.album_info.album_name),
|
||||
albumId: item.album_info.album_id,
|
||||
songmid: item.audio_info.audio_id,
|
||||
source: 'kg',
|
||||
interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000),
|
||||
img: null,
|
||||
lrc: null,
|
||||
hash: item.audio_info.hash,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
})
|
||||
return list
|
||||
},
|
||||
|
||||
// 获取列表信息
|
||||
getListInfo(tagId, tryNum = 0) {
|
||||
if (this._requestObj_listInfo) this._requestObj_listInfo.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_listInfo = httpFetch(this.getInfoUrl(tagId))
|
||||
return this._requestObj_listInfo!.promise.then(({ body }) => {
|
||||
if (body.status !== 1) return this.getListInfo(tagId, ++tryNum)
|
||||
return {
|
||||
limit: body.data.params.pagesize,
|
||||
page: body.data.params.p,
|
||||
total: body.data.params.total,
|
||||
source: 'kg',
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取列表数据
|
||||
getList(sortId, tagId, page) {
|
||||
let tasks = [this.getSongList(sortId, tagId, page)]
|
||||
tasks.push(
|
||||
this.currentTagInfo.id === tagId
|
||||
? Promise.resolve(this.currentTagInfo.info)
|
||||
: this.getListInfo(tagId).then(info => {
|
||||
this.currentTagInfo.id = tagId
|
||||
this.currentTagInfo.info = Object.assign({}, info)
|
||||
return info
|
||||
}),
|
||||
)
|
||||
if (!tagId && page === 1 && sortId === this.sortList[0].id) tasks.push(this.getSongListRecommend()) // 如果是所有类别,则顺便获取推荐列表
|
||||
return Promise.all(tasks).then(([list, info, recommendList]) => {
|
||||
if (recommendList) list.unshift(...recommendList)
|
||||
return {
|
||||
list,
|
||||
...info,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取标签
|
||||
getTags(tryNum = 0) {
|
||||
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_tags = httpFetch(this.getInfoUrl())
|
||||
return this._requestObj_tags!.promise.then(({ body }) => {
|
||||
if (body.status !== 1) return this.getTags(++tryNum)
|
||||
return {
|
||||
hotTag: this.filterInfoHotTag(body.data.hotTag),
|
||||
tags: this.filterTagInfo(body.data.tagids),
|
||||
source: 'kg',
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getDetailPageUrl(id) {
|
||||
if (typeof id == 'string') {
|
||||
if (/^https?:\/\//.test(id)) return id
|
||||
id = id.replace('id_', '')
|
||||
}
|
||||
return `https://www.kugou.com/yy/special/single/${id}.html`
|
||||
},
|
||||
|
||||
search(text, page, limit = 20) {
|
||||
// http://msearchretry.kugou.com/api/v3/search/special?version=9209&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&pagesize=20&filter=0&page=1&sver=2&with_res_tag=0
|
||||
// return httpFetch(`http://ioscdn.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&plat=2&version=7910&correct=1&sver=5`)
|
||||
return httpFetch(`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`)
|
||||
.promise.then(({ body }) => {
|
||||
if (body.errcode != 0) throw new Error('filed')
|
||||
// console.log(body.data.info)
|
||||
return {
|
||||
list: body.data.info.map(item => {
|
||||
return {
|
||||
play_count: formatPlayCount(item.playcount),
|
||||
id: 'id_' + item.specialid,
|
||||
author: item.nickname,
|
||||
name: item.specialname,
|
||||
time: dateFormat(item.publishtime, 'Y-M-D'),
|
||||
img: item.imgurl,
|
||||
grade: item.grade,
|
||||
desc: item.intro,
|
||||
total: item.songcount,
|
||||
source: 'kg',
|
||||
}
|
||||
}),
|
||||
limit,
|
||||
total: body.data.total,
|
||||
source: 'kg',
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// getList
|
||||
// getTags
|
||||
// getListDetail
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createHttpFetch } from './util.ts'
|
||||
|
||||
export default {
|
||||
requestObj: null as any,
|
||||
cancelTipSearch() {
|
||||
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
|
||||
},
|
||||
tipSearchBySong(str) {
|
||||
this.cancelTipSearch()
|
||||
this.requestObj = createHttpFetch(`https://searchtip.kugou.com/getSearchTip?MusicTipCount=10&keyword=${encodeURIComponent(str)}`, {
|
||||
headers: {
|
||||
referer: 'https://www.kugou.com/',
|
||||
},
|
||||
})
|
||||
return this.requestObj.then(body => {
|
||||
return body[0].RecordDatas
|
||||
})
|
||||
},
|
||||
handleResult(rawData) {
|
||||
return rawData.map(info => info.HintInfo)
|
||||
},
|
||||
async search(str) {
|
||||
return this.tipSearchBySong(str).then(result => this.handleResult(result))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { toMD5 } from '../utils.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import type { HttpOptions } from '../types.ts'
|
||||
|
||||
// s.content[0].lyricContent.forEach(([str]) => {
|
||||
// console.log(str)
|
||||
// })
|
||||
|
||||
/**
|
||||
* 签名
|
||||
* @param {*} params
|
||||
* @param {*} apiver
|
||||
*/
|
||||
export const signatureParams = (params: string, platform = 'android', body = ''): string => {
|
||||
let keyparam = 'OIlwieks28dk2k092lksi2UIkp'
|
||||
if (platform === 'web') keyparam = 'NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt'
|
||||
let param_list = params.split('&')
|
||||
param_list.sort()
|
||||
let sign_params = `${keyparam}${param_list.join('')}${body}${keyparam}`
|
||||
return toMD5(sign_params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个适用于KG的Http请求
|
||||
* @param {*} url
|
||||
* @param {*} options
|
||||
* @param {*} retryNum
|
||||
*/
|
||||
export const createHttpFetch = async(url: string, options?: HttpOptions, retryNum = 0): Promise<any> => {
|
||||
if (retryNum > 2) throw new Error('try max num')
|
||||
let result
|
||||
try {
|
||||
result = await httpFetch(url, options).promise
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return createHttpFetch(url, options, ++retryNum)
|
||||
}
|
||||
// console.log(result.statusCode, result.body)
|
||||
if (result.statusCode !== 200 ||
|
||||
(
|
||||
result.body.error_code ??
|
||||
result.body.errcode ??
|
||||
result.body.err_code) != 0
|
||||
) return createHttpFetch(url, options, ++retryNum)
|
||||
if (result.body.data) return result.body.data
|
||||
if (Array.isArray(result.body.info)) return result.body
|
||||
return result.body.info
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeName } from '../index.ts'
|
||||
import { formatSinger, objStr2JSON } from './util.ts'
|
||||
import type { Quality } from '../types.ts'
|
||||
|
||||
// let requestObj_list
|
||||
export default {
|
||||
limit_list: 36,
|
||||
limit_song: 1000,
|
||||
filterListDetail(rawList, albumName, albumId) {
|
||||
// console.log(rawList)
|
||||
// console.log(rawList.length, rawList2.length)
|
||||
return rawList.map((item, inedx) => {
|
||||
let formats = item.formats.split('|')
|
||||
let types: Array<{ type: Quality; size: string | null }> = []
|
||||
let _types: Partial<Record<Quality, { size: string | null }>> = {}
|
||||
if (formats.includes('MP3128')) {
|
||||
types.push({ type: '128k', size: null })
|
||||
_types['128k'] = {
|
||||
size: null,
|
||||
}
|
||||
}
|
||||
// if (formats.includes('MP3192')) {
|
||||
// types.push({ type: '192k', size: null })
|
||||
// _types['192k'] = {
|
||||
// size: null,
|
||||
// }
|
||||
// }
|
||||
if (formats.includes('MP3H')) {
|
||||
types.push({ type: '320k', size: null })
|
||||
_types['320k'] = {
|
||||
size: null,
|
||||
}
|
||||
}
|
||||
// if (formats.includes('AL')) {
|
||||
// types.push({ type: 'ape', size: null })
|
||||
// _types.ape = {
|
||||
// size: null,
|
||||
// }
|
||||
// }
|
||||
if (formats.includes('ALFLAC')) {
|
||||
types.push({ type: 'flac', size: null })
|
||||
_types.flac = {
|
||||
size: null,
|
||||
}
|
||||
}
|
||||
if (formats.includes('HIRFLAC')) {
|
||||
types.push({ type: 'flac24bit', size: null })
|
||||
_types.flac24bit = {
|
||||
size: null,
|
||||
}
|
||||
}
|
||||
// types.reverse()
|
||||
return {
|
||||
singer: formatSinger(decodeName(item.artist)),
|
||||
name: decodeName(item.name),
|
||||
albumName,
|
||||
albumId,
|
||||
songmid: item.id,
|
||||
source: 'kw',
|
||||
interval: null,
|
||||
img: item.pic,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 格式化播放数量
|
||||
* @param {*} num
|
||||
*/
|
||||
formatPlayCount(num: any) {
|
||||
if (num > 100000000) return parseInt((num / 10000000) as any) / 10 + '亿'
|
||||
if (num > 10000) return parseInt((num / 1000) as any) / 10 + '万'
|
||||
return num
|
||||
},
|
||||
getAlbumListDetail(id, page, retryNum = 0) {
|
||||
if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
const requestObj_listDetail = httpFetch(`http://search.kuwo.cn/r.s?pn=${page - 1}&rn=${this.limit_song}&stype=albuminfo&albumid=${id}&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0`)
|
||||
return requestObj_listDetail.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
|
||||
body = objStr2JSON(body)
|
||||
// console.log(body)
|
||||
if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum)
|
||||
body.name = decodeName(body.name)
|
||||
return {
|
||||
list: this.filterListDetail(body.musiclist, body.name, body.albumid),
|
||||
page,
|
||||
limit: this.limit_song,
|
||||
total: parseInt(body.songnum),
|
||||
source: 'kw',
|
||||
info: {
|
||||
name: body.name,
|
||||
img: body.img || body.hts_img,
|
||||
desc: decodeName(body.info),
|
||||
author: decodeName(body.artist),
|
||||
// play_count: this.formatPlayCount(body.playnum),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
// getAlbumListDetail(id, page, retryNum = 0) {
|
||||
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
// return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => {
|
||||
// return resp.promise.then(({ statusCode, body }) => {
|
||||
// console.log(body)
|
||||
// return Promise.reject(new Error('failed'))
|
||||
// // if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
|
||||
// // const data = body.data
|
||||
// // console.log(data)
|
||||
// // if (!data.musicList) return this.getAlbumListDetail(id, page, ++retryNum)
|
||||
// // return {
|
||||
// // list: this.filterListDetail(data.musiclist),
|
||||
// // page,
|
||||
// // limit: this.limit_song,
|
||||
// // total: data.total,
|
||||
// // source: 'kw',
|
||||
// // info: {
|
||||
// // name: data.album,
|
||||
// // img: data.pic,
|
||||
// // desc: data.albuminfo,
|
||||
// // author: data.artist,
|
||||
// // play_count: this.formatPlayCount(data.playCnt),
|
||||
// // },
|
||||
// // }
|
||||
// })
|
||||
// })
|
||||
// },
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { dateFormat2 } from '../index.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
_requestObj2: null as HttpRequestObject | null,
|
||||
async getComment({ songmid }, page = 1, limit = 20) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
|
||||
const _requestObj = httpFetch(`http://ncomment.kuwo.cn/com.s?f=web&type=get_comment&aapiver=1&prod=kwplayer_ar_10.5.2.0&digest=15&sid=${songmid}&start=${limit * (page - 1)}&msgflag=1&count=${limit}&newver=3&uid=0`, {
|
||||
headers: {
|
||||
'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
if (statusCode != 200 || body.code != '200') throw new Error('获取评论失败')
|
||||
// console.log(body)
|
||||
|
||||
const total = body.comments_counts
|
||||
return {
|
||||
source: 'kw',
|
||||
comments: this.filterComment(body.comments),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
maxPage: Math.ceil(total / limit) || 1,
|
||||
}
|
||||
},
|
||||
async getHotComment({ songmid }, page = 1, limit = 100) {
|
||||
if (this._requestObj2) this._requestObj2.cancelHttp()
|
||||
|
||||
const _requestObj2 = httpFetch(`http://ncomment.kuwo.cn/com.s?f=web&type=get_rec_comment&aapiver=1&prod=kwplayer_ar_10.5.2.0&digest=15&sid=${songmid}&start=${limit * (page - 1)}&msgflag=1&count=${limit}&newver=3&uid=0`, {
|
||||
headers: {
|
||||
'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj2.promise
|
||||
if (statusCode != 200 || body.code != '200') throw new Error('获取热门评论失败')
|
||||
// console.log(body)
|
||||
|
||||
const total = body.hot_comments_counts
|
||||
return {
|
||||
source: 'kw',
|
||||
comments: this.filterComment(body.hot_comments),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
maxPage: Math.ceil(total / limit) || 1,
|
||||
}
|
||||
},
|
||||
filterComment(rawList) {
|
||||
if (!rawList) return []
|
||||
return rawList.map(item => {
|
||||
return {
|
||||
id: item.id,
|
||||
text: item.msg,
|
||||
time: item.time,
|
||||
timeStr: dateFormat2(Number(item.time) * 1000),
|
||||
userName: item.u_name,
|
||||
avatar: item.u_pic,
|
||||
userId: item.u_id,
|
||||
likedCount: item.like_num,
|
||||
images: item.mpic ? [decodeURIComponent(item.mpic)] : [],
|
||||
reply: item.child_comments
|
||||
? item.child_comments.map(i => {
|
||||
return {
|
||||
id: i.id,
|
||||
text: i.msg,
|
||||
time: i.time,
|
||||
timeStr: dateFormat2(Number(i.time) * 1000),
|
||||
userName: i.u_name,
|
||||
avatar: i.u_pic,
|
||||
userId: i.u_id,
|
||||
likedCount: i.like_num,
|
||||
images: i.mpic ? [i.mpic] : [],
|
||||
}
|
||||
})
|
||||
: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
async getList(retryNum = 0) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
const _requestObj = httpFetch('http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1¬race=0&uid=0&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1', {
|
||||
headers: {
|
||||
'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
if (statusCode != 200 || body.status !== 'ok') throw new Error('获取热搜词失败')
|
||||
// console.log(body, statusCode)
|
||||
return { source: 'kw', list: this.filterList(body.tagvalue) }
|
||||
},
|
||||
filterList(rawList) {
|
||||
return rawList.map(item => item.key)
|
||||
},
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import tipSearch from './tipSearch.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import { formatSinger } from './util.ts'
|
||||
import leaderboard from './leaderboard.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import pic from './pic.ts'
|
||||
import { apis } from '../api-source.ts'
|
||||
import songList from './songList.ts'
|
||||
import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import album from './album.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
const kw = {
|
||||
_musicInfoRequestObj: null as HttpRequestObject | null,
|
||||
_musicInfoPromiseCancelFn: null,
|
||||
_musicPicRequestObj: null as HttpRequestObject | null,
|
||||
_musicPicPromiseCancelFn: null,
|
||||
album,
|
||||
// context: null,
|
||||
|
||||
|
||||
// init(context) {
|
||||
// if (this.isInited) return
|
||||
// this.isInited = true
|
||||
// this.context = context
|
||||
|
||||
// // this.musicSearch.search('我又想你了').then(res => {
|
||||
// // console.log(res)
|
||||
// // })
|
||||
|
||||
// // this.getMusicUrl('62355680', '320k').then(url => {
|
||||
// // console.log(url)
|
||||
// // })
|
||||
// },
|
||||
|
||||
tipSearch,
|
||||
musicSearch,
|
||||
leaderboard,
|
||||
songList,
|
||||
hotSearch,
|
||||
comment,
|
||||
getLyric(songInfo, isGetLyricx) {
|
||||
// let singer = songInfo.singer.indexOf('、') > -1 ? songInfo.singer.split('、')[0] : songInfo.singer
|
||||
return lyric.getLyric(songInfo, isGetLyricx)
|
||||
},
|
||||
handleMusicInfo(songInfo) {
|
||||
return this.getMusicInfo(songInfo).then(info => {
|
||||
// console.log(JSON.stringify(info))
|
||||
songInfo.name = info.name
|
||||
songInfo.singer = formatSinger(info.artist)
|
||||
songInfo.img = info.pic
|
||||
songInfo.albumName = info.album
|
||||
return songInfo
|
||||
// return Object.assign({}, songInfo, {
|
||||
// name: info.name,
|
||||
// singer: formatSinger(info.artist),
|
||||
// img: info.pic,
|
||||
// albumName: info.album,
|
||||
// })
|
||||
})
|
||||
},
|
||||
|
||||
getMusicUrl(songInfo, type) {
|
||||
return apis('kw').getMusicUrl!(songInfo, type)
|
||||
},
|
||||
|
||||
getMusicInfo(songInfo) {
|
||||
if (this._musicInfoRequestObj) this._musicInfoRequestObj.cancelHttp()
|
||||
this._musicInfoRequestObj = httpFetch(`http://www.kuwo.cn/api/www/music/musicInfo?mid=${songInfo.songmid}`)
|
||||
return this._musicInfoRequestObj!.promise.then(({ body }) => {
|
||||
return body.code === 200 ? body.data : Promise.reject(new Error(body.msg))
|
||||
})
|
||||
},
|
||||
|
||||
getMusicUrls(musicInfo, cb) {
|
||||
let tasks: Array<Promise<any>> = []
|
||||
let songId = musicInfo.songmid
|
||||
musicInfo.types.forEach(type => {
|
||||
tasks.push(kw.getMusicUrl(songId, type.type).promise)
|
||||
})
|
||||
Promise.all(tasks).then(urlInfo => {
|
||||
let typeUrl = {}
|
||||
urlInfo.forEach(info => {
|
||||
typeUrl[info.type] = info.url
|
||||
})
|
||||
cb(typeUrl)
|
||||
})
|
||||
},
|
||||
|
||||
getPic(songInfo) {
|
||||
return pic.getPic(songInfo)
|
||||
},
|
||||
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
return `http://www.kuwo.cn/play_detail/${songInfo.songmid}`
|
||||
},
|
||||
|
||||
// init() {
|
||||
// return getToken()
|
||||
// },
|
||||
}
|
||||
|
||||
export default kw
|
||||
@@ -0,0 +1,236 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, decodeName } from '../index.ts'
|
||||
import { formatSinger, wbdCrypto } from './util.ts'
|
||||
import type { HttpRequestObject, Quality, QualityInfo, SongType } from '../types.ts'
|
||||
|
||||
|
||||
const boardList = [{ id: 'kw__93', name: '飙升榜', bangid: '93' }, { id: 'kw__17', name: '新歌榜', bangid: '17' }, { id: 'kw__16', name: '热歌榜', bangid: '16' }, { id: 'kw__158', name: '抖音热歌榜', bangid: '158' }, { id: 'kw__292', name: '铃声榜', bangid: '292' }, { id: 'kw__284', name: '热评榜', bangid: '284' }, { id: 'kw__290', name: 'ACG新歌榜', bangid: '290' }, { id: 'kw__286', name: '台湾KKBOX榜', bangid: '286' }, { id: 'kw__279', name: '冬日暖心榜', bangid: '279' }, { id: 'kw__281', name: '巴士随身听榜', bangid: '281' }, { id: 'kw__255', name: 'KTV点唱榜', bangid: '255' }, { id: 'kw__280', name: '家务进行曲榜', bangid: '280' }, { id: 'kw__282', name: '熬夜修仙榜', bangid: '282' }, { id: 'kw__283', name: '枕边轻音乐榜', bangid: '283' }, { id: 'kw__278', name: '古风音乐榜', bangid: '278' }, { id: 'kw__264', name: 'Vlog音乐榜', bangid: '264' }, { id: 'kw__242', name: '电音榜', bangid: '242' }, { id: 'kw__187', name: '流行趋势榜', bangid: '187' }, { id: 'kw__204', name: '现场音乐榜', bangid: '204' }, { id: 'kw__186', name: 'ACG神曲榜', bangid: '186' }, { id: 'kw__185', name: '最强翻唱榜', bangid: '185' }, { id: 'kw__26', name: '经典怀旧榜', bangid: '26' }, { id: 'kw__104', name: '华语榜', bangid: '104' }, { id: 'kw__182', name: '粤语榜', bangid: '182' }, { id: 'kw__22', name: '欧美榜', bangid: '22' }, { id: 'kw__184', name: '韩语榜', bangid: '184' }, { id: 'kw__183', name: '日语榜', bangid: '183' }, { id: 'kw__145', name: '会员畅听榜', bangid: '145' }, { id: 'kw__153', name: '网红新歌榜', bangid: '153' }, { id: 'kw__64', name: '影视金曲榜', bangid: '64' }, { id: 'kw__176', name: 'DJ嗨歌榜', bangid: '176' }, { id: 'kw__106', name: '真声音', bangid: '106' }, { id: 'kw__12', name: 'Billboard榜', bangid: '12' }, { id: 'kw__49', name: 'iTunes音乐榜', bangid: '49' }, { id: 'kw__180', name: 'beatport电音榜', bangid: '180' }, { id: 'kw__13', name: '英国UK榜', bangid: '13' }, { id: 'kw__164', name: '百大DJ榜', bangid: '164' }, { id: 'kw__246', name: 'YouTube音乐排行榜', bangid: '246' }, { id: 'kw__265', name: '韩国Genie榜', bangid: '265' }, { id: 'kw__14', name: '韩国M-net榜', bangid: '14' }, { id: 'kw__8', name: '香港电台榜', bangid: '8' }, { id: 'kw__15', name: '日本公信榜', bangid: '15' }, { id: 'kw__151', name: '腾讯音乐人原创榜', bangid: '151' }]
|
||||
|
||||
const sortQualityArray = (array: SongType[]): SongType[] => {
|
||||
const qualityMap = {
|
||||
flac24bit: 4,
|
||||
flac: 3,
|
||||
'320k': 2,
|
||||
'128k': 1,
|
||||
}
|
||||
const rawQualityArray: Array<{ type: number; index: number }> = []
|
||||
const newQualityArray: SongType[] = []
|
||||
|
||||
array.forEach((item, index) => {
|
||||
const type = qualityMap[item.type]
|
||||
if (!type) return
|
||||
rawQualityArray.push({ type, index })
|
||||
})
|
||||
|
||||
rawQualityArray.sort((a, b) => a.type - b.type)
|
||||
|
||||
rawQualityArray.forEach(item => {
|
||||
newQualityArray.push(array[item.index])
|
||||
})
|
||||
|
||||
return newQualityArray
|
||||
}
|
||||
|
||||
interface BoardItem {
|
||||
id: string
|
||||
name: string
|
||||
bangid: number | string
|
||||
}
|
||||
|
||||
export default {
|
||||
list: [
|
||||
{
|
||||
id: 'kwbiaosb',
|
||||
name: '飙升榜',
|
||||
bangid: 93,
|
||||
},
|
||||
{
|
||||
id: 'kwregb',
|
||||
name: '热歌榜',
|
||||
bangid: 16,
|
||||
},
|
||||
{
|
||||
id: 'kwhuiyb',
|
||||
name: '会员榜',
|
||||
bangid: 145,
|
||||
},
|
||||
{
|
||||
id: 'kwdouyb',
|
||||
name: '抖音榜',
|
||||
bangid: 158,
|
||||
},
|
||||
{
|
||||
id: 'kwqsb',
|
||||
name: '趋势榜',
|
||||
bangid: 187,
|
||||
},
|
||||
{
|
||||
id: 'kwhuaijb',
|
||||
name: '怀旧榜',
|
||||
bangid: 26,
|
||||
},
|
||||
{
|
||||
id: 'kwhuayb',
|
||||
name: '华语榜',
|
||||
bangid: 104,
|
||||
},
|
||||
{
|
||||
id: 'kwyueyb',
|
||||
name: '粤语榜',
|
||||
bangid: 182,
|
||||
},
|
||||
{
|
||||
id: 'kwoumb',
|
||||
name: '欧美榜',
|
||||
bangid: 22,
|
||||
},
|
||||
{
|
||||
id: 'kwhanyb',
|
||||
name: '韩语榜',
|
||||
bangid: 184,
|
||||
},
|
||||
{
|
||||
id: 'kwriyb',
|
||||
name: '日语榜',
|
||||
bangid: 183,
|
||||
},
|
||||
] as BoardItem[],
|
||||
// getUrl: (p, l, id) => `http://kbangserver.kuwo.cn/ksong.s?from=pc&fmt=json&pn=${p - 1}&rn=${l}&type=bang&data=content&id=${id}&show_copyright_off=0&pcmp4=1&isbang=1`,
|
||||
regExps: {
|
||||
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
|
||||
},
|
||||
limit: 100,
|
||||
_requestBoardsObj: null as HttpRequestObject | null,
|
||||
|
||||
getBoardsData() {
|
||||
if (this._requestBoardsObj) this._requestBoardsObj.cancelHttp()
|
||||
this._requestBoardsObj = httpFetch('http://qukudata.kuwo.cn/q.k?op=query&cont=tree&node=2&pn=0&rn=1000&fmt=json&level=2')
|
||||
return this._requestBoardsObj!.promise
|
||||
},
|
||||
getData(url: string) {
|
||||
const requestDataObj = httpFetch(url)
|
||||
return requestDataObj.promise
|
||||
},
|
||||
filterData(rawList) {
|
||||
return rawList.map(item => {
|
||||
let types: SongType[] = []
|
||||
const _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
const qualitys = new Set()
|
||||
|
||||
item.n_minfo.split(';').forEach(i => {
|
||||
const info = i.match(this.regExps.mInfo)
|
||||
if (!info) return
|
||||
|
||||
const quality = info[2]
|
||||
const size = info[4].toLocaleUpperCase()
|
||||
|
||||
if (qualitys.has(quality)) return
|
||||
qualitys.add(quality)
|
||||
|
||||
switch (quality) {
|
||||
case '4000':
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = { size }
|
||||
break
|
||||
case '2000':
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = { size }
|
||||
break
|
||||
case '320':
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = { size }
|
||||
break
|
||||
case '128':
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = { size }
|
||||
break
|
||||
}
|
||||
})
|
||||
types = sortQualityArray(types)
|
||||
|
||||
return {
|
||||
singer: formatSinger(decodeName(item.artist)),
|
||||
name: decodeName(item.name),
|
||||
albumName: decodeName(item.album),
|
||||
albumId: item.albumId,
|
||||
songmid: item.id,
|
||||
source: 'kw',
|
||||
interval: formatPlayTime(parseInt(item.duration)),
|
||||
img: item.pic,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
filterBoardsData(rawList) {
|
||||
// console.log(rawList)
|
||||
let list: any[] = []
|
||||
for (const board of rawList) {
|
||||
if (board.source != '1') continue
|
||||
list.push({
|
||||
id: 'kw__' + board.sourceid,
|
||||
name: board.name,
|
||||
bangid: String(board.sourceid),
|
||||
})
|
||||
}
|
||||
return list
|
||||
},
|
||||
async getBoards(retryNum = 0) {
|
||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
// let response
|
||||
// try {
|
||||
// response = await this.getBoardsData()
|
||||
// } catch (error) {
|
||||
// return this.getBoards(retryNum)
|
||||
// }
|
||||
// console.log(response.body)
|
||||
// if (response.statusCode !== 200 || !response.body.child) return this.getBoards(retryNum)
|
||||
// const list = this.filterBoardsData(response.body.child)
|
||||
// // console.log(list)
|
||||
// console.log(JSON.stringify(list))
|
||||
// this.list = list
|
||||
// return {
|
||||
// list,
|
||||
// source: 'kw',
|
||||
// }
|
||||
this.list = boardList
|
||||
return {
|
||||
list: boardList,
|
||||
source: 'kw',
|
||||
}
|
||||
},
|
||||
|
||||
getList(id, page, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
|
||||
const requestBody = { uid: '', devId: '', sFrom: 'kuwo_sdk', user_type: 'AP', carSource: 'kwplayercar_ar_6.0.1.0_apk_keluze.apk', id, pn: page - 1, rn: this.limit }
|
||||
const requestUrl = `https://wbd.kuwo.cn/api/bd/bang/bang_info?${wbdCrypto.buildParam(requestBody)}`
|
||||
const request = httpFetch(requestUrl).promise
|
||||
|
||||
return request.then(({ statusCode, body }) => {
|
||||
const rawData = wbdCrypto.decodeData(body)
|
||||
// console.log(rawData)
|
||||
const data = rawData.data
|
||||
if (statusCode !== 200 || rawData.code != 200 || !data.musiclist) return this.getList(id, page, retryNum)
|
||||
|
||||
const total = parseInt(data.total)
|
||||
const list = this.filterData(data.musiclist)
|
||||
|
||||
return {
|
||||
total,
|
||||
list,
|
||||
limit: this.limit,
|
||||
page,
|
||||
source: 'kw',
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// getDetailPageUrl(id) {
|
||||
// return `http://www.kuwo.cn/rankList/${id}`
|
||||
// },
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeLyric, lrcTools } from './util.ts'
|
||||
import { decodeName } from '../index.ts'
|
||||
|
||||
/*
|
||||
export default {
|
||||
formatTime(time) {
|
||||
let m = parseInt(time / 60)
|
||||
let s = (time % 60).toFixed(2)
|
||||
return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
|
||||
},
|
||||
sortLrcArr(arr) {
|
||||
const lrcSet = new Set()
|
||||
let lrc = []
|
||||
let lrcT = []
|
||||
|
||||
for (const item of arr) {
|
||||
if (lrcSet.has(item.time)) {
|
||||
const tItem = lrc.pop()
|
||||
tItem.time = lrc[lrc.length - 1].time
|
||||
lrcT.push(tItem)
|
||||
lrc.push(item)
|
||||
} else {
|
||||
lrc.push(item)
|
||||
lrcSet.add(item.time)
|
||||
}
|
||||
}
|
||||
|
||||
if (lrcT.length && lrc.length > lrcT.length) {
|
||||
const tItem = lrc.pop()
|
||||
tItem.time = lrc[lrc.length - 1].time
|
||||
lrcT.push(tItem)
|
||||
}
|
||||
|
||||
return {
|
||||
lrc,
|
||||
lrcT,
|
||||
}
|
||||
},
|
||||
transformLrc(songinfo, lrclist) {
|
||||
return `[ti:${songinfo.songName}]\n[ar:${songinfo.artist}]\n[al:${songinfo.album}]\n[by:]\n[offset:0]\n${lrclist ? lrclist.map(l => `[${this.formatTime(l.time)}]${l.lineLyric}\n`).join('') : '暂无歌词'}`
|
||||
},
|
||||
getLyric(songId) {
|
||||
const requestObj = httpFetch(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${songId}`)
|
||||
requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (!body.data?.lrclist?.length) return Promise.reject(new Error('Get lyric failed'))
|
||||
let lrcInfo
|
||||
try {
|
||||
lrcInfo = this.sortLrcArr(body.data.lrclist)
|
||||
} catch {
|
||||
return Promise.reject(new Error('Get lyric failed'))
|
||||
}
|
||||
// console.log(body.data.lrclist)
|
||||
// console.log(lrcInfo.lrc, lrcInfo.lrcT)
|
||||
// console.log({
|
||||
// lyric: decodeName(this.transformLrc(body.data.songinfo, lrc)),
|
||||
// tlyric: decodeName(this.transformLrc(body.data.songinfo, lrcT)),
|
||||
// })
|
||||
return {
|
||||
lyric: decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrc)),
|
||||
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrcT)) : '',
|
||||
}
|
||||
})
|
||||
return requestObj
|
||||
},
|
||||
}
|
||||
*/
|
||||
|
||||
const buf_key = Buffer.from('yeelion')
|
||||
const buf_key_len = buf_key.length
|
||||
const buildParams = (id: string, isGetLyricx: boolean): string => {
|
||||
let params = `user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_${id}`
|
||||
if (isGetLyricx) params += '&lrcx=1'
|
||||
const buf_str = Buffer.from(params)
|
||||
const buf_str_len = buf_str.length
|
||||
const output = new Uint16Array(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_key[j] ^ buf_str[i]
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
return Buffer.from(output).toString('base64')
|
||||
}
|
||||
|
||||
// console.log(buildParams('207527604', false))
|
||||
// console.log(buildParams('207527604', true))
|
||||
|
||||
const timeExp = /^\[([\d:.]*)\]{1}/g
|
||||
const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/
|
||||
const lyricxTag = /^<-?\d+,-?\d+>/
|
||||
export default {
|
||||
/* sortLrcArr(arr) {
|
||||
const lrcSet = new Set()
|
||||
let lrc = []
|
||||
let lrcT = []
|
||||
let markIndex = []
|
||||
for (const item of arr) {
|
||||
if (lrcSet.has(item.time)) {
|
||||
if (lrc.length < 2) continue
|
||||
const index = lrc.findIndex(l => l.time == item.time)
|
||||
markIndex.push(index)
|
||||
if (index == lrc.length - 1) {
|
||||
lrcT.push({ ...lrc[index], time: item.time })
|
||||
lrc.push(item)
|
||||
} else {
|
||||
lrcT.push({ ...lrc[index], time: lrc[index + 1].time })
|
||||
if (item.text) {
|
||||
// const lastIndex = lrc.length - 1
|
||||
// markIndex.push(lastIndex)
|
||||
// lrcT.push({ ...lrc[lastIndex], time: lrc[lastIndex - 1].time })
|
||||
lrc.push(item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lrc.push(item)
|
||||
lrcSet.add(item.time)
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(markIndex)
|
||||
markIndex = Array.from(new Set(markIndex))
|
||||
for (let index = markIndex.length - 1; index >= 0; index--) {
|
||||
lrc.splice(markIndex[index], 1)
|
||||
}
|
||||
|
||||
// if (lrcT.length) {
|
||||
// if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译
|
||||
// const tItem = lrc.pop()
|
||||
// tItem.time = lrc[lrc.length - 1].time
|
||||
// lrcT.push(tItem)
|
||||
// } else {
|
||||
// lrc = arr
|
||||
// lrcT = []
|
||||
// }
|
||||
// }
|
||||
|
||||
console.log(lrc, lrcT)
|
||||
|
||||
return {
|
||||
lrc,
|
||||
lrcT,
|
||||
}
|
||||
}, */
|
||||
sortLrcArr(arr) {
|
||||
const lrcSet = new Set()
|
||||
let lrc: any[] = []
|
||||
let lrcT: any[] = []
|
||||
|
||||
let isLyricx = false
|
||||
for (const item of arr) {
|
||||
if (lrcSet.has(item.time)) {
|
||||
if (lrc.length < 2) continue
|
||||
const tItem = lrc.pop()
|
||||
tItem.time = lrc[lrc.length - 1].time
|
||||
lrcT.push(tItem)
|
||||
lrc.push(item)
|
||||
} else {
|
||||
lrc.push(item)
|
||||
lrcSet.add(item.time)
|
||||
}
|
||||
if (!isLyricx && lyricxTag.test(item.text)) isLyricx = true
|
||||
}
|
||||
|
||||
if (!isLyricx && lrcT.length > lrc.length * 0.3 && lrc.length - lrcT.length > 6) {
|
||||
throw new Error('failed')
|
||||
// if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译
|
||||
// const tItem = lrc.pop()
|
||||
// tItem.time = lrc[lrc.length - 1].time
|
||||
// lrcT.push(tItem)
|
||||
// } else {
|
||||
// lrc = arr
|
||||
// lrcT = []
|
||||
// }
|
||||
}
|
||||
|
||||
return {
|
||||
lrc,
|
||||
lrcT,
|
||||
}
|
||||
},
|
||||
transformLrc(tags, lrclist) {
|
||||
return `${tags.join('\n')}\n${lrclist ? lrclist.map(l => `[${l.time}]${l.text}\n`).join('') : '暂无歌词'}`
|
||||
},
|
||||
parseLrc(lrc: string) {
|
||||
const lines = lrc.split(/\r\n|\r|\n/)
|
||||
let tags: string[] = []
|
||||
let lrcArr: Array<{ time: string; text: string }> = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
let result = timeExp.exec(line)
|
||||
if (result) {
|
||||
const text = line.replace(timeExp, '').trim()
|
||||
let time = RegExp.$1
|
||||
if (/\.\d\d$/.test(time)) time += '0'
|
||||
lrcArr.push({
|
||||
time,
|
||||
text,
|
||||
})
|
||||
} else if (lrcTools.rxps.tagLine.test(line)) {
|
||||
tags.push(line)
|
||||
}
|
||||
}
|
||||
const lrcInfo = this.sortLrcArr(lrcArr)
|
||||
return {
|
||||
lyric: decodeName(this.transformLrc(tags, lrcInfo.lrc)),
|
||||
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(tags, lrcInfo.lrcT)) : '',
|
||||
}
|
||||
},
|
||||
// getLyric2(musicInfo, isGetLyricx = true) {
|
||||
// const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
|
||||
// requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
|
||||
// if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
|
||||
// return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => {
|
||||
// let lrcInfo
|
||||
// console.log(Buffer.from(base64Data, 'base64').toString())
|
||||
// try {
|
||||
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
|
||||
// } catch {
|
||||
// return Promise.reject(new Error('Get lyric failed'))
|
||||
// }
|
||||
// if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
|
||||
// lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
|
||||
// // console.log(lrcInfo.lyric)
|
||||
// // console.log(lrcInfo.tlyric)
|
||||
// // console.log(lrcInfo.yrc)
|
||||
// // console.log(JSON.stringify(lrcInfo))
|
||||
// })
|
||||
// })
|
||||
// return requestObj
|
||||
// },
|
||||
getLyric(musicInfo, isGetLyricx = true) {
|
||||
// this.getLyric2(musicInfo)
|
||||
const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
|
||||
requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
|
||||
if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
|
||||
return decodeLyric({ lrcBase64: raw!.toString('base64'), isGetLyricx }).then(base64Data => {
|
||||
// let lrcInfo
|
||||
// try {
|
||||
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
|
||||
// } catch {
|
||||
// return Promise.reject(new Error('Get lyric failed'))
|
||||
// }
|
||||
let lrcInfo
|
||||
// console.log(Buffer.from(base64Data, 'base64').toString())
|
||||
try {
|
||||
lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error('Get lyric failed'))
|
||||
}
|
||||
// console.log(lrcInfo)
|
||||
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
|
||||
try {
|
||||
lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
|
||||
} catch {
|
||||
lrcInfo.yrc = ''
|
||||
}
|
||||
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
|
||||
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
|
||||
// console.log(lrcInfo)
|
||||
return lrcInfo
|
||||
})
|
||||
})
|
||||
return requestObj
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// import '../../polyfill/array.find.js'
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, decodeName } from '../index.ts'
|
||||
// import { debug } from '../../utils/env.ts'
|
||||
import { formatSinger } from './util.ts'
|
||||
import type { Quality, QualityInfo, SongInfo, SongType } from '../types.ts'
|
||||
|
||||
export default {
|
||||
regExps: {
|
||||
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
|
||||
},
|
||||
limit: 30,
|
||||
total: 0,
|
||||
page: 0,
|
||||
allPage: 1,
|
||||
// cancelFn: null,
|
||||
musicSearch(str: string, page: number, limit: number) {
|
||||
const musicSearchRequestObj = httpFetch(`http://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(str)}&pn=${page - 1}&rn=${limit}&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1`)
|
||||
return musicSearchRequestObj.promise
|
||||
},
|
||||
// getImg(songId) {
|
||||
// return httpGet(`http://player.kuwo.cn/webmusic/sj/dtflagdate?flag=6&rid=MUSIC_${songId}`)
|
||||
// },
|
||||
// getLrc(songId) {
|
||||
// return httpGet(`http://mobile.kuwo.cn/mpage/html5/songinfoandlrc?mid=${songId}&flag=0`)
|
||||
// },
|
||||
handleResult(rawData: any) {
|
||||
const result: SongInfo[] = []
|
||||
if (!rawData) return result
|
||||
// console.log(rawData)
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const info = rawData[i]
|
||||
let songId = info.MUSICRID.replace('MUSIC_', '')
|
||||
// const format = (info.FORMATS || info.formats).split('|')
|
||||
|
||||
if (!info.N_MINFO) {
|
||||
console.log('N_MINFO is undefined')
|
||||
return null
|
||||
}
|
||||
|
||||
const types: SongType[] = []
|
||||
const _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
|
||||
let infoArr = info.N_MINFO.split(';')
|
||||
for (let info of infoArr) {
|
||||
info = info.match(this.regExps.mInfo)
|
||||
if (info) {
|
||||
switch (info[2]) {
|
||||
case '4000':
|
||||
types.push({ type: 'flac24bit', size: info[4] })
|
||||
_types.flac24bit = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
case '2000':
|
||||
types.push({ type: 'flac', size: info[4] })
|
||||
_types.flac = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
case '320':
|
||||
types.push({ type: '320k', size: info[4] })
|
||||
_types['320k'] = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
case '128':
|
||||
types.push({ type: '128k', size: info[4] })
|
||||
_types['128k'] = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
types.reverse()
|
||||
|
||||
let interval = parseInt(info.DURATION)
|
||||
|
||||
result.push({
|
||||
name: decodeName(info.SONGNAME),
|
||||
singer: formatSinger(decodeName(info.ARTIST)),
|
||||
source: 'kw',
|
||||
// img = (info.album.name === '' || info.album.name === '空')
|
||||
// ? `http://player.kuwo.cn/webmusic/sj/dtflagdate?flag=6&rid=MUSIC_160911.jpg`
|
||||
// : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${info.album.mid}.jpg`
|
||||
songmid: songId,
|
||||
albumId: decodeName(info.ALBUMID || ''),
|
||||
interval: Number.isNaN(interval) ? 0 : formatPlayTime(interval),
|
||||
albumName: info.ALBUM ? decodeName(info.ALBUM) : '',
|
||||
lrc: null,
|
||||
img: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
}
|
||||
// console.log(result)
|
||||
return result
|
||||
},
|
||||
search(str: string, page = 1, limit?: number, retryNum = 0) {
|
||||
if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
if (limit == null) limit = this.limit
|
||||
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
|
||||
return this.musicSearch(str, page, limit).then(({ body: result }) => {
|
||||
// console.log(result)
|
||||
if (!result || (result.TOTAL !== '0' && result.SHOW === '0')) return this.search(str, page, limit, ++retryNum)
|
||||
let list = this.handleResult(result.abslist)
|
||||
|
||||
if (list == null) return this.search(str, page, limit, ++retryNum)
|
||||
|
||||
this.total = parseInt(result.TOTAL)
|
||||
this.page = page
|
||||
this.allPage = Math.ceil(this.total / limit)
|
||||
|
||||
return Promise.resolve({
|
||||
list,
|
||||
allPage: this.allPage,
|
||||
total: this.total,
|
||||
limit,
|
||||
source: 'kw',
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import type { SongInfo } from '../types.ts'
|
||||
|
||||
export default {
|
||||
getPic({ songmid }: SongInfo) {
|
||||
const requestObj = httpFetch(`http://artistpicserver.kuwo.cn/pic.web?corp=kuwo&type=rid_pic&pictype=500&size=500&rid=${songmid}`)
|
||||
requestObj.promise = requestObj.promise.then(({ body }) => /^http/.test(body) ? body : null)
|
||||
return requestObj.promise
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, decodeName } from '../index.ts'
|
||||
import { formatSinger, objStr2JSON } from './util.ts'
|
||||
import album from './album.ts'
|
||||
import type { HttpRequestObject, Quality, QualityInfo, SongType } from '../types.ts'
|
||||
|
||||
export default {
|
||||
_requestObj_tags: null as HttpRequestObject | null,
|
||||
_requestObj_hotTags: null as HttpRequestObject | null,
|
||||
_requestObj_list: null as HttpRequestObject | null,
|
||||
limit_list: 36,
|
||||
limit_song: 1000,
|
||||
successCode: 200,
|
||||
sortList: [
|
||||
{
|
||||
name: '最新',
|
||||
id: 'new',
|
||||
},
|
||||
{
|
||||
name: '最热',
|
||||
id: 'hot',
|
||||
},
|
||||
],
|
||||
regExps: {
|
||||
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
|
||||
// http://www.kuwo.cn/playlist_detail/2886046289
|
||||
// https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend
|
||||
listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
|
||||
},
|
||||
tagsUrl: 'http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0&loginUid=0&loginSid=0&appUid=76039576',
|
||||
hotTagUrl: 'http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576',
|
||||
getListUrl({ sortId, id, type, page }: { sortId: string; id: string | null; type: string; page: number }) {
|
||||
if (!id) return `http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0&appUid=76039576&&pn=${page}&rn=${this.limit_list}&order=${sortId}`
|
||||
switch (type) {
|
||||
case '10000': return `http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0&appUid=76039576&pn=${page}&id=${id}&rn=${this.limit_list}`
|
||||
case '43': return `http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id=${id}&prod=pc`
|
||||
}
|
||||
// http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0&appUid=76039576&id=173&pn=1&rn=100
|
||||
},
|
||||
getListDetailUrl(id: string, page: number) {
|
||||
// http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2858093057&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1
|
||||
return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`
|
||||
// http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id=140&prod=pc
|
||||
},
|
||||
|
||||
// http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2849349915&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1
|
||||
// 获取标签
|
||||
getTag(tryNum = 0) {
|
||||
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_tags = httpFetch(this.tagsUrl)
|
||||
return this._requestObj_tags!.promise.then(({ body }) => {
|
||||
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
||||
return this.filterTagInfo(body.data)
|
||||
})
|
||||
},
|
||||
// 获取标签
|
||||
getHotTag(tryNum = 0) {
|
||||
if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_hotTags = httpFetch(this.hotTagUrl)
|
||||
return this._requestObj_hotTags!.promise.then(({ body }) => {
|
||||
if (body.code !== this.successCode) return this.getHotTag(++tryNum)
|
||||
return this.filterInfoHotTag(body.data[0].data)
|
||||
})
|
||||
},
|
||||
filterInfoHotTag(rawList) {
|
||||
return rawList.map(item => ({
|
||||
id: `${item.id}-${item.digest}`,
|
||||
name: item.name,
|
||||
source: 'kw',
|
||||
}))
|
||||
},
|
||||
filterTagInfo(rawList) {
|
||||
return rawList.map(type => ({
|
||||
name: type.name,
|
||||
list: type.data.map(item => ({
|
||||
parent_id: type.id,
|
||||
parent_name: type.name,
|
||||
id: `${item.id}-${item.digest}`,
|
||||
name: item.name,
|
||||
source: 'kw',
|
||||
})),
|
||||
}))
|
||||
},
|
||||
|
||||
// 获取列表数据
|
||||
getList(sortId: string, tagId: string | undefined, page: number, tryNum = 0) {
|
||||
if (this._requestObj_list) this._requestObj_list.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
let id: string | null
|
||||
let type: string
|
||||
if (tagId) {
|
||||
let arr = tagId.split('-')
|
||||
id = arr[0]
|
||||
type = arr[1] ?? ''
|
||||
} else {
|
||||
id = null
|
||||
type = ''
|
||||
}
|
||||
this._requestObj_list = httpFetch(this.getListUrl({ sortId, id, type, page } as any) as any)
|
||||
return this._requestObj_list!.promise.then(({ body }) => {
|
||||
if (!id || type == '10000') {
|
||||
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
|
||||
return {
|
||||
list: this.filterList(body.data.data),
|
||||
total: body.data.total,
|
||||
page: body.data.pn,
|
||||
limit: body.data.rn,
|
||||
source: 'kw',
|
||||
}
|
||||
} else if (!body.length) {
|
||||
return this.getList(sortId, tagId, page, ++tryNum)
|
||||
}
|
||||
return {
|
||||
list: this.filterList2(body),
|
||||
total: 1000,
|
||||
page,
|
||||
limit: 1000,
|
||||
source: 'kw',
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 格式化播放数量
|
||||
* @param {*} num
|
||||
*/
|
||||
formatPlayCount(num: any) {
|
||||
if (num > 100000000) return parseInt((num / 10000000) as any) / 10 + '亿'
|
||||
if (num > 10000) return parseInt((num / 1000) as any) / 10 + '万'
|
||||
return num
|
||||
},
|
||||
filterList(rawData) {
|
||||
return rawData.map(item => ({
|
||||
play_count: this.formatPlayCount(item.listencnt),
|
||||
id: `digest-${item.digest}__${item.id}`,
|
||||
author: item.uname,
|
||||
name: item.name,
|
||||
// time: item.publish_time,
|
||||
total: item.total,
|
||||
img: item.img,
|
||||
grade: item.favorcnt / 10,
|
||||
desc: item.desc,
|
||||
source: 'kw',
|
||||
}))
|
||||
},
|
||||
filterList2(rawData) {
|
||||
// console.log(rawData)
|
||||
const list: any[] = []
|
||||
rawData.forEach(item => {
|
||||
if (!item.label) return
|
||||
list.push(...item.list.map(item => ({
|
||||
play_count: item.play_count && this.formatPlayCount(item.listencnt),
|
||||
id: `digest-${item.digest}__${item.id}`,
|
||||
author: item.uname,
|
||||
name: item.name,
|
||||
total: item.total,
|
||||
// time: item.publish_time,
|
||||
img: item.img,
|
||||
grade: item.favorcnt && item.favorcnt / 10,
|
||||
desc: item.desc,
|
||||
source: 'kw',
|
||||
})))
|
||||
})
|
||||
return list
|
||||
},
|
||||
|
||||
getListDetailDigest8(id, page, tryNum = 0) {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
const requestObj = httpFetch(this.getListDetailUrl(id, page))
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum)
|
||||
return {
|
||||
list: this.filterListDetail(body.musiclist),
|
||||
page,
|
||||
limit: body.rn,
|
||||
total: body.total,
|
||||
source: 'kw',
|
||||
info: {
|
||||
name: body.title,
|
||||
img: body.pic,
|
||||
desc: body.info,
|
||||
author: body.uname,
|
||||
play_count: this.formatPlayCount(body.playnum),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
getListDetailDigest5Info(id, tryNum = 0) {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
const requestObj = httpFetch(`http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node=${id}&pn=0&rn=1&fmt=json&src=mbox&level=2`)
|
||||
return requestObj.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || !body.child) return this.getListDetail(id, ++tryNum)
|
||||
// console.log(body)
|
||||
return body.child.length ? body.child[0].sourceid : null
|
||||
})
|
||||
},
|
||||
getListDetailDigest5Music(id, page, tryNum = 0) {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`)
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum)
|
||||
return {
|
||||
list: this.filterListDetail(body.musiclist),
|
||||
page,
|
||||
limit: body.rn,
|
||||
total: body.total,
|
||||
source: 'kw',
|
||||
info: {
|
||||
name: body.title,
|
||||
img: body.pic,
|
||||
desc: body.info,
|
||||
author: body.uname,
|
||||
play_count: this.formatPlayCount(body.playnum),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
async getListDetailDigest5(id, page, retryNum) {
|
||||
const detailId = await this.getListDetailDigest5Info(id, retryNum)
|
||||
return this.getListDetailDigest5Music(detailId, page, retryNum)
|
||||
},
|
||||
|
||||
filterBDListDetail(rawList) {
|
||||
return rawList.map(item => {
|
||||
let types: SongType[] = []
|
||||
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
for (let info of item.audios) {
|
||||
info.size = info.size?.toLocaleUpperCase()
|
||||
switch (info.bitrate) {
|
||||
case '4000':
|
||||
types.push({ type: 'flac24bit', size: info.size })
|
||||
_types.flac24bit = {
|
||||
size: info.size,
|
||||
}
|
||||
break
|
||||
case '2000':
|
||||
types.push({ type: 'flac', size: info.size })
|
||||
_types.flac = {
|
||||
size: info.size,
|
||||
}
|
||||
break
|
||||
case '320':
|
||||
types.push({ type: '320k', size: info.size })
|
||||
_types['320k'] = {
|
||||
size: info.size,
|
||||
}
|
||||
break
|
||||
case '128':
|
||||
types.push({ type: '128k', size: info.size })
|
||||
_types['128k'] = {
|
||||
size: info.size,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
types.reverse()
|
||||
|
||||
return {
|
||||
singer: item.artists.map(s => s.name).join('、'),
|
||||
name: item.name,
|
||||
albumName: item.album,
|
||||
albumId: item.albumId,
|
||||
songmid: item.id,
|
||||
source: 'kw',
|
||||
interval: formatPlayTime(item.duration),
|
||||
img: item.albumPic,
|
||||
releaseDate: item.releaseDate,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
getReqId() {
|
||||
function t() {
|
||||
return (65536 * (1 + Math.random()) | 0).toString(16).substring(1)
|
||||
}
|
||||
return t() + t() + t() + t() + t() + t() + t() + t()
|
||||
},
|
||||
async getListDetailMusicListByBDListInfo(id, source) {
|
||||
const { body: infoData } = await httpFetch(`https://bd-api.kuwo.cn/api/service/playlist/info/${id}?reqId=${this.getReqId()}&source=${source}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
|
||||
plat: 'h5',
|
||||
},
|
||||
}).promise.catch(() => ({ code: 0 } as any))
|
||||
|
||||
if (infoData.code != 200) return null
|
||||
|
||||
return {
|
||||
name: infoData.data.name,
|
||||
img: infoData.data.pic,
|
||||
desc: infoData.data.description,
|
||||
author: infoData.data.creatorName,
|
||||
play_count: infoData.data.playNum,
|
||||
}
|
||||
},
|
||||
async getListDetailMusicListByBDUserPub(id) {
|
||||
const { body: infoData } = await httpFetch(`https://bd-api.kuwo.cn/api/ucenter/users/pub/${id}?reqId=${this.getReqId()}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
|
||||
plat: 'h5',
|
||||
},
|
||||
}).promise.catch(() => ({ code: 0 } as any))
|
||||
|
||||
if (infoData.code != 200) return null
|
||||
|
||||
// console.log(infoData)
|
||||
return {
|
||||
name: infoData.data.userInfo.nickname + '喜欢的音乐',
|
||||
img: infoData.data.userInfo.headImg,
|
||||
desc: '',
|
||||
author: infoData.data.userInfo.nickname,
|
||||
play_count: '',
|
||||
}
|
||||
},
|
||||
async getListDetailMusicListByBDList(id, source, page, tryNum = 0) {
|
||||
const { body: listData } = await httpFetch(`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${this.limit_song}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
|
||||
plat: 'h5',
|
||||
},
|
||||
}).promise.catch(() => {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
return this.getListDetailMusicListByBDList(id, source, page, ++tryNum)
|
||||
})
|
||||
|
||||
if (listData.code !== 200) return Promise.reject(new Error('failed'))
|
||||
|
||||
return {
|
||||
list: this.filterBDListDetail(listData.data.list),
|
||||
page,
|
||||
limit: listData.data.pageSize,
|
||||
total: listData.data.total,
|
||||
source: 'kw',
|
||||
}
|
||||
},
|
||||
async getListDetailMusicListByBD(id, page) {
|
||||
const uid = /uid=(\d+)/.exec(id)?.[1]
|
||||
const listId = /playlistId=(\d+)/.exec(id)?.[1]
|
||||
const source = /source=(\d+)/.exec(id)?.[1]
|
||||
if (!listId) return Promise.reject(new Error('failed'))
|
||||
|
||||
const task: Array<Promise<any>> = [this.getListDetailMusicListByBDList(listId, source, page)]
|
||||
switch (source) {
|
||||
case '4':
|
||||
task.push(this.getListDetailMusicListByBDListInfo(listId, source))
|
||||
break
|
||||
case '5':
|
||||
task.push(this.getListDetailMusicListByBDUserPub(uid ?? listId))
|
||||
break
|
||||
}
|
||||
const [listData, info] = await Promise.all(task)
|
||||
listData.info = info ?? {
|
||||
name: '',
|
||||
img: '',
|
||||
desc: '',
|
||||
author: '',
|
||||
play_count: '',
|
||||
}
|
||||
// console.log(listData)
|
||||
return listData
|
||||
},
|
||||
|
||||
// 获取歌曲列表内的音乐
|
||||
getListDetail(id, page, retryNum = 0) {
|
||||
// console.log(id)
|
||||
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=5&ownerId=000
|
||||
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=4&ownerId=
|
||||
if (/\/bodian\//.test(id)) return this.getListDetailMusicListByBD(id, page)
|
||||
if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1')
|
||||
else if (/^digest-/.test(id)) {
|
||||
let [digest, _id] = id.split('__')
|
||||
digest = digest.replace('digest-', '')
|
||||
id = _id
|
||||
switch (digest) {
|
||||
case '8':
|
||||
break
|
||||
case '13': return album.getAlbumListDetail(id, page, retryNum)
|
||||
case '5':
|
||||
default: return this.getListDetailDigest5(id, page, retryNum)
|
||||
}
|
||||
}
|
||||
return this.getListDetailDigest8(id, page, retryNum)
|
||||
},
|
||||
filterListDetail(rawData) {
|
||||
// console.log(rawData)
|
||||
return rawData.map(item => {
|
||||
let infoArr = item.N_MINFO.split(';')
|
||||
let types: SongType[] = []
|
||||
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
for (let info of infoArr) {
|
||||
info = info.match(this.regExps.mInfo)
|
||||
if (info) {
|
||||
switch (info[2]) {
|
||||
case '4000':
|
||||
types.push({ type: 'flac24bit', size: info[4] })
|
||||
_types.flac24bit = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
case '2000':
|
||||
types.push({ type: 'flac', size: info[4] })
|
||||
_types.flac = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
case '320':
|
||||
types.push({ type: '320k', size: info[4] })
|
||||
_types['320k'] = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
case '128':
|
||||
types.push({ type: '128k', size: info[4] })
|
||||
_types['128k'] = {
|
||||
size: info[4].toLocaleUpperCase(),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
types.reverse()
|
||||
|
||||
return {
|
||||
singer: formatSinger(decodeName(item.artist)),
|
||||
name: decodeName(item.name),
|
||||
albumName: decodeName(item.album),
|
||||
albumId: item.albumid,
|
||||
songmid: item.id,
|
||||
source: 'kw',
|
||||
interval: formatPlayTime(parseInt(item.duration)),
|
||||
img: null,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
getTags() {
|
||||
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({ tags, hotTag, source: 'kw' }))
|
||||
},
|
||||
getDetailPageUrl(id) {
|
||||
if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1')
|
||||
else if (/^digest-/.test(id)) {
|
||||
let result = id.split('__')
|
||||
id = result[1]
|
||||
}
|
||||
return `http://www.kuwo.cn/playlist_detail/${id}`
|
||||
},
|
||||
|
||||
search(text, page, limit = 20) {
|
||||
return httpFetch(`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`)
|
||||
.promise.then(({ body }) => {
|
||||
body = objStr2JSON(body)
|
||||
// console.log(body)
|
||||
return {
|
||||
list: body.abslist.map(item => {
|
||||
return {
|
||||
play_count: this.formatPlayCount(item.playcnt),
|
||||
id: String(item.playlistid),
|
||||
author: decodeName(item.nickname),
|
||||
name: decodeName(item.name),
|
||||
total: item.songnum,
|
||||
// time: item.publish_time,
|
||||
img: item.pic,
|
||||
desc: decodeName(item.intro),
|
||||
source: 'kw',
|
||||
}
|
||||
}),
|
||||
limit,
|
||||
total: parseInt(body.TOTAL),
|
||||
source: 'kw',
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// getList
|
||||
// getTags
|
||||
// getListDetail
|
||||
@@ -0,0 +1,35 @@
|
||||
// import { decodeName } from '../index.ts'
|
||||
// import { tokenRequest } from './util.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
regExps: {
|
||||
relWord: /RELWORD=(.+)/,
|
||||
},
|
||||
requestObj: null as HttpRequestObject | null,
|
||||
async tipSearchBySong(str: string) {
|
||||
// 报错403,加了referer还是有问题(直接换一个
|
||||
// this.requestObj = await tokenRequest(`http://www.kuwo.cn/api/www/search/searchKey?key=${encodeURIComponent(str)}`)
|
||||
|
||||
this.cancelTipSearch()
|
||||
this.requestObj = httpFetch(`https://tips.kuwo.cn/t.s?corp=kuwo&newver=3&p2p=1¬race=0&c=mbox&w=${encodeURIComponent(str)}&encoding=utf8&rformat=json`, {
|
||||
headers: {
|
||||
Referer: 'http://www.kuwo.cn/',
|
||||
},
|
||||
})
|
||||
return this.requestObj!.promise.then(({ body, statusCode }) => {
|
||||
if (statusCode != 200 || !body.WORDITEMS) return Promise.reject(new Error('请求失败'))
|
||||
return body.WORDITEMS
|
||||
})
|
||||
},
|
||||
handleResult(rawData: any) {
|
||||
return rawData.map(item => item.RELWORD)
|
||||
},
|
||||
cancelTipSearch() {
|
||||
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
|
||||
},
|
||||
async search(str: string) {
|
||||
return this.tipSearchBySong(str).then(result => this.handleResult(result))
|
||||
},
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// import { httpGet, httpFetch } from '../request.ts'
|
||||
import { WIN_MAIN_RENDERER_EVENT_NAME } from '../common/ipcNames.ts'
|
||||
import { rendererInvoke } from '../common/rendererIpc.ts'
|
||||
import { createCipheriv, createDecipheriv } from 'crypto'
|
||||
import { toMD5 } from '../utils.ts'
|
||||
|
||||
// const kw_token = {
|
||||
// token: null,
|
||||
// isGetingToken: false,
|
||||
// }
|
||||
|
||||
// const translationMap = {
|
||||
// "{'": '{"',
|
||||
// "'}\n": '"}',
|
||||
// "'}": '"}',
|
||||
// "':'": '":"',
|
||||
// "','": '","',
|
||||
// "':{'": '":{"',
|
||||
// "':['": '":["',
|
||||
// "'}],'": '"}],"',
|
||||
// "':[{'": '":[{"',
|
||||
// "'},'": '"},"',
|
||||
// "'},{'": '"},{"',
|
||||
// "':[],'": '":[],"',
|
||||
// "':{},'": '":{},"',
|
||||
// "'}]}": '"}]}',
|
||||
// }
|
||||
|
||||
// export const objStr2JSON = str => {
|
||||
// return JSON.parse(str.replace(/(^{'|'}\n$|'}$|':'|','|':\[{'|'}\],'|':{'|'},'|'},{'|':\['|':\[\],'|':{},'|'}]})/g, s => translationMap[s]))
|
||||
// }
|
||||
|
||||
export const objStr2JSON = (str: string): any => {
|
||||
return JSON.parse(str.replace(/('(?=(,\s*')))|('(?=:))|((?<=([:,]\s*))')|((?<={)')|('(?=}))/g, '"'))
|
||||
}
|
||||
|
||||
|
||||
export const formatSinger = (rawData: string): string => rawData.replace(/&/g, '、')
|
||||
|
||||
export const matchToken = (headers: Record<string, any>): string | null => {
|
||||
try {
|
||||
return headers['set-cookie'][0].match(/kw_token=(\w+)/)[1]
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// const wait = time => new Promise(resolve => setTimeout(() => resolve(), time))
|
||||
|
||||
|
||||
// export const getToken = (retryNum = 0) => new Promise((resolve, reject) => {
|
||||
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
// if (kw_token.isGetingToken) return wait(1000).then(() => getToken(retryNum).then(token => resolve(token)))
|
||||
// if (kw_token.token) return resolve(kw_token.token)
|
||||
// kw_token.isGetingToken = true
|
||||
// httpGet('http://www.kuwo.cn/', (err, resp) => {
|
||||
// kw_token.isGetingToken = false
|
||||
// if (err) return getToken(++retryNum)
|
||||
// if (resp.statusCode != 200) return reject(new Error('获取失败'))
|
||||
// const token = kw_token.token = matchToken(resp.headers)
|
||||
// resolve(token)
|
||||
// })
|
||||
// })
|
||||
|
||||
export const decodeLyric = (base64Data: { lrcBase64: string; isGetLyricx: boolean }): Promise<any> =>
|
||||
rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.handle_kw_decode_lyric, base64Data)
|
||||
|
||||
// export const tokenRequest = async(url, options = {}) => {
|
||||
// let token = kw_token.token
|
||||
// if (!token) token = await getToken()
|
||||
// if (!options.headers) {
|
||||
// options.headers = {
|
||||
// Referer: 'http://www.kuwo.cn/',
|
||||
// csrf: token,
|
||||
// cookie: 'kw_token=' + token,
|
||||
// }
|
||||
// }
|
||||
// const requestObj = httpFetch(url, options)
|
||||
// requestObj.promise = requestObj.promise.then(resp => {
|
||||
// // console.log(resp)
|
||||
// if (resp.statusCode == 200) {
|
||||
// kw_token.token = matchToken(resp.headers)
|
||||
// }
|
||||
// return resp
|
||||
// })
|
||||
// return requestObj
|
||||
// }
|
||||
|
||||
interface LrcWordInfo {
|
||||
startTime: number
|
||||
endTime: number
|
||||
timeStr: string
|
||||
newTimeStr?: string
|
||||
}
|
||||
|
||||
export const lrcTools = {
|
||||
rxps: {
|
||||
wordLine: /^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*/,
|
||||
tagLine: /\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]/,
|
||||
wordTimeAll: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/g,
|
||||
wordTime: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/,
|
||||
},
|
||||
offset: 1,
|
||||
offset2: 1,
|
||||
isOK: false,
|
||||
lines: [] as string[],
|
||||
tags: [] as string[],
|
||||
getWordInfo(str: string, str2: string, prevWord?: LrcWordInfo): LrcWordInfo {
|
||||
const offset = parseInt(str)
|
||||
const offset2 = parseInt(str2)
|
||||
let startTime = Math.abs((offset + offset2) / (this.offset * 2))
|
||||
let endTime = Math.abs((offset - offset2) / (this.offset2 * 2)) + startTime
|
||||
if (prevWord) {
|
||||
if (startTime < prevWord.endTime) {
|
||||
prevWord.endTime = startTime
|
||||
if (prevWord.startTime > prevWord.endTime) {
|
||||
prevWord.startTime = prevWord.endTime
|
||||
}
|
||||
|
||||
prevWord.newTimeStr = `<${prevWord.startTime},${prevWord.endTime - prevWord.startTime}>`
|
||||
// console.log(prevWord)
|
||||
}
|
||||
}
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
timeStr: `<${startTime},${endTime - startTime}>`,
|
||||
}
|
||||
},
|
||||
parseLine(line: string): void {
|
||||
if (line.length < 6) return
|
||||
let result = this.rxps.wordLine.exec(line)
|
||||
if (result) {
|
||||
const time = result[1]
|
||||
let words = result[2]
|
||||
if (words == null) {
|
||||
words = ''
|
||||
}
|
||||
const wordTimes = words.match(this.rxps.wordTimeAll)
|
||||
if (!wordTimes) return
|
||||
// console.log(wordTimes)
|
||||
let preTimeInfo
|
||||
for (const timeStr of wordTimes) {
|
||||
const result = this.rxps.wordTime.exec(timeStr)
|
||||
const wordInfo = this.getWordInfo(result![1], result![2], preTimeInfo)
|
||||
words = words.replace(timeStr, wordInfo.timeStr)
|
||||
if (preTimeInfo?.newTimeStr) words = words.replace(preTimeInfo.timeStr, preTimeInfo.newTimeStr)
|
||||
preTimeInfo = wordInfo
|
||||
}
|
||||
this.lines.push(time + words)
|
||||
return
|
||||
}
|
||||
result = this.rxps.tagLine.exec(line)
|
||||
if (!result) return
|
||||
if (result[1] == 'kuwo') {
|
||||
let content = result[2]
|
||||
if (content != null && content.includes('][')) {
|
||||
content = content.substring(0, content.indexOf(']['))
|
||||
}
|
||||
const valueOf = parseInt(content, 8)
|
||||
this.offset = Math.trunc(valueOf / 10)
|
||||
this.offset2 = Math.trunc(valueOf % 10)
|
||||
if (this.offset == 0 || Number.isNaN(this.offset) || this.offset2 == 0 || Number.isNaN(this.offset2)) {
|
||||
this.isOK = false
|
||||
}
|
||||
} else {
|
||||
this.tags.push(line)
|
||||
}
|
||||
},
|
||||
parse(lrc: string): string {
|
||||
// console.log(lrc)
|
||||
const lines = lrc.split(/\r\n|\r|\n/)
|
||||
const tools = Object.create(this)
|
||||
tools.isOK = true
|
||||
tools.offset = 1
|
||||
tools.offset2 = 1
|
||||
tools.lines = []
|
||||
tools.tags = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!tools.isOK) throw new Error('failed')
|
||||
tools.parseLine(line)
|
||||
}
|
||||
if (!tools.lines.length) return ''
|
||||
let lrcs = tools.lines.join('\n')
|
||||
if (tools.tags.length) lrcs = `${tools.tags.join('\n')}\n${lrcs}`
|
||||
// console.log(lrcs)
|
||||
return lrcs
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
const createAesEncrypt = (buffer: Buffer, mode: string, key: Buffer | string, iv: string | Buffer): Buffer => {
|
||||
const cipher = createCipheriv(mode, key, iv)
|
||||
return Buffer.concat([cipher.update(buffer), cipher.final()])
|
||||
}
|
||||
|
||||
const createAesDecrypt = (buffer: Buffer, mode: string, key: Buffer | string, iv: string | Buffer): Buffer => {
|
||||
const cipher = createDecipheriv(mode, key, iv)
|
||||
return Buffer.concat([cipher.update(buffer), cipher.final()])
|
||||
}
|
||||
|
||||
export const wbdCrypto = {
|
||||
aesMode: 'aes-128-ecb',
|
||||
aesKey: Buffer.from([112, 87, 39, 61, 199, 250, 41, 191, 57, 68, 45, 114, 221, 94, 140, 228] as any, 'binary'),
|
||||
aesIv: '',
|
||||
appId: 'y67sprxhhpws',
|
||||
decodeData(base64Result: string): any {
|
||||
const data = Buffer.from(decodeURIComponent(base64Result), 'base64')
|
||||
return JSON.parse(createAesDecrypt(data, this.aesMode, this.aesKey, this.aesIv).toString())
|
||||
},
|
||||
createSign(data: string, time: number): string {
|
||||
const str = `${this.appId}${data}${time}`
|
||||
return toMD5(str).toUpperCase()
|
||||
},
|
||||
buildParam(jsonData: unknown): string {
|
||||
const data = Buffer.from(JSON.stringify(jsonData))
|
||||
const time = Date.now()
|
||||
|
||||
const encodeData = createAesEncrypt(data, this.aesMode, this.aesKey, this.aesIv).toString('base64')
|
||||
const sign = this.createSign(encodeData, time)
|
||||
|
||||
return `data=${encodeURIComponent(encodeData)}&time=${time}&appId=${this.appId}&sign=${sign}`
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// SDK 根请求模块 —— 唯一实现在 common/request.ts,此处仅转发
|
||||
// sdk 内 `import { ... } from '../request.ts'` 的解析目标
|
||||
|
||||
export { httpFetch } from './common/request.ts'
|
||||
export type { HttpOptions, HttpRequestObject, HttpFetchResponse } from './common/request.ts'
|
||||
@@ -0,0 +1,45 @@
|
||||
import { filterMusicInfoItem } from './singer.ts'
|
||||
import { createMusicuFetch } from './utils/index.ts'
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 通过专辑 mid 获取专辑详情与曲目列表
|
||||
* @param {*} id 专辑 mid(搜索结果中的 albumId)
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
async getAlbumDetail(id, page = 1, limit = 100) {
|
||||
const body = await createMusicuFetch({
|
||||
req: {
|
||||
module: 'music.musichallAlbum.AlbumSongList',
|
||||
method: 'GetAlbumSongList',
|
||||
// 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝
|
||||
param: { albumMid: id, begin: (page - 1) * limit, num: limit },
|
||||
},
|
||||
})
|
||||
if (body.req.code != 0) throw new Error('get album song list faild.')
|
||||
|
||||
const data = body.req.data
|
||||
const songList = data.songList ?? []
|
||||
// GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,
|
||||
// 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则
|
||||
const first = songList[0]?.songInfo
|
||||
const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))]
|
||||
return {
|
||||
source: 'tx',
|
||||
info: {
|
||||
name: first?.album?.name ?? null,
|
||||
img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${id}.jpg`,
|
||||
desc: null,
|
||||
author: authors.join('、') || null,
|
||||
},
|
||||
list: this.filterSongList(songList),
|
||||
page,
|
||||
limit,
|
||||
total: data.totalNum,
|
||||
}
|
||||
},
|
||||
filterSongList(raw) {
|
||||
return (raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { dateFormat2 } from '../index.ts'
|
||||
import getMusicInfo from './musicInfo.ts'
|
||||
|
||||
const emojis = {
|
||||
e400846: '😘',
|
||||
e400874: '😴',
|
||||
e400825: '😃',
|
||||
e400847: '😙',
|
||||
e400835: '😍',
|
||||
e400873: '😳',
|
||||
e400836: '😎',
|
||||
e400867: '😭',
|
||||
e400832: '😊',
|
||||
e400837: '😏',
|
||||
e400875: '😫',
|
||||
e400831: '😉',
|
||||
e400855: '😡',
|
||||
e400823: '😄',
|
||||
e400862: '😨',
|
||||
e400844: '😖',
|
||||
e400841: '😓',
|
||||
e400830: '😈',
|
||||
e400828: '😆',
|
||||
e400833: '😋',
|
||||
e400822: '😀',
|
||||
e400843: '😕',
|
||||
e400829: '😇',
|
||||
e400824: '😂',
|
||||
e400834: '😌',
|
||||
e400877: '😷',
|
||||
e400132: '🍉',
|
||||
e400181: '🍺',
|
||||
e401067: '☕️',
|
||||
e400186: '🥧',
|
||||
e400343: '🐷',
|
||||
e400116: '🌹',
|
||||
e400126: '🍃',
|
||||
e400613: '💋',
|
||||
e401236: '❤️',
|
||||
e400622: '💔',
|
||||
e400637: '💣',
|
||||
e400643: '💩',
|
||||
e400773: '🔪',
|
||||
e400102: '🌛',
|
||||
e401328: '🌞',
|
||||
e400420: '👏',
|
||||
e400914: '🙌',
|
||||
e400408: '👍',
|
||||
e400414: '👎',
|
||||
e401121: '✋',
|
||||
e400396: '👋',
|
||||
e400384: '👉',
|
||||
e401115: '✊',
|
||||
e400402: '👌',
|
||||
e400905: '🙈',
|
||||
e400906: '🙉',
|
||||
e400907: '🙊',
|
||||
e400562: '👻',
|
||||
e400932: '🙏',
|
||||
e400644: '💪',
|
||||
e400611: '💉',
|
||||
e400185: '🎁',
|
||||
e400655: '💰',
|
||||
e400325: '🐥',
|
||||
e400612: '💊',
|
||||
e400198: '🎉',
|
||||
e401685: '⚡️',
|
||||
e400631: '💝',
|
||||
e400768: '🔥',
|
||||
e400432: '👑',
|
||||
}
|
||||
|
||||
const songIdMap = new Map()
|
||||
const promises = new Map()
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
_requestObj2: null as HttpRequestObject | null,
|
||||
async getSongId({ songId, songmid }) {
|
||||
if (songId) return songId
|
||||
if (songIdMap.has(songmid)) return songIdMap.get(songmid)
|
||||
if (promises.has(songmid)) return (await promises.get(songmid)).songId
|
||||
const promise = getMusicInfo(songmid)
|
||||
promises.set(songmid, promise)
|
||||
const info = await promise
|
||||
if (!info) throw new Error('获取歌曲信息失败')
|
||||
songIdMap.set(songmid, info.songId)
|
||||
promises.delete(songmid)
|
||||
return info.songId
|
||||
},
|
||||
async getComment(mInfo, page = 1, limit = 20) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
const songId = await this.getSongId(mInfo)
|
||||
|
||||
const _requestObj = httpFetch('http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
|
||||
},
|
||||
form: {
|
||||
uin: '0',
|
||||
format: 'json',
|
||||
cid: '205360772',
|
||||
reqtype: '2',
|
||||
biztype: '1',
|
||||
topid: songId,
|
||||
cmd: '8',
|
||||
needmusiccrit: '1',
|
||||
pagenum: page - 1,
|
||||
pagesize: limit,
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
if (statusCode != 200 || body.code !== 0) throw new Error('获取评论失败')
|
||||
// console.log(body, statusCode)
|
||||
const comment = body.comment
|
||||
return {
|
||||
source: 'tx',
|
||||
comments: this.filterNewComment(comment.commentlist),
|
||||
total: comment.commenttotal,
|
||||
page,
|
||||
limit,
|
||||
maxPage: Math.ceil(comment.commenttotal / limit) || 1,
|
||||
}
|
||||
},
|
||||
async getHotComment(mInfo, page = 1, limit = 20) {
|
||||
// const _requestObj2 = httpFetch('http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
|
||||
// },
|
||||
// form: {
|
||||
// uin: '0',
|
||||
// format: 'json',
|
||||
// cid: '205360772',
|
||||
// reqtype: '2',
|
||||
// biztype: '1',
|
||||
// topid: songId,
|
||||
// cmd: '9',
|
||||
// needmusiccrit: '1',
|
||||
// pagenum: page - 1,
|
||||
// pagesize: limit,
|
||||
// },
|
||||
// })
|
||||
if (this._requestObj2) this._requestObj2.cancelHttp()
|
||||
const songId = await this.getSongId(mInfo)
|
||||
|
||||
const _requestObj2 = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
comm: {
|
||||
cv: 4747474,
|
||||
ct: 24,
|
||||
format: 'json',
|
||||
inCharset: 'utf-8',
|
||||
outCharset: 'utf-8',
|
||||
notice: 0,
|
||||
platform: 'yqq.json',
|
||||
needNewCode: 1,
|
||||
uin: 0,
|
||||
},
|
||||
req: {
|
||||
module: 'music.globalComment.CommentRead',
|
||||
method: 'GetHotCommentList',
|
||||
param: {
|
||||
BizType: 1,
|
||||
BizId: String(songId),
|
||||
LastCommentSeqNo: '',
|
||||
PageSize: limit,
|
||||
PageNum: page - 1,
|
||||
HotType: 1,
|
||||
WithAirborne: 0,
|
||||
PicEnable: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.0.0',
|
||||
referer: 'https://y.qq.com/',
|
||||
origin: 'https://y.qq.com',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj2.promise
|
||||
// console.log('body', body)
|
||||
if (statusCode != 200 || body.code !== 0 || body.req.code !== 0) throw new Error('获取热门评论失败')
|
||||
const comment = body.req.data.CommentList
|
||||
return {
|
||||
source: 'tx',
|
||||
comments: this.filterHotComment(comment.Comments),
|
||||
total: comment.Total,
|
||||
page,
|
||||
limit,
|
||||
maxPage: Math.ceil(comment.Total / limit) || 1,
|
||||
}
|
||||
},
|
||||
filterNewComment(rawList) {
|
||||
return rawList.map(item => {
|
||||
let time = this.formatTime(item.time)
|
||||
let timeStr = time ? dateFormat2(time) : null
|
||||
if (item.middlecommentcontent) {
|
||||
let firstItem = item.middlecommentcontent[0]
|
||||
firstItem.avatarurl = item.avatarurl
|
||||
firstItem.praisenum = item.praisenum
|
||||
item.avatarurl = null
|
||||
item.praisenum = null
|
||||
item.middlecommentcontent.reverse()
|
||||
}
|
||||
return {
|
||||
id: `${item.rootcommentid}_${item.commentid}`,
|
||||
rootId: item.rootcommentid,
|
||||
text: item.rootcommentcontent ? this.replaceEmoji(item.rootcommentcontent).replace(/\\n/g, '\n') : '',
|
||||
time: item.rootcommentid == item.commentid ? time : null,
|
||||
timeStr: item.rootcommentid == item.commentid ? timeStr : null,
|
||||
userName: item.rootcommentnick ? item.rootcommentnick.substring(1) : '',
|
||||
avatar: item.avatarurl,
|
||||
userId: item.encrypt_rootcommentuin,
|
||||
likedCount: item.praisenum,
|
||||
reply: item.middlecommentcontent
|
||||
? item.middlecommentcontent.map(c => {
|
||||
// let index = c.subcommentid.lastIndexOf('_')
|
||||
return {
|
||||
id: `sub_${item.rootcommentid}_${c.subcommentid}`,
|
||||
text: this.replaceEmoji(c.subcommentcontent).replace(/\\n/g, '\n'),
|
||||
time: c.subcommentid == item.commentid ? time : null,
|
||||
timeStr: c.subcommentid == item.commentid ? timeStr : null,
|
||||
userName: c.replynick.substring(1),
|
||||
avatar: c.avatarurl,
|
||||
userId: c.encrypt_replyuin,
|
||||
likedCount: c.praisenum,
|
||||
}
|
||||
})
|
||||
: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
filterHotComment(rawList) {
|
||||
return rawList.map(item => {
|
||||
return {
|
||||
id: `${item.SeqNo}_${item.CmId}`,
|
||||
rootId: item.SeqNo,
|
||||
text: item.Content ? this.replaceEmoji(item.Content).replace(/\\n/g, '\n') : '',
|
||||
time: item.PubTime ? this.formatTime(item.PubTime) : null,
|
||||
timeStr: item.PubTime ? dateFormat2(this.formatTime(item.PubTime)) : null,
|
||||
userName: item.Nick ?? '',
|
||||
images: item.Pic ? [item.Pic] : [],
|
||||
avatar: item.Avatar,
|
||||
location: item.Location ? item.Location : '',
|
||||
userId: item.EncryptUin,
|
||||
likedCount: item.PraiseNum,
|
||||
reply: item.SubComments
|
||||
? item.SubComments.map(c => {
|
||||
return {
|
||||
id: `sub_${c.SeqNo}_${c.CmId}`,
|
||||
text: this.replaceEmoji(c.Content).replace(/\\n/g, '\n'),
|
||||
time: c.PubTime ? this.formatTime(c.PubTime) : null,
|
||||
timeStr: c.PubTime ? dateFormat2(this.formatTime(c.PubTime)) : null,
|
||||
userName: c.Nick ?? '',
|
||||
avatar: c.Avatar,
|
||||
images: c.Pic ? [c.Pic] : [],
|
||||
userId: c.EncryptUin,
|
||||
likedCount: c.PraiseNum,
|
||||
}
|
||||
})
|
||||
: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
replaceEmoji(msg) {
|
||||
let rxp = /^\[em\](e\d+)\[\/em\]$/
|
||||
let result = msg.match(/\[em\]e\d+\[\/em\]/g)
|
||||
if (!result) return msg
|
||||
result = Array.from(new Set(result))
|
||||
for (let item of result) {
|
||||
let code = item.replace(rxp, '$1')
|
||||
msg = msg.replace(new RegExp(item.replace('[em]', '\\[em\\]').replace('[/em]', '\\[\\/em\\]'), 'g'), emojis[code] || '')
|
||||
}
|
||||
return msg
|
||||
},
|
||||
formatTime(time) {
|
||||
return String(time).length < 10 ? null : parseInt(time + '000')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
async getList(retryNum = 0) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
// const _requestObj = httpFetch('https://c.y.qq.com/splcloud/fcgi-bin/gethotkey.fcg', {
|
||||
// method: 'get',
|
||||
// headers: {
|
||||
// Referer: 'https://y.qq.com/portal/player.html',
|
||||
// },
|
||||
// })
|
||||
const _requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||||
method: 'post',
|
||||
body: {
|
||||
comm: {
|
||||
ct: '19',
|
||||
cv: '1803',
|
||||
guid: '0',
|
||||
patch: '118',
|
||||
psrf_access_token_expiresAt: 0,
|
||||
psrf_qqaccess_token: '',
|
||||
psrf_qqopenid: '',
|
||||
psrf_qqunionid: '',
|
||||
tmeAppID: 'qqmusic',
|
||||
tmeLoginType: 0,
|
||||
uin: '0',
|
||||
wid: '0',
|
||||
},
|
||||
hotkey: {
|
||||
method: 'GetHotkeyForQQMusicPC',
|
||||
module: 'tencent_musicsoso_hotkey.HotkeyService',
|
||||
param: {
|
||||
search_id: '',
|
||||
uin: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Referer: 'https://y.qq.com/portal/player.html',
|
||||
},
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
// console.log(body)
|
||||
if (statusCode != 200 || body.code !== 0) throw new Error('获取热搜词失败')
|
||||
// console.log(body)
|
||||
return { source: 'tx', list: this.filterList(body.hotkey.data.vec_hotkey) }
|
||||
},
|
||||
filterList(rawList) {
|
||||
return rawList.map(item => item.query)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import leaderboard from './leaderboard.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import songList from './songList.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import { apis } from '../api-source.ts'
|
||||
import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
import album from './album.ts'
|
||||
// import tipSearch from './tipSearch.ts'
|
||||
|
||||
const tx = {
|
||||
// tipSearch,
|
||||
leaderboard,
|
||||
songList,
|
||||
musicSearch,
|
||||
hotSearch,
|
||||
comment,
|
||||
singer,
|
||||
album,
|
||||
|
||||
getMusicUrl(songInfo, type) {
|
||||
return apis('tx')!.getMusicUrl(songInfo, type)
|
||||
},
|
||||
getLyric(songInfo) {
|
||||
// let singer = songInfo.singer.indexOf('、') > -1 ? songInfo.singer.split('、')[0] : songInfo.singer
|
||||
return lyric.getLyric(songInfo)
|
||||
},
|
||||
async getPic(songInfo) {
|
||||
return `https://y.gtimg.cn/music/photo_new/T002R500x500M000${songInfo.albumId}.jpg`
|
||||
},
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
return `https://y.qq.com/n/yqq/song/${songInfo.songmid}.html`
|
||||
},
|
||||
}
|
||||
|
||||
export default tx
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { HttpRequestObject, SongInfo, SongType, Quality, QualityInfo } from '../types.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
|
||||
let boardList = [{ id: 'tx__4', name: '流行指数榜', bangid: '4' }, { id: 'tx__26', name: '热歌榜', bangid: '26' }, { id: 'tx__27', name: '新歌榜', bangid: '27' }, { id: 'tx__62', name: '飙升榜', bangid: '62' }, { id: 'tx__58', name: '说唱榜', bangid: '58' }, { id: 'tx__57', name: '喜力电音榜', bangid: '57' }, { id: 'tx__28', name: '网络歌曲榜', bangid: '28' }, { id: 'tx__5', name: '内地榜', bangid: '5' }, { id: 'tx__3', name: '欧美榜', bangid: '3' }, { id: 'tx__59', name: '香港地区榜', bangid: '59' }, { id: 'tx__16', name: '韩国榜', bangid: '16' }, { id: 'tx__60', name: '抖快榜', bangid: '60' }, { id: 'tx__29', name: '影视金曲榜', bangid: '29' }, { id: 'tx__17', name: '日本榜', bangid: '17' }, { id: 'tx__52', name: '腾讯音乐人原创榜', bangid: '52' }, { id: 'tx__36', name: 'K歌金曲榜', bangid: '36' }, { id: 'tx__61', name: '台湾地区榜', bangid: '61' }, { id: 'tx__63', name: 'DJ舞曲榜', bangid: '63' }, { id: 'tx__64', name: '综艺新歌榜', bangid: '64' }, { id: 'tx__65', name: '国风热歌榜', bangid: '65' }, { id: 'tx__67', name: '听歌识曲榜', bangid: '67' }, { id: 'tx__72', name: '动漫音乐榜', bangid: '72' }, { id: 'tx__73', name: '游戏音乐榜', bangid: '73' }, { id: 'tx__75', name: '有声榜', bangid: '75' }, { id: 'tx__131', name: '校园音乐人排行榜', bangid: '131' }]
|
||||
|
||||
export default {
|
||||
limit: 300,
|
||||
list: [
|
||||
{
|
||||
id: 'txlxzsb',
|
||||
name: '流行榜',
|
||||
bangid: '4',
|
||||
},
|
||||
{
|
||||
id: 'txrgb',
|
||||
name: '热歌榜',
|
||||
bangid: '26',
|
||||
},
|
||||
{
|
||||
id: 'txwlhgb',
|
||||
name: '网络榜',
|
||||
bangid: '28',
|
||||
},
|
||||
{
|
||||
id: 'txdyb',
|
||||
name: '抖音榜',
|
||||
bangid: '60',
|
||||
},
|
||||
{
|
||||
id: 'txndb',
|
||||
name: '内地榜',
|
||||
bangid: '5',
|
||||
},
|
||||
{
|
||||
id: 'txxgb',
|
||||
name: '香港榜',
|
||||
bangid: '59',
|
||||
},
|
||||
{
|
||||
id: 'txtwb',
|
||||
name: '台湾榜',
|
||||
bangid: '61',
|
||||
},
|
||||
{
|
||||
id: 'txoumb',
|
||||
name: '欧美榜',
|
||||
bangid: '3',
|
||||
},
|
||||
{
|
||||
id: 'txhgb',
|
||||
name: '韩国榜',
|
||||
bangid: '16',
|
||||
},
|
||||
{
|
||||
id: 'txrbb',
|
||||
name: '日本榜',
|
||||
bangid: '17',
|
||||
},
|
||||
{
|
||||
id: 'txtybb',
|
||||
name: 'YouTube榜',
|
||||
bangid: '128',
|
||||
},
|
||||
],
|
||||
listDetailRequest(id, period, limit) {
|
||||
// console.log(id, period, limit)
|
||||
return 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)',
|
||||
},
|
||||
body: {
|
||||
toplist: {
|
||||
module: 'musicToplist.ToplistInfoServer',
|
||||
method: 'GetDetail',
|
||||
param: {
|
||||
topid: id,
|
||||
num: limit,
|
||||
period,
|
||||
},
|
||||
},
|
||||
comm: {
|
||||
uin: 0,
|
||||
format: 'json',
|
||||
ct: 20,
|
||||
cv: 1859,
|
||||
},
|
||||
},
|
||||
}).promise
|
||||
},
|
||||
regExps: {
|
||||
periodList: /<i class="play_cover__btn c_tx_link js_icon_play" data-listkey=".+?" data-listname=".+?" data-tid=".+?" data-date=".+?" .+?<\/i>/g,
|
||||
period: /data-listname="(.+?)" data-tid=".*?\/(.+?)" data-date="(.+?)" .+?<\/i>/,
|
||||
},
|
||||
periods: {},
|
||||
periodUrl: 'https://c.y.qq.com/node/pc/wk_v15/top.html',
|
||||
_requestBoardsObj: null as HttpRequestObject | null,
|
||||
getBoardsData() {
|
||||
if (this._requestBoardsObj) this._requestBoardsObj.cancelHttp()
|
||||
this._requestBoardsObj = httpFetch('https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg?g_tk=1928093487&inCharset=utf-8&outCharset=utf-8¬ice=0&format=json&uin=0&needNewCode=1&platform=h5')
|
||||
return this._requestBoardsObj.promise
|
||||
},
|
||||
getData(url) {
|
||||
const requestDataObj = httpFetch(url)
|
||||
return requestDataObj.promise
|
||||
},
|
||||
filterData(rawList) {
|
||||
// console.log(rawList)
|
||||
return rawList.map(item => {
|
||||
let types: SongType[] = []
|
||||
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
if (item.file.size_128mp3 !== 0) {
|
||||
let size = sizeFormate(item.file.size_128mp3)
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_320mp3 !== 0) {
|
||||
let size = sizeFormate(item.file.size_320mp3)
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_flac !== 0) {
|
||||
let size = sizeFormate(item.file.size_flac)
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_hires !== 0) {
|
||||
let size = sizeFormate(item.file.size_hires)
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
// types.reverse()
|
||||
return {
|
||||
singer: formatSingerName(item.singer, 'name'),
|
||||
name: item.title,
|
||||
albumName: item.album.name,
|
||||
albumId: item.album.mid,
|
||||
source: 'tx',
|
||||
interval: formatPlayTime(item.interval),
|
||||
songId: item.id,
|
||||
albumMid: item.album.mid,
|
||||
strMediaMid: item.file.media_mid,
|
||||
songmid: item.mid,
|
||||
img: (item.album.name === '' || item.album.name === '空')
|
||||
? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : ''
|
||||
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${item.album.mid}.jpg`,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
getPeriods(bangid) {
|
||||
return this.getData(this.periodUrl).then(({ body: html }) => {
|
||||
let result = html.match(this.regExps.periodList)
|
||||
if (!result) return Promise.reject(new Error('get data failed'))
|
||||
result.forEach(item => {
|
||||
let result = item.match(this.regExps.period)
|
||||
if (!result) return
|
||||
this.periods[result[2]] = {
|
||||
name: result[1],
|
||||
bangid: result[2],
|
||||
period: result[3],
|
||||
}
|
||||
})
|
||||
const info = this.periods[bangid]
|
||||
return info && info.period
|
||||
})
|
||||
},
|
||||
filterBoardsData(rawList) {
|
||||
// console.log(rawList)
|
||||
let list: any[] = []
|
||||
for (const board of rawList) {
|
||||
// 排除 MV榜
|
||||
if (board.id == 201) continue
|
||||
|
||||
if (board.topTitle.startsWith('巅峰榜·')) {
|
||||
board.topTitle = board.topTitle.substring(4, board.topTitle.length)
|
||||
}
|
||||
if (!board.topTitle.endsWith('榜')) board.topTitle += '榜'
|
||||
list.push({
|
||||
id: 'tx__' + board.id,
|
||||
name: board.topTitle,
|
||||
bangid: String(board.id),
|
||||
})
|
||||
}
|
||||
return list
|
||||
},
|
||||
async getBoards(retryNum = 0) {
|
||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
// let response
|
||||
// try {
|
||||
// response = await this.getBoardsData()
|
||||
// } catch (error) {
|
||||
// return this.getBoards(retryNum)
|
||||
// }
|
||||
// // console.log(response.body)
|
||||
// if (response.statusCode !== 200 || response.body.code !== 0) return this.getBoards(retryNum)
|
||||
// const list = this.filterBoardsData(response.body.data.topList)
|
||||
// console.log(list)
|
||||
// console.log(JSON.stringify(list))
|
||||
// this.list = list
|
||||
// return {
|
||||
// list,
|
||||
// source: 'tx',
|
||||
// }
|
||||
this.list = boardList
|
||||
return {
|
||||
list: boardList,
|
||||
source: 'tx',
|
||||
}
|
||||
},
|
||||
getList(bangid, page, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
bangid = parseInt(bangid)
|
||||
let info = this.periods[bangid]
|
||||
let p = info ? Promise.resolve(info.period) : this.getPeriods(bangid)
|
||||
return p.then(period => {
|
||||
return this.listDetailRequest(bangid, period, this.limit).then(resp => {
|
||||
if (resp.body.code !== 0) return this.getList(bangid, page, retryNum)
|
||||
return {
|
||||
total: resp.body.toplist.data.songInfoList.length,
|
||||
list: this.filterData(resp.body.toplist.data.songInfoList),
|
||||
limit: this.limit,
|
||||
page: 1,
|
||||
source: 'tx',
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
getDetailPageUrl(id) {
|
||||
if (typeof id == 'string') id = id.replace('tx__', '')
|
||||
return `https://y.qq.com/n/ryqq/toplist/${id}`
|
||||
},
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import getMusicInfo from './musicInfo.ts'
|
||||
import { rendererInvoke } from '../common/rendererIpc.ts'
|
||||
import { WIN_MAIN_RENDERER_EVENT_NAME } from '../common/ipcNames.ts'
|
||||
|
||||
const songIdMap = new Map()
|
||||
const promises = new Map()
|
||||
export const decodeLyric = (lrc: any, tlrc?: any, rlrc?: any): Promise<any> => rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.handle_tx_decode_lyric, { lrc, tlrc, rlrc })
|
||||
|
||||
|
||||
const parseTools = {
|
||||
rxps: {
|
||||
info: /^{"/,
|
||||
lineTime: /^\[(\d+),\d+\]/,
|
||||
lineTime2: /^\[([\d:.]+)\]/,
|
||||
wordTime: /\(\d+,\d+\)/,
|
||||
wordTimeAll: /(\(\d+,\d+\))/g,
|
||||
timeLabelFixRxp: /(?:\.0+|0+)$/,
|
||||
},
|
||||
msFormat(timeMs) {
|
||||
if (Number.isNaN(timeMs)) return ''
|
||||
let ms = timeMs % 1000
|
||||
timeMs /= 1000
|
||||
let m = Math.floor(timeMs / 60).toString().padStart(2, '0')
|
||||
timeMs %= 60
|
||||
let s = Math.floor(timeMs).toString().padStart(2, '0')
|
||||
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
|
||||
},
|
||||
parseLyric(lrc) {
|
||||
lrc = lrc.trim()
|
||||
lrc = lrc.replace(/\r/g, '')
|
||||
if (!lrc) return { lyric: '', yrc: '' }
|
||||
const lines = lrc.split('\n')
|
||||
|
||||
const yrcLines: string[] = []
|
||||
const lrcLines: string[] = []
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim()
|
||||
let result = this.rxps.lineTime.exec(line)
|
||||
if (!result) {
|
||||
if (line.startsWith('[offset')) {
|
||||
yrcLines.push(line)
|
||||
lrcLines.push(line)
|
||||
}
|
||||
if (this.rxps.lineTime2.test(line)) {
|
||||
// yrcLines.push(line)
|
||||
lrcLines.push(line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const startMsTime = parseInt(result[1])
|
||||
const startTimeStr = this.msFormat(startMsTime)
|
||||
if (!startTimeStr) continue
|
||||
|
||||
let words = line.replace(this.rxps.lineTime, '')
|
||||
|
||||
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
|
||||
|
||||
let times = words.match(this.rxps.wordTimeAll)
|
||||
if (!times) continue
|
||||
times = times.map(time => {
|
||||
const result = /\((\d+),(\d+)\)/.exec(time)!
|
||||
return `<${Math.max(parseInt(result[1]) - startMsTime, 0)},${result[2]}>`
|
||||
})
|
||||
const wordArr = words.split(this.rxps.wordTime)
|
||||
const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('')
|
||||
yrcLines.push(`${startTimeStr}${newWords}`)
|
||||
}
|
||||
return {
|
||||
lyric: lrcLines.join('\n'),
|
||||
yrc: yrcLines.join('\n'),
|
||||
}
|
||||
},
|
||||
parseRlyric(lrc) {
|
||||
lrc = lrc.trim()
|
||||
lrc = lrc.replace(/\r/g, '')
|
||||
if (!lrc) return { lyric: '', yrc: '' }
|
||||
const lines = lrc.split('\n')
|
||||
|
||||
const lrcLines: string[] = []
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim()
|
||||
let result = this.rxps.lineTime.exec(line)
|
||||
if (!result) continue
|
||||
|
||||
const startMsTime = parseInt(result[1])
|
||||
const startTimeStr = this.msFormat(startMsTime)
|
||||
if (!startTimeStr) continue
|
||||
|
||||
let words = line.replace(this.rxps.lineTime, '')
|
||||
|
||||
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
|
||||
}
|
||||
return lrcLines.join('\n')
|
||||
},
|
||||
removeTag(str) {
|
||||
return str.replace(/^[\S\s]*?LyricContent="/, '').replace(/"\/>[\S\s]*?$/, '')
|
||||
},
|
||||
getIntv(interval) {
|
||||
if (!interval) return 0
|
||||
if (!interval.includes('.')) interval += '.0'
|
||||
let arr = interval.split(/:|\./)
|
||||
while (arr.length < 3) arr.unshift('0')
|
||||
const [m, s, ms] = arr
|
||||
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
|
||||
},
|
||||
fixRlrcTimeTag(rlrc, lrc) {
|
||||
// console.log(lrc)
|
||||
// console.log(rlrc)
|
||||
const rlrcLines = rlrc.split('\n')
|
||||
let lrcLines = lrc.split('\n')
|
||||
// let temp: string[] = []
|
||||
let newLrc: string[] = []
|
||||
rlrcLines.forEach((line) => {
|
||||
const result = this.rxps.lineTime2.exec(line)
|
||||
if (!result) return
|
||||
const words = line.replace(this.rxps.lineTime2, '')
|
||||
if (!words.trim()) return
|
||||
const t1 = this.getIntv(result[1])
|
||||
|
||||
while (lrcLines.length) {
|
||||
const lrcLine = lrcLines.shift()
|
||||
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
|
||||
if (!lrcLineResult) continue
|
||||
const t2 = this.getIntv(lrcLineResult[1])
|
||||
if (Math.abs(t1 - t2) < 100) {
|
||||
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
|
||||
break
|
||||
}
|
||||
// temp.push(line)
|
||||
}
|
||||
// lrcLines = [...temp, ...lrcLines]
|
||||
// temp = []
|
||||
})
|
||||
return newLrc.join('\n')
|
||||
},
|
||||
fixTlrcTimeTag(tlrc, lrc) {
|
||||
// console.log(lrc)
|
||||
// console.log(tlrc)
|
||||
const tlrcLines = tlrc.split('\n')
|
||||
let lrcLines = lrc.split('\n')
|
||||
// let temp: string[] = []
|
||||
let newLrc: string[] = []
|
||||
tlrcLines.forEach((line) => {
|
||||
const result = this.rxps.lineTime2.exec(line)
|
||||
if (!result) return
|
||||
const words = line.replace(this.rxps.lineTime2, '')
|
||||
if (!words.trim()) return
|
||||
let time = result[1]
|
||||
if (time.includes('.')) {
|
||||
time += ''.padStart(3 - time.split('.')[1].length, '0')
|
||||
}
|
||||
const t1 = this.getIntv(time)
|
||||
|
||||
while (lrcLines.length) {
|
||||
const lrcLine = lrcLines.shift()
|
||||
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
|
||||
if (!lrcLineResult) continue
|
||||
const t2 = this.getIntv(lrcLineResult[1])
|
||||
if (Math.abs(t1 - t2) < 100) {
|
||||
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
|
||||
break
|
||||
}
|
||||
// temp.push(line)
|
||||
}
|
||||
// lrcLines = [...temp, ...lrcLines]
|
||||
// temp = []
|
||||
})
|
||||
return newLrc.join('\n')
|
||||
},
|
||||
parse(lrc, tlrc, rlrc) {
|
||||
const info = {
|
||||
lyric: '',
|
||||
tlyric: '',
|
||||
rlyric: '',
|
||||
yrc: '',
|
||||
}
|
||||
if (lrc) {
|
||||
let { lyric, yrc } = this.parseLyric(this.removeTag(lrc))
|
||||
info.lyric = lyric
|
||||
info.yrc = yrc
|
||||
// console.log(lyric)
|
||||
// console.log(yrc)
|
||||
}
|
||||
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
|
||||
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
|
||||
// console.log(info.lyric)
|
||||
// console.log(info.tlyric)
|
||||
// console.log(info.rlyric)
|
||||
|
||||
return info
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
successCode: 0,
|
||||
async getSongId({ songId, songmid }) {
|
||||
if (songId) return songId
|
||||
if (songIdMap.has(songmid)) return songIdMap.get(songmid)
|
||||
if (promises.has(songmid)) return (await promises.get(songmid)).songId
|
||||
const promise = getMusicInfo(songmid)
|
||||
promises.set(songmid, promise)
|
||||
const info = await promise
|
||||
if (!info) throw new Error('获取歌曲信息失败')
|
||||
songIdMap.set(songmid, info.songId)
|
||||
promises.delete(songmid)
|
||||
return info.songId
|
||||
},
|
||||
async parseLyric(lrc, tlrc, rlrc) {
|
||||
const { lyric, tlyric, rlyric } = await decodeLyric(lrc, tlrc, rlrc)
|
||||
// return {
|
||||
|
||||
// }
|
||||
// console.log(lyric)
|
||||
// console.log(tlyric)
|
||||
// console.log(rlyric)
|
||||
return parseTools.parse(lyric, tlyric, rlyric)
|
||||
},
|
||||
getLyric(mInfo, retryNum = 0) {
|
||||
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))
|
||||
|
||||
return {
|
||||
cancelHttp() {
|
||||
|
||||
},
|
||||
promise: this.getSongId(mInfo).then(songId => {
|
||||
const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
referer: 'https://y.qq.com',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
|
||||
},
|
||||
body: {
|
||||
comm: {
|
||||
ct: '19',
|
||||
cv: '1859',
|
||||
uin: '0',
|
||||
},
|
||||
req: {
|
||||
method: 'GetPlayLyricInfo',
|
||||
module: 'music.musichallSong.PlayLyricInfo',
|
||||
param: {
|
||||
format: 'json',
|
||||
crypt: 1,
|
||||
ct: 19,
|
||||
cv: 1873,
|
||||
interval: 0,
|
||||
lrc_t: 0,
|
||||
qrc: 1,
|
||||
qrc_t: 0,
|
||||
roma: 1,
|
||||
roma_t: 0,
|
||||
songID: songId,
|
||||
trans: 1,
|
||||
trans_t: 0,
|
||||
type: -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.code != this.successCode || body.req.code != this.successCode) return this.getLyric(songId, ++retryNum)
|
||||
const data = body.req.data
|
||||
return this.parseLyric(data.lyric, data.trans, data.roma)
|
||||
})
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// export default {
|
||||
// regexps: {
|
||||
// matchLrc: /.+"lyric":"([\w=+/]*)".+/,
|
||||
// },
|
||||
// getLyric(songmid) {
|
||||
// const requestObj = httpFetch(`https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid=${songmid}&g_tk=5381&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&platform=yqq`, {
|
||||
// headers: {
|
||||
// Referer: 'https://y.qq.com/portal/player.html',
|
||||
// },
|
||||
// })
|
||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||
// if (body.code != 0 || !body.lyric) return Promise.reject(new Error('Get lyric failed'))
|
||||
// return {
|
||||
// lyric: decodeName(b64DecodeUnicode(body.lyric)),
|
||||
// tlyric: decodeName(b64DecodeUnicode(body.trans)),
|
||||
// }
|
||||
// })
|
||||
// return requestObj
|
||||
// },
|
||||
// }
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { HttpRequestObject, SongType, Quality, QualityInfo } from '../types.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
|
||||
const getSinger = (singers) => {
|
||||
let arr: string[] = []
|
||||
singers.forEach(singer => {
|
||||
arr.push(singer.name)
|
||||
})
|
||||
return arr.join('、')
|
||||
}
|
||||
|
||||
export default (songmid) => {
|
||||
const requestObj = 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)',
|
||||
},
|
||||
body: {
|
||||
comm: {
|
||||
ct: '19',
|
||||
cv: '1859',
|
||||
uin: '0',
|
||||
},
|
||||
req: {
|
||||
module: 'music.pf_song_detail_svr',
|
||||
method: 'get_song_detail_yqq',
|
||||
param: {
|
||||
song_type: 0,
|
||||
song_mid: songmid,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.code != 0 || body.req.code != 0) return Promise.reject(new Error('获取歌曲信息失败'))
|
||||
const item = body.req.data.track_info
|
||||
if (!item.file?.media_mid) return null
|
||||
|
||||
let types: SongType[] = []
|
||||
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
const file = item.file
|
||||
if (file.size_128mp3 != 0) {
|
||||
let size = sizeFormate(file.size_128mp3)
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (file.size_320mp3 !== 0) {
|
||||
let size = sizeFormate(file.size_320mp3)
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (file.size_flac !== 0) {
|
||||
let size = sizeFormate(file.size_flac)
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (file.size_hires !== 0) {
|
||||
let size = sizeFormate(file.size_hires)
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
// types.reverse()
|
||||
let albumId = ''
|
||||
let albumName = ''
|
||||
if (item.album) {
|
||||
albumName = item.album.name
|
||||
albumId = item.album.mid
|
||||
}
|
||||
return {
|
||||
singer: getSinger(item.singer),
|
||||
name: item.title,
|
||||
albumName,
|
||||
albumId,
|
||||
source: 'tx',
|
||||
interval: formatPlayTime(item.interval),
|
||||
songId: item.id,
|
||||
albumMid: item.album?.mid ?? '',
|
||||
strMediaMid: item.file.media_mid,
|
||||
songmid: item.mid,
|
||||
img: (albumId === '' || albumId === '空')
|
||||
? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : ''
|
||||
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import { signRequest } from './utils/index.ts'
|
||||
import type { SongInfo, SongType, Quality, QualityInfo } from '../types.ts'
|
||||
|
||||
export default {
|
||||
limit: 50,
|
||||
total: 0,
|
||||
page: 0,
|
||||
allPage: 1,
|
||||
successCode: 0,
|
||||
musicSearch(str, page, limit, retryNum = 0) {
|
||||
if (retryNum > 5) return Promise.reject(new Error('搜索失败'))
|
||||
const searchRequest = signRequest({
|
||||
comm: {
|
||||
ct: '11',
|
||||
cv: '14090508',
|
||||
v: '14090508',
|
||||
tmeAppID: 'qqmusic',
|
||||
phonetype: 'EBG-AN10',
|
||||
deviceScore: '553.47',
|
||||
devicelevel: '50',
|
||||
newdevicelevel: '20',
|
||||
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0',
|
||||
os_ver: '12',
|
||||
OpenUDID: '0',
|
||||
OpenUDID2: '0',
|
||||
QIMEI36: '0',
|
||||
udid: '0',
|
||||
chid: '0',
|
||||
aid: '0',
|
||||
oaid: '0',
|
||||
taid: '0',
|
||||
tid: '0',
|
||||
wid: '0',
|
||||
uid: '0',
|
||||
sid: '0',
|
||||
modeSwitch: '6',
|
||||
teenMode: '0',
|
||||
ui_mode: '2',
|
||||
nettype: '1020',
|
||||
v4ip: '',
|
||||
},
|
||||
req: {
|
||||
module: 'music.search.SearchCgiService',
|
||||
method: 'DoSearchForQQMusicMobile',
|
||||
param: {
|
||||
search_type: 0,
|
||||
searchid: Math.random().toString().slice(2),
|
||||
query: str,
|
||||
page_num: page,
|
||||
num_per_page: limit,
|
||||
highlight: 0,
|
||||
nqc_flag: 0,
|
||||
multi_zhida: 0,
|
||||
cat: 2,
|
||||
grp: 1,
|
||||
sin: 0,
|
||||
sem: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
return searchRequest.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (!body || !body.req || body.code != this.successCode || body.req.code != this.successCode) {
|
||||
return this.musicSearch(str, page, limit, ++retryNum)
|
||||
}
|
||||
return body.req.data
|
||||
})
|
||||
},
|
||||
// randomInt(min, max) {
|
||||
// return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
// },
|
||||
// getSearchId() {
|
||||
// const e = BigInt(this.randomInt(1, 20))
|
||||
// const t = e * 18014398509481984n
|
||||
// const n = BigInt(this.randomInt(0, 4194304)) * 4294967296n
|
||||
// const a = BigInt(Date.now())
|
||||
// const r = (a * 1000n) % (24n * 60n * 60n * 1000n)
|
||||
// return String(t + n + r)
|
||||
// },
|
||||
handleResult(rawList) {
|
||||
// console.log(rawList)
|
||||
if (!rawList || !Array.isArray(rawList)) return []
|
||||
const list: SongInfo[] = []
|
||||
rawList.forEach(item => {
|
||||
if (!item.file?.media_mid) return
|
||||
|
||||
let types: SongType[] = []
|
||||
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
const file = item.file
|
||||
if (file.size_128mp3 != 0) {
|
||||
let size = sizeFormate(file.size_128mp3)
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (file.size_320mp3 !== 0) {
|
||||
let size = sizeFormate(file.size_320mp3)
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (file.size_flac !== 0) {
|
||||
let size = sizeFormate(file.size_flac)
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (file.size_hires !== 0) {
|
||||
let size = sizeFormate(file.size_hires)
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
// types.reverse()
|
||||
let albumId = ''
|
||||
let albumName = ''
|
||||
if (item.album) {
|
||||
albumName = item.album.name
|
||||
albumId = item.album.mid
|
||||
}
|
||||
list.push({
|
||||
singer: formatSingerName(item.singer, 'name'),
|
||||
// name: item.name + (item.title_extra ?? ''),
|
||||
name: item.title,
|
||||
albumName,
|
||||
albumId,
|
||||
source: 'tx',
|
||||
interval: formatPlayTime(item.interval),
|
||||
songId: item.id,
|
||||
albumMid: item.album?.mid ?? '',
|
||||
strMediaMid: item.file.media_mid,
|
||||
songmid: item.mid,
|
||||
img: (albumId === '' || albumId === '空')
|
||||
? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : ''
|
||||
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
})
|
||||
// console.log(list)
|
||||
return list
|
||||
},
|
||||
search(str, page = 1, limit) {
|
||||
if (limit == null) limit = this.limit
|
||||
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
|
||||
return this.musicSearch(str, page, limit).then(({ body, meta }) => {
|
||||
let list = this.handleResult(body.item_song)
|
||||
|
||||
this.total = meta.estimate_sum
|
||||
this.page = page
|
||||
this.allPage = Math.ceil(this.total / limit)
|
||||
|
||||
return Promise.resolve({
|
||||
list,
|
||||
allPage: this.allPage,
|
||||
limit,
|
||||
total: this.total,
|
||||
source: 'tx',
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { SongType, Quality, QualityInfo } from '../types.ts'
|
||||
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import { createMusicuFetch } from './utils/index.ts'
|
||||
|
||||
export const filterMusicInfoItem = item => {
|
||||
const types: SongType[] = []
|
||||
const _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
if (item.file.size_128mp3 != 0) {
|
||||
let size = sizeFormate(item.file.size_128mp3)
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_320mp3 !== 0) {
|
||||
let size = sizeFormate(item.file.size_320mp3)
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_flac !== 0) {
|
||||
let size = sizeFormate(item.file.size_flac)
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_hires !== 0) {
|
||||
let size = sizeFormate(item.file.size_hires)
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
const albumId = item.album.id ?? ''
|
||||
const albumMid = item.album.mid ?? ''
|
||||
const albumName = item.album.name ?? ''
|
||||
return {
|
||||
source: 'tx',
|
||||
singer: formatSingerName(item.singer, 'name'),
|
||||
name: item.title,
|
||||
albumName,
|
||||
albumId,
|
||||
albumMid,
|
||||
interval: formatPlayTime(item.interval),
|
||||
songId: item.id,
|
||||
songmid: item.mid,
|
||||
strMediaMid: item.file.media_mid,
|
||||
img: (albumId === '' || albumId === '空')
|
||||
? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : ''
|
||||
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumMid}.jpg`,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 歌手详情接口(musichallSinger.GetSingerDetail 等)已停用(104403):
|
||||
// 歌手名/统计由 GetSingerSongList 与 GetAlbumList 合成,头像走固定图片 URL 规则
|
||||
export default {
|
||||
/**
|
||||
* 获取歌手信息
|
||||
* @param {*} id
|
||||
*/
|
||||
getInfo(id) {
|
||||
return Promise.all([
|
||||
createMusicuFetch({ req: { module: 'musichall.song_list_server', method: 'GetSingerSongList', param: { singerMid: id, order: 1, begin: 0, num: 1 } } }),
|
||||
createMusicuFetch({ req: { module: 'music.musichallAlbum.AlbumListServer', method: 'GetAlbumList', param: { singerMid: id, order: 0, begin: 0, num: 1, songNumTag: 0, singerID: 0 } } }),
|
||||
]).then(([songBody, albumBody]) => {
|
||||
if (songBody.req.code != 0 || albumBody.req.code != 0) throw new Error('get singer info faild.')
|
||||
const songData = songBody.req.data
|
||||
const albumData = albumBody.req.data
|
||||
// 详情接口停用:歌手名从歌曲条目反查,头像用平台的固定图片 URL 规则
|
||||
const singers = songData.songList?.[0]?.songInfo?.singer ?? []
|
||||
const self = singers.find(s => s.mid === id) ?? singers[0]
|
||||
return {
|
||||
source: 'tx',
|
||||
id,
|
||||
info: {
|
||||
name: self?.name ?? '',
|
||||
desc: null,
|
||||
avatar: `https://y.gtimg.cn/music/photo_new/T001R500x500M000${id}.jpg`,
|
||||
gender: null,
|
||||
},
|
||||
count: {
|
||||
music: songData.totalNum,
|
||||
album: albumData.total,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取歌手专辑列表
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
getAlbumList(id, page = 1, limit = 10) {
|
||||
return createMusicuFetch({
|
||||
req: {
|
||||
module: 'music.musichallAlbum.AlbumListServer',
|
||||
method: 'GetAlbumList',
|
||||
param: {
|
||||
singerMid: id,
|
||||
order: 0,
|
||||
begin: (page - 1) * limit,
|
||||
num: limit,
|
||||
songNumTag: 0,
|
||||
singerID: 0,
|
||||
},
|
||||
},
|
||||
}).then(body => {
|
||||
if (body.req.code != 0) throw new Error('get singer album faild.')
|
||||
|
||||
const list = this.filterAlbumList(body.req.data.albumList)
|
||||
return {
|
||||
source: 'tx',
|
||||
list,
|
||||
limit,
|
||||
page,
|
||||
total: body.req.data.total,
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取歌手歌曲列表
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
async getSongList(id, page = 1, limit = 100) {
|
||||
const body = await createMusicuFetch({
|
||||
req: {
|
||||
module: 'musichall.song_list_server',
|
||||
method: 'GetSingerSongList',
|
||||
param: {
|
||||
singerMid: id,
|
||||
order: 1,
|
||||
begin: (page - 1) * limit,
|
||||
num: limit,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (body.req.code != 0) throw new Error('get singer song list faild.')
|
||||
|
||||
const list = this.filterSongList(body.req.data.songList)
|
||||
return {
|
||||
source: 'tx',
|
||||
list,
|
||||
limit,
|
||||
page,
|
||||
total: body.req.data.totalNum,
|
||||
}
|
||||
},
|
||||
filterAlbumList(raw) {
|
||||
return raw.map(item => {
|
||||
return {
|
||||
id: item.albumID,
|
||||
mid: item.albumMid,
|
||||
count: item.totalNum,
|
||||
info: {
|
||||
name: item.albumName,
|
||||
author: item.singerName,
|
||||
img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${item.albumMid}.jpg`,
|
||||
desc: null,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
filterSongList(raw) {
|
||||
return raw.map(item => {
|
||||
return filterMusicInfoItem(item.songInfo)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { HttpRequestObject, SongType, Quality, QualityInfo } from '../types.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
|
||||
export default {
|
||||
_requestObj_tags: null as HttpRequestObject | null,
|
||||
_requestObj_hotTags: null as HttpRequestObject | null,
|
||||
_requestObj_list: null as HttpRequestObject | null,
|
||||
limit_list: 36,
|
||||
limit_song: 100000,
|
||||
successCode: 0,
|
||||
sortList: [
|
||||
{
|
||||
name: '最热',
|
||||
id: 5,
|
||||
},
|
||||
{
|
||||
name: '最新',
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
regExps: {
|
||||
hotTagHtml: /class="c_bg_link js_tag_item" data-id="\w+">.+?<\/a>/g,
|
||||
hotTag: /data-id="(\w+)">(.+?)<\/a>/,
|
||||
|
||||
// https://y.qq.com/n/yqq/playlist/7217720898.html
|
||||
// https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&appversion=9050006&id=7217720898&ADTAG=qfshare
|
||||
listDetailLink: /\/playlist\/(\d+)/,
|
||||
listDetailLink2: /id=(\d+)/,
|
||||
},
|
||||
tagsUrl: 'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D',
|
||||
hotTagUrl: 'https://c.y.qq.com/node/pc/wk_v15/category_playlist.html',
|
||||
getListUrl(sortId, id, page) {
|
||||
if (id) {
|
||||
id = parseInt(id)
|
||||
return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({
|
||||
comm: { cv: 1602, ct: 20 },
|
||||
playlist: {
|
||||
method: 'get_category_content',
|
||||
param: {
|
||||
titleid: id,
|
||||
caller: '0',
|
||||
category_id: id,
|
||||
size: this.limit_list,
|
||||
page: page - 1,
|
||||
use_page: 1,
|
||||
},
|
||||
module: 'playlist.PlayListCategoryServer',
|
||||
},
|
||||
}))}`
|
||||
}
|
||||
return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({
|
||||
comm: { cv: 1602, ct: 20 },
|
||||
playlist: {
|
||||
method: 'get_playlist_by_tag',
|
||||
param: { id: 10000000, sin: this.limit_list * (page - 1), size: this.limit_list, order: sortId, cur_page: page },
|
||||
module: 'playlist.PlayListPlazaServer',
|
||||
},
|
||||
}))}`
|
||||
},
|
||||
getListDetailUrl(id) {
|
||||
return `https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid=${id}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0`
|
||||
},
|
||||
|
||||
// http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2849349915&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1
|
||||
// 获取标签
|
||||
getTag(tryNum = 0) {
|
||||
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_tags = httpFetch(this.tagsUrl)
|
||||
return this._requestObj_tags!.promise.then(({ body }) => {
|
||||
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
||||
return this.filterTagInfo(body.tags.data.v_group)
|
||||
})
|
||||
},
|
||||
// 获取标签
|
||||
getHotTag(tryNum = 0) {
|
||||
if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_hotTags = httpFetch(this.hotTagUrl)
|
||||
return this._requestObj_hotTags!.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode !== 200) return this.getHotTag(++tryNum)
|
||||
return this.filterInfoHotTag(body)
|
||||
})
|
||||
},
|
||||
filterInfoHotTag(html) {
|
||||
let hotTag = html.match(this.regExps.hotTagHtml)
|
||||
const hotTags: any[] = []
|
||||
if (!hotTag) return hotTags
|
||||
|
||||
hotTag.forEach(tagHtml => {
|
||||
let result = tagHtml.match(this.regExps.hotTag)
|
||||
if (!result) return
|
||||
hotTags.push({
|
||||
id: parseInt(result[1]),
|
||||
name: result[2],
|
||||
source: 'tx',
|
||||
})
|
||||
})
|
||||
return hotTags
|
||||
},
|
||||
filterTagInfo(rawList) {
|
||||
return rawList.map(type => ({
|
||||
name: type.group_name,
|
||||
list: type.v_item.map(item => ({
|
||||
parent_id: type.group_id,
|
||||
parent_name: type.group_name,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
source: 'tx',
|
||||
})),
|
||||
}))
|
||||
},
|
||||
|
||||
// 获取列表数据
|
||||
getList(sortId, tagId, page, tryNum = 0) {
|
||||
if (this._requestObj_list) this._requestObj_list.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_list = httpFetch(
|
||||
this.getListUrl(sortId, tagId, page),
|
||||
)
|
||||
// console.log(this.getListUrl(sortId, tagId, page))
|
||||
return this._requestObj_list!.promise.then(({ body }) => {
|
||||
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
|
||||
return tagId ? this.filterList2(body.playlist.data, page) : this.filterList(body.playlist.data, page)
|
||||
})
|
||||
},
|
||||
|
||||
filterList(data, page) {
|
||||
return {
|
||||
list: data.v_playlist.map(item => ({
|
||||
play_count: formatPlayCount(item.access_num),
|
||||
id: String(item.tid),
|
||||
author: item.creator_info.nick,
|
||||
name: item.title,
|
||||
time: item.modify_time ? dateFormat(item.modify_time * 1000, 'Y-M-D') : '',
|
||||
img: item.cover_url_medium,
|
||||
// grade: item.favorcnt / 10,
|
||||
total: item.song_ids?.length,
|
||||
desc: decodeName(item.desc).replace(/<br>/g, '\n'),
|
||||
source: 'tx',
|
||||
})),
|
||||
total: data.total,
|
||||
page,
|
||||
limit: this.limit_list,
|
||||
source: 'tx',
|
||||
}
|
||||
},
|
||||
filterList2({ content }, page) {
|
||||
// console.log(content.v_item)
|
||||
return {
|
||||
list: content.v_item.map(({ basic }) => ({
|
||||
play_count: formatPlayCount(basic.play_cnt),
|
||||
id: String(basic.tid),
|
||||
author: basic.creator.nick,
|
||||
name: basic.title,
|
||||
// time: basic.publish_time,
|
||||
img: basic.cover.medium_url || basic.cover.default_url,
|
||||
// grade: basic.favorcnt / 10,
|
||||
desc: decodeName(basic.desc).replace(/<br>/g, '\n'),
|
||||
source: 'tx',
|
||||
})),
|
||||
total: content.total_cnt,
|
||||
page,
|
||||
limit: this.limit_list,
|
||||
source: 'tx',
|
||||
}
|
||||
},
|
||||
|
||||
async handleParseId(link, retryNum = 0) {
|
||||
if (retryNum > 2) return Promise.reject(new Error('link try max num'))
|
||||
|
||||
const requestObj_listDetailLink = httpFetch(link)
|
||||
const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise
|
||||
// console.log(headers)
|
||||
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
|
||||
return location == null ? link : location
|
||||
},
|
||||
|
||||
async getListId(id) {
|
||||
if ((/[?&:/]/.test(id))) {
|
||||
if (!this.regExps.listDetailLink.test(id)) {
|
||||
id = await this.handleParseId(id)
|
||||
}
|
||||
let result = this.regExps.listDetailLink.exec(id)
|
||||
if (!result) {
|
||||
result = this.regExps.listDetailLink2.exec(id)
|
||||
if (!result) throw new Error('failed')
|
||||
}
|
||||
id = result[1]
|
||||
// console.log(id)
|
||||
}
|
||||
return id
|
||||
},
|
||||
// 获取歌曲列表内的音乐
|
||||
async getListDetail(id, tryNum = 0) {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
id = await this.getListId(id)
|
||||
|
||||
const requestObj_listDetail = httpFetch(this.getListDetailUrl(id), {
|
||||
headers: {
|
||||
Origin: 'https://y.qq.com',
|
||||
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`,
|
||||
},
|
||||
})
|
||||
const { body } = await requestObj_listDetail.promise
|
||||
|
||||
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
|
||||
const cdlist = body.cdlist[0]
|
||||
return {
|
||||
list: this.filterListDetail(cdlist.songlist),
|
||||
page: 1,
|
||||
limit: cdlist.songlist.length + 1,
|
||||
total: cdlist.songlist.length,
|
||||
source: 'tx',
|
||||
info: {
|
||||
name: cdlist.dissname,
|
||||
img: cdlist.logo,
|
||||
desc: decodeName(cdlist.desc).replace(/<br>/g, '\n'),
|
||||
author: cdlist.nickname,
|
||||
play_count: formatPlayCount(cdlist.visitnum),
|
||||
},
|
||||
}
|
||||
},
|
||||
filterListDetail(rawList) {
|
||||
// console.log(rawList)
|
||||
return rawList.map(item => {
|
||||
let types: SongType[] = []
|
||||
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||
if (item.file.size_128mp3 !== 0) {
|
||||
let size = sizeFormate(item.file.size_128mp3)
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_320mp3 !== 0) {
|
||||
let size = sizeFormate(item.file.size_320mp3)
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_flac !== 0) {
|
||||
let size = sizeFormate(item.file.size_flac)
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
if (item.file.size_hires !== 0) {
|
||||
let size = sizeFormate(item.file.size_hires)
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
// types.reverse()
|
||||
return {
|
||||
singer: formatSingerName(item.singer, 'name'),
|
||||
name: item.title,
|
||||
albumName: item.album.name,
|
||||
albumId: item.album.mid,
|
||||
source: 'tx',
|
||||
interval: formatPlayTime(item.interval),
|
||||
songId: item.id,
|
||||
albumMid: item.album.mid,
|
||||
strMediaMid: item.file.media_mid,
|
||||
songmid: item.mid,
|
||||
img: (item.album.name === '' || item.album.name === '空')
|
||||
? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : ''
|
||||
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${item.album.mid}.jpg`,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
getTags() {
|
||||
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({ tags, hotTag, source: 'tx' }))
|
||||
},
|
||||
|
||||
async getDetailPageUrl(id) {
|
||||
id = await this.getListId(id)
|
||||
|
||||
return `https://y.qq.com/n/ryqq/playlist/${id}`
|
||||
},
|
||||
|
||||
search(text, page, limit = 20, retryNum = 0) {
|
||||
if (retryNum > 5) throw new Error('max retry')
|
||||
return httpFetch(`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${page - 1}&num_per_page=${limit}&format=json&query=${encodeURIComponent(text)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
|
||||
Referer: 'http://y.qq.com/portal/search.html',
|
||||
},
|
||||
})
|
||||
.promise.then(({ body }) => {
|
||||
if (body.code != 0) return this.search(text, page, limit, ++retryNum)
|
||||
// console.log(body.data.list)
|
||||
return {
|
||||
list: body.data.list.map(item => {
|
||||
return {
|
||||
play_count: formatPlayCount(item.listennum),
|
||||
id: String(item.dissid),
|
||||
author: decodeName(item.creator.name),
|
||||
name: decodeName(item.dissname),
|
||||
time: dateFormat(item.createtime, 'Y-M-D'),
|
||||
img: item.imgurl,
|
||||
// grade: item.favorcnt / 10,
|
||||
total: item.song_count,
|
||||
desc: decodeName(decodeName(item.introduction)).replace(/<br>/g, '\n'),
|
||||
source: 'tx',
|
||||
}
|
||||
}),
|
||||
limit,
|
||||
total: body.data.sum,
|
||||
source: 'tx',
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// getList
|
||||
// getTags
|
||||
// getListDetail
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
|
||||
export default {
|
||||
// regExps: {
|
||||
// relWord: /RELWORD=(.+)/,
|
||||
// },
|
||||
requestObj: null as HttpRequestObject | null,
|
||||
tipSearch(str) {
|
||||
this.cancelTipSearch()
|
||||
this.requestObj = httpFetch(`https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?is_xml=0&format=json&key=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0`, {
|
||||
headers: {
|
||||
Referer: 'https://y.qq.com/portal/player.html',
|
||||
},
|
||||
})
|
||||
return this.requestObj.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.code != 0) return Promise.reject(new Error('请求失败'))
|
||||
return body.data
|
||||
})
|
||||
},
|
||||
handleResult(rawData) {
|
||||
return rawData.map(info => `${info.name} - ${info.singer}`)
|
||||
},
|
||||
cancelTipSearch() {
|
||||
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
|
||||
},
|
||||
async search(str) {
|
||||
return this.tipSearch(str).then(result => this.handleResult(result.song.itemlist))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19]
|
||||
const PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5]
|
||||
const SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
|
||||
|
||||
async function hashSHA1(data) {
|
||||
return crypto.createHash('sha1').update(data).digest('hex')
|
||||
}
|
||||
|
||||
function pickHashByIdx(hash, indexes) {
|
||||
return indexes.map((idx) => hash[idx]).join('')
|
||||
}
|
||||
|
||||
function base64Encode(data) {
|
||||
return Buffer.from(data)
|
||||
.toString('base64')
|
||||
.replace(/[\\/+=]/g, '')
|
||||
}
|
||||
|
||||
export async function zzcSign(text) {
|
||||
const hash = await hashSHA1(text)
|
||||
const part1 = pickHashByIdx(hash, PART_1_INDEXES)
|
||||
const part2 = pickHashByIdx(hash, PART_2_INDEXES)
|
||||
const part3 = SCRAMBLE_VALUES.map((value, i) => value ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16))
|
||||
const b64Part = base64Encode(part3).replace(/[\\/+=]/g, '')
|
||||
return `zzc${part1}${b64Part}${part2}`.toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { HttpRequestObject } from '../../types.ts'
|
||||
|
||||
import { httpFetch } from '../../request.ts'
|
||||
import { zzcSign } from './crypto.ts'
|
||||
|
||||
export const signRequest = async(data) => {
|
||||
// console.log(data)
|
||||
const sign = await zzcSign(JSON.stringify(data))
|
||||
// console.log('sign', sign)
|
||||
return httpFetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'QQMusic 14090508(android 12)',
|
||||
},
|
||||
body: data,
|
||||
}).promise
|
||||
}
|
||||
|
||||
/**
|
||||
* musicu.fcg 通用请求(comm 参数对齐榜单模块的可用配置;
|
||||
* musichallSinger/musichallAlbum 的部分详情接口在该通道已停用,见各模块说明)
|
||||
*/
|
||||
export const createMusicuFetch = async(data, retryNum = 0) => {
|
||||
if (retryNum > 2) throw new Error('try max num')
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await 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)',
|
||||
},
|
||||
body: {
|
||||
comm: { uin: 0, format: 'json', ct: 20, cv: 1859 },
|
||||
...data,
|
||||
},
|
||||
}).promise
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return createMusicuFetch(data, ++retryNum)
|
||||
}
|
||||
if (result.statusCode !== 200 || result.body.code != 0) return createMusicuFetch(data, ++retryNum)
|
||||
|
||||
return result.body
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// SDK 共享类型定义
|
||||
// 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。
|
||||
|
||||
/** 音乐平台标识 */
|
||||
export type Source = 'kw' | 'kg' | 'tx' | 'wy'
|
||||
|
||||
/** 音质标识(wav 仅部分接口声明使用) */
|
||||
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
|
||||
|
||||
/** 单音质资源描述(搜索结果 types 数组项) */
|
||||
export interface SongType {
|
||||
type: Quality
|
||||
size: string
|
||||
}
|
||||
|
||||
/** 单音质详情(搜索结果 _types 映射项) */
|
||||
export interface QualityInfo {
|
||||
size: string
|
||||
}
|
||||
|
||||
/** 歌曲信息(搜索结果项 / 取流入参) */
|
||||
export interface SongInfo {
|
||||
songmid?: string
|
||||
hash?: string
|
||||
copyrightId?: string
|
||||
songStringId?: string
|
||||
albumId?: string
|
||||
name?: string | null
|
||||
singer?: string
|
||||
source?: Source
|
||||
interval?: string | number
|
||||
albumName?: string
|
||||
img?: string | null
|
||||
lrc?: string | null
|
||||
otherSource?: string | null
|
||||
types?: SongType[]
|
||||
_types?: Partial<Record<Quality, QualityInfo>>
|
||||
typeUrl?: Partial<Record<Quality, string>>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** httpFetch 响应(与 common/request.ts 契约一致) */
|
||||
export interface HttpResponse {
|
||||
statusCode: number
|
||||
statusMessage?: string
|
||||
headers: Record<string, string | string[]>
|
||||
body: any
|
||||
raw?: Buffer
|
||||
}
|
||||
|
||||
/** httpFetch 返回的可取消请求对象 */
|
||||
export interface HttpRequestObject {
|
||||
promise: Promise<HttpResponse>
|
||||
cancelHttp(): void
|
||||
}
|
||||
|
||||
/** httpFetch 选项(common/request.ts 子集) */
|
||||
export interface HttpOptions {
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: string | object
|
||||
form?: Record<string, unknown>
|
||||
timeout?: number
|
||||
lookup?: unknown
|
||||
family?: number
|
||||
}
|
||||
|
||||
/** 带取消句柄的异步结果(getMusicUrl/getLyric 等的统一返回形态) */
|
||||
export interface RequestObj<T> {
|
||||
promise: Promise<T>
|
||||
cancelHttp(): void
|
||||
}
|
||||
|
||||
/** 取流结果 */
|
||||
export interface MusicUrlResult {
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 歌词结果 */
|
||||
export interface LyricInfo {
|
||||
lyric: string
|
||||
tlyric: string
|
||||
rlyric?: string
|
||||
yrc?: string
|
||||
}
|
||||
|
||||
/** 搜索结果 */
|
||||
export interface SearchResult {
|
||||
list: SongInfo[]
|
||||
allPage: number
|
||||
total: number
|
||||
limit: number
|
||||
source: Source
|
||||
}
|
||||
|
||||
/** 搜索模块(平台 musicSearch 子模块) */
|
||||
export interface MusicSearchModule {
|
||||
search(str: string, page?: number, limit?: number, retryNum?: number): Promise<SearchResult>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 歌词模块 */
|
||||
export interface LyricModule {
|
||||
getLyric(songInfo: SongInfo, ...rest: unknown[]): Promise<LyricInfo> | RequestObj<LyricInfo>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 封面模块 */
|
||||
export interface PicModule {
|
||||
getPic(songInfo: SongInfo, ...rest: unknown[]): Promise<string> | RequestObj<string>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 热搜模块 */
|
||||
export interface HotSearchModule {
|
||||
getList(): Promise<any>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 评论模块 */
|
||||
export interface CommentModule {
|
||||
getComment(songInfo: any, page?: number, limit?: number): Promise<any>
|
||||
getHotComment(songInfo: any, page?: number, limit?: number): Promise<any>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 平台模块(各平台默认导出需满足的最小形状)
|
||||
* 注:SDK 内部实现大量返回 httpFetch 原生的 HttpRequestObject,
|
||||
* 实际 promise 会在 .then() 中被改写为对应结果类型;此处只约束调用侧可见形态。
|
||||
*/
|
||||
export interface PlatformModule {
|
||||
init?(): Promise<unknown> | void
|
||||
musicSearch?: MusicSearchModule
|
||||
leaderboard?: any
|
||||
songList?: any
|
||||
hotSearch?: HotSearchModule
|
||||
comment?: CommentModule
|
||||
tipSearch?: any
|
||||
getMusicUrl?(songInfo: SongInfo, type: Quality): HttpRequestObject
|
||||
getLyric?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject
|
||||
getPic?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject | Promise<any>
|
||||
getMusicInfo?(songInfo: SongInfo): HttpRequestObject | Promise<any>
|
||||
getMusicDetailPageUrl?(songInfo: SongInfo): string
|
||||
[key: string]: any
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import crypto from 'crypto'
|
||||
import dns from 'dns'
|
||||
import { decodeName } from './common/utils.ts'
|
||||
|
||||
export const toMD5 = (str: string): string => crypto.createHash('md5').update(str).digest('hex')
|
||||
|
||||
interface CachedIp {
|
||||
address: string
|
||||
family: number
|
||||
}
|
||||
|
||||
const ipMap = new Map<string, CachedIp | true>()
|
||||
|
||||
export const getHostIp = (hostname: string): CachedIp | undefined => {
|
||||
const result = ipMap.get(hostname)
|
||||
if (typeof result === 'object') return result
|
||||
if (result === true) return
|
||||
ipMap.set(hostname, true)
|
||||
dns.lookup(hostname, {
|
||||
// family: 4,
|
||||
all: false,
|
||||
}, (err, address, family) => {
|
||||
if (err) return console.log(err)
|
||||
ipMap.set(hostname, { address, family })
|
||||
})
|
||||
}
|
||||
|
||||
export const dnsLookup = (
|
||||
hostname: string,
|
||||
options: dns.LookupOptions,
|
||||
callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
|
||||
): void => {
|
||||
const result = getHostIp(hostname)
|
||||
if (result) return callback(null, result.address, result.family)
|
||||
|
||||
dns.lookup(hostname, options, callback as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化歌手
|
||||
* @param singers 歌手数组或字符串
|
||||
* @param nameKey 歌手名键值
|
||||
* @param join 歌手分割字符
|
||||
*/
|
||||
export const formatSingerName = (
|
||||
singers: Array<Record<string, unknown>> | string | null | undefined,
|
||||
nameKey = 'name',
|
||||
join = '、',
|
||||
): string => {
|
||||
if (Array.isArray(singers)) {
|
||||
const singer: string[] = []
|
||||
singers.forEach(item => {
|
||||
const name = item[nameKey]
|
||||
if (!name) return
|
||||
singer.push(String(name))
|
||||
})
|
||||
return decodeName(singer.join(join))
|
||||
}
|
||||
return decodeName(String(singers ?? ''))
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { dateFormat2 } from '../index.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
const emojis = [
|
||||
['大笑', '😃'],
|
||||
['可爱', '😊'],
|
||||
['憨笑', '☺️'],
|
||||
['色', '😍'],
|
||||
['亲亲', '😙'],
|
||||
['惊恐', '😱'],
|
||||
['流泪', '😭'],
|
||||
['亲', '😚'],
|
||||
['呆', '😳'],
|
||||
['哀伤', '😔'],
|
||||
['呲牙', '😁'],
|
||||
['吐舌', '😝'],
|
||||
['撇嘴', '😒'],
|
||||
['怒', '😡'],
|
||||
['奸笑', '😏'],
|
||||
['汗', '😓'],
|
||||
['痛苦', '😖'],
|
||||
['惶恐', '😰'],
|
||||
['生病', '😨'],
|
||||
['口罩', '😷'],
|
||||
['大哭', '😂'],
|
||||
['晕', '😵'],
|
||||
['发怒', '👿'],
|
||||
['开心', '😄'],
|
||||
['鬼脸', '😜'],
|
||||
['皱眉', '😞'],
|
||||
['流感', '😢'],
|
||||
['爱心', '❤️'],
|
||||
['心碎', '💔'],
|
||||
['钟情', '💘'],
|
||||
['星星', '⭐️'],
|
||||
['生气', '💢'],
|
||||
['便便', '💩'],
|
||||
['强', '👍'],
|
||||
['弱', '👎'],
|
||||
['拜', '🙏'],
|
||||
['牵手', '👫'],
|
||||
['跳舞', '👯♀️'],
|
||||
['禁止', '🙅♀️'],
|
||||
['这边', '💁♀️'],
|
||||
['爱意', '💏'],
|
||||
['示爱', '👩❤️👨'],
|
||||
['嘴唇', '👄'],
|
||||
['狗', '🐶'],
|
||||
['猫', '🐱'],
|
||||
['猪', '🐷'],
|
||||
['兔子', '🐰'],
|
||||
['小鸡', '🐤'],
|
||||
['公鸡', '🐔'],
|
||||
['幽灵', '👻'],
|
||||
['圣诞', '🎅'],
|
||||
['外星', '👽'],
|
||||
['钻石', '💎'],
|
||||
['礼物', '🎁'],
|
||||
['男孩', '👦'],
|
||||
['女孩', '👧'],
|
||||
['蛋糕', '🎂'],
|
||||
['18', '🔞'],
|
||||
['圈', '⭕'],
|
||||
['叉', '❌'],
|
||||
]
|
||||
|
||||
const applyEmoji = (text: string): string => {
|
||||
for (const e of emojis) text = text.replaceAll(`[${e[0]}]`, e[1])
|
||||
return text
|
||||
}
|
||||
|
||||
let cursorTools = {
|
||||
cache: {} as Record<string, {
|
||||
page?: number
|
||||
prevCursor?: number
|
||||
cursor?: number
|
||||
orderType?: number
|
||||
offset?: number
|
||||
}>,
|
||||
getCursor(id, page, limit) {
|
||||
let cacheData = this.cache[id]
|
||||
if (!cacheData) cacheData = this.cache[id] = {}
|
||||
let orderType
|
||||
let cursor
|
||||
let offset
|
||||
if (page == 1) {
|
||||
cacheData.page = 1
|
||||
cursor = cacheData.cursor = cacheData.prevCursor = Date.now()
|
||||
orderType = 1
|
||||
offset = 0
|
||||
} else if (cacheData.page) {
|
||||
cursor = cacheData.cursor
|
||||
if (page > cacheData.page) {
|
||||
orderType = 1
|
||||
offset = (page - cacheData.page - 1) * limit
|
||||
} else if (page < cacheData.page) {
|
||||
orderType = 0
|
||||
offset = (cacheData.page - page - 1) * limit
|
||||
} else {
|
||||
cursor = cacheData.cursor = cacheData.prevCursor
|
||||
offset = cacheData.offset
|
||||
orderType = cacheData.orderType
|
||||
}
|
||||
}
|
||||
return {
|
||||
orderType,
|
||||
cursor,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
setCursor(id, cursor, orderType, offset, page) {
|
||||
let cacheData = this.cache[id]
|
||||
if (!cacheData) cacheData = this.cache[id] = {}
|
||||
cacheData.prevCursor = cacheData.cursor
|
||||
cacheData.cursor = cursor
|
||||
cacheData.orderType = orderType
|
||||
cacheData.offset = offset
|
||||
cacheData.page = page
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
_requestObj2: null as HttpRequestObject | null,
|
||||
async getComment({ songmid }, page = 1, limit = 20) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
|
||||
const id = 'R_SO_4_' + songmid
|
||||
|
||||
const cursorInfo = cursorTools.getCursor(songmid, page, limit)
|
||||
|
||||
const _requestObj = httpFetch('https://music.163.com/weapi/comment/resource/comments/get', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
Refere: 'http://music.163.com/',
|
||||
},
|
||||
form: weapi({
|
||||
cursor: cursorInfo.cursor,
|
||||
offset: cursorInfo.offset,
|
||||
orderType: cursorInfo.orderType,
|
||||
pageNo: page,
|
||||
pageSize: limit,
|
||||
rid: id,
|
||||
threadId: id,
|
||||
}),
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
// console.log(body)
|
||||
if (statusCode != 200 || body.code !== 200) throw new Error('获取评论失败')
|
||||
cursorTools.setCursor(songmid, body.data.cursor, cursorInfo.orderType, cursorInfo.offset, page)
|
||||
return { source: 'wy', comments: this.filterComment(body.data.comments), total: body.data.totalCount, page, limit, maxPage: Math.ceil(body.data.totalCount / limit) || 1 }
|
||||
},
|
||||
async getHotComment({ songmid }, page = 1, limit = 100) {
|
||||
if (this._requestObj2) this._requestObj2.cancelHttp()
|
||||
|
||||
const id = 'R_SO_4_' + songmid
|
||||
page = page - 1
|
||||
|
||||
const _requestObj2 = httpFetch(`https://music.163.com/weapi/v1/resource/hotcomments/${id}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
Refere: 'http://music.163.com/',
|
||||
},
|
||||
form: weapi({
|
||||
rid: id,
|
||||
limit,
|
||||
offset: limit * page,
|
||||
beforeTime: Date.now().toString(),
|
||||
}),
|
||||
})
|
||||
const { body, statusCode } = await _requestObj2.promise
|
||||
if (statusCode != 200 || body.code !== 200) throw new Error('获取热门评论失败')
|
||||
const total = body.total ?? 0
|
||||
return { source: 'wy', comments: this.filterComment(body.hotComments), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
|
||||
},
|
||||
filterComment(rawList) {
|
||||
return rawList.map(item => {
|
||||
let data = {
|
||||
id: item.commentId,
|
||||
text: item.content ? applyEmoji(item.content) : '',
|
||||
time: item.time ? item.time : '',
|
||||
timeStr: item.time ? dateFormat2(item.time) : '',
|
||||
location: item.ipLocation?.location,
|
||||
userName: item.user.nickname,
|
||||
avatar: item.user.avatarUrl,
|
||||
userId: item.user.userId,
|
||||
likedCount: item.likedCount,
|
||||
reply: [],
|
||||
}
|
||||
|
||||
let replyData = item.beReplied && item.beReplied[0]
|
||||
return replyData
|
||||
? {
|
||||
id: item.commentId,
|
||||
rootId: replyData.beRepliedCommentId,
|
||||
text: replyData.content ? applyEmoji(replyData.content) : '',
|
||||
time: item.time,
|
||||
timeStr: null,
|
||||
location: replyData.ipLocation?.location,
|
||||
userName: replyData.user.nickname,
|
||||
avatar: replyData.user.avatarUrl,
|
||||
userId: replyData.user.userId,
|
||||
likedCount: null,
|
||||
reply: [data],
|
||||
}
|
||||
: data
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { eapiRequest } from './utils/index.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
_requestObj: null as HttpRequestObject | null,
|
||||
async getList(retryNum = 0) {
|
||||
if (this._requestObj) this._requestObj.cancelHttp()
|
||||
if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
const _requestObj = eapiRequest('/api/search/chart/detail', {
|
||||
id: 'HOT_SEARCH_SONG#@#',
|
||||
})
|
||||
const { body, statusCode } = await _requestObj.promise
|
||||
if (statusCode != 200 || body.code !== 200) throw new Error('获取热搜词失败')
|
||||
|
||||
return { source: 'wy', list: this.filterList(body.data.itemList) }
|
||||
},
|
||||
filterList(rawList) {
|
||||
return rawList.map(item => item.searchWord)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import leaderboard from './leaderboard.ts'
|
||||
import { apis } from '../api-source.ts'
|
||||
import getLyric from './lyric.ts'
|
||||
import getMusicInfo from './musicInfo.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import songList from './songList.ts'
|
||||
import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
// import tipSearch from './tipSearch.ts'
|
||||
|
||||
const wy = {
|
||||
// tipSearch,
|
||||
leaderboard,
|
||||
musicSearch,
|
||||
songList,
|
||||
hotSearch,
|
||||
comment,
|
||||
singer,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return apis('wy').getMusicUrl!(songInfo, type)
|
||||
},
|
||||
getLyric(songInfo) {
|
||||
return getLyric(songInfo.songmid)
|
||||
},
|
||||
getPic(songInfo) {
|
||||
const requestObj = getMusicInfo(songInfo.songmid)
|
||||
return requestObj.promise.then(info => info.al.picUrl)
|
||||
},
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
return `https://music.163.com/#/song?id=${songInfo.songmid}`
|
||||
},
|
||||
}
|
||||
|
||||
export default wy
|
||||
@@ -0,0 +1,209 @@
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import musicDetailApi from './musicDetail.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
const topList = [{ id: 'wy__19723756', name: '飙升榜', bangid: '19723756' },
|
||||
{ id: 'wy__3779629', name: '新歌榜', bangid: '3779629' },
|
||||
{ id: 'wy__2884035', name: '原创榜', bangid: '2884035' },
|
||||
{ id: 'wy__3778678', name: '热歌榜', bangid: '3778678' },
|
||||
{ id: 'wy__991319590', name: '说唱榜', bangid: '991319590' },
|
||||
{ id: 'wy__71384707', name: '古典榜', bangid: '71384707' },
|
||||
{ id: 'wy__1978921795', name: '电音榜', bangid: '1978921795' },
|
||||
{ id: 'wy__5453912201', name: '黑胶VIP爱听榜', bangid: '5453912201' },
|
||||
{ id: 'wy__71385702', name: 'ACG榜', bangid: '71385702' },
|
||||
{ id: 'wy__745956260', name: '韩语榜', bangid: '745956260' },
|
||||
{ id: 'wy__10520166', name: '国电榜', bangid: '10520166' },
|
||||
{ id: 'wy__180106', name: 'UK排行榜周榜', bangid: '180106' },
|
||||
{ id: 'wy__60198', name: '美国Billboard榜', bangid: '60198' },
|
||||
{ id: 'wy__3812895', name: 'Beatport全球电子舞曲榜', bangid: '3812895' },
|
||||
{ id: 'wy__21845217', name: 'KTV唛榜', bangid: '21845217' },
|
||||
{ id: 'wy__60131', name: '日本Oricon榜', bangid: '60131' },
|
||||
{ id: 'wy__2809513713', name: '欧美热歌榜', bangid: '2809513713' },
|
||||
{ id: 'wy__2809577409', name: '欧美新歌榜', bangid: '2809577409' },
|
||||
{ id: 'wy__27135204', name: '法国 NRJ Vos Hits 周榜', bangid: '27135204' },
|
||||
{ id: 'wy__3001835560', name: 'ACG动画榜', bangid: '3001835560' },
|
||||
{ id: 'wy__3001795926', name: 'ACG游戏榜', bangid: '3001795926' },
|
||||
{ id: 'wy__3001890046', name: 'ACG VOCALOID榜', bangid: '3001890046' },
|
||||
{ id: 'wy__3112516681', name: '中国新乡村音乐排行榜', bangid: '3112516681' },
|
||||
{ id: 'wy__5059644681', name: '日语榜', bangid: '5059644681' },
|
||||
{ id: 'wy__5059633707', name: '摇滚榜', bangid: '5059633707' },
|
||||
{ id: 'wy__5059642708', name: '国风榜', bangid: '5059642708' },
|
||||
{ id: 'wy__5338990334', name: '潜力爆款榜', bangid: '5338990334' },
|
||||
{ id: 'wy__5059661515', name: '民谣榜', bangid: '5059661515' },
|
||||
{ id: 'wy__6688069460', name: '听歌识曲榜', bangid: '6688069460' },
|
||||
{ id: 'wy__6723173524', name: '网络热歌榜', bangid: '6723173524' },
|
||||
{ id: 'wy__6732051320', name: '俄语榜', bangid: '6732051320' },
|
||||
{ id: 'wy__6732014811', name: '越南语榜', bangid: '6732014811' },
|
||||
{ id: 'wy__6886768100', name: '中文DJ榜', bangid: '6886768100' },
|
||||
{ id: 'wy__6939992364', name: '俄罗斯top hit流行音乐榜', bangid: '6939992364' },
|
||||
{ id: 'wy__7095271308', name: '泰语榜', bangid: '7095271308' },
|
||||
{ id: 'wy__7356827205', name: 'BEAT排行榜', bangid: '7356827205' },
|
||||
{ id: 'wy__7325478166', name: '编辑推荐榜VOL.44 天才女子摇滚乐队boygenius剖白卑微心迹', bangid: '7325478166' },
|
||||
{ id: 'wy__7603212484', name: 'LOOK直播歌曲榜', bangid: '7603212484' },
|
||||
{ id: 'wy__7775163417', name: '赏音榜', bangid: '7775163417' },
|
||||
{ id: 'wy__7785123708', name: '黑胶VIP新歌榜', bangid: '7785123708' },
|
||||
{ id: 'wy__7785066739', name: '黑胶VIP热歌榜', bangid: '7785066739' },
|
||||
{ id: 'wy__7785091694', name: '黑胶VIP爱搜榜', bangid: '7785091694' },
|
||||
]
|
||||
|
||||
export default {
|
||||
limit: 100000,
|
||||
list: [
|
||||
{
|
||||
id: 'wybsb',
|
||||
name: '飙升榜',
|
||||
bangid: '19723756',
|
||||
},
|
||||
{
|
||||
id: 'wyrgb',
|
||||
name: '热歌榜',
|
||||
bangid: '3778678',
|
||||
},
|
||||
{
|
||||
id: 'wyxgb',
|
||||
name: '新歌榜',
|
||||
bangid: '3779629',
|
||||
},
|
||||
{
|
||||
id: 'wyycb',
|
||||
name: '原创榜',
|
||||
bangid: '2884035',
|
||||
},
|
||||
{
|
||||
id: 'wygdb',
|
||||
name: '古典榜',
|
||||
bangid: '71384707',
|
||||
},
|
||||
{
|
||||
id: 'wydouyb',
|
||||
name: '抖音榜',
|
||||
bangid: '2250011882',
|
||||
},
|
||||
{
|
||||
id: 'wyhyb',
|
||||
name: '韩语榜',
|
||||
bangid: '745956260',
|
||||
},
|
||||
{
|
||||
id: 'wydianyb',
|
||||
name: '电音榜',
|
||||
bangid: '1978921795',
|
||||
},
|
||||
{
|
||||
id: 'wydjb',
|
||||
name: '电竞榜',
|
||||
bangid: '2006508653',
|
||||
},
|
||||
{
|
||||
id: 'wyktvbb',
|
||||
name: 'KTV唛榜',
|
||||
bangid: '21845217',
|
||||
},
|
||||
],
|
||||
getUrl(id) {
|
||||
return `https://music.163.com/discover/toplist?id=${id}`
|
||||
},
|
||||
regExps: {
|
||||
list: /<textarea id="song-list-pre-data" style="display:none;">(.+?)<\/textarea>/,
|
||||
},
|
||||
_requestBoardsObj: null as HttpRequestObject | null,
|
||||
getBoardsData() {
|
||||
if (this._requestBoardsObj) this._requestBoardsObj.cancelHttp()
|
||||
this._requestBoardsObj = httpFetch('https://music.163.com/weapi/toplist', {
|
||||
method: 'post',
|
||||
form: weapi({}),
|
||||
})
|
||||
return this._requestBoardsObj!.promise
|
||||
},
|
||||
getData(id) {
|
||||
const requestBoardsDetailObj = httpFetch('https://music.163.com/weapi/v3/playlist/detail', {
|
||||
method: 'post',
|
||||
form: weapi({
|
||||
id,
|
||||
n: 100000,
|
||||
p: 1,
|
||||
}),
|
||||
})
|
||||
return requestBoardsDetailObj.promise
|
||||
},
|
||||
|
||||
filterBoardsData(rawList) {
|
||||
// console.log(rawList)
|
||||
let list: any[] = []
|
||||
for (const board of rawList) {
|
||||
// 排除 MV榜
|
||||
// if (board.id == 201) continue
|
||||
list.push({
|
||||
id: 'wy__' + board.id,
|
||||
name: board.name,
|
||||
bangid: String(board.id),
|
||||
})
|
||||
}
|
||||
return list
|
||||
},
|
||||
async getBoards(retryNum = 0) {
|
||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
// let response
|
||||
// try {
|
||||
// response = await this.getBoardsData()
|
||||
// } catch (error) {
|
||||
// return this.getBoards(retryNum)
|
||||
// }
|
||||
// console.log(response.body)
|
||||
// if (response.statusCode !== 200 || response.body.code !== 200) return this.getBoards(retryNum)
|
||||
// const list = this.filterBoardsData(response.body.list)
|
||||
// console.log(list)
|
||||
// console.log(JSON.stringify(list))
|
||||
// this.list = list
|
||||
// return {
|
||||
// list,
|
||||
// source: 'wy',
|
||||
// }
|
||||
this.list = topList
|
||||
return {
|
||||
list: topList,
|
||||
source: 'wy',
|
||||
}
|
||||
},
|
||||
async getList(bangid, page, retryNum = 0) {
|
||||
if (++retryNum > 6) return Promise.reject(new Error('try max num'))
|
||||
// console.log(bangid)
|
||||
let resp
|
||||
try {
|
||||
resp = await this.getData(bangid)
|
||||
} catch (err: any) {
|
||||
if (err.message == 'try max num') {
|
||||
throw err
|
||||
} else {
|
||||
return this.getList(bangid, page, retryNum)
|
||||
}
|
||||
}
|
||||
if (resp.statusCode !== 200 || resp.body.code !== 200) return this.getList(bangid, page, retryNum)
|
||||
// console.log(resp.body)
|
||||
let musicDetail
|
||||
try {
|
||||
musicDetail = await (musicDetailApi as any).getList(resp.body.playlist.trackIds.map(trackId => trackId.id))
|
||||
} catch (err: any) {
|
||||
console.log(err)
|
||||
if (err.message == 'try max num') {
|
||||
throw err
|
||||
} else {
|
||||
return this.getList(bangid, page, retryNum)
|
||||
}
|
||||
}
|
||||
// console.log(musicDetail)
|
||||
return {
|
||||
total: musicDetail.list.length,
|
||||
list: musicDetail.list,
|
||||
limit: this.limit,
|
||||
page,
|
||||
source: 'wy',
|
||||
}
|
||||
},
|
||||
|
||||
getDetailPageUrl(id) {
|
||||
if (typeof id == 'string') id = id.replace('wy__', '')
|
||||
return `https://music.163.com/#/discover/toplist?id=${id}`
|
||||
},
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { eapi } from './utils/crypto.ts'
|
||||
import type { LyricInfo, HttpRequestObject } from '../types.ts'
|
||||
// import { decodeName } from '../...ts'
|
||||
|
||||
// const parseLyric = (str, lrc) => {
|
||||
// if (!str) return ''
|
||||
|
||||
// str = str.replace(/\r/g, '')
|
||||
|
||||
// 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}`
|
||||
// str = str.replace(result[1], time)
|
||||
|
||||
// let startTime = 0
|
||||
// str = str.replace(/\(0,1\) /g, ' ').replace(/\(\d+,\d+\)/g, time => {
|
||||
// const [start, end] = time.replace(/^\((\d+,\d+)\)$/, '$1').split(',')
|
||||
|
||||
// time = `<${parseInt(startTime + parseInt(start))},${end}>`
|
||||
// startTime = parseInt(startTime + parseInt(end))
|
||||
// return time
|
||||
// })
|
||||
|
||||
// return str
|
||||
// })
|
||||
|
||||
// yrc = decodeName(yrc)
|
||||
// return yrc.trim()
|
||||
// }
|
||||
|
||||
const eapiRequest = (url: string, data: object) => {
|
||||
return httpFetch('https://interface3.music.163.com/eapi/song/lyric/v1', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
// cookie: 'os=pc; deviceId=A9C064BB4584D038B1565B58CB05F95290998EE8B025AA2D07AE; osver=Microsoft-Windows-10-Home-China-build-19043-64bit; appver=2.5.2.197409; channel=netease; MUSIC_A=37a11f2eb9de9930cad479b2ad495b0e4c982367fb6f909d9a3f18f876c6b49faddb3081250c4980dd7e19d4bd9bf384e004602712cf2b2b8efaafaab164268a00b47359f85f22705cc95cb6180f3aee40f5be1ebf3148d888aa2d90636647d0c3061cd18d77b7a0; __csrf=05b50d54082694f945d7de75c210ef94; mode=Z7M-KP5(7)GZ; NMTID=00OZLp2VVgq9QdwokUgq3XNfOddQyIAAAF_6i8eJg; ntes_kaola_ad=1',
|
||||
},
|
||||
form: eapi(url, data),
|
||||
})
|
||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||
// // console.log(raw)
|
||||
// console.log(body)
|
||||
// // console.log(eapiDecrypt(raw))
|
||||
// // return eapiDecrypt(raw)
|
||||
// return body
|
||||
// })
|
||||
// return requestObj
|
||||
}
|
||||
|
||||
const parseTools = {
|
||||
rxps: {
|
||||
info: /^{"/,
|
||||
lineTime: /^\[(\d+),\d+\]/,
|
||||
wordTime: /\(\d+,\d+,\d+\)/,
|
||||
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
|
||||
},
|
||||
msFormat(timeMs) {
|
||||
if (Number.isNaN(timeMs)) return ''
|
||||
let ms = timeMs % 1000
|
||||
timeMs /= 1000
|
||||
let m = parseInt((timeMs / 60) as any).toString().padStart(2, '0')
|
||||
timeMs %= 60
|
||||
let s = parseInt(timeMs as any).toString().padStart(2, '0')
|
||||
return `[${m}:${s}.${ms}]`
|
||||
},
|
||||
parseLyric(lines) {
|
||||
const yrcLines: string[] = []
|
||||
const lrcLines: string[] = []
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim()
|
||||
let result = this.rxps.lineTime.exec(line)
|
||||
if (!result) {
|
||||
if (line.startsWith('[offset')) {
|
||||
yrcLines.push(line)
|
||||
lrcLines.push(line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const startMsTime = parseInt(result[1])
|
||||
const startTimeStr = this.msFormat(startMsTime)
|
||||
if (!startTimeStr) continue
|
||||
|
||||
let words = line.replace(this.rxps.lineTime, '')
|
||||
|
||||
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
|
||||
|
||||
let times = words.match(this.rxps.wordTimeAll)
|
||||
if (!times) continue
|
||||
times = times.map(time => {
|
||||
const result = /\((\d+),(\d+),\d+\)/.exec(time)
|
||||
return `<${Math.max(parseInt(result![1]) - startMsTime, 0)},${result![2]}>`
|
||||
})
|
||||
const wordArr = words.split(this.rxps.wordTime)
|
||||
wordArr.shift()
|
||||
const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('')
|
||||
yrcLines.push(`${startTimeStr}${newWords}`)
|
||||
}
|
||||
return {
|
||||
lyric: lrcLines.join('\n'),
|
||||
yrc: yrcLines.join('\n'),
|
||||
}
|
||||
},
|
||||
parseHeaderInfo(str) {
|
||||
str = str.trim()
|
||||
str = str.replace(/\r/g, '')
|
||||
if (!str) return null
|
||||
const lines = str.split('\n')
|
||||
return lines.map(line => {
|
||||
if (!this.rxps.info.test(line)) return line
|
||||
try {
|
||||
const info = JSON.parse(line)
|
||||
const timeTag = this.msFormat(info.t)
|
||||
return timeTag ? `${timeTag}${info.c.map(t => t.tx).join('')}` : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
},
|
||||
getIntv(interval) {
|
||||
if (!interval) return 0
|
||||
if (!interval.includes('.')) interval += '.0'
|
||||
let arr = interval.split(/:|\./)
|
||||
while (arr.length < 3) arr.unshift('0')
|
||||
const [m, s, ms] = arr
|
||||
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
|
||||
},
|
||||
fixTimeTag(lrc, targetlrc) {
|
||||
let lrcLines = lrc.split('\n')
|
||||
const targetlrcLines = targetlrc.split('\n')
|
||||
const timeRxp = /^\[([\d:.]+)\]/
|
||||
let temp: string[] = []
|
||||
let newLrc: string[] = []
|
||||
targetlrcLines.forEach((line) => {
|
||||
const result = timeRxp.exec(line)
|
||||
if (!result) return
|
||||
const words = line.replace(timeRxp, '')
|
||||
if (!words.trim()) return
|
||||
const t1 = this.getIntv(result[1])
|
||||
|
||||
while (lrcLines.length) {
|
||||
const lrcLine = lrcLines.shift()!
|
||||
const lrcLineResult = timeRxp.exec(lrcLine)
|
||||
if (!lrcLineResult) continue
|
||||
const t2 = this.getIntv(lrcLineResult[1])
|
||||
if (Math.abs(t1 - t2) < 100) {
|
||||
const lrc = line.replace(timeRxp, lrcLineResult[0]).trim()
|
||||
if (!lrc) continue
|
||||
newLrc.push(lrc)
|
||||
break
|
||||
}
|
||||
temp.push(lrcLine)
|
||||
}
|
||||
lrcLines = [...temp, ...lrcLines]
|
||||
temp = []
|
||||
})
|
||||
return newLrc.join('\n')
|
||||
},
|
||||
parse(ylrc, ytlrc, yrlrc, lrc, tlrc, rlrc) {
|
||||
const info = {
|
||||
lyric: '',
|
||||
tlyric: '',
|
||||
rlyric: '',
|
||||
yrc: '',
|
||||
}
|
||||
if (ylrc) {
|
||||
let lines = this.parseHeaderInfo(ylrc)
|
||||
if (lines) {
|
||||
const result = this.parseLyric(lines)
|
||||
if (ytlrc) {
|
||||
const lines = this.parseHeaderInfo(ytlrc)
|
||||
if (lines) {
|
||||
// if (lines.length == result.lyricLines.length) {
|
||||
info.tlyric = this.fixTimeTag(result.lyric, lines.join('\n'))
|
||||
// } else info.tlyric = lines.join('\n')
|
||||
}
|
||||
}
|
||||
if (yrlrc) {
|
||||
const lines = this.parseHeaderInfo(yrlrc)
|
||||
if (lines) {
|
||||
// if (lines.length == result.lyricLines.length) {
|
||||
info.rlyric = this.fixTimeTag(result.lyric, lines.join('\n'))
|
||||
// } else info.rlyric = lines.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
const timeRxp = /^\[[\d:.]+\]/
|
||||
const headers = lines.filter(l => timeRxp.test(l)).join('\n')
|
||||
info.lyric = `${headers}\n${result.lyric}`
|
||||
info.yrc = result.yrc
|
||||
return info
|
||||
}
|
||||
}
|
||||
if (lrc) {
|
||||
const lines = this.parseHeaderInfo(lrc)
|
||||
if (lines) info.lyric = lines.join('\n')
|
||||
}
|
||||
if (tlrc) {
|
||||
const lines = this.parseHeaderInfo(tlrc)
|
||||
if (lines) info.tlyric = lines.join('\n')
|
||||
}
|
||||
if (rlrc) {
|
||||
const lines = this.parseHeaderInfo(rlrc)
|
||||
if (lines) info.rlyric = lines.join('\n')
|
||||
}
|
||||
|
||||
return info
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/pull/1523/files
|
||||
// export default songmid => {
|
||||
// const requestObj = httpFetch('https://music.163.com/api/linux/forward', {
|
||||
// method: 'post',
|
||||
// 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
// form: linuxapi({
|
||||
// method: 'POST',
|
||||
// url: 'https://music.163.com/api/song/lyric?_nmclfl=1',
|
||||
// params: {
|
||||
// id: songmid,
|
||||
// tv: -1,
|
||||
// lv: -1,
|
||||
// rv: -1,
|
||||
// kv: -1,
|
||||
// },
|
||||
// }),
|
||||
// })
|
||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||
// if (body.code !== 200 || !body?.lrc?.lyric) return Promise.reject(new Error('Get lyric failed'))
|
||||
// // console.log(body)
|
||||
// return {
|
||||
// lyric: body.lrc.lyric,
|
||||
// tlyric: body.tlyric?.lyric ?? '',
|
||||
// rlyric: body.romalrc?.lyric ?? '',
|
||||
// // yrc: parseLyric(body.klyric.lyric),
|
||||
// }
|
||||
// })
|
||||
// return requestObj
|
||||
// }
|
||||
|
||||
// 参考社区通用的歌词时间标签修正思路
|
||||
const fixTimeLabel = (lrc: string, tlrc?: string, romalrc?: string) => {
|
||||
if (lrc) {
|
||||
let newLrc = lrc.replace(/\[(\d{2}:\d{2}):(\d{2})]/g, '[$1.$2]')
|
||||
let newTlrc = tlrc?.replace(/\[(\d{2}:\d{2}):(\d{2})]/g, '[$1.$2]') ?? tlrc
|
||||
if (newLrc != lrc || newTlrc != tlrc) {
|
||||
lrc = newLrc
|
||||
tlrc = newTlrc
|
||||
if (romalrc) romalrc = romalrc.replace(/\[(\d{2}:\d{2}):(\d{2,3})]/g, '[$1.$2]').replace(/\[(\d{2}:\d{2}\.\d{2})0]/g, '[$1]')
|
||||
}
|
||||
}
|
||||
|
||||
return { lrc, tlrc, romalrc }
|
||||
}
|
||||
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/lyric_new.js
|
||||
export default (songmid: string): HttpRequestObject => {
|
||||
const requestObj = eapiRequest('/api/song/lyric/v1', {
|
||||
id: songmid,
|
||||
cp: false,
|
||||
tv: 0,
|
||||
lv: 0,
|
||||
rv: 0,
|
||||
kv: 0,
|
||||
yv: 0,
|
||||
ytv: 0,
|
||||
yrv: 0,
|
||||
})
|
||||
requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.code !== 200 || !body?.lrc?.lyric) return Promise.reject(new Error('Get lyric failed'))
|
||||
const fixTimeLabelLrc = fixTimeLabel(body.lrc.lyric, body.tlyric?.lyric, body.romalrc?.lyric)
|
||||
const info = parseTools.parse(body.yrc?.lyric, body.ytlrc?.lyric, body.yromalrc?.lyric, fixTimeLabelLrc.lrc, fixTimeLabelLrc.tlrc, fixTimeLabelLrc.romalrc)
|
||||
// console.log(info)
|
||||
if (!info.lyric) return Promise.reject(new Error('Get lyric failed'))
|
||||
return info
|
||||
}) as any
|
||||
return requestObj
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
|
||||
|
||||
export default {
|
||||
getSinger(singers) {
|
||||
let arr: any[] = []
|
||||
singers?.forEach(singer => {
|
||||
arr.push(singer.name)
|
||||
})
|
||||
return arr.join('、')
|
||||
},
|
||||
filterList({ songs, privileges }) {
|
||||
// console.log(songs, privileges)
|
||||
const list: any[] = []
|
||||
songs.forEach((item, index) => {
|
||||
const types: Array<{ type: string, size: string | null }> = []
|
||||
const _types: Record<string, { size: string | null }> = {}
|
||||
let size
|
||||
let privilege = privileges[index]
|
||||
if (privilege.id !== item.id) privilege = privileges.find(p => p.id === item.id)
|
||||
if (!privilege) return
|
||||
|
||||
if (privilege.maxBrLevel == 'hires') {
|
||||
size = item.hr ? sizeFormate(item.hr.size) : null
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
switch (privilege.maxbr) {
|
||||
case 999000:
|
||||
size = item.sq ? sizeFormate(item.sq.size) : null
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
case 320000:
|
||||
size = item.h ? sizeFormate(item.h.size) : null
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
case 192000:
|
||||
case 128000:
|
||||
size = item.l ? sizeFormate(item.l.size) : null
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
types.reverse()
|
||||
|
||||
if (item.pc) {
|
||||
list.push({
|
||||
singer: item.pc.ar ?? '',
|
||||
name: item.pc.sn ?? '',
|
||||
albumName: item.pc.alb ?? '',
|
||||
albumId: item.al?.id,
|
||||
source: 'wy',
|
||||
interval: formatPlayTime(item.dt / 1000),
|
||||
songmid: item.id,
|
||||
img: item.al?.picUrl ?? '',
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
} else {
|
||||
list.push({
|
||||
singer: this.getSinger(item.ar),
|
||||
name: item.name ?? '',
|
||||
albumName: item.al?.name,
|
||||
albumId: item.al?.id,
|
||||
source: 'wy',
|
||||
interval: formatPlayTime(item.dt / 1000),
|
||||
songmid: item.id,
|
||||
img: item.al?.picUrl,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
}
|
||||
})
|
||||
// console.log(list)
|
||||
return list
|
||||
},
|
||||
async getList(ids = [], retryNum = 0) {
|
||||
if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
const requestObj = httpFetch('https://music.163.com/weapi/v3/song/detail', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
},
|
||||
form: weapi({
|
||||
c: '[' + ids.map(id => ('{"id":' + id + '}')).join(',') + ']',
|
||||
ids: '[' + ids.join(',') + ']',
|
||||
}),
|
||||
})
|
||||
const { body, statusCode } = await requestObj.promise
|
||||
if (statusCode != 200 || body.code !== 200) throw new Error('获取歌曲详情失败')
|
||||
// console.log(body)
|
||||
return { source: 'wy', list: this.filterList(body) }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import type { RequestObj } from '../types.ts'
|
||||
|
||||
export default (songmid: string): RequestObj<any> => {
|
||||
const requestObj = httpFetch('https://music.163.com/weapi/v3/song/detail', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
Referer: 'https://music.163.com/song?id=' + songmid,
|
||||
origin: 'https://music.163.com',
|
||||
},
|
||||
form: weapi({
|
||||
c: `[{"id":${songmid}}]`,
|
||||
ids: `[${songmid}]`,
|
||||
}),
|
||||
})
|
||||
requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.code !== 200 || !body.songs.length) return Promise.reject(new Error('获取歌曲信息失败'))
|
||||
return body.songs[0]
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// import { httpFetch } from '../request.ts'
|
||||
// import { weapi } from './utils/crypto.ts'
|
||||
import { sizeFormate, formatPlayTime } from '../index.ts'
|
||||
// import musicDetailApi from './musicDetail.ts'
|
||||
import { eapiRequest } from './utils/index.ts'
|
||||
|
||||
export default {
|
||||
limit: 30,
|
||||
total: 0,
|
||||
page: 0,
|
||||
allPage: 1,
|
||||
musicSearch(str, page, limit) {
|
||||
// const searchRequest = eapiRequest('/api/cloudsearch/pc', {
|
||||
// s: str,
|
||||
// type: 1, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
|
||||
// limit,
|
||||
// total: page == 1,
|
||||
// offset: limit * (page - 1),
|
||||
// })
|
||||
const searchRequest = eapiRequest('/api/search/song/list/page', {
|
||||
keyword: str,
|
||||
needCorrect: '1',
|
||||
channel: 'typing',
|
||||
offset: limit * (page - 1),
|
||||
scene: 'normal',
|
||||
total: page == 1,
|
||||
limit,
|
||||
})
|
||||
return searchRequest.promise.then(({ body }) => body)
|
||||
},
|
||||
getSinger(singers) {
|
||||
let arr: any[] = []
|
||||
singers.forEach(singer => {
|
||||
arr.push(singer.name)
|
||||
})
|
||||
return arr.join('、')
|
||||
},
|
||||
handleResult(rawList) {
|
||||
// console.log(rawList)
|
||||
if (!rawList) return []
|
||||
return rawList.map(item => {
|
||||
item = item.baseInfo.simpleSongData
|
||||
const types: Array<{ type: string, size: string | null }> = []
|
||||
const _types: Record<string, { size: string | null }> = {}
|
||||
let size
|
||||
|
||||
if (item.privilege.maxBrLevel == 'hires') {
|
||||
size = item.hr ? sizeFormate(item.hr.size) : null
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
switch (item.privilege.maxbr) {
|
||||
case 999000:
|
||||
size = item.sq ? sizeFormate(item.sq.size) : null
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
case 320000:
|
||||
size = item.h ? sizeFormate(item.h.size) : null
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
case 192000:
|
||||
case 128000:
|
||||
size = item.l ? sizeFormate(item.l.size) : null
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
types.reverse()
|
||||
|
||||
return {
|
||||
singer: this.getSinger(item.ar),
|
||||
name: item.name,
|
||||
albumName: item.al.name,
|
||||
albumId: item.al.id,
|
||||
source: 'wy',
|
||||
interval: formatPlayTime(item.dt / 1000),
|
||||
songmid: item.id,
|
||||
img: item.al.picUrl,
|
||||
lrc: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
})
|
||||
},
|
||||
search(str, page = 1, limit, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
if (limit == null) limit = this.limit
|
||||
return this.musicSearch(str, page, limit).then(result => {
|
||||
// console.log(result)
|
||||
if (!result || result.code !== 200) return this.search(str, page, limit, retryNum)
|
||||
let list = this.handleResult(result.data.resources || [])
|
||||
// console.log(list)
|
||||
|
||||
if (list == null) return this.search(str, page, limit, retryNum)
|
||||
|
||||
this.total = result.data.totalCount || 0
|
||||
this.page = page
|
||||
this.allPage = Math.ceil(this.total / this.limit)
|
||||
|
||||
return {
|
||||
list,
|
||||
allPage: this.allPage,
|
||||
limit: this.limit,
|
||||
total: this.total,
|
||||
source: 'wy',
|
||||
}
|
||||
// return result.data
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { eapiRequest } from './utils/index.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 获取歌手信息
|
||||
* @param {*} id
|
||||
*/
|
||||
getInfo(id) {
|
||||
return eapiRequest('/api/artist/head/info/get', { id }).promise.then(({ body }) => {
|
||||
if (!body || body.code != 200) throw new Error('get singer info faild.')
|
||||
// 响应结构为 { code, data: { artist: { ... } } },无 body.user
|
||||
const artist = body.data?.artist
|
||||
if (!artist) throw new Error('get singer info faild.')
|
||||
return {
|
||||
source: 'wy',
|
||||
id: artist.id,
|
||||
info: {
|
||||
name: artist.name,
|
||||
desc: artist.briefDesc,
|
||||
avatar: artist.avatar ?? artist.cover ?? null,
|
||||
gender: null,
|
||||
},
|
||||
count: {
|
||||
music: artist.musicSize,
|
||||
album: artist.albumSize,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取歌手歌曲列表
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
getSongList(id, page = 1, limit = 100) {
|
||||
return eapiRequest('/api/v2/artist/songs', {
|
||||
id,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
}).promise.then(({ body }) => {
|
||||
if (!body.songs || body.code != 200) throw new Error('get singer song list faild.')
|
||||
|
||||
const list = this.filterSongList(body.songs)
|
||||
return {
|
||||
list,
|
||||
limit,
|
||||
page,
|
||||
total: body.total,
|
||||
source: 'wy',
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取歌手专辑列表
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
getAlbumList(id, page = 1, limit = 10) {
|
||||
return eapiRequest(`/api/artist/albums/${id}`, {
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
}).promise.then(({ body }) => {
|
||||
if (!body.hotAlbums || body.code != 200) throw new Error('get singer album list faild.')
|
||||
|
||||
const list = this.filterAlbumList(body.hotAlbums)
|
||||
return {
|
||||
source: 'wy',
|
||||
list,
|
||||
limit,
|
||||
page,
|
||||
total: body.artist.albumSize,
|
||||
}
|
||||
})
|
||||
},
|
||||
filterAlbumList(raw) {
|
||||
const list: any[] = []
|
||||
raw.forEach(item => {
|
||||
if (!item.id) return
|
||||
list.push({
|
||||
id: item.id,
|
||||
count: item.size,
|
||||
info: {
|
||||
name: item.name,
|
||||
author: formatSingerName(item.artists),
|
||||
img: item.picUrl,
|
||||
desc: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
return list
|
||||
},
|
||||
filterSongList(raw) {
|
||||
const list: any[] = []
|
||||
raw.forEach(item => {
|
||||
if (!item.id) return
|
||||
|
||||
const types: Array<{ type: string, size: string | null }> = []
|
||||
const _types: Record<string, { size: string | null }> = {}
|
||||
let size
|
||||
item.privilege.chargeInfoList.forEach(i => {
|
||||
switch (i.rate) {
|
||||
case 128000:
|
||||
size = item.lMusic ? sizeFormate(item.lMusic.size) : null
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
break
|
||||
case 320000:
|
||||
size = item.hMusic ? sizeFormate(item.hMusic.size) : null
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
break
|
||||
case 999000:
|
||||
size = item.sqMusic ? sizeFormate(item.sqMusic.size) : null
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
break
|
||||
case 1999000:
|
||||
size = item.hrMusic ? sizeFormate(item.hrMusic.size) : null
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
list.push({
|
||||
singer: formatSingerName(item.artists),
|
||||
name: item.name,
|
||||
albumName: item.album.name,
|
||||
albumId: item.album.id,
|
||||
songmid: item.id,
|
||||
source: 'wy',
|
||||
interval: formatPlayTime((item.dt ?? item.duration) / 1000),
|
||||
img: item.album?.picUrl ?? null,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
})
|
||||
return list
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/playlist_catlist.js
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/playlist_hot.js
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/top_playlist.js
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/playlist_detail.js
|
||||
|
||||
import { weapi, linuxapi } from './utils/crypto.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
||||
import musicDetailApi from './musicDetail.ts'
|
||||
import { eapiRequest } from './utils/index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
_requestObj_tags: null as HttpRequestObject | null,
|
||||
_requestObj_hotTags: null as HttpRequestObject | null,
|
||||
_requestObj_list: null as HttpRequestObject | null,
|
||||
limit_list: 30,
|
||||
limit_song: 100000,
|
||||
successCode: 200,
|
||||
cookie: 'MUSIC_U=',
|
||||
sortList: [
|
||||
{
|
||||
name: '最热',
|
||||
id: 'hot',
|
||||
},
|
||||
// {
|
||||
// name: '最新',
|
||||
// id: 'new',
|
||||
// },
|
||||
],
|
||||
regExps: {
|
||||
listDetailLink: /^.+(?:\?|&)id=(\d+)(?:&.*$|#.*$|$)/,
|
||||
listDetailLink2: /^.+\/playlist\/(\d+)\/\d+\/.+$/,
|
||||
},
|
||||
|
||||
async handleParseId(link, retryNum = 0) {
|
||||
if (retryNum > 2) throw new Error('link try max num')
|
||||
|
||||
const requestObj_listDetailLink = httpFetch(link)
|
||||
const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise
|
||||
// console.log(headers)
|
||||
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
|
||||
const url = location == null ? link : location
|
||||
return this.regExps.listDetailLink.test(url)
|
||||
? url.replace(this.regExps.listDetailLink, '$1')
|
||||
: url.replace(this.regExps.listDetailLink2, '$1')
|
||||
},
|
||||
|
||||
async getListId(id) {
|
||||
let cookie
|
||||
if (/###/.test(id)) {
|
||||
const [url, token] = id.split('###')
|
||||
id = url
|
||||
cookie = `MUSIC_U=${token}`
|
||||
}
|
||||
if ((/[?&:/]/.test(id))) {
|
||||
if (this.regExps.listDetailLink.test(id)) {
|
||||
id = id.replace(this.regExps.listDetailLink, '$1')
|
||||
} else if (this.regExps.listDetailLink2.test(id)) {
|
||||
id = id.replace(this.regExps.listDetailLink2, '$1')
|
||||
} else {
|
||||
id = await this.handleParseId(id)
|
||||
}
|
||||
// console.log(id)
|
||||
}
|
||||
return { id, cookie }
|
||||
},
|
||||
async getListDetail(rawId, page, tryNum = 0) { // 获取歌曲列表内的音乐
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
|
||||
const { id, cookie } = await this.getListId(rawId)
|
||||
if (cookie) this.cookie = cookie
|
||||
|
||||
const requestObj_listDetail = httpFetch('https://music.163.com/api/linux/forward', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
form: linuxapi({
|
||||
method: 'POST',
|
||||
url: 'https://music.163.com/api/v3/playlist/detail',
|
||||
params: {
|
||||
id,
|
||||
n: this.limit_song,
|
||||
s: 8,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const { statusCode, body } = await requestObj_listDetail.promise
|
||||
if (statusCode !== 200 || body.code !== this.successCode) return this.getListDetail(id, page, ++tryNum)
|
||||
let limit = 1000
|
||||
let rangeStart = (page - 1) * limit
|
||||
// console.log(body)
|
||||
let list
|
||||
if (body.playlist.trackIds.length == body.privileges.length) {
|
||||
list = this.filterListDetail(body)
|
||||
} else {
|
||||
try {
|
||||
list = (await (musicDetailApi as any).getList(body.playlist.trackIds.slice(rangeStart, limit * page).map(trackId => trackId.id))).list
|
||||
} catch (err: any) {
|
||||
console.log(err)
|
||||
if (err.message == 'try max num') {
|
||||
throw err
|
||||
} else {
|
||||
return this.getListDetail(id, page, ++tryNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.log(list)
|
||||
return {
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total: body.playlist.trackIds.length,
|
||||
source: 'wy',
|
||||
info: {
|
||||
play_count: formatPlayCount(body.playlist.playCount),
|
||||
name: body.playlist.name,
|
||||
img: body.playlist.coverImgUrl,
|
||||
desc: body.playlist.description,
|
||||
author: body.playlist.creator.nickname,
|
||||
},
|
||||
}
|
||||
},
|
||||
filterListDetail({ playlist: { tracks }, privileges }) {
|
||||
// console.log(tracks, privileges)
|
||||
const list: any[] = []
|
||||
tracks.forEach((item, index) => {
|
||||
const types: Array<{ type: string, size: string | null }> = []
|
||||
const _types: Record<string, { size: string | null }> = {}
|
||||
let size
|
||||
let privilege = privileges[index]
|
||||
if (privilege.id !== item.id) privilege = privileges.find(p => p.id === item.id)
|
||||
if (!privilege) return
|
||||
|
||||
if (privilege.maxBrLevel == 'hires') {
|
||||
size = item.hr ? sizeFormate(item.hr.size) : null
|
||||
types.push({ type: 'flac24bit', size })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
switch (privilege.maxbr) {
|
||||
case 999000:
|
||||
size = null
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = {
|
||||
size,
|
||||
}
|
||||
case 320000:
|
||||
size = item.h ? sizeFormate(item.h.size) : null
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
}
|
||||
case 192000:
|
||||
case 128000:
|
||||
size = item.l ? sizeFormate(item.l.size) : null
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
types.reverse()
|
||||
|
||||
if (item.pc) {
|
||||
list.push({
|
||||
singer: item.pc.ar ?? '',
|
||||
name: item.pc.sn ?? '',
|
||||
albumName: item.pc.alb ?? '',
|
||||
albumId: item.al?.id,
|
||||
source: 'wy',
|
||||
interval: formatPlayTime(item.dt / 1000),
|
||||
songmid: item.id,
|
||||
img: item.al?.picUrl ?? '',
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
} else {
|
||||
list.push({
|
||||
singer: formatSingerName(item.ar, 'name'),
|
||||
name: item.name ?? '',
|
||||
albumName: item.al?.name,
|
||||
albumId: item.al?.id,
|
||||
source: 'wy',
|
||||
interval: formatPlayTime(item.dt / 1000),
|
||||
songmid: item.id,
|
||||
img: item.al?.picUrl,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
})
|
||||
}
|
||||
})
|
||||
return list
|
||||
},
|
||||
|
||||
// 获取列表数据
|
||||
getList(sortId, tagId, page, tryNum = 0) {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
if (this._requestObj_list) this._requestObj_list.cancelHttp()
|
||||
this._requestObj_list = httpFetch('https://music.163.com/weapi/playlist/list', {
|
||||
method: 'post',
|
||||
form: weapi({
|
||||
cat: tagId || '全部', // 全部,华语,欧美,日语,韩语,粤语,小语种,流行,摇滚,民谣,电子,舞曲,说唱,轻音乐,爵士,乡村,R&B/Soul,古典,民族,英伦,金属,朋克,蓝调,雷鬼,世界音乐,拉丁,另类/独立,New Age,古风,后摇,Bossa Nova,清晨,夜晚,学习,工作,午休,下午茶,地铁,驾车,运动,旅行,散步,酒吧,怀旧,清新,浪漫,性感,伤感,治愈,放松,孤独,感动,兴奋,快乐,安静,思念,影视原声,ACG,儿童,校园,游戏,70后,80后,90后,网络歌曲,KTV,经典,翻唱,吉他,钢琴,器乐,榜单,00后
|
||||
order: sortId, // hot,new
|
||||
limit: this.limit_list,
|
||||
offset: this.limit_list * (page - 1),
|
||||
total: true,
|
||||
}),
|
||||
})
|
||||
return this._requestObj_list!.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
|
||||
return {
|
||||
list: this.filterList(body.playlists),
|
||||
total: parseInt(body.total),
|
||||
page,
|
||||
limit: this.limit_list,
|
||||
source: 'wy',
|
||||
}
|
||||
})
|
||||
},
|
||||
filterList(rawData) {
|
||||
// console.log(rawData)
|
||||
return rawData.map(item => ({
|
||||
play_count: formatPlayCount(item.playCount),
|
||||
id: String(item.id),
|
||||
author: item.creator.nickname,
|
||||
name: item.name,
|
||||
time: item.createTime ? dateFormat(item.createTime, 'Y-M-D') : '',
|
||||
img: item.coverImgUrl,
|
||||
grade: item.grade,
|
||||
total: item.trackCount,
|
||||
desc: item.description,
|
||||
source: 'wy',
|
||||
}))
|
||||
},
|
||||
|
||||
// 获取标签
|
||||
getTag(tryNum = 0) {
|
||||
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_tags = httpFetch('https://music.163.com/weapi/playlist/catalogue', {
|
||||
method: 'post',
|
||||
form: weapi({}),
|
||||
})
|
||||
return this._requestObj_tags!.promise.then(({ body }) => {
|
||||
// console.log(JSON.stringify(body))
|
||||
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
||||
return this.filterTagInfo(body)
|
||||
})
|
||||
},
|
||||
filterTagInfo({ sub, categories }) {
|
||||
const subList = {}
|
||||
for (const item of sub) {
|
||||
if (!subList[item.category]) subList[item.category] = []
|
||||
subList[item.category].push({
|
||||
parent_id: categories[item.category],
|
||||
parent_name: categories[item.category],
|
||||
id: item.name,
|
||||
name: item.name,
|
||||
source: 'wy',
|
||||
})
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
for (const key of Object.keys(categories)) {
|
||||
list.push({
|
||||
name: categories[key],
|
||||
list: subList[key],
|
||||
source: 'wy',
|
||||
})
|
||||
}
|
||||
return list
|
||||
},
|
||||
|
||||
// 获取热门标签
|
||||
getHotTag(tryNum = 0) {
|
||||
if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_hotTags = httpFetch('https://music.163.com/weapi/playlist/hottags', {
|
||||
method: 'post',
|
||||
form: weapi({}),
|
||||
})
|
||||
return this._requestObj_hotTags!.promise.then(({ body }) => {
|
||||
// console.log(JSON.stringify(body))
|
||||
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
||||
return this.filterHotTagInfo(body.tags)
|
||||
})
|
||||
},
|
||||
filterHotTagInfo(rawList) {
|
||||
return rawList.map(item => ({
|
||||
id: item.playlistTag.name,
|
||||
name: item.playlistTag.name,
|
||||
source: 'wy',
|
||||
}))
|
||||
},
|
||||
|
||||
getTags() {
|
||||
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({ tags, hotTag, source: 'wy' }))
|
||||
},
|
||||
|
||||
async getDetailPageUrl(rawId) {
|
||||
const { id } = await this.getListId(rawId)
|
||||
return `https://music.163.com/#/playlist?id=${id}`
|
||||
},
|
||||
|
||||
search(text, page, limit = 20) {
|
||||
return eapiRequest('/api/cloudsearch/pc', {
|
||||
s: text,
|
||||
type: 1000, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
|
||||
limit,
|
||||
total: page == 1,
|
||||
offset: limit * (page - 1),
|
||||
})
|
||||
.promise.then(({ body }) => {
|
||||
if (body.code != this.successCode) throw new Error('filed')
|
||||
// console.log(body)
|
||||
return {
|
||||
list: this.filterList(body.result.playlists),
|
||||
limit,
|
||||
total: body.result.playlistCount,
|
||||
source: 'wy',
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// getList
|
||||
// getTags
|
||||
// getListDetail
|
||||
@@ -0,0 +1,34 @@
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
export default {
|
||||
requestObj: null as HttpRequestObject | null,
|
||||
cancelTipSearch() {
|
||||
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
|
||||
},
|
||||
tipSearchBySong(str) {
|
||||
this.cancelTipSearch()
|
||||
this.requestObj = httpFetch('https://music.163.com/weapi/search/suggest/web', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com/',
|
||||
},
|
||||
form: weapi({
|
||||
s: str,
|
||||
}),
|
||||
})
|
||||
return this.requestObj!.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.code != 200) return Promise.reject(new Error('请求失败'))
|
||||
return body.result.songs
|
||||
})
|
||||
},
|
||||
handleResult(rawData) {
|
||||
return rawData.map(info => `${info.name} - ${formatSingerName(info.artists, 'name')}`)
|
||||
},
|
||||
async search(str) {
|
||||
return this.tipSearchBySong(str).then(result => this.handleResult(result))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/util/crypto.js
|
||||
import { createCipheriv, createDecipheriv, publicEncrypt, randomBytes, createHash, constants } from 'crypto'
|
||||
const iv = Buffer.from('0102030405060708')
|
||||
const presetKey = Buffer.from('0CoJUm6Qyw8W8jud')
|
||||
const linuxapiKey = Buffer.from('rFgB&h#%2?^eDg:Q')
|
||||
const base62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
const publicKey = '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n-----END PUBLIC KEY-----'
|
||||
const eapiKey = 'e82ckenh8dichen8'
|
||||
|
||||
const aesEncrypt = (buffer: Buffer, mode: string, key: Buffer | string, iv: Buffer | string): Buffer => {
|
||||
const cipher = createCipheriv(mode, key, iv)
|
||||
return Buffer.concat([cipher.update(buffer), cipher.final()])
|
||||
}
|
||||
|
||||
const aesDecrypt = function(cipherBuffer: Buffer, mode: string, key: Buffer | string, iv: Buffer | string): Buffer {
|
||||
let decipher = createDecipheriv(mode, key, iv)
|
||||
return Buffer.concat([decipher.update(cipherBuffer), decipher.final()])
|
||||
}
|
||||
|
||||
const rsaEncrypt = (buffer: Buffer, key: string): Buffer => {
|
||||
buffer = Buffer.concat([Buffer.alloc(128 - buffer.length), buffer])
|
||||
return publicEncrypt({ key, padding: constants.RSA_NO_PADDING }, buffer)
|
||||
}
|
||||
|
||||
export const weapi = (object: object): { params: string, encSecKey: string } => {
|
||||
const text = JSON.stringify(object)
|
||||
const secretKey = randomBytes(16).map(n => ((base62.charAt(n % 62) as any).charCodeAt())) as Buffer
|
||||
return {
|
||||
params: aesEncrypt(Buffer.from(aesEncrypt(Buffer.from(text), 'aes-128-cbc', presetKey, iv).toString('base64')), 'aes-128-cbc', secretKey, iv).toString('base64'),
|
||||
encSecKey: rsaEncrypt(secretKey.reverse(), publicKey).toString('hex'),
|
||||
}
|
||||
}
|
||||
|
||||
export const linuxapi = (object: object): { eparams: string } => {
|
||||
const text = JSON.stringify(object)
|
||||
return {
|
||||
eparams: aesEncrypt(Buffer.from(text), 'aes-128-ecb', linuxapiKey, '').toString('hex').toUpperCase(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const eapi = (url: string, object: object | string): { params: string } => {
|
||||
const text = typeof object === 'object' ? JSON.stringify(object) : object
|
||||
const message = `nobody${url}use${text}md5forencrypt`
|
||||
const digest = createHash('md5').update(message).digest('hex')
|
||||
const data = `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}`
|
||||
return {
|
||||
params: aesEncrypt(Buffer.from(data), 'aes-128-ecb', eapiKey, '').toString('hex').toUpperCase(),
|
||||
}
|
||||
}
|
||||
|
||||
export const eapiDecrypt = (cipherBuffer: Buffer): string => {
|
||||
return aesDecrypt(cipherBuffer, 'aes-128-ecb', eapiKey, '').toString()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { httpFetch } from '../../request.ts'
|
||||
import { eapi } from './crypto.ts'
|
||||
import { getWyCookie } from '../../auth/store.ts'
|
||||
import type { HttpRequestObject } from '../../types.ts'
|
||||
|
||||
export const eapiRequest = (url: string, data: object): HttpRequestObject => {
|
||||
// 登录后携带 MUSIC_U(见 /login/wy),未登录时不附加
|
||||
const cookie = getWyCookie()
|
||||
return httpFetch('http://interface.music.163.com/eapi/batch', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: eapi(url, data),
|
||||
})
|
||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||
// // console.log(raw)
|
||||
// console.log(body)
|
||||
// // console.log(eapiDecrypt(raw))
|
||||
// // return eapiDecrypt(raw)
|
||||
// return body
|
||||
// })
|
||||
// return requestObj
|
||||
}
|
||||
Reference in new issue
Block a user