Pular para o conteúdo

Cap. 20 · Código

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.
Python 3 · Jinja2 · CSV ou SQLite

Execução

Rode em 3 comandos

terminal
$ pip install jinja2
$ python3 generate.py --source csv --input sample-localities.csv --out out/
$ ls out/

Saída esperada

stdout
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-fonte

Código do módulo

generate.py
#!/usr/bin/env python3
"""Chapter 20 - Per-locality SEO landing page generator.
Generates one SEO landing page per locality read from EITHER a CSV file or a
SQLite database. The "official key" (e.g. the Brazilian IBGE municipality code)
is used as the primary key that uniquely identifies each locality.
Key features:
* Two interchangeable data sources selected with ``--source csv|sqlite``.
* Jinja2 templates for rendering the HTML pages (with a built-in minimal
fallback renderer so the script always runs, even without Jinja2 installed).
* INCREMENTAL regeneration: a content hash is stored per locality so that only
pages whose underlying data actually changed are rewritten on disk.
This file is 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 csv
import hashlib
import json
import os
import re
import sqlite3
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Dict, Iterable, List
# ---------------------------------------------------------------------------
# Jinja2 is the preferred renderer. We guard the import so that, if it is not
# installed, the script degrades to a tiny built-in template engine instead of
# crashing. For production output you should install Jinja2 (pip install Jinja2).
# ---------------------------------------------------------------------------
try:
from jinja2 import Environment, FileSystemLoader, select_autoescape
HAVE_JINJA2 = True
except ImportError: # pragma: no cover - environment dependent
HAVE_JINJA2 = False
# Directory that holds the Jinja2 templates, relative to this file.
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
TEMPLATE_NAME = "locality.html.j2"
# Name of the state file used for incremental regeneration when the CSV source
# is used. For the SQLite source we store the hash inside the database itself.
STATE_FILE_NAME = ".locality_state.json"
@dataclass
class Locality:
"""A single locality (municipality/city) to be rendered as a landing page.
``ibge_code`` is the official key and doubles as the primary key.
"""
ibge_code: str
name: str
region: str
slug: str
def content_hash(self) -> str:
"""Return a stable hash of the fields that affect the rendered page.
The hash is what drives incremental regeneration: if any of these fields
change, the hash changes, and the page is rebuilt. If nothing changes,
the hash stays the same and we skip the expensive render + write.
"""
payload = json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Data source readers
# ---------------------------------------------------------------------------
def read_localities_csv(csv_path: Path) -> List[Locality]:
"""Read localities from a CSV file.
Expected columns: ``ibge_code,name,region,slug``.
"""
localities: List[Locality] = []
with csv_path.open(newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
for row in reader:
localities.append(
Locality(
ibge_code=row["ibge_code"].strip(),
name=row["name"].strip(),
region=row["region"].strip(),
slug=row["slug"].strip(),
)
)
return localities
def read_localities_sqlite(db_path: Path) -> List[Locality]:
"""Read localities from a SQLite database.
Expects a ``localities`` table with columns
``ibge_code, name, region, slug`` (ibge_code is the PRIMARY KEY).
"""
localities: List[Locality] = []
conn = sqlite3.connect(str(db_path))
try:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT ibge_code, name, region, slug FROM localities"
)
for row in cur.fetchall():
localities.append(
Locality(
ibge_code=str(row["ibge_code"]).strip(),
name=row["name"].strip(),
region=row["region"].strip(),
slug=row["slug"].strip(),
)
)
finally:
conn.close()
return localities
# ---------------------------------------------------------------------------
# Incremental state handling
# ---------------------------------------------------------------------------
def load_state_csv(state_path: Path) -> Dict[str, str]:
"""Load the {ibge_code: content_hash} map 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_csv(state_path: Path, state: Dict[str, str]) -> None:
"""Persist the {ibge_code: content_hash} map to the JSON state file."""
with state_path.open("w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2, ensure_ascii=False)
def ensure_state_table_sqlite(db_path: Path) -> None:
"""Create the ``locality_state`` table if it does not exist yet."""
conn = sqlite3.connect(str(db_path))
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS locality_state (
ibge_code TEXT PRIMARY KEY,
content_hash TEXT NOT NULL
)
"""
)
conn.commit()
finally:
conn.close()
def load_state_sqlite(db_path: Path) -> Dict[str, str]:
"""Load the {ibge_code: content_hash} map from the SQLite state table."""
ensure_state_table_sqlite(db_path)
conn = sqlite3.connect(str(db_path))
try:
cur = conn.execute("SELECT ibge_code, content_hash FROM locality_state")
return {str(code): h for code, h in cur.fetchall()}
finally:
conn.close()
def save_state_sqlite(db_path: Path, state: Dict[str, str]) -> None:
"""Persist the {ibge_code: content_hash} map to the SQLite state table."""
ensure_state_table_sqlite(db_path)
conn = sqlite3.connect(str(db_path))
try:
conn.executemany(
"INSERT INTO locality_state (ibge_code, content_hash) "
"VALUES (?, ?) "
"ON CONFLICT(ibge_code) DO UPDATE SET content_hash=excluded.content_hash",
list(state.items()),
)
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _render_context(locality: Locality, base_url: str) -> Dict[str, str]:
"""Build the flat context shared by both the Jinja2 and fallback renderers."""
base = base_url.rstrip("/")
return {
"locality.ibge_code": locality.ibge_code,
"locality.name": locality.name,
"locality.region": locality.region,
"locality.slug": locality.slug,
"base_url": base,
"canonical_url": f"{base}/{locality.slug}",
}
def _minimal_render(template_text: str, context: Dict[str, str]) -> str:
"""Tiny stand-in for Jinja2 used only when Jinja2 is not installed.
Supports ``{{ var }}`` substitution and strips ``{# comments #}``. This is a
deliberately minimal fallback so the script still produces output; install
Jinja2 for full template features (autoescape, control flow, etc.).
"""
text = re.sub(r"\{#.*?#\}", "", template_text, flags=re.DOTALL)
def replace(match: "re.Match[str]") -> str:
key = match.group(1).strip()
return str(context.get(key, ""))
return re.sub(r"\{\{\s*(.*?)\s*\}\}", replace, text)
def build_environment() -> "Environment":
"""Build the Jinja2 environment pointed at the templates directory."""
return Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True,
lstrip_blocks=True,
)
def render_locality(env, locality: Locality, base_url: str) -> str:
"""Render a single locality landing page to an HTML string.
Uses Jinja2 when available (``env`` is a real Environment); otherwise falls
back to the built-in minimal renderer so the script keeps working.
"""
if env is not None:
template = env.get_template(TEMPLATE_NAME)
base = base_url.rstrip("/")
return template.render(
locality=locality,
base_url=base,
canonical_url=f"{base}/{locality.slug}",
)
template_text = (TEMPLATES_DIR / TEMPLATE_NAME).read_text(encoding="utf-8")
return _minimal_render(template_text, _render_context(locality, base_url))
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
def generate(
localities: Iterable[Locality],
output_dir: Path,
base_url: str,
previous_state: Dict[str, str],
force: bool = False,
) -> Dict[str, str]:
"""Generate pages incrementally and return the updated state map.
INCREMENTAL LOGIC:
For each locality we compute its content hash. If ``force`` is False and
the stored hash equals the freshly computed hash, the locality's data has
not changed since the last run, so we SKIP rendering + writing that page.
Otherwise we (re)render it and record the new hash. The returned map fully
replaces the previous state so removed localities also drop out.
"""
output_dir.mkdir(parents=True, exist_ok=True)
env = build_environment() if HAVE_JINJA2 else None
renderer = "Jinja2" if HAVE_JINJA2 else "built-in fallback (install Jinja2 for full features)"
print(f"Renderer: {renderer}")
new_state: Dict[str, str] = {}
generated = 0
skipped = 0
for locality in localities:
current_hash = locality.content_hash()
new_state[locality.ibge_code] = current_hash
# Skip when the hash is unchanged (incremental fast path).
if not force and previous_state.get(locality.ibge_code) == current_hash:
skipped += 1
continue
html = render_locality(env, locality, base_url)
page_path = output_dir / f"{locality.slug}.html"
page_path.write_text(html, encoding="utf-8")
generated += 1
print(f"[generated] {page_path}")
print(f"\nDone. {generated} generated, {skipped} unchanged (skipped).")
return new_state
def parse_args(argv: List[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate one SEO landing page per locality."
)
parser.add_argument(
"--source",
choices=["csv", "sqlite"],
default="csv",
help="Where to read localities from (default: csv).",
)
parser.add_argument(
"--input",
default=os.environ.get("LOCALITIES_INPUT", "sample-localities.csv"),
help="Path to the CSV file or SQLite database.",
)
parser.add_argument(
"--out",
dest="out",
default=os.environ.get("LOCALITIES_OUTPUT", "out"),
help="Directory to write generated pages into.",
)
parser.add_argument(
"--base-url",
default=os.environ.get("SITE_BASE_URL", "https://www.example.com/localidades"),
help="Base URL used for canonical links and JSON-LD.",
)
parser.add_argument(
"--force",
action="store_true",
help="Regenerate everything, ignoring the incremental state.",
)
return parser.parse_args(argv)
def main(argv: List[str]) -> int:
args = parse_args(argv)
input_path = Path(args.input)
output_dir = Path(args.out)
if not input_path.exists():
sys.stderr.write(f"ERROR: input not found: {input_path}\n")
return 2
# Load data + previous state depending on the chosen source.
if args.source == "csv":
localities = read_localities_csv(input_path)
output_dir.mkdir(parents=True, exist_ok=True)
previous_state = load_state_csv(output_dir / STATE_FILE_NAME)
else:
localities = read_localities_sqlite(input_path)
previous_state = load_state_sqlite(input_path)
new_state = generate(
localities=localities,
output_dir=output_dir,
base_url=args.base_url,
previous_state=previous_state,
force=args.force,
)
# Persist the updated state so the next run is incremental.
if args.source == "csv":
save_state_csv(output_dir / STATE_FILE_NAME, new_state)
else:
save_state_sqlite(input_path, new_state)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
templates/locality.html.j2
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ locality.name }} - {{ locality.region }} | Atendimento local</title>
<meta name="description"
content="Servicos e atendimento em {{ locality.name }}, {{ locality.region }}. Conheca nossas solucoes locais.">
<link rel="canonical" href="{{ canonical_url }}">
{# JSON-LD LocalBusiness block built entirely from template variables. #}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": "{{ canonical_url }}#business",
"name": "Sua Empresa - {{ locality.name }}",
"url": "{{ canonical_url }}",
"areaServed": {
"@type": "City",
"name": "{{ locality.name }}",
"containedInPlace": {
"@type": "AdministrativeArea",
"name": "{{ locality.region }}"
}
},
"identifier": {
"@type": "PropertyValue",
"propertyID": "IBGE",
"value": "{{ locality.ibge_code }}"
}
}
</script>
</head>
<body>
<header>
<h1>Atendimento em {{ locality.name }} - {{ locality.region }}</h1>
</header>
<main>
<p class="intro">
Voce esta em {{ locality.name }}, na regiao de {{ locality.region }}.
Oferecemos solucoes pensadas para quem vive e trabalha em
{{ locality.name }} (codigo IBGE {{ locality.ibge_code }}).
</p>
{# TODO: Replace this placeholder body with real, locality-specific copy. #}
<section class="body">
<p>
[Conteudo placeholder] Descreva aqui os servicos, diferenciais e provas
sociais relevantes para {{ locality.name }}. Inclua depoimentos locais,
enderecos e horarios quando disponiveis.
</p>
</section>
</main>
<footer>
<p>&copy; Sua Empresa - {{ locality.name }}</p>
</footer>
</body>
</html>
sample-localities.csv
ibge_code,name,region,slug
9900001,Vila Aurora,Vale do Sol,vila-aurora
9900002,Porto Sereno,Litoral Norte,porto-sereno
9900003,Monte Claro,Serra Verde,monte-claro
9900004,Rio das Pedras,Vale do Sol,rio-das-pedras
9900005,Campo Belo,Planalto Central,campo-belo
9900006,Lagoa Azul,Litoral Norte,lagoa-azul
9900007,Alto da Boa Vista,Serra Verde,alto-da-boa-vista
9900008,Nova Esperanca,Sertao Novo,nova-esperanca
9900009,Ponte Alta,Planalto Central,ponte-alta
9900010,Ribeirao Doce,Vale do Sol,ribeirao-doce

Requer API

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
#!/usr/bin/env python3
"""Chapter 20 - Google Business Profile (GBP) sync - STRUCTURE ONLY.
This module shows how a Google Business Profile synchronisation script would be
organised. It pushes/updates location information (name, address, categories,
service area) to the Google Business Profile API.
Because the real API requires OAuth 2.0 (or a service account with domain-wide
delegation), the network-dependent functions are provided as documented stubs.
Fill in the TODO markers with your own credentials before using it for real.
The module is intentionally importable and compilable: the stubs raise
``NotImplementedError`` with a helpful message rather than doing real I/O.
Reference: https://developers.google.com/my-business
"""
from __future__ import annotations
import os
import sys
from typing import Any, Dict, List
# ---------------------------------------------------------------------------
# Configuration is read from environment variables so no secret ever lands in
# source control. TODO: export these in your shell / CI secret store.
# ---------------------------------------------------------------------------
# TODO: Path to your OAuth client secrets JSON downloaded from Google Cloud.
GBP_CLIENT_SECRETS = os.environ.get("GBP_CLIENT_SECRETS", "")
# TODO: Path to a service-account key JSON (alternative to OAuth user flow).
GBP_SERVICE_ACCOUNT_FILE = os.environ.get("GBP_SERVICE_ACCOUNT_FILE", "")
# TODO: The numeric Business Profile account id, e.g. "accounts/1234567890".
GBP_ACCOUNT_ID = os.environ.get("GBP_ACCOUNT_ID", "")
# OAuth scope required to manage Business Profile locations.
GBP_SCOPES = ["https://www.googleapis.com/auth/business.manage"]
def authenticate() -> Any:
"""Authenticate against the Google Business Profile API.
Returns an authorised API client / service object.
TODO:
1. pip install google-api-python-client google-auth google-auth-oauthlib
2. Choose ONE credential strategy:
* OAuth user flow using ``GBP_CLIENT_SECRETS`` (interactive), OR
* Service account using ``GBP_SERVICE_ACCOUNT_FILE`` with domain-wide
delegation.
3. Build the service, e.g.::
from googleapiclient.discovery import build
from google.oauth2.service_account import Credentials
creds = Credentials.from_service_account_file(
GBP_SERVICE_ACCOUNT_FILE, scopes=GBP_SCOPES)
return build("mybusinessbusinessinformation", "v1",
credentials=creds)
"""
raise NotImplementedError(
"authenticate() is a stub. Provide credentials via GBP_CLIENT_SECRETS "
"or GBP_SERVICE_ACCOUNT_FILE and implement the OAuth/service-account "
"flow. See the docstring for the exact steps."
)
def list_locations(service: Any, account_id: str = GBP_ACCOUNT_ID) -> List[Dict[str, Any]]:
"""List all locations under a Business Profile account.
Args:
service: authorised client returned by :func:`authenticate`.
account_id: the ``accounts/{id}`` resource name.
Returns:
A list of location resource dicts.
TODO: Call the ``accounts.locations.list`` endpoint and handle pagination
via the ``nextPageToken`` field.
"""
raise NotImplementedError(
"list_locations() is a stub. Implement the accounts.locations.list "
"call and pagination. Ensure GBP_ACCOUNT_ID is set."
)
def update_location(service: Any, location_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Update a single location's information.
Args:
service: authorised client returned by :func:`authenticate`.
location_name: the ``locations/{id}`` resource name to update.
payload: partial location resource with the fields to change
(e.g. ``title``, ``storefrontAddress``, ``categories``).
Returns:
The updated location resource dict.
TODO: Call ``locations.patch`` with an ``updateMask`` listing exactly the
fields you are changing, otherwise the API rejects the request.
"""
raise NotImplementedError(
"update_location() is a stub. Implement locations.patch with a proper "
"updateMask. Never send fields you are not intentionally updating."
)
def main(argv: List[str]) -> int:
"""Guarded entry point.
Verifies that the minimum configuration is present before attempting any
(currently stubbed) API work. This keeps the module safe to run without
credentials - it exits cleanly with guidance instead of crashing.
"""
missing = []
if not (GBP_CLIENT_SECRETS or GBP_SERVICE_ACCOUNT_FILE):
missing.append("GBP_CLIENT_SECRETS or GBP_SERVICE_ACCOUNT_FILE")
if not GBP_ACCOUNT_ID:
missing.append("GBP_ACCOUNT_ID")
if missing:
sys.stderr.write(
"GBP sync is not configured. Set the following environment "
"variables first:\n - " + "\n - ".join(missing) + "\n"
)
return 1
# Real workflow (once the stubs above are implemented):
service = authenticate()
locations = list_locations(service)
for loc in locations:
# TODO: build the per-location payload from your own data source.
update_location(service, loc["name"], payload={})
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

Dúvidas

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.

Implementação

Prefere que eu implemente?

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

Agendar conversa