#!/usr/bin/env node /* C-05 SEO 验收:扫描 dist 校验 title/description/sitemap 覆盖。 * 用法:node scripts/check_seo.mjs [--dist dist] * 退出码:0 全绿;1 有失败(打印明细)。 */ import { readFileSync, existsSync, readdirSync } from 'node:fs' import { join, resolve } from 'node:path' const args = process.argv.slice(2) const distIdx = args.indexOf('--dist') const DIST = resolve(distIdx >= 0 && args[distIdx + 1] ? args[distIdx + 1] : 'dist') const EXPECT_TOOLS = [ '/baidu-translate', '/utility/json-formatter', '/utility/timestamp', '/utility/base64', '/utility/encoding-converter', '/utility/regex', '/utility/code-formatter', '/utility/color-converter', '/qrcode-generator', '/utility/text-diff', '/utility/calculator', '/utility/date-calculator', '/utility/image-compressor', '/color-picker', '/utility/charset-converter', '/utility/image-editor', ] const EXPECT_PAGES = ['/', '/utility', '/top'] let fail = 0 const ok = (cond, msg) => { console.log(`${cond ? 'PASS' : 'FAIL'} ${msg}`) if (!cond) fail++ } const titleOf = (html) => (html.match(/([\s\S]*?)<\/title>/) || [])[1] || '' const descOf = (html) => (html.match(/<meta name="description" content="([\s\S]*?)"\s*\/?>/) || [])[1] || '' // 1. 工具页 HTML 含正文 + 唯一 title/description const titles = new Set() for (const u of EXPECT_TOOLS) { const f = join(DIST, u.replace(/^\//, ''), 'index.html') ok(existsSync(f), `${u} -> dist 产物存在`) if (!existsSync(f)) continue const html = readFileSync(f, 'utf-8') const t = titleOf(html), d = descOf(html) ok(t.length > 0, `${u} title 非空(${t.slice(0, 24)}…)`) ok(!titles.has(t), `${u} title 唯一`) titles.add(t) ok(d.length > 0, `${u} description 非空`) ok(html.includes('<h1>') && html.includes('免费在线工具'), `${u} 含静态正文(h1+说明,非空#root)`) ok(html.includes('og:title') && html.includes('og:description'), `${u} OG 标签齐全`) } // 2. 聚合页 for (const u of EXPECT_PAGES) { const f = u === '/' ? join(DIST, 'index.seo.html') : join(DIST, u.replace(/^\//, ''), 'index.html') ok(existsSync(f), `${u} -> 聚合产物存在`) if (!existsSync(f)) continue const html = readFileSync(f, 'utf-8') ok(titleOf(html).length > 0 && descOf(html).length > 0, `${u} title/description 非空`) } // 3. sitemap/robots const sm = join(DIST, 'sitemap.xml') ok(existsSync(sm), 'sitemap.xml 存在') if (existsSync(sm)) { const body = readFileSync(sm, 'utf-8') const missing = [...EXPECT_TOOLS, ...EXPECT_PAGES].filter((u) => !body.includes(`<loc>`) || !body.includes(u)) ok(missing.length === 0, `sitemap 覆盖 ${EXPECT_TOOLS.length + EXPECT_PAGES.length} URL${missing.length ? '(缺:' + missing.join(',') + ')' : ''}`) } const robots = join(DIST, 'robots.txt') ok(existsSync(robots), 'robots.txt 存在') if (existsSync(robots)) { const body = readFileSync(robots, 'utf-8') ok(body.includes('Sitemap:'), 'robots 指向 sitemap') ok(body.includes('Disallow: /profile') && body.includes('Disallow: /wallet/'), 'robots 屏蔽登录态页') } // 4. SPA 外壳未被破坏 const shell = join(DIST, 'index.html') ok(existsSync(shell) && readFileSync(shell, 'utf-8').includes('id="root"'), 'SPA 外壳 index.html 完好(含 #root)') // 5. 种子对齐:TOOLS 数量 == 后端启用工具数(硬编码 16,漂移即报) ok(EXPECT_TOOLS.length === 16, `工具清单 16 条(与 seed_tools 启用数对齐)`) console.log(fail === 0 ? `\nSEO CHECK ALL GREEN (${EXPECT_TOOLS.length} tools + ${EXPECT_PAGES.length} pages)` : `\nSEO CHECK FAILED: ${fail} 项`) process.exit(fail === 0 ? 0 : 1)