Node.js: gerando o sitemap.xml de um site
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. 😳
🗺️ 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 `&` 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&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
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.