Volver al Blog

Cómo traduzco este blog con IA

2025-08-2110 min de lectura

Soy hablante nativo de español. Vivo en los Estados Unidos, así que uso inglés todos los días. Escribo mis publicaciones en inglés para practicar y para llegar a más gente.

Google Analytics me mostró algo importante. Más de la mitad de mis visitantes son de países donde el inglés no es el idioma principal. Eso me dijo que estaba perdiendo valor. Así que decidí traducir el blog con IA.

Mi configuración

  • Cómo almaceno las publicaciones: Cada publicación de blog es un archivo simple con el título, fecha, contenido y otros detalles. Piensa en ello como una tarjeta de receta digital.
  • Mi framework de sitio web: Uso Next.js con herramientas de internacionalización que ayudan a manejar múltiples idiomas automáticamente.
  • La IA que uso: La IA Gemini de Google hace el trabajo de traducción real. Le doy instrucciones estrictas para traducir solo el contenido de texto y dejar todas las partes técnicas (como enlaces y código) intactas.
  • Un comando lo hace todo: Ejecuto un solo script que envía mi publicación en inglés a la IA Gemini de Google, que la traduce a los 8 idiomas simultáneamente. El script luego organiza estas traducciones en archivos separados, actualiza todos los enlaces entre idiomas para que los lectores puedan cambiar entre idiomas sin problemas, y verifica dos veces que cada versión de idioma existe y está completa.
  • Cambio de idioma: Cuando los lectores hacen clic para cambiar idiomas, son llevados automáticamente a la versión correcta de la publicación.

Nota importante: El script se llama translate-gemini.ts. Es un script de traducción genérico que puede traducir a cualquier idioma. Usa la variable de entorno TARGET_LOCALE para determinar a qué idioma traducir. Así que solo necesitas este script, no scripts separados para cada idioma.

Mi flujo de trabajo

  1. Escribo la publicación en inglés como app/[locale]/blog/posts/<slug>/index.json. El campo content es HTML. Los bloques de código están cercados.
  2. Traduzco con un comando. El modelo traduce title, excerpt, content y readTime. Los metadatos como date, tags y categories se mantienen iguales.
  3. Mantengo los slugs sincronizados con un mapa translatedSlugs en cada archivo, en inglés y en las traducciones.
  4. Valido que cada idioma exista y que el mapa de slugs esté completo.

Por qué la IA funciona para mí

  • Sigue instrucciones: El prompt protege HTML, código y enlaces.
  • Adaptación cultural: La IA adapta referencias culturales y ejemplos para que sean relevantes para cada audiencia de idioma, lo cual no puedo hacer manualmente ya que no conozco todos los matices culturales.
  • Rápido y bueno: Un modelo rápido con un respaldo automático.
  • Tono consistente: Una temperatura baja mantiene el estilo estable entre idiomas.

Barreras de seguridad

  • Traducción segura de HTML: Solo envío los valores de las claves JSON, no etiquetas ni atributos.
  • Disciplina de slugs: translatedSlugs contiene el slug para en, es, fr, de, ru, nl, it y zh.
  • Validación: Un comando verifica que cada archivo de idioma exista y que el mapa de slugs esté completo.
  • Respetuoso con la API: Añado un pequeño retraso entre llamadas.

Traducciones y SEO

Mucha gente piensa que el SEO multilingüe es solo traducir contenido. Eso es solo el principio. Esto es lo que realmente importa:

La base técnica

  • Etiquetas hreflang: Estas le dicen a los motores de búsqueda qué página de idioma mostrar a qué usuarios. Sin ellas, Google podría mostrar el idioma incorrecto o tratar tus traducciones como contenido duplicado.
  • Estructura de URL: Cada idioma necesita su propia ruta de URL. Uso /es/blog/, /fr/blog/, etc. Esto ayuda a los motores de búsqueda a entender la jerarquía de idiomas.
  • Sitemaps: Incluye todas las versiones de idioma en tu sitemap con anotaciones de idioma apropiadas. Los motores de búsqueda necesitan descubrir e indexar cada versión.
  • Etiquetas canónicas: Apunta cada versión de idioma a sí misma para evitar confusión sobre cuál es la versión "principal".

Más allá de la traducción: Adaptación cultural

  • Palabras clave en cada idioma: La gente busca de manera diferente en diferentes idiomas. "Cómo aprender programación" se convierte en "cómo aprender programación" en español, no solo una traducción literal.
  • Contexto cultural: Algunos conceptos no se traducen directamente. Confío en la IA para adaptar ejemplos y referencias para que sean relevantes para cada audiencia, ya que no conozco todos los matices culturales de cada idioma.
  • El volumen de búsqueda varía: Algunos temas son más populares en ciertos idiomas. Priorizo idiomas donde mi contenido tiene el mayor alcance potencial.

