Назад к блогу

Как я перевожу этот блог с помощью ИИ

2025-08-2110 минут чтения

Я — носитель испанского языка. Я живу в США, поэтому каждый день использую английский. Я пишу свои посты на английском, чтобы практиковаться и охватить больше людей.

Google Analytics показал мне кое-что важное. Около 30-40% моих посетителей — из стран, где английский не является основным языком. Это подсказало мне, что я упускаю возможности. Поэтому я решил перевести блог с помощью ИИ.

Моя настройка

  • Как я храню посты: Каждый пост — это простой файл с заголовком, датой, содержимым и другими деталями. Представьте себе это как цифровую карточку с рецептом.
  • Фреймворк моего сайта: Я использую Next.js с инструментами интернационализации, которые помогают автоматически обрабатывать несколько языков.
  • ИИ, который я использую: Gemini AI от Google выполняет фактическую работу по переводу. Я даю ему строгие инструкции переводить только текстовое содержимое и оставлять все технические части (например, ссылки и код) нетронутыми.
  • Одна команда делает всё: Я запускаю один скрипт, который отправляет мой английский пост в Gemini AI от Google, который переводит его на все 8 языков одновременно. Затем скрипт организует эти переводы в отдельные файлы, обновляет все межъязыковые ссылки, чтобы читатели могли плавно переключаться между языками, и дважды проверяет, что существует и полная версия на каждом языке.
  • Переключение языков: Когда читатели нажимают, чтобы изменить язык, они автоматически переходят к нужной версии поста.

Важно: Скрипт называется translate-gemini.ts . Это универсальный скрипт перевода, который может переводить на любой язык. Он использует переменную окружения TARGET_LOCALE , чтобы определить, на какой язык переводить. Таким образом, вам нужен только один этот скрипт, а не отдельные скрипты для каждого языка.

Мой рабочий процесс

  1. Я пишу пост на английском как app/[locale]/blog/posts/
    <slug>/index.json
    . Поле content содержит HTML. Блоки кода ограждены.
  2. Я перевожу одной командой. Модель переводит title , excerpt , content и readTime . Метаданные, такие как date , tags и categories , остаются неизменными.
  3. Я синхронизирую слаги с помощью карты translatedSlugs в каждом файле, английском и переводах.
  4. Я проверяю, что каждый языковой файл существует и карта слагов полная.

Почему ИИ работает для меня

  • Следует инструкциям: Запрос защищает HTML, код и ссылки.
  • Культурная адаптация: ИИ адаптирует культурные отсылки и примеры, чтобы они были актуальны для аудитории каждого языка, чего я не могу сделать вручную, так как не знаю всех культурных нюансов.
  • Быстро и хорошо: Быстрая модель с автоматическим резервным вариантом.
  • Согласованный тон: Низкая температура поддерживает стабильный стиль на всех языках.

Защитные механизмы

  • Безопасный перевод HTML: Я отправляю только значения ключей JSON, а не теги или атрибуты.
  • Дисциплина слагов: translatedSlugs содержит слаги для en , es , fr , de , ru , nl , it и zh .
  • Проверка: Команда проверяет, что каждый языковой файл существует и что карта слагов полная.
  • Вежливость к API: Я добавляю небольшую задержку между вызовами.

Переводы и SEO

Многие считают, что многоязычное SEO — это просто перевод контента. Это только начало. Вот что действительно важно:

Техническая основа

  • Теги hreflang: Они сообщают поисковым системам, какую языковую страницу показывать каким пользователям. Без них Google может показать неправильный язык или рассматривать ваши переводы как дублированный контент.
  • Структура URL: Каждый язык нуждается в собственном пути URL. Я использую /es/blog/ , /fr/blog/ и т. д. Это помогает поисковым системам понимать языковую иерархию.
  • Карты сайта: Включите все языковые версии в свою карту сайта с соответствующими языковыми аннотациями. Поисковые системы должны обнаруживать и индексировать каждую версию.
  • Канонические теги: Указывайте на каждую языковую версию саму себя, чтобы избежать путаницы относительно того, какая версия является «главной».

