<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Star Apps · Blog</title>
    <link>https://rodolflying.github.io/blog/</link>
    <atom:link href="https://rodolflying.github.io/rss.xml" rel="self" type="application/rss+xml" />
    <description>Automatización, integración de sistemas e IA, explicadas por quien las construye.</description>
    <language>es-cl</language>
    <item>
      <title>F29 sin movimiento: cómo automatizarlo para no volver a pagar una multa por olvido</title>
      <link>https://rodolflying.github.io/blog/declarar-f29-sin-movimiento-automatico</link>
      <guid isPermaLink="true">https://rodolflying.github.io/blog/declarar-f29-sin-movimiento-automatico</guid>
      <pubDate>Wed, 14 Oct 2026 15:00:00 GMT</pubDate>
      <category>automatizacion</category>
      <description>Aunque tu empresa no tenga actividad, el SII exige el F29 todos los meses. Así lo automatizamos con Python y esto le falta a un script para quedar en producción.</description>
      <content:encoded><![CDATA[<p>Tener una SpA sin actividad parece no dar trabajo, hasta que llega la multa. El SII exige el Formulario 29 todos los meses, aunque la empresa no haya emitido una sola factura. A mí se me pasó dos veces y pagué las dos multas. No fue por descuido grave: es un trámite de dos minutos que se olvida justamente porque no pasa nada.</p>
<p>Por eso escribí <a href="https://github.com/rodolflying/sii_bot" target="_blank" rel="noopener noreferrer">sii_bot</a>, un script en Python que entra al portal del SII, declara el mes sin movimiento y guarda el folio. En este artículo explico qué exige la ley, cómo funciona el bot y, sobre todo, qué le falta a un script que &quot;funciona en mi computador&quot; para que una empresa pueda confiar en él.</p>
<h2 id="que-exige-el-sii">Qué exige el SII</h2>
<p>El F29 es la declaración mensual de IVA. Según el SII, quienes están afectos a IVA deben presentarlo desde el mes en que inician actividades, <a href="https://www.sii.cl/como_se_hace_para/declarar_imp_mensuales/Declarar_Iva.pdf" target="_blank" rel="noopener noreferrer">aun cuando no tengan operaciones</a>. Si no hubo ventas ni compras, se declara &quot;sin movimiento&quot;.</p>
<p>Los plazos dependen de cómo y qué declares:</p>
<div class="table-wrap"><table><thead><tr><th>Situación</th><th>Plazo</th></tr></thead><tbody><tr><td>Regla general</td><td>Día 12 del mes siguiente</td></tr><tr><td>Facturador electrónico que declara y paga por internet</td><td>Día 20 del mes siguiente</td></tr><tr><td>Declaración sin movimiento o sin pago, por internet</td><td>Día 28 del mes siguiente</td></tr></tbody></table></div>
<p>Si el último día cae en sábado, domingo, feriado o 31 de diciembre, el plazo se corre al día hábil siguiente (<a href="https://www.sii.cl/portales/mipyme/f29/" target="_blank" rel="noopener noreferrer">SII, plazos del F29</a>). La declaración sin movimiento no se puede presentar en papel.</p>
<p>¿Y si se te pasa? El artículo 97 N°2 del Código Tributario sanciona las declaraciones que no implican un pago inmediato y se presentan fuera de plazo con una multa de <strong>1 UTM a 1 UTA</strong> (<a href="https://www.bcn.cl/leychile/navegar?idNorma=6374" target="_blank" rel="noopener noreferrer">texto en LeyChile</a>). Es poco dinero comparado con una empresa en operación, pero es plata pagada por nada.</p>
<blockquote>
<p><strong>Ojo con dos detalles.</strong> Si tu empresa sí tuvo movimiento y lo declaras &quot;sin movimiento&quot;, tendrás que rectificar y pagar multas e intereses sobre el impuesto que correspondía. Y estar sin movimiento no te libera de la Operación Renta: según el <a href="https://www.sii.cl/preguntas_frecuentes/declaracion_renta/001_140_2247.htm" target="_blank" rel="noopener noreferrer">SII</a>, incluso tras más de 24 meses sin movimiento hay que preparar el balance y declarar la renta anual.</p>
</blockquote>
<h2 id="que-hace-el-bot-paso-a-paso">Qué hace el bot, paso a paso</h2>
<p>El flujo es exactamente el que harías tú en el navegador, pero sin que tengas que acordarte:</p>
<ol>
<li>Inicia sesión en el SII con el RUT y la clave de la empresa, que lee desde un archivo <code>.env</code>.</li>
<li>Abre la declaración del F29 y elige el período anterior: si hoy es octubre, declara septiembre.</li>
<li>Marca la declaración sin movimiento y confirma el envío.</li>
<li>Busca el folio en la consulta de declaraciones del SII.</li>
<li>Anota el período, la fecha y el folio en un CSV, que queda como registro.</li>
</ol>
<p>Usa <a href="https://www.selenium.dev/" target="_blank" rel="noopener noreferrer">Selenium</a> para manejar Chrome como lo haría una persona. Es la opción natural cuando el portal no ofrece una API para hacer el trámite, y es la misma técnica que usamos para otros trámites del portal en el <a href="/projects">caso de bots para el SII</a>.</p>
<p>Calcular el período parece trivial, pero tiene su trampa. La primera versión usaba <code>locale</code> y <code>strftime(&quot;%B&quot;)</code> para obtener el nombre del mes en español. Eso depende de que el sistema tenga instalado el idioma español, y en muchos servidores no está: el script falla antes de empezar. Una tabla propia evita el problema:</p>
<figure class="code"><figcaption><span>periodo.py</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">from</span><span style="color:#C6CFDD"> datetime </span><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> date</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C6CFDD">MESES </span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD"> [</span><span style="color:#FFC857">"enero"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"febrero"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"marzo"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"abril"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"mayo"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"junio"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"julio"</span><span style="color:#C6CFDD">,</span></span>
<span class="line"><span style="color:#FFC857">         "agosto"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"septiembre"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"octubre"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"noviembre"</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">"diciembre"</span><span style="color:#C6CFDD">]</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span style="color:#47E5C2">def</span><span style="color:#7C9CFF"> periodo_anterior</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">hoy</span><span style="color:#C6CFDD">: date) -> tuple[</span><span style="color:#8DF0D8">str</span><span style="color:#C6CFDD">, </span><span style="color:#8DF0D8">int</span><span style="color:#C6CFDD">]:</span></span>
<span class="line"><span style="color:#FFC857">    """Mes y año que se declaran: siempre el mes anterior al actual."""</span></span>
<span class="line"><span style="color:#47E5C2">    if</span><span style="color:#C6CFDD"> hoy.month </span><span style="color:#47E5C2">==</span><span style="color:#F2C7A5"> 1</span><span style="color:#C6CFDD">:</span></span>
<span class="line"><span style="color:#47E5C2">        return</span><span style="color:#C6CFDD"> MESES[</span><span style="color:#F2C7A5">11</span><span style="color:#C6CFDD">], hoy.year </span><span style="color:#47E5C2">-</span><span style="color:#F2C7A5"> 1</span></span>
<span class="line"><span style="color:#47E5C2">    return</span><span style="color:#C6CFDD"> MESES[hoy.month </span><span style="color:#47E5C2">-</span><span style="color:#F2C7A5"> 2</span><span style="color:#C6CFDD">], hoy.year</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span style="color:#7C9CFF">print</span><span style="color:#C6CFDD">(</span><span style="color:#7C9CFF">periodo_anterior</span><span style="color:#C6CFDD">(</span><span style="color:#7C9CFF">date</span><span style="color:#C6CFDD">(</span><span style="color:#F2C7A5">2026</span><span style="color:#C6CFDD">, </span><span style="color:#F2C7A5">1</span><span style="color:#C6CFDD">, </span><span style="color:#F2C7A5">8</span><span style="color:#C6CFDD">)))   </span><span style="color:#8F9CB3;font-style:italic"># ('diciembre', 2025)</span></span>
<span class="line"><span style="color:#7C9CFF">print</span><span style="color:#C6CFDD">(</span><span style="color:#7C9CFF">periodo_anterior</span><span style="color:#C6CFDD">(</span><span style="color:#7C9CFF">date</span><span style="color:#C6CFDD">(</span><span style="color:#F2C7A5">2026</span><span style="color:#C6CFDD">, </span><span style="color:#F2C7A5">10</span><span style="color:#C6CFDD">, </span><span style="color:#F2C7A5">14</span><span style="color:#C6CFDD">)))  </span><span style="color:#8F9CB3;font-style:italic"># ('septiembre', 2026)</span></span></code></pre></figure>
<h2 id="de-script-a-proceso-lo-que-cambiariamos">De script a proceso: lo que cambiaríamos</h2>
<p>La versión publicada en GitHub resuelve mi caso: un computador, una empresa y yo mirando la pantalla. Para que una empresa lo deje corriendo sin mirar, hay que resolver cinco cosas más. Estas son las que revisamos en cualquier automatización antes de darla por terminada.</p>
<h3 id="1-esperar-a-que-la-pagina-este-lista-no-un-tiempo-fijo">1. Esperar a que la página esté lista, no un tiempo fijo</h3>
<p>El script original espera segundos fijos entre pasos (<code>countdown(5)</code>, <code>countdown(7)</code>). Si el SII está lento ese día, el clic llega antes que el botón y todo falla. Si está rápido, pierdes tiempo. Selenium permite esperar hasta que el elemento esté disponible:</p>
<figure class="code"><figcaption><span>Python</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">from</span><span style="color:#C6CFDD"> selenium.webdriver.common.by </span><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> By</span></span>
<span class="line"><span style="color:#47E5C2">from</span><span style="color:#C6CFDD"> selenium.webdriver.support </span><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> expected_conditions </span><span style="color:#47E5C2">as</span><span style="color:#C6CFDD"> EC</span></span>
<span class="line"><span style="color:#47E5C2">from</span><span style="color:#C6CFDD"> selenium.webdriver.support.ui </span><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> WebDriverWait</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span style="color:#47E5C2">def</span><span style="color:#7C9CFF"> clic</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">driver</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">selector</span><span style="color:#C6CFDD">: </span><span style="color:#8DF0D8">str</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">segundos</span><span style="color:#C6CFDD">: </span><span style="color:#8DF0D8">int</span><span style="color:#47E5C2"> =</span><span style="color:#F2C7A5"> 30</span><span style="color:#C6CFDD">) -> </span><span style="color:#F2C7A5">None</span><span style="color:#C6CFDD">:</span></span>
<span class="line"><span style="color:#FFC857">    """Hace clic apenas el botón se puede usar, con un máximo de espera."""</span></span>
<span class="line"><span style="color:#C6CFDD">    boton </span><span style="color:#47E5C2">=</span><span style="color:#7C9CFF"> WebDriverWait</span><span style="color:#C6CFDD">(driver, segundos).</span><span style="color:#7C9CFF">until</span><span style="color:#C6CFDD">(</span></span>
<span class="line"><span style="color:#C6CFDD">        EC.</span><span style="color:#7C9CFF">element_to_be_clickable</span><span style="color:#C6CFDD">((By.CSS_SELECTOR, selector))</span></span>
<span class="line"><span style="color:#C6CFDD">    )</span></span>
<span class="line"><span style="color:#C6CFDD">    boton.</span><span style="color:#7C9CFF">click</span><span style="color:#C6CFDD">()</span></span></code></pre></figure>
<h3 id="2-selectores-que-sobrevivan-a-un-cambio-de-diseno">2. Selectores que sobrevivan a un cambio de diseño</h3>
<p>Rutas como <code>/html/body/div[1]/div/div/div/div[3]/button[2]</code> se rompen con cualquier ajuste en la página. Conviene buscar elementos por su <code>id</code>, por su etiqueta o por el texto del botón, que cambian mucho menos que la estructura.</p>
<h3 id="3-saber-si-ya-esta-hecho-antes-de-hacerlo">3. Saber si ya está hecho antes de hacerlo</h3>
<p>En la primera versión, si el envío fallaba el script imprimía &quot;este mes ya fue declarado o ocurrió un error&quot;. Son dos situaciones muy distintas: una es éxito y la otra exige actuar. Un proceso confiable revisa antes de actuar y verifica después:</p>
<figure class="code"><figcaption><span>Python</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> csv</span></span>
<span class="line"><span style="color:#47E5C2">from</span><span style="color:#C6CFDD"> pathlib </span><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> Path</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C6CFDD">REGISTRO </span><span style="color:#47E5C2">=</span><span style="color:#7C9CFF"> Path</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">"declaraciones/meses_declarados.csv"</span><span style="color:#C6CFDD">)</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span style="color:#47E5C2">def</span><span style="color:#7C9CFF"> ya_declarado</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">periodo</span><span style="color:#C6CFDD">: </span><span style="color:#8DF0D8">str</span><span style="color:#C6CFDD">) -> </span><span style="color:#8DF0D8">bool</span><span style="color:#C6CFDD">:</span></span>
<span class="line"><span style="color:#FFC857">    """True si el período ya tiene folio en el registro."""</span></span>
<span class="line"><span style="color:#47E5C2">    if</span><span style="color:#47E5C2"> not</span><span style="color:#C6CFDD"> REGISTRO.</span><span style="color:#7C9CFF">exists</span><span style="color:#C6CFDD">():</span></span>
<span class="line"><span style="color:#47E5C2">        return</span><span style="color:#F2C7A5"> False</span></span>
<span class="line"><span style="color:#47E5C2">    with</span><span style="color:#C6CFDD"> REGISTRO.</span><span style="color:#7C9CFF">open</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">newline</span><span style="color:#47E5C2">=</span><span style="color:#FFC857">""</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">encoding</span><span style="color:#47E5C2">=</span><span style="color:#FFC857">"utf-8"</span><span style="color:#C6CFDD">) </span><span style="color:#47E5C2">as</span><span style="color:#C6CFDD"> f:</span></span>
<span class="line"><span style="color:#47E5C2">        return</span><span style="color:#7C9CFF"> any</span><span style="color:#C6CFDD">(fila[</span><span style="color:#FFC857">"periodo"</span><span style="color:#C6CFDD">] </span><span style="color:#47E5C2">==</span><span style="color:#C6CFDD"> periodo </span><span style="color:#47E5C2">and</span><span style="color:#C6CFDD"> fila[</span><span style="color:#FFC857">"folio"</span><span style="color:#C6CFDD">] </span><span style="color:#47E5C2">for</span><span style="color:#C6CFDD"> fila </span><span style="color:#47E5C2">in</span><span style="color:#C6CFDD"> csv.</span><span style="color:#7C9CFF">DictReader</span><span style="color:#C6CFDD">(f))</span></span></code></pre></figure>
<p>Con esa función el bot puede correr todos los días sin riesgo: si el mes ya tiene folio, no hace nada. Después de enviar, consulta el folio en el SII. Si no lo encuentra, no lo da por hecho: avisa.</p>
<h3 id="4-evidencia-que-no-se-pueda-confundir">4. Evidencia que no se pueda confundir</h3>
<p>Revisando el código para este artículo encontramos un error que vale como lección. El paso que descarga el certificado en PDF quedó comentado, pero la función que renombra &quot;el último PDF de la carpeta&quot; sigue activa. Si la carpeta tiene el PDF del mes anterior, lo renombra como si fuera el actual. La regla es simple: nunca supongas qué se descargó. Verifica el nombre, la fecha y, si se puede, el contenido.</p>
<h3 id="5-un-calendario-y-alguien-que-reciba-el-aviso">5. Un calendario y alguien que reciba el aviso</h3>
<p>Un script que corre solo cuando te acuerdas de abrirlo tiene el mismo problema que la declaración manual. En un servidor Linux basta una línea de <code>cron</code> (en Windows, el Programador de tareas):</p>
<figure class="code"><figcaption><span>Terminal</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#8F9CB3;font-style:italic"># Todos los días a las 9:00, del 5 al 25 de cada mes</span></span>
<span class="line"><span style="color:#7C9CFF">0</span><span style="color:#F2C7A5"> 9</span><span style="color:#FFC857"> 5-25</span><span style="color:#FFC857"> *</span><span style="color:#FFC857"> *</span><span style="color:#FFC857"> /opt/sii_bot/.venv/bin/python</span><span style="color:#FFC857"> /opt/sii_bot/main.py</span></span></code></pre></figure>
<p>Como el bot revisa antes de actuar, correr todos los días no duplica nada: el primer día declara y los siguientes solo confirman. Si el día 25 todavía no hay folio, quedan tres días para resolverlo a mano antes del 28. Cada resultado (el folio o el error) debería llegar a quien corresponde, por correo o por un grupo de Telegram.</p>
<p>La clave tributaria merece su propio cuidado. Un <code>.env</code> en un notebook sirve para probar. En producción la clave vive en el servidor, fuera del repositorio y con acceso restringido.</p>
<h2 id="cuando-conviene-y-cuando-no">Cuándo conviene y cuándo no</h2>
<p>Conviene para sociedades sin actividad: SpA recién creadas, sociedades de inversión, empresas en pausa o un contador que lleva varias de ellas. Para varias empresas, el mismo bot recorre una lista de RUT.</p>
<p>No conviene declarar sin movimiento de forma automática si la empresa tiene movimiento. Ahí el F29 lleva montos reales y el camino es otro: la propuesta de declaración que arma el SII con el Registro de Compras y Ventas. Una automatización puede prepararla y revisar las diferencias, pero la aprobación final la da una persona. Así trabajamos en Star Apps: la máquina hace el trabajo repetitivo y tu equipo revisa, aprueba y decide.</p>
<p>El código está en <a href="https://github.com/rodolflying/sii_bot" target="_blank" rel="noopener noreferrer">GitHub</a>. Si quieres que corra en un servidor, con avisos y para varias empresas, conversemos.</p>
]]></content:encoded>
    </item>
    <item>
      <title>AutoRead: una extensión para escuchar cualquier texto sin que suene a robot</title>
      <link>https://rodolflying.github.io/blog/extension-chrome-leer-en-voz-alta</link>
      <guid isPermaLink="true">https://rodolflying.github.io/blog/extension-chrome-leer-en-voz-alta</guid>
      <pubDate>Wed, 14 Oct 2026 15:00:00 GMT</pubDate>
      <category>herramientas</category>
      <description>Seleccionas un texto, presionas Leer y lo escuchas con una voz natural. Cómo funciona por dentro y cómo llevamos la misma idea al botón Escuchar de este blog.</description>
      <content:encoded><![CDATA[<p>Paso buena parte del día leyendo respuestas largas de asistentes de IA, documentación y artículos. En 2023 escribí una primera extensión para que el navegador me leyera en voz alta las respuestas de ChatGPT. Con textos cortos funcionaba. Con los largos sonaba mal: leía los enlaces letra por letra, se trababa con las viñetas y cortaba la entonación en cada coma.</p>
<p><a href="https://github.com/rodolflying/TextToSpeech-ChromeExtension" target="_blank" rel="noopener noreferrer">AutoRead</a> es la versión que reescribí en 2026 con una sola meta: no hacer más cosas, sino hablar mejor. Este artículo explica cómo está hecha y qué decisiones hacen la diferencia. Al final verás que la misma idea quedó integrada en este blog: si prefieres escuchar este texto en vez de leerlo, usa el botón <strong>Escuchar artículo</strong> de arriba.</p>
<h2 id="como-se-usa">Cómo se usa</h2>
<ol>
<li>Selecciona un texto en cualquier página.</li>
<li>Abre AutoRead desde la barra de extensiones.</li>
<li>Elige la voz, o déjala en automático, y la velocidad, entre 0,5x y 3x.</li>
<li>Presiona <strong>Leer</strong>. Puedes detenerla cuando quieras.</li>
</ol>
<p>La voz y la velocidad quedan guardadas para la próxima vez.</p>
<h2 id="como-esta-hecha">Cómo está hecha</h2>
<p>Es una extensión de Chrome con Manifest V3, el formato actual de extensiones. Tiene tres piezas, cada una con una sola tarea:</p>
<ul>
<li><strong>El panel</strong> (<code>popup.html</code> y <code>popup.js</code>): los controles de voz, velocidad, leer y detener.</li>
<li><strong>El service worker</strong> (<code>background.js</code>): obtiene el texto seleccionado con <code>chrome.scripting</code>, lo prepara y se lo entrega al motor de voz del navegador con <code>chrome.tts</code>.</li>
<li><strong>El manifiesto</strong> (<code>manifest.json</code>): declara los permisos que necesita, que son <code>activeTab</code>, <code>scripting</code>, <code>storage</code> y <code>tts</code>.</li>
</ul>
<p>Cuando la voz está en automático, detecta el idioma del texto con <code>chrome.i18n.detectLanguage</code> y elige una voz de ese idioma. Las preferencias se guardan con <code>chrome.storage.local</code>.</p>
<h2 id="tres-decisiones-para-que-no-suene-a-robot">Tres decisiones para que no suene a robot</h2>
<h3 id="1-limpiar-el-texto-antes-de-leerlo">1. Limpiar el texto antes de leerlo</h3>
<p>El texto copiado de una página trae ruido que un motor de voz lee tal cual: direcciones web, viñetas, saltos de línea y espacios de más. Una limpieza mínima cambia mucho la experiencia:</p>
<figure class="code"><figcaption><span>limpiar.js</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">function</span><span style="color:#7C9CFF"> limpiarParaLeer</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">texto</span><span style="color:#C6CFDD">) {</span></span>
<span class="line"><span style="color:#47E5C2">  return</span><span style="color:#C6CFDD"> texto</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">replace</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">/­/</span><span style="color:#47E5C2">g</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">''</span><span style="color:#C6CFDD">)                </span><span style="color:#8F9CB3;font-style:italic">// guiones invisibles de partición</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">replace</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">/https</span><span style="color:#47E5C2">?</span><span style="color:#FFC857">:</span><span style="color:#F2C7A5">\/\/</span><span style="color:#FFC857">\S</span><span style="color:#47E5C2">+</span><span style="color:#FFC857">/</span><span style="color:#47E5C2">g</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">'enlace'</span><span style="color:#C6CFDD">)  </span><span style="color:#8F9CB3;font-style:italic">// nadie quiere oír una URL letra por letra</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">replace</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">/</span><span style="color:#47E5C2">^</span><span style="color:#FFC857">[•\-*]\s</span><span style="color:#47E5C2">+</span><span style="color:#FFC857">/</span><span style="color:#47E5C2">gm</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">', '</span><span style="color:#C6CFDD">)         </span><span style="color:#8F9CB3;font-style:italic">// las viñetas se vuelven pausas breves</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">replace</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">/[ \t]</span><span style="color:#47E5C2">+</span><span style="color:#FFC857">/</span><span style="color:#47E5C2">g</span><span style="color:#C6CFDD">, </span><span style="color:#FFC857">' '</span><span style="color:#C6CFDD">)</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">trim</span><span style="color:#C6CFDD">();</span></span>
<span class="line"><span style="color:#C6CFDD">}</span></span></code></pre></figure>
<p>Los saltos de línea se conservan a propósito: marcan dónde termina cada párrafo, y eso se usa en el paso siguiente.</p>
<h3 id="2-dividir-por-parrafos-no-por-comas">2. Dividir por párrafos, no por comas</h3>
<p>Un motor de voz decide la entonación según el fragmento que recibe. Si le entregas el texto cortado en cada coma, cada pedazo suena como una frase aparte, con la voz subiendo y bajando sin sentido. Si le entregas el párrafo completo, la entonación acompaña la idea.</p>
<p>AutoRead envía un párrafo a la vez. Solo si un párrafo supera los 1.500 caracteres lo divide en oraciones:</p>
<figure class="code"><figcaption><span>trozos.js</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">function</span><span style="color:#7C9CFF"> enTrozos</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">texto</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">maximo</span><span style="color:#47E5C2"> =</span><span style="color:#F2C7A5"> 1500</span><span style="color:#C6CFDD">) {</span></span>
<span class="line"><span style="color:#47E5C2">  return</span><span style="color:#C6CFDD"> texto</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">split</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">/\n</span><span style="color:#47E5C2">+</span><span style="color:#FFC857">/</span><span style="color:#C6CFDD">)                          </span><span style="color:#8F9CB3;font-style:italic">// un trozo por párrafo</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">map</span><span style="color:#C6CFDD">((</span><span style="color:#EEF2F8">p</span><span style="color:#C6CFDD">) </span><span style="color:#47E5C2">=></span><span style="color:#C6CFDD"> p.</span><span style="color:#7C9CFF">trim</span><span style="color:#C6CFDD">())</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">filter</span><span style="color:#C6CFDD">(Boolean)</span></span>
<span class="line"><span style="color:#C6CFDD">    .</span><span style="color:#7C9CFF">flatMap</span><span style="color:#C6CFDD">((</span><span style="color:#EEF2F8">p</span><span style="color:#C6CFDD">) </span><span style="color:#47E5C2">=></span><span style="color:#C6CFDD"> (p.length </span><span style="color:#47E5C2">&#x3C;=</span><span style="color:#C6CFDD"> maximo </span><span style="color:#47E5C2">?</span><span style="color:#C6CFDD"> [p] </span><span style="color:#47E5C2">:</span><span style="color:#C6CFDD"> p.</span><span style="color:#7C9CFF">match</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">/[</span><span style="color:#47E5C2">^</span><span style="color:#FFC857">.!?]</span><span style="color:#47E5C2">+</span><span style="color:#FFC857">[.!?]</span><span style="color:#47E5C2">*</span><span style="color:#FFC857">/</span><span style="color:#47E5C2">g</span><span style="color:#C6CFDD">).</span><span style="color:#7C9CFF">map</span><span style="color:#C6CFDD">((</span><span style="color:#EEF2F8">s</span><span style="color:#C6CFDD">) </span><span style="color:#47E5C2">=></span><span style="color:#C6CFDD"> s.</span><span style="color:#7C9CFF">trim</span><span style="color:#C6CFDD">())));</span></span>
<span class="line"><span style="color:#C6CFDD">}</span></span></code></pre></figure>
<p>Los trozos se leen en orden: cuando el motor avisa que terminó uno, empieza el siguiente. Así nunca se superponen y detener la lectura es inmediato.</p>
<h3 id="3-la-voz-correcta-para-el-idioma">3. La voz correcta para el idioma</h3>
<p>Una voz en inglés leyendo español suena peor que cualquier robot. Por eso el modo automático detecta el idioma antes de elegir la voz.</p>
<p>Hay un detalle que importa en una empresa. Las voces disponibles son de dos tipos. Las <strong>locales</strong> vienen con el sistema operativo y convierten el texto en audio dentro del computador. Las <strong>en línea</strong>, como las de Google en Chrome o las voces &quot;Natural&quot; de Edge, suenan mejor, pero el texto viaja a los servidores del proveedor para convertirse en audio. Para un artículo público da lo mismo. Para un contrato o un informe interno, conviene elegir una voz local.</p>
<h2 id="la-misma-idea-integrada-en-este-blog">La misma idea, integrada en este blog</h2>
<p>Cada artículo de este blog tiene un botón <strong>Escuchar artículo</strong>. Una página web no puede usar <code>chrome.tts</code>, que es exclusivo de las extensiones, así que usa la <a href="https://developer.mozilla.org/es/docs/Web/API/Web_Speech_API" target="_blank" rel="noopener noreferrer">Web Speech API</a>, que viene en los navegadores modernos. Las reglas son las mismas: limpiar el texto, leer por bloques (títulos, párrafos y elementos de lista) y preferir una voz en español. Los bloques de código se anuncian, pero no se leen, y el párrafo que se está leyendo queda destacado para que puedas seguirlo con la vista.</p>
<p>Aprendimos una cosa más en el camino. En Chrome, algunas voces en línea pueden cortarse cuando un fragmento es muy largo. Por eso aquí los párrafos largos se agrupan en oraciones de hasta unos 220 caracteres, sin cortar nunca una oración por la mitad. El núcleo cabe en pocas líneas:</p>
<figure class="code"><figcaption><span>escuchar.js</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">function</span><span style="color:#7C9CFF"> leer</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">trozos</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">voz</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">i</span><span style="color:#47E5C2"> =</span><span style="color:#F2C7A5"> 0</span><span style="color:#C6CFDD">) {</span></span>
<span class="line"><span style="color:#47E5C2">  if</span><span style="color:#C6CFDD"> (i </span><span style="color:#47E5C2">>=</span><span style="color:#C6CFDD"> trozos.length) </span><span style="color:#47E5C2">return</span><span style="color:#C6CFDD">;</span></span>
<span class="line"><span style="color:#47E5C2">  const</span><span style="color:#F2C7A5"> frase</span><span style="color:#47E5C2"> =</span><span style="color:#47E5C2"> new</span><span style="color:#7C9CFF"> SpeechSynthesisUtterance</span><span style="color:#C6CFDD">(trozos[i]);</span></span>
<span class="line"><span style="color:#C6CFDD">  frase.lang </span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD"> voz?.lang </span><span style="color:#47E5C2">??</span><span style="color:#FFC857"> 'es-CL'</span><span style="color:#C6CFDD">;</span></span>
<span class="line"><span style="color:#47E5C2">  if</span><span style="color:#C6CFDD"> (voz) frase.voice </span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD"> voz;</span></span>
<span class="line"><span style="color:#C6CFDD">  frase.</span><span style="color:#7C9CFF">onend</span><span style="color:#47E5C2"> =</span><span style="color:#C6CFDD"> () </span><span style="color:#47E5C2">=></span><span style="color:#7C9CFF"> leer</span><span style="color:#C6CFDD">(trozos, voz, i </span><span style="color:#47E5C2">+</span><span style="color:#F2C7A5"> 1</span><span style="color:#C6CFDD">);  </span><span style="color:#8F9CB3;font-style:italic">// el siguiente trozo empieza al terminar este</span></span>
<span class="line"><span style="color:#C6CFDD">  speechSynthesis.</span><span style="color:#7C9CFF">speak</span><span style="color:#C6CFDD">(frase);</span></span>
<span class="line"><span style="color:#C6CFDD">}</span></span></code></pre></figure>
<p>Las voces cargan de forma asíncrona: la primera llamada a <code>speechSynthesis.getVoices()</code> puede venir vacía. Hay que escuchar el evento <code>voiceschanged</code> antes de elegir una.</p>
<h2 id="para-que-sirve-en-una-empresa">Para qué sirve en una empresa</h2>
<ul>
<li><strong>Accesibilidad.</strong> Para personas con baja visión, dislexia o fatiga visual, escuchar es la diferencia entre leer un documento largo o dejarlo para después.</li>
<li><strong>Revisar mejor.</strong> Escuchar un informe mientras sigues el texto ayuda a encontrar errores que la vista se salta, porque el oído no completa palabras como lo hace el ojo.</li>
<li><strong>Manuales y procedimientos.</strong> Un procedimiento que se puede escuchar llega a quien no tiene tiempo de sentarse a leerlo.</li>
</ul>
<p>No requiere licencias ni servicios externos: la voz ya está en el navegador. Agregar un botón de escuchar a una intranet, un visor de reportes o un sistema interno es poco trabajo y le sirve a todo el equipo.</p>
<h2 id="como-instalarla">Cómo instalarla</h2>
<p>AutoRead no está en la tienda de Chrome. Se instala desde el código:</p>
<ol>
<li>Descarga o clona el <a href="https://github.com/rodolflying/TextToSpeech-ChromeExtension" target="_blank" rel="noopener noreferrer">repositorio</a>.</li>
<li>Abre <code>chrome://extensions</code> y activa el <strong>Modo de desarrollador</strong>.</li>
<li>Presiona <strong>Cargar descomprimida</strong> y elige la carpeta del proyecto.</li>
<li>Fíjala en la barra con el ícono de la pieza de rompecabezas.</li>
</ol>
<p>En Edge (<code>edge://extensions</code>) y Brave (<code>brave://extensions</code>) los pasos son los mismos.</p>
<p>Si la usas y se te ocurre una mejora, abre un issue o haz un fork. Así creció <a href="/blog/integrar-sistemas-sin-api">GPT_scraper</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cuando el sistema no tiene API: dos caminos que aprendimos con GPT_scraper</title>
      <link>https://rodolflying.github.io/blog/integrar-sistemas-sin-api</link>
      <guid isPermaLink="true">https://rodolflying.github.io/blog/integrar-sistemas-sin-api</guid>
      <pubDate>Wed, 14 Oct 2026 15:00:00 GMT</pubDate>
      <category>integraciones</category>
      <description>Un script para respaldar conversaciones de ChatGPT llegó a 139 estrellas en GitHub. Lo que enseñó sirve para integrar portales y sistemas que no ofrecen API.</description>
      <content:encoded><![CDATA[<p>En marzo de 2023 ChatGPT no tenía un botón para descargar tu historial. Yo lo usaba a diario y quería buscar, ordenar y analizar mis conversaciones, pero no había cómo sacarlas. Así nació <a href="https://github.com/rodolflying/GPT_scraper" target="_blank" rel="noopener noreferrer">GPT_scraper</a>, un par de scripts en Python que extraían el historial completo por dos caminos distintos.</p>
<p>El repositorio tiene hoy 139 estrellas y 12 forks en GitHub. No es por el código, que era sencillo. Es porque mucha gente tenía el mismo problema: <strong>sus datos estaban ahí, pero sin una salida</strong>.</p>
<p>Ese problema es exactamente el que vemos en las empresas. El portal del proveedor muestra las órdenes pero no las exporta. La plataforma antigua guarda años de historia y nadie sabe cómo sacarla. El sitio del Estado obliga a descargar documento por documento. Lo que aprendimos con GPT_scraper se aplica tal cual.</p>
<h2 id="la-idea-clave-toda-pagina-web-conversa-con-un-servidor">La idea clave: toda página web conversa con un servidor</h2>
<p>Una aplicación web moderna no trae los datos escritos en la página. El navegador los pide a un servidor, casi siempre en formato JSON, y después los dibuja. Puedes verlo tú mismo: abre las herramientas de desarrollo del navegador (F12), ve a la pestaña <strong>Network</strong> o <strong>Red</strong>, filtra por <strong>Fetch/XHR</strong> y recarga la página. Cada línea es una de esas conversaciones.</p>
<p>De ahí salen los dos caminos.</p>
<h2 id="camino-1-llamar-a-los-mismos-servicios-que-usa-la-web">Camino 1: llamar a los mismos servicios que usa la web</h2>
<p>Si la página pide los datos a una dirección, tu programa puede pedirlos a la misma dirección con tu sesión. En GPT_scraper había dos llamadas: una que listaba las conversaciones por páginas de 100 y otra que traía cada conversación completa a partir de su identificador.</p>
<p>El procedimiento es siempre parecido:</p>
<ol>
<li>Encuentra en la pestaña Network la llamada que trae lo que necesitas.</li>
<li>Cópiala con clic derecho, <strong>Copy as cURL</strong>, y pruébala en una herramienta como Postman o Insomnia.</li>
<li>Identifica qué cabeceras identifican tu sesión y cómo funciona la paginación.</li>
<li>Reprodúcela en Python con <code>requests</code>.</li>
</ol>
<p>Revisando el código para este artículo encontramos un error de paginación en la versión original. Después de la primera página sumaba 101 al desplazamiento en vez de 100, así que se saltaba una conversación. Así se recorre una lista paginada sin ese problema:</p>
<figure class="code"><figcaption><span>paginacion.py</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> random</span></span>
<span class="line"><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> time</span></span>
<span class="line"></span>
<span class="line"><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> requests</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span style="color:#47E5C2">def</span><span style="color:#7C9CFF"> traer_todo</span><span style="color:#C6CFDD">(</span><span style="color:#EEF2F8">session</span><span style="color:#C6CFDD">: requests.Session, </span><span style="color:#EEF2F8">url</span><span style="color:#C6CFDD">: </span><span style="color:#8DF0D8">str</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">por_pagina</span><span style="color:#C6CFDD">: </span><span style="color:#8DF0D8">int</span><span style="color:#47E5C2"> =</span><span style="color:#F2C7A5"> 100</span><span style="color:#C6CFDD">) -> list[</span><span style="color:#8DF0D8">dict</span><span style="color:#C6CFDD">]:</span></span>
<span class="line"><span style="color:#FFC857">    """Recorre un servicio paginado con offset y limit hasta la última página."""</span></span>
<span class="line"><span style="color:#C6CFDD">    items, offset </span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD"> [], </span><span style="color:#F2C7A5">0</span></span>
<span class="line"><span style="color:#47E5C2">    while</span><span style="color:#F2C7A5"> True</span><span style="color:#C6CFDD">:</span></span>
<span class="line"><span style="color:#C6CFDD">        r </span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD"> session.</span><span style="color:#7C9CFF">get</span><span style="color:#C6CFDD">(url, </span><span style="color:#EEF2F8">params</span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD">{</span><span style="color:#FFC857">"offset"</span><span style="color:#C6CFDD">: offset, </span><span style="color:#FFC857">"limit"</span><span style="color:#C6CFDD">: por_pagina}, </span><span style="color:#EEF2F8">timeout</span><span style="color:#47E5C2">=</span><span style="color:#F2C7A5">30</span><span style="color:#C6CFDD">)</span></span>
<span class="line"><span style="color:#C6CFDD">        r.</span><span style="color:#7C9CFF">raise_for_status</span><span style="color:#C6CFDD">()</span></span>
<span class="line"><span style="color:#C6CFDD">        pagina </span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD"> r.</span><span style="color:#7C9CFF">json</span><span style="color:#C6CFDD">()[</span><span style="color:#FFC857">"items"</span><span style="color:#C6CFDD">]</span></span>
<span class="line"><span style="color:#C6CFDD">        items.</span><span style="color:#7C9CFF">extend</span><span style="color:#C6CFDD">(pagina)</span></span>
<span class="line"><span style="color:#47E5C2">        if</span><span style="color:#7C9CFF"> len</span><span style="color:#C6CFDD">(pagina) </span><span style="color:#47E5C2">&#x3C;</span><span style="color:#C6CFDD"> por_pagina:</span></span>
<span class="line"><span style="color:#47E5C2">            return</span><span style="color:#C6CFDD"> items</span></span>
<span class="line"><span style="color:#C6CFDD">        offset </span><span style="color:#47E5C2">+=</span><span style="color:#C6CFDD"> por_pagina</span></span>
<span class="line"><span style="color:#C6CFDD">        time.</span><span style="color:#7C9CFF">sleep</span><span style="color:#C6CFDD">(random.</span><span style="color:#7C9CFF">uniform</span><span style="color:#C6CFDD">(</span><span style="color:#F2C7A5">2</span><span style="color:#C6CFDD">, </span><span style="color:#F2C7A5">5</span><span style="color:#C6CFDD">))  </span><span style="color:#8F9CB3;font-style:italic"># ritmo de persona, no de ataque</span></span></code></pre></figure>
<p>La <code>session</code> lleva las cabeceras de tu propia sesión, las mismas que copiaste del navegador. La pausa aleatoria entre páginas no es un detalle. GPT_scraper esperaba de 2 a 5 segundos entre conversaciones para no ser bloqueado, y la versión con navegador sí llegó a ser bloqueada por pedir demasiado.</p>
<p><strong>A favor:</strong> es rápido, entrega datos ordenados y no depende del diseño de la página. Si cambian los colores o mueven un botón, sigue funcionando.</p>
<p><strong>En contra:</strong> esos servicios no están documentados y pueden cambiar sin aviso. La sesión expira, y hay que respetar los límites de velocidad y los términos de uso del sistema.</p>
<h2 id="camino-2-manejar-un-navegador-como-lo-haria-una-persona">Camino 2: manejar un navegador como lo haría una persona</h2>
<p>Cuando no hay una llamada clara que reproducir, queda el navegador. Herramientas como <a href="https://www.selenium.dev/" target="_blank" rel="noopener noreferrer">Selenium</a> o <a href="https://playwright.dev/python/" target="_blank" rel="noopener noreferrer">Playwright</a> abren Chrome, inician sesión, hacen clic, llenan formularios y leen lo que aparece en pantalla. El segundo script de GPT_scraper hacía eso: abría cada conversación y copiaba el texto.</p>
<p>Es la misma técnica de nuestro <a href="/blog/declarar-f29-sin-movimiento-automatico">bot que declara el F29 en el SII</a>. Ahí no hay datos que leer, sino un trámite que hacer: elegir el período, confirmar y guardar el folio.</p>
<p><strong>A favor:</strong> funciona casi siempre, porque hace lo mismo que una persona. Sirve para formularios, descargas de PDF y páginas que se arman en el servidor.</p>
<p><strong>En contra:</strong> es más lento y se rompe cuando cambia el diseño de la página. Además, necesita esperas bien hechas y avisos cuando algo falla.</p>
<h2 id="como-elegimos-en-un-proyecto">Cómo elegimos en un proyecto</h2>
<p>Antes de escribir una línea de código, recorremos estas opciones en orden. Cada una cuesta más de mantener que la anterior:</p>
<div class="table-wrap"><table><thead><tr><th>Opción</th><th>Cuándo usarla</th><th>Mantención</th></tr></thead><tbody><tr><td>API oficial o integración del proveedor</td><td>Siempre que exista, aunque sea de pago</td><td>Baja</td></tr><tr><td>Exportación o reporte que ya envía el sistema</td><td>Si el sistema exporta a Excel o CSV, o manda un reporte por correo</td><td>Baja</td></tr><tr><td>Servicios internos de la web</td><td>Con permiso del dueño del sistema, cuando importan el volumen y la velocidad</td><td>Media</td></tr><tr><td>Navegador automatizado</td><td>Cuando no queda otra, o el proceso es llenar formularios</td><td>Media a alta</td></tr></tbody></table></div>
<p>La segunda fila es la más subestimada. Muchas veces el sistema ya envía un reporte diario por correo que nadie aprovecha, y automatizar su lectura es lo más barato y lo más estable.</p>
<h2 id="lo-que-no-hay-que-hacer">Lo que no hay que hacer</h2>
<ul>
<li><strong>Entrar con credenciales ajenas o saltarse protecciones.</strong> En Chile, la <a href="https://www.bcn.cl/leychile/navegar?idNorma=1177743" target="_blank" rel="noopener noreferrer">Ley 21.459</a> sanciona a quien accede a un sistema &quot;sin autorización o excediendo la autorización que posea&quot; superando barreras técnicas. Se automatiza con tu cuenta, sobre tus datos y con permiso de quien administra el sistema.</li>
<li><strong>Ignorar los términos de uso.</strong> Muchos servicios prohíben el acceso automatizado a su aplicación web. GPT_scraper solo usaba tu cuenta y tus conversaciones. Aun así, hoy existe una forma oficial, y es la que corresponde usar.</li>
<li><strong>Saturar el servidor.</strong> Pausas, horarios de baja carga y guardar lo que ya descargaste para no pedirlo dos veces.</li>
<li><strong>Dejar contraseñas o tokens en el código.</strong> Van en variables de entorno o en un gestor de secretos, nunca en el repositorio.</li>
</ul>
<h2 id="que-paso-con-gpt-scraper">Qué pasó con GPT_scraper</h2>
<p>ChatGPT agregó poco después una exportación oficial, se mudó a chatgpt.com y sus servicios internos cambiaron. Hoy, para respaldar tu historial, el camino correcto es el oficial: <strong>Settings → Data controls → Export data</strong>. OpenAI te envía un enlace para descargar un archivo ZIP (<a href="https://help.openai.com/en/articles/7260999-exporting-your-chatgpt-history-and-data" target="_blank" rel="noopener noreferrer">guía de OpenAI</a>).</p>
<p>El repositorio probablemente ya no funcione tal cual, y lo dejamos como material de estudio. Sus dos caminos siguen siendo la forma más clara de explicar cómo integrar un sistema que no fue pensado para integrarse.</p>
<p>Si en tu empresa alguien saca datos de un sistema copiando y pegando, ese sistema casi seguro tiene uno de estos dos caminos. Encontrar el correcto es la primera parte de nuestro diagnóstico.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Transcribe audios de WhatsApp gratis y sin subirlos a internet</title>
      <link>https://rodolflying.github.io/blog/transcribir-audios-whatsapp-whisper</link>
      <guid isPermaLink="true">https://rodolflying.github.io/blog/transcribir-audios-whatsapp-whisper</guid>
      <pubDate>Wed, 14 Oct 2026 15:00:00 GMT</pubDate>
      <category>ia-datos</category>
      <description>Con Whisper y unas pocas líneas de Python, los audios de tu equipo se convierten en texto en tu propio computador, sin costo por minuto y sin que la información salga de la empresa.</description>
      <content:encoded><![CDATA[<p>En muchas empresas el reporte de terreno llega por WhatsApp, y en audio. Es lo más rápido para quien está con las manos ocupadas, pero lo más lento para quien recibe: hay que escucharlo entero, no se puede buscar y no queda en ningún sistema. Lo mismo pasa con las reuniones, las entrevistas y las clases.</p>
<p>Transcribir esos audios ya no requiere pagar un servicio por minuto ni subir conversaciones internas a la nube. <a href="https://github.com/openai/whisper" target="_blank" rel="noopener noreferrer">Whisper</a>, el modelo de reconocimiento de voz que OpenAI publicó como código abierto, corre en un computador de oficina y entiende bien el español. En este artículo te mostramos cómo usarlo con un script corto que probamos antes de publicar.</p>
<h2 id="por-que-local-y-no-un-servicio-en-la-nube">Por qué local y no un servicio en la nube</h2>
<p>Hay servicios que transcriben muy bien. La pregunta es qué estás dispuesto a enviarles. Un audio de terreno puede mencionar fallas, nombres, clientes o montos. Una reunión de gerencia, todavía más. Con Whisper en tu equipo:</p>
<ul>
<li><strong>El audio no sale de tu computador.</strong> Solo la primera vez se descarga el modelo; después funciona sin internet.</li>
<li><strong>No hay costo por minuto.</strong> Transcribir diez audios o diez mil cuesta lo mismo: el tiempo del computador.</li>
<li><strong>No dependes de una cuenta ni de un proveedor</strong> que cambie precios o condiciones.</li>
</ul>
<p>El costo es la velocidad. En un computador sin tarjeta gráfica, un audio de un par de minutos tarda segundos o minutos según el modelo que elijas. Para la mayoría de los usos, eso basta.</p>
<h2 id="lo-que-necesitas">Lo que necesitas</h2>
<p>Usamos <a href="https://github.com/SYSTRAN/faster-whisper" target="_blank" rel="noopener noreferrer">faster-whisper</a>, una implementación de Whisper más rápida y liviana que la original. Tiene una ventaja práctica: decodifica el audio con PyAV, que trae incluido lo necesario, así que <strong>no tienes que instalar FFmpeg</strong>. Lee directamente los <code>.ogg</code> y <code>.opus</code> de las notas de voz de WhatsApp, además de <code>.mp3</code>, <code>.m4a</code> y <code>.wav</code>.</p>
<p>Crea un entorno virtual e instala la librería:</p>
<figure class="code"><figcaption><span>Terminal</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#7C9CFF">python</span><span style="color:#FFC857"> -m</span><span style="color:#FFC857"> venv</span><span style="color:#FFC857"> .venv</span></span>
<span class="line"><span style="color:#7C9CFF">source</span><span style="color:#FFC857"> .venv/bin/activate</span><span style="color:#8F9CB3;font-style:italic">    # en Windows: .venv\Scripts\activate</span></span>
<span class="line"><span style="color:#7C9CFF">pip</span><span style="color:#FFC857"> install</span><span style="color:#FFC857"> faster-whisper</span></span></code></pre></figure>
<h2 id="el-script">El script</h2>
<p>Pon los audios en una carpeta llamada <code>audios</code>. El script los recorre en orden alfabético, transcribe cada uno y deja todo en un solo archivo de texto:</p>
<figure class="code"><figcaption><span>transcribir.py</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span style="color:#47E5C2">from</span><span style="color:#C6CFDD"> pathlib </span><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> Path</span></span>
<span class="line"></span>
<span class="line"><span style="color:#47E5C2">from</span><span style="color:#C6CFDD"> faster_whisper </span><span style="color:#47E5C2">import</span><span style="color:#C6CFDD"> WhisperModel</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C6CFDD">CARPETA </span><span style="color:#47E5C2">=</span><span style="color:#7C9CFF"> Path</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">"audios"</span><span style="color:#C6CFDD">)             </span><span style="color:#8F9CB3;font-style:italic"># .ogg/.opus de WhatsApp, .mp3, .m4a, .wav...</span></span>
<span class="line"><span style="color:#C6CFDD">SALIDA </span><span style="color:#47E5C2">=</span><span style="color:#7C9CFF"> Path</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">"transcripciones.md"</span><span style="color:#C6CFDD">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C6CFDD">modelo </span><span style="color:#47E5C2">=</span><span style="color:#7C9CFF"> WhisperModel</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">"small"</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">device</span><span style="color:#47E5C2">=</span><span style="color:#FFC857">"cpu"</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">compute_type</span><span style="color:#47E5C2">=</span><span style="color:#FFC857">"int8"</span><span style="color:#C6CFDD">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#47E5C2">with</span><span style="color:#C6CFDD"> SALIDA.</span><span style="color:#7C9CFF">open</span><span style="color:#C6CFDD">(</span><span style="color:#FFC857">"w"</span><span style="color:#C6CFDD">, </span><span style="color:#EEF2F8">encoding</span><span style="color:#47E5C2">=</span><span style="color:#FFC857">"utf-8"</span><span style="color:#C6CFDD">) </span><span style="color:#47E5C2">as</span><span style="color:#C6CFDD"> f:</span></span>
<span class="line"><span style="color:#47E5C2">    for</span><span style="color:#C6CFDD"> audio </span><span style="color:#47E5C2">in</span><span style="color:#7C9CFF"> sorted</span><span style="color:#C6CFDD">(p </span><span style="color:#47E5C2">for</span><span style="color:#C6CFDD"> p </span><span style="color:#47E5C2">in</span><span style="color:#C6CFDD"> CARPETA.</span><span style="color:#7C9CFF">iterdir</span><span style="color:#C6CFDD">() </span><span style="color:#47E5C2">if</span><span style="color:#C6CFDD"> p.</span><span style="color:#7C9CFF">is_file</span><span style="color:#C6CFDD">()):</span></span>
<span class="line"><span style="color:#C6CFDD">        segmentos, info </span><span style="color:#47E5C2">=</span><span style="color:#C6CFDD"> modelo.</span><span style="color:#7C9CFF">transcribe</span><span style="color:#C6CFDD">(</span><span style="color:#8DF0D8">str</span><span style="color:#C6CFDD">(audio), </span><span style="color:#EEF2F8">vad_filter</span><span style="color:#47E5C2">=</span><span style="color:#F2C7A5">True</span><span style="color:#C6CFDD">)</span></span>
<span class="line"><span style="color:#C6CFDD">        texto </span><span style="color:#47E5C2">=</span><span style="color:#FFC857"> " "</span><span style="color:#C6CFDD">.</span><span style="color:#7C9CFF">join</span><span style="color:#C6CFDD">(s.text.</span><span style="color:#7C9CFF">strip</span><span style="color:#C6CFDD">() </span><span style="color:#47E5C2">for</span><span style="color:#C6CFDD"> s </span><span style="color:#47E5C2">in</span><span style="color:#C6CFDD"> segmentos)</span></span>
<span class="line"><span style="color:#C6CFDD">        f.</span><span style="color:#7C9CFF">write</span><span style="color:#C6CFDD">(</span><span style="color:#47E5C2">f</span><span style="color:#FFC857">"## </span><span style="color:#F2C7A5">{</span><span style="color:#C6CFDD">audio.name</span><span style="color:#F2C7A5">}</span><span style="color:#FFC857"> (</span><span style="color:#F2C7A5">{</span><span style="color:#C6CFDD">info.language</span><span style="color:#F2C7A5">}</span><span style="color:#FFC857">)</span><span style="color:#F2C7A5">\n\n{</span><span style="color:#C6CFDD">texto</span><span style="color:#F2C7A5">}\n\n</span><span style="color:#FFC857">"</span><span style="color:#C6CFDD">)</span></span>
<span class="line"><span style="color:#7C9CFF">        print</span><span style="color:#C6CFDD">(</span><span style="color:#47E5C2">f</span><span style="color:#FFC857">"Listo: </span><span style="color:#F2C7A5">{</span><span style="color:#C6CFDD">audio.name</span><span style="color:#F2C7A5">}</span><span style="color:#FFC857">"</span><span style="color:#C6CFDD">)</span></span></code></pre></figure>
<p>Tres detalles que importan:</p>
<ul>
<li><code>compute_type=&quot;int8&quot;</code> reduce la memoria y acelera el trabajo en procesador, con una pérdida de calidad que en la práctica no se nota.</li>
<li><code>vad_filter=True</code> salta los silencios antes de transcribir. En audios de terreno, con pausas y ruido, evita frases inventadas en los tramos sin voz.</li>
<li>El idioma se detecta solo y queda en <code>info.language</code>. Si todos tus audios son en español, puedes fijarlo con <code>language=&quot;es&quot;</code> y ganar un poco de velocidad.</li>
</ul>
<p>Lo probamos con dos audios: un reporte de terreno en <code>.wav</code> y los acuerdos de una reunión en <code>.ogg</code>, el formato de WhatsApp. Este fue el resultado, sin corregir nada:</p>
<figure class="code"><figcaption><span>transcripciones.md</span><button type="button" class="code-copy" data-copy>Copiar</button></figcaption><pre class="shiki star-night" style="background-color:#0A1020;color:#C6CFDD" tabindex="0"><code><span class="line"><span>## 01-reporte-terreno.wav (es)</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Hola, te cuento cómo quedó la bomba del sector norte. Cambiamos el sello mecánico y quedó funcionando a las 4 de la tarde. Mañana hay que revisar la vibración.</span></span>
<span class="line"><span></span></span>
<span class="line"><span>## 02-reunion.ogg (es)</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Acordamos tres cosas. Primero, el informe sale el viernes. Segundo, Carla revisa los costos. Tercero, la próxima reunión es el martes.</span></span></code></pre></figure>
<p>Si necesitas saber en qué minuto se dijo algo, cada segmento trae su inicio y su fin en segundos (<code>s.start</code> y <code>s.end</code>). Puedes escribir una línea por segmento en vez de unirlos.</p>
<h2 id="que-modelo-elegir">Qué modelo elegir</h2>
<p>El primer argumento de <code>WhisperModel</code> es el tamaño del modelo. Más grande es más preciso y más lento:</p>
<div class="table-wrap"><table><thead><tr><th>Modelo</th><th>Peso aproximado</th><th>Cuándo usarlo</th></tr></thead><tbody><tr><td><code>tiny</code>, <code>base</code></td><td>75 MB y 150 MB</td><td>Pruebas rápidas o computadores muy modestos</td></tr><tr><td><code>small</code></td><td>500 MB</td><td>Buen equilibrio para español en un computador de oficina</td></tr><tr><td><code>medium</code></td><td>1,5 GB</td><td>Audios difíciles: ruido, acentos marcados, términos técnicos</td></tr><tr><td><code>turbo</code></td><td>1,6 GB</td><td>Casi la calidad del más grande, bastante más rápido; ideal con tarjeta gráfica</td></tr><tr><td><code>large-v3</code></td><td>3 GB</td><td>La máxima precisión, si el tiempo no importa</td></tr></tbody></table></div>
<p>Si tienes una tarjeta gráfica NVIDIA, cambia a <code>device=&quot;cuda&quot;</code> y <code>compute_type=&quot;float16&quot;</code>. La diferencia de velocidad es grande.</p>
<h2 id="del-script-al-proceso">Del script al proceso</h2>
<p>El script resuelve el caso de una persona con una carpeta de audios. En una empresa, lo valioso es que la transcripción llegue sola a donde se usa:</p>
<ul>
<li><strong>Una carpeta compartida o un buzón de correo</strong> donde el equipo deja los audios, y una tarea que los transcribe cada pocos minutos.</li>
<li><strong>Un resumen con los acuerdos y los pendientes</strong>, preparado por un modelo de lenguaje que también puede correr en tu servidor.</li>
<li><strong>El texto cargado en el sistema que corresponde</strong>: la orden de trabajo, la ficha del cliente o el acta de la reunión.</li>
</ul>
<p>Ahí la transcripción deja de ser un truco y se convierte en un registro: el técnico sigue reportando por audio, como le acomoda, y la jefatura tiene el texto listo para buscar y decidir.</p>
<p>Si en tu equipo los reportes llegan por audio y se pierden en el chat, cuéntanos cómo se hace hoy.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
