diff --git a/lib/detector.js b/lib/detector.js index de2a2cf..a9d8f9d 100644 --- a/lib/detector.js +++ b/lib/detector.js @@ -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 +}; diff --git a/lib/tester.js b/lib/tester.js index ba12ea2..9ac4680 100644 --- a/lib/tester.js +++ b/lib/tester.js @@ -1,12 +1,21 @@ /** - * 插件测试模块 - 支持日志回调 - * 每个步骤都会通过 logger 回调实时输出 + * 插件测试模块 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 } = require('./detector'); +const { detectPlatform, PLATFORMS, QUALITY_MAP } = require('./detector'); + +// ============================================================ +// HTTP 工具函数 +// ============================================================ function fetchPluginCode(url, timeout = 15000) { return new Promise((resolve, reject) => { @@ -17,7 +26,7 @@ function fetchPluginCode(url, timeout = 15000) { port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), path: parsedUrl.pathname + parsedUrl.search, method: 'GET', - headers: { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/1.1', 'Accept': '*/*' }, + headers: { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/3.0', 'Accept': '*/*' }, timeout }; const req = client.request(options, (res) => { @@ -38,42 +47,163 @@ function fetchPluginCode(url, timeout = 15000) { }); } -function testUrlLatency(url, timeout = 10000) { +/** + * 测试 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 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/1.1', 'Accept': '*/*', 'Range': 'bytes=0-1023' }, + 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(options, (res) => { + const req = client.request(reqOptions, (res) => { const latency = Date.now() - startTime; let bodySize = 0; - res.on('data', (chunk) => { bodySize += chunk.length; if (bodySize > 4096) res.destroy(); }); - res.on('end', () => 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), error: null })); - res.on('error', () => finish({ url, status: res.statusCode, latency, success: false, contentType: '', contentLength: 0, error: '响应读取错误' })); + 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, error: '请求超时' }); }); - req.on('error', (err) => finish({ url, status: 0, latency: Date.now() - startTime, success: false, contentType: '', contentLength: 0, error: err.message })); + 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, error: err.message }); + finish({ url, status: 0, latency: 0, success: false, contentType: '', contentLength: 0, bodyPreview: '', error: err.message }); } }); } /** - * 测试插件主入口 - 带日志回调 - * @param {string} url - * @param {string} customCode - * @param {string} filename - * @param {function} logger - 日志回调 (level, message, data) + * 构建并测试 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 = { @@ -94,7 +224,10 @@ async function testPlugin(url, customCode = null, filename = '', logger = null) 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'] || '' }; + 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 }; @@ -104,7 +237,10 @@ async function testPlugin(url, customCode = null, filename = '', logger = null) return result; } } else if (code) { - result.downloadInfo = { success: true, latency: 0, size: Buffer.byteLength(code), statusCode: 0, contentType: 'text/plain' }; + result.downloadInfo = { + success: true, latency: 0, size: Buffer.byteLength(code), + statusCode: 0, contentType: 'text/plain' + }; log('success', `文件已读取 | 大小: ${formatSize(Buffer.byteLength(code))}`); } @@ -125,68 +261,138 @@ async function testPlugin(url, customCode = null, filename = '', logger = null) 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.author) log('info', `作者: ${detection.metadata.author}`); + 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: 功能分析 + // 步骤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: 延时测试 - const apiUrls = detection.metadata.apiUrls || []; - const testUrls = apiUrls - .filter(u => !u.match(/\.(png|jpg|jpeg|gif|svg|ico|css|woff|ttf)$/i)) - .filter(u => !u.includes('catcat.work')) - .filter(u => !u.includes('w3.org')) - .filter(u => !u.includes('schema.org')) - .slice(0, 5); - + // 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`); + log(pluginUrlTest.success ? 'success' : 'warn', + `插件源 URL | 状态: ${pluginUrlTest.status} | 延时: ${pluginUrlTest.latency}ms`); } - if (testUrls.length > 0) { - log('info', `开始延时测试,共 ${testUrls.length} 个 API 端点...`); - for (let i = 0; i < testUrls.length; i++) { - const testUrl = testUrls[i]; - log('info', `[${i + 1}/${testUrls.length}] 测试: ${truncate(testUrl, 60)}`); + // 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 = testUrls.map(u => testUrlLatency(u)); + 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', `延时测试完成 | 成功: ${successCount}/${testUrls.length} | 平均延时: ${avgLatency}ms`); + 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', `延时测试完成 | 所有端点均不可达`); + log('warn', `直连 API 测试完成 | 所有端点均不可达`); } - } else if (!url || customCode) { - log('info', `未发现可测试的 API 端点`); } - // 步骤5: 音质分析 + // 步骤5: 音质测试结果汇总 const declaredQualities = detection.metadata.quality || []; - const qualityMap = getQualityMap(detection.platform); - result.qualityTests = declaredQualities.map(q => ({ quality: q, label: qualityMap[q] || q, declared: true, testable: testUrls.length > 0 })); + 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] || q, declared: false, testable: testUrls.length > 0 })); + 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(', ')}`); - } else if (declaredQualities.length > 0) { - log('success', `支持音质: ${declaredQualities.map(q => `${q}(${qualityMap[q] || q})`).join(', ')}`); } // 步骤6: 评分 @@ -198,55 +404,101 @@ async function testPlugin(url, customCode = null, filename = '', logger = null) return result; } -function getQualityMap(platform) { - const maps = { - lx: { '128k': '标准 128kbps', '192k': '标准 192kbps', '320k': '高品质 320kbps', 'flac': '无损 FLAC', 'flac24bit': 'Hi-Res FLAC 24bit', 'hires': 'Hi-Res', 'master': '母带 Master' }, - qzv2: { 'low': '低音质', 'standard': '标准音质', 'exhigh': '极高音质', 'high': '高音质', 'lossless': '无损音质', 'hires': 'Hi-Res', 'master': '母带 Master' }, - musicfree: { 'low': '低音质', 'standard': '标准音质', 'high': '高音质', 'super': '超高音质', 'hires': 'Hi-Res', 'master': '母带 Master' } - }; - return maps[platform] || {}; -} - function getDefaultQualities(platform) { const defaults = { - lx: ['128k', '192k', '320k', 'flac', 'flac24bit', 'master'], - qzv2: ['standard', 'exhigh', 'high', 'lossless', 'hires', 'master'], + 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 = {}; - 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 = '文件上传'; } + let score = 0; + const details = {}; - if (result.platform !== 'unknown') { score += 20; details.format = result.platformLabel; } else { details.format = '未识别'; } + // 下载/读取 (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 = '文件上传'; + } - const successLatency = result.latencyTests.filter(t => t.success); - if (result.latencyTests.length > 0) { + // 格式识别 (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}/${result.latencyTests.length}`; - if (avgLatency < 300) score += 30; else if (avgLatency < 800) score += 25; else if (avgLatency < 1500) score += 18; else if (avgLatency < 3000) score += 10; else score += 5; - } else { details.avgLatency = -1; details.successRate = `0/${result.latencyTests.length}`; } - } else { score += 15; details.avgLatency = -1; details.successRate = 'N/A'; } + 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; - score += Math.min(15, declaredCount * 5); - details.qualityCount = result.qualityTests.length; details.qualities = result.qualityTests.map(q => q.quality).join(', '); - } else { details.qualityCount = 0; } + 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(15, result.metadata.features.length * 3); + 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'; + 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 }; } @@ -257,7 +509,9 @@ function formatSize(bytes) { } function truncate(str, max) { - if (!str) return ''; if (str.length <= max) return str; return str.substring(0, max - 3) + '...'; + if (!str) return ''; + if (str.length <= max) return str; + return str.substring(0, max - 3) + '...'; } -module.exports = { testPlugin, fetchPluginCode, testUrlLatency }; +module.exports = { testPlugin, fetchPluginCode, testUrlLatency, testQzApiEndpoint }; diff --git a/package-lock.json b/package-lock.json index 3729168..14a6c70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "koneko-music-tester", - "version": "1.1.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "koneko-music-tester", - "version": "1.1.0", + "version": "3.0.0", "dependencies": { - "express": "^4.18.2" + "adm-zip": "^0.5.10", + "express": "^4.18.2", + "multer": "^1.4.5-lts.1" } }, "node_modules/accepts": { @@ -24,6 +26,21 @@ "node": ">= 0.6" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -54,6 +71,23 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -92,6 +126,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -128,6 +177,12 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -441,6 +496,12 @@ "node": ">= 0.10" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -510,12 +571,52 @@ "node": ">= 0.6" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmmirror.com/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -525,6 +626,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -564,6 +674,12 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -617,6 +733,27 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -775,6 +912,29 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -797,6 +957,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -806,6 +972,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -823,6 +995,15 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index a1af6a9..1d43f49 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "koneko-music-tester", - "version": "1.1.0", - "description": "Koneko 音源测试平台 - Lx/QZV2/MusicFree 插件音质延时测试", + "version": "3.0.0", + "description": "Koneko 音源测试平台 - Lx/QZV2/MusicFree 插件音质延时测试 (逆向分析增强版)", "main": "server.js", "scripts": { "start": "node server.js", "dev": "node server.js" }, "dependencies": { - "express": "^4.18.2" + "express": "^4.18.2", + "multer": "^1.4.5-lts.1", + "adm-zip": "^0.5.10" } } diff --git a/public/css/style.css b/public/css/style.css index f24a081..cde2907 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -329,8 +329,12 @@ body::before{content:'';position:fixed;inset:0;background:var(--bg-primary);z-in .latency-bar-value.bad{color:var(--danger)} .quality-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px} -.quality-tag{padding:3px 8px;border-radius:4px;font-size:11px;font-weight:500;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border-color)} +.quality-tag{padding:3px 8px;border-radius:4px;font-size:11px;font-weight:500;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border-color);display:inline-flex;align-items:center;gap:2px} .quality-tag.declared{background:var(--success-bg);color:var(--success);border-color:transparent} +.quality-tag.qpass{background:rgba(0,184,148,0.15);color:var(--success);border-color:rgba(0,184,148,0.3)} +.quality-tag.qfail{background:rgba(225,112,85,0.15);color:var(--danger);border-color:rgba(225,112,85,0.3)} +.quality-tag.untested{opacity:0.6} +.quality-tag svg{width:10px;height:10px} .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:1000;display:none;align-items:center;justify-content:center;padding:20px} .modal-overlay.show{display:flex} diff --git a/public/js/app.js b/public/js/app.js index cf7f617..219bf3b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,6 +1,7 @@ /** - * Koneko 音源测试平台 - 前端逻辑 v1.1 + * Koneko 音源测试平台 - 前端逻辑 v2.0 * SSE 实时日志 + 上传进度 + 平台展示 + * 支持 .qz/.zip/.js 文件上传和服务端解压 */ const state = { @@ -67,7 +68,7 @@ function initFileUpload() { function handleFiles(fileList) { for (const file of fileList) { - if (file.name.match(/\.(js|zip)$/i)) state.files.push(file); + if (file.name.match(/\.(js|zip|qz)$/i)) state.files.push(file); } renderFileList(); } @@ -78,7 +79,7 @@ function renderFileList() { state.files.forEach((file, idx) => { const chip = document.createElement('div'); chip.className = 'file-chip'; - chip.innerHTML = `${file.name}`; + chip.innerHTML = `${file.name}`; chip.querySelector('.remove-file').addEventListener('click', () => { state.files.splice(idx, 1); renderFileList(); }); container.appendChild(chip); }); @@ -86,20 +87,56 @@ function renderFileList() { } // 逐个读取文件,显示进度 +// .qz/.zip 文件上传到服务端解压,.js 文件直接读取 async function readFilesWithProgress(files) { const plugins = []; const total = files.length; $('uploadProgress').style.display = 'block'; - for (let i = 0; i < total; i++) { - const file = files[i]; - const pct = Math.round(((i) / total) * 100); - $('uploadProgressText').textContent = `读取文件 ${i + 1}/${total}: ${file.name}`; - $('uploadProgressPercent').textContent = `${pct}%`; - $('uploadProgressFill').style.width = `${pct}%`; + + // 先将所有 .qz/.zip 文件批量上传到服务端解压 + const archiveFiles = files.filter(f => f.name.match(/\.(qz|zip)$/i)); + const jsFiles = files.filter(f => !f.name.match(/\.(qz|zip)$/i)); + + if (archiveFiles.length > 0) { + $('uploadProgressText').textContent = `正在上传 ${archiveFiles.length} 个压缩包到服务端解压...`; + $('uploadProgressPercent').textContent = '0%'; + $('uploadProgressFill').style.width = '0%'; + + const formData = new FormData(); + for (const f of archiveFiles) formData.append('files', f); + + try { + const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const data = await res.json(); + if (data.success && data.plugins) { + for (const p of data.plugins) { + plugins.push({ code: p.code, filename: p.filename }); + } + $('uploadProgressText').textContent = `解压完成: ${archiveFiles.length} 个文件 -> ${data.plugins.length} 个插件`; + $('uploadProgressPercent').textContent = '100%'; + $('uploadProgressFill').style.width = '100%'; + addLog('success', `压缩包解压完成: ${archiveFiles.length} 个文件 -> ${data.plugins.length} 个插件`); + } else { + addLog('error', `压缩包解压失败: ${data.error || '未知错误'}`); + } + } catch (err) { + addLog('error', `上传失败: ${err.message}`); + } + await sleep(300); + } + + // 读取 JS 文件 + for (let i = 0; i < jsFiles.length; i++) { + const file = jsFiles[i]; + const pct = Math.round(((archiveFiles.length > 0 ? 1 : 0 + i) / total) * 100); + $('uploadProgressText').textContent = `读取文件 ${i + 1}/${jsFiles.length}: ${file.name}`; + $('uploadProgressPercent').textContent = `${Math.min(100, pct)}%`; + $('uploadProgressFill').style.width = `${Math.min(100, pct)}%`; const code = await readFile(file); plugins.push({ code, filename: file.name }); await sleep(50); } + $('uploadProgressText').textContent = `全部文件读取完成`; $('uploadProgressPercent').textContent = `100%`; $('uploadProgressFill').style.width = `100%`; @@ -330,7 +367,7 @@ function renderIncrementalCard(index, result) { card.id = `result-card-${index}`; grid.appendChild(card); } - card.outerHTML = renderResultCard(result, index).replace('