Pular para o conteúdo
PHP

llms.txt: por que importa e como gerar em PHP

Ilustração de um unicórnio de crina colorida entregando um pergaminho curto e limpo a um robô, enquanto uma pilha bagunçada de páginas cheias de div e ul fica esquecida ao lado

Olá meus Unicórnios! 🦄✨

Eu andei fazendo uma pergunta meio incômoda para mim mesma: quando alguém pergunta para uma IA sobre um assunto que eu já escrevi aqui, o que exatamente ela lê do meu blog? 🤔

A resposta me deixou desconfortável o suficiente para eu ir medir. E o número que apareceu é o motivo deste artigo inteiro: a página que a IA lê para me citar é 99% enfeite. Neste artigo eu mostro a medição, explico por que o llms.txt resolve isso, e monto um gerador em PHP puro que não rastreia nada, porque não precisa.

📏 O número que me convenceu

Eu peguei uma página deste blog e medi duas coisas: o tamanho do HTML que o servidor entrega, e o tamanho que aquela mesma página ocupa em uma linha de llms.txt. Título e descrição inteiros, sem cortar nada de conteúdo.

HTML da pagina: 26437 bytes
Linha do llms.txt: 223 bytes
Reducao: 99.2%

Isso mesmo, 99,2%. 🤯 De cada mil bytes que um modelo baixa para saber do que esse artigo trata, oito carregam a informação e novecentos e noventa e dois carregam menu, rodapé, JSON-LD, atributos, uma div dentro da outra e o CSS crítico que eu mesma coloquei ali para a página abrir rápido.

E repare na ironia: nada disso é defeito. Cada um daqueles bytes existe por um bom motivo e serve muito bem a quem é gente. O problema é que o público mudou e o formato não. Eu escrevi uma página para humano e estou entregando ela para uma máquina que paga por token e tem uma janela de contexto para gastar.

🎯 Por que isso importa mais do que parecia

Durante anos a régua foi uma só: aparecer na lista de resultados. Você otimizava para o clique. Só que hoje boa parte das perguntas termina numa resposta pronta, com duas ou três fontes citadas embaixo, e ninguém clica em nada. A disputa deixou de ser por posição e virou uma disputa por ser a fonte que a resposta usa.

E é aqui que os 99,2% mordem. Um modelo montando uma resposta tem orçamento de contexto, e ele reparte esse orçamento entre as fontes que abriu. Se o seu site gasta vinte e seis mil bytes para dizer o que cabia em duzentos, você não foi censurado: você foi caro. 😅

Tem um segundo efeito, e esse é o que eu acho mais interessante. O llms.txt é a única parte do seu site em que você escreve a descrição de si mesmo em prosa, para ser lida como prosa. No HTML você espalha sinal em meta, em JSON-LD, em título, e torce para que a máquina remonte o quebra-cabeça do jeito certo. No llms.txt você simplesmente diz. Não é pouca coisa deixar de depender da interpretação de terceiros.

O formato em si cabe em meia dúzia de linhas, e eu não vou repetir aqui o que já expliquei em detalhe quando montei um gerador em Node.js. Se você quer a anatomia do arquivo com calma, ou se o seu site é legado em ISO-8859-1 (tem uma armadilha feia de acentos esperando por você lá), o caminho é este:

Node.js: gerando o llms.txt do seu site O formato explicado regra por regra, o rastreio em largura para sites sem sitemap, e a armadilha do charset que corrompe todo acento em silêncio. blog.palomamacetko.com.br

🗺️ Em PHP, não rastreie: leia o sitemap

Aqui vem a diferença que fez este gerador ficar bem menor que o outro. Rastrear um site é um problema chato de verdade: fila de URLs, controle de profundidade, deduplicação, robots.txt, link quebrado, laço infinito em paginação. Eu resolvi tudo isso da outra vez.

