SYS ONLINESVR sao-paulo-01EDITION 02/2026▓▒░ ░▒▓UTC-3

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

generate.py
1#!/usr/bin/env python3
2"""Chapter 20 - Per-locality SEO landing page generator.
3
4Generates one SEO landing page per locality read from EITHER a CSV file or a
5SQLite database. The "official key" (e.g. the Brazilian IBGE municipality code)
6is used as the primary key that uniquely identifies each locality.
7
8Key features:
9 * Two interchangeable data sources selected with ``--source csv|sqlite``.
10 * Jinja2 templates for rendering the HTML pages (with a built-in minimal
11 fallback renderer so the script always runs, even without Jinja2 installed).
12 * INCREMENTAL regeneration: a content hash is stored per locality so that only
13 pages whose underlying data actually changed are rewritten on disk.
14
15This file is part of the "living code" companion repository for the book
16"Aparecer ou Sumir" / "Be Seen or Be Forgotten".
17"""
18
19from __future__ import annotations
20
21import argparse
22import csv
23import hashlib
24import json
25import os
26import re
27import sqlite3
28import sys
29from dataclasses import dataclass, asdict
30from pathlib import Path
31from typing import Dict, Iterable, List
32
33# ---------------------------------------------------------------------------
34# Jinja2 is the preferred renderer. We guard the import so that, if it is not
35# installed, the script degrades to a tiny built-in template engine instead of
36# crashing. For production output you should install Jinja2 (pip install Jinja2).
37# ---------------------------------------------------------------------------
38try:
39 from jinja2 import Environment, FileSystemLoader, select_autoescape
40
41 HAVE_JINJA2 = True
42except ImportError: # pragma: no cover - environment dependent
43 HAVE_JINJA2 = False
44
45
46# Directory that holds the Jinja2 templates, relative to this file.
47TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
48TEMPLATE_NAME = "locality.html.j2"
49
50# Name of the state file used for incremental regeneration when the CSV source
51# is used. For the SQLite source we store the hash inside the database itself.
52STATE_FILE_NAME = ".locality_state.json"
53
54
55@dataclass
56class Locality:
57 """A single locality (municipality/city) to be rendered as a landing page.
58
59 ``ibge_code`` is the official key and doubles as the primary key.
60 """
61
62 ibge_code: str
63 name: str
64 region: str
65 slug: str
66
67 def content_hash(self) -> str:
68 """Return a stable hash of the fields that affect the rendered page.
69
70 The hash is what drives incremental regeneration: if any of these fields
71 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()
76
77
78# ---------------------------------------------------------------------------
79# Data source readers
80# ---------------------------------------------------------------------------
81def read_localities_csv(csv_path: Path) -> List[Locality]:
82 """Read localities from a CSV file.
83
84 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 localities
99
100
101def read_localities_sqlite(db_path: Path) -> List[Locality]:
102 """Read localities from a SQLite database.
103
104 Expects a ``localities`` table with columns
105 ``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.Row
111 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 localities
126
127
128# ---------------------------------------------------------------------------
129# Incremental state handling
130# ---------------------------------------------------------------------------
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 {}
137
138
139def 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)
143
144
145def 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 NULL
154 )
155 """
156 )
157 conn.commit()
158 finally:
159 conn.close()
160
161
162def 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()
171
172
173def 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()
187
188
189# ---------------------------------------------------------------------------
190# Rendering
191# ---------------------------------------------------------------------------
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 }
203
204
205def _minimal_render(template_text: str, context: Dict[str, str]) -> str:
206 """Tiny stand-in for Jinja2 used only when Jinja2 is not installed.
207
208 Supports ``{{ var }}`` substitution and strips ``{# comments #}``. This is a
209 deliberately minimal fallback so the script still produces output; install
210 Jinja2 for full template features (autoescape, control flow, etc.).
211 """
212 text = re.sub(r"\{#.*?#\}", "", template_text, flags=re.DOTALL)
213
214 def replace(match: "re.Match[str]") -> str:
215 key = match.group(1).strip()
216 return str(context.get(key, ""))
217
218 return re.sub(r"\{\{\s*(.*?)\s*\}\}", replace, text)
219
220
221def 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 )
229
230
231def render_locality(env, locality: Locality, base_url: str) -> str:
232 """Render a single locality landing page to an HTML string.
233
234 Uses Jinja2 when available (``env`` is a real Environment); otherwise falls
235 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 )
245
246 template_text = (TEMPLATES_DIR / TEMPLATE_NAME).read_text(encoding="utf-8")
247 return _minimal_render(template_text, _render_context(locality, base_url))
248
249
250# ---------------------------------------------------------------------------
251# Orchestration
252# ---------------------------------------------------------------------------
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.
261
262 INCREMENTAL LOGIC:
263 For each locality we compute its content hash. If ``force`` is False and
264 the stored hash equals the freshly computed hash, the locality's data has
265 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 fully
267 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 None
271
272 renderer = "Jinja2" if HAVE_JINJA2 else "built-in fallback (install Jinja2 for full features)"
273 print(f"Renderer: {renderer}")
274
275 new_state: Dict[str, str] = {}
276 generated = 0
277 skipped = 0
278
279 for locality in localities:
280 current_hash = locality.content_hash()
281 new_state[locality.ibge_code] = current_hash
282
283 # Skip when the hash is unchanged (incremental fast path).
284 if not force and previous_state.get(locality.ibge_code) == current_hash:
285 skipped += 1
286 continue
287
288 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 += 1
292 print(f"[generated] {page_path}")
293
294 print(f"\nDone. {generated} generated, {skipped} unchanged (skipped).")
295 return new_state
296
297
298def 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)
330
331
332def main(argv: List[str]) -> int:
333 args = parse_args(argv)
334 input_path = Path(args.input)
335 output_dir = Path(args.out)
336
337 if not input_path.exists():
338 sys.stderr.write(f"ERROR: input not found: {input_path}\n")
339 return 2
340
341 # 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)
349
350 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 )
357
358 # 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)
363
364 return 0
365
366
367if __name__ == "__main__":
368 raise SystemExit(main(sys.argv[1:]))
templates/locality.html.j2
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 }}">
10
11 {# 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>
39
40 <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 em
44 {{ locality.name }} (codigo IBGE {{ locality.ibge_code }}).
45 </p>
46
47 {# 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 provas
51 sociais relevantes para {{ locality.name }}. Inclua depoimentos locais,
52 enderecos e horarios quando disponiveis.
53 </p>
54 </section>
55 </main>
56
57 <footer>
58 <p>&copy; Sua Empresa - {{ locality.name }}</p>
59 </footer>
60</body>
61</html>
sample-localities.csv
1ibge_code,name,region,slug
29900001,Vila Aurora,Vale do Sol,vila-aurora
39900002,Porto Sereno,Litoral Norte,porto-sereno
49900003,Monte Claro,Serra Verde,monte-claro
59900004,Rio das Pedras,Vale do Sol,rio-das-pedras
69900005,Campo Belo,Planalto Central,campo-belo
79900006,Lagoa Azul,Litoral Norte,lagoa-azul
89900007,Alto da Boa Vista,Serra Verde,alto-da-boa-vista
99900008,Nova Esperanca,Sertao Novo,nova-esperanca
109900009,Ponte Alta,Planalto Central,ponte-alta
119900010,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.

gbp_sync.py
1#!/usr/bin/env python3
2"""Chapter 20 - Google Business Profile (GBP) sync - STRUCTURE ONLY.
3
4This module shows how a Google Business Profile synchronisation script would be
5organised. It pushes/updates location information (name, address, categories,
6service area) to the Google Business Profile API.
7
8Because the real API requires OAuth 2.0 (or a service account with domain-wide
9delegation), the network-dependent functions are provided as documented stubs.
10Fill in the TODO markers with your own credentials before using it for real.
11
12The module is intentionally importable and compilable: the stubs raise
13``NotImplementedError`` with a helpful message rather than doing real I/O.
14
15Reference: https://developers.google.com/my-business
16"""
17
18from __future__ import annotations
19
20import os
21import sys
22from typing import Any, Dict, List
23
24
25# ---------------------------------------------------------------------------
26# Configuration is read from environment variables so no secret ever lands in
27# 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", "")
35
36# OAuth scope required to manage Business Profile locations.
37GBP_SCOPES = ["https://www.googleapis.com/auth/business.manage"]
38
39
40def authenticate() -> Any:
41 """Authenticate against the Google Business Profile API.
42
43 Returns an authorised API client / service object.
44
45 TODO:
46 1. pip install google-api-python-client google-auth google-auth-oauthlib
47 2. Choose ONE credential strategy:
48 * OAuth user flow using ``GBP_CLIENT_SECRETS`` (interactive), OR
49 * Service account using ``GBP_SERVICE_ACCOUNT_FILE`` with domain-wide
50 delegation.
51 3. Build the service, e.g.::
52
53 from googleapiclient.discovery import build
54 from google.oauth2.service_account import Credentials
55 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 )
65
66
67def list_locations(service: Any, account_id: str = GBP_ACCOUNT_ID) -> List[Dict[str, Any]]:
68 """List all locations under a Business Profile account.
69
70 Args:
71 service: authorised client returned by :func:`authenticate`.
72 account_id: the ``accounts/{id}`` resource name.
73
74 Returns:
75 A list of location resource dicts.
76
77 TODO: Call the ``accounts.locations.list`` endpoint and handle pagination
78 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 )
84
85
86def update_location(service: Any, location_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
87 """Update a single location's information.
88
89 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 change
93 (e.g. ``title``, ``storefrontAddress``, ``categories``).
94
95 Returns:
96 The updated location resource dict.
97
98 TODO: Call ``locations.patch`` with an ``updateMask`` listing exactly the
99 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 )
105
106
107def main(argv: List[str]) -> int:
108 """Guarded entry point.
109
110 Verifies that the minimum configuration is present before attempting any
111 (currently stubbed) API work. This keeps the module safe to run without
112 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")
119
120 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 1
126
127 # 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 0
134
135
136if __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.