|
1 | 1 | #!/usr/bin/env node |
2 | 2 | /** |
3 | | - * verify-seo.mjs — 校验 ossheroes 开发者详情页的 SEO 结构化数据。 |
| 3 | + * 校验规范页面只暴露 /heroes/ 与 /hero/ URL,并校验 sitemap / llms.txt。 |
4 | 4 | * |
5 | | - * 随机抽取 3 个开发者详情页 HTML,验证: |
6 | | - * 1. 含 <script type="application/ld+json"> 且解析为 ProfilePage / Person |
7 | | - * 2. 含 og:title、og:description、og:image、twitter:image 等标签 |
8 | | - * |
9 | | - * 用法:node scripts/verify-seo.mjs [dist-dir] |
10 | | - * 默认 dist-dir 为脚本上级目录的 dist/ossheroes。成功 exit 0,失败 exit 1。 |
| 5 | + * 用法:node scripts/verify-seo.mjs [ossheroes-dist] [www-dist] |
11 | 6 | */ |
12 | 7 | import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs'; |
13 | 8 | import { join, dirname, resolve } from 'node:path'; |
14 | 9 | import { fileURLToPath } from 'node:url'; |
15 | 10 |
|
16 | 11 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
17 | | -const DEFAULT_DIST = join(__dirname, '..', 'dist', 'ossheroes'); |
| 12 | +const DEFAULT_DIST = join(__dirname, '..', 'dist'); |
| 13 | +const DEFAULT_WWW_DIST = join(__dirname, '..', '..', 'www', 'dist'); |
18 | 14 | const DIST = process.argv[2] ? resolve(process.argv[2]) : DEFAULT_DIST; |
19 | | - |
| 15 | +const WWW_DIST = process.argv[3] ? resolve(process.argv[3]) : DEFAULT_WWW_DIST; |
| 16 | +const SITE = 'https://opensource.win'; |
20 | 17 | const errors = []; |
21 | | -function check(cond, msg) { |
22 | | - console.log(`${cond ? '✓' : '✗'} ${msg}`); |
23 | | - if (!cond) errors.push(msg); |
24 | | -} |
25 | 18 |
|
26 | | -/** 可复现的伪随机采样:固定种子的 mulberry32,CI 稳定但仍具随机性。 */ |
27 | | -function mulberry32(seed) { |
28 | | - return function () { |
29 | | - seed |= 0; |
30 | | - seed = (seed + 0x6d2b79f5) | 0; |
31 | | - let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); |
32 | | - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; |
33 | | - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
34 | | - }; |
| 19 | +function check(condition, message) { |
| 20 | + console.log(`${condition ? '✓' : '✗'} ${message}`); |
| 21 | + if (!condition) errors.push(message); |
35 | 22 | } |
36 | | -function sample(arr, n, seed = 99007711) { |
37 | | - const rng = mulberry32(seed); |
38 | | - const pool = [...arr]; |
39 | | - const out = []; |
40 | | - while (out.length < n && pool.length) { |
41 | | - out.push(pool.splice(Math.floor(rng() * pool.length), 1)[0]); |
42 | | - } |
43 | | - return out; |
| 23 | + |
| 24 | +function isDir(path) { |
| 25 | + return existsSync(path) && statSync(path).isDirectory(); |
44 | 26 | } |
45 | 27 |
|
46 | | -function isDir(p) { |
47 | | - return existsSync(p) && statSync(p).isDirectory(); |
| 28 | +function read(path) { |
| 29 | + return readFileSync(path, 'utf8'); |
48 | 30 | } |
49 | 31 |
|
50 | | -/** 在所有 <meta> 标签中查找含 `attr="value"` 者,返回其 content(属性顺序无关)。 */ |
51 | 32 | function getMeta(html, attr, value) { |
52 | | - const valueEsc = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
53 | | - const attrRe = new RegExp(`${attr}=["']${valueEsc}["']`); |
54 | | - const tag = (html.match(/<meta\b[^>]*>/gi) || []).find((t) => attrRe.test(t)); |
55 | | - if (!tag) return null; |
56 | | - const cm = tag.match(/content=["']([^"']*)["']/i); |
57 | | - return cm ? cm[1] : null; |
| 33 | + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 34 | + const attrRe = new RegExp(`${attr}=["']${escaped}["']`); |
| 35 | + const tag = (html.match(/<meta\b[^>]*>/gi) || []).find((item) => attrRe.test(item)); |
| 36 | + return tag?.match(/content=["']([^"']*)["']/i)?.[1] ?? null; |
| 37 | +} |
| 38 | + |
| 39 | +function getCanonical(html) { |
| 40 | + return html.match(/<link\s+rel=["']canonical["']\s+href=["']([^"']+)["']/i)?.[1] ?? null; |
| 41 | +} |
| 42 | + |
| 43 | +function getJsonLd(html) { |
| 44 | + const matches = [...html.matchAll(/<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)]; |
| 45 | + return matches.flatMap((match) => { |
| 46 | + try { |
| 47 | + return [JSON.parse(match[1])]; |
| 48 | + } catch { |
| 49 | + return []; |
| 50 | + } |
| 51 | + }); |
58 | 52 | } |
59 | 53 |
|
60 | | -/** 递归收集 JSON-LD 对象中的所有 @type 值(含 mainEntity 等)。 */ |
61 | | -function collectTypes(obj, into) { |
62 | | - if (!obj || typeof obj !== 'object') return; |
63 | | - const t = obj['@type']; |
64 | | - if (t) (Array.isArray(t) ? t : [t]).forEach((x) => into.add(x)); |
65 | | - for (const k of Object.keys(obj)) { |
66 | | - if (k === '@type') continue; |
67 | | - const v = obj[k]; |
68 | | - if (v && typeof v === 'object') collectTypes(v, into); |
| 54 | +function assertCanonicalPage(path, canonicalPath, label) { |
| 55 | + if (!existsSync(path)) { |
| 56 | + check(false, `${label}: 页面存在`); |
| 57 | + return ''; |
69 | 58 | } |
| 59 | + const html = read(path); |
| 60 | + const canonical = `${SITE}${canonicalPath}`; |
| 61 | + check(getCanonical(html) === canonical, `${label}: canonical 为 ${canonicalPath}`); |
| 62 | + check(getMeta(html, 'property', 'og:url') === canonical, `${label}: og:url 为 ${canonicalPath}`); |
| 63 | + check(!html.includes(`${SITE}/ossheroes/`), `${label}: 不暴露旧规范 URL`); |
| 64 | + return html; |
70 | 65 | } |
71 | 66 |
|
72 | 67 | if (!isDir(DIST)) { |
73 | 68 | console.error(`✗ 产物目录不存在: ${DIST}(请先运行 pnpm --filter ossheroes build)`); |
74 | 69 | process.exit(1); |
75 | 70 | } |
76 | 71 |
|
77 | | -const entries = readdirSync(DIST); |
78 | | -const candidateDirs = entries |
79 | | - .filter((e) => !e.startsWith('.') && e !== 'assets' && !/^ranking-/.test(e)) |
80 | | - .filter((e) => isDir(join(DIST, e))) |
81 | | - .sort(); |
82 | | -check(candidateDirs.length >= 3, `开发者目录数 >= 3(实际 ${candidateDirs.length})`); |
| 72 | +const heroesDir = join(DIST, 'heroes'); |
| 73 | +const heroDir = join(DIST, 'hero'); |
| 74 | +assertCanonicalPage(join(heroesDir, 'index.html'), '/heroes/', '/heroes/ 首页'); |
83 | 75 |
|
84 | | -const picks = sample(candidateDirs, 3); |
85 | | -for (const d of picks) { |
86 | | - const file = join(DIST, d, 'index.html'); |
87 | | - if (!existsSync(file)) { |
88 | | - check(false, `${d}: index.html 不存在`); |
89 | | - continue; |
90 | | - } |
91 | | - const html = readFileSync(file, 'utf8'); |
| 76 | +const rankingYears = isDir(heroesDir) |
| 77 | + ? readdirSync(heroesDir).map((entry) => entry.match(/^ranking-(\d{4})$/)?.[1]).filter(Boolean).sort() |
| 78 | + : []; |
| 79 | +check(rankingYears.length > 0, '检测到年度榜单页面'); |
| 80 | +for (const year of rankingYears) { |
| 81 | + assertCanonicalPage(join(heroesDir, `ranking-${year}`, 'index.html'), `/heroes/ranking-${year}/`, `/heroes/ranking-${year}/`); |
| 82 | +} |
92 | 83 |
|
93 | | - // 1. JSON-LD |
94 | | - const ldRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; |
95 | | - const blocks = [...html.matchAll(ldRe)].map((m) => m[1]); |
96 | | - check(blocks.length > 0, `${d}: 含 <script type="application/ld+json">`); |
97 | | - const types = new Set(); |
98 | | - let parsed = 0; |
99 | | - for (const b of blocks) { |
100 | | - try { |
101 | | - collectTypes(JSON.parse(b), types); |
102 | | - parsed++; |
103 | | - } catch { |
104 | | - /* 忽略畸形块 */ |
105 | | - } |
106 | | - } |
107 | | - check(parsed > 0, `${d}: JSON-LD 可解析(${parsed}/${blocks.length})`); |
108 | | - const seo = [...types]; |
109 | | - check( |
110 | | - seo.includes('ProfilePage') || seo.includes('Person'), |
111 | | - `${d}: JSON-LD 类型为 ProfilePage/Person(${seo.join('/') || '无'})`, |
112 | | - ); |
| 84 | +const logins = isDir(heroDir) |
| 85 | + ? readdirSync(heroDir).filter((login) => existsSync(join(heroDir, login, 'index.html'))).sort() |
| 86 | + : []; |
| 87 | +check(logins.length >= 3, `开发者详情页数 >= 3(实际 ${logins.length})`); |
| 88 | +for (const login of logins.slice(0, 3)) { |
| 89 | + const canonicalPath = `/hero/${login}/`; |
| 90 | + const html = assertCanonicalPage(join(heroDir, login, 'index.html'), canonicalPath, `/hero/${login}/`); |
| 91 | + const jsonLd = getJsonLd(html); |
| 92 | + check(jsonLd.length > 0, `/hero/${login}/: JSON-LD 可解析`); |
| 93 | + const profile = jsonLd.find((item) => item['@type'] === 'ProfilePage'); |
| 94 | + check(!!profile, `/hero/${login}/: JSON-LD 包含 ProfilePage`); |
| 95 | + check(!!getMeta(html, 'property', 'og:title'), `/hero/${login}/: og:title`); |
| 96 | + check(!!getMeta(html, 'property', 'og:description'), `/hero/${login}/: og:description`); |
| 97 | + check(!!getMeta(html, 'property', 'og:image'), `/hero/${login}/: og:image`); |
| 98 | + check(!!getMeta(html, 'name', 'twitter:image'), `/hero/${login}/: twitter:image`); |
| 99 | + check(profile?.url === `${SITE}${canonicalPath}`, `/hero/${login}/: JSON-LD url 为规范 URL`); |
| 100 | + check(profile?.['@id'] === `${SITE}${canonicalPath}`, `/hero/${login}/: JSON-LD @id 为规范 URL`); |
| 101 | + check(profile?.mainEntity?.url === `${SITE}${canonicalPath}`, `/hero/${login}/: Person url 为规范 URL`); |
| 102 | + check(profile?.mainEntity?.['@id'] === `${SITE}${canonicalPath}#person`, `/hero/${login}/: Person @id 为规范 URL`); |
| 103 | +} |
113 | 104 |
|
114 | | - // 2. OG + twitter 标签 |
115 | | - check(!!getMeta(html, 'property', 'og:title'), `${d}: og:title`); |
116 | | - check(!!getMeta(html, 'property', 'og:description'), `${d}: og:description`); |
117 | | - check(!!getMeta(html, 'property', 'og:image'), `${d}: og:image`); |
118 | | - check(!!getMeta(html, 'name', 'twitter:image'), `${d}: twitter:image`); |
| 105 | +const sitemap = join(WWW_DIST, 'sitemap.xml'); |
| 106 | +const llms = join(WWW_DIST, 'llms.txt'); |
| 107 | +const hasWwwBuild = isDir(WWW_DIST); |
| 108 | +check( |
| 109 | + hasWwwBuild, |
| 110 | + `官网产物目录存在: ${WWW_DIST}(请先运行 pnpm --filter www build)`, |
| 111 | +); |
| 112 | +check(existsSync(sitemap), '根 sitemap.xml 已生成'); |
| 113 | +if (existsSync(sitemap)) { |
| 114 | + const xml = read(sitemap); |
| 115 | + check(xml.includes(`${SITE}/heroes/`), 'sitemap 包含 /heroes/'); |
| 116 | + check(xml.includes(`${SITE}/heroes/ranking-`), 'sitemap 包含 /heroes/ 年度榜单'); |
| 117 | + check(xml.includes(`${SITE}/hero/`), 'sitemap 包含 /hero/ 开发者详情'); |
| 118 | + check(!xml.includes('/ossheroes/'), 'sitemap 不含旧 /ossheroes/ URL'); |
| 119 | +} |
| 120 | +check(existsSync(llms), 'llms.txt 已生成'); |
| 121 | +if (existsSync(llms)) { |
| 122 | + const text = read(llms); |
| 123 | + check(text.includes(`${SITE}/heroes/`) && text.includes(`${SITE}/hero/`), 'llms.txt 使用新 URL'); |
| 124 | + check(!text.includes('/ossheroes/'), 'llms.txt 不含旧 /ossheroes/ URL'); |
119 | 125 | } |
120 | 126 |
|
121 | 127 | if (errors.length) { |
122 | 128 | console.error(`\n❌ verify-seo 失败:${errors.length} 项未通过`); |
123 | 129 | process.exit(1); |
124 | 130 | } |
125 | 131 | console.log('\n✅ verify-seo 通过'); |
126 | | -process.exit(0); |
|
0 commit comments