Retour au blog

Comment je traduis ce blog avec l'IA

2025-08-2110 min de lecture

Je suis hispanophone. Je vis aux États-Unis, donc j'utilise l'anglais tous les jours. J'écris mes articles en anglais pour pratiquer et pour toucher un public plus large.

Google Analytics m'a montré quelque chose d'important. Environ 30 à 40 % de mes visiteurs proviennent de pays où l'anglais n'est pas la langue principale. Cela m'a indiqué que je laissais passer des opportunités. J'ai donc décidé de traduire le blog avec l'IA.

Mon installation

  • Comment je stocke les articles : Chaque article de blog est un simple fichier contenant le titre, la date, le contenu et d'autres détails. Imaginez une fiche recette numérique.
  • Mon framework de site web : J'utilise Next.js avec des outils d'internationalisation qui aident à gérer plusieurs langues automatiquement.
  • L'IA que j'utilise : L'IA Gemini de Google effectue le travail de traduction proprement dit. Je lui donne des instructions strictes pour qu'elle ne traduise que le contenu textuel et laisse toutes les parties techniques (liens et code) intactes.
  • Une seule commande fait tout : J'exécute un seul script qui envoie mon article anglais à l'IA Gemini de Google, qui le traduit simultanément dans 8 langues. Le script organise ensuite ces traductions dans des fichiers séparés, met à jour tous les liens interlangues afin que les lecteurs puissent passer d'une langue à l'autre en toute transparence, et vérifie que chaque version linguistique existe et est complète.
  • Changement de langue : Lorsque les lecteurs cliquent pour changer de langue, ils sont automatiquement redirigés vers la bonne version de l'article.

Remarque importante : Le script s'appelle translate-gemini.ts . Il s'agit d'un script de traduction générique qui peut traduire dans n'importe quelle langue. Il utilise la variable d'environnement TARGET_LOCALE pour déterminer dans quelle langue traduire. Vous n'avez donc besoin que de ce script, et non de scripts séparés pour chaque langue.

Mon workflow

  1. J'écris l'article en anglais sous la forme app/[locale]/blog/posts/
    <slug>/index.json
    . Le champ content est en HTML. Les blocs de code sont délimités.
  2. Je traduis avec une seule commande. Le modèle traduit title , excerpt , content et readTime . Les métadonnées comme date , tags et categories restent inchangées.
  3. Je synchronise les slugs avec une map translatedSlugs dans chaque fichier, anglais et traductions.
  4. Je valide que chaque langue existe et que la map des slugs est complète.

Pourquoi l'IA fonctionne pour moi

  • Suit les instructions : L'invite protège le HTML, le code et les liens.
  • Adaptation culturelle : L'IA adapte les références et les exemples culturels pour les rendre pertinents pour chaque public linguistique, ce que je ne peux pas faire manuellement car je ne connais pas toutes les nuances culturelles.
  • Rapide et efficace : Un modèle rapide avec une solution de repli automatique.
  • Ton cohérent : Une faible température maintient le style constant dans toutes les langues.

Gardes fous

  • Traduction HTML sécurisée : J'envoie uniquement les valeurs des clés JSON, pas les balises ou les attributs.
  • Discipline des slugs : translatedSlugs contient le slug pour en , es , fr , de , ru , nl , it et zh .
  • Validation : Une commande vérifie que chaque fichier linguistique existe et que la map des slugs est complète.
  • Poli envers l'API : J'ajoute un léger délai entre les appels.

Traductions et référencement

Beaucoup de gens pensent que le référencement multilingue consiste simplement à traduire le contenu. Ce n'est que le début. Voici ce qui compte vraiment :

Les bases techniques

  • Balises hreflang : Elles indiquent aux moteurs de recherche quelle page linguistique afficher à quels utilisateurs. Sans elles, Google pourrait afficher la mauvaise langue ou traiter vos traductions comme du contenu dupliqué.
  • Structure des URL : Chaque langue a besoin de son propre chemin d'URL. J'utilise /es/blog/ , /fr/blog/ , etc. Cela aide les moteurs de recherche à comprendre la hiérarchie des langues.
  • Plans de site : Incluez toutes les versions linguistiques dans votre plan de site avec des annotations linguistiques appropriées. Les moteurs de recherche doivent découvrir et indexer chaque version.
  • Balises canonical : Pointez chaque version linguistique sur elle-même pour éviter toute confusion quant à la version « principale ».

