Pular para o conteúdo
Node.js

Node.js: gerando o sitemap.xml de um site

Paloma Macetko
Ilustração de um unicórnio de crina colorida traçando com uma varinha linhas de luz que ligam janelinhas de páginas web em uma árvore ramificada, ao lado de uma coruja de óculos segurando um pergaminho com uma lista de endereços

Olá meus Unicórnios! 🦄✨

Todo site precisa de um sitemap.xml, e a maioria dos sites que eu mantenho tem o dele gerado pelo próprio sistema — o CMS sabe quais páginas existem, então é só listar. Mas de vez em quando cai no meu colo um site que eu não controlo: um site antigo, feito à mão, sem banco de dados, ou o site de um cliente que está no ar há anos e ninguém sabe direito quantas páginas tem lá dentro. 😅

Nesses casos só existe um jeito: rastrear o site como o Google faz. Começar pela home, ler os links, seguir cada um, e ir montando a lista. Neste artigo eu faço exatamente isso em Node.js puro — sem biblioteca de crawl, sem navegador headless, só o fetch nativo e uma fila.

E tem a armadilha que me custou a tarde. Uma linha, escrita do jeito que qualquer pessoa escreveria, fazia a resposta nunca chegar — sem erro no servidor, sem erro no navegador, sem nada. Só a página girando para sempre. 😳

Exemplos_GerarSitemap no GitHub O código completo deste artigo: o rastreador, o endpoint com progresso ao vivo e uma página no navegador para você testar no seu site sem escrever código. github.com

🗺️ O que o sitemap.xml precisa ter

Menos coisa do que parece. O formato do sitemaps.org é um XML com um <urlset> e um <url> por página. Dentro de cada um, só o <loc> é obrigatório:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://exemplo.com/</loc>
    <lastmod>2026-08-05</lastmod>
  </url>
</urlset>

Existem ainda <changefreq> e <priority>, que eu deixei de fora de propósito: o Google disse publicamente que ignora os dois. Encher o arquivo com campo que ninguém lê só aumenta o peso.

O <lastmod> esse sim vale, quando for verdadeiro. Eu pego do cabeçalho Last-Modified da própria resposta HTTP, e omito quando o servidor não manda — melhor não ter do que ter mentira.

🌳 Rastreando em largura

O rastreio é uma busca em largura (BFS): uma fila de URLs para visitar, um conjunto de URLs já vistas, e um laço que tira da fila, baixa, extrai os links e devolve os novos para o fim da fila.

Largura e não profundidade, e a diferença importa de verdade. Repare na linha que tira o item da fila:

    // shift() tira do inicio: e isso que faz a busca ser em LARGURA. Trocar por
    // pop() viraria busca em profundidade e o limite de paginas cortaria um
    // ramo fundo do site em vez de uma camada inteira.
    const { url, profundidade } = fila.shift();

É o shift() que faz a busca ser em largura — ele tira do começo. Trocar por pop() parece inofensivo e vira busca em profundidade: o rastreador desce por um ramo só, o mais fundo que conseguir. Com um limite de páginas, a diferença é brutal — em largura você pega a home, o menu inteiro e as seções principais; em profundidade você pega a home e depois 499 páginas de um único subdiretório. 😬

O conjunto de visitadas tem um detalhe que eu já errei antes:

  // `visitadas` guarda o que JA ENTROU NA FILA, nao o que ja foi baixado. Marcar
  // so depois do download faria a mesma URL entrar varias vezes: em um site com
  // menu, todas as paginas apontam para /contato, e ele seria enfileirado uma
  // vez por pagina.
  const visitadas = new Set();
  const fila = [{ url: urlInicial, profundidade: 0 }];
  visitadas.add(urlInicial);
  const urls = [];

Marcar a URL ao enfileirar, não ao baixar. Parece a mesma coisa e não é: num site com menu, todas as páginas apontam para /contato. Se você só marcar depois de baixar, o /contato entra na fila uma vez por página do site — e num site de 500 páginas isso são 500 entradas idênticas esperando na fila. 🤯

🔗 Extraindo os links (e o que não é link)

