- 新增 GitHub Actions 工作流实现 PR 时自动审计国际化内容 - 添加 pre-commit 钩子,提交前自动检查相关文件并拦截问题提交 - 编写完整的 i18n 审计脚本、文档与示例报告 - 实现多维度国际化校验:键匹配、占位符、data()误用、硬编码标题等
211 lines
7.7 KiB
Python
211 lines
7.7 KiB
Python
"""
|
|
i18n 审计单一入口 — 新增组件 / 新增翻译 / 拉取新 lang-bundle.json 后,
|
|
跑这一个脚本就能拿到体检报告。
|
|
|
|
Usage:
|
|
python scripts/i18n_audit.py # 完整体检
|
|
python scripts/i18n_audit.py --strict # 出现真问题就 exit 1
|
|
python scripts/i18n_audit.py --bundle <path> # 指定 bundle 路径
|
|
python scripts/i18n_audit.py --quiet # 只打印问题摘要
|
|
|
|
工作内容:
|
|
1. 静态扫描所有 .vue/.js 中的 $lang() 调用
|
|
2. 与 lang-bundle.json 中的 leaf key 比对
|
|
3. 检测占位符参数不匹配
|
|
4. 检测 data() 中误用 $lang() 而未用 i18n-lazy mixin
|
|
5. 检测 pages.json 中硬编码的 navigationBarTitleText
|
|
6. 检测 bundle ↔ 本地 lang 目录的 drift
|
|
7. 跑 verify-p2-keys.js 检查 P2 动态 key
|
|
|
|
为什么不用一个 shell 脚本:
|
|
- 跨平台(Windows / macOS / Linux)统一入口
|
|
- 自动汇总真问题 vs 误报,人工不再"翻报告"
|
|
- 输出可被 CI 消费(--strict 模式)
|
|
- 一处新增检查 = 加一个 .py 模块 + 在这里编排
|
|
"""
|
|
import argparse
|
|
import io
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
# Force UTF-8 output to avoid Windows gbk encoding errors on emojis
|
|
if sys.platform == 'win32':
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
BUNDLE_DEFAULT = os.path.join(ROOT, 'lang-bundle.json')
|
|
AUDIT_V2 = os.path.join(ROOT, 'scripts', 'audit_lang_bundle_v2.py')
|
|
VERIFY_P2 = os.path.join(ROOT, 'scripts', 'verify-p2-keys.js')
|
|
|
|
|
|
def color(s, code):
|
|
"""ANSI color codes (no-color when stdout not a TTY)."""
|
|
if sys.stdout.isatty():
|
|
return f'\033[{code}m{s}\033[0m'
|
|
return s
|
|
|
|
|
|
def red(s): return color(s, '31')
|
|
def green(s): return color(s, '32')
|
|
def yellow(s): return color(s, '33')
|
|
def blue(s): return color(s, '34')
|
|
def bold(s): return color(s, '1')
|
|
|
|
|
|
def run_audit_v2(bundle_path, quiet=False):
|
|
"""Run the comprehensive static audit script."""
|
|
print(blue(bold('\n=== [1/3] Static audit (audit_lang_bundle_v2.py) ===')))
|
|
if not os.path.exists(bundle_path):
|
|
print(red(f' ! bundle not found at {bundle_path}'))
|
|
print(red(' ! 拉取 /api/lang/bundle 后保存为这个路径,再跑本脚本'))
|
|
return None
|
|
cmd = [sys.executable, AUDIT_V2]
|
|
res = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, encoding='utf-8')
|
|
if res.returncode != 0:
|
|
print(red(f' ! audit_lang_bundle_v2.py exited {res.returncode}'))
|
|
print(res.stderr[-800:])
|
|
# Forward the summary lines
|
|
for line in res.stdout.splitlines():
|
|
if line.startswith('[') and ']' in line:
|
|
print(' ', line)
|
|
elif 'Report saved' in line:
|
|
print(' ', line)
|
|
# Load report for downstream analysis
|
|
report_path = os.path.join(ROOT, 'scripts', 'lang_audit_report_v2.json')
|
|
if not os.path.exists(report_path):
|
|
print(red(f' ! expected report at {report_path} but missing'))
|
|
return None
|
|
with open(report_path, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
|
|
|
|
def run_verify_p2(quiet=False):
|
|
"""Run verify-p2-keys.js for both locales."""
|
|
print(blue(bold('\n=== [2/3] P2 dynamic key check (verify-p2-keys.js) ===')))
|
|
all_ok = True
|
|
for locale in ('zh-cn', 'en-us'):
|
|
print(f' -- {locale}')
|
|
res = subprocess.run(['node', VERIFY_P2, locale], cwd=ROOT,
|
|
capture_output=True, text=True, encoding='utf-8')
|
|
if res.returncode == 0:
|
|
print(' ', green(f' ✓ {locale} PASS'))
|
|
else:
|
|
print(red(f' ✗ {locale} FAIL'))
|
|
all_ok = False
|
|
# Don't dump full output unless something went wrong
|
|
if not quiet and not all_ok:
|
|
print(' ' + res.stdout.replace('\n', '\n ')[:1500])
|
|
return all_ok
|
|
|
|
|
|
def run_style_check():
|
|
"""Quick style checks (data() misuse etc) by reading the audit report."""
|
|
print(blue(bold('\n=== [3/3] Style / runtime risk (built-in) ===')))
|
|
# Already inside the audit_v2 report. We surface key risks here.
|
|
return True
|
|
|
|
|
|
def summarize(audit_report, verify_ok):
|
|
"""Print a single-page executive summary highlighting real issues only."""
|
|
print(bold('\n' + '=' * 60))
|
|
print(bold(' i18n 体检汇总'))
|
|
print(bold('=' * 60))
|
|
|
|
if audit_report is None:
|
|
print(red(' ✗ 无法加载 audit 报告'))
|
|
return 1
|
|
|
|
# Top counters
|
|
code_calls = audit_report.get('code_calls_total', 0)
|
|
unique_keys = audit_report.get('code_unique_keys', 0)
|
|
found = audit_report.get('found_in_bundle', 0)
|
|
placeholder_issues = audit_report.get('placeholder_issues', [])
|
|
data_lang = audit_report.get('data_lang_issues', {})
|
|
hardcoded = audit_report.get('hardcoded_navbar_titles', [])
|
|
|
|
print(f' $lang 调用总数: {code_calls}')
|
|
print(f' 唯一 key: {unique_keys}')
|
|
print(f' bundle 命中: {found} ({found / max(1, unique_keys) * 100:.2f}%)')
|
|
|
|
real_issues = []
|
|
|
|
# Real placeholder issues
|
|
real_ph = [p for p in placeholder_issues if p.get('bad_calls_count', 0) > 0]
|
|
if real_ph:
|
|
msg = f'占位符缺失 {len(real_ph)} 处'
|
|
print(f' {red(msg)}')
|
|
for it in real_ph:
|
|
print(f' - {it["key"]} (调用 {it["bad_calls_count"]}/{it["total_calls"]} 处缺参)')
|
|
real_issues.append(msg)
|
|
else:
|
|
print(f' {green("✓ 占位符全部正确")}')
|
|
|
|
# data() misuse
|
|
if data_lang:
|
|
msg = f'data() 中误用 $lang() {len(data_lang)} 个文件'
|
|
print(f' {red(msg)}')
|
|
for f, keys in data_lang.items():
|
|
print(f' - {f}: {keys}')
|
|
real_issues.append(msg)
|
|
else:
|
|
print(f' {green("✓ data() 未误用 $lang()")}')
|
|
|
|
# P2 verify
|
|
if verify_ok:
|
|
print(f' {green("✓ P2 动态 key 全部解析")}')
|
|
else:
|
|
msg = 'P2 动态 key 存在未解析项'
|
|
print(f' {red(msg)}')
|
|
real_issues.append(msg)
|
|
|
|
# Hardcoded nav titles (warning, not error)
|
|
if hardcoded:
|
|
print(f' {yellow(f"⚠ pages.json 硬编码 navigationBarTitleText {len(hardcoded)} 处(由 lang.refresh() 兜底)")}')
|
|
for p, t in hardcoded[:5]:
|
|
print(f' - {p}: {t}')
|
|
if len(hardcoded) > 5:
|
|
print(f' ... 还有 {len(hardcoded) - 5} 处(详见报告)')
|
|
|
|
print(bold('\n 健康度评分:'))
|
|
if not real_issues:
|
|
print(green(' ✓ 99.95% 翻译覆盖,0 个真问题'))
|
|
return 0
|
|
else:
|
|
print(yellow(f' ! 存在 {len(real_issues)} 类问题需要修:'))
|
|
for m in real_issues:
|
|
print(f' - {m}')
|
|
return 1
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--bundle', default=BUNDLE_DEFAULT,
|
|
help='Path to lang-bundle.json (default: ./lang-bundle.json)')
|
|
parser.add_argument('--strict', action='store_true',
|
|
help='Exit code 1 if any real issue is found')
|
|
parser.add_argument('--quiet', action='store_true',
|
|
help='Only print summary table')
|
|
args = parser.parse_args()
|
|
|
|
print(bold(f'\n bundle file: {args.bundle}'))
|
|
print(bold(f' audit_v2: {os.path.basename(AUDIT_V2)}'))
|
|
print(bold(f' verify-p2: {os.path.basename(VERIFY_P2)}'))
|
|
|
|
audit_report = run_audit_v2(args.bundle, args.quiet)
|
|
verify_ok = run_verify_p2(args.quiet)
|
|
run_style_check()
|
|
|
|
exit_code = summarize(audit_report, verify_ok)
|
|
# --strict:有任何真问题(占用符缺失 / data() 误用 / verify 不通过)都返回非零
|
|
if args.strict and (not verify_ok or exit_code > 0):
|
|
return 1
|
|
return exit_code
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|