首页 › tutorials › 造i18n提取Skill:硬编码文案一键抽离

从零造一个国际化文案提取 Skill:硬编码字符串秒变多语言资源

概述

本教程带你在 60 分钟内从零构建一个名为 i18n-extract 的 Claude Code Skill。它能自动扫描前端源码里硬编码的用户可见文案(中文、长英文短语、模板字符串里的插值),输出 i18n 键值草稿和一份逐文件、逐行号的改造计划。过程中你会吃透 Skill 的全部核心机制:SKILL.md 前置元数据(frontmatter)、渐进式披露(progressive disclosure)、动态上下文注入(! 反引号命令)、命名参数与 ${CLAUDE_SKILL_DIR} 路径替换、以及 paths 文件触发器。适合已装好 Claude Code、能读一点 Python/JavaScript、想把自己重复粘贴的流程沉淀成可复用工具的开发者。

前置条件

一、先想清楚:什么场景该做成 Skill

Skill 的本质是"按需加载的指令包"。官方文档给了一句很好的判断标准:当你反复把同一段指令、清单或多步流程粘贴进对话,或者 CLAUDE.md 的某一段从"事实陈述"长成了"操作规程"时,就该把它抽成 Skill。 与 CLAUDE.md 不同,Skill 的正文只在被触发时才进入上下文——那些动辄上千行的参考资料平时几乎零成本,用到才付费。

国际化文案提取正好命中这个标准:每次接手一个没做 i18n 的老前端项目,你都要重复"找中文 → 想 key → 生成 zh/en JSON → 列改造点"这套动作。把它固化成 Skill 后,输入 /i18n-extract src 即可,还能让 Claude 在你提到"国际化""多语言"时自动触发。

在动手前,先用三句话界定 Skill 的边界,这三句会直接决定 description 怎么写:

边界清晰,description 就不会写大、不会乱触发。

二、Skill 的目录结构:渐进式披露怎么落地

Claude Code 的 Skill 遵循"渐进式披露"原则:SKILL.md 只放概览和导航,详细的参考资料拆到同目录下的其他文件,用到才读。官方建议 SKILL.md 控制在 500 行以内。我们的 Skill 结构如下:

i18n-extract/
├── SKILL.md              # 入口:元数据 + 注入扫描结果 + 任务指令
├── scripts/
│   └── extract.py        # 文案扫描脚本(被注入执行,不进上下文)
└── reference.md          # key 命名规范、各 i18n 库接入要点(按需读)

三个文件分工明确:SKILL.md 是"调度中心",extract.py 是"确定性工具"(正则扫描这种确定的事交给代码,不交给模型,省 token 又稳),reference.md 是"详尽字典"(只在需要查 i18next 还是 vue-i18n 的接入差异时读)。这正是渐进式披露的三层:入口常驻、工具执行、参考按需。

存放位置决定可见范围。本教程做成个人级 Skill,放在 ~/.claude/skills/,所有项目都能用;若只想给当前项目用,就放项目根的 .claude/skills/。同一名字在多级同时存在时,优先级是:企业级 > 个人级 > 项目级,且都能覆盖同名内置 Skill。

三、第 1 步:创建目录骨架

mkdir -p ~/.claude/skills/i18n-extract/scripts
cd ~/.claude/skills/i18n-extract

执行后得到空骨架。Claude Code 支持热加载:在本会话里新增、修改、删除 ~/.claude/skills/ 下的 Skill 都立即生效,无需重启(注意只针对 SKILL.md 文本;若 Skill 同时是 plugin,改了 hooks/、.mcp.json 等需要 /reload-plugins)。

四、第 2 步:写扫描脚本 scripts/extract.py

确定性逻辑全部交给脚本。下面这份脚本零依赖,用正则启发式抽候选文案,输出 JSON:

#!/usr/bin/env python3
"""extract.py — 扫描前端源码里硬编码的用户可见文案。
用法: python3 extract.py <文件或目录> [--ext js,ts,jsx,tsx,vue]
输出: JSON 数组,每项含 file / line / text / key
"""
import argparse, json, os, re, sys

CJK = re.compile(r'[\u4e00-\u9fff]')
STR       = re.compile(r"""(['"])((?:\\.|(?!\1).)*)\1""")
TEMPLATE  = re.compile(r'`((?:\\.|[^`])*)`')
JSX_TEXT  = re.compile(r'>([^<>{}]+)<')
NOISE = re.compile(
    r'\b(import|from|require|console|http|url|src|href|className|key|type|name)\s*[:=]',
    re.I)

def keyify(text: str):
    s = re.sub(r'[^\w\u4e00-\u9fff]+', '_', text).strip('_').lower()
    parts = [p for p in parts if p][:6] if False else [p for p in s.split('_') if p][:6]
    return parts[0] + ''.join(p.capitalize() for p in parts[1:]) if parts else None

