Pular para o conteúdo

Cap. 21 · Código

Gerador de sitemaps por tipo

Vários sitemaps separados por tipo (pages, posts, products, localities, news) mais um sitemap index. Cache com TTL por tipo e sitemap de notícias limitado aos itens das últimas 48 horas. Só usa a biblioteca padrão do Python.
Python 3 · biblioteca padrão

Execução

Rode em 3 comandos

terminal
$ python3 sitemap_generator.py --out out/
$ ls out/
$ cat out/sitemap-news.xml

Saída esperada

stdout
[built] sitemap-pages.xml (3 source items)
[built] sitemap-posts.xml (2 source items)
[built] sitemap-products.xml (2 source items)
[built] sitemap-localities.xml (2 source items)
[built] sitemap-news.xml (3 source items, 2 within 48h window)
[index] sitemap.xml (5 sitemaps)

Repare em sitemap-news.xml: dos 3 itens de exemplo, só 2 entram ("2 within 48h window") — o item publicado há 5 dias é filtrado pela regra das 48h do Google News.

Código-fonte

Código do módulo

sitemap_generator.py
#!/usr/bin/env python3
"""Chapter 21 - Typed multi-sitemap generator with a sitemap index.
Generates SEVERAL sitemaps split BY TYPE (pages, posts, products, localities,
news) plus a top-level sitemap INDEX that references them all. Two production
concerns are demonstrated:
* Per-type TTL cache: each sitemap type is only rebuilt when its cache TTL has
expired. Last-built timestamps live in a small JSON state file.
* News sitemap (Google News format): includes ONLY items published within the
last 48 hours, using the ``news:news`` namespace.
The script ships with inline SAMPLE data so it runs with zero setup (standard
library only - no pip install needed).
Part of the "living code" companion repository for the book
"Aparecer ou Sumir" / "Be Seen or Be Forgotten".
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Optional
from xml.sax.saxutils import escape
# XML namespaces used across the generated files.
NS_SITEMAP = "http://www.sitemaps.org/schemas/sitemap/0.9"
NS_NEWS = "http://www.google.com/schemas/sitemap-news/0.9"
# Name of the JSON file that records when each type's sitemap was last built.
STATE_FILE_NAME = ".sitemap_state.json"
# The 48-hour window is a Google News requirement: news sitemaps must only list
# articles published in the last two days.
NEWS_WINDOW = timedelta(hours=48)
# Per-type cache TTLs. A type's sitemap is only rebuilt once its TTL elapses.
# The news sitemap gets a short TTL because its content is time-sensitive.
DEFAULT_TTL_SECONDS: Dict[str, int] = {
"pages": 24 * 3600, # slow-changing: once a day
"posts": 6 * 3600, # editorial content: a few times a day
"products": 3600, # catalogue: hourly
"localities": 24 * 3600, # geo pages: once a day
"news": 15 * 60, # news: every 15 minutes
}
def now_utc() -> datetime:
"""Return the current time as an aware UTC datetime (easy to mock in tests)."""
return datetime.now(timezone.utc)
# ---------------------------------------------------------------------------
# Sample data source. In a real project each of these would be a DB query.
# Every item is a dict with at least "loc" and "lastmod". News items also carry
# "title" and "published" (an ISO-8601 timestamp).
# ---------------------------------------------------------------------------
def fetch_items(item_type: str, base_url: str) -> List[Dict[str, str]]:
"""Return the URL items for a given sitemap type.
TODO: replace this sample implementation with real queries against your CMS
/ database. The shape of each returned dict must stay the same.
"""
base = base_url.rstrip("/")
today = now_utc().strftime("%Y-%m-%d")
recent = now_utc().isoformat()
yesterday = (now_utc() - timedelta(hours=12)).isoformat()
samples: Dict[str, List[Dict[str, str]]] = {
"pages": [
{"loc": f"{base}/", "lastmod": today},
{"loc": f"{base}/sobre", "lastmod": today},
{"loc": f"{base}/contato", "lastmod": today},
],
"posts": [
{"loc": f"{base}/blog/seo-local", "lastmod": today},
{"loc": f"{base}/blog/sitemaps-por-tipo", "lastmod": today},
],
"products": [
{"loc": f"{base}/produtos/plano-pro", "lastmod": today},
{"loc": f"{base}/produtos/plano-basico", "lastmod": today},
],
"localities": [
{"loc": f"{base}/localidades/vila-aurora", "lastmod": today},
{"loc": f"{base}/localidades/porto-sereno", "lastmod": today},
],
"news": [
# Fresh item: published now -> inside the 48h window -> INCLUDED.
{
"loc": f"{base}/noticias/lancamento",
"lastmod": today,
"title": "Lancamento da nova ferramenta de SEO",
"published": recent,
},
# Fresh item: published 12h ago -> inside the 48h window -> INCLUDED.
{
"loc": f"{base}/noticias/atualizacao",
"lastmod": today,
"title": "Atualizacao do algoritmo de busca",
"published": yesterday,
},
# Stale item: published 5 days ago -> outside 48h -> FILTERED OUT.
{
"loc": f"{base}/noticias/antiga",
"lastmod": today,
"title": "Noticia antiga",
"published": (now_utc() - timedelta(days=5)).isoformat(),
},
],
}
return samples.get(item_type, [])
# ---------------------------------------------------------------------------
# State / TTL handling
# ---------------------------------------------------------------------------
def load_state(state_path: Path) -> Dict[str, float]:
"""Load {type: last_built_epoch_seconds} from the JSON state file."""
if state_path.exists():
with state_path.open(encoding="utf-8") as handle:
return json.load(handle)
return {}
def save_state(state_path: Path, state: Dict[str, float]) -> None:
"""Persist {type: last_built_epoch_seconds} to the JSON state file."""
with state_path.open("w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2)
def is_ttl_expired(state: Dict[str, float], item_type: str, ttl_seconds: int) -> bool:
"""Return True when a type must be rebuilt because its TTL has expired.
TTL LOGIC: we compare "now" against the last-built timestamp. If more than
``ttl_seconds`` have passed (or the type was never built), the cache is
considered stale and the sitemap is rebuilt.
"""
last_built = state.get(item_type)
if last_built is None:
return True
age_seconds = now_utc().timestamp() - last_built
return age_seconds >= ttl_seconds
# ---------------------------------------------------------------------------
# XML builders
# ---------------------------------------------------------------------------
def build_urlset(items: List[Dict[str, str]]) -> str:
"""Build a standard <urlset> sitemap document from URL items."""
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append(f'<urlset xmlns="{NS_SITEMAP}">')
for item in items:
lines.append(" <url>")
lines.append(f" <loc>{escape(item['loc'])}</loc>")
if item.get("lastmod"):
lines.append(f" <lastmod>{escape(item['lastmod'])}</lastmod>")
lines.append(" </url>")
lines.append("</urlset>")
return "\n".join(lines) + "\n"
def build_news_urlset(
items: List[Dict[str, str]],
publication_name: str,
language: str,
) -> str:
"""Build a Google News <urlset> including only items from the last 48h.
48h WINDOW: each item's ``published`` timestamp is parsed and compared
against ``now - 48h``. Anything older is skipped, per Google News rules.
"""
cutoff = now_utc() - NEWS_WINDOW
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append(
f'<urlset xmlns="{NS_SITEMAP}" xmlns:news="{NS_NEWS}">'
)
for item in items:
published = _parse_iso(item.get("published"))
if published is None or published < cutoff:
# Outside the 48-hour window -> excluded from the news sitemap.
continue
lines.append(" <url>")
lines.append(f" <loc>{escape(item['loc'])}</loc>")
lines.append(" <news:news>")
lines.append(" <news:publication>")
lines.append(f" <news:name>{escape(publication_name)}</news:name>")
lines.append(f" <news:language>{escape(language)}</news:language>")
lines.append(" </news:publication>")
lines.append(
f" <news:publication_date>{escape(item['published'])}"
"</news:publication_date>"
)
lines.append(f" <news:title>{escape(item.get('title', ''))}</news:title>")
lines.append(" </news:news>")
lines.append(" </url>")
lines.append("</urlset>")
return "\n".join(lines) + "\n"
def build_sitemap_index(sitemap_urls: List[str], lastmod: str) -> str:
"""Build the top-level <sitemapindex> referencing every type sitemap."""
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append(f'<sitemapindex xmlns="{NS_SITEMAP}">')
for url in sitemap_urls:
lines.append(" <sitemap>")
lines.append(f" <loc>{escape(url)}</loc>")
lines.append(f" <lastmod>{escape(lastmod)}</lastmod>")
lines.append(" </sitemap>")
lines.append("</sitemapindex>")
return "\n".join(lines) + "\n"
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
"""Best-effort ISO-8601 parser that always returns an aware UTC datetime."""
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
def generate(
base_url: str,
output_dir: Path,
types: List[str],
ttl_overrides: Dict[str, int],
publication_name: str,
language: str,
force: bool = False,
) -> None:
"""Generate the per-type sitemaps (honouring TTLs) and the sitemap index."""
output_dir.mkdir(parents=True, exist_ok=True)
state_path = output_dir / STATE_FILE_NAME
state = load_state(state_path)
sitemap_urls: List[str] = []
base = base_url.rstrip("/")
for item_type in types:
ttl = ttl_overrides.get(item_type, DEFAULT_TTL_SECONDS.get(item_type, 3600))
filename = f"sitemap-{item_type}.xml"
out_path = output_dir / filename
public_url = f"{base}/{filename}"
sitemap_urls.append(public_url)
# TTL cache decision: skip rebuild when still fresh (unless forced).
if not force and out_path.exists() and not is_ttl_expired(state, item_type, ttl):
print(f"[cached] {filename} (TTL not expired)")
continue
items = fetch_items(item_type, base_url)
if item_type == "news":
xml = build_news_urlset(items, publication_name, language)
kept = xml.count("<url>")
print(
f"[built] {filename} ({len(items)} source items, "
f"{kept} within 48h window)"
)
else:
xml = build_urlset(items)
print(f"[built] {filename} ({len(items)} source items)")
out_path.write_text(xml, encoding="utf-8")
state[item_type] = now_utc().timestamp()
# The index is always rewritten so it references the current set of files.
index_xml = build_sitemap_index(
sitemap_urls, lastmod=now_utc().strftime("%Y-%m-%d")
)
(output_dir / "sitemap.xml").write_text(index_xml, encoding="utf-8")
print(f"[index] sitemap.xml ({len(sitemap_urls)} sitemaps)")
save_state(state_path, state)
def parse_args(argv: List[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate typed sitemaps + a sitemap index with TTL cache."
)
parser.add_argument(
"--base-url",
default=os.environ.get("SITE_BASE_URL", "https://www.example.com"),
help="Site base URL used to build absolute locations.",
)
parser.add_argument(
"--out",
dest="out",
default=os.environ.get("SITEMAP_OUTPUT", "out"),
help="Directory to write the sitemap files into.",
)
parser.add_argument(
"--types",
default="pages,posts,products,localities,news",
help="Comma-separated list of sitemap types to build.",
)
parser.add_argument(
"--publication-name",
default=os.environ.get("NEWS_PUBLICATION_NAME", "Sua Publicacao"),
help="Publication name used in the news sitemap.",
)
parser.add_argument(
"--language",
default=os.environ.get("NEWS_LANGUAGE", "pt"),
help="Language code used in the news sitemap.",
)
parser.add_argument(
"--force",
action="store_true",
help="Rebuild every sitemap regardless of TTL.",
)
return parser.parse_args(argv)
def main(argv: List[str]) -> int:
args = parse_args(argv)
types = [t.strip() for t in args.types.split(",") if t.strip()]
generate(
base_url=args.base_url,
output_dir=Path(args.out),
types=types,
ttl_overrides={}, # TODO: load per-type TTL overrides here if desired.
publication_name=args.publication_name,
language=args.language,
force=args.force,
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

Dúvidas

Perguntas frequentes

Preciso instalar alguma dependência?

Não. O sitemap_generator.py usa apenas a biblioteca padrão do Python. Basta rodar python3 sitemap_generator.py --out out/ com os dados de exemplo já embutidos.

Como funciona o cache com TTL por tipo?

Cada tipo (pages, posts, products, localities, news) tem um TTL próprio. O horário da última construção fica em out/.sitemap_state.json. Um sitemap só é reconstruído quando seu TTL expira — o de notícias tem TTL curto (15 min) porque é sensível ao tempo. Use --force para reconstruir tudo.

Por que só duas das três notícias aparecem no sitemap?

O sitemap de notícias segue a regra do Google News: só inclui itens publicados nas últimas 48 horas. Dos três itens de exemplo, dois são recentes (entram) e um foi publicado há 5 dias (é filtrado). A saída mostra '3 source items, 2 within 48h window'.

Implementação

Prefere que eu implemente?

Conte o seu caso numa conversa e a gente define o escopo juntos.

Agendar conversa