{"id":146,"date":"2026-03-26T20:56:29","date_gmt":"2026-03-26T20:56:29","guid":{"rendered":"https:\/\/rfog.es\/?p=146"},"modified":"2026-03-26T20:56:29","modified_gmt":"2026-03-26T20:56:29","slug":"de-asimov-en-ingles-a-mi-e-reader-en-espanol-como-construi-un-traductor-de-epubs-con-alma-usando-ia-local","status":"publish","type":"post","link":"https:\/\/rfog.es\/?p=146","title":{"rendered":"De Asimov en ingl\u00e9s a mi e-reader en espa\u00f1ol: C\u00f3mo constru\u00ed un traductor de EPUBs \u00abcon alma\u00bb usando IA local"},"content":{"rendered":"\n<p>Todos los que amamos la lectura nos hemos topado con ese muro: un libro o una revista t\u00e9cnica que nos morimos por leer, pero que solo est\u00e1 disponible en ingl\u00e9s. S\u00ed, existen traductores autom\u00e1ticos, pero suelen ser fr\u00edos, pierden las sutilezas y, lo peor de todo, destrozan el formato del libro original.<\/p>\n\n\n\n<p>Aprovechando la potencia de mi nuevo <strong>Mac Studio M4 Max<\/strong>, decid\u00ed que ya era hora de dejar de pelearme con diccionarios y crear una herramienta que hiciera el trabajo sucio por m\u00ed, pero con la calidad de un traductor que entiende el contexto.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">La idea: No queremos palabras, queremos historias<\/h3>\n\n\n\n<p>La premisa era sencilla: quer\u00eda un proceso que tomara un archivo <strong>EPUB<\/strong>, lo tradujera usando un modelo de lenguaje (LLM) potente como <strong>Command R 35B<\/strong> y me devolviera otro <strong>EPUB<\/strong> id\u00e9ntico en formato pero en mi idioma.<\/p>\n\n\n\n<p>El camino no fue lineal, y como suele pasar en el desarrollo, la primera idea no fue la definitiva.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Paso 1: El desv\u00edo por el Markdown<\/h3>\n\n\n\n<p>Mi idea original era convertir el libro a Markdown, traducirlo y volver a generar el EPUB. \u00bfEl problema? Los libros electr\u00f3nicos son estructuras complejas de XHTML y CSS. Al convertir a Markdown, perd\u00eda los estilos espec\u00edficos de la editorial, las fuentes embebidas y esa maquetaci\u00f3n que hace que un libro sea c\u00f3modo de leer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Paso 2: La cirug\u00eda est\u00e9tica (XHTML bloque a bloque)<\/h3>\n\n\n\n<p>Cambiamos de estrategia. Empezamos a abrir las \u00abtripas\u00bb del libro y a extraer cada p\u00e1rrafo del c\u00f3digo XHTML para envi\u00e1rselo al modelo. Funcionaba, pero surgi\u00f3 un enemigo inesperado: <strong>la p\u00e9rdida de contexto<\/strong>.<\/p>\n\n\n\n<p>Si le env\u00edas a una IA diez p\u00e1rrafos sueltos, no sabe si \u00abthe ship\u00bb 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\u00f3n perd\u00eda coherencia. Adem\u00e1s, el modelo a veces se \u00abcansaba\u00bb y dejaba los \u00faltimos p\u00e1rrafos de cada bloque en ingl\u00e9s.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Paso 3: El equilibrio perfecto (Markdown interno)<\/h3>\n\n\n\n<p>La soluci\u00f3n final fue un enfoque h\u00edbrido. En lugar de convertir el libro entero, el script ahora:<\/p>\n\n\n\n<ol start=\"1\" class=\"wp-block-list\">\n<li>Extrae el contenido de cada cap\u00edtulo.<\/li>\n\n\n\n<li>Lo limpia de \u00abruido\u00bb de c\u00f3digo convirti\u00e9ndolo temporalmente a <strong>Markdown<\/strong>.<\/li>\n\n\n\n<li>Env\u00eda el <strong>cap\u00edtulo entero<\/strong> al modelo. Al ver el cap\u00edtulo completo, la IA entiende el tono, el g\u00e9nero y la continuidad narrativa.<\/li>\n\n\n\n<li>El resultado se convierte de nuevo a HTML y se inyecta en la estructura original del libro.<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">El toque final: Un ciudadano ejemplar en macOS<\/h3>\n\n\n\n<p>Para que el proceso fuera perfecto, a\u00f1adimos dos detalles de \u00abcalidad de vida\u00bb:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Separaci\u00f3n sil\u00e1bica:<\/strong> El script marca el nuevo libro como \u00abEspa\u00f1ol\u00bb. Esto parece una tonter\u00eda, pero si no lo haces, el Kindle o el Kobo intentar\u00e1n separar las palabras siguiendo reglas inglesas, y ver\u00e1s cortes extra\u00f1\u00edsimos al final de cada l\u00ednea.<\/li>\n\n\n\n<li><strong>Gesti\u00f3n de memoria:<\/strong> Usando la l\u00ednea de comandos de <strong>LM Studio<\/strong>, el script carga el modelo al empezar y lo descarga al terminar. As\u00ed, mis 36GB de RAM vuelven a estar disponibles para programar en C# o C++ en cuanto el libro est\u00e1 listo.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfPirater\u00eda o cultura?<\/h3>\n\n\n\n<p>Esta herramienta no es para piratear. Es para acceder a cultura que, de otro modo, se me quedar\u00eda fuera del alcance por la barrera idiom\u00e1tica. Es usar la tecnolog\u00eda para lo que mejor sabe hacer: derribar muros.<\/p>\n\n\n\n<p>Si tienes un Mac potente o una buena GPU y quieres probarlo, aqu\u00ed te dejo el script final. Solo necesitas <strong>LM Studio<\/strong> corriendo de fondo.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">El Script: EPUB Translator v4.3<\/h3>\n\n\n\n<p>Python<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import os\nimport sys\nimport subprocess\nimport warnings\nimport shutil\nimport ebooklib\nfrom ebooklib import epub\nfrom bs4 import BeautifulSoup, XMLParsedAsHTMLWarning\nfrom markdownify import markdownify as md\nimport markdown\nfrom openai import OpenAI\n\n# --- CONFIGURACI\u00d3N ---\nPORT = \"46357\"\nMODEL_ID = \"c4ai-command-r-08-2024\" \nTIMEOUT_LLM = 1200.0 # 20 minutos por cap\u00edtulo\n\nDEBUG_ROOT = \"debug_traduccion\"\nORIGINAL_DIR = os.path.join(DEBUG_ROOT, \"1_original_xhtml\")\nTRANSLATED_DIR = os.path.join(DEBUG_ROOT, \"2_traducido_xhtml\")\n\nwarnings.filterwarnings(\"ignore\", category=XMLParsedAsHTMLWarning)\nBASE_URL = f\"http:\/\/localhost:{PORT}\/v1\"\nclient = OpenAI(base_url=BASE_URL, api_key=\"lm-studio\", timeout=TIMEOUT_LLM)\n\ndef preparar_carpetas():\n    if os.path.exists(DEBUG_ROOT): shutil.rmtree(DEBUG_ROOT)\n    os.makedirs(ORIGINAL_DIR); os.makedirs(TRANSLATED_DIR)\n\ndef manage_model(action=\"load\"):\n    \"\"\"Gestiona el modelo usando el CLI 'lms' de LM Studio.\"\"\"\n    try:\n        if action == \"load\":\n            print(f\"&#x1f680; Cargando modelo: {MODEL_ID}...\")\n            subprocess.run(&#91;\"lms\", \"load\", MODEL_ID], check=True, capture_output=True)\n        else:\n            print(\"&#x1f4a4; Descargando modelos...\")\n            subprocess.run(&#91;\"lms\", \"unload\", \"--all\"], check=True, capture_output=True)\n        print(f\"&#x2705; Operaci\u00f3n '{action}' completada.\")\n    except Exception as e:\n        print(f\"&#x26a0;&#xfe0f; Error al gestionar modelo: {e}\")\n\ndef is_untranslated(original, translated):\n    \"\"\"Detecta si el modelo ha devuelto el texto original sin cambios.\"\"\"\n    def clean(t): return \"\".join(t.split()).lower()\n    return clean(original) == clean(translated)\n\ndef translate_markdown(content, source_lang, genre):\n    system_prompt = f\"\"\"You are a professional literary translator. \nTranslate this Markdown from {source_lang} to Spanish. Genre: {genre}.\nRULES:\n1. Preserve all Markdown formatting.\n2. Return ONLY the translated Markdown.\"\"\"\n\n    try:\n        response = client.chat.completions.create(\n            model=\"local-model\",\n            messages=&#91;{\"role\": \"system\", \"content\": system_prompt}, {\"role\": \"user\", \"content\": content}],\n            temperature=0.1\n        )\n        translated = response.choices&#91;0].message.content\n        if is_untranslated(content, translated):\n            print(\"   &#91;!] El modelo devolvi\u00f3 el texto original. Reintentando...\")\n            return None\n        return translated\n    except Exception as e:\n        print(f\"   &#91;!] Error API: {e}\")\n        return None\n\ndef process_epub(input_path, source_lang, genre):\n    preparar_carpetas()\n    manage_model(\"load\")\n    \n    book = epub.read_epub(input_path)\n    # Forzar metadatos de idioma a espa\u00f1ol para hyphenation correcta\n    book.metadata&#91;'http:\/\/purl.org\/dc\/elements\/1.1\/']&#91;'language'] = &#91;('es', {})]\n    book.set_language('es')\n\n    for item in book.get_items():\n        if item.get_type() == ebooklib.ITEM_DOCUMENT:\n            item_name = item.get_name()\n            \n            # Saltamos portadas e \u00edndices para no romper enlaces estructurales\n            skip_keywords = &#91;\"TOC\", \"COVER\", \"TITLEPAGE\", \"NAV\", \"PORTADA\"]\n            if any(key in item_name.upper() for key in skip_keywords):\n                print(f\"&#x23e9; Saltando: {item_name}\")\n                continue\n\n            print(f\"&#x1f4d6; Traduciendo: {item_name}\")\n            file_name = item_name.replace(\"\/\", \"_\")\n            original_xhtml = item.get_content().decode('utf-8')\n            \n            # Guardar original para debug\n            with open(os.path.join(ORIGINAL_DIR, file_name), \"w\", encoding=\"utf-8\") as f:\n                f.write(original_xhtml)\n\n            # Paso a Markdown -&gt; Traducci\u00f3n -&gt; Paso a HTML\n            md_content = md(original_xhtml, heading_style=\"ATX\")\n            translated_md = translate_markdown(md_content, source_lang, genre)\n            \n            if translated_md:\n                new_body = markdown.markdown(translated_md)\n                soup = BeautifulSoup(original_xhtml, features=\"xml\")\n                if soup.body:\n                    soup.body.clear()\n                    soup.body.append(BeautifulSoup(new_body, \"html.parser\"))\n                \n                # Marcar el archivo interno tambi\u00e9n como espa\u00f1ol\n                html_tag = soup.find('html')\n                if html_tag:\n                    html_tag&#91;'lang'] = 'es'\n                    html_tag&#91;'xml:lang'] = 'es'\n                final_xhtml = soup.encode('utf-8')\n            else:\n                final_xhtml = original_xhtml.encode('utf-8')\n\n            item.set_content(final_xhtml)\n            with open(os.path.join(TRANSLATED_DIR, file_name), \"wb\") as f: \n                f.write(final_xhtml)\n\n    out_name = f\"ES_{os.path.basename(input_path)}\"\n    epub.write_epub(out_name, book)\n    manage_model(\"unload\")\n    print(f\"\\n&#x2705; Proceso finalizado: {out_name}\")\n\nif __name__ == \"__main__\":\n    if len(sys.argv) &lt; 4:\n        print(\"Uso: python traductor.py libro.epub English ScienceFiction\")\n    else:\n        process_epub(sys.argv&#91;1], sys.argv&#91;2], sys.argv&#91;3])\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Entrada creada con el apoyo de Gemini 3.1<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Todos los que amamos la lectura nos hemos topado con ese muro: un libro o una revista t\u00e9cnica que nos morimos por leer, pero que solo est\u00e1 disponible en ingl\u00e9s. S\u00ed, existen traductores autom\u00e1ticos, pero suelen ser fr\u00edos, pierden las sutilezas y, lo peor de todo, destrozan el formato del libro original. Aprovechando la potencia &hellip; <a href=\"https:\/\/rfog.es\/?p=146\" class=\"more-link\">Continuar leyendo<span class=\"screen-reader-text\"> \u00abDe Asimov en ingl\u00e9s a mi e-reader en espa\u00f1ol: C\u00f3mo constru\u00ed un traductor de EPUBs \u00abcon alma\u00bb usando IA local\u00bb<\/span><\/a><\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[8],"tags":[],"class_list":["post-146","post","type-post","status-publish","format-standard","hentry","category-investigaciones-himbestijadas"],"_links":{"self":[{"href":"https:\/\/rfog.es\/index.php?rest_route=\/wp\/v2\/posts\/146","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rfog.es\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rfog.es\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rfog.es\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rfog.es\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=146"}],"version-history":[{"count":1,"href":"https:\/\/rfog.es\/index.php?rest_route=\/wp\/v2\/posts\/146\/revisions"}],"predecessor-version":[{"id":147,"href":"https:\/\/rfog.es\/index.php?rest_route=\/wp\/v2\/posts\/146\/revisions\/147"}],"wp:attachment":[{"href":"https:\/\/rfog.es\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=146"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rfog.es\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=146"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rfog.es\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=146"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}