""" Helpers de SEO usados pelas páginas renderizadas no servidor (busca e anúncio): - slugs amigáveis para URLs com palavra-chave; - montagem de /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)