Pular para o conteúdo
Node.js

Node.js: gerando o llms.txt do seu site

Paloma Macetko
Ilustração de um unicórnio de crina colorida lendo um pergaminho luminoso que se transforma em uma lista de itens, ao lado de uma coruja de óculos apontando para uma teia de páginas ligadas por fios brilhantes

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. 😳

Exemplos_GerarLLMS no GitHub O código completo deste artigo: o gerador, a linha de comando e uma página no navegador para você testar no seu site sem escrever código. github.com

🤖 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:

Exemplos_GerarLLMS no GitHub O gerador completo, a linha de comando, o servidor com formulário e o README com as armadilhas resumidas. github.com

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

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

Leia também