За пределами перевода: культурная адаптация

  • Ключевые слова на каждом языке: Люди ищут по-разному на разных языках. «Как изучать программирование» становится «cómo aprender programación» на испанском, а не просто дословный перевод.
  • Культурный контекст: Некоторые понятия не переводятся напрямую. Я полагаюсь на ИИ, чтобы адаптировать примеры и ссылки, чтобы они были актуальны для каждой аудитории, так как я не знаю всех культурных нюансов каждого языка.
  • Объём поиска варьируется: Некоторые темы более популярны на определённых языках. Я отдаю приоритет языкам, где у моего контента наибольший потенциал охвата.

Сигналы пользовательского опыта

  • Определение языка: Автоматически определять язык пользователя по настройкам браузера, но позволять им переопределять его.
  • Последовательная навигация: Переключатель языка должен работать на каждой странице и сохранять контекст пользователя.
  • Скорость загрузки: Каждая языковая версия должна загружаться так же быстро, как оригинал. Не позволяйте переводу замедлять ваш сайт.
  • Мобильный опыт: Переключение языка должно хорошо работать на мобильных устройствах, где пространство экрана ограничено.

Соображения по контент-стратегии

  • Не всё нуждается в переводе: Некоторые посты могут быть более актуальны для англоязычной аудитории. Я перевожу выборочно, основываясь на потенциальной аудитории.
  • Синхронизация обновлений: Когда я обновляю английскую версию, все переводы также должны быть обновлены. Устаревшие переводы вредят SEO.
  • Качество важнее количества: Лучше иметь меньше высококачественных переводов, чем много плохо переведённых страниц.
  • Локальные обратные ссылки: Переведённый контент может получать обратные ссылки с сайтов на этих языках, улучшая ваш общий авторитет домена.

Ключевое понимание: многоязычное SEO — это создание отдельных, оптимизированных впечатлений для каждой языковой аудитории, а не просто перевод слов.

Команды, которые я запускаю

# Перевод одного поста на все языки\nnpm run translate-all translate my-new-post\n\n# Проверка существования всех языковых файлов и карт слагов\nnpm run translate-all validate my-new-post\n\n# Повторный запуск и перезапись переводов после редактирования английской версии\nnpm run translate-all translate my-new-post --force

Вот и всё. Одна команда для перевода, одна для проверки. Переключатель работает, потому что каждый JSON-файл содержит список слагов для каждого языка.

Полная система перевода

Моя система перевода состоит из двух скриптов, работающих вместе. Основной скрипт-оркестратор обрабатывает несколько языков, а рабочий скрипт выполняет фактический перевод ИИ. Вот оба скрипта:

Основной скрипт-оркестратор (translate-all-languages.ts)

Это скрипт, который вы запускаете напрямую. Он координирует перевод на все 8 языков и управляет сопоставлениями слагов:

Нажмите, чтобы развернуть/свернуть скрипт-оркестратор
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🌍 Blog Translation Manager\n\nUsage:\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>        # Обновление сопоставлений слагов\n\nExamples:\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:', error);\n    process.exit(1);\n  }\n}\n\nmain().catch(console.error);

Как работает скрипт

Скрипт делает несколько вещей:

  • Оркестровка перевода: Он вызывает отдельные скрипты перевода для каждого целевого языка
  • Управление слагами: Он генерирует и обновляет сопоставление translatedSlugs в каждом файле
  • Проверка: Он проверяет, что все языковые файлы существуют и имеют полные сопоставления слагов
  • Обработка ошибок: Он корректно обрабатывает ошибки и продолжает работу с другими языками

Ключевое понимание заключается в том, что каждый языковой файл должен знать слаг для каждого другого языка. Это позволяет переключателю языка работать корректно.

Конфигурация API ИИ

Вот как я настраиваю вызовы Gemini API для надёжного перевода:

