/** * 模拟抓取校验脚本 * 自动遍历全站sitemap.xml,抓取全部公开页面,校验页面质量 */ const https = require('https'); const http = require('http'); const { JSDOM } = require('jsdom'); const fs = require('fs'); const path = require('path'); // 配置 const CONFIG = { baseUrl: process.env.BASE_URL || 'http://localhost:3000', timeout: 10000, outputDir: path.join(__dirname, '../reports'), }; // 测试结果 const results = { totalPages: 0, passedPages: 0, failedPages: 0, warnings: 0, errors: [], warningsList: [], details: [], }; /** * 获取页面内容 */ function fetchPage(url) { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http; const timeout = CONFIG.timeout; const req = protocol.get(url, { timeout }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { resolve({ status: res.statusCode, headers: res.headers, body: data, url: url, }); }); }); req.on('error', (err) => { reject(err); }); req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); }); }); } /** * 解析sitemap.xml */ async function parseSitemap() { try { const response = await fetchPage(`${CONFIG.baseUrl}/sitemap.xml`); const dom = new JSDOM(response.body); const urls = dom.window.document.querySelectorAll('url'); const sitemapUrls = []; urls.forEach((url) => { const loc = url.querySelector('loc'); if (loc) { sitemapUrls.push(loc.textContent); } }); return sitemapUrls; } catch (error) { console.error('解析sitemap失败:', error.message); return []; } } /** * 校验H1唯一性 */ function validateH1(html, url) { const dom = new JSDOM(html); const h1Elements = dom.window.document.querySelectorAll('h1'); if (h1Elements.length === 0) { return { valid: false, message: '缺少H1标签', }; } if (h1Elements.length > 1) { return { valid: false, message: `存在${h1Elements.length}个H1标签,应唯一`, }; } return { valid: true, message: 'H1标签唯一', content: h1Elements[0].textContent.trim(), }; } /** * 校验标题层级 */ function validateHeadings(html, url) { const dom = new JSDOM(html); const headings = dom.window.document.querySelectorAll('h1, h2, h3, h4, h5, h6'); const headingLevels = []; headings.forEach((h) => { const level = parseInt(h.tagName.substring(1)); headingLevels.push(level); }); // 检查层级是否连续 let previousLevel = 0; let isValid = true; let errorMessage = ''; for (let i = 0; i < headingLevels.length; i++) { const currentLevel = headingLevels[i]; // H1应该在最前面 if (i === 0 && currentLevel !== 1) { isValid = false; errorMessage = '第一个标题应该是H1'; break; } // 检查层级跳跃 if (currentLevel > previousLevel + 1 && previousLevel !== 0) { isValid = false; errorMessage = `标题层级跳跃:从H${previousLevel}到H${currentLevel}`; break; } previousLevel = currentLevel; } return { valid: isValid, message: errorMessage || '标题层级规范', headings: headingLevels, }; } /** * 校验JSON-LD结构化数据 */ function validateJsonLd(html, url) { const dom = new JSDOM(html); const scripts = dom.window.document.querySelectorAll('script[type="application/ld+json"]'); const jsonLdData = []; let isValid = true; let errorMessage = ''; scripts.forEach((script) => { try { const data = JSON.parse(script.textContent); jsonLdData.push(data); // 基本结构校验 if (!data['@context']) { isValid = false; errorMessage = 'JSON-LD缺少@context'; } if (!data['@type']) { isValid = false; errorMessage = 'JSON-LD缺少@type'; } } catch (error) { isValid = false; errorMessage = `JSON-LD语法错误: ${error.message}`; } }); return { valid: isValid, message: errorMessage || 'JSON-LD结构化数据合法', count: jsonLdData.length, data: jsonLdData, }; } /** * 校验内部链接 */ function validateInternalLinks(html, baseUrl) { const dom = new JSDOM(html); const links = dom.window.document.querySelectorAll('a[href]'); const internalLinks = []; const brokenLinks = []; links.forEach((link) => { const href = link.getAttribute('href'); // 跳过外部链接和锚点 if (href.startsWith('http') || href.startsWith('#') || href.startsWith('mailto:')) { return; } // 构建完整URL let fullUrl; if (href.startsWith('/')) { fullUrl = `${CONFIG.baseUrl}${href}`; } else { fullUrl = `${CONFIG.baseUrl}/${href}`; } internalLinks.push({ text: link.textContent.trim(), href: href, fullUrl: fullUrl, }); }); return { valid: true, message: `发现${internalLinks.length}个内部链接`, links: internalLinks, brokenLinks: brokenLinks, }; } /** * 校验单个页面 */ async function validatePage(url) { const pageResult = { url: url, status: 'passed', checks: [], errors: [], warnings: [], }; try { // 获取页面 const response = await fetchPage(url); // 校验HTTP状态码 if (response.status !== 200) { pageResult.status = 'failed'; pageResult.errors.push(`HTTP状态码异常: ${response.status}`); results.errors.push({ url: url, error: `HTTP状态码异常: ${response.status}`, }); } pageResult.checks.push({ name: 'HTTP状态码', status: response.status === 200 ? 'passed' : 'failed', message: `状态码: ${response.status}`, }); // 校验H1唯一性 const h1Result = validateH1(response.body, url); pageResult.checks.push({ name: 'H1唯一性', status: h1Result.valid ? 'passed' : 'failed', message: h1Result.message, content: h1Result.content, }); if (!h1Result.valid) { pageResult.status = 'failed'; pageResult.errors.push(h1Result.message); results.errors.push({ url: url, error: h1Result.message, }); } // 校验标题层级 const headingsResult = validateHeadings(response.body, url); pageResult.checks.push({ name: '标题层级', status: headingsResult.valid ? 'passed' : 'failed', message: headingsResult.message, }); if (!headingsResult.valid) { pageResult.status = 'failed'; pageResult.errors.push(headingsResult.message); results.errors.push({ url: url, error: headingsResult.message, }); } // 校验JSON-LD const jsonLdResult = validateJsonLd(response.body, url); pageResult.checks.push({ name: 'JSON-LD结构化数据', status: jsonLdResult.valid ? 'passed' : 'failed', message: jsonLdResult.message, count: jsonLdResult.count, }); if (!jsonLdResult.valid) { pageResult.status = 'failed'; pageResult.errors.push(jsonLdResult.message); results.errors.push({ url: url, error: jsonLdResult.message, }); } // 校验内部链接 const linksResult = validateInternalLinks(response.body, url); pageResult.checks.push({ name: '内部链接', status: linksResult.valid ? 'passed' : 'failed', message: linksResult.message, linkCount: linksResult.links.length, }); } catch (error) { pageResult.status = 'failed'; pageResult.errors.push(`页面抓取失败: ${error.message}`); results.errors.push({ url: url, error: `页面抓取失败: ${error.message}`, }); } return pageResult; } /** * 生成测试报告 */ function generateReport() { // 创建报告目录 if (!fs.existsSync(CONFIG.outputDir)) { fs.mkdirSync(CONFIG.outputDir, { recursive: true }); } const reportPath = path.join(CONFIG.outputDir, 'crawler_report.json'); const reportContent = { summary: { totalTime: new Date().toISOString(), baseUrl: CONFIG.baseUrl, totalPages: results.totalPages, passedPages: results.passedPages, failedPages: results.failedPages, warnings: results.warnings, passRate: ((results.passedPages / results.totalPages) * 100).toFixed(2) + '%', }, errors: results.errors, warnings: results.warningsList, details: results.details, }; fs.writeFileSync(reportPath, JSON.stringify(reportContent, null, 2)); // 生成可读报告 const readableReportPath = path.join(CONFIG.outputDir, 'crawler_report.md'); let readableReport = `# 爬虫校验报告 生成时间: ${new Date().toISOString()} ## 摘要 - 总页面数: ${results.totalPages} - 通过页面: ${results.passedPages} - 失败页面: ${results.failedPages} - 警告: ${results.warnings} - 通过率: ${((results.passedPages / results.totalPages) * 100).toFixed(2)}% ## 失败详情 `; if (results.errors.length === 0) { readableReport += '无失败项\n\n'; } else { results.errors.forEach((error, index) => { readableReport += `${index + 1}. ${error.url}\n - ${error.error}\n\n`; }); } readableReport += `## 页面详情 `; results.details.forEach((detail) => { readableReport += `### ${detail.url} 状态: ${detail.status} `; detail.checks.forEach((check) => { readableReport += `- ${check.name}: ${check.status} - ${check.message}\n`; }); readableReport += '\n'; }); fs.writeFileSync(readableReportPath, readableReport); console.log(`报告已生成:`); console.log(` - JSON报告: ${reportPath}`); console.log(` - 可读报告: ${readableReportPath}`); } /** * 主函数 */ async function main() { console.log('开始爬虫校验...'); console.log(`目标URL: ${CONFIG.baseUrl}`); // 获取sitemap console.log('正在解析sitemap...'); const sitemapUrls = await parseSitemap(); if (sitemapUrls.length === 0) { console.log('未找到sitemap,使用默认页面列表'); // 默认页面列表 sitemapUrls.push( `${CONFIG.baseUrl}/`, `${CONFIG.baseUrl}/about`, `${CONFIG.baseUrl}/evolution`, `${CONFIG.baseUrl}/evolution/method`, `${CONFIG.baseUrl}/value`, `${CONFIG.baseUrl}/value/creation`, `${CONFIG.baseUrl}/value/transfer`, `${CONFIG.baseUrl}/value/delivery`, `${CONFIG.baseUrl}/value/support`, `${CONFIG.baseUrl}/amb`, `${CONFIG.baseUrl}/amb/a`, `${CONFIG.baseUrl}/amb/m`, `${CONFIG.baseUrl}/amb/b`, `${CONFIG.baseUrl}/capabilities`, `${CONFIG.baseUrl}/cases`, `${CONFIG.baseUrl}/knowledge`, `${CONFIG.baseUrl}/concepts`, `${CONFIG.baseUrl}/faq`, `${CONFIG.baseUrl}/match`, `${CONFIG.baseUrl}/contact` ); } console.log(`共发现${sitemapUrls.length}个页面`); // 逐个校验页面 for (let i = 0; i < sitemapUrls.length; i++) { const url = sitemapUrls[i]; console.log(`[${i + 1}/${sitemapUrls.length}] 正在校验: ${url}`); const pageResult = await validatePage(url); results.details.push(pageResult); results.totalPages++; if (pageResult.status === 'passed') { results.passedPages++; console.log(` ✓ 通过`); } else { results.failedPages++; console.log(` ✗ 失败`); pageResult.errors.forEach((error) => { console.log(` - ${error}`); }); } } // 生成报告 console.log('\n正在生成报告...'); generateReport(); console.log('\n校验完成!'); console.log(`通过率: ${((results.passedPages / results.totalPages) * 100).toFixed(2)}%`); // 返回结果 return results; } // 如果直接运行此脚本 if (require.main === module) { main().catch(console.error); } module.exports = { main, validatePage, parseSitemap, CONFIG, };