Files
lucky_shop/scripts/i18n_audit.py
ZF sun f37baad045 feat(i18n): add full i18n audit toolchain and pre-commit hook
- 新增 GitHub Actions 工作流实现 PR 时自动审计国际化内容
- 添加 pre-commit 钩子,提交前自动检查相关文件并拦截问题提交
- 编写完整的 i18n 审计脚本、文档与示例报告
- 实现多维度国际化校验:键匹配、占位符、data()误用、硬编码标题等
2026-07-04 09:57:43 +08:00

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())