Señales de experiencia de usuario

  • Detección de idioma: Detecta automáticamente el idioma del usuario desde la configuración del navegador, pero permite que lo anule.
  • Navegación consistente: El selector de idioma debe funcionar en cada página y mantener el contexto del usuario.
  • Velocidad de carga: Cada versión de idioma debe cargar tan rápido como la original. No dejes que la traducción ralentice tu sitio.
  • Experiencia móvil: El cambio de idioma necesita funcionar bien en dispositivos móviles donde el espacio de pantalla es limitado.

Consideraciones de estrategia de contenido

  • No todo necesita traducción: Algunas publicaciones podrían ser más relevantes para hablantes de inglés. Traduzco selectivamente basándome en la audiencia potencial.
  • Sincronización de actualizaciones: Cuando actualizo la versión en inglés, todas las traducciones también necesitan ser actualizadas. Las traducciones obsoletas perjudican el SEO.
  • Calidad sobre cantidad: Es mejor tener menos traducciones de alta calidad que muchas páginas mal traducidas.
  • Backlinks locales: El contenido traducido puede ganar backlinks de sitios en esos idiomas, mejorando tu autoridad de dominio general.

La idea clave: El SEO multilingüe se trata de crear experiencias separadas y optimizadas para cada audiencia de idioma, no solo traducir palabras.

Los comandos que ejecuto

# Traduce una publicación a todos los idiomas
npm run translate-all translate my-new-post

# Valida que todos los archivos de idioma y mapas de slugs existan
npm run translate-all validate my-new-post

# Vuelve a ejecutar y sobrescribe las traducciones después de editar el inglés
npm run translate-all translate my-new-post --force

Eso es todo. Uno para traducir, uno para validar. El selector funciona porque cada archivo JSON lista el slug por idioma.

El script de traducción completo

Aquí está el script completo que uso para traducir todas mis publicaciones de blog. Maneja 8 idiomas y mantiene todo sincronizado:

import fs from 'fs-extra';
import path from 'path';
import { execSync } from 'child_process';

type PostData = Record;

const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'ru', 'nl', 'it', 'zh'] as const;
const SOURCE_LOCALE = 'en';

interface TranslationConfig {
  locale: string;
  scriptCommand: string;
}

const TRANSLATION_CONFIGS: TranslationConfig[] = [
  { locale: 'es', scriptCommand: 'npm run translate-es-gemini' },
  { locale: 'fr', scriptCommand: 'npm run translate-fr-gemini' },
  { locale: 'de', scriptCommand: 'npm run translate-de-gemini' },
  { locale: 'ru', scriptCommand: 'npm run translate-ru-gemini' },
  { locale: 'nl', scriptCommand: 'npm run translate-nl-gemini' },
  { locale: 'it', scriptCommand: 'npm run translate-it-gemini' },
  { locale: 'zh', scriptCommand: 'npm run translate-zh-gemini' },
];

/**
 * Genera mapeos de slugs a partir de nombres de archivos traducidos
 */
async function generateSlugMappings(postSlug: string): Promise> {
  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);
  const slugMappings: Record = {};

  if (!(await fs.pathExists(postsDir))) {
    throw new Error(`El directorio de publicaciones no se encontró: ${postsDir}`);
  }

  // Comienza con el slug en inglés
  slugMappings[SOURCE_LOCALE] = postSlug;

  // Comprueba los archivos de traducción existentes para extraer los slugs
  for (const locale of SUPPORTED_LOCALES) {
    if (locale === SOURCE_LOCALE) continue;

    const translationFile = path.join(postsDir, `index.${locale}.json`);
    if (await fs.pathExists(translationFile)) {
      try {
        const translationData: PostData = await fs.readJson(translationFile);
        const translatedSlugs = translationData.translatedSlugs;
        if (translatedSlugs && translatedSlugs[locale]) {
          slugMappings[locale] = translatedSlugs[locale];
        } else {
          console.warn(`Advertencia: Slug traducido faltante para ${locale} en ${postSlug}`);
          slugMappings[locale] = postSlug; // Retrocede al slug en inglés
        }
      } catch (error) {
        console.warn(`Advertencia: No se pudo leer el archivo de traducción para ${locale}: ${translationFile}`);
        slugMappings[locale] = postSlug; // Retrocede al slug en inglés
      }
    } else {
      console.warn(`Advertencia: Archivo de traducción faltante para ${locale}: ${translationFile}`);
      slugMappings[locale] = postSlug; // Retrocede al slug en inglés
    }
  }

  return slugMappings;
}