A extração é uma expressão regular procurando href dentro de <a>. Sim, regex em HTML — aquela coisa que todo mundo diz para nunca fazer. Aqui ela serve porque eu não preciso entender o documento, só preciso dos href, e trazer um parser de HTML inteiro para isso seria a dependência que este projeto não tem.

O que importa mesmo é o que acontece com cada link achado. Quatro filtros antes de ele entrar na fila: já visto? mesmo host? parece HTML? permitido pelo robots? E a normalização, que tem uma linha fácil de esquecer:

/**
 * Resolve `href` (relativo ou absoluto) contra `urlBase`, remove o fragmento e
 * devolve a URL absoluta. Mantem a querystring. Devolve null se nao for
 * http/https ou se nao for uma URL valida.
 *
 * O fragmento tem de sair: /precos e /precos#planos sao a MESMA pagina para o
 * servidor. Sem essa linha, o rastreio visita a mesma pagina uma vez por
 * ancora do menu e o sitemap sai cheio de duplicatas.
 */
export function normalizarUrl(href, urlBase) {
  try {
    const u = new URL(href, urlBase);
    if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
    u.hash = '';
    return u.toString();
  } catch {
    return null;
  }
}

O u.hash = '' é pequeno e resolve um problema chato. Para o servidor, /precos e /precos#planos são a mesma página — a âncora nunca sai do navegador. Sem essa linha, um menu com âncoras (aqueles de página única, "Sobre", "Planos", "Contato", todos apontando para o mesmo lugar) enfileira a mesma página uma vez por âncora, e o sitemap sai cheio de duplicata.

Confirmado no teste:

/precos#planos => https://ex.com/precos
/precos        => https://ex.com/precos

O filtro de extensão (.jpg, .pdf, .zip…) evita enfileirar arquivo que não é página. E, depois do download, tem uma segunda barreira que pega o que a extensão não denuncia:

    // Sem esta checagem, um PDF ou uma imagem sem extensao na URL entraria no
    // sitemap e ainda seria lido como HTML na busca por links.
    const contentType = res.headers.get('content-type') || '';
    if (!contentType.includes('text/html')) continue;

Precisa das duas. Um link para /catalogo/download não tem extensão nenhuma na URL e pode devolver um PDF de 40 MB — que entraria no sitemap e ainda seria lido como HTML na busca por links.

💥 O bug que me custou a tarde

Se você só for ler um pedaço deste artigo, leia este. 🙏

Rastreio de site é lento — são dezenas ou centenas de requisições HTTP, uma atrás da outra. Deixar a pessoa olhando para uma tela parada por dois minutos é cruel, então eu quis progresso ao vivo: o endpoint responde em NDJSON (uma linha JSON por evento, enviada na hora que acontece) e a página vai mostrando o contador subir.

E se a pessoa cansar e fechar a aba no meio? Aí eu preciso parar o rastreio — não faz sentido continuar baixando 400 páginas para alguém que já foi embora. A forma óbvia de detectar isso, e a que eu escrevi sem pensar duas vezes:

let abortado = false;
req.on('close', () => {
  abortado = true;
});

Rodei. A página ficou girando. Para sempre. 😐

Sem erro no terminal do servidor. Sem erro no console do navegador. O curl ficava pendurado até o timeout que eu tinha posto — dois minutos inteiros, zero byte recebido:

status=000 bytes=0
curl: (28) Operation timed out after 120009 milliseconds with 0 bytes received

O pior é que o rastreio funcionava. Testado fora do Express, direto na função, ele achava as páginas em 119 milissegundos:

1 http://127.0.0.1:8029/ | Blog da Paloma Macetko — Artigos técnicos
2 http://127.0.0.1:8029/busca/ | Buscar | Paloma Macetko
3 http://127.0.0.1:8029/nodejs-gerenciando-codigos-2fa-totp/ | Node.js: 2FA (TOTP)
fim em 119 ms | paginas: 5 | motivo: max-paginas

Então o rastreio estava certo e alguma coisa entre ele e o navegador estava comendo tudo. Eu montei um servidor mínimo só para cronometrar o evento:

