Gerador de páginas por localidade
Uma landing page de SEO por localidade, a partir de um CSV (ou SQLite). Renderiza com Jinja2, usa o código IBGE como chave primária e só regera as páginas cujo conteúdo mudou.
Rode em 3 comandos
$ pip install jinja2
$ python3 generate.py --source csv --input sample-localities.csv --out out/
$ ls out/Saída esperada
Renderer: Jinja2
[generated] out/vila-aurora.html
[generated] out/porto-sereno.html
[generated] out/monte-claro.html
[generated] out/rio-das-pedras.html
[generated] out/campo-belo.html
[generated] out/lagoa-azul.html
[generated] out/alto-da-boa-vista.html
[generated] out/nova-esperanca.html
[generated] out/ponte-alta.html
[generated] out/ribeirao-doce.html
Done. 10 generated, 0 unchanged (skipped).Código do módulo
1#!/usr/bin/env python32"""Chapter 20 - Per-locality SEO landing page generator.34Generates one SEO landing page per locality read from EITHER a CSV file or a5SQLite database. The "official key" (e.g. the Brazilian IBGE municipality code)6is used as the primary key that uniquely identifies each locality.78Key features:9 * Two interchangeable data sources selected with ``--source csv|sqlite``.10 * Jinja2 templates for rendering the HTML pages (with a built-in minimal11 fallback renderer so the script always runs, even without Jinja2 installed).12 * INCREMENTAL regeneration: a content hash is stored per locality so that only13 pages whose underlying data actually changed are rewritten on disk.1415This file is part of the "living code" companion repository for the book16"Aparecer ou Sumir" / "Be Seen or Be Forgotten".17"""1819from __future__ import annotations2021import argparse22import csv23import hashlib24import json25import os26import re27import sqlite328import sys29from dataclasses import dataclass, asdict30from pathlib import Path31from typing import Dict, Iterable, List3233# ---------------------------------------------------------------------------34# Jinja2 is the preferred renderer. We guard the import so that, if it is not35# installed, the script degrades to a tiny built-in template engine instead of36# crashing. For production output you should install Jinja2 (pip install Jinja2).37# ---------------------------------------------------------------------------38try:39 from jinja2 import Environment, FileSystemLoader, select_autoescape4041 HAVE_JINJA2 = True42except ImportError: # pragma: no cover - environment dependent43 HAVE_JINJA2 = False444546# Directory that holds the Jinja2 templates, relative to this file.47TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"48TEMPLATE_NAME = "locality.html.j2"4950# Name of the state file used for incremental regeneration when the CSV source51# is used. For the SQLite source we store the hash inside the database itself.52STATE_FILE_NAME = ".locality_state.json"535455@dataclass56class Locality:57 """A single locality (municipality/city) to be rendered as a landing page.5859 ``ibge_code`` is the official key and doubles as the primary key.60 """6162 ibge_code: str63 name: str64 region: str65 slug: str6667 def content_hash(self) -> str:68 """Return a stable hash of the fields that affect the rendered page.6970 The hash is what drives incremental regeneration: if any of these fields71 change, the hash changes, and the page is rebuilt. If nothing changes,72 the hash stays the same and we skip the expensive render + write.73 """74 payload = json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)75 return hashlib.sha256(payload.encode("utf-8")).hexdigest()767778# ---------------------------------------------------------------------------79# Data source readers80# ---------------------------------------------------------------------------81def read_localities_csv(csv_path: Path) -> List[Locality]:82 """Read localities from a CSV file.8384 Expected columns: ``ibge_code,name,region,slug``.85 """86 localities: List[Locality] = []87 with csv_path.open(newline="", encoding="utf-8") as handle:88 reader = csv.DictReader(handle)89 for row in reader:90 localities.append(91 Locality(92 ibge_code=row["ibge_code"].strip(),93 name=row["name"].strip(),94 region=row["region"].strip(),95 slug=row["slug"].strip(),96 )97 )98 return localities99100101def read_localities_sqlite(db_path: Path) -> List[Locality]:102 """Read localities from a SQLite database.103104 Expects a ``localities`` table with columns105 ``ibge_code, name, region, slug`` (ibge_code is the PRIMARY KEY).106 """107 localities: List[Locality] = []108 conn = sqlite3.connect(str(db_path))109 try:110 conn.row_factory = sqlite3.Row111 cur = conn.execute(112 "SELECT ibge_code, name, region, slug FROM localities"113 )114 for row in cur.fetchall():115 localities.append(116 Locality(117 ibge_code=str(row["ibge_code"]).strip(),118 name=row["name"].strip(),119 region=row["region"].strip(),120 slug=row["slug"].strip(),121 )122 )123 finally:124 conn.close()125 return localities126127128# ---------------------------------------------------------------------------129# Incremental state handling130# ---------------------------------------------------------------------------131def load_state_csv(state_path: Path) -> Dict[str, str]:132 """Load the {ibge_code: content_hash} map from the JSON state file."""133 if state_path.exists():134 with state_path.open(encoding="utf-8") as handle:135 return json.load(handle)136 return {}137138139def save_state_csv(state_path: Path, state: Dict[str, str]) -> None:140 """Persist the {ibge_code: content_hash} map to the JSON state file."""141 with state_path.open("w", encoding="utf-8") as handle:142 json.dump(state, handle, indent=2, ensure_ascii=False)143144145def ensure_state_table_sqlite(db_path: Path) -> None:146 """Create the ``locality_state`` table if it does not exist yet."""147 conn = sqlite3.connect(str(db_path))148 try:149 conn.execute(150 """151 CREATE TABLE IF NOT EXISTS locality_state (152 ibge_code TEXT PRIMARY KEY,153 content_hash TEXT NOT NULL154 )155 """156 )157 conn.commit()158 finally:159 conn.close()160161162def load_state_sqlite(db_path: Path) -> Dict[str, str]:163 """Load the {ibge_code: content_hash} map from the SQLite state table."""164 ensure_state_table_sqlite(db_path)165 conn = sqlite3.connect(str(db_path))166 try:167 cur = conn.execute("SELECT ibge_code, content_hash FROM locality_state")168 return {str(code): h for code, h in cur.fetchall()}169 finally:170 conn.close()171172173def save_state_sqlite(db_path: Path, state: Dict[str, str]) -> None:174 """Persist the {ibge_code: content_hash} map to the SQLite state table."""175 ensure_state_table_sqlite(db_path)176 conn = sqlite3.connect(str(db_path))177 try:178 conn.executemany(179 "INSERT INTO locality_state (ibge_code, content_hash) "180 "VALUES (?, ?) "181 "ON CONFLICT(ibge_code) DO UPDATE SET content_hash=excluded.content_hash",182 list(state.items()),183 )184 conn.commit()185 finally:186 conn.close()187188189# ---------------------------------------------------------------------------190# Rendering191# ---------------------------------------------------------------------------192def _render_context(locality: Locality, base_url: str) -> Dict[str, str]:193 """Build the flat context shared by both the Jinja2 and fallback renderers."""194 base = base_url.rstrip("/")195 return {196 "locality.ibge_code": locality.ibge_code,197 "locality.name": locality.name,198 "locality.region": locality.region,199 "locality.slug": locality.slug,200 "base_url": base,201 "canonical_url": f"{base}/{locality.slug}",202 }203204205def _minimal_render(template_text: str, context: Dict[str, str]) -> str:206 """Tiny stand-in for Jinja2 used only when Jinja2 is not installed.207208 Supports ``{{ var }}`` substitution and strips ``{# comments #}``. This is a209 deliberately minimal fallback so the script still produces output; install210 Jinja2 for full template features (autoescape, control flow, etc.).211 """212 text = re.sub(r"\{#.*?#\}", "", template_text, flags=re.DOTALL)213214 def replace(match: "re.Match[str]") -> str:215 key = match.group(1).strip()216 return str(context.get(key, ""))217218 return re.sub(r"\{\{\s*(.*?)\s*\}\}", replace, text)219220221def build_environment() -> "Environment":222 """Build the Jinja2 environment pointed at the templates directory."""223 return Environment(224 loader=FileSystemLoader(str(TEMPLATES_DIR)),225 autoescape=select_autoescape(["html", "xml", "j2"]),226 trim_blocks=True,227 lstrip_blocks=True,228 )229230231def render_locality(env, locality: Locality, base_url: str) -> str:232 """Render a single locality landing page to an HTML string.233234 Uses Jinja2 when available (``env`` is a real Environment); otherwise falls235 back to the built-in minimal renderer so the script keeps working.236 """237 if env is not None:238 template = env.get_template(TEMPLATE_NAME)239 base = base_url.rstrip("/")240 return template.render(241 locality=locality,242 base_url=base,243 canonical_url=f"{base}/{locality.slug}",244 )245246 template_text = (TEMPLATES_DIR / TEMPLATE_NAME).read_text(encoding="utf-8")247 return _minimal_render(template_text, _render_context(locality, base_url))248249250# ---------------------------------------------------------------------------251# Orchestration252# ---------------------------------------------------------------------------253def generate(254 localities: Iterable[Locality],255 output_dir: Path,256 base_url: str,257 previous_state: Dict[str, str],258 force: bool = False,259) -> Dict[str, str]:260 """Generate pages incrementally and return the updated state map.261262 INCREMENTAL LOGIC:263 For each locality we compute its content hash. If ``force`` is False and264 the stored hash equals the freshly computed hash, the locality's data has265 not changed since the last run, so we SKIP rendering + writing that page.266 Otherwise we (re)render it and record the new hash. The returned map fully267 replaces the previous state so removed localities also drop out.268 """269 output_dir.mkdir(parents=True, exist_ok=True)270 env = build_environment() if HAVE_JINJA2 else None271272 renderer = "Jinja2" if HAVE_JINJA2 else "built-in fallback (install Jinja2 for full features)"273 print(f"Renderer: {renderer}")274275 new_state: Dict[str, str] = {}276 generated = 0277 skipped = 0278279 for locality in localities:280 current_hash = locality.content_hash()281 new_state[locality.ibge_code] = current_hash282283 # Skip when the hash is unchanged (incremental fast path).284 if not force and previous_state.get(locality.ibge_code) == current_hash:285 skipped += 1286 continue287288 html = render_locality(env, locality, base_url)289 page_path = output_dir / f"{locality.slug}.html"290 page_path.write_text(html, encoding="utf-8")291 generated += 1292 print(f"[generated] {page_path}")293294 print(f"\nDone. {generated} generated, {skipped} unchanged (skipped).")295 return new_state296297298def parse_args(argv: List[str]) -> argparse.Namespace:299 parser = argparse.ArgumentParser(300 description="Generate one SEO landing page per locality."301 )302 parser.add_argument(303 "--source",304 choices=["csv", "sqlite"],305 default="csv",306 help="Where to read localities from (default: csv).",307 )308 parser.add_argument(309 "--input",310 default=os.environ.get("LOCALITIES_INPUT", "sample-localities.csv"),311 help="Path to the CSV file or SQLite database.",312 )313 parser.add_argument(314 "--out",315 dest="out",316 default=os.environ.get("LOCALITIES_OUTPUT", "out"),317 help="Directory to write generated pages into.",318 )319 parser.add_argument(320 "--base-url",321 default=os.environ.get("SITE_BASE_URL", "https://www.example.com/localidades"),322 help="Base URL used for canonical links and JSON-LD.",323 )324 parser.add_argument(325 "--force",326 action="store_true",327 help="Regenerate everything, ignoring the incremental state.",328 )329 return parser.parse_args(argv)330331332def main(argv: List[str]) -> int:333 args = parse_args(argv)334 input_path = Path(args.input)335 output_dir = Path(args.out)336337 if not input_path.exists():338 sys.stderr.write(f"ERROR: input not found: {input_path}\n")339 return 2340341 # Load data + previous state depending on the chosen source.342 if args.source == "csv":343 localities = read_localities_csv(input_path)344 output_dir.mkdir(parents=True, exist_ok=True)345 previous_state = load_state_csv(output_dir / STATE_FILE_NAME)346 else:347 localities = read_localities_sqlite(input_path)348 previous_state = load_state_sqlite(input_path)349350 new_state = generate(351 localities=localities,352 output_dir=output_dir,353 base_url=args.base_url,354 previous_state=previous_state,355 force=args.force,356 )357358 # Persist the updated state so the next run is incremental.359 if args.source == "csv":360 save_state_csv(output_dir / STATE_FILE_NAME, new_state)361 else:362 save_state_sqlite(input_path, new_state)363364 return 0365366367if __name__ == "__main__":368 raise SystemExit(main(sys.argv[1:]))
1<!DOCTYPE html>2<html lang="pt-BR">3<head>4 <meta charset="utf-8">5 <meta name="viewport" content="width=device-width, initial-scale=1">6 <title>{{ locality.name }} - {{ locality.region }} | Atendimento local</title>7 <meta name="description"8 content="Servicos e atendimento em {{ locality.name }}, {{ locality.region }}. Conheca nossas solucoes locais.">9 <link rel="canonical" href="{{ canonical_url }}">1011 {# JSON-LD LocalBusiness block built entirely from template variables. #}12 <script type="application/ld+json">13 {14 "@context": "https://schema.org",15 "@type": "LocalBusiness",16 "@id": "{{ canonical_url }}#business",17 "name": "Sua Empresa - {{ locality.name }}",18 "url": "{{ canonical_url }}",19 "areaServed": {20 "@type": "City",21 "name": "{{ locality.name }}",22 "containedInPlace": {23 "@type": "AdministrativeArea",24 "name": "{{ locality.region }}"25 }26 },27 "identifier": {28 "@type": "PropertyValue",29 "propertyID": "IBGE",30 "value": "{{ locality.ibge_code }}"31 }32 }33 </script>34</head>35<body>36 <header>37 <h1>Atendimento em {{ locality.name }} - {{ locality.region }}</h1>38 </header>3940 <main>41 <p class="intro">42 Voce esta em {{ locality.name }}, na regiao de {{ locality.region }}.43 Oferecemos solucoes pensadas para quem vive e trabalha em44 {{ locality.name }} (codigo IBGE {{ locality.ibge_code }}).45 </p>4647 {# TODO: Replace this placeholder body with real, locality-specific copy. #}48 <section class="body">49 <p>50 [Conteudo placeholder] Descreva aqui os servicos, diferenciais e provas51 sociais relevantes para {{ locality.name }}. Inclua depoimentos locais,52 enderecos e horarios quando disponiveis.53 </p>54 </section>55 </main>5657 <footer>58 <p>© Sua Empresa - {{ locality.name }}</p>59 </footer>60</body>61</html>
1ibge_code,name,region,slug29900001,Vila Aurora,Vale do Sol,vila-aurora39900002,Porto Sereno,Litoral Norte,porto-sereno49900003,Monte Claro,Serra Verde,monte-claro59900004,Rio das Pedras,Vale do Sol,rio-das-pedras69900005,Campo Belo,Planalto Central,campo-belo79900006,Lagoa Azul,Litoral Norte,lagoa-azul89900007,Alto da Boa Vista,Serra Verde,alto-da-boa-vista99900008,Nova Esperanca,Sertao Novo,nova-esperanca109900009,Ponte Alta,Planalto Central,ponte-alta119900010,Ribeirao Doce,Vale do Sol,ribeirao-doce
Sync com Google Business Profile — requer API
Este módulo mostra a estrutura do sync com a API do Google Business Profile. As funções de rede são stubs documentados (levantam NotImplementedError). Configure as credenciais via variáveis de ambiente (GBP_CLIENT_SECRETS ou GBP_SERVICE_ACCOUNT_FILE e GBP_ACCOUNT_ID) e implemente o fluxo OAuth / service-account descrito nas docstrings.
1#!/usr/bin/env python32"""Chapter 20 - Google Business Profile (GBP) sync - STRUCTURE ONLY.34This module shows how a Google Business Profile synchronisation script would be5organised. It pushes/updates location information (name, address, categories,6service area) to the Google Business Profile API.78Because the real API requires OAuth 2.0 (or a service account with domain-wide9delegation), the network-dependent functions are provided as documented stubs.10Fill in the TODO markers with your own credentials before using it for real.1112The module is intentionally importable and compilable: the stubs raise13``NotImplementedError`` with a helpful message rather than doing real I/O.1415Reference: https://developers.google.com/my-business16"""1718from __future__ import annotations1920import os21import sys22from typing import Any, Dict, List232425# ---------------------------------------------------------------------------26# Configuration is read from environment variables so no secret ever lands in27# source control. TODO: export these in your shell / CI secret store.28# ---------------------------------------------------------------------------29# TODO: Path to your OAuth client secrets JSON downloaded from Google Cloud.30GBP_CLIENT_SECRETS = os.environ.get("GBP_CLIENT_SECRETS", "")31# TODO: Path to a service-account key JSON (alternative to OAuth user flow).32GBP_SERVICE_ACCOUNT_FILE = os.environ.get("GBP_SERVICE_ACCOUNT_FILE", "")33# TODO: The numeric Business Profile account id, e.g. "accounts/1234567890".34GBP_ACCOUNT_ID = os.environ.get("GBP_ACCOUNT_ID", "")3536# OAuth scope required to manage Business Profile locations.37GBP_SCOPES = ["https://www.googleapis.com/auth/business.manage"]383940def authenticate() -> Any:41 """Authenticate against the Google Business Profile API.4243 Returns an authorised API client / service object.4445 TODO:46 1. pip install google-api-python-client google-auth google-auth-oauthlib47 2. Choose ONE credential strategy:48 * OAuth user flow using ``GBP_CLIENT_SECRETS`` (interactive), OR49 * Service account using ``GBP_SERVICE_ACCOUNT_FILE`` with domain-wide50 delegation.51 3. Build the service, e.g.::5253 from googleapiclient.discovery import build54 from google.oauth2.service_account import Credentials55 creds = Credentials.from_service_account_file(56 GBP_SERVICE_ACCOUNT_FILE, scopes=GBP_SCOPES)57 return build("mybusinessbusinessinformation", "v1",58 credentials=creds)59 """60 raise NotImplementedError(61 "authenticate() is a stub. Provide credentials via GBP_CLIENT_SECRETS "62 "or GBP_SERVICE_ACCOUNT_FILE and implement the OAuth/service-account "63 "flow. See the docstring for the exact steps."64 )656667def list_locations(service: Any, account_id: str = GBP_ACCOUNT_ID) -> List[Dict[str, Any]]:68 """List all locations under a Business Profile account.6970 Args:71 service: authorised client returned by :func:`authenticate`.72 account_id: the ``accounts/{id}`` resource name.7374 Returns:75 A list of location resource dicts.7677 TODO: Call the ``accounts.locations.list`` endpoint and handle pagination78 via the ``nextPageToken`` field.79 """80 raise NotImplementedError(81 "list_locations() is a stub. Implement the accounts.locations.list "82 "call and pagination. Ensure GBP_ACCOUNT_ID is set."83 )848586def update_location(service: Any, location_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:87 """Update a single location's information.8889 Args:90 service: authorised client returned by :func:`authenticate`.91 location_name: the ``locations/{id}`` resource name to update.92 payload: partial location resource with the fields to change93 (e.g. ``title``, ``storefrontAddress``, ``categories``).9495 Returns:96 The updated location resource dict.9798 TODO: Call ``locations.patch`` with an ``updateMask`` listing exactly the99 fields you are changing, otherwise the API rejects the request.100 """101 raise NotImplementedError(102 "update_location() is a stub. Implement locations.patch with a proper "103 "updateMask. Never send fields you are not intentionally updating."104 )105106107def main(argv: List[str]) -> int:108 """Guarded entry point.109110 Verifies that the minimum configuration is present before attempting any111 (currently stubbed) API work. This keeps the module safe to run without112 credentials - it exits cleanly with guidance instead of crashing.113 """114 missing = []115 if not (GBP_CLIENT_SECRETS or GBP_SERVICE_ACCOUNT_FILE):116 missing.append("GBP_CLIENT_SECRETS or GBP_SERVICE_ACCOUNT_FILE")117 if not GBP_ACCOUNT_ID:118 missing.append("GBP_ACCOUNT_ID")119120 if missing:121 sys.stderr.write(122 "GBP sync is not configured. Set the following environment "123 "variables first:\n - " + "\n - ".join(missing) + "\n"124 )125 return 1126127 # Real workflow (once the stubs above are implemented):128 service = authenticate()129 locations = list_locations(service)130 for loc in locations:131 # TODO: build the per-location payload from your own data source.132 update_location(service, loc["name"], payload={})133 return 0134135136if __name__ == "__main__":137 raise SystemExit(main(sys.argv[1:]))
Perguntas frequentes
Preciso instalar o Jinja2 para rodar?
O ideal é sim: pip install jinja2. Mas o generate.py degrada para um renderizador mínimo embutido caso o Jinja2 não esteja disponível, então ele sempre produz páginas — só sem os recursos completos do Jinja2.
Como funciona a regeneração incremental?
Cada localidade tem um hash de conteúdo (SHA-256 dos campos). O estado fica em out/.locality_state.json (fonte CSV) ou na tabela locality_state (fonte SQLite). Na próxima execução, só as localidades cujo hash mudou são regeradas. Use --force para reconstruir tudo.
Por que o código IBGE é a chave primária?
O código IBGE identifica cada município de forma única e estável, mesmo quando o nome muda de grafia. Usá-lo como chave primária evita páginas duplicadas e mantém as URLs consistentes ao longo do tempo.