def looks_user_facing(text: str) -> bool:
    if len(text) < 2:
        return False
    if CJK.search(text):
        return True
    # 长英文短语(≥2 个空格分隔的词、含字母)也视为候选文案
    words = [w for w in text.split() if re.search(r'[A-Za-z]', w)]
    return len(words) >= 2 and len(text) >= 6

def scan_file(path, out):
    try:
        lines = open(path, encoding='utf-8').read().splitlines()
    except Exception:
        return
    for i, line in enumerate(lines, 1):
        if NOISE.search(line):
            continue
        for rx in (STR, TEMPLATE, JSX_TEXT):
            grp = 2 if rx is not JSX_TEXT else 1
            for m in rx.finditer(line):
                text = m.group(grp).strip()
                if looks_user_facing(text):
                    k = keyify(text)
                    if k:
                        out.append({"file": path, "line": i, "text": text, "key": k})

def walk(root, exts):
    for dp, dn, fn in os.walk(root):
        dn[:] = [d for d in dn if d not in {'node_modules', 'dist', 'build', '.git'}]
        for f in fn:
            if any(f.endswith('.' + e) for e in exts):
                yield os.path.join(dp, f)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('target')
    ap.add_argument('--ext', default='js,ts,jsx,tsx,vue')
    a = ap.parse_args()
    exts = [e.strip() for e in a.ext.split(',')]
    found = []
    if os.path.isdir(a.target):
        for f in walk(a.target, exts):
            scan_file(f, found)
    else:
        scan_file(a.target, found)
    seen, uniq = set(), []
    for f in found:                       # 同文案去重
        if f['text'] in seen:
            continue
        seen.add(f['text']); uniq.append(f)
    print(json.dumps(uniq, ensure_ascii=False, indent=2))

if __name__ == '__main__':
    main()

先脱离 Claude 单独测脚本,确保确定性部分靠谱:

# 准备一个最小样本
mkdir -p /tmp/demo && cat > /tmp/demo/Login.jsx <<'EOF'
import React from 'react';
export const Login = ({user}) => (
  <div className="card">
    <h1>欢迎回来</h1>
    <p>请输入您的账号信息,{user.name}</p>
    <button>登录系统</button>
    <button>忘记密码?</button>
  </div>
);
EOF

python3 ~/.claude/skills/i18n-extract/scripts/extract.py /tmp/demo

预期输出(顺序可能不同):

[
  { "file": "/tmp/demo/Login.jsx", "line": 4, "text": "欢迎回来", "key": "欢迎回来" },
  { "file": "/tmp/demo/Login.jsx", "line": 5, "text": "请输入您的账号信息,{user.name}", "key": "请输入您的账号信息" },
  { "file": "/tmp/demo/Login.jsx", "line": 6, "text": "登录系统", "key": "登录系统" },
  { "file": "/tmp/demo/Login.jsx", "line": 7, "text": "忘记密码?", "key": "忘记密码" }
]

注意 keyify 对中文不会驼峰化(只对英文词分段),这正是把"设计 key"留给模型的原因——脚本只负责"找候选",模型负责"想 key 名"。常见问题:

五、第 3 步:写 SKILL.md——元数据 + 注入 + 指令

这是整个 Skill 的大脑。把下面内容存为 ~/.claude/skills/i18n-extract/SKILL.md:

---
name: i18n-extract
description: 提取前端源码中硬编码的用户可见文案并生成国际化键值与改造计划。当用户提到"国际化""i18n""多语言""提取文案""hardcoded string""把中文抽成 key""vue-i18n/i18next 接入"时使用。
when_to_use: 用户想把 JSX/TSX/Vue/模板里的中文字符串或长英文短语抽成 t() 调用和翻译资源文件时触发。
paths: "**/*.{js,ts,jsx,tsx,vue}"
argument-hint: "[扫描目录或文件]"
arguments: [target]
allowed-tools: Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/extract.py *) Read Edit Glob Grep
---

## 当前扫描结果

!`python3 ${CLAUDE_SKILL_DIR}/scripts/extract.py "$target" 2>/dev/null || echo '[]'`

## 任务

你是国际化重构专家。基于上面的 JSON 扫描结果:

1. **校验去误报**:逐条审阅 text,剔除控制台日志、调试文本、纯技术标识。保留真正面向用户的文案。
2. **设计 key**:按页面/模块分组,驼峰命名(如 `login.welcomeTitle`、`login.enterAccountInfo`)。脚本给的 `key` 仅供启发。
3. **产出资源文件**:生成 `src/locales/zh-CN.json` 与 `src/locales/en-US.json` 草稿——中文值用原文,英文值先给占位 `{TODO}`。
4. **改造清单**:列出需把字面量替换为 `t('key')` 的 `文件:行号 → key` 映射,并标注项目使用的 i18n 库。

