完成网络音乐、工具页面与安装器体验升级

This commit is contained in:
2026-08-18 13:54:33 +08:00
parent 337390f53e
commit 149c28082d
1082 changed files with 26442 additions and 162838 deletions
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"wx_appid": "wx79f2c4418704b4f8",
"wx_lite_appid": "wx72b795aca60ad321",
"wx_secret": "4efcab88b700769e376e3f6087b8abc9",
"wx_lite_secret": "33e486041e5e25729a4e3d2da7502f9a",
"srcappid": 2919,
"appid": 1005,
"apiver": 20,
"clientver": 20489,
"liteAppid": 3116,
"liteClientver": 11440
}
+318
View File
@@ -0,0 +1,318 @@
const CryptoJS = require('crypto-js');
const forge = require('node-forge');
const { randomString } = require('./util');
const publicRasKey = `-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB\n-----END PUBLIC KEY-----`;
const publicLiteRasKey = `-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDECi0Np2UR87scwrvTr72L6oO01rBbbBPriSDFPxr3Z5syug0O24QyQO8bg27+0+4kBzTBTBOZ/WWU0WryL1JSXRTXLgFVxtzIY41Pe7lPOgsfTCn5kZcvKhYKJesKnnJDNr5/abvTGf+rHG3YRwsCHcQ08/q6ifSioBszvb3QiwIDAQAB\n-----END PUBLIC KEY-----`;
const rsaKeyCache = new Map();
function encodeUtf8(str) {
if (typeof TextEncoder !== 'undefined') {
return new TextEncoder().encode(str);
}
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(str, 'utf8'));
}
const codePoints = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
const next = str.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
code = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
i++;
}
}
codePoints.push(code);
}
const bytes = [];
for (const code of codePoints) {
if (code <= 0x7f) {
bytes.push(code);
} else if (code <= 0x7ff) {
bytes.push(
0xc0 | (code >> 6),
0x80 | (code & 0x3f),
);
} else if (code <= 0xffff) {
bytes.push(
0xe0 | (code >> 12),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
} else {
bytes.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
}
}
return new Uint8Array(bytes);
}
function decodeUtf8(uint8) {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder().decode(uint8);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(uint8).toString('utf8');
}
let out = '';
let i = 0;
while (i < uint8.length) {
const byte1 = uint8[i++];
if (byte1 < 0x80) {
out += String.fromCharCode(byte1);
continue;
}
if (byte1 < 0xe0) {
const byte2 = uint8[i++] & 0x3f;
const codePoint = ((byte1 & 0x1f) << 6) | byte2;
out += String.fromCharCode(codePoint);
continue;
}
if (byte1 < 0xf0) {
const byte2 = uint8[i++] & 0x3f;
const byte3 = uint8[i++] & 0x3f;
const codePoint = ((byte1 & 0x0f) << 12) | (byte2 << 6) | byte3;
out += String.fromCharCode(codePoint);
continue;
}
const byte2 = uint8[i++] & 0x3f;
const byte3 = uint8[i++] & 0x3f;
const byte4 = uint8[i++] & 0x3f;
let codePoint = ((byte1 & 0x07) << 18) | (byte2 << 12) | (byte3 << 6) | byte4;
codePoint -= 0x10000;
out += String.fromCharCode(
(codePoint >> 10) + 0xd800,
(codePoint & 0x3ff) + 0xdc00,
);
}
return out;
}
function normalizeBuffer(data) {
if (data instanceof Uint8Array) return data;
const str = typeof data === 'string' ? data : JSON.stringify(data);
return encodeUtf8(str);
}
function wordArrayFromBuffer(uint8) {
const words = [];
for (let i = 0; i < uint8.length; i += 4) {
words.push(
((uint8[i] || 0) << 24) | ((uint8[i + 1] || 0) << 16) |
((uint8[i + 2] || 0) << 8) | (uint8[i + 3] || 0)
);
}
return CryptoJS.lib.WordArray.create(words, uint8.length);
}
function wordArrayToBuffer(wordArray) {
const { words, sigBytes } = wordArray;
const uint8 = new Uint8Array(sigBytes);
for (let i = 0; i < sigBytes; i++) {
uint8[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
}
return uint8;
}
function uint8ArrayToHex(arr) {
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
function utf8WordArray(input) {
return typeof input === 'string' ? CryptoJS.enc.Utf8.parse(input) : wordArrayFromBuffer(input);
}
function getForgePublicKey(pem) {
if (!rsaKeyCache.has(pem)) {
rsaKeyCache.set(pem, forge.pki.publicKeyFromPem(pem));
}
return rsaKeyCache.get(pem);
}
function bufferToBinaryString(buffer) {
let out = '';
for (let i = 0; i < buffer.length; i++) out += String.fromCharCode(buffer[i]);
return out;
}
function rsaRawEncrypt(buffer, publicKey) {
const keyLength = Math.ceil(publicKey.n.bitLength() / 8);
const message = new forge.jsbn.BigInteger(uint8ArrayToHex(buffer), 16);
const encrypted = message.modPow(publicKey.e, publicKey.n);
return encrypted.toString(16).padStart(keyLength * 2, '0');
}
/**
* @typedef {{str: string, key: string}} AesEncrypt
*/
/**
* MD5 加密
* @param {BufferLike} data
* @returns {string}
*/
function cryptoMd5(data) {
const buffer = typeof data === 'object' ? JSON.stringify(data) : data;
return CryptoJS.MD5(buffer).toString(CryptoJS.enc.Hex);
}
/**
* Sha1 加密
* @param {BufferLike} data
* @returns { string }
*/
function cryptoSha1(data) {
const buffer = typeof data === 'object' ? JSON.stringify(data) : data;
return CryptoJS.SHA1(buffer).toString(CryptoJS.enc.Hex);
}
/**
* AES 加密
* @param {BufferLike} data 需要加密的数据
* @param {{ key?:string, iv?: string } | undefined} opt
* @returns {AesEncrypt | string}
*/
function cryptoAesEncrypt(data, opt) {
if (typeof data === 'object') data = JSON.stringify(data);
const buffer = normalizeBuffer(data);
let key;
let iv;
let tempKey = '';
if (opt?.key && opt?.iv) {
key = opt.key;
iv = opt.iv;
} else {
tempKey = opt?.key || randomString(16).toLowerCase();
key = cryptoMd5(tempKey).substring(0, 32);
iv = key.substring(key.length - 16);
}
const encrypted = CryptoJS.AES.encrypt(wordArrayFromBuffer(buffer), utf8WordArray(key), {
iv: utf8WordArray(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const hex = CryptoJS.enc.Hex.stringify(encrypted.ciphertext);
if (opt?.key && opt?.key) return hex;
return { str: hex, key: tempKey };
}
/**
* AES 解密
* @param {string} data
* @param {string} key
* @param {string?} iv
* @returns {string | Record<string, string>}
*/
function cryptoAesDecrypt(data, key, iv) {
if (!iv) key = cryptoMd5(key).substring(0, 32);
iv = iv || key.substring(key.length - 16);
const cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Hex.parse(data) });
const decrypted = CryptoJS.AES.decrypt(cipherParams, utf8WordArray(key), {
iv: utf8WordArray(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const text = decodeUtf8(wordArrayToBuffer(decrypted));
try {
return JSON.parse(text);
} catch (e) {
return text;
}
}
/**
* RSA加密
* @param {BufferLike} data
* @param {string?} publicKey
* @returns {string} hex
*/
function cryptoRSAEncrypt(data, publicKey) {
const isLite = process.env.platform === 'lite';
const buffer = normalizeBuffer(data);
const pem = publicKey || (isLite ? publicLiteRasKey : publicRasKey);
const key = getForgePublicKey(pem);
const keyLength = Math.ceil(key.n.bitLength() / 8);
if (buffer.length > keyLength) throw new Error('Data length exceeds key size');
let padded = buffer;
if (buffer.length < keyLength) {
padded = new Uint8Array(keyLength);
padded.set(buffer);
}
return rsaRawEncrypt(padded, key);
}
function rsaEncrypt2(data) {
const isLite = process.env.platform === 'lite';
const buffer = normalizeBuffer(data);
const key = getForgePublicKey(isLite ? publicLiteRasKey : publicRasKey);
const encrypted = key.encrypt(bufferToBinaryString(buffer), 'RSAES-PKCS1-V1_5');
return forge.util.bytesToHex(encrypted);
}
function playlistAesEncrypt(data) {
const useData = typeof data === 'object' ? JSON.stringify(data) : data;
const key = randomString(6).toLowerCase();
const encryptKey = cryptoMd5(key).substring(0, 16);
const iv = cryptoMd5(key).substring(16, 32);
const encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(useData), utf8WordArray(encryptKey), {
iv: utf8WordArray(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return { key, str: CryptoJS.enc.Base64.stringify(encrypted.ciphertext) };
}
function playlistAesDecrypt(data) {
const encryptKey = cryptoMd5(data.key).substring(0, 16);
const iv = cryptoMd5(data.key).substring(16, 32);
const cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Base64.parse(data.str) });
const decrypted = CryptoJS.AES.decrypt(cipherParams, utf8WordArray(encryptKey), {
iv: utf8WordArray(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const text = decodeUtf8(wordArrayToBuffer(decrypted));
try {
return JSON.parse(text);
} catch (e) {
return text;
}
}
module.exports = {
cryptoAesDecrypt,
cryptoAesEncrypt,
cryptoMd5,
cryptoRSAEncrypt,
rsaEncrypt2,
cryptoSha1,
playlistAesEncrypt,
playlistAesDecrypt,
publicLiteRasKey,
publicRasKey,
wordArrayFromBuffer,
};
+351
View File
@@ -0,0 +1,351 @@
/**
* @fileoverview 酷狗音乐 API 行为指纹模拟生成器(Node.js 版)
*
* 本模块用于在服务端生成模拟的用户行为指纹数据,替代浏览器端 WASM 的功能。
* 主要用于自动化请求场景(如批量登录、接口调用等),绕过酷狗的行为检测机制。
*
* 核心功能:
* 1. 生成模拟的鼠标移动轨迹(贝塞尔曲线 + 随机抖动)
* 2. 生成模拟的页面交互事件(滚动、窗口 resize 等)
* 3. 使用 AES-128-CBC 加密行为数据得到 EDTEncrypted Data Token
* 4. 使用 RSA-OAEP SHA-256 加密 AES 密钥得到 SIDSession ID
* 5. 服务端用 RSA 私钥解密 SID 得到 AES 密钥,再用 AES 密钥解密 EDT 还原行为数据
*
* 加密方案:
* 明文(行为数据)→ AES-128-CBC 加密 → EDTBase64
* AES 密钥 → RSA-OAEP SHA-256 加密 → SIDBase64
*
* @module generate_simulate
* @requires crypto-js - AES 加密库
* @requires node-forge - RSA 加密库
* @requires ./util - 工具函数(randomString
*/
const { randomString } = require('./util');
const CryptoJS = require('crypto-js');
const forge = require('node-forge');
/**
* RSA 公钥(PEM 格式)
* 从酷狗 WASM 二进制中提取的 SPKI 公钥,用于 RSA-OAEP SHA-256 加密 AES 密钥
* 算法: RSA-2048,公钥指数 65537 (0x10001)
*
* 服务端持有对应的私钥,用于解密 SID 获取 AES 密钥
*/
const publicKey = `-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF\nlFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/\nW64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA\n2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW\nPmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc\n56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa\nrQIDAQAB\n-----END PUBLIC KEY-----`;
/**
* AES 初始化向量(固定值)
* ASCII 解码为 "kugousecurity123"
* 与浏览器端 WASM 中硬编码的 IV 一致,每次加密都使用相同的 IV
* @type {string}
*/
const iv = 'kugousecurity123';
/**
* 哨兵值(接近 0xFFFFFFFF 的随机值)
* WASM 中每条事件记录后都会跟一条哨兵记录,用于标记事件结束或表示"无数据"
* 每次调用 generateSimulate 时会重新生成,增加指纹随机性
* @type {number}
*/
let SENTINEL = 0xffffffff - Math.floor(Math.random() * 20);
/**
* 生成 EDT 中的 data 字段(用户行为指纹数据)
*
* 模拟真实用户在页面上的交互行为,包括:
* - 窗口加载/resize 事件(type 6
* - 页面滚动事件(type 5)
* - 鼠标移动轨迹(type 3,贝塞尔曲线生成)
*
* 事件编码格式:各条目用冒号 `:` 分隔,每个条目的字段用逗号 `,` 分隔
* - type-3(鼠标移动): "3,时间差,子索引,X,Y"
* - type-5(滚动/计时): "5,时间差,事件索引"
* - type-6(窗口事件): "6,时间差,事件索引,宽,高"
* - 哨兵记录: 时间差字段替换为 SENTINEL 值
*
* @param {Object} opts - 配置项
* @param {number} opts.startX - 鼠标起点 X 坐标
* @param {number} opts.startY - 鼠标起点 Y 坐标
* @param {number} opts.endX - 鼠标终点 X 坐标
* @param {number} opts.endY - 鼠标终点 Y 坐标
* @param {number} opts.mousePoints - 鼠标轨迹采样点数
* @returns {string} 编码后的 data 字段字符串,各事件条目用 `:` 分隔
*/
function generateEDTData(opts) {
const { startX, startY, endX, endY, mousePoints } = opts;
const entries = []; // 所有事件条目
let ts = 0; // 累计时间戳(毫秒),从 0 开始递增
let ei = 0; // 全局事件索引(用于 type-5/6 事件的标识)
// --- 初始化: 两个 type-5 零事件 ---
// 模拟 WASM 启动时记录的初始事件,每个事件后跟一条哨兵记录
entries.push(f5(0, 0));
entries.push(fs5(0));
entries.push(f5(0, 0));
entries.push(fs5(0));
// --- 窗口事件 (type 6) ---
// 模拟窗口加载/resize 事件,窗口尺寸设为 750x500(模拟移动端页面)
ts += ri(5, 20); // 随机延迟 5-20ms(模拟页面加载耗时)
entries.push(f6(ts, ei, 750, 500)); // 窗口事件记录
entries.push(fs6(ei, 750, 500)); // 对应的哨兵记录
ei++;
// --- 滚动事件 (type 5) ---
// 模拟用户滚动页面的行为(3 次滚动,间隔不均匀)
for (let i = 0; i < 3; i++) {
ts += ri(80, 600); // 滚动间隔 80-600ms(模拟不均匀的滚动节奏)
entries.push(f5(ts, ei));
entries.push(fs5(ei));
ei++;
}
// --- 鼠标轨迹 (type 3) ---
// 用三阶贝塞尔曲线生成平滑的鼠标移动路径
const path = bezierPath(startX, startY, endX, endY, mousePoints);
let si = 0; // 子索引(0 或 1,交替变化)
for (let i = 0; i < path.length; i++) {
const { x, y } = path[i];
ts += ri(8, 50); // 鼠标移动间隔 8-50ms
entries.push(f3(ts, si, Math.round(x), Math.round(y)));
entries.push(fs3(si, Math.round(x), Math.round(y)));
// 每隔 12 帧插入一个滚动事件,模拟边滚动边移动鼠标的真实行为
if (i > 0 && i % 12 === 0) {
ts += ri(20, 60);
entries.push(f5(ts, ei));
entries.push(fs5(ei));
ei++;
}
si = (si + 1) % 2; // 子索引在 0 和 1 之间交替
}
// --- 结束事件 ---
// 最后一个鼠标位置,带微小随机偏移(模拟点击前的微调)
ts += ri(5, 30);
entries.push(f3(ts, 1, Math.round(endX + ri(-5, 5)), Math.round(endY + ri(-5, 5))));
entries.push(fs3(1, Math.round(endX), Math.round(endY)));
return entries.join(':');
}
// ============================================================
// 贝塞尔曲线鼠标路径生成
// ============================================================
/**
* 用三阶贝塞尔曲线生成模拟真人的鼠标移动路径
*
* 真人鼠标轨迹特点:
* - 不是直线,有弧度和加速减速
* - 有微小抖动(手抖),起步时抖动大,移动后趋于稳定
* - 起步慢、中间快、结束减速(由贝塞尔曲线的参数 t 均匀采样自然实现)
*
* 三阶贝塞尔公式: B(t) = (1-t)³·P0 + 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³·P3
* 其中 P0=起点, P3=终点, P1/P2=两个随机控制点
*
* @param {number} sx - 起点 X
* @param {number} sy - 起点 Y
* @param {number} ex - 终点 X
* @param {number} ey - 终点 Y
* @param {number} n - 采样点数(越多轨迹越平滑)
* @returns {Array<{x:number, y:number}>} 路径点数组,长度为 n+1
*/
function bezierPath(sx, sy, ex, ey, n) {
// 生成两个随机控制点,使路径不是直线而是有弧度的曲线
// 控制点在起点到终点的连线附近随机偏移
const c1x = sx + (ex - sx) * 0.3 + ri(-80, 80); // 第一个控制点 X(起点 30% 处 + 随机偏移)
const c1y = sy + (ey - sy) * 0.2 + ri(-60, 60); // 第一个控制点 Y(起点 20% 处 + 随机偏移)
const c2x = sx + (ex - sx) * 0.7 + ri(-60, 60); // 第二个控制点 X(起点 70% 处 + 随机偏移)
const c2y = sy + (ey - sy) * 0.8 + ri(-40, 40); // 第二个控制点 Y(起点 80% 处 + 随机偏移)
const pts = [];
for (let i = 0; i <= n; i++) {
const t = i / n; // 参数 t 从 0 到 1,均匀采样
const u = 1 - t; // 1 - t,用于公式简化
// 三阶贝塞尔公式展开计算
const x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex;
const y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey;
// 抖动幅度: 起点大(3px),逐渐减小到 0.5px(模拟起步时手抖,移动后趋于稳定)
const jitter = Math.max(0.5, 3 - t * 2.5);
pts.push({
x: x + (Math.random() - 0.5) * jitter, // X 方向随机抖动
y: y + (Math.random() - 0.5) * jitter, // Y 方向随机抖动
});
}
return pts;
}
// ============================================================
// 事件记录格式化函数
// ============================================================
/**
* 格式化 type-3 事件(鼠标/触摸移动)
* @param {number} t - 时间差(毫秒)
* @param {number} i - 子索引(0 或 1,交替变化)
* @param {number} x - 鼠标 X 坐标
* @param {number} y - 鼠标 Y 坐标
* @returns {string} 格式: "3,时间差,子索引,X,Y"
*/
function f3(t, i, x, y) {
return `3,${t},${i},${x},${y}`;
}
/**
* 格式化 type-5 事件(滚动/计时)
* @param {number} t - 时间差(毫秒)
* @param {number} i - 事件索引
* @returns {string} 格式: "5,时间差,事件索引"
*/
function f5(t, i) {
return `5,${t},${i}`;
}
/**
* 格式化 type-6 事件(窗口事件)
* @param {number} t - 时间差(毫秒)
* @param {number} i - 事件索引
* @param {number} x - 窗口宽度
* @param {number} y - 窗口高度
* @returns {string} 格式: "6,时间差,事件索引,宽,高"
*/
function f6(t, i, x, y) {
return `6,${t},${i},${x},${y}`;
}
/**
* 格式化 type-3 哨兵记录(鼠标事件结束标记)
* 哨兵记录的时间差字段使用 SENTINEL 值,标记该类型事件序列的结束
* @param {number} i - 子索引
* @param {number} x - X 坐标
* @param {number} y - Y 坐标
* @returns {string} 格式: "3,SENTINEL,子索引,X,Y"
*/
function fs3(i, x, y) {
return `3,${SENTINEL},${i},${x},${y}`;
}
/**
* 格式化 type-5 哨兵记录(滚动事件结束标记)
* @param {number} i - 事件索引
* @returns {string} 格式: "5,SENTINEL,事件索引"
*/
function fs5(i) {
return `5,${SENTINEL},${i}`;
}
/**
* 格式化 type-6 哨兵记录(窗口事件结束标记)
* @param {number} i - 事件索引
* @param {number} x - 窗口宽度
* @param {number} y - 窗口高度
* @returns {string} 格式: "6,SENTINEL,事件索引,宽,高"
*/
function fs6(i, x, y) {
return `6,${SENTINEL},${i},${x},${y}`;
}
/**
* 生成 [min, max] 范围内的随机整数(包含两端)
* @param {number} min - 最小值
* @param {number} max - 最大值
* @returns {number} 随机整数
*/
function ri(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// ============================================================
// 核心导出函数
// ============================================================
/**
* 生成模拟的 sid 和 edt 加密数据
*
* 完整流程:
* 1. 生成随机 AES-128 密钥(16 字节,取 MD5 哈希的前 16 字符)
* 2. 随机化鼠标轨迹参数(起点、终点、采样点数)
* 3. 生成模拟行为数据(鼠标轨迹 + 滚动 + 窗口事件)
* 4. 拼接完整明文: mid=xxx;userid=xxx;dfid=xxx;webgl=xxx;webdriver=0;ts=xxx;data=xxx
* 5. AES-128-CBC 加密明文 → EDTBase64
* 6. RSA-OAEP SHA-256 加密 AES 密钥 → SIDBase64
*
* @param {string|number} mid - 设备 MID 标识,不存在时默认 0
* @param {string|number} userid - 用户 ID,不存在时默认 0
* @param {string|number} dfid - 设备指纹 ID(由 register_dev 接口返回),不存在时默认 0
* @param {string} [webglHash] - WebGL 指纹哈希,不传时自动生成
* @returns {{ edt: string, sid: string }} 加密后的数据对象
* - edt: AES-128-CBC 加密后的行为数据(Base64
* - sid: RSA-OAEP 加密后的 AES 密钥(Base64
*/
const generateSimulate = (mid, userid, dfid, webglHash) => {
// 每次调用重新生成哨兵值,增加指纹随机性
SENTINEL = 0xffffffff - Math.floor(Math.random() * 20);
// 生成随机 AES-128 密钥:先生成 16 字节随机字符串,取其 MD5 哈希的前 16 字符
const key = CryptoJS.MD5(randomString(16)).toString(CryptoJS.enc.Hex).substring(0, 16);
// 随机化鼠标轨迹参数,使每次请求的行为指纹不同
const points = ri(30, 60); // 鼠标轨迹采样点数(30~60 个点)
const startX = ri(200, 600); // 鼠标起点 X(页面中部区域)
const startY = ri(200, 500); // 鼠标起点 Y
const endX = ri(500, 700); // 鼠标终点 X(登录按钮附近)
const endY = ri(80, 150); // 鼠标终点 Y
// 参数默认值处理
mid = mid || 0;
userid = userid || 0;
dfid = dfid || 0;
webglHash = webglHash || generateWebGLHash();
const ts = Date.now(); // 当前时间戳
// 生成行为数据(鼠标轨迹 + 滚动 + 窗口事件)
const data = generateEDTData({ startX, startY, endX, endY, mousePoints: points });
// 拼接完整明文
// 格式: mid=xxx;userid=xxx;dfid=xxx;webgl=xxx;webdriver=0;ts=xxx;data=xxx
// - mid: 设备标识
// - userid: 用户 ID
// - dfid: 设备指纹 ID
// - webgl: WebGL 渲染器指纹哈希
// - webdriver: 是否为自动化驱动(0 表示否)
// - ts: 时间戳
// - data: 行为事件数据
const sidPlaintext = `mid=${mid};userid=${userid};dfid=${dfid};webgl=${webglHash};webdriver=0;ts=${ts};data=${data}`;
console.log(sidPlaintext);
// 第1步: AES-128-CBC 加密行为指纹明文 → EDT
// - 密钥: 随机生成的 16 字符字符串
// - IV: 固定值 "kugousecurity123"
// - 填充: PKCS7
// - 输出: Base64 编码的密文
const edtData = CryptoJS.AES.encrypt(sidPlaintext, CryptoJS.enc.Utf8.parse(key), {
iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
}).toString();
// 第2步: RSA-OAEP SHA-256 加密 AES 密钥 → SID
// - 使用酷狗服务器的 RSA 公钥加密 AES 密钥
// - 哈希算法: SHA-256
// - MGF1 哈希: SHA-256(与主哈希一致)
// - 输出: Base64 编码的密文
const rsaKey = forge.pki.publicKeyFromPem(publicKey);
const encrypted = rsaKey.encrypt(key, 'RSA-OAEP', {
md: forge.md.sha256.create(), // 主哈希算法
mgf1: { md: forge.md.sha256.create() }, // MGF1 掩码生成函数的哈希算法
});
const ciphertext = forge.util.encode64(encrypted); // Base64 编码
// 返回 EDT(加密的行为数据)和 SID(加密的 AES 密钥)
return { edt: edtData, sid: ciphertext };
};
module.exports = { generateSimulate };
+188
View File
@@ -0,0 +1,188 @@
/**
* @fileoverview 酷狗音乐 API 请求签名工具
*
* 本模块提供多种签名算法,用于对 API 请求参数进行加密签名,
* 确保请求的合法性和防篡改。酷狗服务端会验证这些签名。
*
* 签名类型:
* - signatureWebParams: Web 版 API 请求签名
* - signatureAndroidParams: Android 版 API 请求签名(支持标准版/概念版 lite)
* - signatureRegisterParams: 设备注册接口签名
* - signParams: 通用 sign 签名
* - signKey: 请求密钥签名(区分平台)
* - signCloudKey: 云盘接口密钥签名
* - signParamsKey: 参数密钥签名(区分平台)
*
* 所有签名算法均基于 MD5 哈希,通过将盐值(salt)+ 参数拼接后取 MD5 实现。
*
* @module helper
* @requires ./crypto - MD5 加密函数
* @requires ./config.json - 平台配置(appid、clientver 等)
*/
const CryptoJS = require('crypto-js');
const { cryptoMd5, wordArrayFromBuffer } = require('./crypto');
const { appid: useAppid, liteAppid, clientver: useClientver, liteClientver } = require('./config.json');
/**
* Web 版 API 请求 signature 签名
*
* 签名算法:
* 1. 将所有参数按 key=value 格式拼接
* 2. 对参数字符串按字母顺序排序
* 3. 拼接为: 盐值 + 排序后的参数串 + 盐值
* 4. 对整体取 MD5 哈希
*
* @param {Object} params - 请求参数键值对
* @returns {string} MD5 签名字符串(32位小写hex)
*/
const signatureWebParams = (params) => {
const str = 'NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt'; // Web 版签名盐值
const paramsString = Object.keys(params)
.map((key) => `${key}=${params[key]}`)
.sort() // 按 key 字母排序
.join(''); // 拼接为连续字符串
return cryptoMd5(`${str}${paramsString}${str}`);
};
/**
* Android 版 API 请求 signature 签名
*
* 与 Web 版的区别:
* - 盐值不同,且区分标准版和概念版(lite)
* - 支持附加请求体数据(data 参数)到签名中
* - 参数值为对象时会先 JSON.stringify
*
* @param {Object} params - 请求参数键值对
* @param {string} [data] - 可选的请求体数据(如 POST body)
* @returns {string} MD5 签名字符串
*/
const signatureAndroidParams = (params, data) => {
const isLite = process.env.platform === 'lite';
const str = isLite ? 'LnT6xpN3khm36zse0QzvmgTZ3waWdRSA' : `OIlwieks28dk2k092lksi2UIkp`;
const paramsString = Object.keys(params)
.sort()
.map((key) => `${key}=${typeof params[key] === 'object' ? JSON.stringify(params[key]) : params[key]}`)
.join('');
if (Buffer.isBuffer(data)) {
const hasher = CryptoJS.algo.MD5.create();
hasher.update(CryptoJS.enc.Utf8.parse(str));
hasher.update(CryptoJS.enc.Utf8.parse(paramsString));
hasher.update(wordArrayFromBuffer(data));
hasher.update(CryptoJS.enc.Utf8.parse(str));
return hasher.finalize().toString(CryptoJS.enc.Hex);
}
return cryptoMd5(`${str}${paramsString}${data || ''}${str}`);
};
/**
* 设备注册接口(register_devsignature 签名
*
* 签名算法:
* 1. 提取所有参数的值(忽略 key)
* 2. 对值按字母排序
* 3. 拼接为: "1014" + 排序后的值串 + "1014"
* 4. 对整体取 MD5 哈希
*
* @param {Object} params - 请求参数键值对
* @returns {string} MD5 签名字符串
*/
const signatureRegisterParams = (params) => {
const paramsString = Object.keys(params)
.map((key) => params[key]) // 只取值,忽略 key
.sort()
.join('');
return cryptoMd5(`1014${paramsString}1014`); // 盐值为 "1014"
};
/**
* 通用 sign 签名
*
* 签名算法:
* 1. 参数按 key 排序
* 2. 每个参数拼接为 key+value(无等号)
* 3. 拼接为: 排序后的参数串 + 请求体数据 + 盐值
* 4. 对整体取 MD5 哈希
*
* @param {Object} params - 请求参数键值对
* @param {string} [data] - 可选的请求体数据
* @returns {string} MD5 签名字符串
*/
const signParams = (params, data) => {
const str = 'R6snCXJgbCaj9WFRJKefTMIFp0ey6Gza'; // 签名盐值
const paramsString = Object.keys(params)
.sort()
.map((key) => `${key}${params[key]}`) // key+value 无等号
.join('');
return cryptoMd5(`${paramsString}${data || ''}${str}`);
};
/**
* 请求密钥签名(signKey
*
* 用于生成请求的 signKey 参数,区分标准版和概念版。
* 签名算法:MD5(hash + 盐值 + appid + mid + userid)
*
* @param {string} hash - 请求哈希值
* @param {string} mid - 设备 MID 标识
* @param {(string|number)} [userid] - 用户 ID,默认 0
* @param {(string|number)} [appid] - 应用 ID,默认使用配置文件中的值
* @returns {string} MD5 签名字符串
*/
const signKey = (hash, mid, userid, appid) => {
const isLite = process.env.platform === 'lite';
// 标准版和概念版使用不同的盐值
const str = isLite ? '185672dd44712f60bb1736df5a377e82' : '57ae12eb6890223e355ccfcb74edf70d';
return cryptoMd5(`${hash}${str}${appid || useAppid}${mid}${userid || 0}`);
};
/**
* 云盘接口密钥签名(signCloudKey
*
* 用于云盘相关接口的签名验证。
* 签名算法:MD5("musicclound" + hash + pid + 盐值)
*
* @param {string} hash - 请求哈希值
* @param {string} pid - 云盘资源 PID
* @returns {string} MD5 签名字符串
*/
const signCloudKey = (hash, pid) => {
const str = 'ebd1ac3134c880bda6a2194537843caa0162e2e7'; // 云盘签名盐值
return cryptoMd5(`musicclound${hash}${pid}${str}`);
};
/**
* 参数密钥签名(signParamsKey
*
* 用于生成 sign 参数,区分标准版和概念版。
* 签名算法:MD5(appid + 盐值 + clientver + data)
*
* @param {string|number} data - 签名数据(通常为请求哈希或时间戳)
* @param {(string|number)} [appid] - 应用 ID,默认使用配置文件中的值
* @param {(string|number)} [clientver] - 客户端版本号,默认使用配置文件中的值
* @returns {string} MD5 签名字符串
*/
const signParamsKey = (data, appid, clientver) => {
const isLite = process.env.platform === 'lite';
// 标准版和概念版使用不同的盐值
const str = isLite ? 'LnT6xpN3khm36zse0QzvmgTZ3waWdRSA' : 'OIlwieks28dk2k092lksi2UIkp';
// 根据平台选择默认的 appid
appid = appid || (isLite ? liteAppid : useAppid);
// 根据平台选择默认的 clientver
clientver = clientver || (isLite ? liteClientver : useClientver);
return cryptoMd5(`${appid}${str}${clientver}${data}`);
};
module.exports = {
signKey,
signParams,
signParamsKey,
signCloudKey,
signatureAndroidParams,
signatureRegisterParams,
signatureWebParams,
};
+99
View File
@@ -0,0 +1,99 @@
/**
* @fileoverview 工具模块统一导出入口
*
* 本文件是 util 目录的索引模块,负责:
* 1. 从各子模块导入所有工具函数和常量
* 2. 根据当前平台(标准版/概念版 lite)选择对应的配置值
* 3. 统一导出供 API 模块使用
*
* API 模块(module/ 目录下的文件)通过 `require('../util')` 引用本文件,
* 即可获得所有需要的工具函数和配置常量。
*
* @module util/index
*/
// ========== 配置常量 ==========
const { apiver, appid, wx_appid, wx_lite_appid, wx_secret, wx_lite_secret, srcappid, clientver, liteAppid, liteClientver } = require('./config.json');
// ========== 加密函数 ==========
const {
cryptoAesDecrypt, // AES 解密
cryptoAesEncrypt, // AES 加密
cryptoMd5, // MD5 哈希
cryptoRSAEncrypt, // RSA 加密
cryptoSha1, // SHA1 哈希
rsaEncrypt2, // RSA 加密 v2(用于 register_dev 等接口)
playlistAesEncrypt, // 歌单 AES 加密
playlistAesDecrypt, // 歌单 AES 解密
publicLiteRasKey, // 概念版 RSA 公钥
publicRasKey, // 标准版 RSA 公钥
} = require('./crypto');
// ========== 请求函数 ==========
const { createRequest } = require('./request');
// ========== 签名函数 ==========
const { signKey, signParams, signParamsKey, signCloudKey, signatureAndroidParams, signatureRegisterParams, signatureWebParams } = require('./helper');
// ========== 工具函数 ==========
const { randomString, decodeLyrics, parseCookieString, cookieToJson, randomNumber, calculateMid, isUUIDv4 } = require('./util');
// ========== 平台判断 ==========
// 根据环境变量 platform 判断当前是否为概念版(lite)
const isLite = process.env.platform === 'lite';
// 根据平台选择对应的 appid 和 clientver
const useAppid = isLite ? liteAppid : appid;
const useClientver = isLite ? liteClientver : clientver;
/**
* 统一导出所有工具函数和配置常量
*
* API 模块通过 `const { xxx } = require('../util')` 按需引入。
*/
module.exports = {
// --- 配置常量 ---
apiver, // API 版本号
appid: useAppid, // 应用 ID(根据平台自动选择)
// liteAppid, // 概念版应用 ID(注释掉,不对外暴露)
// liteClientver, // 概念版客户端版本号(注释掉,不对外暴露)
wx_appid, // 微信小程序应用 ID
wx_lite_appid, // 微信概念版小程序应用 ID
wx_secret, // 微信小程序密钥
wx_lite_secret, // 微信概念版小程序密钥
srcappid, // 来源应用 ID
clientver: useClientver, // 客户端版本号(根据平台自动选择)
isLite, // 是否为概念版
// --- 加密函数 ---
cryptoAesDecrypt, // AES 解密
cryptoAesEncrypt, // AES 加密
cryptoMd5, // MD5 哈希
cryptoRSAEncrypt, // RSA 加密
cryptoSha1, // SHA1 哈希
rsaEncrypt2, // RSA 加密 v2
playlistAesEncrypt, // 歌单 AES 加密
playlistAesDecrypt, // 歌单 AES 解密
// --- 请求函数 ---
createRequest, // 创建 HTTP 请求
// --- 签名函数 ---
signKey, // 请求密钥签名
signParams, // 通用 sign 签名
signParamsKey, // 参数密钥签名
signCloudKey, // 云盘接口密钥签名
signatureAndroidParams, // Android 版 signature 签名
signatureRegisterParams, // 设备注册 signature 签名
signatureWebParams, // Web 版 signature 签名
// --- 工具函数 ---
randomString, // 随机字符串生成
decodeLyrics, // KRC 歌词解码
parseCookieString, // Cookie 字符串格式化
cookieToJson, // Cookie 字符串转 JSON
publicLiteRasKey, // 概念版 RSA 公钥
publicRasKey, // 标准版 RSA 公钥
randomNumber, // 随机数字字符串生成
calculateMid, // 设备 MID 计算
isUUIDv4 // 判断是否为 UUID V4
};
+107
View File
@@ -0,0 +1,107 @@
/**
* @fileoverview 基于内存的缓存实现
*
* 提供简单的键值对缓存,支持:
* - 按时间自动过期(通过 setTimeout 实现)
* - 过期回调通知
* - 批量清除
*
* 作为 apicache 的底层存储引擎使用。
*
* @module memory-cache
*/
/**
* MemoryCache 构造函数
*
* 初始化空的缓存存储和计数器。
* @constructor
*/
function MemoryCache() {
this.cache = {}; // 缓存数据存储 { key: { value, expire, timeout } }
this.size = 0; // 当前缓存条目数
}
/**
* 添加缓存条目
*
* @param {string} key - 缓存键
* @param {*} value - 缓存值(任意类型)
* @param {number} time - 过期时间(毫秒)
* @param {function} [timeoutCallback] - 过期时的回调函数 (value, key) => void
* @returns {Object} 创建的缓存条目 { value, expire, timeout }
*/
MemoryCache.prototype.add = function (key, value, time, timeoutCallback) {
const old = this.cache[key];
const instance = this;
const entry = {
value, // 缓存的值
expire: time + Date.now(), // 过期时间戳(毫秒)
timeout: setTimeout(function () {
// 自动过期:删除条目并触发回调
instance.delete(key);
return timeoutCallback && typeof timeoutCallback === 'function' && timeoutCallback(value, key);
}, time),
};
this.cache[key] = entry;
this.size = Object.keys(this.cache).length;
return entry;
};
/**
* 删除缓存条目
*
* @param {string} key - 缓存键
* @returns {null} 始终返回 null
*/
MemoryCache.prototype.delete = function (key) {
const entry = this.cache[key];
if (entry) clearTimeout(entry.timeout); // 清除自动过期定时器
delete this.cache[key];
this.size = Object.keys(this.cache).length;
return null;
};
/**
* 获取缓存条目(包含元数据)
*
* @param {string} key - 缓存键
* @returns {Object|undefined} 缓存条目 { value, expire, timeout },不存在返回 undefined
*/
MemoryCache.prototype.get = function (key) {
return this.cache[key];
};
/**
* 获取缓存的值(仅返回 value 部分)
*
* @param {string} key - 缓存键
* @returns {*} 缓存的值,不存在返回 undefined
*/
MemoryCache.prototype.getValue = function (key) {
const entry = this.get(key);
return entry && entry.value;
};
/**
* 清除所有缓存条目
*
* 遍历所有键并逐个删除(会清除对应的定时器)。
* @returns {true} 始终返回 true
*/
MemoryCache.prototype.clear = function () {
Object.keys(this.cache).forEach(function (key) {
this.delete(key);
}, this);
return true;
};
module.exports = MemoryCache;
+245
View File
@@ -0,0 +1,245 @@
/**
* @fileoverview 酷狗音乐 API HTTP 请求封装
*
* 本模块是所有 API 请求的底层发送函数,负责:
* 1. 构建请求参数(注入默认设备标识、时间戳等)
* 2. 根据加密类型生成请求签名(signature/sign
* 3. 配置请求头(User-Agent、设备信息、IP 等)
* 4. 发送 HTTP 请求(通过 axios
* 5. 处理响应(解析 Cookie、SSA 验证码、错误处理)
* 6. 在需要二次验证时自动生成模拟行为指纹(sid/edt)
*
* 所有 API 模块(module/ 目录)通过 `useAxios(config)` 调用本函数发送请求。
*
* @module request
* @requires axios - HTTP 客户端
* @requires ./helper - 签名函数
* @requires ./util - 工具函数(parseCookieString
* @requires ./config.json - 平台配置
* @requires ./runtime - 代理配置解析
* @requires ./generate_simulate - 行为指纹模拟生成
*/
const axios = require('axios');
const { signKey, signatureAndroidParams, signatureRegisterParams, signatureWebParams } = require('./helper');
const { parseCookieString } = require('./util');
const { appid, clientver, liteAppid, liteClientver } = require('./config.json');
const { resolveProxy } = require('./runtime');
const { generateSimulate } = require('./generate_simulate');
/**
* @typedef {Object} UseAxiosResponse
* @description API 请求的统一响应格式
* @property {number} status - HTTP 状态码(200=成功,502=失败)
* @property {any} body - 响应体(JSON 对象或原始数据)
* @property {string[]} cookie - 响应中的 Set-Cookie 数组(已格式化)
* @property {Record<string, string>} [headers] - 响应头(如 ssa-code
*/
/**
* 创建并发送 API 请求
*
* 完整流程:
* 1. 从 cookie 中提取设备标识(dfid、mid、uuid、token、userid
* 2. 构建默认请求参数(dfid、mid、uuid、appid、clientver、clienttime
* 3. 根据 encryptType 生成签名(signature
* 4. 配置请求头(User-Agent、设备信息、IP 透传)
* 5. 配置代理(如果设置了 KUGOU_API_PROXY 环境变量)
* 6. 发送请求并处理响应
* 7. 如果需要二次验证(SSA),自动生成模拟行为指纹
*
* @param {Object} options - 请求配置
* @param {'get'|'GET'|'post'|'POST'} options.method - HTTP 请求方法
* @param {string} options.url - 请求路径(如 "/v1/search"
* @param {string} [options.baseURL] - 基础 URL(默认 "https://gateway.kugou.com"
* @param {Record<string, any>} [options.params] - URL 查询参数
* @param {Record<string, any>} [options.data] - 请求体(POST 数据)
* @param {Record<string, string|number>} [options.headers] - 自定义请求头
* @param {'android'|'web'|'register'} options.encryptType - 签名加密方式
* @param {Object} options.cookie - 请求 Cookie 对象
* @param {boolean} [options.encryptKey] - 是否生成 signKey
* @param {boolean} [options.clearDefaultParams] - 是否清除默认参数
* @param {boolean} [options.notSignature] - 是否跳过签名
* @param {string} [options.ip] - 客户端 IP
* @param {string} [options.realIP] - 真实 IP(优先级高于 ip)
* @returns {Promise<UseAxiosResponse>} 统一格式的响应对象
*/
const createRequest = (options) => {
return new Promise(async (resolve, reject) => {
const isLite = process.env.platform === 'lite';
// ========== 从 Cookie 中提取设备标识 ==========
const dfid = options?.cookie?.dfid || '-'; // 设备指纹 IDregister_dev 接口返回)
const mid = `${options?.cookie?.KUGOU_API_MID}`; // 设备 MIDserver.js 通过 calculateMid 生成)
const uuid = '-'; // 设备 UUID(当前固定为 '-'
const token = options?.cookie?.token || ''; // 用户登录令牌
const userid = options?.cookie?.userid || 0; // 用户 ID
const clienttime = Math.floor(Date.now() / 1000); // 当前时间戳(秒)
const ip = options?.realIP || options?.ip || ''; // 客户端 IP(用于 IP 透传)
const webglHash = options?.cookie?.KUGOU_API_WEBGL; // WebGL 指纹哈希
// ========== 构建请求头 ==========
// kg-rc / kg-thash / kg-rec / kg-rf: 酷狗内部标识头,用于服务端识别请求来源
const headers = { dfid, clienttime, mid, 'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': 1, 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F' };
// IP 透传:将客户端真实 IP 通过 X-Real-IP / X-Forwarded-For 传递给酷狗服务端
if (ip) {
headers['X-Real-IP'] = ip;
headers['X-Forwarded-For'] = ip;
}
// ========== 构建默认请求参数 ==========
// 这些参数会自动注入到每个请求中,模拟真实客户端行为
const defaultParams = {
dfid, // 设备指纹 ID
mid, // 设备 MID
uuid, // 设备 UUID
appid: isLite ? liteAppid : appid, // 应用 ID(根据平台选择)
clientver: isLite ? liteClientver : clientver, // 客户端版本号(根据平台选择)
clienttime, // 请求时间戳(秒)
};
// 如果有登录令牌和用户 ID,也加入默认参数
if (token) defaultParams['token'] = token;
if (userid && userid !== 0) defaultParams['userid'] = userid;
// 合并默认参数和自定义参数(clearDefaultParams 为 true 时仅使用自定义参数)
const params = options?.clearDefaultParams ? options?.params || {} : Object.assign({}, defaultParams, options?.params || {});
// 同步 clienttime 到请求头
headers['clienttime'] = params.clienttime;
// ========== 生成 signKey(可选) ==========
// 某些接口需要额外的 key 参数作为签名验证
if (options?.encryptKey) {
params['key'] = signKey(params['hash'], params['mid'], params['userid'], params['appid']);
}
// ========== 序列化请求体 ==========
const data = Buffer.isBuffer(options?.data) ? options.data : typeof options?.data === 'object' ? JSON.stringify(options.data) : options?.data || '';
// ========== 生成请求签名 ==========
// 根据 encryptType 选择不同的签名算法
// - android: Android 版签名(默认,最常用)
// - web: Web 版签名
// - register: 设备注册签名
if (!params['signature'] && !options.notSignature) {
switch (options?.encryptType) {
case 'register':
params['signature'] = signatureRegisterParams(params);
break;
case 'web':
params['signature'] = signatureWebParams(params);
break;
case 'android':
default:
params['signature'] = signatureAndroidParams(params, data);
break;
}
}
// ========== 配置请求选项 ==========
options['params'] = params;
options['baseURL'] = options?.baseURL || 'https://gateway.kugou.com'; // 默认网关地址
options['headers'] = Object.assign({ 'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi' }, options?.headers || {}, {
dfid,
clienttime: params.clienttime,
mid,
});
const requestOptions = {
params,
data: options?.data,
method: options.method,
baseURL: options?.baseURL,
url: options.url,
headers: Object.assign({}, options?.headers || {}, headers),
withCredentials: true, // 携带 Cookie
responseType: options.responseType, // 响应类型(如 'arraybuffer'
};
// ========== 代理配置 ==========
// 如果设置了 KUGOU_API_PROXY 环境变量,使用代理发送请求
const proxyConfig = resolveProxy();
if (proxyConfig) {
requestOptions.proxy = proxyConfig;
}
if (options.data) requestOptions.data = options.data;
if (params) requestOptions.params = params;
// ========== CDN 接口特殊处理 ==========
// openapicdn 基础 URL 的接口需要将参数拼接到 URL 中
if (options.baseURL?.includes('openapicdn')) {
const url = requestOptions.url;
const _params = Object.keys(params)
.map((key) => `${key}=${params[key]}`)
.join('&');
requestOptions.url = `${url}?${_params}`;
requestOptions.params = {};
}
// ========== 发送请求 ==========
const answer = { status: 500, body: {}, cookie: [], headers: {} };
try {
const response = await axios(requestOptions);
let ssaCode = '';
const body = response.data;
// 解析响应中的 Set-Cookie(格式化为干净的 key=value 字符串)
answer.cookie = (response.headers['set-cookie'] || []).map((x) => parseCookieString(x));
// ========== SSA 验证码处理 ==========
// ssa-code 响应头表示需要进行二次安全验证(如滑块验证码、短信验证码)
if (response.headers['ssa-code'] || response.headers['SSA-CODE']) {
const _ssaCode = response.headers['ssa-code'] || response.headers['SSA-CODE'];
answer.headers['ssa-code'] = _ssaCode;
ssaCode = _ssaCode;
}
// 解析响应体为 JSON
try {
answer.body = JSON.parse(body.toString());
} catch (error) {
answer.body = body;
}
// ========== 响应状态判断 ==========
if (response.data.status === 0 || (response.data?.error_code && response.data.error_code !== 0)) {
// 请求失败:status=0 或 error_code 非 0
answer.status = 502;
// 如果有 SSA 验证码,生成模拟行为指纹(sid/edt)附加到响应中
// 客户端拿到 sid/edt 后可以用于后续的验证请求
if (ssaCode) {
const { edt, sid } = generateSimulate(mid, userid, dfid, webglHash);
if (edt) answer.body.edt = edt;
if (sid) answer.body.sid = sid;
answer.body.ssaCode = ssaCode;
}
reject(answer);
} else {
// 请求成功
answer.status = 200;
// 同样在成功时附加 SSA 验证码信息(某些接口成功时也需要二次验证)
if (ssaCode) {
const { edt, sid } = generateSimulate(mid, userid, dfid, webglHash);
if (edt) answer.body.edt = edt;
if (sid) answer.body.sid = sid;
answer.body.ssaCode = ssaCode;
}
resolve(answer);
}
} catch (e) {
// 网络错误或请求异常
answer.status = 502;
answer.body = { status: 0, msg: e };
reject(answer);
}
});
};
module.exports = { createRequest };
+182
View File
@@ -0,0 +1,182 @@
/**
* @fileoverview 运行时配置工具
*
* 处理命令行参数解析和运行时环境变量配置,包括:
* - 命令行参数解析(--key=value 格式)
* - CLI 参数覆盖环境变量(proxy、platform、port 等)
* - 代理地址解析与缓存
*
* @module runtime
* @requires url - URL 解析(用于代理地址解析)
*/
const { URL } = require('url');
/**
* 缓存的代理地址原始字符串(用于避免重复解析)
* @type {string | undefined}
*/
let cachedProxyRaw;
/**
* 缓存的代理配置对象(AxiosProxyConfig 格式)
* @type {import('axios').AxiosProxyConfig | null}
*/
let cachedProxy;
/**
* 解析命令行参数
*
* 仅解析 `--key=value` 格式的参数,忽略其他格式。
* 解析规则:
* - 必须以 `--` 开头
* - 必须包含 `=` 分隔符
* - key 和 value 均不能为空
*
* @param {string[]} [args] - 参数数组,默认使用 process.argv.slice(2)
* @returns {Record<string, string>} 解析后的键值对对象
*
* @example
* // 命令行: node app.js --proxy=http://127.0.0.1:8080 --platform=lite
* parseCliArgs() // => { proxy: 'http://127.0.0.1:8080', platform: 'lite' }
*/
function parseCliArgs(args) {
const source = Array.isArray(args) ? args : process.argv.slice(2);
return source.reduce((acc, rawArg) => {
if (typeof rawArg !== 'string') {
return acc;
}
const arg = rawArg.trim();
// 必须以 -- 开头
if (!arg.startsWith('--')) {
return acc;
}
const eqIndex = arg.indexOf('=');
// = 必须在 -- 之后且不在末尾(确保 key 和 value 都存在)
if (eqIndex <= 2 || eqIndex === arg.length - 1) {
return acc;
}
const key = arg.slice(2, eqIndex).trim();
const value = arg.slice(eqIndex + 1).trim();
if (!key || !value) return acc;
acc[key] = value;
return acc;
}, {});
}
/**
* 应用命令行参数覆盖环境变量
*
* 支持的 CLI 参数:
* - --proxy: 设置 KUGOU_API_PROXY(代理地址)
* - --platform: 设置 platform(平台类型,如 "lite"
* - --guid: 设置 KUGOU_API_GUID(设备 GUID
* - --dev: 设置 KUGOU_API_DEV(开发设备标识)
* - --mac: 设置 KUGOU_API_MAC(设备 MAC 地址)
* - --port: 设置 PORT(服务器端口,需为正整数)
*
* @param {string[]} [args] - 参数数组,默认使用 process.argv.slice(2)
*/
function applyCliOverrides(args) {
const parsed = parseCliArgs(args);
if (parsed.proxy) {
process.env.KUGOU_API_PROXY = parsed.proxy;
}
if (parsed.platform) {
process.env.platform = parsed.platform;
}
if (parsed.guid) {
process.env.KUGOU_API_GUID = parsed.guid;
}
if (parsed.dev) {
process.env.KUGOU_API_DEV = parsed.dev;
}
if (parsed.mac) {
process.env.KUGOU_API_MAC = parsed.mac;
}
if (parsed.port) {
const port = Number(parsed.port);
if (!Number.isNaN(port) && port > 0) {
process.env.PORT = String(port);
} else {
console.warn(`[cli] Invalid port value "${parsed.port}", fallback to default.`);
}
}
}
/**
* 解析代理配置
*
* 从环境变量 KUGOU_API_PROXY 读取代理地址,解析为 Axios 兼容的代理配置对象。
* 支持 HTTP/HTTPS 协议,支持带认证信息的代理(user:password@host:port)。
*
* 解析结果会被缓存,避免每次请求都重复解析。
* 当环境变量值未变化时直接返回缓存结果。
*
* @returns {import('axios').AxiosProxyConfig | null} 代理配置对象,无代理时返回 null
*
* @example
* // KUGOU_API_PROXY=http://user:pass@127.0.0.1:8080
* resolveProxy()
* // => { protocol: 'http', host: '127.0.0.1', port: 8080, auth: { username: 'user', password: 'pass' } }
*/
function resolveProxy() {
const rawProxyEnv = typeof process.env.KUGOU_API_PROXY === 'string' ? process.env.KUGOU_API_PROXY.trim() : undefined;
const rawProxy = rawProxyEnv && rawProxyEnv.length > 0 ? rawProxyEnv : undefined;
// 无代理配置,清空缓存
if (!rawProxy) {
cachedProxyRaw = undefined;
cachedProxy = null;
return null;
}
// 缓存命中,直接返回
if (cachedProxyRaw === rawProxy) {
return cachedProxy;
}
// 缓存未命中,重新解析
cachedProxyRaw = rawProxy;
try {
const parsed = new URL(rawProxy);
// 仅支持 HTTP/HTTPS 代理协议
if (!/^https?:$/.test(parsed.protocol)) {
console.warn(`[proxy] Unsupported proxy protocol: ${parsed.protocol}`);
cachedProxy = null;
return null;
}
// 构建 Axios 代理配置
const proxyConfig = {
protocol: parsed.protocol.replace(':', ''), // 去掉末尾的冒号
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80, // 默认端口
};
// 如果代理地址包含认证信息
if (parsed.username || parsed.password) {
proxyConfig.auth = {
username: parsed.username,
password: parsed.password,
};
}
cachedProxy = proxyConfig;
console.info(`[proxy] Using proxy ${parsed.protocol}//${parsed.host}`);
} catch (error) {
console.warn(`[proxy] Failed to parse proxy address "${rawProxy}": ${error.message}`);
cachedProxy = null;
}
return cachedProxy;
}
module.exports = { applyCliOverrides, parseCliArgs, resolveProxy };
+310
View File
@@ -0,0 +1,310 @@
/**
* @fileoverview 通用工具函数库
*
* 提供酷狗音乐 API 项目中常用的工具函数,包括:
* - 随机字符串/数字生成
* - Cookie 解析与格式化
* - KRC 歌词解码(XOR 解密 + zlib 解压)
* - 设备 MID 计算(基于 MD5 的大整数转换)
* - GUID 生成(UUID v4 格式)
* - WebGL 指纹哈希生成(浏览器/Node 双环境支持)
*
* @module util
* @requires pako - zlib 解压库(用于 KRC 歌词解码)
* @requires crypto-js - 加密库(用于 MID 计算中的 MD5 哈希)
* @requires big-integer - 大整数库(用于 MID 的进制转换)
*/
const pako = require('pako');
const CryptoJS = require('crypto-js');
const bigInt = require('big-integer');
/**
* 生成随机字符串(大写字母 + 数字)
*
* 字符池: 1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ36个字符)
* 每次从字符池中随机选取一个字符,拼接为指定长度的字符串。
*
* @param {number} [len=16] - 字符串长度,默认 16
* @returns {string} 随机字符串
*
* @example
* randomString(8) // => "A3B7K9X2"
*/
const randomString = (len = 16) => {
const keyString = '1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const _key = [];
const keyStringArr = keyString.split('');
for (let i = 0; i < len; i += 1) {
const ceil = Math.ceil((keyStringArr.length - 1) * Math.random());
const _tmp = keyStringArr[ceil];
_key.push(_tmp);
}
return _key.join('');
};
/**
* 生成随机数字字符串
*
* 字符池: 123456789010个数字字符)
* 每次从字符池中随机选取一个数字字符,拼接为指定长度的字符串。
*
* @param {number} [len=16] - 字符串长度,默认 16
* @returns {string} 随机数字字符串
*
* @example
* randomNumber(6) // => "384729"
*/
const randomNumber = (len = 16) => {
const keyString = '1234567890';
const _key = [];
const keyStringArr = keyString.split('');
for (let i = 0; i < len; i += 1) {
const ceil = Math.ceil((keyStringArr.length - 1) * Math.random());
const _tmp = keyStringArr[ceil];
_key.push(_tmp);
}
return _key.join('');
};
/**
* 格式化 Cookie 字符串
*
* 移除 Cookie 中的非数据字段(Domain、path、expires、HttpOnly),
* 仅保留有效的键值对数据。
*
* @param {string} cookie - 原始 Cookie 字符串
* @returns {string} 格式化后的 Cookie 字符串
*
* @example
* parseCookieString('token=abc; Domain=.kugou.com; path=/; HttpOnly')
* // => 'token=abc'
*/
const parseCookieString = (cookie) => {
const t = cookie.replace(/\s*(Domain|domain|path|expires)=[^(;|$)]+;*/g, '');
return t.replace(/;HttpOnly/g, '');
};
/**
* Cookie 字符串转 JSON 对象
*
* 将 Cookie 字符串按 `;` 分割,每个键值对按 `=` 分割为 key 和 value。
*
* @param {string} cookie - Cookie 字符串
* @returns {Object} Cookie 键值对对象
*
* @example
* cookieToJson('token=abc; userid=123')
* // => { token: 'abc', userid: '123' }
*/
const cookieToJson = (cookie) => {
if (!cookie) return {};
let cookieArr = cookie.split(';');
let obj = {};
cookieArr.forEach((i) => {
let arr = i.split('=');
obj[arr[0]] = arr[1];
});
return obj;
};
/**
* KRC 歌词解码
*
* 酷狗 KRC 歌词文件的解码流程:
* 1. 跳过前 4 字节(文件头标识)
* 2. 剩余字节与固定密钥进行 XOR 异或解密
* 3. 使用 pako(zlib)解压得到明文歌词
*
* XOR 密钥(16字节循环使用):
* [64, 71, 97, 119, 94, 50, 116, 71, 81, 54, 49, 45, 206, 210, 110, 105]
*
* @param {string | Uint8Array | Buffer} val - 加密的歌词数据
* - string: Base64 编码的歌词数据
* - Uint8Array: 原始字节数组
* - Buffer: Node.js Buffer
* @returns {string} 解码后的明文歌词,解码失败返回空字符串
*/
const decodeLyrics = (val) => {
let bytes = null;
if (val instanceof Uint8Array) bytes = val;
if (Buffer.isBuffer(val)) bytes = new Uint8Array(val);
if (typeof val === 'string') bytes = new Uint8Array(Buffer.from(val, 'base64'));
if (bytes === null) return '';
// XOR 解密密钥(16字节,循环使用)
const enKey = [64, 71, 97, 119, 94, 50, 116, 71, 81, 54, 49, 45, 206, 210, 110, 105];
const krcBytes = bytes.slice(4); // 跳过前 4 字节文件头
const len = krcBytes.byteLength;
// XOR 异或解密
for (let index = 0; index < len; index += 1) {
krcBytes[index] = krcBytes[index] ^ enKey[index % enKey.length];
}
// zlib 解压
try {
const inflate = pako.inflate(krcBytes);
return Buffer.from(inflate).toString('utf8');
} catch {
return '';
}
};
/**
* 计算设备 MID
*
* 将输入字符串(通常是 GUID)进行 MD5 哈希,然后将哈希值作为 16 进制大整数
* 转换为 10 进制字符串表示。
*
* 算法:
* 1. 对输入字符串取 MD5 哈希(32位hex)
* 2. 将 hex 字符串视为 16 进制数
* 3. 逐位累加: sum += digit * 16^(position)
* 4. 返回十进制字符串
*
* @param {string} str - 输入字符串(通常为设备 GUID)
* @returns {string} MID 的十进制字符串表示
*
* @example
* calculateMid('550e8400-e29b-41d4-a716-446655440000')
* // => "123456789012345678901234567890"
*/
const calculateMid = (str) => {
let bigInteger = bigInt(0);
const bigInteger2 = bigInt(16); // 进制基数
const digest = CryptoJS.MD5(str).toString(CryptoJS.enc.Hex); // MD5 哈希
const length = digest.length;
for (let i = 0; i < length; i += 1) {
const charValue = bigInt(parseInt(digest.charAt(i), 16)); // 当前位的值
const powerValue = bigInteger2.pow(length - 1 - i); // 16 的幂次
bigInteger = bigInteger.add(charValue.multiply(powerValue)); // 累加
}
return bigInteger.toString();
};
/**
* 生成随机 GUIDUUID v4 格式)
*
* 格式: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
* - 第三段以 4 开头(UUID v4 标识)
* - 第四段以 8/9/a/b 开头(UUID v4 变体标识)
*
* @returns {string} UUID v4 格式的 GUID 字符串
*
* @example
* getGuid() // => "550e8400-e29b-41d4-a716-446655440000"
*/
const getGuid = () => {
const e = () => {
return ((65536 * (1 + Math.random())) | 0).toString(16).substring(1);
};
return `${e()}${e()}-${e()}-${e()}-${e()}-${e()}${e()}${e()}`;
};
/**
* 生成 WebGL 指纹哈希值
*
* WebGL 指纹是浏览器指纹的重要组成部分,通过以下信息生成唯一哈希:
*
* 浏览器环境:
* 1. 编译顶点/片段着色器,创建 WebGL 程序
* 2. 绘制一个三角形并读取像素数据
* 3. 获取显卡厂商、渲染器名称、WebGL 版本等元数据
* 4. 使用 FNV-1a 64-bit 哈希算法对像素数据 + 元数据进行哈希
*
* Node 环境或 WebGL 不可用时:
* 生成随机 uint64 作为模拟指纹
*
* @returns {string} WebGL 指纹的十进制字符串表示
*/
const generateWebGLHash = () => {
// 浏览器环境:通过 canvas 获取真实的 WebGL 渲染器信息
if (typeof document !== 'undefined') {
try {
const c = document.createElement('canvas');
c.width = 200;
c.height = 50;
const gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (gl) {
// --- 编译着色器(和 WASM 中的逻辑一致)---
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, 'attribute vec4 position;void main(){gl_Position=position;}');
gl.compileShader(vs);
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, 'void main(){gl_FragColor=vec4(1.0,1.0,1.0,1.0);}');
gl.compileShader(fs);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
gl.useProgram(prog);
// --- 绘制一个三角形 ---
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(prog, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
gl.viewport(0, 0, 200, 50);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// --- 读取渲染结果并哈希 ---
const pixels = new Uint8Array(200 * 50 * 4);
gl.readPixels(0, 0, 200, 50, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
// 同时加入元数据(显卡厂商、渲染器名称、WebGL 版本)
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : '';
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : '';
const version = gl.getParameter(gl.VERSION);
// FNV-1a 64-bit 哈希算法
let h = BigInt('14695981039346656037'); // FNV offset basis
const prime = BigInt('1099511628211'); // FNV prime
// 哈希像素数据
for (let i = 0; i < pixels.length; i++) {
h = ((h ^ BigInt(pixels[i])) * prime) & BigInt('0xFFFFFFFFFFFFFFFF');
}
// 哈希元数据
const meta = vendor + '|' + renderer + '|' + version;
for (let i = 0; i < meta.length; i++) {
h = ((h ^ BigInt(meta.charCodeAt(i))) * prime) & BigInt('0xFFFFFFFFFFFFFFFF');
}
return h.toString();
}
} catch (e) {}
}
// Node 环境或 WebGL 不可用:生成随机 uint64 作为模拟指纹
const hi = Math.floor(Math.random() * 0xffffffff);
const lo = Math.floor(Math.random() * 0xffffffff);
return (BigInt(hi) * BigInt(0x100000000) + BigInt(lo)).toString();
};
/**
* 判断是否为UUID v4
* @param {string} str
* @returns { Boolean }
*/
const isUUIDv4 = (str) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str);
}
module.exports = {
decodeLyrics,
cookieToJson,
parseCookieString,
randomString,
randomNumber,
calculateMid,
getGuid,
generateWebGLHash,
isUUIDv4
};