app.post('/t', async (req, res) => {
  let abortado = false;
  const t0 = Date.now();
  req.on('close', () => console.log('req close disparou! t=' + (Date.now() - t0) + 'ms'));
  await new Promise(r => setTimeout(r, 300));
  console.log('apos 300ms, abortado =', abortado);
});

E o terminal me contou tudo:

>> req close disparou! (t=1ms)
>> apos 300ms, abortado = true
corpo recebido: ""

Isso mesmo: 1 milissegundo. Com o cliente perfeitamente conectado, esperando a resposta.

O motivo é o express.json(). Ele lê o corpo do POST inteirinho para transformar em objeto — e quando o corpo acaba de chegar, o stream do req terminou. Terminou de ler, e o Node emite close. O evento não significa "o cliente foi embora": significa "o request acabou". Num GET sem corpo você nem repara; num POST com JSON, ele dispara imediatamente.

Aí é dominó: abortado vira true antes da primeira página, a função que escreve cada linha começa com if (abortado) return e engole todos os eventos, o res.end() nunca é chamado, e o cliente espera uma resposta que nunca vai existir.

A correção é ouvir o res, que só fecha quando a conexão cai de verdade:

  // ATENCAO: tem de ser `res.on('close')`, NUNCA `req.on('close')`. Com o
  // express.json() lendo o corpo do POST, o `req` termina assim que o JSON
  // acaba de chegar — e o 'close' dele dispara em ~1ms, com o cliente
  // perfeitamente conectado. Ouvindo o `req`, `abortado` vira true antes da
  // primeira pagina, todo evento e engolido e a resposta fica vazia para
  // sempre, sem erro nenhum. O `res` so fecha quando a conexao cai de verdade.
  let abortado = false;
  res.on('close', () => {
    if (!res.writableEnded) abortado = true;
  });

O res.writableEnded é o que separa os dois casos, e eu medi os dois para ter certeza:

caso 1 (cliente normal)
  >> apos 300ms, abortado = false
  >> res close (t=314ms) writableEnded=true
  corpo recebido: "{\"ok\":true}\n"

caso 2 (cliente desiste aos 100ms)
  >> res close (t=102ms) writableEnded=false
  >> apos 300ms, abortado = true

No caso 1 o close só chega aos 314 ms, depois do res.end(), com writableEnded=true — resposta entregue inteira. No caso 2 ele chega aos 102 ms com writableEnded=false — esse é abandono de verdade, e aí sim vale parar.

Com a linha trocada, o mesmo rastreio que dava timeout de dois minutos:

status=200 bytes=5251 tempo=0.342955s
linhas NDJSON: 21
{"tipo":"pagina","loc":"http://127.0.0.1:8029/","titulo":"Blog da Paloma Macetko","total":1}
{"tipo":"pagina","loc":"http://127.0.0.1:8029/busca/","titulo":"Buscar","total":2}
tipo: fim | paginas: 20 | motivo: max-paginas

343 milissegundos. Era uma linha. 😅

⚠️ O & que invalida o arquivo inteiro

Essa é clássica e machuca porque só aparece em produção. A montagem do XML:

/**
 * Monta o sitemap.xml a partir de [{ loc, lastmod? }].
 *
 * O `&` precisa virar `&amp;` mesmo dentro de <loc>: uma URL com querystring
 * (`?id=1&cor=azul`) gera um XML invalido sem esse escape, e o Google recusa o
 * arquivo inteiro — nao so a linha com problema.
 */
export function montarSitemapXml(urls) {
  const corpo = urls
    .map(({ loc, lastmod }) => {
      const modificado = lastmod ? `\n    <lastmod>${lastmod}</lastmod>` : '';
      return `  <url>\n    <loc>${escaparXml(loc)}</loc>${modificado}\n  </url>`;
    })
    .join('\n');
  return (
    '<?xml version="1.0" encoding="UTF-8"?>\n' +
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
    corpo +
    '\n</urlset>\n'
  );
}

O escaparXml() não é enfeite. Uma URL com querystring — /produtos?id=1&cor=azul, o que qualquer loja tem aos montes — carrega um &, e & solto em XML é erro de sintaxe. O detalhe cruel: o parser não descarta aquela linha, ele recusa o documento inteiro. Uma URL malformada e o Google rejeita o sitemap todo.