/**
 * Actualiza todos los archivos de traducción con mapeos de slugs completos
 */
async function updateSlugMappings(postSlug: string, slugMappings: Record) {
  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);

  for (const locale of SUPPORTED_LOCALES) {
    const fileName = locale === SOURCE_LOCALE ? 'index.json' : `index.${locale}.json`;
    const filePath = path.join(postsDir, fileName);

    if (await fs.pathExists(filePath)) {
      try {
        const data: PostData = await fs.readJson(filePath);
        data.translatedSlugs = slugMappings;
        await fs.writeJson(filePath, data, { spaces: 2 });
        console.log(`✅ Mapeos de slugs actualizados en ${fileName}`);
      } catch (error) {
        console.error(`❌ Error al actualizar mapeos de slugs en ${fileName}:`, error);
      }
    }
  }
}

/**
 * Traduce una sola publicación a todos los idiomas
 */
async function translatePost(postSlug: string, force: boolean = false) {
  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', postSlug);
  const sourceFile = path.join(postsDir, 'index.json');

  // Verifica que el archivo fuente exista
  if (!(await fs.pathExists(sourceFile))) {
    throw new Error(`Archivo fuente no encontrado: ${sourceFile}`);
  }

  console.log(`\n🌍 Traduciendo publicación: ${postSlug}`);
  console.log(`📁 Directorio: ${postsDir}`);

  // Traduce a cada idioma de destino
  for (const config of TRANSLATION_CONFIGS) {
    const targetFile = path.join(postsDir, `index.${config.locale}.json`);
    const exists = await fs.pathExists(targetFile);

    if (!exists || force) {
      try {
        console.log(`\n🔄 Traduciendo a ${config.locale}...`);
        const command = `${config.scriptCommand} -- --only ${postSlug} ${force ? '--force' : ''}`;
        execSync(command, { stdio: 'inherit', cwd: process.cwd() });
        console.log(`✅ Traducción a ${config.locale} exitosa`);
        
        // Pequeño retraso para ser respetuoso con la API
        await new Promise(resolve => setTimeout(resolve, 500));
      } catch (error) {
        console.error(`❌ Error al traducir a ${config.locale}:`, error);
      }
    } else {
      console.log(`⏭️  Saltando ${config.locale} (ya existe, usa --force para sobrescribir)`);
    }
  }

  // Genera y actualiza los mapeos de slugs
  console.log(`\n🔗 Actualizando mapeos de slugs...`);
  try {
    const slugMappings = await generateSlugMappings(postSlug);
    await updateSlugMappings(postSlug, slugMappings);
    console.log(`✅ Mapeos de slugs actualizados para todos los idiomas`);
  } catch (error) {
    console.error(`❌ Error al actualizar mapeos de slugs:`, error);
  }
}

/**
 * Encuentra todas las publicaciones de blog
 */
async function findAllPosts(): Promise {
  const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts');
  
  if (!(await fs.pathExists(postsDir))) {
    throw new Error(`Directorio de publicaciones no encontrado: ${postsDir}`);
  }

  const entries = await fs.readdir(postsDir, { withFileTypes: true });
  return entries
    .filter(entry => entry.isDirectory())
    .map(entry => entry.name)
    .filter(name => !name.startsWith('.'));
}

/**
 * Valida la completitud de las traducciones
 */
async function validateTranslations(postSlug?: string) {
  const posts = postSlug ? [postSlug] : await findAllPosts();
  let allValid = true;

  console.log(`\n🔍 Validando traducciones para ${posts.length} publicación(es)...`);

  for (const slug of posts) {
    const postsDir = path.join(process.cwd(), 'app/[locale]/blog/posts', slug);
    console.log(`\n📋 Comprobando: ${slug}`);

    for (const locale of SUPPORTED_LOCALES) {
      const fileName = locale === SOURCE_LOCALE ? 'index.json' : `index.${locale}.json`;
      const filePath = path.join(postsDir, fileName);
      
      if (await fs.pathExists(filePath)) {
        try {
          const data: PostData = await fs.readJson(filePath);
          
          // Comprueba si translatedSlugs está completo
          const translatedSlugs = data.translatedSlugs || {};
          const missingLanguages = SUPPORTED_LOCALES.filter(lang => !translatedSlugs[lang]);
          
          if (missingLanguages.length > 0) {
            console.log(`  ⚠️  ${locale}: Mapeos de slugs faltantes para ${missingLanguages.join(', ')}`);
            allValid = false;
          } else {
            console.log(`  ✅ ${locale}: Completo`);
          }
        } catch (error) {
          console.log(`  ❌ ${locale}: JSON inválido`);
          allValid = false;
        }
      } else {
        console.log(`  ❌ ${locale}: Archivo faltante`);
        allValid = false;
      }
    }
  }

  return allValid;
}

