diff --git a/musicSdk/wy/utils/index.ts b/musicSdk/wy/utils/index.ts index 35ebc87..ec91b07 100644 --- a/musicSdk/wy/utils/index.ts +++ b/musicSdk/wy/utils/index.ts @@ -1,6 +1,6 @@ import { httpFetch } from '../../request.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' export const eapiRequest = (url: string, data: object): HttpRequestObject => { diff --git a/src/apis/kg.js b/src/apis/kg.js index 5f741af..1af13da 100644 --- a/src/apis/kg.js +++ b/src/apis/kg.js @@ -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 } diff --git a/src/auth/kg.js b/src/auth/kg.js index 74f8c74..dafefe9 100644 --- a/src/auth/kg.js +++ b/src/auth/kg.js @@ -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() diff --git a/src/auth/store.d.ts b/src/auth/store.d.ts deleted file mode 100644 index f5fc9bf..0000000 --- a/src/auth/store.d.ts +++ /dev/null @@ -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 diff --git a/src/auth/store.js b/src/auth/store.js index 805ef90..ef630b0 100644 --- a/src/auth/store.js +++ b/src/auth/store.js @@ -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; +}; diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 0000000..1b2208e --- /dev/null +++ b/src/auth/store.ts @@ -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 = {} +let loaded = false + +const load = (): Record => { + 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): 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): 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 +} diff --git a/src/types/modules.d.ts b/src/types/modules.d.ts index c393fb4..e404bf9 100644 --- a/src/types/modules.d.ts +++ b/src/types/modules.d.ts @@ -17,6 +17,14 @@ declare module '../common/request.js' { export function httpFetch(url: string, options?: Record): 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