返回博客

我如何用AI翻译这篇博客

2025-08-21阅读 10 分钟

我是一个以西班牙语为母语的人。我住在美国,所以每天都使用英语。我用英语撰写我的文章是为了练习和接触更多的人。

Google Analytics 向我展示了一些重要的信息。大约 30-40% 的访问者来自英语不是主要语言的国家/地区。这告诉我我错失了机会。所以我决定使用 AI 来翻译博客。

我的设置

  • 我如何存储文章:每篇博文都是一个简单的文件,包含标题、日期、内容和其他详细信息。可以把它想象成一张数字食谱卡。
  • 我的网站框架:我使用 Next.js 以及可以自动处理多种语言的国际化工具。
  • 我使用的 AI谷歌的 Gemini AI 负责实际的翻译工作。我会给出严格的指令,只翻译文本内容,而保留所有技术部分(如链接和代码)。
  • 一个命令搞定一切:我运行一个简单的脚本,将我的英文文章发送给谷歌的 Gemini AI,它会同时将其翻译成所有 8 种语言。然后,脚本将这些翻译整理成单独的文件,更新所有跨语言链接,以便读者可以无缝地在语言之间切换,并仔细检查每种语言的版本是否存在且完整。
  • 语言切换:当读者点击更改语言时,他们会自动跳转到文章的正确版本。

重要提示:该脚本称为 translate-gemini.ts 。这是一个通用的翻译脚本,可以翻译成任何语言。它使用 TARGET_LOCALE 环境变量来确定要翻译成哪种语言。因此,你只需要这个脚本,不需要为每种语言准备单独的脚本。

我的工作流程

  1. 我用英语撰写文章,文件路径为 app/[locale]/blog/posts/
    <slug>/index.json
    content 字段为 HTML。代码块是用围栏括起来的。
  2. 我用一个命令进行翻译。模型会翻译 titleexcerptcontentreadTime 。元数据,如 datetagscategories 保持不变。
  3. 我使用每个文件(英语和翻译)中的 translatedSlugs 映射来保持 slug 同步。
  4. 我验证每种语言的版本是否存在以及 slug 映射是否完整。

为什么 AI 对我有用

  • 遵循指令:提示会保护 HTML、代码和链接。
  • 文化适应:AI 会根据每种语言的受众调整文化参考和示例,这是我无法手动完成的,因为我不了解所有语言的文化细微之处。
  • 快速且准确:快速模型,并带有自动回退机制。
  • 语气一致:低温度可以保持跨语言的风格稳定。

安全措施

  • HTML 安全翻译:我只发送 JSON 键的值,而不是标签或属性。
  • Slug 规范translatedSlugs 包含 enesfrderunlitzh 的 slug。
  • 验证:一个命令会检查每种语言的文件是否存在以及 slug 映射是否完整。
  • 对 API 友好:我在调用之间添加了一个小的延迟。

翻译和 SEO

许多人认为多语言 SEO 只是翻译内容。但这仅仅是开始。真正重要的是:

技术基础

  • Hreflang 标签:这些标签告诉搜索引擎向哪些用户显示哪种语言的页面。如果没有这些标签,谷歌可能会显示错误的语言,或者将你的翻译视为重复内容。
  • URL 结构:每种语言都需要自己的 URL 路径。我使用 /es/blog//fr/blog/ 等。这有助于搜索引擎理解语言层次结构。
  • 站点地图:在你的站点地图中包含所有语言版本,并使用正确的语言注释。搜索引擎需要发现并索引每个版本。
  • 规范标签:将每个语言版本指向自身,以避免对哪个是“主要”版本产生混淆。

超越翻译:文化适应

  • 每种语言的关键词:人们在不同语言中的搜索方式不同。“如何学习编程”在西班牙语中变成了“cómo aprender programación”,而不仅仅是字面翻译。
  • 文化背景:有些概念无法直接翻译。我依靠 AI 来调整示例和参考,使其与每个受众相关,因为我不了解每种语言的所有文化细微之处。
  • 搜索量不同:某些主题在某些语言中更受欢迎。我优先考虑我的内容在其中具有最大潜在影响力的语言。

