llms.txt: por que importa e como gerar em PHP
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?
<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?
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; em vez do E comercial?
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?
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?
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?
Leia também
OpenSEO: instalando na VPS e auditando um site
Como instalar o OpenSEO, alternativa aberta ao Semrush, numa VPS com Docker, auditar um site de verdade e entender a chave da DataForSEO.
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
IndexNow: avisando o Bing e o Yandex mais rápido
Como criar a chave do IndexNow e avisar Bing e Yandex das suas páginas novas com Node.js e PHP: as requisições, os erros reais e as armadilhas.