De Asimov en inglés a mi e-reader en español: Cómo construí un traductor de EPUBs «con alma» usando IA local

Todos los que amamos la lectura nos hemos topado con ese muro: un libro o una revista técnica que nos morimos por leer, pero que solo está disponible en inglés. Sí, existen traductores automáticos, pero suelen ser fríos, pierden las sutilezas y, lo peor de todo, destrozan el formato del libro original.

Aprovechando la potencia de mi nuevo Mac Studio M4 Max, decidí que ya era hora de dejar de pelearme con diccionarios y crear una herramienta que hiciera el trabajo sucio por mí, pero con la calidad de un traductor que entiende el contexto.

La idea: No queremos palabras, queremos historias

La premisa era sencilla: quería un proceso que tomara un archivo EPUB, lo tradujera usando un modelo de lenguaje (LLM) potente como Command R 35B y me devolviera otro EPUB idéntico en formato pero en mi idioma.

El camino no fue lineal, y como suele pasar en el desarrollo, la primera idea no fue la definitiva.

Paso 1: El desvío por el Markdown

Mi idea original era convertir el libro a Markdown, traducirlo y volver a generar el EPUB. ¿El problema? Los libros electrónicos son estructuras complejas de XHTML y CSS. Al convertir a Markdown, perdía los estilos específicos de la editorial, las fuentes embebidas y esa maquetación que hace que un libro sea cómodo de leer.

Paso 2: La cirugía estética (XHTML bloque a bloque)

Cambiamos de estrategia. Empezamos a abrir las «tripas» del libro y a extraer cada párrafo del código XHTML para enviárselo al modelo. Funcionaba, pero surgió un enemigo inesperado: la pérdida de contexto.

Si le envías a una IA diez párrafos sueltos, no sabe si «the ship» es una nave espacial o un barco, o si el protagonista es hombre o mujer si no se menciona en ese trozo exacto. La traducción perdía coherencia. Además, el modelo a veces se «cansaba» y dejaba los últimos párrafos de cada bloque en inglés.

Paso 3: El equilibrio perfecto (Markdown interno)

La solución final fue un enfoque híbrido. En lugar de convertir el libro entero, el script ahora:

  1. Extrae el contenido de cada capítulo.
  2. Lo limpia de «ruido» de código convirtiéndolo temporalmente a Markdown.
  3. Envía el capítulo entero al modelo. Al ver el capítulo completo, la IA entiende el tono, el género y la continuidad narrativa.
  4. El resultado se convierte de nuevo a HTML y se inyecta en la estructura original del libro.

El toque final: Un ciudadano ejemplar en macOS

Para que el proceso fuera perfecto, añadimos dos detalles de «calidad de vida»:

  • Separación silábica: El script marca el nuevo libro como «Español». Esto parece una tontería, pero si no lo haces, el Kindle o el Kobo intentarán separar las palabras siguiendo reglas inglesas, y verás cortes extrañísimos al final de cada línea.
  • Gestión de memoria: Usando la línea de comandos de LM Studio, el script carga el modelo al empezar y lo descarga al terminar. Así, mis 36GB de RAM vuelven a estar disponibles para programar en C# o C++ en cuanto el libro está listo.

¿Piratería o cultura?

Esta herramienta no es para piratear. Es para acceder a cultura que, de otro modo, se me quedaría fuera del alcance por la barrera idiomática. Es usar la tecnología para lo que mejor sabe hacer: derribar muros.

Si tienes un Mac potente o una buena GPU y quieres probarlo, aquí te dejo el script final. Solo necesitas LM Studio corriendo de fondo.


El Script: EPUB Translator v4.3

Python

import os
import sys
import subprocess
import warnings
import shutil
import ebooklib
from ebooklib import epub
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
from markdownify import markdownify as md
import markdown
from openai import OpenAI

# --- CONFIGURACIÓN ---
PORT = "46357"
MODEL_ID = "c4ai-command-r-08-2024" 
TIMEOUT_LLM = 1200.0 # 20 minutos por capítulo

DEBUG_ROOT = "debug_traduccion"
ORIGINAL_DIR = os.path.join(DEBUG_ROOT, "1_original_xhtml")
TRANSLATED_DIR = os.path.join(DEBUG_ROOT, "2_traducido_xhtml")

warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
BASE_URL = f"http://localhost:{PORT}/v1"
client = OpenAI(base_url=BASE_URL, api_key="lm-studio", timeout=TIMEOUT_LLM)