Só que aí eu percebi uma bobagem: eu já tenho a lista pronta. Ela se chama sitemap.xml, está na raiz do site, e é uma lista de páginas que eu mesma declarei como as que importam. Rastrear o próprio site para descobrir o que ele tem é redescobrir, de fora e no chute, uma coisa que eu já sabia de dentro e com certeza.

Então o gerador em PHP não rastreia nada. Ele lê o sitemap.xml, visita cada URL, colhe o título e a descrição, e monta o arquivo. Sem fila, sem profundidade, sem parser de robots.txt. É PHP 7.4 puro: nenhum Composer, nenhuma extensão além do que já vem.

📥 Baixando uma página sem ficar cego

A primeira função é a que baixa uma URL. Ela parece besteira, e é justamente onde mora a primeira armadilha:

// Baixa uma URL e devolve o conteudo, ou null se nao der.
function baixar($url)
{
    $contexto = stream_context_create(array(
        'http' => array(
            'timeout'       => 15,
            'user_agent'    => 'gerador-llms-txt/1.0',
            // Sem isto, um 404 vira warning do PHP e o retorno e false,
            // sem que voce consiga ler o codigo do erro para explicar.
            'ignore_errors' => true,
        ),
        'ssl'  => array('verify_peer' => true, 'verify_peer_name' => true),
    ));

    $conteudo = @file_get_contents($url, false, $contexto);
    if ($conteudo === false) {
        return null;
    }

    // $http_response_header e preenchido pelo PHP a cada file_get_contents.
    // Precisa ser lido AQUI dentro: fora da funcao ele nao existe.
    $codigo = 0;
    if (isset($http_response_header[0])) {
        $partes = explode(' ', $http_response_header[0]);
        if (isset($partes[1])) {
            $codigo = (int) $partes[1];
        }
    }
    if ($codigo >= 400) {
        return null;
    }

    return $conteudo;
}

Duas linhas aí valem o parágrafo.

A primeira é o ignore_errors. Sem ele, o PHP trata qualquer resposta 4xx ou 5xx como falha: solta um warning na sua tela e devolve false. Você fica sabendo que deu errado e não fica sabendo o quê, porque o corpo da resposta foi jogado fora junto. Com a opção ligada, o PHP entrega o conteúdo normalmente e deixa você decidir o que fazer com o status, que é exatamente o que a linha seguinte faz.

A segunda é o $http_response_header, e essa eu acho uma das coisas mais esquisitas do PHP. 😳 É uma variável que o PHP cria sozinho no escopo local toda vez que um file_get_contents HTTP roda. Ela não é global, não é superglobal, não é retorno de nada. Se você tentar lê-la depois, fora da função que fez a requisição, ela simplesmente não existe. É por isso que o código extrai o status ali dentro, na mesma função: fora dali seria tarde demais.

🧩 Lendo o sitemap, índice ou não

Com o download resolvido, tirar as URLs do sitemap é quase trivial. Quase:

// Le as <loc> de um sitemap.xml e devolve a lista de URLs.
function lerSitemap($xml)
{
    // O sitemap pode ser um indice de outros sitemaps. Nos dois casos as
    // URLs estao em <loc>, entao uma expressao so resolve os dois.
    $achou = preg_match_all('#<loc>\s*(.*?)\s*</loc>#i', $xml, $encontrados);
    if (!$achou) {
        return array();
    }

    $urls = array();
    foreach ($encontrados[1] as $bruta) {
        // O sitemap e XML: o & vem escrito como entidade e precisa voltar ao
        // normal, senao a URL gravada no llms.txt nao abre no navegador.
        $urls[] = html_entity_decode($bruta, ENT_QUOTES | ENT_XML1, 'UTF-8');
    }
    return $urls;
}

