完成网络音乐、工具页面与安装器体验升级
This commit is contained in:
+1024
File diff suppressed because it is too large
Load Diff
+12
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* @fileoverview 酷狗音乐 API 行为指纹模拟生成器(Node.js 版)
|
||||
*
|
||||
* 本模块用于在服务端生成模拟的用户行为指纹数据,替代浏览器端 WASM 的功能。
|
||||
* 主要用于自动化请求场景(如批量登录、接口调用等),绕过酷狗的行为检测机制。
|
||||
*
|
||||
* 核心功能:
|
||||
* 1. 生成模拟的鼠标移动轨迹(贝塞尔曲线 + 随机抖动)
|
||||
* 2. 生成模拟的页面交互事件(滚动、窗口 resize 等)
|
||||
* 3. 使用 AES-128-CBC 加密行为数据得到 EDT(Encrypted Data Token)
|
||||
* 4. 使用 RSA-OAEP SHA-256 加密 AES 密钥得到 SID(Session ID)
|
||||
* 5. 服务端用 RSA 私钥解密 SID 得到 AES 密钥,再用 AES 密钥解密 EDT 还原行为数据
|
||||
*
|
||||
* 加密方案:
|
||||
* 明文(行为数据)→ AES-128-CBC 加密 → EDT(Base64)
|
||||
* AES 密钥 → RSA-OAEP SHA-256 加密 → SID(Base64)
|
||||
*
|
||||
* @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 加密明文 → EDT(Base64)
|
||||
* 6. RSA-OAEP SHA-256 加密 AES 密钥 → SID(Base64)
|
||||
*
|
||||
* @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
@@ -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_dev)signature 签名
|
||||
*
|
||||
* 签名算法:
|
||||
* 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
@@ -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
@@ -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
@@ -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 || '-'; // 设备指纹 ID(register_dev 接口返回)
|
||||
const mid = `${options?.cookie?.KUGOU_API_MID}`; // 设备 MID(server.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
@@ -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
@@ -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');
|
||||
|
||||
/**
|
||||
* 生成随机字符串(大写字母 + 数字)
|
||||
*
|
||||
* 字符池: 1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ(36个字符)
|
||||
* 每次从字符池中随机选取一个字符,拼接为指定长度的字符串。
|
||||
*
|
||||
* @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('');
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成随机数字字符串
|
||||
*
|
||||
* 字符池: 1234567890(10个数字字符)
|
||||
* 每次从字符池中随机选取一个数字字符,拼接为指定长度的字符串。
|
||||
*
|
||||
* @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();
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成随机 GUID(UUID 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
|
||||
};
|
||||
Reference in New Issue
Block a user