用户体验信号

  • 语言检测:自动从浏览器设置中检测用户语言,但允许他们覆盖它。
  • 一致的导航:语言切换器必须在每个页面上都能正常工作,并保持用户的上下文。
  • 加载速度:每个语言版本都应该像原始版本一样快速加载。不要让翻译减慢你的网站速度。
  • 移动体验:语言切换需要在屏幕空间有限的移动设备上也能正常工作。

内容策略注意事项

  • 并非所有内容都需要翻译:有些文章可能与英语使用者更相关。我会根据潜在受众有选择地进行翻译。
  • 更新同步:当我更新英语版本时,所有翻译也需要更新。过时的翻译会损害 SEO。
  • 质量重于数量:拥有少量高质量的翻译比拥有许多翻译质量差的页面更好。
  • 本地反向链接:翻译后的内容可以从这些语言的网站获得反向链接,从而提高你的整体域名权威。

关键在于:多语言 SEO 是为每种语言的受众创建单独的、优化的体验,而不仅仅是翻译文字。

我运行的命令

# 将一篇帖子翻译成所有语言\nnpm run translate-all translate my-new-post\n\n# 验证所有语言文件和 slug 映射是否存在\nnpm run translate-all validate my-new-post\n\n# 编辑英文后重新运行并覆盖翻译\nnpm run translate-all translate my-new-post --force

就是这样。一个用于翻译,一个用于验证。切换器之所以有效,是因为每个 JSON 文件都列出了每种语言的 slug。

完整的翻译系统

我的翻译系统由两个协同工作的脚本组成。主协调脚本处理多种语言,而工作脚本则进行实际的 AI 翻译。以下是这两个脚本:

主协调脚本 (translate-all-languages.ts)

这是你直接运行的脚本。它协调对所有 8 种语言的翻译并管理 slug 映射:

