#!/usr/bin/env tsx /** * 标题层级检查脚本 * * 对关键页面进行标题(h1-h6)层级扫描,确保: * 1. 每个页面有且仅有一个 h1 * 2. 标题层级不跳跃(例如 h1 后直接出现 h3) * 3. 标题不为空 */ import { chromium } from 'playwright'; import { spawn } from 'child_process'; import path from 'path'; const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000'; const PAGES = [ { path: '/', name: '首页' }, { path: '/about', name: '关于我们' }, { path: '/products', name: '产品中心' }, { path: '/products/erp', name: '产品详情-ERP' }, { path: '/solutions', name: '解决方案' }, { path: '/solutions/manufacturing', name: '解决方案详情-制造' }, { path: '/services', name: '服务列表' }, { path: '/services/software', name: '服务详情-软件开发' }, { path: '/news', name: '新闻列表' }, { path: '/contact', name: '联系我们' }, ]; interface HeadingIssue { type: 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'skipped_level'; message: string; detail?: string; } interface PageResult { name: string; url: string; passed: boolean; headings: Array<{ level: number; text: string }>; issues: HeadingIssue[]; } function startServer(): Promise> { return new Promise((resolve, reject) => { const server = spawn('npm', ['run', 'preview'], { cwd: path.resolve(__dirname, '../..'), stdio: 'pipe', shell: true, }); server.stdout?.on('data', (data: Buffer) => { const output = data.toString(); if (output.includes('Ready') || output.includes('3000')) { resolve(server); } }); server.stderr?.on('data', (data: Buffer) => { const output = data.toString(); if (output.includes('EADDRINUSE') || output.includes('Failed')) { reject(new Error(output)); } }); setTimeout(() => resolve(server), 5000); }); } async function waitForServer(url: string, retries = 30): Promise { for (let i = 0; i < retries; i++) { try { const res = await fetch(url, { method: 'HEAD' }); if (res.ok || res.status === 404) return; } catch { await new Promise((r) => setTimeout(r, 1000)); } } throw new Error(`Server at ${url} did not become ready in time`); } function checkHeadings(headings: Array<{ level: number; text: string }>): HeadingIssue[] { const issues: HeadingIssue[] = []; const h1Headings = headings.filter((h) => h.level === 1); if (h1Headings.length === 0) { issues.push({ type: 'missing_h1', message: '页面缺少 h1 标题' }); } else if (h1Headings.length > 1) { issues.push({ type: 'multiple_h1', message: `页面存在 ${h1Headings.length} 个 h1 标题`, detail: h1Headings.map((h) => h.text).join(' | '), }); } for (let i = 0; i < headings.length; i++) { const current = headings[i]!; if (!current.text.trim()) { issues.push({ type: 'empty_heading', message: `h${current.level} 标题内容为空`, }); continue; } if (i === 0) continue; const previous = headings[i - 1]!; if (current.level > previous.level + 1) { issues.push({ type: 'skipped_level', message: `标题层级跳跃: h${previous.level} → h${current.level}`, detail: `"${previous.text}" → "${current.text}"`, }); } } return issues; } async function runCheck() { let server; try { console.log('🚀 启动预览服务...'); server = await startServer(); await waitForServer(BASE_URL); console.log('🔍 检查页面标题层级...\n'); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await context.newPage(); const results: PageResult[] = []; let totalIssues = 0; for (const { path: pagePath, name } of PAGES) { const url = `${BASE_URL}${pagePath}`; try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(1500); const headings = await page.evaluate(() => { const elements = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')); return elements.map((el) => ({ level: parseInt(el.tagName[1]!, 10), text: el.textContent?.trim() ?? '', })); }); const issues = checkHeadings(headings); totalIssues += issues.length; const result: PageResult = { name, url, passed: issues.length === 0, headings, issues, }; results.push(result); const status = issues.length === 0 ? '✅' : '❌'; console.log(`${status} ${name} (${issues.length} 个问题)`); for (const issue of issues) { console.log(` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ''}`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`❌ ${name} 检查失败:`, message); results.push({ name, url, passed: false, headings: [], issues: [{ type: 'missing_h1', message: `页面加载失败: ${message}` }], }); } } await browser.close(); console.log('\n📊 检查摘要'); console.log(` 扫描页面: ${results.length}`); console.log(` 通过页面: ${results.filter((r) => r.passed).length}`); console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`); console.log(` 问题总数: ${totalIssues}`); const reportPath = path.resolve(__dirname, '../../heading-hierarchy-report.json'); require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2)); console.log(`\n📝 详细报告已保存: ${reportPath}`); process.exit(totalIssues > 0 ? 1 : 0); } catch (error) { console.error('检查执行失败:', error); process.exit(1); } finally { if (server) { server.kill('SIGTERM'); } } } runCheck();