Spaces:
Paused
Paused
| """ | |
| 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) | |