#!/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" | "http_error"; 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) => { // N-24:`npm run preview` = `next start -p 3000`,而本项目是 output:'standalone', // Next 16 明确告警 `"next start" does not work with "output: standalone"` ⇒ 此前该门禁跑在不受支持的降级路径上。 // 有产物就直起 standalone server(与 check:axe / Jenkinsfile / lighthouserc 同法),否则回退并大声告警。 const standalone = path.resolve( __dirname, "../../dist/standalone/server.js", ); const useStandalone = require("fs").existsSync(standalone); if (!useStandalone) { console.warn( "⚠ 未找到 dist/standalone/server.js,回退到 `npm run preview`(该路径在 standalone 下不受支持,结论仅参考)", ); } const server = spawn( useStandalone ? "node" : "npm", useStandalone ? [standalone] : ["run", "preview"], { env: useStandalone ? { ...process.env, HOSTNAME: "localhost", PORT: "3000", NODE_ENV: "production", } : process.env, cwd: path.resolve(__dirname, "../.."), stdio: "pipe", shell: true, detached: 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); }); } // 负 PID = 杀整个进程组。spawn 的 `npm run preview` 只是包装进程,真正 LISTEN :3000 的是它的 // next-server 孙进程;只 kill 包装进程会让孙进程被 init 收养继续占端口(验收报告 §9 实测残留 39 分钟), // 叠加 Playwright 的 reuseExistingServer: true 后,下一轮 E2E / Lighthouse 会静默复用它。 let serverStopped = false; function stopServer(server: ReturnType | null | undefined): void { if (!server?.pid || serverStopped) return; serverStopped = true; try { process.kill(-server.pid, "SIGTERM"); } catch { /* 进程组已自行退出 */ } } async function waitForServer(url: string, retries = 30): Promise { for (let i = 0; i < retries; i++) { try { const res = await fetch(url, { method: "HEAD" }); // N-24:原本 `|| res.status === 404` 把「已响应但站点是错的」当成就绪; // 而 :3000 上任何别的服务都会立刻满足它,于是整轮量的是错对象。 if (res.ok) return; } catch { // 服务未起:连接被拒 } // 就绪等待必须每轮都退避:启动期返回 503/500 等非 ok 状态时, // 若只在 catch 里 sleep,30 次重试会在毫秒内跑完并误报「服务未就绪」。 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 { const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000, }); // N-24:page.goto 不对 404/500 抛错,而 Next 的 not-found 页只有一个 h1、无层级跳跃 // ⇒ 路由消失时本门禁会安静地判「层级正常」。必须显式断言状态码。 const httpStatus = response?.status() ?? 0; if (httpStatus >= 400) { totalIssues++; results.push({ name, url, passed: false, headings: [], issues: [ { type: "http_error", message: `HTTP ${httpStatus} —— 页面未正常渲染(路由缺失/服务端错误)`, detail: "标题层级检查未执行", }, ], }); console.log(` ❌ ${name} (${pagePath}): HTTP ${httpStatus}`); continue; } 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() 不会执行 finally,所以两处出口都得自己收服服务再退出。 stopServer(server); process.exit(totalIssues > 0 ? 1 : 0); } catch (error) { console.error("检查执行失败:", error); stopServer(server); process.exit(1); } finally { stopServer(server); } } runCheck();