<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Blog da VirtuAIres</title><link>https://ai.virtuaires.com.br/blog/</link><atom:link href="https://ai.virtuaires.com.br/rss.xml" rel="self" type="application/rss+xml"/><description>A VirtuAIres desenvolve sistemas sob medida para empresas: IA, integrações, automações, engenharia reversa e software open source. Conheça o Nanci.</description><language>pt-BR</language><lastBuildDate>Sat, 24 Oct 2026 00:00:00 GMT</lastBuildDate><item><title>Quando o app feito com IA começa a quebrar</title><link>https://ai.virtuaires.com.br/blog/quando-o-app-feito-com-ia-comeca-a-quebrar/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/quando-o-app-feito-com-ia-comeca-a-quebrar/</guid><pubDate>Sat, 24 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Bug atrás de bug, qualquer mudança quebra tudo, ninguém sabe se está seguro. O que os levantamentos públicos dizem sobre apps feitos com IA e por onde começar.</description><content:encoded><![CDATA[<p>Alguém viu um anúncio, descreveu uma ideia em português e, em uma tarde, tinha um sistema no ar. Cadastro de clientes, controle de pedidos, um painel. Funcionou por semanas. Depois veio o primeiro bug. A correção trouxe outro. Uma funcionalidade nova não sai de jeito nenhum, e ninguém sabe dizer se os dados dos clientes estão protegidos ou onde, exatamente, aquilo está rodando.</p>
<p>Este texto registra o que os levantamentos públicos de 2025 dizem sobre apps feitos com IA por pessoas sem formação técnica, quais sintomas se repetem e por onde começar a arrumar. É o primeiro de uma série. Os dados citados são de relatórios e relatos abertos. Os dois casos vistos de perto vêm de trabalhos nossos.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Os sintomas se repetem: <strong>bug atrás de bug, qualquer mudança quebra tudo, sem versionamento, sem saber se está seguro, sem saber colocar no ar</strong>.</li>
<li>Em outubro de 2025, uma varredura da Escape em <strong>mais de 5.600 apps públicos</strong> feitos em plataformas de IA achou <strong>mais de 2.000 vulnerabilidades</strong>.</li>
<li>Em julho de 2025, um teste da Veracode com <strong>mais de 100 modelos</strong> de IA mostrou que o código gerado escolheu a opção insegura em <strong>45%</strong> das tarefas.</li>
<li>Na pesquisa do Stack Overflow de 2025, <strong>46%</strong> dos desenvolvedores disseram não confiar na precisão do que a IA produz, e <strong>66%</strong> apontaram respostas “quase certas” como o problema principal.</li>
<li>Os problemas mudam conforme a ferramenta: <strong>plataforma no navegador</strong>, <strong>agente no computador</strong> ou <strong>código colado de um chat</strong>. Cada um tem um post nesta série.</li>
<li>O caminho é o mesmo em todos: <strong>diagnosticar antes de editar, travar o que funciona, corrigir uma coisa por vez</strong>. Na nossa avaliação, refazer do zero raramente compensa.</li>
</ul>
<h2 id="os-sintomas">Os sintomas</h2>
<p>Os relatos públicos, de fóruns a boletins de segurança, descrevem, com frequência, a mesma lista. Em geral os sintomas chegam juntos.</p>
<p><strong>Bug atrás de bug.</strong> A pessoa pede uma correção, a IA mexe em dez arquivos, e algo que funcionava para de funcionar. A próxima correção repete o ciclo. Sem testes automatizados, ninguém sabe o que a mudança afetou até um usuário reclamar.</p>
<p><strong>Qualquer alteração quebra tudo.</strong> É o mesmo sintoma visto de outro ângulo. O código gerado em uma única conversa longa tende a ter funções que dependem umas das outras de formas que ninguém mapeou, porque ninguém leu.</p>
<p><strong>Sem versionamento.</strong> Não há histórico. A versão que funcionava na terça não existe mais depois da mudança de quarta. Quem usa o Git, o sistema de controle de versões que a <a href="https://docs.github.com/pt/get-started/start-your-journey/about-github-and-git">documentação do GitHub</a> explica para iniciantes, tem cada estado guardado com data e descrição. Quem não usa só tem o estado atual.</p>
<p><strong>Sem saber se está seguro.</strong> O app pede login, então parece protegido. Mas o banco pode estar acessível a quem souber o endereço, as chaves de serviços pagos podem estar no código que o navegador baixa, e dados de um cliente podem aparecer para outro ao trocar um número na URL. Um caso que vimos: um site com um endereço de recibos numerados em sequência. Trocando o número, qualquer pessoa abria o recibo de qualquer cliente, sem estar logada. A tela de login protegia a tela, não o dado.</p>
<p><strong>Sem saber colocar no ar, ou tirar.</strong> A plataforma hospeda, e a pessoa não sabe o que acontece se parar de pagar, como levar o app para outro lugar ou como fazer uma cópia do banco.</p>
<p><strong>Uma funcionalidade que não sai.</strong> Integrar pagamento, enviar e-mail, importar uma planilha. A IA tenta, quebra outra coisa, tenta de novo. Um relato em português no <a href="https://www.tabnews.com.br/diegomes42/a-desilusao-do-vibe-coding">TabNews</a>, de um engenheiro civil, conta mais de 5.000 créditos da Manus gastos em semanas num mapa interativo que nunca chegou a funcionar.</p>
<h2 id="o-que-os-números-dizem">O que os números dizem</h2>
<p>Os sintomas não são impressão de quem está frustrado. Há medições.</p>
<p><strong>Vulnerabilidades em escala.</strong> Em outubro de 2025, a empresa de segurança Escape publicou a <a href="https://escape.tech/blog/methodology-how-we-discovered-vulnerabilities-apps-built-with-vibe-coding/">metodologia</a> de uma varredura em mais de 5.600 aplicações públicas feitas em plataformas de criação por IA, cerca de 4.000 delas na Lovable. Resultado: mais de 2.000 vulnerabilidades, mais de 400 segredos expostos e 175 casos de dados pessoais acessíveis, incluindo registros médicos e dados bancários. Os apps funcionavam. O problema não aparecia na tela.</p>
<p><strong>O código gerado escolhe o caminho inseguro.</strong> Em julho de 2025, a Veracode publicou um <a href="https://www.veracode.com/press-release/ai-generated-code-poses-major-security-risks-in-nearly-half-of-all-development-tasks-veracode-research-reveals/">teste</a> com 80 tarefas de programação em mais de 100 modelos de IA. Quando havia um jeito seguro e um inseguro de resolver a tarefa, o modelo escolheu o inseguro em 45% dos casos. A taxa não melhorou com modelos mais novos, embora o código sintaticamente correto tenha melhorado. É um estudo de uma empresa que vende ferramentas de segurança, com tarefas escolhidas por ela, mas o resultado é coerente, na nossa leitura, com o que a varredura da Escape achou em apps publicados.</p>
<p><strong>Quem programa também desconfia.</strong> A <a href="https://survey.stackoverflow.co/2025/ai">pesquisa anual do Stack Overflow</a> de 2025, com cerca de 49 mil respostas, mostra que 84% dos desenvolvedores usam ou planejam usar IA, mas 46% não confiam na precisão do que ela produz. O problema mais citado, por 66%, foi a resposta “quase certa, mas não exatamente”. Quem sabe programar percebe o quase. Quem não sabe só percebe quando quebra.</p>
<p><strong>Mais rápido nem sempre.</strong> Em julho de 2025, a <a href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/">METR</a> publicou um experimento controlado com 16 desenvolvedores experientes e 246 tarefas reais. Com IA, levaram 19% mais tempo, e ainda assim acharam que tinham sido 20% mais rápidos. A amostra é pequena e os autores pedem cautela, mas o resultado explica uma sensação comum: o progresso parece grande enquanto se conversa com a IA, e pequeno quando se olha o que ficou pronto.</p>
<p><strong>Incidentes com nome.</strong> Em maio de 2025, o <a href="https://nvd.nist.gov/vuln/detail/CVE-2025-48757">NVD</a>, o banco de vulnerabilidades do governo americano, registrou uma falha em apps gerados pela Lovable: tabelas de banco sem a proteção por linha, legíveis e graváveis por qualquer pessoa sem login. A Lovable contesta o registro, dizendo que cada cliente é responsável pelos próprios dados. Em julho de 2025, a Wiz encontrou na Base44 uma <a href="https://thehackernews.com/2025/07/wiz-uncovers-critical-access-bypass.html">falha de autenticação</a> que dava acesso a apps privados com um identificador público. Foi corrigida em 24 horas. No mesmo mês, o <a href="https://oecd.ai/en/incidents/2025-07-19-1eb1">registro de incidentes da OCDE</a> documentou um agente da Replit que apagou um banco de dados em uso durante um teste, contra a instrução explícita do usuário. A Replit respondeu separando bancos de desenvolvimento e produção.</p>
<p>Nenhum desses casos é exclusivo de uma plataforma. São o mesmo problema: código e configuração que ninguém revisou, em produção, com dados de gente.</p>
<p>E não é exclusivo de quem não programa. Um caso que vimos era de um programador que trabalhava sozinho: o app baixava a coleção inteira do banco para o navegador de cada usuário, o que expunha os dados de todos e gerou uma conta de leituras que ele só notou quando cresceu. Saber programar não substitui olhar. A diferença é que quem sabe tem como olhar.</p>
<h2 id="três-situações-três-posts">Três situações, três posts</h2>
<p>O que fazer depende de como o app foi feito. Nesta série, separamos três situações.</p>
<p><strong>Plataforma no navegador.</strong> Lovable, Bolt, Base44, Replit, v0. A pessoa nunca viu o código. Paga em créditos, hospeda na plataforma, usa o banco que a plataforma criou. O problema central é a dependência: não sabe o que custa, não sabe sair, não sabe o que está exposto.</p>
<p><strong>Agente no computador.</strong> Cursor, Claude Code, Codex, Windsurf. O código está na máquina da pessoa, o que já é meio caminho. Falta método: sem Git, sem testes, conversas de horas num só chat, “aplicar tudo” sem ler. O problema central é organização.</p>
<p><strong>Código colado de um chat.</strong> ChatGPT ou Gemini no navegador, e o código vai para uma planilha, um site em WordPress, um fluxo de automação, um script que roda no notebook de alguém. Não há projeto. O problema central é juntar o que está espalhado antes de qualquer outra coisa.</p>
<h2 id="por-onde-começar-em-qualquer-caso">Por onde começar, em qualquer caso</h2>
<p>A ordem é a mesma nos três.</p>
<ol>
<li><strong>Diagnosticar antes de editar.</strong> Listar o que existe: telas, rotas, tabelas, integrações, onde está hospedado, quem tem acesso. Pedir à IA que “conserte” antes disso é mais uma mudança sem controle.</li>
<li><strong>Versionar o estado atual.</strong> Antes de qualquer alteração, o código vai para um repositório Git. É o ponto de retorno. O <a href="https://git-scm.com/book/pt-br/v2">livro do Git em português</a> é gratuito e os dois primeiros capítulos bastam para entender o que isso significa.</li>
<li><strong>Travar o que funciona.</strong> Testes que registram o comportamento atual das partes que estão certas. Daí em diante, qualquer mudança que quebrar algo avisa na hora, e não pelo cliente. Rodar esses testes a cada commit, de forma automática, é o que mostramos no post sobre o <a href="https://ai.virtuaires.com.br/blog/build-do-tauri-no-github-actions/">build do Tauri no GitHub Actions</a>.</li>
<li><strong>Olhar a segurança com lista.</strong> O <a href="https://owasp.org/Top10/">OWASP Top 10</a> é a lista pública das dez categorias de falha mais comuns em aplicações web. Controle de acesso quebrado é a primeira da lista, e é o tipo de falha dos incidentes citados acima: a tabela sem RLS, o acesso indevido na Base44 e os recibos abertos.</li>
<li><strong>Corrigir uma coisa por vez</strong>, com o teste rodando a cada passo. Se quebrou, volta. Se passou, guarda.</li>
<li><strong>Só depois, decidir o que muda de lugar.</strong> Sair da plataforma, trocar o banco, simplificar. Com o app travado e versionado, essa decisão fica barata. Antes, é um salto no escuro.</li>
</ol>
<p>Refazer do zero é a tentação quando o diagnóstico assusta. Na nossa experiência, costuma ser um erro: joga fora o que já está certo, repete os mesmos problemas com a mesma ferramenta e não deixa nada aprendido. A exceção é quando o diagnóstico mostra que a solução inteira era maior do que o problema, e uma planilha ou um formulário resolvem. Isso acontece, e é bom quando acontece.</p>
<h2 id="o-que-isto-não-resolve">O que isto não resolve</h2>
<p>Nada acima torna o app bom. Torna o app conhecido, versionado e testado, que é o que permite melhorá-lo sem medo. Também não substitui alguém que leia o código: a IA que gerou os problemas não vai achar todos eles sozinha.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Este post é um levantamento de fontes públicas, não uma medição nossa. As fontes foram consultadas em 16 de outubro de 2026: a metodologia da Escape de outubro de 2025, o comunicado da Veracode de 30 de julho de 2025, a pesquisa do Stack Overflow de 2025, o estudo da METR de 10 de julho de 2025, o registro CVE-2025-48757 no NVD, a cobertura do The Hacker News sobre a Base44 e o registro de incidentes da OCDE sobre a Replit. Números que só encontramos em páginas de empresas que vendem serviços de correção ficaram de fora. Os casos apresentados como vistos de perto, o dos recibos e o do app em Firebase, são de trabalhos nossos. Não medimos nenhum app de terceiros para este texto. A VirtuaIRES não tem vínculo com nenhuma das plataformas ou empresas citadas.</p>
<p>Um exemplo do que uma IA pode fazer com segurança quando o acesso aos dados é desenhado antes está no post sobre <a href="https://ai.virtuaires.com.br/blog/perguntar-ao-chatgpt-sobre-os-dados-do-sistema/">perguntar ao ChatGPT sobre os dados do sistema</a>. Organizar e completar um sistema que começou com IA é parte do que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/solucoes-com-ia/">soluções com IA</a>.</p>
]]></content:encoded></item><item><title>Build do Tauri no GitHub Actions: quanto demora</title><link>https://ai.virtuaires.com.br/blog/build-do-tauri-no-github-actions/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/build-do-tauri-no-github-actions/</guid><pubDate>Thu, 22 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Medimos cerca de 380 builds de um app Tauri no GitHub Actions: o que corta o tempo, como cache, bundlers e perfil de release, e o que não ajudou, como sccache.</description><content:encoded><![CDATA[<p>Um app <a href="https://tauri.app/">Tauri</a> leva minutos para compilar no GitHub Actions, e a internet está cheia de conselhos genéricos sobre Rust em CI: cache, sccache, trocar o linker, cortar dependências. Quisemos saber quais valem para o Tauri, e quanto.</p>
<p>Este texto registra o que cerca de 380 builds válidos, em 24 lotes, mostraram entre 10 e 12 de setembro de 2026. Cada número abaixo é a mediana de pelo menos 2 amostras, e cada afirmação traz o grau de confiança que o registro de evidências dá a ela. O app é pequeno, com um frontend trivial, e isso limita o que os números dizem.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Runner de 4 núcleos, sem cache, app pequeno: <strong>3 a 5,5 minutos</strong>. O cargo é quase tudo.</li>
<li>Um cache do <code>target/</code> corta <strong>55 a 65%</strong> de um commit comum. rust-cache e actions/cache empatam.</li>
<li>No Linux, <strong>não gerar AppImage e rpm</strong> economiza 1m14s, mais que qualquer flag.</li>
<li><code>codegen-units = 1</code> é a opção cara do perfil de release. LTO fat custa bem menos.</li>
<li><code>panic = "abort"</code> <strong>corta o binário do Windows pela metade</strong>.</li>
<li>sccache, troca de linker e corte de dependências <strong>não ajudaram</strong>.</li>
<li>A variação entre runners é de <strong>5 a 30%</strong>, até 40% no macOS. Diferença abaixo de 10% é ruído.</li>
</ul>
<h2 id="onde-o-tempo-vai">Onde o tempo vai</h2>
<p>Um build frio, sem cache, num runner de 4 núcleos, estágio por estágio:</p>
<table>
<thead>
<tr>
<th>Estágio</th>
<th style="text-align: right">Linux</th>
<th style="text-align: right">Windows</th>
<th style="text-align: right">macOS</th>
</tr>
</thead>
<tbody>
<tr>
<td>Dependências do sistema (apt)</td>
<td style="text-align: right">33 s</td>
<td style="text-align: right">0</td>
<td style="text-align: right">0</td>
</tr>
<tr>
<td>pnpm install</td>
<td style="text-align: right">2 s</td>
<td style="text-align: right">10 s</td>
<td style="text-align: right">3 s</td>
</tr>
<tr>
<td>Build do frontend</td>
<td style="text-align: right">2 s</td>
<td style="text-align: right">3 s</td>
<td style="text-align: right">2 s</td>
</tr>
<tr>
<td>cargo</td>
<td style="text-align: right">3m43s</td>
<td style="text-align: right">5m05s</td>
<td style="text-align: right">2m50s</td>
</tr>
<tr>
<td>Empacotamento</td>
<td style="text-align: right">1m18s</td>
<td style="text-align: right">17 s</td>
<td style="text-align: right">12 s</td>
</tr>
</tbody>
</table>
<p>Confiança alta. O apt, que muita gente aponta como o vilão do Linux, custa 33 segundos. O cargo custa de 3 a 5 minutos, e dentro dele o piso é a crate do app, que carrega o runtime do Tauri monomorfizado: de 40 a 55 segundos no Linux e de 70 a 110 no Windows, mesmo com tudo o mais em cache.</p>
<p>O tamanho do runner decide os números absolutos. Repositórios públicos ganham 4 núcleos no Linux e no Windows, e privados, 2. O mesmo build frio levou 8m57s contra 5m03s no Linux e 9m41s contra 5m25s no Windows. Confiança média, porque o lado de 2 núcleos é uma amostra só. As proporções entre opções transferem melhor que os tempos.</p>
<p>A variação entre runners idênticos é de 5 a 30%. Builds frios iguais no Linux repetiram com 4 segundos de diferença, mas o Windows espalhou de 4m30s a 5m54s, e dois jobs idênticos no macOS no mesmo run levaram 3m33s e 5m02s. Uma diferença de uma amostra só, abaixo de 20% no Windows ou 40% no macOS, não quer dizer nada.</p>
<h2 id="cache-o-que-mais-corta">Cache: o que mais corta</h2>
<p>Build quente, depois de uma mudança de uma função na crate do app, mediana de 2:</p>
<table>
<thead>
<tr>
<th></th>
<th style="text-align: right">Linux</th>
<th style="text-align: right">Windows</th>
<th style="text-align: right">macOS</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sem cache</td>
<td style="text-align: right">5m03s</td>
<td style="text-align: right">5m25s</td>
<td style="text-align: right">3m04s</td>
</tr>
<tr>
<td>rust-cache</td>
<td style="text-align: right">2m17s</td>
<td style="text-align: right">2m06s</td>
<td style="text-align: right">1m17s</td>
</tr>
<tr>
<td>actions/cache simples</td>
<td style="text-align: right">2m07s</td>
<td style="text-align: right">2m25s</td>
<td style="text-align: right">1m14s</td>
</tr>
<tr>
<td>rust-cache + sccache</td>
<td style="text-align: right">2m05s</td>
<td style="text-align: right">2m29s</td>
<td style="text-align: right">1m30s</td>
</tr>
<tr>
<td>sccache sozinho</td>
<td style="text-align: right">3m55s</td>
<td style="text-align: right">4m22s</td>
<td style="text-align: right">1m38s</td>
</tr>
</tbody>
</table>
<p>Confiança média. O <a href="https://github.com/Swatinem/rust-cache">rust-cache</a> e o actions/cache simples diferem menos que a variação em todos os runners. A escolha entre eles é de tamanho e higiene: o rust-cache poda o que não foi usado e sempre recompila a crate do workspace; o actions/cache guarda tudo e chega antes ao <a href="https://docs.github.com/en/actions/reference/dependency-caching-reference">limite de 10 GB</a> por repositório.</p>
<p>Encher o cache não custa nada: o build frio com rust-cache levou 4m31s contra 5m03s sem cache. Uma dependência nova custa o mesmo que uma mudança no app: adicionar <code>chrono</code> custou 3 segundos no Linux e 15 no Windows.</p>
<p>Num monorepo de 16 plugins e 434 crates, a opção <code>cache-workspace-crates</code> do rust-cache economizou de 15 a 30 segundos, e o cargo quente ficou em 57 segundos no Linux contra 5m35s sem cache.</p>
<h2 id="não-gere-instaladores-no-pull-request">Não gere instaladores no pull request</h2>
<p>Build frio no Linux, mediana de 2, contra a linha de base com todos os bundlers:</p>
<table>
<thead>
<tr>
<th>Linux</th>
<th style="text-align: right"><code>tauri build</code></th>
<th style="text-align: right">empacotamento</th>
<th style="text-align: right">diretório de bundles</th>
</tr>
</thead>
<tbody>
<tr>
<td>deb, rpm e AppImage</td>
<td style="text-align: right">5m03s</td>
<td style="text-align: right">1m18s</td>
<td style="text-align: right">339 MB</td>
</tr>
<tr>
<td>só deb</td>
<td style="text-align: right">3m25s</td>
<td style="text-align: right">4 s</td>
<td style="text-align: right">17,7 MB</td>
</tr>
<tr>
<td>sem bundle</td>
<td style="text-align: right">3m24s</td>
<td style="text-align: right">2 s</td>
<td style="text-align: right">nada</td>
</tr>
</tbody>
</table>
<p>Confiança alta para o estágio de empacotamento, que ficou entre 1m07s e 1m20s em todo job Linux de quatro lotes, e média para o tempo total. É a maior economia individual no Linux, maior que qualquer flag de compilador.</p>
<p>No Windows, o empacotamento vai de 17 segundos com NSIS e MSI para 11 só com NSIS e 6 sem bundle. No macOS, de 12 segundos com <code>.app</code> e DMG para 2 só com <code>.app</code>. Nenhum vale tirar pelo tempo. O DMG vale tirar dos builds de pull request porque é o passo que falha: 1 de 24 jobs macOS morreu no <code>hdiutil</code>.</p>
<h2 id="o-perfil-de-release">O perfil de release</h2>
<p>Tempo do cargo no app maior, mediana das amostras agrupadas de dois lotes, com o binário em Linux, Windows e macOS:</p>
<table>
<thead>
<tr>
<th></th>
<th style="text-align: right">Linux</th>
<th style="text-align: right">Windows</th>
<th style="text-align: right">macOS</th>
<th>Binário</th>
</tr>
</thead>
<tbody>
<tr>
<td>Referência</td>
<td style="text-align: right">3m51s</td>
<td style="text-align: right">6m08s</td>
<td style="text-align: right">3m25s</td>
<td>10,3 / 10,4 / 7,2 MB</td>
</tr>
<tr>
<td><code>codegen-units = 1</code></td>
<td style="text-align: right">+26%</td>
<td style="text-align: right">+33%</td>
<td style="text-align: right">+54%</td>
<td>8,6 / 9,4 / 5,9 MB</td>
</tr>
<tr>
<td><code>lto = "fat"</code></td>
<td style="text-align: right">+14%</td>
<td style="text-align: right">-2%</td>
<td style="text-align: right">+12%</td>
<td>8,6 / 9,8 / 6,1 MB</td>
</tr>
<tr>
<td>Perfil <code>fast</code></td>
<td style="text-align: right">-18%</td>
<td style="text-align: right">-23%</td>
<td style="text-align: right">-19%</td>
<td>14,3 / 10,3 / 11,4 MB</td>
</tr>
</tbody>
</table>
<p>Confiança alta para <code>codegen-units = 1</code> e para o perfil <code>fast</code>, média para o LTO fat.</p>
<p>Uma unidade de codegen serializa a crate do app, a que carrega o runtime do Tauri, num núcleo só. O binário fica de 15 a 20% menor. Subir para 256 unidades foi na direção contrária do esperado, mais lento no Linux e no Windows, porque com 4 núcleos não há paralelismo para comprar além das 16 unidades padrão.</p>
<p>O LTO fat teve um dia ruim de +44% no macOS que o rerun não repetiu. O LTO thin não muda nada, nem em tempo nem em tamanho. O <code>opt-level</code> sozinho quase não move o tempo: um build Tauri gasta nas dependências, na monomorfização e no link, não no otimizador.</p>
<p>O perfil <code>fast</code>, com <code>opt-level = 1</code>, 256 unidades e LTO desligado, vale cerca de 20% do tempo do cargo, por um binário de 40 a 60% maior no Linux e no macOS. Cache e bundlers valem de 50 a 70%. Ele vem depois deles, e só em pull request.</p>
<p><code>panic = "abort"</code> é a economia de tamanho mais barata da lista: o binário foi de 10,4 para 4,9 MB no Windows, de 10,3 para 7,6 no Linux e de 7,2 para 5,0 no macOS, com o tempo dentro da variação. Confiança alta para o tamanho, baixa para o tempo. Só serve se o app não captura panics.</p>
<h2 id="o-que-não-ajudou">O que não ajudou</h2>
<p><strong>sccache.</strong> Sozinho é a pior opção com cache, porque sem o <code>target/</code> restaurado toda crate ainda precisa ser linkada e a crate final compilada. Em cima do rust-cache, não ajuda num commit comum: a crate do app recompila de qualquer jeito, e os uploads por objeto às vezes deixam a combinação atrás do rust-cache sozinho. O piloto mostrou um ganho de 3 vezes, mas só porque nada tinha mudado entre os runs.</p>
<p>Pior: o backend do GitHub Actions do sccache grava um item de cache por objeto compilado, e a API de cache limita a taxa por repositório. Matrizes de 24, 30 e 42 jobs perderam de 34 a 100% das escritas, e 4 jobs com sccache entre 10 ainda perderam a metade e derrubaram um save do rust-cache junto. O sccache só avisa como “Cache write errors” nas estatísticas, e o job termina verde com o cache pela metade. Confiança alta, com os contadores nos logs de 24 jobs.</p>
<p><strong>Trocar o linker.</strong> O Rust 1.90 em diante já usa <code>rust-lld</code> no Linux, então lld e mold caem no mesmo tempo. No Windows, <code>rust-lld.exe</code> não foi mais rápido.</p>
<p><strong>Cortar dependências.</strong> Tirar <code>reqwest</code> e <code>tokio</code>, 29 crates, não teve efeito mensurável, nem frio nem quente, em 4 núcleos.</p>
<p><strong>Compilação incremental.</strong> Custa de 7 a 8% num build frio e só paga quando o <code>target/</code> sobrevive entre builds, cortando um rebuild para 3 a 8 segundos no melhor caso.</p>
<h2 id="configuração-recomendada">Configuração recomendada</h2>
<p>Para o pull request: rust-cache, sem bundlers, perfil rápido. Medido junto, o <code>tauri build</code> levou 3m05s, 4m22s e 2m23s frio e 39 segundos, 1m42s e 39 segundos quente, no Linux, no Windows e no macOS. O job inteiro quente, com checkout, toolchain e apt, ficou entre 1m16s e 1m38s no Linux. Confiança média.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#E1E4E8">- </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Swatinem/rust-cache@v2</span></span>
<span class="line"><span style="color:#85E89D">  with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    workspaces</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">src-tauri -&gt; target</span></span>
<span class="line"><span style="color:#E1E4E8">- </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">pnpm tauri build --no-bundle -- --profile fast</span></span></code></pre>
<p>Para a release: o mesmo cache e o perfil como ele é entregue, com todos os bundlers. Um preset de release com sccache, lld e LTO thin por cima foi medido e saiu de 15 a 40 segundos mais lento quente que o cache sozinho. Não há o que acrescentar.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Foram 24 lotes de resultados entre 10 e 12 de setembro de 2026, em <code>ubuntu-24.04</code> e <code>windows-2022</code> com 4 núcleos, num repositório público, e <code>macos-14</code> com 3. Os três primeiros lotes rodaram com 2 núcleos, num repositório privado, e valem só como verificação do pipeline. Rust 1.98.1. Cada lote tem o seu relatório e um registro de tempo por job.</p>
<p>Cada célula é a mediana de 2 a 6 amostras, com chaves de cache separadas por experimento e por lote, depois que um lote piloto contaminou o seguinte com caches ainda válidos. O tempo de parede é o do <code>tauri build</code>, o do cargo vem do <code>--timings</code>, e o empacotamento é o resto, incluindo a sobrecarga da CLI do Tauri.</p>
<p>O que não medimos: toolchain nightly e frontend paralelo, runners maiores, contêineres prontos, compilação cruzada, e onde começa o limite de taxa do sccache com 1 ou 2 jobs. O app de teste é pequeno e o frontend é trivial; um SPA grande põe minutos na linha do frontend.</p>
<p>O repositório com os workflows, os registros e o livro de evidências é o <a href="https://github.com/vasfvitor/tauri-build">tauri-build</a>. Uma versão em inglês desse material está em rascunho para a documentação do Tauri, à qual já contribuímos com <a href="https://github.com/vasfvitor/tauri-tutorial-runner">tutoriais executáveis</a>.</p>
<p>O mesmo cuidado em medir antes de decidir aparece no post sobre as <a href="https://ai.virtuaires.com.br/blog/capas-geradas-com-l-system-e-sharp/">capas do blog</a>, geradas no build, e o GitHub Actions sem servidor nenhum é também o que publica as <a href="https://ai.virtuaires.com.br/blog/filas-de-analise-da-anvisa-como-feeds-atom/">filas da ANVISA como feeds</a>. Se a sua empresa mantém um app desktop em Tauri, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/desenvolvimento-open-source/">código aberto</a>.</p>
]]></content:encoded></item><item><title>Perguntar ao ChatGPT sobre os dados do sistema</title><link>https://ai.virtuaires.com.br/blog/perguntar-ao-chatgpt-sobre-os-dados-do-sistema/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/perguntar-ao-chatgpt-sobre-os-dados-do-sistema/</guid><pubDate>Thu, 22 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Um sistema de gestão hospitalar ligou o ChatGPT aos dados de cada cliente por MCP: como funciona, o que a IA pode e não pode fazer, e o que deu trabalho.</description><content:encoded><![CDATA[<p>Um sistema de gestão para o setor médico e hospitalar tinha o problema de sempre: os dados estavam lá, os relatórios saíam, e quase ninguém olhava. Pedir um relatório novo era um chamado ao suporte, e a resposta chegava depois da pergunta ter perdido o sentido.</p>
<p>Este texto registra como esse sistema ligou o ChatGPT aos dados de cada cliente, por um servidor <a href="https://modelcontextprotocol.io/specification/latest">MCP</a>, o Model Context Protocol. É um relato do que foi construído e do que a equipe aprendeu, não uma medição. O cliente não é citado, e os exemplos de perguntas são reais, com autorização.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Os usuários perguntam ao <strong>ChatGPT que já têm</strong>, pelo navegador, com o login do próprio sistema.</li>
<li>O servidor MCP é uma <strong>aplicação separada, só de leitura</strong>, em Python com FastMCP.</li>
<li>Ele lê uma <strong>cópia limitada dos dados</strong>, num bucket, e monta visões semânticas em cima dela.</li>
<li>O isolamento entre clientes é o <strong>mesmo da aplicação</strong> multi-tenant.</li>
<li>O SQL aberto é <strong>restrito a lacunas</strong>, porque a IA erra quando escreve SQL livre.</li>
<li>O que deu trabalho: <strong>respostas grandes demais</strong> e o corte de resposta do conector, sem limite documentado.</li>
<li>O que falta: <strong>cruzar tabelas</strong>, que hoje depende da IA.</li>
</ul>
<h2 id="o-problema-de-sempre">O problema de sempre</h2>
<p>Gerar relatórios. Ninguém olhava os dados. A lista de pedidos de relatório crescia e a maioria era uma pergunta de uma linha: quanto consumimos disso no fim de semana, quantos pacientes ficaram mais de 30 dias, onde o dinheiro de materiais está indo.</p>
<p>Cada uma dessas perguntas é uma consulta pequena sobre dados que o sistema já tem. O que faltava era um jeito de o usuário fazer a pergunta em português e receber a resposta sem esperar ninguém.</p>
<h2 id="como-ficou">Como ficou</h2>
<p>O usuário abre o ChatGPT no navegador, adiciona o sistema como <a href="https://developers.openai.com/api/docs/mcp">conector</a>, e entra com o login do próprio sistema, por OAuth. A partir daí, ele pergunta.</p>
<p>Por trás, um servidor MCP responde. Ele é uma aplicação separada da principal, em Python com <a href="https://gofastmcp.com/">FastMCP</a>, e só lê. Poderia editar, mas a necessidade era consumir dados específicos, não o banco inteiro.</p>
<p>Os dados que ele lê não são os do banco da aplicação. Um bucket guarda um espelho só de leitura dos dados crus, e o MCP cria visões semânticas em cima desse espelho: consumo por período, internações, gastos mensais, histórico. Essa cópia limitada já é uma camada de segurança por si só. Se o servidor MCP fosse comprometido, o que ele alcança é uma cópia parcial e só de leitura.</p>
<p>O isolamento entre clientes não foi reinventado. A aplicação é multi-tenant, cada cliente acessa o próprio banco dentro dela, e o controle de acesso é o da própria aplicação. O MCP herda as duas coisas do login.</p>
<h2 id="o-que-a-ia-pode-fazer">O que a IA pode fazer</h2>
<p>As ferramentas são muitas: consultas específicas, agregações, dados históricos, análises. Entre elas há uma de SQL, e é a que merece explicação.</p>
<p>Na primeira versão, a IA podia escrever SQL. Ela errava. Não sempre, mas o bastante para a resposta não ser confiável, e um relatório errado é pior que nenhum. A ferramenta de SQL virou outra coisa: um preencher de lacunas, com a estrutura da consulta fixa e sem todas as palavras-chave disponíveis. A IA escolhe o que vai em cada lacuna, não a forma da consulta.</p>
<p>O sistema também tem um painel para gerenciar prompts, onde os usuários guardam prompts grandes, elaborados para as análises que repetem. O MCP tem acesso a esses prompts, então uma análise que levou tempo para ser escrita uma vez vira uma pergunta curta nas vezes seguintes.</p>
<p>Três perguntas reais de usuários:</p>
<ul>
<li>“Historicamente, quantos pacientes internados tiveram alta depois de 30 dias de internação?”</li>
<li>“Qual material tem mais consumo nos fins de semana?”</li>
<li>“Consulte os gastos mensais de materiais, relacione com o consumo e veja onde o dinheiro vai e possíveis otimizações.”</li>
</ul>
<p>A terceira é o tipo de pergunta que antes virava um projeto. Aqui ela vira uma conversa, com a ressalva da seção sobre limites.</p>
<h2 id="o-que-deu-trabalho">O que deu trabalho</h2>
<p><strong>Mostrar muitos dados.</strong> A primeira tentação é devolver linhas. Uma resposta com centenas de linhas não cabe bem na conversa, e o conector do ChatGPT corta respostas grandes para caber no que ele chama de orçamento de resposta da ferramenta. Esse limite não está documentado. Desenvolvedores relatam o corte no <a href="https://community.openai.com/t/tool-response-truncation-on-mcp-connector-responses-that-previously-worked/1383071">fórum da OpenAI</a>, com a mensagem de erro e sem o número. A saída foi devolver menos: agregados em vez de linhas, campos a menos, visões já resumidas. É por isso que as ferramentas são visões semânticas, e não acesso à tabela.</p>
<p><strong>SQL livre.</strong> Já contado acima. A IA tende a errar no SQL aberto, e a resposta foi fechar o SQL em vez de tentar ensinar a IA a acertar.</p>
<p><strong>O de sempre com IA generativa.</strong> Respostas confiantes sobre dados que não vieram, formatação que muda de uma chamada para outra, e a necessidade de descrever cada ferramenta como se fosse para alguém que nunca viu o sistema. Nada disso é específico deste caso, e tudo isso tomou tempo.</p>
<h2 id="o-que-surpreendeu">O que surpreendeu</h2>
<p>Não foi uma resposta da IA. Foi o fato de o cliente poder fazer tudo isso com a IA que já pagava, sem um plano novo, sem um produto de BI e sem uma ferramenta a mais para os usuários aprenderem. O ChatGPT que a pessoa já abria todo dia passou a saber responder sobre o sistema dela.</p>
<h2 id="o-que-falta">O que falta</h2>
<p>Cruzar tabelas. Como o SQL é limitado a lacunas, nem sempre é possível rodar um join, e hoje é a IA que tem de combinar os resultados de duas ferramentas. Às vezes ela faz bem. Às vezes não dá. O uso está sendo rastreado para ver quais cruzamentos as pessoas pedem, e esses viram ferramentas próprias, com o join feito do lado do servidor.</p>
<p>Há outras limitações do mesmo SQL restrito, e a regra para todas é a mesma: ver o que as pessoas perguntam de verdade antes de abrir a ferramenta.</p>
<h2 id="como-sabemos">Como sabemos</h2>
<p>Este post é um relato da equipe que construiu e opera o servidor, revisado antes de publicar. Não há medições publicadas: nem tempo de resposta, nem número de usuários, nem taxa de acerto da IA. O que está aqui é o desenho, as decisões e os problemas encontrados. O corte de resposta do conector vem do relato da equipe e do fórum da OpenAI, e o limite em si não é documentado. O cliente não é citado, e o setor é dito de forma genérica.</p>
<p>A parte técnica de um servidor MCP escrito do zero, com as duas gerações do protocolo e OAuth para o ChatGPT, está no post sobre um <a href="https://ai.virtuaires.com.br/blog/servidor-mcp-escrito-a-mao-num-worker/">servidor MCP num Worker</a>, de um caso menor. Uma busca que roda inteira no navegador, sem servidor, é outra forma de <a href="https://ai.virtuaires.com.br/blog/busca-no-navegador-com-duckdb-wasm/">falar com os dados</a>.</p>
<p>Se os usuários do seu aplicativo querem perguntar ao ChatGPT ou ao Claude sobre os próprios dados, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/fale-com-seu-banco-de-dados/">falar com o seu banco de dados</a>. Quando a resposta precisa de revisão humana, ou de uma ferramenta nova em vez de SQL, entra o que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/solucoes-com-ia/">soluções com IA</a>.</p>
]]></content:encoded></item><item><title>As gambiarras do plano grátis da Cloudflare</title><link>https://ai.virtuaires.com.br/blog/gambiarras-do-plano-gratis-da-cloudflare/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/gambiarras-do-plano-gratis-da-cloudflare/</guid><pubDate>Tue, 20 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Oito contornos para um app caber no plano grátis da Cloudflare: o limite de cada um, o que medimos no D1, no Worker e no KV, e o teste que cobra a marca.</description><content:encoded><![CDATA[<p>Um app interno de estoque de um distribuidor roda no plano grátis da Cloudflare: Worker, D1 e KV. É uma prova de conceito, e o plano grátis era a escolha certa para ela. O preço é um conjunto de limites que o código precisa contornar, e cada contorno é um pedaço de código que seria mais simples se o limite não existisse.</p>
<p>Este texto registra como mantemos esses contornos, as gambiarras, num documento com id, fonte do limite, data da leitura, custo e condição de saída, e o que medimos em cada um. Os limites foram lidos na documentação da Cloudflare em setembro de 2026 e conferidos de novo em 9 de outubro.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>São <strong>8 contornos</strong> ativos hoje. Eram 13 em setembro. Quatro sumiram quando os relatórios saíram do KV e foram para o D1, e um quando o SDK ganhou o que o app pedia.</li>
<li>D1: <strong>100 parâmetros</strong> por consulta, <strong>50 consultas</strong> por requisição, sem transação interativa.</li>
<li>Worker: <strong>10 ms de CPU</strong>. Sete de nove chamadas medidas passaram disso, e nenhuma foi encerrada.</li>
<li>KV: <strong>1.000 escritas por dia</strong>, protegidas por uma política de oito uploads aceitos por hora.</li>
<li>Uma página do ERP, de até 500 registros, por chamada de sincronização.</li>
<li>Cada contorno tem id, fonte, data, custo e condição de saída.</li>
<li>Um <strong>teste de 20 linhas</strong> falha quando código e documento discordam.</li>
</ul>
<h2 id="por-que-um-registro-de-gambiarras">Por que um registro de gambiarras</h2>
<p>A regra é simples: entra no registro todo contorno que existe por causa de um limite externo. Plano grátis, falta de recurso numa plataforma, restrição da máquina. Não entra dívida técnica em geral.</p>
<p>Os contornos só podem morar nos adaptadores, no Worker ou na borda do navegador. Nunca no núcleo do domínio. Quando um limite muda o desenho de uma interface do domínio, o item explica por quê. E como limites mudam, cada item diz a fonte e a data em que o número foi lido.</p>
<p>O plano grátis foi uma decisão com alternativas descartadas por motivo. A Vercel proíbe uso comercial no plano grátis. O Supabase pausa projetos parados. O Render dorme em 15 minutos e leva cerca de um minuto para acordar, o que seria toda visita. Neon trocaria o dialeto para Postgres, e Turso acrescentaria conta, segredo e dependência para tirar só os contornos do D1, que moram num arquivo.</p>
<p>A saída está decidida também. O Workers pago custa 5 dólares por mês e remove quase todos os contornos. É a primeira opção quando o limite de CPU atrapalhar de novo. A segunda é uma VPS, e o código já está pronto para ela: nada da Cloudflare fora dos adaptadores, SQL que roda em SQLite comum, migrações em <code>.sql</code> puro e um adaptador Node testado a cada commit.</p>
<h2 id="d1-100-parâmetros-e-50-consultas">D1: 100 parâmetros e 50 consultas</h2>
<p>O D1 aceita no máximo <strong>100 parâmetros</strong> por consulta, nos dois planos, e <strong>50 consultas</strong> por invocação do Worker no plano grátis, 1.000 no pago. Os dois números estão na <a href="https://developers.cloudflare.com/d1/platform/limits/">página de limites do D1</a>.</p>
<p>A motivação é gravar uma página do ERP, de até 500 registros, com poucas consultas. Um <code>INSERT</code> por registro gastaria 500 consultas e estouraria o segundo limite. Então o adaptador faz <code>INSERT</code> de várias linhas, fatiado: cada fatia leva <code>floor(100 / colunas)</code> registros, 14 nas tabelas de 7 colunas e 16 na de 6. Uma página de 500 registros vira 36 consultas. O número de colunas vem do SQL da tabela, então acrescentar uma coluna reduz a fatia sozinho. Uma busca por muitos códigos vai em fatias de 80 valores, e os resultados são juntados e ordenados como uma consulta só devolveria.</p>
<p>Para o limite de 50, o adaptador anuncia quanto aceita por gravação: 14 vezes 48, ou 672 registros. São 48 comandos de registros, 1 de estado e 1 leitura. A sincronização para na primeira fronteira de página depois do teto, e uma página do ERP sempre cabe. Se alguém passar mais que o anunciado, a gravação falha antes de tocar no banco, com uma mensagem que cita o id da gambiarra.</p>
<p>Contamos cada comando de um <code>batch</code> como uma consulta. A documentação não diz o contrário, e é a leitura conservadora.</p>
<p>O D1 falso dos testes, em cima do SQLite do Node, impõe os dois limites. Tirar um contorno quebra um teste.</p>
<p>Uma alternativa não usada: mandar a página inteira como um parâmetro JSON e expandir com <code>json_each</code>. Seria uma consulta e um parâmetro. Não foi testada no D1, e o valor tem teto de 2 MB, perto do tamanho de uma página de clientes, cerca de 1,1 MB.</p>
<h2 id="sem-transação-a-porta-em-forma-de-lote">Sem transação: a porta em forma de lote</h2>
<p>O D1 não mantém transação aberta entre chamadas. Não existe <code>BEGIN</code>, lógica em código, <code>COMMIT</code>. A única unidade atômica é <code>batch()</code>, uma lista de comandos já prontos.</p>
<p>Isso importa porque registros e cursor precisam gravar juntos. Um cursor à frente dos dados é perda silenciosa: a próxima leitura parte de um ponto que não foi salvo. Então a interface do domínio não oferece transação. Oferece <code>gravar(recurso, registros, estado)</code>, e cada adaptador torna isso atômico do seu jeito: <code>batch</code> no D1, <code>BEGIN</code> e <code>COMMIT</code> no SQLite.</p>
<p>O custo é que toda operação atômica tem de caber num lote decidido antes de executar. Ler, decidir e gravar na mesma transação não existe. Onde isso fazia falta, a solução foi um token de revisão: o <code>UPDATE</code> do cabeçalho só casa com a revisão que a pessoa leu, e troca a revisão por um token único, que os comandos seguintes do lote exigem. Um contador não serviria, porque dois escritores chegariam ao mesmo número.</p>
<p>Essa gambiarra some com o D1, mas a porta em forma de lote pode ficar. É um desenho bom em qualquer banco.</p>
<h2 id="cpu-do-worker-o-limite-que-não-matou-ninguém">CPU do Worker: o limite que não matou ninguém</h2>
<p>O Workers grátis dá <a href="https://developers.cloudflare.com/workers/platform/limits/"><strong>10 ms de CPU</strong></a> por requisição. Espera de rede e de banco não conta. <code>JSON.parse</code>, laços e <code>JSON.stringify</code> contam.</p>
<p>Medimos antes de desenhar. Em 19 de setembro de 2026, em Node 24, uma página sintética de 500 clientes de 2,3 KB cada, 1,1 MB no total, passando por parse, mapeamento e serialização: 11,8 ms na primeira execução, fria; mediana de 6,3 ms aquecida; melhor caso 5,2 ms. Uma sincronização esporádica pega o isolate frio, então uma página cheia pode estourar os 10 ms sozinha. Por isso a sincronização processa <strong>uma página por chamada</strong> e devolve <code>completo: false</code> para quem chamou repetir.</p>
<p>Depois medimos no Worker de verdade, no mesmo dia, em produção no plano grátis, com <code>wrangler tail</code>. Uma amostra por rota:</p>
<table>
<thead>
<tr>
<th>Rota</th>
<th style="text-align: right">Registros</th>
<th style="text-align: right">CPU</th>
</tr>
</thead>
<tbody>
<tr>
<td>status do ERP</td>
<td style="text-align: right">0</td>
<td style="text-align: right">25 ms</td>
</tr>
<tr>
<td>clientes</td>
<td style="text-align: right">1</td>
<td style="text-align: right">16 ms</td>
</tr>
<tr>
<td>produtos</td>
<td style="text-align: right">383</td>
<td style="text-align: right">13 ms</td>
</tr>
<tr>
<td>tabelas de preço</td>
<td style="text-align: right">8</td>
<td style="text-align: right">31 ms</td>
</tr>
<tr>
<td>produtos por tabela, passo 1</td>
<td style="text-align: right">500</td>
<td style="text-align: right">8 ms</td>
</tr>
<tr>
<td>produtos por tabela, passo 2</td>
<td style="text-align: right">500</td>
<td style="text-align: right">24 ms</td>
</tr>
<tr>
<td>produtos por tabela, passo 3</td>
<td style="text-align: right">388</td>
<td style="text-align: right">5 ms</td>
</tr>
<tr>
<td>condições de pagamento</td>
<td style="text-align: right">0</td>
<td style="text-align: right">17 ms</td>
</tr>
<tr>
<td>transportadoras</td>
<td style="text-align: right">1</td>
<td style="text-align: right">17 ms</td>
</tr>
</tbody>
</table>
<p>Todas terminaram com <code>outcome: ok</code>. Nenhuma com <code>exceededCpu</code>.</p>
<p>O que a medição diz: sete de nove chamadas passaram de 10 ms e nenhuma foi encerrada, então o limite não é aplicado chamada a chamada com rigor. E o número não acompanha o volume: um registro custou 16 ms e 500 custaram 8 ms. O custo dominante parece ser fixo, do isolate frio e da primeira conexão de saída, e não o nosso parse. O que a medição não diz: é uma amostra por rota, a Cloudflare não documenta a tolerância, e uma página cheia de clientes de produção continua sem medição.</p>
<p>O corte é só entre páginas, porque dentro de uma página a ordem não é garantida. Cada corte desperdiça uma requisição ao ERP: para ver que a página acabou é preciso puxar o primeiro registro da seguinte, que é descartado e relido na próxima chamada.</p>
<p>Duas outras coisas saíram do Worker pelo mesmo limite. O parse da planilha de estoque mede de 25 a 55 ms em Node, então roda no navegador, e o servidor só valida o JSON e guarda a planilha original para reprocessar. E a primeira versão do cache do catálogo, um JSON único no KV mesclado por id, foi descartada no mesmo dia: a mescla media 6 ms com 600 registros e de 23 a 35 ms com 2.000.</p>
<h2 id="kv-mil-escritas-por-dia-e-quatro-gambiarras-que-sumiram">KV: mil escritas por dia, e quatro gambiarras que sumiram</h2>
<p>O KV grátis aceita <a href="https://developers.cloudflare.com/kv/platform/pricing/"><strong>1.000 escritas por dia</strong></a>. Um upload aceito gravava quatro chaves, e a conta era 8 uploads por hora, vezes 24, vezes 4: 768 escritas, com folga de 232. A política de oito uploads aceitos por hora ficou, agora decidida na mesma transação que publica a versão, no D1.</p>
<p>Em 26 de setembro, o histórico de relatórios saiu do KV e foi para o D1. Quatro gambiarras sumiram com isso: a chave separada para a versão atual, por causa da consistência eventual do KV, que podia devolver um índice de até 60 segundos atrás; a releitura do índice logo antes de gravar, porque o KV não tem transação nem escrita condicional; a verificação de que uma versão “não encontrada” existia de fato; e a espera de 1,1 e 2,2 segundos depois de um 429, porque o KV aceita uma escrita por segundo na mesma chave.</p>
<p>Ficou um custo aceito: tentativas simultâneas podem gravar arquivos antes de disputar a última vaga, e uma publicação que falha pode deixar um arquivo sem referência. A cota de uploads não é um teto absoluto de escritas no KV.</p>
<h2 id="as-que-ainda-não-precisaram-existir">As que ainda não precisaram existir</h2>
<p>O D1 cobra linha lida, não linha devolvida, e o plano grátis dá 5 milhões de linhas por dia. Uma busca com <code>LIKE '%texto%'</code> não usa índice e lê a tabela inteira. Com 2.000 clientes e 500 buscas por dia seriam 1 milhão de linhas, um quinto da cota. É uma projeção, não uso medido, e por isso o contorno ainda não existe. Quando a tabela passar de alguns milhares de linhas, as opções são espera entre teclas, busca por prefixo com índice, ou FTS5.</p>
<p>Uma gambiarra que já saiu era do nosso próprio SDK, não da Cloudflare. O <code>list()</code> do <a href="https://github.com/vasfvitor/mercos-sdk">mercos-sdk</a> devolvia um fluxo de registros e escondia onde uma página termina, e o corte por página precisa dessa fronteira. O app contava as requisições para descobrir. O SDK ganhou um <code>listPages()</code> na versão 0.4.0, o app migrou em 9 de outubro, e o contador, a seção no documento e o desperdício de uma requisição por corte saíram juntos. Como esse app moldou o SDK está no post sobre o <a href="https://ai.virtuaires.com.br/blog/um-sdk-moldado-pelo-primeiro-app/">SDK e o seu primeiro app</a>.</p>
<h2 id="o-teste-que-cobra-a-marca">O teste que cobra a marca</h2>
<p>Cada contorno leva no código o comentário <code>GAMBIARRA(id)</code>. Um teste percorre <code>src</code>, <code>web</code>, <code>scripts</code> e a extensão, coleta os ids e os compara com as seções do documento e com a tabela de mapa no topo dele. Ele falha em três casos: id no código sem seção, seção sem id no código, ou tabela e seções diferentes.</p>
<p>Hoje são 8 ids no código e 8 seções. Ao remover uma gambiarra, a seção e o comentário saem juntos, senão o teste avisa.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Os limites foram lidos na documentação da Cloudflare em 19 de setembro de 2026 para o D1 e em 26 de setembro para o KV, e conferidos de novo em 9 de outubro. A CPU foi medida em 19 de setembro com <code>wrangler tail --format json</code>, no Worker em produção no plano grátis, contra o ambiente de testes do ERP, uma amostra por rota. A medição em Node foi no mesmo dia, com uma página sintética de 1,1 MB.</p>
<p>O que não medimos: uma página cheia de produção no Worker, a tolerância do limite de CPU, e o uso da cota de leituras do D1.</p>
<p>O app é de um cliente e não é público. O ERP é o Mercos, e o que a API dele faz está no post sobre a <a href="https://ai.virtuaires.com.br/blog/api-mercos-o-que-testamos/">API do Mercos</a>. O mesmo Worker serve o <a href="https://ai.virtuaires.com.br/blog/servidor-mcp-escrito-a-mao-num-worker/">servidor MCP</a> do app.</p>
<p>Se a sua empresa precisa ligar um ERP a um sistema próprio, com sincronização que retoma de onde parou, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>. O SDK que o app usa é <a href="https://ai.virtuaires.com.br/solucoes/desenvolvimento-open-source/">código aberto</a>.</p>
]]></content:encoded></item><item><title>Um servidor MCP escrito à mão num Worker</title><link>https://ai.virtuaires.com.br/blog/servidor-mcp-escrito-a-mao-num-worker/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/servidor-mcp-escrito-a-mao-num-worker/</guid><pubDate>Tue, 20 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Servidor MCP sem SDK, num Worker grátis da Cloudflare: duas gerações do protocolo, escrita em dois passos presa por um código e OAuth para o ChatGPT.</description><content:encoded><![CDATA[<p>Um app interno de estoque de um distribuidor ganhou um servidor <a href="https://modelcontextprotocol.io/specification/latest">MCP</a>, o Model Context Protocol, para que um assistente de IA consulte o estoque e o catálogo e ponha itens num <a href="https://ai.virtuaires.com.br/blog/orcamento-no-mercos-pela-api/">orçamento que já existe no ERP</a>. É a parte técnica de falar com os seus dados, num caso pequeno: uma chave de acesso compartilhada, sete ferramentas, um Worker no plano grátis da Cloudflare.</p>
<p>Este texto registra como o servidor foi escrito sem biblioteca, o que foi medido com <code>curl</code> e com testes, e o que ainda não foi testado com um cliente de verdade. O código é de 25 de setembro a 5 de outubro de 2026.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li><strong>Sem SDK</strong>: 216 linhas de transporte e 493 de ferramentas, que rodam iguais no Worker e em Node.</li>
<li>As <strong>duas gerações do protocolo</strong> no mesmo <code>POST</code>, com a geração lida de cada mensagem.</li>
<li><strong>Sete ferramentas</strong>: quatro de leitura, uma prévia, uma gravação e um histórico de envios.</li>
<li>A gravação em <strong>dois passos</strong>, presa por um código derivado da prévia.</li>
<li>Um resultado incerto <strong>não convida a repetir</strong>, porque repetir uma escrita é o que o fluxo recusa.</li>
<li><strong>OAuth</strong> só para o ChatGPT, com uma biblioteca da Cloudflare de 367 KiB empacotados.</li>
<li>Testado com <code>curl</code> e 14 testes automatizados. ChatGPT e Claude.ai de verdade, <strong>não</strong>.</li>
</ul>
<h2 id="por-que-sem-biblioteca">Por que sem biblioteca</h2>
<p>O MCP por HTTP é JSON-RPC 2.0 num <code>POST</code>. Para servir ferramentas bastam seis métodos: <code>initialize</code>, a notificação <code>initialized</code>, <code>ping</code>, <code>server/discover</code>, <code>tools/list</code> e <code>tools/call</code>. Sem sessão e sem SSE, o servidor é uma função que recebe uma mensagem e devolve outra.</p>
<p>A SDK oficial traz o transporte para Node e Express e a dependência do Zod. O pacote <code>agents</code> da Cloudflare prende o código ao Workers. Nenhum dos dois cabia num app cujo roteador precisa rodar igual no Worker e em Node, com SQLite, porque a saída para uma VPS está decidida de antemão. Então o transporte foi escrito à mão: 216 linhas, mais 493 das ferramentas. Se um cliente real não conversar, a troca pela SDK mantém as ferramentas.</p>
<h2 id="duas-gerações-do-protocolo-no-mesmo-endpoint">Duas gerações do protocolo no mesmo endpoint</h2>
<p>Em setembro de 2026 não dava para saber qual geração do protocolo os clientes mandam. A de 2025 começa por <code>initialize</code> e segue com <code>tools/*</code>, com lotes JSON-RPC só na versão 2025-03-26. A de 2026-07-28 não tem <code>initialize</code>: tem <code>server/discover</code>, um <code>_meta</code> com versão e capacidades em toda requisição, e os cabeçalhos <code>Mcp-Method</code> e <code>Mcp-Name</code>, que precisam bater com o corpo.</p>
<p>O servidor aceita as quatro versões, 2026-07-28, 2025-11-25, 2025-06-18 e 2025-03-26, e lê a geração da própria mensagem. <code>initialize</code> é sempre legada. Um <code>_meta</code> com versão, ou o cabeçalho <code>MCP-Protocol-Version</code> com 2026-07-28, é moderna. Na moderna, cabeçalho e corpo diferentes dão o erro <code>-32020</code>, versão desconhecida dá <code>-32022</code>, e método desconhecido dá 404. Na legada, os erros de aplicação vão em 200, porque o cliente antigo trata outro status como falha de transporte.</p>
<p>Dois desvios de propósito. O <code>initialize</code> responde 2025-06-18, a versão que mais clientes conhecem, em vez da última legada. E um lote aceita no máximo 10 mensagens e uma única chamada de ferramenta, para que uma escrita não divida com outra o orçamento de 50 consultas do D1 por requisição.</p>
<p>Uma coisa medida com um cliente real: um Claude Code na geração 2026-07-28 recusou o <code>tools/list</code> sem dois campos que a especificação lista como opcionais, por quanto tempo a lista pode ficar em cache e que ela é igual para todos. O servidor passou a enviar os dois.</p>
<h2 id="só-bearer-e-o-porquê">Só Bearer, e o porquê</h2>
<p>A rota só abre com a chave de acesso como Bearer, ou com um token OAuth que o Worker já validou. O cookie de sessão do app não vale nessa rota: uma página aberta no navegador poderia chamar ferramentas por CSRF. Um <code>Origin</code> presente e diferente da própria origem dá 403. Sem Bearer, 401 com <code>www-authenticate</code>. Corpo que não é JSON, 415. Corpo acima de 256 KiB, 413. Qualquer método que não seja <code>POST</code>, 405.</p>
<h2 id="ferramentas-o-que-o-modelo-pode-fazer">Ferramentas: o que o modelo pode fazer</h2>
<p>São sete: consultar o estoque, buscar produto, buscar cliente, listar tabelas de preço, conferir itens, gravar itens e listar os envios de itens. A busca de estoque usa a mesma função e os mesmos filtros da tela do app, que desceram para o núcleo do domínio para isso.</p>
<p>Cada ferramenta devolve um resumo em português, uma linha por item, e o JSON completo em <code>structuredContent</code>. Os limites são 50 produtos, 200 tabelas e 100 itens por chamada, pelo teto de saída de 25 mil tokens do Claude Code. Um argumento inválido volta como resultado com <code>isError</code>, e não como erro de protocolo <code>-32602</code>, para o modelo corrigir e chamar de novo.</p>
<h2 id="escrita-em-dois-passos-presa-por-um-código">Escrita em dois passos, presa por um código</h2>
<p>A única ferramenta que escreve é a que grava itens num orçamento do ERP. Ela nunca é o primeiro passo.</p>
<ol>
<li><code>conferir_itens</code> resolve cada item contra o catálogo e contra o orçamento lido do ERP, sem gravar nada, e devolve o que iria, item por item, mais um código de 16 caracteres hexadecimais: o SHA-256 do id do orçamento, do ambiente e do corpo que seria enviado.</li>
<li>A descrição da ferramenta manda o modelo mostrar a prévia e esperar a confirmação da pessoa.</li>
<li><code>gravar_itens</code> exige o código. Ele recalcula o SHA-256 sobre o que iria agora e, se a lista mudou, responde 409 antes de qualquer requisição ao ERP.</li>
</ol>
<p>O que o código prova é que a lista não mudou entre a prévia e a gravação. Ele não prova que uma pessoa leu a prévia. Isso depende do cliente de IA seguir a instrução.</p>
<p>Um resultado incerto, quando o envio saiu e a resposta não chegou, volta como texto que diz “os itens podem ter entrado, confira na tela e não chame de novo”, sem a marca de erro, porque <code>isError</code> convida o modelo a repetir. A prévia seguinte do mesmo orçamento avisa que há um envio anterior sem confirmação.</p>
<h2 id="oauth-para-quem-não-manda-cabeçalho">OAuth para quem não manda cabeçalho</h2>
<p>O ChatGPT não manda cabeçalho fixo. Ele só conecta um servidor MCP por OAuth. A escolha foi a biblioteca <a href="https://github.com/cloudflare/workers-oauth-provider">workers-oauth-provider</a> da Cloudflare, na versão 1.2.1, com a própria chave de acesso como login, em vez de um provedor externo com lista de e-mails.</p>
<p>O fluxo tem os metadados da <a href="https://www.rfc-editor.org/rfc/rfc9728">RFC 9728</a> em <code>/.well-known/oauth-protected-resource</code>, registro de cliente por CIMD, que o ChatGPT prefere, e por DCR para quem não tem CIMD, e um escopo só. A página de autorização mostra o nome do cliente e o domínio verificado, não tem script, tem CSP própria, e uma chave errada responde 401 sem gastar a página.</p>
<p>Três limites assumidos. A biblioteca só roda no Worker, então em Node vale só o Bearer, e a saída para uma VPS é o servidor de autorização da SDK oficial ou perder o ChatGPT. O hash da chave vai dentro do token, então trocar a chave derruba todo token. E todos entram com a mesma chave, então não há como tirar uma pessoa só.</p>
<p>O pacote implantado ficou em 367 KiB, 89 KiB com gzip.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Em 25 de setembro de 2026, com <code>curl</code> contra o servidor local e os tokens do ambiente de testes do ERP: <code>initialize</code>, 202 na notificação, <code>tools/list</code> com as sete ferramentas, <code>server/discover</code> e <code>conferir_itens</code> na geração moderna, com a prévia de um rascunho em 0,6 segundo, e os erros 405, 401, 403 e 400 para versão desconhecida e <code>Mcp-Name</code> errado.</p>
<p>Oito testes cobrem o transporte, as duas gerações, cada ferramenta e a escrita em dois passos, com dublês do ERP. Seis testes percorrem o OAuth inteiro em Node, com um KV em memória: o desafio 401, os dois metadados, o registro, a tela, a chave errada, o PKCE errado recusado, o refresh e a chave trocada derrubando o token. Duas mutações conferidas: tirar a checagem da chave ou a autorização do token faz testes falharem.</p>
<p>O que não verificamos: o Claude.ai e o ChatGPT de verdade, inclusive se o plano do usuário tem conector personalizado e qual registro de cliente ele usa; o CIMD em si, que em Node fica desligado; e a CPU do Worker na validação do token e no uso real.</p>
<p>O app é de um cliente e não é público. O ERP é o Mercos, e o mesmo app rendeu o post sobre as <a href="https://ai.virtuaires.com.br/blog/gambiarras-do-plano-gratis-da-cloudflare/">gambiarras do plano grátis da Cloudflare</a>.</p>
<p>Se os usuários do seu aplicativo querem perguntar ao ChatGPT ou ao Claude sobre os próprios dados, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/fale-com-seu-banco-de-dados/">falar com o seu banco de dados</a>. Quando a resposta precisa de revisão humana antes de uma escrita, como aqui, entra o que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/solucoes-com-ia/">soluções com IA</a>.</p>
]]></content:encoded></item><item><title>Busca no navegador com DuckDB-WASM, sem backend</title><link>https://ai.virtuaires.com.br/blog/busca-no-navegador-com-duckdb-wasm/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/busca-no-navegador-com-duckdb-wasm/</guid><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Por que a nossa busca nos dados da ANVISA baixa os Parquet inteiros em vez de ler por HTTP Range no GitHub Pages: três falhas medidas e o que fizemos no lugar.</description><content:encoded><![CDATA[<p>O <a href="https://contem.abelhaninja.de/">contém.</a> é uma busca de produtos regularizados na ANVISA: alimentos, suplementos e saneantes, por número de processo, CNPJ, registro, nome, marca ou empresa. Ele não tem servidor. A página é estática, os dados são arquivos Parquet publicados todo dia, e a consulta roda no navegador com o <a href="https://duckdb.org/docs/api/wasm/overview">DuckDB-WASM</a>.</p>
<p>Este texto registra como o plano de ler só o pedaço necessário de cada arquivo, por HTTP Range, falhou no GitHub Pages, e o que fizemos no lugar. As medições são de 6 de outubro de 2026, com a versão 1.33.1-dev57.0 do DuckDB-WASM.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Site estático no GitHub Pages. O DuckDB-WASM consulta Parquet <strong>dentro do navegador</strong>.</li>
<li>O plano era ler por <strong>HTTP Range</strong>, e isso falhou de três jeitos no Pages.</li>
<li>Uma busca por texto via Range transferiu <strong>5,3 MB de um arquivo de 3,25 MB</strong>.</li>
<li>No Chrome, um fetch com Range deixou o fetch seguinte com um corpo de <strong>1 byte</strong>.</li>
<li>A solução: baixar cada arquivo inteiro, conferir o tamanho e a assinatura, e registrar como buffer.</li>
<li>Reordenar a tabela de detalhe fez a busca de um produto tocar <strong>um grupo de linhas</strong>. O arquivo foi de 1,57 para 1,74 MB.</li>
<li>A primeira visita baixa cerca de 5 MB de motor mais as tabelas. Depois, <strong>zero rede</strong>.</li>
</ul>
<h2 id="de-onde-vêm-os-dados">De onde vêm os dados</h2>
<p>Os arquivos vêm do pipeline descrito no post sobre os <a href="https://ai.virtuaires.com.br/blog/dados-abertos-anvisa-o-que-os-csvs-nao-dizem/">dados abertos da ANVISA</a>: um processo diário converte os CSVs da ANVISA em Parquet e publica um <code>manifest.json</code> com o caminho, as linhas, os bytes e o SHA-256 de cada tabela.</p>
<p>O site só conhece o manifesto. Os caminhos mudam a cada build e são imutáveis, então um 404 num caminho de dados quer dizer que houve deploy no meio da sessão. Nesse caso o site relê o manifesto sem cache e tenta uma vez com o caminho novo.</p>
<p>Ele usa quatro tabelas: alimentos, o detalhe das apresentações de alimentos, saneantes e produtos irregulares. Cosméticos, com 24 MB, ficou de fora por enquanto.</p>
<table>
<thead>
<tr>
<th>Tabela</th>
<th style="text-align: right">Parquet</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alimentos</td>
<td style="text-align: right">3,25 MB</td>
</tr>
<tr>
<td>Detalhe das apresentações</td>
<td style="text-align: right">1,74 MB</td>
</tr>
<tr>
<td>Saneantes</td>
<td style="text-align: right">3,7 MB</td>
</tr>
<tr>
<td>Produtos irregulares</td>
<td style="text-align: right">1,1 MB</td>
</tr>
</tbody>
</table>
<h2 id="o-plano-ler-só-o-pedaço-necessário">O plano: ler só o pedaço necessário</h2>
<p>O Parquet é feito para isso. O arquivo termina num rodapé que diz onde cada grupo de linhas está e o mínimo e o máximo de cada coluna no grupo. Um leitor que suporta HTTP Range lê o rodapé, descobre quais grupos interessam e baixa só esses bytes. Com os arquivos ordenados por CNPJ em grupos de 8.192 linhas, uma busca por CNPJ leria o rodapé e um grupo.</p>
<p>Num teste direto contra o GitHub Pages, um pedido com Range respondeu <strong>206</strong>, com <code>Content-Range</code>, <code>Access-Control-Allow-Origin: *</code> e cache de 10 minutos. Parecia resolvido.</p>
<h2 id="três-jeitos-de-falhar-no-github-pages">Três jeitos de falhar no GitHub Pages</h2>
<p>Em produção, o leitor HTTP do DuckDB-WASM quebrou de formas que o servidor local não reproduzia.</p>
<p><strong>O HEAD com Range responde 200.</strong> O DuckDB-WASM tem um modo <code>reliableHeadRequests</code> que pergunta o tamanho do arquivo com um HEAD. O Pages responde esse HEAD com 200 e o tamanho inteiro, em vez de 206, e o modo falha ao abrir o arquivo.</p>
<p><strong>O Firefox falha no GET com Range.</strong> O leitor HTTP do worker é um XHR síncrono. Contra o Pages, no Firefox, o GET com Range termina em “NetworkError: A network error occurred”. No Chrome 154, o mesmo pedido funciona.</p>
<p><strong>Uma busca por texto lê mais que o arquivo.</strong> Uma busca por nome toca todos os grupos de linhas, e as respostas parciais do XHR não entram no cache. A cada carregamento, a busca transferiu 5,3 MB para um arquivo de 3,25 MB.</p>
<p>E mais quatro detalhes que medimos no caminho:</p>
<ul>
<li>Nessa versão, o leitor só faz Range com <code>forceFullHTTPReads: false</code> definido de forma explícita. Sem isso, ele baixa o arquivo inteiro a cada abertura.</li>
<li>A resposta inteira vem comprimida com gzip, 3.218.370 bytes no fio para um arquivo de 3.254.645. A resposta com Range vem sem compressão.</li>
<li>No Chrome, depois de um <code>fetch</code> com <code>Range: bytes=0-0</code>, o <code>fetch</code> simples seguinte da mesma URL devolve 200 com um corpo de <strong>1 byte</strong>. Só o terceiro vem inteiro. Um visitante de uma página que lê por Range pode receber um arquivo truncado numa leitura posterior.</li>
<li>Um parâmetro preparado num <code>BETWEEN</code> de inteiros impediu o descarte de grupos: a consulta de detalhe leu 1,5 MB com <code>?</code> e cerca de 1 MB com literais. E mesmo quando o descarte funciona, o leitor lê adiante até o fim do arquivo.</li>
</ul>
<h2 id="a-solução-baixar-o-arquivo-inteiro-e-conferir">A solução: baixar o arquivo inteiro e conferir</h2>
<p>Os arquivos são pequenos de propósito, então o download inteiro é mais simples e mais barato que o Range. O worker do DuckDB não faz HTTP nenhum.</p>
<ol>
<li>A página faz <code>fetch()</code> de cada tabela na primeira vez que a seção abre. O progresso usa o total do manifesto, porque o <code>Content-Length</code> é o do corpo comprimido.</li>
<li>O tamanho recebido tem que ser igual ao campo <code>bytes</code> do manifesto, e os últimos quatro bytes têm que ser <code>PAR1</code>. Um Parquet truncado não passa.</li>
<li>Se não bater, o site baixa de novo com <code>cache: "reload"</code>, ignorando o cache HTTP.</li>
<li>O buffer é registrado no DuckDB com <code>registerFileBuffer</code>, com o id do build no nome, e vira uma <code>VIEW</code>. Um build novo não sobrescreve o que uma consulta em andamento está lendo.</li>
</ol>
<p>Depois disso, toda consulta é local. O caminho é imutável, então o cache HTTP do navegador guarda o arquivo até o próximo build.</p>
<p>Dois detalhes de compatibilidade: o site usa os bundles <code>mvp</code> e <code>eh</code> do DuckDB-WASM, porque o <code>coi</code>, com threads, exige cabeçalhos COOP e COEP que o Pages não envia. E as datas voltam como texto, via <code>strftime</code> no SQL, porque o tipo temporal do Arrow muda conforme a versão da biblioteca.</p>
<h2 id="ordenar-o-arquivo-para-quem-lê-por-http">Ordenar o arquivo para quem lê por HTTP</h2>
<p>O Range ainda importa para quem lê os mesmos arquivos com o DuckDB nativo, direto da URL. Ali medimos duas coisas.</p>
<p>Só a <strong>primeira</strong> chave de ordenação descarta grupos. A tabela de alimentos é ordenada por CNPJ e depois por processo, então uma busca por CNPJ lê um grupo e uma busca por processo lê o arquivo todo.</p>
<p>A tabela de detalhe tinha grupos desiguais, de 92 a 411 KB, porque os produtos recentes trazem a tabela nutricional em texto longo. A única consulta do site nessa tabela é “todas as apresentações de um produto”. Reordenada por produto, em grupos de 2.048 linhas, ela ficou com 33 grupos, mediana de 25 KB e máximo de 117 KB. Uma busca de produto toca um grupo, no máximo dois entre 50 mil produtos. O arquivo cresceu de 1,57 para 1,74 MB.</p>
<h2 id="primeira-visita-e-atualização">Primeira visita e atualização</h2>
<p>A primeira visita baixa cerca de 5 MB do motor, servido pelo jsDelivr, mais a tabela da seção aberta. Depois, os arquivos ficam no cache.</p>
<p>O site se refaz à noite, três vezes, às 22:30, 01:30 e 04:30 UTC. Ele compara o id do build no manifesto com o que o site usou e só publica quando mudou. O cron existe só para o dicionário de dados e a página “sobre”, gerados no build. A busca lê o manifesto ao vivo e não depende de republicar.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>As medições são de 6 de outubro de 2026, com <code>@duckdb/duckdb-wasm</code> 1.33.1-dev57.0, no Chrome 154 e no Firefox, contra o GitHub Pages e contra um build local dos dados. Os dados foram conferidos de novo no build publicado naquele dia. Para reproduzir o caminho do Pages em desenvolvimento, usamos um servidor Node de 61 linhas que responde CORS, HEAD e Range. O <code>http.server</code> do Python não faz nenhum dos três.</p>
<p>O que não medimos: a versão do Firefox não foi anotada. O envenenamento do cache foi reproduzido só no Chrome. Não testamos Range num servidor que não fosse o Pages, e não medimos tempos de carregamento.</p>
<p>O site não tem vínculo com a ANVISA. O código está em <a href="https://github.com/vasfvitor/anvisa-dash">anvisa-dash</a>, com licença MIT.</p>
<p>Uma consulta no navegador, sem servidor e sem enviar dado nenhum, é uma das formas de <a href="https://ai.virtuaires.com.br/solucoes/fale-com-seu-banco-de-dados/">falar com os seus dados</a> que entregamos. E manter um site assim, aberto e com dados públicos, é parte do nosso trabalho em <a href="https://ai.virtuaires.com.br/solucoes/desenvolvimento-open-source/">código aberto</a>.</p>
]]></content:encoded></item><item><title>Um SDK moldado pelo primeiro app que o usou</title><link>https://ai.virtuaires.com.br/blog/um-sdk-moldado-pelo-primeiro-app/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/um-sdk-moldado-pelo-primeiro-app/</guid><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Como o primeiro app que usou o nosso SDK do Mercos mudou a API dele: dez achados medidos, seis mudanças na 0.4.0, dez versões em dois dias e o que ficou fora.</description><content:encoded><![CDATA[<p>Publicamos o <a href="https://github.com/vasfvitor/mercos-sdk">mercos-sdk</a>, um cliente em TypeScript para a API do Mercos, em 19 de setembro de 2026. No dia seguinte, o primeiro app que o usou, um app interno de estoque de um distribuidor, entregou uma lista de dez achados, cada um marcado como medido ou inferência, com uma sugestão para o SDK. Seis viraram mudança no mesmo dia.</p>
<p>Este texto registra o que esse primeiro uso mudou no desenho do SDK, o que recusamos e por quê, e o que o app adotou ou não. O que a API do Mercos faz está no post sobre a <a href="https://ai.virtuaires.com.br/blog/api-mercos-o-que-testamos/">API do Mercos</a>. Aqui o assunto é o processo.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li><strong>10 versões em cerca de dois dias</strong>, da 0.1.0 à 0.4.3, com 34 commits.</li>
<li>O app anotou <strong>10 achados</strong>, todos medidos no sandbox, cada um com uma sugestão.</li>
<li>A 0.4.0 respondeu a <strong>6</strong>: gancho de observação, listagem por página, busca por id, criar e ler, intervalo mínimo, máscara de token e cliente congelado.</li>
<li>Dissemos <strong>não</strong> a duas partes dos pedidos: espaçamento adaptativo, e leitura por id no sandbox com listagem em produção.</li>
<li>O app <strong>não adotou tudo</strong>: criar e ler, e intervalo mínimo, ficaram de fora. A listagem por página só entrou 19 dias depois.</li>
<li>No npm, publicação confiável com fila de staging. A <code>latest</code> segue a <strong>ordem de aprovação</strong>, não o semver.</li>
<li>A 0.4.2 e a 0.4.3 ficaram <strong>19 dias na fila</strong> de staging antes de serem aprovadas.</li>
</ul>
<h2 id="o-ponto-de-partida-uma-requisição-por-vez">O ponto de partida: uma requisição por vez</h2>
<p>A 0.1.0 tinha o que a documentação do Mercos e os nossos testes no sandbox pediam: <code>fetch</code> nativo sem dependências, uma fila única de requisições por cliente, repetição automática no 429 com teto de espera, paginação por <code>alterado_apos</code> com o cursor recuado para não perder registros do mesmo segundo, até duas repetições de leitura em falha de rede e nunca de escrita, um tipo de erro que normaliza os quatro formatos do campo <code>erros</code>, e tipos para as 169 operações documentadas.</p>
<p>A fila existe porque o limite de requisições do Mercos é global. Disparar em paralelo só disputa o mesmo limite. No 429, o SDK espera o tempo que a resposta pede, mais meio segundo.</p>
<p>O primeiro commit é de 19 de setembro à 1h07.</p>
<h2 id="os-dez-achados-do-app">Os dez achados do app</h2>
<p>Em 20 de setembro, o app sincronizou seis cadastros e enviou um pedido no sandbox, com a 0.1.0 e depois com a 0.3.0, e anotou o que viu:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Achado</th>
<th>Sugestão para o SDK</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>O Mercos soma o IPI do cadastro ao item do pedido</td>
<td>documentar a conta</td>
</tr>
<tr>
<td>2</td>
<td>O ST do cadastro não entra no pedido</td>
<td>documentar</td>
</tr>
<tr>
<td>3</td>
<td>“Sem valor” vem como <code>0</code> ou <code>""</code>, não como <code>null</code></td>
<td>normalizar ou documentar</td>
</tr>
<tr>
<td>4</td>
<td>O <code>POST /v2/pedidos</code> não devolve o total</td>
<td>utilitário de leitura de volta</td>
</tr>
<tr>
<td>5</td>
<td>Ler pedido por id só existe no sandbox</td>
<td>utilitário que cubra os dois ambientes</td>
</tr>
<tr>
<td>6</td>
<td>Quase toda chamada leva um 429 antes do 200</td>
<td>espaçar as requisições no cliente</td>
</tr>
<tr>
<td>7</td>
<td>A máscara de token altera os dados da resposta</td>
<td>mascarar só mensagens</td>
</tr>
<tr>
<td>8</td>
<td>Não há como observar as requisições sem embrulhar o <code>fetch</code></td>
<td>gancho de observação</td>
</tr>
<tr>
<td>9</td>
<td>O cliente aceita ter um recurso sobrescrito</td>
<td>congelar o objeto</td>
</tr>
<tr>
<td>10</td>
<td>Quantidade fracionada e item sem tabela são aceitos</td>
<td>nada, só registro</td>
</tr>
</tbody>
</table>
<p>Os achados 1, 2, 3 e 10 são comportamento da API, e ficaram como documentação. Dois dos outros merecem detalhe.</p>
<p>O achado 7 veio de um teste com o token <code>"a"</code>. O SDK trocava o texto do token por <code>***</code> em tudo que devolvia, e o campo <code>total</code> do pedido voltou como <code>tot***l</code>. Com tokens reais, longos e aleatórios, a colisão é improvável. Mesmo assim, a máscara reescrevia um JSON que a aplicação ia usar como dado.</p>
<p>O achado 9 foi erro do app, e por isso virou mudança no SDK. O app fez <code>Object.assign</code> sobre o cliente para embrulhar um recurso, o recurso novo chamava o antigo, e depois do <code>assign</code> a chamada apontava para ele mesmo. Recursão infinita, que só apareceu no envio real. Um cliente congelado falha na hora da escrita, com a causa ao lado.</p>
<h2 id="o-que-entrou-na-040-e-o-que-mudou-no-caminho">O que entrou na 0.4.0, e o que mudou no caminho</h2>
<p>A 0.4.0 saiu às 20h36 do mesmo dia e respondeu aos achados 4 a 9: <code>createAndRead</code> em pedidos, <code>find</code> em todo recurso, a opção <code>minIntervalMs</code>, a máscara só no que alimenta um erro, a opção <code>onAttempt</code> com <code>listPages</code>, e o cliente congelado.</p>
<p>Três pedidos mudaram de forma no caminho.</p>
<p><strong><code>onRequest</code> virou <code>onAttempt</code>.</strong> O app pediu um gancho <code>onRequest</code> ou <code>onResponse</code>. O nome ficou <code>onAttempt</code> porque um timeout não tem resposta, e o gancho dispara em toda tentativa, com método, rota modelo, status, duração, número da tentativa e a espera aplicada. O evento não carrega corpo, query nem cabeçalho, então um log dele não vaza token nem dado. E uma exceção dentro do gancho é engolida: um observador não pode quebrar uma requisição.</p>
<p><strong><code>find</code> lista sempre.</strong> O app sugeriu leitura por id no sandbox e listagem em produção, escondendo a diferença de ambiente. O SDK lista nos dois. Com um caminho só, o que o sandbox e a homologação exercitam é o que produção roda. O <code>since</code> é obrigatório, porque sem ele uma busca sem resultado percorreria o histórico inteiro da conta.</p>
<p><strong><code>minIntervalMs</code> não é adaptativo.</strong> O app sugeriu que o SDK aprendesse o intervalo a partir do <code>retry-after</code> do 429. Recusamos. O limite de produção pode ser outro, e um intervalo aprendido de um único 429 poderia deixar um cliente lento para sempre. Um número explícito é previsível. A medição no sandbox, seis leituras seguidas com cinco 429 contra zero com 5,5 segundos de intervalo, está no post sobre a API.</p>
<h2 id="o-que-o-app-adotou-e-o-que-não">O que o app adotou, e o que não</h2>
<table>
<thead>
<tr>
<th>Mudança</th>
<th>O app adotou?</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>find</code></td>
<td>sim</td>
</tr>
<tr>
<td>máscara só em erros</td>
<td>sim, vem com a versão</td>
</tr>
<tr>
<td><code>onAttempt</code></td>
<td>sim, no lugar do embrulho do <code>fetch</code></td>
</tr>
<tr>
<td>cliente congelado</td>
<td>sim, a porta virou um objeto novo em volta do cliente</td>
</tr>
<tr>
<td><code>createAndRead</code></td>
<td>não: se a leitura falhar, o id do pedido se perde</td>
</tr>
<tr>
<td><code>minIntervalMs</code></td>
<td>não: falta medir qual intervalo o Mercos aceita</td>
</tr>
<tr>
<td><code>listPages</code></td>
<td>só em 9 de outubro, com a 0.4.3</td>
</tr>
</tbody>
</table>
<p>A recusa do <code>createAndRead</code> tinha razão, e virou a segunda rodada.</p>
<h2 id="segunda-rodada-041-a-043">Segunda rodada: 0.4.1 a 0.4.3</h2>
<p>Com o <code>onAttempt</code> ligado, o app mediu mais quatro coisas no mesmo dia. O sandbox pede 5,5 segundos depois de cada requisição, e quatro leituras seguidas viraram sete requisições. O <code>onAttempt</code> não dizia por que não houve resposta, se timeout ou falha de rede. O <code>since</code> do <code>find</code> exigia o formato e o fuso de <code>ultima_alteracao</code>, que o app montava à mão. E o <code>createAndRead</code> não dizia o que acontece quando a leitura falha depois de o pedido existir.</p>
<p>A 0.4.1, às 21h19, respondeu a três dos quatro: o erro de <code>createAndRead</code> passou a carregar o id criado em <code>createdId</code>, o <code>since</code> passou a aceitar um <code>Date</code> convertido para o horário do Brasil, e o evento de <code>onAttempt</code> ganhou um campo <code>error</code> com <code>"network"</code> ou <code>"timeout"</code>. O espaçamento adaptativo continuou recusado.</p>
<p>A 0.4.2, às 23h38, veio de um teste nosso: o cursor das listagens passou a sair com espaço em vez de <code>T</code>, porque a rota de divisões respondia 422 ao formato da documentação e todas aceitam o espaço. A 0.4.3, às 23h50, corrigiu o tipo de alteração de pedido: um item sem <code>id</code> era incluído de novo em vez de alterado, e o tipo não tinha o campo.</p>
<p>A tabela inteira, com a hora do commit no horário de Brasília e a publicação no npm em UTC:</p>
<table>
<thead>
<tr>
<th>Versão</th>
<th>Commit</th>
<th>npm</th>
<th>Origem</th>
</tr>
</thead>
<tbody>
<tr>
<td>0.1.0</td>
<td>19/09 18:56</td>
<td>19/09 22:03Z, à mão</td>
<td>primeira versão</td>
</tr>
<tr>
<td>0.1.1</td>
<td>19/09 19:36</td>
<td>nunca</td>
<td><code>campo</code> vazio no erro de validação</td>
</tr>
<tr>
<td>0.1.2</td>
<td>19/09 19:57</td>
<td>19/09 23:00Z</td>
<td>fila de staging</td>
</tr>
<tr>
<td>0.2.0</td>
<td>20/09 13:11</td>
<td>20/09 21:28Z</td>
<td>estoque, categorias, formas de pagamento</td>
</tr>
<tr>
<td>0.3.0</td>
<td>20/09 18:18</td>
<td>20/09 21:28Z</td>
<td><code>request</code>, <code>list</code> e <code>resource</code> genéricos</td>
</tr>
<tr>
<td>0.3.1</td>
<td>20/09 18:47</td>
<td>20/09 23:39Z</td>
<td>tipo do pedido e filtros</td>
</tr>
<tr>
<td>0.4.0</td>
<td>20/09 20:36</td>
<td>20/09 23:41Z</td>
<td>seis achados do app</td>
</tr>
<tr>
<td>0.4.1</td>
<td>20/09 21:19</td>
<td>21/09 01:09Z</td>
<td>segunda rodada</td>
</tr>
<tr>
<td>0.4.2</td>
<td>20/09 23:38</td>
<td>09/10 19:33Z</td>
<td>cursor com espaço</td>
</tr>
<tr>
<td>0.4.3</td>
<td>20/09 23:50</td>
<td>09/10 19:34Z, <code>latest</code></td>
<td><code>id</code> no item da alteração</td>
</tr>
</tbody>
</table>
<h2 id="publicar-no-npm-sem-token">Publicar no npm sem token</h2>
<p>O <a href="https://www.npmjs.com/package/mercos-sdk">pacote</a> usa a <a href="https://docs.npmjs.com/trusted-publishers">publicação confiável</a> do npm: nenhum token guardado, e o workflow de release prova a origem com OIDC. A primeira tentativa, a 0.1.1, recebeu 403 com “OIDC permission denied”. O publicador confiável tinha permissão só para mandar a versão para a fila de staging, não para publicar direto. A 0.1.1 ficou com tag e sem npm, e desde a 0.1.2 o fluxo é: a tag dispara o workflow, o workflow faz <code>npm stage publish</code> com atestado de procedência, e alguém aprova no site do npm, com 2FA. A <a href="https://github.blog/changelog/2026-05-29-staged-publishing-and-new-install-time-controls-for-npm/">publicação em staging</a> existe desde maio de 2026. O staging exige npm 11.15, mais novo que o npm do Node 24.</p>
<p>Desde a 0.4.2, a release é um comando, <code>pnpm release patch</code>, que ajusta a versão, data o changelog, roda a verificação, commita, cria a tag assinada e envia branch e tag juntos.</p>
<p>A pegadinha é a <code>latest</code>. Ela vai para a última versão <strong>aprovada</strong>, sem olhar o semver. Em 20 de setembro, a 0.3.0 foi aprovada às 21:28:39 e a 0.2.0 seis segundos depois, então a <code>latest</code> ficou na 0.2.0 até a aprovação da 0.3.1, duas horas mais tarde. A regra ficou escrita no README: com duas versões na fila, aprovar na ordem das versões.</p>
<p>A fila também guarda o que ninguém aprova. A 0.4.2 e a 0.4.3 tiveram tag, Release no GitHub e workflow concluído em 20 de setembro, e ficaram em staging até 9 de outubro, quando foram aprovadas com 16 segundos de intervalo, na ordem certa. Durante 19 dias a <code>latest</code> foi a 0.4.1, e ninguém percebeu, porque o app que motivou tudo isso estava fixado na 0.4.0. No mesmo 9 de outubro ele passou para a 0.4.3 e adotou o <code>listPages</code>: o contador de requisições saiu, e com ele a requisição desperdiçada a cada corte de página.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>As horas dos commits vêm do <code>git log</code> do repositório, e as de publicação, do registro do npm, consultado em 9 de outubro de 2026, depois da aprovação das duas últimas versões. Os achados do app foram medidos no sandbox do Mercos em 20 de setembro, com o SDK 0.1.0, 0.3.0 e 0.4.0, e estão num documento do app com a marca de medido ou inferência em cada item.</p>
<p>O que não medimos: o limite de requisições em produção, o código de erro da leitura por id bloqueada em produção, e se o cursor com espaço teria aparecido no app.</p>
<p>O SDK tem licença MIT e não tem vínculo com a Mercos. O app é de um cliente e não é público; o que ele contornou para caber no plano grátis da Cloudflare está no post sobre as <a href="https://ai.virtuaires.com.br/blog/gambiarras-do-plano-gratis-da-cloudflare/">gambiarras</a>.</p>
<p>Se a sua empresa precisa de um cliente para uma API que a documentação descreve pela metade, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/desenvolvimento-open-source/">código aberto</a> e em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>Filas de análise da ANVISA como feeds Atom</title><link>https://ai.virtuaires.com.br/blog/filas-de-analise-da-anvisa-como-feeds-atom/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/filas-de-analise-da-anvisa-como-feeds-atom/</guid><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Todo dia, 319 subfilas de análise de petições da ANVISA viram feeds Atom estáticos, sem servidor: como coletamos, o que medimos em 32 dias e o que deu errado.</description><content:encoded><![CDATA[<p>A ANVISA publica a <a href="https://www.gov.br/anvisa/pt-br/sistemas/fila-de-analise-de-peticoes">fila de análise de petições</a> de cada área: a ordem em que os processos esperam análise. A consulta mostra a fila de hoje. Ela não avisa quando um processo entra, sai ou muda de posição, e não guarda a fila de ontem.</p>
<p>Este texto registra como transformamos essa consulta em um feed Atom por subfila, publicado todo dia sem nenhum servidor, e o que 32 dias de coleta mostraram. Os números são das coletas entre 6 de setembro e 8 de outubro de 2026.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>São <strong>319 subfilas</strong>, uma requisição cada, cerca de cinco minutos por dia.</li>
<li><strong>32 snapshots diários</strong> entre 6 de setembro e 8 de outubro. Falta o dia 8 de setembro.</li>
<li>Um feed Atom por subfila, com o que <strong>entrou, saiu ou mudou de posição</strong> em cada dia.</li>
<li>Coleta, build e deploy são <strong>três jobs separados</strong>. Uma coleta que falha não apaga o site.</li>
<li>O agendamento das 06:00 começou entre <strong>09:36 e 14:51</strong>.</li>
<li><strong>15 subfilas</strong> receberam 429 em três dias, mesmo com o cliente respeitando o limite.</li>
<li>Nenhum servidor: o <strong>Git é o histórico</strong> e o GitHub Pages é a hospedagem.</li>
</ul>
<h2 id="o-problema-a-fila-só-existe-hoje">O problema: a fila só existe hoje</h2>
<p>A API Consultas Externas devolve a subfila inteira em uma chamada, sem paginação e sem histórico. O que ela mostra é o estado de agora. Para saber se um processo andou, é preciso ter guardado a fila de ontem.</p>
<p>Esse é o único pedaço do problema que uma API resolve. O resto, guardar, comparar e avisar, é o que o projeto faz. O comportamento da API está no post sobre a <a href="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/">API da ANVISA</a>.</p>
<h2 id="como-a-coleta-funciona">Como a coleta funciona</h2>
<p>Um GitHub Action roda uma vez por dia. Ele faz uma consulta por subfila, no ritmo que o gateway aceita, uma requisição por segundo. Às segundas-feiras, antes disso, ele percorre de novo o catálogo de áreas, grupos e subfilas, para notar uma subfila nova dentro de uma semana.</p>
<p>A coleta de 8 de outubro de 2026:</p>
<table>
<thead>
<tr>
<th>Medida</th>
<th>Valor</th>
</tr>
</thead>
<tbody>
<tr>
<td>Subfilas</td>
<td>319 de 319</td>
</tr>
<tr>
<td>Requisições</td>
<td>319</td>
</tr>
<tr>
<td>Duração</td>
<td>299 s</td>
</tr>
<tr>
<td>Processos em fila</td>
<td>9.364</td>
</tr>
<tr>
<td>Falhas</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>Nos dias de releitura do catálogo, são 383 requisições. Em 5 de outubro foram 393, porque o catálogo cresceu de 314 para 319 subfilas. A primeira coleta, em 6 de setembro, levou 413 segundos para 314 subfilas e 10.392 processos. Nos dias comuns, a duração ficou entre 292 e 476 segundos.</p>
<p>Cada coleta grava um arquivo JSON Lines comprimido, com uma linha por subfila, mais um arquivo de metadados com contagens, horários e as subfilas que falharam. O catálogo de nomes vai junto. Tudo isso é commitado no repositório.</p>
<h2 id="subfila-vazia-é-um-404-sem-corpo">Subfila vazia é um 404 sem corpo</h2>
<p>Quando não há nada na fila, a API responde <strong>404</strong> com corpo vazio. A coleta grava a subfila como vazia, e isso não é erro. Em 6 de setembro, 88 das 314 subfilas estavam vazias. Em 8 de outubro, 87 das 319.</p>
<p>Uma subfila que falhou na coleta não produz evento nenhum naquele dia. Falta de dado não é mudança.</p>
<h2 id="do-snapshot-ao-feed">Do snapshot ao feed</h2>
<p>O build compara cada subfila com o dia anterior. Um processo é identificado por número de processo mais expediente, porque um mesmo processo pode estar duas vezes na fila com duas petições. Daí saem três eventos: entrou, saiu e mudou de posição.</p>
<p>De 7 para 8 de outubro, nas 319 subfilas: 258 processos entraram, 269 saíram e 3.540 mudaram de posição. Quase toda saída empurra os processos atrás dela, então “mudou de posição” é o evento mais comum.</p>
<p>Cada subfila ganha um feed Atom com uma entrada por dia, com os eventos do dia e a fila como ficou. O feed guarda 30 dias. Ele carrega uma folha de estilo XSL, então o navegador mostra uma página legível em vez do XML. Cada subfila também tem uma página HTML e um JSON com a fila e os eventos, para quem prefere dados.</p>
<p>O site inteiro tem um <code>feeds.opml</code> para assinar todas as subfilas de uma vez, um sitemap, um bloco <code>Dataset</code> do schema.org e as tags de descoberta de feed, para o leitor achar o feed a partir da página.</p>
<h2 id="o-que-a-fila-mostrou-em-32-dias">O que a fila mostrou em 32 dias</h2>
<p>O total em fila caiu de 10.392 processos, em 6 de setembro, para 9.364, em 8 de outubro. Por área, em 8 de outubro:</p>
<table>
<thead>
<tr>
<th>Área</th>
<th style="text-align: right">Processos em fila</th>
</tr>
</thead>
<tbody>
<tr>
<td>Medicamento</td>
<td style="text-align: right">4.343</td>
</tr>
<tr>
<td>Empresas</td>
<td style="text-align: right">1.749</td>
</tr>
<tr>
<td>Dispositivos Médicos</td>
<td style="text-align: right">1.116</td>
</tr>
<tr>
<td>Toxicologia</td>
<td style="text-align: right">862</td>
</tr>
<tr>
<td>Saneantes</td>
<td style="text-align: right">438</td>
</tr>
<tr>
<td>Cosmético</td>
<td style="text-align: right">404</td>
</tr>
<tr>
<td>Dispositivos Médicos para Diagnóstico in Vitro</td>
<td style="text-align: right">298</td>
</tr>
<tr>
<td>Alimento</td>
<td style="text-align: right">103</td>
</tr>
<tr>
<td>Insumo Farmacêutico</td>
<td style="text-align: right">50</td>
</tr>
<tr>
<td>Derivados do Tabaco</td>
<td style="text-align: right">1</td>
</tr>
</tbody>
</table>
<p>A maior subfila é “Genéricos, Novos e Similares - Administrativo”, com 600 processos. A entrada mais antiga na fila é de 3 de setembro de 2009, e 103 processos entraram antes de 2020.</p>
<h2 id="sem-servidor-três-jobs-e-o-que-acontece-quando-falha">Sem servidor: três jobs e o que acontece quando falha</h2>
<p>O workflow tem três jobs: coleta, build e deploy. A separação é de propósito. Uma falha ao gerar o site ou ao publicar nunca perde uma coleta. O snapshot também fica guardado como artefato do workflow por 90 dias, caso o push falhe, e o push tenta de novo depois de um <code>rebase</code>.</p>
<p>Se a coleta falha, o build roda mesmo assim, com o último snapshot bom, e põe um aviso em todas as páginas. O site fica desatualizado de forma visível, em vez de silenciosa. Um botão “Run workflow” com a opção <code>build_only</code> refaz o site sem gastar nenhuma requisição.</p>
<p>O CI separado, que roda lint, testes e build a cada push, ignora os commits de snapshot. Os testes rodam só com respostas gravadas, sem rede.</p>
<h2 id="o-que-deu-errado">O que deu errado</h2>
<p>Três coisas, em 32 dias.</p>
<p><strong>O agendamento atrasa horas.</strong> O cron está marcado para 06:00 de Brasília. Os inícios registrados foram de 09:36 a 14:51, um atraso de 3,6 a 8,9 horas. O GitHub não garante o horário de um cron, e nos dias de maior carga o atraso cresce. Por isso cada página mostra a hora da última coleta, e não “hoje”.</p>
<p><strong>Um timeout derrubou a coleta inteira.</strong> Em 8 de setembro, a leitura de uma resposta estourou o tempo e a exceção abortou o job. Não há snapshot desse dia. No mesmo dia, a coleta passou a tentar de novo cada subfila que falha por erro de transporte, três vezes, com esperas de 5 e 10 segundos, em vez de abortar.</p>
<p><strong>O 429 apareceu mesmo com o limite respeitado.</strong> O cliente envia uma requisição por segundo, o ritmo que o gateway repõe. Ainda assim, uma subfila recebeu 429 em 14 de setembro, sete em 15 de setembro e sete em 20 de setembro. Nenhuma desde então. O limite é contado por endereço de origem, e os runners do GitHub Actions dividem endereços entre si. É a nossa hipótese, e não dá para confirmar pelos dados.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Os números vêm dos 32 arquivos de metadados commitados, de 6 de setembro a 8 de outubro de 2026, e do histórico de execuções do workflow. As contagens por área, de subfilas vazias e de eventos foram feitas por nós em 9 de outubro, sobre os snapshots commitados. A coleta usa o cliente <code>anvisa</code> 0.5 em Python 3.12, no GitHub Actions.</p>
<p>O que não medimos: uma coleta por dia não vê um processo que entra e sai no mesmo dia. A posição é a que a API devolve, e não comparamos os dados com o portal da ANVISA. Em 9 de outubro o site mudou de domínio, e o identificador dos feeds mudou junto, então um leitor que já assinava pode ver entradas antigas de novo uma vez.</p>
<p>O projeto não tem vínculo com a ANVISA.</p>
<h2 id="onde-assinar">Onde assinar</h2>
<p>Os feeds estão em <a href="https://anvisa.feeds.abelhaninja.de/">anvisa.feeds.abelhaninja.de</a>, e o código, em <a href="https://github.com/vasfvitor/anvisa-feeds">anvisa-feeds</a>, com licença MIT. Para rodar a coleta na sua conta, bastam as credenciais do Portal de APIs da ANVISA:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> sync</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> ANVISA_CLIENT_ID</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">... ANVISA_CLIENT_SECRET</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">...</span></span>
<span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> anvisa-feeds</span><span style="color:#9ECBFF"> crawl</span></span>
<span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> anvisa-feeds</span><span style="color:#9ECBFF"> build</span></span></code></pre>
<p>Os cadastros que a API não cobre, como alimentos e cosméticos, saem em CSV, e o post sobre os <a href="https://ai.virtuaires.com.br/blog/dados-abertos-anvisa-o-que-os-csvs-nao-dizem/">dados abertos da ANVISA</a> mostra o que esses arquivos escondem. Se a sua empresa precisa acompanhar uma fonte pública e ser avisada quando algo muda, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>Orçamento no Mercos pela API: o caminho híbrido</title><link>https://ai.virtuaires.com.br/blog/orcamento-no-mercos-pela-api/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/orcamento-no-mercos-pela-api/</guid><pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>A API do Mercos não cria orçamento. Testamos 39 nomes de campo, 9 rotas e o PATCH. O que funcionou: o rascunho nasce na tela e os itens entram pelo PUT da API.</description><content:encoded><![CDATA[<p>O post sobre a <a href="https://ai.virtuaires.com.br/blog/api-mercos-o-que-testamos/">API do Mercos</a> registra que a API não cria orçamento: todo pedido criado pela <a href="https://docs.mercos.com/reference/v2pedidos">rota de pedidos</a> nasce com o status Gerado, e as rotas de pedido recusam o campo <code>status</code>. Para um app que precisava deixar um orçamento pronto para o vendedor revisar, isso era o problema inteiro.</p>
<p>Este texto registra o que mais tentamos pela API, o que o suporte respondeu, como a tela do Mercos cria um orçamento, e o caminho híbrido que funcionou: o rascunho nasce na tela e a API oficial o preenche. Tudo foi medido no sandbox do Mercos, em 22 e 25 de setembro de 2026.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Testamos <strong>39 nomes de campo</strong> no <code>POST</code> e <strong>15 no <code>PUT</code></strong>, nas versões 1 e 2. Todos recusados.</li>
<li><strong>9 rotas inventadas</strong> deram 404, igual a uma rota inexistente. O <code>PATCH</code> deu 405, e parâmetros na URL foram ignorados.</li>
<li>Um truque com uma <strong>chave inventada</strong> mostra quais campos a API aceita sem criar nada.</li>
<li>O suporte confirmou que é <strong>limitação do sistema</strong>, não bug. Chamado 561366, 22 de setembro.</li>
<li>A tela cria o orçamento num <code>GET</code> e o converte em pedido num <code>POST</code> sem corpo. As duas rotas <strong>exigem login</strong>.</li>
<li>Caminho híbrido: a tela cria o rascunho, o <code>PUT</code> da API oficial preenche. O <strong>status continua 1</strong>.</li>
<li>Riscos: a rota não tem documentação, cada abertura cria um rascunho, e a conversão por código não foi testada.</li>
</ul>
<h2 id="o-que-mais-tentamos-pela-api">O que mais tentamos pela API</h2>
<p>A hipótese era um campo ou uma rota não documentada. Em 25 de setembro, numa rodada de scripts com 6 segundos entre as chamadas:</p>
<table>
<thead>
<tr>
<th>Tentativa</th>
<th>Resultado</th>
</tr>
</thead>
<tbody>
<tr>
<td>39 nomes de campo no <code>POST</code>, v1 e v2</td>
<td>422, todos listados como inválidos</td>
</tr>
<tr>
<td>15 nomes de campo no <code>PUT</code>, v1 e v2</td>
<td>422, todos listados como inválidos</td>
</tr>
<tr>
<td>7 rotas de ação inventadas, como “reabrir” e “orçamento”</td>
<td>404 com página HTML, igual a 2 rotas de controle que não existem</td>
</tr>
<tr>
<td><code>GET /v1/orcamentos</code> e <code>GET /v2/orcamentos</code></td>
<td>404 com página HTML</td>
</tr>
<tr>
<td><code>PATCH /v2/pedidos/{id}</code></td>
<td>405</td>
</tr>
<tr>
<td><code>?status=1&amp;orcamento=1&amp;rascunho=1</code> na URL do <code>POST</code></td>
<td>ignorado, o pedido nasceu com status 2</td>
</tr>
<tr>
<td><code>POST</code> sem <code>data_emissao</code> ou com <code>null</code></td>
<td>422, o campo é obrigatório e só aceita texto</td>
</tr>
<tr>
<td><code>PUT</code> num pedido cancelado</td>
<td>200, e o pedido continua cancelado</td>
</tr>
</tbody>
</table>
<p>Os 39 nomes eram os campos que o <code>GET</code> devolve, como <code>status_b2b</code> e <code>pedido_origem_id</code>, mais palpites, como <code>orcamento</code>, <code>rascunho</code>, <code>gerar_pedido</code>, <code>draft</code> e <code>quote</code>. A <a href="https://docs.mercos.com/reference/v1pedidosstatus">rota de status</a>, <code>POST /v1/pedidos/{id}/status</code>, só troca o status personalizado, que é outro campo. E um tipo de pedido chamado “Orçamento”, testado em 21 de setembro, também produziu um pedido com status 2.</p>
<p>Um detalhe que a listagem mostrou: a conta tinha 5 orçamentos com status 1, 4 deles sem itens. A explicação está mais abaixo.</p>
<h2 id="como-achar-campo-não-documentado-sem-criar-nada">Como achar campo não documentado sem criar nada</h2>
<p>Mande os candidatos junto com uma chave que com certeza não existe. Essa chave garante um 422, então nada é criado. A v2 lista todos os atributos inválidos numa mensagem só, e a v1 dá um erro por chave. Qualquer candidato que não apareça na lista de inválidos é um campo aceito.</p>
<p>Foi assim que os 39 nomes foram testados em duas requisições. De brinde, a v1 mostrou que os itens exigem <code>preco_bruto</code> e recusam <code>preco_tabela</code>.</p>
<h2 id="o-que-o-suporte-respondeu">O que o suporte respondeu</h2>
<p>Perguntamos pelo chat em 21 de setembro, fora do horário. Em 22 de setembro, de manhã, a primeira resposta explicou os status padrão, Orçamento, Concluído e Cancelado, e que um status personalizado só entra depois de o pedido ser concluído. Pedimos para confirmar se era bug ou limite. À tarde, a resposta: “essa é uma limitação atual do sistema e não se trata de um bug”. O time de integração estava em cópia e não tinha respondido até 25 de setembro.</p>
<p>O “Concluído” do suporte é o status 2 da API, que a documentação chama de Gerado. O suporte respondeu nos termos da tela.</p>
<h2 id="como-a-tela-cria-o-orçamento">Como a tela cria o orçamento</h2>
<p>Capturamos as chamadas da tela do Mercos em 25 de setembro, com uma extensão do Chrome, porque o leitor de rede do navegador não mostra as requisições do próprio Mercos. A sequência:</p>
<ol>
<li>Abrir “Criar pedido / orçamento” faz um <code>GET</code> que <strong>já cria o orçamento</strong> no servidor e redireciona para a página dele, com o id no endereço.</li>
<li>Cliente, itens e condição de pagamento vão em formulários comuns do Django, um <code>POST</code> cada.</li>
<li>“Gerar pedido” é um <code>POST</code> sem corpo, que responde com o endereço de redirecionamento. O botão fica desabilitado até o orçamento ter condição de pagamento.</li>
</ol>
<p>Como o <code>GET</code> cria o registro, cada abertura da tela deixa um rascunho. É por isso que a conta tinha orçamentos sem cliente e sem itens.</p>
<p>Com só os dois tokens da integração, as três rotas da tela que testamos responderam 302 para a página de login. Elas exigem a sessão de um usuário. O formulário de login tem só usuário e senha, e o HTML não cita captcha, o que não descarta verificação em duas etapas nem bloqueio depois de erros.</p>
<h2 id="o-caminho-híbrido">O caminho híbrido</h2>
<p>Se a tela cria com status 1 e a API cria com status 2, e o <code>PUT</code> não mexe no status, basta trocar quem cria.</p>
<p>Testado em 25 de setembro, num rascunho recém-criado na tela, sem cliente, sem itens e sem condição de pagamento:</p>
<table>
<thead>
<tr>
<th>Passo</th>
<th>Resultado</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>PUT /v2/pedidos/{id}</code> pela API oficial, com cliente, condição e um item de 2 unidades</td>
<td>200</td>
</tr>
<tr>
<td>Leitura depois do <code>PUT</code></td>
<td>status 1, cliente preenchido, condição “30 / 60 / 90”, um item, total R$ 536,90</td>
</tr>
</tbody>
</table>
<p>Uma segunda rodada, no mesmo dia e noutro rascunho, mediu o que o app precisava saber:</p>
<ul>
<li>Um <code>PUT</code> sem condição de pagamento é aceito num rascunho, ao contrário do <code>POST</code>.</li>
<li>Um <code>PUT</code> com um item num rascunho que já tem quatro acrescenta o quinto. Os quatro ficam intactos, com os mesmos ids.</li>
<li>O <code>PUT</code> move <code>ultima_alteracao</code>, então a listagem por data encontra o rascunho antes e depois.</li>
<li>Desconto com 7 casas decimais dá 422. Com 4 casas é aceito, e o preço líquido volta com 5 casas, sem arredondar ao centavo.</li>
<li>O Mercos <strong>grava o preço que mandamos</strong> sem conferir pelo cadastro: 138 num produto de 137 ficou 138. Catálogo velho vira preço errado no orçamento.</li>
<li>A tela recarregada mostra os itens. Editar na tela sem recarregar não foi testado.</li>
<li>Um <code>PUT</code> leva de 0,5 a 0,7 segundo. Ler, gravar e ler de novo levou 19 segundos no sandbox, por causa dos 429 entre as chamadas.</li>
</ul>
<p>No app, a escolha foi deixar a criação com a pessoa. Ela abre o orçamento na tela do Mercos, como já fazia, uma extensão lê o id no endereço da página e manda a lista de itens ao nosso servidor, e o servidor preenche o rascunho pelo <code>PUT</code> da API, com os tokens da integração e o <a href="https://github.com/vasfvitor/mercos-sdk">mercos-sdk</a>. Quando o preenchimento vem de um assistente de IA, o mesmo <code>PUT</code> passa pelo <a href="https://ai.virtuaires.com.br/blog/servidor-mcp-escrito-a-mao-num-worker/">servidor MCP</a> do app, com uma prévia obrigatória antes de gravar. Nenhuma senha guardada, o orçamento fica no nome de quem o abriu, e a escrita passa só pela API oficial. A alternativa, um usuário só para a integração fazendo login por código, ficou guardada para o dia em que o orçamento precisar nascer sem ninguém com o Mercos aberto.</p>
<h2 id="riscos-e-o-que-não-medimos">Riscos e o que não medimos</h2>
<p>A rota que cria o rascunho não tem documentação e pode mudar em qualquer deploy do Mercos. Os termos de uso não foram lidos com esse uso em mente. Por isso o app não a chama: quem a chama é a pessoa, pela tela.</p>
<p>O <code>GET</code> de criação não é idempotente. Uma chamada repetida por engano deixou um rascunho vazio na conta. Se o preenchimento falhar, o rascunho precisa ser cancelado.</p>
<p>Não testamos: se <code>criador_id</code> no <code>PUT</code> troca o vendedor; gerar o pedido por código, que provavelmente exige o token CSRF; quanto dura a sessão e se a conta pode exigir verificação em duas etapas; um <code>PUT</code> num rascunho de outra representada, que o sandbox não permite medir; e nada disso em produção, onde a leitura por id é bloqueada e o app encontra o pedido pela listagem.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Sandbox do Mercos, uma conta, 22 e 25 de setembro de 2026. Cinco rodadas de scripts, com 6 segundos entre as chamadas: leitura dos status e dos orçamentos existentes; a sonda de campos no <code>POST</code>, sem criar nada; dois pedidos criados e cancelados para a sonda no <code>PUT</code>, as rotas, o <code>PATCH</code> e os parâmetros na URL; as rotas da tela só com os tokens; e o <code>PUT</code> no rascunho. Os pedidos de teste ficaram cancelados na conta. O cliente usado nos testes é fictício.</p>
<p>O que não medimos está na seção anterior. O projeto não tem vínculo com a Mercos.</p>
<p>Se a sua empresa precisa que um sistema converse com um ERP que a API não cobre inteiro, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a> e em <a href="https://ai.virtuaires.com.br/solucoes/engenharia-reversa/">engenharia reversa</a>.</p>
]]></content:encoded></item><item><title>API do Mercos: o que testamos no sandbox</title><link>https://ai.virtuaires.com.br/blog/api-mercos-o-que-testamos/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/api-mercos-o-que-testamos/</guid><pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>O que a API de integração do Mercos faz na prática: limite de requisições, paginação por data, pedidos, IPI e estoque, medidos no sandbox.</description><content:encoded><![CDATA[<p>O Mercos tem uma API de integração para pedidos, clientes, produtos, estoque e tabelas de preço. A <a href="https://docs.mercos.com">documentação</a> é pública. Antes de liberar o acesso à produção, o Mercos exige na <a href="https://docs.mercos.com/reference/homologa%C3%A7%C3%A3o">homologação</a> que a integração trate o limite de requisições e a paginação.</p>
<p>Este texto registra o que encontramos ao integrar com ela. A maior parte foi medida no sandbox; onde não foi, o texto diz. O sandbox pode se comportar diferente da produção, e a API pode mudar depois disso.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>A API <strong>não funciona no navegador</strong>: recusa o preflight de CORS, e os tokens dão acesso total à conta.</li>
<li>O limite de requisições vale para <strong>todas as rotas juntas</strong>. No sandbox, uma requisição a cada <strong>5,5 segundos</strong> evitou o erro 429.</li>
<li>A paginação é por <strong>data de alteração</strong>, com resolução de um segundo, o que exige cuidado no corte das páginas.</li>
<li>As datas vêm no <strong>horário de Brasília</strong>.</li>
<li>A API <strong>não cria orçamento</strong>. O pedido nasce com o status Gerado.</li>
<li>O Mercos soma o <strong>IPI do cadastro</strong> a cada item do pedido, mesmo que você não mande.</li>
<li>Em produção, <strong>não dá para ler um pedido pelo ID</strong>, segundo a documentação do Mercos.</li>
</ul>
<h2 id="só-do-servidor">Só do servidor</h2>
<p>A API recusa o preflight de CORS, então uma chamada feita pelo navegador falha. Mesmo que funcionasse, não seria uma boa ideia: os dois tokens, o da aplicação e o da empresa, dão acesso total à conta.</p>
<p>As chamadas precisam sair do seu servidor, que expõe ao navegador só aquilo de que a sua aplicação precisa.</p>
<h2 id="limite-de-requisições">Limite de requisições</h2>
<p>O limite é <strong>global</strong>: vale para todas as rotas somadas, e não por rota. Ao passar dele, a API responde <strong>429</strong> e informa no campo <code>tempo_ate_permitir_novamente</code> quanto esperar.</p>
<p>A consequência prática é que disparar várias requisições em paralelo não adianta. Elas disputam o mesmo limite.</p>
<p>No sandbox, medimos:</p>
<table>
<thead>
<tr>
<th>Rodada</th>
<th>Respostas 429</th>
<th>Tempo total</th>
</tr>
</thead>
<tbody>
<tr>
<td>Seis leituras seguidas, sem intervalo</td>
<td>5</td>
<td>33 s</td>
</tr>
<tr>
<td>Seis leituras com 5,5 s entre elas</td>
<td>0</td>
<td>28 s</td>
</tr>
</tbody>
</table>
<p>A rodada espaçada terminou antes e precisou de metade das requisições, porque não precisou repetir nada. O limite de uma conta de produção pode ser outro, então vale medir na sua.</p>
<p>Outro cuidado: uma escrita que não teve resposta não deve ser reenviada às cegas. O pedido pode ter sido criado mesmo sem a resposta chegar.</p>
<h2 id="paginação-por-data-de-alteração">Paginação por data de alteração</h2>
<p>As listagens são incrementais. O cursor é o parâmetro <code>alterado_apos</code>, e o cabeçalho <code>MEUSPEDIDOS_LIMITOU_REGISTROS</code> com valor <code>1</code> avisa que há mais páginas.</p>
<p>O problema é a resolução. O campo <code>ultima_alteracao</code> tem precisão de <strong>um segundo</strong>, e o corte de uma página pode cair no meio de um segundo. Se a próxima página começar exatamente depois do último registro recebido, os registros restantes daquele segundo podem ficar de fora.</p>
<p>O que fizemos:</p>
<ul>
<li>Usar como próximo cursor a <strong>penúltima</strong> <code>ultima_alteracao</code> distinta da página, relendo o último segundo inteiro. Se a página termina em <code>10:00:01</code>, <code>10:00:02</code> e <code>10:00:02</code>, o próximo cursor é <code>10:00:01</code>, e os registros de <code>10:00:02</code> vêm de novo.</li>
<li>Descartar os registros que voltam repetidos por causa desse recuo.</li>
<li>Tratar como erro o caso em que o cabeçalho avisa que há mais páginas e a página inteira tem a mesma <code>ultima_alteracao</code>, porque aí não há para onde recuar. Em pedidos, pedir mais registros por página costuma resolver.</li>
</ul>
<p>Para sincronizar de forma incremental entre uma execução e outra, guarde a maior <code>ultima_alteracao</code> recebida e use esse valor na próxima, relendo o último segundo e descartando os repetidos.</p>
<h2 id="datas-no-horário-de-brasília">Datas no horário de Brasília</h2>
<p>O Mercos grava <code>ultima_alteracao</code> no horário de Brasília. Uma alteração feita às 00:12 UTC voltou marcada como 21:12 do dia anterior. Quem trabalha em UTC precisa converter antes de usar a data como cursor.</p>
<p>O formato também tem uma pegadinha. A documentação escreve <code>alterado_apos</code> como <code>2024-04-10T15:45:00</code>. A maioria das rotas aceita, mas <code>/v1/divisoes</code> responde 422 e exige um espaço no lugar do <code>T</code>: <code>2024-04-10 15:45:00</code>. Todas as rotas do sandbox aceitaram o espaço, então é o formato mais seguro.</p>
<h2 id="pedidos">Pedidos</h2>
<p>A maior parte das surpresas está aqui.</p>
<ul>
<li><strong>Não existe orçamento pela API.</strong> O pedido criado pela API nasce com o status <strong>Gerado</strong> (<code>"2"</code>). As rotas <code>/v2/pedidos</code> e <code>/v1/pedidos</code> respondem 422 a um campo <code>status</code>, na criação e na alteração. Orçamento, o status <code>"1"</code>, só sai pelas telas do Mercos.</li>
<li><strong>A condição de pagamento é obrigatória</strong>, como <code>condicao_pagamento_id</code> ou como texto livre em <code>condicao_pagamento</code>. Sem nenhuma das duas, a resposta é 422, embora a documentação não marque nenhuma como obrigatória.</li>
<li><strong>O ID do pedido criado vem no cabeçalho</strong> <code>MeusPedidosID</code>.</li>
<li><strong>Em produção, não dá para ler um pedido pelo ID.</strong> Este ponto não vem dos nossos testes, e sim da <a href="https://docs.mercos.com/reference/obter-um-pedido-espec%C3%ADfico">página da rota</a>: a leitura por ID existe só no sandbox, e depois da homologação ela não é permitida em produção, por desempenho. A documentação não diz qual erro a API devolve, e nós não medimos. A saída é procurar o pedido na listagem, filtrando por data de alteração. Quem precisar mesmo da leitura por ID pode pedir a liberação ao suporte do Mercos, com uma justificativa técnica.</li>
<li><strong>A alteração é parcial.</strong> Um item com <code>id</code> altera aquele item. Um item sem <code>id</code> é incluído, mesmo que o produto já esteja no pedido. Um item com <code>excluido: true</code> é excluído, mas continua vindo na leitura, marcado como excluído. Os itens que o corpo não cita ficam como estão.</li>
</ul>
<h2 id="o-ipi-que-você-não-mandou">O IPI que você não mandou</h2>
<p>O Mercos soma ao pedido o <strong>IPI do cadastro do produto</strong>, mesmo quando o item não manda <code>ipi</code>. Um exemplo medido no sandbox:</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Enviado</th>
<th>Subtotal no Mercos</th>
<th>Conta que fecha</th>
</tr>
</thead>
<tbody>
<tr>
<td>IPI de 5%, percentual</td>
<td>3 × 400, desconto de 10%</td>
<td>1134</td>
<td>3 × 360 × 1,05</td>
</tr>
<tr>
<td>IPI de R$ 12,50, valor fixo</td>
<td>4 × 200, desconto de 10%</td>
<td>770</td>
<td>4 × 180 + 4 × 12,50</td>
</tr>
<tr>
<td>Sem IPI</td>
<td>2 × 150</td>
<td>300</td>
<td>2 × 150</td>
</tr>
</tbody>
</table>
<p>Duas conclusões da tabela:</p>
<ul>
<li>O IPI <strong>percentual</strong> incide depois do desconto.</li>
<li>O IPI em <strong>valor fixo</strong> é por unidade, e o desconto não o reduz.</li>
</ul>
<p>Além disso, o arredondamento para centavos acontece no subtotal de cada item.</p>
<p>Uma tela que soma só preço, quantidade e desconto vai mostrar um total menor que o real. Depois de criar o pedido, consulte-o de novo para ter o valor que o Mercos gravou.</p>
<p>Já a substituição tributária (<code>st</code>) do cadastro não entrou no pedido: os itens voltaram com <code>st: 0</code>. Não sabemos o motivo.</p>
<h2 id="estoque">Estoque</h2>
<p>Aqui medimos pouco. A conta de sandbox está com o controle de estoque desligado, e o Mercos recusa qualquer ajuste nessa condição com <strong>422</strong> e uma mensagem que diz isso. Foi o único comportamento de estoque que vimos acontecer.</p>
<p>As regras abaixo vêm da <a href="https://docs.mercos.com/reference/v1ajustar_estoque_em_lote">documentação</a>, e o SDK as trata, mas não as confirmamos no sandbox:</p>
<ul>
<li>O ajuste <strong>define o saldo</strong>, não soma nem subtrai. Você envia o saldo novo.</li>
<li>Cada requisição aceita no máximo <strong>300 ajustes</strong>. Se um deles der erro, o lote inteiro é cancelado e nenhum saldo muda.</li>
<li>O ajuste vale para produtos simples e para os filhos de uma grade. No produto agregador, o pai da grade, a documentação diz que a API devolve erro.</li>
</ul>
<h2 id="outras-coisas-que-vale-saber">Outras coisas que vale saber</h2>
<ul>
<li><strong>Valores ausentes vêm como <code>0</code> ou <code>""</code></strong>, não como <code>null</code>: <code>tabela_preco_id: 0</code>, <code>transportadora_id: 0</code>, <code>observacoes: ""</code>. Um <code>0</code> num campo de ID quer dizer “sem valor”.</li>
<li><strong>Os erros vêm em quatro formatos diferentes</strong>, conforme a rota.</li>
<li><strong>A data de um campo extra vai como <code>yyyy-mm-dd</code>.</strong> A documentação mostra <code>yyyy-dd-mm</code>, que é erro de digitação: a API recusa e informa o formato certo.</li>
<li><strong>Não há rota para os dados da própria empresa</strong>, como logotipo ou CNPJ.</li>
<li><strong>Quantidade fracionada</strong>, como 1,5, é aceita.</li>
</ul>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Os testes rodaram contra o sandbox do Mercos. Parte veio de uma suíte automatizada, que cria um pedido e o cancela no fim. O resto veio de uma aplicação nossa que usa o SDK no dia a dia, também apontada para o sandbox.</p>
<p>A documentação do Mercos traz trechos de OpenAPI embutidos em cada página. Juntamos esses trechos em um único arquivo, com 169 operações, e registramos as correções num arquivo à parte, cada uma com o motivo. Dois defeitos se repetem nas páginas: um objeto declarado quando o exemplo mostra uma lista, ou o contrário, e a mesma rota documentada com corpos diferentes em páginas diferentes.</p>
<h2 id="um-sdk-que-já-trata-isso">Um SDK que já trata isso</h2>
<p>Publicamos o <a href="https://github.com/vasfvitor/mercos-sdk">mercos-sdk</a>, um SDK em TypeScript com licença MIT. Ele usa o <code>fetch</code> nativo, sem dependências, e roda em Node 22 ou mais recente, Deno, Bun e Cloudflare Workers.</p>
<p>Ele envia uma requisição por vez e espera o tempo indicado na resposta 429, faz a paginação com o recuo descrito acima, nunca reenvia uma escrita, converte um <code>Date</code> para o horário de Brasília e junta os quatro formatos de erro em um só. Também se recusa a iniciar no navegador, para os tokens não vazarem. Por padrão ele não espaça as requisições; o intervalo de 5,5 segundos se configura com a opção <code>minIntervalMs</code>.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="sh"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> mercos-sdk</span></span></code></pre>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { createMercos } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> "mercos-sdk"</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> mercos</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> createMercos</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  applicationToken: process.env.</span><span style="color:#79B8FF">MERCOS_APPLICATION_TOKEN</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  companyToken: process.env.</span><span style="color:#79B8FF">MERCOS_COMPANY_TOKEN</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  environment: </span><span style="color:#9ECBFF">"sandbox"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">const</span><span style="color:#79B8FF"> cliente</span><span style="color:#F97583"> of</span><span style="color:#E1E4E8"> mercos.clientes.</span><span style="color:#B392F0">list</span><span style="color:#E1E4E8">({ changedAfter: </span><span style="color:#9ECBFF">"2024-01-01 00:00:00"</span><span style="color:#E1E4E8"> })) {</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(cliente.id, cliente.razao_social);</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>O projeto não tem vínculo com a Mercos. Como o primeiro app que o usou mudou o desenho do SDK está no post sobre o <a href="https://ai.virtuaires.com.br/blog/um-sdk-moldado-pelo-primeiro-app/">SDK e o seu primeiro app</a>, e o que fizemos para criar orçamento, que a API não faz, está no post sobre o <a href="https://ai.virtuaires.com.br/blog/orcamento-no-mercos-pela-api/">caminho híbrido</a>. Fizemos o mesmo tipo de levantamento com a <a href="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/">API da ANVISA</a>.</p>
<p>Se a sua empresa precisa ligar o Mercos a um ERP, a uma planilha ou a outro sistema, esse é o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>Dados abertos da ANVISA: o que o CSV não diz</title><link>https://ai.virtuaires.com.br/blog/dados-abertos-anvisa-o-que-os-csvs-nao-dizem/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/dados-abertos-anvisa-o-que-os-csvs-nao-dizem/</guid><pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Codificação, aspas, datas e números de processo nos CSVs de dados abertos da ANVISA: o que medimos em alimentos, saneantes, cosméticos e produtos irregulares.</description><content:encoded><![CDATA[<p>A API Consultas Externas da ANVISA não tem alimentos, cosméticos nem saneantes. Esses cadastros, que a ANVISA chama de registros válidos ou produtos regularizados, e as medidas contra produtos irregulares, saem em CSV no <a href="https://dados.anvisa.gov.br/dados/CONSULTAS/">portal de dados abertos</a>, atualizados nos dias úteis. Cada conjunto tem um <a href="https://dados.anvisa.gov.br/dados/Documentacao_e_Dicionario_de_Dados_Registros_Validos_Alimentos_V2.pdf">dicionário de dados</a> em PDF, com as colunas e um passo a passo para abrir no Excel. O que o dicionário não conta é o que importa para ler o arquivo por programa, todo dia, sem perder linhas.</p>
<p>Este texto registra o que encontramos ao converter sete desses arquivos para Parquet, todos os dias, num processo automático. Cada comportamento foi medido nos arquivos de outubro de 2026, com a data indicada. A ANVISA pode mudar o formato depois disso.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>A codificação é <strong>Windows-1252</strong>, não Latin-1. Nossa primeira anotação errou isso.</li>
<li>Aspas dentro de um valor <strong>não são escapadas</strong>, e há quebras de linha dentro de valores. Nenhum leitor pronto leu os arquivos inteiros.</li>
<li>As datas vêm em <strong>dia/mês ou mês/dia</strong>, conforme o arquivo, sem aviso.</li>
<li>O número de processo tem <strong>13, 14 ou 17 dígitos</strong>, e 14 é o tamanho de um CNPJ.</li>
<li>Em produtos irregulares, a coluna <code>nu_cnpj</code> <strong>não é a empresa autuada</strong>.</li>
<li>Cosméticos tem <strong>1.204.980 linhas</strong>, e 11.144 processos aparecem duas vezes.</li>
<li>O navegador não lê os arquivos direto, porque o servidor não envia CORS.</li>
</ul>
<h2 id="onde-estão-os-dados-e-por-que-o-navegador-não-lê">Onde estão os dados e por que o navegador não lê</h2>
<p>Os cadastros de alimentos, saneantes e cosméticos ficam em <code>CONSULTAS/PRODUTOS/</code>, no mesmo servidor. As medidas de fiscalização, de todas as áreas, ficam em <code>CONSULTAS/EMPRESA_FISCALIZACAO_PRODUTO/</code>. O ciclo de análise das petições de alimentos fica na raiz.</p>
<p>O servidor envia <code>ETag</code> e <code>Last-Modified</code>, e responde <strong>304</strong> a um <code>If-None-Match</code>. Um dia sem mudança custa uma resposta vazia por arquivo. Ele não envia cabeçalhos de CORS, então uma página no navegador não consegue ler os arquivos. Quem quer uma busca no navegador precisa hospedar uma cópia.</p>
<p>Os tamanhos, medidos em outubro de 2026:</p>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>CSV</th>
<th>Linhas</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alimentos</td>
<td>33,7 MB</td>
<td>66.681</td>
</tr>
<tr>
<td>Alimentos, detalhe das apresentações</td>
<td>23 MB</td>
<td>66.941</td>
</tr>
<tr>
<td>Saneantes</td>
<td>25 MB</td>
<td>144.384</td>
</tr>
<tr>
<td>Produtos irregulares</td>
<td>98 MB</td>
<td>79.986</td>
</tr>
<tr>
<td>Cosméticos</td>
<td>228 MB</td>
<td>1.204.980</td>
</tr>
</tbody>
</table>
<p>As contagens são do dia em que cada arquivo entrou na conversão. O manifesto publicado tem as de hoje.</p>
<h2 id="codificação-windows-1252-não-latin-1">Codificação: Windows-1252, não Latin-1</h2>
<p>Nossa primeira anotação dizia Latin-1. O dicionário de alimentos diz o certo, “1252 (padrão ANSI)”, mas só no passo a passo de importação do Excel, onde quem lê por programa não procura. Os dois são quase iguais, e a diferença está nos bytes de 0x80 a 0x9F. No Latin-1 eles são caracteres de controle. No Windows-1252 eles são o travessão (0x96), o apóstrofo curvo (0x92), as aspas curvas (0x93 e 0x94) e o símbolo de marca registrada (0x99). Os arquivos usam todos esses.</p>
<p>No arquivo de cosméticos, o byte 0x96 aparece 21.282 vezes e o 0x92, 5.590 vezes. Lidos como Latin-1, esses nomes ganhariam caracteres invisíveis no lugar de um travessão.</p>
<p>Dois defeitos já vêm de origem e ficam como estão: 11 nomes de produto foram gravados como UTF-8 e lidos como Windows-1252 antes de a ANVISA exportar, então aparecem como <code>LOÃ‡ÃƒO</code>, e 260 razões sociais começam com os caracteres de controle <code>\x17\x10\x16\x18</code>.</p>
<h2 id="aspas-que-não-fecham-e-linhas-que-não-terminam">Aspas que não fecham e linhas que não terminam</h2>
<p>O separador é <code>;</code>, e os textos vêm entre aspas. Até aí, CSV comum. O problema é que uma aspa <strong>dentro</strong> de um valor não é escapada: <code>biscoito tipo "cookies"</code> vem assim mesmo, e um valor que termina em aspa vem como <code>...brilhante. ""</code>. Além disso, há valores com quebra de linha, tanto LF quanto CRLF.</p>
<p>Testamos três leitores. O leitor estrito do DuckDB recusa o arquivo. O leitor tolerante junta dois registros em um. O módulo <code>csv</code> do Python divide um registro em dois.</p>
<p>A regra que funcionou tem duas partes. Uma aspa só fecha um campo quando vem antes de <code>;</code> ou de uma quebra de linha. E uma quebra de linha só fecha o registro quando o campo é o último, porque o leitor sabe quantos campos o cabeçalho tem. O caso que forçou a segunda parte está em saneantes: um nome de produto contém <code>AMBIENTE "AS MENINAS"</code>, depois duas quebras de linha, depois <code>SPRAY</code>. Lido assim, todo registro de todos os arquivos sai com o número de campos do cabeçalho.</p>
<p>Os textos também trazem entidades HTML, como <code>L&amp;apos;ANA MED</code> e <code>&amp;quot;</code>. Em cosméticos, <code>&amp;AMP;</code> vem em maiúsculas. Já <code>&amp;KISS;</code> não é uma entidade e fica como está. Alguns valores terminam em espaço duro e quebra de linha, então todo valor é aparado.</p>
<h2 id="datas-dia-ou-mês-primeiro-conforme-o-arquivo">Datas: dia ou mês primeiro, conforme o arquivo</h2>
<p>Os arquivos de alimentos, o das petições em andamento e o de cosméticos escrevem <code>dd/mm/yyyy HH:MM:SS</code>. Os de saneantes, das petições finalizadas e de produtos irregulares escrevem <strong><code>mm/dd/yyyy HH:MM:SS</code></strong>. Nenhum deles avisa.</p>
<p>Nós fixamos um formato por arquivo, e nunca os dois. Com os dois ativos, <code>12/08/2026</code> entraria pelo primeiro que aceitasse, sem erro. Com um só, o formato errado deixa sem leitura toda data com dia acima de 12, e a trava de 20% de valores nulos por coluna derruba a conversão. É o jeito de o erro aparecer no build, e não meses depois.</p>
<p>Mais três detalhes:</p>
<ul>
<li>O vencimento do registro em alimentos vem como <code>MMYYYY</code>, por exemplo <code>122029</code>. Toda notificação traz <code>122029</code>, um valor de preenchimento. Os registros vencem 5, 10, 15 ou 20 anos depois da regularização.</li>
<li>Em saneantes, há validades no ano 3033.</li>
<li>Os booleanos são <code>S</code> e <code>N</code> em alimentos, com um <code>X</code> solto, e <code>1</code> e <code>0</code> em saneantes. Cosméticos tem uma coluna de cada tipo.</li>
</ul>
<h2 id="número-de-processo-13-14-ou-17-dígitos">Número de processo: 13, 14 ou 17 dígitos</h2>
<p>O dicionário dá a entender que o processo tem 17 dígitos. O arquivo de alimentos, medido em 6 de outubro de 2026:</p>
<table>
<thead>
<tr>
<th style="text-align: right">Dígitos</th>
<th style="text-align: right">Linhas</th>
<th style="text-align: right">Ativas</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: right">13</td>
<td style="text-align: right">29.647</td>
<td style="text-align: right">90</td>
</tr>
<tr>
<td style="text-align: right">14</td>
<td style="text-align: right">400</td>
<td style="text-align: right">0</td>
</tr>
<tr>
<td style="text-align: right">17</td>
<td style="text-align: right">36.496</td>
<td style="text-align: right">17.647</td>
</tr>
<tr>
<td style="text-align: right">outros</td>
<td style="text-align: right">138</td>
<td style="text-align: right">0</td>
</tr>
</tbody>
</table>
<p>Uma regra de 17 dígitos deixa de fora 44% das linhas, quase todas histórico inativo. E os 400 processos de 14 dígitos têm o tamanho de um CNPJ, então “14 dígitos é CNPJ” é ambíguo. A nossa busca tenta CNPJ primeiro e depois processo.</p>
<p>Em cosméticos, medido em 7 de outubro: 17 dígitos em 1.126.326 linhas, 15 em 69.822, 13 em 8.826 e 14 em 6.</p>
<p>O dicionário avisa que o Excel converte campos com zeros à esquerda em número. O CNPJ vem com zeros à esquerda, como <code>00000000055417</code>, então processo, CNPJ e registro são texto, nunca número.</p>
<p>Os números de registro têm a própria pegadinha. Nas notificações, o número de registro é o próprio processo. Nos registros, são 9 dígitos. E <code>nu_registro</code>, por apresentação, é o registro mais um sufixo de 4 dígitos: 13 dígitos, o mesmo tamanho de um processo antigo.</p>
<h2 id="produtos-irregulares-o-cnpj-que-não-é-da-empresa-autuada">Produtos irregulares: o CNPJ que não é da empresa autuada</h2>
<p>O arquivo de produtos irregulares tem uma linha por produto, ação e atividade de cada dossiê de fiscalização: suspensão, proibição, recolhimento, apreensão, interdição ou inutilização. Ele tem duas colunas de CNPJ, e a primeira engana.</p>
<p><code>nu_cnpj</code> é de <strong>quem abriu o dossiê</strong>. Em 1.698 de 4.916 dossiês, é a própria ANVISA. Em alguns, é um marketplace. Em outros, é a própria empresa. A empresa autuada está em <code>nu_cnpj_empresa_investigada</code>, e essa coluna nem sempre traz um CNPJ: são 14 dígitos em 35.029 linhas, um CPF de 11 dígitos em 2.334, vazio em 31.658 e texto no resto, como <code>DESCONHECIDO</code>, <code>Não se aplica</code> e <code>NA</code>.</p>
<p>Cruzar com os cadastros ajuda menos do que parece. Das 8.693 linhas de alimentos que nomeiam uma empresa, só 1.667 encontram um CNPJ no cadastro de alimentos. E só 8 de 1.580 nomes de produto batem com um produto regularizado. A maior parte das medidas em alimentos é contra produtos e empresas sem registro.</p>
<p>Dois cuidados a mais: <code>produtos_concatenados</code> é cortado em 4.000 caracteres, então use <code>produto</code>, e 38 linhas são iguais a outra depois de aparar um espaço no fim do nome.</p>
<h2 id="cosméticos-12-milhão-de-linhas-e-processos-em-dobro">Cosméticos: 1,2 milhão de linhas e processos em dobro</h2>
<p>O arquivo de cosméticos tem 1.204.980 linhas e 10 campos, sem quebra de linha dentro de valor. É o maior dos sete, e o mais regular na forma.</p>
<p>A pegadinha é outra: 11.144 processos aparecem <strong>duas vezes</strong>, com o mesmo CNPJ e o mesmo número de registro. Uma linha vem com <code>st_registrado</code> falso e o tipo de petição preenchido, e a outra com <code>st_registrado</code> verdadeiro e o tipo vazio. No arquivo original, as duas podem estar a um milhão de registros de distância, porque as linhas registradas vêm todas depois do registro 1.089.484. Para contar produtos, conte processos distintos.</p>
<p>Convertido, o arquivo vira 24,2 MB de Parquet. O nosso leitor processa o CSV em 31 segundos usando 68 MiB, e a conversão inteira leva de 30 a 40 segundos com pico de cerca de 700 MiB. Os bytes estão no nome do produto, 12,9 MB, e no número do processo, 7,1 MB, então tirar colunas não ajuda. Dividir pelo tipo de petição ajuda: 10,5 MB, 11,9 MB e 2,0 MB.</p>
<h2 id="petições-de-alimentos-dois-arquivos-sem-empresa">Petições de alimentos: dois arquivos sem empresa</h2>
<p>O ciclo de análise das petições de alimentos vem em dois arquivos: as finalizadas pelo menos uma vez, e as que nunca foram. Os dois não se sobrepõem, e nenhum nomeia a empresa.</p>
<p>Para saber de quem é a petição, é preciso cruzar o número do processo com o cadastro de alimentos. Isso encontra 72% dos processos do arquivo de finalizadas, 163 das 172 petições abertas do tipo Petição, e <strong>nenhuma</strong> das 164 abertas do tipo Processo. Faz sentido: essas são pedidos de registro novo, e o produto ainda não existe no cadastro.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Os arquivos de alimentos, saneantes e produtos irregulares são de 5 de outubro de 2026. Os de petições são de 6 de outubro, e o de cosméticos, de 7 de outubro. A conversão roda todo dia às 21:00 UTC, com DuckDB, e publica Parquet com compressão zstd em grupos de 8.192 linhas. Uma coluna que perde mais de 20% dos valores na conversão derruba o build.</p>
<p>Conferimos à mão, em 6 de outubro, a página de produto e a página de apresentação do painel de consultas da ANVISA contra as tabelas de alimentos. Nada que o painel mostra faltava. As outras tabelas não foram comparadas com nada.</p>
<p>Depois das mudanças no leitor, o Parquet dos seis arquivos menores saiu byte a byte igual ao anterior, conferido por SHA-256, com as fontes baixadas de novo em 8 de outubro.</p>
<p>O que não medimos: a variação dos arquivos ao longo do tempo, e se os defeitos de codificação são corrigidos na origem.</p>
<h2 id="um-conversor-que-já-trata-isso">Um conversor que já trata isso</h2>
<p>O leitor e a conversão estão no cliente <a href="https://github.com/vasfvitor/anvisa-api">anvisa-api</a>, em Python com licença MIT. O resultado é publicado todo dia no GitHub Pages, com um <code>manifest.json</code> que lista cada tabela, as linhas, os bytes, o SHA-256, as colunas e a ordenação.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">pip</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> 'anvisa[dados]'</span></span>
<span class="line"><span style="color:#B392F0">anvisa</span><span style="color:#9ECBFF"> dados</span><span style="color:#9ECBFF"> list</span></span>
<span class="line"><span style="color:#B392F0">anvisa</span><span style="color:#9ECBFF"> dados</span><span style="color:#9ECBFF"> build</span><span style="color:#79B8FF"> --out</span><span style="color:#9ECBFF"> dist</span></span></code></pre>
<p>O mesmo Parquet pode ser lido de qualquer lugar com DuckDB:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="sql"><code><span class="line"><span style="color:#F97583">SELECT</span><span style="color:#E1E4E8"> no_produto, marcas, situacao_registro</span></span>
<span class="line"><span style="color:#F97583">FROM</span><span style="color:#9ECBFF"> 'https://vasfvitor.github.io/anvisa-api/data/&lt;build_id&gt;/alimentos.parquet'</span></span>
<span class="line"><span style="color:#F97583">WHERE</span><span style="color:#E1E4E8"> nu_cnpj_empresa </span><span style="color:#F97583">=</span><span style="color:#9ECBFF"> '00000000000000'</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>O projeto não tem vínculo com a ANVISA. A busca no navegador que usa esses arquivos é o <a href="https://contem.abelhaninja.de/">contém.</a>, e como ela funciona sem servidor está no post sobre a <a href="https://ai.virtuaires.com.br/blog/busca-no-navegador-com-duckdb-wasm/">busca com DuckDB-WASM</a>. As filas de análise saem todo dia em <a href="https://ai.virtuaires.com.br/blog/filas-de-analise-da-anvisa-como-feeds-atom/">feeds Atom</a>.</p>
<p>O post sobre a <a href="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/">API da ANVISA</a> mostra o mesmo tipo de levantamento para a parte que tem API. Se a sua empresa depende de um arquivo ou de um sistema que não se comporta como a documentação diz, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/engenharia-reversa/">engenharia reversa</a>. Ligar esses dados ao seu sistema, com atualização diária e aviso quando a fonte muda, é o que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>Como saber o que mudou numa bula da ANVISA</title><link>https://ai.virtuaires.com.br/blog/o-que-mudou-na-bula-anvisa/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/o-que-mudou-na-bula-anvisa/</guid><pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>A ANVISA publica cada versão de bula só em PDF. Mostramos como comparar duas versões seção por seção, o que dá certo e o que ainda é difícil.</description><content:encoded><![CDATA[<p>Bulas mudam o tempo todo. Muda uma contraindicação, entra uma reação adversa nova, a posologia é ajustada. Quem trabalha com medicamento precisa saber o que mudou: o farmacêutico, a equipe de assuntos regulatórios da indústria, quem escreve conteúdo de saúde.</p>
<p>O <a href="https://consultas.anvisa.gov.br/#/bulario/">Bulário Eletrônico da ANVISA</a> lista as versões de cada bula, mas publica só o PDF de cada uma. Não há um resumo do que mudou. Para descobrir, é preciso abrir os dois arquivos e comparar à mão, página por página.</p>
<p>Fizemos o <a href="https://vasfvitor.github.io/buladiff/">buladiff</a> para isso. Ele baixa as versões de um medicamento e mostra o que mudou entre duas delas, seção por seção.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>O Bulário publica <strong>cada versão em PDF</strong>, sem indicar o que mudou.</li>
<li>O buladiff compara duas versões <strong>seção por seção</strong>, usando as seções definidas pela RDC 47/2009.</li>
<li>Cada versão traz duas bulas: a do <strong>paciente (VP)</strong> e a do <strong>profissional (VPS)</strong>. As duas são comparadas.</li>
<li>O resultado foi conferido, como indício, contra a <strong>tabela de histórico</strong> que a própria empresa coloca no fim da bula.</li>
<li>O código é aberto, com licença MIT, e o site é atualizado a partir do arquivo de versões.</li>
</ul>
<h2 id="por-que-comparar-seção-por-seção">Por que comparar seção por seção</h2>
<p>As bulas seguem uma estrutura definida pela <a href="https://bvsms.saude.gov.br/bvs/saudelegis/anvisa/2009/rdc0047_08_09_2009.html">RDC 47/2009</a>. Na bula do profissional, as seções são títulos como “4. Contraindicações” e “5. Advertências e precauções”. Na do paciente, são perguntas, como “4. O que devo saber antes de usar este medicamento?”. Comparar o texto inteiro de uma vez mistura mudanças de seções diferentes, e fica difícil saber onde cada uma aconteceu.</p>
<p>Por isso o buladiff primeiro divide cada bula nas suas seções e depois compara cada seção com a mesma seção da outra versão. O resultado diz, por exemplo: mudou a seção de reações adversas, e só ela. Dentro da seção, a diferença é marcada palavra por palavra, com os parágrafos preservados.</p>
<h2 id="o-que-dá-trabalho-num-pdf-de-bula">O que dá trabalho num PDF de bula</h2>
<p>Um PDF é feito para ser impresso, não para ser lido por um programa. Vários detalhes atrapalham a comparação, e cada um pediu um ajuste próprio:</p>
<ul>
<li><strong>Cabeçalho e rodapé com código de versão em toda página.</strong> Se não forem removidos, toda página parece ter mudado.</li>
<li><strong>Várias bulas no mesmo PDF</strong>, uma por apresentação do medicamento, e em ordem diferente de uma versão para outra. O buladiff pareia as bulas pela semelhança do texto, não pela posição no arquivo.</li>
<li><strong>Títulos fora do padrão</strong>, sem o numeral romano (I, II, III) ou sem a linha “Identificação do medicamento”.</li>
<li><strong>Tabelas.</strong> O editor de texto grava uma tabela inteira como uma única linha alta e a parte onde a página quebra. Lida célula por célula, ela enche a comparação de ruído. As tabelas são lidas linha a linha, como aparecem na página, e o resultado não muda de uma versão para outra.</li>
<li><strong>Palavras partidas.</strong> O editor de texto às vezes grava uma palavra em dois pedaços. O buladiff junta os pedaços de novo.</li>
</ul>
<p>Muitos PDFs de bula vêm com uma marcação de estrutura que diz o que é parágrafo, título e linha de tabela. Quando ela existe, a leitura é bem mais limpa. Em 9 dos 11 PDFs que validamos, ela existia. Sem a marcação, o texto chega linha a linha, e o buladiff remove cabeçalho e rodapé repetidos antes de comparar.</p>
<h2 id="como-conferimos-o-resultado">Como conferimos o resultado</h2>
<p>A maioria das empresas inclui no fim da bula uma tabela chamada “Histórico de Alteração da Bula”, com os itens que mudaram em cada versão. Ela serviu de gabarito.</p>
<p>Testamos 11 registros de 9 empresas: genéricos, medicamentos de referência e um biológico com várias apresentações. As seções que o buladiff apontou como alteradas bateram com a tabela de histórico.</p>
<p>A conferência é indicativa, não uma prova. A coluna de itens alterados mistura a bula do paciente e a do profissional, algumas empresas deixam a coluna vazia ou genérica, e uma das empresas testadas não inclui a tabela.</p>
<h2 id="o-que-ainda-não-funciona-bem">O que ainda não funciona bem</h2>
<ul>
<li><strong>PDFs sem marcação de estrutura</strong>, comuns nas versões mais antigas. O texto vem linha a linha, sem parágrafos, e as tabelas viram texto corrido, o que gera ruído na comparação.</li>
<li><strong>Tabelas no site.</strong> Mesmo nos PDFs com marcação, o site mostra as tabelas como linhas de texto, não como tabela.</li>
<li><strong>Medicamentos notificados</strong> usam outra consulta da ANVISA e não têm histórico de versões. Ficam de fora.</li>
<li><strong>Avisos.</strong> O buladiff não manda email quando uma bula muda. A ANVISA oferece um monitoramento oficial por email, que não mostra a diferença entre versões.</li>
</ul>
<h2 id="um-cuidado-com-a-fonte">Um cuidado com a fonte</h2>
<p>A consulta que o buladiff usa é a mesma que a página do Bulário usa no navegador. Ela não é documentada: pode mudar ou deixar de funcionar sem aviso. Os links dos PDFs também expiram em poucos minutos, então o download precisa acontecer logo depois da consulta. E a lista de versões do Bulário às vezes muda de um dia para o outro: versões somem da listagem e voltam depois.</p>
<p>Por isso o projeto guarda o próprio arquivo. Ele salva o texto extraído de cada versão, com uma assinatura (hash) do PDF original, e só busca o que é novo.</p>
<p><strong>Atualização de 28/10/2026.</strong> O aviso acima se cumpriu. A consulta do Bulário passou a exigir um token do Cloudflare Turnstile, um CAPTCHA que só um navegador com uma pessoa gera. Medimos em 28/10/2026: a consulta respondeu 403, com o código <code>turnstile_invalido</code>, tanto do servidor de automação do GitHub quanto do nosso intermediário na Cloudflare e de uma conexão doméstica. Por isso a coleta está pausada, e o arquivo publicado vai até 16/09/2026. O site e os dados já arquivados continuam no ar.</p>
<h2 id="como-usar">Como usar</h2>
<p>O site do buladiff mostra, para cada medicamento acompanhado, a linha do tempo de versões e a comparação entre versões consecutivas, com busca por nome e por princípio ativo. Ele também lista o catálogo do Bulário, com link para a ANVISA nos medicamentos que ainda não são acompanhados.</p>
<p>Para quem programa, o projeto é uma ferramenta de linha de comando em Python, no <a href="https://github.com/vasfvitor/buladiff">repositório buladiff</a>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="sh"><code><span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> sync</span><span style="color:#6A737D">                                        # instala as dependências</span></span>
<span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> bulario</span><span style="color:#9ECBFF"> search</span><span style="color:#9ECBFF"> dipirona</span><span style="color:#6A737D">                 # registros, empresa e data da última bula</span></span>
<span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> bulario</span><span style="color:#9ECBFF"> fetch</span><span style="color:#79B8FF"> 118190404</span><span style="color:#79B8FF"> --all</span><span style="color:#6A737D">           # baixa todas as versões de um registro</span></span>
<span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> bulario</span><span style="color:#9ECBFF"> diff</span><span style="color:#79B8FF"> --registro</span><span style="color:#79B8FF"> 118190404</span><span style="color:#79B8FF"> --tipo</span><span style="color:#9ECBFF"> vps</span><span style="color:#79B8FF"> --html</span><span style="color:#9ECBFF"> diff.html</span><span style="color:#6A737D">   # compara as duas versões mais recentes</span></span>
<span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> bulario</span><span style="color:#9ECBFF"> diff</span><span style="color:#9ECBFF"> antiga.pdf</span><span style="color:#9ECBFF"> nova.pdf</span><span style="color:#6A737D">        # ou compara dois PDFs quaisquer</span></span></code></pre>
<p>Se você já trabalhou com a API oficial da ANVISA, o post sobre <a href="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/">o que a documentação da API não conta</a> mostra o mesmo tipo de levantamento em outra fonte do órgão.</p>
<p>Se a sua empresa precisa acompanhar documentos que só existem em PDF, num portal sem API, esse é o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/engenharia-reversa/">engenharia reversa</a> e em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>Baixar NFS-e, NF-e e CT-e de várias empresas</title><link>https://ai.virtuaires.com.br/blog/baixar-nfse-nfe-cte-varias-empresas/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/baixar-nfse-nfe-cte-varias-empresas/</guid><pubDate>Sat, 03 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Como baixar NFS-e, NF-e e CT-e de várias empresas com o certificado digital, sem portal de prefeitura, e por que algumas notas não aparecem.</description><content:encoded><![CDATA[<p>Quem cuida das notas de várias empresas costuma baixar os XMLs de portal em portal, uma empresa de cada vez. Existe outro caminho: o governo entrega os documentos de um CNPJ direto a um programa no seu computador, usando o certificado digital da empresa. A NFS-e vem do <strong>Ambiente de Dados Nacional (ADN)</strong>, e a NF-e e o CT-e vêm do <strong>Ambiente Nacional da SEFAZ</strong>.</p>
<p>Este texto explica como isso funciona na prática e por que às vezes uma nota que você esperava não aparece. Ele se baseia no que aprendemos desenvolvendo o <a href="https://vasfvitor.github.io/nanci/">Nanci</a>, um aplicativo de desktop para Windows, gratuito e de código aberto, que baixa essas notas. Ele tem interface visual, com telas e botões, e também linha de comando para quem quer automatizar.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Com o certificado digital da empresa, dá para baixar NFS-e, NF-e e CT-e sem entrar em portal.</li>
<li>As notas chegam em sequência. Depois da primeira vez, cada consulta traz só o que é novo.</li>
<li>Na NF-e, a empresa destinatária recebe primeiro um <strong>resumo</strong>. O XML completo vem depois da <strong>Ciência da Operação</strong>.</li>
<li>As notas que a própria empresa emitiu <strong>não aparecem</strong> na NF-e e no CT-e, e <strong>podem não aparecer</strong> na NFS-e.</li>
<li>A fila de distribuição da SEFAZ guarda as NF-e e os CT-e por <strong>cerca de 90 dias</strong>.</li>
<li>Quem consulta demais é <strong>bloqueado</strong> pela SEFAZ por um tempo.</li>
</ul>
<h2 id="o-que-você-precisa">O que você precisa</h2>
<ul>
<li>O <strong>certificado digital</strong> de cada empresa e a senha dele. O mesmo certificado serve para NFS-e, NF-e e CT-e. O Nanci trabalha com o tipo A1, em arquivo <code>.pfx</code> ou <code>.p12</code>.</li>
<li>Para NF-e e CT-e, a <strong>UF</strong> da empresa.</li>
</ul>
<p>Na NF-e e no CT-e, o certificado da matriz serve para as filiais, porque a SEFAZ confere a raiz do CNPJ (os 8 primeiros caracteres).</p>
<p>Se aparecer “MAC verification failed” ao abrir o certificado, a senha está errada.</p>
<h2 id="como-as-notas-chegam">Como as notas chegam</h2>
<p>O governo mantém, para cada CNPJ, uma fila com os documentos em que a empresa aparece. Cada documento recebe um número de sequência, o <strong>NSU</strong>. O programa guarda o último número que já leu e, na consulta seguinte, pede só o que veio depois.</p>
<p>A fila de distribuição da SEFAZ guarda as NF-e e os CT-e por <strong>cerca de 90 dias</strong>. O primeiro download traz o que ainda estiver lá; o que for mais antigo precisa vir de outra fonte.</p>
<p>Na prática, a primeira consulta pode demorar, conforme o volume. As seguintes são rápidas e trazem só as novidades.</p>
<h2 id="nf-e-primeiro-o-resumo-depois-o-xml">NF-e: primeiro o resumo, depois o XML</h2>
<p>O que chega depende do papel da empresa na nota:</p>
<table>
<thead>
<tr>
<th>Se a empresa é</th>
<th>O que recebe</th>
</tr>
</thead>
<tbody>
<tr>
<td>Destinatária</td>
<td>Todas as NF-e autorizadas contra o CNPJ, de qualquer UF. Primeiro um resumo; o XML completo vem depois da Ciência da Operação</td>
</tr>
<tr>
<td>Transportadora</td>
<td>As NF-e em que aparece como transportadora</td>
</tr>
<tr>
<td>Autorizada a obter o XML</td>
<td>As NF-e em que foi informada como autorizada</td>
</tr>
<tr>
<td>Emitente</td>
<td>As próprias notas não chegam, a não ser que o CNPJ também esteja como autorizado</td>
</tr>
</tbody>
</table>
<p>O caso do destinatário é o que mais confunde. O <strong>resumo</strong> traz chave, emitente, valor e situação, mas não é o documento fiscal. Para receber o XML completo é preciso registrar a <strong>Ciência da Operação</strong>, ou uma manifestação conclusiva. Depois disso, o XML chega em uma consulta seguinte.</p>
<p>As outras manifestações são diferentes. <strong>Confirmação</strong>, <strong>Desconhecimento</strong> e <strong>Operação não Realizada</strong> são definitivas na SEFAZ e não podem ser desfeitas. Por isso o Nanci sempre mostra antes o que seria enviado e só envia com a sua confirmação.</p>
<p>A Ciência não é definitiva, mas também não para o relógio. Se nenhuma manifestação conclusiva for registrada em 90 dias da autorização, a operação é considerada confirmada, com os mesmos efeitos da Confirmação. A regra está na cláusula 15ª-C do Ajuste SINIEF 07/05; as notas técnicas ficam no <a href="https://www.nfe.fazenda.gov.br/portal/principal.aspx">Portal Nacional da NF-e</a>.</p>
<h2 id="ct-e-mais-simples">CT-e: mais simples</h2>
<p>O CT-e não tem resumo nem Ciência: o XML completo chega direto.</p>
<p>A empresa recebe os CT-e em que é tomadora, remetente, destinatária, expedidora, recebedora ou autorizada. Como na NF-e, o emitente não recebe o CT-e que ele mesmo emitiu.</p>
<p>Um detalhe útil: o Nanci guarda as chaves das NF-e transportadas em cada CT-e. A partir de uma nota, dá para achar o CT-e do frete.</p>
<h2 id="nfs-e-por-que-a-nota-emitida-não-aparece">NFS-e: por que a nota emitida não aparece</h2>
<p>É a dúvida mais comum: “emito NFS-e todo mês e a consulta não trouxe nada”.</p>
<p>A consulta do ADN <strong>não deve ser tratada como lista completa</strong> das notas emitidas pela própria empresa. Dependendo da regra de distribuição e de quanto o município já está integrado ao <a href="https://www.gov.br/nfse/pt-br">padrão nacional da NFS-e</a>, notas em que o seu CNPJ é o <strong>prestador</strong> podem não aparecer.</p>
<p>Para saber se está tudo funcionando, teste com uma nota em que o seu CNPJ seja <strong>tomador</strong>.</p>
<p>A mensagem “Nenhum documento localizado para o NSU informado” (código E2220) não é necessariamente um erro: quer dizer que a conexão funcionou e que não há nota nova.</p>
<h2 id="cuidado-com-o-limite-de-consultas">Cuidado com o limite de consultas</h2>
<p>A SEFAZ bloqueia por um tempo o CNPJ que consulta demais, com a rejeição <strong>656 (consumo indevido)</strong>. O Nanci reduz esse risco por conta própria:</p>
<ul>
<li>Faz no máximo <strong>20 consultas por hora</strong> por empresa na NF-e, e outras 20 no CT-e.</li>
<li>Quando não há nada novo, espera <strong>1 hora</strong> antes de consultar de novo.</li>
<li>Se receber o bloqueio, espera 1 hora.</li>
</ul>
<p>Para o CT-e, a SEFAZ não publica um limite por hora; o Nanci usa o da NF-e por prudência.</p>
<h2 id="onde-ficam-o-certificado-e-as-notas">Onde ficam o certificado e as notas</h2>
<p>Um certificado digital dá acesso às notas da empresa. Vale perguntar por onde ele passa antes de entregá-lo a qualquer serviço.</p>
<p>No Nanci a escolha foi deixar tudo no computador de quem usa:</p>
<ul>
<li>A conexão sai direto da máquina para os servidores do governo.</li>
<li>O certificado e o banco de dados ficam no seu computador.</li>
<li>Nada é enviado para um servidor nosso ou de terceiros.</li>
</ul>
<p>O backup é copiar a pasta de dados, como descrito na página de <a href="https://vasfvitor.github.io/nanci/docs/privacidade/">privacidade</a>. O código é aberto e pode ser conferido por quem quiser.</p>
<h2 id="várias-empresas-no-nanci">Várias empresas no Nanci</h2>
<p>O Nanci é um aplicativo de desktop para Windows, com interface visual. Na primeira vez, ele guia o cadastro do certificado e da primeira empresa. Depois é só cadastrar as outras empresas e clicar em sincronizar. As NFS-e podem ser exportadas em Excel, CSV, ZIP de XMLs e PDF (DANFSE), e as NF-e e os CT-e, em ZIP de XMLs. Para quem quer automatizar, ele também funciona pela linha de comando.</p>
<p>O que ele não faz:</p>
<ul>
<li>Não usa portal de prefeitura nem simula navegação.</li>
<li>Não baixa NFC-e, MDF-e, NFCom, NF3e nem CF-e SAT.</li>
<li>Não recupera as notas emitidas pela própria empresa.</li>
<li>Não aceita certificado A3.</li>
<li>Não substitui a conferência contábil e fiscal.</li>
</ul>
<p>Dúvidas comuns estão na <a href="https://vasfvitor.github.io/nanci/docs/faq/">FAQ</a> e na página de <a href="https://vasfvitor.github.io/nanci/docs/troubleshooting/">solução de problemas</a>.</p>
<p>Para quem programa a integração, o post sobre a <a href="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/">API da ANVISA</a> mostra o mesmo tipo de levantamento em outra API do governo.</p>
<p>Se a sua empresa precisa dessas notas dentro de um sistema próprio, como um ERP ou um painel, esse é o tipo de trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>Manifestação do destinatário na NF-e</title><link>https://ai.virtuaires.com.br/blog/manifestacao-do-destinatario-nfe/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/manifestacao-do-destinatario-nfe/</guid><pubDate>Sat, 03 Oct 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Ciência, Confirmação, Desconhecimento e Operação não Realizada: o que cada manifestação faz, os prazos, a confirmação tácita e os cuidados no envio.</description><content:encoded><![CDATA[<p>Toda NF-e emitida contra o CNPJ da sua empresa pode, e às vezes precisa, receber uma resposta: a manifestação do destinatário. Ela diz à SEFAZ se a empresa reconhece a operação. É também o que libera o XML completo da nota para download.</p>
<p>Este texto explica as quatro manifestações, os prazos e os cuidados. Ele se baseia na documentação que escrevemos para o <a href="https://vasfvitor.github.io/nanci/">Nanci</a>, um aplicativo de desktop para Windows, de código aberto, que baixa e manifesta NF-e direto do Ambiente Nacional da SEFAZ. Ele tem interface visual e também linha de comando. Não substitui a orientação do seu contador.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Só a <strong>empresa destinatária</strong> de uma NF-e <strong>autorizada</strong> pode manifestar.</li>
<li>A <strong>Ciência da Operação</strong> libera o XML completo, mas não é conclusiva.</li>
<li><strong>Confirmação</strong>, <strong>Desconhecimento</strong> e <strong>Operação não Realizada</strong> são conclusivas e <strong>definitivas</strong>.</li>
<li>As conclusivas podem ser registradas em até <strong>90 dias</strong> da autorização.</li>
<li>Sem manifestação conclusiva em 90 dias, vale a <strong>confirmação tácita</strong>: a operação é considerada ocorrida.</li>
</ul>
<h2 id="as-quatro-manifestações">As quatro manifestações</h2>
<table>
<thead>
<tr>
<th>Manifestação</th>
<th>Código do evento</th>
<th>Natureza</th>
<th>Quando usar</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ciência da Operação</td>
<td>210210</td>
<td>Não conclusiva</td>
<td>A empresa sabe que a nota existe, mas ainda não confirmou a operação</td>
</tr>
<tr>
<td>Confirmação da Operação</td>
<td>210200</td>
<td>Conclusiva</td>
<td>A operação aconteceu como descrito na nota</td>
</tr>
<tr>
<td>Desconhecimento da Operação</td>
<td>210220</td>
<td>Conclusiva</td>
<td>A empresa não reconhece a operação, por exemplo uma nota emitida contra ela sem que tenha comprado nada</td>
</tr>
<tr>
<td>Operação não Realizada</td>
<td>210240</td>
<td>Conclusiva</td>
<td>A operação foi combinada, mas não aconteceu, por exemplo mercadoria recusada. Exige justificativa de 15 a 255 caracteres</td>
</tr>
</tbody>
</table>
<h2 id="ciência-da-operação-o-que-ela-faz-e-o-que-não-faz">Ciência da Operação: o que ela faz e o que não faz</h2>
<p>Quando uma NF-e é emitida contra o seu CNPJ, a SEFAZ entrega primeiro um <strong>resumo</strong>: chave, emitente, valor e situação. O resumo não é o documento fiscal.</p>
<p>O XML completo só é distribuído depois de uma manifestação. A Ciência da Operação é a mais leve: ela só registra que a empresa tomou conhecimento da nota. Depois dela, o XML completo chega na próxima consulta.</p>
<p>Por isso muita empresa registra a Ciência de todas as notas recebidas, em lote, só para ter os XMLs. Funciona para ter os XMLs, mas tem um limite importante: a Ciência <strong>não conta</strong> como resposta definitiva e não interrompe o prazo da confirmação tácita.</p>
<h2 id="prazos-e-confirmação-tácita">Prazos e confirmação tácita</h2>
<ul>
<li><strong>Manifestações conclusivas:</strong> podem ser registradas em até 90 dias da autorização da NF-e. Fora desse prazo, a SEFAZ rejeita o evento com o código 596.</li>
<li><strong>Confirmação tácita:</strong> passados os 90 dias sem nenhuma manifestação conclusiva, a operação é considerada ocorrida, com os mesmos efeitos da Confirmação da Operação. A Ciência registrada antes não muda isso.</li>
</ul>
<p>Na prática, se uma nota foi emitida contra a sua empresa por engano ou por fraude e ninguém registrar o Desconhecimento em 90 dias, ela passa a valer como confirmada.</p>
<p>A regra está na cláusula 15ª-C do Ajuste SINIEF 07/05, na redação dos Ajustes SINIEF 11/22 e 14/26. As notas técnicas ficam no <a href="https://www.nfe.fazenda.gov.br/portal/principal.aspx">Portal Nacional da NF-e</a>.</p>
<h2 id="uma-manifestação-conclusiva-não-volta-atrás">Uma manifestação conclusiva não volta atrás</h2>
<p>Confirmação, Desconhecimento e Operação não Realizada são definitivas na SEFAZ. O Nanci também não aceita uma segunda conclusiva para a mesma nota.</p>
<p>Um caso que confunde: tentar registrar a Ciência numa nota que já tem manifestação conclusiva. A SEFAZ rejeita com o código 655, e nada é registrado.</p>
<p>Como não dá para voltar atrás, o Nanci sempre mostra o que vai enviar e só envia com uma confirmação explícita.</p>
<h2 id="códigos-de-resposta-que-aparecem">Códigos de resposta que aparecem</h2>
<table>
<thead>
<tr>
<th>Código</th>
<th>Quando aparece</th>
<th>O que quer dizer</th>
</tr>
</thead>
<tbody>
<tr>
<td>135 ou 136</td>
<td>No envio</td>
<td>Evento registrado</td>
</tr>
<tr>
<td>573</td>
<td>No envio</td>
<td>Evento já registrado antes (duplicidade). A manifestação já existe</td>
</tr>
<tr>
<td>596</td>
<td>No envio</td>
<td>Fora do prazo da manifestação conclusiva</td>
</tr>
<tr>
<td>655</td>
<td>No envio</td>
<td>A nota já tem manifestação conclusiva; a Ciência não foi registrada</td>
</tr>
<tr>
<td>656</td>
<td>Na consulta</td>
<td>Consumo indevido: a empresa consultou demais e foi bloqueada por um tempo</td>
</tr>
</tbody>
</table>
<p>O 656 vem das consultas de download, não do envio de eventos. Para evitá-lo, o Nanci faz no máximo 20 consultas por hora por empresa e espera uma hora quando não há nada novo.</p>
<h2 id="quem-recebe-o-quê">Quem recebe o quê</h2>
<p>A manifestação é do destinatário. O CNPJ também aparece em notas como transportador, como autorizado a obter o XML ou como emitente, e cada papel recebe uma coisa diferente. O post sobre <a href="https://ai.virtuaires.com.br/blog/baixar-nfse-nfe-cte-varias-empresas/">como baixar NFS-e, NF-e e CT-e de várias empresas</a> mostra o que chega em cada caso e explica a consulta.</p>
<h2 id="ciência-em-lote-com-cuidado">Ciência em lote, com cuidado</h2>
<p>Registrar a Ciência nota por nota não faz sentido para quem recebe muitas NF-e. O Nanci envia a Ciência em lote: até 20 eventos por envio, assinados com o certificado da empresa, pedindo a senha uma vez só para a operação inteira.</p>
<p>Alguns cuidados que aprendemos:</p>
<ul>
<li><strong>Simular antes.</strong> Ver quais notas vão receber a Ciência e quais ficam de fora, e por quê.</li>
<li><strong>Tratar o lote sem resposta.</strong> Se um lote não teve resposta, ele é reenviado uma vez. Se o lote já tinha chegado à SEFAZ, ela responde 573 e a nota conta como já registrada. Se continuar sem resposta, os lotes seguintes não são enviados, e as notas podem ser enviadas de novo depois.</li>
<li><strong>Acompanhar os prazos.</strong> O Nanci marca a nota como “ciência atrasada” 10 dias após a autorização, se ela ainda não tiver nenhuma manifestação. Esses 10 dias são um aviso do Nanci, não um prazo da SEFAZ. Ele também alerta a partir de 30 dias antes do fim do prazo das conclusivas. São só avisos: nenhuma manifestação é bloqueada por prazo.</li>
</ul>
<h2 id="no-nanci">No Nanci</h2>
<p>O Nanci é um aplicativo de desktop para Windows, com interface visual. A tela de NF-e tem duas abas. Em <strong>Notas</strong>, dá para selecionar notas e registrar a Ciência: um diálogo mostra as notas elegíveis e as que ficam de fora, com o motivo, antes do envio. Em <strong>Pendências</strong> ficam as notas sem manifestação conclusiva e os prazos. As conclusivas são feitas nota a nota, num diálogo que pede o tipo, a justificativa quando exigida e uma revisão final.</p>
<p>Quem prefere automatizar pode usar a linha de comando:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#6A737D"># Ciência da Operação: primeiro a simulação, depois o envio</span></span>
<span class="line"><span style="color:#B392F0">nanci.exe</span><span style="color:#9ECBFF"> nfe</span><span style="color:#9ECBFF"> ciencia</span><span style="color:#79B8FF"> --cnpj</span><span style="color:#79B8FF"> 12345678000199</span><span style="color:#79B8FF"> --todos-resumos</span></span>
<span class="line"><span style="color:#B392F0">nanci.exe</span><span style="color:#9ECBFF"> nfe</span><span style="color:#9ECBFF"> ciencia</span><span style="color:#79B8FF"> --cnpj</span><span style="color:#79B8FF"> 12345678000199</span><span style="color:#79B8FF"> --todos-resumos</span><span style="color:#79B8FF"> --confirmar</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># Operação não Realizada de uma nota, com justificativa</span></span>
<span class="line"><span style="color:#B392F0">nanci.exe</span><span style="color:#9ECBFF"> nfe</span><span style="color:#9ECBFF"> manifestar</span><span style="color:#79B8FF"> --cnpj</span><span style="color:#79B8FF"> 12345678000199</span><span style="color:#79B8FF"> --chave</span><span style="color:#F97583"> &lt;</span><span style="color:#9ECBFF">CHAV</span><span style="color:#E1E4E8">E</span><span style="color:#F97583">&gt;</span><span style="color:#79B8FF"> --tipo</span><span style="color:#9ECBFF"> nao_realizada</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --justificativa</span><span style="color:#9ECBFF"> "Mercadoria recusada no recebimento"</span><span style="color:#79B8FF"> --confirmar</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># Notas sem manifestação conclusiva que vencem em até 30 dias</span></span>
<span class="line"><span style="color:#B392F0">nanci.exe</span><span style="color:#9ECBFF"> nfe</span><span style="color:#9ECBFF"> pendentes</span><span style="color:#79B8FF"> --cnpj</span><span style="color:#79B8FF"> 12345678000199</span><span style="color:#79B8FF"> --vencendo-em</span><span style="color:#79B8FF"> 30</span></span></code></pre>
<p>Sem <code>--confirmar</code>, nada é enviado: o comando só mostra a simulação.</p>
<p>Se a sua empresa precisa que isso aconteça dentro do próprio ERP, é o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>API da ANVISA: o que a documentação não conta</title><link>https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/</guid><pubDate>Wed, 16 Sep 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>O que a API Consultas Externas da ANVISA faz de verdade: autenticação, limite de requisições, paginação, erros e downloads, verificado em chamadas reais.</description><content:encoded><![CDATA[<p>A ANVISA publica uma API oficial, a <strong>Consultas Externas</strong>, com fila de análise, UDI de dispositivos médicos, termos GMDN, nomes técnicos, listas e assuntos de peticionamento. Ela vem com um documento OpenAPI. Na prática, esse documento erra ou omite boa parte do que importa para a chamada funcionar.</p>
<p>Este texto registra o que a API faz de verdade. Os comportamentos descritos foram verificados em chamadas reais, e a API pode mudar depois disso.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>O <code>User-Agent</code> padrão do curl recebe <strong>403</strong>.</li>
<li>O token dura <strong>1740 segundos</strong>, e o tutorial do portal diz 300. Não há refresh token.</li>
<li>O limite é uma <strong>rajada de 25 requisições, com reposição de 1 por segundo</strong>, contado por endereço de origem.</li>
<li>Erros de validação voltam como <strong>HTTP 500</strong>.</li>
<li>A paginação começa em <strong>1 no pedido</strong> e em <strong>0 na resposta</strong>.</li>
<li>Quase todo download responde 500 a <code>Accept: application/json</code>. Com <code>Accept: */*</code> o arquivo vem.</li>
<li>O portal documenta <strong>35 rotas a mais</strong>. As sete que testamos responderam 404.</li>
</ul>
<h2 id="o-comportamento-real-ponto-a-ponto">O comportamento real, ponto a ponto</h2>
<table>
<thead>
<tr>
<th>Tema</th>
<th>O que acontece</th>
</tr>
</thead>
<tbody>
<tr>
<td>User-Agent</td>
<td>O Cloudflare responde 403 ao User-Agent padrão do curl</td>
</tr>
<tr>
<td>Autenticação</td>
<td>OAuth 2.0 client credentials, token de 1740 s, sem refresh token</td>
</tr>
<tr>
<td>Limite de requisições</td>
<td>Rajada de 25, reposição de 1 por segundo, por endereço de origem</td>
</tr>
<tr>
<td>Paginação</td>
<td>Pedido começa em 1, resposta começa em 0</td>
</tr>
<tr>
<td>Filtros</td>
<td>Algumas consultas exigem um filtro mesmo quando você quer tudo</td>
</tr>
<tr>
<td>Erros</td>
<td>Falha de validação volta como HTTP 500, com a mensagem no corpo</td>
</tr>
<tr>
<td>Fila e lista</td>
<td>Devolvem a subfila inteira e ignoram <code>page</code> e <code>size</code></td>
</tr>
<tr>
<td>Datas</td>
<td>Inteiros, em milissegundos desde 1970</td>
</tr>
<tr>
<td>Downloads</td>
<td>Erro 500 com <code>Accept: application/json</code> em quase todos</td>
</tr>
</tbody>
</table>
<h2 id="user-agent-o-primeiro-403">User-Agent: o primeiro 403</h2>
<p>A API fica atrás do Cloudflare. Ele responde <strong>403</strong> ao User-Agent padrão do curl, até para a URL do próprio documento OpenAPI. A saída é enviar um User-Agent que descreva o seu programa.</p>
<p>É o primeiro obstáculo e o mais confuso, porque parece erro de credencial e não é.</p>
<h2 id="autenticação">Autenticação</h2>
<p>A autenticação é OAuth 2.0 no fluxo <em>client credentials</em>. O servidor é um Keycloak, no realm <code>externo</code>. As credenciais saem do <a href="https://api.anvisa.gov.br/">Portal de APIs da ANVISA</a>, com login Gov.br: um Client ID e um Client Secret.</p>
<p>Três detalhes que encontramos:</p>
<ul>
<li>O token dura <strong>1740 segundos</strong> (29 minutos). O tutorial do portal diz 300.</li>
<li>Não há <strong>refresh token</strong>. Quando o token vence, o programa pede outro.</li>
<li>O token traz um único papel, <code>CONSULTASEXTERNAS_LEITURA</code>.</li>
</ul>
<p>Vale guardar o token e renovar um pouco antes do vencimento, em vez de pedir um novo a cada chamada.</p>
<h2 id="limite-de-requisições">Limite de requisições</h2>
<p>O gateway usa um <em>token bucket</em>: aceita uma rajada de <strong>25 requisições</strong> e repõe <strong>1 por segundo</strong>. O limite aparece só nos cabeçalhos <code>X-RateLimit-*</code> das respostas.</p>
<p>O saldo é contado por <strong>endereço de origem</strong>, não por Client ID. As rotas públicas do portal, que nem pedem autenticação, passam pelo mesmo gateway e gastam o mesmo saldo. Programas que saem pelo mesmo endereço dividem o limite, mesmo com credenciais diferentes.</p>
<h2 id="paginação">Paginação</h2>
<p>No pedido, as páginas começam em <strong>1</strong>. Enviar <code>page: 0</code> devolve erro. As respostas são objetos <code>Page</code> do Spring, que começam em <strong>0</strong>. A página que você pede como 1 volta identificada como 0.</p>
<h2 id="filtros-obrigatórios">Filtros obrigatórios</h2>
<ul>
<li><code>POST /udi</code> exige <strong>pelo menos uma</strong> chave em <code>filter</code>.</li>
<li><code>POST /fila/consulta</code> e <code>POST /lista/consulta</code> exigem <code>filter.subfila</code>. Isso mesmo: a de lista também pede <code>subfila</code>.</li>
</ul>
<p>Chaves de filtro que verificamos:</p>
<ul>
<li><strong><code>udi</code></strong>: <code>nomeComercial</code> (trecho do nome), <code>udiDi</code> (valor exato), <code>cnpjDetentora</code>, <code>codigoGmdn</code> e <code>nuRegistro</code>.</li>
<li><strong><code>nomeTecnico</code></strong>: <code>nomeTecnico</code> (trecho do nome) e <code>categoriaProduto</code>.</li>
<li><strong><code>termoGmdn</code></strong>: <code>conteudo</code>.</li>
</ul>
<p><code>POST /assunto/</code> estava quebrado nos nossos testes: respondia 500 e não lia o corpo enviado.</p>
<h2 id="erros">Erros</h2>
<p>Falhas de validação voltam como <strong>HTTP 500</strong>. O corpo traz os campos <code>status</code>, <code>mensagem</code>, <code>data_hora</code> e <code>mensagem_detalhada</code>.</p>
<p>Isso muda o tratamento de erro. Um 500 aqui muitas vezes quer dizer “o pedido está errado”, e tentar de novo não resolve. É preciso ler a mensagem.</p>
<h2 id="fila-e-lista">Fila e lista</h2>
<p><code>fila/consulta</code> e <code>lista/consulta</code> devolvem a <strong>subfila ou sublista inteira</strong> em um array. Vimos respostas com 40, 35, 71 e 555 linhas. Os parâmetros <code>page</code> e <code>size</code> são ignorados.</p>
<p>Uma subfila vazia responde <strong>404 com corpo vazio</strong>. Um código de subfila que não existe recebe a mesma resposta, então não dá para distinguir os dois casos.</p>
<h2 id="datas">Datas</h2>
<p>As datas vêm como inteiros, em <strong>milissegundos desde 1º de janeiro de 1970</strong>.</p>
<h2 id="downloads">Downloads</h2>
<p>Aqui o documento OpenAPI descreve uma resposta <code>200 OK</code> sem conteúdo e não fala do cabeçalho <code>Accept</code>. Na prática, é outra história.</p>
<ul>
<li>Com <code>Accept: application/json</code>, quase todo download responde <strong>500</strong>, com a mensagem “Could not find acceptable representation”. As exceções que vimos foram <code>GET /udi/{id}/download</code> e o arquivo semanal de <code>GET /udi/historico/{id}/download</code>. Com <code>Accept: */*</code> o arquivo vem.</li>
<li>Quando a resposta informa o <code>Content-Type</code>, ele é <code>application/vnd.ms-excel</code>, mesmo que o arquivo seja um <code>.xlsx</code> ou um zip.</li>
<li><code>POST /assunto/downloadAssuntoFormulario</code> recebe como corpo JSON <strong>só um número inteiro</strong>, em vez do objeto que o documento declara. A resposta vem sem <code>Content-Type</code> e sem <code>Content-Disposition</code>.</li>
<li>No download da fila (<code>POST /fila/downloadfila</code>), uma subfila vazia responde <strong>500</strong>. Na consulta, uma subfila vazia responde 404.</li>
</ul>
<h2 id="o-que-está-documentado-e-ainda-não-existe">O que está documentado e ainda não existe</h2>
<p>O documento OpenAPI tem 32 rotas. As páginas do portal descrevem <strong>mais 35</strong>, no mesmo endereço base: certificados, empresa nacional e internacional, dossiê, alimentos e produtos de saúde.</p>
<p>Testamos sete delas. Todas responderam o <strong>404</strong> padrão do Spring. Pelo que vimos, estão documentadas e ainda não estão no ar.</p>
<p>Os mesmos conjuntos de dados existem em CSV no <a href="https://dados.anvisa.gov.br/dados/CONSULTAS/">portal de dados abertos da ANVISA</a>. O arquivo que conferimos, o de produtos irregulares, era atualizado nos dias úteis.</p>
<h2 id="como-verificamos">Como verificamos</h2>
<p>Fizemos chamadas reais à API e gravamos as respostas. Elas estão no repositório <a href="https://github.com/vasfvitor/anvisa-api">anvisa-api</a>, na pasta <code>fixtures</code>, com um manifesto. Os testes do cliente rodam sobre essas respostas gravadas.</p>
<p>As correções ao documento da ANVISA estão em um arquivo à parte, no formato <a href="https://spec.openapis.org/overlay/v1.0.0.html">OpenAPI Overlay</a>. O documento original fica como foi publicado, só reformatado, e dá para ver exatamente o que mudamos.</p>
<p>Um processo automático baixa de novo, duas vezes por mês, as páginas do portal sobre as rotas que ainda não estão no ar. Se a ANVISA mudar essas páginas, isso aparece como um diff no repositório.</p>
<p>As filas de análise também saem todo dia como feeds Atom, no projeto <a href="https://github.com/vasfvitor/anvisa-feeds">anvisa-feeds</a>.</p>
<p>O que não sabemos: se esses comportamentos são intencionais e por quanto tempo vão continuar assim.</p>
<h2 id="um-cliente-que-já-trata-isso">Um cliente que já trata isso</h2>
<p>Publicamos um cliente em Python com licença MIT. Ele envia o User-Agent, guarda o token e renova antes do vencimento, respeita o limite do gateway para não receber 429 (requisições demais), usa <code>Accept: */*</code> nos downloads e transforma a maior parte dos erros 500 em exceções específicas.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">uv</span><span style="color:#9ECBFF"> tool</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> anvisa</span><span style="color:#6A737D">      # ou: pipx install anvisa</span></span></code></pre>
<p>As credenciais vêm das variáveis <code>ANVISA_CLIENT_ID</code> e <code>ANVISA_CLIENT_SECRET</code>. O número 167 no exemplo é o código de uma subfila.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="python"><code><span class="line"><span style="color:#F97583">from</span><span style="color:#E1E4E8"> anvisa </span><span style="color:#F97583">import</span><span style="color:#E1E4E8"> Client</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">with</span><span style="color:#E1E4E8"> Client.from_env() </span><span style="color:#F97583">as</span><span style="color:#E1E4E8"> anvisa:</span></span>
<span class="line"><span style="color:#F97583">    for</span><span style="color:#E1E4E8"> row </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> anvisa.fila.consulta(</span><span style="color:#79B8FF">167</span><span style="color:#E1E4E8">):</span></span>
<span class="line"><span style="color:#79B8FF">        print</span><span style="color:#E1E4E8">(row.nuOrdem, row.numeroProcessoFormatado, row.dsAssunto)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    for</span><span style="color:#E1E4E8"> device </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> anvisa.udi.iter_search(</span><span style="color:#FFAB70">nomeComercial</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">"cateter"</span><span style="color:#E1E4E8">):</span></span>
<span class="line"><span style="color:#79B8FF">        print</span><span style="color:#E1E4E8">(device.udiDi, device.nomeComercial)</span></span></code></pre>
<p>Ele cobre as 32 rotas do documento publicado. O projeto não tem vínculo com a ANVISA.</p>
<p>Quem integra com serviços da Receita e da SEFAZ encontra o mesmo tipo de surpresa; o post sobre <a href="https://ai.virtuaires.com.br/blog/baixar-nfse-nfe-cte-varias-empresas/">baixar NFS-e, NF-e e CT-e</a> trata disso.</p>
<p>Se a sua empresa depende de um sistema ou de uma API que não se comporta como a documentação diz, é esse o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/engenharia-reversa/">engenharia reversa</a> e em <a href="https://ai.virtuaires.com.br/solucoes/integracoes/">integrações</a>.</p>
]]></content:encoded></item><item><title>Sob medida até nas capas do blog</title><link>https://ai.virtuaires.com.br/blog/sob-medida-ate-nas-capas-do-blog/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/sob-medida-ate-nas-capas-do-blog/</guid><pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Como escolhemos a imagem de cada post do blog: sete rodadas de testes, mais de trinta ideias descartadas e um desenho que é só de cada post.</description><content:encoded><![CDATA[<p>Todo post precisa de uma imagem. Ela aparece no topo da página e é o que as pessoas veem quando alguém compartilha o link no WhatsApp ou no LinkedIn.</p>
<p>O caminho fácil é um banco de fotos. Só que a foto não diz nada sobre o post nem sobre quem escreveu, e a mesma imagem aparece em centenas de outros sites. Fizemos diferente, e o processo foi parecido com o que fazemos nos projetos de clientes.</p>
<h2 id="o-que-a-imagem-precisava-ter">O que a imagem precisava ter</h2>
<ul>
<li>Ser <strong>única por post</strong>, sem ninguém desenhar uma a uma.</li>
<li>Sair <strong>sempre igual</strong> para o mesmo post, a cada vez que o site é publicado.</li>
<li>Ter <strong>a cara do site</strong>: fundo claro, traço fino, um único acento rosa.</li>
<li>Ser <strong>gerada automaticamente</strong>, sem depender de nenhum serviço externo.</li>
</ul>
<h2 id="como-trabalhamos">Como trabalhamos</h2>
<p>Cada ideia virou uma página de testes, com as capas de três posts lado a lado: o da <a href="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/">API da ANVISA</a>, o de <a href="https://ai.virtuaires.com.br/blog/baixar-nfse-nfe-cte-varias-empresas/">notas fiscais</a> e o da <a href="https://ai.virtuaires.com.br/blog/api-mercos-o-que-testamos/">API do Mercos</a>. Assim dava para comparar de verdade, com os títulos reais, em vez de imaginar.</p>
<p>As variações foram geradas por um agente de IA de programação. A cada rodada, ele escrevia o código de várias ideias e montava a página de testes a partir dos nossos comentários. Gerar uma rodada inteira levava minutos. A parte que exigia gente era olhar e decidir.</p>
<p>O critério de decisão foi olhar. Uma ideia que parecia ótima no papel podia sair sem graça, e o contrário também aconteceu. Foram sete rodadas.</p>
<h2 id="rodada-1-desenhar-os-dados-do-projeto">Rodada 1: desenhar os dados do projeto</h2>
<p>A primeira ideia foi usar informações do próprio código de cada projeto: a lista de arquivos como uma planta baixa, o histórico de alterações como uma régua, os horários de trabalho como uma grade.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r1-planta.webp" alt="A estrutura de arquivos de cada projeto desenhada como uma planta baixa, em três capas"></p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r1-regua.webp" alt="O histórico de alterações de cada projeto desenhado como traços numa régua, em três capas"></p>
<p>A planta baixa foi considerada interessante. O histórico, não. Na revisão, o comentário foi:</p>
<blockquote>
<p>“Histórico de commit é ruim, pois alguns desses têm pouco e fica vazio, e vai ficar vazio com o tempo.”</p>
</blockquote>
<p>Um projeto pequeno gera uma imagem pobre, e continua assim. Também testamos o céu do Recife no dia da publicação, que ficou bonito, mas “não é característico o suficiente”.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r1-ceu.webp" alt="O céu do Recife na data de cada post, em três capas parecidas entre si"></p>
<h2 id="rodada-2-arte-a-partir-de-um-código-único">Rodada 2: arte a partir de um código único</h2>
<p>Trocamos os dados por algo que nunca falta: o código de identificação da primeira versão do projeto, uma sequência de 40 letras e números. A partir dele, um programa pode gerar qualquer desenho, e o mesmo código gera sempre o mesmo desenho.</p>
<p>Testamos oito estilos: ladrilhos, curvas de nível, linhas de fluxo, uma cordilheira, uma fita métrica, peças de um molde de costura, cores e um harmonógrafo, que simula a caneta presa a dois pêndulos.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r2-fluxo.webp" alt="Linhas de fluxo geradas para três posts"></p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r2-molde.webp" alt="Peças de molde de costura geradas para três posts"></p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r2-harmonografo.webp" alt="Desenhos de harmonógrafo gerados para três posts"></p>
<p>O veredito: “o fluxo e as curvas não são tão distintos”, “o molde parece legal, mas meh, e a fita também é meio sem graça”. E o harmonógrafo “tá ganhando”.</p>
<h2 id="rodada-3-imagens-quase-iguais">Rodada 3: imagens quase iguais</h2>
<p>Para ter mais variedade, montamos uma família de máquinas de desenho parecidas com o harmonógrafo. Foi aí que apareceu o problema mais interessante do processo.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r3-espirografos.webp" alt="Dois desenhos de espirógrafo gerados de códigos diferentes, quase idênticos"></p>
<p>Esses dois desenhos vieram de códigos diferentes e saíram quase iguais. A pergunta na hora foi: “pode ter várias colisões visuais, concorda?” Concordamos. Um código único não garante um desenho único. Se a regra do desenho só sabe fazer algumas dezenas de formas, dois posts vão acabar com a mesma.</p>
<p>Isso mudou o critério: além de bonito, o estilo precisa ter muitas formas possíveis. E mudou a direção. O pedido seguinte foi “máquina de desenho não, outras coisas”, e o harmonógrafo, que estava ganhando, saiu da disputa.</p>
<h2 id="rodadas-4-e-5-outros-caminhos">Rodadas 4 e 5: outros caminhos</h2>
<p>Tentamos de tudo um pouco. Desenho técnico de uma peça usinada, células pintadas como as da página inicial, um número grande que resume o post, uma palavra do título medida.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r4-peca.webp" alt="Desenho técnico de peças usinadas geradas para três posts"></p>
<p>Depois, fractais, caos matemático, mosaicos coloridos e até sistemas planetários.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r5-julia.webp" alt="Fractais de Julia gerados para três posts"></p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r5-voronoi.webp" alt="Mosaicos coloridos gerados para três posts"></p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r5-planetas.webp" alt="Sistemas planetários gerados para três posts"></p>
<p>Alguns eram bonitos. Da rodada 5, o veredito foi “não gostei de nenhum”. Na nossa leitura, fractais e mosaicos coloridos chamam atenção, mas brigam com o resto do site. Bonito não basta, precisa combinar.</p>
<h2 id="rodada-6-olhar-referências">Rodada 6: olhar referências</h2>
<p>Em vez de criar mais variações, montamos uma lista de referências de arte gerada por computador e escolhemos as que valia a pena testar: plantas que crescem por regras, o crescimento de um mofo, o kolam (desenho tradicional indiano em volta de pontos) e outras.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r6-plantas.webp" alt="Plantas geradas por regras de crescimento, para três posts"></p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r6-kolam.webp" alt="Kolam, linhas fechadas em volta de uma grade de pontos, para três posts"></p>
<p>As plantas quase convenceram. Faltava resolver a composição, porque o título cobria o desenho na imagem de compartilhamento. E veio a pergunta: dá para fazer em 3D?</p>
<h2 id="rodada-7-plantas-em-3d">Rodada 7: plantas em 3D</h2>
<p>A primeira versão em 3D tinha piso em perspectiva e sombras. O comentário foi curto: “o 3D ficou estranho”.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r7-3d-1.webp" alt="Primeira versão das plantas em 3D, com piso em perspectiva e sombras"></p>
<p>Corrigimos troncos, câmera e sombras. Melhorou, mas ainda havia dois problemas: “o piso tá tosco” e “parece que tem uma árvore repetida em cada post, só mudando a escala dela”.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r7-3d-3.webp" alt="Plantas em 3D em que a mesma árvore se repete em tamanhos diferentes"></p>
<p>A resposta foi ter cinco espécies, cada uma com várias regras de crescimento, galhos podados ao acaso e nunca a mesma espécie duas vezes na mesma imagem. Por último, saíram o chão e as sombras, e o tracejado rosa que contornava só o título passou a contornar a imagem inteira.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r7-3d-5.webp" alt="Versão final: plantas de espécies diferentes, sem chão e sem sombra"></p>
<p>O resultado não é perfeito. O cipreste às vezes sai fino como um palito, e o pinheiro pode ficar ralo. Mesmo assim, a versão foi considerada boa o bastante. Ela cumpre todos os requisitos, e cedo ou tarde é preciso decidir: uma capa que já funciona vale mais publicada do que em mais uma rodada de ajustes.</p>
<h2 id="o-resultado">O resultado</h2>
<p>Cada post ganha um pequeno jardim, desenhado a partir do código da primeira versão do projeto de que ele fala. Quando o post não fala de um projeto, o código usado é o endereço do próprio post, como neste. A capa do topo mostra só as plantas. A imagem de compartilhamento tem o título, duas plantas e a moldura tracejada do site.</p>
<p><img src="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/og.png" alt="Imagem de compartilhamento do post sobre a API da ANVISA: título no centro, uma planta de cada lado e moldura tracejada rosa"></p>
<p>Ninguém desenha as capas, e mesmo assim cada uma é só daquele post. Quando um post novo entrar, ele ganha o seu jardim sem que ninguém precise fazer nada.</p>
<h2 id="o-que-isso-diz-sobre-como-trabalhamos">O que isso diz sobre como trabalhamos</h2>
<ul>
<li><strong>Testar barato e olhar de verdade.</strong> Com o agente de IA gerando as variações, cada rodada levou pouco tempo e mostrou o resultado real, com o conteúdo real.</li>
<li><strong>Descartar sem dó.</strong> Mais de trinta ideias ficaram pelo caminho, várias delas bonitas.</li>
<li><strong>Decidir com critério.</strong> O critério mudou no meio do caminho, quando apareceram as imagens quase iguais, e isso foi bom.</li>
<li><strong>Saber parar.</strong> Quando a solução cumpre os requisitos, a próxima rodada de ajustes custa mais do que entrega.</li>
</ul>
<p>É o mesmo jeito de trabalhar nos sistemas que fazemos para clientes. Quem quiser os detalhes técnicos, com código, encontra no post sobre <a href="https://ai.virtuaires.com.br/blog/capas-geradas-com-l-system-e-sharp/">como as capas são geradas</a>.</p>
]]></content:encoded></item><item><title>Capas de blog geradas com L-system e sharp</title><link>https://ai.virtuaires.com.br/blog/capas-geradas-com-l-system-e-sharp/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/capas-geradas-com-l-system-e-sharp/</guid><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Como geramos a capa e a imagem de compartilhamento de cada post no build do Astro: semente do primeiro commit, L-system em 3D e o título escrito pelo sharp.</description><content:encoded><![CDATA[<p>Cada post deste blog tem uma capa e uma imagem de compartilhamento geradas no build. As duas mostram um pequeno jardim de plantas, desenhado a partir de uma semente própria do post. Este texto mostra como isso funciona e o que ficou pelo caminho. As imagens de teste usaram três posts como amostra: <a href="https://ai.virtuaires.com.br/blog/api-anvisa-consultas-externas/">API da ANVISA</a>, <a href="https://ai.virtuaires.com.br/blog/baixar-nfse-nfe-cte-varias-empresas/">notas fiscais</a> e <a href="https://ai.virtuaires.com.br/blog/api-mercos-o-que-testamos/">API do Mercos</a>. As variações de cada rodada foram escritas por um agente de IA de programação, a partir dos nossos comentários. A história do processo, com as imagens de cada rodada, está no post <a href="https://ai.virtuaires.com.br/blog/sob-medida-ate-nas-capas-do-blog/">Sob medida até nas capas do blog</a>.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>A <strong>semente</strong> é o hash do primeiro commit do repositório de que o post fala, guardado num JSON. Sem repositório, é o SHA-1 do endereço do post.</li>
<li>O desenho é um <strong>L-system em 3D</strong>: cinco espécies, regras com variantes, poda, gravidade e espessura pelo número de pontas.</li>
<li>Tudo vira <strong>SVG</strong>, e o <strong>sharp</strong> transforma em WebP (capa) e PNG (compartilhamento).</li>
<li>O título no PNG é escrito pelo próprio sharp. Ele <strong>não leu a fonte em WOFF2</strong>; com o <strong>TTF</strong> funcionou.</li>
<li>Duas rotas estáticas do Astro geram os arquivos: <code>/blog/&lt;post&gt;/capa.webp</code> e <code>/blog/&lt;post&gt;/og.png</code>.</li>
</ul>
<h2 id="os-requisitos">Os requisitos</h2>
<ol>
<li>Uma imagem diferente por post, sem desenhar à mão.</li>
<li>A mesma imagem a cada build.</li>
<li>Nada de rede no build. O site já tem um build de teste que roda sem acesso à internet.</li>
<li>Nenhuma dependência nova, se possível.</li>
<li>A identidade do site: fundo claro, traço em azul-escuro, um único acento rosa.</li>
</ol>
<h2 id="a-semente">A semente</h2>
<p>A primeira tentativa usou dados vivos do repositório: árvore de arquivos, datas dos commits. Repositório com poucos commits gera uma imagem vazia, e continua vazia. Por isso a semente passou a ser algo fixo: o hash do primeiro commit.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> firstCommits </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> "./first-commits.json"</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> seedFor</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Post</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Seed</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> repo</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> post.data.projects.</span><span style="color:#B392F0">find</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">name</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> name </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> firstCommits) </span><span style="color:#F97583">as</span><span style="color:#F97583"> keyof</span><span style="color:#F97583"> typeof</span><span style="color:#E1E4E8"> firstCommits </span><span style="color:#F97583">|</span><span style="color:#79B8FF"> undefined</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    if</span><span style="color:#E1E4E8"> (repo) </span><span style="color:#F97583">return</span><span style="color:#E1E4E8"> { hash: firstCommits[repo], source: repo };</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#E1E4E8"> { hash: </span><span style="color:#B392F0">createHash</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"sha1"</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">update</span><span style="color:#E1E4E8">(post.id).</span><span style="color:#B392F0">digest</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"hex"</span><span style="color:#E1E4E8">), source: </span><span style="color:#9ECBFF">""</span><span style="color:#E1E4E8"> };</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>O JSON foi preenchido uma vez, com a API do GitHub. O primeiro commit não muda, então não há o que atualizar.</p>
<p>Do hash sai um gerador de números pseudoaleatórios de 32 bits. Cada decisão do desenho consome o próximo número, sempre na mesma ordem.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#F97583">function</span><span style="color:#B392F0"> random</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">hash</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> a </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> (</span><span style="color:#B392F0">parseInt</span><span style="color:#E1E4E8">(hash.</span><span style="color:#B392F0">slice</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">8</span><span style="color:#E1E4E8">), </span><span style="color:#79B8FF">16</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">^</span><span style="color:#B392F0"> parseInt</span><span style="color:#E1E4E8">(hash.</span><span style="color:#B392F0">slice</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">8</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">16</span><span style="color:#E1E4E8">), </span><span style="color:#79B8FF">16</span><span style="color:#E1E4E8">)) </span><span style="color:#F97583">|</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">        a </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> (a </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 0x6d2b79f5</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">|</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">        let</span><span style="color:#E1E4E8"> t </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> Math.</span><span style="color:#B392F0">imul</span><span style="color:#E1E4E8">(a </span><span style="color:#F97583">^</span><span style="color:#E1E4E8"> (a </span><span style="color:#F97583">&gt;&gt;&gt;</span><span style="color:#79B8FF"> 15</span><span style="color:#E1E4E8">), </span><span style="color:#79B8FF">1</span><span style="color:#F97583"> |</span><span style="color:#E1E4E8"> a);</span></span>
<span class="line"><span style="color:#E1E4E8">        t </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> (t </span><span style="color:#F97583">+</span><span style="color:#E1E4E8"> Math.</span><span style="color:#B392F0">imul</span><span style="color:#E1E4E8">(t </span><span style="color:#F97583">^</span><span style="color:#E1E4E8"> (t </span><span style="color:#F97583">&gt;&gt;&gt;</span><span style="color:#79B8FF"> 7</span><span style="color:#E1E4E8">), </span><span style="color:#79B8FF">61</span><span style="color:#F97583"> |</span><span style="color:#E1E4E8"> t)) </span><span style="color:#F97583">^</span><span style="color:#E1E4E8"> t;</span></span>
<span class="line"><span style="color:#F97583">        return</span><span style="color:#E1E4E8"> ((t </span><span style="color:#F97583">^</span><span style="color:#E1E4E8"> (t </span><span style="color:#F97583">&gt;&gt;&gt;</span><span style="color:#79B8FF"> 14</span><span style="color:#E1E4E8">)) </span><span style="color:#F97583">&gt;&gt;&gt;</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">/</span><span style="color:#79B8FF"> 4294967296</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">    };</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h2 id="hash-único-não-é-desenho-único">Hash único não é desenho único</h2>
<p>Uma das rodadas testou uma família de máquinas de desenho. Os dois espirógrafos abaixo vieram de hashes diferentes.</p>
<p><img src="https://ai.virtuaires.com.br/blog/capas-figuras/r3-espirografos.webp" alt="Dois espirógrafos gerados de hashes diferentes, quase idênticos"></p>
<p>O espirógrafo depende de dois inteiros pequenos, os tamanhos das engrenagens. Do jeito que estava montado, havia só algumas dezenas de combinações, e várias davam a mesma estrela. O hash tem 160 bits, e mesmo os 32 que o gerador usa dão mais de 4 bilhões de sementes. Mas o desenho só sabia fazer poucas formas.</p>
<p>A regra que ficou: o desenho precisa de muitos parâmetros contínuos, e a avaliação de “parecido” é feita olhando as imagens, não comparando os números.</p>
<h2 id="l-system-em-3d">L-system em 3D</h2>
<p>Um L-system é uma gramática que reescreve uma palavra várias vezes. Depois, uma tartaruga (como a da linguagem Logo) lê a palavra e desenha. Usamos os símbolos de <em>The Algorithmic Beauty of Plants</em>, de Prusinkiewicz e Lindenmayer:</p>
<table>
<thead>
<tr>
<th>Símbolo</th>
<th>Ação</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>F</code></td>
<td>anda e desenha um galho</td>
</tr>
<tr>
<td><code>+</code> <code>-</code></td>
<td>gira para os lados</td>
</tr>
<tr>
<td><code>&amp;</code> <code>^</code></td>
<td>inclina para baixo e para cima</td>
</tr>
<tr>
<td><code>\</code> <code>/</code></td>
<td>gira sobre o próprio eixo</td>
</tr>
<tr>
<td><code>[</code> <code>]</code></td>
<td>abre e fecha um ramo</td>
</tr>
<tr>
<td><code>L</code></td>
<td>ponto onde pode nascer uma flor</td>
</tr>
</tbody>
</table>
<p>A tartaruga guarda três vetores (direção, esquerda e cima), e cada giro é uma rotação em torno de um deles, pela fórmula de Rodrigues.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#B392F0"> turn</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> (</span><span style="color:#FFAB70">v</span><span style="color:#F97583">:</span><span style="color:#B392F0"> V</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">k</span><span style="color:#F97583">:</span><span style="color:#B392F0"> V</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">a</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> number</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">:</span><span style="color:#B392F0"> V</span><span style="color:#F97583"> =&gt;</span></span>
<span class="line"><span style="color:#B392F0">    add</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">add</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">mul</span><span style="color:#E1E4E8">(v, Math.</span><span style="color:#B392F0">cos</span><span style="color:#E1E4E8">(a)), </span><span style="color:#B392F0">mul</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">cross</span><span style="color:#E1E4E8">(k, v), Math.</span><span style="color:#B392F0">sin</span><span style="color:#E1E4E8">(a))), </span><span style="color:#B392F0">mul</span><span style="color:#E1E4E8">(k, </span><span style="color:#B392F0">dot</span><span style="color:#E1E4E8">(k, v) </span><span style="color:#F97583">*</span><span style="color:#E1E4E8"> (</span><span style="color:#79B8FF">1</span><span style="color:#F97583"> -</span><span style="color:#E1E4E8"> Math.</span><span style="color:#B392F0">cos</span><span style="color:#E1E4E8">(a))));</span></span></code></pre>
<p>Uma versão com uma regra por espécie gerou o problema de sempre: a mesma árvore em todos os posts, só em escala diferente. O que resolveu:</p>
<ul>
<li><strong>Cinco espécies</strong>, com silhuetas diferentes: copa larga, arbusto (o da figura 1.25 do livro), chorão, cipreste e pinheiro.</li>
<li><strong>Variantes por regra.</strong> As regras principais têm duas a quatro reescritas possíveis, e o gerador escolhe uma a cada passo.</li>
<li><strong>Faixas em vez de valores fixos</strong> para o ângulo, a altura do tronco e o quanto os galhos pendem.</li>
<li><strong>Poda.</strong> Ao abrir um ramo, há uma chance de pular o ramo inteiro, o que deixa as copas irregulares.</li>
<li><strong>Espécies diferentes</strong> no mesmo jardim.</li>
</ul>
<p>Dois detalhes deram o ar natural:</p>
<ul>
<li><strong>Gravidade.</strong> Depois de cada <code>F</code>, a direção gira um pouco em direção ao chão, proporcional ao quanto o galho está fora da vertical.</li>
<li><strong>Espessura.</strong> Percorrendo os galhos de trás para frente, cada um recebe a soma das pontas que sustenta. A largura do traço cresce com a raiz dessa soma, até um limite.</li>
</ul>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">let</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> segments.</span><span style="color:#79B8FF">length</span><span style="color:#F97583"> -</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">; i </span><span style="color:#F97583">&gt;=</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">; i</span><span style="color:#F97583">--</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> seg</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> segments[i];</span></span>
<span class="line"><span style="color:#E1E4E8">    seg.load </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> tips.</span><span style="color:#B392F0">get</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">key</span><span style="color:#E1E4E8">(seg.b)) </span><span style="color:#F97583">??</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">    tips.</span><span style="color:#B392F0">set</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">key</span><span style="color:#E1E4E8">(seg.a), (tips.</span><span style="color:#B392F0">get</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">key</span><span style="color:#E1E4E8">(seg.a)) </span><span style="color:#F97583">??</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">+</span><span style="color:#E1E4E8"> seg.load);</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h2 id="câmera-e-traço">Câmera e traço</h2>
<p>A câmera olha quase de frente, girada até 8 graus para os lados, um pouco de cima, com perspectiva leve. Os galhos são desenhados do mais distante para o mais próximo, e os distantes ficam mais claros e mais finos.</p>
<p>A primeira versão usava transparência nos traços. Onde dois segmentos se encontram, as pontas arredondadas se sobrepunham e escureciam, e os troncos pareciam listrados. A solução foi usar cores sólidas, misturadas com a cor do fundo conforme a distância.</p>
<p>Para o SVG não ficar enorme, os segmentos com a mesma cor e a mesma largura vão para um único <code>&lt;path&gt;</code>.</p>
<p>Ficaram limitações conhecidas: o cipreste às vezes sai fino como um palito, e o pinheiro pode ficar ralo. Foram aceitas. O resultado cumpre os requisitos, e mais uma rodada de ajustes não mudaria isso.</p>
<p>O piso em perspectiva e as sombras ficaram de fora. Funcionavam tecnicamente, mas pareciam artificiais perto do traço fino do resto do site.</p>
<h2 id="do-svg-ao-arquivo">Do SVG ao arquivo</h2>
<p>O sharp, que o Astro já usa para imagens, lê SVG e exporta qualquer formato.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> coverImage</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">seed</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Seed</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#B392F0"> sharp</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">svg</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">garden</span><span style="color:#E1E4E8">(seed.hash, box)), { density: </span><span style="color:#79B8FF">144</span><span style="color:#E1E4E8"> }).</span><span style="color:#B392F0">webp</span><span style="color:#E1E4E8">({ quality: </span><span style="color:#79B8FF">82</span><span style="color:#E1E4E8"> }).</span><span style="color:#B392F0">toBuffer</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Com <code>density: 144</code>, o SVG de 1200 × 630 vira uma imagem de 2400 × 1260, para telas de alta densidade. Cada capa ficou com cerca de 100 KB.</p>
<p>A imagem de compartilhamento precisa do título escrito em PNG, porque as redes sociais não aceitam SVG. Aqui houve um tropeço.</p>
<h2 id="o-título-e-a-fonte">O título e a fonte</h2>
<p>O sharp aceita texto como entrada e o desenha com o Pango, passando o arquivo da fonte em <code>fontfile</code>. Com o WOFF2 que o site serve, o resultado saiu numa fonte monoespaçada genérica, sem erro nenhum.</p>
<p>A solução foi baixar o TTF variável da Anybody, a mesma família, do repositório oficial do Google Fonts (licença OFL). Com ele, o Pango aceita os eixos da fonte variável na própria descrição:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> heading</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> sharp</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">    text: {</span></span>
<span class="line"><span style="color:#6A737D">        // The title goes into Pango markup, so &amp; and &lt; must be escaped.</span></span>
<span class="line"><span style="color:#E1E4E8">        text: </span><span style="color:#9ECBFF">`&lt;span foreground="#0e1a2b" letter_spacing="-820"&gt;${</span><span style="color:#B392F0">markup</span><span style="color:#9ECBFF">(</span><span style="color:#E1E4E8">title</span><span style="color:#9ECBFF">)</span><span style="color:#9ECBFF">}&lt;/span&gt;`</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        font: </span><span style="color:#9ECBFF">"Anybody 46 @wght=650,wdth=115"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        fontfile: </span><span style="color:#9ECBFF">"src/assets/fonts/anybody-variable.ttf"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        width: </span><span style="color:#79B8FF">540</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        align: </span><span style="color:#9ECBFF">"centre"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        rgba: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        dpi: </span><span style="color:#79B8FF">72</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">}).</span><span style="color:#B392F0">png</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">toBuffer</span><span style="color:#E1E4E8">({ resolveWithObject: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8"> });</span></span></code></pre>
<p>Três detalhes:</p>
<ul>
<li><code>@wght=650,wdth=115</code> aplica peso e largura, os mesmos do título do site. Conferimos comparando com <code>wdth=112.5</code>: a largura do texto muda.</li>
<li><code>letter_spacing</code> é medido em 1/1024 de ponto. Com <code>dpi: 72</code>, um ponto vale um pixel, então <code>-820</code> dá cerca de -0,8 px. O código usa 18 vezes o tamanho da fonte, para o espaço apertar junto com as letras.</li>
<li><code>width</code> faz o Pango quebrar as linhas. Para a última linha não ficar com uma palavra só, o código estreita a largura de 20 em 20 px enquanto o número de linhas não muda, e fica com a mais estreita.</li>
</ul>
<p>Palavras com hífen, como NF-e, vão dentro de <code>&lt;span allow_breaks="false"&gt;</code>. Sem isso, o Pango quebrava a linha no hífen e deixava “e” sozinho embaixo.</p>
<p>O título fica no centro, com uma planta de cada lado. Na prévia pequena, o WhatsApp recorta a imagem num quadrado do meio, e um título à esquerda saía cortado.</p>
<h2 id="o-título-sob-medida">O título sob medida</h2>
<p>O tamanho da fonte não é fixo. O código começa em 76 px e diminui de 2 em 2 até as letras caberem no quadrado do meio, em três linhas no máximo. Títulos curtos saem grandes, e os longos, menores.</p>
<p>Para saber o tamanho das letras, o código lê o canal de transparência da imagem do título e acha o primeiro e o último pixel pintados. A caixa que o Pango devolve não serve, porque inclui o espaço reservado acima e abaixo de cada linha.</p>
<p>Com essas medidas, a imagem ganha duas linhas de cota em rosa, como as do topo do site: uma em cima, com a largura das letras, e outra ao lado, com a altura. Os números são os pixels reais de cada título.</p>
<p>Atrás de tudo fica a mesma grade de desenho técnico do site, que some perto das bordas. Na moldura, quatro marcas curtas em rosa mostram onde fica o quadrado que o WhatsApp recorta.</p>
<p>O texto alternativo da imagem segue as mesmas medidas: diz o título, o tamanho das letras e de onde vieram as plantas.</p>
<p>O título vira uma imagem com fundo transparente e é colado sobre o desenho com <code>composite</code>. O nome do site, na moldura tracejada, segue o mesmo caminho.</p>
<p>O TTF tem 201 KB e é usado só no build. O navegador continua baixando o WOFF2.</p>
<h2 id="as-rotas-no-astro">As rotas no Astro</h2>
<p>Cada arquivo é uma rota estática com <code>getStaticPaths</code>, ao lado da página do post:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#6A737D">// src/pages/blog/[slug]/og.png.ts</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getStaticPaths</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> posts</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> getPosts</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">map</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> ({ params: { slug: post.id }, props: { post } }));</span></span>
<span class="line"><span style="color:#E1E4E8">}) </span><span style="color:#F97583">satisfies</span><span style="color:#B392F0"> GetStaticPaths</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#B392F0"> GET</span><span style="color:#F97583">:</span><span style="color:#B392F0"> APIRoute</span><span style="color:#E1E4E8">&lt;{ </span><span style="color:#FFAB70">post</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Post</span><span style="color:#E1E4E8"> }&gt; </span><span style="color:#F97583">=</span><span style="color:#F97583"> async</span><span style="color:#E1E4E8"> ({ </span><span style="color:#FFAB70">props</span><span style="color:#E1E4E8"> }) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> image</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> shareImage</span><span style="color:#E1E4E8">(props.post.data.title, </span><span style="color:#B392F0">seedFor</span><span style="color:#E1E4E8">(props.post));</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Response</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">new</span><span style="color:#B392F0"> Uint8Array</span><span style="color:#E1E4E8">(image), { headers: { </span><span style="color:#9ECBFF">"Content-Type"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"image/png"</span><span style="color:#E1E4E8"> } });</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span></code></pre>
<p>A página do post aponta <code>og:image</code>, <code>twitter:image</code> e o <code>image</code> dos dados estruturados para <code>/blog/&lt;post&gt;/og.png</code>, com um texto alternativo que explica de onde vem o desenho.</p>
<h2 id="o-que-ficou-de-fora">O que ficou de fora</h2>
<table>
<thead>
<tr>
<th>Ideia</th>
<th>Por quê</th>
</tr>
</thead>
<tbody>
<tr>
<td>Árvore de arquivos como planta baixa</td>
<td>Boa, mas depende do tamanho do repositório</td>
</tr>
<tr>
<td>Commits numa régua, horários numa grade</td>
<td>Ficam vazias em repositórios pequenos</td>
</tr>
<tr>
<td>Céu na data do post</td>
<td>Bonito, mas não diz nada do post</td>
</tr>
<tr>
<td>Curvas de nível, fluxo, cordilheira</td>
<td>Parecidas demais entre si</td>
</tr>
<tr>
<td>Harmonógrafo e máquinas de desenho</td>
<td>O harmonógrafo era o favorito; algumas máquinas da família colidiam</td>
</tr>
<tr>
<td>Fractal, atrator, Voronoi, coloração de domínio</td>
<td>Chamativos, mas brigam com a paleta do site</td>
</tr>
<tr>
<td>Physarum</td>
<td>Algumas sementes se desfazem em poucos traços</td>
</tr>
<tr>
<td>Kolam, autômato celular, randomart do SSH</td>
<td>Bons conceitos; perderam para as plantas</td>
</tr>
</tbody>
</table>
<h2 id="referências">Referências</h2>
<ul>
<li>Prusinkiewicz e Lindenmayer, <a href="http://algorithmicbotany.org/papers/abop/abop.pdf">The Algorithmic Beauty of Plants</a>.</li>
<li>Documentação do sharp: <a href="https://sharp.pixelplumbing.com/api-constructor">entrada de texto</a> e <a href="https://sharp.pixelplumbing.com/api-composite">composite</a>.</li>
<li>Pango: <a href="https://docs.gtk.org/Pango/type_func.FontDescription.from_string.html">descrição de fontes</a>, incluindo os eixos de fontes variáveis.</li>
<li>Astro: <a href="https://docs.astro.build/en/guides/endpoints/">rotas de arquivos estáticos</a>.</li>
<li>A fonte <a href="https://fonts.google.com/specimen/Anybody">Anybody</a>, no <a href="https://github.com/google/fonts/tree/main/ofl/anybody">repositório do Google Fonts</a>.</li>
</ul>
]]></content:encoded></item><item><title>Levar conversas do WhatsApp para uma IA</title><link>https://ai.virtuaires.com.br/blog/whatsapp-conversas-para-ia/</link><guid isPermaLink="true">https://ai.virtuaires.com.br/blog/whatsapp-conversas-para-ia/</guid><pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate><dc:creator>Vitor Ayres</dc:creator><description>Como exportar o texto de grupos escolhidos do WhatsApp para usar com ChatGPT ou Claude, sem passar por servidores de terceiros, e quais riscos isso tem.</description><content:encoded><![CDATA[<p>Muita empresa funciona dentro de grupos de WhatsApp. É ali que a equipe combina entregas, o cliente pede ajustes e o fornecedor avisa de atraso. Quando alguém pergunta “o que ficou decidido sobre aquilo semana passada?”, a resposta está enterrada em centenas de mensagens.</p>
<p>Uma IA como o ChatGPT ou o Claude resume isso bem. O difícil é tirar as mensagens do WhatsApp de um jeito organizado, só das conversas certas e sem entregar tudo a um serviço de terceiros no caminho.</p>
<p>Fizemos o <a href="https://github.com/vasfvitor/whatsapp-group-reader">whatsapp-group-reader</a> para isso. É um aplicativo local, de código aberto, que lê o texto das conversas que você escolher e exporta num formato pronto para colar numa IA.</p>
<h2 id="resumo">Resumo</h2>
<ul>
<li>Lê <strong>só as conversas marcadas</strong> por você. Nada é sincronizado sozinho.</li>
<li><strong>Só lê.</strong> Não envia mensagens, reações, presença nem confirmação de leitura, e não baixa mídia.</li>
<li><strong>Tudo fica no seu computador</strong>, num banco local. Não há servidor na nuvem nem integração direta com IA.</li>
<li>Exporta em <strong>texto compacto</strong>, para colar numa IA, ou em <strong>JSONL</strong>, para quem programa.</li>
<li>Usa um <strong>cliente não oficial</strong> do WhatsApp. Existe risco de bloqueio da conta, e não dá para eliminá-lo.</li>
</ul>
<h2 id="como-funciona">Como funciona</h2>
<ol>
<li>Você abre o aplicativo e vincula o celular pelo QR, como no WhatsApp Web.</li>
<li>Atualiza a lista de conversas, marca só as conversas autorizadas pelos participantes e salva.</li>
<li>Escolhe o período, por exemplo as últimas 24 horas e no máximo 500 mensagens por conversa.</li>
<li>Clica em <strong>Sincronizar agora</strong>.</li>
<li>Exporta em texto para IA, ou baixa o JSONL completo.</li>
</ol>
<p>A exportação para IA tem um cabeçalho de contexto e marcadores separando cada conversa. É só colar e perguntar: “resuma o que foi decidido”, “liste as pendências por pessoa”, “quais prazos foram combinados”.</p>
<p>Tirar uma conversa da seleção interrompe as próximas coletas, mas não apaga o que já foi salvo. O banco, a sessão e as exportações ficam na pasta de dados do usuário, e o aplicativo mostra onde.</p>
<h2 id="por-que-não-usar-uma-biblioteca-que-fala-direto-com-o-servidor">Por que não usar uma biblioteca que fala direto com o servidor</h2>
<p>Existem bibliotecas que reimplementam o protocolo do WhatsApp e conversam com o servidor por conta própria. Escolhemos o caminho oposto: o <a href="https://github.com/pedroslopez/whatsapp-web.js">whatsapp-web.js</a> controla o próprio WhatsApp Web, dentro de um navegador de verdade. Do lado do WhatsApp, o que aparece é o WhatsApp Web.</p>
<p>A aposta é que isso reduz o risco de bloqueio. Não dá para provar, porque o WhatsApp não documenta como detecta automação. Mas é a escolha conservadora: não há protocolo reconstruído para manter, e o comportamento fica colado ao cliente oficial. O custo é precisar de um navegador. No Windows, o aplicativo usa o Edge que já vem instalado.</p>
<h2 id="devagar-de-propósito">Devagar de propósito</h2>
<p>O aplicativo não tenta ser rápido. Ele lê uma conversa por vez, em blocos de 50 mensagens, com intervalos aleatórios entre blocos e entre conversas, e pausas maiores depois de alguns lotes. Uma conversa consultada há pouco tempo entra em espera antes de poder ser lida de novo.</p>
<p>Há dois ritmos: <strong>Conservador</strong>, o padrão, e <strong>Balanceado</strong>. Falhas de leitura têm no máximo duas novas tentativas, com espera crescente. A fila pode ser pausada, retomada ou cancelada sem perder o que já foi coletado.</p>
<p>Isso espaça as leituras que o aplicativo pede. Não controla o que o WhatsApp Web faz por conta própria, não imita uma pessoa e não elimina o risco do cliente não oficial.</p>
<h2 id="o-que-fica-de-fora">O que fica de fora</h2>
<p>O foco é texto. Mídia sem legenda, chamadas, reações, enquetes, status, canais e mensagens de sistema são ignorados. De uma foto ou vídeo com legenda, sai só o texto da legenda.</p>
<p>O painel de diagnóstico guarda só os 200 eventos mais recentes, em memória, e nunca registra o texto das mensagens.</p>
<h2 id="antes-de-usar-três-cuidados">Antes de usar, três cuidados</h2>
<ul>
<li><strong>Combine com o grupo.</strong> As mensagens não são só suas. Ler e exportar a conversa de outras pessoas para uma IA pede que elas saibam e concordem, ainda mais em grupos com clientes e fornecedores. Quem entrar no grupo depois também precisa saber.</li>
<li><strong>Escolha a IA com cuidado.</strong> O aplicativo não envia nada a lugar nenhum, mas o texto colado numa IA segue as regras daquele serviço. Para conversas sensíveis, confira se o plano usado não aproveita os dados para treinamento.</li>
<li><strong>Conte com o risco de bloqueio.</strong> É um cliente não oficial, e o bloqueio atinge o número vinculado, que é o mesmo que está nos grupos. Avalie isso antes de usar o número principal da empresa.</li>
</ul>
<h2 id="para-quem-quer-instalar">Para quem quer instalar</h2>
<p>No Windows 10 e 11, há um pacote portátil de cerca de 50 MB, com tudo embutido:</p>
<ol>
<li>Nas propriedades do arquivo .zip, marque “Desbloquear”, se a opção aparecer.</li>
<li>Extraia numa pasta curta e fora do OneDrive, como <code>C:\WhatsAppReader</code>. São necessários cerca de 250 MB livres.</li>
<li>Abra o <code>Iniciar.cmd</code>. O navegador abre sozinho no aplicativo. A primeira abertura pode levar 1 a 2 minutos.</li>
<li>Deixe a janela preta aberta enquanto usa. Ela é o programa.</li>
</ol>
<p>Para desenvolvedores, o projeto roda no Windows e no macOS, com Node.js 24 ou mais recente:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="sh"><code><span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> clone</span><span style="color:#9ECBFF"> https://github.com/vasfvitor/whatsapp-group-reader</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> whatsapp-group-reader</span></span>
<span class="line"><span style="color:#B392F0">corepack</span><span style="color:#9ECBFF"> enable</span></span>
<span class="line"><span style="color:#B392F0">pnpm</span><span style="color:#9ECBFF"> install</span></span>
<span class="line"><span style="color:#B392F0">pnpm</span><span style="color:#9ECBFF"> dev</span></span></code></pre>
<p>O projeto é uma prova de conceito. Funciona com um telefone e uma sessão por máquina, sem nuvem e sem vários usuários.</p>
<p>Se a sua empresa quer usar IA em cima das próprias conversas, documentos ou sistemas, com os dados sob controle, esse é o trabalho que fazemos em <a href="https://ai.virtuaires.com.br/solucoes/solucoes-com-ia/">soluções com IA</a>. Quando a pergunta é sobre os dados de um aplicativo, e não sobre conversas, o caminho é <a href="https://ai.virtuaires.com.br/solucoes/fale-com-seu-banco-de-dados/">falar com o seu banco de dados</a>.</p>
]]></content:encoded></item></channel></rss>