11#!/usr/bin/env node
22/**
3- * 校验规范页面只暴露 /heroes/ 与 /hero/ URL,并校验 sitemap / llms.txt。
3+ * 校验规范页面(URL 级三语言:en 无前缀 + /zh-CN/ + /zh-TW/)只暴露
4+ * /heroes/ 与 /hero/ URL,校验 canonical / hreflang / og:locale / <html lang>,
5+ * 并校验 sitemap / llms.txt。
46 *
57 * 用法:node scripts/verify-seo.mjs [ossheroes-dist] [www-dist]
68 */
@@ -14,6 +16,9 @@ const DEFAULT_WWW_DIST = join(__dirname, '..', '..', 'www', 'dist');
1416const DIST = process . argv [ 2 ] ? resolve ( process . argv [ 2 ] ) : DEFAULT_DIST ;
1517const WWW_DIST = process . argv [ 3 ] ? resolve ( process . argv [ 3 ] ) : DEFAULT_WWW_DIST ;
1618const SITE = 'https://opensource.win' ;
19+ /** 全站语言:en 为默认语言,URL 无前缀 */
20+ const LOCALES = [ 'en' , 'zh-CN' , 'zh-TW' ] ;
21+ const OG_LOCALE = { en : 'en_US' , 'zh-CN' : 'zh_CN' , 'zh-TW' : 'zh_TW' } ;
1722const errors = [ ] ;
1823
1924function check ( condition , message ) {
@@ -40,6 +45,22 @@ function getCanonical(html) {
4045 return html . match ( / < l i n k \s + r e l = [ " ' ] c a n o n i c a l [ " ' ] \s + h r e f = [ " ' ] ( [ ^ " ' ] + ) [ " ' ] / i) ?. [ 1 ] ?? null ;
4146}
4247
48+ /** hreflang → href 映射(<link rel="alternate" hreflang="..." href="...">) */
49+ function getHreflangs ( html ) {
50+ const map = { } ;
51+ const tags = html . match ( / < l i n k \b [ ^ > ] * r e l = [ " ' ] a l t e r n a t e [ " ' ] [ ^ > ] * > / gi) || [ ] ;
52+ for ( const tag of tags ) {
53+ const hreflang = tag . match ( / h r e f l a n g = [ " ' ] ( [ ^ " ' ] + ) [ " ' ] / i) ?. [ 1 ] ;
54+ const href = tag . match ( / h r e f = [ " ' ] ( [ ^ " ' ] + ) [ " ' ] / i) ?. [ 1 ] ;
55+ if ( hreflang && href ) map [ hreflang ] = href ;
56+ }
57+ return map ;
58+ }
59+
60+ function getHtmlLang ( html ) {
61+ return html . match ( / < h t m l \s + l a n g = [ " ' ] ( [ ^ " ' ] + ) [ " ' ] / i) ?. [ 1 ] ?? null ;
62+ }
63+
4364function getJsonLd ( html ) {
4465 const matches = [ ...html . matchAll ( / < s c r i p t \b [ ^ > ] * t y p e = [ " ' ] a p p l i c a t i o n \/ l d \+ j s o n [ " ' ] [ ^ > ] * > ( [ \s \S ] * ?) < \/ s c r i p t > / gi) ] ;
4566 return matches . flatMap ( ( match ) => {
@@ -51,15 +72,34 @@ function getJsonLd(html) {
5172 } ) ;
5273}
5374
54- function assertCanonicalPage ( path , canonicalPath , label ) {
75+ /** locale 中立路径 → 该语言的 URL 路径(en 无前缀) */
76+ function localePath ( locale , path ) {
77+ return locale === 'en' ? path : `/${ locale } ${ path } ` ;
78+ }
79+
80+ /**
81+ * 校验某个语言版本的规范页面:canonical / og:url 自指当前语言 URL、
82+ * 4 条 hreflang alternate(x-default → en)、<html lang> 与 og:locale。
83+ */
84+ function assertCanonicalPage ( path , canonicalPath , locale , label ) {
5585 if ( ! existsSync ( path ) ) {
5686 check ( false , `${ label } : 页面存在` ) ;
5787 return '' ;
5888 }
5989 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 } ` ) ;
90+ const canonical = `${ SITE } ${ localePath ( locale , canonicalPath ) } ` ;
91+ check ( getCanonical ( html ) === canonical , `${ label } : canonical 为 ${ localePath ( locale , canonicalPath ) } ` ) ;
92+ check ( getMeta ( html , 'property' , 'og:url' ) === canonical , `${ label } : og:url 为 ${ localePath ( locale , canonicalPath ) } ` ) ;
93+ check ( getHtmlLang ( html ) === locale , `${ label } : <html lang="${ locale } ">` ) ;
94+ check ( getMeta ( html , 'property' , 'og:locale' ) === OG_LOCALE [ locale ] , `${ label } : og:locale 为 ${ OG_LOCALE [ locale ] } ` ) ;
95+ const hreflangs = getHreflangs ( html ) ;
96+ for ( const l of LOCALES ) {
97+ check (
98+ hreflangs [ l ] === `${ SITE } ${ localePath ( l , canonicalPath ) } ` ,
99+ `${ label } : hreflang ${ l } 指向 ${ localePath ( l , canonicalPath ) } ` ,
100+ ) ;
101+ }
102+ check ( hreflangs [ 'x-default' ] === `${ SITE } ${ localePath ( 'en' , canonicalPath ) } ` , `${ label } : hreflang x-default 指向 en URL` ) ;
63103 check ( ! html . includes ( `${ SITE } /ossheroes/` ) , `${ label } : 不暴露旧规范 URL` ) ;
64104 return html ;
65105}
@@ -71,14 +111,14 @@ if (!isDir(DIST)) {
71111
72112const heroesDir = join ( DIST , 'heroes' ) ;
73113const heroDir = join ( DIST , 'hero' ) ;
74- assertCanonicalPage ( join ( heroesDir , 'index.html' ) , '/heroes/' , '/heroes/ 首页' ) ;
114+ assertCanonicalPage ( join ( heroesDir , 'index.html' ) , '/heroes/' , 'en' , ' /heroes/ 首页(en) ') ;
75115
76116const rankingYears = isDir ( heroesDir )
77117 ? readdirSync ( heroesDir ) . map ( ( entry ) => entry . match ( / ^ r a n k i n g - ( \d { 4 } ) $ / ) ?. [ 1 ] ) . filter ( Boolean ) . sort ( )
78118 : [ ] ;
79119check ( rankingYears . length > 0 , '检测到年度榜单页面' ) ;
80120for ( const year of rankingYears ) {
81- assertCanonicalPage ( join ( heroesDir , `ranking-${ year } ` , 'index.html' ) , `/heroes/ranking-${ year } /` , `/heroes/ranking-${ year } /` ) ;
121+ assertCanonicalPage ( join ( heroesDir , `ranking-${ year } ` , 'index.html' ) , `/heroes/ranking-${ year } /` , 'en' , `/heroes/ranking-${ year } /(en) ` ) ;
82122}
83123
84124const logins = isDir ( heroDir )
@@ -87,7 +127,7 @@ const logins = isDir(heroDir)
87127check ( logins . length >= 3 , `开发者详情页数 >= 3(实际 ${ logins . length } )` ) ;
88128for ( const login of logins . slice ( 0 , 3 ) ) {
89129 const canonicalPath = `/hero/${ login } /` ;
90- const html = assertCanonicalPage ( join ( heroDir , login , 'index.html' ) , canonicalPath , `/hero/${ login } /` ) ;
130+ const html = assertCanonicalPage ( join ( heroDir , login , 'index.html' ) , canonicalPath , 'en' , `/hero/${ login } /(en) ` ) ;
91131 const jsonLd = getJsonLd ( html ) ;
92132 check ( jsonLd . length > 0 , `/hero/${ login } /: JSON-LD 可解析` ) ;
93133 const profile = jsonLd . find ( ( item ) => item [ '@type' ] === 'ProfilePage' ) ;
@@ -96,12 +136,42 @@ for (const login of logins.slice(0, 3)) {
96136 check ( ! ! getMeta ( html , 'property' , 'og:description' ) , `/hero/${ login } /: og:description` ) ;
97137 check ( ! ! getMeta ( html , 'property' , 'og:image' ) , `/hero/${ login } /: og:image` ) ;
98138 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` ) ;
139+ const enUrl = `${ SITE } ${ canonicalPath } ` ;
140+ check ( profile ?. url === enUrl , `/hero/${ login } /: JSON-LD url 为规范 URL` ) ;
141+ check ( profile ?. [ '@id' ] === enUrl , `/hero/${ login } /: JSON-LD @id 为规范 URL` ) ;
142+ check ( profile ?. mainEntity ?. url === enUrl , `/hero/${ login } /: Person url 为规范 URL` ) ;
143+ check ( profile ?. mainEntity ?. [ '@id' ] === `${ enUrl } #person` , `/hero/${ login } /: Person @id 为规范 URL` ) ;
144+ }
145+
146+ /* ---- 语言版本:/zh-CN/ 与 /zh-TW/ 的首页 + 各年榜单 + 抽样详情页 ---- */
147+ for ( const locale of LOCALES . filter ( ( l ) => l !== 'en' ) ) {
148+ const localeRoot = join ( DIST , locale ) ;
149+ assertCanonicalPage ( join ( localeRoot , 'heroes' , 'index.html' ) , '/heroes/' , locale , `/${ locale } /heroes/ 首页` ) ;
150+ for ( const year of rankingYears ) {
151+ assertCanonicalPage (
152+ join ( localeRoot , 'heroes' , `ranking-${ year } ` , 'index.html' ) ,
153+ `/heroes/ranking-${ year } /` ,
154+ locale ,
155+ `/${ locale } /heroes/ranking-${ year } /` ,
156+ ) ;
157+ }
158+ for ( const login of logins . slice ( 0 , 3 ) ) {
159+ assertCanonicalPage ( join ( localeRoot , 'hero' , login , 'index.html' ) , `/hero/${ login } /` , locale , `/${ locale } /hero/${ login } /` ) ;
160+ }
103161}
104162
163+ /* zh-TW 正文应为繁体(OpenCC s2tw 构建期转换) */
164+ const zhTwHome = existsSync ( join ( DIST , 'zh-TW' , 'heroes' , 'index.html' ) )
165+ ? read ( join ( DIST , 'zh-TW' , 'heroes' , 'index.html' ) )
166+ : '' ;
167+ check ( zhTwHome . includes ( '開源' ) && zhTwHome . includes ( '開發者' ) , 'zh-TW 首页正文为繁体(開源 / 開發者)' ) ;
168+
169+ /* 首次访问重定向脚本只存在于 en 页面 */
170+ const enHome = read ( join ( heroesDir , 'index.html' ) ) ;
171+ check ( enHome . includes ( 'osw-language' ) && enHome . includes ( 'location.replace' ) , 'en 首页内联首访重定向脚本' ) ;
172+ const zhCnHome = read ( join ( DIST , 'zh-CN' , 'heroes' , 'index.html' ) ) ;
173+ check ( ! zhCnHome . includes ( 'location.replace' ) , 'zh-CN 首页不含重定向脚本' ) ;
174+
105175const sitemap = join ( WWW_DIST , 'sitemap.xml' ) ;
106176const llms = join ( WWW_DIST , 'llms.txt' ) ;
107177const hasWwwBuild = isDir ( WWW_DIST ) ;
@@ -116,6 +186,21 @@ if (existsSync(sitemap)) {
116186 check ( xml . includes ( `${ SITE } /heroes/ranking-` ) , 'sitemap 包含 /heroes/ 年度榜单' ) ;
117187 check ( xml . includes ( `${ SITE } /hero/` ) , 'sitemap 包含 /hero/ 开发者详情' ) ;
118188 check ( ! xml . includes ( '/ossheroes/' ) , 'sitemap 不含旧 /ossheroes/ URL' ) ;
189+ check (
190+ xml . includes ( 'xmlns:xhtml="http://www.w3.org/1999/xhtml"' ) ,
191+ 'sitemap 声明 xmlns:xhtml 命名空间' ,
192+ ) ;
193+ for ( const l of LOCALES ) {
194+ check (
195+ xml . includes ( `hreflang="${ l } " href="${ SITE } ${ localePath ( l , '/heroes/' ) } "` ) ,
196+ `sitemap 含 /heroes/ 的 ${ l } alternate` ,
197+ ) ;
198+ }
199+ check (
200+ xml . includes ( `hreflang="x-default" href="${ SITE } /heroes/"` ) ,
201+ 'sitemap 含 x-default → en alternate' ,
202+ ) ;
203+ check ( xml . includes ( `${ SITE } /zh-CN/hero/` ) && xml . includes ( `${ SITE } /zh-TW/hero/` ) , 'sitemap 含 zh-CN / zh-TW 开发者详情 URL' ) ;
119204}
120205check ( existsSync ( llms ) , 'llms.txt 已生成' ) ;
121206if ( existsSync ( llms ) ) {
0 commit comments