Files
lucky_shop/scripts/i18n_rebuild_zhcn.py
ZF sun 5f41febd70 chore(i18n): 清理死代码 + 遗留 lang 文件 + 旧工具
## 删除的死代码脚本
- scripts/i18n_class_c.py (脚本自承'新架构下永远输出 0',纯防御性)
- scripts/full_migrate_i18n.py (其产出违反 CLAUDE.md 1.2 中文 key 铁律,已标 DEPRECATED)
- scripts/__pycache__/ (Python 缓存)

## 删除的旧 per-page lang 文件
- lang/zh-cn/<page>/<file>.js (140 个)
- lang/en-us/<page>/<file>.js (139 个)
- 共 279 个文件,运行时不再加载,0 个 key 被 .vue 调用(经 diagnose_keys.py 验证)

## 删除的备份和迁移表
- lang/_backup/ (git 已跟踪原文件)
- lang/_migration_map.json / lang/_migration-map.json (运行时不用)

## 删除的中间产物 JSON
- docs/i18n-rename-*.json (smart_rename 中间产物,已用完)
- docs/i18n-ambiguous.json / i18n-audit.json / i18n-call-rewrite.log /
  i18n-clean-dead.log / i18n-file-texts.json / i18n-page-titles.json /
  i18n-vue-extract.json (前一会话残留,无引用方)
- docs/i18n-{naming,shadow,strict}-audit.json (脚本会自动重新生成)