O html_entity_decode ali no meio não é enfeite. O sitemap.xml é XML, e em XML o E comercial é obrigado a aparecer escrito como entidade. Uma URL com mais de um parâmetro sai do sitemap com a entidade no meio. Se você gravar isso direto no llms.txt, o link fica quebrado para quem clicar, e o Markdown não vai desfazer a entidade por você porque Markdown não tem a menor ideia do que é uma entidade HTML.

🔤 O título, e a entidade que vaza para o Markdown

Extrair título e descrição é o coração do gerador, e é onde a mesma armadilha volta pela porta dos fundos:

// Tira o texto do title e da meta description de um HTML.
function lerTituloEDescricao($html)
{
    $titulo    = '';
    $descricao = '';

    if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $achado)) {
        $titulo = trim($achado[1]);
    }
    if (preg_match('#<meta[^>]+name=["\']description["\'][^>]+content=["\'](.*?)["\']#is', $html, $achado)) {
        $descricao = trim($achado[1]);
    }

    // O HTML guarda o & como entidade. Sem decodificar, um titulo que fale
    // de "J&T" sai com a entidade literal dentro do arquivo final.
    $titulo    = html_entity_decode($titulo, ENT_QUOTES, 'UTF-8');
    $descricao = html_entity_decode($descricao, ENT_QUOTES, 'UTF-8');

    // O titulo quase sempre tem o nome do site depois de uma barra vertical.
    // No llms.txt isso e ruido repetido em toda linha, entao sai.
    $titulo = preg_replace('#\s+[|]\s+.*$#u', '', $titulo);

    return array('titulo' => $titulo, 'descricao' => $descricao);
}

Esse html_entity_decode é o bug mais provável do gerador inteiro, e é silencioso. O HTML é obrigado a escrever o E comercial como entidade, então qualquer título que tenha um chega até você assim. O llms.txt é Markdown, não HTML: ali a entidade não significa nada, é só texto. Sem essa linha, um artigo sobre a transportadora J&T fica gravado no seu arquivo com um amp; plantado no meio do nome, e o modelo que ler vai processar essa string literalmente. Nada quebra, nada avisa, e o arquivo continua parecendo perfeitamente normal numa olhada por cima.

Aquele preg_replace do fim é uma opinião, não uma regra. Quase todo site coloca o nome dele no fim de cada título, e num arquivo com cem linhas isso é o mesmo pedaço repetido cem vezes, gastando contexto para não informar nada, já que o nome do site está lá no # da primeira linha. Se o seu título usa outro separador, é aqui que você mexe.

✍️ Montando a linha

A montagem de cada item é curtinha, e tem uma decisão de honestidade dentro:

// Monta uma linha de item do llms.txt.
function montarLinha($url, $titulo, $descricao)
{
    if ($titulo === '') {
        // Sem titulo, o texto do link vira a URL. Feio de proposito:
        // avisa que falta titulo naquela pagina em vez de inventar um.
        $titulo = $url;
    }

    $linha = '- [' . $titulo . '](' . $url . ')';
    if ($descricao !== '') {
        $linha .= ': ' . $descricao;
    }
    return $linha;
}

Quando a página não tem título, o texto do link vira a própria URL, e fica feio. É para ficar. 🙂 A alternativa seria eu inventar um título a partir do caminho, transformando /sobre-nos em "Sobre Nos", e aí o arquivo passaria a mentir com cara de completo. Feio e verdadeiro ganha de bonito e inventado, sempre. Além do mais, o arquivo feio te avisa que tem uma página sua sem título, o que é uma informação útil de graça.

E é a descrição depois dos dois-pontos que faz o formato valer a pena. Sem ela, você escreveu uma lista de URLs, que é exatamente o que o sitemap.xml já fazia, e aí não havia motivo nenhum para tanto trabalho.

🧵 O laço, e a home que vira cabeçalho