Au-delà de la traduction : l'adaptation culturelle

  • Mots clés dans chaque langue : Les gens recherchent différemment dans différentes langues. « Comment apprendre la programmation » devient « cómo aprender programación » en espagnol, et non une simple traduction littérale.
  • Contexte culturel : Certains concepts ne se traduisent pas directement. Je compte sur l'IA pour adapter les exemples et les références afin qu'ils soient pertinents pour chaque public, car je ne connais pas toutes les nuances culturelles de chaque langue.
  • Le volume de recherche varie : Certains sujets sont plus populaires dans certaines langues. Je privilégie les langues où mon contenu a le plus fort potentiel de diffusion.

Signaux d'expérience utilisateur

  • Détection de la langue : Détectez automatiquement la langue de l'utilisateur à partir des paramètres du navigateur, mais permettez-lui de la remplacer.
  • Navigation cohérente : Le sélecteur de langue doit fonctionner sur chaque page et maintenir le contexte de l'utilisateur.
  • Vitesse de chargement : Chaque version linguistique doit se charger aussi rapidement que l'original. Ne laissez pas la traduction ralentir votre site.
  • Expérience mobile : Le changement de langue doit bien fonctionner sur les appareils mobiles où l'espace écran est limité.

Considérations relatives à la stratégie de contenu

  • Tout n'a pas besoin d'être traduit : Certains articles peuvent être plus pertinents pour les anglophones. Je traduis de manière sélective en fonction du public potentiel.
  • Synchronisation des mises à jour : Lorsque je mets à jour la version anglaise, toutes les traductions doivent également être mises à jour. Des traductions obsolètes nuisent au référencement.
  • Qualité plutôt que quantité : Il vaut mieux avoir moins de traductions de haute qualité que de nombreuses pages mal traduites.
  • Liens retour locaux : Le contenu traduit peut obtenir des liens retour de sites dans ces langues, améliorant ainsi votre autorité de domaine globale.

L'idée clé : le référencement multilingue consiste à créer des expériences distinctes et optimisées pour chaque public linguistique, et non simplement à traduire des mots.

Les commandes que j'exécute

# Traduire un article dans toutes les langues\nnpm run translate-all translate my-new-post\n\n# Vérifier que tous les fichiers linguistiques et les maps de slugs existent\nnpm run translate-all validate my-new-post\n\n# Re-exécuter et écraser les traductions après avoir modifié l'anglais\nnpm run translate-all translate my-new-post --force

Voilà. Une pour traduire, une pour valider. Le sélecteur fonctionne parce que chaque fichier JSON répertorie le slug par langue.

Le système de traduction complet

Mon système de traduction est composé de deux scripts fonctionnant ensemble. Le script orchestrateur principal gère plusieurs langues, tandis que le script de travail effectue la traduction par IA. Voici les deux scripts :

Script orchestrateur principal (translate-all-languages.ts)

C'est le script que vous exécutez directement. Il coordonne la traduction dans les 8 langues et gère les mappages de slugs :

Cliquez pour développer/réduire le script orchestrateur
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  [--force]  # Translate specific post\n  npm run translate-all translate --all [--force]       # Translate all posts\n  npm run translate-all validate [post-slug]            # Validate translations\n  npm run translate-all update-slugs         # Update slug mappings\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);

Fonctionnement du script

Le script fait plusieurs choses :

  • Orchestration de la traduction : Il appelle des scripts de traduction individuels pour chaque langue cible
  • Gestion des slugs : Il génère et met à jour le mappage translatedSlugs dans chaque fichier
  • Validation : Il vérifie que tous les fichiers linguistiques existent et ont des mappages de slugs complets
  • Gestion des erreurs : Il gère les échecs en douceur et continue avec les autres langues

L'idée clé est que chaque fichier linguistique doit connaître le slug de toutes les autres langues. Cela permet au sélecteur de langue de fonctionner correctement.

Configuration de l'API IA

Voici comment je configure les appels à l'API Gemini pour une traduction fiable :

