v3.0: 逆向分析增强 - QZ插件深度解析器

- 逆向分析 QZMusic_PC pluginSystem.ts 和 CeruMusic 插件文档
- 重写 detector.js: 支持 webpack 打包和混淆代码解析
- 正确提取 pluginInfo.info.id 识别音乐平台(mg/wy/tx/kw/kg)
- 提取 QZ API 端点(api.qz.shiqianjiang.cn)和 X-API-KEY
- 重写 tester.js: 自动测试 QZ API 端点+逐音质级别测试
- 前端更新: 音质测试状态显示(PASS/FAIL)、QZ API 信息展示
- MD报告增加 QZ API 状态列和音质通过率
- 完整音质等级体系: 128k~master 共14级
This commit is contained in:
Koneko Tester
2026-08-04 16:19:35 +08:00
parent 99d813cedf
commit 2c6ea2450f
7 changed files with 1465 additions and 223 deletions

View File

@@ -1,6 +1,12 @@
/**
* 插件格式自动检测模块
* 支持检测: Lx Music / QZV2 / MusicFree 三种插件格式
* 插件格式自动检测模块 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 = {
@@ -17,28 +23,84 @@ const PLATFORM_LABELS = {
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', 'master'],
format: 'JavaScript 脚本 (事件驱动)',
qualities: ['128k', '192k', '320k', 'flac', 'flac24bit', 'hires', 'master'],
features: ['musicUrl', 'lyric', 'pic'],
sources: ['kw', 'kg', 'tx', 'wy', 'mg', 'local'],
detectionHints: ['globalThis.lx', 'EVENT_NAMES', '@name 注释头'],
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)',
qualities: ['low', 'standard', 'exhigh', 'high', 'lossless', 'hires', 'master'],
features: ['search', 'getUrl', 'getLyric', 'getPlaylist', 'getAlbum'],
sources: ['自定义'],
detectionHints: ['pluginInfo', 'getUrl', 'supportFunc'],
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/'
},
{
@@ -47,38 +109,263 @@ const ALL_PLATFORMS = [
fullName: 'MusicFree',
format: 'CommonJS 模块',
qualities: ['low', 'standard', 'high', 'super', 'hires', 'master'],
features: ['search', 'getMediaSource', 'getMusicInfo', 'getLyric', 'getAlbumInfo', 'getMusicSheetInfo', 'getArtistWorks', 'getTopLists', 'getRecommendSheetTags', 'getMusicComments'],
features: ['search', 'getMediaSource', 'getMusicInfo', 'getLyric', 'getAlbumInfo'],
sources: ['自定义'],
detectionHints: ['platform:', 'getMediaSource', 'supportedSearchType'],
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 ===
// === 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 (/['"`]128k['"`]|['"`]320k['"`]|['"`]flac24bit['"`]/.test(code)) scores.lx += 1;
if (/sources\s*:\s*\{/.test(code) && /qualitys/.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 ===
if (/pluginInfo\s*[:?]/.test(code)) scores.qzv2 += 3;
// === 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 += 2;
if (/getPlay(list|List)\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 (/info\s*:\s*\{[\s\S]*?id\s*:/.test(code) && /info\s*:\s*\{[\s\S]*?name\s*:/.test(code)) scores.qzv2 += 1;
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 ===
// === 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;
@@ -107,92 +394,358 @@ function detectPlatform(code, filename = '') {
return { platform: bestPlatform, platformLabel: PLATFORM_LABELS[bestPlatform], metadata, confidence: bestScore, scores };
}
function extractMetadata(code, platform, filename) {
// ============================================================
// 元数据提取
// ============================================================
function extractMetadata(code, platform, filename = '') {
const metadata = {
name: '', version: '', author: '', description: '', homepage: '',
quality: [], features: [], sources: [], apiUrls: []
name: '',
version: '',
author: '',
description: '',
homepage: '',
quality: [],
qualityDetails: [],
features: [],
sources: [],
musicPlatform: '',
musicPlatformName: '',
apiUrls: [],
filteredApiUrls: [],
qzApiEndpoint: null,
isObfuscated: false,
};
const urlMatches = code.match(/https?:\/\/[^\s"'`<>)}\]]+/g) || [];
metadata.apiUrls = [...new Set(urlMatches)].slice(0, 20);
if (platform === 'lx') {
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();
const qualitySet = new Set();
if (/['"`]128k['"`]/.test(code)) qualitySet.add('128k');
if (/['"`]192k['"`]/.test(code)) qualitySet.add('192k');
if (/['"`]320k['"`]/.test(code)) qualitySet.add('320k');
if (/['"`]flac24bit['"`]/.test(code)) qualitySet.add('flac24bit');
if (/['"`]flac['"`]/.test(code)) qualitySet.add('flac');
if (/['"`]hires['"`]/.test(code)) qualitySet.add('hires');
if (/['"`]master['"`]/.test(code)) qualitySet.add('master');
metadata.quality = [...qualitySet];
if (/musicUrl/.test(code)) metadata.features.push('musicUrl');
if (/lyric/.test(code)) metadata.features.push('lyric');
if (/pic/.test(code)) metadata.features.push('pic');
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];
} else if (platform === 'qzv2') {
const nameMatch = code.match(/name\s*:\s*["'`]([^"'`]+)["'`]/); if (nameMatch) metadata.name = nameMatch[1];
const idMatch = code.match(/id\s*:\s*["'`]([^"'`]+)["'`]/); if (idMatch) metadata.description = `ID: ${idMatch[1]}`;
const descMatch = code.match(/description\s*:\s*["'`]([^"'`]+)["'`]/); if (descMatch) metadata.description = descMatch[1];
const versionMatch = code.match(/version\s*:\s*["'`]?(["'`]?)([\d.]+)/); if (versionMatch) metadata.version = versionMatch[2];
const qualityMatch = code.match(/quality\s*:\s*\[([^\]]+)\]/);
if (qualityMatch) {
const qItems = qualityMatch[1].match(/["'`]([^"'`]+)["'`]/g);
if (qItems) metadata.quality = qItems.map(q => q.replace(/["'`]/g, ''));
}
const supportFuncMatch = code.match(/supportFunc\s*:\s*\[([^\]]+)\]/);
if (supportFuncMatch) {
const fItems = supportFuncMatch[1].match(/["'`]([^"'`]+)["'`]/g);
if (fItems) metadata.features = fItems.map(f => f.replace(/["'`]/g, ''));
}
if (/getUrl/.test(code) && !metadata.features.includes('getUrl')) metadata.features.push('getUrl');
if (/getLyric/.test(code) && !metadata.features.includes('getLyric')) metadata.features.push('getLyric');
if (/search/.test(code) && !metadata.features.includes('search')) metadata.features.push('search');
// 检测是否混淆
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') {
const platformMatch = code.match(/platform\s*:\s*["'`]([^"'`]+)["'`]/); if (platformMatch) metadata.name = platformMatch[1];
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];
extractMusicFreeMetadata(code, metadata);
}
const qualitySet = new Set();
if (/['"`]low['"`]/.test(code)) qualitySet.add('low');
if (/['"`]standard['"`]/.test(code)) qualitySet.add('standard');
if (/['"`]high['"`]/.test(code)) qualitySet.add('high');
if (/['"`]super['"`]/.test(code)) qualitySet.add('super');
if (/['"`]hires['"`]/.test(code)) qualitySet.add('hires');
if (/['"`]master['"`]/.test(code)) qualitySet.add('master');
metadata.quality = [...qualitySet];
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 域名推断音乐平台(备用方法)
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;
}
}
if (!metadata.name && filename) metadata.name = filename.replace(/\.(js|zip)$/i, '');
// 过滤 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;
}
module.exports = { detectPlatform, PLATFORMS, PLATFORM_LABELS, ALL_PLATFORMS };
/**
* 提取 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
};