若扫描结果为空,说明未发现候选文案,建议用户检查 `--ext` 后缀或目标目录。

## 规则

- 模板插值(如 `${name}`、`{user.name}`)保留为占位:`欢迎,{name}`,资源文件用对应库的占位语法。
- 重复文案只建一个 key,全项目复用。
- 多词纯英文短语视为待翻译文案;单字/单词技术词(`isLoading`、`button`)视为代码标识,跳过。
- 用 Glob/Grep 确认项目 i18n 库(查 `package.json` 是否含 i18next/vue-i18n/@formatjs),资源目录以现状为准。
- 详细 key 命名规范与各库接入差异见 [reference.md](reference.md),需要时再读。

这里每一行 frontmatter 都有讲究:

字段 作用 在本 Skill 中的意义
name Skill 列表里的显示名 命令名实际取自目录名 i18n-extract,name 只影响显示
description + when_to_use 模型判断"何时自动触发"的依据 两者合计在列表里被截断到 1536 字符,把核心触发词前置
paths 仅当编辑匹配文件时才自动加载 限定在前端源码,避免在写后端时乱触发
arguments / argument-hint 声明命名参数、自动补全提示 [target] 映射到正文里的 $target
allowed-tools 激活期间免确认可用工具 把脚本路径写成精确的权限规则,减少放行弹窗

正文里最关键的两处:

  1. **!python3 ${CLAUDE_SKILL_DIR}/scripts/extract.py "$target"`** ——这是**动态上下文注入**。Claude Code 在把 Skill 内容交给模型之前,先执行该命令,把输出原位替换进去。于是模型看到的不是"请扫描代码",而是"扫描结果已经在这里:[JSON]"。这把"让模型自己找文案"的不确定性,降成了"给模型现成数据让它加工"的确定性。${CLAUDE_SKILL_DIR}始终指向SKILL.md` 所在目录,保证脚本路径不依赖当前工作目录。
  2. $target ——命名参数替换。声明 arguments: [target] 后,第 1 个位置参数会填入 $target。你也可以用 $ARGUMENTS(全部)、$0/$1(按索引)等。

六、第 4 步:补充 reference.md(可选的第三层)

把"详尽但不常用"的内容放进来,体现渐进式披露。例如:

# i18n key 命名规范与库接入要点

## 命名
- 小驼峰,按 域.页面.用途 分层:`auth.login.rememberMe`、`cart.empty.hint`。
- 动词/状态用动词原形:`save`、`saving`、`saved`。
- 含插值统一用单大括号占位:`共 {count} 条`。

## 库差异速查
- i18next:`t('key', { count })`,占位 `{{count}}`,资源挂在 `i18n.init({ resources })`。
- vue-i18n:模板里 `{{ $t('key') }}`,组合式 `const { t } = useI18n()`。
- @formatjs (react-intl):`<FormattedMessage id="key" values={{count}}/>`,占位 `{count}`,需提取消息编译。

SKILL.md 末尾的链接 见 [reference.md](reference.md) 告诉模型"这份资料存在、需要时再读",平时不占上下文。

七、第 5 步:在交互会话里测试

claude

进入会话后有两种触发方式。手动触发(最可靠):

/i18n-extract /tmp/demo

自动触发——把一份含中文的前端文件喂给它,然后说:

帮我把 src/components 里的硬编码中文抽成 i18n

模型读到 description/paths 匹配,会自动加载本 Skill。预期行为:模型先执行注入的扫描脚本,拿到 JSON,再按任务清单产出资源文件草稿和改造清单。

调试技巧:若想确认脚本注入是否生效,临时在 ! 命令后加一行 ## 扫描输出已注入 观察模型是否引用了 JSON 内容。

八、第 6 步:收敛触发——让 Skill 不该出现时绝不出现

新 Skill 常见两个毛病:不触发和乱触发。本 Skill 用了三个手段收敛:

  1. paths: "**/*.{js,ts,jsx,tsx,vue}" ——只在编辑前端文件时才有资格自动加载。
  2. description 里同时写"做什么"和"何时用"(trigger 词),把核心用例放最前。
  3. 若你只想要手动 /i18n-extract、不让模型自动触发,加一行 disable-model-invocation: true。反之,若是纯背景知识、不希望出现在 / 菜单,用 user-invocable: false。
需求 配置
只手动触发 disable-model-invocation: true
只模型自动触发、藏起菜单 user-invocable: false
限定文件类型自动触发 paths: "**/*.{ts,tsx}"
跑重活怕跑偏 allowed-tools 收紧 + context: fork 丢进子代理

