<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>The-M-Akita-Chronicles | AkitaOnRails.com</title><link>https://akitaonrails.github.io/tags/the-m-akita-chronicles/</link><description>Blog do Fabio Akita do Canal do YouTube 'Akitando' falando sobre tecnologia, carreira e coisas geek.</description><generator>Hugo -- gohugo.io</generator><language>pt-BR</language><lastBuildDate>Mon, 19 Oct 2026 23:20:49 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/index.xml" rel="self" type="application/rss+xml"/><item><title>Do Zero à Pós-Produção em 1 Semana - Como usar IA em Projetos de Verdade | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 12:49:58 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;the-m-akita-chronicles&lt;/a&gt;. Esta é a parte 9 e Final!&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;274 commits. 8 dias. 4 aplicações. 1.323 testes. Um sistema completo em produção — com assinantes reais recebendo uma newsletter toda segunda-feira às 7h, um podcast gerado por IA no Spotify, um blog estático no Netlify, e um bot no Discord que é minha interface operacional.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">the-m-akita-chronicles</a>. Esta é a parte 9 e Final!</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>274 commits. 8 dias. 4 aplicações. 1.323 testes. Um sistema completo em produção — com assinantes reais recebendo uma newsletter toda segunda-feira às 7h, um podcast gerado por IA no Spotify, um blog estático no Netlify, e um bot no Discord que é minha interface operacional.</p>
<p>Fiz isso com um parceiro: Claude Code, um agente de IA rodando no terminal. E a lição mais importante que tirei não é que &ldquo;IA programa sozinha&rdquo; — é que <strong>IA programando sozinha é a receita pro desastre</strong>. O que funciona é algo que já tem nome e décadas de história: Extreme Programming. Só que agora o par no pair programming não é humano.</p>
<blockquote>
  <p>Eu preciso que todos vocês passem por uma experiência: uma <strong>IMERSÃO DE 1 SEMANA</strong> completamente <strong>DESAPEGADO</strong> do código, usando a IA como pair programming, indo do zero até produção em qualquer projetinho pequeno. Vai por mim, até vocês fazerem pelo menos, não vão entender. Mas depois muda tudo.</p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/pair-programming.jpg" alt="pair programming"  loading="lazy" /></p>
<h2>O Mito do &ldquo;One-Shot Prompt&rdquo;<span class="hx:absolute hx:-mt-20" id="o-mito-do-one-shot-prompt"></span>
    <a href="#o-mito-do-one-shot-prompt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tem uma narrativa crescente de que &ldquo;vibe coding&rdquo; é escrever uma spec detalhada, dar pro agente de IA, e ele entrega o sistema pronto. Tipo aqueles vídeos de &ldquo;criei um SaaS em 10 minutos com o Cursor&rdquo;. Parece mágico até você olhar o que foi realmente entregue: um protótipo sem testes, sem segurança, sem tratamento de erros, sem deploy, sem nada que faça um sistema sobreviver ao contato com usuários reais.</p>
<p>A realidade dos meus 274 commits conta outra história:</p>
<table>
  <thead>
      <tr>
          <th>Categoria</th>
          <th>Commits</th>
          <th>%</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Features novas (<code>Add ...</code>)</td>
          <td>101</td>
          <td>37%</td>
      </tr>
      <tr>
          <td>Bug fixes (<code>Fix ...</code>)</td>
          <td>45</td>
          <td>16%</td>
      </tr>
      <tr>
          <td>Refactoring/hardening (<code>Harden</code>, <code>Extract</code>, <code>DRY</code>, <code>Rework</code>, <code>Replace</code>)</td>
          <td>27</td>
          <td>10%</td>
      </tr>
      <tr>
          <td>Segurança</td>
          <td>21</td>
          <td>8%</td>
      </tr>
      <tr>
          <td>Deploy/infra</td>
          <td>30</td>
          <td>11%</td>
      </tr>
      <tr>
          <td>Testes/CI</td>
          <td>44</td>
          <td>16%</td>
      </tr>
      <tr>
          <td>Documentação</td>
          <td>27</td>
          <td>10%</td>
      </tr>
  </tbody>
</table>
<p><strong>Só 37% dos commits são features.</strong> O resto é o trabalho que faz um software de verdade: corrigir bugs, endurecer contra falhas, proteger contra ataques, deployar, testar, documentar. Quem mostra só a parte das features está vendendo uma ilusão.</p>
<p>E mais: muitas das features surgiram <strong>porque</strong> a iteração revelou que eram necessárias. <code>ContentPreflight</code> não estava no plano original — nasceu quando a segunda newsletter saiu com uma seção vazia porque um job falhou silenciosamente. <code>RecoverStaleDeliveriesJob</code> nasceu quando percebi que um crash no meio do envio deixava emails num limbo eterno. <code>StealthBrowser</code> nasceu quando o Morningstar bloqueou o HTTParty. Nenhum spec do mundo prevê isso. <strong>O sistema correto emerge da iteração, não da especificação.</strong></p>
<h2>Pair Programming Com Um Agente de IA<span class="hx:absolute hx:-mt-20" id="pair-programming-com-um-agente-de-ia"></span>
    <a href="#pair-programming-com-um-agente-de-ia" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Eu não uso o Claude Code como um gerador de código. Uso como um par de pair programming.</p>
<p>A diferença é fundamental. Um gerador de código recebe uma spec e devolve output. Um par de pair programming <strong>conversa</strong>. Eu interrompo quando ele toma um caminho errado. Dou contexto que ele não tem. Questiono decisões. Peço alternativas. Às vezes digo &ldquo;para, isso tá ficando complicado demais&rdquo; e ele simplifica.</p>
<p>Alguns exemplos reais dos 274 commits:</p>
<p><strong>Interrupção por excesso de engenharia.</strong> Quando pedi o sistema de email delivery, a primeira proposta tinha uma state machine com 8 estados, retry queues separadas, e dead letter handling. Interrompi: &ldquo;Simplifica. Quatro estados: pending, sending, sent, unknown. Na dúvida, não reenvia.&rdquo; O resultado foi o <code>SendSingleEmailJob</code> com a variável <code>ses_accepted</code> como pivô — 40 linhas que resolvem o problema inteiro.</p>
<p><strong>Contexto que o agente não tem.</strong> Quando estávamos no Yahoo Finance, o Claude tentou usar HTTParty. Expliquei: &ldquo;Yahoo faz TLS fingerprinting, vai bloquear qualquer HTTP client que não seja browser real.&rdquo; Resultado: headless Chromium com crumb authentication. Isso não está em nenhum tutorial — é o tipo de conhecimento que vem de tentar e falhar.</p>
<p><strong>Decisão de arquitetura conjunta.</strong> O podcast pipeline poderia ser um monolito Python. Sugeri: &ldquo;Rails orquestra, Python serve TTS via HTTP, conteúdo integra por filesystem.&rdquo; O resultado — <code>QwenTtsClient</code> no Ruby chamando <code>podcast-tts</code> em Python — mantém cada linguagem no que faz melhor.</p>
<p><strong>Prompt refinement iterativo.</strong> As primeiras newsletters saíam genéricas. Os comentários do Akita e do Marvin soavam iguais. Fui ajustando prompts com ele: &ldquo;Akita nunca diz &rsquo;talvez&rsquo;. Marvin usa &lsquo;bom&hellip;&rsquo; e &rsquo;enfim&rsquo;. O LLM vai suavizar opiniões se você não for explícito.&rdquo; Cada ajuste era um commit de prompt, testado com dados reais.</p>
<p>O padrão é: eu trago o <strong>quê</strong> e o <strong>porquê</strong>. O agente traz o <strong>como</strong> — e o como é frequentemente melhor do que eu faria sozinho. Mas o como sem o quê produz código correto que resolve o problema errado. E o quê sem o como produz specs que nunca saem do papel.</p>
<h2>Extreme Programming Não É Moda dos Anos 2000<span class="hx:absolute hx:-mt-20" id="extreme-programming-não-é-moda-dos-anos-2000"></span>
    <a href="#extreme-programming-n%c3%a3o-%c3%a9-moda-dos-anos-2000" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/agile-lifecycle-development-process-diagram-vector-31188796.jpg" alt="agile lifecycle development process diagram"  loading="lazy" /></p>
<p>Tudo que fizemos tem nome no XP. Não porque planejei — porque são as práticas que naturalmente emergem quando você programa com feedback rápido:</p>
<h3>Pair Programming<span class="hx:absolute hx:-mt-20" id="pair-programming"></span>
    <a href="#pair-programming" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Já expliquei. O par é o agente. A dinâmica é a mesma: um pilota, outro navega. Eu navego mais do que piloto — defino direção, questiono decisões, corrijo rota. O agente pilota mais do que navega — escreve código, roda testes, propõe soluções. Quando inverte (eu ditando código exato, ele apenas digitando), o resultado sempre piora.</p>
<h3>Small Releases<span class="hx:absolute hx:-mt-20" id="small-releases"></span>
    <a href="#small-releases" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Dos 274 commits, <strong>cada um passa no CI</strong>. Sem exceção. Não existe &ldquo;commit quebrando que vai ser consertado no próximo&rdquo;. Cada commit em master é production-ready.</p>
<p>O CI roda em cada commit: RuboCop (estilo) → bundler-audit (vulnerabilidades de gems) → Brakeman (segurança estática) → testes completos. São ~22 segundos. Parece pouco, mas multiplicado por 274 commits são 100+ minutos de validação automatizada que eu não precisei fazer manualmente.</p>
<p>A distribuição de commits por dia mostra o ritmo:</p>
<table>
  <thead>
      <tr>
          <th>Data</th>
          <th>Commits</th>
          <th>Destaque</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>10/fev (seg)</td>
          <td>4</td>
          <td>Scaffold inicial, Discord bot, blog</td>
      </tr>
      <tr>
          <td>11/fev (ter)</td>
          <td>41</td>
          <td>Explosão de features: 7 seções de newsletter, temas, X polling</td>
      </tr>
      <tr>
          <td>12/fev (qua)</td>
          <td>49</td>
          <td>Podcast pipeline, deploy, segurança, market data real</td>
      </tr>
      <tr>
          <td>13/fev (qui)</td>
          <td>29</td>
          <td>Daily digest, scraping resiliente, hardening</td>
      </tr>
      <tr>
          <td>14/fev (sex)</td>
          <td>30</td>
          <td>Integração end-to-end, email hardening, blog deploy</td>
      </tr>
      <tr>
          <td>15/fev (sáb)</td>
          <td>40</td>
          <td>Podcast fine-tuning, cross-app, story management</td>
      </tr>
      <tr>
          <td>16/fev (dom)</td>
          <td>26</td>
          <td>Primeira newsletter real enviada para assinantes</td>
      </tr>
      <tr>
          <td>17/fev (seg)</td>
          <td>31</td>
          <td>Refactoring, Application Commands, anime fix</td>
      </tr>
      <tr>
          <td>18/fev (ter)</td>
          <td>24</td>
          <td>DRY shortcodes, backup job, consolidação</td>
      </tr>
  </tbody>
</table>
<p>A &ldquo;explosão&rdquo; do dia 2 não é caos — são small releases empilhadas. Cada uma adiciona uma feature, roda CI, está pronta pra deploy. Não é waterfall de 2 semanas seguido de big bang merge. É integração contínua de verdade.</p>
<h3>Test-Driven Development<span class="hx:absolute hx:-mt-20" id="test-driven-development"></span>
    <a href="#test-driven-development" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>1.323 testes. 970 no marvin-bot (6 segundos em paralelo), 353 no newsletter (1 segundo em paralelo). Ratio:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Código (linhas)</th>
          <th>Testes (linhas)</th>
          <th>Ratio teste/código</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>9.348</td>
          <td>13.929</td>
          <td>1.49x</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>3.397</td>
          <td>5.391</td>
          <td>1.59x</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>12.745</strong></td>
          <td><strong>19.320</strong></td>
          <td><strong>1.52x</strong></td>
      </tr>
  </tbody>
</table>
<p><strong>Mais linhas de teste do que de código.</strong> Em ambas as aplicações. E isso não é vanidade — é a infraestrutura que permite velocidade.</p>
<p>Coverage por SimpleCov:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Line Coverage</th>
          <th>Branch Coverage</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>82.4%</td>
          <td>73.1%</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>86.8%</td>
          <td>70.5%</td>
      </tr>
  </tbody>
</table>
<p>Não é 100% — e nunca será. Os 17-18% não cobertos são majoritariamente integração com APIs externas (Discord, SES, OpenRouter) que são mockadas nos testes. O coverage real em lógica de negócio está acima de 95%.</p>
<p>A pergunta que importa: <strong>esses testes são úteis?</strong> A prova empírica: em 274 commits, eu vi o CI falhar e pegar um bug real mais de 50 vezes. Sem esses testes, cada um desses bugs teria ido pra produção. O agente propõe uma mudança, o teste pega a regressão, a mudança é corrigida antes de commitar. Sem testes, o agente te entrega código plausível que silenciosamente quebra algo que funcionava ontem.</p>
<p>TDD com um agente de IA é multiplicativo. O agente é bom em gerar testes — sabe os padrões, sabe os edge cases comuns, sabe a estrutura do Minitest. E os testes que ele gera viram a rede de segurança das mudanças que ele mesmo faz depois. É um ciclo virtuoso: <strong>testes permitem velocidade, velocidade gera mais testes</strong>.</p>
<h3>Refactoring Contínuo<span class="hx:absolute hx:-mt-20" id="refactoring-contínuo"></span>
    <a href="#refactoring-cont%c3%adnuo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>27 commits explícitos de refactoring. Alguns exemplos:</p>
<ul>
<li><strong><code>Extract StealthBrowser wrapper</code></strong> — O código de headless browser com stealth patches estava duplicado em 4 clients. Extraiu pra um serviço, 4 clientes ficaram com 5 linhas cada.</li>
<li><strong><code>DRY section rendering: collapse 5 bubble methods + 7 Hugo shortcodes</code></strong> — 5 métodos de renderização de bolha no <code>MarkdownToHtml</code> que eram copy-paste com cores diferentes colapsados em 1 método parametrizado. 7 shortcodes Hugo delegando pra 1 partial.</li>
<li><strong><code>Replace market recap LLM generation with Yahoo Finance real data</code></strong> — O LLM inventava números de mercado. Substituí por dados reais do Yahoo Finance + commentary do LLM. Melhor resultado, menor custo.</li>
<li><strong><code>Replace DuckDuckGo with Brave Search API</code></strong> — DuckDuckGo não tem API estável. Brave Search tem API oficial com 2.000 queries/mês grátis. Troca limpa.</li>
<li><strong><code>Rework ArticleFetcher: browser-first with anti-detection</code></strong> — Duas reescrituras completas. A primeira tentou HTTP-first com fallback pra browser. A segunda inverteu: browser-first porque a maioria dos links de leitores são SPAs. Cada reescritura manteve os testes passando.</li>
</ul>
<p>O refactoring é onde o par de pair programming brilha. Eu digo &ldquo;esse código tá duplicado, extrai um concern&rdquo; e o agente faz em 2 minutos o que me levaria 20. Mas <strong>eu</strong> decido o quê extrair e <strong>como</strong> a interface deveria ser. O agente sozinho não refatora — ele empilha código novo em cima do existente.</p>
<h3>Integração Contínua<span class="hx:absolute hx:-mt-20" id="integração-contínua"></span>
    <a href="#integra%c3%a7%c3%a3o-cont%c3%adnua" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O CI já descrito acima: rubocop + bundler-audit + brakeman + testes. Roda em cada commit, em ambas as apps. Brakeman merece destaque:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Controllers</th>
          <th>Models</th>
          <th>Warnings</th>
          <th>Ignored</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>6</td>
          <td>8</td>
          <td>0</td>
          <td>0</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>10</td>
          <td>5</td>
          <td>0</td>
          <td>2</td>
      </tr>
  </tbody>
</table>
<p>Zero warnings de segurança em ambos. As 2 ignored no newsletter são false positives documentados (SQL interpolation com paths de config, não user input).</p>
<p>Brakeman pegou problemas reais durante o desenvolvimento: SQL injection numa query de busca, path traversal no controller de imagens, redirect aberto no unsubscribe. Cada um corrigido antes de ir pra produção, no mesmo commit que o CI flaggou.</p>
<h2>A Anatomia de Uma Feature: Do Pedido ao Deploy<span class="hx:absolute hx:-mt-20" id="a-anatomia-de-uma-feature-do-pedido-ao-deploy"></span>
    <a href="#a-anatomia-de-uma-feature-do-pedido-ao-deploy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vou pegar um exemplo concreto pra ilustrar como o fluxo funciona na prática. O <code>List-Unsubscribe</code> (commit <code>5950ccc</code>).</p>
<p><strong>Contexto</strong>: emails de confirmação não chegavam no Gmail. Tudo verde — DKIM, SPF, DMARC, SES aceitando. Mas Gmail jogava no spam silenciosamente.</p>
<p><strong>Investigação</strong> (eu + agente): pesquisamos. Desde fevereiro de 2024, Gmail exige <code>List-Unsubscribe</code> + <code>List-Unsubscribe-Post</code> em todo email de bulk sender. Sem eles, penaliza o <strong>domínio inteiro</strong> — incluindo emails transacionais.</p>
<p><strong>Implementação</strong> (agente executa, eu valido): header no <code>SesMailer</code>, endpoint RFC 8058 no <code>UnsubscriptionsController</code> (skip CSRF pra email clients, retorna 200 ao invés de redirect), testes cobrindo ambos os flows.</p>
<p><strong>CI</strong>: rubocop OK, brakeman pegou o <code>skip_forgery_protection</code> como warning (correto — validamos que é condicional). Testes passam.</p>
<p><strong>Deploy</strong>: <code>kamal deploy</code>. Em 2 minutos, a correção está em produção. Reputação do domínio leva 1-2 dias pra se recuperar. No dia seguinte, emails de confirmação voltam a chegar.</p>
<p><strong>Tempo total</strong>: ~25 minutos do &ldquo;por que o Gmail tá rejeitando?&rdquo; ao deploy em produção. Sem o agente, seriam horas de leitura de RFC, implementação manual, e esperança de que não esqueci nada.</p>
<h2>O Que a IA Faz Bem (E o Que Faz Mal)<span class="hx:absolute hx:-mt-20" id="o-que-a-ia-faz-bem-e-o-que-faz-mal"></span>
    <a href="#o-que-a-ia-faz-bem-e-o-que-faz-mal" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Depois de 274 commits com um agente de IA, tenho uma noção bem clara dos pontos fortes e fracos:</p>
<h3>Faz bem:<span class="hx:absolute hx:-mt-20" id="faz-bem"></span>
    <a href="#faz-bem" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Boilerplate e scaffolding</strong>: jobs, services, testes, migrations — o agente produz na velocidade de digitação</li>
<li><strong>Testes</strong>: surpreendentemente bom em identificar edge cases e gerar testes abrangentes</li>
<li><strong>Refactoring mecânico</strong>: renomear, extrair métodos, mover código entre arquivos — rápido e preciso</li>
<li><strong>Pesquisa contextual</strong>: &ldquo;como funciona RFC 8058?&rdquo; produz resposta acionável em segundos</li>
<li><strong>Consistência</strong>: segue padrões estabelecidos no projeto (concerns, naming conventions, test structure) sem esquecer</li>
</ul>
<h3>Faz mal:<span class="hx:absolute hx:-mt-20" id="faz-mal"></span>
    <a href="#faz-mal" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Decisões de arquitetura</strong>: tende a over-engineer. Precisa de freio humano constante</li>
<li><strong>Conhecimento de domínio específico</strong>: não sabe que o Yahoo faz TLS fingerprinting ou que o VoiceDesign do Qwen produz sotaque europeu</li>
<li><strong>Opiniões</strong>: suaviza tudo. Prompts de personalidade viram mush genérico sem instruções explícitas</li>
<li><strong>Segurança proativa</strong>: implementa o que você pede, mas raramente sugere proteções que você não pediu (SSRF, rate limiting, encryption at rest)</li>
<li><strong>Priorização</strong>: executa qualquer coisa com igual entusiasmo. Não diz &ldquo;isso é perda de tempo&rdquo; ou &ldquo;faça X antes de Y&rdquo;</li>
</ul>
<p>IA não programa sozinha, e não é inútil. <strong>É um multiplicador do programador, não um substituto</strong>. Um programador medíocre com IA produz código medíocre mais rápido. Um programador experiente com IA produz código bom na velocidade que antes só era possível com equipes.</p>
<h2>Os Números que Importam<span class="hx:absolute hx:-mt-20" id="os-números-que-importam"></span>
    <a href="#os-n%c3%bameros-que-importam" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vamos ao tamanho real do que foi construído em 8 dias:</p>
<h3>Código<span class="hx:absolute hx:-mt-20" id="código"></span>
    <a href="#c%c3%b3digo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Componente</th>
          <th>Linhas</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot app (Ruby)</td>
          <td>9.348</td>
      </tr>
      <tr>
          <td>marvin-bot tests (Ruby)</td>
          <td>13.929</td>
      </tr>
      <tr>
          <td>newsletter app (Ruby)</td>
          <td>3.397</td>
      </tr>
      <tr>
          <td>newsletter tests (Ruby)</td>
          <td>5.391</td>
      </tr>
      <tr>
          <td>podcast-tts (Python)</td>
          <td>1.770</td>
      </tr>
      <tr>
          <td>Hugo templates (HTML)</td>
          <td>193</td>
      </tr>
      <tr>
          <td>Blog CSS</td>
          <td>261</td>
      </tr>
      <tr>
          <td>AI prompts (Markdown)</td>
          <td>795</td>
      </tr>
      <tr>
          <td>CLAUDE.md (guia do projeto)</td>
          <td>702</td>
      </tr>
  </tbody>
</table>
<p><strong>Total de código funcional: ~16.000 linhas. Total com testes: ~36.000 linhas.</strong></p>
<h3>Componentes<span class="hx:absolute hx:-mt-20" id="componentes"></span>
    <a href="#componentes" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>43 services</strong> no marvin-bot (scrapers, AI, parsers, validators, uploaders)</li>
<li><strong>38 jobs</strong> no marvin-bot (geração de conteúdo, daily digest, podcast, infraestrutura)</li>
<li><strong>12 services</strong> no newsletter (email delivery, publishing, assembly)</li>
<li><strong>11 jobs</strong> no newsletter (sending, recovery, backup, blog publishing)</li>
<li><strong>23 prompt templates</strong> de IA (personalidade, geração, podcast, tools)</li>
<li><strong>17 shortcodes</strong> Hugo (renderização de seções no blog)</li>
<li><strong>3 RubyLLM tools</strong> (image generation, web search, web fetch)</li>
</ul>
<h3>Pipeline<span class="hx:absolute hx:-mt-20" id="pipeline"></span>
    <a href="#pipeline" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>10 seções</strong> de conteúdo geradas por semana (anime, hacker news, youtube, market, world events, history x3, holidays, closing remarks)</li>
<li><strong>10 daily digest jobs</strong> postando no Discord às 8h</li>
<li><strong>1 podcast</strong> de ~30 minutos gerado por semana (script + TTS + assembly + S3)</li>
<li><strong>1 newsletter</strong> montada, publicada no blog, e enviada por email toda segunda</li>
</ul>
<h2>Isso É 200 User Stories?<span class="hx:absolute hx:-mt-20" id="isso-é-200-user-stories"></span>
    <a href="#isso-%c3%a9-200-user-stories" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/66d7f28718285e260f53fe6e_018b4d67589e6f4d2fdf9130502016fb4a75e73a-1200x674.jpg" alt="sprint planning"  loading="lazy" /></p>
<p>Olhei os 274 commits e tentei mapear pra user stories no sentido ágil. Nem todo commit é uma story — muitos são fixes dentro da mesma feature, ajustes de prompt, ou cleanup de CI. Mas a contagem conservadora de features distintas, cada uma com critérios de aceitação implícitos (funciona, tem teste, passa no CI, deploya):</p>
<blockquote>
  <p><strong>Estimativa: ~180-200 user stories.</strong></p>

</blockquote>
<p>Isso inclui:</p>
<ul>
<li>Cada seção de newsletter (10 geradores distintos com scraping, AI, validação, fallback)</li>
<li>O pipeline de email (delivery tracking, claiming atômico, recovery, List-Unsubscribe)</li>
<li>O pipeline de podcast (script LLM, TTS dual-model, assembly, S3, RSS)</li>
<li>O Discord bot (15+ comandos, embeds, context, multi-turn /ask, daily digest)</li>
<li>O blog (Hugo + Hextra, 17 shortcodes, RSS podcast, SEO, GA)</li>
<li>A subscription page (confirmação, temas, LGPD, termos de serviço)</li>
<li>Infraestrutura (Kamal deploy, Cloudflare, SES config, RunPod, backups)</li>
<li>21 commits de segurança (SSRF, CSP, rate limiting, encryption, path traversal)</li>
</ul>
<p>Em Scrum tradicional com uma equipe de 2-3 devs, uma story bem feita (com code review, QA, deploy) leva em média 1-2 dias. 200 stories × 1.5 dias / 2 devs = <strong>~150 dias de trabalho, ou ~30 semanas em sprints de 2 semanas</strong> (contando planning, review, retro, e o overhead inevitável de coordenação entre pessoas).</p>
<p>Sendo generoso — equipe sênior, stories bem definidas, sem impedimentos — talvez 10-15 semanas.</p>
<blockquote>
  <p>Nós fizemos em 8 dias. Uma pessoa e um agente de IA.</p>

</blockquote>
<p>Mas — e isso é crucial — <strong>eu não sou um dev qualquer e isso não é um projeto qualquer</strong>. Eu tenho 30 anos de experiência em software. Sei quais decisões importam e quais não. Sei quando parar de engenheirar e shipar. Sei quando o agente está errado antes de ele terminar de gerar o código. E o projeto é solo-developer, sem code review externo, sem QA separado, sem coordenação entre equipes.</p>
<p>O multiplicador real do agente não é &ldquo;10x mais código&rdquo;. É <strong>eliminar o tempo morto</strong>. Sem agente, 70% do meu tempo seria digitando boilerplate, lendo docs de API, escrevendo testes mecânicos, debugando typos. Com agente, esse tempo vira zero e eu foco 100% em decisões de arquitetura, domínio, e qualidade.</p>
<h2>O CLAUDE.md: A Spec que Evolui<span class="hx:absolute hx:-mt-20" id="o-claudemd-a-spec-que-evolui"></span>
    <a href="#o-claudemd-a-spec-que-evolui" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Uma coisa que funciona excepcionalmente bem com agentes de IA é ter um documento vivo que descreve o projeto. O <code>CLAUDE.md</code> do projeto tem 702 linhas e cobre:</p>
<ul>
<li>Visão geral da arquitetura</li>
<li>Stack tecnológico completo</li>
<li>Todas as variáveis de ambiente</li>
<li>Estrutura do diretório de conteúdo</li>
<li>Serviços, jobs e models de cada app</li>
<li>12 &ldquo;common hurdles&rdquo; com soluções documentadas</li>
<li>14 design patterns do projeto</li>
<li>Pipeline semanal completo com horários</li>
<li>Checklist pós-implementação</li>
</ul>
<p>Esse documento não foi escrito de uma vez. Ele <strong>evoluiu com o projeto</strong>. Cada vez que descobríamos um hurdle (Yahoo Finance precisa de Chromium, HN RSS muda de formato, LLM suaviza opiniões), documentávamos no CLAUDE.md. Na próxima sessão, o agente já sabe.</p>
<p>É o equivalente do onboarding doc de uma equipe — mas o &ldquo;novo membro da equipe&rdquo; é o agente, e ele lê o doc inteiro em 2 segundos antes de cada interação. O investimento em documentação retorna exponencialmente com agentes de IA porque <strong>eles realmente leem</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-59-38.jpg" alt="claude.md"  loading="lazy" /></p>
<h2>As Lições<span class="hx:absolute hx:-mt-20" id="as-lições"></span>
    <a href="#as-li%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>1. Vibe coding sem disciplina é protótipo descartável.</strong> 274 commits com CI, testes, security scanning, e small releases é o oposto de vibe coding. É engenharia de software com um copiloto que digita rápido.</p>
<p><strong>2. TDD é mais importante com IA, não menos.</strong> O agente modifica código com confiança porque tem 1.323 testes como rede de segurança. Sem testes, cada mudança é uma aposta.</p>
<p><strong>3. O humano decide o quê. O agente decide o como.</strong> Inverta e o resultado piora dramaticamente.</p>
<p><strong>4. Refactoring contínuo é obrigatório.</strong> O agente empilha código. Se você não poda regularmente — extrair concerns, DRY duplicações, simplificar interfaces — a base de código vira um monólito de 500 linhas por arquivo.</p>
<p><strong>5. Documentação é investimento com retorno imediato.</strong> Cada hora documentando no CLAUDE.md economiza horas de contexto perdido em sessões futuras.</p>
<p><strong>6. Small releases são a chave.</strong> Cada commit é production-ready. Se algo der errado, reverte um commit. Se a feature era má ideia, nunca dependeu de outra. Isso só funciona com CI automático.</p>
<p><strong>7. Segurança não é fase — é hábito.</strong> 21 commits de segurança espalhados por 8 dias, não concentrados numa &ldquo;sprint de segurança&rdquo; no final. Brakeman roda em todo commit. Vulnerabilidades são corrigidas no momento que aparecem.</p>
<p><strong>8. O agente nunca diz &ldquo;não&rdquo;.</strong> Isso é um bug, não uma feature. Se você pede algo over-engineered, ele implementa com entusiasmo. Se pede algo inseguro, ele implementa sem reclamar. <strong>Você</strong> é o freio. <strong>Você</strong> é o code review. <strong>Você</strong> é o adulto na sala.</p>
<h2>O Contra-Exemplo: FrankMD<span class="hx:absolute hx:-mt-20" id="o-contra-exemplo-frankmd"></span>
    <a href="#o-contra-exemplo-frankmd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tudo que descrevi acima pode soar teórico. <em>&ldquo;Claro que TDD e refactoring contínuo são importantes&rdquo;</em> — todo mundo concorda em tese. Então vou provar empiricamente com um contra-exemplo do <strong>mesmo desenvolvedor, com o mesmo agente de IA, duas semanas antes</strong>.</p>
<p>Antes do M.Akita Chronicles, eu construí o <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a> — um editor Markdown em Rails, estilo Obsidian/VSCode. 212 commits em 19 dias. Mesmo setup: eu + Claude Code como par de pair programming.</p>
<p><img src="https://camo.githubusercontent.com/4d7ffcd26d22eb57334017e3b946a10eeb59cacf609edcc8caebf68d03d528b4/68747470733a2f2f6e65772d75706c6f6164732d616b6974616f6e7261696c732e73332e75732d656173742d322e616d617a6f6e6177732e636f6d2f6672616e6b6d642f323032362f30322f73637265656e73686f742d323032362d30322d30315f31342d31332d32392e6a7067" alt="frankmd"  loading="lazy" /></p>
<p>A diferença: no FrankMD, eu fiz o que qualquer desenvolvedor faz quando &ldquo;é só um projetinho pessoal&rdquo;. Foquei em features. Testes? Depois. Refactoring? Quando necessário. CI? Roda quando lembrar.</p>
<h3>A Espiral do <code>app_controller.js</code><span class="hx:absolute hx:-mt-20" id="a-espiral-do-app_controllerjs"></span>
    <a href="#a-espiral-do-app_controllerjs" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O FrankMD é um SPA-like com Stimulus. No início, todo o JavaScript vivia em um único arquivo: <code>app_controller.js</code>. Aqui está o que aconteceu com ele ao longo dos commits:</p>
<table>
  <thead>
      <tr>
          <th>Momento</th>
          <th>LOC</th>
          <th>O que aconteceu</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commit #1 (inicial)</td>
          <td>506</td>
          <td>Scaffold limpo, editor básico</td>
      </tr>
      <tr>
          <td>Commit #44 (~10 features depois)</td>
          <td>~4.372</td>
          <td>File finder, themes, typewriter, image picker, emoji&hellip;</td>
      </tr>
      <tr>
          <td>Commit #54 (pico)</td>
          <td><strong>4.990</strong></td>
          <td>Tudo dentro. AI grammar, video embed, content search&hellip;</td>
      </tr>
      <tr>
          <td><strong>Refactor 1</strong>: Extract utilities + image picker</td>
          <td>3.249</td>
          <td>-35%, 6 módulos + 1 controller extraídos</td>
      </tr>
      <tr>
          <td><strong>Refactor 2</strong>: Extract 10 Stimulus controllers</td>
          <td>1.803</td>
          <td>-45%, 10 controllers com testes</td>
      </tr>
      <tr>
          <td>Features crescem de novo&hellip;</td>
          <td>2.739</td>
          <td>Mais código empilhado</td>
      </tr>
      <tr>
          <td><strong>Refactor 3</strong>: Extract utility modules</td>
          <td>2.684</td>
          <td>-2%, pequeno</td>
      </tr>
      <tr>
          <td><strong>Refactor 4</strong>: Extract domain controllers</td>
          <td>2.074</td>
          <td>-23%, 4 controllers novos</td>
      </tr>
      <tr>
          <td>Features crescem&hellip;</td>
          <td>2.367</td>
          <td>De volta a crescer</td>
      </tr>
      <tr>
          <td><strong>Refactor 5</strong>: Extract autosave/scroll-sync</td>
          <td>1.837</td>
          <td>-22%</td>
      </tr>
      <tr>
          <td><strong>Refactor 6</strong>: Replace custom JS with Rails 8 built-ins</td>
          <td>1.511</td>
          <td>-17%, -383 linhas líquidas</td>
      </tr>
      <tr>
          <td>Estado final</td>
          <td>1.576</td>
          <td>39 controllers + 29 lib modules</td>
      </tr>
  </tbody>
