Node.js: gerando o llms.txt do seu site
Olá meus Unicórnios! 🦄✨
Faz uns meses que eu venho vendo llms.txt aparecer na raiz dos
sites que eu visito, do lado do robots.txt de sempre. Eu ignorei por
um bom tempo — mais um arquivinho de padrão que alguém inventou, pensei. 😅 Aí
eu fui olhar de perto e percebi que o problema que ele resolve é bem real, e é
um problema que eu já tinha vivido sem saber o nome.
Neste artigo eu conto o que é o llms.txt, por que ele existe, e
monto do zero um gerador em Node.js puro — sem uma dependência
sequer — que rastreia o seu site e cospe o arquivo pronto. E, claro, as
armadilhas: tem uma delas que corrompe todo acento de site em ISO-8859-1 e
passa completamente silenciosa. 😳
🤖 O que é o llms.txt, afinal
A ideia é de setembro de 2024, do Jeremy Howard, e a proposta cabe em uma frase: um arquivo em Markdown, na raiz do site, que explica o seu site para um modelo de linguagem.
Repare no detalhe: em Markdown. E é aí que está a graça, porque o
problema que ele resolve é o seguinte. Quando uma IA precisa entender o seu
site, ela baixa o HTML. E o seu HTML tem menu, rodapé, banner de cookie, script
de analytics, <div> dentro de <div> dentro
de <div>… e, no meio de tudo isso, o conteúdo. O modelo tem
uma janela de contexto limitada, e você acabou de gastar metade dela com
markup.
O llms.txt é o atalho: uma página só, em texto, que diz quem
você é e onde está cada coisa. É o mesmo espírito do robots.txt,
mas com um público diferente e um objetivo invertido — o robots.txt
diz o que não ler; o llms.txt diz o que vale a
pena ler.
📐 O formato: quatro regras e acabou
O formato é deliciosamente simples. Markdown comum, com uma estrutura esperada:
# Nome do site
> Uma frase que resume o que o site é.
Um parágrafo ou dois de contexto, se ajudar.
## Páginas
- [Título da página](https://exemplo.com.br/pagina/): descrição opcional.
- [Outra página](https://exemplo.com.br/outra/): o que tem nela.
Só isso. Um # com o nome, um > com o resumo,
seções em ## e listas de links. A descrição depois dos dois-pontos
é opcional, mas é ela que faz o arquivo valer a pena: sem ela, você entregou uma
lista de URLs — o que o sitemap.xml já fazia.
E ele não substitui o sitemap. Os dois convivem, com
públicos diferentes: o sitemap.xml é XML para o robô do buscador
descobrir URLs; o llms.txt é prosa para o modelo entender
assunto.
🕸️ O plano: rastrear o próprio site
Para montar o arquivo eu preciso da lista de páginas e do título de cada uma. Se o site tem banco de dados, o caminho curto é uma consulta. Mas eu queria uma ferramenta que funcionasse em qualquer site — inclusive nos estáticos, inclusive nos que eu não tenho o código.
Então o gerador faz o que um buscador faz: rastreia. Pede a página inicial,
lê os <a href>, enfileira os links do mesmo domínio, repete.
Uma busca em largura clássica, com três limites — quantas páginas no máximo, até
que profundidade, e se obedece ao robots.txt.
O melhor: em Node.js moderno isso não precisa de nenhuma
dependência. O fetch, o URL, o
TextDecoder e o AbortSignal.timeout já vêm na caixa
desde o Node 18. O package.json do exemplo não tem a chave
dependencies — não é minimalismo por esporte, é que realmente não
há o que instalar.
🔗 Normalizando a URL (ou: por que o mesmo link aparece três vezes)
Antes de enfileirar qualquer link é preciso normalizá-lo. O
href no HTML pode ser relativo (/precos), absoluto, ou
coisa que nem é página (mailto:, javascript:).
/**
* Resolve `href` (relativo ou absoluto) contra `base`, tira o fragmento e
* devolve a URL absoluta. Devolve null se nao for http/https ou nao parsear.
*
* O fragmento tem de sair: `/precos` e `/precos#planos` sao a MESMA pagina, e
* sem remover o `#` o rastreador baixa a mesma URL varias vezes e o llms.txt
* sai com linhas repetidas.
*/
export function normalizarUrl(href, base) {
try {
const u = new URL(href, base);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
u.hash = '';
return u.toString();
} catch {
return null;
}
}
A linha que importa aqui é o u.hash = '', e ela me custou uma
lista cheia de repetição na primeira tentativa. /precos e
/precos#planos são a mesma página — o fragmento
nunca chega ao servidor, ele é coisa do navegador. Sem apagar o
#, o rastreador trata os dois como URLs diferentes, baixa a mesma
página duas vezes e escreve as duas no arquivo final. Em um site com menu
âncora, isso vira dezenas de linhas duplicadas.
Repare também que o try/catch aqui não é decoração: o
new URL() lança quando a string não é uma URL
válida, e HTML de verdade está cheio de href="#" e
href="". Devolver null e deixar quem chama filtrar é
mais simples que validar antes.
😳 A armadilha que corrompe todo acento
Se você só for ler um pedaço deste artigo, leia este. 🙏
O caminho óbvio para ler o corpo de uma resposta é
await res.text(). É o que eu fiz. E funcionou lindamente — até eu
apontar o rastreador para um site PHP antigo, servido em ISO-8859-1, e ver isto
saindo no título:
res.text() do fetch : Promo��es e V�deos
lerHtml() : Promoções e Vídeos
Essa saída é real, das duas funções rodando sobre os mesmos bytes. O motivo é
que o res.text() do fetch sempre
decodifica como UTF-8. Sempre. Ele ignora solenemente o
charset=iso-8859-1 que o servidor mandou no
Content-Type — está na especificação do WHATWG, não é bug de
implementação.
E olha o tamanho da traição: o rastreio não falha. Não tem exceção, não tem aviso, não tem código de erro. Você gera o arquivo, olha por cima, vê uma lista bonita de links e publica. O acento quebrado só aparece se alguém ler linha por linha — ou se o modelo do outro lado tentar entender "Promo��es".
A saída é ler os bytes crus e escolher o decodificador na mão:
/**
* Le o corpo da resposta respeitando o charset declarado pelo site.
*
* O `res.text()` do fetch SEMPRE decodifica como UTF-8, ignorando o charset do
* Content-Type. Site legado servido em ISO-8859-1 sai com acento corrompido
* ("Promocoes" vira "Promo��es") e esse lixo vai direto para o titulo no
* llms.txt. Por isso lemos os bytes e escolhemos o decodificador: primeiro o
* charset do header, senao o do <meta charset>, senao UTF-8.
*/
export async function lerHtml(res) {
const bytes = new Uint8Array(await res.arrayBuffer());
let charset = charsetDoContentType(res.headers.get('content-type'));
if (!charset) {
// O <meta charset> esta no inicio do documento e em ASCII; latin1 le byte
// a byte sem falhar, o que basta para achar a declaracao.
const inicio = new TextDecoder('latin1').decode(bytes.subarray(0, 2048));
const meta =
/<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(inicio) ||
/<meta[^>]+content\s*=\s*["'][^"']*charset\s*=\s*([\w-]+)/i.exec(inicio);
if (meta) charset = meta[1].toLowerCase();
}
try {
return new TextDecoder(charset || 'utf-8').decode(bytes);
} catch {
// Charset que o TextDecoder nao conhece: cai para UTF-8, melhor esforco.
return new TextDecoder('utf-8').decode(bytes);
}
}
São três tentativas, em ordem de confiança. Primeiro o charset que o servidor
declarou no cabeçalho, que é a fonte mais autoritativa. Se o cabeçalho não
disser nada — e muito servidor manda só text/html pelado — a gente
fareja o <meta charset> nos primeiros 2 KB do documento.
Nenhum dos dois? UTF-8, que hoje é o palpite certo em 95% dos casos.
Duas sutilezas que valem o comentário no código. A primeira: para farejar o
<meta> eu decodifico o início como latin1, e
isso é de propósito — o latin1 mapeia qualquer byte para algum
caractere e nunca lança, o que é perfeito para uma leitura
exploratória em que só me interessa achar uma declaração escrita em ASCII.
A segunda: aquele catch no fim existe porque o
TextDecoder lança se receber o nome de um charset
que ele não conhece. Um site com charset=windows-874-x ou qualquer
digitação torta derrubaria o rastreio inteiro por causa de uma página. Melhor
esforço em UTF-8 e segue o baile.
🚦 Obedecendo ao robots.txt — só o grupo certo
Rastrear site dos outros pede educação, então o gerador lê o
robots.txt antes de começar. O parser é mínimo, mas tem um detalhe
que precisa estar certo:
/** Caminhos Disallow do grupo `User-agent: *` (parser minimo do robots.txt). */
export function lerRobotsDisallow(robotsTxt) {
const regras = [];
let noGrupoEstrela = false;
for (const linhaCrua of robotsTxt.split(/\r?\n/)) {
const linha = linhaCrua.replace(/#.*$/, '').trim();
if (!linha) continue;
const [chaveCrua, ...resto] = linha.split(':');
const chave = chaveCrua.trim().toLowerCase();
const valor = resto.join(':').trim();
if (chave === 'user-agent') {
noGrupoEstrela = valor === '*';
} else if (chave === 'disallow' && noGrupoEstrela && valor) {
regras.push(valor);
}
}
return regras;
}
A variável noGrupoEstrela é o detalhe. O robots.txt
é dividido em grupos por User-agent, e as regras de um grupo
só valem para aquele agente. Um arquivo assim é comuníssimo:
User-agent: *
Disallow: /admin/
User-agent: AhrefsBot
Disallow: /
Um parser ingênuo que só procurasse linhas Disallow: ia
encontrar o / do segundo grupo e concluir que o site inteiro está
proibido — e devolver um llms.txt vazio, sem explicar por quê. A
flag garante que a gente só acumula regra enquanto está dentro do grupo
*, que é o que se aplica a nós.
🔁 O laço do rastreio
Com as peças no lugar, o rastreio é uma fila:
while (fila.length) {
if (paginas.length >= teto) break;
const { url, nivel } = fila.shift();
let res;
try {
res = await buscar(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch {
continue; // caiu ou estourou o tempo: pula a pagina, nao aborta o rastreio
}
if (!res.ok) continue;
const ct = res.headers.get('content-type') || '';
if (!ct.includes('text/html')) continue;
// Le o HTML uma vez so: serve para o titulo E para os links. Se a leitura
// falhar, seguimos sem titulo em vez de perder a pagina inteira.
let html = '';
try {
html = await lerHtml(res);
} catch {
html = '';
}
const pagina = { url, titulo: extrairTitulo(html) };
paginas.push(pagina);
if (aoAchar) aoAchar(pagina, paginas.length);
// A primeira pagina rastreada e a home: e dela que sai a descricao do site.
if (paginas.length === 1) descricaoDoSite = extrairDescricao(html);
if (nivel >= profundidade) continue;
for (const link of extrairLinks(html, url)) {
if (vistas.has(link)) continue;
if (!mesmoHost(link, urlInicial)) continue;
if (!ehPaginaHtml(link)) continue;
if (!permitida(link)) continue;
vistas.add(link);
fila.push({ url: link, nivel: nivel + 1 });
}
}
Três decisões aqui não são óbvias e valem o parágrafo.
O continue depois do fetch não é
break. Uma página que caiu, que demorou demais ou que
devolveu 500 é ignorada, e o rastreio continua nas outras. Trocar por
break faria um link podre no meio do site truncar a lista inteira —
e, de novo, sem erro nenhum aparecer.
O HTML é lido uma vez só. O mesmo texto serve para extrair o
título e para extrair os links. Parece trivial, mas o corpo de uma resposta
fetch é um stream: você lê uma vez e acabou. Ler duas
vezes lança TypeError: Body is unusable.
A descrição do site sai da primeira página. É uma aposta
consciente: a primeira URL da fila é a que o usuário informou, quase sempre a
home, e a <meta name="description"> da home é justamente o
resumo do site que o formato pede na linha do >.
Confesso que essa última eu só coloquei porque bati o olho no sistema de onde tirei a ideia e vi que lá o campo estava sempre vazio — a função que extraía a descrição existia, estava exportada, e nunca era chamada. 🤯 A linha do resumo simplesmente nunca saía. É o tipo de coisa que ninguém nota porque o arquivo continua parecendo certo sem ela.
📝 Montando o arquivo
Depois de todo o rastreio, a montagem é a parte mansa:
/**
* Monta o llms.txt no formato do llmstxt.org a partir das paginas rastreadas.
*
* O formato e proposital: um H1 com o nome do site, uma linha de citacao com o
* resumo e listas de links com descricao. E Markdown para a maquina ler — sem
* menu, sem banner, sem script.
*
* @param {{url: string, titulo?: string}[]} paginas
* @param {{host: string, titulo?: string, descricao?: string}} dadosDoSite
*/
export function montarLlmsTxt(paginas, dadosDoSite) {
const cabecalho = dadosDoSite.titulo || dadosDoSite.host;
const linhas = [`# ${cabecalho}`, ''];
if (dadosDoSite.descricao) {
linhas.push(`> ${dadosDoSite.descricao}`, '');
}
linhas.push('## Páginas', '');
for (const p of paginas) {
// Sem <title>, o texto do link vira a propria URL: feio, mas honesto —
// avisa que aquela pagina esta sem titulo em vez de inventar um.
linhas.push(`- [${p.titulo || p.url}](${p.url})`);
}
linhas.push('');
return linhas.join('\n');
}
O p.titulo || p.url no fim é uma escolha de honestidade. Quando
a página não tem <title>, o texto do link vira a própria URL.
Fica feio — e é para ficar. A alternativa seria inventar um título a partir do
caminho (/sobre-nos → "Sobre Nos"), e aí o arquivo mentiria com
cara de completo. Feio e verdadeiro ganha de bonito e inventado.
▶️ Rodando
Pela linha de comando, é só apontar para o site:
node gerar.js https://seusite.com.br --paginas=50 --profundidade=2
Esta é a saída real dele rodando contra este blog, limitado a 12 páginas:
Rastreando http://127.0.0.1:8029/ ...
1. Blog da Paloma Macetko — Artigos técnicos de programação
2. Buscar | Paloma Macetko
3. Node.js: gerenciando códigos 2FA (TOTP) | Paloma Macetko
4. Tomadas Tuya pela API da nuvem, com Node.js | Paloma Macetko
5. MyISAM para InnoDB em massa com Node.js | Paloma Macetko
6. Parcelamento do Mercado Pago sem chamar a API | Paloma Macetko
7. Criando um MCP para uma API com OAuth | Paloma Macetko
8. Auto-update no Electron com GitHub Actions | Paloma Macetko
9. Electron: transformando um site React em app desktop | Paloma Macetko
10. GitHub Actions: compilando Electron para 3 sistemas | Paloma Macetko
11. Google Maps API: extraindo telefones de uma cidade | Paloma Macetko
12. Tomadas Tuya pelo Home Assistant, por código | Paloma Macetko
12 pagina(s) -> llms-teste.txt
E o começo do arquivo 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.
## Páginas
- [Blog da Paloma Macetko — Artigos técnicos de programação](http://127.0.0.1:8029/)
- [Buscar | Paloma Macetko](http://127.0.0.1:8029/busca/)
- [Node.js: gerenciando códigos 2FA (TOTP)](http://127.0.0.1:8029/nodejs-gerenciando-codigos-2fa-totp/)
Repare que a linha do > veio preenchida — é a
meta description da home, colhida no caminho. 🎉
Quem preferir clicar, o repositório traz um servidor de 51 linhas com uma página de formulário:
node servidor.js # http://localhost:3010
Você informa a URL, escolhe quantas páginas e a profundidade, e o arquivo aparece pronto na tela com um botão para baixar.
🧭 Onde colocar o arquivo
Na raiz do domínio, como https://seusite.com.br/llms.txt. É só
um arquivo estático — não precisa de rota, nem de header especial. Se o seu
servidor entregar como text/plain, ótimo; nada quebra se ele
entregar de outro jeito.
E aqui vai o conselho que eu dou depois de gerar o meu: o rastreio é
o rascunho, não a versão final. Rodar o gerador e publicar direto
funciona, mas o arquivo fica sendo uma lista de tudo. O bom mesmo é abrir o
resultado, jogar fora as páginas que não interessam a ninguém (busca,
paginação, política de privacidade) e agrupar o que sobrou em seções por
assunto. O formato aceita quantos ## você quiser — e é exatamente
essa curadoria que separa um llms.txt útil de um
sitemap.xml disfarçado.
📦 O código completo
Está tudo no repositório, incluindo o servidor e a página do navegador. São quatro arquivos, nenhuma dependência, e roda com o Node que você já tem:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.