九、进阶:让 Skill 在子代理里跑、并自动归档

当目录很大、扫描 + 生成耗时较长时,可以把整件事丢给一个 fork 子代理,主会话不被长输出淹没,结束后只回传精简结果。在 frontmatter 加:

context: fork
agent: Explore

context: fork 让 Skill 在独立上下文里执行;agent 指定子代理类型(这里用只读探索型 Explore,因为它能 Glob/Grep 但不写文件——生成 JSON 草稿时再换回默认代理)。配合 ${CLAUDE_SESSION_ID},还能把每次扫描结果归档到日志:

!`mkdir -p logs && python3 ${CLAUDE_SKILL_DIR}/scripts/extract.py "$target" | tee logs/i18n-${CLAUDE_SESSION_ID}.json`

${CLAUDE_SESSION_ID} 是当前会话 ID,${CLAUDE_EFFORT} 是当前 effort 档位(low/medium/high/xhigh/max,可在正文里据此调整指令详尽度)。

十、实战案例:给一个真实组件库做 i18n 接入

假设有个 React 组件库 my-ui,结构如下:

my-ui/
├ package.json          # 含 react、未含 i18next
└ src/
  ├ Button.tsx          # <button>提交</button>
  ├ Modal.tsx           # title="确认删除?"  confirmText="确定"
  └ pages/Order.tsx     # 共 {total} 笔订单

操作流程:

  1. cd my-ui && claude
  2. /i18n-extract src
  3. 模型注入扫描 → 得到约 3~5 条候选 → 校验(剔除 type="submit" 这类属性值)→ 设计 key:common.submit、order.deleteConfirm、order.confirm、order.totalCount。
  4. 模型用 Glob 读 package.json 发现未装 i18n 库 → 提示"建议先 npm i i18next react-i18next",并给出 src/locales/zh-CN.json、en-US.json 草稿。
  5. 模型给出改造清单:src/Button.tsx:12 → common.submit、src/pages/Order.tsx:8 → order.totalCount……
  6. 你确认后,让模型用 Edit 逐处替换为 {t('common.submit')} 并补 useTranslation()。

整套流程从手动翻找的半小时,压到一两分钟。关键是扫描确定性、加工智能化——这正是带脚本的 Skill 比纯提示词 Skill 强的地方。

工作流程图

flowchart TD
    A["用户: /i18n-extract src"] --> B["Claude Code 匹配 Skill"]
    B --> C["读取 SKILL.md frontmatter"]
    C --> D{"满足 paths 触发条件?"}
    D -- 否 --> Z["不自动加载"]
    D -- 是 --> E["解析参数 \$target"]
    E --> F["动态注入: 执行 extract.py"]
    F --> G["脚本输出 JSON 原位替换进正文"]
    G --> H["模型读到的已是现成扫描结果"]
    H --> I["模型校验去误报"]
    I --> J["设计 i18n key + 生成资源文件草稿"]
    J --> K["产出 文件:行号 改造清单"]
    K --> L{"用户确认改造?"}
    L -- 是 --> M["Edit 逐处替换为 t() 调用"]
    L -- 否 --> N["结束, 输出草稿"]
    M --> O["完成"]

    subgraph 渐进式披露三层
      S1["SKILL.md 入口<br/>常驻"]
      S2["extract.py 脚本<br/>执行, 不进上下文"]
      S3["reference.md 详尽规范<br/>按需读"]
    end

延伸阅读

训练营适用性评估

参考来源

  1. Extend Claude with skills — Claude Code 官方文档 — Skill 的 frontmatter 字段、目录结构、动态上下文注入 ! 语法、字符串替换 ${CLAUDE_SKILL_DIR}、context: fork 子代理执行的权威说明。
  2. Skill authoring best practices — Claude Platform Docs — name(≤64 字符)、description 写法、description+when_to_use 合计 1536 字符截断规则、正文精简与 CLAUDE.md 的一致性原则。
  3. Claude Code Skills 2026 — Progressive Disclosure(anhtu.dev) — 工程团队如何用三层渐进式披露(入口/工具/参考)标准化工作流的实践拆解。
  4. Build a Claude Skill: YAML Frontmatter, SKILL.md Instructions(sjramblings.io) — 手把手构建 Skill 的实战教程,覆盖前缀触发词与目录布局。
  5. Writing SKILL.md — DeepWiki (spences10/claude-skills-cli) — SKILL.md 正文组织、支撑文件引用与按需加载的工程化细节。

本文由「51学通信」原创整理,站长爱卫生(微信:gprshome201101)长期分享通信与 AI 工程化实战,欢迎交流。

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入