v3.0.0: QZ插件逆向分析增强 - 支持pluginInfo解析、音乐平台识别、.qz/.zip解压
This commit is contained in:
751
lib/detector.js
Normal file
751
lib/detector.js
Normal file
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* 插件格式自动检测模块 v3.0
|
||||
* 支持: Lx Music / QZV2 (QZ PC + 澜音Ceru) / MusicFree
|
||||
* 核心改进: 深度逆向分析 QZ 插件结构,支持混淆代码解析
|
||||
*
|
||||
* 逆向分析来源:
|
||||
* - QZMusic_PC/src/main/pluginSystem.ts (GitHub: lqtmcstudio/QZMusic_PC)
|
||||
* - CeruMusic 插件开发文档 (ceru.docs.shiqianjiang.cn)
|
||||
* - 实际 QZ 插件样本 (mg/wy/tx/kw/kg 平台)
|
||||
*/
|
||||
|
||||
const PLATFORMS = {
|
||||
LX: 'lx',
|
||||
QZV2: 'qzv2',
|
||||
MUSICFREE: 'musicfree',
|
||||
UNKNOWN: 'unknown'
|
||||
};
|
||||
|
||||
const PLATFORM_LABELS = {
|
||||
lx: '落雪音乐 (Lx Music)',
|
||||
qzv2: '清泽音乐 (QZV2)',
|
||||
musicfree: 'MusicFree',
|
||||
unknown: '未知格式'
|
||||
};
|
||||
|
||||
/**
|
||||
* 音乐源平台映射表
|
||||
* key = pluginInfo.info.id 或 Lx Music source key
|
||||
* 逆向来源: pluginSystem.ts getPluginId() + 澜音文档 sources 定义
|
||||
*/
|
||||
const MUSIC_SOURCES = {
|
||||
'kw': { name: '酷我音乐', fullName: 'KuWo Music', domains: ['kuwo.cn', 'kuwo.com'] },
|
||||
'tx': { name: 'QQ音乐', fullName: 'QQ Music', domains: ['qq.com', 'y.qq.com', 'c.y.qq.com', 'u.y.qq.com'] },
|
||||
'wy': { name: '网易云音乐', fullName: 'NetEase Music', domains: ['163.com', 'music.126.net', 'music.163.com', 'interface.music.163.com'] },
|
||||
'kg': { name: '酷狗音乐', fullName: 'KuGou Music', domains: ['kugou.com', 'kugou.cn'] },
|
||||
'mg': { name: '咪咕音乐', fullName: 'Migu Music', domains: ['migu.cn', 'miguvideo', 'jadeite.migu.cn', 'musicapp.cn', 'nf.migu.cn'] },
|
||||
'local': { name: '本地音乐', fullName: 'Local', domains: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
* QZ 插件 supportFunc 已知值映射
|
||||
* 逆向来源: 实际插件样本 + pluginSystem.ts callCandidate 机制
|
||||
*/
|
||||
const SUPPORT_FUNC_NAMES = {
|
||||
'search_song': '歌曲搜索',
|
||||
'search_playlist': '歌单搜索',
|
||||
'search_album': '专辑搜索',
|
||||
'playlist': '歌单详情',
|
||||
'album': '专辑详情',
|
||||
'lyric': '歌词获取',
|
||||
'search': '搜索',
|
||||
'getUrl': '获取播放链接',
|
||||
'getLyric': '获取歌词',
|
||||
'getPlaylist': '获取歌单',
|
||||
'getAlbum': '获取专辑',
|
||||
'musicSearch': '音乐搜索',
|
||||
'hotSearch': '热搜',
|
||||
'songList': '歌单列表',
|
||||
};
|
||||
|
||||
/**
|
||||
* 音质等级完整映射表
|
||||
* 逆向来源: QZ Flutter main.dart 枚举 + 澜音文档 qualitys 定义 + 实际插件样本
|
||||
*/
|
||||
const QUALITY_MAP = {
|
||||
'128k': { name: '标准音质', shortName: '标准', level: 1 },
|
||||
'192k': { name: '较高音质', shortName: '较高', level: 2 },
|
||||
'320k': { name: '高品音质', shortName: '高品', level: 3 },
|
||||
'flac': { name: '无损音质', shortName: '无损', level: 4 },
|
||||
'flac24bit': { name: 'Hi-Res FLAC', shortName: 'Hi-Res', level: 5 },
|
||||
'hires': { name: 'Hi-Res 高解析度', shortName: 'Hi-Res', level: 5 },
|
||||
'atmos': { name: '杜比全景声', shortName: '全景声', level: 6 },
|
||||
'master': { name: '母带音质', shortName: '母带', level: 7 },
|
||||
'low': { name: '低音质', shortName: '低', level: 0 },
|
||||
'standard': { name: '标准音质', shortName: '标准', level: 1 },
|
||||
'exhigh': { name: '极高音质', shortName: '极高', level: 3 },
|
||||
'high': { name: '高音质', shortName: '高', level: 3 },
|
||||
'lossless': { name: '无损音质', shortName: '无损', level: 4 },
|
||||
'super': { name: '超高音质', shortName: '超高', level: 5 },
|
||||
};
|
||||
|
||||
/** 所有支持的平台信息(用于前端展示) */
|
||||
const ALL_PLATFORMS = [
|
||||
{
|
||||
id: 'lx',
|
||||
name: '落雪音乐',
|
||||
fullName: 'Lx Music',
|
||||
format: 'JavaScript 脚本 (事件驱动)',
|
||||
qualities: ['128k', '192k', '320k', 'flac', 'flac24bit', 'hires', 'master'],
|
||||
features: ['musicUrl', 'lyric', 'pic'],
|
||||
sources: ['kw', 'kg', 'tx', 'wy', 'mg'],
|
||||
detectionHints: ['globalThis.lx', 'EVENT_NAMES', '@name 注释头', 'sources/qualitys 对象'],
|
||||
docUrl: 'https://lxmusic.toside.cn/desktop/custom-source'
|
||||
},
|
||||
{
|
||||
id: 'qzv2',
|
||||
name: '清泽音乐',
|
||||
fullName: 'QZ Music v2',
|
||||
format: 'JS 模块 (ZIP/JS/QZ) - webpack 打包',
|
||||
qualities: ['128k', '320k', 'flac', 'hires', 'atmos', 'master', 'standard', 'exhigh', 'lossless'],
|
||||
features: ['search_song', 'search_playlist', 'playlist', 'album', 'lyric', 'getUrl', 'musicSearch'],
|
||||
sources: ['kw', 'tx', 'wy', 'kg', 'mg'],
|
||||
detectionHints: ['pluginInfo.info.id', 'getUrl(id, quality)', 'supportFunc', 'musicSearch', '__nccwpck_require__'],
|
||||
docUrl: 'https://music.qz.shiqianjiang.cn/'
|
||||
},
|
||||
{
|
||||
id: 'musicfree',
|
||||
name: 'MusicFree',
|
||||
fullName: 'MusicFree',
|
||||
format: 'CommonJS 模块',
|
||||
qualities: ['low', 'standard', 'high', 'super', 'hires', 'master'],
|
||||
features: ['search', 'getMediaSource', 'getMusicInfo', 'getLyric', 'getAlbumInfo'],
|
||||
sources: ['自定义'],
|
||||
detectionHints: ['platform:', 'getMediaSource', 'supportedSearchType', 'srcUrl'],
|
||||
docUrl: 'https://musicfree.catcat.work/plugin/protocol.html'
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// QZ pluginInfo 提取 - 核心逆向分析逻辑
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 尝试从代码中提取 pluginInfo 对象 (QZV2 格式)
|
||||
* 使用平衡括号匹配算法提取 JSON 子串
|
||||
* 支持: webpack 打包代码、混淆代码
|
||||
*/
|
||||
function extractPluginInfo(code) {
|
||||
const patterns = [
|
||||
/pluginInfo\s*:\s*\{/,
|
||||
/["']pluginInfo["']\s*:\s*\{/,
|
||||
/pluginInfo\s*=\s*\{/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = code.match(pattern);
|
||||
if (!match) continue;
|
||||
|
||||
const startIdx = match.index + match[0].length - 1;
|
||||
const result = extractBalancedObject(code, startIdx);
|
||||
if (result) {
|
||||
try {
|
||||
const obj = eval('(' + result + ')');
|
||||
if (obj && typeof obj === 'object') return obj;
|
||||
} catch (e) {
|
||||
try {
|
||||
const fixed = result.replace(/,\s*([}\]])/g, '$1');
|
||||
const obj = JSON.parse(fixed);
|
||||
if (obj && typeof obj === 'object') return obj;
|
||||
} catch (e2) {
|
||||
// 混淆代码: 尝试部分提取
|
||||
const partial = extractPluginInfoPartial(result);
|
||||
if (partial) return partial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从混淆的 pluginInfo 字符串中提取可读信息
|
||||
* 混淆代码中 info.id 通常是明文字符串
|
||||
*/
|
||||
function extractPluginInfoPartial(rawStr) {
|
||||
const partial = { info: {}, quality: [], supportFunc: [], env: [], ext: [] };
|
||||
|
||||
// 提取 info.id (通常是明文)
|
||||
const idMatch = rawStr.match(/['"]?id['"]?\s*:\s*['"]([a-z0-9_-]+)['"]/i);
|
||||
if (idMatch) partial.info.id = idMatch[1];
|
||||
|
||||
// 提取 info.version
|
||||
const versionMatch = rawStr.match(/['"]?version['"]?\s*:\s*['"]?(\d[\d.]*)/);
|
||||
if (versionMatch) partial.info.version = versionMatch[1];
|
||||
|
||||
// 提取 info.name (仅提取明文字符串,跳过混淆变量引用)
|
||||
const nameMatch = rawStr.match(/['"]?name['"]?\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (nameMatch && !nameMatch[1].startsWith('_0x') && !nameMatch[1].match(/^[a-f0-9]{6,}$/i)) {
|
||||
partial.info.name = nameMatch[1];
|
||||
}
|
||||
|
||||
// 提取 quality 中的明文 id
|
||||
const qualityIdMatches = rawStr.matchAll(/['"]?id['"]?\s*:\s*['"]([a-z0-9_]+)['"]/gi);
|
||||
for (const m of qualityIdMatches) {
|
||||
if (QUALITY_MAP[m[1]]) {
|
||||
partial.quality.push({ id: m[1], name: QUALITY_MAP[m[1]].name, ui: '' });
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 supportFunc 中的明文字符串
|
||||
for (const funcName of Object.keys(SUPPORT_FUNC_NAMES)) {
|
||||
const regex = new RegExp(`['"]${funcName}['"]`);
|
||||
if (regex.test(rawStr)) {
|
||||
partial.supportFunc.push(funcName);
|
||||
}
|
||||
}
|
||||
|
||||
if (partial.info.id || partial.quality.length > 0 || partial.supportFunc.length > 0) {
|
||||
return partial;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定位置开始提取平衡的 {} 对象
|
||||
*/
|
||||
function extractBalancedObject(code, startIdx) {
|
||||
if (code[startIdx] !== '{') return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let stringChar = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let i = startIdx; i < code.length; i++) {
|
||||
const char = code[i];
|
||||
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (char === '\\') { escaped = true; continue; }
|
||||
if (inString) {
|
||||
if (char === stringChar) inString = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
inString = true;
|
||||
stringChar = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '{') depth++;
|
||||
else if (char === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return code.substring(startIdx, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从代码中提取 QZ API 端点
|
||||
* 逆向来源: getUrl 函数模式分析
|
||||
* 典型模式: axios.get(`https://api.qz.shiqianjiang.cn/music/url?songId=${songId}&quality=${quality}&source=mg`)
|
||||
*/
|
||||
function extractQzApiEndpoint(code) {
|
||||
const result = {
|
||||
apiUrl: '',
|
||||
sourceParam: '',
|
||||
apiKey: '',
|
||||
rawPattern: '',
|
||||
};
|
||||
|
||||
// 查找 QZ API URL - 支持模板字符串 ${} 变量
|
||||
// 逆向来源: getUrl 函数中 axios.get(`https://api.qz.shiqianjiang.cn/music/url?songId=${songId}&quality=${quality}&source=mg`)
|
||||
const apiPatterns = [
|
||||
// 匹配含 ${} 模板变量的 URL
|
||||
/https?:\/\/api\.qz\.shiqianjiang\.cn(?:[^\s`"'<>]|\$\{[^}]+\})*/,
|
||||
/https?:\/\/[a-z0-9.-]+\/music\/url(?:[^\s`"'<>]|\$\{[^}]+\})*/,
|
||||
/https?:\/\/[a-z0-9.-]+\/api\/music(?:[^\s`"'<>]|\$\{[^}]+\})*/,
|
||||
];
|
||||
|
||||
for (const pattern of apiPatterns) {
|
||||
const match = code.match(pattern);
|
||||
if (match) {
|
||||
result.apiUrl = match[0];
|
||||
result.rawPattern = 'qz_api';
|
||||
|
||||
// 提取 source 参数 - 从 URL 中查找
|
||||
const sourceMatch = result.apiUrl.match(/source[=:](['"]?)(\w+)\1/);
|
||||
if (sourceMatch) {
|
||||
result.sourceParam = sourceMatch[2];
|
||||
} else {
|
||||
// 如果 URL 中没有明文 source,从代码上下文中查找
|
||||
// 模式: source=mg 或 source=${variable} 后面的 &source=mg
|
||||
const contextSourceMatch = code.match(/source[=:]\s*['"]?(wy|tx|kw|kg|mg)['"]?/i);
|
||||
if (contextSourceMatch) {
|
||||
result.sourceParam = contextSourceMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 从代码中查找 X-API-KEY
|
||||
const apiKeyMatch = code.match(/X-API-KEY['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||
if (apiKeyMatch) {
|
||||
result.apiKey = apiKeyMatch[1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从代码中提取音乐平台直接 API 端点
|
||||
* 按平台分组,用于延时测试
|
||||
*/
|
||||
function extractPlatformApiUrls(code, musicPlatform) {
|
||||
// 匹配 URL - 支持模板字符串 ${} 变量
|
||||
const allUrls = code.match(/https?:\/\/(?:[^\s"'`<>)}\]]|\$\{[^}]+\})+/g) || [];
|
||||
const uniqueUrls = [...new Set(allUrls)];
|
||||
|
||||
// 排除无关 URL
|
||||
const excludePatterns = [
|
||||
/w3\.org/i, /schema\.org/i, /github\.com/i, /nodejs\.org/i,
|
||||
/mozilla\.org/i, /google\.com/i, /localhost/i, /encoding\.spec/i,
|
||||
/icu-project\.org/i, /tools\.ietf\.org/i, /haible\.de/i,
|
||||
/moztw\.org/i, /khngai\.com/i, /abelcheung\.org/i,
|
||||
/\.png$|\.jpg$|\.jpeg$|\.gif$|\.svg$|\.ico$|\.css$|\.woff$|\.ttf$/i,
|
||||
/catcat\.work/i, /lxmusic\.toside\.cn/i, /qz\.shiqianjiang/i,
|
||||
/wikipedia\.org/i, /doubaocdn/i, /aka\.doubaocdn/i,
|
||||
/unpkg\.com/i, /cdn\.jsdelivr/i, /fonts\.googleapis/i,
|
||||
/creativecommons/i, /gnu\.org/i, /opensource\.org/i,
|
||||
];
|
||||
|
||||
let filtered = uniqueUrls.filter(u => !excludePatterns.some(p => p.test(u)));
|
||||
|
||||
// 如果有音乐平台信息,优先保留与平台域名匹配的 URL
|
||||
if (musicPlatform && MUSIC_SOURCES[musicPlatform]) {
|
||||
const domains = MUSIC_SOURCES[musicPlatform].domains;
|
||||
if (domains.length > 0) {
|
||||
const platformUrls = filtered.filter(u => {
|
||||
const lower = u.toLowerCase();
|
||||
return domains.some(d => lower.includes(d));
|
||||
});
|
||||
if (platformUrls.length > 0) {
|
||||
// 同时保留 QZ API URL 和平台直连 URL
|
||||
const qzApiUrls = filtered.filter(u => /api\.qz\.shiqianjiang/i.test(u));
|
||||
filtered = [...new Set([...qzApiUrls, ...platformUrls])];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去掉模板字符串中的 ${} 变量,替换为示例值
|
||||
const cleanedUrls = filtered.map(u => {
|
||||
return u.replace(/\$\{[^}]+\}/g, 'test').replace(/\$\w+/g, 'test');
|
||||
});
|
||||
|
||||
return [...new Set(cleanedUrls)].slice(0, 10);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 格式检测主函数
|
||||
// ============================================================
|
||||
|
||||
function detectPlatform(code, filename = '') {
|
||||
const scores = { lx: 0, qzv2: 0, musicfree: 0 };
|
||||
|
||||
// === Lx Music 检测 ===
|
||||
if (/\/\*\*[\s\S]*?@name\s+/m.test(code)) scores.lx += 2;
|
||||
if (/globalThis\.lx\b/.test(code)) scores.lx += 3;
|
||||
if (/EVENT_NAMES/.test(code)) scores.lx += 2;
|
||||
if (/send\s*\(\s*EVENT_NAMES\.inited/.test(code)) scores.lx += 3;
|
||||
if (/lx\.(request|on|send)\b/.test(code)) scores.lx += 2;
|
||||
if (/qualitys\s*:/.test(code)) scores.lx += 2; // 澜音格式特征
|
||||
if (/cerumusic\.(request|utils)/.test(code)) scores.lx += 3; // 澜音原生
|
||||
if (/type\s*:\s*['"]cr['"]/.test(code)) scores.lx += 3; // 澜音 type=cr
|
||||
|
||||
// === QZV2 检测 (QZ PC 原生格式) ===
|
||||
if (/pluginInfo\s*[:?]\s*\{/.test(code)) scores.qzv2 += 3;
|
||||
if (/pluginInfo\s*:\s*\{[\s\S]*?info\s*:/.test(code)) scores.qzv2 += 3; // 嵌套 info
|
||||
if (/getUrl\s*[:(]/.test(code)) scores.qzv2 += 2;
|
||||
if (/supportFunc/.test(code)) scores.qzv2 += 3;
|
||||
if (/__nccwpck_require__/.test(code)) scores.qzv2 += 2; // webpack 打包特征
|
||||
if (/musicSearch/.test(code)) scores.qzv2 += 2;
|
||||
if (/search_song|search_playlist|search_album/.test(code)) scores.qzv2 += 2;
|
||||
if (/getPlay(?:list|List)\s*[:(]/.test(code)) scores.qzv2 += 1;
|
||||
if (/allPage/.test(code)) scores.qzv2 += 1;
|
||||
if (/api\.qz\.shiqianjiang\.cn/.test(code)) scores.qzv2 += 3; // QZ 官方 API
|
||||
if (/X-API-KEY/.test(code)) scores.qzv2 += 1;
|
||||
|
||||
// === MusicFree 检测 ===
|
||||
if (/platform\s*:\s*["'`]/.test(code)) scores.musicfree += 2;
|
||||
if (/getMediaSource\s*[:(]/.test(code)) scores.musicfree += 3;
|
||||
if (/getMusicInfo\s*[:(]/.test(code)) scores.musicfree += 2;
|
||||
if (/getAlbumInfo|getMusicSheetInfo/.test(code)) scores.musicfree += 2;
|
||||
if (/supportedSearchType/.test(code)) scores.musicfree += 2;
|
||||
if (/cacheControl/.test(code)) scores.musicfree += 1;
|
||||
if (/srcUrl/.test(code)) scores.musicfree += 1;
|
||||
if (/primaryKey/.test(code)) scores.musicfree += 1;
|
||||
if (/getTopLists|getRecommendSheetTags/.test(code)) scores.musicfree += 2;
|
||||
|
||||
// 排除规则
|
||||
if (scores.musicfree >= 3 && scores.qzv2 > 0) {
|
||||
if (/getMediaSource/.test(code) && !/pluginInfo/.test(code)) {
|
||||
scores.qzv2 = Math.max(0, scores.qzv2 - 2);
|
||||
}
|
||||
}
|
||||
|
||||
let bestPlatform = PLATFORMS.UNKNOWN;
|
||||
let bestScore = 0;
|
||||
for (const [platform, score] of Object.entries(scores)) {
|
||||
if (score > bestScore) { bestScore = score; bestPlatform = platform; }
|
||||
}
|
||||
if (bestScore < 2) bestPlatform = PLATFORMS.UNKNOWN;
|
||||
|
||||
const metadata = extractMetadata(code, bestPlatform, filename);
|
||||
return { platform: bestPlatform, platformLabel: PLATFORM_LABELS[bestPlatform], metadata, confidence: bestScore, scores };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 元数据提取
|
||||
// ============================================================
|
||||
|
||||
function extractMetadata(code, platform, filename = '') {
|
||||
const metadata = {
|
||||
name: '',
|
||||
version: '',
|
||||
author: '',
|
||||
description: '',
|
||||
homepage: '',
|
||||
quality: [],
|
||||
qualityDetails: [],
|
||||
features: [],
|
||||
sources: [],
|
||||
musicPlatform: '',
|
||||
musicPlatformName: '',
|
||||
apiUrls: [],
|
||||
filteredApiUrls: [],
|
||||
qzApiEndpoint: null,
|
||||
isObfuscated: false,
|
||||
};
|
||||
|
||||
// 检测是否混淆
|
||||
metadata.isObfuscated = /_0x[0-9a-f]+/.test(code) || code.split('\n').length < 10 && code.length > 50000;
|
||||
|
||||
if (platform === 'qzv2') {
|
||||
extractQZV2Metadata(code, metadata);
|
||||
} else if (platform === 'lx') {
|
||||
extractLxMetadata(code, metadata);
|
||||
} else if (platform === 'musicfree') {
|
||||
extractMusicFreeMetadata(code, metadata);
|
||||
}
|
||||
|
||||
// 根据 URL 域名推断音乐平台(备用方法)
|
||||
if (!metadata.musicPlatform) {
|
||||
const allUrls = code.match(/https?:\/\/[^\s"'`<>)}\]]+/g) || [];
|
||||
metadata.musicPlatform = detectPlatformFromUrls(allUrls);
|
||||
if (metadata.musicPlatform && MUSIC_SOURCES[metadata.musicPlatform]) {
|
||||
metadata.musicPlatformName = MUSIC_SOURCES[metadata.musicPlatform].name;
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤 API URL
|
||||
metadata.filteredApiUrls = extractPlatformApiUrls(code, metadata.musicPlatform);
|
||||
|
||||
// 提取 QZ API 端点信息
|
||||
metadata.qzApiEndpoint = extractQzApiEndpoint(code);
|
||||
|
||||
if (!metadata.name && filename) {
|
||||
metadata.name = filename.replace(/\.(js|zip|qz)$/i, '');
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 QZV2 格式元数据 - 深度解析 pluginInfo
|
||||
* 支持混淆代码和 webpack 打包代码
|
||||
*/
|
||||
function extractQZV2Metadata(code, metadata) {
|
||||
// 1. 尝试解析完整的 pluginInfo 对象
|
||||
const pluginInfo = extractPluginInfo(code);
|
||||
|
||||
if (pluginInfo) {
|
||||
// info 字段
|
||||
if (pluginInfo.info) {
|
||||
metadata.name = pluginInfo.info.name || '';
|
||||
metadata.version = String(pluginInfo.info.version || '');
|
||||
metadata.description = pluginInfo.info.description || '';
|
||||
metadata.author = pluginInfo.info.author || '';
|
||||
|
||||
// 从 info.id 获取音乐平台 (关键!)
|
||||
// 逆向来源: pluginSystem.ts - info.id 同时作为音源平台标识
|
||||
const platformId = pluginInfo.info.id;
|
||||
if (platformId && MUSIC_SOURCES[platformId]) {
|
||||
metadata.musicPlatform = platformId;
|
||||
metadata.musicPlatformName = MUSIC_SOURCES[platformId].name;
|
||||
metadata.sources = [platformId];
|
||||
} else if (platformId) {
|
||||
metadata.musicPlatform = platformId;
|
||||
metadata.musicPlatformName = platformId.toUpperCase();
|
||||
metadata.sources = [platformId];
|
||||
}
|
||||
}
|
||||
|
||||
// quality 字段 - 解析完整的音质对象数组
|
||||
if (Array.isArray(pluginInfo.quality) && pluginInfo.quality.length > 0) {
|
||||
metadata.qualityDetails = pluginInfo.quality.map(q => ({
|
||||
id: q.id || '',
|
||||
name: q.name || (QUALITY_MAP[q.id] ? QUALITY_MAP[q.id].name : q.id),
|
||||
ui: q.ui || ''
|
||||
}));
|
||||
metadata.quality = pluginInfo.quality.map(q => q.id).filter(Boolean);
|
||||
}
|
||||
|
||||
// supportFunc 字段
|
||||
if (Array.isArray(pluginInfo.supportFunc) && pluginInfo.supportFunc.length > 0) {
|
||||
metadata.features = pluginInfo.supportFunc;
|
||||
}
|
||||
|
||||
// env 字段
|
||||
if (Array.isArray(pluginInfo.env) && pluginInfo.env.length > 0) {
|
||||
metadata.features.push('env_config');
|
||||
// 提取 env 配置项
|
||||
metadata.envConfig = pluginInfo.env.map(e => ({
|
||||
key: e.key || '',
|
||||
name: e.name || '',
|
||||
description: e.description || '',
|
||||
})).filter(e => e.key);
|
||||
}
|
||||
|
||||
// ext 字段
|
||||
if (Array.isArray(pluginInfo.ext) && pluginInfo.ext.length > 0) {
|
||||
metadata.features.push('extensions');
|
||||
// 提取扩展信息
|
||||
metadata.extConfig = pluginInfo.ext.map(e => ({
|
||||
name: e.name || '',
|
||||
description: e.description || '',
|
||||
entry: e.entry || '',
|
||||
type: e.type || '',
|
||||
})).filter(e => e.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果 pluginInfo 解析失败或 info.id 未提取到,尝试从代码中直接搜索
|
||||
if (!metadata.musicPlatform) {
|
||||
// 搜索 pluginInfo 附近的 info.id (混淆代码中 id 值仍是明文)
|
||||
const idPatterns = [
|
||||
/info['"]?\s*:\s*\{[^}]*?['"]?id['"]?\s*:\s*['"]([a-z]{2})['"]/i,
|
||||
/['"]?id['"]?\s*:\s*['"]([a-z]{2})['"][^}]*?['"]?version['"]?/i,
|
||||
/source[=:'"]+\s*['"]?(wy|tx|kw|kg|mg)['"]/i,
|
||||
];
|
||||
|
||||
for (const pattern of idPatterns) {
|
||||
const match = code.match(pattern);
|
||||
if (match && MUSIC_SOURCES[match[1]]) {
|
||||
metadata.musicPlatform = match[1];
|
||||
metadata.musicPlatformName = MUSIC_SOURCES[match[1]].name;
|
||||
metadata.sources = [match[1]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 正则回退: 提取 name (仅从 pluginInfo 上下文中提取,避免混淆代码误匹配)
|
||||
if (!metadata.name) {
|
||||
// 在 pluginInfo 附近的 info 块中查找 name
|
||||
const infoNameMatch = code.match(/info['"]?\s*:\s*\{[^}]*?['"]?name['"]?\s*:\s*["'`]([^"'`]+)["'`]/);
|
||||
if (infoNameMatch && !infoNameMatch[1].startsWith('_0x') && !infoNameMatch[1].match(/^[a-f0-9]{6,}$/i)) {
|
||||
metadata.name = infoNameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 正则回退: 提取 version
|
||||
if (!metadata.version) {
|
||||
const versionMatch = code.match(/['"]?version['"]?\s*:\s*['"]?(\d[\d.]*)/);
|
||||
if (versionMatch) metadata.version = versionMatch[1];
|
||||
}
|
||||
|
||||
// 5. 正则回退: 提取 description
|
||||
if (!metadata.description) {
|
||||
const descMatch = code.match(/['"]?description['"]?\s*:\s*["'`]([^"'`]+)["'`]/);
|
||||
if (descMatch && !descMatch[1].startsWith('_0x')) metadata.description = descMatch[1];
|
||||
}
|
||||
|
||||
// 6. 如果还没找到音乐平台,从文件名推断
|
||||
if (!metadata.musicPlatform && metadata.name) {
|
||||
const idMatch = metadata.name.match(/\b(kw|tx|wy|kg|mg)\b/i);
|
||||
if (idMatch) {
|
||||
const id = idMatch[1].toLowerCase();
|
||||
if (MUSIC_SOURCES[id]) {
|
||||
metadata.musicPlatform = id;
|
||||
metadata.musicPlatformName = MUSIC_SOURCES[id].name;
|
||||
metadata.sources = [id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 如果还没找到音质,使用正则从代码中搜索已知音质 ID
|
||||
if (metadata.quality.length === 0) {
|
||||
const qualitySet = new Set();
|
||||
const qualityIds = Object.keys(QUALITY_MAP);
|
||||
for (const qid of qualityIds) {
|
||||
const regex = new RegExp(`["'\`]${qid}["'\`]`);
|
||||
if (regex.test(code)) qualitySet.add(qid);
|
||||
}
|
||||
metadata.quality = [...qualitySet];
|
||||
metadata.qualityDetails = metadata.quality.map(q => ({
|
||||
id: q,
|
||||
name: QUALITY_MAP[q] ? QUALITY_MAP[q].name : q,
|
||||
ui: ''
|
||||
}));
|
||||
}
|
||||
|
||||
// 8. 补充功能检测 (从代码中搜索已知 supportFunc 名称)
|
||||
if (metadata.features.length === 0) {
|
||||
for (const funcName of Object.keys(SUPPORT_FUNC_NAMES)) {
|
||||
const regex = new RegExp(`['"]${funcName}['"]`);
|
||||
if (regex.test(code)) metadata.features.push(funcName);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 额外功能检测
|
||||
if (/getUrl/.test(code) && !metadata.features.includes('getUrl')) metadata.features.push('getUrl');
|
||||
if (/getLyric/.test(code) && !metadata.features.some(f => f.includes('lyric') || f.includes('Lyric'))) metadata.features.push('getLyric');
|
||||
if (/musicSearch/.test(code) && !metadata.features.includes('musicSearch')) metadata.features.push('musicSearch');
|
||||
if (/allPage/.test(code)) metadata.features.push('allPage');
|
||||
if (/getAlbum/.test(code) && !metadata.features.includes('album')) metadata.features.push('getAlbum');
|
||||
if (/hotSearch/.test(code) && !metadata.features.includes('hotSearch')) metadata.features.push('hotSearch');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 Lx Music 格式元数据
|
||||
*/
|
||||
function extractLxMetadata(code, metadata) {
|
||||
const nameMatch = code.match(/@name\s+(.+)/);
|
||||
if (nameMatch) metadata.name = nameMatch[1].trim();
|
||||
const descMatch = code.match(/@description\s+(.+)/);
|
||||
if (descMatch) metadata.description = descMatch[1].trim();
|
||||
const versionMatch = code.match(/@version\s+(.+)/);
|
||||
if (versionMatch) metadata.version = versionMatch[1].trim();
|
||||
const authorMatch = code.match(/@author\s+(.+)/);
|
||||
if (authorMatch) metadata.author = authorMatch[1].trim();
|
||||
const homeMatch = code.match(/@homepage\s+(.+)/);
|
||||
if (homeMatch) metadata.homepage = homeMatch[1].trim();
|
||||
|
||||
// 澜音格式: pluginInfo 扁平结构
|
||||
if (!metadata.name) {
|
||||
const crNameMatch = code.match(/name\s*:\s*['"]([^'"]+)['"]/);
|
||||
if (crNameMatch) metadata.name = crNameMatch[1];
|
||||
}
|
||||
if (!metadata.version) {
|
||||
const crVersionMatch = code.match(/version\s*:\s*['"]([^'"]+)['"]/);
|
||||
if (crVersionMatch) metadata.version = crVersionMatch[1];
|
||||
}
|
||||
if (!metadata.author) {
|
||||
const crAuthorMatch = code.match(/author\s*:\s*['"]([^'"]+)['"]/);
|
||||
if (crAuthorMatch) metadata.author = crAuthorMatch[1];
|
||||
}
|
||||
|
||||
// 音质检测 - 完整等级体系
|
||||
const qualitySet = new Set();
|
||||
for (const qid of Object.keys(QUALITY_MAP)) {
|
||||
const regex = new RegExp(`['"\`]${qid}['"\`]`);
|
||||
if (regex.test(code)) qualitySet.add(qid);
|
||||
}
|
||||
metadata.quality = [...qualitySet];
|
||||
if (qualitySet.size > 0) {
|
||||
metadata.qualityDetails = metadata.quality.map(q => ({
|
||||
id: q, name: QUALITY_MAP[q] ? QUALITY_MAP[q].name : q, ui: ''
|
||||
}));
|
||||
}
|
||||
|
||||
// 功能检测
|
||||
if (/musicUrl/.test(code)) metadata.features.push('musicUrl');
|
||||
if (/lyric/.test(code)) metadata.features.push('lyric');
|
||||
if (/pic/.test(code)) metadata.features.push('pic');
|
||||
if (/cerumusic\.request/.test(code)) metadata.features.push('cerumusic_request');
|
||||
|
||||
// 音源平台检测 - sources 对象的 key
|
||||
const sourcePattern = /\b(kw|kg|tx|wy|mg|local)\b\s*:/g;
|
||||
let match;
|
||||
const sourceSet = new Set();
|
||||
while ((match = sourcePattern.exec(code)) !== null) sourceSet.add(match[1]);
|
||||
metadata.sources = [...sourceSet];
|
||||
|
||||
// 设置主音乐平台
|
||||
for (const s of metadata.sources) {
|
||||
if (s !== 'local' && MUSIC_SOURCES[s]) {
|
||||
metadata.musicPlatform = s;
|
||||
metadata.musicPlatformName = MUSIC_SOURCES[s].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 多平台标记
|
||||
if (metadata.sources.filter(s => s !== 'local').length > 1) {
|
||||
metadata.musicPlatform = 'multi';
|
||||
metadata.musicPlatformName = '多平台';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 MusicFree 格式元数据
|
||||
*/
|
||||
function extractMusicFreeMetadata(code, metadata) {
|
||||
const platformMatch = code.match(/platform\s*:\s*["'`]([^"'`]+)["'`]/);
|
||||
if (platformMatch) {
|
||||
metadata.name = platformMatch[1];
|
||||
const pid = platformMatch[1].toLowerCase();
|
||||
for (const [key, val] of Object.entries(MUSIC_SOURCES)) {
|
||||
if (pid.includes(key) || pid.includes(val.name) || (val.fullName && pid.includes(val.fullName.toLowerCase()))) {
|
||||
metadata.musicPlatform = key;
|
||||
metadata.musicPlatformName = val.name;
|
||||
metadata.sources = [key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const versionMatch = code.match(/version\s*:\s*["'`]([^"'`]+)["'`]/);
|
||||
if (versionMatch) metadata.version = versionMatch[1];
|
||||
const authorMatch = code.match(/author\s*:\s*["'`]([^"'`]+)["'`]/);
|
||||
if (authorMatch) metadata.author = authorMatch[1];
|
||||
const srcUrlMatch = code.match(/srcUrl\s*:\s*["'`]([^"'`]+)["'`]/);
|
||||
if (srcUrlMatch) metadata.homepage = srcUrlMatch[1];
|
||||
|
||||
// 音质检测
|
||||
const qualitySet = new Set();
|
||||
for (const qid of Object.keys(QUALITY_MAP)) {
|
||||
const regex = new RegExp(`['"\`]${qid}['"\`]`);
|
||||
if (regex.test(code)) qualitySet.add(qid);
|
||||
}
|
||||
metadata.quality = [...qualitySet];
|
||||
if (qualitySet.size > 0) {
|
||||
metadata.qualityDetails = metadata.quality.map(q => ({
|
||||
id: q, name: QUALITY_MAP[q] ? QUALITY_MAP[q].name : q, ui: ''
|
||||
}));
|
||||
}
|
||||
|
||||
// 功能检测
|
||||
const featureMap = [
|
||||
['search', 'search'], ['getMediaSource', 'getMediaSource'], ['getMusicInfo', 'getMusicInfo'],
|
||||
['getLyric', 'getLyric'], ['getAlbumInfo', 'getAlbumInfo'], ['getMusicSheetInfo', 'getMusicSheetInfo'],
|
||||
['getArtistWorks', 'getArtistWorks'], ['importMusicItem', 'importMusicItem'],
|
||||
['importMusicSheet', 'importMusicSheet'], ['getTopLists', 'getTopLists'],
|
||||
['getTopListDetail', 'getTopListDetail'], ['getRecommendSheetTags', 'getRecommendSheetTags'],
|
||||
['getRecommendSheetsByTag', 'getRecommendSheetsByTag'], ['getMusicComments', 'getMusicComments']
|
||||
];
|
||||
for (const [pattern, label] of featureMap) {
|
||||
if (new RegExp(pattern).test(code)) metadata.features.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 列表推断音乐平台
|
||||
*/
|
||||
function detectPlatformFromUrls(urls) {
|
||||
for (const url of urls) {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
for (const [id, info] of Object.entries(MUSIC_SOURCES)) {
|
||||
if (id === 'local') continue;
|
||||
for (const domain of info.domains) {
|
||||
if (lowerUrl.includes(domain)) return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectPlatform, PLATFORMS, PLATFORM_LABELS, ALL_PLATFORMS, MUSIC_SOURCES,
|
||||
QUALITY_MAP, SUPPORT_FUNC_NAMES, extractPluginInfo, extractQzApiEndpoint
|
||||
};
|
||||
517
lib/tester.js
Normal file
517
lib/tester.js
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* 插件测试模块 v3.0 - 逆向分析增强版
|
||||
* 支持日志回调、QZ API 端点测试、音质级别测试
|
||||
*
|
||||
* 逆向来源:
|
||||
* - QZ getUrl(songId, quality) 调用 api.qz.shiqianjiang.cn/music/url
|
||||
* - 代理服务器 localhost:5266 测试模式
|
||||
* - 各音乐平台直接 API 端点测试
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { URL } = require('url');
|
||||
const { detectPlatform, PLATFORMS, QUALITY_MAP } = require('./detector');
|
||||
|
||||
// ============================================================
|
||||
// HTTP 工具函数
|
||||
// ============================================================
|
||||
|
||||
function fetchPluginCode(url, timeout = 15000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const client = parsedUrl.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/3.0', 'Accept': '*/*' },
|
||||
timeout
|
||||
};
|
||||
const req = client.request(options, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return fetchPluginCode(new URL(res.headers.location, url).href, timeout).then(resolve).catch(reject);
|
||||
}
|
||||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
||||
let data = ''; let dataBytes = Buffer.alloc(0);
|
||||
res.on('data', (chunk) => { typeof chunk === 'string' ? data += chunk : dataBytes = Buffer.concat([dataBytes, chunk]); });
|
||||
res.on('end', () => {
|
||||
const content = data || dataBytes.toString('utf-8');
|
||||
resolve({ code: content, statusCode: res.statusCode, headers: res.headers, size: Buffer.byteLength(content) });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('下载超时')); });
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 URL 延时 - 支持自定义 headers
|
||||
*/
|
||||
function testUrlLatency(url, options = {}) {
|
||||
const timeout = options.timeout || 10000;
|
||||
const headers = options.headers || { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/3.0', 'Accept': '*/*' };
|
||||
if (options.range) headers['Range'] = options.range;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now(); let resolved = false;
|
||||
const finish = (result) => { if (!resolved) { resolved = true; resolve(result); } };
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const client = parsedUrl.protocol === 'https:' ? https : http;
|
||||
const reqOptions = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: options.method || 'GET',
|
||||
headers: headers,
|
||||
timeout
|
||||
};
|
||||
const req = client.request(reqOptions, (res) => {
|
||||
const latency = Date.now() - startTime;
|
||||
let bodySize = 0;
|
||||
let bodyChunks = [];
|
||||
res.on('data', (chunk) => {
|
||||
bodySize += chunk.length;
|
||||
if (bodyChunks.length < 5) bodyChunks.push(chunk);
|
||||
if (bodySize > 8192) res.destroy();
|
||||
});
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(bodyChunks).toString('utf-8').substring(0, 500);
|
||||
finish({
|
||||
url, status: res.statusCode, latency,
|
||||
success: res.statusCode >= 200 && res.statusCode < 400,
|
||||
contentType: res.headers['content-type'] || '',
|
||||
contentLength: parseInt(res.headers['content-length'] || '0', 10),
|
||||
bodyPreview: body,
|
||||
error: null
|
||||
});
|
||||
});
|
||||
res.on('error', () => finish({
|
||||
url, status: res.statusCode, latency, success: false,
|
||||
contentType: '', contentLength: 0, bodyPreview: '', error: '响应读取错误'
|
||||
}));
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
finish({ url, status: 0, latency: timeout, success: false, contentType: '', contentLength: 0, bodyPreview: '', error: '请求超时' });
|
||||
});
|
||||
req.on('error', (err) => finish({
|
||||
url, status: 0, latency: Date.now() - startTime, success: false,
|
||||
contentType: '', contentLength: 0, bodyPreview: '', error: err.message
|
||||
}));
|
||||
req.end();
|
||||
} catch (err) {
|
||||
finish({ url, status: 0, latency: 0, success: false, contentType: '', contentLength: 0, bodyPreview: '', error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建并测试 QZ API 端点
|
||||
* 逆向来源: getUrl(songId, quality) -> api.qz.shiqianjiang.cn/music/url?songId=X&quality=Y&source=Z
|
||||
*/
|
||||
async function testQzApiEndpoint(qzApiEndpoint, musicPlatform, qualities, log) {
|
||||
const results = [];
|
||||
|
||||
if (!qzApiEndpoint || !qzApiEndpoint.apiUrl) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 构建基础 API URL
|
||||
let baseUrl = qzApiEndpoint.apiUrl;
|
||||
// 替换模板变量为测试值
|
||||
baseUrl = baseUrl.replace(/\$\{songId\}|\$\{id\}|\$\{musicId\}/g, '1');
|
||||
baseUrl = baseUrl.replace(/\$\{quality\}/g, '128k');
|
||||
baseUrl = baseUrl.replace(/\$\{[^}]+\}/g, '1');
|
||||
|
||||
// 如果 URL 中没有 source 参数,添加它
|
||||
if (musicPlatform && !/source=/.test(baseUrl)) {
|
||||
baseUrl += (baseUrl.includes('?') ? '&' : '?') + `source=${musicPlatform}`;
|
||||
}
|
||||
|
||||
// 构建请求 headers
|
||||
const headers = { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/3.0', 'Accept': 'application/json' };
|
||||
if (qzApiEndpoint.apiKey) {
|
||||
headers['X-API-KEY'] = qzApiEndpoint.apiKey;
|
||||
log('info', `使用 API Key: ${qzApiEndpoint.apiKey.substring(0, 8)}...`);
|
||||
}
|
||||
|
||||
log('info', `测试 QZ API 端点: ${truncate(baseUrl, 100)}`);
|
||||
|
||||
// 测试基础 API 连通性
|
||||
const baseTest = await testUrlLatency(baseUrl, { headers, timeout: 12000 });
|
||||
results.push({
|
||||
...baseTest,
|
||||
isQzApi: true,
|
||||
quality: 'basic',
|
||||
label: 'API 连通性测试'
|
||||
});
|
||||
|
||||
if (baseTest.success) {
|
||||
log('success', `QZ API 连通 | 状态: ${baseTest.status} | 延时: ${baseTest.latency}ms`);
|
||||
if (baseTest.bodyPreview) {
|
||||
// 检查响应体是否包含 url 字段
|
||||
if (baseTest.bodyPreview.includes('"url"') || baseTest.bodyPreview.includes("'url'")) {
|
||||
log('success', `API 返回包含 url 字段`);
|
||||
} else if (baseTest.bodyPreview.includes('error') || baseTest.bodyPreview.includes('Error')) {
|
||||
log('warn', `API 返回错误信息: ${truncate(baseTest.bodyPreview, 100)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('warn', `QZ API 不通 | 状态: ${baseTest.status} | 错误: ${baseTest.error || '未知'}`);
|
||||
}
|
||||
|
||||
// 逐个测试音质级别
|
||||
const testQualities = qualities.length > 0 ? qualities : ['128k', '320k', 'flac'];
|
||||
log('info', `开始音质级别测试 (${testQualities.length} 个)...`);
|
||||
|
||||
for (const quality of testQualities) {
|
||||
// 构建音质测试 URL
|
||||
let qualityUrl = qzApiEndpoint.apiUrl
|
||||
.replace(/\$\{songId\}|\$\{id\}|\$\{musicId\}/g, '1')
|
||||
.replace(/\$\{quality\}/g, quality)
|
||||
.replace(/\$\{[^}]+\}/g, '1');
|
||||
|
||||
if (musicPlatform && !/source=/.test(qualityUrl)) {
|
||||
qualityUrl += (qualityUrl.includes('?') ? '&' : '?') + `source=${musicPlatform}`;
|
||||
}
|
||||
|
||||
// 替换已有的 quality 参数值
|
||||
qualityUrl = qualityUrl.replace(/(quality=)[^&]*/, `$1${quality}`);
|
||||
|
||||
const qualityTest = await testUrlLatency(qualityUrl, { headers, timeout: 10000 });
|
||||
const qualityLabel = QUALITY_MAP[quality] ? QUALITY_MAP[quality].name : quality;
|
||||
|
||||
results.push({
|
||||
...qualityTest,
|
||||
isQzApi: true,
|
||||
quality: quality,
|
||||
label: qualityLabel
|
||||
});
|
||||
|
||||
const statusIcon = qualityTest.success ? 'OK' : 'FAIL';
|
||||
log(qualityTest.success ? 'success' : 'warn',
|
||||
`音质 ${quality} (${qualityLabel}) | ${statusIcon} | 状态: ${qualityTest.status} | 延时: ${qualityTest.latency}ms`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 测试主函数
|
||||
// ============================================================
|
||||
|
||||
async function testPlugin(url, customCode = null, filename = '', logger = null) {
|
||||
const log = (level, message, data) => { if (logger) logger(level, message, data); };
|
||||
const result = {
|
||||
url: url || filename, filename,
|
||||
platform: 'unknown', platformLabel: '未知格式',
|
||||
metadata: {}, downloadInfo: null, latencyTests: [], qualityTests: [],
|
||||
overallStatus: 'pending', summary: {}, testedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
let code = customCode;
|
||||
|
||||
// 步骤1: 下载/读取
|
||||
log('info', `开始处理: ${filename || url || '未知'}`);
|
||||
if (!code && url) {
|
||||
log('info', `正在下载插件文件...`);
|
||||
try {
|
||||
const downloadStart = Date.now();
|
||||
const response = await fetchPluginCode(url);
|
||||
const downloadLatency = Date.now() - downloadStart;
|
||||
code = response.code;
|
||||
result.downloadInfo = {
|
||||
success: true, latency: downloadLatency, size: response.size,
|
||||
statusCode: response.statusCode, contentType: response.headers['content-type'] || ''
|
||||
};
|
||||
log('success', `下载完成 | 大小: ${formatSize(response.size)} | 耗时: ${downloadLatency}ms`);
|
||||
} catch (err) {
|
||||
result.downloadInfo = { success: false, latency: 0, error: err.message };
|
||||
result.overallStatus = 'failed';
|
||||
result.summary = { error: `插件下载失败: ${err.message}`, score: 0 };
|
||||
log('error', `下载失败: ${err.message}`);
|
||||
return result;
|
||||
}
|
||||
} else if (code) {
|
||||
result.downloadInfo = {
|
||||
success: true, latency: 0, size: Buffer.byteLength(code),
|
||||
statusCode: 0, contentType: 'text/plain'
|
||||
};
|
||||
log('success', `文件已读取 | 大小: ${formatSize(Buffer.byteLength(code))}`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
result.overallStatus = 'failed';
|
||||
result.summary = { error: '无插件代码', score: 0 };
|
||||
log('error', '无插件代码');
|
||||
return result;
|
||||
}
|
||||
|
||||
// 步骤2: 格式检测
|
||||
log('info', `正在识别插件格式...`);
|
||||
const detection = detectPlatform(code, filename);
|
||||
result.platform = detection.platform;
|
||||
result.platformLabel = detection.platformLabel;
|
||||
result.metadata = detection.metadata;
|
||||
result.detectionScores = detection.scores;
|
||||
|
||||
if (detection.platform !== 'unknown') {
|
||||
log('success', `格式识别: ${detection.platformLabel} (置信度: ${detection.confidence})`);
|
||||
if (detection.metadata.isObfuscated) {
|
||||
log('info', `检测到混淆代码,使用部分提取模式`);
|
||||
}
|
||||
if (detection.metadata.name) log('info', `插件名称: ${detection.metadata.name}`);
|
||||
if (detection.metadata.version) log('info', `版本: ${detection.metadata.version}`);
|
||||
if (detection.metadata.description) log('info', `描述: ${detection.metadata.description}`);
|
||||
if (detection.metadata.musicPlatformName) {
|
||||
log('success', `音乐平台: ${detection.metadata.musicPlatformName} (${detection.metadata.musicPlatform || 'N/A'})`);
|
||||
}
|
||||
} else {
|
||||
log('warn', `未能识别插件格式,得分: lx=${detection.scores.lx}, qzv2=${detection.scores.qzv2}, musicfree=${detection.scores.musicfree}`);
|
||||
}
|
||||
|
||||
// 步骤3: 功能与音质分析
|
||||
if (detection.metadata.features && detection.metadata.features.length > 0) {
|
||||
log('info', `功能列表: ${detection.metadata.features.join(', ')}`);
|
||||
}
|
||||
if (detection.metadata.sources && detection.metadata.sources.length > 0) {
|
||||
log('info', `音源: ${detection.metadata.sources.join(', ').toUpperCase()}`);
|
||||
}
|
||||
if (detection.metadata.quality && detection.metadata.quality.length > 0) {
|
||||
const qualityStr = detection.metadata.quality.map((q, i) => {
|
||||
const detail = detection.metadata.qualityDetails[i] || {};
|
||||
return `${q}(${detail.name || QUALITY_MAP[q]?.name || q})`;
|
||||
}).join(', ');
|
||||
log('success', `支持音质 (${detection.metadata.quality.length} 级): ${qualityStr}`);
|
||||
}
|
||||
if (detection.metadata.envConfig && detection.metadata.envConfig.length > 0) {
|
||||
log('info', `环境配置: ${detection.metadata.envConfig.map(e => `${e.key}(${e.name})`).join(', ')}`);
|
||||
}
|
||||
if (detection.metadata.extConfig && detection.metadata.extConfig.length > 0) {
|
||||
log('info', `扩展功能: ${detection.metadata.extConfig.map(e => e.name).join(', ')}`);
|
||||
}
|
||||
|
||||
// 步骤4: 延时测试
|
||||
// 4a: 测试插件源 URL 可达性
|
||||
if (url && !customCode) {
|
||||
log('info', `测试插件源 URL 可达性...`);
|
||||
const pluginUrlTest = await testUrlLatency(url);
|
||||
result.latencyTests.push({ ...pluginUrlTest, isPluginUrl: true });
|
||||
log(pluginUrlTest.success ? 'success' : 'warn',
|
||||
`插件源 URL | 状态: ${pluginUrlTest.status} | 延时: ${pluginUrlTest.latency}ms`);
|
||||
}
|
||||
|
||||
// 4b: 测试 QZ API 端点 (核心!)
|
||||
const qzApi = detection.metadata.qzApiEndpoint;
|
||||
if (qzApi && qzApi.apiUrl) {
|
||||
log('info', `发现 QZ API 端点: ${truncate(qzApi.apiUrl, 80)}`);
|
||||
if (qzApi.sourceParam) log('info', `API source 参数: ${qzApi.sourceParam}`);
|
||||
if (qzApi.apiKey) log('info', `API Key: ${qzApi.apiKey.substring(0, 8)}...`);
|
||||
|
||||
const qzApiResults = await testQzApiEndpoint(
|
||||
qzApi,
|
||||
detection.metadata.musicPlatform,
|
||||
detection.metadata.quality,
|
||||
log
|
||||
);
|
||||
result.latencyTests.push(...qzApiResults);
|
||||
} else {
|
||||
log('info', `未发现 QZ 统一 API 端点,测试直连 API...`);
|
||||
}
|
||||
|
||||
// 4c: 测试音乐平台直连 API 端点
|
||||
const directApiUrls = (detection.metadata.filteredApiUrls || []).slice(0, 5);
|
||||
const qzApiUrls = directApiUrls.filter(u => /api\.qz\.shiqianjiang/i.test(u));
|
||||
const platformUrls = directApiUrls.filter(u => !/api\.qz\.shiqianjiang/i.test(u));
|
||||
|
||||
if (platformUrls.length > 0) {
|
||||
log('info', `测试音乐平台直连 API (${platformUrls.length} 个)...`);
|
||||
for (let i = 0; i < platformUrls.length; i++) {
|
||||
log('info', `[${i + 1}/${platformUrls.length}] ${truncate(platformUrls[i], 80)}`);
|
||||
}
|
||||
const latencyPromises = platformUrls.map(u => testUrlLatency(u, { range: 'bytes=0-1023' }));
|
||||
const latencyResults = await Promise.all(latencyPromises);
|
||||
result.latencyTests.push(...latencyResults);
|
||||
|
||||
const successCount = latencyResults.filter(t => t.success).length;
|
||||
if (successCount > 0) {
|
||||
const avgLatency = Math.round(
|
||||
latencyResults.filter(t => t.success).reduce((sum, t) => sum + t.latency, 0) / successCount
|
||||
);
|
||||
log('success', `直连 API 测试完成 | 成功: ${successCount}/${platformUrls.length} | 平均延时: ${avgLatency}ms`);
|
||||
} else {
|
||||
log('warn', `直连 API 测试完成 | 所有端点均不可达`);
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤5: 音质测试结果汇总
|
||||
const declaredQualities = detection.metadata.quality || [];
|
||||
const qualityDetails = detection.metadata.qualityDetails || [];
|
||||
const qualityMap = QUALITY_MAP;
|
||||
|
||||
// 从 QZ API 测试结果中提取音质测试状态
|
||||
const qzQualityResults = result.latencyTests.filter(t => t.isQzApi && t.quality && t.quality !== 'basic');
|
||||
|
||||
result.qualityTests = declaredQualities.map((q, idx) => {
|
||||
const detail = qualityDetails[idx] || {};
|
||||
const qzResult = qzQualityResults.find(t => t.quality === q);
|
||||
return {
|
||||
quality: q,
|
||||
label: detail.name || qualityMap[q]?.name || q,
|
||||
ui: detail.ui || '',
|
||||
declared: true,
|
||||
tested: !!qzResult,
|
||||
testStatus: qzResult ? (qzResult.success ? 'pass' : 'fail') : 'untested',
|
||||
testLatency: qzResult?.latency || 0,
|
||||
testStatusCode: qzResult?.status || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 如果有 QZ API 测试但没有声明音质,从测试结果生成
|
||||
if (result.qualityTests.length === 0 && qzQualityResults.length > 0) {
|
||||
result.qualityTests = qzQualityResults.map(t => ({
|
||||
quality: t.quality,
|
||||
label: t.label || qualityMap[t.quality]?.name || t.quality,
|
||||
ui: '',
|
||||
declared: false,
|
||||
tested: true,
|
||||
testStatus: t.success ? 'pass' : 'fail',
|
||||
testLatency: t.latency,
|
||||
testStatusCode: t.status,
|
||||
}));
|
||||
}
|
||||
|
||||
// 如果没有任何音质信息,使用默认值
|
||||
if (result.qualityTests.length === 0 && detection.platform !== 'unknown') {
|
||||
const defaults = getDefaultQualities(detection.platform);
|
||||
result.qualityTests = defaults.map(q => ({
|
||||
quality: q, label: qualityMap[q]?.name || q, ui: '',
|
||||
declared: false, tested: false, testStatus: 'untested', testLatency: 0, testStatusCode: 0
|
||||
}));
|
||||
log('info', `未声明音质,使用默认音质列表: ${defaults.join(', ')}`);
|
||||
}
|
||||
|
||||
// 步骤6: 评分
|
||||
log('info', `正在计算综合评分...`);
|
||||
result.summary = calculateSummary(result);
|
||||
result.overallStatus = result.summary.score >= 60 ? 'passed' : (result.summary.score > 0 ? 'warning' : 'failed');
|
||||
log('success', `测试完成 | 评分: ${result.summary.score}/100 | 等级: ${result.summary.details?.grade || 'D'} | 状态: ${result.overallStatus === 'passed' ? '通过' : (result.overallStatus === 'warning' ? '警告' : '失败')}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getDefaultQualities(platform) {
|
||||
const defaults = {
|
||||
lx: ['128k', '320k', 'flac', 'flac24bit', 'hires', 'master'],
|
||||
qzv2: ['128k', '320k', 'flac', 'hires', 'master'],
|
||||
musicfree: ['low', 'standard', 'high', 'super', 'hires', 'master']
|
||||
};
|
||||
return defaults[platform] || [];
|
||||
}
|
||||
|
||||
function calculateSummary(result) {
|
||||
let score = 0;
|
||||
const details = {};
|
||||
|
||||
// 下载/读取 (20分)
|
||||
if (result.downloadInfo && result.downloadInfo.success) {
|
||||
score += 20; details.download = '成功';
|
||||
} else if (result.downloadInfo && !result.downloadInfo.success) {
|
||||
details.download = '失败';
|
||||
return { score: 0, details, error: result.downloadInfo.error };
|
||||
} else {
|
||||
score += 20; details.download = '文件上传';
|
||||
}
|
||||
|
||||
// 格式识别 (20分)
|
||||
if (result.platform !== 'unknown') {
|
||||
score += 20; details.format = result.platformLabel;
|
||||
} else {
|
||||
details.format = '未识别';
|
||||
}
|
||||
|
||||
// 音乐平台识别 (5分)
|
||||
if (result.metadata?.musicPlatform) {
|
||||
score += 5;
|
||||
details.musicPlatform = result.metadata.musicPlatformName;
|
||||
}
|
||||
|
||||
// 延时测试 (30分)
|
||||
const allLatencyTests = result.latencyTests || [];
|
||||
const successLatency = allLatencyTests.filter(t => t.success);
|
||||
|
||||
if (allLatencyTests.length > 0) {
|
||||
if (successLatency.length > 0) {
|
||||
const avgLatency = successLatency.reduce((sum, t) => sum + t.latency, 0) / successLatency.length;
|
||||
details.avgLatency = Math.round(avgLatency);
|
||||
details.successRate = `${successLatency.length}/${allLatencyTests.length}`;
|
||||
|
||||
// QZ API 测试单独加分
|
||||
const qzApiSuccess = successLatency.filter(t => t.isQzApi);
|
||||
if (qzApiSuccess.length > 0) {
|
||||
score += 25; // QZ API 通则高分
|
||||
details.qzApiStatus = 'OK';
|
||||
} else {
|
||||
if (avgLatency < 300) score += 25;
|
||||
else if (avgLatency < 800) score += 20;
|
||||
else if (avgLatency < 1500) score += 15;
|
||||
else if (avgLatency < 3000) score += 8;
|
||||
else score += 4;
|
||||
}
|
||||
} else {
|
||||
details.avgLatency = -1;
|
||||
details.successRate = `0/${allLatencyTests.length}`;
|
||||
score += 3;
|
||||
}
|
||||
} else {
|
||||
score += 15;
|
||||
details.avgLatency = -1;
|
||||
details.successRate = 'N/A';
|
||||
}
|
||||
|
||||
// 音质 (15分)
|
||||
if (result.qualityTests.length > 0) {
|
||||
const declaredCount = result.qualityTests.filter(q => q.declared).length;
|
||||
const testedCount = result.qualityTests.filter(q => q.tested).length;
|
||||
const passedCount = result.qualityTests.filter(q => q.testStatus === 'pass').length;
|
||||
score += Math.min(15, declaredCount * 3 + passedCount * 2);
|
||||
details.qualityCount = result.qualityTests.length;
|
||||
details.qualities = result.qualityTests.map(q => q.quality).join(', ');
|
||||
details.qualityPassRate = `${passedCount}/${testedCount}`;
|
||||
} else {
|
||||
details.qualityCount = 0;
|
||||
}
|
||||
|
||||
// 功能 (10分)
|
||||
if (result.metadata.features && result.metadata.features.length > 0) {
|
||||
score += Math.min(10, result.metadata.features.length * 2);
|
||||
details.features = result.metadata.features.join(', ');
|
||||
}
|
||||
|
||||
details.score = Math.min(100, score);
|
||||
if (details.score >= 85) details.grade = 'S';
|
||||
else if (details.score >= 70) details.grade = 'A';
|
||||
else if (details.score >= 55) details.grade = 'B';
|
||||
else if (details.score >= 40) details.grade = 'C';
|
||||
else details.grade = 'D';
|
||||
|
||||
return { score: Math.min(100, score), details };
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB';
|
||||
return (bytes / 1048576).toFixed(1) + 'MB';
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return '';
|
||||
if (str.length <= max) return str;
|
||||
return str.substring(0, max - 3) + '...';
|
||||
}
|
||||
|
||||
module.exports = { testPlugin, fetchPluginCode, testUrlLatency, testQzApiEndpoint };
|
||||
Reference in New Issue
Block a user