← Voltar ao Blog

LLMO sem teste quebra: Playwright CI em 7 passos

Instalei llms.txt no raiz, JSON-LD em cada post, FAQSchema nos tutoriais. Fechei a aba, marquei “LLMO” como feito, e fui tomar café.

Depois eu abri o terminal e me dei conta de que nunca tinha visto, com os olhos, o que o GPTBot efetivamente recebe quando ele puxa uma das minhas páginas.

O inspector do browser mostra a página renderizada depois do JavaScript. O GPTBot (documentado pela OpenAI aqui) baixa HTML sem rodar JS na maioria dos casos — comportamento repetidamente observado em testes públicos, embora a OpenAI não liste capacidade de rendering na página oficial. Essas duas visões não são a mesma coisa. E o jeito que a maioria dos sites descobre é via a métrica mais atrasada do mundo: o tráfego de IA some, três meses depois, sem explicação.

Esse texto é o esqueleto Playwright + GitHub Actions que estou montando para bater nas URLs críticas antes de considerar um deploy “pronto”. Sete passos, zero dependência paga, zero MCP. Nada aqui está rodando no CI do kenimoto.dev neste momento; é o próximo item da minha lista. Mas os quatro primeiros passos rodam local hoje mesmo, e já é onde a maioria dos problemas aparece.

Por que o browser mente para você

Quando você abre uma página no Chrome, o browser baixa o HTML, executa o JS, aplica o CSS, renderiza o DOM final, e te mostra a página pronta. Quando o GPTBot abre a mesma página, ele baixa o HTML e para por aí.

Se seu site é estático (Astro, Hugo, 11ty), os dois HTMLs são praticamente iguais. Se é SPA (React sem SSR, Vue, Svelte puro), o GPTBot vê um shell quase vazio. Esse buraco eu já descrevi em por que o ChatGPT ignora seu site, e os logs de 30 dias mostram que os cinco bots que mais batem no meu servidor são todos HTTP puros, sem gtag, sem rendering.

Mas o problema não acaba quando você mudou pra SSG. Três coisas quebram em silêncio mesmo com Astro ou Hugo:

  • llms.txt servido como text/html por regra de rewrite errada. Vi isso em 5 de 30 sites na minha auditoria recente.
  • JSON-LD com erro de sintaxe que o Chrome tolera e o parser rigoroso de crawler ignora.
  • Rota voltando 404 ou 403 depois de uma mudança de framework — tipicamente o /llms.txt sumindo quando alguém mexe no deploy.

Nenhum dos três aparece no console do browser. Nenhum deles afeta a métrica do GA4. Você descobre lendo log cru de servidor, meses depois.

Os 7 passos

Antes de descrever: o que segue é o esqueleto que eu rodo com npx playwright test na máquina local. O workflow do Actions é o passo lógico seguinte, mas é opcional — a maior parte dos problemas de LLMO são lentos de aparecer, não precisam de run por minuto.

Passo 1: lista as URLs críticas

Primeiro, decida o que é “crítico”. Para o kenimoto.dev é algo assim:

  • /llms.txt (ponto de entrada declarado)
  • /pt/blog/<slug>/ para três posts recentes
  • /pt/books/<slug>/ para o LP ativo

Para um blog pequeno, cinco a sete URLs cobrem o essencial. Mais do que isso vira ruído e o teste começa a demorar.

Grave num tests/llmo-urls.json:

{
  "urls": [
    "https://kenimoto.dev/llms.txt",
    "https://kenimoto.dev/pt/blog/auditei-30-llms-txt-ia-2026-5-anti-padroes/",
    "https://kenimoto.dev/pt/books/llmo-quickstart/"
  ]
}

Passo 2: use o request context, não o browser

O erro clássico é usar page.goto() para testar LLMO. Isso é browser context: executa JS, renderiza DOM, carrega imagens. Não é o que o GPTBot faz. O Playwright tem um segundo tipo de contexto, request, que fala HTTP puro:

import { test, expect, request } from '@playwright/test';

test('GPTBot pode ler as URLs críticas', async () => {
  const ctx = await request.newContext({
    extraHTTPHeaders: {
      'User-Agent': 'Mozilla/5.0 (compatible; GPTBot/1.4; +https://openai.com/gptbot)'
    }
  });
  // ...
});

Sem JS, sem renderização, sem cookies de sessão. É o mais perto que você consegue chegar da visão de um crawler sem rodar um crawler de verdade.

Passo 3: User-Agent real

A string do User-Agent do GPTBot é pública e está documentada pela OpenAI. Confira a versão corrente ali antes de copiar — a OpenAI já virou o número de versão sem avisar, e um UA quebrado faz o firewall do seu provedor bloquear o teste.

Se quiser testar múltiplos crawlers, varie o UA entre GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot e Google-Extended. Esses cinco são os que mais bateram nos meus logs em 30 dias (ranking detalhado aqui). Em geral, se o GPTBot recebe HTML correto, os outros também recebem. Mas o Google-Extended responde ao robots.txt de forma independente, então vale checar separado.

