通用基础项目

    6. I18n完善与SEO

    会员专享 · 非会员仅可阅读 30% 的正文。

    发布时间
    May 17, 2025
    阅读时间
    5 min read
    作者
    Felix
    访问
    会员专享
    这是预览内容

    非会员仅可阅读 30% 的正文。

    在上章中,我们讲了一下I18n的一些配置。那么这一章,我们需要继续完善I18n的自动化脚本,以及完成I18n的SEO配置。

    自动化翻译的问题

    那我们先来讲讲问题吧。这一节的版本是 github tag v2.2.1

    full模式

    首先问题最大的是full模式,也许它不应该叫full,应该叫reset, 因为 missing 模式已经能够高效地处理增量翻译需求。保留 full 模式的唯一理由是在需要完全重置翻译时使用,这种情况应该很少见,且要避免使用,因为在后期这样做所需的上下文实在是太大了,并且有风险。

    但最现实的问题是AI输出这么多的全量内容,根本无法使用JSON.parse去转成Json,内容越多,要输出固定格式化的内容就越难,因此我直接把full模式去掉了,默认就是 missing模式。

    "i18n:translate": "tsx scripts/18n/cli.ts --mode=missing",
    "i18n:keys": "tsx scripts/18n/cli.ts --mode=keys --keys",
    "i18n:list": "tsx scripts/18n/cli.ts --list-locales"
    

    missing模式的token消耗

    在使用 AI 模型进行翻译时,每个请求都会消耗 token。Token 是文本的基本单位,通常一个单词会被分解为一个或多个 token。

    当前的翻译实现存在严重的 token 消耗问题,主要体现在:

    1. 重复的提示模板:对每种语言都使用相同的冗长提示
    2. 单语言单次请求:每种语言单独发送一个请求,无法共享上下文
    3. 多次 API 调用:每个语言一次 API 调用,增加了延迟和成本

    解法是使用所有语言缺失键的并集作为源文本进行翻译:

    1. 批量处理翻译请求:通过将所有语言的缺失键合并为一个集合,我们可以在一次API调用中请求多种语言的翻译,而不是为每种语言单独发送请求。

    2. 减少重复内容:当多种语言都缺少相同的键时,我们只需要将这些键的英文原文发送一次,而不是每种语言都重复发送。

    3. 共享上下文:AI模型可以在一次请求中理解所有需要翻译的内容,这有助于保持翻译的一致性,特别是对于相关术语。

    4. 降低API调用成本:减少API调用次数不仅降低了延迟,还显著减少了token消耗,因为我们不再需要为每种语言重复发送相同的提示模板。

    例如,假设我们有三种语言(中文、日语和韩语),每种语言都缺少5个相同的键。使用旧方法,我们需要发送3个独立请求,每个请求都包含完整的提示模板和5个键的内容。而使用新方法,我们只需发送1个请求,包含一次提示模板和5个键的内容,然后请求AI模型同时生成所有三种语言的翻译。

    那有没有更好的做法那?显然有的,就是添加一张key + 语言的对照表,一个key对应所有语言的翻译,每次以key跟en.json进行对照,始终以对照表为主,删除对照表的一行,就删除所有key的一行,对照表的一行中有任意一列为空,就重新以AI生成。现在我的主项目就是这么做的,前后端+本土的小伙伴一起去运营这张key+语言的对照表。但这套方案是太复杂了,不适合单人开发。

    优化后的翻译流程

    1. 收集所有目标语言的缺失键
    2. 计算所有缺失键的并集
    3. 从英文消息中提取这些键的值
    4. 构建一个包含所有缺失键和目标语言的单一请求
    5. 发送请求到AI模型,获取所有语言的翻译结果
    6. 解析结果并更新各语言的翻译文件

    这种方法不仅提高了效率,还大大降低了API调用的成本,特别是在处理多语言项目时。主要的核心改动代码:

     case 'missing':
            // 收集所有语言的缺失键
        for (const locale of localesToTranslate) {
          let existingTranslations = {}
          const localeFilePath = path.join(messagesDir, `${locale.code}.json`)
    
          try {
            const existingContent = await fs.readFile(localeFilePath, 'utf-8')
            existingTranslations = JSON.parse(existingContent)
          } catch (err) {
            console.log(`未找到 ${locale.code} 的现有翻译,将创建新文件。`)
          }
    
          const missingKeys = findMissingKeys(englishMessages, existingTranslations)
          if (missingKeys.length > 0) {
            missingKeysByLocale[locale.code] = missingKeys
          }
        }
    
        // 如果所有语言都没有缺失键,则提前返回
        if (Object.keys(missingKeysByLocale).length === 0) {
          return localesToTranslate.map((locale) => ({
            success: true,
            locale: locale.code,
            message: `${locale.name} 没有发现缺失的键`,
            translatedKeys: []
          }))
        }
    
        // 使用所有缺失键的并集作为源
        const allMissingKeys = [...new Set(Object.values(missingKeysByLocale).flat())]
        sourceToTranslate = extractKeys(englishMessages, allMissingKeys)
        break
    

    在当前的项目中,不要写大量的翻译文案后,再去跑pnpm i18n:translate,如果遇见了大量翻译不能保持JSON结构的情况,可以采用分批跑的方式。

    不需要翻译的Key

    显然我们还需要维护一个不需要翻译的Key列表,因为类似品牌名、公司信息等值其实是不需要翻译的,只需要把en的值填入到其他的json翻译文件中。

    上下文问题

    在做AI翻译的时候,有一个没办法避免的问题,那就是max tokens超限,这是一次响应中最多可以生成的token限制,大家其实如果经常问题AI问题,有时候也会遇到,而这种问题在面临长文本翻译的时候格外的明显。

    而解决方案是采用批处理的方式,每一批设置几个key,使用key模式,循环往下,直到翻译完成,大家可以执行pnpm i18n:sequential

    /**
     * 顺序翻译所有键
     * @param options 翻译选项
     */
    export async function sequentialTranslate(options: Omit<TranslationOptions, 'mode' | 'keys'> = {}): Promise<void> {
      try {
        // 读取英文消息文件(作为基准)
        const messagesDir = path.join(process.cwd(), 'messages')
        const englishMessagesPath = path.join(messagesDir, 'en.json')
        const englishMessagesText = await fs.readFile(englishMessagesPath, 'utf-8')
        const englishMessages = JSON.parse(englishMessagesText)
    
        // 确定要翻译的目标语言
        const { targetLocales } = options
        const localesToTranslate = targetLocales
          ? locales.filter((l) => targetLocales.includes(l.code) && l.code !== 'en')
          : locales.filter((l) => l.code !== 'en')
    
        if (localesToTranslate.length === 0) {
          console.log('没有找到要翻译的目标语言')
          return
        }
    
        // 提取英文文件中的所有键
        const allKeys = extractAllKeys(englishMessages)
        console.log(`英文文件中共有 ${allKeys.length} 个键`)
    
        let allMissingKeys: string[] = []
    
        console.log('开始检查各语言文件中缺失的键...')
        for (const locale of localesToTranslate) {
          const localeFilePath = path.join(messagesDir, `${locale.code}.json`)
    
          // 检查目标语言文件是否存在
          let existingTranslations = {}
          let fileExists = true
    
          try {
            const existingContent = await fs.readFile(localeFilePath, 'utf-8')
            try {
              existingTranslations = JSON.parse(existingContent)
            } catch (parseErr) {
              console.log(`⚠️ ${locale.code} 文件解析失败,将视为空文件`)
              fileExists = false
            }
          } catch (err) {
            console.log(`⚠️ 未找到 ${locale.code} 的现有翻译文件,将创建新文件`)
            fileExists = false
          }
    
          // 确定缺失的键
          let missingKeys: string[] = []
    
          if (!fileExists || Object.keys(existingTranslations).length === 0) {
            // 如果文件不存在或为空,则所有键都是缺失的
            missingKeys = [...allKeys]
            console.log(`📝 ${locale.code}: 需要翻译所有 ${missingKeys.length} 个键`)
          } else {
            // 递归查找缺失的键
            missingKeys = findMissingOrEmptyKeys(englishMessages, existingTranslations)
            if (missingKeys.length > 0) {
              console.log(`📝 ${locale.code}: 需要翻译 ${missingKeys.length} 个键`)
            } else {
              console.log(`✅ ${locale.code}: 已包含所有键,无需翻译`)
            }
          }
    
          // 记录这个语言的缺失键
          if (missingKeys.length > 0) {
            allMissingKeys = [...new Set([...allMissingKeys, ...missingKeys])]
          }
        }
    
        // 如果没有缺失的键,提前结束
        if (allMissingKeys.length === 0) {
          console.log('✨ 所有语言文件都已包含所有键,无需翻译')
          return
        }
    
        console.log(`\n总共发现 ${allMissingKeys.length} 个不同的键需要翻译`)
    
        // 设置批次大小和分批
        const batchSize = 5 // 每批处理一个键,可以根据需要调整
        const batches = []
    
        // 将键分成批次
        for (let i = 0; i < allMissingKeys.length; i += batchSize) {
          batches.push(allMissingKeys.slice(i, i + batchSize))
        }
    
        console.log(`将分 ${batches.length} 批进行翻译\n`)
    
        // 顺序翻译每个批次
        let successCount = 0
        let failureCount = 0
        let skippedCount = 0
    
        for (let i = 0; i < batches.length; i++) {
          const batch = batches[i]
          console.log(`🔄 开始翻译批次 ${i + 1}/${batches.length},包含键: ${batch.join(', ')}`)
    
          const translationOptions: TranslationOptions = {
            mode: 'keys',
            keys: batch,
            ...options
          }
    
          try {
            const results = await translateMessages(translationOptions)
    
            // 处理结果
            for (const result of results) {
              if (result.success) {
                if (result.translatedKeys && result.translatedKeys.length > 0) {
                  console.log(`✅ ${result.locale}: ${result.message}`)
                  successCount += result.translatedKeys.length
                } else if (result.message?.includes('没有需要翻译的内容')) {
                  console.log(`ℹ️ ${result.locale}: ${result.message}`)
                  skippedCount += batch.length
                }
              } else {
                console.log(`❌ ${result.locale}: ${result.error}`)
                failureCount += batch.length
              }
            }
          } catch (error) {
            console.error(`❌ 批次 ${i + 1} 翻译失败:`, error)
            failureCount += batch.length
          }
        }
    
        console.log('\n✨ 翻译完成!')
        console.log('====================')
        console.log(`📊 总计键数: ${allKeys.length}`)
        console.log(`✅ 成功翻译: ${successCount}`)
        console.log(`⏭️ 跳过翻译: ${skippedCount}`)
        console.log(`❌ 失败翻译: ${failureCount}`)
      } catch (error) {
        console.error('❌ 顺序翻译过程失败:', error)
        throw error
      }
    }
    
    会员专享

    订阅后解锁完整文章

    支持创作、解锁全文,未来更新也会第一时间送达。

    评论

    加入讨论

    0 条评论
    登录后评论

    还没有评论,来占个沙发吧。