Com as peças prontas, a execução é linear. A primeira página do sitemap recebe um tratamento diferente das outras:

    foreach ($urls as $url) {
        if ($lidas >= $limite) {
            break;
        }

        $html = baixar($url);
        if ($html === null) {
            // Pagina fora do ar nao derruba o gerador: pula e segue.
            echo "  (pulei, nao respondeu) $url\n";
            continue;
        }
        $lidas++;

        $dados = lerTituloEDescricao($html);

        // A primeira pagina do sitemap costuma ser a home: dela saem o
        // nome e o resumo do site inteiro.
        if ($lidas === 1) {
            if ($dados['titulo'] !== '') {
                $nomeDoSite = $dados['titulo'];
            }
            $resumoDoSite = $dados['descricao'];
            continue;
        }

        $itens[] = montarLinha($url, $dados['titulo'], $dados['descricao']);
        echo '  ' . $lidas . '. ' . $dados['titulo'] . "\n";
    }

O continue depois do baixar() merece atenção porque a troca por break é fácil de fazer sem pensar, e o estrago é grande: uma página fora do ar no meio do sitemap truncaria o arquivo inteiro, deixando de fora tudo o que vinha depois dela. E, como sempre nesses casos, o gerador terminaria dizendo que deu tudo certo. Uma página que não responde é um item a menos, nunca um motivo para desistir das outras.

O if ($lidas === 1) é a aposta consciente do gerador. A primeira URL do sitemap é quase sempre a home, e o título da home é o nome do site, enquanto a descrição dela é o resumo do site. São exatamente as duas coisas que o formato pede na linha do # e na do >. O continue ali garante que a home vire cabeçalho em vez de virar um item da lista, e não as duas coisas.

💾 Gravando, e falando português quando dá errado

A gravação é a parte mansa, com uma exigência: se falhar, tem que dizer o motivo em português para um humano:

// Escreve o arquivo no disco, avisando em portugues se nao der.
function gravarArquivo($caminho, $texto)
{
    $bytes = file_put_contents($caminho, $texto);
    if ($bytes === false) {
        throw new Exception('Nao consegui gravar em ' . $caminho . '. Confira a permissao da pasta.');
    }
    echo 'Gravei ' . $bytes . " bytes em $caminho\n";
}

E o try/catch que envolve a execução inteira existe para que qualquer um dos tropeços previstos vire uma frase legível, e não um rastro de pilha:

    $xml = baixar($site . '/sitemap.xml');
    if ($xml === null) {
        throw new Exception('Nao achei o sitemap.xml em ' . $site . '/sitemap.xml');
    }

    $urls = lerSitemap($xml);
    if (count($urls) === 0) {
        throw new Exception('O sitemap veio sem nenhuma tag loc. Confira se e mesmo um sitemap.');
    }

▶️ Rodando

Chamar é apontar para o site, com um limite opcional de páginas:

php gerar-llms.php http://127.0.0.1:8029 8

E a saída:

Achei 285 URLs no sitemap.
  2. Paloma Macetko
  3. Node.js: enviando SMS e lendo respostas na SMSMais
  4. Node.js: áudio como ligação pela SMSMais
  5. RAG na prática: o Qdrant respondendo com a OpenAI
  6. Texto em áudio por CURL, com IA local na sua VPS
  7. Busca semântica no Qdrant: digitando o que você quer
  8. Áudio em texto por CURL, com IA local na sua VPS
Gravei 2136 bytes em llms.txt

Repare que a numeração começa no 2. Não é bug: a página 1 foi a home, que virou o cabeçalho do arquivo em vez de virar item da lista. 🎉 E o começo do que saiu:

# Blog da Paloma Macetko — Artigos técnicos de programação

> Artigos técnicos de programação: PHP, JavaScript, banco de dados e robótica. Código que funciona, explicado por quem escreveu — e um pouco da minha jornada.

## Paginas

