skimobiliaria / app /seo.py
Carlexxx
xxx
8072ab3
Raw
History Blame Contribute Delete
16.4 kB
"""
Helpers de SEO usados pelas páginas renderizadas no servidor (busca e anúncio):
- slugs amigáveis para URLs com palavra-chave;
- montagem de <title>/meta description específicos por página;
- dados estruturados (JSON-LD) no padrão schema.org.
"""
import json
import re
import unicodedata
from typing import Optional
# As três cidades atendidas pela SK — mapeamento fixo entre o "slug" usado
# na URL (sem acento, minúsculo) e o nome oficial armazenado na planilha.
CIDADES_SLUG_PARA_NOME = {
"matinhos": "Matinhos",
"guaratuba": "Guaratuba",
"pontal-do-parana": "Pontal do Paraná",
}
CIDADES_NOME_PARA_SLUG = {v: k for k, v in CIDADES_SLUG_PARA_NOME.items()}
NEGOCIO_SLUG_PARA_NOME = {"comprar": "Venda", "alugar": "Locação"}
NEGOCIO_NOME_PARA_SLUG = {v: k for k, v in NEGOCIO_SLUG_PARA_NOME.items()}
def slugify(texto: str) -> str:
"""Converte texto livre em slug de URL: sem acento, minúsculo, hifens."""
texto = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode("ascii")
texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
return texto or "imovel"
def slug_anuncio(anuncio: dict) -> str:
"""Slug com palavras-chave (tipo + bairro/cidade) para a URL do anúncio."""
partes = [anuncio.get("tipo_imovel", ""), anuncio.get("bairro", ""), anuncio.get("cidade", "")]
base = slugify(" ".join(p for p in partes if p))
return base or slugify(anuncio.get("titulo", "imovel"))
def cidade_slug(nome_cidade: str) -> str:
return CIDADES_NOME_PARA_SLUG.get(nome_cidade, slugify(nome_cidade))
def url_anuncio(anuncio: dict, base_url: str = "") -> str:
return f"{base_url}/imovel/{anuncio['id']}/{slug_anuncio(anuncio)}"
def url_busca(cidade: Optional[str] = None, tipo_negocio: Optional[str] = None, base_url: str = "") -> str:
prefixo = "/busca"
if tipo_negocio in NEGOCIO_NOME_PARA_SLUG:
prefixo = f"/{NEGOCIO_NOME_PARA_SLUG[tipo_negocio]}"
if cidade:
return f"{base_url}{prefixo}/{cidade_slug(cidade)}"
return f"{base_url}{prefixo}"
def truncar(texto: str, tamanho: int) -> str:
texto = (texto or "").strip()
if len(texto) <= tamanho:
return texto
return texto[:tamanho].rsplit(" ", 1)[0].rstrip(",.;") + "…"
def preco_formatado(valor: float) -> str:
return "R$ " + f"{float(valor or 0):,.0f}".replace(",", ".")
def preco_exibicao(a: dict) -> str:
"""Texto do valor pronto para exibição. Retorna "" quando o preço não foi
informado (0/vazio) — nesses casos o valor não deve aparecer no site."""
if not a.get("preco"):
return ""
valor = preco_formatado(a["preco"])
return f"{valor}/mês" if a["tipo_negocio"] == "Locação" else valor
STATUS_CLASSE = {
"Disponível": "status-disponivel", "Reservado": "status-reservado", "Inativo": "status-inativo",
"Vendido": "status-vendido", "Alugado": "status-alugado",
}
def status_classe(status: str) -> str:
return STATUS_CLASSE.get(status, "status-disponivel")
def render_card_ssr(a: dict, base_url: str = "") -> str:
"""Card de imóvel renderizado no servidor (link real, crawlável) para a listagem inicial da busca.
Usa o mesmo HTML/atributos (`data-midias`, `.media-nav`, `.media-slide`) que o JS gera em
cardImovel(), então o carrossel de fotos/vídeos e o popup já funcionam via main.js mesmo
antes do busca.js "hidratar" a página com a versão totalmente interativa."""
from html import escape
midias_raw = a.get("midias")
try:
midias = json.loads(midias_raw) if isinstance(midias_raw, str) else (midias_raw or [])
except (json.JSONDecodeError, TypeError):
midias = []
midias = [str(m) for m in midias if m]
if midias:
primeira = midias[0]
eh_video = bool(re.search(r"\.(mp4|webm|mov|ogg)(\?|#|$)", primeira, re.I))
eh_audio = bool(re.search(r"\.(mp3|wav|m4a|aac|oga|opus|weba)(\?|#|$)", primeira, re.I))
if eh_video:
primeiro_slide = f'<video src="{escape(primeira)}#t=0.1" muted playsinline preload="metadata"></video><span class="media-play">▶</span>'
elif eh_audio:
primeiro_slide = (
'<span class="media-audio-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
'stroke-width="1.5" width="28" height="28"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/>'
'<circle cx="18" cy="16" r="3"/></svg></span>'
)
else:
primeiro_slide = f'<img src="{escape(primeira)}" alt="Foto: {escape(a["titulo"])}" loading="lazy">'
setas_html = ""
if len(midias) > 1:
setas_html = (
'<button type="button" class="media-nav prev" data-dir="-1" aria-label="Mídia anterior">‹</button>'
'<button type="button" class="media-nav next" data-dir="1" aria-label="Próxima mídia">›</button>'
f'<span class="media-counter">1/{len(midias)}</span>'
)
midias_json_attr = escape(json.dumps(midias, ensure_ascii=False))
media_html = (
f'<div class="card-media has-media" data-midias=\'{midias_json_attr}\'>'
f'<div class="media-slide" data-idx="0">{primeiro_slide}</div>{setas_html}'
)
else:
media_html = (
'<div class="card-media" data-midias=\'[]\'>'
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">'
'<path d="M3 21h18M5 21V9l7-6 7 6v12M9 21v-6h6v6"/></svg>'
)
tag_cls = "tag-venda" if a["tipo_negocio"] == "Venda" else "tag-locacao"
destaque_html = '<span class="tag-destaque">Destaque</span>' if a.get("destaque") else ""
specs = []
if a.get("quartos"):
specs.append(f"{a['quartos']} quarto{'s' if a['quartos'] != 1 else ''}")
area = a.get("area_util") or a.get("area_total")
if area:
specs.append(f"{area}m²")
specs_html = " · ".join(specs)
url = url_anuncio(a, base_url)
status_cls = status_classe(a["status"])
preco_html = f'<div class="card-price">{preco_exibicao(a)}</div>' if a.get("preco") else ""
return (
f'<a class="card" href="{url}">'
f'{media_html}'
f'<span class="tag {tag_cls}">{a["tipo_negocio"]}</span>{destaque_html}'
f'</div>'
f'<div class="card-body">'
f'<span class="status-pill {status_cls}">{escape(a["status"])}</span>'
f'{preco_html}'
f'<h3 class="card-title">{escape(a["titulo"])}</h3>'
f'<div class="card-loc">📍 {escape(a["bairro"])}, {escape(a["cidade"])}</div>'
f'<div class="card-specs"><span>{specs_html}</span></div>'
f'</div></a>'
)
def render_galeria_ssr(midias: list[str], titulo: str) -> str:
"""Galeria da página de detalhe do anúncio, renderizada no servidor.
Suporta foto, vídeo e áudio na mídia principal e nas miniaturas — evita o
bug de tentar exibir vídeo/áudio dentro de uma tag <img>."""
from html import escape
if not midias:
return ""
def _tag_grande(url: str) -> str:
if re.search(r"\.(mp4|webm|mov|ogg)(\?|#|$)", url, re.I):
return f'<video src="{escape(url)}" controls playsinline></video>'
if re.search(r"\.(mp3|wav|m4a|aac|oga|opus|weba)(\?|#|$)", url, re.I):
return (
'<div class="media-audio-full"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
'stroke-width="1.5" width="48" height="48"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/>'
f'<circle cx="18" cy="16" r="3"/></svg><audio src="{escape(url)}" controls></audio></div>'
)
return f'<img src="{escape(url)}" alt="Foto principal: {escape(titulo)}">'
def _thumb(url: str, indice: int) -> str:
if re.search(r"\.(mp4|webm|mov|ogg)(\?|#|$)", url, re.I):
return '<span class="thumb-video">▶</span>'
if re.search(r"\.(mp3|wav|m4a|aac|oga|opus|weba)(\?|#|$)", url, re.I):
return (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">'
'<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>'
)
return f'<img src="{escape(url)}" alt="Foto {indice + 1} do imóvel {escape(titulo)}">'
thumbs_html = ""
if len(midias) > 1:
itens = "".join(
f'<button type="button" class="gallery-thumb {"active" if i == 0 else ""}" data-idx="{i}">{_thumb(m, i)}</button>'
for i, m in enumerate(midias)
)
thumbs_html = f'<div class="gallery-thumbs">{itens}</div>'
return (
f'<div class="gallery reveal reveal-zoom" data-idx="0">'
f'<div class="gallery-main">{_tag_grande(midias[0])}'
f'<span class="gallery-count">📷 1/{len(midias)}</span></div>'
f'{thumbs_html}</div>'
)
def render_showcase_ssr(destaques: list[dict], base_url: str = "") -> str:
"""Carrossel de destaques da página inicial, renderizado no servidor —
mesma marcação (classes/atributos) que static/js/home.js gera em
montarShowcase(), para que o JS só precise "ligar" a interatividade
(troca de slide/dots) em cima do HTML que já chegou pronto, sem precisar
buscar e desenhar os destaques via API antes de aparecerem para o
usuário (ou para o Google)."""
from html import escape
if not destaques:
return '<p class="empty-state">Nenhum imóvel em destaque no momento.</p>'
def _slide(a: dict) -> str:
midias_raw = a.get("midias")
try:
midias = json.loads(midias_raw) if isinstance(midias_raw, str) else (midias_raw or [])
except (json.JSONDecodeError, TypeError):
midias = []
foto = next((str(m) for m in midias if m), "") if midias else ""
tag_cls = "tag-venda" if a["tipo_negocio"] == "Venda" else "tag-locacao"
url = url_anuncio(a, base_url)
img_html = f'<img src="{escape(foto)}" alt="{escape(a.get("titulo") or "")}" loading="lazy">' if foto else ""
estilo = "" if foto else ' style="background:var(--midia-placeholder-2)"'
return (
f'<a class="showcase-slide" href="{url}"{estilo}>'
f'{img_html}'
f'<span class="tag showcase-tag {tag_cls}">{escape(a["tipo_negocio"])}</span>'
f'<div class="showcase-info"><h2>{escape(a.get("titulo") or "")}</h2></div>'
f'</a>'
)
dots_html = ""
arrows_html = ""
if len(destaques) > 1:
dots_html = '<div class="showcase-dots">' + "".join(
f'<button type="button" class="showcase-dot{" active" if i == 0 else ""}" data-idx="{i}" '
f'aria-label="Ir para o imóvel {i + 1}"></button>'
for i in range(len(destaques))
) + "</div>"
arrows_html = (
'<button type="button" class="showcase-arrow prev" aria-label="Imóvel anterior">‹</button>'
'<button type="button" class="showcase-arrow next" aria-label="Próximo imóvel">›</button>'
)
slides_html = "".join(_slide(a) for a in destaques)
return f'<div class="showcase-track">{slides_html}</div>{dots_html}{arrows_html}'
# --------------------------------------------------------------------------
# Página do anúncio
# --------------------------------------------------------------------------
def titulo_anuncio(a: dict) -> str:
acao = "à venda" if a["tipo_negocio"] == "Venda" else "para alugar"
return f"{a['titulo']}{a['tipo_imovel']} {acao} em {a['bairro']}, {a['cidade']}/{a['estado']} | SK Imobiliária"
def descricao_anuncio(a: dict) -> str:
acao = "à venda" if a["tipo_negocio"] == "Venda" else "para locação"
area = a.get("area_util") or a.get("area_total")
base = f"{a['tipo_imovel']} {acao} em {a['bairro']}, {a['cidade']} (PR)"
if a.get("preco"):
base += f" por {preco_formatado(a['preco'])}"
partes = [base]
if a.get("quartos"):
partes.append(f"{a['quartos']} quarto(s)")
if area:
partes.append(f"{area}m²")
resumo = ", ".join(partes) + "."
extra = (a.get("descricao") or "").strip()
texto = f"{resumo} {extra}" if extra else f"{resumo} Fale com a SK Imobiliária e agende uma visita."
return truncar(texto, 160)
def jsonld_anuncio(a: dict, url: str, midias: list[str]) -> str:
disponibilidade = "https://schema.org/InStock" if a["status"] == "Disponível" else "https://schema.org/OutOfStock"
funcao = "http://purl.org/goodrelations/v1#LeaseOut" if a["tipo_negocio"] == "Locação" else "http://purl.org/goodrelations/v1#Sell"
area = a.get("area_util") or a.get("area_total")
dados = {
"@context": "https://schema.org",
"@type": "RealEstateListing",
"name": a["titulo"],
"description": descricao_anuncio(a),
"url": url,
"datePosted": a.get("data_cadastro"),
"address": {
"@type": "PostalAddress",
"streetAddress": a.get("endereco") or None,
"addressLocality": a.get("cidade"),
"addressRegion": a.get("estado"),
"postalCode": a.get("cep") or None,
"addressCountry": "BR",
},
}
# Só inclui a oferta de preço quando o valor foi de fato informado (evita
# declarar "price": 0 no schema.org, o que confundiria buscadores/agregadores).
if a.get("preco"):
dados["offers"] = {
"@type": "Offer",
"price": a["preco"],
"priceCurrency": "BRL",
"availability": disponibilidade,
"businessFunction": funcao,
"url": url,
}
imagens = [
m for m in midias
if not re.search(r"\.(mp4|webm|mov|ogg|mp3|wav|m4a|aac|oga|opus|weba)(\?|#|$)", m, re.I)
]
if imagens:
dados["image"] = imagens
if a.get("quartos"):
dados["numberOfRooms"] = a["quartos"]
if area:
dados["floorSize"] = {"@type": "QuantitativeValue", "value": area, "unitCode": "MTK"}
breadcrumb = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Início", "item": url.rsplit("/imovel/", 1)[0] + "/"},
{"@type": "ListItem", "position": 2, "name": "Imóveis", "item": url.rsplit("/imovel/", 1)[0] + "/busca"},
{"@type": "ListItem", "position": 3, "name": a["cidade"], "item": url_busca(a["cidade"], base_url=url.rsplit("/imovel/", 1)[0])},
{"@type": "ListItem", "position": 4, "name": a["titulo"]},
],
}
return json.dumps([dados, breadcrumb], ensure_ascii=False, default=str)
# --------------------------------------------------------------------------
# Página de busca / listagem
# --------------------------------------------------------------------------
def titulo_busca(cidade: Optional[str], tipo_negocio: Optional[str]) -> str:
acao = {"Venda": "Comprar", "Locação": "Alugar"}.get(tipo_negocio, "Comprar e alugar")
if cidade:
return f"{acao} imóveis em {cidade}, PR | Casas, apartamentos e terrenos | SK Imobiliária"
return f"{acao} imóveis no litoral do Paraná | Matinhos, Guaratuba e Pontal do Paraná | SK Imobiliária"
def descricao_busca(cidade: Optional[str], tipo_negocio: Optional[str], total: int) -> str:
acao = {"Venda": "à venda", "Locação": "para locação"}.get(tipo_negocio, "à venda e para locação")
local = f"em {cidade}" if cidade else "no litoral do Paraná (Matinhos, Guaratuba e Pontal do Paraná)"
texto = f"{total} imóveis {acao} {local}: casas, apartamentos, terrenos e coberturas. Filtre por preço, bairro e características e fale direto com a SK Imobiliária."
return truncar(texto, 160)
def jsonld_busca(resultados: list[dict], url: str, base_url: str) -> str:
itens = [
{
"@type": "ListItem",
"position": i + 1,
"url": url_anuncio(a, base_url),
"name": a["titulo"],
}
for i, a in enumerate(resultados)
]
return json.dumps({
"@context": "https://schema.org",
"@type": "ItemList",
"url": url,
"itemListElement": itens,
}, ensure_ascii=False)