Files
danyang-match/scripts/audit-miniapp-language.mjs
T

202 lines
8.7 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* 小程序文案语种审计:找出「会显示给用户、但不是中文简体」的文本。
*
* 为什么需要它:小程序里最典型的一类缺陷是**后端枚举码直接当展示值**——
* 例如某个 <picker range="{{['any','accept','reject']}}"> 的下拉里出现
* any / accept / reject,而同一行的选中值却是「不限」。静态类型检查、
* WXSS/WXML 编译、结构守卫全都不会报,只有人眼在真机上才发现。
*
* 检查项:
* A 文本节点里的 ASCII 单词(剥掉注释与 {{ }} 表达式后仍有英文)
* B 中文属性(placeholder/title/label/subtitle/action/desc…)里的 ASCII 单词
* C <picker range="{{[...]}}"> 字面量选项里出现纯英文
* D JS 里 label: 'English' 这类未汉化的文案字段
* E 繁体字(与简体异形的常用字表)——「其他非中文简体」里的常见一类
* F 页面 .json 的 navigationBarTitleText 为英文
*
* 用法:node scripts/audit-miniapp-language.mjs [--json] [--out=file]
* 退出码:有命中 → 1;干净 → 0。(可直接当守卫用)
*/
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
const STACK_FILE = 'E:/develop/公益婚恋服务平台/.workbuddy/audit_stack.txt'
try {
main()
} catch (e) {
try { writeFileSync(STACK_FILE, 'AUDIT_CRASH:\n' + ((e && e.stack) || String(e)), 'utf8') } catch (_) {}
console.error((e && e.stack) || e)
process.exit(3)
}
function main() {
const root = resolve(import.meta.dirname, '..')
const MINIAPP = join(root, 'miniapp')
const AS_JSON = process.argv.includes('--json')
const outArg = process.argv.find((a) => a.startsWith('--out='))
/** 允许出现在界面上的非中文片段(单位、专名缩写等),按需增补并写清理由 */
const ASCII_ALLOW = new Set([
'cm', 'kg', 'ml', 'km', 'm', // 计量单位
'ID', 'id', // 证件/编号类专名
'APP', 'App', // 平台专名
'VIP',
'AI',
'OK',
'Lv', 'LV',
'MB', 'GB', 'KB', // 体积
'TEL',
'URL',
'el', // 个别组件前缀(如 el-tag 为历史遗留 Web 组件,不在小程序渲染路径)
// 图片/文档格式专名(与 cm/kg 同理,属技术缩写而非「其他语言」)
'JPG', 'PNG', 'GIF', 'PDF', 'WEBP',
])
/**
* 与简体**异形**的常用繁体字。只收录「简体写法不同」的字,
* 简繁同形的字(如 趣/域/婚/子/女/定/案 等)绝不能放进来,否则全是误报。
*/
const TRADITIONAL_CHARS = new Set(
'個們這來時說對開關聯繫預約資標準確認興學歷職業區狀況選擇設檔訊報幫隱權註銷帳號審過實證齡體車產戶現電話碼驗動參簽紅薦發務條詳細臺灣門網絡軟機腦錄簡無線藍員會構團隊義誌願評價滿歡適檢醫療護飲當還讓給與從麼樣為長愛見覺點種問間東風雲視圖書課師圓場園館廳營記軍農極變層屬歲廣慶應處養雙頁頂項順顧題額類頻飛飯飽餓鮮藥衛親眾優傳偉傷兒'
)
/** 需要审计的用户可见属性 */
const TEXT_ATTRS = [
'placeholder', 'title', 'subtitle', 'action', 'label', 'desc', 'description',
'content', 'text', 'tip', 'tips', 'confirm-text', 'cancel-text', 'empty-text',
'hint', 'notice', 'caption', 'unit',
]
const findings = []
const add = (level, file, line, kind, detail) => {
findings.push({ level, file: relative(root, file).replace(/\\/g, '/'), line, kind, detail })
}
function walk(dir, filter, out = []) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === '.git' || name === 'dist') continue
const p = join(dir, name)
const st = statSync(p)
if (st.isDirectory()) walk(p, filter, out)
else if (filter(name)) out.push(p)
}
return out
}
/** 把 <!-- --> 注释替换成等长空白,保持行号不变 */
function stripWxmlComments(src) {
return src.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '))
}
/** 剥离 {{ }} 表达式(表达式里出现英文是正常的:变量名/枚举判断) */
function stripMustache(src) {
return src.replace(/\{\{[\s\S]*?\}\}/g, (m) => ' '.repeat(m.length))
}
function linesOf(src) {
return src.split(/\r?\n/)
}
function asciiWords(text) {
const words = text.match(/[A-Za-z][A-Za-z0-9_'-]*/g) || []
return words.filter((w) => !ASCII_ALLOW.has(w))
}
/* ---------------- A/B/C:WXML ---------------- */
for (const file of walk(join(MINIAPP, 'pages'), (n) => n.endsWith('.wxml'))
.concat(walk(join(MINIAPP, 'components'), (n) => n.endsWith('.wxml')))) {
const src = stripWxmlComments(readFileSync(file, 'utf8'))
const lines = linesOf(src)
// A 文本节点
lines.forEach((line, i) => {
// 先在整行上剥离 {{ }},否则 mustache 内的 > / <(如 minlength > 0 && x < y)
// 会破坏「>文本<」的提取,把比较运算符误判为文本节点。
const strippedLine = stripMustache(line)
const texts = [...strippedLine.matchAll(/>([^<>]*)</g)].map((m) => m[1])
for (const raw of texts) {
const text = stripMustache(raw).trim()
if (!text) continue
const words = asciiWords(text)
if (words.length) add('error', file, i + 1, 'A-文本节点', `"${text.trim()}" → ${words.join(', ')}`)
}
})
// B 中文属性
// 负向环视:排除 bind:action / catch:action / data-action 等事件绑定——
// 它们的取值是处理器标识(onXxx),不是界面可见文案。
const attrRe = new RegExp(`(?<![:\\w-])(${TEXT_ATTRS.join('|')})="([^"]*)"`, 'g')
lines.forEach((line, i) => {
for (const m of line.matchAll(attrRe)) {
const value = stripMustache(m[2])
if (!value.trim() || value.includes('{{')) continue
const words = asciiWords(value)
if (words.length) add('error', file, i + 1, 'B-属性', `${m[1]}="${value}" → ${words.join(', ')}`)
}
})
// C picker 字面量选项
lines.forEach((line, i) => {
for (const m of line.matchAll(/range="\{\{\s*\[([^\]]*)\]\s*\}\}"/g)) {
const items = m[1].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
const bad = items.filter((s) => s && !/[\u4e00-\u9fa5]/.test(s))
if (bad.length) add('error', file, i + 1, 'C-picker 选项', `纯英文/非中文选项: ${bad.join(', ')}`)
}
})
}
/* ---------------- D:JS/TS 未汉化文案字段 ---------------- */
const LABEL_KEYS = '(label|title|subtitle|text|placeholder|desc|description|name|tip|notice)'
for (const file of walk(MINIAPP, (n) => /\.(js|ts)$/.test(n))) {
const src = readFileSync(file, 'utf8')
const lines = linesOf(src)
lines.forEach((line, i) => {
// 跳过注释行
if (/^\s*(\/\/|\*|\/\*)/.test(line)) return
const re = new RegExp(`${LABEL_KEYS}\\s*:\\s*(['"])([A-Za-z][A-Za-z0-9 _'-]*)\\1`, 'g')
for (const m of line.matchAll(re)) {
if (ASCII_ALLOW.has(m[3])) continue
add('error', file, i + 1, 'D-文案字段', `${m[1]}: '${m[3]}'`)
}
})
}
/* ---------------- E:繁体字 ---------------- */
for (const file of walk(MINIAPP, (n) => /\.(wxml|js|ts|json)$/.test(n))) {
const src = file.endsWith('.wxml') ? stripWxmlComments(readFileSync(file, 'utf8')) : readFileSync(file, 'utf8')
linesOf(src).forEach((line, i) => {
const hits = [...new Set([...line].filter((ch) => TRADITIONAL_CHARS.has(ch)))]
if (hits.length) add('error', file, i + 1, 'E-繁体字', hits.join(''))
})
}
/* ---------------- F:页面 .json navigationBarTitleText 英文 ---------------- */
for (const file of walk(join(MINIAPP, 'pages'), (n) => n.endsWith('.json'))) {
let json
try { json = JSON.parse(readFileSync(file, 'utf8')) } catch (_) { continue }
const t = json && json.navigationBarTitleText
if (typeof t === 'string' && t.trim() && /[A-Za-z]/.test(t) && !/[\u4e00-\u9fa5]/.test(t)) {
add('error', file, 1, 'F-窗口标题', `navigationBarTitleText="${t}"`)
}
}
/* ---------------- 输出 ---------------- */
if (outArg) {
writeFileSync(outArg.slice('--out='.length), JSON.stringify(findings, null, 2), 'utf8')
}
if (AS_JSON) {
console.log(JSON.stringify(findings, null, 2))
} else {
const byKind = {}
for (const f of findings) (byKind[f.kind] ||= []).push(f)
for (const [kind, list] of Object.entries(byKind)) {
console.log(`\n【${kind}】${list.length} 处`)
for (const f of list) console.log(` ${f.file}:${f.line} ${f.detail}`)
}
console.log(`\n合计命中:${findings.length}`)
}
process.exit(findings.length ? 1 : 0)
}