</table>
<p>O padrão é claro: <strong>construir-construir-construir-PARAR-refatorar</strong>. O arquivo cresceu 10x em 54 commits, atingiu quase 5.000 linhas, e então precisei de <strong>6 rodadas de refatoração</strong> pra trazer de volta ao aceitável. Cada parada foi um commit grande (centenas de linhas movidas, dezenas de arquivos criados), não um ajuste cirúrgico.</p>
<p>O CSS seguiu o mesmo padrão: 1.053 linhas num único <code>application.css</code>, até o commit de refactoring que explodiu em 10 arquivos de tema + 9 arquivos de componente.</p>
<h3>Testes: Antes vs. Depois<span class="hx:absolute hx:-mt-20" id="testes-antes-vs-depois"></span>
    <a href="#testes-antes-vs-depois" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>No FrankMD, o 2º commit foi &ldquo;Add comprehensive test suite&rdquo; — testes de sistema (integração browser). Parece responsável, mas esses testes cobriam <strong>o happy path e nada mais</strong>. Os testes de unidade JavaScript vieram só no commit #52, <strong>depois que o app_controller.js já tinha 5.000 linhas</strong> — porque eu precisava de testes pra refatorar com segurança. Testes retroativos.</p>
<p>A cobertura Ruby reflete isso: teve que ser empurrada de 77% pra 89% num commit explícito de &ldquo;Add tests for untested success paths&rdquo;. Não era TDD — era <strong>preencher buracos depois</strong>.</p>
<h3>Os Números Lado a Lado<span class="hx:absolute hx:-mt-20" id="os-números-lado-a-lado"></span>
    <a href="#os-n%c3%bameros-lado-a-lado" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>FrankMD</th>
          <th>M.Akita Chronicles</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>212</td>
          <td>274</td>
      </tr>
      <tr>
          <td>Dias</td>
          <td>19</td>
          <td>8</td>
      </tr>
      <tr>
          <td>Commits/dia</td>
          <td>11</td>
          <td>34</td>
      </tr>
      <tr>
          <td>Testes</td>
          <td>1.364 JS + ~490 Ruby</td>
          <td>1.323 Ruby</td>
      </tr>
      <tr>
          <td>Coverage Ruby</td>
          <td>~89% (retroativo)</td>
          <td>82-87% (orgânico)</td>
      </tr>
      <tr>
          <td>Ratio teste/código</td>
          <td>~1.2x</td>
          <td>1.52x</td>
      </tr>
      <tr>
          <td>Refactors &ldquo;pare tudo&rdquo;</td>
          <td>6 grandes</td>
          <td>0</td>
      </tr>
      <tr>
          <td>Refactors incrementais</td>
          <td>16</td>
          <td>27</td>
      </tr>
      <tr>
          <td>Maior arquivo (pico)</td>
          <td>4.990 LOC</td>
          <td>—</td>
      </tr>
      <tr>
          <td>CI a cada commit</td>
          <td>Não</td>
          <td>Sim</td>
      </tr>
  </tbody>
</table>
<p>O número que importa: <strong>commits por dia</strong>. O M.Akita Chronicles teve 3x mais throughput — com TDD, CI, e refactoring contínuo — porque <strong>nunca precisou parar</strong>. Nenhum commit de &ldquo;parar tudo e refatorar 5.000 linhas em 10 arquivos&rdquo;. Nenhum momento de &ldquo;preciso adicionar testes retroativos pra poder mexer nesse código sem medo&rdquo;.</p>
<h3>A Lição Concreta<span class="hx:absolute hx:-mt-20" id="a-lição-concreta"></span>
    <a href="#a-li%c3%a7%c3%a3o-concreta" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Os 6 refactors grandes do FrankMD não foram &ldquo;refactoring contínuo&rdquo;. Foram <strong>cirurgias de emergência em dívida técnica acumulada</strong>. Cada um levou horas — não os 2 minutos do &ldquo;extrai um concern&rdquo; descrito acima. E cada um introduziu riscos: quando você move 1.500 linhas de um arquivo pra 10 arquivos novos, a chance de quebrar algo é proporcional ao tamanho da mudança.</p>
<p>Com TDD e refactoring contínuo, o M.Akita Chronicles nunca acumulou essa dívida. Os 27 refactors são commits pequenos — &ldquo;Extract StealthBrowser&rdquo;, &ldquo;DRY bubble methods&rdquo; — que levam minutos e são protegidos por 1.323 testes que já existiam quando o refactoring foi feito.</p>
<p><strong>Mesmo desenvolvedor. Mesmo agente de IA. Duas abordagens diferentes. Resultado: o projeto disciplinado entregou mais, em menos tempo, com menos dor.</strong></p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/41MD3fXWOXL.jpg" alt="xp book"  loading="lazy" /></p>
<p>Esse projeto não é um showcase de IA. É um showcase de engenharia de software acelerada por IA. E a prova mais forte é que o <strong>mesmo desenvolvedor com o mesmo agente</strong> produziu resultados radicalmente diferentes dependendo do processo.</p>
<p>No FrankMD, sem disciplina, o resultado foi previsível: um arquivo de 5.000 linhas, 6 cirurgias de emergência, testes retroativos, e 11 commits por dia. No M.Akita Chronicles, com XP, o resultado foi 34 commits por dia, zero paradas forçadas, 1.323 testes orgânicos, e um sistema em produção em 8 dias. A variável não foi a IA — foi o processo.</p>
<p>274 commits em 8 dias não acontecem porque o agente é mágico. Acontecem porque Extreme Programming — pair programming, TDD, small releases, refactoring contínuo, integração contínua — funciona tão bem com um par de IA quanto funciona com um par humano. Talvez melhor em alguns aspectos: o agente nunca cansa, nunca discute por ego, nunca diz &ldquo;mas sempre fizemos assim&rdquo;, e lê 700 linhas de documentação antes de cada interação sem reclamar.</p>
<p>O futuro do desenvolvimento não é &ldquo;IA substituindo programadores&rdquo;. É programadores que sabem usar IA como ferramenta — com a mesma disciplina que sempre usaram com qualquer outra ferramenta — produzindo em uma semana o que equipes inteiras levam trimestres.</p>
<p>Não porque a IA é genial. Porque <strong>o processo</strong> é genial. O XP tinha razão desde o começo. Só faltava um par que nunca cansa e digita a 1000 palavras por minuto.</p>
<p>Lembrem-se:</p>
<blockquote>
  <p>&ldquo;A IA é seu espelho, ele revela mais rápido quem você é. Se for incompetente, vai produzir coisas ruins mais rápido. Se for competente, vai produzir coisas boas mais rápido.&rdquo; - by Akita</p>

</blockquote>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category><category>the-m-akita-chronicles</category></item><item><title>Testes de Integração em MonoRepo | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/20/testes-de-integracao-em-monorepo-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/20/testes-de-integracao-em-monorepo-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 12:48:45 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 8.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Testes unitários são a zona de conforto de todo desenvolvedor. Você mocka tudo, isola tudo, roda em milissegundos, e o verde no terminal te dá uma falsa sensação de segurança. Até o dia que você deploya e descobre que o serviço A grava num formato que o serviço B não consegue ler. Ou que o RSS do Hacker News mudou de Atom pra RSS e seu parser explodiu. Ou que a Yahoo Finance começou a retornar 429 depois do terceiro ticker porque o rate limit que funcionava em teste não existe na vida real.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 8.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Testes unitários são a zona de conforto de todo desenvolvedor. Você mocka tudo, isola tudo, roda em milissegundos, e o verde no terminal te dá uma falsa sensação de segurança. Até o dia que você deploya e descobre que o serviço A grava num formato que o serviço B não consegue ler. Ou que o RSS do Hacker News mudou de Atom pra RSS e seu parser explodiu. Ou que a Yahoo Finance começou a retornar 429 depois do terceiro ticker porque o rate limit que funcionava em teste não existe na vida real.</p>
<p>Testes unitários provam que as peças funcionam isoladas. Testes de integração provam que o <strong>sistema</strong> funciona. E num projeto com múltiplas aplicações compartilhando dados via filesystem, a diferença entre os dois é a diferença entre &ldquo;o código compila&rdquo; e &ldquo;a newsletter chega na inbox&rdquo;.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-22-50.jpg" alt="unit tests"  loading="lazy" /></p>
<h2>O Problema: Três Apps, Um Filesystem, Zero Garantias<span class="hx:absolute hx:-mt-20" id="o-problema-três-apps-um-filesystem-zero-garantias"></span>
    <a href="#o-problema-tr%c3%aas-apps-um-filesystem-zero-garantias" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O projeto tem uma arquitetura incomum: três aplicações Rails/Hugo num monorepo, integradas via um diretório <code>content/</code> compartilhado:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>akitando-news/
├── marvin-bot/    # Gera conteúdo (Discord bot, AI, scraping)
├── newsletter/    # Monta e envia a newsletter
├── blog/          # Hugo &#43; Hextra → Netlify
├── podcast-tts/   # Servidor TTS (Python/FastAPI)
└── content/       # Markdown &#43; YAML, a cola entre tudo</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O marvin-bot escreve Markdown com frontmatter YAML em <code>content/anime/</code>, <code>content/hacker_news/</code>, etc. O newsletter lê esses arquivos pra montar a newsletter. O blog serve o mesmo conteúdo como páginas estáticas. O podcast lê a newsletter montada pra gerar o script de diálogo.</p>
<p>O contrato entre esses sistemas é <strong>implícito</strong>. Não tem schema. Não tem API versionada. Não tem protobuf. O contrato é: &ldquo;o arquivo Markdown tem esse frontmatter, com esses campos, nessa estrutura&rdquo;. Se o marvin-bot muda o formato de um campo — digamos, muda <code>image</code> de string pra array — o newsletter quebra silenciosamente. Nenhum teste unitário de nenhum dos dois apps detecta isso.</p>
<p>Essa é a falha fundamental de testar sistemas distribuídos com testes unitários: <strong>você está testando cada lado do contrato separadamente, mas nunca o contrato em si</strong>.</p>
<h2>Camada 1: Testes Unitários com Isolamento Paranoico<span class="hx:absolute hx:-mt-20" id="camada-1-testes-unitários-com-isolamento-paranoico"></span>
    <a href="#camada-1-testes-unit%c3%a1rios-com-isolamento-paranoico" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Antes de falar de integração, preciso explicar o que os testes unitários fazem nesse projeto — porque sem essa base, os testes de integração não teriam onde pisar.</p>
<p>Os 977 testes do marvin-bot rodam em paralelo usando todos os cores da máquina. Cada worker de teste tem seu próprio diretório:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">parallelize</span><span class="p">(</span><span class="ss">workers</span><span class="p">:</span> <span class="ss">:number_of_processors</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">parallelize_setup</span> <span class="k">do</span> <span class="o">|</span><span class="n">worker</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span> <span class="o">=</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;tmp&#34;</span><span class="p">,</span> <span class="s2">&#34;test_content_w</span><span class="si">#{</span><span class="n">worker</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Mas worker-level não é suficiente. Dentro de cada worker, múltiplos testes rodam sequencialmente — e se um teste deixa um arquivo em <code>content/stories/</code>, o próximo teste pode encontrar dados espúrios. A solução: <strong>um diretório único por teste</strong>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">setup</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@base_content_dir</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@test_content_dir</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@base_content_dir</span><span class="p">,</span> <span class="no">SecureRandom</span><span class="o">.</span><span class="n">hex</span><span class="p">(</span><span class="mi">8</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span> <span class="o">=</span> <span class="vi">@test_content_dir</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">,</span> <span class="s2">&#34;stories&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">,</span> <span class="s2">&#34;newsletters&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">,</span> <span class="s2">&#34;images&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">teardown</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">FileUtils</span><span class="o">.</span><span class="n">rm_rf</span><span class="p">(</span><span class="vi">@test_content_dir</span><span class="p">)</span> <span class="k">if</span> <span class="vi">@test_content_dir</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">content_dir</span> <span class="o">=</span> <span class="vi">@base_content_dir</span> <span class="k">if</span> <span class="vi">@base_content_dir</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Presta atenção na linha do teardown que restaura <code>@base_content_dir</code>. Sem ela, o próximo teste criaria seu diretório <strong>dentro</strong> do diretório do teste anterior — <code>tmp/test_content_w3/abc123/def456/</code> — e em 50 testes você teria um caminho com 50 níveis de profundidade. Aprendi isso perdendo uma tarde inteira debugando por que os testes ficavam cada vez mais lentos ao longo da suite.</p>
<p>Esse padrão — worker-level base + per-test subdirectory + restore no teardown — é idêntico nos dois apps. E é o que permite rodar 977 testes em 7 segundos com 32 workers paralelos.</p>
<p>A regra de ouro: <strong>o <code>content_dir</code> de teste NUNCA pode apontar pro <code>../content</code> real</strong>. Se apontar, o <code>rm_rf</code> do teardown apaga os prompts de produção. Não é hipotético — aconteceu durante o desenvolvimento.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-24-08.jpg" alt="unit tests newsletter"  loading="lazy" /></p>
<h2>Camada 2: O Ambiente de Integração<span class="hx:absolute hx:-mt-20" id="camada-2-o-ambiente-de-integração"></span>
    <a href="#camada-2-o-ambiente-de-integra%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Testes unitários mockam tudo: APIs externas, AI, Discord, SES. Isso é correto — você não quer gastar $2 em tokens do GPT-5.2 toda vez que roda <code>bin/rails test</code>. Mas significa que os testes nunca exercitam o pipeline real.</p>
<p>O projeto tem um terceiro environment além de development e test: <strong>integration</strong>. Não é test. Não é staging. É um modo específico pra rodar o pipeline completo com dados reais, usando APIs reais, mas sem mandar emails de verdade:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># config/environments/integration.rb</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Jobs rodam inline (perform_later vira perform_now)</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">active_job</span><span class="o">.</span><span class="n">queue_adapter</span> <span class="o">=</span> <span class="ss">:inline</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Emails salvos em arquivo ao invés de enviados via SES</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">action_mailer</span><span class="o">.</span><span class="n">delivery_method</span> <span class="o">=</span> <span class="ss">:file</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">action_mailer</span><span class="o">.</span><span class="n">file_settings</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">location</span><span class="p">:</span> <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;tmp/integration_emails&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>queue_adapter = :inline</code> é crucial. Em produção, <code>perform_later</code> enfileira o job no SolidQueue e um worker separado executa. Em integração, executa <strong>na hora, no mesmo processo</strong>. Isso transforma o pipeline assíncrono de produção num pipeline síncrono que você pode rodar, observar, e debugar sequencialmente.</p>
<p>O SES é substituído por escrita em disco — cada email vira um arquivo HTML em <code>tmp/integration_emails/</code>. Depois de rodar o pipeline, você abre esses HTMLs no browser pra validar visualmente que o email renderiza correto.</p>
<h2>Camada 3: DevCache — LLM Não É Idempotente<span class="hx:absolute hx:-mt-20" id="camada-3-devcache--llm-não-é-idempotente"></span>
    <a href="#camada-3-devcache--llm-n%c3%a3o-%c3%a9-idempotente" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui está um problema que quem trabalha com IA em produção conhece: chamar o GPT-5.2 com o mesmo prompt duas vezes <strong>não retorna a mesma resposta</strong>. E cada chamada custa dinheiro. Se você está iterando no pipeline de integração — rodando, ajustando um prompt, rodando de novo — gastar $3 por iteração fica caro rápido.</p>
