我如何用AI翻译这篇博客
我是一个以西班牙语为母语的人。我住在美国,所以每天都使用英语。我用英语撰写我的文章是为了练习和接触更多的人。
Google Analytics 向我展示了一些重要的信息。大约 30-40% 的访问者来自英语不是主要语言的国家/地区。这告诉我我错失了机会。所以我决定使用 AI 来翻译博客。
我的设置
- 我如何存储文章:每篇博文都是一个简单的文件,包含标题、日期、内容和其他详细信息。可以把它想象成一张数字食谱卡。
- 我的网站框架:我使用 Next.js 以及可以自动处理多种语言的国际化工具。
- 我使用的 AI:谷歌的 Gemini AI 负责实际的翻译工作。我会给出严格的指令,只翻译文本内容,而保留所有技术部分(如链接和代码)。
- 一个命令搞定一切:我运行一个简单的脚本,将我的英文文章发送给谷歌的 Gemini AI,它会同时将其翻译成所有 8 种语言。然后,脚本将这些翻译整理成单独的文件,更新所有跨语言链接,以便读者可以无缝地在语言之间切换,并仔细检查每种语言的版本是否存在且完整。
- 语言切换:当读者点击更改语言时,他们会自动跳转到文章的正确版本。
重要提示:该脚本称为
translate-gemini.ts
。这是一个通用的翻译脚本,可以翻译成任何语言。它使用
TARGET_LOCALE
环境变量来确定要翻译成哪种语言。因此,你只需要这个脚本,不需要为每种语言准备单独的脚本。
我的工作流程
- 我用英语撰写文章,文件路径为
app/[locale]/blog/posts/
。
<slug>/index.jsoncontent
字段为 HTML。代码块是用围栏括起来的。 - 我用一个命令进行翻译。模型会翻译
title
、excerpt
、content
和readTime
。元数据,如date
、tags
和categories
保持不变。 - 我使用每个文件(英语和翻译)中的
translatedSlugs
映射来保持 slug 同步。 - 我验证每种语言的版本是否存在以及 slug 映射是否完整。
为什么 AI 对我有用
- 遵循指令:提示会保护 HTML、代码和链接。
- 文化适应:AI 会根据每种语言的受众调整文化参考和示例,这是我无法手动完成的,因为我不了解所有语言的文化细微之处。
- 快速且准确:快速模型,并带有自动回退机制。
- 语气一致:低温度可以保持跨语言的风格稳定。
安全措施
- HTML 安全翻译:我只发送 JSON 键的值,而不是标签或属性。
- Slug 规范:
translatedSlugs
包含en
、es
、fr
、de
、ru
、nl
、it
和zh
的 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.local
(
GOOGLE_API_KEY
或
GEMINI_API_KEY
)中。
模型?
我使用的是一个快速的 Gemini 模型,如果它不可用,则会自动回退。你可以使用
GEMINI_MODEL
进行覆盖。
旧的 URL 怎么办?
我使用重定向来保持旧 URL 的可用性。slug 映射系统会自动处理这个问题。
总结
AI 不会取代你的声音。它消除了繁琐的工作。保持你的源代码简洁,自动化可重复的部分,进行验证并发布。我共享的脚本处理了复杂性,以便你可以专注于写作。