Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# seo.py : rendu HTML côté serveur pour le référencement.5#6# Le SPA React reste inchangé : ce module pré-remplit le HTML initial servi par7# le catch-all de web.py — <title>/meta/canonical/og: uniques, JSON-LD8# schema.org, contenu et liens internes dans <div id="root"> (que React9# remplace au montage). Il génère aussi robots.txt et les sitemaps.10#11# Pages servies :12# / accueil enrichi (stats + liens villes/types)13# /propriete/{uid}[/{slug}] fiche (301 vers le slug canonique,14# 410 si retirée, 404 si inconnue)15# /a-vendre/{ville}[/{type}] pages programmatiques par ville (+ type)16# /type/{type} page par type de propriété (province)17# /stats /agences /conditions /confidentialite /profil meta dédiées18# /robots.txt /sitemap.xml /sitemaps/*.xml19# -----------------------------------------------------------------------------20from __future__ import annotations2122import html23import json24import os25import re26import time27import unicodedata28from datetime import datetime, timezone29from pathlib import Path30from urllib.parse import quote3132from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response3334from . import db3536ROOT = Path(__file__).resolve().parent.parent37FRONTEND_DIST = ROOT / "frontend" / "dist"38FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"3940BASE_URL = os.environ.get("IMMOKA_BASE_URL", "https://www.immo-ka.com").rstrip("/")41SITE_NAME = "Immo-Ka"4243# même règle de visibilité que /api/listings (dédup Centris + prix affichable)44VISIBLE = "active=1 AND dup_hidden=0 AND published=1"45MIN_LISTINGS = 3 # seuil de qualité : pas de page ville/type quasi vide46PAGE_SIZE = 48 # annonces par page sur les pages programmatiques4748# -----------------------------------------------------------------------------49# Utilitaires50# -----------------------------------------------------------------------------5152def slugify(s: str) -> str:53 """Slug URL — MÊME algorithme que slugify() de frontend/src/api.ts."""54 s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()55 s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")56 return s[:80].strip("-")575859def listing_slug(row) -> str:60 """Slug d'une fiche — MÊME logique que fichePath() de frontend/src/api.ts."""61 base = slugify(row["address"] or row["title"] or "")62 city = slugify(row["city"] or "")63 if city and city not in base:64 base = slugify(f"{base} {city}") if base else city65 return base666768def _esc(s) -> str:69 return html.escape(str(s or ""), quote=True)707172def _fmt_n(n) -> str:73 return f"{int(n):,}".replace(",", " ")747576def _fmt_price(p) -> str:77 return f"{_fmt_n(round(p))} $" if p is not None else "Prix sur demande"787980def _iso_date(ts) -> str:81 try:82 return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")83 except (TypeError, ValueError):84 return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")858687# petit cache TTL en mémoire (les données changent au rythme du watch, ~1 h)88_cache: dict[str, tuple[float, object]] = {}899091def _cached(key: str, ttl: float, build):92 now = time.time()93 hit = _cache.get(key)94 if hit and now - hit[0] < ttl:95 return hit[1]96 val = build()97 _cache[key] = (now, val)98 return val99100101# -----------------------------------------------------------------------------102# Registres de slugs (villes, types) — reconstruits toutes les 15 min103# -----------------------------------------------------------------------------104105def _build_registry() -> dict:106 con = db.connect()107 cities: dict[str, dict] = {}108 for r in con.execute(109 f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"110 " AND city<>'' GROUP BY city"):111 slug = slugify(r["city"])112 if len(slug) < 2:113 continue114 e = cities.setdefault(slug, {"label": r["city"], "n": 0, "values": [], "best": 0})115 e["n"] += r["n"]116 e["values"].append(r["city"])117 if r["n"] > e["best"]:118 e["best"] = r["n"]; e["label"] = r["city"]119 types: dict[str, dict] = {}120 for r in con.execute(121 f"SELECT property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"122 " AND property_type<>'' GROUP BY property_type"):123 slug = slugify(r["property_type"])124 if len(slug) < 2:125 continue126 e = types.setdefault(slug, {"label": r["property_type"], "n": 0, "values": [], "best": 0})127 e["n"] += r["n"]128 e["values"].append(r["property_type"])129 if r["n"] > e["best"]:130 e["best"] = r["n"]; e["label"] = r["property_type"]131 city_types: dict[tuple[str, str], int] = {}132 for r in con.execute(133 f"SELECT city, property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"134 " AND city<>'' AND property_type<>'' GROUP BY city, property_type"):135 cs, ts_ = slugify(r["city"]), slugify(r["property_type"])136 if len(cs) < 2 or len(ts_) < 2:137 continue138 city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"]139 con.close()140 return {"cities": cities, "types": types, "city_types": city_types}141142143def registry() -> dict:144 return _cached("registry", 900, _build_registry)145146147# -----------------------------------------------------------------------------148# Gabarit : dist/index.html, dépouillé de son <title>/description statiques149# -----------------------------------------------------------------------------150151_tpl_cache: tuple[float, str] | None = None152153154def _template() -> str:155 global _tpl_cache156 path = FRONTEND_DIR / "index.html"157 mtime = path.stat().st_mtime158 if _tpl_cache and _tpl_cache[0] == mtime:159 return _tpl_cache[1]160 tpl = path.read_text(encoding="utf-8")161 tpl = re.sub(r"<title>.*?</title>\s*", "", tpl, flags=re.S)162 tpl = re.sub(r'<meta name="description"[^>]*>\s*', "", tpl)163 tpl = re.sub(r'<meta (?:property="og:|name="twitter:)[^>]*>\s*', "", tpl)164 _tpl_cache = (mtime, tpl)165 return tpl166167168def _page(title: str, description: str, canonical: str, body: str,169 jsonld: list[dict] | None = None, og_image: str | None = None,170 og_type: str = "website", noindex: bool = False,171 status: int = 200) -> HTMLResponse:172 head = [173 f"<title>{_esc(title)}</title>",174 f'<meta name="description" content="{_esc(description)}" />',175 f'<link rel="canonical" href="{_esc(canonical)}" />',176 f'<meta property="og:site_name" content="{SITE_NAME}" />',177 '<meta property="og:locale" content="fr_CA" />',178 f'<meta property="og:type" content="{og_type}" />',179 f'<meta property="og:title" content="{_esc(title)}" />',180 f'<meta property="og:description" content="{_esc(description)}" />',181 f'<meta property="og:url" content="{_esc(canonical)}" />',182 ]183 if og_image:184 head.append(f'<meta property="og:image" content="{_esc(og_image)}" />')185 else:186 og_image = BASE_URL + "/og.png"187 head.append(f'<meta property="og:image" content="{_esc(og_image)}" />')188 head.append('<meta property="og:image:width" content="1200" />')189 head.append('<meta property="og:image:height" content="630" />')190 head.append('<meta name="twitter:card" content="summary_large_image" />')191 head.append(f'<meta name="twitter:image" content="{_esc(og_image)}" />')192 if noindex:193 head.append('<meta name="robots" content="noindex" />')194 for obj in (jsonld or []):195 head.append('<script type="application/ld+json">'196 + json.dumps(obj, ensure_ascii=False) + "</script>")197 tpl = _template()198 out = tpl.replace("</head>", " " + "\n ".join(head) + "\n </head>", 1)199 out = out.replace('<div id="root"></div>',200 f'<div id="root"><div class="container seo-ssr">{body}</div></div>', 1)201 return HTMLResponse(out, status_code=status)202203204# -----------------------------------------------------------------------------205# Blocs HTML réutilisables206# -----------------------------------------------------------------------------207208def fiche_href(uid: str, slug: str = "") -> str:209 path = f"/propriete/{quote(uid, safe='')}"210 return path + (f"/{slug}" if slug else "")211212213def _item_li(r) -> str:214 href = fiche_href(r["uid"], listing_slug(r))215 bits = [b for b in [216 r["property_type"],217 f"{r['bedrooms']} ch." if r["bedrooms"] is not None else "",218 f"{r['bathrooms']} sdb" if r["bathrooms"] is not None else "",219 f"{_fmt_n(round(r['area_sqft']))} pi²" if r["area_sqft"] else "",220 ] if b]221 name = r["address"] or r["title"] or "Propriété"222 loc = ", ".join(x for x in [r["sector"], r["city"]] if x)223 return (f'<li><a href="{href}"><strong>{_esc(name)}</strong></a> — '224 f'{_esc(_fmt_price(r["price"]))}'225 + (f' · {_esc(" · ".join(bits))}' if bits else "")226 + (f' · {_esc(loc)}' if loc else "") + "</li>")227228229def _agg_stats(con, cities: list[str] | None = None,230 types_values: list[str] | None = None) -> dict:231 where = VISIBLE232 args: list = []233 if cities:234 where += f" AND city IN ({','.join('?' * len(cities))})"235 args += cities236 if types_values:237 where += f" AND property_type IN ({','.join('?' * len(types_values))})"238 args += types_values239 row = con.execute(240 f"SELECT COUNT(*) n, AVG(price) avg_p, MIN(price) min_p, MAX(price) max_p"241 f" FROM listings WHERE {where}", args).fetchone()242 med = None243 if row["n"]:244 med_row = con.execute(245 f"SELECT price FROM listings WHERE {where}"246 f" ORDER BY price LIMIT 1 OFFSET ?", args + [row["n"] // 2]).fetchone()247 med = med_row["price"] if med_row else None248 return {"n": row["n"], "avg": row["avg_p"], "med": med,249 "min": row["min_p"], "max": row["max_p"], "where": where, "args": args}250251252def _pagination_html(base_path: str, page: int, pages: int) -> str:253 if pages <= 1:254 return ""255 out = ['<nav class="seo-pages" aria-label="Pagination">']256 if page > 1:257 prev = base_path if page == 2 else f"{base_path}?page={page - 1}"258 out.append(f'<a rel="prev" href="{prev}">← Page précédente</a> ')259 out.append(f"<span>Page {page} de {pages}</span>")260 if page < pages:261 out.append(f' <a rel="next" href="{base_path}?page={page + 1}">Page suivante →</a>')262 out.append("</nav>")263 return "".join(out)264265266def _breadcrumb_ld(crumbs: list[tuple[str, str]]) -> dict:267 return {268 "@context": "https://schema.org",269 "@type": "BreadcrumbList",270 "itemListElement": [271 {"@type": "ListItem", "position": i + 1, "name": name,272 "item": BASE_URL + path}273 for i, (name, path) in enumerate(crumbs)274 ],275 }276277278# -----------------------------------------------------------------------------279# Accueil280# -----------------------------------------------------------------------------281282def _home_data() -> dict:283 def build():284 con = db.connect()285 row = con.execute(286 f"SELECT COUNT(*) total, COUNT(DISTINCT city) cities,"287 f" COUNT(DISTINCT source) sources, AVG(price) avg_p"288 f" FROM listings WHERE {VISIBLE}").fetchone()289 recent = [dict(r) for r in con.execute(290 f"SELECT uid, address, title, city, sector, property_type, price,"291 f" bedrooms, bathrooms, area_sqft FROM listings WHERE {VISIBLE}"292 f" ORDER BY first_seen DESC LIMIT 12")]293 con.close()294 return {**dict(row), "recent": recent}295 return _cached("home", 900, build)296297298def render_home() -> HTMLResponse:299 d = _home_data()300 reg = registry()301 total = _fmt_n(d["total"])302 title = f"Immo-Ka — {total} propriétés à vendre au Québec · Un service Groupe KA"303 desc = (f"{total} propriétés à vendre dans {_fmt_n(d['cities'])} villes du Québec, "304 f"agrégées depuis {d['sources']} sources (RE/MAX, Via Capitale, Sutton, "305 f"Proprio Direct, DuProprio, Centris et plus) — mises à jour en continu. "306 f"Prix moyen : {_fmt_price(d['avg_p'])}.")307 top_cities = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:60]308 types = sorted(reg["types"].items(), key=lambda kv: -kv[1]["n"])309 body = [310 f"<h1>Propriétés à vendre au Québec — {total} annonces de toutes les agences</h1>",311 f"<p>Immo-Ka agrège en continu les propriétés à vendre affichées publiquement par "312 f"les agences de courtage immobilier et les plateformes du Québec : RE/MAX, "313 f"Via Capitale, Sutton, Proprio Direct, Engel & Völkers, Sotheby's, DuProprio, "314 f"LesPAC et plus — {d['sources']} sources, {_fmt_n(d['cities'])} villes, "315 f"prix moyen {_esc(_fmt_price(d['avg_p']))}. Chaque fiche renvoie à l'annonce "316 f"originale de l'agence.</p>",317 "<h2>Propriétés à vendre par ville</h2>",318 "<ul>" + "".join(319 f'<li><a href="/a-vendre/{s}">Propriétés à vendre à {_esc(e["label"])}</a>'320 f" ({_fmt_n(e['n'])})</li>"321 for s, e in top_cities if e["n"] >= MIN_LISTINGS) + "</ul>",322 "<h2>Par type de propriété</h2>",323 "<ul>" + "".join(324 f'<li><a href="/type/{s}">{_esc(e["label"])} à vendre au Québec</a>'325 f" ({_fmt_n(e['n'])})</li>"326 for s, e in types if e["n"] >= MIN_LISTINGS) + "</ul>",327 "<h2>Dernières propriétés ajoutées</h2>",328 "<ul>" + "".join(_item_li(r) for r in d["recent"]) + "</ul>",329 '<p><a href="/stats">Statistiques du marché</a> · '330 '<a href="/agences">Agences couvertes</a></p>',331 ]332 jsonld = [{333 "@context": "https://schema.org",334 "@type": "WebSite",335 "name": SITE_NAME,336 "url": BASE_URL + "/",337 "inLanguage": "fr-CA",338 "description": desc,339 "potentialAction": {340 "@type": "SearchAction",341 "target": {"@type": "EntryPoint",342 "urlTemplate": BASE_URL + "/?q={search_term_string}"},343 "query-input": "required name=search_term_string",344 },345 }, {346 "@context": "https://schema.org",347 "@type": "Organization",348 "name": "Groupe-Ka",349 "url": BASE_URL + "/",350 "email": "contact@groupe-ka.com",351 }]352 return _page(title, desc, BASE_URL + "/", "".join(body), jsonld)353354355# -----------------------------------------------------------------------------356# Pages programmatiques : ville, ville+type, type357# -----------------------------------------------------------------------------358359def _render_category(city_slug: str | None, type_slug: str | None,360 page: int) -> HTMLResponse:361 reg = registry()362 city = reg["cities"].get(city_slug) if city_slug else None363 ptype = reg["types"].get(type_slug) if type_slug else None364 if (city_slug and not city) or (type_slug and not ptype):365 return render_404()366 if city_slug and type_slug and reg["city_types"].get((city_slug, type_slug), 0) < 1:367 return render_404()368369 con = db.connect()370 st = _agg_stats(con, city["values"] if city else None,371 ptype["values"] if ptype else None)372 if st["n"] < 1:373 con.close()374 return render_404()375376 pages = max(1, -(-st["n"] // PAGE_SIZE))377 if page < 1 or page > pages:378 con.close()379 return render_404()380 rows = con.execute(381 f"SELECT uid, address, title, city, sector, property_type, price,"382 f" bedrooms, bathrooms, area_sqft FROM listings WHERE {st['where']}"383 f" ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?",384 st["args"] + [PAGE_SIZE, (page - 1) * PAGE_SIZE]).fetchall()385386 # maillage interne387 links = []388 if city:389 # types offerts dans cette ville390 tlinks = []391 for (cs, ts_), n in sorted(reg["city_types"].items(), key=lambda kv: -kv[1]):392 if cs == city_slug and n >= 1 and ts_ in reg["types"] and ts_ != type_slug:393 lbl = reg["types"][ts_]["label"]394 tlinks.append(f'<li><a href="/a-vendre/{cs}/{ts_}">'395 f"{_esc(lbl)} à vendre à {_esc(city['label'])}</a> ({_fmt_n(n)})</li>")396 if tlinks:397 links.append("<h2>Autres types de propriété à "398 + _esc(city["label"]) + "</h2><ul>" + "".join(tlinks[:20]) + "</ul>")399 if type_slug:400 links.append(f'<p><a href="/a-vendre/{city_slug}">Toutes les propriétés à vendre '401 f"à {_esc(city['label'])}</a> · "402 f'<a href="/type/{type_slug}">{_esc(ptype["label"])} à vendre au Québec</a></p>')403 top = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:30]404 links.append("<h2>Autres villes</h2><ul>" + "".join(405 f'<li><a href="/a-vendre/{s}{"/" + type_slug if type_slug and (s, type_slug) in reg["city_types"] else ""}">'406 f'Propriétés à vendre à {_esc(e["label"])}</a> ({_fmt_n(e["n"])})</li>'407 for s, e in top if s != city_slug and e["n"] >= MIN_LISTINGS) + "</ul>")408 con.close()409410 if city and ptype:411 base_path = f"/a-vendre/{city_slug}/{type_slug}"412 h1 = f"{ptype['label']} à vendre à {city['label']}"413 what = f"{ptype['label'].lower()} à vendre à {city['label']}"414 elif city:415 base_path = f"/a-vendre/{city_slug}"416 h1 = f"Propriétés à vendre à {city['label']}"417 what = f"propriétés à vendre à {city['label']}"418 else:419 base_path = f"/type/{type_slug}"420 h1 = f"{ptype['label']} à vendre au Québec"421 what = f"{ptype['label'].lower()} à vendre au Québec"422423 canonical = BASE_URL + base_path + (f"?page={page}" if page > 1 else "")424 title = f"{h1} — {_fmt_n(st['n'])} annonces" + (f" (page {page})" if page > 1 else "") + " | Immo-Ka"425 desc = (f"{_fmt_n(st['n'])} {what} : prix médian {_fmt_price(st['med'])}, "426 f"prix moyen {_fmt_price(st['avg'])}. Annonces de toutes les agences "427 f"(RE/MAX, Via Capitale, Sutton, DuProprio…), mises à jour en continu.")428 stats_p = (f"<p><strong>{_fmt_n(st['n'])}</strong> annonces · prix médian "429 f"<strong>{_esc(_fmt_price(st['med']))}</strong> · prix moyen "430 f"<strong>{_esc(_fmt_price(st['avg']))}</strong> · de "431 f"{_esc(_fmt_price(st['min']))} à {_esc(_fmt_price(st['max']))}.</p>")432 crumbs = [("Accueil", "/")]433 if city:434 crumbs.append((f"À vendre à {city['label']}", f"/a-vendre/{city_slug}"))435 if ptype:436 crumbs.append((f"{ptype['label']}", base_path))437 else:438 crumbs.append((h1, base_path))439 body = (f'<nav aria-label="Fil d\'Ariane">'440 + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs)441 + f"</nav><h1>{_esc(h1)}</h1>" + stats_p442 + "<ul>" + "".join(_item_li(r) for r in rows) + "</ul>"443 + _pagination_html(base_path, page, pages)444 + "".join(links))445 return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)])446447448# -----------------------------------------------------------------------------449# Fiche propriété450# -----------------------------------------------------------------------------451452_TYPE_SCHEMA = {453 "maison": "SingleFamilyResidence", "condo": "Apartment",454 "chalet": "House", "bi-generation": "House", "domaine-et-villa": "House",455 "duplex": "Residence", "triplex": "Residence", "quadruplex": "Residence",456 "multiplex": "Residence", "loft": "Apartment", "maison-mobile": "House",457}458459460def render_listing(uid: str, slug: str | None) -> Response:461 con = db.connect()462 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()463 con.close()464 if row is None:465 return render_404()466467 city_slug = slugify(row["city"] or "")468 city_known = city_slug in registry()["cities"]469 city_href = f"/a-vendre/{city_slug}" if city_known else "/"470471 if not row["active"]:472 # propriété retirée / vendue → 410 Gone, avec des portes de sortie473 name = row["address"] or row["title"] or "Propriété"474 body = (f"<h1>Cette propriété n'est plus à vendre</h1>"475 f"<p>L'annonce « {_esc(name)} » ({_esc(row['city'] or 'Québec')}) a été "476 f"retirée ou vendue.</p><ul>"477 + (f'<li><a href="{city_href}">Propriétés à vendre à '478 f"{_esc(row['city'])}</a></li>" if city_known else "")479 + '<li><a href="/">Toutes les propriétés à vendre au Québec</a></li></ul>')480 return _page(f"Propriété retirée — {name} | {SITE_NAME}",481 "Cette annonce a été retirée ou vendue.",482 BASE_URL + fiche_href(uid), body, noindex=True, status=410)483484 expected = listing_slug(row)485 if expected and slug != expected:486 return RedirectResponse(BASE_URL + fiche_href(uid, expected), status_code=301)487488 d = dict(row)489 images = json.loads(d.get("images") or "[]")490 features = json.loads(d.get("features") or "[]")491 name = d["address"] or d["title"] or "Propriété à vendre"492 loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec"493 canonical = BASE_URL + fiche_href(uid, expected)494 ptype = d["property_type"] or "Propriété"495496 specs = [(lbl, val) for lbl, val in [497 ("Type", ptype),498 ("Prix", _fmt_price(d["price"]) if d["price"] is not None else d["price_label"]),499 ("Chambres", d["bedrooms"]),500 ("Salles de bain", d["bathrooms"]),501 ("Salles d'eau", d["powder_rooms"]),502 ("Superficie habitable", f"{_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else None),503 ("Terrain", f"{_fmt_n(round(d['lot_sqft']))} pi²" if d["lot_sqft"] else None),504 ("Année de construction", d["year_built"]),505 ("Ville", d["city"]),506 ("Secteur", d["sector"]),507 ("N° MLS", d["mls"]),508 ("Courtier", d["broker_name"]),509 ("Agence", d["agency"]),510 ] if val not in (None, "", 0)]511 descr = (d["description"] or "").strip()512 if len(descr) > 1500:513 descr = descr[:1500].rsplit(" ", 1)[0] + "…"514515 type_slug = slugify(ptype)516 crumbs = [("Accueil", "/")]517 if city_known:518 crumbs.append((f"À vendre à {d['city']}", city_href))519 if (city_slug, type_slug) in registry()["city_types"]:520 crumbs.append((ptype, f"/a-vendre/{city_slug}/{type_slug}"))521 crumbs.append((name, fiche_href(uid, expected)))522523 body = [524 '<nav aria-label="Fil d\'Ariane">'525 + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs[:-1])526 + f" › {_esc(name)}</nav>",527 f"<h1>{_esc(name)}</h1>",528 f"<p><strong>{_esc(ptype)} à vendre à {_esc(loc)}</strong> — "529 f"{_esc(_fmt_price(d['price']) if d['price'] is not None else (d['price_label'] or 'Prix sur demande'))}</p>",530 ]531 if images:532 body.append("".join(533 f'<img src="{_esc(u)}" alt="{_esc(name)} — photo {i + 1}" loading="lazy" />'534 for i, u in enumerate(images[:3])))535 body.append("<h2>Caractéristiques</h2><ul>" + "".join(536 f"<li><strong>{_esc(l)} :</strong> {_esc(v)}</li>" for l, v in specs) + "</ul>")537 if descr:538 body.append(f"<h2>Description</h2><p>{_esc(descr)}</p>")539 if features:540 body.append("<h2>Détails</h2><ul>" + "".join(541 f"<li>{_esc(f)}</li>" for f in features[:20]) + "</ul>")542 if d["url"]:543 body.append(f'<p><a href="{_esc(d["url"])}" rel="noopener">'544 f"Voir l'annonce originale chez {_esc(d['broker_name'] or d['agency'] or 'l’agence')}</a></p>")545 if city_known:546 body.append(f'<p><a href="{city_href}">Propriétés à vendre à {_esc(d["city"])}</a>'547 + (f' · <a href="/a-vendre/{city_slug}/{type_slug}">{_esc(ptype)} à vendre à '548 f'{_esc(d["city"])}</a>'549 if (city_slug, type_slug) in registry()["city_types"] else "") + "</p>")550551 about_type = _TYPE_SCHEMA.get(type_slug, "Residence")552 about: dict = {553 "@type": about_type,554 "name": name,555 "address": {"@type": "PostalAddress",556 "streetAddress": d["address"] or None,557 "addressLocality": d["city"] or None,558 "addressRegion": "QC", "addressCountry": "CA"},559 }560 if d["lat"] is not None and d["lng"] is not None:561 about["geo"] = {"@type": "GeoCoordinates",562 "latitude": d["lat"], "longitude": d["lng"]}563 if d["bedrooms"] is not None:564 about["numberOfBedrooms"] = d["bedrooms"]565 if d["bathrooms"] is not None:566 about["numberOfBathroomsTotal"] = d["bathrooms"]567 if d["area_sqft"]:568 about["floorSize"] = {"@type": "QuantitativeValue",569 "value": round(d["area_sqft"]), "unitCode": "FTK"}570 if d["year_built"]:571 about["yearBuilt"] = d["year_built"]572 about = {k: v for k, v in about.items() if v is not None}573 about["address"] = {k: v for k, v in about["address"].items() if v is not None}574 jsonld: list[dict] = [{575 "@context": "https://schema.org",576 "@type": "RealEstateListing",577 "name": name,578 "url": canonical,579 "inLanguage": "fr-CA",580 "datePosted": _iso_date(d.get("first_seen")),581 "image": images[:6] or None,582 "about": about,583 }, _breadcrumb_ld(crumbs)]584 if d["price"] is not None:585 jsonld[0]["offers"] = {"@type": "Offer", "price": round(d["price"], 2),586 "priceCurrency": "CAD",587 "availability": "https://schema.org/InStock"}588 jsonld[0] = {k: v for k, v in jsonld[0].items() if v is not None}589590 title = f"{name} — {ptype} à vendre, {d['city'] or 'Québec'} | {_fmt_price(d['price']) if d['price'] is not None else 'Prix sur demande'}"591 meta_desc = (f"{ptype} à vendre à {loc}"592 + (f", {d['bedrooms']} chambres" if d["bedrooms"] else "")593 + (f", {_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else "")594 + f" — {_fmt_price(d['price']) if d['price'] is not None else 'prix sur demande'}. "595 + (descr[:120] + "…" if len(descr) > 120 else descr))596 return _page(title, meta_desc, canonical, "".join(body), jsonld,597 og_image=images[0] if images else None, og_type="article")598599600# -----------------------------------------------------------------------------601# Pages statiques du SPA (meta dédiées) et 404602# -----------------------------------------------------------------------------603604_STATIC_META = {605 "/stats": ("Statistiques du marché immobilier québécois",606 "Prix moyens, écarts prix demandé vs estimation Vrai-Prix par bannière, "607 "et volumes d'annonces par source — statistiques en continu d'Immo-Ka.",608 False),609 "/agences": ("Agences immobilières couvertes",610 "Toutes les bannières et agences agrégées par Immo-Ka : RE/MAX, Via Capitale, "611 "Sutton, Proprio Direct, Engel & Völkers, Sotheby's, DuProprio et plus.",612 False),613 "/taux-hypothecaires": (614 "Taux hypothécaires au Canada — comparateur en direct",615 "Comparez les taux hypothécaires réellement publiés par RBC, TD, BMO, CIBC, "616 "Scotia, BNC, Desjardins, Tangerine, EQ et plus — fixes et variables, avec "617 "source officielle, fraîcheur et historique. Collecte continue par Immo-Ka.",618 False),619 "/conditions": ("Conditions d'utilisation",620 "Conditions d'utilisation de la plateforme Immo-Ka (Groupe-Ka).", False),621 "/confidentialite": ("Politique de confidentialité",622 "Politique de confidentialité d'Immo-Ka (Groupe-Ka) — Loi 25.", False),623 "/profil": ("Mon profil", "Votre compte Groupe KA sur Immo-Ka.", True),624 "/contact": ("Contact — Groupe KA",625 "Écrire au Groupe KA : contact@groupe-ka.com (projets et données), "626 "info@groupe-ka.com (médias), admin@groupe-ka.com (légal et Loi 25). "627 "Immo-Ka est un service Groupe KA — https://www.groupe-ka.com.",628 False),629}630631632def render_static(path: str) -> HTMLResponse:633 t, desc, noindex = _STATIC_META[path]634 body = f"<h1>{_esc(t)}</h1><p>{_esc(desc)}</p>"635 return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex)636637638def render_demenageurs() -> HTMLResponse:639 from . import demenageurs as dem640 doc = dem.load()641 movers = doc.get("movers", [])642 n = len(movers)643 title = f"Déménageurs au Québec — annuaire complet ({n} entreprises) | {SITE_NAME}"644 description = (f"Annuaire complet des déménageurs du Québec : {n} entreprises de "645 "déménagement dans les 17 régions administratives, avec téléphone, "646 "site web et note Google. Trouvez un déménageur près de chez vous.")647 by_region: dict[str, list] = {}648 for m in movers:649 by_region.setdefault(m["region"], []).append(m)650651 def _mover_ld(m: dict) -> dict:652 d = {"@type": "MovingCompany", "name": m["name"]}653 if m.get("address"):654 d["address"] = m["address"]655 if m.get("phone"):656 d["telephone"] = m["phone"]657 if m.get("website"):658 d["url"] = m["website"]659 return d660661 parts = [f"<h1>Déménageurs du Québec — {n} entreprises</h1>",662 "<p>" + _esc(description) + "</p>"]663 for region in sorted(by_region, key=lambda r: -len(by_region[r])):664 ms = by_region[region]665 parts.append(f"<h2>{_esc(region)} ({len(ms)})</h2><ul>")666 for m in ms:667 item = f"<b>{_esc(m['name'])}</b> — {_esc(m['city'])}"668 if m.get("phone"):669 item += ", " + _esc(m["phone"])670 if m.get("website"):671 item += ' — <a href="' + _esc(m["website"]) + '" rel="nofollow">site web</a>'672 parts.append("<li>" + item + "</li>")673 parts.append("</ul>")674 jsonld = [_breadcrumb_ld([("Accueil", "/"), ("Déménageurs", "/demenageurs")]),675 {"@context": "https://schema.org", "@type": "ItemList",676 "name": "Déménageurs du Québec", "numberOfItems": n,677 "itemListElement": [678 {"@type": "ListItem", "position": i + 1, "item": _mover_ld(m)}679 for i, m in enumerate(movers[:100])]}]680 return _page(title, description, BASE_URL + "/demenageurs", "".join(parts),681 jsonld=jsonld)682683684def render_inspecteurs() -> HTMLResponse:685 from . import inspecteurs as insp686 doc = insp.load()687 entries = doc.get("movers", [])688 n = len(entries)689 title = (f"Inspecteurs en bâtiment au Québec — annuaire complet "690 f"({n} entreprises) | {SITE_NAME}")691 description = (f"Annuaire complet des inspecteurs en bâtiment du Québec : {n} "692 "entreprises d'inspection préachat, prévente et préréception dans "693 "les 17 régions administratives, avec téléphone, site web et note "694 "Google. Trouvez un inspecteur près de chez vous.")695 by_region: dict[str, list] = {}696 for m in entries:697 by_region.setdefault(m["region"], []).append(m)698699 def _insp_ld(m: dict) -> dict:700 d = {"@type": "ProfessionalService", "name": m["name"]}701 if m.get("address"):702 d["address"] = m["address"]703 if m.get("phone"):704 d["telephone"] = m["phone"]705 if m.get("website"):706 d["url"] = m["website"]707 return d708709 parts = [f"<h1>Inspecteurs en bâtiment du Québec — {n} entreprises</h1>",710 "<p>" + _esc(description) + "</p>"]711 for region in sorted(by_region, key=lambda r: -len(by_region[r])):712 ms = by_region[region]713 parts.append(f"<h2>{_esc(region)} ({len(ms)})</h2><ul>")714 for m in ms:715 item = f"<b>{_esc(m['name'])}</b> — {_esc(m['city'])}"716 if m.get("phone"):717 item += ", " + _esc(m["phone"])718 if m.get("website"):719 item += ' — <a href="' + _esc(m["website"]) + '" rel="nofollow">site web</a>'720 parts.append("<li>" + item + "</li>")721 parts.append("</ul>")722 jsonld = [_breadcrumb_ld([("Accueil", "/"), ("Inspecteurs en bâtiment", "/inspecteurs")]),723 {"@context": "https://schema.org", "@type": "ItemList",724 "name": "Inspecteurs en bâtiment du Québec", "numberOfItems": n,725 "itemListElement": [726 {"@type": "ListItem", "position": i + 1, "item": _insp_ld(m)}727 for i, m in enumerate(entries[:100])]}]728 return _page(title, description, BASE_URL + "/inspecteurs", "".join(parts),729 jsonld=jsonld)730731732def render_404() -> HTMLResponse:733 body = ('<h1>Page introuvable</h1><p>Le lien demandé n\'existe pas.</p>'734 '<p><a href="/">Toutes les propriétés à vendre au Québec</a></p>')735 return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.",736 BASE_URL + "/", body, noindex=True, status=404)737738739# -----------------------------------------------------------------------------740# robots.txt et sitemaps741# -----------------------------------------------------------------------------742743FICHES_PER_SITEMAP = 40000744745746def robots_txt() -> PlainTextResponse:747 return PlainTextResponse(748 "User-agent: *\n"749 "Allow: /\n"750 "Disallow: /api/\n"751 "Disallow: /profil\n"752 f"\nSitemap: {BASE_URL}/sitemap.xml\n")753754755def _xml(urls: list[str]) -> Response:756 body = ('<?xml version="1.0" encoding="UTF-8"?>\n'757 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'758 + "\n".join(urls) + "\n</urlset>")759 return Response(body, media_type="application/xml")760761762def _url_el(loc: str, lastmod: str | None = None) -> str:763 lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""764 return f" <url><loc>{html.escape(loc)}</loc>{lm}</url>"765766767def sitemap_index() -> Response:768 def build():769 con = db.connect()770 n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"]771 last = con.execute(772 f"SELECT MAX(updated_at) m FROM listings WHERE {VISIBLE}").fetchone()["m"]773 con.close()774 parts = -(-n // FICHES_PER_SITEMAP) or 1775 lm = _iso_date(last)776 maps = [f"{BASE_URL}/sitemaps/fiches-{i + 1}.xml" for i in range(parts)]777 maps += [f"{BASE_URL}/sitemaps/villes.xml",778 f"{BASE_URL}/sitemaps/villes-types.xml",779 f"{BASE_URL}/sitemaps/types.xml",780 f"{BASE_URL}/sitemaps/pages.xml"]781 body = ('<?xml version="1.0" encoding="UTF-8"?>\n'782 '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'783 + "\n".join(f" <sitemap><loc>{html.escape(m)}</loc>"784 f"<lastmod>{lm}</lastmod></sitemap>" for m in maps)785 + "\n</sitemapindex>")786 return body787 return Response(_cached("sm:index", 3600, build), media_type="application/xml")788789790def sitemap_file(name: str) -> Response:791 m = re.fullmatch(r"fiches-(\d+)\.xml", name)792 if m:793 part = int(m.group(1))794795 def build():796 con = db.connect()797 rows = con.execute(798 f"SELECT uid, address, title, city, updated_at FROM listings"799 f" WHERE {VISIBLE} ORDER BY uid LIMIT ? OFFSET ?",800 (FICHES_PER_SITEMAP, (part - 1) * FICHES_PER_SITEMAP)).fetchall()801 con.close()802 if not rows:803 return None804 return [_url_el(BASE_URL + fiche_href(r["uid"], listing_slug(r)),805 _iso_date(r["updated_at"])) for r in rows]806 urls = _cached(f"sm:fiches:{part}", 3600, build)807 if urls is None:808 return Response("Sitemap introuvable", status_code=404)809 return _xml(urls)810811 if name == "villes.xml":812 def build():813 reg = registry()814 lm = _city_lastmod()815 return [_url_el(f"{BASE_URL}/a-vendre/{s}", lm.get(s))816 for s, e in sorted(reg["cities"].items())817 if e["n"] >= MIN_LISTINGS]818 return _xml(_cached("sm:villes", 3600, build))819820 if name == "villes-types.xml":821 def build():822 reg = registry()823 lm = _city_lastmod()824 return [_url_el(f"{BASE_URL}/a-vendre/{cs}/{ts_}", lm.get(cs))825 for (cs, ts_), n in sorted(reg["city_types"].items())826 if n >= MIN_LISTINGS and cs in reg["cities"] and ts_ in reg["types"]827 and reg["cities"][cs]["n"] >= MIN_LISTINGS]828 return _xml(_cached("sm:villes-types", 3600, build))829830 if name == "types.xml":831 def build():832 reg = registry()833 return [_url_el(f"{BASE_URL}/type/{s}")834 for s, e in sorted(reg["types"].items()) if e["n"] >= MIN_LISTINGS]835 return _xml(_cached("sm:types", 3600, build))836837 if name == "pages.xml":838 return _xml([_url_el(f"{BASE_URL}{p}")839 for p in ["/", "/stats", "/agences", "/taux-hypothecaires", "/demenageurs",840 "/inspecteurs",841 "/conditions", "/confidentialite"]])842843 return Response("Sitemap introuvable", status_code=404)844845846def _city_lastmod() -> dict[str, str]:847 def build():848 con = db.connect()849 out: dict[str, str] = {}850 for r in con.execute(851 f"SELECT city, MAX(updated_at) m FROM listings WHERE {VISIBLE}"852 " AND city<>'' GROUP BY city"):853 s = slugify(r["city"])854 if s:855 prev = out.get(s)856 cur = _iso_date(r["m"])857 out[s] = max(prev, cur) if prev else cur858 con.close()859 return out860 return _cached("sm:citylastmod", 3600, build)861862863# -----------------------------------------------------------------------------864# Résolution de slugs pour le frontend (pages /a-vendre côté client)865# -----------------------------------------------------------------------------866867def resolve_slugs(ville: str | None, ptype: str | None) -> dict | None:868 reg = registry()869 out: dict = {}870 if ville:871 e = reg["cities"].get(ville)872 if not e:873 return None874 out["city"] = e["label"]875 out["city_n"] = e["n"]876 if ptype:877 e = reg["types"].get(ptype)878 if not e:879 return None880 out["property_type"] = e["label"]881 out["type_n"] = e["n"]882 return out883884885# -----------------------------------------------------------------------------886# Routage : appelé par le catch-all de web.py887# -----------------------------------------------------------------------------888889def render_for_path(path: str, query: dict) -> Response | None:890 """HTML SEO pour `path` (ex. « /a-vendre/levis »), ou None → index brut."""891 path = path.rstrip("/") or "/"892 try:893 page = max(1, int(query.get("page", "1")))894 except ValueError:895 page = 1896897 if path == "/":898 return render_home()899 if path == "/demenageurs":900 return render_demenageurs()901 if path == "/inspecteurs":902 return render_inspecteurs()903 if path in _STATIC_META:904 return render_static(path)905906 parts = [p for p in path.split("/") if p]907 if parts[0] == "propriete" and len(parts) in (2, 3):908 return render_listing(parts[1], parts[2] if len(parts) == 3 else None)909 if parts[0] == "a-vendre" and len(parts) in (2, 3):910 return _render_category(parts[1], parts[2] if len(parts) == 3 else None, page)911 if parts[0] == "type" and len(parts) == 2:912 return _render_category(None, parts[1], page)913 return render_404()914