/** * YouTube Crawler Benchmark v2 Test * 基于 benchmark_v2.md 的 7 维度评分标准 * * 强制 Mock 模式运行(无外部依赖) */ const fs = require('fs'); const path = require('path'); // 加载被测代码( evolve_v2 版本) const { YouTubeTrendingCrawler, getMockTrendingVideos } = require('./youtube_crawler'); const LOGS = []; function log(message, level = 'info') { const entry = { time: new Date().toISOString(), level, message }; LOGS.push(entry); console.log(`[${level.toUpperCase()}] ${message}`); } // ============ 评分容器 ============ const SCORES = { functionality: { score: 0, max: 20, details: [] }, api_usage: { score: 0, max: 15, details: [] }, data_completeness: { score: 0, max: 20, details: [] }, code_quality: { score: 0, max: 15, details: [] }, error_handling: { score: 0, max: 15, details: [] }, extensibility: { score: 0, max: 10, details: [] }, performance: { score: 0, max: 5, details: [] } }; // ============ 维度1: 功能完整性 (20分) ============ async function testCoreFields() { log('\n--- 测试: 核心字段覆盖 (6分) ---'); let score = 0; try { const crawler = new YouTubeTrendingCrawler({ useMock: true }); const videos = await crawler.get_trending('US', 5); if (!videos || !videos.length) throw new Error('No videos'); const v = videos[0]; const required = [ 'video_id', 'title', 'channel_id', 'channel_title', 'published_at', 'view_count', 'category_id', 'source_url', 'crawl_time' ]; const missing = required.filter(k => v[k] === undefined); if (missing.length === 0) { score += 4; log('✅ 9个必填字段全部存在'); } else { log(`❌ 缺失字段: ${missing.join(', ')}`); } // 可选字段检查(duration, like_count, comment_count, thumbnail_url) const hasOptional = ['duration', 'like_count', 'comment_count', 'thumbnail_url'] .some(k => v[k] !== undefined); if (hasOptional) { score += 2; log('✅ 包含可选扩展字段'); } } catch (err) { log(`❌ 核心字段测试失败: ${err.message}`, 'error'); } SCORES.functionality.details.push({ test: 'core_fields', score, max: 6 }); SCORES.functionality.score += score; log(`得分: ${score}/6`); return score; } async function testMultiRegionAndCategory() { log('\n--- 测试: 多地区与分类支持 (4分) ---'); let score = 0; try { const crawler = new YouTubeTrendingCrawler({ useMock: true }); const regions = ['US', 'JP', 'KR', 'GB', 'DE', 'FR', 'IN', 'BR', 'RU']; const results = await crawler.get_multi_region_trending(regions.slice(0, 3), 2); if (results && typeof results === 'object' && Object.keys(results).length >= 3) { score += 2; log('✅ 多地区支持正常'); } const cats = ['10', '20', '24', '28']; let catSupported = 0; for (const cat of cats) { try { const vids = await crawler.get_trending('US', 2, cat); if (vids && vids.length > 0) catSupported++; } catch (e) {} } if (catSupported >= 2) { score += 2; log('✅ 分类过滤支持'); } } catch (err) { log(`❌ 多地区分类测试失败: ${err.message}`, 'error'); } SCORES.functionality.details.push({ test: 'multi_region_category', score, max: 4 }); SCORES.functionality.score += score; log(`得分: ${score}/4`); return score; } function testOutputFormats() { log('\n--- 测试: 输出格式 JSON/CSV (4分) ---'); let score = 0; try { const crawler = new YouTubeTrendingCrawler({ useMock: true }); const data = [{ video_id: 'v1', title: 'T1' }]; const jsonPath = '/tmp/bench_v2_test.json'; crawler.save_to_json(data, jsonPath); if (fs.existsSync(jsonPath)) { const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); if (Array.isArray(parsed)) { score += 2; log('✅ JSON 输出正常'); } fs.unlinkSync(jsonPath); } const csvPath = '/tmp/bench_v2_test.csv'; crawler.save_to_csv(data, csvPath); if (fs.existsSync(csvPath)) { const content = fs.readFileSync(csvPath, 'utf-8'); if (content.includes('video_id') && content.includes('T1')) { score += 2; log('✅ CSV 输出正常'); } fs.unlinkSync(csvPath); } } catch (err) { log(`❌ 输出格式测试失败: ${err.message}`, 'error'); } SCORES.functionality.details.push({ test: 'output_formats', score, max: 4 }); SCORES.functionality.score += score; log(`得分: ${score}/4`); return score; } async function testVideoIdAndUrl() { log('\n--- 测试: video_id 与 source_url (3分) ---'); let score = 0; try { const crawler = new YouTubeTrendingCrawler({ useMock: true }); const videos = await crawler.get_trending('US', 3); const v = videos[0]; if (v.video_id && typeof v.video_id === 'string' && v.video_id.length === 11) { score += 1; log('✅ video_id 为11位字符串'); } else if (v.video_id && typeof v.video_id === 'string') { score += 0.5; } if (v.source_url && v.source_url.includes('youtube.com/watch?v=')) { score += 2; log('✅ source_url 格式正确'); } } catch (err) { log(`❌ video_id/url 测试失败: ${err.message}`, 'error'); } SCORES.functionality.details.push({ test: 'video_id_url', score, max: 3 }); SCORES.functionality.score += score; log(`得分: ${score}/3`); return score; } async function testCrawlTime() { log('\n--- 测试: crawl_time ISO8601 (3分) ---'); let score = 0; try { const crawler = new YouTubeTrendingCrawler({ useMock: true }); const videos = await crawler.get_trending('US', 2); const v = videos[0]; if (v.crawl_time && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v.crawl_time)) { score += 3; log('✅ crawl_time 为 ISO8601 格式'); } } catch (err) { log(`❌ crawl_time 测试失败: ${err.message}`, 'error'); } SCORES.functionality.details.push({ test: 'crawl_time', score, max: 3 }); SCORES.functionality.score += score; log(`得分: ${score}/3`); return score; } // ============ 维度2: API规范使用 (15分) ============ function testApiParams() { log('\n--- 测试: API 参数正确性 (6分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('snippet,statistics,contentDetails')) { score += 2; log('✅ part 包含 snippet,statistics,contentDetails'); } if (code.includes('chart') && code.includes('mostPopular')) { score += 2; log('✅ chart=mostPopular'); } if (code.includes('maxResults') && code.includes('50')) { score += 1; log('✅ maxResults 上限控制'); } if (!code.includes('search.list') && !code.match(/search\s*\.\s*list/)) { score += 1; log('✅ 未使用 search.list'); } } catch (err) { log(`❌ API参数测试失败: ${err.message}`, 'error'); } SCORES.api_usage.details.push({ test: 'api_params', score, max: 6 }); SCORES.api_usage.score += score; log(`得分: ${score}/6`); return score; } function testQuotaAndRateLimit() { log('\n--- 测试: 配额与频率控制 (5分) ---'); let score = 0; try { const crawler = new YouTubeTrendingCrawler({ useMock: true }); if (crawler.quotaUsed !== undefined && crawler.quotaLimit !== undefined) { score += 2; log('✅ 配额追踪属性存在'); } if (typeof crawler.getRemainingQuota === 'function') { score += 1; log('✅ 配额检查方法存在'); } const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('quota') && code.includes('10000')) { score += 1; log('✅ 配额限制配置'); } if (code.match(/delay|sleep|setTimeout|AbortSignal\.timeout/) && (code.includes('2000') || code.includes('requestDelay') || code.includes('interval'))) { score += 1; log('✅ 请求间隔或超时控制'); } } catch (err) { log(`❌ 配额频率测试失败: ${err.message}`, 'error'); } SCORES.api_usage.details.push({ test: 'quota_rate', score, max: 5 }); SCORES.api_usage.score += score; log(`得分: ${score}/5`); return score; } function testApiHeaders() { log('\n--- 测试: API Key 与请求头 (4分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('process.env.YOUTUBE_API_KEY')) { score += 2; log('✅ API Key 从环境变量读取'); } if (code.includes('User-Agent') || code.includes('user-agent')) { score += 2; log('✅ 包含 User-Agent 头'); } } catch (err) { log(`❌ API头测试失败: ${err.message}`, 'error'); } SCORES.api_usage.details.push({ test: 'api_headers', score, max: 4 }); SCORES.api_usage.score += score; log(`得分: ${score}/4`); return score; } // ============ 维度3: 数据完整性与准确性 (20分) ============ async function testFieldMapping() { log('\n--- 测试: 字段映射与类型转换 (8分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); const crawler = new YouTubeTrendingCrawler({ useMock: true }); // Mock API item with all fields const apiItem = { id: 'AbCdEfGhIjK', snippet: { title: 'Test Title', channelTitle: 'Test Channel', channelId: 'UC123456', publishedAt: '2024-01-15T10:00:00Z', categoryId: '10', thumbnails: { default: { url: 'http://ex.com/d.jpg' }, medium: { url: 'http://ex.com/m.jpg' }, high: { url: 'http://ex.com/h.jpg' }, standard: { url: 'http://ex.com/s.jpg' }, maxres: { url: 'http://ex.com/x.jpg' } } }, statistics: { viewCount: '1234567', likeCount: '9876', commentCount: '543' }, contentDetails: { duration: 'PT3M33S' } }; const info = crawler.extract_video_info(apiItem); if (info.video_id === 'AbCdEfGhIjK') score += 1; if (info.title === 'Test Title') score += 1; if (info.channel_id === 'UC123456') score += 1; if (info.view_count === 1234567) score += 1; if (info.like_count === 9876) score += 1; if (info.comment_count === 543) score += 1; if (info.duration === 'PT3M33S') score += 1; if (info.thumbnail_url && info.thumbnail_url.includes('h.jpg')) score += 1; log(`✅ 字段映射测试通过: ${score}/8`); } catch (err) { log(`❌ 字段映射测试失败: ${err.message}`, 'error'); } SCORES.data_completeness.details.push({ test: 'field_mapping', score, max: 8 }); SCORES.data_completeness.score += score; log(`得分: ${score}/8`); return score; } async function testDataTypes() { log('\n--- 测试: 数据类型与格式 (6分) ---'); let score = 0; try { const crawler = new YouTubeTrendingCrawler({ useMock: true }); const videos = await crawler.get_trending('US', 3); const v = videos[0]; if (typeof v.view_count === 'number') { score += 2; log('✅ view_count 为数字'); } if (v.published_at && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v.published_at)) { score += 2; log('✅ published_at 为 ISO8601'); } if (typeof v.video_id === 'string') { score += 1; log('✅ video_id 为字符串'); } if (v.thumbnail_url === null || typeof v.thumbnail_url === 'string') { score += 1; log('✅ thumbnail_url 类型正确'); } } catch (err) { log(`❌ 数据类型测试失败: ${err.message}`, 'error'); } SCORES.data_completeness.details.push({ test: 'data_types', score, max: 6 }); SCORES.data_completeness.score += score; log(`得分: ${score}/6`); return score; } function testSchemaValidation() { log('\n--- 测试: Schema 校验与兜底 (6分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); const crawler = new YouTubeTrendingCrawler({ useMock: true }); // 测试缺失字段的兜底 const partial = crawler.extract_video_info({ id: 'XxXxXxXxXxX', snippet: {}, statistics: {}, contentDetails: {} }); const hasFallbacks = partial.title !== undefined && partial.view_count !== undefined; if (hasFallbacks) { score += 3; log('✅ 缺失字段有兜底值'); } if (code.includes('schema') || code.includes('validate') || code.includes('_cleanString')) { score += 3; log('✅ 有数据校验或清理逻辑'); } } catch (err) { log(`❌ Schema测试失败: ${err.message}`, 'error'); } SCORES.data_completeness.details.push({ test: 'schema_validation', score, max: 6 }); SCORES.data_completeness.score += score; log(`得分: ${score}/6`); return score; } // ============ 维度4: 代码质量与可维护性 (15分) ============ function testModularity() { log('\n--- 测试: 模块化设计 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('class YouTubeTrendingCrawler')) score += 2; const methods = ['get_trending', 'extract_video_info', 'save_to_json', 'save_to_csv', '_fetchFromAPI', '_fetchFromYTDLP']; let found = 0; methods.forEach(m => { if (code.includes(m)) found++; }); if (found >= 4) score += 2; if (code.includes('constructor(')) score += 1; log(`✅ 模块化检查通过: ${score}/5`); } catch (err) { log(`❌ 模块化测试失败: ${err.message}`, 'error'); } SCORES.code_quality.details.push({ test: 'modularity', score, max: 5 }); SCORES.code_quality.score += score; log(`得分: ${score}/5`); return score; } function testDocumentation() { log('\n--- 测试: 注释与 JSDoc (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); const jsdocCount = (code.match(/\/\*\*/g) || []).length; if (jsdocCount >= 3) { score += 3; log(`✅ 包含 ${jsdocCount} 个 JSDoc 注释`); } if (code.includes('@param') && code.includes('@returns')) { score += 2; log('✅ 包含参数和返回类型注解'); } } catch (err) { log(`❌ 注释测试失败: ${err.message}`, 'error'); } SCORES.code_quality.details.push({ test: 'documentation', score, max: 5 }); SCORES.code_quality.score += score; log(`得分: ${score}/5`); return score; } async function testInputValidation() { log('\n--- 测试: 输入验证 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('_validateRegionCode') || code.includes('validateRegionCode')) score += 2; if (code.includes('_validateMaxResults') || (code.includes('maxResults') && code.includes('50'))) score += 2; const crawler = new YouTubeTrendingCrawler({ useMock: true }); try { await crawler.get_trending('INVALID'); } catch (e) { score += 1; log('✅ 无效 regionCode 被拦截'); } } catch (err) { log(`❌ 输入验证测试失败: ${err.message}`, 'error'); } SCORES.code_quality.details.push({ test: 'input_validation', score, max: 5 }); SCORES.code_quality.score += score; log(`得分: ${score}/5`); return score; } // ============ 维度5: 错误处理与降级策略 (15分) ============ function testHttpErrorHandling() { log('\n--- 测试: HTTP 错误码分层处理 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); const codes = ['400', '403', '404', '429', '500', '502', '503']; let found = 0; codes.forEach(c => { if (code.includes(c)) found++; }); if (found >= 5) { score += 3; log(`✅ 处理了 ${found} 种 HTTP 错误码`); } else if (found >= 3) { score += 2; log(`⚠️ 处理了 ${found} 种 HTTP 错误码`); } if (code.includes('retry') || code.includes('Retry') || code.includes('attempt')) { score += 2; log('✅ 有重试逻辑'); } } catch (err) { log(`❌ HTTP错误处理测试失败: ${err.message}`, 'error'); } SCORES.error_handling.details.push({ test: 'http_errors', score, max: 5 }); SCORES.error_handling.score += score; log(`得分: ${score}/5`); return score; } function testFallbackChain() { log('\n--- 测试: 三级降级链 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('enableFallback') || code.includes('fallback')) score += 1; if (code.includes('yt-dlp') || code.includes('ytdlp') || code.includes('YTDLP')) score += 1; if (code.includes('invidious') || code.includes('Invidious')) { score += 3; log('✅ 包含 Invidious 三级降级'); } else { score += 1; log('⚠️ 只有 API→yt-dlp 两级降级'); } } catch (err) { log(`❌ 降级链测试失败: ${err.message}`, 'error'); } SCORES.error_handling.details.push({ test: 'fallback_chain', score, max: 5 }); SCORES.error_handling.score += score; log(`得分: ${score}/5`); return score; } function testBackoffAndTimeout() { log('\n--- 测试: 指数退避与超时 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('TimeoutError') || code.includes('timeout') || code.includes('AbortSignal.timeout')) { score += 2; log('✅ 有超时控制'); } if (code.includes('Math.pow') || code.includes('**') || code.includes('exponential') || code.match(/\*\s*1000/)) { score += 2; log('✅ 有指数退避逻辑'); } if (code.includes('30') && code.includes('000')) { score += 1; log('✅ 30s 超时参数'); } } catch (err) { log(`❌ 退避超时测试失败: ${err.message}`, 'error'); } SCORES.error_handling.details.push({ test: 'backoff_timeout', score, max: 5 }); SCORES.error_handling.score += score; log(`得分: ${score}/5`); return score; } // ============ 维度6: 扩展性与配置灵活性 (10分) ============ function testConfigurability() { log('\n--- 测试: 配置灵活性 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('constructor(options')) score += 1; const params = ['apiKey', 'useMock', 'timeout', 'maxRetries', 'enableFallback', 'quotaLimit']; let found = 0; params.forEach(p => { if (code.includes(p)) found++; }); if (found >= 3) { score += 2; log('✅ 丰富的构造函数配置'); } if (code.includes('regionCode') && code.includes('videoCategoryId') && code.includes('maxResults')) { score += 2; log('✅ 方法参数可配置'); } } catch (err) { log(`❌ 配置灵活性测试失败: ${err.message}`, 'error'); } SCORES.extensibility.details.push({ test: 'configurability', score, max: 5 }); SCORES.extensibility.score += score; log(`得分: ${score}/5`); return score; } function testBulkRequests() { log('\n--- 测试: 批量请求与输出格式切换 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('get_multi_region_trending')) { score += 2; log('✅ 支持批量多地区请求'); } if (code.includes('save_to_json') && code.includes('save_to_csv')) { score += 2; log('✅ 支持 JSON 和 CSV 输出'); } if (code.includes('regionCode') && !code.includes("'US'") && code.includes('options')) { score += 1; log('✅ 地区参数可配置'); } } catch (err) { log(`❌ 批量请求测试失败: ${err.message}`, 'error'); } SCORES.extensibility.details.push({ test: 'bulk_requests', score, max: 5 }); SCORES.extensibility.score += score; log(`得分: ${score}/5`); return score; } // ============ 维度7: 性能与效率 (5分) ============ function testPerformance() { log('\n--- 测试: 性能优化 (5分) ---'); let score = 0; try { const code = fs.readFileSync(path.join(__dirname, 'youtube_crawler.js'), 'utf-8'); if (code.includes('cache') || code.includes('Cache') || code.includes('memo')) { score += 2; log('✅ 有缓存机制'); } if (code.includes('maxResults') && code.includes('50')) { score += 1; log('✅ 单次请求最大化'); } if (code.includes('2000') || code.includes('delay') || code.includes('interval') || code.includes('setTimeout')) { score += 1; log('✅ 有请求间隔控制'); } if (code.includes('Promise.all') || code.includes('concurrency') || code.includes('p-limit')) { score += 1; log('✅ 有并发控制'); } } catch (err) { log(`❌ 性能测试失败: ${err.message}`, 'error'); } SCORES.performance.details.push({ test: 'performance', score, max: 5 }); SCORES.performance.score += score; log(`得分: ${score}/5`); return score; } // ============ 主流程 ============ async function runBenchmark() { log('========================================'); log('YouTube Crawler Benchmark v2'); log('========================================'); log(`开始时间: ${new Date().toISOString()}`); await testCoreFields(); await testMultiRegionAndCategory(); testOutputFormats(); await testVideoIdAndUrl(); await testCrawlTime(); testApiParams(); testQuotaAndRateLimit(); testApiHeaders(); await testFieldMapping(); await testDataTypes(); testSchemaValidation(); testModularity(); testDocumentation(); await testInputValidation(); testHttpErrorHandling(); testFallbackChain(); testBackoffAndTimeout(); testConfigurability(); testBulkRequests(); testPerformance(); // 计算总分(按权重) const total = SCORES.functionality.score + SCORES.api_usage.score + SCORES.data_completeness.score + SCORES.code_quality.score + SCORES.error_handling.score + SCORES.extensibility.score + SCORES.performance.score; const maxTotal = 100; log('\n========================================'); log('评分汇总'); log('========================================'); log(`功能完整性: ${SCORES.functionality.score.toString().padStart(2)}/${SCORES.functionality.max}`); log(`API规范使用: ${SCORES.api_usage.score.toString().padStart(2)}/${SCORES.api_usage.max}`); log(`数据完整性与准确性: ${SCORES.data_completeness.score.toString().padStart(2)}/${SCORES.data_completeness.max}`); log(`代码质量与可维护性: ${SCORES.code_quality.score.toString().padStart(2)}/${SCORES.code_quality.max}`); log(`错误处理与降级策略: ${SCORES.error_handling.score.toString().padStart(2)}/${SCORES.error_handling.max}`); log(`扩展性与配置灵活性: ${SCORES.extensibility.score.toString().padStart(2)}/${SCORES.extensibility.max}`); log(`性能与效率: ${SCORES.performance.score.toString().padStart(2)}/${SCORES.performance.max}`); log('----------------------------------------'); log(`总分: ${total}/${maxTotal}`); log(`目标: ≥90`); log(`结果: ${total >= 90 ? '✅ 通过' : total >= 70 ? '⚠️ 需改进' : '❌ 未通过'}`); log('========================================'); const result = { total_score: total, max_score: maxTotal, passed: total >= 90, target: 90, dimensions: { functionality: { score: SCORES.functionality.score, max: SCORES.functionality.max, details: SCORES.functionality.details }, api_usage: { score: SCORES.api_usage.score, max: SCORES.api_usage.max, details: SCORES.api_usage.details }, data_completeness: { score: SCORES.data_completeness.score, max: SCORES.data_completeness.max, details: SCORES.data_completeness.details }, code_quality: { score: SCORES.code_quality.score, max: SCORES.code_quality.max, details: SCORES.code_quality.details }, error_handling: { score: SCORES.error_handling.score, max: SCORES.error_handling.max, details: SCORES.error_handling.details }, extensibility: { score: SCORES.extensibility.score, max: SCORES.extensibility.max, details: SCORES.extensibility.details }, performance: { score: SCORES.performance.score, max: SCORES.performance.max, details: SCORES.performance.details } }, timestamp: new Date().toISOString(), logs: LOGS, weak_dimensions: total >= 90 ? [] : Object.entries(SCORES) .filter(([_, v]) => v.score < v.max * 0.8) .map(([k, _]) => k) }; fs.writeFileSync( path.join(__dirname, 'benchmark_v2_result.json'), JSON.stringify(result, null, 2) ); return result; } runBenchmark().then(r => { process.exit(r.passed ? 0 : 1); }).catch(err => { console.error('Benchmark v2 failed:', err); process.exit(1); });