- [Paloma Macetko](https://blog.palomamacetko.com.br/autor/): Desenvolvedora de software. Escrevo tutoriais técnicos de PHP, Node.js, APIs e automação, com código rodado e as armadilhas que a documentação não conta.
- [Node.js: enviando SMS e lendo respostas na SMSMais](https://blog.palomamacetko.com.br/nodejs-enviando-sms-e-recebendo-respostas-pela-smsmais/): Como enviar SMS pela API da SMSMais com Node.js, entender os status de entrega e receber a resposta do destinatario por webhook.

Duas coisas para conferir na sua primeira execução. A linha do > veio preenchida, o que quer dizer que a home tinha descrição. Se a sua vier vazia, o problema não é o gerador: é a home sem meta description, e você acabou de descobrir isso de brinde. E os acentos saíram inteiros, com "áudio" e "semântica" no lugar, porque cada título passou pelo html_entity_decode antes de ser gravado.

🧹 O gerado é rascunho, não é o arquivo final

Olha de novo aquela primeira linha da lista: a página do autor. Ela está no sitemap com todo direito, mas ela não ensina nada a ninguém sobre nenhum assunto. Rodar o gerador e publicar direto funciona, e entrega um arquivo honesto, mas entrega uma lista de tudo.

O que separa um llms.txt útil de um sitemap.xml disfarçado é o que você faz depois: abrir o resultado, jogar fora o que é ruído (busca, política de privacidade, paginação de tag) e agrupar o que sobrou em seções por assunto. O formato aceita quantos ## você quiser, e um arquivo com "## Artigos de PHP" e "## Artigos de Node.js" diz muito mais sobre você do que oitenta linhas em ordem de sitemap.

Depois é só deixar o arquivo na raiz do domínio, como https://seusite.com.br/llms.txt. É um arquivo estático: não precisa de rota, nem de cabeçalho especial, nem de nada.

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

Por que o llms.txt importa se nenhum buscador é obrigado a ler?
Porque o custo de gerar é de minutos e o problema que ele resolve é medido. Quando uma IA abre uma página deste blog para te citar, ela recebe 26.437 bytes de HTML, dos quais quase tudo é menu, rodapé e <div>. A mesma página cabe em 223 bytes de llms.txt, com o título e a descrição intactos. Você não controla se vão ler, mas controla o que encontram quando leem.
Preciso rastrear o site inteiro para gerar o llms.txt?
Não, e é aí que o PHP leva vantagem. Se o seu site já tem sitemap.xml, a lista de páginas está pronta e assinada por você: nada de fila, profundidade, robots.txt ou links quebrados. O gerador vira um foreach em cima das tags <loc>.
Por que o meu llms.txt sai com a entidade &amp;amp; em vez do E comercial?
Porque o título veio do HTML, onde o E comercial é obrigatoriamente escrito como entidade. O llms.txt é Markdown, não HTML, e ali a entidade não significa nada: ela fica literal no arquivo. A correção é uma chamada de html_entity_decode() em cima do título e da descrição, logo depois de extraí-los.
Por que preciso de ignore_errors no file_get_contents?
Porque sem ele um 404 vira um warning do PHP e a função devolve false, sem que você consiga ler o código de status para explicar o que houve. Com ignore_errors ligado, o corpo vem normalmente e o código fica em $http_response_header, que precisa ser lido dentro da própria função: fora dela essa variável não existe.
O llms.txt substitui o sitemap.xml ou o robots.txt?
Nenhum dos dois. O robots.txt diz o que não ler, o sitemap.xml lista URLs para o robô do buscador, e o llms.txt descreve assunto em prosa para o modelo. Neste gerador os dois até trabalham juntos: o sitemap.xml é a matéria-prima que o llms.txt consome.
Devo publicar o llms.txt exatamente como o gerador cuspiu?
O gerado é o rascunho. Ele lista tudo o que está no sitemap, inclusive página de busca, de autor e de paginação, que não interessam a ninguém. O que faz o arquivo valer a pena é a curadoria depois: apagar o que é ruído e agrupar o resto em seções por assunto, que o formato aceita à vontade.

Leia também