Testado com um parser de verdade, para não ficar no "deve funcionar":

<loc>https://loja.exemplo/produtos?id=1&amp;cor=azul</loc>

valido
loc lido de volta: https://loja.exemplo/produtos?id=1&cor=azul

Escapa na gravação, o parser desescapa na leitura, e a URL volta idêntica. É assim que tem de ser.

🔤 O charset que o fetch ignora

Essa eu já tinha levado no gerador de llms.txt, e ela volta aqui inteirinha porque o problema é o mesmo: eu guardo o <title> de cada página.

O res.text() do fetch sempre decodifica como UTF-8, e ignora solenemente o charset que o site declarou. Site legado em ISO-8859-1 — que é meio PHP antigo do mundo — devolve todo acento corrompido. A solução é ler os bytes e escolher o decodificador na mão: Content-Type primeiro, <meta charset> depois, UTF-8 como último recurso.

--- titulo de pagina ISO-8859-1 ---
lido:     "Promoções e Vídeos"
esperado: "Promoções e Vídeos"
bate: true

🛡️ Uma página quebrada não derruba o resto

Site de verdade tem link podre. Tem página que caiu, tem link para um domínio que expirou, tem aquele endpoint que trava e não responde nunca. Se qualquer um deles derrubar o rastreio, você nunca gera o sitemap de um site real.

Duas defesas, as duas necessárias:

    let res;
    try {
      res = await buscar(url, { signal: AbortSignal.timeout(TEMPO_LIMITE_MS) });
    } catch {
      continue; // Falhou ou estourou o tempo: ignora esta pagina e segue.
    }
    if (!res.ok) continue;

O AbortSignal.timeout() é nativo desde o Node 17 e resolve o caso mais traiçoeiro: a página que não dá erro, só demora. Sem timeout, um único endpoint travado segura o rastreio inteiro para sempre — e como não há exceção, não há o que capturar. O catch vazio com continue cobre o resto: DNS que não resolve, conexão recusada, certificado inválido.

Repare que é continue, não break. Uma página ruim sai da lista; as outras seguem normalmente.

▶️ Rodando

O repositório tem o rastreador, o endpoint e uma página para testar no navegador:

git clone https://github.com/cmacetko/Exemplos_GerarSitemap.git
cd Exemplos_GerarSitemap
npm install
npm start

Abra http://localhost:3009, digite a URL do seu site e veja o contador subir enquanto o rastreio acontece. No fim, o XML aparece pronto para copiar ou baixar.

Pela linha de comando, o -N do curl é obrigatório — sem ele o curl guarda tudo e entrega junto no fim, e você perde justamente o streaming:

curl -N -X POST http://localhost:3009/api/rastrear \
  -H "Content-Type: application/json" \
  -d '{"url":"https://exemplo.com","maxPaginas":50,"profundidadeMax":2}'

E se você quiser ver o bug do req.on('close') acontecer com os seus próprios olhos — vale a pena, porque ele é silencioso demais para acreditar de ouvido — o README ensina qual linha trocar. São 30 segundos e a página fica girando para sempre. 🙂

🚧 Onde isso não serve

O rastreio lê o HTML como o servidor entregou e procura <a href>. Numa SPA — React, Vue, Angular — o HTML inicial costuma ser uma casca vazia, e os links só existem depois que o JavaScript roda no navegador. O resultado é um sitemap com uma página só.

Para esses sites o caminho é outro: um navegador de verdade (Puppeteer, Playwright) ou o sitemap que o próprio framework gera no build. A página de demonstração avisa quando encontra pouquíssimas páginas, que é exatamente o sintoma disso.

E vale dizer: rastrear site dos outros pede juízo. Este código dispara as requisições uma atrás da outra, sem pausa e sem paralelismo — o que já é educado — mas rastrear um site que não é seu, com limite alto, é mandar centenas de requisições para uma máquina alheia. No site do cliente, com o cliente sabendo, tudo bem. Fora disso, respeite o robots.txt: o parâmetro está lá para ser ligado.

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

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

Leia também