11#!/usr/bin/env node
2- // 从本地安装的 Wappalyzer 扩展抽取与我们 rules name 命中的图标,放到 public/icons/tech /<slug>.{svg,png}
2+ // 从本地安装的 Wappalyzer 扩展抽取与我们 rules name 命中的图标,放到 public/skills /<slug>.{svg,png}
33// 用法:WAPPALYZER_ICON_DIR=<wappalyzer 安装目录的 images/icons> node build-scripts/extract-wappalyzer-icons.mjs
44// 默认到百分浏览器(Cent Browser)的 Wappalyzer 6.12.2 安装路径找
5+ // 设计参考:https://github.com/setube/stackprism/issues/6
56import fs from 'node:fs'
67import path from 'node:path'
78import { fileURLToPath } from 'node:url'
@@ -13,8 +14,9 @@ const DEFAULT_DIR =
1314 'C:/Users/19622/AppData/Local/CentBrowser/User Data/Default/Extensions/gppongmhjkpfnbhagpmjfkannfbllamg/6.12.2_0/images/icons'
1415const ICON_DIR = process . env . WAPPALYZER_ICON_DIR || DEFAULT_DIR
1516const RULES_DIR = path . join ( repoRoot , 'public' , 'rules' )
16- const OUTPUT_DIR = path . join ( repoRoot , 'public' , 'icons' , 'tech ')
17+ const OUTPUT_DIR = path . join ( repoRoot , 'public' , 'skills ' )
1718const CUSTOM_DIR = path . join ( __dirname , 'custom-icons' )
19+ const MANIFEST_PATH = path . join ( repoRoot , 'src' , 'ui' , 'components' , 'skills-index.json' )
1820
1921// service worker / page-detector 在运行时硬编码塞到识别结果里、不在 rules JSON 中出现的技术名
2022const EXTRA_NAMES = [ 'HTTP/2' , 'HTTP/3' , 'HTTPS' ]
@@ -44,12 +46,16 @@ const walk = (dir, files = []) => {
4446 return files
4547}
4648
47- const slugify = raw =>
48- String ( raw || '' )
49+ // 规范化函数:跟 TechChip / issue#6 保持一致
50+ // 小写 + `&`→`and` + `+`→`plus`,保留 [a-z0-9] 和 CJK 统一表意文字(让"百度统计"、"飞书"这类纯中文名也能查到)
51+ const normalize = raw =>
52+ String ( raw ?? '' )
4953 . toLowerCase ( )
50- . replace ( / [ ^ a - z 0 - 9 ] / g, '' )
54+ . replace ( / & / g, 'and' )
55+ . replace ( / \+ / g, 'plus' )
56+ . replace ( / [ ^ a - z 0 - 9 一 - 龥 ] + / g, '' )
5157
52- // 跟 TechChip 里的 toSlug 保持一致:用 ` / `(带空格)拆别名 ,保留 HTTP/2 这类纯版本号写法
58+ // 拆别名: ` / `(带空格)只在别名分隔时出现 ,保留 HTTP/2 这类纯版本号写法
5359const primaryName = raw =>
5460 String ( raw || '' )
5561 . split ( ' / ' ) [ 0 ]
@@ -59,7 +65,7 @@ const primaryName = raw =>
5965const iconBySlug = new Map ( )
6066for ( const f of fs . readdirSync ( ICON_DIR ) ) {
6167 if ( ! f . endsWith ( '.svg' ) && ! f . endsWith ( '.png' ) ) continue
62- const slug = slugify ( f . replace ( / \. ( s v g | p n g ) $ / i, '' ) )
68+ const slug = normalize ( f . replace ( / \. ( s v g | p n g ) $ / i, '' ) )
6369 if ( ! slug ) continue
6470 if ( ! iconBySlug . has ( slug ) ) iconBySlug . set ( slug , [ ] )
6571 iconBySlug . get ( slug ) . push ( f )
@@ -82,13 +88,13 @@ fs.mkdirSync(OUTPUT_DIR, { recursive: true })
8288// 给定一个 name,返回命中的 Wappalyzer slug(可能跟 localKey 不同,例如 cloudflarewebanalytics → cloudflare)
8389const matchWappalyzerSlug = name => {
8490 const base = primaryName ( name )
85- const fullSlug = slugify ( base )
91+ const fullSlug = normalize ( base )
8692 if ( iconBySlug . has ( fullSlug ) ) return fullSlug
8793 // 首词兜底:"Cloudflare Web Analytics" → "Cloudflare" → cloudflare;
8894 // "Microsoft Teams" → "Microsoft"。会用品牌主 logo,牺牲一点准确度换覆盖率
8995 const firstWord = base . split ( / \s + / ) [ 0 ]
9096 if ( ! firstWord ) return null
91- const firstSlug = slugify ( firstWord )
97+ const firstSlug = normalize ( firstWord )
9298 if ( firstSlug && firstSlug !== fullSlug && iconBySlug . has ( firstSlug ) ) return firstSlug
9399 return null
94100}
@@ -99,14 +105,14 @@ let svgCount = 0
99105let pngCount = 0
100106let customCount = 0
101107let totalBytes = 0
102- // manifest :localKey(规则名 slug) → 实际磁盘文件名(可能多个 key 共用一个文件,节省体积)
103- const manifest = { }
108+ // skillsIndex :localKey(规则名 slug) → 实际磁盘文件名(可能多个 key 共用一个文件,节省体积)
109+ const skillsIndex = { }
104110// 已写到磁盘的 Wappalyzer slug → 文件名,避免重复写
105111const writtenFiles = new Map ( )
106112const seenLocalKeys = new Set ( )
107113
108114for ( const name of ruleNames ) {
109- const localKey = slugify ( primaryName ( name ) )
115+ const localKey = normalize ( primaryName ( name ) )
110116 if ( ! localKey || seenLocalKeys . has ( localKey ) ) continue
111117 const matchedSlug = matchWappalyzerSlug ( name )
112118 if ( ! matchedSlug ) continue
@@ -127,7 +133,7 @@ for (const name of ruleNames) {
127133 }
128134
129135 seenLocalKeys . add ( localKey )
130- manifest [ localKey ] = filename
136+ skillsIndex [ localKey ] = filename
131137 mappedKeys ++
132138 if ( matchedSlug !== localKey ) prefixMatched ++
133139}
@@ -137,29 +143,29 @@ for (const name of ruleNames) {
137143if ( fs . existsSync ( CUSTOM_DIR ) ) {
138144 for ( const f of fs . readdirSync ( CUSTOM_DIR ) ) {
139145 if ( ! f . endsWith ( '.svg' ) && ! f . endsWith ( '.png' ) ) continue
140- const slug = slugify ( f . replace ( / \. ( s v g | p n g ) $ / i, '' ) )
146+ const slug = normalize ( f . replace ( / \. ( s v g | p n g ) $ / i, '' ) )
141147 if ( ! slug ) continue
142148 const ext = path . extname ( f ) . toLowerCase ( ) . slice ( 1 )
143149 const filename = slug + '.' + ext
144150 const dst = path . join ( OUTPUT_DIR , filename )
145151 fs . copyFileSync ( path . join ( CUSTOM_DIR , f ) , dst )
146- if ( ! manifest [ slug ] ) mappedKeys ++
147- manifest [ slug ] = filename
152+ if ( ! skillsIndex [ slug ] ) mappedKeys ++
153+ skillsIndex [ slug ] = filename
148154 customCount ++
149155 totalBytes += fs . statSync ( dst ) . size
150156 }
151157}
152158
153- // 写出 manifest ,运行时 TechChip 用它判断本地是否有图标、是 svg 还是 png ,避免无意义 404
154- const sortedManifest = { }
155- for ( const slug of Object . keys ( manifest ) . sort ( ) ) sortedManifest [ slug ] = manifest [ slug ]
156- const manifestPath = path . join ( repoRoot , 'src' , 'ui' , 'components' , 'local-icon-manifest.json' )
157- fs . writeFileSync ( manifestPath , JSON . stringify ( sortedManifest ) + '\n' , 'utf8' )
159+ // 写出 skills-index.json ,运行时 TechChip 用它判断本地是否有图标,避免无意义 404
160+ const sortedIndex = { }
161+ for ( const slug of Object . keys ( skillsIndex ) . sort ( ) ) sortedIndex [ slug ] = skillsIndex [ slug ]
162+ const out = { schemaVersion : 1 , skillsIndex : sortedIndex }
163+ fs . writeFileSync ( MANIFEST_PATH , JSON . stringify ( out ) + '\n' , 'utf8' )
158164
159165console . log (
160166 `抽取完成:${ mappedKeys } 个映射 → ${ writtenFiles . size + customCount } 个物理文件 ` +
161167 `(svg: ${ svgCount } , png: ${ pngCount } , custom: ${ customCount } , 首词兜底: ${ prefixMatched } )`
162168)
163169console . log ( `输出目录:${ OUTPUT_DIR } ` )
164170console . log ( `总大小:${ ( totalBytes / 1024 / 1024 ) . toFixed ( 2 ) } MB` )
165- console . log ( `manifest :${ manifestPath } ` )
171+ console . log ( `索引 :${ MANIFEST_PATH } ` )
0 commit comments