feat(kg): 取流切换到手机客户端通道(gateway /v5/url,android 签名)

This commit is contained in:
snowy-yang committed 2026-09-18 21:28:43 +08:00
1 parent 45f3a4b989
commit c8c939aa32
7 files changed
+202 -119

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
import { httpFetch } from '../../request.ts' import { httpFetch } from '../../request.ts'
import { eapi } from './crypto.ts' import { eapi } from './crypto.ts'
import { getWyCookie } from '../../src/auth/store.js' import { getWyCookie } from '../../../src/auth/store.ts'
import type { HttpRequestObject } from '../../types.ts' import type { HttpRequestObject } from '../../types.ts'
export const eapiRequest = (url: string, data: object): HttpRequestObject => { export const eapiRequest = (url: string, data: object): HttpRequestObject => {
+67 -33
View File
@@ -1,51 +1,85 @@
// 酷狗音乐 URL 获取(直连) // 酷狗音乐 URL 获取(手机客户端通道)
// 接口: https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash={hash}&album_id={albumId}&mid={mid} // 接口: 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 } // 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url } // promise resolve → { type, url }
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.js' import { httpFetch } from '../common/request.js'
import { getKgAuth } from '../auth/store.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 = () => { const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
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
})
})
export const getMusicUrl = (songInfo, type) => { export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null } const requestObj = { cancelHttp() {}, promise: null }
const hash = songInfo._types?.[type]?.hash || songInfo.hash const hash = (songInfo._types?.[type]?.hash || songInfo.hash || '').toLowerCase()
if (!hash) { if (!hash) {
requestObj.promise = Promise.reject(new Error('缺少 hash')) requestObj.promise = Promise.reject(new Error('缺少 hash'))
return requestObj return requestObj
} }
requestObj.promise = getPlayUrl(hash, songInfo.albumId).then(data => {
const url = data.play_url || data.play_backup_url requestObj.cancelHttp = () => {} // 单次 GET,无需取消
if (!url) return Promise.reject(new Error('获取失败'))
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 { type, url }
}) })()
return requestObj return requestObj
} }
+15 -17
View File
@@ -21,29 +21,28 @@ const randomString = (n) => {
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('') return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
} }
/** 设备标识持久化在凭据里,保证同一登录态的请求指纹稳定 */ export const androidSign = (params, data = '') => {
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 = '') => {
const s = Object.keys(params).sort() const s = Object.keys(params).sort()
.map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`) .map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`)
.join('') .join('')
return md5(SALT_SIGN + s + data + SALT_SIGN) 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) */ /** 网关移动端签名请求(gateway.kugou.com + x-router) */
const gatewayRequest = async(url, { params: extraParams, data, router }) => { const gatewayRequest = async(url, { params: extraParams, data, router }) => {
const dev = device() const dev = getDevice()
const mid = dev.mid || (dev.mid = calculateMid(dev.guid))
setCredentials('_kg_device', dev)
const params = { 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), clienttime: Math.floor(Date.now() / 1000),
...extraParams, ...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}`, { const r = await httpFetch(`https://gateway.kugou.com${url}?${qs}&signature=${signature}`, {
method: 'post', method: 'post',
headers: { 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', 'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
'x-router': router, 'x-router': router,
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi', '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) * 验证 token 并返回账号资料(usercenter /v3/get_my_info)
* p = RSA 原始加密(零填充){ token, clienttime } * p = RSA 原始加密(零填充 128 字节){ token, clienttime }
*/ */
export const verifyToken = async({ token, userid }) => { export const verifyToken = async({ token, userid }) => {
const unixSec = Math.floor(Date.now() / 1000) const unixSec = Math.floor(Date.now() / 1000)
const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec })) const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec }))
// 原始 RSA(无填充):左侧零填充到密钥长度(128 字节)
const padded = Buffer.concat([Buffer.alloc(128 - payload.length), payload]) 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 p = crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex').toUpperCase()
-29
View File
@@ -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
+34 -39
View File
@@ -1,50 +1,45 @@
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库) // 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
// 结构:{ wy: { cookie, musicU, userid?, nickname?, updatedAt }, kg: { token, userid, nickname?, updatedAt } } // 结构:{ wy: {...}, kg: {...}, _kg_device: {...} }
// 注意:本文件经 tsc 就地发射 store.js 供纯 JS 模块引用(与 src/apis/index.ts 同约定)
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url';
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json');
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json') let cache = {};
let loaded = false;
let cache = null
const load = () => { const load = () => {
if (!cache) { if (loaded)
return cache;
try { try {
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {} cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {};
} catch {
cache = {}
} }
catch {
cache = {};
} }
return cache loaded = true;
} return cache;
};
const save = (data) => { const save = (data) => {
cache = data cache = data;
mkdirSync(dirname(DATA_FILE), { recursive: true }) loaded = true;
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)) mkdirSync(dirname(DATA_FILE), { recursive: true });
} writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
};
export const getCredentials = (platform) => load()[platform] ?? null export const getCredentials = (platform) => load()[platform] ?? null;
export const setCredentials = (platform, credentials) => { export const setCredentials = (platform, credentials) => {
const data = load() const data = load();
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() } data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() };
save(data) save(data);
} };
export const clearCredentials = (platform) => { export const clearCredentials = (platform) => {
const data = load() const data = load();
delete data[platform] delete data[platform];
save(data) save(data);
} };
/** wy 登录 cookie 字符串(未登录返回空串) */ /** wy 登录 cookie 字符串(未登录返回空串) */
export const getWyCookie = () => getCredentials('wy')?.cookie || '' export const getWyCookie = () => getCredentials('wy')?.cookie || '';
/** kg 登录态(未登录返回 null) */ /** kg 登录态(未登录返回 null) */
export const getKgAuth = () => { export const getKgAuth = () => {
const cred = getCredentials('kg') const cred = getCredentials('kg');
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null;
} };
+77
View File
@@ -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
}
+8
View File
@@ -17,6 +17,14 @@ declare module '../common/request.js' {
export function httpFetch(url: string, options?: Record<string, any>): HttpRequestObject 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' { declare module './kw.js' {
const mod: { getMusicUrl(songInfo: any, type: string): any; default: any } const mod: { getMusicUrl(songInfo: any, type: string): any; default: any }
export = mod export = mod