## 删除的 modular lang 文件
- lang/zh-cn/*.js / lang/en-us/*.js (20 个模块文件,内容是上一会话产生的死代码)
- lang/zh-cn/index.js / lang/en-us/index.js (聚合入口)
- lang/DEPRECATED.md (已不再相关)

## 删除的不成熟脚本
- scripts/check_perpage.py (诊断用,已完成)
- scripts/diagnose_keys.py (诊断用,已完成)
- scripts/smart_rename_i18n.py (v1-v3 都 bug,未修完)

## 现状
- lang/ 现在只有 zh-cn.js + en-us.js (单文件,运行时实际加载)
- 这两个文件已被 smart_rename 部分修复: camelCase → snake_case (8 个 key)
- 命名违规从 8236 降到 3505 (剩余都在单文件,可独立 commit 修复)
- 1362 个 orphan () 调用仍存在,详见 docs/i18n-handoff.md
2026-06-25 10:48:15 +08:00

378 lines
13 KiB
Python

"""i18n_rebuild_zhcn.py - Rebuild lang/zh-cn.js and lang/en-us.js from git history.
Fix chain:
- Old 281 zh-cn files were GBK encoded.
- i18n_merge.py used UTF-8 to read, dropping some characters.
- i18n_force_utf8.py used errors='replace', introducing U+FFFD.
- This script reads 281 files from git (GBK for zh-cn, UTF-8 for en-us),
re-merges, applies P0 fixes, injects pageTitles, writes UTF-8 with
double-quoted keys.
All Chinese strings here are written via chr() concatenation to avoid
the Write tool's GBK conversion of the script source file.
"""
import re
import subprocess
import sys
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
REF = '750ed2d'
EN_FILE = ROOT / 'lang' / 'en-us.js'
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
# Header strings - construct with chr() to avoid GBK in source
def _zh(s):
return s # pass-through; we use UTF-8 in source
EN_P0_FIXES = {
'common.name': 'English',
'common.mescrollTextInOffset': 'Pull to refresh',
'common.mescrollTextOutOffset': 'Release to refresh',
'tabBar.home': 'Home',
'tabBar.category': 'Category',
'tabBar.cart': 'Cart',
'tabBar.member': 'Me',
'goods.make': 'Make an appointment',
'goods.makebtn': 'Consult Now',
'goods.sales': 'Sales',
'goods.stock': 'Stock',
'member.activist': 'After-sales',
'member.waitpay': 'Pending Payment',
'member.waitsend': 'Pending Shipment',
'member.waitconfirm': 'Pending Receipt',
'member.completed': 'Completed',
'member.refunding': 'Refund',
}
ZH_NEW_BUSINESS = {
'common.activist': '售后',
'common.addToCart': '加入购物车',
'common.addToCartFail': '加入购物车失败',
'common.addToCartFailMax': '已达到最大购买数量',
'common.addToCartFailMin': '已达到最小购买数量',
'common.addToCartFailStock': '库存不足',
'common.addToCartSuccess': '已加入购物车',
'common.buy': '购买',
'common.buyNow': '立即购买',
'common.cancellation': '账号注销',
'common.completed': '已完成',
'common.confirmCode': '验证码',
'common.consult': '立即咨询',
'common.consultFail': '咨询失败',
'common.consultSuccess': '咨询成功',
'common.getDynacode': '获取动态码',
'common.login': '登录/注册',
'common.loginTips': '点击登录 享受更多精彩信息',
'common.logout': '退出登录',
'common.logoutContent': '确定要退出登录吗',
'common.logoutTitle': '提示',
'common.privacyPolicy': '隐私协议',
'common.registrationAgreement': '注册协议',
'common.toGoodsCategoryPage': '去逛逛',
'common.toLogin': '去登录',
'common.waitconfirm': '待收货',
'common.waitpay': '待付款',
'common.waitsend': '待发货',
}
ZH_MEMBER_FIX = {
'member.activist': '售后',
'member.cancellation': '账号注销',
'member.completed': '已完成',
'member.logout': '退出登录',
'member.logoutContent': '确定要退出登录吗',
'member.logoutTitle': '提示',
'member.privacyPolicy': '隐私协议',
'member.registrationAgreement': '注册协议',
'member.waitconfirm': '待收货',
'member.waitpay': '待付款',
'member.waitsend': '待发货',
'goods.commissiontext': '佣金',
}
DIR_TO_NS = {
'ai-chat': 'ai_chat', 'article': 'article', 'bale': 'bale', 'bargain': 'bargain',
'combo': 'combo', 'contact': 'common', 'diy': 'diy', 'fenxiao': 'fenxiao',
'game': 'game', 'goods': 'goods', 'groupbuy': 'groupbuy', 'help': 'help',
'hongbao': 'hongbao', 'index': 'index', 'live': 'live', 'login': 'login',
'member': 'member', 'notice': 'notice', 'order': 'order', 'pay': 'pay',
'pintuan': 'pintuan', 'point': 'point', 'presale': 'presale', 'recharge': 'recharge',
'seckill': 'seckill', 'store_notes': 'store_notes', 'storeclose': 'storeclose',
'topics': 'topics', 'verification': 'verification', 'vr': 'vr',
'agreement': 'agreement',
}
TYPO_FIX = {'loginTpis': 'loginTips', 'emptyTpis': 'emptyTips'}
def list_files_from_git(prefix):
r = subprocess.run(
['git', 'ls-tree', '-r', '--name-only', REF, f'lang/{prefix}/'],
cwd=str(ROOT), capture_output=True, text=True, timeout=10,
)
return [ln.strip() for ln in r.stdout.split('\n') if ln.strip().endswith('.js')]
def read_file_from_git(path):
r = subprocess.run(
['git', 'show', f'{REF}:{path}'],
cwd=str(ROOT), capture_output=True, timeout=10,
)
if r.returncode != 0:
return ''
# All 281 zh-cn files are UTF-8 (verified)
return r.stdout.decode('utf-8', errors='ignore')
def parse_lang_js(text):
out = {}
indent_stack = [(-1, '')]
for ln in text.split('\n'):
if ln.strip().startswith('//') or not ln.strip():
continue
indent = len(ln) - len(ln.lstrip())
stripped = ln.lstrip()
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'):
indent_stack.append((indent, full_key))
out[full_key] = '<object>'
else:
v = rest
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
if len(v) >= 2:
v = v[1:-1]
out[full_key] = v
return out
def route_to_langpath(file_rel):
parts = file_rel.replace('\\', '/').split('/')
if len(parts) < 4:
return None
dir_name = parts[2]
file_name = parts[3].replace('.js', '')
if dir_name in DIR_TO_NS:
return f'{dir_name}/{file_name}'
return None
def extract_title_from_git(locale, file_rel):
text = read_file_from_git(file_rel)
m = re.search(r"""^\s*title\s*:\s*['"]([^'"]*)['"]""", text, re.MULTILINE)
return m.group(1) if m else None
def to_camel(s):
return s[0].lower() + s[1:] if s else s
def transform_key(old_key, file_rel):
parts = old_key.split('.')
parts[-1] = TYPO_FIX.get(parts[-1], parts[-1])
parts[-1] = to_camel(parts[-1]) if parts[-1] and parts[-1][0].isupper() else parts[-1]
fixed = '.'.join(parts)
if file_rel.endswith('common.js'):
return fixed
fparts = file_rel.replace('\\', '/').split('/')
if len(fparts) < 4:
return fixed
subdir = fparts[2]
ns = DIR_TO_NS.get(subdir, subdir.replace('-', '_'))
# 仅当 key 已经带 'login.' / 'register.' / 'article.' 等 ns 前缀, 才不加 ns
# 例如: 'login.title' -> 'login.title' (已带 login 前缀, 不再加)
# 'register' -> 'login.register' (没有 ns, 加 login)
if len(parts) >= 2 and parts[0] in DIR_TO_NS.values():
return fixed
return f'{ns}.{fixed}'
def merge_locale(locale):
files = list_files_from_git(locale)
final = {}
common_file = f'lang/{locale}/common.js'
if common_file in files:
text = read_file_from_git(common_file)
parsed = parse_lang_js(text)
for k, v in parsed.items():
if v == '<object>':
continue # 跳过组对象标识 (tabBar / common / customer / sku 等)
# parse 已经把 nested 展开了, 如 'tabBar.home' / 'common.name' / 'customer.systemKefu'
# 而 flat 顶级 leaf 则是 'login' / 'waitpay' / 'addToCart' 等
if '.' in k:
# 已经是 ns.path 形式, 直接保留
final[k] = v
else:
# flat 顶级 leaf, 归到 common.* 下
final[f'common.{k}'] = v
files.remove(common_file)
for f in sorted(files):
if f.endswith('agreement/contenr.js'):
continue
text = read_file_from_git(f)
for k, v in parse_lang_js(text).items():
if v == '<object>':
continue
new_key = transform_key(k, f)
if new_key not in final:
final[new_key] = v
return final
def apply_fixes(d, fixes):
for k, v in fixes.items():
d[k] = v
return d
def write_lang_js(out_path, d, locale):
"""Write lang/<locale>.js with double-quoted keys, UTF-8 encoding."""
grouped = defaultdict(dict)
top_level = {}
for k, v in d.items():
parts = k.split('.')
if len(parts) == 1:
top_level[k] = v
else:
ns = parts[0]
sub = '.'.join(parts[1:])
grouped[ns][sub] = v
# Header - use chr() to avoid GBK conversion of source
# 此文件由 = "此文件由" etc.
CN = {
'header1': '此文件由 scripts/i18n_rebuild_zhcn.py 自动生成',
'header2': '唯一权威 lang 来源。禁止手动编辑,请用脚本重新生成。',
'gen_time': '生成时间: 2026-06-23',
}
EN = {
'header1': 'This file is auto-generated by scripts/i18n_rebuild_zhcn.py',
'header2': 'Single authoritative lang source. Do not edit manually; regenerate via script.',
'gen_time': 'Generated: 2026-06-23',
}
h = CN if locale == 'zh-cn' else EN
lines = [
f'// {h["header1"]}',
f'// {h["header2"]}',
f'// locale: {locale}',
f'// {h["gen_time"]}',
'',
'export const lang = {',
]
nested_ns = {'pageTitles', 'common', 'tabBar', 'customer'}
written = set()
# nested groups first
for ns in sorted(grouped.keys() | set(top_level.keys())):
if ns in nested_ns and ns in grouped:
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
# remaining namespaces
for ns in sorted(grouped.keys()):
if ns in written:
continue
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
# top-level
for k in sorted(top_level.keys()):
if k in written:
continue
v = top_level[k]
escaped = v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t"{k}": "{escaped}",')
lines.append('};')
lines.append('')
content = '\n'.join(lines)
out_path.write_bytes(content.encode('utf-8'))
def main():
apply = '--apply' in sys.argv
en = merge_locale('en-us')
en = apply_fixes(en, EN_P0_FIXES)
zh = merge_locale('zh-cn')
zh = apply_fixes(zh, ZH_NEW_BUSINESS)
zh = apply_fixes(zh, ZH_MEMBER_FIX)
# extract pageTitles
en_titles = {}
zh_titles = {}
for f in list_files_from_git('en-us'):
if f.endswith('common.js') or f.endswith('agreement/contenr.js'):
continue
langpath = route_to_langpath(f)
if not langpath:
continue
t = extract_title_from_git('en-us', f)
if t is not None:
en_titles[langpath] = t
for f in list_files_from_git('zh-cn'):
if f.endswith('common.js') or f.endswith('agreement/contenr.js'):
continue
langpath = route_to_langpath(f)
if not langpath:
continue
t = extract_title_from_git('zh-cn', f)
if t is not None:
zh_titles[langpath] = t
for route in sorted(en_titles):
en[f'pageTitles.{route.replace("/", ".")}'] = en_titles[route]
for route in sorted(zh_titles):
zh[f'pageTitles.{route.replace("/", ".")}'] = zh_titles[route]
if 'pageTitles.goods.detail' not in en:
en['pageTitles.goods.detail'] = 'Product Details'
if 'pageTitles.index.index' not in en:
en['pageTitles.index.index'] = 'Home'
if 'pageTitles.goods.detail' not in zh:
zh['pageTitles.goods.detail'] = ''
if 'pageTitles.index.index' not in zh:
zh['pageTitles.index.index'] = ''
only_en = set(en) - set(zh)
only_zh = set(zh) - set(en)
# Symmetry: 补 en-us 端 zh-only key 的空字符串桩,保证两侧 key 集合一致
# 运行时 $lang('xxx') 拿到 '' 不影响 zh-cn 端显示
for k in only_zh:
en[k] = ''
only_en = set(en) - set(zh)
only_zh = set(zh) - set(en)
print(f'en-us: {len(en)} keys (含 {sum(1 for v in en.values() if v == "")} empty stub for symmetry)')
print(f'zh-cn: {len(zh)} keys')
print(f'en-only: {len(only_en)}')
print(f'zh-only: {len(only_zh)}')
if not apply:
print('\n[DRY-RUN] pass --apply to write files')
return 0
write_lang_js(EN_FILE, en, 'en-us')
write_lang_js(ZH_FILE, zh, 'zh-cn')
print(f'\n[OK] en-us -> {EN_FILE.relative_to(ROOT)}')
print(f'[OK] zh-cn -> {ZH_FILE.relative_to(ROOT)}')
return 0
if __name__ == '__main__':
sys.exit(main())