# -----------------------------------------------------------------------------
# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# seo.py : rendu HTML côté serveur pour le référencement.
#
# Le SPA React reste inchangé : ce module pré-remplit le HTML initial servi par
# le catch-all de web.py —
/meta/canonical/og: uniques, JSON-LD
# schema.org, contenu et liens internes dans
(que React
# remplace au montage). Il génère aussi robots.txt et les sitemaps.
#
# Pages servies :
# / accueil enrichi (stats + liens villes/types)
# /propriete/{uid}[/{slug}] fiche (301 vers le slug canonique,
# 410 si retirée, 404 si inconnue)
# /a-vendre/{ville}[/{type}] pages programmatiques par ville (+ type)
# /type/{type} page par type de propriété (province)
# /stats /agences /conditions /confidentialite /profil meta dédiées
# /robots.txt /sitemap.xml /sitemaps/*.xml
# -----------------------------------------------------------------------------
from __future__ import annotations
import html
import json
import os
import re
import time
import unicodedata
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
from . import db
ROOT = Path(__file__).resolve().parent.parent
FRONTEND_DIST = ROOT / "frontend" / "dist"
FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"
BASE_URL = os.environ.get("IMMOKA_BASE_URL", "https://www.immo-ka.com").rstrip("/")
SITE_NAME = "Immo-Ka"
# même règle de visibilité que /api/listings (dédup Centris + prix affichable)
VISIBLE = "active=1 AND dup_hidden=0 AND published=1"
MIN_LISTINGS = 3 # seuil de qualité : pas de page ville/type quasi vide
PAGE_SIZE = 48 # annonces par page sur les pages programmatiques
# -----------------------------------------------------------------------------
# Utilitaires
# -----------------------------------------------------------------------------
def slugify(s: str) -> str:
"""Slug URL — MÊME algorithme que slugify() de frontend/src/api.ts."""
s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
return s[:80].strip("-")
def listing_slug(row) -> str:
"""Slug d'une fiche — MÊME logique que fichePath() de frontend/src/api.ts."""
base = slugify(row["address"] or row["title"] or "")
city = slugify(row["city"] or "")
if city and city not in base:
base = slugify(f"{base} {city}") if base else city
return base
def _esc(s) -> str:
return html.escape(str(s or ""), quote=True)
def _fmt_n(n) -> str:
return f"{int(n):,}".replace(",", " ")
def _fmt_price(p) -> str:
return f"{_fmt_n(round(p))} $" if p is not None else "Prix sur demande"
def _iso_date(ts) -> str:
try:
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")
except (TypeError, ValueError):
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
# petit cache TTL en mémoire (les données changent au rythme du watch, ~1 h)
_cache: dict[str, tuple[float, object]] = {}
def _cached(key: str, ttl: float, build):
now = time.time()
hit = _cache.get(key)
if hit and now - hit[0] < ttl:
return hit[1]
val = build()
_cache[key] = (now, val)
return val
# -----------------------------------------------------------------------------
# Registres de slugs (villes, types) — reconstruits toutes les 15 min
# -----------------------------------------------------------------------------
def _build_registry() -> dict:
con = db.connect()
cities: dict[str, dict] = {}
for r in con.execute(
f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"
" AND city<>'' GROUP BY city"):
slug = slugify(r["city"])
if len(slug) < 2:
continue
e = cities.setdefault(slug, {"label": r["city"], "n": 0, "values": [], "best": 0})
e["n"] += r["n"]
e["values"].append(r["city"])
if r["n"] > e["best"]:
e["best"] = r["n"]; e["label"] = r["city"]
types: dict[str, dict] = {}
for r in con.execute(
f"SELECT property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"
" AND property_type<>'' GROUP BY property_type"):
slug = slugify(r["property_type"])
if len(slug) < 2:
continue
e = types.setdefault(slug, {"label": r["property_type"], "n": 0, "values": [], "best": 0})
e["n"] += r["n"]
e["values"].append(r["property_type"])
if r["n"] > e["best"]:
e["best"] = r["n"]; e["label"] = r["property_type"]
city_types: dict[tuple[str, str], int] = {}
for r in con.execute(
f"SELECT city, property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"
" AND city<>'' AND property_type<>'' GROUP BY city, property_type"):
cs, ts_ = slugify(r["city"]), slugify(r["property_type"])
if len(cs) < 2 or len(ts_) < 2:
continue
city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"]
con.close()
return {"cities": cities, "types": types, "city_types": city_types}
def registry() -> dict:
return _cached("registry", 900, _build_registry)
# -----------------------------------------------------------------------------
# Gabarit : dist/index.html, dépouillé de son /description statiques
# -----------------------------------------------------------------------------
_tpl_cache: tuple[float, str] | None = None
def _template() -> str:
global _tpl_cache
path = FRONTEND_DIR / "index.html"
mtime = path.stat().st_mtime
if _tpl_cache and _tpl_cache[0] == mtime:
return _tpl_cache[1]
tpl = path.read_text(encoding="utf-8")
tpl = re.sub(r".*?\s*", "", tpl, flags=re.S)
tpl = re.sub(r']*>\s*', "", tpl)
tpl = re.sub(r']*>\s*', "", tpl)
_tpl_cache = (mtime, tpl)
return tpl
def _page(title: str, description: str, canonical: str, body: str,
jsonld: list[dict] | None = None, og_image: str | None = None,
og_type: str = "website", noindex: bool = False,
status: int = 200) -> HTMLResponse:
head = [
f"{_esc(title)}",
f'',
f'',
f'',
'',
f'',
f'',
f'',
f'',
]
if og_image:
head.append(f'')
else:
og_image = BASE_URL + "/og.png"
head.append(f'')
head.append('')
head.append('')
head.append('')
head.append(f'')
if noindex:
head.append('')
for obj in (jsonld or []):
head.append('")
tpl = _template()
out = tpl.replace("", " " + "\n ".join(head) + "\n ", 1)
out = out.replace('',
f'
{body}
', 1)
return HTMLResponse(out, status_code=status)
# -----------------------------------------------------------------------------
# Blocs HTML réutilisables
# -----------------------------------------------------------------------------
def fiche_href(uid: str, slug: str = "") -> str:
path = f"/propriete/{quote(uid, safe='')}"
return path + (f"/{slug}" if slug else "")
def _item_li(r) -> str:
href = fiche_href(r["uid"], listing_slug(r))
bits = [b for b in [
r["property_type"],
f"{r['bedrooms']} ch." if r["bedrooms"] is not None else "",
f"{r['bathrooms']} sdb" if r["bathrooms"] is not None else "",
f"{_fmt_n(round(r['area_sqft']))} pi²" if r["area_sqft"] else "",
] if b]
name = r["address"] or r["title"] or "Propriété"
loc = ", ".join(x for x in [r["sector"], r["city"]] if x)
return (f'
{_esc(name)} — '
f'{_esc(_fmt_price(r["price"]))}'
+ (f' · {_esc(" · ".join(bits))}' if bits else "")
+ (f' · {_esc(loc)}' if loc else "") + "
")
def _agg_stats(con, cities: list[str] | None = None,
types_values: list[str] | None = None) -> dict:
where = VISIBLE
args: list = []
if cities:
where += f" AND city IN ({','.join('?' * len(cities))})"
args += cities
if types_values:
where += f" AND property_type IN ({','.join('?' * len(types_values))})"
args += types_values
row = con.execute(
f"SELECT COUNT(*) n, AVG(price) avg_p, MIN(price) min_p, MAX(price) max_p"
f" FROM listings WHERE {where}", args).fetchone()
med = None
if row["n"]:
med_row = con.execute(
f"SELECT price FROM listings WHERE {where}"
f" ORDER BY price LIMIT 1 OFFSET ?", args + [row["n"] // 2]).fetchone()
med = med_row["price"] if med_row else None
return {"n": row["n"], "avg": row["avg_p"], "med": med,
"min": row["min_p"], "max": row["max_p"], "where": where, "args": args}
def _pagination_html(base_path: str, page: int, pages: int) -> str:
if pages <= 1:
return ""
out = ['")
return "".join(out)
def _breadcrumb_ld(crumbs: list[tuple[str, str]]) -> dict:
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": name,
"item": BASE_URL + path}
for i, (name, path) in enumerate(crumbs)
],
}
# -----------------------------------------------------------------------------
# Accueil
# -----------------------------------------------------------------------------
def _home_data() -> dict:
def build():
con = db.connect()
row = con.execute(
f"SELECT COUNT(*) total, COUNT(DISTINCT city) cities,"
f" COUNT(DISTINCT source) sources, AVG(price) avg_p"
f" FROM listings WHERE {VISIBLE}").fetchone()
recent = [dict(r) for r in con.execute(
f"SELECT uid, address, title, city, sector, property_type, price,"
f" bedrooms, bathrooms, area_sqft FROM listings WHERE {VISIBLE}"
f" ORDER BY first_seen DESC LIMIT 12")]
con.close()
return {**dict(row), "recent": recent}
return _cached("home", 900, build)
def render_home() -> HTMLResponse:
d = _home_data()
reg = registry()
total = _fmt_n(d["total"])
title = f"Immo-Ka — {total} propriétés à vendre au Québec · Un service Groupe KA"
desc = (f"{total} propriétés à vendre dans {_fmt_n(d['cities'])} villes du Québec, "
f"agrégées depuis {d['sources']} sources (RE/MAX, Via Capitale, Sutton, "
f"Proprio Direct, DuProprio, Centris et plus) — mises à jour en continu. "
f"Prix moyen : {_fmt_price(d['avg_p'])}.")
top_cities = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:60]
types = sorted(reg["types"].items(), key=lambda kv: -kv[1]["n"])
body = [
f"
Propriétés à vendre au Québec — {total} annonces de toutes les agences
",
f"
Immo-Ka agrège en continu les propriétés à vendre affichées publiquement par "
f"les agences de courtage immobilier et les plateformes du Québec : RE/MAX, "
f"Via Capitale, Sutton, Proprio Direct, Engel & Völkers, Sotheby's, DuProprio, "
f"LesPAC et plus — {d['sources']} sources, {_fmt_n(d['cities'])} villes, "
f"prix moyen {_esc(_fmt_price(d['avg_p']))}. Chaque fiche renvoie à l'annonce "
f"originale de l'agence.
'
for s, e in top if s != city_slug and e["n"] >= MIN_LISTINGS) + "
")
con.close()
if city and ptype:
base_path = f"/a-vendre/{city_slug}/{type_slug}"
h1 = f"{ptype['label']} à vendre à {city['label']}"
what = f"{ptype['label'].lower()} à vendre à {city['label']}"
elif city:
base_path = f"/a-vendre/{city_slug}"
h1 = f"Propriétés à vendre à {city['label']}"
what = f"propriétés à vendre à {city['label']}"
else:
base_path = f"/type/{type_slug}"
h1 = f"{ptype['label']} à vendre au Québec"
what = f"{ptype['label'].lower()} à vendre au Québec"
canonical = BASE_URL + base_path + (f"?page={page}" if page > 1 else "")
title = f"{h1} — {_fmt_n(st['n'])} annonces" + (f" (page {page})" if page > 1 else "") + " | Immo-Ka"
desc = (f"{_fmt_n(st['n'])} {what} : prix médian {_fmt_price(st['med'])}, "
f"prix moyen {_fmt_price(st['avg'])}. Annonces de toutes les agences "
f"(RE/MAX, Via Capitale, Sutton, DuProprio…), mises à jour en continu.")
stats_p = (f"
{_fmt_n(st['n'])} annonces · prix médian "
f"{_esc(_fmt_price(st['med']))} · prix moyen "
f"{_esc(_fmt_price(st['avg']))} · de "
f"{_esc(_fmt_price(st['min']))} à {_esc(_fmt_price(st['max']))}.
")
crumbs = [("Accueil", "/")]
if city:
crumbs.append((f"À vendre à {city['label']}", f"/a-vendre/{city_slug}"))
if ptype:
crumbs.append((f"{ptype['label']}", base_path))
else:
crumbs.append((h1, base_path))
body = (f'
{_esc(h1)}
" + stats_p
+ "
" + "".join(_item_li(r) for r in rows) + "
"
+ _pagination_html(base_path, page, pages)
+ "".join(links))
return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)])
# -----------------------------------------------------------------------------
# Fiche propriété
# -----------------------------------------------------------------------------
_TYPE_SCHEMA = {
"maison": "SingleFamilyResidence", "condo": "Apartment",
"chalet": "House", "bi-generation": "House", "domaine-et-villa": "House",
"duplex": "Residence", "triplex": "Residence", "quadruplex": "Residence",
"multiplex": "Residence", "loft": "Apartment", "maison-mobile": "House",
}
def render_listing(uid: str, slug: str | None) -> Response:
con = db.connect()
row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
con.close()
if row is None:
return render_404()
city_slug = slugify(row["city"] or "")
city_known = city_slug in registry()["cities"]
city_href = f"/a-vendre/{city_slug}" if city_known else "/"
if not row["active"]:
# propriété retirée / vendue → 410 Gone, avec des portes de sortie
name = row["address"] or row["title"] or "Propriété"
body = (f"
Cette propriété n'est plus à vendre
"
f"
L'annonce « {_esc(name)} » ({_esc(row['city'] or 'Québec')}) a été "
f"retirée ou vendue.
')
return _page(f"Propriété retirée — {name} | {SITE_NAME}",
"Cette annonce a été retirée ou vendue.",
BASE_URL + fiche_href(uid), body, noindex=True, status=410)
expected = listing_slug(row)
if expected and slug != expected:
return RedirectResponse(BASE_URL + fiche_href(uid, expected), status_code=301)
d = dict(row)
images = json.loads(d.get("images") or "[]")
features = json.loads(d.get("features") or "[]")
name = d["address"] or d["title"] or "Propriété à vendre"
loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec"
canonical = BASE_URL + fiche_href(uid, expected)
ptype = d["property_type"] or "Propriété"
specs = [(lbl, val) for lbl, val in [
("Type", ptype),
("Prix", _fmt_price(d["price"]) if d["price"] is not None else d["price_label"]),
("Chambres", d["bedrooms"]),
("Salles de bain", d["bathrooms"]),
("Salles d'eau", d["powder_rooms"]),
("Superficie habitable", f"{_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else None),
("Terrain", f"{_fmt_n(round(d['lot_sqft']))} pi²" if d["lot_sqft"] else None),
("Année de construction", d["year_built"]),
("Ville", d["city"]),
("Secteur", d["sector"]),
("N° MLS", d["mls"]),
("Courtier", d["broker_name"]),
("Agence", d["agency"]),
] if val not in (None, "", 0)]
descr = (d["description"] or "").strip()
if len(descr) > 1500:
descr = descr[:1500].rsplit(" ", 1)[0] + "…"
type_slug = slugify(ptype)
crumbs = [("Accueil", "/")]
if city_known:
crumbs.append((f"À vendre à {d['city']}", city_href))
if (city_slug, type_slug) in registry()["city_types"]:
crumbs.append((ptype, f"/a-vendre/{city_slug}/{type_slug}"))
crumbs.append((name, fiche_href(uid, expected)))
body = [
'",
f"
{_esc(name)}
",
f"
{_esc(ptype)} à vendre à {_esc(loc)} — "
f"{_esc(_fmt_price(d['price']) if d['price'] is not None else (d['price_label'] or 'Prix sur demande'))}
",
]
if images:
body.append("".join(
f''
for i, u in enumerate(images[:3])))
body.append("
")
about_type = _TYPE_SCHEMA.get(type_slug, "Residence")
about: dict = {
"@type": about_type,
"name": name,
"address": {"@type": "PostalAddress",
"streetAddress": d["address"] or None,
"addressLocality": d["city"] or None,
"addressRegion": "QC", "addressCountry": "CA"},
}
if d["lat"] is not None and d["lng"] is not None:
about["geo"] = {"@type": "GeoCoordinates",
"latitude": d["lat"], "longitude": d["lng"]}
if d["bedrooms"] is not None:
about["numberOfBedrooms"] = d["bedrooms"]
if d["bathrooms"] is not None:
about["numberOfBathroomsTotal"] = d["bathrooms"]
if d["area_sqft"]:
about["floorSize"] = {"@type": "QuantitativeValue",
"value": round(d["area_sqft"]), "unitCode": "FTK"}
if d["year_built"]:
about["yearBuilt"] = d["year_built"]
about = {k: v for k, v in about.items() if v is not None}
about["address"] = {k: v for k, v in about["address"].items() if v is not None}
jsonld: list[dict] = [{
"@context": "https://schema.org",
"@type": "RealEstateListing",
"name": name,
"url": canonical,
"inLanguage": "fr-CA",
"datePosted": _iso_date(d.get("first_seen")),
"image": images[:6] or None,
"about": about,
}, _breadcrumb_ld(crumbs)]
if d["price"] is not None:
jsonld[0]["offers"] = {"@type": "Offer", "price": round(d["price"], 2),
"priceCurrency": "CAD",
"availability": "https://schema.org/InStock"}
jsonld[0] = {k: v for k, v in jsonld[0].items() if v is not None}
title = f"{name} — {ptype} à vendre, {d['city'] or 'Québec'} | {_fmt_price(d['price']) if d['price'] is not None else 'Prix sur demande'}"
meta_desc = (f"{ptype} à vendre à {loc}"
+ (f", {d['bedrooms']} chambres" if d["bedrooms"] else "")
+ (f", {_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else "")
+ f" — {_fmt_price(d['price']) if d['price'] is not None else 'prix sur demande'}. "
+ (descr[:120] + "…" if len(descr) > 120 else descr))
return _page(title, meta_desc, canonical, "".join(body), jsonld,
og_image=images[0] if images else None, og_type="article")
# -----------------------------------------------------------------------------
# Pages statiques du SPA (meta dédiées) et 404
# -----------------------------------------------------------------------------
_STATIC_META = {
"/stats": ("Statistiques du marché immobilier québécois",
"Prix moyens, écarts prix demandé vs estimation Vrai-Prix par bannière, "
"et volumes d'annonces par source — statistiques en continu d'Immo-Ka.",
False),
"/agences": ("Agences immobilières couvertes",
"Toutes les bannières et agences agrégées par Immo-Ka : RE/MAX, Via Capitale, "
"Sutton, Proprio Direct, Engel & Völkers, Sotheby's, DuProprio et plus.",
False),
"/taux-hypothecaires": (
"Taux hypothécaires au Canada — comparateur en direct",
"Comparez les taux hypothécaires réellement publiés par RBC, TD, BMO, CIBC, "
"Scotia, BNC, Desjardins, Tangerine, EQ et plus — fixes et variables, avec "
"source officielle, fraîcheur et historique. Collecte continue par Immo-Ka.",
False),
"/conditions": ("Conditions d'utilisation",
"Conditions d'utilisation de la plateforme Immo-Ka (Groupe-Ka).", False),
"/confidentialite": ("Politique de confidentialité",
"Politique de confidentialité d'Immo-Ka (Groupe-Ka) — Loi 25.", False),
"/profil": ("Mon profil", "Votre compte Groupe KA sur Immo-Ka.", True),
"/contact": ("Contact — Groupe KA",
"Écrire au Groupe KA : contact@groupe-ka.com (projets et données), "
"info@groupe-ka.com (médias), admin@groupe-ka.com (légal et Loi 25). "
"Immo-Ka est un service Groupe KA — https://www.groupe-ka.com.",
False),
}
def render_static(path: str) -> HTMLResponse:
t, desc, noindex = _STATIC_META[path]
body = f"
{_esc(t)}
{_esc(desc)}
"
return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex)
def render_demenageurs() -> HTMLResponse:
from . import demenageurs as dem
doc = dem.load()
movers = doc.get("movers", [])
n = len(movers)
title = f"Déménageurs au Québec — annuaire complet ({n} entreprises) | {SITE_NAME}"
description = (f"Annuaire complet des déménageurs du Québec : {n} entreprises de "
"déménagement dans les 17 régions administratives, avec téléphone, "
"site web et note Google. Trouvez un déménageur près de chez vous.")
by_region: dict[str, list] = {}
for m in movers:
by_region.setdefault(m["region"], []).append(m)
def _mover_ld(m: dict) -> dict:
d = {"@type": "MovingCompany", "name": m["name"]}
if m.get("address"):
d["address"] = m["address"]
if m.get("phone"):
d["telephone"] = m["phone"]
if m.get("website"):
d["url"] = m["website"]
return d
parts = [f"
Déménageurs du Québec — {n} entreprises
",
"
" + _esc(description) + "
"]
for region in sorted(by_region, key=lambda r: -len(by_region[r])):
ms = by_region[region]
parts.append(f"
{_esc(region)} ({len(ms)})
")
for m in ms:
item = f"{_esc(m['name'])} — {_esc(m['city'])}"
if m.get("phone"):
item += ", " + _esc(m["phone"])
if m.get("website"):
item += ' — site web'
parts.append("
" + item + "
")
parts.append("
")
jsonld = [_breadcrumb_ld([("Accueil", "/"), ("Déménageurs", "/demenageurs")]),
{"@context": "https://schema.org", "@type": "ItemList",
"name": "Déménageurs du Québec", "numberOfItems": n,
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "item": _mover_ld(m)}
for i, m in enumerate(movers[:100])]}]
return _page(title, description, BASE_URL + "/demenageurs", "".join(parts),
jsonld=jsonld)
def render_inspecteurs() -> HTMLResponse:
from . import inspecteurs as insp
doc = insp.load()
entries = doc.get("movers", [])
n = len(entries)
title = (f"Inspecteurs en bâtiment au Québec — annuaire complet "
f"({n} entreprises) | {SITE_NAME}")
description = (f"Annuaire complet des inspecteurs en bâtiment du Québec : {n} "
"entreprises d'inspection préachat, prévente et préréception dans "
"les 17 régions administratives, avec téléphone, site web et note "
"Google. Trouvez un inspecteur près de chez vous.")
by_region: dict[str, list] = {}
for m in entries:
by_region.setdefault(m["region"], []).append(m)
def _insp_ld(m: dict) -> dict:
d = {"@type": "ProfessionalService", "name": m["name"]}
if m.get("address"):
d["address"] = m["address"]
if m.get("phone"):
d["telephone"] = m["phone"]
if m.get("website"):
d["url"] = m["website"]
return d
parts = [f"
Inspecteurs en bâtiment du Québec — {n} entreprises
",
"
" + _esc(description) + "
"]
for region in sorted(by_region, key=lambda r: -len(by_region[r])):
ms = by_region[region]
parts.append(f"
{_esc(region)} ({len(ms)})
")
for m in ms:
item = f"{_esc(m['name'])} — {_esc(m['city'])}"
if m.get("phone"):
item += ", " + _esc(m["phone"])
if m.get("website"):
item += ' — site web'
parts.append("
" + item + "
")
parts.append("
")
jsonld = [_breadcrumb_ld([("Accueil", "/"), ("Inspecteurs en bâtiment", "/inspecteurs")]),
{"@context": "https://schema.org", "@type": "ItemList",
"name": "Inspecteurs en bâtiment du Québec", "numberOfItems": n,
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "item": _insp_ld(m)}
for i, m in enumerate(entries[:100])]}]
return _page(title, description, BASE_URL + "/inspecteurs", "".join(parts),
jsonld=jsonld)
def render_404() -> HTMLResponse:
body = ('
')
return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.",
BASE_URL + "/", body, noindex=True, status=404)
# -----------------------------------------------------------------------------
# robots.txt et sitemaps
# -----------------------------------------------------------------------------
FICHES_PER_SITEMAP = 40000
def robots_txt() -> PlainTextResponse:
return PlainTextResponse(
"User-agent: *\n"
"Allow: /\n"
"Disallow: /api/\n"
"Disallow: /profil\n"
f"\nSitemap: {BASE_URL}/sitemap.xml\n")
def _xml(urls: list[str]) -> Response:
body = ('\n'
'\n'
+ "\n".join(urls) + "\n")
return Response(body, media_type="application/xml")
def _url_el(loc: str, lastmod: str | None = None) -> str:
lm = f"{lastmod}" if lastmod else ""
return f" {html.escape(loc)}{lm}"
def sitemap_index() -> Response:
def build():
con = db.connect()
n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"]
last = con.execute(
f"SELECT MAX(updated_at) m FROM listings WHERE {VISIBLE}").fetchone()["m"]
con.close()
parts = -(-n // FICHES_PER_SITEMAP) or 1
lm = _iso_date(last)
maps = [f"{BASE_URL}/sitemaps/fiches-{i + 1}.xml" for i in range(parts)]
maps += [f"{BASE_URL}/sitemaps/villes.xml",
f"{BASE_URL}/sitemaps/villes-types.xml",
f"{BASE_URL}/sitemaps/types.xml",
f"{BASE_URL}/sitemaps/pages.xml"]
body = ('\n'
'\n'
+ "\n".join(f" {html.escape(m)}"
f"{lm}" for m in maps)
+ "\n")
return body
return Response(_cached("sm:index", 3600, build), media_type="application/xml")
def sitemap_file(name: str) -> Response:
m = re.fullmatch(r"fiches-(\d+)\.xml", name)
if m:
part = int(m.group(1))
def build():
con = db.connect()
rows = con.execute(
f"SELECT uid, address, title, city, updated_at FROM listings"
f" WHERE {VISIBLE} ORDER BY uid LIMIT ? OFFSET ?",
(FICHES_PER_SITEMAP, (part - 1) * FICHES_PER_SITEMAP)).fetchall()
con.close()
if not rows:
return None
return [_url_el(BASE_URL + fiche_href(r["uid"], listing_slug(r)),
_iso_date(r["updated_at"])) for r in rows]
urls = _cached(f"sm:fiches:{part}", 3600, build)
if urls is None:
return Response("Sitemap introuvable", status_code=404)
return _xml(urls)
if name == "villes.xml":
def build():
reg = registry()
lm = _city_lastmod()
return [_url_el(f"{BASE_URL}/a-vendre/{s}", lm.get(s))
for s, e in sorted(reg["cities"].items())
if e["n"] >= MIN_LISTINGS]
return _xml(_cached("sm:villes", 3600, build))
if name == "villes-types.xml":
def build():
reg = registry()
lm = _city_lastmod()
return [_url_el(f"{BASE_URL}/a-vendre/{cs}/{ts_}", lm.get(cs))
for (cs, ts_), n in sorted(reg["city_types"].items())
if n >= MIN_LISTINGS and cs in reg["cities"] and ts_ in reg["types"]
and reg["cities"][cs]["n"] >= MIN_LISTINGS]
return _xml(_cached("sm:villes-types", 3600, build))
if name == "types.xml":
def build():
reg = registry()
return [_url_el(f"{BASE_URL}/type/{s}")
for s, e in sorted(reg["types"].items()) if e["n"] >= MIN_LISTINGS]
return _xml(_cached("sm:types", 3600, build))
if name == "pages.xml":
return _xml([_url_el(f"{BASE_URL}{p}")
for p in ["/", "/stats", "/agences", "/taux-hypothecaires", "/demenageurs",
"/inspecteurs",
"/conditions", "/confidentialite"]])
return Response("Sitemap introuvable", status_code=404)
def _city_lastmod() -> dict[str, str]:
def build():
con = db.connect()
out: dict[str, str] = {}
for r in con.execute(
f"SELECT city, MAX(updated_at) m FROM listings WHERE {VISIBLE}"
" AND city<>'' GROUP BY city"):
s = slugify(r["city"])
if s:
prev = out.get(s)
cur = _iso_date(r["m"])
out[s] = max(prev, cur) if prev else cur
con.close()
return out
return _cached("sm:citylastmod", 3600, build)
# -----------------------------------------------------------------------------
# Résolution de slugs pour le frontend (pages /a-vendre côté client)
# -----------------------------------------------------------------------------
def resolve_slugs(ville: str | None, ptype: str | None) -> dict | None:
reg = registry()
out: dict = {}
if ville:
e = reg["cities"].get(ville)
if not e:
return None
out["city"] = e["label"]
out["city_n"] = e["n"]
if ptype:
e = reg["types"].get(ptype)
if not e:
return None
out["property_type"] = e["label"]
out["type_n"] = e["n"]
return out
# -----------------------------------------------------------------------------
# Routage : appelé par le catch-all de web.py
# -----------------------------------------------------------------------------
def render_for_path(path: str, query: dict) -> Response | None:
"""HTML SEO pour `path` (ex. « /a-vendre/levis »), ou None → index brut."""
path = path.rstrip("/") or "/"
try:
page = max(1, int(query.get("page", "1")))
except ValueError:
page = 1
if path == "/":
return render_home()
if path == "/demenageurs":
return render_demenageurs()
if path == "/inspecteurs":
return render_inspecteurs()
if path in _STATIC_META:
return render_static(path)
parts = [p for p in path.split("/") if p]
if parts[0] == "propriete" and len(parts) in (2, 3):
return render_listing(parts[1], parts[2] if len(parts) == 3 else None)
if parts[0] == "a-vendre" and len(parts) in (2, 3):
return _render_category(parts[1], parts[2] if len(parts) == 3 else None, page)
if parts[0] == "type" and len(parts) == 2:
return _render_category(None, parts[1], page)
return render_404()