Limites et paramètres de jetons

  • maxOutputTokens : 8192 : Ceci est crucial pour les contenus volumineux. La limite par défaut est beaucoup plus basse, ce qui a entraîné des réponses tronquées et des erreurs d'analyse. Avec 8192 jetons, je peux traduire jusqu'à environ 6 000 mots (anglais), ce qui couvre la plupart des articles et des billets de blog.
  • temperature : 0,2 : Une faible température maintient les traductions cohérentes et prévisibles dans toutes les langues.
  • topP : 0,95 : Contrôle la diversité tout en maintenant la qualité.
  • topK : 40 : Limite les choix de vocabulaire pour des traductions plus ciblées.

Conversion jetons-mots

  • Entrée anglaise : environ 0,75 mot par jeton, donc 8192 jetons ≈ 6 000-6 500 mots
  • Expansion de la traduction : La sortie est généralement 10 à 30 % plus longue que l'entrée (l'allemand peut être 20 à 30 % plus long, l'espagnol 10 à 20 % plus long)
  • Balisage HTML : Utilise des jetons mais ne se traduit pas, donc le contenu réellement traduisible est moindre
  • Exemple concret : Cet article de blog (environ 2 400 mots) utilise environ 3 000 jetons d'entrée et environ 4 000 à 5 000 jetons de sortie

Stratégie de gestion du contenu

  • Petits champs d'abord : Traduire le titre, l'extrait et le temps de lecture en un seul appel d'API (rapide et fiable).
  • Traduction du contenu complet : Pour les contenus de plus de 2 000 caractères, traduire l'ensemble du contenu en un seul appel avec des limites de jetons élevées.
  • Solution de repli par découpage : Si la traduction complète échoue, revenir automatiquement à la division du contenu en blocs d'environ 1 500 caractères.
  • Analyse JSON intelligente : Gérer les réponses encapsulées dans des blocs de code markdown (```json) que l'IA renvoie parfois.

Gestion des erreurs

  • Solution de repli du modèle : Si le modèle principal (gemini-2.5-flash-lite) n'est pas disponible, passer automatiquement à gemini-1.5-flash-latest.
  • Dégradation en douceur : Si un bloc échoue, utiliser le contenu anglais original pour cette section et continuer.
  • Politesse de l'API : Ajouter des délais entre les appels pour respecter les limites de taux.

La leçon clé : définissez toujours maxOutputTokens explicitement. Les limites par défaut sont trop basses pour les contenus réels, ce qui provoque des erreurs d'analyse mystérieuses qui ressemblent à des problèmes d'API mais qui sont en fait des problèmes de limite de jetons.

Conseils si vous essayez cela

  • Écrivez simplement. Les phrases courtes se traduisent bien.
  • Délimitez les blocs de code et encapsulez le code en ligne dans <code> .
  • Évitez les jeux de mots si vous n'allez pas relire le texte cible.
  • Utilisez du HTML sémantique. Les titres et les listes aident les lecteurs et les modèles.
  • Gardez votre contenu source propre et bien structuré.

FAQ

Est-ce que je modifie les traductions ?

Uniquement pour l'espagnol, car c'est la seule langue que je connais en plus de l'anglais. Pour les titres et les introductions, je veux un point de départ rapide, puis je peaufine là où c'est important. Pour les autres langues, je me fie à la qualité de la traduction de l'IA.

Confidentialité ?

Le contenu est envoyé à l'API Gemini. Je n'envoie pas de secrets. Les clés API se trouvent dans .env.local ( GOOGLE_API_KEY ou GEMINI_API_KEY ).

Modèles ?

J'utilise un modèle Gemini rapide et je passe automatiquement à une solution de repli s'il n'est pas disponible. Vous pouvez remplacer avec GEMINI_MODEL .

Qu'en est-il des anciennes URL ?

Je maintiens le fonctionnement des anciennes URL avec des redirections. Le système de mappage des slugs gère cela automatiquement.

Conclusion

L'IA ne remplace pas votre voix. Elle supprime le travail fastidieux. Gardez votre source propre, automatisez les parties répétables, validez et publiez. Le script que j'ai partagé gère la complexité afin que vous puissiez vous concentrer sur l'écriture.