def preparar_carpetas():
    if os.path.exists(DEBUG_ROOT): shutil.rmtree(DEBUG_ROOT)
    os.makedirs(ORIGINAL_DIR); os.makedirs(TRANSLATED_DIR)

def manage_model(action="load"):
    """Gestiona el modelo usando el CLI 'lms' de LM Studio."""
    try:
        if action == "load":
            print(f"🚀 Cargando modelo: {MODEL_ID}...")
            subprocess.run(["lms", "load", MODEL_ID], check=True, capture_output=True)
        else:
            print("💤 Descargando modelos...")
            subprocess.run(["lms", "unload", "--all"], check=True, capture_output=True)
        print(f"✅ Operación '{action}' completada.")
    except Exception as e:
        print(f"⚠️ Error al gestionar modelo: {e}")

def is_untranslated(original, translated):
    """Detecta si el modelo ha devuelto el texto original sin cambios."""
    def clean(t): return "".join(t.split()).lower()
    return clean(original) == clean(translated)

def translate_markdown(content, source_lang, genre):
    system_prompt = f"""You are a professional literary translator. 
Translate this Markdown from {source_lang} to Spanish. Genre: {genre}.
RULES:
1. Preserve all Markdown formatting.
2. Return ONLY the translated Markdown."""

    try:
        response = client.chat.completions.create(
            model="local-model",
            messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": content}],
            temperature=0.1
        )
        translated = response.choices[0].message.content
        if is_untranslated(content, translated):
            print("   [!] El modelo devolvió el texto original. Reintentando...")
            return None
        return translated
    except Exception as e:
        print(f"   [!] Error API: {e}")
        return None

def process_epub(input_path, source_lang, genre):
    preparar_carpetas()
    manage_model("load")
    
    book = epub.read_epub(input_path)
    # Forzar metadatos de idioma a español para hyphenation correcta
    book.metadata['http://purl.org/dc/elements/1.1/']['language'] = [('es', {})]
    book.set_language('es')

    for item in book.get_items():
        if item.get_type() == ebooklib.ITEM_DOCUMENT:
            item_name = item.get_name()
            
            # Saltamos portadas e índices para no romper enlaces estructurales
            skip_keywords = ["TOC", "COVER", "TITLEPAGE", "NAV", "PORTADA"]
            if any(key in item_name.upper() for key in skip_keywords):
                print(f"⏩ Saltando: {item_name}")
                continue

            print(f"📖 Traduciendo: {item_name}")
            file_name = item_name.replace("/", "_")
            original_xhtml = item.get_content().decode('utf-8')
            
            # Guardar original para debug
            with open(os.path.join(ORIGINAL_DIR, file_name), "w", encoding="utf-8") as f:
                f.write(original_xhtml)

            # Paso a Markdown -> Traducción -> Paso a HTML
            md_content = md(original_xhtml, heading_style="ATX")
            translated_md = translate_markdown(md_content, source_lang, genre)
            
            if translated_md:
                new_body = markdown.markdown(translated_md)
                soup = BeautifulSoup(original_xhtml, features="xml")
                if soup.body:
                    soup.body.clear()
                    soup.body.append(BeautifulSoup(new_body, "html.parser"))
                
                # Marcar el archivo interno también como español
                html_tag = soup.find('html')
                if html_tag:
                    html_tag['lang'] = 'es'
                    html_tag['xml:lang'] = 'es'
                final_xhtml = soup.encode('utf-8')
            else:
                final_xhtml = original_xhtml.encode('utf-8')

            item.set_content(final_xhtml)
            with open(os.path.join(TRANSLATED_DIR, file_name), "wb") as f: 
                f.write(final_xhtml)

    out_name = f"ES_{os.path.basename(input_path)}"
    epub.write_epub(out_name, book)
    manage_model("unload")
    print(f"\n✅ Proceso finalizado: {out_name}")

if __name__ == "__main__":
    if len(sys.argv) < 4:
        print("Uso: python traductor.py libro.epub English ScienceFiction")
    else:
        process_epub(sys.argv[1], sys.argv[2], sys.argv[3])

Entrada creada con el apoyo de Gemini 3.1

El misterio de los gigabytes fantasma en macOS: Cómo encontrar y recuperar el espacio perdido

Es una de las situaciones más frustrantes a las que te puedes enfrentar en macOS: tu disco duro empieza a llenarse de forma incontrolable y no tienes ni puta idea de por qué. Abres la ventana de Almacenamiento y ves que una categoría genérica llamada «Datos del sistema» está devorando cientos de gigabytes.