Passo 4: assertivas sobre o conteúdo

Aqui é onde você decide o que considera “quebrado”. Para o /llms.txt eu tenho quatro:

const response = await ctx.get('https://kenimoto.dev/llms.txt');

expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/plain');

const body = await response.text();
expect(body.length).toBeGreaterThan(500);
expect(body).toContain('kenimoto.dev');

Quatro linhas, nenhum regex sofisticado. Se o llms.txt virar text/html por engano, a segunda falha. Se o build gerou arquivo vazio, a terceira falha. Se o header do arquivo foi renomeado em algum refactor, a quarta falha.

Para páginas de blog, eu troco a última assertiva pela presença da tag JSON-LD:

expect(body).toContain('<script type="application/ld+json">');
expect(body).toMatch(/"@type"\s*:\s*"Article"/);

Passo 5: hard fail em content-type errado

A regra que eu considero não negociável é essa:

expect(response.headers()['content-type']).toContain('text/plain');

Não é cosmético. O parser de muitos crawlers decide o que fazer com o payload baseado nesse header. text/html com conteúdo de .txt dentro é o pior cenário possível: 200 OK, parece OK, mas o parser trata como HTML e desiste no primeiro < que não fecha. É o 404 que não admite que é 404.

A auditoria de setembro mostrou que esse é o problema número 1 de startups que vendem produto para desenvolvedores de LLM. Então sim, dá para errar isso.

Passo 6: GitHub Actions no cron

Até aqui tudo roda local. Para subir em CI o esqueleto do workflow é esse:

name: LLMO Smoke Test
on:
  schedule:
    - cron: '0 2 * * *'
  workflow_dispatch:

jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test tests/llmo.spec.ts

Um run por dia às 02:00 UTC. Para um blog de uma pessoa, o custo do Actions é desprezível. Não precisa de agendamento mais apertado: LLMO quebra devagar, não precisa de alarme por minuto. Em teste local meu request context com cinco URLs encerra em menos de dez segundos; em runner público pode subir por causa da instalação do browser, mas continua na casa de um a dois minutos.

Passo 7: alerta quando quebra

O job falhar no GitHub Actions já envia email por default. Se você quer barulho maior, dá para plugar um webhook:

      - name: Alerta Telegram
        if: failure()
        run: |
          curl -s -X POST \
            "https://api.telegram.org/bot${{ secrets.TG_TOKEN }}/sendMessage" \
            -d chat_id=${{ secrets.TG_CHAT }} \
            -d text="LLMO smoke falhou em kenimoto.dev"

Isso é opcional. Email do GitHub é suficiente para a maioria dos blogs. Se o seu volume de deploy for alto, Slack ou Telegram economizam um clique por dia.

O que esse teste NÃO cobre

Vale ser honesto sobre o que fica de fora. O esqueleto acima:

  • Não mede citação em AI. Isso só sai dos logs de servidor ou de um checker tipo o do capítulo 3 do LLMO Quickstart, que vai no ChatGPT, no Claude e no Perplexity e conta quantas vezes seu nome aparece.
  • Não valida JSON-LD semanticamente. Ele só checa que a tag <script type="application/ld+json"> existe. Para validar o schema use o Rich Results Test.
  • Não substitui o curl manual. Antes de confiar no CI, rode curl -A "GPTBot/1.4" -I https://seusite.com/llms.txt uma vez na mão. É o mesmo teste em versão express, e serve pra calibrar expectativa antes de automatizar.

O teste é um gate de regressão. Ele garante que uma mudança no build não quebrou o que já estava funcionando. Não garante que o LLMO está otimizado — garante que ele continua acessível.

O guia maior de LLMO, com os critérios de llms.txt / JSON-LD / FAQSchema / autoridade que o teste assume que você já implementou, está em llmoframework.com. Esse esqueleto Playwright é o pedaço de verificação automatizada que não cabia no framework.

O que me motivou a montar isso

Antes desse esqueleto, eu “sabia” que o LLMO estava funcionando porque eu tinha implementado as peças. Depois de bater os logs no post dos cinco crawlers e de rodar a auditoria dos 30 llms.txt, fiquei com a sensação de que o próprio kenimoto.dev poderia estar numa dessas listas sem eu saber. Ninguém olha pro próprio site do ângulo do crawler até alguém puxar o tapete.

A automação não é sofisticada. São três arquivos: um .spec.ts, um .json com as URLs, um workflow do Actions. O retorno é um alarme pré-deploy em cima da única coisa que ninguém mais está checando: se o crawler está recebendo o que você acha que está mandando.

Vou colocar o passo 6 e 7 no CI do kenimoto.dev essa semana. Se alguma coisa quebrar, eu quero ser o segundo a saber — o primeiro vai ser o próprio Actions, de madrugada, com e-mail que eu vou ler no café.


ken imoto · WebRTC & Voice AI engineer · kenimoto.dev · TabNews

LLMO Quickstart Livro relacionado LLMO Quickstart tutorial LLMO | Otimização para Busca por IA em 30 minutos · llms.txt · JSON-LD caminho mais curto Ver a página do livro →