Generar Sitemaps en Vivo
Cómo construir y servir sitemaps XML en vivo desde los datos de tu aplicación, con soporte multi-idioma, paginación y fechas lastmod automáticas.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Visión General
Los sitemaps XML informan a los motores de búsqueda qué páginas existen en tu sitio, cuán a menudo cambian, y su prioridad relativa. Mientras que los sitemaps estáticos funcionan para sitios pequeños, los sitemaps en vivo son esenciales para contenido grande o que cambia frecuentemente (blogs, e-commerce, contenido generado por usuarios). El siguiente enfoque cubre la generación de sitemap XML desde consultas de base de datos o APIs de contenido, el manejo de paginación cuando las URLs exceden el límite de 50.000 por archivo, y la adición de anotaciones multi-idioma hreflang para SEO internacional.
Cuándo Usar
Usa este recurso cuando:
- Tu sitio tiene miles de páginas que cambian regularmente y un sitemap estático es inmantenible. Consulta Background Jobs para regeneración programada.
- Ejecutas un sitio multi-idioma y necesitas anotaciones
xhtml:linken sitemaps para hreflang. Consulta Environment Variables para configuración por locale. - Quieres incluir metadatos
lastmod,changefreqypriorityderivados de timestamps de contenido. Consulta Cron Jobs para actualizaciones programadas. - Necesitas un archivo de índice de sitemap que referencie múltiples archivos de sitemap paginados para sitios muy grandes. Consulta Compression Gzip para reducir tamaño de transferencia de sitemaps.
Solución
Python (Flask)
from flask import Flask, Response
from datetime import datetime
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 = 3 # Consulta tu DB para total de páginas / 50000
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>conteniendo entradas<url>, cada una con elementos hijos<loc>,<lastmod>,<changefreq>y<priority>. Los sitemaps válidos deben estar codificados en UTF-8 y escapados correctamente. - Generación en vivo consulta tu base de datos, CMS o sistema de archivos en tiempo de request para producir sitemaps frescos. Para sitios de alto tráfico, cachea el XML generado por unas horas en lugar de reconstruir en cada request.
- Paginación vía índice de sitemap — cada archivo de sitemap puede contener como máximo 50.000 URLs y debe estar bajo 50MB sin comprimir. Para sitios más grandes, crea un archivo de índice de sitemap que referencie múltiples sitemaps paginados (
sitemap-1.xml,sitemap-2.xml, etc.). - Anotaciones hreflang usan elementos
<xhtml:link>dentro de cada<url>para declarar variantes de idioma. Cada URL debe listarse a sí misma y todas sus alternativas, incluyendo la versión canónica. Esto es crítico para SEO multilingüe.
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.
Preguntas Frecuentes
¿Con qué frecuencia debo regenerar el sitemap?
Para contenido que cambia diariamente, regenera al menos una vez al día. Para sitios estáticos, reconstruye el sitemap como parte de tu pipeline de deployment. Para sitios altamente en vivo (foros, marketplaces), genera on-demand con un cache corto (ej. 1 hora) para balancear frescura y performance.
¿Puedo incluir imágenes y videos en el sitemap?
Sí. Usa las extensiones de Image Sitemap y Video Sitemap de Google agregando elementos <image:image> y <video:video> dentro de cada <url>. Esto ayuda a Google a descubrir contenido multimedia que podría no estar enlazado vía HTML estándar.
¿Necesito un sitemap separado para cada idioma?
No necesariamente. Puedes incluir todas las variantes de idioma en un solo sitemap usando anotaciones <xhtml:link rel="alternate" hreflang="...">. Sin embargo, para sitios multi-idioma muy grandes, dividir por idioma puede hacer los sitemaps más manejables y permitir tracking de lastmod específico por idioma.
Compresión Gzip para Sitemaps
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")
Integración con Robots.txt
# robots.txt
User-agent: *
Allow: /
# Apuntar al índice de sitemap
Sitemap: https://example.com/sitemap-index.xml
Caché con Redis
import redis
from functools import wraps
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)
Extensión News Sitemap
<?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>
Mejores Prácticas Adicionales
- Usa un índice de sitemap para sitios grandes. Mantén cada archivo de sitemap bajo 40.000 URLs para dejar margen:
def split_sitemaps(urls, max_per_file=40000):
for i in range(0, len(urls), max_per_file):
yield urls[i:i + max_per_file]
- Establece headers Content-Type correctamente. Sirve como
application/xmlpara sin comprimir yapplication/gzippara comprimido:
# Sin comprimir
res.set("Content-Type", "application/xml")
# Comprimido
res.set("Content-Type", "application/gzip")
res.set("Content-Disposition", 'attachment; filename="sitemap.xml.gz"')
- Excluye URLs no canónicas. Filtra redirecciones, páginas noindex y variantes parametrizadas:
def filter_canonical_urls(urls):
return [
url for url in urls
if url["status"] == 200
and not url["noindex"]
and not url["redirect"]
and "?" not in url["loc"] # excluir URLs parametrizadas
]
Errores Comunes Adicionales
- Incluir URLs que retornan 404 o 301. Los sitemaps solo deben listar URLs canónicas con 200 OK:
# Validar URLs antes de agregar al sitemap
import requests
def validate_url(url: str) -> bool:
try:
r = requests.head(url, allow_redirects=False, timeout=5)
return r.status_code == 200
except requests.RequestException:
return False
- No actualizar el sitemap después de eliminar contenido. Las páginas removidas deben quitarse del sitemap:
# Remover URLs eliminadas del caché de sitemap
def invalidate_sitemap_cache(url_path: str):
r.delete(f"sitemap:{url_path}")
r.delete("sitemap:/sitemap.xml") # invalidar sitemap completo
- Usar URLs relativas en el sitemap. Todas las URLs deben ser absolutas con dominio y protocolo completo:
<!-- Mal -->
<loc>/blog/post-1</loc>
<!-- Bien -->
<loc>https://example.com/blog/post-1</loc>
FAQ Adicional
Debo usar changefreq y priority?
Google ignora changefreq y priority en la práctica. lastmod es el único metadata que Google usa consistentemente. Inclúyelos por completitud pero enfócate en valores lastmod precisos.
Como envío un sitemap vía robots.txt?
Agrega una directiva Sitemap: en tu archivo robots.txt. Es la forma más simple de anunciar tu sitemap sin envío manual a cada motor de búsqueda:
Sitemap: https://example.com/sitemap-index.xml
Cuál es la diferencia entre índice de sitemap y sitemap?
Un índice de sitemap es un archivo XML que lista múltiples archivos de sitemap. Cada archivo de sitemap lista URLs individuales. Usa un índice cuando excedes 50.000 URLs o 50MB por archivo de sitemap.
Tips de Rendimiento
- Cachéa sitemaps con un TTL corto. Regenera cada 1-6 horas en lugar de en cada request:
# Cachear por 1 hora
@cache_sitemap(ttl_seconds=3600)
@app.route("/sitemap.xml")
def sitemap():
...
- Genera sitemaps en background jobs. Para sitios grandes, genera sitemaps vía un cron job y sirve el archivo estático:
# background task
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)
- Comprime sitemaps con gzip. Reduce el tamaño de transferencia en 70-90% para sitemaps grandes:
# Generar y comprimir
gzip -c sitemap-1.xml > sitemap-1.xml.gz Recursos Relacionados
Background Jobs
How to schedule and run background jobs using cron, task queues, and workers.
RecipeCLI Tool with Argument Parsing
How to build a professional command-line interface with argument parsing, flags, and subcommands.
RecipeEnvironment Variables
How to read, set, and manage environment variables securely across Python, JavaScript, and Java.
RecipeFeature Flags
How to implement feature toggles to safely roll out, test, and rollback functionality without deploying code.
RecipeHealth Check Endpoint
How to implement a production-ready health check endpoint for monitoring and load balancers.
GuideDocker for Developers — A Complete Guide
Learn Docker from the ground up: images, containers, Dockerfiles, networks, volumes, and Docker Compose for local development.