点击展开/折叠协调器脚本
import fs from 'fs-extra';\nimport path from 'path';\nimport { execSync } from 'child_process';\n\ntype PostData = Record;\n\nconst SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'ru', 'nl', 'it', 'zh'] as const;\nconst SOURCE_LOCALE = 'en';\n\ninterface TranslationConfig {\n  locale: string;\n  scriptCommand: string;\n}\n\nconst TRANSLATION_CONFIGS: TranslationConfig[] = [\n  { locale: 'es', scriptCommand: 'npm run translate-es-gemini' },\n  { locale: 'fr', scriptCommand: 'npm run translate-fr-gemini' },\n  { locale: 'de', scriptCommand: 'npm run translate-de-gemini' },\n  { locale: 'ru', scriptCommand: 'npm run translate-ru-gemini' },\n  { locale: 'nl', scriptCommand: 'npm run translate-nl-gemini' },\n  { locale: 'it', scriptCommand: 'npm run translate-it-gemini' },\n  { locale: 'zh', scriptCommand: 'npm run translate-zh-gemini' },\n];\n\n/**\n * Generate slug mappings from translated file names\n */\nasync function generateSlugMappings(postSlug: string): Promise> {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);\n  const slugMappings: Record = {};\n\n  if (!(await fs.pathExists(postsDir))) {\n    throw new Error(\`Post directory not found: ${postsDir}\`);\n  }\n\n  // Start with English slug\n  slugMappings[SOURCE_LOCALE] = postSlug;\n\n  // Check for existing translation files to extract slugs\n  for (const locale of SUPPORTED_LOCALES) {\n    if (locale === SOURCE_LOCALE) continue;\n\n    const translationFile = path.join(postsDir, \`index.${locale}.json\`);\n    if (await fs.pathExists(translationFile)) {\n      try {\n        const translationData: PostData = await fs.readJson(translationFile);\n        const translatedSlugs = translationData.translatedSlugs;\n        if (translatedSlugs && translatedSlugs[locale]) {\n          slugMappings[locale] = translatedSlugs[locale];\n        } else {\n          console.warn(\`Warning: Missing translated slug for ${locale} in ${postSlug}\`);\n          slugMappings[locale] = postSlug; // Fallback to English slug\n        }\n      } catch (error) {\n        console.warn(\`Warning: Could not read translation file for ${locale}: ${translationFile}\`);\n        slugMappings[locale] = postSlug; // Fallback to English slug\n      }\n    } else {\n      console.warn(\`Warning: Translation file missing for ${locale}: ${translationFile}\`);\n      slugMappings[locale] = postSlug; // Fallback to English slug\n    }\n  }\n\n  return slugMappings;\n}\n\n/**\n * Update all translation files with complete slug mappings\n */\nasync function updateSlugMappings(postSlug: string, slugMappings: Record) {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);\n\n  for (const locale of SUPPORTED_LOCALES) {\n    const fileName = locale === SOURCE_LOCALE ? 'index.json' : \`index.${locale}.json\`;\n    const filePath = path.join(postsDir, fileName);\n\n    if (await fs.pathExists(filePath)) {\n      try {\n        const data: PostData = await fs.readJson(filePath);\n        data.translatedSlugs = slugMappings;\n        await fs.writeJson(filePath, data, { spaces: 2 });\n        console.log(\`✅ Updated slug mappings in ${fileName}\`);\n      } catch (error) {\n        console.error(\`❌ Failed to update slug mappings in ${fileName}:\`, error);\n      }\n    }\n  }\n}\n\n/**\n * Translate a single post to all languages\n */\nasync function translatePost(postSlug: string, force: boolean = false) {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);\n  const sourceFile = path.join(postsDir, 'index.json');\n\n  // Verify source file exists\n  if (!(await fs.pathExists(sourceFile))) {\n    throw new Error(\`Source file not found: ${sourceFile}\`);\n  }\n\n  console.log(\`\\n🌍 Translating post: ${postSlug}\`);\n  console.log(\`📁 Directory: ${postsDir}\`);\n\n  // Translate to each target language\n  for (const config of TRANSLATION_CONFIGS) {\n    const targetFile = path.join(postsDir, \`index.${config.locale}.json\`);\n    const exists = await fs.pathExists(targetFile);\n\n    if (!exists || force) {\n      try {\n        console.log(\`\\n🔄 Translating to ${config.locale}...\`);\n        const command = \`${config.scriptCommand} -- --only ${postSlug} ${force ? '--force' : ''}\`;\n        execSync(command, { stdio: 'inherit', cwd: process.cwd() });\n        console.log(\`✅ Successfully translated to ${config.locale}\`);\n        \n        // Small delay to be polite to the API\n        await new Promise(resolve => setTimeout(resolve, 500));\n      } catch (error) {\n        console.error(\`❌ Failed to translate to ${config.locale}:\`, error);\n      }\n    } else {\n      console.log(\`⏭️  Skipping ${config.locale} (already exists, use --force to overwrite)\`);\n    }\n  }\n\n  // Generate and update slug mappings\n  console.log(\`\\n🔗 Updating slug mappings...\`);\n  try {\n    const slugMappings = await generateSlugMappings(postSlug);\n    await updateSlugMappings(postSlug, slugMappings);\n    console.log(\`✅ Slug mappings updated for all languages\`);\n  } catch (error) {\n    console.error(\`❌ Failed to update slug mappings:\`, error);\n  }\n}\n\n/**\n * Find all blog posts\n */\nasync function findAllPosts(): Promise {\n  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts');\n  \n  if (!(await fs.pathExists(postsDir))) {\n    throw new Error(\`Posts directory not found: ${postsDir}\`);\n  }\n\n  const entries = await fs.readdir(postsDir, { withFileTypes: true });\n  return entries\n    .filter(entry => entry.isDirectory())\n    .map(entry => entry.name)\n    .filter(name => !name.startsWith('.'));\n}\n\n/**\n * Validate translation completeness\n */\nasync function validateTranslations(postSlug?: string) {\n  const posts = postSlug ? [postSlug] : await findAllPosts();\n  let allValid = true;\n\n  console.log(\`\\n🔍 Validating translations for ${posts.length} post(s)...\`);\n\n  for (const slug of posts) {\n    const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', slug);\n    console.log(\`\\n📋 Checking: ${slug}\`);\n\n    for (const locale of SUPPORTED_LOCALES) {\n      const fileName = locale === SOURCE_LOCALE ? 'index.json' : \`index.${locale}.json\`;\n      const filePath = path.join(postsDir, fileName);\n      \n      if (await fs.pathExists(filePath)) {\n        try {\n          const data: PostData = await fs.readJson(filePath);\n          \n          // Check if translatedSlugs is complete\n          const translatedSlugs = data.translatedSlugs || {};\n          const missingLanguages = SUPPORTED_LOCALES.filter(lang => !translatedSlugs[lang]);\n          \n          if (missingLanguages.length > 0) {\n            console.log(\`  ⚠️  ${locale}: Missing slug mappings for ${missingLanguages.join(', ')}\`);\n            allValid = false;\n          } else {\n            console.log(\`  ✅ ${locale}: Complete\`);\n          }\n        } catch (error) {\n          console.log(\`  ❌ ${locale}: Invalid JSON\`);\n          allValid = false;\n        }\n      } else {\n        console.log(\`  ❌ ${locale}: Missing file\`);\n        allValid = false;\n      }\n    }\n  }\n\n  return allValid;\n}\n\nasync function main() {\n  const args = process.argv.slice(2);\n  const command = args[0];\n  \n  try {\n    switch (command) {\n      case 'translate': {\n        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'translate');\n        const force = args.includes('--force');\n        const all = args.includes('--all');\n\n        if (all) {\n          const allPosts = await findAllPosts();\n          console.log(\`🚀 Translating all ${allPosts.length} posts...\`);\n          \n          for (const slug of allPosts) {\n            await translatePost(slug, force);\n          }\n        } else if (postSlug) {\n          await translatePost(postSlug, force);\n        } else {\n          console.error('❌ Please specify a post slug or use --all');\n          process.exit(1);\n        }\n        break;\n      }\n\n      case 'validate': {\n        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'validate');\n        const isValid = await validateTranslations(postSlug);\n        \n        if (isValid) {\n          console.log('\\n✅ All translations are complete and valid!');\n        } else {\n          console.log('\\n⚠️  Some translations are incomplete or invalid.');\n          process.exit(1);\n        }\n        break;\n      }\n\n      case 'update-slugs': {\n        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'update-slugs');\n        \n        if (postSlug) {\n          const slugMappings = await generateSlugMappings(postSlug);\n          await updateSlugMappings(postSlug, slugMappings);\n          console.log(\`✅ Updated slug mappings for ${postSlug}\`);\n        } else {\n          console.error('❌ Please specify a post slug');\n          process.exit(1);\n        }\n        break;\n      }\n\n      default:\n        console.log(\`\n🌍 博客翻译管理器\n\n用法:\n  npm run translate-all translate <post-slug> [--force]  # 翻译特定文章\n  npm run translate-all translate --all [--force]       # 翻译所有文章\n  npm run translate-all validate [post-slug]            # 验证翻译\n  npm run translate-all update-slugs <post-slug>        # 更新 slug 映射\n\n示例:\n  npm run translate-all translate how-to-read-nginx-access-logs\n  npm run translate-all translate --all --force\n  npm run translate-all validate\n  npm run translate-all update-slugs how-to-read-nginx-access-logs\n        \`);\n        break;\n    }\n  } catch (error) {\n    console.error('❌ 错误:', error);\n    process.exit(1);\n  }\n}\n\nmain().catch(console.error);

脚本的工作原理

脚本执行以下操作:

  • 翻译协调:它为每种目标语言调用单独的语言翻译脚本
  • Slug 管理:它生成并更新每个文件中的 translatedSlugs 映射
  • 验证:它检查所有语言文件是否存在以及 slug 映射是否完整
  • 错误处理:它可以优雅地处理故障并继续处理其他语言

关键在于,每个语言文件都需要知道其他每种语言的 slug。这使得语言切换器能够正常工作。

AI API 配置

以下是我如何配置 Gemini API 调用以实现可靠的翻译:

令牌限制和参数

  • maxOutputTokens: 8192:这对于大型内容至关重要。默认限制要低得多,这会导致响应被截断和解析失败。使用 8192 个令牌,我可以翻译多达约 6,000 个单词(英语),这涵盖了大多数博文和文章。
  • temperature: 0.2:低温度可以保持跨语言的翻译一致性和可预测性。
  • topP: 0.95:在保持质量的同时控制多样性。
  • topK: 40:限制词汇选择,以获得更集中的翻译。

令牌到单词的转换

  • 英语输入:每个令牌约 0.75 个单词,因此 8192 个令牌 ≈ 6,000-6,500 个单词
  • 翻译扩展:输出通常比输入长 10-30%(德语可能长 20-30%,西班牙语长 10-20%)
  • HTML 标记:占用令牌但不进行翻译,因此实际可翻译的内容更少
  • 实际示例:这篇博文(约 2,400 个单词)使用了约 3,000 个输入令牌和约 4,000-5,000 个输出令牌

内容处理策略

  • 先处理小字段:在一个 API 调用中翻译标题、摘要和阅读时间(快速且可靠)。
  • 完整内容翻译:对于超过 2,000 个字符的内容,在一个调用中使用高令牌限制来翻译整个内容。
  • 分块回退:如果完整翻译失败,则自动回退到将内容分成约 1,500 个字符的块。
  • 智能 JSON 解析:处理 AI 有时返回的用 Markdown 代码块 (```json) 包装的响应。

错误处理

  • 模型回退:如果主要模型 (gemini-2.5-flash-lite) 不可用,则自动切换到 gemini-1.5-flash-latest。
  • 优雅降级:如果一个块失败,则使用该部分的原始英语内容并继续。
  • API 礼貌:在调用之间添加延迟以遵守速率限制。

关键教训:始终明确设置 maxOutputTokens 。默认限制对于实际内容来说太低了,会导致神秘的解析失败,这些失败看起来像是 API 问题,但实际上是令牌限制问题。

如果你尝试这样做,这里有一些提示

  • 简单地写作。短句翻译效果好。
  • 用围栏括起代码块,并将内联代码用 <code> 括起来。
  • 如果你不会审核目标文本,请避免使用文字游戏。
  • 使用语义 HTML。标题和列表可以帮助读者和模型。
  • 保持你的源内容简洁明了且结构良好。

常见问题

我需要编辑翻译吗?

只需要编辑西班牙语,因为除了英语外,这是我唯一知道的语言。对于标题和引言,我想要一个快速的起点,然后我会在重要的地方进行润色。对于其他语言,我依赖于 AI 翻译的质量。

隐私?

内容会发送到 Gemini API。我没有发送任何秘密信息。API 密钥位于 .env.localGOOGLE_API_KEYGEMINI_API_KEY )中。

模型?

我使用的是一个快速的 Gemini 模型,如果它不可用,则会自动回退。你可以使用 GEMINI_MODEL 进行覆盖。

旧的 URL 怎么办?

我使用重定向来保持旧 URL 的可用性。slug 映射系统会自动处理这个问题。

总结

AI 不会取代你的声音。它消除了繁琐的工作。保持你的源代码简洁,自动化可重复的部分,进行验证并发布。我共享的脚本处理了复杂性,以便你可以专注于写作。