feat(kg): 取流切换到手机客户端通道(gateway /v5/url,android 签名)
This commit is contained in:
1 parent
45f3a4b989
commit
c8c939aa32
7 files changed
+203
-120
No files matched your search
+67
-33
@@ -1,51 +1,85 @@
|
||||
// 酷狗音乐 URL 获取(直连)
|
||||
// 接口: https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash={hash}&album_id={albumId}&mid={mid}
|
||||
// 酷狗音乐 URL 获取(手机客户端通道)
|
||||
// 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名)
|
||||
// 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源,
|
||||
// VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high
|
||||
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { getKgAuth } from '../auth/store.js'
|
||||
import { androidSign, getDevice } from '../auth/kg.js'
|
||||
|
||||
let mid = ''
|
||||
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
|
||||
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
|
||||
const APPID = 1005
|
||||
const CLIENTVER = 11430
|
||||
|
||||
const getMid = () => {
|
||||
if (mid) return Promise.resolve(mid)
|
||||
// 随机生成 26 位小写字母数字混合的 mid
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
mid = Array.from({ length: 26 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
return Promise.resolve(mid)
|
||||
}
|
||||
|
||||
const getPlayUrl = (hash, albumId) => getMid().then(m => {
|
||||
const auth = getKgAuth()
|
||||
const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}${auth ? `&token=${encodeURIComponent(auth.token)}&userid=${auth.userid}` : ''}`
|
||||
return httpFetch(url, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
Referer: 'https://www.kugou.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || body.err_code != 0 || !body.data) {
|
||||
return Promise.reject(new Error('获取失败'))
|
||||
}
|
||||
return body.data
|
||||
})
|
||||
})
|
||||
const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null }
|
||||
const hash = songInfo._types?.[type]?.hash || songInfo.hash
|
||||
const hash = (songInfo._types?.[type]?.hash || songInfo.hash || '').toLowerCase()
|
||||
if (!hash) {
|
||||
requestObj.promise = Promise.reject(new Error('缺少 hash'))
|
||||
return requestObj
|
||||
}
|
||||
requestObj.promise = getPlayUrl(hash, songInfo.albumId).then(data => {
|
||||
const url = data.play_url || data.play_backup_url
|
||||
if (!url) return Promise.reject(new Error('获取失败'))
|
||||
|
||||
requestObj.cancelHttp = () => {} // 单次 GET,无需取消
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
const dev = getDevice()
|
||||
const auth = getKgAuth()
|
||||
const userid = Number(auth?.userid || 0)
|
||||
|
||||
const params = {
|
||||
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
||||
clienttime: Math.floor(Date.now() / 1000),
|
||||
album_id: Number(songInfo.albumId ?? 0),
|
||||
area_code: 1,
|
||||
hash,
|
||||
ssa_flag: 'is_fromtrack',
|
||||
version: CLIENTVER,
|
||||
page_id: 151369488,
|
||||
quality: qualityMap[type] ?? '128',
|
||||
album_audio_id: Number(songInfo.songmid ?? 0),
|
||||
behavior: 'play',
|
||||
pid: 2,
|
||||
cmd: 26,
|
||||
pidversion: 3001,
|
||||
IsFreePart: 0,
|
||||
ppage_id: '463467626,350369493,788954147',
|
||||
cdnBackup: 1,
|
||||
module: '',
|
||||
}
|
||||
if (auth) {
|
||||
params.token = auth.token
|
||||
params.userid = userid
|
||||
}
|
||||
params.key = md5(`${hash}${SALT_KEY}${APPID}${dev.mid}${userid}`)
|
||||
params.signature = androidSign(params)
|
||||
|
||||
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
|
||||
const r = await httpFetch(`https://gateway.kugou.com/v5/url?${qs}`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
|
||||
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
|
||||
'x-router': 'trackercdn.kugou.com',
|
||||
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
|
||||
const body = r.body && typeof r.body === 'object' ? r.body : {}
|
||||
if (body.status !== 1 || !Array.isArray(body.data)) {
|
||||
throw new Error(body.error || (auth ? '获取失败' : '获取失败(该资源需要酷狗登录)'))
|
||||
}
|
||||
const item = body.data.find(d => d.url) ?? body.data[0]
|
||||
const url = item?.url || item?.fallback
|
||||
if (!url) throw new Error('获取失败')
|
||||
return { type, url }
|
||||
})
|
||||
})()
|
||||
return requestObj
|
||||
}
|
||||
|
||||
|
||||
+15
-17
@@ -21,29 +21,28 @@ const randomString = (n) => {
|
||||
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
}
|
||||
|
||||
/** 设备标识持久化在凭据里,保证同一登录态的请求指纹稳定 */
|
||||
const device = () => {
|
||||
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
|
||||
}
|
||||
|
||||
const androidSign = (params, data = '') => {
|
||||
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 = device()
|
||||
const mid = dev.mid || (dev.mid = calculateMid(dev.guid))
|
||||
setCredentials('_kg_device', dev)
|
||||
|
||||
const dev = getDevice()
|
||||
const params = {
|
||||
dfid: dev.dfid, mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
||||
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
||||
clienttime: Math.floor(Date.now() / 1000),
|
||||
...extraParams,
|
||||
}
|
||||
@@ -52,7 +51,7 @@ const gatewayRequest = async(url, { params: extraParams, data, router }) => {
|
||||
const r = await httpFetch(`https://gateway.kugou.com${url}?${qs}&signature=${signature}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
dfid: dev.dfid, mid, clienttime: String(params.clienttime),
|
||||
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',
|
||||
@@ -65,12 +64,11 @@ const gatewayRequest = async(url, { params: extraParams, data, router }) => {
|
||||
|
||||
/**
|
||||
* 验证 token 并返回账号资料(usercenter /v3/get_my_info)
|
||||
* p = RSA 原始加密(零填充){ token, clienttime }
|
||||
* 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 }))
|
||||
// 原始 RSA(无填充):左侧零填充到密钥长度(128 字节)
|
||||
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()
|
||||
|
||||
|
||||
Vendored
-29
@@ -1,29 +0,0 @@
|
||||
// src/auth/store.js 的类型声明(实现为同目录 store.js)
|
||||
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
|
||||
}
|
||||
|
||||
interface KgDevice {
|
||||
guid: string
|
||||
mid: string
|
||||
dfid: string
|
||||
}
|
||||
|
||||
export declare function getCredentials(platform: 'wy' | 'kg' | '_kg_device'): WyCredentials | KgCredentials | KgDevice | null
|
||||
export declare function setCredentials(platform: 'wy' | 'kg' | '_kg_device', credentials: object): void
|
||||
export declare function clearCredentials(platform: 'wy' | 'kg' | '_kg_device'): void
|
||||
export declare function getWyCookie(): string
|
||||
export declare function getKgAuth(): { token: string; userid: number | string } | null
|
||||
+35
-40
@@ -1,50 +1,45 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: { cookie, musicU, userid?, nickname?, updatedAt }, kg: { token, userid, nickname?, updatedAt } }
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
|
||||
|
||||
let cache = null
|
||||
|
||||
// 结构:{ wy: {...}, kg: {...}, _kg_device: {...} }
|
||||
// 注意:本文件经 tsc 就地发射 store.js 供纯 JS 模块引用(与 src/apis/index.ts 同约定)
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json');
|
||||
let cache = {};
|
||||
let loaded = false;
|
||||
const load = () => {
|
||||
if (!cache) {
|
||||
if (loaded)
|
||||
return cache;
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
|
||||
} catch {
|
||||
cache = {}
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {};
|
||||
}
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
catch {
|
||||
cache = {};
|
||||
}
|
||||
loaded = true;
|
||||
return cache;
|
||||
};
|
||||
const save = (data) => {
|
||||
cache = data
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true })
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export const getCredentials = (platform) => load()[platform] ?? null
|
||||
|
||||
cache = data;
|
||||
loaded = true;
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true });
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
||||
};
|
||||
export const getCredentials = (platform) => load()[platform] ?? null;
|
||||
export const setCredentials = (platform, credentials) => {
|
||||
const data = load()
|
||||
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
|
||||
save(data)
|
||||
}
|
||||
|
||||
const data = load();
|
||||
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() };
|
||||
save(data);
|
||||
};
|
||||
export const clearCredentials = (platform) => {
|
||||
const data = load()
|
||||
delete data[platform]
|
||||
save(data)
|
||||
}
|
||||
|
||||
const data = load();
|
||||
delete data[platform];
|
||||
save(data);
|
||||
};
|
||||
/** wy 登录 cookie 字符串(未登录返回空串) */
|
||||
export const getWyCookie = () => getCredentials('wy')?.cookie || ''
|
||||
|
||||
export const getWyCookie = () => getCredentials('wy')?.cookie || '';
|
||||
/** kg 登录态(未登录返回 null) */
|
||||
export const getKgAuth = () => {
|
||||
const cred = getCredentials('kg')
|
||||
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
|
||||
}
|
||||
const cred = getCredentials('kg');
|
||||
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: {...}, kg: {...}, _kg_device: {...} }
|
||||
// 注意:本文件经 tsc 就地发射 store.js 供纯 JS 模块引用(与 src/apis/index.ts 同约定)
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
|
||||
|
||||
export interface WyCredentials {
|
||||
cookie: string
|
||||
musicU: string
|
||||
userid?: number
|
||||
nickname?: string | null
|
||||
avatar?: string | null
|
||||
vipType?: number | null
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface KgCredentials {
|
||||
token: string
|
||||
userid: number | string
|
||||
nickname?: string | null
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface KgDevice {
|
||||
guid: string
|
||||
mid: string
|
||||
dfid: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
let cache: Record<string, any> = {}
|
||||
let loaded = false
|
||||
|
||||
const load = (): Record<string, any> => {
|
||||
if (loaded) return cache
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
|
||||
} catch {
|
||||
cache = {}
|
||||
}
|
||||
loaded = true
|
||||
return cache
|
||||
}
|
||||
|
||||
const save = (data: Record<string, any>): void => {
|
||||
cache = data
|
||||
loaded = true
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true })
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export const getCredentials = (platform: string): any => load()[platform] ?? null
|
||||
|
||||
export const setCredentials = (platform: string, credentials: Record<string, any>): void => {
|
||||
const data = load()
|
||||
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
|
||||
save(data)
|
||||
}
|
||||
|
||||
export const clearCredentials = (platform: string): void => {
|
||||
const data = load()
|
||||
delete data[platform]
|
||||
save(data)
|
||||
}
|
||||
|
||||
/** wy 登录 cookie 字符串(未登录返回空串) */
|
||||
export const getWyCookie = (): string => (getCredentials('wy') as WyCredentials | null)?.cookie || ''
|
||||
|
||||
/** kg 登录态(未登录返回 null) */
|
||||
export const getKgAuth = (): { token: string; userid: number | string } | null => {
|
||||
const cred = getCredentials('kg') as KgCredentials | null
|
||||
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
|
||||
}
|
||||
Vendored
+8
@@ -17,6 +17,14 @@ declare module '../common/request.js' {
|
||||
export function httpFetch(url: string, options?: Record<string, any>): HttpRequestObject
|
||||
}
|
||||
|
||||
declare module '../../src/auth/store.js' {
|
||||
export function getCredentials(platform: string): any
|
||||
export function setCredentials(platform: string, credentials: object): void
|
||||
export function clearCredentials(platform: string): void
|
||||
export function getWyCookie(): string
|
||||
export function getKgAuth(): { token: string; userid: number | string } | null
|
||||
}
|
||||
|
||||
declare module './kw.js' {
|
||||
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
|
||||
export = mod
|
||||
|
||||
Reference in new issue
Block a user