async function main() {
  const args = process.argv.slice(2);
  const command = args[0];
  
  try {
    switch (command) {
      case 'translate': {
        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'translate');
        const force = args.includes('--force');
        const all = args.includes('--all');

        if (all) {
          const allPosts = await findAllPosts();
          console.log(`🚀 Traduciendo las ${allPosts.length} publicaciones...`);
          
          for (const slug of allPosts) {
            await translatePost(slug, force);
          }
        } else if (postSlug) {
          await translatePost(postSlug, force);
        } else {
          console.error('❌ Por favor, especifica un slug de publicación o usa --all');
          process.exit(1);
        }
        break;
      }

      case 'validate': {
        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'validate');
        const isValid = await validateTranslations(postSlug);
        
        if (isValid) {
          console.log('\n✅ ¡Todas las traducciones están completas y son válidas!');
        } else {
          console.log('\n⚠️  Algunas traducciones están incompletas o no son válidas.');
          process.exit(1);
        }
        break;
      }

      case 'update-slugs': {
        const postSlug = args.find(arg => !arg.startsWith('--') && arg !== 'update-slugs');
        
        if (postSlug) {
          const slugMappings = await generateSlugMappings(postSlug);
          await updateSlugMappings(postSlug, slugMappings);
          console.log(`✅ Mapeos de slugs actualizados para ${postSlug}`);
        } else {
          console.error('❌ Por favor, especifica un slug de publicación');
          process.exit(1);
        }
        break;
      }

      default:
        console.log(`
🌍 Gestor de Traducción de Blogs

Uso:
  npm run translate-all translate  [--force]  # Traducir publicación específica
  npm run translate-all translate --all [--force]       # Traducir todas las publicaciones
  npm run translate-all validate [post-slug]            # Validar traducciones
  npm run translate-all update-slugs         # Actualizar mapeos de slugs

Ejemplos:
  npm run translate-all translate how-to-read-nginx-access-logs
  npm run translate-all translate --all --force
  npm run translate-all validate
  npm run translate-all update-slugs how-to-read-nginx-access-logs
        `);
        break;
    }
  } catch (error) {
    console.error('❌ Error:', error);
    process.exit(1);
  }
}

main().catch(console.error);

Cómo funciona el script

El script hace varias cosas:

  • Orquestación de traducción: Llama a scripts de traducción individuales para cada idioma de destino.
  • Gestión de slugs: Genera y actualiza el mapeo translatedSlugs en cada archivo.
  • Validación: Comprueba que todos los archivos de idioma existan y tengan mapeos de slugs completos.
  • Manejo de errores: Maneja fallos con gracia y continúa con otros idiomas.

La idea clave es que cada archivo de idioma necesita conocer el slug de todos los demás idiomas. Esto permite que el selector de idioma funcione correctamente.

Consejos si lo intentas

  • Escribe de forma sencilla. Las frases cortas se traducen bien.
  • Cerca los bloques de código y envuelve el código en línea en <code>.
  • Evita juegos de palabras si no vas a revisar el texto de destino.
  • Usa HTML semántico. Los encabezados y las listas ayudan a los lectores y a los modelos.
  • Mantén tu contenido fuente limpio y bien estructurado.

Preguntas frecuentes

¿Edito las traducciones?

Solo para español, ya que es el único idioma que conozco además del inglés. Para títulos e introducciones, quiero un punto de partida rápido, luego pulo donde importa. Para otros idiomas, confío en la calidad de la traducción de IA.

¿Privacidad?

El contenido se envía a la API de Gemini. No envío secretos. Las claves de API residen en .env.local (GOOGLE_API_KEY o GEMINI_API_KEY).

¿Modelos?

Uso un modelo Gemini rápido y recurro automáticamente si no está disponible. Puedes anularlo con GEMINI_MODEL.

¿Qué pasa con las URLs antiguas?

Mantengo las URLs antiguas funcionando con redirecciones. El sistema de mapeo de slugs maneja esto automáticamente.

Conclusión

La IA no reemplaza tu voz. Elimina el trabajo pesado. Mantén tu fuente limpia, automatiza las partes repetibles, valida y envía. El script que compartí maneja la complejidad para que puedas concentrarte en escribir.