Ejecutas herramientas como BuhoCleaner o CleanMyMac y no encuentran nada. Ejecutas comandos de consola y la suma de tus archivos no cuadra con el espacio ocupado. Parece kafkiano, especialmente si estás en una instalación limpia.

Si te encuentras en esta situación, el problema casi siempre radica en un proceso del sistema que ha entrado en un bucle infinito de escritura, oculto detrás de las férreas capas de seguridad de Apple. Así es como puedes desenmascararlo paso a paso.

Por qué fallan las herramientas convencionales

En las versiones modernas de macOS, el sistema cuenta con SIP (System Integrity Protection) y estrictas políticas de privacidad (TCC). Esto significa que hay carpetas críticas a las que ni siquiera el superusuario (root) puede acceder por defecto.

Si un demonio del sistema entra en bucle y llena una de estas carpetas protegidas, las aplicaciones de limpieza de terceros no verán esos archivos. Y lo que es peor: si usas el comando du en la Terminal sin los permisos adecuados, el sistema simplemente saltará esos directorios en silencio, haciéndote creer que el disco está vacío cuando en realidad está a reventar.

El proceso para cazar los archivos fantasma

Para encontrar exactamente qué directorio se está comiendo tu almacenamiento, vamos a usar la Terminal, pero primero tenemos que darle el poder para ver el disco completo.

Paso 1: Otorgar Acceso Total al Disco

  1. Abre Ajustes del Sistema.
  2. Ve a Privacidad y seguridad > Acceso total al disco.
  3. Busca Terminal en la lista y activa el interruptor (si no está, añádela con el botón «+»).
  4. Cierra la Terminal por completo y vuelve a abrirla.

Paso 2: Escanear la raíz del volumen de datos

Con la Terminal sin restricciones, vamos a escanear el volumen real donde residen los datos de usuario. Ejecuta este comando (te pedirá la contraseña):

Bash

sudo du -hd 1 /System/Volumes/Data 2>/dev/null | sort -hr | head -n 12

Este comando listará los directorios más pesados ordenados de mayor a menor, omitiendo los errores de lectura. Aquí verás la realidad. Si la carpeta /System/Volumes/Data/Users muestra un tamaño desproporcionado (por ejemplo, más de 300 GB cuando apenas tienes archivos), ya sabes dónde está el problema.

Paso 3: Rastrear el directorio exacto

Ahora toca ir bajando niveles. Escanea tu carpeta de usuario:

Bash

sudo du -hd 1 /Users/TU_USUARIO 2>/dev/null | sort -hr | head -n 12

Si el sumidero de gigabytes está en Library, sigue profundizando. En muchos casos de instalaciones recientes, el problema se esconde en los contenedores de grupo:

Bash

sudo du -hd 1 /Users/TU_USUARIO/Library/Group\ Containers 2>/dev/null | sort -hr | head -n 12

Paso 4: Identificar al culpable (El caso de useractivityd y cloudd)

Si en el paso anterior descubres que una carpeta llamada group.com.apple.coreservices.useractivityd ocupa una barbaridad de espacio, acabas de dar con un error conocido de la infraestructura de CloudKit y Continuidad.

El proceso cloudd (que maneja la sincronización en segundo plano, incluso si iCloud Drive está apagado) o useractivityd (encargado de Handoff y el Portapapeles Universal) pueden atascarse tras una instalación limpia o un error de red. Al fallar, entran en un bucle infinito, volcando la memoria caché al disco una y otra vez hasta agotarlo.

Cómo solucionarlo de raíz

Una vez localizada la carpeta temporal inflada, la solución es detener el proceso y eliminar la basura generada. Son cachés de estado, por lo que su borrado es seguro.

  1. Detén el proceso responsable temporalmente:Bashkillall useractivityd (Nota: si tu problema venía de CloudKit general, puedes usar killall cloudd)
  2. Vacía el contenido de la carpeta problemática (asegúrate de poner la ruta exacta que te devolvió el comando du):Bashsudo rm -rf ~/Library/Group\ Containers/group.com.apple.coreservices.useractivityd/*

Tras ejecutar esto, verás en el Finder cómo tu espacio libre vuelve a la normalidad de forma instantánea. Para evitar que el bucle se reinicie, es recomendable ir a Ajustes del Sistema > General > AirDrop y Handoff, desactivar la opción de permitir Handoff y volver a activarla pasados unos segundos.