Лимиты токенов и параметры

  • maxOutputTokens: 8192: Это очень важно для большого контента. Предельный лимит по умолчанию намного ниже, что вызывало усечённые ответы и ошибки синтаксического анализа. С 8192 токенами я могу переводить до ~6000 слов (английский), что охватывает большинство постов и статей в блоге.
  • temperature: 0.2: Низкая температура делает переводы согласованными и предсказуемыми на всех языках.
  • topP: 0.95: Управляет разнообразием, сохраняя при этом качество.
  • topK: 40: Ограничивает выбор лексики для более целенаправленных переводов.

Преобразование токенов в слова

  • Английский ввод: ~0,75 слов на токен, поэтому 8192 токена ≈ 6000-6500 слов
  • Расширение перевода: Вывод обычно на 10-30% длиннее, чем ввод (немецкий может быть на 20-30% длиннее, испанский на 10-20% длиннее)
  • HTML-разметка: Занимает токены, но не переводится, поэтому фактическое количество переводимого контента меньше
  • Пример из реальной жизни: Этот пост в блоге (~2400 слов) использует ~3000 входных токенов и ~4000-5000 выходных токенов

Стратегия обработки контента

  • Сначала небольшие поля: Переводите заголовок, выдержку и readTime в одном вызове API (быстро и надёжно).
  • Перевод полного контента: Для контента более 2000 символов переводите весь контент в одном вызове с высокими лимитами токенов.
  • Резервный вариант с разбиением на части: Если полный перевод не удаётся, автоматически переходите к разбиению контента на части по ~1500 символов.
  • Умный синтаксический анализ JSON: Обрабатывайте ответы, заключённые в блоки кода markdown (```json), которые ИИ иногда возвращает.

Обработка ошибок

  • Резервная модель: Если основная модель (gemini-2.5-flash-lite) недоступна, автоматически переключайтесь на gemini-1.5-flash-latest.
  • Грациозное ухудшение: Если часть не удаётся перевести, используйте исходный английский контент для этого раздела и продолжайте.
  • Вежливость к API: Добавляйте задержки между вызовами, чтобы соблюдать ограничения скорости.

Ключевой урок: всегда явно устанавливайте maxOutputTokens . Лимиты по умолчанию слишком низки для реального контента, вызывая загадочные ошибки синтаксического анализа, которые выглядят как проблемы API, но на самом деле являются проблемами лимита токенов.

Советы, если вы попробуете это

  • Пишите просто. Короткие предложения хорошо переводятся.
  • Ограждайте блоки кода и заключайте встроенный код в <code> .
  • Избегайте каламбуров, если вы не будете проверять целевой текст.
  • Используйте семантический HTML. Заголовки и списки помогают читателям и моделям.
  • Поддерживайте исходный контент чистым и хорошо структурированным.

Часто задаваемые вопросы

Редактирую ли я переводы?

Только для испанского, так как это единственный язык, который я знаю, кроме английского. Для заголовков и вступлений мне нужна быстрая отправная точка, а затем я полирую там, где это важно. Для других языков я полагаюсь на качество перевода ИИ.

Конфиденциальность?

Контент отправляется в Gemini API. Я не отправляю секреты. Ключи API находятся в .env.local ( GOOGLE_API_KEY или GEMINI_API_KEY ).

Модели?

Я использую быструю модель Gemini и автоматически переключаюсь на резервную, если она недоступна. Вы можете переопределить её с помощью GEMINI_MODEL .

А как насчёт старых URL-адресов?

Я поддерживаю работу старых URL-адресов с помощью перенаправлений. Система сопоставления слагов обрабатывает это автоматически.

Вывод

ИИ не заменяет ваш голос. Он убирает рутинную работу. Поддерживайте свой исходный код чистым, автоматизируйте повторяющиеся части, проверяйте и выпускайте. Скрипт, который я поделился, обрабатывает сложность, чтобы вы могли сосредоточиться на написании.