Generar Sitemaps XML Dinámicamente
Construye y sirve sitemaps XML desde los datos vivos de tu aplicación — paginación más allá del límite de 50k URLs, anotaciones hreflang, lastmod reales y los trucos de caché que lo hacen barato de servir.
Visión General
Un sitemap XML es la lista que le entregas a los motores de búsqueda: qué páginas existen, cuándo cambiaron y cuánto importa cada una. Un archivo estático funciona mientras el sitio es pequeño; en cuanto el contenido cambia a diario — blogs, tiendas, páginas de usuarios — quieres el sitemap generado desde datos vivos. Abajo va una versión funcional en tres lenguajes: XML construido desde una consulta a la base de datos o una API de contenido, paginación para cuando superas el límite de 50.000 URLs por archivo, y anotaciones hreflang para sitios multi-idioma.
Cuándo Usar
Usa este recurso cuando:
- Tu sitio tiene miles de páginas que cambian con regularidad y editar un sitemap estático a mano dejó de ser viable.
- Mantienes un sitio multi-idioma y necesitas anotaciones
xhtml:linken el sitemap para hreflang. - Quieres
lastmod,changefreqypriorityderivados de timestamps reales del contenido en lugar de valores fijos. - Estás en — o camino a — las 50.000 URLs y necesitas un índice de sitemaps que referencie archivos paginados.
Solución
Python (Flask)
from flask import Flask, Response
from datetime import datetime
import math
import xml.etree.ElementTree as ET
app = Flask(__name__)
def generate_sitemap(urls):
urlset = ET.Element("urlset", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")
for url_data in urls:
url_elem = ET.SubElement(urlset, "url")
ET.SubElement(url_elem, "loc").text = url_data["loc"]
ET.SubElement(url_elem, "lastmod").text = url_data["lastmod"]
ET.SubElement(url_elem, "changefreq").text = url_data.get("changefreq", "weekly")
ET.SubElement(url_elem, "priority").text = str(url_data.get("priority", "0.5"))
return ET.tostring(urlset, encoding="unicode")
@app.route("/sitemap.xml")
def sitemap():
urls = [
{"loc": "https://example.com/", "lastmod": "2024-06-01", "priority": "1.0"},
{"loc": "https://example.com/blog/post-1", "lastmod": "2024-06-10", "priority": "0.8"},
{"loc": "https://example.com/products/item-1", "lastmod": "2024-06-05", "priority": "0.6"},
]
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += generate_sitemap(urls)
return Response(xml, mimetype="application/xml")
# Índice de sitemap para sitios grandes
@app.route("/sitemap-index.xml")
def sitemap_index():
sitemap_count = math.ceil(total_url_count / 50000) # p.ej. len(urls) desde tu DB
sitemapindex = ET.Element("sitemapindex", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")
for i in range(1, sitemap_count + 1):
sitemap_elem = ET.SubElement(sitemapindex, "sitemap")
ET.SubElement(sitemap_elem, "loc").text = f"https://example.com/sitemap-{i}.xml"
ET.SubElement(sitemap_elem, "lastmod").text = datetime.now().strftime("%Y-%m-%d")
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += ET.tostring(sitemapindex, encoding="unicode")
return Response(xml, mimetype="application/xml")
JavaScript (Express)
const express = require("express");
const app = express();
function buildSitemap(urls) {
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
];
for (const url of urls) {
lines.push(" <url>");
lines.push(` <loc>${escapeXml(url.loc)}</loc>`);
lines.push(` <lastmod>${url.lastmod}</lastmod>`);
lines.push(` <changefreq>${url.changefreq || "weekly"}</changefreq>`);
lines.push(` <priority>${url.priority || "0.5"}</priority>`);
lines.push(" </url>");
}
lines.push("</urlset>");
return lines.join("\n");
}
function escapeXml(str) {
return str.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
app.get("/sitemap.xml", (req, res) => {
const urls = [
{ loc: "https://example.com/", lastmod: "2024-06-01", priority: "1.0" },
{ loc: "https://example.com/blog/post-1", lastmod: "2024-06-10", priority: "0.8" }
];
res.set("Content-Type", "application/xml");
res.send(buildSitemap(urls));
});
// Sitemap paginado con hreflang
app.get("/sitemap-products.xml", (req, res) => {
const products = getProducts(); // Desde DB
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
' xmlns:xhtml="http://www.w3.org/1999/xhtml">'
];
for (const product of products) {
lines.push(" <url>");
lines.push(` <loc>${escapeXml(product.url)}</loc>`);
lines.push(` <lastmod>${product.updatedAt}</lastmod>`);
// Anotaciones hreflang
for (const lang of ["en", "es", "de"]) {
lines.push(` <xhtml:link rel="alternate" hreflang="${lang}"`);
lines.push(` href="${escapeXml(product.url)}?lang=${lang}" />`);
}
lines.push(" </url>");
}
lines.push("</urlset>");
res.set("Content-Type", "application/xml");
res.send(lines.join("\n"));
});
Java (Spring Boot)
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.StringWriter;
import java.time.LocalDate;
import java.util.List;
@RestController
public class SitemapController {
record UrlEntry(String loc, String lastmod, String changefreq, String priority) {}
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
public String sitemap() {
List<UrlEntry> urls = List.of(
new UrlEntry("https://example.com/", "2024-06-01", "daily", "1.0"),
new UrlEntry("https://example.com/blog/post-1", "2024-06-10", "weekly", "0.8"),
new UrlEntry("https://example.com/products/item-1", "2024-06-05", "weekly", "0.6")
);
return buildSitemap(urls);
}
private String buildSitemap(List<UrlEntry> urls) {
try {
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = builder.newDocument();
Element urlset = doc.createElement("urlset");
urlset.setAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
doc.appendChild(urlset);
for (UrlEntry entry : urls) {
Element url = doc.createElement("url");
urlset.appendChild(url);
Element loc = doc.createElement("loc");
loc.setTextContent(entry.loc());
url.appendChild(loc);
Element lastmod = doc.createElement("lastmod");
lastmod.setTextContent(entry.lastmod());
url.appendChild(lastmod);
Element changefreq = doc.createElement("changefreq");
changefreq.setTextContent(entry.changefreq());
url.appendChild(changefreq);
Element priority = doc.createElement("priority");
priority.setTextContent(entry.priority());
url.appendChild(priority);
}
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
StringWriter writer = new StringWriter();
transformer.transform(new DOMSource(doc), new StreamResult(writer));
return writer.toString();
} catch (Exception e) {
throw new RuntimeException("Failed to generate sitemap", e);
}
}
}
Explicación
- Estructura XML sigue el protocolo sitemaps.org: una raíz
<urlset>con entradas<url>, cada una con<loc>,<lastmod>,<changefreq>y<priority>. Todo debe ser UTF-8 y estar bien escapado — un&sin escapar en un query string rompe el archivo entero para el crawler. - Generación en vivo significa consultar tu base de datos, CMS o sistema de archivos en tiempo de request y renderizar XML fresco. En un sitio con tráfico, no reconstruyas en cada hit — cachea la salida una o dos horas (ver Tips de Rendimiento).
- Paginación vía índice de sitemaps — un archivo de sitemap toca techo en 50.000 URLs o 50MB sin comprimir. Pasado ese punto, sirve un
sitemapindexque apunte asitemap-1.xml,sitemap-2.xml, y así sucesivamente. - Anotaciones hreflang van dentro de cada
<url>como elementos<xhtml:link>, y cada URL debe listarse a sí misma más todas sus alternativas — la canónica incluida. Hazlo mal y tu indexación multi-idioma se rompe en silencio.
Variantes
| Enfoque | Fuente | Hreflang | Mejor Para |
|---|---|---|---|
| Consulta a base de datos | SQL/ORM | Manual | Sitios con mucho contenido (blogs, CMS) |
| Build estático | Sistema de archivos | Pre-generado | Sitios JAMstack/SSG (Astro, Next.js) |
| API-driven | REST/GraphQL | Desde metadata de API | CMS headless (Contentful, Strapi) |
| Archivo cacheado | Redis/disco | Estático después del primer request | Sitios de alto tráfico con contenido estable |
Lo que funciona
- Siempre escapa XML — las URLs deben escapar XML para
&,<,>,"y'. Un ampersand sin escapar en un query string rompe el parser del sitemap. - Establece
lastmodpreciso — usa la fecha real de modificación del contenido, no la fecha actual. Google ignora valoreslastmodimprecisos y puede dejar de confiar en tu sitemap. - Pagina antes de alcanzar límites — comienza a generar un índice de sitemap cuando te acerques a 40.000 URLs para dejar margen de crecimiento. Cada sitemap referenciado debe estar bajo 50MB.
- Incluye solo URLs canónicas — excluye variantes parametrizadas, IDs de sesión y páginas 404. Los sitemaps solo deben listar páginas canónicas e indexables.
- Envía a motores de búsqueda — registra tu sitemap en Google Search Console y Bing Webmaster Tools. Para sitios muy grandes, usa la API de Search Console para enviar updates de sitemap programáticamente.
Errores Comunes
- Generar sitemaps con URLs
http://cuando el sitio usa HTTPS — los motores de búsqueda tratan estos como sitios separados y pueden ignorar la versión HTTP. - Olvidar escapar XML en URLs con parámetros de query, causando errores de parser en crawlers de motores de búsqueda.
- Listar páginas noindex o cadenas de redirección en el sitemap, desperdiciando presupuesto de crawl y confundiendo a los motores de búsqueda.
- Usar la fecha actual para todos los valores
lastmod, haciendo el atributo sin sentido y potencialmente ignorado por los crawlers. - Omitir el índice de sitemap para sitios con 100.000+ URLs, resultando en archivos de sitemap individuales que violan el límite de 50.000 URLs / 50MB.
Configuración en Producción
Robots.txt
Apunta a los crawlers al índice, no a los sitemaps individuales — una línea Sitemap: basta:
# robots.txt
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap-index.xml
Extensión News Sitemap
Para Google News, añade el namespace news: con metadatos de publicación. Las entradas no pueden tener más de 48 horas:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
<url>
<loc>https://example.com/news/article-1</loc>
<news:news>
<news:publication>
<news:name>Example Times</news:name>
<news:language>en</news:language>
</news:publication>
<news:publication_date>2024-06-15</news:publication_date>
<news:title>Breaking Story</news:title>
</news:news>
</url>
</urlset>
Tips de Rendimiento
- Cachea el XML renderizado con un TTL corto. Reconstruir en cada request es trabajo desperdiciado — regenera cada 1-6 horas:
import redis
from functools import wraps
from flask import request
r = redis.Redis(host="localhost", port=6379, db=0)
def cache_sitemap(ttl_seconds=3600):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
cache_key = f"sitemap:{request.path}"
cached = r.get(cache_key)
if cached:
return Response(cached, mimetype="application/xml")
xml = fn(*args, **kwargs)
r.setex(cache_key, ttl_seconds, xml)
return Response(xml, mimetype="application/xml")
return wrapper
return decorator
@app.route("/sitemap.xml")
@cache_sitemap(ttl_seconds=3600)
def sitemap():
urls = query_urls_from_db()
return generate_sitemap(urls)
- Genera los sitemaps en un background job para sitios grandes. Escribe los archivos a disco con un cron y deja que el servidor web los sirva como estáticos — coste de consulta cero por request:
# tarea en segundo plano
def generate_sitemap_files():
urls = query_all_urls()
for i, batch in enumerate(split_sitemaps(urls)):
xml = build_sitemap_xml(batch)
with open(f"/var/www/sitemap-{i+1}.xml", "w") as f:
f.write(xml)
- Sírvelos comprimidos con gzip. El XML comprime un 70-90%; apunta robots.txt a un
.xml.gzo genéralo junto al normal:
import gzip
from flask import Response
@app.route("/sitemap.xml.gz")
def sitemap_gzipped():
xml = generate_sitemap(urls)
compressed = gzip.compress(xml.encode("utf-8"))
return Response(compressed, mimetype="application/gzip")
# o genera y comprime por adelantado
gzip -c sitemap-1.xml > sitemap-1.xml.gz
See Also
- Protocolo sitemaps.org — el schema oficial: elementos
urlset,urlysitemapindex - Google Search Central: Sitemaps — límites, formatos y envío
- Google: Versiones localizadas — cómo interactúan las alternativas
xhtml:linkdel sitemap con hreflang - Companion: generadores de sitemap ejecutables (Flask, Express, Spring Boot)
Preguntas frecuentes
¿Con qué frecuencia debo regenerar el sitemap?
Ajusta la respuesta a la velocidad a la que se mueve tu contenido: contenido que cambia a diario, regenera a diario; un sitio casi estático, reconstruye dentro del pipeline de despliegue; algo muy vivo (un foro, un marketplace), genera bajo demanda detrás de un caché corto — una hora suele equilibrar frescura y carga.
¿Puedo incluir imágenes y videos en el sitemap?
¿Necesito un sitemap separado por idioma?
Normalmente no — un solo sitemap puede llevar todos los idiomas vía <xhtml:link rel="alternate" hreflang="..."> en cada URL. Dividir por idioma solo compensa a gran escala, cuando el tracking de lastmod por idioma o el tamaño de archivo empiezan a molestar.
¿Los campos changefreq y priority sirven para algo?
Apenas — Google ha dicho que en gran parte los ignora; lastmod es el campo que sí lee. Déjalos si no te cuestan nada, pero no esperes que dirijan al crawler.
Recursos Relacionados
Tareas en Segundo Plano (Background Jobs)
Descarga trabajo lento de las peticiones HTTP: ejecuta tareas en segundo plano con colas de trabajo, programación cron y workers en Python, JavaScript y Java.
RecipeCron: programar tareas en Linux, Python y Node.js
Cómo programar y gestionar tareas recurrentes usando sintaxis cron en Linux, Python, Node.js y Kubernetes.
RecipeComprimir y Descomprimir Archivos con Gzip y Brotli
Cómo reducir tamaños de archivos para APIs, assets estáticos y logs usando Gzip, Brotli y zlib con compresión streaming, negociación de contenido y lo que funciona.
RecipeEndpoint de Health Check
Cómo implementar un endpoint de health check listo para producción para monitoreo y load balancers.
RecipeConfigurar headers HTTP Cache-Control para APIs y assets
Establece headers Cache-Control, ETag y Last-Modified para controlar la caché de navegadores y CDN en respuestas de API y assets estáticos