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.
Rode em 3 comandos
$ python3 sitemap_generator.py --out out/
$ ls out/
$ cat out/sitemap-news.xmlSaída esperada
[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 do módulo
1#!/usr/bin/env python32"""Chapter 21 - Typed multi-sitemap generator with a sitemap index.34Generates SEVERAL sitemaps split BY TYPE (pages, posts, products, localities,5news) plus a top-level sitemap INDEX that references them all. Two production6concerns are demonstrated:78 * Per-type TTL cache: each sitemap type is only rebuilt when its cache TTL has9 expired. Last-built timestamps live in a small JSON state file.10 * News sitemap (Google News format): includes ONLY items published within the11 last 48 hours, using the ``news:news`` namespace.1213The script ships with inline SAMPLE data so it runs with zero setup (standard14library only - no pip install needed).1516Part of the "living code" companion repository for the book17"Aparecer ou Sumir" / "Be Seen or Be Forgotten".18"""1920from __future__ import annotations2122import argparse23import json24import os25import sys26from datetime import datetime, timedelta, timezone27from pathlib import Path28from typing import Dict, List, Optional29from xml.sax.saxutils import escape3031# XML namespaces used across the generated files.32NS_SITEMAP = "http://www.sitemaps.org/schemas/sitemap/0.9"33NS_NEWS = "http://www.google.com/schemas/sitemap-news/0.9"3435# Name of the JSON file that records when each type's sitemap was last built.36STATE_FILE_NAME = ".sitemap_state.json"3738# The 48-hour window is a Google News requirement: news sitemaps must only list39# articles published in the last two days.40NEWS_WINDOW = timedelta(hours=48)4142# Per-type cache TTLs. A type's sitemap is only rebuilt once its TTL elapses.43# The news sitemap gets a short TTL because its content is time-sensitive.44DEFAULT_TTL_SECONDS: Dict[str, int] = {45 "pages": 24 * 3600, # slow-changing: once a day46 "posts": 6 * 3600, # editorial content: a few times a day47 "products": 3600, # catalogue: hourly48 "localities": 24 * 3600, # geo pages: once a day49 "news": 15 * 60, # news: every 15 minutes50}515253def now_utc() -> datetime:54 """Return the current time as an aware UTC datetime (easy to mock in tests)."""55 return datetime.now(timezone.utc)565758# ---------------------------------------------------------------------------59# Sample data source. In a real project each of these would be a DB query.60# Every item is a dict with at least "loc" and "lastmod". News items also carry61# "title" and "published" (an ISO-8601 timestamp).62# ---------------------------------------------------------------------------63def fetch_items(item_type: str, base_url: str) -> List[Dict[str, str]]:64 """Return the URL items for a given sitemap type.6566 TODO: replace this sample implementation with real queries against your CMS67 / database. The shape of each returned dict must stay the same.68 """69 base = base_url.rstrip("/")70 today = now_utc().strftime("%Y-%m-%d")71 recent = now_utc().isoformat()72 yesterday = (now_utc() - timedelta(hours=12)).isoformat()7374 samples: Dict[str, List[Dict[str, str]]] = {75 "pages": [76 {"loc": f"{base}/", "lastmod": today},77 {"loc": f"{base}/sobre", "lastmod": today},78 {"loc": f"{base}/contato", "lastmod": today},79 ],80 "posts": [81 {"loc": f"{base}/blog/seo-local", "lastmod": today},82 {"loc": f"{base}/blog/sitemaps-por-tipo", "lastmod": today},83 ],84 "products": [85 {"loc": f"{base}/produtos/plano-pro", "lastmod": today},86 {"loc": f"{base}/produtos/plano-basico", "lastmod": today},87 ],88 "localities": [89 {"loc": f"{base}/localidades/vila-aurora", "lastmod": today},90 {"loc": f"{base}/localidades/porto-sereno", "lastmod": today},91 ],92 "news": [93 # Fresh item: published now -> inside the 48h window -> INCLUDED.94 {95 "loc": f"{base}/noticias/lancamento",96 "lastmod": today,97 "title": "Lancamento da nova ferramenta de SEO",98 "published": recent,99 },100 # Fresh item: published 12h ago -> inside the 48h window -> INCLUDED.101 {102 "loc": f"{base}/noticias/atualizacao",103 "lastmod": today,104 "title": "Atualizacao do algoritmo de busca",105 "published": yesterday,106 },107 # Stale item: published 5 days ago -> outside 48h -> FILTERED OUT.108 {109 "loc": f"{base}/noticias/antiga",110 "lastmod": today,111 "title": "Noticia antiga",112 "published": (now_utc() - timedelta(days=5)).isoformat(),113 },114 ],115 }116 return samples.get(item_type, [])117118119# ---------------------------------------------------------------------------120# State / TTL handling121# ---------------------------------------------------------------------------122def load_state(state_path: Path) -> Dict[str, float]:123 """Load {type: last_built_epoch_seconds} from the JSON state file."""124 if state_path.exists():125 with state_path.open(encoding="utf-8") as handle:126 return json.load(handle)127 return {}128129130def save_state(state_path: Path, state: Dict[str, float]) -> None:131 """Persist {type: last_built_epoch_seconds} to the JSON state file."""132 with state_path.open("w", encoding="utf-8") as handle:133 json.dump(state, handle, indent=2)134135136def is_ttl_expired(state: Dict[str, float], item_type: str, ttl_seconds: int) -> bool:137 """Return True when a type must be rebuilt because its TTL has expired.138139 TTL LOGIC: we compare "now" against the last-built timestamp. If more than140 ``ttl_seconds`` have passed (or the type was never built), the cache is141 considered stale and the sitemap is rebuilt.142 """143 last_built = state.get(item_type)144 if last_built is None:145 return True146 age_seconds = now_utc().timestamp() - last_built147 return age_seconds >= ttl_seconds148149150# ---------------------------------------------------------------------------151# XML builders152# ---------------------------------------------------------------------------153def build_urlset(items: List[Dict[str, str]]) -> str:154 """Build a standard <urlset> sitemap document from URL items."""155 lines = ['<?xml version="1.0" encoding="UTF-8"?>']156 lines.append(f'<urlset xmlns="{NS_SITEMAP}">')157 for item in items:158 lines.append(" <url>")159 lines.append(f" <loc>{escape(item['loc'])}</loc>")160 if item.get("lastmod"):161 lines.append(f" <lastmod>{escape(item['lastmod'])}</lastmod>")162 lines.append(" </url>")163 lines.append("</urlset>")164 return "\n".join(lines) + "\n"165166167def build_news_urlset(168 items: List[Dict[str, str]],169 publication_name: str,170 language: str,171) -> str:172 """Build a Google News <urlset> including only items from the last 48h.173174 48h WINDOW: each item's ``published`` timestamp is parsed and compared175 against ``now - 48h``. Anything older is skipped, per Google News rules.176 """177 cutoff = now_utc() - NEWS_WINDOW178 lines = ['<?xml version="1.0" encoding="UTF-8"?>']179 lines.append(180 f'<urlset xmlns="{NS_SITEMAP}" xmlns:news="{NS_NEWS}">'181 )182 for item in items:183 published = _parse_iso(item.get("published"))184 if published is None or published < cutoff:185 # Outside the 48-hour window -> excluded from the news sitemap.186 continue187 lines.append(" <url>")188 lines.append(f" <loc>{escape(item['loc'])}</loc>")189 lines.append(" <news:news>")190 lines.append(" <news:publication>")191 lines.append(f" <news:name>{escape(publication_name)}</news:name>")192 lines.append(f" <news:language>{escape(language)}</news:language>")193 lines.append(" </news:publication>")194 lines.append(195 f" <news:publication_date>{escape(item['published'])}"196 "</news:publication_date>"197 )198 lines.append(f" <news:title>{escape(item.get('title', ''))}</news:title>")199 lines.append(" </news:news>")200 lines.append(" </url>")201 lines.append("</urlset>")202 return "\n".join(lines) + "\n"203204205def build_sitemap_index(sitemap_urls: List[str], lastmod: str) -> str:206 """Build the top-level <sitemapindex> referencing every type sitemap."""207 lines = ['<?xml version="1.0" encoding="UTF-8"?>']208 lines.append(f'<sitemapindex xmlns="{NS_SITEMAP}">')209 for url in sitemap_urls:210 lines.append(" <sitemap>")211 lines.append(f" <loc>{escape(url)}</loc>")212 lines.append(f" <lastmod>{escape(lastmod)}</lastmod>")213 lines.append(" </sitemap>")214 lines.append("</sitemapindex>")215 return "\n".join(lines) + "\n"216217218def _parse_iso(value: Optional[str]) -> Optional[datetime]:219 """Best-effort ISO-8601 parser that always returns an aware UTC datetime."""220 if not value:221 return None222 try:223 parsed = datetime.fromisoformat(value)224 except ValueError:225 return None226 if parsed.tzinfo is None:227 parsed = parsed.replace(tzinfo=timezone.utc)228 return parsed.astimezone(timezone.utc)229230231# ---------------------------------------------------------------------------232# Orchestration233# ---------------------------------------------------------------------------234def generate(235 base_url: str,236 output_dir: Path,237 types: List[str],238 ttl_overrides: Dict[str, int],239 publication_name: str,240 language: str,241 force: bool = False,242) -> None:243 """Generate the per-type sitemaps (honouring TTLs) and the sitemap index."""244 output_dir.mkdir(parents=True, exist_ok=True)245 state_path = output_dir / STATE_FILE_NAME246 state = load_state(state_path)247248 sitemap_urls: List[str] = []249 base = base_url.rstrip("/")250251 for item_type in types:252 ttl = ttl_overrides.get(item_type, DEFAULT_TTL_SECONDS.get(item_type, 3600))253 filename = f"sitemap-{item_type}.xml"254 out_path = output_dir / filename255 public_url = f"{base}/{filename}"256 sitemap_urls.append(public_url)257258 # TTL cache decision: skip rebuild when still fresh (unless forced).259 if not force and out_path.exists() and not is_ttl_expired(state, item_type, ttl):260 print(f"[cached] {filename} (TTL not expired)")261 continue262263 items = fetch_items(item_type, base_url)264 if item_type == "news":265 xml = build_news_urlset(items, publication_name, language)266 kept = xml.count("<url>")267 print(268 f"[built] {filename} ({len(items)} source items, "269 f"{kept} within 48h window)"270 )271 else:272 xml = build_urlset(items)273 print(f"[built] {filename} ({len(items)} source items)")274275 out_path.write_text(xml, encoding="utf-8")276 state[item_type] = now_utc().timestamp()277278 # The index is always rewritten so it references the current set of files.279 index_xml = build_sitemap_index(280 sitemap_urls, lastmod=now_utc().strftime("%Y-%m-%d")281 )282 (output_dir / "sitemap.xml").write_text(index_xml, encoding="utf-8")283 print(f"[index] sitemap.xml ({len(sitemap_urls)} sitemaps)")284285 save_state(state_path, state)286287288def parse_args(argv: List[str]) -> argparse.Namespace:289 parser = argparse.ArgumentParser(290 description="Generate typed sitemaps + a sitemap index with TTL cache."291 )292 parser.add_argument(293 "--base-url",294 default=os.environ.get("SITE_BASE_URL", "https://www.example.com"),295 help="Site base URL used to build absolute locations.",296 )297 parser.add_argument(298 "--out",299 dest="out",300 default=os.environ.get("SITEMAP_OUTPUT", "out"),301 help="Directory to write the sitemap files into.",302 )303 parser.add_argument(304 "--types",305 default="pages,posts,products,localities,news",306 help="Comma-separated list of sitemap types to build.",307 )308 parser.add_argument(309 "--publication-name",310 default=os.environ.get("NEWS_PUBLICATION_NAME", "Sua Publicacao"),311 help="Publication name used in the news sitemap.",312 )313 parser.add_argument(314 "--language",315 default=os.environ.get("NEWS_LANGUAGE", "pt"),316 help="Language code used in the news sitemap.",317 )318 parser.add_argument(319 "--force",320 action="store_true",321 help="Rebuild every sitemap regardless of TTL.",322 )323 return parser.parse_args(argv)324325326def main(argv: List[str]) -> int:327 args = parse_args(argv)328 types = [t.strip() for t in args.types.split(",") if t.strip()]329 generate(330 base_url=args.base_url,331 output_dir=Path(args.out),332 types=types,333 ttl_overrides={}, # TODO: load per-type TTL overrides here if desired.334 publication_name=args.publication_name,335 language=args.language,336 force=args.force,337 )338 return 0339340341if __name__ == "__main__":342 raise SystemExit(main(sys.argv[1:]))
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'.