<p>A solução é o <code>DevCache</code>: um cache file-based que só ativa no env de integração:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DevCache</span>
</span></span><span class="line"><span class="cl">  <span class="no">CACHE_TTL</span> <span class="o">=</span> <span class="mi">1</span><span class="o">.</span><span class="n">day</span>
</span></span><span class="line"><span class="cl">  <span class="no">CACHE_ENVS</span> <span class="o">=</span> <span class="sx">%w[integration]</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">namespace</span><span class="p">,</span> <span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">yield</span> <span class="k">unless</span> <span class="n">enabled?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">cache_path</span><span class="p">(</span><span class="n">namespace</span><span class="p">,</span> <span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">path</span><span class="o">.</span><span class="n">exist?</span> <span class="o">&amp;&amp;</span> <span class="no">File</span><span class="o">.</span><span class="n">mtime</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="o">&gt;</span> <span class="no">CACHE_TTL</span><span class="o">.</span><span class="n">ago</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">path</span><span class="o">.</span><span class="n">read</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="k">yield</span>
</span></span><span class="line"><span class="cl">    <span class="n">path</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="no">JSON</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span><span class="n">result</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Em produção e em test, <code>DevCache.fetch</code> executa o bloco direto. Em integração, salva o resultado como JSON e retorna o cached na próxima execução. TTL de 1 dia — suficiente pra iterar no mesmo dia sem recalcular, curto o bastante pra não servir dados velhos.</p>
<p>O código de geração usa assim:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">summary</span> <span class="o">=</span> <span class="no">DevCache</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;summarize&#34;</span><span class="p">,</span> <span class="n">url_key</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">AiChat</span><span class="o">.</span><span class="n">summarize</span><span class="p">(</span><span class="n">article</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">,</span> <span class="ss">source_url</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Primeira execução: chama o GPT-5.2, paga $0.02, salva em <code>tmp/dev_cache/summarize/{md5}.json</code>. Segunda execução: lê do cache, custo zero. O <code>FORCE=1</code> busta o cache quando você quer forçar regeneração — mas como flag explícita, não como default.</p>
<p>O DevCache existe em ambos os apps (marvin-bot e newsletter) com implementação idêntica. Quando roda <code>integration:clean</code>, ambos os caches são limpos.</p>
<p>Ponto: <strong>cache de LLM em ambiente de desenvolvimento não é otimização — é requisito</strong>. Sem ele, iterar num prompt que precisa de 8 chamadas de API fica financeiramente inviável.</p>
<h2>Camada 4: Dados Reais via Rsync<span class="hx:absolute hx:-mt-20" id="camada-4-dados-reais-via-rsync"></span>
    <a href="#camada-4-dados-reais-via-rsync" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui é onde a maioria dos projetos para. Eles têm testes unitários, talvez um ambiente de staging, e mandam pro ar. O problema: staging usa dados de teste. Dados de teste são <strong>limpos demais</strong>. Não têm os edge cases que dados reais têm:</p>
<ul>
<li>Artigos com títulos em japonês (anime ranking)</li>
<li>URLs que redirecionam 3 vezes antes de chegar no destino</li>
<li>Imagens que retornam 200 com Content-Type <code>text/html</code> (CloudFlare error page)</li>
<li>Newsletters anteriores com formatos de frontmatter ligeiramente diferentes</li>
<li>Stories com <code>score: null</code> porque foram criadas antes de o campo existir</li>
</ul>
<p>A solução: <strong>rsync de produção</strong>.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Limpa conteúdo gerado (preserva stories e imagens de produção)</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> marvin-bot <span class="o">&amp;&amp;</span> bin/rails integration:clean
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Sincroniza content/ de produção pra local</span>
</span></span><span class="line"><span class="cl">rsync -avz --delete production:/path/to/content/ ../content/
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Roda o pipeline completo com dados reais</span>
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_ENV</span><span class="o">=</span>integration bin/rails integration:pipeline</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>integration:clean</code> é cirúrgico: remove diretórios gerados (<code>anime/</code>, <code>hacker_news/</code>, <code>newsletters/</code>, <code>podcasts/</code>) mas preserva <code>stories/</code> e <code>images/</code> — que são dados submetidos pelo usuário via Discord. Depois do rsync, o diretório local é uma cópia exata da produção.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-25-37.jpg" alt="cleanup"  loading="lazy" /></p>
<p>O pipeline então gera todas as seções a partir desses dados reais. Se um parser quebra com um título em coreano, você descobre aqui — não em produção às 17h de domingo quando os 8 jobs rodam em paralelo.</p>
<h2>Camada 5: O Pipeline Completo<span class="hx:absolute hx:-mt-20" id="camada-5-o-pipeline-completo"></span>
    <a href="#camada-5-o-pipeline-completo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O coração do sistema de integração é o <code>integration:pipeline</code>. Ele simula a semana inteira de produção em uma execução:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">RAILS_ENV</span><span class="o">=</span>integration bin/rails integration:pipeline</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O pipeline roda em ondas, respeitando dependências:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Wave 1 (paralelo, 8 jobs):
  Book, Holidays, History, Geek History,
  Anime, Hacker News, YouTube, World Events

Wave 2 (depende de World Events):
  Market Recap

Wave 3 (depende de tudo):
  Closing Remarks

Preflight → Newsletter Assembly → Podcast</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-26-13.jpg" alt="waves"  loading="lazy" /></p>
<p>Cada wave roda seus jobs em threads paralelas, exatamente como a produção faz com SolidQueue. A execução paralela não é um detalhe de implementação — é uma feature de teste. Se dois jobs tentam escrever no mesmo arquivo, ou se um job lê um arquivo que outro está no meio de escrever, o bug aparece aqui.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">threads</span> <span class="o">=</span> <span class="n">to_run</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="nb">name</span><span class="p">,</span> <span class="n">klass</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">Thread</span><span class="o">.</span><span class="n">new</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="o">.</span><span class="n">connection_pool</span><span class="o">.</span><span class="n">with_connection</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">run_single_job</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">klass</span><span class="p">,</span> <span class="n">target_date</span><span class="p">,</span> <span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="n">threads</span><span class="o">.</span><span class="n">each</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:join</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>with_connection</code> é necessário porque cada thread precisa de sua própria conexão SQLite — sem ele, conexões vazam e o pool esgota.</p>
<p>Ao final, o pipeline imprime uma tabela de status e um resumo de billing:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-34-56.jpg" alt="billing"  loading="lazy" /></p>
<p>Esse billing summary não é vaidade — é controle de custos. Se um prompt refatorado aumenta o output tokens em 3x, você vê aqui antes de ir pra produção e gastar 10x num domingo com 200 stories.</p>
<h2>Camada 6: Preflight — Validação Estrutural Automatizada<span class="hx:absolute hx:-mt-20" id="camada-6-preflight--validação-estrutural-automatizada"></span>
    <a href="#camada-6-preflight--valida%c3%a7%c3%a3o-estrutural-automatizada" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Depois de gerar todas as seções, o pipeline roda o <code>ContentPreflight</code>. Ele não valida o <em>conteúdo</em> — valida a <em>estrutura</em>. Cada seção é checada:</p>
<ul>
<li><strong>Arquivo existe?</strong> Se <code>content/anime/2026-02-16.md</code> não existe, algo falhou.</li>
<li><strong>Item count mínimo?</strong> A seção de anime precisa de pelo menos 3 itens. Se tem 1, o gerador teve um problema.</li>
<li><strong>Markers obrigatórios?</strong> O Markdown precisa ter <code>[COMMENTARY]</code> e <code>[AKITA]</code> — sem eles, a newsletter assembly não sabe onde cortar.</li>
<li><strong>Dados específicos?</strong> Market recap precisa de pelo menos 5 ticker rows.</li>
</ul>
<p>O resultado é um status por seção: <code>pass</code>, <code>degraded</code>, <code>fail</code>, ou <code>skip</code>. O pipeline usa isso pra decidir se a newsletter pode ser montada automaticamente ou se precisa de intervenção humana:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-36-19.jpg" alt="preflight"  loading="lazy" /></p>
<p>Em produção, esse resultado vai pro Discord como um embed. No pipeline de integração, é printado no terminal. A lógica é a mesma — o que muda é o canal de notificação.</p>
<h2>Camada 7: Cross-App — Newsletter Assembly<span class="hx:absolute hx:-mt-20" id="camada-7-cross-app--newsletter-assembly"></span>
    <a href="#camada-7-cross-app--newsletter-assembly" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui está onde o monorepo paga dividendos. O marvin-bot precisa invocar o newsletter pra montar a newsletter final. Em produção, são servidores diferentes. No pipeline de integração, é um <code>system()</code> call:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">env_vars</span> <span class="o">=</span> <span class="s2">&#34;RAILS_ENV=integration CONTENT_DIR=</span><span class="si">#{</span><span class="no">Shellwords</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">content_dir</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">cmd</span> <span class="o">=</span> <span class="s2">&#34;cd </span><span class="si">#{</span><span class="n">newsletter_dir</span><span class="si">}</span><span class="s2"> &amp;&amp; </span><span class="si">#{</span><span class="n">env_vars</span><span class="si">}</span><span class="s2"> bin/rails integration:generate_all&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">system</span><span class="p">(</span><span class="n">cmd</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-38-17.jpg" alt="assembly"  loading="lazy" /></p>
<p>O newsletter lê os arquivos que o marvin-bot acabou de gerar em <code>content/</code>, monta a newsletter, e salva o resultado em <code>content/newsletters/</code>. O CONTENT_DIR é passado explicitamente — os dois apps apontam pro mesmo diretório.</p>
<p>Se o formato do frontmatter mudou e o newsletter não consegue parsear, o erro aparece aqui. Se o <code>SectionParser</code> do newsletter divergiu do <code>SectionParser</code> do marvin-bot (são cópias idênticas, mas manuais), aqui é onde você descobre.</p>
<p>Depois da newsletter, o podcast: o pipeline invoca <code>podcast:integration</code>, que gera o script de diálogo a partir da newsletter montada, sintetiza o áudio via TTS, e monta o MP3 final. É o pipeline mais longo — de newsletter Markdown a MP3 pronto pro Spotify, passando por LLM de duas passadas e síntese de voz por turn.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-47-52.jpg" alt="tts"  loading="lazy" /></p>
<h2>O Valor de Testar com Dados de Produção<span class="hx:absolute hx:-mt-20" id="o-valor-de-testar-com-dados-de-produção"></span>
    <a href="#o-valor-de-testar-com-dados-de-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vou dar exemplos concretos de bugs que só apareceram com dados reais:</p>
<p><strong>1. Títulos de anime em japonês quebravam o slugify.</strong>
O <code>ContentWriter.slugify</code> usava <code>parameterize</code> do Rails, que strip caracteres non-ASCII. Título: &ldquo;進撃の巨人 Season 4&rdquo; → slug: &ldquo;season-4&rdquo;. Dois animes com subtítulo &ldquo;Season 4&rdquo; collidiam no filesystem. Nunca apareceu em teste porque os fixtures usavam títulos em inglês.</p>
<p><strong>2. O parser de HackerNews assumia RSS, mas o feed mudou pra Atom.</strong>
<code>rss/channel/item</code> virou <code>feed/entry</code>. Testes unitários mockavam o XML. O parser em si nunca foi testado contra o feed real. Na integração com rsync do feed real, o parser retornou zero itens — detectado pelo preflight como <code>fail</code>.</p>
<p><strong>3. Yahoo Finance retornava 429 depois do 5º ticker.</strong>
O rate limiting não existia em teste (HTTP era mockado). Na integração real, os primeiros 5 tickers passavam, depois tudo falhava. A solução — sleep entre requests — nunca seria implementada sem dados reais revelando o problema.</p>
<p><strong>4. Imagens com URL válida mas Content-Type errado.</strong>
Uma CDN retornava 200 para URLs de imagem, mas com body HTML (Cloudflare error page). O <code>UrlValidator.reachable?</code> retornava <code>true</code>. O <code>ImageProcessor.download_image</code> baixava o HTML e tentava redimensionar. O mini_magick crashava com um erro obscuro. O fix: validar Content-Type no HEAD response — mas esse cenário nunca apareceria com URLs mockadas.</p>
<p><strong>5. O newsletter assembly falhava com stories que tinham <code>score: null</code>.</strong>
Stories antigas, criadas antes do campo <code>score</code> ter um default, tinham <code>nil</code> no frontmatter. O assembly tentava fazer <code>sort_by { |s| s[:frontmatter][&quot;score&quot;] }</code> — <code>nil &lt;=&gt; &quot;high&quot;</code> levanta <code>ArgumentError</code>. Só apareceu quando rsynquei dados de produção que tinham 3 meses de stories acumuladas.</p>
<p>Nenhum desses bugs seria pego por teste unitário. Todos foram pegos pelo pipeline de integração com dados reais.</p>
<h2>Dicas Práticas<span class="hx:absolute hx:-mt-20" id="dicas-práticas"></span>
    <a href="#dicas-pr%c3%a1ticas" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>1. Separe Custo de Fidelidade<span class="hx:absolute hx:-mt-20" id="1-separe-custo-de-fidelidade"></span>
    <a href="#1-separe-custo-de-fidelidade" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O pipeline de integração completo custa ~$0.40 em API calls. Não é muito, mas se você roda 10 vezes num dia iterando, são $4. O DevCache resolve isso: primeira execução paga, as seguintes são grátis.</p>
<p>Mas tem cenários onde você <strong>quer</strong> pagar de novo — quando muda um prompt e precisa ver o resultado real. O <code>FORCE=1</code> busta o cache seletivamente. E o <code>SKIP_EXISTING=1</code> pula seções que já foram geradas, economizando tempo quando você só quer re-rodar uma seção específica.</p>
<h3>2. O Integration Environment Não É Staging<span class="hx:absolute hx:-mt-20" id="2-o-integration-environment-não-é-staging"></span>
    <a href="#2-o-integration-environment-n%c3%a3o-%c3%a9-staging" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Staging é um servidor que roda 24/7 simulando produção. O integration environment é um <strong>modo de execução local</strong> que você liga quando precisa e desliga quando terminou. Não precisa de infraestrutura dedicada. Não precisa de database separado. É o mesmo código, na mesma máquina, com flags diferentes.</p>
<p>A diferença é filosófica: staging testa &ldquo;o sistema funciona no servidor de staging?&rdquo;. Integration testa &ldquo;o sistema funciona <strong>com esses dados específicos</strong>?&rdquo;. O primeiro é environment-dependent. O segundo é data-dependent. E bugs de dados são mais comuns que bugs de environment.</p>
<h3>3. Clean Antes de Rsync, Não Depois<span class="hx:absolute hx:-mt-20" id="3-clean-antes-de-rsync-não-depois"></span>
    <a href="#3-clean-antes-de-rsync-n%c3%a3o-depois" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A ordem importa: limpe o conteúdo gerado <strong>antes</strong> de sincronizar de produção. Se fizer o contrário — rsync primeiro, clean depois — você perde os dados de produção que acabou de baixar.</p>
<p>O <code>integration:clean</code> é cirúrgico por design. Remove anime/, hacker_news/, newsletters/ (gerados), mas preserva stories/ e images/ (produção). Isso permite um workflow iterativo: rsync uma vez, roda o pipeline, ajusta um prompt, roda de novo — sem re-baixar 500MB de content.</p>
<h3>4. Preflight É Seu Smoke Test<span class="hx:absolute hx:-mt-20" id="4-preflight-é-seu-smoke-test"></span>
    <a href="#4-preflight-%c3%a9-seu-smoke-test" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Não confie que &ldquo;rodou sem exceção = está correto&rdquo;. Um job de geração pode rodar, produzir um Markdown com 2 itens ao invés de 10, e retornar sucesso. O preflight detecta isso verificando contagens mínimas, markers obrigatórios, e estrutura esperada.</p>
<p>Na prática, todo bug de LLM que encontrei foi detectado pelo preflight: respostas truncadas, formato errado, seções faltando. O GPT-5.2 é bom, mas não é determinístico — e quando erra, erra de formas criativas que nenhum schema validation pegaria.</p>
<h3>5. Billing Summary Não É Opcional<span class="hx:absolute hx:-mt-20" id="5-billing-summary-não-é-opcional"></span>
    <a href="#5-billing-summary-n%c3%a3o-%c3%a9-opcional" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Se você está usando LLM em produção, <strong>todo</strong> pipeline de teste precisa reportar custo. Não por contabilidade — por engenharia. Um prompt que funciona mas custa 3x mais que o anterior é um bug de performance. Tokens de input e output por provider, custo por job, custo total — tudo visível a cada execução.</p>
<h3>6. Monorepo Facilita, Mas Não Resolve<span class="hx:absolute hx:-mt-20" id="6-monorepo-facilita-mas-não-resolve"></span>
    <a href="#6-monorepo-facilita-mas-n%c3%a3o-resolve" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Ter os três apps no mesmo repositório facilita: um <code>git bisect</code> cobre mudanças em ambos os lados do contrato. Um PR que muda o formato do frontmatter no marvin-bot <strong>pode</strong> também atualizar o parser no newsletter — no mesmo commit.</p>
<p>Mas &ldquo;pode&rdquo; não é &ldquo;garante&rdquo;. O contrato entre os apps é file-based e implícito. Não tem tipo compartilhado, não tem interface comum. O <code>SectionParser</code> é copiado manualmente entre os dois apps. Se alguém edita um e esquece do outro, divergem. O teste de integração é a <strong>única</strong> verificação de que os dois lados concordam.</p>
<h3>7. Preview Antes de Enviar<span class="hx:absolute hx:-mt-20" id="7-preview-antes-de-enviar"></span>
    <a href="#7-preview-antes-de-enviar" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O pipeline não termina na geração. O newsletter tem um <code>newsletter:preview</code> que renderiza o resultado final em HTML light e dark:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> newsletter
</span></span><span class="line"><span class="cl"><span class="nv">FILE</span><span class="o">=</span>../content/newsletters/2026-02-16.md bin/rails newsletter:preview
</span></span><span class="line"><span class="cl"><span class="c1"># → tmp/preview/newsletter_light.html</span>
</span></span><span class="line"><span class="cl"><span class="c1"># → tmp/preview/newsletter_dark.html</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Abrir esses HTMLs no browser é o teste de integração visual. O Markdown pode estar perfeito, o frontmatter correto, todos os campos presentes — mas a imagem de um anime aparece esticada porque o aspect ratio mudou. Ou o bloco de comentário do Marvin não tem contraste suficiente no dark mode. Esses bugs são visuais — nenhum teste automatizado pega.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-48-32.jpg" alt="final assembly"  loading="lazy" /></p>
<h3>8. O Pipeline de CI É Diferente do Pipeline de Integração<span class="hx:absolute hx:-mt-20" id="8-o-pipeline-de-ci-é-diferente-do-pipeline-de-integração"></span>
    <a href="#8-o-pipeline-de-ci-%c3%a9-diferente-do-pipeline-de-integra%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>São coisas diferentes. CI roda em todo commit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">bin/ci  <span class="c1"># rubocop + bundler-audit + brakeman + tests (~22s)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Integração roda quando você quer validar o pipeline end-to-end — antes de um deploy, depois de mudar prompts, ou depois de rsync de dados novos de produção. CI é rápido e barato. Integração é lento e custa dinheiro. Os dois são necessários.</p>
<h2>O Custo de Não Ter Integração<span class="hx:absolute hx:-mt-20" id="o-custo-de-não-ter-integração"></span>
    <a href="#o-custo-de-n%c3%a3o-ter-integra%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vou ser direto: se esse projeto não tivesse o pipeline de integração, eu teria enviado pelo menos 3 newsletters quebradas.</p>
<p>Uma com seção de anime faltando porque o parser de XML assumia RSS e o feed era Atom. Uma com todas as imagens de YouTube apontando pra 404 porque o padrão de URL de thumbnail mudou. E uma com o market recap mostrando tickers do mês passado porque o Yahoo Finance bloqueou as requests e o job falhou silenciosamente.</p>
<p>Testes unitários teriam passado em todos esses cenários. O CI teria dado verde. O deploy teria ido pro ar. E às 7 da manhã de segunda-feira, 300 pessoas teriam recebido uma newsletter com buracos.</p>
<p>O pipeline de integração é caro — em tempo de setup, em custo de API, em complexidade de manutenção. Mas é ordens de magnitude mais barato que enviar conteúdo quebrado pra assinantes reais. Especialmente quando você só tem uma chance por semana.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A hierarquia de testes nesse projeto é:</p>
<ol>
<li><strong>Testes unitários</strong> (1.330 nos dois apps, 7 segundos em paralelo, $0): prova que cada peça funciona isolada</li>
<li><strong>CI pipeline</strong> (rubocop + audit + brakeman + tests, ~22 segundos, $0): prova que o código está saudável</li>
<li><strong>Pipeline de integração</strong> (dados reais, APIs reais, ~3 minutos, ~$0.40): prova que o <strong>sistema</strong> funciona</li>
<li><strong>Preview visual</strong> (HTML no browser, manual, $0): prova que o resultado final é apresentável</li>
</ol>
<p>Cada camada pega um tipo diferente de bug. Nenhuma sozinha é suficiente. E a que mais surpreende — a que pega os bugs que você jurava que não existiam — é a terceira: dados reais, pipeline real, sem mocks.</p>
<p>A confiança que você tem no seu sistema é proporcional ao quão reais são os dados nos seus testes. Mocka tudo e você tem 100% de coverage e zero de confiança. Testa com dados de produção e você tem a verdade — por mais inconveniente que seja.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>testes</category><category>engenharia-de-software</category></item><item><title>SQLite + Kamal: Deploy de Rails sem Drama | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/20/sqlite-kamal-deploy-de-rails-sem-drama-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/20/sqlite-kamal-deploy-de-rails-sem-drama-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 03:18:55 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 7.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Se eu te dissesse que dá pra rodar uma aplicação Rails completa em produção — com banco de dados, fila de jobs, cache — num único VPS de $12/mês sem instalar PostgreSQL, Redis, ou qualquer outro serviço externo, você acreditaria?&lt;/p&gt;
&lt;p&gt;Pois é. Esse é o Rails 8 com SQLite e Kamal.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 7.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Se eu te dissesse que dá pra rodar uma aplicação Rails completa em produção — com banco de dados, fila de jobs, cache — num único VPS de $12/mês sem instalar PostgreSQL, Redis, ou qualquer outro serviço externo, você acreditaria?</p>
<p>Pois é. Esse é o Rails 8 com SQLite e Kamal.</p>
<h2>SQLite no Rails 8: agora é pra valer<span class="hx:absolute hx:-mt-20" id="sqlite-no-rails-8-agora-é-pra-valer"></span>
    <a href="#sqlite-no-rails-8-agora-%c3%a9-pra-valer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Rails 8 trouxe o SQLite como opção real de produção, não como brinquedo de desenvolvimento. E quando digo &ldquo;real&rdquo;, quero dizer que o <code>rails new</code> gera tudo pronto: banco principal em SQLite, SolidQueue (jobs) em SQLite, SolidCache (cache) em SQLite, SolidCable (WebSocket) em SQLite.</p>
<p><strong>Quatro serviços que antes precisavam de PostgreSQL + Redis + Memcached agora são arquivos <code>.sqlite3</code> no disco.</strong></p>
<p>O WAL mode (Write-Ahead Logging) habilita leituras concorrentes enquanto uma escrita acontece. Pra aplicações com dezenas de milhares de requisições por dia — que é a imensa maioria das aplicações Rails no mundo — SQLite é mais que suficiente. E a latência de leitura é medida em <strong>microsegundos</strong>, não milissegundos. É acesso a disco local, não round-trip de rede.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># database.yml — produção com SQLite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">primary</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">database</span><span class="p">:</span><span class="w"> </span><span class="l">storage/production.sqlite3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">queue</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">database</span><span class="p">:</span><span class="w"> </span><span class="l">storage/production_queue.sqlite3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">migrations_paths</span><span class="p">:</span><span class="w"> </span><span class="l">db/queue_migrate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cache</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">&lt;&lt;</span><span class="p">:</span><span class="w"> </span><span class="cp">*default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">database</span><span class="p">:</span><span class="w"> </span><span class="l">storage/production_cache.sqlite3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">migrations_paths</span><span class="p">:</span><span class="w"> </span><span class="l">db/cache_migrate</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Três arquivos. Sem conexão TCP. Sem pooling. Sem &ldquo;o PostgreSQL tá aceitando conexões?&rdquo;. Backup é um <code>VACUUM INTO</code> e pronto — um comando SQL que produz uma cópia atômica. Restore? Cola o arquivo de volta. Tenta fazer isso com um dump de PostgreSQL de 2GB.</p>
<h2>Kamal: Docker Deploy Sem Kubernetes<span class="hx:absolute hx:-mt-20" id="kamal-docker-deploy-sem-kubernetes"></span>
    <a href="#kamal-docker-deploy-sem-kubernetes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Kamal é o deployer do Rails 8. Pensa nele como &ldquo;SSH + Docker, mas inteligente&rdquo;. Sem Kubernetes. Sem Helm charts. Sem YAML de 300 linhas descrevendo um Ingress Controller.</p>
<p>A configuração inteira cabe em um arquivo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># config/deploy.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">minha-app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">meuregistry/minha-app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">servers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">hosts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">107.170.70.49</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">options</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">network</span><span class="p">:</span><span class="w"> </span><span class="l">minha-network</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">minha-app-storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">minha-app-content:/rails/content</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ssl</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">app.meudominio.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">builder</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">arch</span><span class="p">:</span><span class="w"> </span><span class="l">amd64</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">clear</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">RAILS_LOG_LEVEL</span><span class="p">:</span><span class="w"> </span><span class="l">info</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">secret</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">RAILS_MASTER_KEY</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">DATABASE_URL</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O que o Kamal faz quando você roda <code>kamal deploy</code>:</p>
<ol>
<li>Builda a imagem Docker localmente (ou em CI)</li>
<li>Pusha pro registry</li>
<li>SSH no servidor</li>
<li>Puxa a nova imagem</li>
<li>Roda migrations</li>
<li>Reinicia o container com <strong>zero downtime</strong> via kamal-proxy</li>
</ol>
<p>O kamal-proxy é o segredo: ele funciona como reverse proxy na frente dos seus containers. Quando o deploy acontece, ele sobe o novo container, espera ele ficar healthy, redireciona o tráfego, e depois derruba o antigo. Seus usuários não percebem nada.</p>
<h2>Volumes Docker: O Segredo do SQLite em Produção<span class="hx:absolute hx:-mt-20" id="volumes-docker-o-segredo-do-sqlite-em-produção"></span>
    <a href="#volumes-docker-o-segredo-do-sqlite-em-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O ponto crítico de SQLite em Docker é: <strong>os dados não podem morrer com o container</strong>. Docker volumes resolvem isso:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">minha-app-storage:/rails/storage</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O diretório <code>storage/</code> contém todos os bancos SQLite. Com o volume Docker, eles persistem entre deploys. O container é efêmero; os dados são permanentes.</p>
<p>Mas tem um detalhe que pega muita gente: se você roda <strong>dois serviços</strong> diferentes que compartilham dados via arquivos (não banco), precisa de um volume compartilhado:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Serviço A escreve conteúdo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">content-compartilhado:/rails/content</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Serviço B lê conteúdo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">content-compartilhado:/rails/content</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O mesmo volume Docker montado em dois containers. Simples, funciona, e não precisa de NFS, S3, ou qualquer coisa sofisticada. É um diretório no disco do servidor.</p>
<h2>Múltiplos Serviços no Mesmo VPS<span class="hx:absolute hx:-mt-20" id="múltiplos-serviços-no-mesmo-vps"></span>
    <a href="#m%c3%baltiplos-servi%c3%a7os-no-mesmo-vps" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Uma vantagem pouco discutida do Kamal: você pode rodar <strong>múltiplas aplicações</strong> no mesmo servidor. O kamal-proxy roteia por hostname:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>app.meudominio.com    → container-app
bot.meudominio.com    → container-bot</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada serviço tem seu próprio <code>deploy.yml</code>, sua própria imagem Docker, seu próprio ciclo de deploy. Mas todos rodam no mesmo VPS, compartilhando a mesma rede Docker para comunicação interna.</p>
<p>O SSL é automático via Let&rsquo;s Encrypt — o kamal-proxy cuida disso. Cada hostname ganha seu certificado sem você configurar nada.</p>
<h2>Hooks: Automação no Deploy<span class="hx:absolute hx:-mt-20" id="hooks-automação-no-deploy"></span>
    <a href="#hooks-automa%c3%a7%c3%a3o-no-deploy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Kamal suporta hooks em vários pontos do ciclo de deploy. O mais útil é o <code>pre-deploy</code>, que roda <strong>antes</strong> do container novo substituir o antigo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># .kamal/hooks/pre-deploy</span>
</span></span><span class="line"><span class="cl"><span class="c1">#!/bin/sh</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Roda migrations antes do container ficar ativo</span>
</span></span><span class="line"><span class="cl"><span class="n">ssh</span> <span class="n">root</span><span class="err">@</span><span class="vg">$KAMAL_HOSTS</span> <span class="p">\</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;docker exec minha-app-web bin/rails db:migrate&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Outros hooks úteis:</p>
<ul>
<li><code>post-deploy</code> — notificar equipe, limpar cache</li>
<li><code>pre-connect</code> — verificar saúde do servidor</li>
<li><code>docker-setup</code> — instalar dependências no host</li>
</ul>
<h2>O Dockerfile que o Rails Gera<span class="hx:absolute hx:-mt-20" id="o-dockerfile-que-o-rails-gera"></span>
    <a href="#o-dockerfile-que-o-rails-gera" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Rails 8 gera um Dockerfile otimizado de produção. Alguns destaques:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Multi-stage build: build stage grande, runtime stage mínima</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ruby:3.3-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Instala apenas dependências de runtime</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y libsqlite3-0<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Aqui instala tudo pra compilar gems nativas</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y build-essential libsqlite3-dev<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copia e instala gems</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> Gemfile* ./<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> bundle install --without development test<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Stage final: só o necessário pra rodar</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>build /usr/local/bundle /usr/local/bundle<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> . .<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> bundle <span class="nb">exec</span> bootsnap precompile --gemfile app/ lib/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Multi-stage build significa que a imagem final não tem compiladores, headers de desenvolvimento, ou qualquer coisa desnecessária. A imagem de produção fica enxuta.</p>
<h2>Secrets: Sem .env em Produção<span class="hx:absolute hx:-mt-20" id="secrets-sem-env-em-produção"></span>
    <a href="#secrets-sem-env-em-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Kamal tem um sistema de secrets que lê de um arquivo local (<code>.kamal/secrets</code>) e injeta como variáveis de ambiente no container. Esse arquivo <strong>nunca</strong> vai pro git:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># .kamal/secrets (gitignored)</span>
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_MASTER_KEY</span><span class="o">=</span>abc123...
</span></span><span class="line"><span class="cl"><span class="nv">AWS_ACCESS_KEY_ID</span><span class="o">=</span>AKIA...</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No <code>deploy.yml</code>, você referencia:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">secret</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">RAILS_MASTER_KEY</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">AWS_ACCESS_KEY_ID</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O Kamal lê do arquivo local e configura no servidor. Simples, seguro, sem HashiCorp Vault ou AWS Secrets Manager (a menos que você queira — Kamal suporta adaptadores).</p>
<h2>Backup de SQLite: Absurdamente Simples (Se Fizer Certo)<span class="hx:absolute hx:-mt-20" id="backup-de-sqlite-absurdamente-simples-se-fizer-certo"></span>
    <a href="#backup-de-sqlite-absurdamente-simples-se-fizer-certo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quer backup? Um comando SQL:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">VACUUM</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="s1">&#39;/tmp/backup/newsletter.sqlite3&#39;</span><span class="p">;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>VACUUM INTO</code> é <strong>atômico</strong> — produz uma cópia consistente e desfragmentada do banco, mesmo com escritas acontecendo. Roda enquanto a aplicação está servindo requests normalmente. E a cópia resultante é um banco SQLite completo e funcional — abre, consulta, restaura.</p>
<p><strong>Atenção</strong>: copiar o arquivo <code>.sqlite3</code> diretamente (<code>cp</code>, <code>tar</code>, <code>rsync</code>) de um banco em uso <strong>pode corromper o backup</strong>. Se uma escrita estiver em andamento no momento da cópia, você fica com um arquivo meio-escrito. O SQLite tem WAL (Write-Ahead Log) e journaling que protegem o banco ativo — mas a cópia crua não herda essa proteção.</p>
<p>Na prática, automatizei isso com um job Rails que roda a cada hora:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">BackupDatabaseJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">backup_dir</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="no">Rails</span><span class="o">.</span><span class="n">configuration</span><span class="o">.</span><span class="n">content_dir</span><span class="p">,</span> <span class="s2">&#34;backups&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">FileUtils</span><span class="o">.</span><span class="n">mkdir_p</span><span class="p">(</span><span class="n">backup_dir</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">dest</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">backup_dir</span><span class="p">,</span> <span class="s2">&#34;newsletter.sqlite3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="o">.</span><span class="n">connection</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span><span class="s2">&#34;VACUUM INTO &#39;</span><span class="si">#{</span><span class="n">dest</span><span class="si">}</span><span class="s2">&#39;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O backup vai pro diretório compartilhado de conteúdo — o mesmo que já é sincronizado com <code>rsync</code> pra máquina local. Sem agente de backup extra, sem serviço externo, sem snapshot de volume. Um SQL, um arquivo, um <code>rsync</code>.</p>
<p>Compara isso com <code>pg_dump</code> de um banco PostgreSQL de produção com dezenas de tabelas e constraints. O SQLite backup é <strong>um comando que produz um arquivo funcional</strong>. Restaurar? Copia de volta e reinicia.</p>
<h2>Quando NÃO Usar SQLite<span class="hx:absolute hx:-mt-20" id="quando-não-usar-sqlite"></span>
    <a href="#quando-n%c3%a3o-usar-sqlite" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vou ser honesto: SQLite não é pra tudo.</p>
<ul>
<li><strong>Múltiplos servidores web</strong> escrevendo no mesmo banco? Não. SQLite é single-writer. Se você precisa de horizontal scaling com múltiplos nós, vai de PostgreSQL.</li>
<li><strong>Datasets massivos</strong> (centenas de GB)? PostgreSQL tem melhor query planning e paralelismo.</li>
<li><strong>Replicação e alta disponibilidade</strong>? PostgreSQL com streaming replication.</li>
</ul>
<p>Mas a pergunta honesta é: <strong>quantas aplicações realmente precisam disso?</strong> A vasta maioria roda num único servidor e nunca vai precisar de mais. E pra essas, SQLite + Kamal é a combinação mais produtiva que existe hoje.</p>
<h2>O Custo Real<span class="hx:absolute hx:-mt-20" id="o-custo-real"></span>
    <a href="#o-custo-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vamos fazer a conta:</p>
<table>
  <thead>
      <tr>
          <th>Componente</th>
          <th>Antes</th>
          <th>Agora</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Servidor</td>
          <td>VPS $24/mês</td>
          <td>VPS $12/mês</td>
      </tr>
      <tr>
          <td>Banco</td>
          <td>RDS PostgreSQL $30/mês</td>
          <td>SQLite (incluso)</td>
      </tr>
      <tr>
          <td>Redis</td>
          <td>ElastiCache $15/mês</td>
          <td>SolidQueue (incluso)</td>
      </tr>
      <tr>
          <td>Cache</td>
          <td>ElastiCache $15/mês</td>
          <td>SolidCache (incluso)</td>
      </tr>
      <tr>
          <td>Deploy</td>
          <td>CI/CD complexo</td>
          <td><code>kamal deploy</code></td>
      </tr>
      <tr>
          <td>SSL</td>
          <td>Certbot + cron</td>
          <td>Automático</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>~$84/mês + dor de cabeça</strong></td>
          <td><strong>$12/mês</strong></td>
      </tr>
  </tbody>
</table>
<p>E o custo cognitivo? Ao invés de debugar &ldquo;por que o Redis perdeu meus jobs?&rdquo;, &ldquo;por que o PostgreSQL tá rejeitando conexões?&rdquo;, &ldquo;por que o cert expirou?&rdquo;, você foca no que importa: <strong>o produto</strong>.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SQLite + Kamal não é downgrade. É que a maioria das aplicações nunca precisou da complexidade que a gente achava que precisava. O Rails 8 abraçou isso e entregou uma experiência de deploy que é, sem exagero, a mais simples que já existiu no ecossistema Ruby.</p>
<p>Um VPS. Um comando. Zero serviços externos. E uma aplicação que roda tão rápido quanto qualquer setup enterprise com 15 serviços no docker-compose.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>rails</category><category>devops</category></item><item><title>Discord como Admin Panel | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/20/discord-como-admin-panel-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/20/discord-como-admin-panel-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 03:18:24 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 6.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Todo projeto Rails começa igual: você cria um admin panel. Active Admin, Administrate ou um CRUD caseiro. Tabelas, formulários, botões. E a cada semana, você usa 5% das telas que construiu.&lt;/p&gt;
&lt;p&gt;Agora imagine que seu admin panel é uma conversa. Você cola um link; o sistema processa. Digita &lt;code&gt;/week&lt;/code&gt; e vê o resumo. &lt;code&gt;/approve&lt;/code&gt; e a newsletter está agendada. &lt;code&gt;/ask akita como funciona o pipeline de podcast?&lt;/code&gt; e uma IA te explica a arquitetura do seu próprio sistema.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 6.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Todo projeto Rails começa igual: você cria um admin panel. Active Admin, Administrate ou um CRUD caseiro. Tabelas, formulários, botões. E a cada semana, você usa 5% das telas que construiu.</p>
<p>Agora imagine que seu admin panel é uma conversa. Você cola um link; o sistema processa. Digita <code>/week</code> e vê o resumo. <code>/approve</code> e a newsletter está agendada. <code>/ask akita como funciona o pipeline de podcast?</code> e uma IA te explica a arquitetura do seu próprio sistema.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-40-53.jpg" alt="/ask akita"  loading="lazy" /></p>
<p>Esse é o Discord como interface operacional. Não é gambiarra — é <strong>a melhor interface CRUD que existe pra um time de uma pessoa</strong>.</p>
<h2>Por Que Discord e Não Um Web Admin?<span class="hx:absolute hx:-mt-20" id="por-que-discord-e-não-um-web-admin"></span>
    <a href="#por-que-discord-e-n%c3%a3o-um-web-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vamos ser honestos sobre o que um admin panel exige:</p>
<ol>
<li><strong>Autenticação</strong>: login, sessão, CSRF, password reset</li>
<li><strong>Autorização</strong>: roles, permissions, who can do what</li>
<li><strong>UI</strong>: tabelas, paginação, formulários, validação client-side</li>
<li><strong>Deployment</strong>: outra rota, outro controller, outro set de testes</li>
<li><strong>Mobile</strong>: responsive? App? PWA?</li>
</ol>
<p>E o que o Discord já te dá de graça:</p>
<ol>
<li><strong>Auth</strong>: Discord cuida. Você verifica o <code>author_id</code></li>
<li><strong>Autorização</strong>: uma lista de admin IDs e pronto</li>
<li><strong>UI</strong>: embeds com cores, campos, thumbnails, reações como status</li>
<li><strong>Deployment</strong>: zero. Usa o que já está deployado</li>
<li><strong>Mobile</strong>: app nativo no iOS e Android, push notifications</li>
</ol>
<p>O trade-off? Não serve pra tudo. Um CRUD complexo com formulários de 20 campos? Melhor um web admin. Mas pra operações de comando/resposta — que são 90% do que você faz no dia a dia — Discord é imbatível.</p>
<h2>discordrb: A Gem Que Funciona<span class="hx:absolute hx:-mt-20" id="discordrb-a-gem-que-funciona"></span>
    <a href="#discordrb-a-gem-que-funciona" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A gem <a href="https://github.com/shardlab/discordrb"target="_blank" rel="noopener"><code>discordrb</code></a> (fork do shardlab, versão 3.7.2) é a mais madura pra Ruby. Não é perfeita — tem idiossincrasias. Mas funciona de verdade em produção.</p>
<p>O setup mínimo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">intents</span><span class="p">:</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:server_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:direct_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="p">(</span><span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">15</span><span class="p">)</span>  <span class="c1"># message_content intent</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Toda mensagem passa por aqui</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">run</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Aquele <code>(1 &lt;&lt; 15)</code> é o <em>Message Content Intent</em>. Sem ele, seu bot recebe eventos mas o <code>event.content</code> vem vazio. Precisa habilitar no Discord Developer Portal &gt; Bot &gt; Privileged Gateway Intents. É a primeira armadilha que pega todo mundo.</p>
<p><a href="https://discord.com/developers/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-58-46.jpg" alt="discord dashboard"  loading="lazy" /></a></p>
<p>Não vou detalhar como configurar um bot no Discord, tem <a href="https://www.ionos.com/digitalguide/server/know-how/creating-discord-bot/"target="_blank" rel="noopener">vários tutoriais</a> pra isso já, você acha fácil online. Vou focar só no meu caso de uso como exemplo.</p>
<h2>Padrão 1: Parser + Dispatcher<span class="hx:absolute hx:-mt-20" id="padrão-1-parser--dispatcher"></span>
    <a href="#padr%c3%a3o-1-parser--dispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Misturar parsing de comandos com lógica de negócio é receita pra espaguete. Separa em três camadas:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Discord Event → Parser → Dispatcher → Job/Service</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O runner (que conversa com a API do Discord) é burro de propósito:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="k">next</span> <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">author</span><span class="o">.</span><span class="n">bot_account?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">parsed</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">event</span><span class="o">.</span><span class="n">content</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">urls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">parsed</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span>
</span></span><span class="line"><span class="cl">    <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">parsed</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{2705}&#34;</span><span class="p">)</span>  <span class="c1"># checkmark</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Submission</span>
</span></span><span class="line"><span class="cl">    <span class="no">ProcessMessageJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="ss">url</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">url</span><span class="p">,</span> <span class="ss">tags</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">tags</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{1F916}&#34;</span><span class="p">)</span>  <span class="c1"># robot</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{274C}&#34;</span><span class="p">)</span>  <span class="c1"># red X</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Três resultados visuais instantâneos: checkmark (comando aceito), robô (URL sendo processada), X vermelho (erro). O usuário sabe o que aconteceu sem esperar nenhuma resposta textual.</p>
<p>O parser retorna objetos tipados, não strings. O dispatcher faz um <code>case</code> gigante mas cada handler é um método isolado. Cada handler chama um job ou service. <strong>Nada de lógica de negócio no runner.</strong></p>
<h3>Evolução: Discord Application Commands<span class="hx:absolute hx:-mt-20" id="evolução-discord-application-commands"></span>
    <a href="#evolu%c3%a7%c3%a3o-discord-application-commands" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A versão inicial parseava texto puro — o usuário digitava <code>/week</code> e o parser interpretava. Funciona, mas tem limitações: sem autocomplete, sem validação de argumentos, sem documentação inline.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-56-15.jpg" alt="help"  loading="lazy" /></p>
<p>O Discord tem um sistema nativo de <strong>Application Commands</strong> — slash commands registrados na API que aparecem com autocomplete, descrição, e tipagem de parâmetros. Quando o usuário digita <code>/</code>, o Discord mostra todos os comandos disponíveis com descrição.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;List this week&#39;s stories&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:approve</span><span class="p">,</span> <span class="s2">&#34;Approve newsletter for sending&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:ask</span><span class="p">,</span> <span class="s2">&#34;Ask AI a question&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;question&#34;</span><span class="p">,</span> <span class="s2">&#34;Your question&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;personality&#34;</span><span class="p">,</span> <span class="s2">&#34;AI personality&#34;</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span><span class="p">,</span> <span class="s2">&#34;Marvin&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O handler usa <code>bot.application_command</code> ao invés de parsear texto:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch_week</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Listing stories...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Os dois sistemas coexistem: Application Commands pra quem prefere autocomplete, texto puro pra quem prefere digitar rápido. O dispatcher é o mesmo — a diferença é só como o comando chega.</p>
<h2>Padrão 2: Reações Como Status<span class="hx:absolute hx:-mt-20" id="padrão-2-reações-como-status"></span>
    <a href="#padr%c3%a3o-2-rea%c3%a7%c3%b5es-como-status" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-55.jpg" alt="reactions"  loading="lazy" /></p>
<p>A feature mais subestimada do Discord pra bots é reações. Elas são feedback visual instantâneo que não polui o canal com mensagens:</p>
<ul>
<li><strong>Checkmark</strong> = comando recebido e enfileirado</li>
<li><strong>Robô</strong> = processamento assíncrono iniciado</li>
<li><strong>X vermelho</strong> = erro (detalhes no embed que segue)</li>
</ul>
<p>O usuário cola um link. Vê o robô aparecer. 30 segundos depois, um embed rico aparece com título, resumo, imagem e comentário da IA. Se algo falhar, aparece um embed vermelho com a mensagem de erro.</p>
<p>Isso é melhor que qualquer spinner de loading numa web UI.</p>
<h2>Padrão 3: Embeds Ricos Como Dashboard<span class="hx:absolute hx:-mt-20" id="padrão-3-embeds-ricos-como-dashboard"></span>
    <a href="#padr%c3%a3o-3-embeds-ricos-como-dashboard" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-44-36.jpg" alt="new story"  loading="lazy" /></p>
<p>Discord embeds são surpreendentemente poderosos:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;Story Preview&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">url</span><span class="p">:</span> <span class="n">source_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFF4500</span><span class="p">,</span>           <span class="c1"># vermelho-alaranjado pra high priority</span>
</span></span><span class="line"><span class="cl">  <span class="ss">description</span><span class="p">:</span> <span class="n">summary</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">thumbnail</span><span class="p">:</span> <span class="p">{</span> <span class="ss">url</span><span class="p">:</span> <span class="n">image</span> <span class="p">},</span>  <span class="c1"># imagem do artigo</span>
</span></span><span class="line"><span class="cl">  <span class="ss">fields</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Akita diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">akita_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Arvin diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">marvin_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;File&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="s2">&#34;`stories/2026-02-16-titulo.md`&#34;</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">footer</span><span class="p">:</span> <span class="p">{</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;fire high | tech_news | verified&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">Discordrb</span><span class="o">::</span><span class="no">API</span><span class="o">::</span><span class="no">Channel</span><span class="o">.</span><span class="n">create_message</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="kp">false</span><span class="p">,</span> <span class="o">[</span><span class="n">embed</span><span class="o">]</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cores indicam prioridade (vermelho = high, âmbar = medium, verde = low). O footer mostra score, seção e status de fact-check tudo numa linha. Você bate o olho e sabe o estado da coisa.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-45-11.jpg" alt="week list"  loading="lazy" /></p>
<p>Para relatórios, múltiplos embeds lado a lado (Discord aceita até 10 por mensagem) funcionam como um mini-dashboard. O comando <code>/week</code> lista todas as histórias com ações inline — <code>/load @a3f2b1</code> pra ver detalhes, <code>/delete @a3f2b1</code> pra remover. O hash é estável (baseado no path do arquivo), então não muda quando você deleta outra história.</p>
<h2>Padrão 4: Context Per Channel<span class="hx:absolute hx:-mt-20" id="padrão-4-context-per-channel"></span>
    <a href="#padr%c3%a3o-4-context-per-channel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-46-29.jpg" alt="load"  loading="lazy" /></p>
<p>Quando o usuário digita <code>/load #3</code>, o #3 se refere à listagem mais recente. Mas e se dois canais estão sendo usados simultaneamente?</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ChannelContext</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="no">EXPIRY_DURATION</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">for_channel</span><span class="p">(</span><span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">find_or_create_by!</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">set_week_stories!</span><span class="p">(</span><span class="nb">hash</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update!</span><span class="p">(</span><span class="ss">week_stories_json</span><span class="p">:</span> <span class="nb">hash</span><span class="o">.</span><span class="n">to_json</span><span class="p">,</span> <span class="ss">expires_at</span><span class="p">:</span> <span class="no">EXPIRY_DURATION</span><span class="o">.</span><span class="n">from_now</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">resolve_story_reference</span><span class="p">(</span><span class="n">ref</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A@[a-f0-9]+\z/i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># Hash reference (stable across re-listings)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">hash</span> <span class="o">=</span> <span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;@&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">downcase</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">path</span><span class="o">|</span> <span class="n">md5_prefix</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="o">==</span> <span class="nb">hash</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">elsif</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A#?\d+\z/</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">[</span><span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;#&#34;</span><span class="p">)</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada canal tem seu próprio contexto com expiração de 2 horas. O <code>/week</code> armazena o mapeamento número→arquivo. Depois, <code>/load #3</code> consulta esse contexto. Se expirou, o bot diz &ldquo;run <code>/week</code> first&rdquo;.</p>
<p>E o detalhe mais útil: referências por hash (<code>@a3f2b1</code>) ao invés de número. Quando você deleta a história #2, a #3 vira #2 — mas o hash <code>@a3f2b1</code> continua apontando pro mesmo arquivo. Isso evita o clássico &ldquo;deletei a errada porque a lista mudou&rdquo;.</p>
<h2>Padrão 5: Admin Check Simples<span class="hx:absolute hx:-mt-20" id="padrão-5-admin-check-simples"></span>
    <a href="#padr%c3%a3o-5-admin-check-simples" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Nada de RBAC complexo. Uma lista de IDs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">ADMIN_COMMANDS</span> <span class="o">=</span> <span class="o">%</span><span class="n">i</span><span class="o">[</span><span class="n">delete</span> <span class="n">update_score</span> <span class="n">approve</span> <span class="n">newsletter</span> <span class="n">publish_blog</span><span class="o">].</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">author_id</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">ADMIN_COMMANDS</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">command</span><span class="o">.</span><span class="n">type</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">send_error</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;Permission denied. Admin privileges required.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_ADMIN_USER_IDS&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;,&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">admin_ids</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># dev: sem IDs = tudo permitido</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Em dev, sem <code>DISCORD_ADMIN_USER_IDS</code> configurado, tudo é permitido. Em produção, é uma lista de Discord user IDs. Sem banco, sem roles table, sem middleware. Dez linhas resolvem autorização.</p>
<h2>Padrão 6: Operações Assíncronas com Feedback<span class="hx:absolute hx:-mt-20" id="padrão-6-operações-assíncronas-com-feedback"></span>
    <a href="#padr%c3%a3o-6-opera%c3%a7%c3%b5es-ass%c3%adncronas-com-feedback" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Operações lentas (gerar imagem, processar artigo, chamar IA) vão pra background jobs. O feedback visual é em três tempos:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">handle_ask</span><span class="p">(</span><span class="n">personality</span><span class="p">,</span> <span class="n">question</span><span class="p">,</span> <span class="n">attachments</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 1. Feedback imediato: &#34;estou pensando&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">thinking_id</span> <span class="o">=</span> <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Thinking...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 2. Job faz o trabalho pesado em background</span>
</span></span><span class="line"><span class="cl">  <span class="no">AskJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">question</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="n">thinking_id</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># No AskJob:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">result</span> <span class="o">=</span> <span class="no">AiChat</span><span class="o">.</span><span class="n">ask_multimodal</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="o">[</span><span class="no">GenerateImageTool</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 3. Remove &#34;Thinking...&#34; e mostra resultado</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">delete_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">thinking_message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;**Marvin:** </span><span class="si">#{</span><span class="n">result</span><span class="o">[</span><span class="ss">:text</span><span class="o">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-47-26.jpg" alt="thinking"  loading="lazy" /></p>
<p>O &ldquo;Thinking&hellip;&rdquo; aparece instantaneamente, o job roda em background, e quando termina, a mensagem temporária some e o resultado real aparece. Se o job falhar, o &ldquo;Thinking&hellip;&rdquo; some e um embed de erro aparece.</p>
<p>Sem isso, o usuário manda uma pergunta e fica olhando pra tela sem saber se o bot ouviu. UX terrível.</p>
<h2>Padrão 7: RubyLLM + Tool Calling No Discord<span class="hx:absolute hx:-mt-20" id="padrão-7-rubyllm--tool-calling-no-discord"></span>
    <a href="#padr%c3%a3o-7-rubyllm--tool-calling-no-discord" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A parte mais poderosa: dar ferramentas pro LLM. O <code>/ask</code> tem três tools disponíveis: <code>GenerateImageTool</code> (gera imagens via Gemini), <code>WebSearchTool</code> (busca na web via Brave Search), e <code>WebFetchTool</code> (busca e resume páginas web). O LLM decide sozinho qual usar baseado na pergunta. Quando o usuário pede &ldquo;gera uma imagem de um gato programando&rdquo;, o LLM escolhe o tool certo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">GenerateImageTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Generate an image using Gemini. Use when the user asks to create or draw.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:prompt</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Image description&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:aspect_ratio</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;1:1 or 16:9&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:,</span> <span class="ss">aspect_ratio</span><span class="p">:</span> <span class="s2">&#34;1:1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">image_data</span> <span class="o">=</span> <span class="no">ImageGenerator</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:</span> <span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;Failed to generate image&#34;</span> <span class="k">unless</span> <span class="n">image_data</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">save_temp_file</span><span class="p">(</span><span class="n">image_data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Thread</span><span class="o">.</span><span class="n">current</span><span class="o">[</span><span class="ss">:generated_images</span><span class="o">]</span> <span class="o">&lt;&lt;</span> <span class="n">path</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Image generated successfully. Tell the user it&#39;s ready.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>Thread.current[:generated_images]</code> é o truque pra passar dados de volta do tool pro job. O LLM chama o tool durante <code>chat.ask</code>, o tool salva o arquivo e registra o path. Depois que <code>chat.ask</code> retorna, o job pega os paths e manda pro Discord como file uploads.</p>
<p>Por que thread-local e não retorno direto? Porque o LLM pode chamar múltiplos tools numa única resposta, e o retorno do tool volta pro LLM (não pro seu código) — é ele que decide o que responder ao usuário. O thread-local é o side channel pra seu código saber o que aconteceu.</p>
<h2>Padrão 8: Notificações de Status Em Produção<span class="hx:absolute hx:-mt-20" id="padrão-8-notificações-de-status-em-produção"></span>
    <a href="#padr%c3%a3o-8-notifica%c3%a7%c3%b5es-de-status-em-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Todo job de geração de conteúdo notifica um canal dedicado:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordStatus</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_start</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;Starting </span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2">...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_done</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> complete.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_error</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> FAILED: </span><span class="si">#{</span><span class="n">error</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O canal de status é separado do canal de operação. Quando os 8 jobs de geração de conteúdo rodam no domingo às 17h, o canal de status vira um log em tempo real:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Starting Anime Ranking...
Starting Hacker News...
Starting YouTube Digest...
Anime Ranking complete.
YouTube Digest complete.
Hacker News complete.
Starting Market Recap...  (wave 2, after world events)
...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>De manhã, se algo deu errado, você abre o Discord no celular e sabe exatamente o quê — sem SSH no servidor, sem grep em logs.</p>
<p>E quando o bot reinicia após um deploy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">status_channel</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_STATUS_CHANNEL_ID&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Bot reconnected and ready.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-48-20.jpg" alt="log"  loading="lazy" /></p>
<p>Simples, mas você sabe que o deploy funcionou sem checar nada.</p>
<h2>Padrão 9: Cross-App Communication<span class="hx:absolute hx:-mt-20" id="padrão-9-cross-app-communication"></span>
    <a href="#padr%c3%a3o-9-cross-app-communication" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O sistema tem dois apps Rails que precisam se falar. A newsletter precisa notificar o Discord quando termina de montar uma edição. Mas a newsletter não tem conexão Discord — quem tem é o marvin-bot.</p>
<p>Solução: HTTP relay.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Na newsletter — manda pro marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DiscordRelayClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">send_embed</span><span class="p">(</span><span class="ss">title</span><span class="p">:,</span> <span class="ss">description</span><span class="p">:,</span> <span class="ss">color</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/discord_notify&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;embed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">payload</span><span class="p">:</span> <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span> <span class="ss">description</span><span class="p">:</span> <span class="n">description</span><span class="p">,</span> <span class="ss">color</span><span class="p">:</span> <span class="n">color</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># No marvin-bot — recebe e repassa pro Discord</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Api</span><span class="o">::</span><span class="no">DiscordNotifyController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">create</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">params</span><span class="o">[</span><span class="ss">:type</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;embed&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;message&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">][</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A newsletter fala HTTP. O marvin-bot traduz pra Discord. Um <code>ADMIN_TOKEN</code> compartilhado autentica. Todos os métodos resgatam erros e retornam nil — se o Discord estiver fora, a newsletter não trava. Fire and forget.</p>
<p>Mas tem um segundo fluxo cross-app mais interessante: o trigger de podcast. Quando a newsletter é aprovada, ela precisa avisar o marvin-bot pra gerar o podcast:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Na newsletter — dispara geração de podcast no marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MarvinBotClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">trigger_podcast</span><span class="p">(</span><span class="ss">date</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/trigger/podcast&#34;</span><span class="p">,</span> <span class="ss">date</span><span class="p">:</span> <span class="n">date</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso inicia um pipeline inteiro no marvin-bot: script LLM de duas passadas → TTS por turno na GPU → assembly com loudnorm → upload S3. E o <code>PublishAndSendJob</code> da newsletter fica esperando (via <code>retry_on PodcastNotReady</code>) até o podcast ficar pronto. Dois apps, duas linguagens de backend (Ruby e Python), conectados por HTTP fire-and-forget com polling de resultado. Não é elegante — mas funciona toda semana sem intervenção.</p>
<h2>Padrão 10: Cost Tracking Integrado<span class="hx:absolute hx:-mt-20" id="padrão-10-cost-tracking-integrado"></span>
    <a href="#padr%c3%a3o-10-cost-tracking-integrado" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Cada chamada de IA é rastreada com tokens e custo estimado:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">create_chat</span><span class="p">(</span><span class="nb">caller</span><span class="p">:,</span> <span class="ss">model</span><span class="p">:,</span> <span class="ss">provider</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="ss">provider</span><span class="p">:</span> <span class="n">provider</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span><span class="o">.</span><span class="n">on_end_message</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="no">ApiUsage</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">caller_name</span><span class="p">:</span> <span class="nb">caller</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">input_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">input_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">output_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">output_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">cost_usd</span><span class="p">:</span> <span class="n">estimate_cost</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>E no Discord: <code>/cost</code> mostra o gasto por mês, ano, e por provider. Sem dashboard separado, sem Grafana, sem nada. Um comando e a informação que importa.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-49-05.jpg" alt="cost"  loading="lazy" /></p>
<h2>Padrão 11: Nunca Falhe Silenciosamente<span class="hx:absolute hx:-mt-20" id="padrão-11-nunca-falhe-silenciosamente"></span>
    <a href="#padr%c3%a3o-11-nunca-falhe-silenciosamente" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Esse é o padrão que eu devia ter implementado desde o começo, mas só aprendi da pior maneira — em produção.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-46.jpg" alt="daily"  loading="lazy" /></p>
<p>O projeto tem ~10 daily jobs que buscam dados de fontes externas (Hacker News, GitHub Trending, Morningstar, etc.) e postam embeds no Discord. Cada client segue um padrão &ldquo;defensivo&rdquo;:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># O padrão que PARECE correto:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDevClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="no">API_URL</span><span class="p">,</span> <span class="ss">headers</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Authorization&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;Bearer </span><span class="si">#{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</span>
</span></span><span class="line"><span class="cl">    <span class="n">parse</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;DailyDevClient failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># E no job:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDailyDevJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># ← O PROBLEMA</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... posta embed no Discord</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Esse código nunca crasheia. Nunca levanta exceção. Nunca enche o log de erros. É o código mais &ldquo;robusto&rdquo; possível.</p>
<p>É também completamente <strong>inútil pra diagnóstico</strong>. Quando deployei e rodei os daily jobs manualmente, só um dos dez funcionou. Mas não havia <strong>nenhum</strong> erro nos logs. Nenhum. O <code>return [] unless response.success?</code> engolia o HTTP 401 sem logar. O <code>return if posts.empty?</code> descartava o resultado sem avisar ninguém. Tudo verde, tudo silencioso, zero dados.</p>
<p>A correção tem duas partes:</p>
<p><strong>Parte 1: Clients logam o código HTTP:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;DailyDevClient: API returned HTTP </span><span class="si">#{</span><span class="n">response</span><span class="o">.</span><span class="n">code</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Parece óbvio, mas é fácil esquecer quando você escreve <code>return []</code> como reflexo defensivo. O <code>response.code</code> é a informação que diferencia &ldquo;token expirado&rdquo; (401) de &ldquo;rate limited&rdquo; (429) de &ldquo;API fora do ar&rdquo; (503). Sem ele, tudo parece igual: array vazio.</p>
<p><strong>Parte 2: Jobs avisam no Discord quando não têm dados:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DailyDiscordEmbed</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">report_empty</span><span class="p">(</span><span class="n">title</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2"> returned no results&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">channel_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">channel_ids</span><span class="o">.</span><span class="n">first</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">present?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;⚠️ </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> returned no results. Check logs.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFFA500</span><span class="p">,</span>  <span class="c1"># laranja = warning, não vermelho = error</span>
</span></span><span class="line"><span class="cl">      <span class="ss">timestamp</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="o">.</span><span class="n">iso8601</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">embed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> empty report failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Agora quando um daily job não traz dados:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">  <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">    <span class="n">report_empty</span><span class="p">(</span><span class="s2">&#34;daily.dev Popular&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-02.jpg" alt="no results"  loading="lazy" /></p>
<p>Um embed laranja aparece no Discord. De manhã, abro o canal e vejo: &ldquo;⚠️ daily.dev Popular returned no results&rdquo;. Vou nos logs e descubro: &ldquo;DailyDevClient: API returned HTTP 401&rdquo;. Token expirado. Corrigido em 2 minutos.</p>
<p>Sem esse padrão, eu teria descoberto semanas depois — quando alguém perguntasse &ldquo;por que nunca tem posts do daily.dev?&rdquo;.</p>
<p>A lição: <strong><code>rescue =&gt; []</code> não é error handling. É error hiding.</strong> Cada ponto de falha precisa de dois sinais: um log com o detalhe técnico (HTTP code, mensagem de exceção) e uma notificação visual no canal onde você já está olhando (Discord). Se o bot É sua interface operacional, falhas do bot precisam aparecer NA interface.</p>
<h2>Padrão 12: Conversas Com Memória (<code>/ask</code> Multi-Turn)<span class="hx:absolute hx:-mt-20" id="padrão-12-conversas-com-memória-ask-multi-turn"></span>
    <a href="#padr%c3%a3o-12-conversas-com-mem%c3%b3ria-ask-multi-turn" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A primeira versão do <code>/ask</code> criava um <code>RubyLLM::Chat</code> novo a cada invocação. Funcionava, mas era como conversar com alguém que tem amnésia:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask o que é Ruby?
→ Ruby é uma linguagem de programação criada por Matz em 1995...

/ask compare com Python
→ Comparar o quê com Python? Poderia ser mais específico?</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O ruby_llm já mantém histórico internamente — cada <code>chat.ask()</code> appende à lista de mensagens e manda o contexto completo pro modelo. O que faltava era persistir o objeto <code>Chat</code> entre invocações.</p>
<p>A solução é um store in-memory com três safeguards:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">AskSessionStore</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_SESSIONS</span> <span class="o">=</span> <span class="mi">100</span>       <span class="c1"># total across all users</span>
</span></span><span class="line"><span class="cl">  <span class="no">SESSION_TTL</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</span>    <span class="c1"># inactivity timeout</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_MESSAGES</span> <span class="o">=</span> <span class="mi">50</span>        <span class="c1"># ~25 exchanges, then auto-reset</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="no">Session</span> <span class="o">=</span> <span class="no">Struct</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">:chat</span><span class="p">,</span> <span class="ss">:last_used</span><span class="p">,</span> <span class="ss">keyword_init</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">get_or_create</span><span class="p">(</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:,</span> <span class="ss">tools</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">synchronize</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">cleanup_expired</span>
</span></span><span class="line"><span class="cl">      <span class="n">key</span> <span class="o">=</span> <span class="o">[</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">session</span> <span class="o">=</span> <span class="vi">@sessions</span><span class="o">[</span><span class="n">key</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">last_used</span> <span class="o">=</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">session</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="no">MAX_MESSAGES</span>
</span></span><span class="line"><span class="cl">          <span class="vi">@sessions</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="k">return</span> <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">end</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">chat</span>
</span></span><span class="line"><span class="cl">      <span class="k">else</span>
</span></span><span class="line"><span class="cl">        <span class="n">evict_oldest</span> <span class="k">if</span> <span class="vi">@sessions</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;=</span> <span class="no">MAX_SESSIONS</span>
</span></span><span class="line"><span class="cl">        <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-54-32.jpg" alt="ask memory"  loading="lazy" /></p>
<p>A chave vai ser <code>(author_id, personality)</code> — cada usuário tem sessões separadas pro Marvin e pro Akita. Volátil de propósito: reiniciou o servidor, perdeu o histórico. Sem banco, sem serialização, sem complexidade. É memória de trabalho, não persistência.</p>
<p>O <code>/ask clear</code> reseta a sessão. Sem isso, não tem como &ldquo;começar de novo&rdquo; quando a conversa fica confusa.</p>
<p>Agora:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask o que é Ruby?
→ Ruby é uma linguagem de programação criada por Matz em 1995...

/ask compare com Python
→ Ruby e Python têm filosofias parecidas — ambas priorizam felicidade do
  programador — mas Ruby abraça blocos e metaprogramação enquanto Python
  prefere explicitness...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O LLM sabe que &ldquo;compare&rdquo; se refere a Ruby porque o histórico da sessão inclui a pergunta anterior. É a diferença entre um chatbot e um assistente.</p>
<h2>Padrão 13: Registrando Application Commands (Autocomplete Nativo)<span class="hx:absolute hx:-mt-20" id="padrão-13-registrando-application-commands-autocomplete-nativo"></span>
    <a href="#padr%c3%a3o-13-registrando-application-commands-autocomplete-nativo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A seção sobre Application Commands acima mostrou o conceito simplificado. Na prática, registrar 18 comandos com subcommands, parâmetros tipados e choices exige uma estrutura dedicada. Veja como o projeto resolve isso.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-18.jpg" alt="auto complete"  loading="lazy" /></p>
<h3>O Problema Com Texto Puro<span class="hx:absolute hx:-mt-20" id="o-problema-com-texto-puro"></span>
    <a href="#o-problema-com-texto-puro" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Quando o bot só parseia texto, o usuário precisa decorar a sintaxe:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/update score #1 #2 #3 high       ← funciona
/update score #1 #2 #3 alta       ← falha silenciosamente
/update scoree #1 high             ← ignorado</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Sem autocomplete, sem validação de parâmetros, sem documentação inline. O usuário digita <code>/</code> e não sabe o que está disponível.</p>
<h3>O Módulo <code>DiscordCommands</code><span class="hx:absolute hx:-mt-20" id="o-módulo-discordcommands"></span>
    <a href="#o-m%c3%b3dulo-discordcommands" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Toda a definição de comandos vive num módulo separado com dois métodos públicos: <code>register!</code> (cadastra os comandos na API do Discord) e <code>setup_handlers!</code> (conecta os handlers ao bot):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordCommands</span>
</span></span><span class="line"><span class="cl">  <span class="no">SCORE_CHOICES</span>  <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">  <span class="no">SECTION_CHOICES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Tech&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;tech&#34;</span><span class="p">,</span> <span class="s2">&#34;Global&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;global&#34;</span><span class="p">,</span> <span class="s2">&#34;Financial&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;financial&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Q&amp;A&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;qa&#34;</span><span class="p">,</span> <span class="s2">&#34;Entertainment&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;entertainment&#34;</span><span class="p">,</span> <span class="s2">&#34;Open Source&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;opensource&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Product&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;product&#34;</span><span class="p">,</span> <span class="s2">&#34;Book&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;book&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">  <span class="no">PERSONALITY_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;Marvin (default)&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span><span class="p">,</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">server_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">server_id</span>
</span></span><span class="line"><span class="cl">    <span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">register_week</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_ask</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... mais 14 comandos</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_week_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_add_handler</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_update_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_ask_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... mais 14 handlers</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Guild vs Global<span class="hx:absolute hx:-mt-20" id="guild-vs-global"></span>
    <a href="#guild-vs-global" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O <code>server_id</code> (ou <code>sid</code>) controla o escopo do registro. Com <code>server_id</code>, o comando aparece instantaneamente no servidor especificado. Sem, o comando é global — disponível em todos os servidores, mas pode levar até uma hora pra propagar.</p>
<p>Para um bot privado de operações internas, sempre use guild-scoped. O env var <code>DISCORD_SERVER_ID</code> é configurado no deploy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;Story list and weekly report&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Se <code>sid</code> for nil, o discordrb registra globalmente. Em dev, é conveniente; em produção, guild-scoped é mais rápido e permite comandos diferentes por servidor.</p>
<h3>Anatomia De Um Comando<span class="hx:absolute hx:-mt-20" id="anatomia-de-um-comando"></span>
    <a href="#anatomia-de-um-comando" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O método mais simples — sem parâmetros:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_count</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:count</span><span class="p">,</span> <span class="s2">&#34;Show story count for this week&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Com parâmetros tipados:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:add</span><span class="p">,</span> <span class="s2">&#34;Submit a URL for the newsletter&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:url</span><span class="p">,</span> <span class="s2">&#34;The URL to submit&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:tags</span><span class="p">,</span> <span class="s2">&#34;Tags (e.g. #tech #high)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:comment</span><span class="p">,</span> <span class="s2">&#34;Optional comment about this link&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O Discord mostra isso como um formulário inline: campo <code>url</code> obrigatório, <code>tags</code> e <code>comment</code> opcionais. O usuário vê a descrição de cada parâmetro enquanto preenche.</p>
<h3>Subcommands: Agrupando Ações<span class="hx:absolute hx:-mt-20" id="subcommands-agrupando-ações"></span>
    <a href="#subcommands-agrupando-a%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Comandos complexos usam subcommands. <code>/update</code> sozinho não faz nada — o usuário precisa escolher o que atualizar:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">,</span> <span class="s2">&#34;Update story properties&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">,</span> <span class="s2">&#34;Update story priority score&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:tag</span><span class="p">,</span> <span class="s2">&#34;Update story section tag&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:section</span><span class="p">,</span> <span class="s2">&#34;Section tag&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SECTION_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... mais subcommands: title, description, comment, source</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Quando o usuário digita <code>/update</code>, o Discord mostra uma lista de subcommands: score, tag, title, description, comment, image, source. Ao escolher <code>score</code>, aparece o campo <code>stories</code> (texto livre) e <code>level</code> (dropdown com High/Medium/Low). Impossível errar a sintaxe.</p>
<h3>Choices: Dropdowns Nativos<span class="hx:absolute hx:-mt-20" id="choices-dropdowns-nativos"></span>
    <a href="#choices-dropdowns-nativos" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O hash <code>SCORE_CHOICES</code> vira um dropdown no Discord. A chave é o label visível (&ldquo;High&rdquo;), o valor é o que chega no handler (&ldquo;high&rdquo;):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">SCORE_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># No registro:</span>
</span></span><span class="line"><span class="cl"><span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O usuário não precisa lembrar se é &ldquo;high&rdquo;, &ldquo;HIGH&rdquo;, &ldquo;alta&rdquo;, ou &ldquo;3&rdquo;. Seleciona no dropdown e pronto. Isso elimina uma categoria inteira de erros de input.</p>
<h3>Attachments: Upload de Arquivos<span class="hx:absolute hx:-mt-20" id="attachments-upload-de-arquivos"></span>
    <a href="#attachments-upload-de-arquivos" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O Discord suporta parâmetros do tipo attachment — o usuário pode arrastar um arquivo direto no comando:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No handler, os attachments resolvidos vêm como URLs do CDN do Discord:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">resolve_attachments</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">&amp;.</span><span class="n">attachments</span><span class="o">&amp;.</span><span class="n">any?</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">.</span><span class="n">attachments</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Lifecycle: Onde Registrar, Onde Conectar Handlers<span class="hx:absolute hx:-mt-20" id="lifecycle-onde-registrar-onde-conectar-handlers"></span>
    <a href="#lifecycle-onde-registrar-onde-conectar-handlers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Esse é o ponto que mais confunde. Os <strong>handlers</strong> precisam ser registrados <strong>antes</strong> do <code>bot.run</code>. O <strong>registro na API do Discord</strong> pode acontecer no <code>bot.ready</code> (que dispara após a conexão WebSocket):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MarvinBotRunner</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">run!</span>
</span></span><span class="line"><span class="cl">    <span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span> <span class="ss">intents</span><span class="p">:</span> <span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 1. Handlers ANTES do bot.run (senão o bot não sabe como responder)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># 2. Registro na API DEPOIS da conexão (idempotente, safe on reconnect)</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">run</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Por que no <code>bot.ready</code>? Porque o bot pode reconectar (Discord fecha WebSockets periodicamente). O <code>register!</code> é idempotente — registrar o mesmo comando duas vezes não duplica, só atualiza. Colocar no <code>ready</code> garante que os comandos existem mesmo após um reconnect.</p>
<h3>Bridge: Application Command → CommandDispatcher<span class="hx:absolute hx:-mt-20" id="bridge-application-command--commanddispatcher"></span>
    <a href="#bridge-application-command--commanddispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O ponto mais elegante da arquitetura: o handler de Application Command não tem lógica de negócio. Ele traduz os parâmetros do Discord pro formato que o <code>CommandDispatcher</code> já espera:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">type</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="o">]</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="o">[]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;\u{2705}&#34;</span><span class="p">,</span> <span class="ss">ephemeral</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>  <span class="c1"># checkmark só visível pro autor</span>
</span></span><span class="line"><span class="cl">  <span class="n">command</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="ss">type</span><span class="p">:</span> <span class="n">type</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">args</span><span class="p">:</span> <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="n">match</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">attachments</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">author_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="s2">&#34;Discord interaction error (</span><span class="si">#{</span><span class="n">type</span><span class="si">}</span><span class="s2">): </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada handler de subcommand é um one-liner que monta os argumentos e chama <code>dispatch_interaction</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">)</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">stories</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;stories&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">level</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;level&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="ss">:update_score</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="p">,</span> <span class="n">stories</span><span class="p">,</span> <span class="kp">nil</span><span class="p">,</span> <span class="n">level</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>CommandDispatcher</code> não sabe se o comando veio de texto puro ou de Application Command. A mesma lógica, a mesma autorização, os mesmos jobs. Nenhum código duplicado.</p>
<h3>O Resultado<span class="hx:absolute hx:-mt-20" id="o-resultado"></span>
    <a href="#o-resultado" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O projeto registra 18 Application Commands com combinações de subcommands, parâmetros tipados, dropdowns e upload de arquivos. O usuário digita <code>/</code> e vê tudo disponível com descrição. Escolhe um comando e o Discord guia o preenchimento campo a campo. Erros de sintaxe são impossíveis — o Discord valida antes de enviar.</p>
<p>E os dois sistemas coexistem: quem prefere autocomplete usa Application Commands, quem prefere digitar rápido usa texto puro. O <code>CommandDispatcher</code> recebe os dois da mesma forma. Adicionar um novo comando é adicionar um <code>register_*</code>, um <code>setup_*_handler</code>, e um método no dispatcher — sem mexer no runner.</p>
<h2>As Armadilhas do discordrb<span class="hx:absolute hx:-mt-20" id="as-armadilhas-do-discordrb"></span>
    <a href="#as-armadilhas-do-discordrb" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Nem tudo são flores. Algumas dores reais:</p>
<p><strong>Gateway disconnects</strong>: O WebSocket com o Discord cai de vez em quando. O discordrb reconecta automaticamente, mas seus handlers <code>bot.ready</code> e <code>bot.message</code> precisam ser resilientes a reconexões.</p>
<p><strong>Message Content Intent</strong>: Desde 2022, bots em mais de 75 servidores precisam de aprovação pra ler conteúdo de mensagens. Pra bots privados (que é o caso de operação interna), isso não é problema. Mas se você planeja distribuir o bot, prepare o paperwork.</p>
<p><strong>API Rate Limits</strong>: O Discord rate-limita por rota. Mandar 10 embeds de uma vez pro mesmo canal? Vai bater no limite. O discordrb faz rate limit handling internamente, mas chamadas rápidas demais causam delays visíveis.</p>
<p><strong>Embed Limits</strong>: Título máximo 256 chars. Description 4096. Field value 1024. Excedeu? Erro silencioso. Sempre trunca antes de enviar.</p>
<p><strong>File Uploads</strong>: Limit de 25MB no plano gratuito, 50MB com Nitro. Pra imagens de IA geradas isso nunca é problema, mas se for mandar PDFs ou áudio, cuidado.</p>
<h2>Quando NÃO Usar Discord Como Admin<span class="hx:absolute hx:-mt-20" id="quando-não-usar-discord-como-admin"></span>
    <a href="#quando-n%c3%a3o-usar-discord-como-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><strong>Formulários complexos</strong>: editar 15 campos de um registro? Tela web</li>
<li><strong>Bulk operations</strong>: importar CSV com 1000 registros? Script</li>
<li><strong>Visualização de dados</strong>: gráficos, dashboards analíticos? Grafana/Metabase</li>
<li><strong>Aprovação multi-step</strong>: workflows com 5 etapas de aprovação? Precisa de estado persistido</li>
</ul>
<p>Mas pra o dia a dia operacional — curar conteúdo, aprovar newsletters, monitorar jobs, checar custos, conversar com IA — Discord é a interface mais produtiva que já usei. E o melhor: os assinantes nunca vão saber que a newsletter semanal deles foi curada por um cara digitando comandos no Discord do celular enquanto toma café.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-57-11.jpg" alt="hello"  loading="lazy" /></p>
<p>Usar Discord como admin panel não é preguiça — é pragmatismo. A melhor interface é a que você já usa o dia inteiro. Auth, push notifications, mobile, rich formatting, file sharing — tudo de graça. Você só constrói a lógica de negócio.</p>
<p>A gem <a href="https://rubyllm.com/"target="_blank" rel="noopener"><code>ruby_llm</code></a> com tool calling leva o bot de &ldquo;executor de comandos&rdquo; pra &ldquo;assistente que pensa&rdquo;. Com memória de sessão, pensa <strong>e lembra</strong>. Quando o LLM pode gerar imagens, buscar na web, manter contexto entre perguntas, e responder sobre a arquitetura do próprio sistema — aí deixou de ser CRUD e virou co-piloto.</p>
<p>E o padrão Parser → Dispatcher → Job mantém tudo testável. O parser não sabe nada de Discord. O dispatcher não sabe nada de IA. Os jobs não sabem nada de interface. Troca qualquer uma dessas camadas e as outras nem percebem.</p>
<p>No fim, a melhor admin UI é a que não precisa de deploy separado. E o pior bug é o que falha em silêncio — se o bot é sua interface operacional, as falhas do bot precisam aparecer <strong>na</strong> interface, não escondidas num log que ninguém lê.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>automacao</category><category>backend</category></item><item><title>Jobs Assíncronos que sobrevivem ao Caos | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/19/jobs-asssincronos-que-sobrevivem-ao-caos-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/19/jobs-asssincronos-que-sobrevivem-ao-caos-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Thu, 19 Feb 2026 01:10:08 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 5.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Vou ser direto: a maioria dos desenvolvedores trata background jobs como se fossem scripts que rodam uma vez e pronto. &amp;ldquo;Ah, coloca num Sidekiq e tá bom.&amp;rdquo; Não, &lt;strong&gt;não tá bom&lt;/strong&gt;. Jobs que processam coisas importantes — enviar emails, publicar conteúdo, chamar APIs externas — precisam ser tratados como cidadãos de primeira classe na sua arquitetura.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 5.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Vou ser direto: a maioria dos desenvolvedores trata background jobs como se fossem scripts que rodam uma vez e pronto. &ldquo;Ah, coloca num Sidekiq e tá bom.&rdquo; Não, <strong>não tá bom</strong>. Jobs que processam coisas importantes — enviar emails, publicar conteúdo, chamar APIs externas — precisam ser tratados como cidadãos de primeira classe na sua arquitetura.</p>
<p>Neste post vou mostrar como o Rails 8 com ActiveJob e SolidQueue mudou minha perspectiva sobre processamento assíncrono. Não é teoria — são padrões que emergiram de um sistema real rodando em produção toda semana.</p>
<h2>O Problema: Jobs São Frágeis por Natureza<span class="hx:absolute hx:-mt-20" id="o-problema-jobs-são-frágeis-por-natureza"></span>
    <a href="#o-problema-jobs-s%c3%a3o-fr%c3%a1geis-por-natureza" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Pense no cenário: você tem um job que monta uma newsletter, publica num blog via API do GitHub, espera um podcast ficar pronto, e depois dispara emails para centenas de assinantes. Qualquer passo pode falhar. A API pode dar timeout. O podcast pode demorar mais que o esperado. O servidor pode reiniciar no meio do envio.</p>
<p>Se você tratar isso como um script linear, vai sofrer. A pergunta certa não é &ldquo;como faço esse job rodar?&rdquo; — é <strong>&ldquo;como faço esse job se recuperar quando algo der errado?&rdquo;</strong></p>
<h2>Padrão 1: retry_on Com Exceções Específicas<span class="hx:absolute hx:-mt-20" id="padrão-1-retry_on-com-exceções-específicas"></span>
    <a href="#padr%c3%a3o-1-retry_on-com-exce%c3%a7%c3%b5es-espec%c3%adficas" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O <code>retry_on</code> do ActiveJob é absurdamente poderoso, mas a maioria usa errado. Olha o anti-padrão clássico:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MeuJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">StandardError</span><span class="p">,</span> <span class="ss">wait</span><span class="p">:</span> <span class="mi">5</span><span class="o">.</span><span class="n">seconds</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso re-tenta <strong>qualquer</strong> erro 3 vezes e desiste. Serve pra quase nada no mundo real. O padrão que funciona é criar exceções específicas para estados transitórios:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PodcastNotReady</span> <span class="o">&lt;</span> <span class="no">StandardError</span><span class="p">;</span> <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PublishAndSendJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">PodcastNotReady</span><span class="p">,</span> <span class="ss">wait</span><span class="p">:</span> <span class="mi">15</span><span class="o">.</span><span class="n">minutes</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">16</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">metadata</span> <span class="o">=</span> <span class="n">check_podcast_metadata</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="no">PodcastNotReady</span><span class="p">,</span> <span class="s2">&#34;Aguardando podcast&#34;</span> <span class="k">unless</span> <span class="n">metadata</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># continua o trabalho...</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Percebe o que está acontecendo? O job espera até <strong>4 horas</strong> (15min × 16) pelo podcast ficar pronto, fazendo polling a cada 15 minutos. Não é um loop infinito — tem um limite claro. E quando estoura esse limite, você trata o timeout explicitamente:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">PodcastNotReady</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">executions</span> <span class="o">&gt;=</span> <span class="mi">16</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Publica sem podcast e avisa a equipe</span>
</span></span><span class="line"><span class="cl">    <span class="n">publish_without_podcast</span><span class="p">(</span><span class="n">newsletter</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">notify_team</span><span class="p">(</span><span class="s2">&#34;Podcast não ficou pronto a tempo&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="c1"># re-raise pra SolidQueue agendar o próximo retry</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso é <strong>degradação graciosa</strong>. O sistema não para porque um componente falhou — ele se adapta e continua.</p>
<h2>Padrão 2: Locks Distribuídos<span class="hx:absolute hx:-mt-20" id="padrão-2-locks-distribuídos"></span>
    <a href="#padr%c3%a3o-2-locks-distribu%c3%addos" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quando você tem múltiplos caminhos que podem disparar o mesmo job (um cron de segurança, um trigger manual, uma API), precisa garantir que só uma instância roda por vez.</p>
<p>O conceito é simples: um lock baseado em arquivo (ou banco) que expira automaticamente:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DeployLock</span>
</span></span><span class="line"><span class="cl">  <span class="no">LOCK_DIR</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">root</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;tmp/locks&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">DEFAULT_TTL</span> <span class="o">=</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">with_lock</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="ss">ttl</span><span class="p">:</span> <span class="no">DEFAULT_TTL</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">acquire!</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="ss">ttl</span><span class="p">:</span> <span class="n">ttl</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">yield</span>
</span></span><span class="line"><span class="cl">  <span class="k">ensure</span>
</span></span><span class="line"><span class="cl">    <span class="n">release</span><span class="p">(</span><span class="nb">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Com SQLite, isso é trivial — sem Redis, sem coordenação externa. O lock tem TTL pra não travar se o processo morrer. E o <code>ensure</code> garante que libera mesmo se o bloco levantar exceção.</p>
<h2>Padrão 3: Envio Atômico de Emails<span class="hx:absolute hx:-mt-20" id="padrão-3-envio-atômico-de-emails"></span>
    <a href="#padr%c3%a3o-3-envio-at%c3%b4mico-de-emails" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui é onde a maioria dos sistemas de newsletter falha espetacularmente. O cenário: você está enviando 500 emails e o servidor reinicia no email 247. O que acontece quando o job re-executa?</p>
<p>Se você só iterou uma lista, vai reenviar os primeiros 247. Seus assinantes vão adorar receber a newsletter duas vezes.</p>
<p>A solução é <strong>claiming atômico</strong> por destinatário:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Antes de enviar, cria um registro por assinante</span>
</span></span><span class="line"><span class="cl"><span class="n">subscribers</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="ss">newsletter</span><span class="p">:</span> <span class="n">newsletter</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">subscriber</span><span class="p">:</span> <span class="nb">sub</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;pending&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Na hora de enviar, faz claim atômico</span>
</span></span><span class="line"><span class="cl"><span class="n">delivery</span> <span class="o">=</span> <span class="no">EmailDelivery</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;pending&#34;</span><span class="p">,</span> <span class="s2">&#34;failed&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">lock</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">first</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">delivery</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">send_email</span><span class="p">(</span><span class="n">delivery</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">delivery</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sent&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Se o servidor morrer entre <code>sending</code> e <code>sent</code>, o registro fica como &ldquo;sending&rdquo; — e um job de recuperação (<code>RecoverStaleDeliveriesJob</code>) periodicamente move esses registros para &ldquo;unknown&rdquo; depois de um timeout, pra que nunca sejam automaticamente reenviados. Emails ambíguos <strong>nunca</strong> são reenviados automaticamente. Esse é o tipo de detalhe que separa um sistema hobby de um sistema de produção.</p>
<h2>Padrão 4: Jobs Orquestradores<span class="hx:absolute hx:-mt-20" id="padrão-4-jobs-orquestradores"></span>
    <a href="#padr%c3%a3o-4-jobs-orquestradores" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Um erro comum é colocar lógica demais num único job. O padrão que funciona é ter <strong>jobs orquestradores</strong> que delegam para jobs especializados:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>PublishAndSendJob (orquestrador)
  ├── Espera podcast (retry_on PodcastNotReady)
  ├── PublishToBlogJob.publish(newsletter)
  └── SendNewsletterJob.perform_now(newsletter.id)
        └── SendSingleEmailJob (por assinante)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O orquestrador coordena a sequência. Cada job especializado sabe fazer exatamente uma coisa e pode ser re-executado independentemente. Se o envio de emails falhar, você pode re-disparar só o <code>SendNewsletterJob</code> sem republicar o blog.</p>
<h2>Padrão 5: Safety Nets com Cron<span class="hx:absolute hx:-mt-20" id="padrão-5-safety-nets-com-cron"></span>
    <a href="#padr%c3%a3o-5-safety-nets-com-cron" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Não confie em uma única cadeia de execução. O cron do SolidQueue (<code>config/recurring.yml</code>) serve como rede de segurança:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">send_newsletter</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">class</span><span class="p">:</span><span class="w"> </span><span class="l">SendNewsletterJob</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0 12 * * 1&#34;</span><span class="w">  </span><span class="c"># Segunda 9h BRT (UTC-3)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Se o <code>PublishAndSendJob</code> das 7h falhou catastroficamente, o cron das 9h dispara o <code>SendNewsletterJob</code> como fallback. O job verifica se a newsletter já foi enviada e faz no-op se sim. <strong>Idempotência</strong> é a palavra-chave aqui — o job pode rodar quantas vezes quiser e o resultado é sempre o mesmo.</p>
<p>Mas cuidado com o inverso: um job que se reagenda infinitamente quando não encontra trabalho é uma bomba-relógio. Se o job não encontra newsletter pronta, ele simplesmente retorna. O cron cuida de tentar de novo na próxima semana.</p>
<h2>Padrão 6: Notificações de Status<span class="hx:absolute hx:-mt-20" id="padrão-6-notificações-de-status"></span>
    <a href="#padr%c3%a3o-6-notifica%c3%a7%c3%b5es-de-status" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Todo job de longa duração deve comunicar seu progresso. Um concern simples resolve:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordStatus</span>
</span></span><span class="line"><span class="cl">  <span class="kp">extend</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Concern</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_start</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">channel</span><span class="p">:</span> <span class="n">status_channel</span><span class="p">,</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;▶️ </span><span class="si">#{</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_done</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">channel</span><span class="p">:</span> <span class="n">status_channel</span><span class="p">,</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;✅ </span><span class="si">#{</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_error</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">channel</span><span class="p">:</span> <span class="n">status_channel</span><span class="p">,</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;❌ </span><span class="si">#{</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada job inclui o concern e chama <code>notify_start</code> no início, <code>notify_done</code> no sucesso, <code>notify_error</code> no rescue. Quando algo dá errado às 3 da manhã, você acorda e vê exatamente onde parou — sem precisar cavar logs.</p>
<h2>SolidQueue: O Fim da Dependência de Redis<span class="hx:absolute hx:-mt-20" id="solidqueue-o-fim-da-dependência-de-redis"></span>
    <a href="#solidqueue-o-fim-da-depend%c3%aancia-de-redis" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Uma nota sobre SolidQueue, que veio como padrão no Rails 8: usar o mesmo banco SQLite para jobs e dados da aplicação simplifica <strong>drasticamente</strong> a operação. Não precisa de Redis rodando separado. Não precisa se preocupar com o Redis reiniciar e perder jobs que estavam na memória.</p>
<p>Os jobs ficam persistidos no banco. Se o servidor reiniciar, eles estão lá esperando. O retry state é preservado. É absurdamente mais simples que a alternativa, e pra 99% dos casos, a performance é mais que suficiente.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Rails 8 com ActiveJob e SolidQueue não inventou nada revolucionário. O que ele fez foi deixar ridiculamente fácil de implementar padrões que antes precisavam de infraestrutura pesada:</p>
<ul>
<li><strong>retry_on</strong> com exceções específicas e limites claros</li>
<li><strong>Locks distribuídos</strong> com TTL automático</li>
<li><strong>Claiming atômico</strong> pra operações que não podem duplicar</li>
<li><strong>Jobs orquestradores</strong> que delegam e se recuperam</li>
<li><strong>Crons de segurança</strong> como fallback</li>
<li><strong>Notificações</strong> pra visibilidade operacional</li>
</ul>
<p>Nenhum desses padrões é complicado sozinho. Mas juntos, é o que faz a diferença entre um sistema que quebra na primeira falha e um que aguenta o tranco em produção — e te deixa dormir tranquilo na segunda-feira.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>rails</category><category>sistemas-distribuidos</category></item><item><title>Frontend Sem Framework | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/19/frontend-sem-framework-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/19/frontend-sem-framework-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Thu, 19 Feb 2026 01:08:23 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 4.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Existe uma obsessão no mercado de que frontend = React. Ou Vue. Ou Svelte. Ou o framework da semana. E se eu te disser que é possível ter um frontend moderno, responsivo, com dark mode, email-compatible, multi-plataforma — sem uma única linha de JavaScript framework?&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 4.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Existe uma obsessão no mercado de que frontend = React. Ou Vue. Ou Svelte. Ou o framework da semana. E se eu te disser que é possível ter um frontend moderno, responsivo, com dark mode, email-compatible, multi-plataforma — sem uma única linha de JavaScript framework?</p>
<p>Não estou falando de sites estáticos dos anos 2000. Estou falando de Tailwind CSS v4 no Rails, Hugo com tema Hextra pro blog, e templates de email que funcionam do Gmail ao Outlook 2016. Tudo no mesmo projeto, com um desenvolvedor.</p>
<h2>Tailwind CSS v4: O Fim do CSS Artesanal<span class="hx:absolute hx:-mt-20" id="tailwind-css-v4-o-fim-do-css-artesanal"></span>
    <a href="#tailwind-css-v4-o-fim-do-css-artesanal" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Eu tenho uma confissão: CSS sempre foi minha kryptonita. Não porque é difícil — mas porque é <strong>imprevisível</strong>. Você muda um <code>margin-top</code> e quebra o layout de uma página que nem tocou. Cascata, especificidade, herança — é um campo minado.</p>
<p>Tailwind resolve isso da forma mais pragmática possível: classes utilitárias direto no HTML.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;flex items-center gap-4 p-6 bg-white dark:bg-gray-800
</span></span></span><span class="line"><span class="cl"><span class="s">            rounded-xl shadow-sm border border-gray-200 dark:border-gray-700&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">img</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;w-12 h-12 rounded-full&#34;</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/images/avatar.jpg&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">h3</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;font-semibold text-gray-900 dark:text-gray-100&#34;</span><span class="p">&gt;</span>Título<span class="p">&lt;/</span><span class="nt">h3</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">p</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;text-sm text-gray-500 dark:text-gray-400&#34;</span><span class="p">&gt;</span>Descrição<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>&ldquo;Mas isso é inline style com passos extras!&rdquo; — Não, não é. É um sistema de design com constraints. Você não escolhe &ldquo;margin: 17px&rdquo; — você escolhe <code>m-4</code> (16px) ou <code>m-5</code> (20px). Esse constraint force consistência visual sem precisar de um design system documentado em 40 páginas.</p>
<p>E o Tailwind v4, que veio com o Rails via <code>tailwindcss-rails</code>, tem uma mudança fundamental: <strong>CSS-first configuration</strong>. Acabou o <code>tailwind.config.js</code>. Tudo é CSS:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="s2">&#34;tailwindcss&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="k">theme</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">--color-brand</span><span class="o">:</span> <span class="p">#</span><span class="nn">8b5cf6</span><span class="o">;</span>
</span></span><span class="line"><span class="cl">  <span class="nt">--font-sans</span><span class="o">:</span> <span class="s2">&#34;Inter&#34;</span><span class="o">,</span> <span class="nt">system-ui</span><span class="o">,</span> <span class="nt">sans-serif</span><span class="o">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Menos um arquivo de config. Menos um passo de build. Mais simples.</p>
<h2>Dark Mode: Respeite Seu Usuário<span class="hx:absolute hx:-mt-20" id="dark-mode-respeite-seu-usuário"></span>
    <a href="#dark-mode-respeite-seu-usu%c3%a1rio" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-05-42.jpg" alt="change theme"  loading="lazy" /></p>
<p>Uma coisa que me irrita profundamente é site que ignora preferência de tema do sistema operacional. O usuário configurou dark mode no OS — respeita.</p>
<p>Com Tailwind, dark mode é trivial:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">body</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100&#34;</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>dark:</code> prefixo aplica o estilo quando o sistema operacional está em dark mode (via <code>prefers-color-scheme: dark</code>). Não precisa de JavaScript. Não precisa de toggle. Não precisa de cookie. Funciona.</p>
<p>Mas &ldquo;funciona&rdquo; é o mínimo. O desafio real é manter <strong>consistência</strong> entre dezenas de componentes. Toda cor que você usar precisa de uma variante <code>dark:</code>. Todo background, todo texto, toda borda.</p>
<p>O truque: crie convenções e siga. Background principal? <code>bg-white dark:bg-gray-900</code>. Texto primário? <code>text-gray-900 dark:text-gray-100</code>. Texto secundário? <code>text-gray-500 dark:text-gray-400</code>. Bordas? <code>border-gray-200 dark:border-gray-700</code>. Com 4-5 pares definidos, 90% dos componentes ficam cobertos.</p>
<h2>Hugo + Hextra: Blog Sem Banco de Dados<span class="hx:absolute hx:-mt-20" id="hugo--hextra-blog-sem-banco-de-dados"></span>
    <a href="#hugo--hextra-blog-sem-banco-de-dados" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O blog do projeto roda Hugo com o tema Hextra. Hugo é um gerador de sites estáticos escrito em Go — absurdamente rápido. Rebuild completo em &lt;500ms mesmo com dezenas de páginas.</p>
<p>O que torna o Hextra especial é que ele é um tema de <strong>documentação</strong> adaptado pra blog. Sidebar de navegação, search integrado, table of contents — tudo pronto. E customizável via layouts parciais:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>layouts/
├── _partials/
│   ├── custom/
│   │   └── head-end.html          # Injeta analytics, fonts, CSS customizado
│   └── section-box.html           # Partial compartilhado (DRY, veja abaixo)
├── blog/
│   └── home.html                  # Homepage customizada
├── newsletters/
│   └── single.html                # Template de newsletter individual
├── podcast-transcripts/
│   └── list.html                  # Listagem de episódios
└── shortcodes/                    # 17 shortcodes — componentes sem JS
    ├── podcast-turn.html          # Balão de fala no transcript
    ├── akita-comment.html         # Comentário do Akita
    ├── marvin-comment.html        # Comentário do Marvin
    ├── history-timeline.html      # Timeline de eventos históricos
    ├── market-ticker.html         # Tabela de tickers financeiros
    ├── market-map.html            # Mapa FinViz S&amp;P 500
    ├── subscribe-cta.html         # Call-to-action de inscrição
    ├── score.html                 # Badge de relevância (high/medium/low)
    ├── holidays.html              # Feriados e datas comemorativas
    ├── ref-stories.html           # Links de referência
    ├── book-downloads.html        # Links de download do livro
    ├── hacker-news.html           # ┐
    ├── anime-ranking.html         # │ Section boxes — delegam para
    ├── youtube-digest.html        # │ section-box.html (ver abaixo)
    ├── world-events.html          # │
    ├── market-news.html           # │
    └── qa.html                    # ┘</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada tipo de conteúdo tem seu template. Newsletters têm sidebar de navegação e seções visuais. Transcrições de podcast têm player embutido e formatação de diálogo. O mesmo conteúdo Markdown é renderizado de formas diferentes dependendo do <code>type</code> no frontmatter.</p>
<h2>Shortcodes: Componentes Sem JavaScript<span class="hx:absolute hx:-mt-20" id="shortcodes-componentes-sem-javascript"></span>
    <a href="#shortcodes-componentes-sem-javascript" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Hugo shortcodes são o equivalente a componentes React, mas sem build step:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- layouts/shortcodes/podcast-turn.html --&gt;</span>
</span></span><span class="line"><span class="cl">{{ $speaker := .Get &#34;speaker&#34; }}
</span></span><span class="line"><span class="cl">{{ $isAkita := eq $speaker &#34;akita&#34; }}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;podcast-turn&#34;</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;display: flex; {{ if $isAkita }}flex-direction: row{{ else }}flex-direction: row-reverse{{ end }};&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">img</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/images/{{ $speaker }}.jpg&#34;</span>
</span></span><span class="line"><span class="cl">       <span class="na">style</span><span class="o">=</span><span class="s">&#34;width: 40px; height: 40px; border-radius: 50%;&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;bubble {{ if $isAkita }}akita-bubble{{ else }}marvin-bubble{{ end }}&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    {{ .Inner | markdownify }}
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No Markdown, o autor escreve:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>{{&lt; podcast-turn speaker=&#34;akita&#34; &gt;}}
Então, vamos falar sobre o que aconteceu essa semana no mundo tech...
{{&lt; /podcast-turn &gt;}}</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-06-23.jpg" alt="baloon"  loading="lazy" /></p>
<p>E o Hugo renderiza um balão de chat estilizado, com avatar, alinhamento correto, e markdown processado. Zero JavaScript. Zero framework. HTML + CSS + template logic.</p>
<h3>DRY com Partial Compartilhado<span class="hx:absolute hx:-mt-20" id="dry-com-partial-compartilhado"></span>
    <a href="#dry-com-partial-compartilhado" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Com 17 shortcodes, duplicação vira problema rápido. Sete deles — os &ldquo;section boxes&rdquo; que envolvem seções da newsletter (Hacker News, Anime, YouTube, etc.) — têm a mesma estrutura visual: ícone, título, borda colorida, conteúdo Markdown. Só mudam a cor e o label.</p>
<p>A solução é um partial compartilhado que recebe parâmetros:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- layouts/partials/section-box.html --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;section-box {{ .class }}&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;section-box-header&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>{{ .icon }}<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span> {{ .label }}
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;section-box-content&#34;</span><span class="p">&gt;</span>{{ .content | markdownify }}<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>E cada shortcode vira uma linha:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- layouts/shortcodes/hacker-news.html --&gt;</span>
</span></span><span class="line"><span class="cl">{{ partial &#34;section-box&#34; (dict &#34;class&#34; &#34;hacker-news&#34; &#34;icon&#34; &#34;<span class="ni">&amp;#128274;</span>&#34; &#34;label&#34; &#34;The Hacker News&#34; &#34;content&#34; .Inner) }}</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-07-02.jpg" alt="hacker news"  loading="lazy" /></p>
<p>É o equivalente Hugo de herança de componentes: um partial polimórfico parametrizado por <code>dict</code>. O CSS em <code>custom.css</code> define cores por classe (<code>.hacker-news</code>, <code>.anime-ranking</code>, etc.) com variantes light/dark. Resultado: 7 shortcodes que antes tinham HTML duplicado agora são 7 linhas + 1 partial + CSS.</p>
<p>Mas tem um pegadinho crucial: <strong>shortcodes são exclusivos do Hugo</strong>. O mesmo conteúdo que vai pro blog também vai por email. E email não entende shortcodes.</p>
<h2>O Desafio: Mesmo Conteúdo, Dois Renders<span class="hx:absolute hx:-mt-20" id="o-desafio-mesmo-conteúdo-dois-renders"></span>
    <a href="#o-desafio-mesmo-conte%c3%bado-dois-renders" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui está o problema central de frontend nesse tipo de projeto: o mesmo arquivo Markdown precisa funcionar em dois contextos completamente diferentes.</p>
<p><strong>No blog (Hugo):</strong></p>
<ul>
<li>Shortcodes funcionam (<code>\{\{&lt; subscribe-cta &gt;\}\}</code>)</li>
<li>CSS com classes (Tailwind, Hextra)</li>
<li>Dark mode via <code>prefers-color-scheme</code></li>
<li>Imagens responsive</li>
<li>Layout flexível</li>
</ul>
<p><strong>No email (Rails → Amazon SES):</strong></p>
<ul>
<li>Shortcodes são texto morto — precisam ser removidos</li>
<li>CSS deve ser <strong>inline</strong> (muitos clientes de email ignoram <code>&lt;style&gt;</code>)</li>
<li>Dark mode? Esqueça pra 80% dos clientes</li>
<li>Imagens com URLs absolutas e tamanho fixo</li>
<li>Layout com <code>&lt;table&gt;</code> (sim, em 2026)</li>
</ul>
<p>A solução: dois pipelines de renderização independentes. O conteúdo Markdown é a source of truth. O Hugo transforma pra web. O Rails transforma pra email. Cada um com suas regras.</p>
<p>No lado do Rails, o serviço <code>MarkdownToHtml</code> precisa:</p>
<ol>
<li>Processar Markdown padrão</li>
<li>Converter <code>\{\{&lt; shortcode &gt;\}\}</code> em HTML equivalente ou removê-los — via um hash de <code>SHORTCODE_PATTERNS</code> que mapeia cada shortcode a uma regex + template inline com CSS</li>
<li>Aplicar tema (light/dark baseado na preferência do assinante)</li>
<li>Injetar CSS inline pra compatibilidade de email clients</li>
<li>Reescrever URLs de imagem pra absolutas</li>
</ol>
<p>É mais trabalho que parece. Cada shortcode precisa de um equivalente email-safe. Avatares de comentário precisam de URLs absolutas. Seções com background colorido precisam de <code>&lt;td style=&quot;background-color:...&quot;&gt;</code> ao invés de classes CSS.</p>
<h2>Email HTML: O Pior Frontend do Mundo<span class="hx:absolute hx:-mt-20" id="email-html-o-pior-frontend-do-mundo"></span>
    <a href="#email-html-o-pior-frontend-do-mundo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Se você acha que cross-browser compatibility é difícil, tente cross-email-client compatibility. O Outlook 2016 renderiza HTML usando o <strong>engine do Microsoft Word</strong>. Sim, Word. Não é piada.</p>
<p>As regras de sobrevivência:</p>
<ol>
<li><strong>Layout com <code>&lt;table&gt;</code></strong>. <code>display: flex</code>? <code>grid</code>? Não existem no Outlook.</li>
<li><strong>CSS inline em todo elemento</strong>. <code>&lt;style&gt;</code> tags são ignoradas por vários clients.</li>
<li><strong>Sem <code>margin</code> em imagens</strong>. Use <code>padding</code> na <code>&lt;td&gt;</code> pai.</li>
<li><strong>Fonts web não funcionam</strong>. Use font stack: <code>font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;</code></li>
<li><strong>Temas são preferência do assinante</strong>, não detecção automática — muitos email clients não suportam <code>prefers-color-scheme</code>.</li>
</ol>
<p>O template de email do projeto é um ERB que recebe variáveis de tema:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>&lt;td style=&#34;padding: 8px 28px; color: &lt;%= @theme[:text] %&gt;;
           background: &lt;%= @theme[:card_bg] %&gt;;&#34;&gt;
  &lt;%= raw @content %&gt;
&lt;/td&gt;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O tema define todas as cores:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">THEMES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">light</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;#1a1a1a&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">card_bg</span><span class="p">:</span> <span class="s2">&#34;#ffffff&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">link_color</span><span class="p">:</span> <span class="s2">&#34;#7c3aed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">dark</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;#e6edf3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">card_bg</span><span class="p">:</span> <span class="s2">&#34;#161b22&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">link_color</span><span class="p">:</span> <span class="s2">&#34;#a78bfa&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada assinante escolhe seu tema na hora de se inscrever. O email é renderizado com as cores corretas pra aquela pessoa. Sem media queries, sem detecção automática — <strong>porque email clients não suportam de forma confiável</strong>. É mais simples dar a escolha pro usuário.</p>
<h2>Paleta de Cores: HSL e a Ciência por Trás das Escolhas<span class="hx:absolute hx:-mt-20" id="paleta-de-cores-hsl-e-a-ciência-por-trás-das-escolhas"></span>
    <a href="#paleta-de-cores-hsl-e-a-ci%c3%aancia-por-tr%c3%a1s-das-escolhas" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>&ldquo;Mas como vocês escolheram essas cores?&rdquo; — Não foi no olhômetro. Existe uma ciência por trás, e ela se chama <strong>HSL</strong> (Hue, Saturation, Lightness).</p>
<p>A maioria dos desenvolvedores pensa em cor como hex (<code>#e8eef8</code>) ou RGB (<code>rgb(232, 238, 248)</code>). São formatos de máquina — úteis pra renderizar, inúteis pra <strong>projetar</strong>. Quando você olha <code>#3b50a2</code> e <code>#c93030</code> lado a lado, não tem como saber intuitivamente se vão funcionar juntas. São só números.</p>
<p>HSL resolve isso. Em vez de misturar canais de luz (Red, Green, Blue), você descreve cor em termos humanos:</p>
<ul>
<li><strong>Hue</strong> (matiz): a posição no círculo cromático, de 0° a 360°. Vermelho = 0°, verde = 120°, azul = 240°.</li>
<li><strong>Saturation</strong> (saturação): quão &ldquo;viva&rdquo; é a cor. 0% = cinza, 100% = cor pura.</li>
<li><strong>Lightness</strong> (luminosidade): quão clara ou escura. 0% = preto, 100% = branco.</li>
</ul>
<p>Com HSL, você controla cada dimensão independentemente. E é aí que entra a construção da paleta.</p>
<h3>Passo 1: Distribuir Matizes no Círculo Cromático<span class="hx:absolute hx:-mt-20" id="passo-1-distribuir-matizes-no-círculo-cromático"></span>
    <a href="#passo-1-distribuir-matizes-no-c%c3%adrculo-crom%c3%a1tico" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-09-47.jpg" alt="color wheel"  loading="lazy" /></p>
<p>A newsletter tem 8 tipos de seção, cada uma com cor própria. Pra que todas &ldquo;pertençam&rdquo; visualmente ao mesmo projeto, os matizes são distribuídos uniformemente no círculo de 360°:</p>
<table>
  <thead>
      <tr>
          <th>Seção</th>
          <th>Matiz</th>
          <th>Graus</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>YouTube</td>
          <td>Vermelho</td>
          <td>0°</td>
      </tr>
      <tr>
          <td>História</td>
          <td>Sépia</td>
          <td>35°</td>
      </tr>
      <tr>
          <td>Mundo</td>
          <td>Âmbar</td>
          <td>45°</td>
      </tr>
      <tr>
          <td>Livro/Downloads</td>
          <td>Verde</td>
          <td>140°</td>
      </tr>
      <tr>
          <td>Feriados</td>
          <td>Teal</td>
          <td>180°</td>
      </tr>
      <tr>
          <td>Anime/Q&amp;A</td>
          <td>Azul</td>
          <td>210°</td>
      </tr>
      <tr>
          <td>Market News</td>
          <td>Royal</td>
          <td>225°</td>
      </tr>
      <tr>
          <td>Hacker News</td>
          <td>Roxo</td>
          <td>270°</td>
      </tr>
  </tbody>
</table>
<p>Oito matizes espaçados ao redor do círculo. Não é aleatório — é a mesma lógica de uma <strong>paleta policromática</strong> em teoria das cores. Quando os matizes são equidistantes, o olho humano percebe harmonia mesmo com cores completamente diferentes. É o mesmo princípio que artistas usam com um círculo cromático de 12 cores desde o século XVIII.</p>
<h3>Passo 2: Fixar Saturação e Luminosidade por Camada<span class="hx:absolute hx:-mt-20" id="passo-2-fixar-saturação-e-luminosidade-por-camada"></span>
    <a href="#passo-2-fixar-satura%c3%a7%c3%a3o-e-luminosidade-por-camada" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/HSV_color_solid_cylinder_saturation_gray.jpg" alt="saturation luminosity"  loading="lazy" /></p>
<p>Aqui está o truque que faz tudo funcionar junto. Cada &ldquo;camada&rdquo; visual (background, texto, borda) tem saturação e luminosidade <strong>fixas</strong> — só o matiz muda:</p>
<p><strong>Tema claro:</strong></p>
<ul>
<li><strong>Backgrounds</strong>: ~92-95% luminosidade, saturação moderada. Com luminosidade tão alta, mesmo saturações diferentes resultam em cores pastel com peso visual parecido — nenhuma seção &ldquo;grita&rdquo; mais que outra.</li>
<li><strong>Texto</strong>: ~15-20% luminosidade. Escuro o suficiente pra garantir <strong>ratio de contraste &gt;7:1</strong> contra o background claro (acima do mínimo WCAG AAA de 7:1).</li>
<li><strong>Bordas</strong>: ~45-55% luminosidade. O acento cromático — visível mas não dominante.</li>
</ul>
<p><strong>Tema escuro:</strong></p>
<ul>
<li><strong>Backgrounds</strong>: ~10-12% luminosidade. Profundidade uniforme — todas as seções têm a mesma &ldquo;escuridão&rdquo;.</li>
<li><strong>Texto</strong>: ~80-85% luminosidade. Claro o suficiente pra <strong>ratio de contraste &gt;6:1</strong> no fundo escuro.</li>
<li><strong>Bordas</strong>: mesma faixa de luminosidade que no tema claro — funcionam como acento nos dois contextos.</li>
</ul>
<p>Na prática, isso significa que gerar a variante dark de qualquer cor é mecânico: mantém o matiz, inverte a luminosidade. Background que era 94% vira 11%. Texto que era 18% vira 83%. Não é design — é aritmética.</p>
<h3>Passo 3: Verificar Contraste (WCAG)<span class="hx:absolute hx:-mt-20" id="passo-3-verificar-contraste-wcag"></span>
    <a href="#passo-3-verificar-contraste-wcag" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://accessibilityassistant.com/blog/accessibility-insights/wcag-2-colour-contrast-accessibility-guidelines/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/Why-Colour-Contrast-Accessibility-Matters-More-Than-Ever.jpg" alt="Why Colour Contrast Accessibility Matters More Than Ever"  loading="lazy" /></a></p>
<p>Cores bonitas que ninguém consegue ler não servem pra nada. O padrão WCAG (Web Content Accessibility Guidelines) define ratios mínimos:</p>
<ul>
<li><strong>AA</strong>: 4.5:1 pra texto normal, 3:1 pra texto grande</li>
<li><strong>AAA</strong>: 7:1 pra texto normal, 4.5:1 pra texto grande</li>
</ul>
<p>Os backgrounds claros com ~95% luminosidade contra texto com ~20% luminosidade dão ratio &gt;7:1 — WCAG AAA. Os backgrounds escuros (~11%) contra texto claro (~82%) dão &gt;6:1 — confortavelmente WCAG AA, próximo de AAA.</p>
<p>Isso não é por altruísmo. Email com baixo contraste é mais difícil de ler, gera menos engajamento, e em casos extremos é sinalizado por filtros de acessibilidade de email clients.</p>
<h3>O Resultado<span class="hx:absolute hx:-mt-20" id="o-resultado"></span>
    <a href="#o-resultado" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-07-50.jpg" alt="example colors"  loading="lazy" /></p>
<p>Quando você abre a newsletter e vê a seção do Hacker News (roxa), seguida pelo YouTube (vermelha), seguida pelo Anime (azul) — elas parecem &ldquo;do mesmo projeto&rdquo; apesar de cores completamente diferentes. Isso é o HSL trabalhando: matizes diferentes, mesma saturação, mesma luminosidade. O olho percebe coerência sem saber explicar por quê.</p>
<p>E a melhor parte: adicionar uma seção nova é trivial. Escolhe um matiz que não está ocupado no círculo, aplica as mesmas regras de saturação/luminosidade por camada, e a nova seção automaticamente &ldquo;pertence&rdquo; ao design existente.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># O comentário no código resume as regras:</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Hues evenly spaced: blue 210, green 140, sepia 35, amber 45, royal 225, purple 270, red 0, teal 180</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Light backgrounds: ~95% lightness, ~25% saturation</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Light text: ~20% lightness (&gt;7:1 contrast ratio on light bg)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Dark backgrounds: ~10-12% lightness</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Dark text: ~80-85% lightness (&gt;6:1 contrast ratio on dark bg)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># - Borders: ~45-55% lightness</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Seis regras. Oito matizes. Dois temas. 100% determinístico. Nenhuma decisão subjetiva de &ldquo;acho que esse azul fica bonito&rdquo; — é sistema de design derivado de propriedades geométricas do espaço de cor HSL.</p>
<h2>HTML Bem Formado: O Filtro de Spam que Ninguém Te Conta<span class="hx:absolute hx:-mt-20" id="html-bem-formado-o-filtro-de-spam-que-ninguém-te-conta"></span>
    <a href="#html-bem-formado-o-filtro-de-spam-que-ningu%c3%a9m-te-conta" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tudo que descrevi acima — tables, CSS inline, font stacks — resolve o problema de <strong>renderização</strong>. Mas tem um segundo motivo pra seguir essas regras que a maioria dos desenvolvedores ignora: <strong>provedores de email analisam a qualidade do seu HTML pra decidir se é spam</strong>.</p>
<p>Gmail, Outlook, Yahoo — todos passam o HTML do seu email por heurísticas antes de mostrar pro usuário. HTML malformado é um dos sinais clássicos de spam. Faz sentido: spammers não testam templates. Eles cospem HTML quebrado gerado por ferramentas de mass-mailing baratas. Se seu email parece output de spammer, vai ser tratado como spam.</p>
<p>Os sinais que disparam filtros:</p>
<ol>
<li><strong>Tags não fechadas ou mal aninhadas</strong>. Um <code>&lt;div&gt;</code> que abre e nunca fecha, ou um <code>&lt;/td&gt;</code> antes do <code>&lt;/tr&gt;</code>, é flag imediata. Provedores esperam HTML que parse sem erros — não HTML &ldquo;que o browser conserta&rdquo;.</li>
<li><strong>Ratio imagem/texto desbalanceado</strong>. Email que é basicamente uma imagem gigante com pouco texto? Spam clássico. Provedores querem ver texto real — não texto dentro de imagens. A regra de ouro é manter pelo menos 60% texto, 40% imagem.</li>
<li><strong>Atributo <code>alt</code> ausente em imagens</strong>. Além de acessibilidade, <code>alt</code> vazio ou ausente é sinal de descuido — e descuido correlaciona com spam. Toda <code>&lt;img&gt;</code> precisa de <code>alt</code> descritivo.</li>
<li><strong>CSS que esconde conteúdo</strong>. <code>display: none</code>, <code>visibility: hidden</code>, <code>font-size: 0</code>, texto da mesma cor que o background — são técnicas clássicas de keyword stuffing. Mesmo que você use legitimamente pra responsive email, o filtro não sabe a diferença.</li>
<li><strong>HTML excessivamente pesado</strong>. Email com 500KB de HTML levanta suspeita. Mantenha leve — o conteúdo real raramente precisa de mais de 100KB de HTML.</li>
<li><strong>Links com texto que não corresponde ao href</strong>. <code>&lt;a href=&quot;https://site-real.com&quot;&gt;https://site-falso.com&lt;/a&gt;</code> é phishing textbook. Mesmo discrepâncias sutis (domínio no texto diferente do domínio no link) são penalizadas.</li>
</ol>
<p>O ponto que conecta tudo: <strong>as mesmas práticas que fazem email renderizar corretamente no Outlook são as que mantêm seus emails fora do spam</strong>. Tables com estrutura limpa? HTML bem formado. CSS inline em cada elemento ao invés de hacks com <code>&lt;style&gt;</code>? Sem CSS escondido. Imagens com dimensões explícitas e <code>alt</code> text? Conteúdo legítimo.</p>
<p>Não é coincidência. Provedores de email calibraram seus filtros observando o que spammers fazem — e spammers fazem exatamente o oposto de HTML email bem construído. Quando você segue as melhores práticas de compatibilidade, <strong>de graça</strong> você também está sinalizando legitimidade pros filtros de spam.</p>
<p>A lição prática: trate o HTML do seu email como se fosse um documento XML. Valide, teste, e garanta que todo tag abre e fecha na ordem certa. Use ferramentas como <a href="https://www.emailonacid.com/"target="_blank" rel="noopener">Email on Acid</a> ou <a href="https://www.litmus.com/"target="_blank" rel="noopener">Litmus</a> pra testar em múltiplos clients — eles também reportam problemas de spam scoring. E resista à tentação de copiar templates prontos da internet sem validar — muitos são um desastre de HTML malformado que passa despercebido no Gmail mas faz o Outlook ter um ataque cardíaco e faz o filtro de spam do Yahoo te marcar como suspeito.</p>
<h2>RSS Feeds: O Frontend Esquecido<span class="hx:absolute hx:-mt-20" id="rss-feeds-o-frontend-esquecido"></span>
    <a href="#rss-feeds-o-frontend-esquecido" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_01-12-48.jpg" alt="RSS"  loading="lazy" /></p>
<p>Um detalhe que desenvolvedores web ignoram: RSS é um frontend. Podcasts no Spotify e Apple Podcasts são consumidos via RSS. O feed precisa de:</p>
<ul>
<li>XML válido (parsers de podcast são rígidos)</li>
<li>Metadata completa (título, descrição, duração, tamanho do arquivo)</li>
<li>URLs de imagem que <strong>realmente funcionam</strong> (Spotify valida)</li>
<li>Cover art com dimensões mínimas (1400×1400 pra Spotify)</li>
<li>GUIDs únicos por episódio</li>
</ul>
<p>Hugo gera RSS automaticamente, mas o template padrão não tem os campos que plataformas de podcast exigem. Um template customizado com tags iTunes é necessário:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;itunes:duration&gt;</span>{{ .Params.duration_seconds }}<span class="nt">&lt;/itunes:duration&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;itunes:image</span> <span class="na">href=</span><span class="s">&#34;{{ .Params.cover_image | absURL }}&#34;</span><span class="nt">/&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;enclosure</span> <span class="na">url=</span><span class="s">&#34;{{ .Params.audio_url }}&#34;</span>
</span></span><span class="line"><span class="cl">           <span class="na">length=</span><span class="s">&#34;{{ .Params.file_size }}&#34;</span>
</span></span><span class="line"><span class="cl">           <span class="na">type=</span><span class="s">&#34;audio/mpeg&#34;</span><span class="nt">/&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>A Filosofia: Conteúdo Primeiro, Framework Nunca<span class="hx:absolute hx:-mt-20" id="a-filosofia-conteúdo-primeiro-framework-nunca"></span>
    <a href="#a-filosofia-conte%c3%bado-primeiro-framework-nunca" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O padrão que emerge de todo esse setup é: <strong>o conteúdo é soberano</strong>. Markdown com frontmatter YAML é o formato universal. Cada frontend (blog, email, RSS, podcast player) consome o mesmo conteúdo e renderiza do seu jeito.</p>
<p>Não existe &ldquo;componente React que busca dados de uma API&rdquo;. Existe um arquivo Markdown que é lido por quem precisar. Isso é radicalmente simples — e radicalmente poderoso. Trocar o blog engine de Hugo pra outro? Muda os templates, o conteúdo fica igual. Trocar o provedor de email de SES pra Sendgrid? Muda o mailer, os templates ficam iguais.</p>
<p>É o Unix philosophy aplicado a frontend: cada ferramenta faz uma coisa bem feita, conectadas por um formato universal (Markdown + YAML).</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Você não precisa de React pra ter frontend moderno. Tailwind v4 te dá design system com constraints, dark mode nativo, e zero JavaScript. Hugo te dá um blog absurdamente rápido com templates flexíveis. ERB com CSS inline te dá emails que funcionam em qualquer client.</p>
<p>O investimento real é entender as restrições de cada plataforma e construir pipelines de renderização pra cada uma. Não é aprender o framework da moda que vai morrer em 18 meses.</p>
<p>No final das contas, a melhor abstração de frontend é nenhuma. HTML, CSS, e Markdown. Simples, debugável, e vai funcionar daqui a 10 anos.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>frontend</category><category>desenvolvimento-web</category></item><item><title>Servindo IA na Nuvem: Meu TTS pessoal | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/18/servindo-ia-na-nuvem-meu-tts-pessoal-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/18/servindo-ia-na-nuvem-meu-tts-pessoal-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Wed, 18 Feb 2026 15:28:46 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 3.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;Já vou falar do podcast hoje; lembrem-se de que o &lt;a href="https://blog.themakitachronicles.com/podcast-transcripts/"target="_blank" rel="noopener"&gt;episódio piloto já está no ar&lt;/a&gt; (transcripts e RSS pra assinar) e no canal no Spotify.&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Todo tutorial de IA começa com &lt;em&gt;&amp;ldquo;instale o modelo, rode inference, pronto!&amp;rdquo;&lt;/em&gt;. Nenhum te conta o que acontece quando você precisa rodar isso em produção com uptime real, custo controlado, e qualidade consistente.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 3.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>Já vou falar do podcast hoje; lembrem-se de que o <a href="https://blog.themakitachronicles.com/podcast-transcripts/"target="_blank" rel="noopener">episódio piloto já está no ar</a> (transcripts e RSS pra assinar) e no canal no Spotify.</p>
<p>&ndash;</p>
<p>Todo tutorial de IA começa com <em>&ldquo;instale o modelo, rode inference, pronto!&rdquo;</em>. Nenhum te conta o que acontece quando você precisa rodar isso em produção com uptime real, custo controlado, e qualidade consistente.</p>
<p>Vou contar como foi colocar um modelo de TTS (Text-to-Speech) em produção numa GPU na nuvem. Porque se você está pensando em servir modelos de IA, precisa saber no que está se metendo.</p>
<h2>O Cenário<span class="hx:absolute hx:-mt-20" id="o-cenário"></span>
    <a href="#o-cen%c3%a1rio" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><blockquote>
  <p>O objetivo: gerar um podcast de ~30 minutos a partir de texto, com duas vozes distintas, qualidade de broadcast, pronto pra Spotify. Toda semana, automaticamente, sem intervenção humana.</p>

</blockquote>
<p>O modelo escolhido: <a href="https://qwen.ai/blog?id=qwen3tts-0115"target="_blank" rel="noopener"><strong>Qwen3-TTS</strong></a>, um modelo de text-to-speech que suporta voice cloning e fine-tuning. Roda em GPU — precisa de pelo menos 6GB de VRAM pra inference.</p>
<p>A plataforma: <a href="https://runpod.io/"target="_blank" rel="noopener"><strong>RunPod</strong></a>, que oferece GPUs serverless com cobrança por segundo de uso.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_12-33-45.jpg" alt="runpod"  loading="lazy" /></p>
<p>Parece simples, né? Pois aguarde.</p>
<h2>Problema 1: Cold Start — O Assassino Silencioso<span class="hx:absolute hx:-mt-20" id="problema-1-cold-start--o-assassino-silencioso"></span>
    <a href="#problema-1-cold-start--o-assassino-silencioso" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>GPU serverless funciona assim: quando não tem requisição, o worker desliga. Quando chega uma requisição, ele liga, carrega o modelo na VRAM, e processa.</p>
<p>O tempo de &ldquo;carregar o modelo na VRAM&rdquo; é o <em>cold start</em>. Pra um modelo de ~4GB, estamos falando de <strong>30 a 90 segundos</strong>. Sua requisição HTTP fica pendurada, esperando. Se seu timeout é de 30 segundos, você nem chega a ver o modelo funcionar — só vê timeout.</p>
<p>A solução ingênua: <em>&ldquo;aumenta o timeout!&rdquo;</em>. A solução real: <strong>health check com polling</strong>.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@app.get</span><span class="p">(</span><span class="s2">&#34;/health&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">health</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">model_loaded</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JSONResponse</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">status_code</span><span class="o">=</span><span class="mi">503</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;loading&#34;</span><span class="p">,</span> <span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Model loading...&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;ready&#34;</span><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O cliente (no caso, um job Rails) faz polling no <code>/health</code> antes de mandar a requisição real:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">wait_for_server</span><span class="p">(</span><span class="ss">max_wait</span><span class="p">:</span> <span class="mi">180</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">deadline</span> <span class="o">=</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span> <span class="o">+</span> <span class="n">max_wait</span>
</span></span><span class="line"><span class="cl">  <span class="kp">loop</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">http_get</span><span class="p">(</span><span class="s2">&#34;/health&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="n">response</span><span class="o">.</span><span class="n">code</span> <span class="o">==</span> <span class="mi">200</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="s2">&#34;Server não iniciou a tempo&#34;</span> <span class="k">if</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span> <span class="o">&gt;</span> <span class="n">deadline</span>
</span></span><span class="line"><span class="cl">    <span class="nb">sleep</span> <span class="mi">5</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Parece primitivo? É. Mas funciona melhor do que qualquer solução sofisticada de &ldquo;warm pool&rdquo; que custa 10x mais.</p>
<h2>Problema 2: Onde Ficam os Pesos do Modelo?<span class="hx:absolute hx:-mt-20" id="problema-2-onde-ficam-os-pesos-do-modelo"></span>
    <a href="#problema-2-onde-ficam-os-pesos-do-modelo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Um modelo de 4GB precisa estar disponível quando o worker inicia. Três opções:</p>
<ol>
<li><strong>Bake no Docker</strong>: Imagem de 4GB+. Build lento. Push lento. Pull lento.</li>
<li><strong>Download do HuggingFace no boot</strong>: Depende de rede. Pode falhar. Adiciona minutos ao cold start.</li>
<li><strong>Network Volume</strong>: Disco persistente montado no worker.</li>
</ol>
<p>A opção 3 é a correta. O RunPod oferece Network Volumes que persistem entre execuções. O modelo fica lá, sempre pronto. O cold start é só o tempo de carregar da disk pra VRAM, sem download.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_12-35-43.jpg" alt="network storage"  loading="lazy" /></p>
<p>Mas tem uma pegadinha: o volume pode estar montado ou não, dependendo de como o worker é configurado. Então o código de setup precisa ser resiliente:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">NETWORK_VOLUME</span> <span class="o">=</span> <span class="s2">&#34;/runpod-volume&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCAL_FALLBACK</span> <span class="o">=</span> <span class="s2">&#34;/workspace/models&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">resolve_model_path</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">isdir</span><span class="p">(</span><span class="n">NETWORK_VOLUME</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">base</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">NETWORK_VOLUME</span><span class="p">,</span> <span class="s2">&#34;huggingface&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;HF_HOME&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">base</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s2">&#34;hub&#34;</span><span class="p">,</span> <span class="s2">&#34;models--Qwen--Qwen3-TTS&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Fallback: download local</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">LOCAL_FALLBACK</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Auto-detecção. Se o volume existe, usa. Se não, funciona do mesmo jeito (mais lento no primeiro boot). <strong>Nunca assuma que a infraestrutura está perfeita.</strong></p>
<h2>Problema 3: Dois Modos de Voz, Dois Conjuntos de Parâmetros<span class="hx:absolute hx:-mt-20" id="problema-3-dois-modos-de-voz-dois-conjuntos-de-parâmetros"></span>
    <a href="#problema-3-dois-modos-de-voz-dois-conjuntos-de-par%c3%a2metros" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui está algo que nenhuma documentação vai te dizer: modelos de TTS com voice cloning e fine-tuning precisam de <strong>parâmetros de sampling completamente diferentes</strong> dependendo do modo.</p>
<p>Com uma voz fine-tuned (treinada especificamente no modelo), você quer parâmetros conservadores:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>temperature: 0.5
top_p: 0.8
repetition_penalty: 1.1</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Com voice cloning (voz base + áudio de referência), os mesmos parâmetros produzem <strong>ruído branco</strong>. Literalmente. White noise. Porque o modelo de clone precisa de mais &ldquo;criatividade&rdquo; pra reconstruir as características vocais:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>temperature: 0.9
top_p: 0.95
repetition_penalty: 1.05</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Descobri isso depois de horas gerando áudio inutilizável. A documentação do modelo não menciona. Os papers acadêmicos não mencionam. É o tipo de conhecimento que só vem de <strong>experimentação em produção</strong>.</p>
<h2>Problema 4: O Áudio de Referência Importa Mais que o Modelo<span class="hx:absolute hx:-mt-20" id="problema-4-o-áudio-de-referência-importa-mais-que-o-modelo"></span>
    <a href="#problema-4-o-%c3%a1udio-de-refer%c3%aancia-importa-mais-que-o-modelo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Voice cloning usa um trecho de áudio como referência pra replicar a voz. Parece simples: grava 10 segundos, passa pro modelo e pronto.</p>
<p>Exceto que se o áudio de referência começa com a fala imediatamente — sem nem 200 milissegundos de silêncio no início — o modelo aprende que &ldquo;o áudio começa sem onset&rdquo;. E <strong>toda geração subsequente</strong> vai cortar a primeira sílaba.</p>
<p>Isso não é um bug do modelo. É o modelo fazendo exatamente o que você pediu: <em>&ldquo;gere áudio que começa igual a essa referência&rdquo;</em>. Se a referência começa cortada, a geração começa cortada.</p>
<p>A correção não é pós-processamento (o áudio já foi gerado sem a sílaba). É <strong>corrigir a referência</strong>: garantir pelo menos 300ms de silêncio antes da fala.</p>
<p>Pra quem vem do mundo de software, isso é contra-intuitivo. Estamos acostumados a tratar dados de entrada como imutáveis e consertar na saída. Com modelos generativos, a qualidade da entrada é <strong>tudo</strong>.</p>
<h2>Problema 5: Timeout e Limites de Geração<span class="hx:absolute hx:-mt-20" id="problema-5-timeout-e-limites-de-geração"></span>
    <a href="#problema-5-timeout-e-limites-de-gera%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Um modelo de TTS gera áudio token por token. Se você não limitar, ele pode gerar indefinidamente — e no RunPod, você paga por segundo de GPU. Um turno de diálogo de 30 palavras não deveria gerar 3 minutos de áudio, mas sem limite, pode.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">MAX_NEW_TOKENS</span> <span class="o">=</span> <span class="mi">720</span>  <span class="c1"># ~60 segundos de áudio a 12Hz</span>
</span></span><span class="line"><span class="cl"><span class="n">GENERATION_TIMEOUT</span> <span class="o">=</span> <span class="mi">180</span>  <span class="c1"># 3 minutos absolutos</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">future</span> <span class="o">=</span> <span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">model</span><span class="o">.</span><span class="n">generate</span><span class="p">,</span> <span class="o">**</span><span class="n">params</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">future</span><span class="o">.</span><span class="n">result</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">GENERATION_TIMEOUT</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Dupla proteção: limite de tokens na geração e timeout absoluto no processo. Se qualquer um disparar, o turno falha e pode ser re-tentado com parâmetros ajustados.</p>
<h2>Problema 6: Pós-Processamento é Metade do Trabalho<span class="hx:absolute hx:-mt-20" id="problema-6-pós-processamento-é-metade-do-trabalho"></span>
    <a href="#problema-6-p%c3%b3s-processamento-%c3%a9-metade-do-trabalho" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O modelo gera WAV cru. Pra chegar em áudio de podcast quality, o caminho é longo:</p>
<ol>
<li><strong>Normalização de loudness</strong>: Spotify exige -14 LUFS. Sem normalização, cada turno tem volume diferente.</li>
<li><strong>Re-encoding</strong>: Os WAVs do modelo podem ter formatos sutilmente diferentes (sample rate, bit depth). Concatenar com <code>-c copy</code> causa <strong>segmentos silenciosos</strong>. Precisa re-encodar tudo pro mesmo formato.</li>
<li><strong>Conversão pra MP3</strong>: CBR 192kbps, 44.1kHz, com metadata ID3.</li>
</ol>
<p>E a normalização de loudness precisa ser <strong>two-pass</strong>: primeiro mede, depois aplica. Single-pass é chute — e chute em produção gera áudio com volume inconsistente entre episódios.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Pass 1: análise</span>
</span></span><span class="line"><span class="cl">ffmpeg -i input.wav -af <span class="nv">loudnorm</span><span class="o">=</span><span class="nv">I</span><span class="o">=</span>-14:print_format<span class="o">=</span>json -f null -
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Pass 2: aplicação dos valores medidos</span>
</span></span><span class="line"><span class="cl">ffmpeg -i input.wav -af <span class="nv">loudnorm</span><span class="o">=</span><span class="nv">I</span><span class="o">=</span>-14:measured_I<span class="o">=</span>-18.5:measured_TP<span class="o">=</span>-2.1:... output.wav</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Tudo isso roda no servidor, depois da geração do TTS. E precisa de <code>ffmpeg</code> instalado no container — mais uma dependência pra gerenciar.</p>
<h2>Problema 7: O Script Importa Mais que o Modelo de Voz<span class="hx:absolute hx:-mt-20" id="problema-7-o-script-importa-mais-que-o-modelo-de-voz"></span>
    <a href="#problema-7-o-script-importa-mais-que-o-modelo-de-voz" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Depois de resolver GPU, cold start, voice cloning, sampling, normalização — o podcast sai. E soa&hellip; robótico. Não por causa do TTS. O TTS é excelente. Soa robótico porque o <strong>texto</strong> é robótico.</p>
<p>Esse é o problema que ninguém menciona em pipelines de TTS: a qualidade do áudio começa no script, não no modelo de voz. Um TTS perfeito lendo um texto seco e formal produz áudio seco e formal. Garbage in, garbage out — mas num nível que você não espera.</p>
<h3>Duas Passadas: Draft e Refine<span class="hx:absolute hx:-mt-20" id="duas-passadas-draft-e-refine"></span>
    <a href="#duas-passadas-draft-e-refine" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A solução é gerar o script em duas passadas de LLM:</p>
<p><strong>Passada 1 (Draft)</strong>: Transforma a newsletter em diálogo. Aqui o LLM recebe o conteúdo completo e cria a estrutura — quem fala o quê, em que ordem, com quais transições. <strong>Sem mudar o conteúdo, só a forma!</strong></p>
<p><strong>Passada 2 (Refine)</strong>: Pega o draft e torna natural. Adiciona hesitações, reações, interrupções, auto-correções. Transforma &ldquo;script lido&rdquo; em &ldquo;conversa real&rdquo;.</p>
<p>Por que não fazer tudo numa passada? Porque são tarefas cognitivamente diferentes. Estruturar conteúdo e adicionar naturalidade ao mesmo tempo resulta num texto que não faz nem um nem outro direito. Duas passadas especializadas produzem resultado consistentemente melhor.</p>
<h3>Personalidade Precisa de Instruções Brutalmente Específicas<span class="hx:absolute hx:-mt-20" id="personalidade-precisa-de-instruções-brutalmente-específicas"></span>
    <a href="#personalidade-precisa-de-instru%c3%a7%c3%b5es-brutalmente-espec%c3%adficas" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>&ldquo;Faça o Akita soar confiante&rdquo; não funciona. O LLM vai produzir confiança genérica — frases motivacionais, tom positivo. Não é isso. Akita é confiante porque é <strong>assertivo e impaciente</strong>. A diferença está nos detalhes do prompt:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Akita Delivery Style
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Declarative, not tentative**: &#34;Isso é óbvio&#34; not &#34;Eu acho que talvez isso seja...&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Dismissive of nonsense**: &#34;Isso é bobagem&#34;, &#34;Não tem nenhum mistério aqui&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Owns his track record**: &#34;Eu avisei&#34;, &#34;Quem assistiu meu canal já sabe&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Never hedges**: No &#34;talvez&#34;, &#34;quem sabe&#34;, &#34;pode ser que&#34;
</span></span><span class="line"><span class="cl">- <span class="gs">**Impatient with the obvious**</span>: &#34;Vocês já sabem como funciona&#34;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>E pro Marvin (o co-host robô):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="k">-</span> **Filler words** (Marvin only): &#34;bom...&#34;, &#34;olha...&#34;, &#34;enfim&#34;, &#34;pois é&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Self-corrections** (Marvin only): &#34;quer dizer...&#34;, &#34;ou melhor...&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> Akita doesn&#39;t second-guess himself — if he corrects, it&#39;s
</span></span><span class="line"><span class="cl">  &#34;Não, peraí&#34; and he restates with even MORE conviction</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Sem esse nível de especificidade, os dois personagens soam iguais. O LLM precisa de exemplos concretos de <strong>como</strong> cada um fala, não de descrições abstratas de personalidade.</p>
<h3>Normalização Pra Fala: O Que Parece Certo no Texto Soa Errado no Áudio<span class="hx:absolute hx:-mt-20" id="normalização-pra-fala-o-que-parece-certo-no-texto-soa-errado-no-áudio"></span>
    <a href="#normaliza%c3%a7%c3%a3o-pra-fala-o-que-parece-certo-no-texto-soa-errado-no-%c3%a1udio" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Siglas. O inimigo invisível do TTS. &ldquo;EUA&rdquo; no texto é perfeitamente legível. Falado pelo TTS, sai &ldquo;éu-á&rdquo; — incompreensível. O prompt precisa de regras explícitas de normalização:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Spoken Text Normalization (MANDATORY)
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`EUA`</span> → <span class="sb">`Estados Unidos`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`UE`</span> → <span class="sb">`União Europeia`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`IA`</span> → <span class="sb">`inteligência artificial`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`LLM`</span> → <span class="sb">`modelo de linguagem`</span>
</span></span><span class="line"><span class="cl">- <span class="sb">`API`</span> → <span class="sb">`interface de programação`</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso precisa estar no prompt de <strong>ambas</strong> as passadas (draft e refine), porque o LLM tem tendência a reintroduzir siglas que removeu na passada anterior.</p>
<h3>O LLM Vai Reescrever Suas Opiniões (Se Você Deixar)<span class="hx:absolute hx:-mt-20" id="o-llm-vai-reescrever-suas-opiniões-se-você-deixar"></span>
    <a href="#o-llm-vai-reescrever-suas-opini%c3%b5es-se-voc%c3%aa-deixar" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Essa foi a lição mais sutil. A newsletter tem comentários opinativos do Akita e do Marvin em cada seção. Quando o LLM transforma em diálogo, ele <strong>substitui</strong> as opiniões por versões mais &ldquo;balanceadas&rdquo; e &ldquo;nuançadas&rdquo;. Akita que dizia &ldquo;isso é bobagem&rdquo; vira Akita dizendo &ldquo;isso tem seus méritos, mas&hellip;&rdquo;. Marvin pessimista vira Marvin &ldquo;levemente cético&rdquo;.</p>
<p>O LLM faz isso porque foi treinado pra ser helpful e balanced. Opiniões fortes parecem &ldquo;unhelpful&rdquo; pro modelo, então ele suaviza. A instrução original — &ldquo;use os comentários da newsletter como sementes pra conversa&rdquo; — era vaga demais. Dava liberdade pro modelo reinterpretar.</p>
<p>A correção é ser explícito e inflexível:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**PRESERVE THE ORIGINAL OPINIONS**: The newsletter contains Akita and
</span></span><span class="line"><span class="cl">Marvin commentary on each topic. These opinions are the author&#39;s real
</span></span><span class="line"><span class="cl">voice — you MUST preserve their substance, stance, and conclusions.
</span></span><span class="line"><span class="cl">Make the delivery conversational, but do NOT change what they actually
</span></span><span class="line"><span class="cl">think. If Akita&#39;s comment says &#34;isso é bobagem&#34;, the podcast version
</span></span><span class="line"><span class="cl">must convey the same dismissal. Rephrase for spoken delivery, but
</span></span><span class="line"><span class="cl">never substitute a different opinion.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>É a diferença entre &ldquo;adapte pra conversa&rdquo; (LLM entende: &ldquo;reescreva como quiser&rdquo;) e &ldquo;mude o COMO, nunca o QUÊ&rdquo; (LLM entende: &ldquo;mantenha a opinião, troque a embalagem&rdquo;).</p>
<p>E isso vale pra qualquer prompt engineering com conteúdo autoral: <strong>o LLM vai homogeneizar sua voz se você não for explícito sobre preservá-la</strong>. E num podcast onde a personalidade dos hosts É o produto, perder a voz é perder tudo.</p>
<h2>O Custo Real de IA em Produção<span class="hx:absolute hx:-mt-20" id="o-custo-real-de-ia-em-produção"></span>
    <a href="#o-custo-real-de-ia-em-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vamos ser práticos sobre custos no RunPod:</p>
<table>
  <thead>
      <tr>
          <th>Recurso</th>
          <th>Custo</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GPU L40 (serverless)</td>
          <td>~$0.39/hora</td>
      </tr>
      <tr>
          <td>Network Volume 20GB</td>
          <td>$1.40/mês</td>
      </tr>
      <tr>
          <td>Geração de 1 episódio (~30 min áudio)</td>
          <td>~$0.50–1.50</td>
      </tr>
  </tbody>
</table>
<p>O custo por episódio é baixo. O custo de <strong>descobrir como fazer funcionar</strong> é alto. Foram dezenas de horas de experimentação, logs incompreensíveis de CUDA, gerações falhadas, e áudio com qualidade inutilizável antes de chegar numa pipeline estável.</p>
<p>E o custo cognitivo: manter dois ecossistemas (Python pra ML, Ruby pra aplicação) sincronizados, com interfaces HTTP entre eles, e debugging que cruza fronteiras de linguagem.</p>
<p>De qualquer forma, pra quem pensou <em>&ldquo;Por que não Elevenlabs?&rdquo;</em>, bom, eu consegui qualidade similar, numa fração do custo, e aprendi muito no processo (e vocês também, lendo aqui).</p>
<h2>Lições<span class="hx:absolute hx:-mt-20" id="lições"></span>
    <a href="#li%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ol>
<li>
<p><strong>Cold start é um requisito, não um bug.</strong> Design seu sistema esperando que o modelo demore pra carregar.</p>
</li>
<li>
<p><strong>Network volumes são obrigatórios</strong> pra modelos grandes. Não bake pesos no Docker, não faça download no boot.</p>
</li>
<li>
<p><strong>Parâmetros de sampling não são universais.</strong> Cada modo de operação do mesmo modelo pode precisar de configuração completamente diferente.</p>
</li>
<li>
<p><strong>A qualidade da entrada domina a qualidade da saída.</strong> Tanto pro áudio de referência do TTS quanto pro script. Se a referência corta a primeira sílaba, toda geração corta. Se o script é genérico, o áudio é genérico.</p>
</li>
<li>
<p><strong>Timeouts em todas as camadas.</strong> Token limit, process timeout, HTTP timeout. Se não limitar, o custo escala e a geração trava.</p>
</li>
<li>
<p><strong>Pós-processamento é metade do pipeline.</strong> O modelo gera matéria-prima. Normalização, encoding, metadata — tudo isso é trabalho real.</p>
</li>
<li>
<p><strong>Separe o servidor de ML da aplicação.</strong> Python pra inference, Ruby pra orquestração. Tentar fazer tudo numa linguagem é receita pra frustração.</p>
</li>
<li>
<p><strong>Prompts precisam de duas passadas pra conteúdo complexo.</strong> Estruturar e naturalizar são tarefas diferentes. Forçar as duas numa passada produz resultado medíocre em ambas.</p>
</li>
<li>
<p><strong>O LLM vai homogeneizar sua voz.</strong> Opiniões fortes viram &ldquo;nuanced takes&rdquo; se você não for explícito sobre preservá-las. Instrua o modelo a mudar a embalagem, nunca o conteúdo.</p>
</li>
</ol>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>IA em produção não é um tutorial de HuggingFace com 10 linhas. É infraestrutura, é experimentação, é debugging de problemas que não existem em software tradicional. Mas quando funciona — quando seu sistema gera um podcast inteiro automaticamente toda semana, com qualidade broadcast, sem intervenção humana — a sensação é que valeu cada hora de dor de cabeça.</p>
<p>O truque é não subestimar a complexidade. E ter um sistema robusto (como os jobs do Rails que mencionei no primeiro post) orquestrando tudo — porque IA sem orquestração confiável é só um script caro que trava.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>midia-generativa</category><category>cloud</category></item><item><title>Web Scrapping em 2026 | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/18/web-scrapping-em-2026-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/18/web-scrapping-em-2026-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Wed, 18 Feb 2026 14:57:22 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 2.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Dez anos atrás, buscar dados em um site era trivial. Quatro linhas de Ruby: &lt;code&gt;Net::HTTP.get&lt;/code&gt;, Nokogiri parse, CSS selector, pronto. O mundo inteiro estava aberto pra qualquer script com um User-Agent decente.&lt;/p&gt;
&lt;p&gt;Hoje? Metade dos sites que preciso ler pra montar minha newsletter retorna &amp;ldquo;Are you a robot?&amp;rdquo; pra qualquer requisição HTTP convencional. A outra metade renderiza o conteúdo inteiramente em JavaScript — não há HTML nenhum na resposta do servidor. E os mais paranoicos combinam as duas coisas.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 2.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Dez anos atrás, buscar dados em um site era trivial. Quatro linhas de Ruby: <code>Net::HTTP.get</code>, Nokogiri parse, CSS selector, pronto. O mundo inteiro estava aberto pra qualquer script com um User-Agent decente.</p>
<p>Hoje? Metade dos sites que preciso ler pra montar minha newsletter retorna &ldquo;Are you a robot?&rdquo; pra qualquer requisição HTTP convencional. A outra metade renderiza o conteúdo inteiramente em JavaScript — não há HTML nenhum na resposta do servidor. E os mais paranoicos combinam as duas coisas.</p>
<p>Meu objetivo era criar uma rotina diária pro M.arvin me devolver as principais notícias pela manhã no meu Discord. Assim eu não preciso sair caçando as notícias do dia. Parecia simples… no começo.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_11-59-42.jpg" alt="daily routine"  loading="lazy" /></p>
<p>Este post é sobre como passei de &ldquo;uma chamada HTTP&rdquo; pra &ldquo;empacotar um Chromium inteiro no Docker&rdquo; — e por que isso é a realidade de qualquer projeto que precisa ler a web programaticamente em 2026.</p>
<h2>A Idade de Ouro: HTTP Simples<span class="hx:absolute hx:-mt-20" id="a-idade-de-ouro-http-simples"></span>
    <a href="#a-idade-de-ouro-http-simples" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vamos começar pelo que ainda funciona. Alguns serviços oferecem dados estruturados sem nenhuma resistência:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># RSS feeds: o formato mais resiliente da web</span>
</span></span><span class="line"><span class="cl"><span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="p">(</span><span class="s2">&#34;https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com/world&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">xml</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">uri</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span> <span class="o">=</span> <span class="no">REXML</span><span class="o">::</span><span class="no">Document</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">xml</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span><span class="o">.</span><span class="n">get_elements</span><span class="p">(</span><span class="s2">&#34;//item&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">title</span> <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="n">url</span>   <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;link&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># APIs públicas: JSON limpo, documentado, estável</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;https://api.jikan.moe/v4/top/anime?filter=airing&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">animes</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">parsed_response</span><span class="o">[</span><span class="s2">&#34;data&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># GitHub trending: HTML server-rendered sem proteção</span>
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;https://github.com/trending&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">body</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">HTML</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">repos</span> <span class="o">=</span> <span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="s2">&#34;article.Box-row&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Hacker News, GitHub Trending, APIs de anime, feeds RSS, Project Gutenberg — todos funcionam com um <code>GET</code> e um parser. Sem JavaScript, sem autenticação implícita, sem fingerprinting. Assim como a web deveria ser.</p>
<p>O problema é que esses são a exceção. Os sites que têm dados financeiros, notícias exclusivas ou conteúdo premium — exatamente os dados que mais importam — estão cada vez mais blindados.</p>
<h2>O Primeiro Obstáculo: SPAs e JavaScript Rendering<span class="hx:absolute hx:-mt-20" id="o-primeiro-obstáculo-spas-e-javascript-rendering"></span>
    <a href="#o-primeiro-obst%c3%a1culo-spas-e-javascript-rendering" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quando o servidor retorna algo tipo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">html</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;root&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/app-3fa8c2.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">html</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Seu <code>Net::HTTP.get</code> retorna exatamente isso: uma div vazia e um link pra um bundle de 2 MB. O conteúdo real — artigos, preços, listas — é montado no browser por React, Vue, Angular ou o framework da semana.</p>
<p>Pra um script, essa página é inútil. Pra extrair o conteúdo, você precisa executar o JavaScript. Isso significa rodar um browser real.</p>
<p>No mundo Ruby, a resposta é a gem <code>ferrum</code> — um cliente CDP (Chrome DevTools Protocol) que controla o Chromium programaticamente:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">browser</span> <span class="o">=</span> <span class="no">Ferrum</span><span class="o">::</span><span class="no">Browser</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">headless</span><span class="p">:</span> <span class="s2">&#34;new&#34;</span><span class="p">,</span> <span class="ss">timeout</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="s2">&#34;https://www.morningstar.com/markets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">network</span><span class="o">.</span><span class="n">wait_for_idle</span>  <span class="c1"># espera o JS terminar de carregar</span>
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="n">browser</span><span class="o">.</span><span class="n">body</span>  <span class="c1"># agora sim o DOM está populado</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">quit</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Esse <code>browser.body</code> retorna o HTML <strong>depois</strong> que o JavaScript executou. Mesmos dados que você vê no browser, agora acessíveis pro seu parser.</p>
<p>Mas aqui começa a dor de cabeça: você precisa de um binário do Chromium disponível no sistema. No Docker, isso significa adicionar ~300MB à imagem. No servidor, significa mais uma dependência pra gerenciar. E a latência de abrir um browser, navegar, esperar JS renderizar, e extrair o HTML — estamos falando de 3 a 10 segundos por página, ao invés dos 200ms de um HTTP GET.</p>
<p>O projeto usa isso como estratégia principal no <code>ArticleFetcher</code>: primeiro tenta com browser (pra pegar SPAs), depois faz um fallback HTTP simples (pra pegar metadata OpenGraph quando o browser falha). A maioria dos links que os leitores mandam no Discord são de sites que renderizam com JavaScript, então o browser-first compensa.</p>
<h2>O Segundo Obstáculo: Bot Detection<span class="hx:absolute hx:-mt-20" id="o-segundo-obstáculo-bot-detection"></span>
    <a href="#o-segundo-obst%c3%a1culo-bot-detection" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Abrir uma página com Chromium headless resolve o problema de JavaScript. Mas resolve 50% dos sites — não 100%. Porque existe uma indústria inteira dedicada a distinguir browsers reais de browsers automatizados.</p>
<h3>O Que o Headless Chrome Revela<span class="hx:absolute hx:-mt-20" id="o-que-o-headless-chrome-revela"></span>
    <a href="#o-que-o-headless-chrome-revela" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Chromium em modo headless é quase idêntico ao Chrome normal. Quase. As diferenças são sutis, mas suficientes pra detecção:</p>
<p><strong>1. <code>navigator.webdriver</code></strong> — Em todo Chrome controlado por automação, essa propriedade retorna <code>true</code>. Em browsers normais, <code>undefined</code>. É o flag mais óbvio.</p>
<p><strong>2. <code>navigator.plugins</code></strong> — Chrome normal tem 3-5 plugins (PDF viewer, etc.). Headless retorna array vazio. Bot detectado.</p>
<p><strong>3. <code>navigator.userAgentData</code></strong> — A Client Hints API. Chrome real retorna brands, platform, mobile flag. Headless pode não ter isso.</p>
<p><strong>4. WebGL fingerprint</strong> — O renderer WebGL num headless Chrome retorna &ldquo;SwiftShader&rdquo; (renderizador por software). Chrome real retorna &ldquo;NVIDIA GeForce&rdquo; ou &ldquo;Intel Iris&rdquo; ou o que for. Bot detectado.</p>
<p><strong>5. Timing e behavior</strong> — Browsers reais demoram pra carregar. Fazem scroll. Movem o mouse. Têm focus events. Um script que faz <code>go_to</code> → <code>body</code> em 2 segundos não se parece com um humano.</p>
<p>A solução é o que chamo de StealthBrowser — um wrapper que injeta patches JavaScript antes de qualquer script da página rodar:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">StealthBrowser</span>
</span></span><span class="line"><span class="cl">  <span class="no">STEALTH_JS</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">JS</span>
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Remove</span> <span class="n">o</span> <span class="n">flag</span> <span class="n">de</span> <span class="n">automação</span>
</span></span><span class="line"><span class="cl">    <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;webdriver&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kp">false</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Simula</span> <span class="n">plugins</span> <span class="n">reais</span>
</span></span><span class="line"><span class="cl">    <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;plugins&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Chrome PDF Plugin&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;internal-pdf-viewer&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Chrome PDF Viewer&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;mhjfbmdgcfjbbpaeojofohoefgiehjai&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Native Client&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;internal-nacl-plugin&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Simula</span> <span class="no">WebGL</span> <span class="n">renderer</span> <span class="n">real</span>
</span></span><span class="line"><span class="cl">    <span class="n">const</span> <span class="n">getParameter</span> <span class="o">=</span> <span class="no">WebGLRenderingContext</span><span class="o">.</span><span class="n">prototype</span><span class="o">.</span><span class="n">getParameter</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="no">WebGLRenderingContext</span><span class="o">.</span><span class="n">prototype</span><span class="o">.</span><span class="n">getParameter</span> <span class="o">=</span> <span class="n">function</span><span class="p">(</span><span class="nb">p</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nb">p</span> <span class="o">===</span> <span class="mi">37445</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;Google Inc. (NVIDIA)&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nb">p</span> <span class="o">===</span> <span class="mi">37446</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;ANGLE (NVIDIA, GeForce GTX 1080 ...)&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="n">getParameter</span><span class="o">.</span><span class="n">call</span><span class="p">(</span><span class="n">this</span><span class="p">,</span> <span class="nb">p</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Client</span> <span class="no">Hints</span> <span class="no">API</span> <span class="p">(</span><span class="n">ausente</span> <span class="n">em</span> <span class="n">headless</span> <span class="o">=</span> <span class="n">bot</span> <span class="n">signal</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">navigator</span><span class="o">.</span><span class="n">userAgentData</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;userAgentData&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">          <span class="ss">brands</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span> <span class="ss">brand</span><span class="p">:</span> <span class="s1">&#39;Chromium&#39;</span><span class="p">,</span> <span class="ss">version</span><span class="p">:</span> <span class="s1">&#39;131&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span> <span class="ss">brand</span><span class="p">:</span> <span class="s1">&#39;Google Chrome&#39;</span><span class="p">,</span> <span class="ss">version</span><span class="p">:</span> <span class="s1">&#39;131&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">          <span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">mobile</span><span class="p">:</span> <span class="kp">false</span><span class="p">,</span> <span class="ss">platform</span><span class="p">:</span> <span class="s1">&#39;Linux&#39;</span>
</span></span><span class="line"><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="no">JS</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="nb">name</span><span class="p">:,</span> <span class="ss">wait_for</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span> <span class="o">=</span> <span class="no">Ferrum</span><span class="o">::</span><span class="no">Browser</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">headless</span><span class="p">:</span> <span class="s2">&#34;new&#34;</span><span class="p">,</span> <span class="ss">timeout</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">browser_options</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;disable-blink-features&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;AutomationControlled&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;user-agent&#34;</span> <span class="o">=&gt;</span> <span class="no">REAL_USER_AGENT</span>
</span></span><span class="line"><span class="cl">      <span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Injeta ANTES de qualquer script da página rodar</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">.</span><span class="n">page</span><span class="o">.</span><span class="n">command</span><span class="p">(</span><span class="s2">&#34;Page.addScriptToEvaluateOnNewDocument&#34;</span><span class="p">,</span> <span class="ss">source</span><span class="p">:</span> <span class="no">STEALTH_JS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">yield</span> <span class="n">browser</span>
</span></span><span class="line"><span class="cl">  <span class="k">ensure</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">&amp;.</span><span class="n">quit</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O truque-chave é <code>Page.addScriptToEvaluateOnNewDocument</code> — um comando CDP que registra o JavaScript pra ser executado <strong>antes</strong> de qualquer script da página. Isso significa que, quando o bot detector roda, ele vê os valores falsos que injetamos, e não os reais. E <code>disable-blink-features=AutomationControlled</code> remove o flag <code>navigator.webdriver</code> no nível do browser, antes mesmo do JavaScript.</p>
<p>Isso funciona pra maioria dos sites. Morningstar, Hindenburg Research, sites de notícias genéricos — passam com o stealth. Yahoo Finance precisa de mais — TLS fingerprinting que bloqueia até o Chromium sem crumb authentication. Mas o browser stealth resolve o grosso.</p>
<h2>O Terceiro Obstáculo: DataDome e CDPs de Anti-Bot<span class="hx:absolute hx:-mt-20" id="o-terceiro-obstáculo-datadome-e-cdps-de-anti-bot"></span>
    <a href="#o-terceiro-obst%c3%a1culo-datadome-e-cdps-de-anti-bot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>E aí chegamos aos profissionais. <strong>DataDome</strong>, Cloudflare Turnstile, PerimeterX, Akamai Bot Manager — são serviços especializados em detectar automação. E eles jogam num nível completamente diferente.</p>
<p>A Reuters, por exemplo, usa DataDome. Quando tentei acessar <code>reuters.com/world/</code> com o StealthBrowser, recebi de volta uma página de 1.500 bytes com um captcha JavaScript. Todos os patches de <code>navigator.webdriver</code>, WebGL, plugins — inúteis.</p>
<p>Por quê? Porque o DataDome não checa só propriedades JavaScript. Ele detecta o <strong>próprio protocolo de automação</strong>.</p>
<h3>O Problema Fundamental do CDP<span class="hx:absolute hx:-mt-20" id="o-problema-fundamental-do-cdp"></span>
    <a href="#o-problema-fundamental-do-cdp" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Quando o <strong>Ferrum</strong> (ou Puppeteer, ou Playwright) controla um Chrome, ele faz isso via Chrome DevTools Protocol — o CDP. E a primeira coisa que o Ferrum faz ao conectar é enviar <code>Runtime.enable</code> — um comando que ativa o runtime evaluation no browser.</p>
<p>O problema é que <code>Runtime.enable</code> muda o comportamento observável do browser. Especificamente, ele ativa interceptors internos que podem ser detectados via <code>console.debug</code>. O DataDome usa isso: ele faz um <code>console.debug</code> com um getter que observa se o runtime está interceptando. Se está — e com CDP sempre está — bot detectado.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// O que o DataDome faz (simplificado):
</span></span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">detected</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nb">Object</span><span class="p">.</span><span class="nx">defineProperty</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="s1">&#39;id&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">get</span><span class="o">:</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">detected</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span> <span class="k">return</span> <span class="s1">&#39;test&#39;</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">debug</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// Se Runtime.enable está ativo, o getter é trigado
</span></span></span><span class="line"><span class="cl"><span class="c1">// Em um browser normal, não é
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso não é algo que você pode patchar com JavaScript. O <code>Runtime.enable</code> é mandado pelo Ferrum <strong>antes</strong> de qualquer script rodar — é parte do protocolo de automação. Pra resolver, você precisaria modificar o Ferrum pra não mandar esse comando, o que quebraria a maioria das funcionalidades da gem.</p>
<h3>As Alternativas (e Por Que Nenhuma é Ideal)<span class="hx:absolute hx:-mt-20" id="as-alternativas-e-por-que-nenhuma-é-ideal"></span>
    <a href="#as-alternativas-e-por-que-nenhuma-%c3%a9-ideal" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Pesquisei extensivamente. As opções:</p>
<ol>
<li>
<p><strong>nodriver (Python)</strong>: Usa Chrome via CDP sem <code>Runtime.enable</code>. Não tem equivalente em Ruby. E mesmo assim, está numa corrida de gato-e-rato com DataDome.</p>
</li>
<li>
<p><strong>curl-impersonate</strong>: Faz TLS fingerprinting idêntico ao Chrome sem abrir um browser. Bom pra sites com TLS detection, inútil pra SPAs.</p>
</li>
<li>
<p><strong>Scraping APIs</strong> (ScraperAPI, ZenRows): Terceirizam o problema. Funcionam, mas custam dinheiro e adicionam latência e dependência.</p>
</li>
<li>
<p><strong>Fork do Ferrum</strong>: Modificar a gem pra opcionalmente não mandar <code>Runtime.enable</code>. Viável tecnicamente, mas manutenção infinita.</p>
</li>
<li>
<p><strong>Google News RSS</strong>: O Google já leu a Reuters. O resultado tá num feed RSS público. Sem JavaScript, sem DataDome, sem browser.</p>
</li>
</ol>
<p>A opção 5 venceu. Pra Reuters, troquei toda a complexidade de headless browser por:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ReutersClient</span>
</span></span><span class="line"><span class="cl">  <span class="no">RSS_URL</span> <span class="o">=</span> <span class="s2">&#34;https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com/world&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch_headlines</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">xml</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="no">URI</span><span class="p">(</span><span class="no">RSS_URL</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="no">REXML</span><span class="o">::</span><span class="no">Document</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">xml</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span><span class="o">.</span><span class="n">get_elements</span><span class="p">(</span><span class="s2">&#34;//item&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span><span class="o">.</span><span class="n">filter_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">title</span> <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">      <span class="n">url</span>   <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;link&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">      <span class="k">next</span> <span class="k">if</span> <span class="n">title</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">url</span><span class="o">.</span><span class="n">blank?</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\s*-\s*Reuters\s*\z/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">),</span> <span class="ss">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="ss">category</span><span class="p">:</span> <span class="kp">nil</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;ReutersClient failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Simples. Confiável. Zero dependências de browser. O link do Google News redireciona pro artigo original quando o leitor clica — funciona perfeitamente pra uma newsletter.</p>
<p>Nem todo problema de scraping precisa de solução de scraping. Às vezes o dado já está disponível num formato acessível — você só precisa achar.</p>
<h2>O Quarto Obstáculo: Sites que Mudam de Estrutura (Diariamente)<span class="hx:absolute hx:-mt-20" id="o-quarto-obstáculo-sites-que-mudam-de-estrutura-diariamente"></span>
    <a href="#o-quarto-obst%c3%a1culo-sites-que-mudam-de-estrutura-diariamente" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Mesmo quando o scraping funciona, a manutenção nunca para. O Morningstar é um caso didático — porque me fez errar <strong>duas vezes</strong> antes de chegar numa solução resiliente.</p>
<h3>Tentativa 1: Selecionar pela Hierarquia DOM<span class="hx:absolute hx:-mt-20" id="tentativa-1-selecionar-pela-hierarquia-dom"></span>
    <a href="#tentativa-1-selecionar-pela-hierarquia-dom" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Originalmente, os links de artigos estavam dentro de <code>&lt;h2&gt;</code> e <code>&lt;h3&gt;</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">h2</span><span class="p">&gt;&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/markets/article-slug&#34;</span><span class="p">&gt;</span>Título do Artigo<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;&lt;/</span><span class="nt">h2</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Um dia depois: zero resultados. O Morningstar mudou a estrutura pra flat links com classes CSS específicas — sem heading elements:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">a</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;mdc-basic-feed-item__mdc&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/markets/article-slug&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">header</span><span class="p">&gt;&lt;</span><span class="nt">h3</span><span class="p">&gt;</span>Título do Artigo<span class="p">&lt;/</span><span class="nt">h3</span><span class="p">&gt;&lt;/</span><span class="nt">header</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;mdc-metadata__mdc&#34;</span><span class="p">&gt;</span>Autor · Data<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Tentativa 2: Selecionar pela Classe CSS<span class="hx:absolute hx:-mt-20" id="tentativa-2-selecionar-pela-classe-css"></span>
    <a href="#tentativa-2-selecionar-pela-classe-css" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A correção óbvia: trocar hierarquia DOM por seletores CSS baseados na classe do componente:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">ARTICLE_CSS</span> <span class="o">=</span> <span class="s2">&#34;a.mdc-basic-feed-item__mdc, a.mdc-card--link__mdc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="no">ARTICLE_CSS</span><span class="p">)</span><span class="o">.</span><span class="n">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">link</span><span class="o">|</span> <span class="o">...</span> <span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Funcionou. Por exatamente um dia. Depois: zero resultados de novo. O Morningstar — como muitos sites modernos com frameworks CSS-in-JS — gera nomes de classe dinamicamente. O que era <code>mdc-basic-feed-item__mdc</code> virou outra coisa. E pode virar algo diferente amanhã.</p>
<p>Isso é cada vez mais comum. Sites que usam CSS Modules, styled-components, ou Tailwind com JIT geram hashes aleatórios nos nomes de classe como parte do build. A cada deploy do site, as classes mudam. Seu seletor hardcoded é uma bomba-relógio.</p>
<h3>Tentativa 3: Não Depender de Nenhum Nome de Classe<span class="hx:absolute hx:-mt-20" id="tentativa-3-não-depender-de-nenhum-nome-de-classe"></span>
    <a href="#tentativa-3-n%c3%a3o-depender-de-nenhum-nome-de-classe" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A solução final é inverter a lógica: ao invés de dizer ao parser <strong>quais</strong> classes procurar, deixar o parser <strong>descobrir</strong> quais links são artigos baseado em propriedades estruturais que não mudam:</p>
<ol>
<li><strong>Links de artigos têm URLs longas</strong>: <code>/news/why-stock-market-is-up-today-2026-02-17</code> vs links de navegação como <code>/markets</code> ou <code>/tools</code></li>
<li><strong>Links de artigos são agrupados</strong>: aparecem em listas, feeds, grids — múltiplos links com a mesma classe CSS</li>
<li><strong>Links de artigos têm títulos descritivos</strong>: textos longos, não labels curtas como &ldquo;Markets&rdquo; ou &ldquo;Tools&rdquo;</li>
</ol>
<p>O algoritmo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">parse_html</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">HTML</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 1. Coleta todos os &lt;a&gt; internos com href, classe, e texto suficiente</span>
</span></span><span class="line"><span class="cl">  <span class="n">candidates</span> <span class="o">=</span> <span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="s2">&#34;a[href]&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">link</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">href</span> <span class="o">=</span> <span class="n">link</span><span class="o">[</span><span class="s2">&#34;href&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">    <span class="n">css_class</span> <span class="o">=</span> <span class="n">link</span><span class="o">[</span><span class="s2">&#34;class&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">href</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">css_class</span><span class="o">.</span><span class="n">blank?</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">unless</span> <span class="n">internal_href?</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">extract_path</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">segments</span> <span class="o">=</span> <span class="n">path</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;/&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">segments</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;</span> <span class="mi">2</span>  <span class="c1"># links de nav têm 1 segmento: /markets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">title</span> <span class="o">=</span> <span class="n">extract_title</span><span class="p">(</span><span class="n">link</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">title</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">title</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;</span> <span class="mi">10</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span> <span class="ss">url</span><span class="p">:</span> <span class="n">absolutize</span><span class="p">(</span><span class="n">href</span><span class="p">),</span> <span class="ss">css_class</span><span class="p">:</span> <span class="n">css_class</span><span class="p">,</span> <span class="ss">slug_length</span><span class="p">:</span> <span class="n">path</span><span class="o">.</span><span class="n">length</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 2. Agrupa por classe CSS</span>
</span></span><span class="line"><span class="cl">  <span class="n">groups</span> <span class="o">=</span> <span class="n">candidates</span><span class="o">.</span><span class="n">group_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">c</span><span class="o">[</span><span class="ss">:css_class</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 3. Filtra: mínimo 3 links no grupo (clusters de artigos, não links soltos)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 4. Seleciona o grupo com maior média de comprimento de slug</span>
</span></span><span class="line"><span class="cl">  <span class="n">best</span> <span class="o">=</span> <span class="n">groups</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">_cls</span><span class="p">,</span> <span class="n">items</span><span class="o">|</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;=</span> <span class="mi">3</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">max_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">_cls</span><span class="p">,</span> <span class="n">items</span><span class="o">|</span> <span class="n">items</span><span class="o">.</span><span class="n">sum</span> <span class="p">{</span> <span class="o">|</span><span class="n">i</span><span class="o">|</span> <span class="n">i</span><span class="o">[</span><span class="ss">:slug_length</span><span class="o">]</span> <span class="p">}</span><span class="o">.</span><span class="n">to_f</span> <span class="o">/</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">best</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">best</span><span class="o">[</span><span class="mi">1</span><span class="o">].</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">c</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="ss">:title</span><span class="p">,</span> <span class="ss">:url</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="n">uniq</span> <span class="p">{</span> <span class="o">|</span><span class="n">a</span><span class="o">|</span> <span class="n">a</span><span class="o">[</span><span class="ss">:url</span><span class="o">]</span> <span class="p">}</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O que salva essa abordagem: <strong>nenhum nome de classe é hardcoded</strong>. O Morningstar pode renomear todas as classes amanhã e o parser vai continuar funcionando — porque ele descobre o padrão de artigos pela estrutura, não pelo nome.</p>
<p>O que é estável num site:</p>
<ul>
<li><strong>URLs de artigos são sempre mais longas</strong> que URLs de navegação (isso é arquitetural, não cosmético)</li>
<li><strong>Artigos são sempre agrupados</strong> sob o mesmo componente (isso é UX, não implementação)</li>
<li><strong>Títulos de artigos são descritivos</strong> (isso é editorial, não técnico)</li>
</ul>
<p>O que <strong>não</strong> é estável:</p>
<ul>
<li>Nomes de classes CSS (mudam a cada build)</li>
<li>Hierarquia DOM (muda a cada redesign)</li>
<li>Atributos <code>data-*</code> (mudam conforme a equipe de frontend decide)</li>
</ul>
<p>Resumindo: não tente identificar elementos pelo nome — identifique pelo comportamento. Links de artigos se comportam diferente de links de navegação de formas mensuráveis (URL length, clustering, título), e essas propriedades são inerentes ao que o elemento É, não a como ele foi implementado.</p>
<h2>A Hierarquia de Confiabilidade<span class="hx:absolute hx:-mt-20" id="a-hierarquia-de-confiabilidade"></span>
    <a href="#a-hierarquia-de-confiabilidade" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Depois de horas quebrando a cabeça, ficou óbvia uma hierarquia de fontes de dados:</p>
<table>
  <thead>
      <tr>
          <th>Fonte</th>
          <th>Confiabilidade</th>
          <th>Latência</th>
          <th>Manutenção</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>API oficial (JSON)</td>
          <td>Alta</td>
          <td>~200ms</td>
          <td>Baixa</td>
      </tr>
      <tr>
          <td>RSS/Atom feed</td>
          <td>Alta</td>
          <td>~300ms</td>
          <td>Baixa</td>
      </tr>
      <tr>
          <td>HTML server-rendered</td>
          <td>Média</td>
          <td>~500ms</td>
          <td>Média</td>
      </tr>
      <tr>
          <td>SPA com headless browser</td>
          <td>Baixa-Média</td>
          <td>~5s</td>
          <td>Alta</td>
      </tr>
      <tr>
          <td>Site com anti-bot ativo</td>
          <td>Baixa</td>
          <td>~8s+</td>
          <td>Altíssima</td>
      </tr>
  </tbody>
</table>
<p>A regra: <strong>sempre prefira a fonte mais simples que funcione</strong>. Se o site tem RSS, use RSS. Se tem API, use a API. Só vá pro headless browser quando não há alternativa.</p>
<p>No projeto, a distribuição ficou assim:</p>
<p><strong>HTTP simples</strong> (Net::HTTP ou HTTParty):</p>
<ul>
<li>Google News RSS (Reuters world headlines)</li>
<li>Hacker News RSS (top stories)</li>
<li>HackerNoon RSS (top stories)</li>
<li>dev.to API (trending articles)</li>
<li>Jikan API (anime rankings)</li>
<li>YouTube Data API (vídeos)</li>
<li>Yahoo Finance news RSS</li>
</ul>
<p><strong>Headless browser</strong> (StealthBrowser + Ferrum):</p>
<ul>
<li>Morningstar (SPA + componentes dinâmicos)</li>
<li>Yahoo Finance charts (TLS fingerprinting + crumb auth)</li>
<li>FinViz market map (canvas rendering)</li>
<li>Artigos genéricos de leitores (qualquer URL pode ser SPA)</li>
<li>Hindenburg Research (JavaScript-rendered)</li>
</ul>
<p>Ou seja: <strong>a maioria das fontes de dados funciona sem browser</strong>. O browser é necessário pra meia dúzia de sites problemáticos — mas são esses sites que dão mais trabalho.</p>
<h2>O Custo Real<span class="hx:absolute hx:-mt-20" id="o-custo-real"></span>
    <a href="#o-custo-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vamos ser práticos sobre o que &ldquo;empacotar um Chrome&rdquo; significa:</p>
<p><strong>Imagem Docker</strong>: +300MB. O Chromium e suas dependências (fontes, libs gráficas, sandbox) são enormes. Num Dockerfile otimizado com multi-stage build:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Instala Chromium e dependências mínimas</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y chromium fonts-liberation libnss3 libatk-bridge2.0-0 <span class="se">\
</span></span></span><span class="line"><span class="cl">    libdrm2 libxkbcommon0 libgbm1</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Memória</strong>: Cada instância de Chromium consome 200-400MB de RAM. Se você tem 3 workers paralelos, são 1.2GB só pra browsers. Num VPS de 2GB, isso é 60% da memória.</p>
<p><strong>Latência</strong>: Uma página que leva 200ms via HTTP leva 3-8 segundos via headless browser (boot do processo, navegação, JS execution, network idle). Pra 10 fontes de dados, são 30-80 segundos ao invés de 2.</p>
<p><strong>Estabilidade</strong>: Chromium pode crashar, travar, ou vazar memória. Timeout de 30 segundos com cleanup no <code>ensure</code> é obrigatório:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="nb">name</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="nb">name</span><span class="p">:</span> <span class="nb">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">yield</span> <span class="n">browser</span>
</span></span><span class="line"><span class="cl"><span class="k">ensure</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span><span class="o">&amp;.</span><span class="n">quit</span>  <span class="c1"># SEMPRE quit, mesmo com exceção</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Se não fizer o <code>quit</code>, o processo Chromium fica pendurado consumindo CPU e memória. Já tive servidor com 15 processos Chrome zumbis comendo 3GB de RAM porque um <code>ensure</code> estava faltando.</p>
<h2>O Futuro (Ou: Vai Piorar)<span class="hx:absolute hx:-mt-20" id="o-futuro-ou-vai-piorar"></span>
    <a href="#o-futuro-ou-vai-piorar" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A tendência é clara: sites estão ficando mais difíceis de acessar programaticamente. A cada trimestre, os DataDomes e Cloudflares da vida adicionam novas técnicas de detecção. Chrome headless fica mais parecido com Chrome real — mas os detectores acompanham.</p>
<p>A web que nasceu aberta e legível por máquina está se tornando um jardim murado. Até RSS, que é trivial de implementar e útil pra todo mundo, está sendo abandonado por muitos sites — porque não querem que seus dados sejam lidos fora do contexto (e dos anúncios) do site original.</p>
<p>Pra quem constrói sistemas que agregam informação, o playbook é:</p>
<ol>
<li><strong>Use APIs e feeds sempre que existirem</strong>. É mais estável, mais rápido, mais respeitoso</li>
<li><strong>Mantenha um browser stealth pra SPAs</strong> que não têm alternativa</li>
<li><strong>Nunca hardcode seletores CSS</strong>. Sites com CSS-in-JS mudam nomes de classe a cada deploy. Identifique elementos por propriedades estruturais (comprimento de URL, clustering, tamanho do título) que sobrevivem a redesigns</li>
<li><strong>Detecte bloqueios e falhe graciosamente</strong> — <code>og:description</code> como fallback, título extraído da URL como último recurso</li>
<li><strong>Nunca dependa de um único método de acesso</strong>. O site que funciona hoje pode bloquear amanhã</li>
<li><strong>Aceite que alguns sites são inacessíveis</strong>. Se o DataDome não deixa, o dado terá que vir de outra fonte</li>
</ol>
<p>A web em 2026 é um campo de batalha entre quem quer ler e quem quer controlar a leitura. Mas no longo prazo, a criatividade de quem quer acessar dados vence — mas custa mais esforço a cada ano. E, diferente do que eu pensei no começo: nunca é simples.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>desenvolvimento-web</category><category>automacao</category></item><item><title>Enviando Emails sem Spammar | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/17/enviando-emails-sem-spammar-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/17/enviando-emails-sem-spammar-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Tue, 17 Feb 2026 20:59:48 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. Esta é a parte 1.&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Enviar emails parece trivial. Chama uma API, passa o HTML, manda. Pronto. Até o dia em que você descobre que enviou a mesma newsletter duas vezes pra 300 pessoas. Ou que 50 e-mails voltaram como bounce e a Amazon suspendeu sua conta. Ou que seu servidor reiniciou no meio do envio e agora você não sabe quem recebeu e quem não recebeu.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">/themakitachronicles</a>. Esta é a parte 1.</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Enviar emails parece trivial. Chama uma API, passa o HTML, manda. Pronto. Até o dia em que você descobre que enviou a mesma newsletter duas vezes pra 300 pessoas. Ou que 50 e-mails voltaram como bounce e a Amazon suspendeu sua conta. Ou que seu servidor reiniciou no meio do envio e agora você não sabe quem recebeu e quem não recebeu.</p>
<p>O Amazon SES é eficiente — e brutalmente impiedoso com quem não trata cada envio como uma operação que pode falhar em cinco formas. Neste post vou mostrar os padrões que transformam &ldquo;mandar email&rdquo; em &ldquo;mandar email de forma confiável&rdquo;.</p>
<h2>Por Que SES e Não Sendgrid/Mailgun/Resend?<span class="hx:absolute hx:-mt-20" id="por-que-ses-e-não-sendgridmailgunresend"></span>
    <a href="#por-que-ses-e-n%c3%a3o-sendgridmailgunresend" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Preço. Ponto. O SES cobra $0.10 por mil e-mails. Sendgrid cobra $20/mês por 50 mil. Pra uma newsletter pequena, os dois são baratos. Mas o SES escala sem mudar de plano — e como já estou na AWS pra S3, não preciso de mais um vendor.</p>
<p>A desvantagem? SES é low-level. Não tem interface bonita de drag-and-drop, não tem analytics integrado, não tem IP dedicado no plano free. Você ganha uma API que aceita HTML e retorna um <code>message_id</code>. O resto é problema seu.</p>
<p>E é exatamente aí que mora o perigo.</p>
<h2>O Problema Fundamental: At-Most-Once vs At-Least-Once<span class="hx:absolute hx:-mt-20" id="o-problema-fundamental-at-most-once-vs-at-least-once"></span>
    <a href="#o-problema-fundamental-at-most-once-vs-at-least-once" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quando um serviço externo aceita sua requisição, mas o seu processo morre antes de registrar o sucesso, você tem um dilema:</p>
<ol>
<li><strong>Reenviar</strong>: o assinante pode receber duas vezes (at-least-once)</li>
<li><strong>Não reenviar</strong>: o assinante pode não receber (at-most-once)</li>
</ol>
<p>Pra transações financeiras, at-least-once com deduplicação é o padrão. Pra email, <strong>at-most-once é preferível</strong>. Ninguém reclama de não receber uma newsletter. Todo mundo reclama de receber duas.</p>
<p>Isso define toda a arquitetura: <strong>na dúvida, não reenvie</strong>.</p>
<h2>Padrão 1: Um Registro Por Destinatário<span class="hx:absolute hx:-mt-20" id="padrão-1-um-registro-por-destinatário"></span>
    <a href="#padr%c3%a3o-1-um-registro-por-destinat%c3%a1rio" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O erro mais comum em sistemas de newsletter é iterar uma lista de assinantes num loop:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># ERRADO — se o job morrer no email 247, re-execução reenvia 1-246</span>
</span></span><span class="line"><span class="cl"><span class="n">subscribers</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">SesMailer</span><span class="o">.</span><span class="n">send</span><span class="p">(</span><span class="ss">to</span><span class="p">:</span> <span class="nb">sub</span><span class="o">.</span><span class="n">email</span><span class="p">,</span> <span class="ss">body</span><span class="p">:</span> <span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A solução é criar um registro de entrega <strong>antes</strong> de enviar:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Cria registros em batch</span>
</span></span><span class="line"><span class="cl"><span class="n">rows</span> <span class="o">=</span> <span class="n">subscriber_ids</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="nb">id</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="ss">newsletter_id</span><span class="p">:</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="p">,</span> <span class="ss">subscriber_id</span><span class="p">:</span> <span class="nb">id</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;pending&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="no">EmailDelivery</span><span class="o">.</span><span class="n">insert_all</span><span class="p">(</span><span class="n">rows</span><span class="p">,</span> <span class="ss">unique_by</span><span class="p">:</span> <span class="o">[</span><span class="ss">:newsletter_id</span><span class="p">,</span> <span class="ss">:subscriber_id</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Enfileira um job individual por registro</span>
</span></span><span class="line"><span class="cl"><span class="no">EmailDelivery</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">newsletter</span><span class="p">:</span> <span class="n">newsletter</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;pending&#34;</span><span class="p">,</span> <span class="s2">&#34;failed&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">             <span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">delivery</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">SendSingleEmailJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada <code>EmailDelivery</code> é uma máquina de estados: <code>pending → sending → sent</code>. Se o job morrer, o registro fica em <code>sending</code> — e <strong>nunca</strong> é automaticamente reenviado.</p>
<p>O <code>unique_by</code> garante idempotência: rodar esse código duas vezes não cria registros duplicados.</p>
<h2>Padrão 2: Claiming Atômico<span class="hx:absolute hx:-mt-20" id="padrão-2-claiming-atômico"></span>
    <a href="#padr%c3%a3o-2-claiming-at%c3%b4mico" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Dois workers não podem enviar o mesmo email. O claim precisa ser atômico:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim_for_sending!</span>
</span></span><span class="line"><span class="cl">  <span class="n">updated</span> <span class="o">=</span> <span class="nb">self</span><span class="o">.</span><span class="n">class</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="nb">id</span><span class="p">,</span> <span class="ss">status</span><span class="p">:</span> <span class="sx">%w[pending failed]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">update_all</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">sending_started_at</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">send_attempts</span><span class="p">:</span> <span class="no">Arel</span><span class="o">.</span><span class="n">sql</span><span class="p">(</span><span class="s2">&#34;COALESCE(send_attempts, 0) + 1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">updated</span> <span class="o">==</span> <span class="mi">1</span>  <span class="c1"># true se ninguém pegou antes</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso é um UPDATE com WHERE — se dois workers tentam ao mesmo tempo, só um consegue o <code>updated == 1</code>. O outro recebe <code>0</code> e desiste. Sem locks explícitos, sem race conditions.</p>
<p>O <code>send_attempts</code> é incrementado atomicamente no banco, não em Ruby. Isso garante a contagem correta, mesmo com concorrência.</p>
<h2>Padrão 3: O Fluxo do SendSingleEmailJob<span class="hx:absolute hx:-mt-20" id="padrão-3-o-fluxo-do-sendsingleemailjob"></span>
    <a href="#padr%c3%a3o-3-o-fluxo-do-sendsingleemailjob" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui está o coração do sistema. O job de envio individual segue uma sequência deliberada:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SendSingleEmailJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:mailer</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">           <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">delivery_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ses_accepted</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span> <span class="o">=</span> <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">delivery_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 1. Validação: assinante ainda ativo?</span>
</span></span><span class="line"><span class="cl">    <span class="k">unless</span> <span class="n">subscriber_active?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">subscriber</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;Subscriber inactive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 2. Guardrail: nunca reenviar o que já foi</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="sx">%w[sent unknown bounced]</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">status</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 3. Claim atômico</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">delivery</span><span class="o">.</span><span class="n">claim_for_sending!</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 4. Renderiza e envia</span>
</span></span><span class="line"><span class="cl">    <span class="n">html</span> <span class="o">=</span> <span class="n">render_newsletter</span><span class="p">(</span><span class="n">delivery</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">message_id</span> <span class="o">=</span> <span class="n">ses_client</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span><span class="ss">to</span><span class="p">:</span> <span class="n">email</span><span class="p">,</span> <span class="ss">body</span><span class="p">:</span> <span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ses_accepted</span> <span class="o">=</span> <span class="kp">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 5. Marca sucesso</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span><span class="o">.</span><span class="n">mark_sent!</span><span class="p">(</span><span class="n">message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># SES rejeitou ANTES de aceitar — seguro retentar</span>
</span></span><span class="line"><span class="cl">    <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;Rate limited: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span>  <span class="c1"># ActiveJob retry_on cuida do reagendamento</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ses_accepted</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># SES ACEITOU mas algo quebrou depois — estado ambíguo</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_unknown!</span><span class="p">(</span><span class="s2">&#34;Uncertain: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">delivery</span><span class="o">&amp;.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Presta atenção na variável <code>ses_accepted</code>. Ela é o pivô de toda a lógica de erro:</p>
<ul>
<li><strong>SES rejeitou</strong> (<code>ses_accepted = false</code>): safe to retry. O email não foi aceito; pode tentar de novo.</li>
<li><strong>SES aceitou</strong> (<code>ses_accepted = true</code>): estado ambíguo. O email <em>provavelmente</em> foi enviado. Marcar como <code>unknown</code> e <strong>nunca</strong> retentar automaticamente.</li>
</ul>
<p>Essa distinção é o que impede a duplicação. A maioria dos sistemas trata tudo como &ldquo;failed&rdquo; e re-tenta — o que pode resultar em e-mails duplicados quando o SES aceitou, mas o seu processo crashou antes de registrar.</p>
<h2>Padrão 4: O Guardrail dos Status Imutáveis<span class="hx:absolute hx:-mt-20" id="padrão-4-o-guardrail-dos-status-imutáveis"></span>
    <a href="#padr%c3%a3o-4-o-guardrail-dos-status-imut%c3%a1veis" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Observe a linha:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">return</span> <span class="k">if</span> <span class="sx">%w[sent unknown bounced]</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">delivery</span><span class="o">.</span><span class="n">status</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso não é defensivo à toa. É uma regra de negócio: esses três estados são <strong>terminais</strong>. Uma vez que um delivery chega em <code>sent</code>, <code>unknown</code> ou <code>bounced</code>, nenhum código no sistema pode movê-lo automaticamente de volta pra <code>pending</code>.</p>
<p>A única forma de reenviar um email <code>unknown</code> é por meio de intervenção manual no console. E é assim que deve ser — se você não tem certeza de que o e-mail foi entregue, a decisão de reenviar é humana, não do sistema.</p>
<h2>Padrão 5: Recuperação de Deliveries Travados<span class="hx:absolute hx:-mt-20" id="padrão-5-recuperação-de-deliveries-travados"></span>
    <a href="#padr%c3%a3o-5-recupera%c3%a7%c3%a3o-de-deliveries-travados" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Se o worker crashar com um delivery em <code>sending</code>, ele fica preso nesse estado pra sempre. Um job periódico limpa isso:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RecoverStaleDeliveriesJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="no">STALE_AFTER</span> <span class="o">=</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">stale</span> <span class="o">=</span> <span class="no">EmailDelivery</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;sending&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                         <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;sending_started_at &lt; ?&#34;</span><span class="p">,</span> <span class="no">STALE_AFTER</span><span class="o">.</span><span class="n">ago</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">count</span> <span class="o">=</span> <span class="n">stale</span><span class="o">.</span><span class="n">update_all</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span> <span class="ss">sending_started_at</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">notify_team</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">count</span><span class="si">}</span><span class="s2"> stale deliveries moved to unknown&#34;</span><span class="p">)</span> <span class="k">if</span> <span class="n">count</span> <span class="o">&gt;</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Por que <code>unknown</code> e não <code>failed</code>? Por que um delivery em <code>sending</code> há 30 minutos pode ter sido aceito pelo SES, mas não registrado? Mover pra <code>failed</code> significaria retentar — e, potencialmente, duplicar. <code>unknown</code> é o limbo seguro: &ldquo;não sei o que aconteceu; um humano precisa decidir&rdquo;.</p>
<h2>Padrão 6: Retry Inteligente no SES<span class="hx:absolute hx:-mt-20" id="padrão-6-retry-inteligente-no-ses"></span>
    <a href="#padr%c3%a3o-6-retry-inteligente-no-ses" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES tem três categorias de erro:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Transitório — SES está ocupado, tente depois</span>
</span></span><span class="line"><span class="cl"><span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">TooManyRequestsException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl"><span class="n">retry_on</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">LimitExceededException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="ss">wait</span><span class="p">:</span> <span class="ss">:polynomially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</span> <span class="mi">5</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Permanente — sua conta ou email tem problema</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">MessageRejected</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># NÃO re-tenta. O SES rejeitou esse email especificamente.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">Aws</span><span class="o">::</span><span class="no">SESV2</span><span class="o">::</span><span class="no">Errors</span><span class="o">::</span><span class="no">AccountSuspendedException</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Sua conta foi suspensa. Não adianta retentar nada.</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>polynomially_longer</code> é chave: 3s, 18s, 83s, 293s&hellip; O backoff polinomial é mais agressivo que o exponencial, o que é bom pro SES — ele quer que você desacelere rápido.</p>
<p><strong>Nunca</strong> use <code>retry_on StandardError</code>. Isso re-tenta erros de programação (nil pointer, método inexistente) que vão falhar eternamente.</p>
<h2>Padrão 7: Tags SES para Rastreabilidade<span class="hx:absolute hx:-mt-20" id="padrão-7-tags-ses-para-rastreabilidade"></span>
    <a href="#padr%c3%a3o-7-tags-ses-para-rastreabilidade" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Cada email enviado carrega tags que permitem correlacionar eventos depois:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">mailer</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">to</span><span class="p">:</span> <span class="n">subscriber</span><span class="o">.</span><span class="n">email</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">html_body</span><span class="p">:</span> <span class="n">html</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">tags</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;NewsletterID&#34;</span> <span class="o">=&gt;</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;DeliveryID&#34;</span>   <span class="o">=&gt;</span> <span class="n">delivery</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Quando o SES notifica um bounce ou um complaint via SNS, o payload inclui essas tags. O reconciliador encontra o <code>EmailDelivery</code> correspondente e atualiza o status:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">reconcile_event</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">delivery</span> <span class="o">=</span> <span class="n">find_by_tag</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="s2">&#34;DeliveryID&#34;</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">             <span class="n">find_by_message_id</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="k">unless</span> <span class="n">delivery</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">event_type</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;delivery&#34;</span>  <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_sent!</span><span class="p">(</span><span class="n">message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;bounce&#34;</span>    <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_bounced!</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;complaint&#34;</span> <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_bounced!</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="s2">&#34;reject&#34;</span>    <span class="k">then</span> <span class="n">delivery</span><span class="o">.</span><span class="n">mark_failed!</span><span class="p">(</span><span class="s2">&#34;SES rejected&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Isso fecha o loop: mesmo que o delivery tenha ficado como <code>unknown</code> porque o worker tenha crashado, o SES notifica eventualmente se entregou ou não. O reconciliador corrige o estado.</p>
<h2>Padrão 8: Configuration Sets para Monitoramento<span class="hx:absolute hx:-mt-20" id="padrão-8-configuration-sets-para-monitoramento"></span>
    <a href="#padr%c3%a3o-8-configuration-sets-para-monitoramento" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>SES tem um recurso chamado Configuration Sets que é absurdamente subutilizado:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">params</span><span class="o">[</span><span class="ss">:configuration_set_name</span><span class="o">]</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;SES_CONFIGURATION_SET&#34;</span><span class="o">]</span> <span class="k">if</span> <span class="n">configured?</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O Configuration Set te dá:</p>
<ul>
<li><strong>Delivery events</strong>: notificações de entrega, bounce, complaint via SNS</li>
<li><strong>Reputation dashboard</strong>: taxa de bounce e complaint em tempo real</li>
<li><strong>Sending statistics</strong>: volume por hora/dia</li>
</ul>
<p>Sem Configuration Set, você está voando cego. Com ele, sabe exatamente o estado da sua reputação como sender.</p>
<p>A Amazon suspende sua conta se a taxa de bounce passar de 5% ou se as complaints passarem de 0,1%. Com o dashboard, você vê isso acontecendo antes que seja tarde demais.</p>
<h2>Padrão 9: Relatório de Envio Diferido<span class="hx:absolute hx:-mt-20" id="padrão-9-relatório-de-envio-diferido"></span>
    <a href="#padr%c3%a3o-9-relat%c3%b3rio-de-envio-diferido" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Não adianta reportar resultado imediatamente após enfileirar. Os jobs ainda estão rodando. O relatório precisa ser <strong>diferido</strong>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Enfileira relatório 30 minutos depois do envio</span>
</span></span><span class="line"><span class="cl"><span class="no">SendingReportJob</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="ss">wait</span><span class="p">:</span> <span class="mi">30</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">newsletter</span><span class="o">.</span><span class="n">id</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>E o relatório se reagenda se muitos deliveries ainda estão pendentes:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">,</span> <span class="n">reschedule_count</span> <span class="o">=</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">counts</span> <span class="o">=</span> <span class="n">newsletter</span><span class="o">.</span><span class="n">email_deliveries</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="ss">:status</span><span class="p">)</span><span class="o">.</span><span class="n">count</span>
</span></span><span class="line"><span class="cl">  <span class="n">pending_ratio</span> <span class="o">=</span> <span class="n">pending_plus_sending</span> <span class="o">/</span> <span class="n">total</span><span class="o">.</span><span class="n">to_f</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">pending_ratio</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">.</span><span class="mi">10</span> <span class="o">&amp;&amp;</span> <span class="n">reschedule_count</span> <span class="o">&lt;</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Mais de 10% ainda em andamento — checa de novo em 15min</span>
</span></span><span class="line"><span class="cl">    <span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="ss">wait</span><span class="p">:</span> <span class="mi">15</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="n">newsletter_id</span><span class="p">,</span> <span class="n">reschedule_count</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">send_report</span><span class="p">(</span><span class="n">counts</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O relatório final mostra sent/failed/pending/unknown/bounced. Se tem <code>unknown</code> &gt; 0, a cor é vermelha — precisa de atenção humana.</p>
<h2>O Sandbox Trap<span class="hx:absolute hx:-mt-20" id="o-sandbox-trap"></span>
    <a href="#o-sandbox-trap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Uma dor que todo mundo passa e ninguém documenta: o SES começa em modo sandbox. Você só pode enviar para e-mails verificados. Pra sair do sandbox, precisa de um request manual explicando seu caso de uso, volume estimado, e como trata bounces/complaints.</p>
<p>Dicas pra aprovação:</p>
<ol>
<li><strong>Tenha domínio verificado com DKIM</strong> — não mande de <code>@gmail.com</code></li>
<li><strong>Explique o opt-in duplo</strong> — &ldquo;assinante confirma email antes de receber&rdquo;</li>
<li><strong>Mostre tratamento de bounce</strong> — &ldquo;bounces são automaticamente processados e removidos&rdquo;</li>
<li><strong>Comece pequeno</strong> — peça 200/dia, escale depois</li>
</ol>
<p>A Amazon é paranoica em relação à reputação. Mostra que você respeita isso.</p>
<h2>DKIM, SPF, e DMARC: A Trilogia que Mantém Seus E-mails na Inbox<span class="hx:absolute hx:-mt-20" id="dkim-spf-e-dmarc-a-trilogia-que-mantém-seus-e-mails-na-inbox"></span>
    <a href="#dkim-spf-e-dmarc-a-trilogia-que-mant%c3%a9m-seus-e-mails-na-inbox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Sem autenticação DNS correta, seus e-mails vão pro spam. Três registros são obrigatórios:</p>
<ul>
<li><strong>SPF</strong>: declara quais servidores podem enviar em nome do seu domínio</li>
<li><strong>DKIM</strong>: assinatura criptográfica que prova que o email não foi alterado</li>
<li><strong>DMARC</strong>: política que diz o que fazer com emails que falham SPF/DKIM</li>
</ul>
<p>O SES gera os registros DKIM automaticamente (são 3 CNAMEs). SPF e DMARC você configura manualmente no DNS.</p>
<p>Se você não fizer isso, mesmo o Gmail vai marcar seus e-mails como suspeitos. E o pior: provedores como Microsoft e Yahoo estão cada vez mais agressivos com e-mails sem DMARC.</p>
<h2>Padrão 10: List-Unsubscribe — O Header que o Gmail Exige<span class="hx:absolute hx:-mt-20" id="padrão-10-list-unsubscribe--o-header-que-o-gmail-exige"></span>
    <a href="#padr%c3%a3o-10-list-unsubscribe--o-header-que-o-gmail-exige" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Desde fevereiro de 2024, o Gmail tem uma regra dura pra bulk senders (quem manda mais de 5.000 emails/dia pra endereços do Gmail): todo email precisa ter os headers <code>List-Unsubscribe</code> e <code>List-Unsubscribe-Post</code>. Sem eles, o Gmail <strong>penaliza a reputação do domínio inteiro</strong> — não só os e-mails em massa, mas também os transacionais (como confirmação de cadastro, reset de senha, etc.).</p>
<p>Descobri isso da pior forma: usuários do Gmail não recebiam o e-mail de confirmação de inscrição. DKIM passando, SPF passando, DMARC passando, SES aceitando — tudo verde. Mas o Gmail estava silenciosamente encaminhando para o spam porque a newsletter não tinha <code>List-Unsubscribe</code>.</p>
<p>A implementação tem duas partes: o header no email e o endpoint que processa o unsubscribe.</p>
<h3>O Header<span class="hx:absolute hx:-mt-20" id="o-header"></span>
    <a href="#o-header" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Cada email precisa de dois headers — com URL personalizada por assinante:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">mailer</span><span class="o">.</span><span class="n">send_email</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">to</span><span class="p">:</span> <span class="n">subscriber</span><span class="o">.</span><span class="n">email</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">html_body</span><span class="p">:</span> <span class="n">html</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">headers</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;List-Unsubscribe&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;&lt;https://seudominio.com/unsubscribe?token=</span><span class="si">#{</span><span class="n">subscriber</span><span class="o">.</span><span class="n">unsubscribe_token</span><span class="si">}</span><span class="s2">&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;List-Unsubscribe-Post&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;List-Unsubscribe=One-Click&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No SES v2, headers customizados vão no campo <code>headers</code> do <code>simple</code> content:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">simple</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">subject</span><span class="p">:</span> <span class="p">{</span> <span class="ss">data</span><span class="p">:</span> <span class="n">subject</span><span class="p">,</span> <span class="ss">charset</span><span class="p">:</span> <span class="s2">&#34;UTF-8&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">body</span><span class="p">:</span> <span class="p">{</span> <span class="ss">html</span><span class="p">:</span> <span class="p">{</span> <span class="o">...</span> <span class="p">},</span> <span class="ss">text</span><span class="p">:</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="ss">headers</span><span class="p">:</span> <span class="n">headers</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="nb">name</span><span class="p">,</span> <span class="n">value</span><span class="o">|</span> <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="nb">name</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">value</span><span class="o">.</span><span class="n">to_s</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Atenção</strong>: o link de unsubscribe no <strong>corpo</strong> do email (o que está no footer) não conta. O Gmail exige especificamente o <strong>header HTTP</strong>. São coisas diferentes. Você precisa dos dois.</p>
<h3>O Endpoint RFC 8058<span class="hx:absolute hx:-mt-20" id="o-endpoint-rfc-8058"></span>
    <a href="#o-endpoint-rfc-8058" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Quando o usuário clica em &ldquo;Cancelar inscrição&rdquo; no Gmail, o que acontece não é um GET no browser. O Gmail faz um POST direto pro seu servidor com o body <code>List-Unsubscribe=One-Click</code>. Sem cookies, sem CSRF token, sem redirect.</p>
<p>Isso significa que seu controller precisa:</p>
<ol>
<li><strong>Pular verificação CSRF</strong> pra esse tipo de request</li>
<li><strong>Retornar 200 OK</strong> ao invés de redirect (clients de email não seguem redirects)</li>
<li><strong>Continuar funcionando normalmente</strong> pra unsubscribes via browser</li>
</ol>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">UnsubscriptionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># RFC 8058: email clients POST sem CSRF token</span>
</span></span><span class="line"><span class="cl">  <span class="n">skip_forgery_protection</span> <span class="ss">only</span><span class="p">:</span> <span class="ss">:destroy</span><span class="p">,</span> <span class="k">if</span><span class="p">:</span> <span class="ss">:one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">destroy</span>
</span></span><span class="line"><span class="cl">    <span class="n">subscriber</span> <span class="o">=</span> <span class="no">Subscriber</span><span class="o">.</span><span class="n">find_by</span><span class="p">(</span><span class="ss">unsubscribe_token</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:token</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">subscriber</span><span class="o">&amp;.</span><span class="n">unsubscribe!</span> <span class="k">unless</span> <span class="n">subscriber</span><span class="o">&amp;.</span><span class="n">unsubscribed?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">      <span class="n">head</span> <span class="ss">:ok</span>  <span class="c1"># Gmail espera 200, não redirect</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">redirect_to</span> <span class="n">unsubscribe_path</span><span class="p">(</span><span class="ss">token</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:token</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">one_click_unsubscribe?</span>
</span></span><span class="line"><span class="cl">    <span class="n">params</span><span class="o">[</span><span class="s2">&#34;List-Unsubscribe&#34;</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;One-Click&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>skip_forgery_protection</code> é condicional — só dispara quando o request contém o parâmetro <code>List-Unsubscribe=One-Click</code>. Requests normais do browser continuam com proteção CSRF total.</p>
<p>Depois de implementar isso, a reputação do domínio no Gmail leva 1-2 dias pra se recuperar. Os emails de confirmação voltaram a chegar na inbox.</p>
<h2>Padrão 11: A Suppression List Silenciosa do SES<span class="hx:absolute hx:-mt-20" id="padrão-11-a-suppression-list-silenciosa-do-ses"></span>
    <a href="#padr%c3%a3o-11-a-suppression-list-silenciosa-do-ses" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O SES tem uma armadilha que pega muita gente: a <strong>account-level suppression list</strong>. Quando um email dá hard bounce (endereço inexistente) ou gera um complaint (marcou como spam), o SES automaticamente adiciona esse endereço a uma lista interna. A partir daí, toda vez que você manda pra esse endereço, o SES <strong>aceita a requisição, retorna um message_id e, silenciosamente, não entrega</strong>.</p>
<p>Leu de novo: <strong>o SES retorna sucesso, mas não manda</strong>. Seu sistema acha que mandou. O assinante nunca recebe. Nenhum erro, nenhum bounce, nenhum log.</p>
<p>Pra ver quem está na lista:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">aws sesv2 list-suppressed-destinations --output table</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Pra remover um endereço (por exemplo, depois de corrigir um problema de deliverability):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">aws sesv2 delete-suppressed-destination --email-address <span class="s2">&#34;usuario@gmail.com&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Cuidado</strong>: não saia removendo tudo. Se o endereço deu bounce por typo (<code>usuario@gnail.com</code> em vez de <code>@gmail.com</code>), remover da suppression list só vai gerar outro bounce — e piorar sua taxa de bounce, exatamente o que o SES monitora pra decidir se suspende sua conta.</p>
<p>A regra: só remova endereços que você tenha motivo concreto pra acreditar que são válidos. Typos e endereços abandonados ficam na lista — é o SES que te protege de você mesmo.</p>
<h2>Encriptação de Emails em Repouso<span class="hx:absolute hx:-mt-20" id="encriptação-de-emails-em-repouso"></span>
    <a href="#encripta%c3%a7%c3%a3o-de-emails-em-repouso" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Um detalhe que muitos ignoram: os e-mails dos seus assinantes estão no banco. Se o banco vazar, vazam todos os e-mails. O Rails 8 tem Active Record Encryption nativo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Subscriber</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="n">encrypts</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">deterministic</span><span class="p">:</span> <span class="kp">true</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>deterministic: true</code> é crucial: permite que <code>find_by(email: &quot;x@y.com&quot;)</code> funcione normalmente, pois o mesmo input sempre gera o mesmo ciphertext. O índice unique continua funcionando. Do ponto de vista da aplicação, nada muda — mas, no banco, os e-mails são cifrados.</p>
<p>As chaves vivem em variáveis de ambiente, não no banco de dados. O banco vazou? Os e-mails são ilegíveis sem as chaves.</p>
<h2>O Custo Total<span class="hx:absolute hx:-mt-20" id="o-custo-total"></span>
    <a href="#o-custo-total" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Pra uma newsletter com 1.000 assinantes enviando 4 edições por mês:</p>
<table>
  <thead>
      <tr>
          <th>Item</th>
          <th>Custo</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SES (4.000 emails)</td>
          <td>$0.40/mês</td>
      </tr>
      <tr>
          <td>Domínio verificado</td>
          <td>$0</td>
      </tr>
      <tr>
          <td>Configuration Set</td>
          <td>$0</td>
      </tr>
      <tr>
          <td>SNS notifications</td>
          <td>~$0 (free tier)</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>$0.40/mês</strong></td>
      </tr>
  </tbody>
</table>
<p>Compara com o Mailchimp ($13/mês), o ConvertKit ($15/mês) ou o Buttondown ($9/mês). A diferença é que, com SES, você <strong>construiu</strong> a infraestrutura em vez de alugar. É mais trabalho inicial, mas com zero lock-in e controle total.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Enviar e-mail confiável não é sobre chamar uma API. É sobre:</p>
<ul>
<li><strong>Registros por destinatário</strong> com claiming atômico</li>
<li><strong>Estados terminais</strong> que nunca são revertidos automaticamente</li>
<li><strong>Distinção explícita</strong> entre &ldquo;SES rejeitou&rdquo; e &ldquo;SES aceitou mas algo quebrou&rdquo;</li>
<li><strong>Recuperação de estados ambíguos</strong> sempre pra <code>unknown</code>, nunca pra <code>failed</code></li>
<li><strong>Relatórios diferidos</strong> que se reagendam até ter dados completos</li>
<li><strong>Reconciliação via webhooks</strong> que fecha o loop de entrega</li>
<li><strong>DNS correto</strong> (DKIM/SPF/DMARC) pra não ir pro spam</li>
<li><strong><code>List-Unsubscribe</code> em todo email</strong> — sem ele, Gmail penaliza seu domínio inteiro</li>
<li><strong>Consciência da suppression list</strong> — SES pode aceitar e silenciosamente não entregar</li>
</ul>
<p>A regra de ouro: <strong>na dúvida, não reenvie</strong>. É melhor um assinante não receber uma edição do que receber duas. A reputação do seu domínio — e a paciência dos seus assinantes — dependem disso.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>seguranca</category><category>backend</category></item><item><title>Vibe Code: Do Zero à Produção em 6 DIAS | The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/16/vibe-code-do-zero-a-producao-em-6-dias-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/16/vibe-code-do-zero-a-producao-em-6-dias-the-m-akita-chronicles/</guid><pubDate>Mon, 16 Feb 2026 14:19:56 +0000</pubDate><description>&lt;p&gt;&lt;strong&gt;ASSINE O NOVO NEWSLETTER:&lt;/strong&gt; &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;
&lt;strong&gt;LEIA A VERSÃO BLOG:&lt;/strong&gt; &lt;a href="https://blog.themakitachronicles.com/"target="_blank" rel="noopener"&gt;Blog&lt;/a&gt;
&lt;strong&gt;OUÇA NO SPOTIFY!!&lt;/strong&gt; &lt;a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener"&gt;Podcast&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-14_18-59-53.png" alt="a ideia" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;No dia 10 de fevereiro de 2026, eu postei o tweet acima, só jogando no ar uma coisa que estava na minha to-do list há anos, desde que eu publicava vídeos no YouTube. Nunca fiz porque eu odeio HTML/CSS e ter que fazer template de e-mail era suficiente pra não começar. Além disso, fazer mais uma newsletter, que é só uma lista de links chata, não me motivava.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>ASSINE O NOVO NEWSLETTER:</strong> <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>
<strong>LEIA A VERSÃO BLOG:</strong> <a href="https://blog.themakitachronicles.com/"target="_blank" rel="noopener">Blog</a>
<strong>OUÇA NO SPOTIFY!!</strong> <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">Podcast</a></p>
<p>&ndash;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-14_18-59-53.png" alt="a ideia"  loading="lazy" /></p>
<p>No dia 10 de fevereiro de 2026, eu postei o tweet acima, só jogando no ar uma coisa que estava na minha to-do list há anos, desde que eu publicava vídeos no YouTube. Nunca fiz porque eu odeio HTML/CSS e ter que fazer template de e-mail era suficiente pra não começar. Além disso, fazer mais uma newsletter, que é só uma lista de links chata, não me motivava.</p>
<p>Mas nesse dia eu já estava terminando meu segundo projeto com vibe coding, o <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a>, e acho que entendi os principais truques e a metodologia pra fazer um produto completo da forma certa. Então, começaram a surgir várias ideias. Dei dump e escrevi este <a href="https://gist.github.com/akitaonrails/d2a7983fc4c839b8071f5d0babaadf94"target="_blank" rel="noopener">pequeno documento</a>.</p>
<p><a href="https://gist.github.com/akitaonrails/d2a7983fc4c839b8071f5d0babaadf94"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-40-04.jpg" alt="conceito original"  loading="lazy" /></a></p>
<p>Pensei: e se eu fizesse não só o código do sistema de newsletter com vibe code, mas também tivesse parte do próprio conteúdo gerada por IA de forma inusitada, em forma de comentaristas: eu e um robô? E, claro, com Guia do Mochileiro na mente, tinha que ser o MARVIN, ou melhor, o &ldquo;M.Arvin&rdquo; (pra ninguém encher com copyright).</p>
<p>É claro, vai ter versão por e-mail semanal e um blog de arquivos. Mas já que vai ter tudo isso, por que não reescrever já toda a newsletter em script de podcast? E já que recentemente saiu o <a href="https://qwen.ai/blog?id=qwen3tts-0115"target="_blank" rel="noopener">Qwen3 TTS</a>, open source, por que não treinar um LoRa com minha voz (obviamente, tenho centenas de horas de minha voz no YouTube) e fazer um podcast totalmente automatizado?</p>
<p>Finalmente, um dos desmotivadores pra mim seria criar um Web Admin tradicional e ter que entrar manualmente lá pra cadastrar os links de notícias e meus comentários. Eu sei que ia me dar preguiça e, a uma hora, ia cansar. Só tinha uma solução: criar um bot mais inteligente com o qual eu pudesse conversar via <strong>DISCORD</strong>. Assim, eu posso rapidamente acessar, seja do meu PC, seja do meu smartphone, sem precisar logar num sistema separado nem nada. Eu já converso com pessoas no Discord, então a fricção seria zero.</p>
<p>Então:</p>
<ul>
<li>Marvin Bot pra gerenciar todo conteúdo (que eu mando e os automatizados, com comentários e tudo mais)</li>
<li>Monitor de X.com pra checar o que eu postei e já pegar pra usar na newsletter. Incluindo as replies que eu dou, que vão virar Q&amp;A!</li>
<li>Newsletter website pra assinar, cancelar, etc</li>
<li>Blog pra arquivos de newsletter e de transcripts dos podcasts (também com termos de serviço e de privacidade — sim, sou LGPD compliant!)</li>
<li>Gerador automático de vozes e montador de podcast que vai subir automaticamente pra deixar disponível pro Spotify</li>
<li>E ainda templates de blog e de e-mail, com versões de tema light e dark.</li>
</ul>
<p>Você, que é um programador experiente, sem IA, quanto tempo levaria pra fazer tudo isso?</p>
<p>Pois bem, eu comecei no dia 10 de fevereiro. Terminei tudo no domingo, dia 15 de fevereiro. E hoje, dia 16, rodaram os jobs de produção pela primeira vez, o que gerou newsletter, blog posts, podcast, upload pro Spotify, envio de email.</p>
<p>E não foi do jeito podre que todo vibe coder amador faz:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-37-20.jpg" alt="newsletter stats"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-37-27.jpg" alt="bot stats"  loading="lazy" /></p>
<p>Quem é de Rails deve ter ficado surpreso: projeto com taxa de 1 pra 1 de testes! 1 linha de testes pra cada 1 linha de código: 100% de cobertura. E isso, sem contar um projeto separado deles de integração completa, ponta a ponta (que usei pra garantir que a produção ficaria perfeita).</p>
<p>Todas as estratégias que eu aprendi ao longo dos anos colocando projeto em produção: segurança, escalabilidade, durabilidade, garantia de entrega sem spammar duplicatas de e-mails, etc. Depois vejo se faço mais posts específicos sobre alguns dos truques técnicos.</p>
<p>Este projeto não vou abrir como open source. Eu fiz 100% customizado pra mim, pro meu fluxo de trabalho e meus estilos. Não é difícil pra outro programador competente fazer a mesma coisa com vibe code: se eu consegui, qualquer um consegue. Então é só não ter preguiça de pensar e fazer. Aliás, não entendo como tech youtubers ainda não estão lançando 1 projeto completo em produção, por vídeo, pra todo mundo testar e ver.</p>
<p>No screenshot também não está o servidor de Qwen3-TTS, que produz áudio. Esse é bem mais simples, um servidor PyTorch. Aqui foi Python mesmo. O mais importante é o fine tuning do modelo pra gerar vozes exatamente do jeito que eu queria:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-45-20.jpg" alt="qwen3 server"  loading="lazy" /></p>
<p>O blog é todo com <strong>Hugo/Hextra</strong>, como este também. E meu bot publica lá automaticamente.</p>
<h2>M.Arvin Bot<span class="hx:absolute hx:-mt-20" id="marvin-bot"></span>
    <a href="#marvin-bot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O M.Arvin tem uma personalidade que eu detalhei como eu queria. E é um bot de Discord, então meu trabalho é assim:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-45-48.jpg" alt="bot url register"  loading="lazy" /></p>
<p>Só preciso mandar um link com meu comentário (igual eu faria no X.com), ou posso postar direto no X, que ele vai pegar igual. Daí ele busca o conteúdo, faz um resuminho, faz um pequeno fact-check e o M.Arvin gera seu comentário em cima do meu.</p>
<p>Eu programei ele pra responder a um monte de comandos pra facilitar meu dia a dia. Isso é só uma pequena parte:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-56-13.jpg" alt="bot help commands"  loading="lazy" /></p>
<p>Eis um exemplo: eu tenho controle sobre quanto de IA ele está usando (é bem barato):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-57-07.jpg" alt="AI cost"  loading="lazy" /></p>
<h2>Deployment<span class="hx:absolute hx:-mt-20" id="deployment"></span>
    <a href="#deployment" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Neste post não vou detalhar muito, mas uma coisa em que demorei bastante, passando por várias discussões e versões com o Claude Code, foi na arquitetura de infraestrutura. E no final ele conseguiu me dar um Deployment Guide, que consegui seguir até o fim e sabendo que minimamente está como eu mesmo faria se tivesse feito tudo sozinho:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_11-58-45.jpg" alt="topology"  loading="lazy" /></p>
<p>Nenhum software tem garantia de ser 100% perfeito; este também não deve ser. Mesmo eu tendo revisado por dias, passando por cada etapa várias vezes, sempre vai ter algum bug que eu não previ. Eu &ldquo;acho&rdquo; que, pelo menos, tudo o que sei que costuma dar problema em produção, &ldquo;acho&rdquo; que consegui cobrir de forma satisfatória. Se fosse uma equipe humana, eu teria colocado em produção num estado pior do que o que atingi com o Claude.</p>
<p>Tudo o que dava pra proteger com Cloudflare, eu configurei. Todas as boas práticas pra e-mail/mailing, eu segui. Todas as precauções contra acidentes eu tomei (todo deploy automatizado com Kamal) e todos os e-mails dos assinantes foram encriptados com AES-256 no banco de dados (mesmo que vazem por acidente, não vazam os dados dos usuários). Não implementei profiling, cookies nem pixels de rastreamento, nada. Privacidade foi o foco.</p>
<p>Tudo o que dava pra reduzir custos, eu reduzi; afinal, vou pagar do meu bolso. Então, coisas como GPU na RunPod pra gerar os áudios são serverless. Eu subo só no momento de gerar; ele faz todo o processo e, depois, a máquina desliga. O servidor está todo organizado numa imagem Docker leve. Todos os modelos de IA estão num volume de rede separado.</p>
<h2>Spoiler do Processo<span class="hx:absolute hx:-mt-20" id="spoiler-do-processo"></span>
    <a href="#spoiler-do-processo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Depois pretendo fazer um post maior falando da minha metodologia, mas é muito simples: trate o Claude Code/Codex como se fosse um programador humano esforçado — que vai cometer muitos erros, mas sabe obedecer ordens super explícitas, diretas, claras e sem ambiguidade. Meu papel é de gerente de produto, Tech Lead ou QA. Têm que ser os TRÊS papéis.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-06-47.jpg" alt="commits"  loading="lazy" /></p>
<p>No total, o Claude Code fez 201 commits. Mas não são commits normais; considero que, num projeto normal com humanos, cada um teria feito aproximadamente <strong>150 Pull Requests</strong>.</p>
<p>Pergunta a vocês que participam de sprints em projetos: quanto tempo leva pra ter 150 user stories/bug fixes aprovados e em produção no seu projeto real? Quantos user stories vocês conseguem fazer e aprovar (com QA e tudo) em UMA semana?</p>
<p>Eis um exemplo de um commit/&ldquo;user story&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-08-55.jpg" alt="commit example"  loading="lazy" /></p>
<p>Quando estava lá no commit, talvez 50, eu já pensava: &ldquo;Já funciona!&rdquo;* É o estágio que todo criador de conteúdo (que nunca colocou projetos de verdade em produção) já postaria, dizendo: <em>&ldquo;fiz vibe code em 2 dias&rdquo;</em>. De fato, em 2 dias eu já tinha o grosso das funcionalidades prontas.</p>
<p>Os outros 4 dias foram:</p>
<ul>
<li>1 dia pra deploy em produção (quando tem tudo planejado com antecedência, todas as contingências já pensadas, é fácil).</li>
<li>3 dias só testando, criando testes automatizados/integração, gerando e regerando, exercitando todas as etapas de todas as partes, múltiplas vezes, e fazendo ajustes, refatorações ou até mesmo jogando fora um pedaço inteiro pra mudar, porque os testes indicaram que valia mais a pena.</li>
</ul>
<p>Isso levou de 50 a 200 commits! E é a parte que todo vibe coder <strong>pula</strong>. Pra ser justo, é a parte que qualquer pessoa, com ou sem IA, pularia. E é por isso que sempre os projetos dão errado e vão pra produção cheios de erros e de buracos óbvios de segurança.</p>
<blockquote>
  <p>Mesmo depois de pedir ao Claude Code pra revisar boas práticas de segurança e de cobertura de testes, múltiplas vezes. Depois eu abri o Codex e pedi pra fazer a mesma coisa, e ele ainda achou mais buracos de segurança e mais testes faltando, múltiplas vezes. Mesmo depois dos dois fazerem essas checagens várias vezes, eu ainda encontrei sozinho mais buracos que mandei consertar. Em nenhum momento, nenhum dos dois conseguiu achar &ldquo;TUDO&rdquo;. Ninguém consegue, nem a IA.</p>

</blockquote>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Programar Vibe Coding da forma correta só tem um jeito: Extreme Programming!</p>
<p>Sim, todas as coisas &ldquo;chatas&rdquo; e &ldquo;burocráticas&rdquo; que todo desenvolvedor odeia fazer são o que garante que a IA vai devolver um resultado aceitável pra colocar em produção:</p>
<ul>
<li>Pair Programming: você precisa mandar a IA planejar primeiro e te dizer exatamente tudo o que vai fazer, ANTES de começar. Eu reviso, ajusto e, só quando estou satisfeito, deixo fazer. Fico olhando cada coisa que ele faz e, no final, mando ajustar o que acho que faltou.</li>
<li>Test-Driven Development: toda funcionalidade deve vir acompanhada de testes unitários. Toda correção de bug precisa de testes de regressão.</li>
<li>Continuous Integration: Toda vez que termina de implementar, precisa rodar script de CI pra ver se nada quebrou em outro lugar. Mais do que isso: no branch master, só há commits com testes que passam. Eu posso reverter em qualquer ponto e o projeto roda.</li>
<li>Small Releases: isso eu achei ótimo no Claude, mesmo se eu acabar esquecendo de mandar ele comitar uma tarefa e mandar fazer outra, no fim, ele não faz aquela tosquice de <code>git add .</code> — não: ele separa em 2 commits e descreve cada um da forma correta. Além disso, eu subi produção na sexta-feira, e continuei sábado e domingo testando integração local e em ambiente de produção, pra garantir que não tem isso de <em>&ldquo;só na minha máquina funciona&rdquo;</em></li>
<li>Coding Standard: eu iniciei com o mínimo de requerimentos técnicos, mas, durante o desenvolvimento, mandei refatorar do meu jeito várias e várias vezes.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-16_12-21-22.jpg" alt="tech stack"  loading="lazy" /></p>
<p>Essas são as principais tarefas a serem feitas. Já adianto: &ldquo;Spec Driven Development&rdquo; não é totalmente inútil, mas a estrutura não tem importância: é o seu texto que conta.</p>
<p>É a mesma coisa que User Stories. Grande bosta que você escreveu &ldquo;Eu, como o usuário final, Quero que quando digitar X &hellip;, o sistema responda com Y&rdquo;. O formato NUNCA é importante. O que é importante é COMO você escreveu o que vai no meio. Se você não sabe escrever, a IA não vai saber responder a você como deveria. Vou fazer outro artigo só pra demonstrar como todos vocês são péssimos em escrever.</p>
<p>O recado é simples: Vibe Code funciona, em partes.</p>
<blockquote>
  <p>O que funciona mesmo é &ldquo;Senior Agile Vibe Coder&rdquo;</p>

</blockquote>
<p>Vou perguntar de novo: quanto tempo, sem IA, você levaria pra produzir 150 user stories/bug fixes, no escopo que resumi no começo? Até colocar em produção com tudo testado? Duvido que seja em 6 dias como eu fiz.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>vibe-coding</category><category>agile</category></item></channel></rss>