spb/toit-ka Public
Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com
Python 40.2%
TypeScript 39%
CSS 20.2%
HTML 0.7%
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3# Project: Toit-Ka4# -----------------------------------------------------------------------------5# seo.py : rendu HTML côté serveur pour le référencement.6#7# Le SPA React reste inchangé : ce module pré-remplit le HTML initial servi par8# le catch-all de web.py — <title>/meta/canonical/og uniques, JSON-LD9# schema.org, contenu et liens internes dans <div id="root"> (que React10# remplace au montage). Il génère aussi robots.txt et les sitemaps.11#12# Pages servies :13# / accueil bi-univers (louer + acheter)14# /annonce/{uid}[/{slug}] fiche (301 slug canonique, 410 retirée)15# /louer/{ville}[/{type}] pages programmatiques location16# /acheter/{ville}[/{type}] pages programmatiques vente17# /louer/type/{type} /acheter/type/{type} pages par type (province)18# /stats /conditions /confidentialite /profil meta dédiées19# /robots.txt /sitemap.xml /sitemaps/*.xml20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html24import json25import os26import re27import time28from datetime import datetime, timezone29from pathlib import Path30from urllib.parse import quote3132from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response3334from . import db35from .villes import slugify3637ROOT = Path(__file__).resolve().parent.parent38FRONTEND_DIST = ROOT / "frontend" / "dist"39FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"4041BASE_URL = os.environ.get("TOITKA_BASE_URL", "https://www.toit-ka.com").rstrip("/")42SITE_NAME = "Toit-Ka"4344VISIBLE = "active=1"45MIN_LISTINGS = 346PAGE_SIZE = 4847FICHES_PER_SITEMAP = 400004849# vocabulaire par univers50TXV = {51 "louer": {52 "path": "louer", "noun": "logements", "verbe": "à louer",53 "prix": "loyer", "Prix": "Loyer",54 },55 "acheter": {56 "path": "acheter", "noun": "propriétés", "verbe": "à vendre",57 "prix": "prix", "Prix": "Prix",58 },59}606162# --- utilitaires ----------------------------------------------------------------6364def listing_slug(row) -> str:65 """Slug d'une fiche — MÊME logique que fichePath() de frontend/src/api.ts."""66 base = slugify(row["address"] or row["title"] or "")67 city = slugify(row["city"] or "")68 if city and city not in base:69 base = slugify(f"{base} {city}") if base else city70 return base717273def fiche_href(uid: str, slug: str = "") -> str:74 return f"/annonce/{quote(uid, safe='')}" + (f"/{slug}" if slug else "")757677def _esc(s) -> str:78 return html.escape(str(s or ""), quote=True)798081def _fmt_n(n) -> str:82 return f"{int(n):,}".replace(",", " ")838485def _fmt_price(p, tx: str) -> str:86 if p is None:87 return "Prix sur demande"88 s = f"{_fmt_n(round(p))} $"89 return s + " /mois" if tx == "louer" else s909192def _iso_date(ts) -> str:93 try:94 return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")95 except (TypeError, ValueError):96 return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")979899_cache: dict[str, tuple[float, object]] = {}100101102def _cached(key: str, ttl: float, build):103 now = time.time()104 hit = _cache.get(key)105 if hit and now - hit[0] < ttl:106 return hit[1]107 val = build()108 _cache[key] = (now, val)109 return val110111112# --- registres de slugs (par univers) — reconstruits toutes les 15 min ----------113114def _build_registry() -> dict:115 con = db.connect()116 out: dict = {}117 try:118 for tx in ("louer", "acheter"):119 cities: dict[str, dict] = {}120 for r in con.execute(121 f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"122 " AND transaction_type=? AND city<>'' GROUP BY city", (tx,)):123 slug = slugify(r["city"])124 if len(slug) >= 2:125 cities[slug] = {"label": r["city"], "n": r["n"]}126 types: dict[str, dict] = {}127 for r in con.execute(128 f"SELECT type, COUNT(*) n FROM listings WHERE {VISIBLE}"129 " AND transaction_type=? AND type<>'' GROUP BY type", (tx,)):130 slug = slugify(r["type"])131 if len(slug) >= 2:132 types[slug] = {"label": r["type"], "n": r["n"]}133 city_types: dict[tuple[str, str], int] = {}134 for r in con.execute(135 f"SELECT city, type, COUNT(*) n FROM listings WHERE {VISIBLE}"136 " AND transaction_type=? AND city<>'' AND type<>''"137 " GROUP BY city, type", (tx,)):138 cs, ts_ = slugify(r["city"]), slugify(r["type"])139 if len(cs) >= 2 and len(ts_) >= 2:140 city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"]141 out[tx] = {"cities": cities, "types": types, "city_types": city_types}142 finally:143 con.close()144 return out145146147def registry() -> dict:148 return _cached("registry", 900, _build_registry)149150151# --- gabarit ---------------------------------------------------------------------152153_tpl_cache: tuple[float, str] | None = None154155156def _template() -> str:157 global _tpl_cache158 path = FRONTEND_DIR / "index.html"159 mtime = path.stat().st_mtime160 if _tpl_cache and _tpl_cache[0] == mtime:161 return _tpl_cache[1]162 tpl = path.read_text(encoding="utf-8")163 tpl = re.sub(r"<title>.*?</title>\s*", "", tpl, flags=re.S)164 tpl = re.sub(r'<meta name="description"[^>]*>\s*', "", tpl)165 _tpl_cache = (mtime, tpl)166 return tpl167168169def _page(title: str, description: str, canonical: str, body: str,170 jsonld: list[dict] | None = None, og_image: str | None = None,171 og_type: str = "website", noindex: bool = False,172 status: int = 200) -> HTMLResponse:173 head = [174 f"<title>{_esc(title)}</title>",175 f'<meta name="description" content="{_esc(description)}" />',176 f'<link rel="canonical" href="{_esc(canonical)}" />',177 f'<meta property="og:site_name" content="{SITE_NAME}" />',178 '<meta property="og:locale" content="fr_CA" />',179 f'<meta property="og:type" content="{og_type}" />',180 f'<meta property="og:title" content="{_esc(title)}" />',181 f'<meta property="og:description" content="{_esc(description)}" />',182 f'<meta property="og:url" content="{_esc(canonical)}" />',183 ]184 if og_image:185 head.append(f'<meta property="og:image" content="{_esc(og_image)}" />')186 head.append('<meta name="twitter:card" content="summary_large_image" />')187 else:188 head.append('<meta name="twitter:card" content="summary" />')189 if noindex:190 head.append('<meta name="robots" content="noindex" />')191 for obj in (jsonld or []):192 head.append('<script type="application/ld+json">'193 + json.dumps(obj, ensure_ascii=False) + "</script>")194 tpl = _template()195 out = tpl.replace("</head>", " " + "\n ".join(head) + "\n </head>", 1)196 out = out.replace('<div id="root"></div>',197 f'<div id="root"><div class="container seo-ssr">{body}</div></div>', 1)198 return HTMLResponse(out, status_code=status)199200201# --- blocs -----------------------------------------------------------------------202203def _item_li(r) -> str:204 tx = r["transaction_type"]205 href = fiche_href(r["uid"], listing_slug(r))206 bits = [b for b in [207 r["type"],208 f"{r['bedrooms']} ch." if r["bedrooms"] is not None else "",209 f"{_fmt_n(round(r['area_sqft']))} pi²" if r["area_sqft"] else "",210 ] if b]211 name = r["address"] or r["title"] or ("Logement" if tx == "louer" else "Propriété")212 loc = ", ".join(x for x in [r["sector"], r["city"]] if x)213 return (f'<li><a href="{href}"><strong>{_esc(name)}</strong></a> — '214 f'{_esc(_fmt_price(r["price"], tx))}'215 + (f' · {_esc(" · ".join(bits))}' if bits else "")216 + (f' · {_esc(loc)}' if loc else "") + "</li>")217218219def _agg_stats(con, tx: str, city_label: str | None = None,220 type_label: str | None = None) -> dict:221 where = f"{VISIBLE} AND transaction_type=?"222 args: list = [tx]223 if city_label:224 where += " AND city=?"; args.append(city_label)225 if type_label:226 where += " AND type=?"; args.append(type_label)227 row = con.execute(228 f"SELECT COUNT(*) n, AVG(price) avg_p FROM listings WHERE {where}",229 args).fetchone()230 priced = con.execute(231 f"SELECT COUNT(*) n FROM listings WHERE {where} AND price IS NOT NULL",232 args).fetchone()["n"]233 med = None234 if priced:235 med_row = con.execute(236 f"SELECT price FROM listings WHERE {where} AND price IS NOT NULL"237 f" ORDER BY price LIMIT 1 OFFSET ?", args + [priced // 2]).fetchone()238 med = med_row["price"] if med_row else None239 return {"n": row["n"], "avg": row["avg_p"], "med": med,240 "where": where, "args": args}241242243def _breadcrumb_ld(crumbs: list[tuple[str, str]]) -> dict:244 return {245 "@context": "https://schema.org",246 "@type": "BreadcrumbList",247 "itemListElement": [248 {"@type": "ListItem", "position": i + 1, "name": name,249 "item": BASE_URL + path}250 for i, (name, path) in enumerate(crumbs)251 ],252 }253254255def _pagination_html(base_path: str, page: int, pages: int) -> str:256 if pages <= 1:257 return ""258 out = ['<nav class="seo-pages" aria-label="Pagination">']259 if page > 1:260 prev = base_path if page == 2 else f"{base_path}?page={page - 1}"261 out.append(f'<a rel="prev" href="{prev}">← Page précédente</a> ')262 out.append(f"<span>Page {page} de {pages}</span>")263 if page < pages:264 out.append(f' <a rel="next" href="{base_path}?page={page + 1}">Page suivante →</a>')265 out.append("</nav>")266 return "".join(out)267268269# --- accueil ----------------------------------------------------------------------270271def _home_data() -> dict:272 def build():273 con = db.connect()274 try:275 out = {}276 for tx in ("louer", "acheter"):277 row = con.execute(278 f"SELECT COUNT(*) total, COUNT(DISTINCT NULLIF(city,'')) cities,"279 f" COUNT(DISTINCT source) sources, AVG(price) avg_p"280 f" FROM listings WHERE {VISIBLE} AND transaction_type=?",281 (tx,)).fetchone()282 recent = [dict(r) for r in con.execute(283 f"SELECT uid, transaction_type, address, title, city, sector,"284 f" type, price, bedrooms, area_sqft FROM listings"285 f" WHERE {VISIBLE} AND transaction_type=?"286 f" ORDER BY first_seen DESC LIMIT 8", (tx,))]287 out[tx] = {**dict(row), "recent": recent}288 return out289 finally:290 con.close()291 return _cached("home", 900, build)292293294def render_home() -> HTMLResponse:295 d = _home_data()296 reg = registry()297 total = d["louer"]["total"] + d["acheter"]["total"]298 title = (f"Toit-Ka — Louer ou acheter au Québec : {_fmt_n(total)} annonces, "299 f"un seul endroit")300 desc = (f"{_fmt_n(d['louer']['total'])} logements à louer et "301 f"{_fmt_n(d['acheter']['total'])} propriétés à vendre partout au Québec, "302 f"agrégés depuis les gestionnaires immobiliers, agences et plateformes "303 f"(RE/MAX, Via Capitale, Sutton, DuProprio, Kijiji…) — mises à jour en "304 f"continu, chaque fiche renvoie à l'annonce originale.")305 body = [306 f"<h1>Louer ou acheter un toit au Québec — {_fmt_n(total)} annonces, un seul endroit</h1>",307 f"<p>Toit-Ka réunit la location (Lou-Ka) et la vente (Immo-Ka) : "308 f"{_fmt_n(d['louer']['total'])} logements à louer (loyer moyen "309 f"{_esc(_fmt_price(d['louer']['avg_p'], 'louer'))}) et "310 f"{_fmt_n(d['acheter']['total'])} propriétés à vendre (prix moyen "311 f"{_esc(_fmt_price(d['acheter']['avg_p'], 'acheter'))}), mis à jour en continu. "312 f"Chaque fiche renvoie à l'annonce originale de la source.</p>",313 ]314 for tx, label in (("louer", "Logements à louer"), ("acheter", "Propriétés à vendre")):315 top = sorted(reg[tx]["cities"].items(), key=lambda kv: -kv[1]["n"])[:40]316 verbe = TXV[tx]["verbe"]317 body.append(f"<h2>{label} par ville</h2><ul>" + "".join(318 f'<li><a href="/{tx}/{s}">{label} à {_esc(e["label"])}</a>'319 f" ({_fmt_n(e['n'])})</li>"320 for s, e in top if e["n"] >= MIN_LISTINGS) + "</ul>")321 types = sorted(reg[tx]["types"].items(), key=lambda kv: -kv[1]["n"])322 body.append(f"<h3>Par type</h3><ul>" + "".join(323 f'<li><a href="/{tx}/type/{s}">{_esc(e["label"])} {verbe} au Québec</a>'324 f" ({_fmt_n(e['n'])})</li>"325 for s, e in types if e["n"] >= MIN_LISTINGS) + "</ul>")326 body.append("<h3>Dernières annonces</h3><ul>"327 + "".join(_item_li(r) for r in d[tx]["recent"]) + "</ul>")328 body.append('<p><a href="/stats">Statistiques du marché</a></p>')329 jsonld = [{330 "@context": "https://schema.org",331 "@type": "WebSite",332 "name": SITE_NAME,333 "url": BASE_URL + "/",334 "inLanguage": "fr-CA",335 "description": desc,336 "potentialAction": {337 "@type": "SearchAction",338 "target": {"@type": "EntryPoint",339 "urlTemplate": BASE_URL + "/?q={search_term_string}"},340 "query-input": "required name=search_term_string",341 },342 }, {343 "@context": "https://schema.org",344 "@type": "Organization",345 "name": "Groupe-Ka",346 "url": BASE_URL + "/",347 "email": "contact@groupe-ka.com",348 }]349 return _page(title, desc, BASE_URL + "/", "".join(body), jsonld)350351352# --- pages programmatiques ---------------------------------------------------------353354def _render_category(tx: str, city_slug: str | None, type_slug: str | None,355 page: int) -> HTMLResponse:356 reg = registry().get(tx)357 if reg is None:358 return render_404()359 city = reg["cities"].get(city_slug) if city_slug else None360 ptype = reg["types"].get(type_slug) if type_slug else None361 if (city_slug and not city) or (type_slug and not ptype):362 return render_404()363 if city_slug and type_slug and reg["city_types"].get((city_slug, type_slug), 0) < 1:364 return render_404()365366 v = TXV[tx]367 con = db.connect()368 try:369 st = _agg_stats(con, tx, city["label"] if city else None,370 ptype["label"] if ptype else None)371 if st["n"] < 1:372 return render_404()373 pages = max(1, -(-st["n"] // PAGE_SIZE))374 if page < 1 or page > pages:375 return render_404()376 rows = con.execute(377 f"SELECT uid, transaction_type, address, title, city, sector, type,"378 f" price, bedrooms, area_sqft FROM listings WHERE {st['where']}"379 f" ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?",380 st["args"] + [PAGE_SIZE, (page - 1) * PAGE_SIZE]).fetchall()381382 links = []383 if city:384 tlinks = []385 for (cs, ts_), n in sorted(reg["city_types"].items(), key=lambda kv: -kv[1]):386 if cs == city_slug and n >= 1 and ts_ in reg["types"] and ts_ != type_slug:387 lbl = reg["types"][ts_]["label"]388 tlinks.append(f'<li><a href="/{tx}/{cs}/{ts_}">'389 f"{_esc(lbl)} {v['verbe']} à {_esc(city['label'])}</a>"390 f" ({_fmt_n(n)})</li>")391 if tlinks:392 links.append(f"<h2>Autres types {v['verbe']} à "393 + _esc(city["label"]) + "</h2><ul>"394 + "".join(tlinks[:20]) + "</ul>")395 if type_slug:396 tous = "Tous les logements" if tx == "louer" else "Toutes les propriétés"397 links.append(f'<p><a href="/{tx}/{city_slug}">{tous} '398 f'{v["verbe"]} à {_esc(city["label"])}</a> · '399 f'<a href="/{tx}/type/{type_slug}">{_esc(ptype["label"])} '400 f'{v["verbe"]} au Québec</a></p>')401 other_tx = "acheter" if tx == "louer" else "louer"402 if city_slug in registry()[other_tx]["cities"]:403 ov = TXV[other_tx]404 links.append(f'<p><a href="/{other_tx}/{city_slug}">'405 f'{ov["noun"].capitalize()} {ov["verbe"]} à '406 f'{_esc(city["label"])}</a></p>')407 top = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:30]408 links.append("<h2>Autres villes</h2><ul>" + "".join(409 f'<li><a href="/{tx}/{s}{"/" + type_slug if type_slug and (s, type_slug) in reg["city_types"] else ""}">'410 f'{v["noun"].capitalize()} {v["verbe"]} à {_esc(e["label"])}</a> ({_fmt_n(e["n"])})</li>'411 for s, e in top if s != city_slug and e["n"] >= MIN_LISTINGS) + "</ul>")412 finally:413 con.close()414415 if city and ptype:416 base_path = f"/{tx}/{city_slug}/{type_slug}"417 h1 = f"{ptype['label']} {v['verbe']} à {city['label']}"418 elif city:419 base_path = f"/{tx}/{city_slug}"420 h1 = f"{v['noun'].capitalize()} {v['verbe']} à {city['label']}"421 else:422 base_path = f"/{tx}/type/{type_slug}"423 h1 = f"{ptype['label']} {v['verbe']} au Québec"424425 canonical = BASE_URL + base_path + (f"?page={page}" if page > 1 else "")426 title = (f"{h1} — {_fmt_n(st['n'])} annonces"427 + (f" (page {page})" if page > 1 else "") + f" | {SITE_NAME}")428 desc = (f"{_fmt_n(st['n'])} {h1.lower()} : {v['prix']} médian "429 f"{_fmt_price(st['med'], tx)}, {v['prix']} moyen "430 f"{_fmt_price(st['avg'], tx)}. Annonces agrégées de toutes les sources, "431 f"mises à jour en continu — chaque fiche renvoie à l'annonce originale.")432 stats_p = (f"<p><strong>{_fmt_n(st['n'])}</strong> annonces · {v['prix']} médian "433 f"<strong>{_esc(_fmt_price(st['med'], tx))}</strong> · {v['prix']} moyen "434 f"<strong>{_esc(_fmt_price(st['avg'], tx))}</strong>.</p>")435 crumbs = [("Accueil", "/")]436 if city:437 crumbs.append((f"{v['noun'].capitalize()} {v['verbe']} à {city['label']}",438 f"/{tx}/{city_slug}"))439 if ptype:440 crumbs.append((ptype["label"], base_path))441 else:442 crumbs.append((h1, base_path))443 body = ('<nav aria-label="Fil d\'Ariane">'444 + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs)445 + f"</nav><h1>{_esc(h1)}</h1>" + stats_p446 + "<ul>" + "".join(_item_li(r) for r in rows) + "</ul>"447 + _pagination_html(base_path, page, pages)448 + "".join(links))449 return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)])450451452# --- fiche ---------------------------------------------------------------------------453454_TYPE_SCHEMA = {455 "maison": "SingleFamilyResidence", "condo": "Apartment",456 "chalet": "House", "duplex": "Residence", "triplex": "Residence",457 "multiplex": "Residence", "maison-mobile": "House",458 "studio": "Apartment", "loft": "Apartment", "appartement": "Apartment",459 "chambre": "Room", "penthouse": "Apartment",460}461462463def render_listing(uid: str, slug: str | None) -> Response:464 con = db.connect()465 try:466 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()467 finally:468 con.close()469 if row is None:470 return render_404()471472 tx = row["transaction_type"]473 v = TXV[tx]474 city_slug = slugify(row["city"] or "")475 city_known = city_slug in registry()[tx]["cities"]476 city_href = f"/{tx}/{city_slug}" if city_known else "/"477478 if not row["active"]:479 name = row["address"] or row["title"] or "Annonce"480 body = (f"<h1>Cette annonce n'est plus disponible</h1>"481 f"<p>L'annonce « {_esc(name)} » ({_esc(row['city'] or 'Québec')}) a été "482 f"retirée.</p><ul>"483 + (f'<li><a href="{city_href}">{v["noun"].capitalize()} {v["verbe"]} à '484 f"{_esc(row['city'])}</a></li>" if city_known else "")485 + f'<li><a href="/">Tous les toits {v["verbe"]} au Québec</a></li></ul>')486 return _page(f"Annonce retirée — {name} | {SITE_NAME}",487 "Cette annonce a été retirée.",488 BASE_URL + fiche_href(uid), body, noindex=True, status=410)489490 expected = listing_slug(row)491 if expected and slug != expected:492 return RedirectResponse(BASE_URL + fiche_href(uid, expected), status_code=301)493494 d = dict(row)495 images = json.loads(d.get("images") or "[]")496 name = d["address"] or d["title"] or f"Toit {v['verbe']}"497 loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec"498 canonical = BASE_URL + fiche_href(uid, expected)499 ptype = d["type"] or ("Logement" if tx == "louer" else "Propriété")500501 specs = [(lbl, val) for lbl, val in [502 ("Type", ptype),503 (v["Prix"], _fmt_price(d["price"], tx) if d["price"] is not None else d["price_label"]),504 ("Chambres", d["bedrooms"]),505 ("Salles de bain", d["bathrooms"]),506 ("Superficie", f"{_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else None),507 ("Terrain", f"{_fmt_n(round(d['lot_sqft']))} pi²" if d["lot_sqft"] else None),508 ("Année de construction", d["year_built"]),509 ("Animaux", d["pets"]),510 ("Meublé", "oui" if d["furnished"] == 1 else ("non" if d["furnished"] == 0 else None)),511 ("Disponibilité", "maintenant" if d["availability_date"] == "now" else d["availability_date"]),512 ("Ville", d["city"]),513 ("Secteur", d["sector"]),514 ("N° MLS", d["mls"]),515 ("Courtier", d["broker_name"]),516 ] if val not in (None, "", 0)]517 descr = (d["description"] or "").strip()518 if len(descr) > 1500:519 descr = descr[:1500].rsplit(" ", 1)[0] + "…"520521 type_slug = slugify(ptype)522 crumbs = [("Accueil", "/")]523 if city_known:524 crumbs.append((f"{v['verbe'].capitalize()} à {d['city']}", city_href))525 if (city_slug, type_slug) in registry()[tx]["city_types"]:526 crumbs.append((ptype, f"/{tx}/{city_slug}/{type_slug}"))527 crumbs.append((name, fiche_href(uid, expected)))528529 body = [530 '<nav aria-label="Fil d\'Ariane">'531 + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs[:-1])532 + f" › {_esc(name)}</nav>",533 f"<h1>{_esc(name)}</h1>",534 f"<p><strong>{_esc(ptype)} {v['verbe']} à {_esc(loc)}</strong> — "535 f"{_esc(_fmt_price(d['price'], tx) if d['price'] is not None else (d['price_label'] or 'Prix sur demande'))}</p>",536 ]537 if images:538 body.append("".join(539 f'<img src="{_esc(u)}" alt="{_esc(name)} — photo {i + 1}" loading="lazy" />'540 for i, u in enumerate(images[:3])))541 body.append("<h2>Caractéristiques</h2><ul>" + "".join(542 f"<li><strong>{_esc(l)} :</strong> {_esc(x)}</li>" for l, x in specs) + "</ul>")543 if descr:544 body.append(f"<h2>Description</h2><p>{_esc(descr)}</p>")545 if d["url"]:546 body.append(f'<p><a href="{_esc(d["url"])}" rel="noopener">'547 f"Voir l'annonce originale ({_esc(d['source'])})</a></p>")548 if city_known:549 body.append(f'<p><a href="{city_href}">{v["noun"].capitalize()} {v["verbe"]} à '550 f'{_esc(d["city"])}</a></p>')551552 about_type = _TYPE_SCHEMA.get(type_slug, "Residence")553 about: dict = {554 "@type": about_type,555 "name": name,556 "address": {"@type": "PostalAddress",557 "streetAddress": d["address"] or None,558 "addressLocality": d["city"] or None,559 "addressRegion": "QC", "addressCountry": "CA"},560 }561 if d["lat"] is not None and d["lng"] is not None:562 about["geo"] = {"@type": "GeoCoordinates",563 "latitude": d["lat"], "longitude": d["lng"]}564 if d["bedrooms"] is not None:565 about["numberOfBedrooms"] = d["bedrooms"]566 if d["area_sqft"]:567 about["floorSize"] = {"@type": "QuantitativeValue",568 "value": round(d["area_sqft"]), "unitCode": "FTK"}569 if d["year_built"]:570 about["yearBuilt"] = d["year_built"]571 about = {k: x for k, x in about.items() if x is not None}572 about["address"] = {k: x for k, x in about["address"].items() if x is not None}573 jsonld: list[dict] = [{574 "@context": "https://schema.org",575 "@type": "RealEstateListing",576 "name": name,577 "url": canonical,578 "inLanguage": "fr-CA",579 "datePosted": _iso_date(d.get("first_seen")),580 "image": images[:6] or None,581 "about": about,582 }, _breadcrumb_ld(crumbs)]583 if d["price"] is not None:584 offer = {"@type": "Offer", "price": round(d["price"], 2),585 "priceCurrency": "CAD",586 "availability": "https://schema.org/InStock"}587 if tx == "louer":588 offer["priceSpecification"] = {589 "@type": "UnitPriceSpecification",590 "price": round(d["price"], 2), "priceCurrency": "CAD",591 "unitText": "MOIS"}592 jsonld[0]["offers"] = offer593 jsonld[0] = {k: x for k, x in jsonld[0].items() if x is not None}594595 title = (f"{name} — {ptype} {v['verbe']}, {d['city'] or 'Québec'} | "596 f"{_fmt_price(d['price'], tx) if d['price'] is not None else 'Prix sur demande'}")597 meta_desc = (f"{ptype} {v['verbe']} à {loc}"598 + (f", {d['bedrooms']} chambres" if d["bedrooms"] else "")599 + (f", {_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else "")600 + f" — {_fmt_price(d['price'], tx) if d['price'] is not None else 'prix sur demande'}. "601 + (descr[:120] + "…" if len(descr) > 120 else descr))602 return _page(title, meta_desc, canonical, "".join(body), jsonld,603 og_image=images[0] if images else None, og_type="article")604605606# --- pages statiques et 404 -----------------------------------------------------------607608_STATIC_META = {609 "/stats": ("Statistiques du marché — louer et acheter",610 "Loyers moyens, prix moyens, volumes par ville et par type : "611 "statistiques en continu des deux univers de Toit-Ka.", False),612 "/conditions": ("Conditions d'utilisation",613 "Conditions d'utilisation de la plateforme Toit-Ka (Groupe-Ka).", False),614 "/confidentialite": ("Politique de confidentialité",615 "Politique de confidentialité de Toit-Ka (Groupe-Ka) — Loi 25.", False),616 "/profil": ("Mon profil", "Votre compte Groupe KA sur Toit-Ka.", True),617}618619620def render_static(path: str) -> HTMLResponse:621 t, desc, noindex = _STATIC_META[path]622 body = f"<h1>{_esc(t)}</h1><p>{_esc(desc)}</p>"623 return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex)624625626def render_404() -> HTMLResponse:627 body = ('<h1>Page introuvable</h1><p>Le lien demandé n\'existe pas.</p>'628 '<p><a href="/">Tous les toits à louer et à vendre au Québec</a></p>')629 return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.",630 BASE_URL + "/", body, noindex=True, status=404)631632633# --- robots + sitemaps ------------------------------------------------------------------634635def robots_txt() -> PlainTextResponse:636 return PlainTextResponse(637 "User-agent: *\n"638 "Allow: /\n"639 "Disallow: /api/\n"640 "Disallow: /profil\n"641 f"\nSitemap: {BASE_URL}/sitemap.xml\n")642643644def _xml(urls: list[str]) -> Response:645 body = ('<?xml version="1.0" encoding="UTF-8"?>\n'646 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'647 + "\n".join(urls) + "\n</urlset>")648 return Response(body, media_type="application/xml")649650651def _url_el(loc: str, lastmod: str | None = None) -> str:652 lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""653 return f" <url><loc>{html.escape(loc)}</loc>{lm}</url>"654655656def sitemap_index() -> Response:657 def build():658 con = db.connect()659 try:660 n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"]661 last = con.execute(662 f"SELECT MAX(updated_at) m FROM listings WHERE {VISIBLE}").fetchone()["m"]663 finally:664 con.close()665 parts = -(-n // FICHES_PER_SITEMAP) or 1666 lm = _iso_date(last)667 maps = [f"{BASE_URL}/sitemaps/fiches-{i + 1}.xml" for i in range(parts)]668 maps += [f"{BASE_URL}/sitemaps/villes-louer.xml",669 f"{BASE_URL}/sitemaps/villes-acheter.xml",670 f"{BASE_URL}/sitemaps/villes-types.xml",671 f"{BASE_URL}/sitemaps/types.xml",672 f"{BASE_URL}/sitemaps/pages.xml"]673 return ('<?xml version="1.0" encoding="UTF-8"?>\n'674 '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'675 + "\n".join(f" <sitemap><loc>{html.escape(m)}</loc>"676 f"<lastmod>{lm}</lastmod></sitemap>" for m in maps)677 + "\n</sitemapindex>")678 return Response(_cached("sm:index", 3600, build), media_type="application/xml")679680681def sitemap_file(name: str) -> Response:682 m = re.fullmatch(r"fiches-(\d+)\.xml", name)683 if m:684 part = int(m.group(1))685686 def build():687 con = db.connect()688 try:689 rows = con.execute(690 f"SELECT uid, address, title, city, updated_at FROM listings"691 f" WHERE {VISIBLE} ORDER BY uid LIMIT ? OFFSET ?",692 (FICHES_PER_SITEMAP, (part - 1) * FICHES_PER_SITEMAP)).fetchall()693 finally:694 con.close()695 if not rows:696 return None697 return [_url_el(BASE_URL + fiche_href(r["uid"], listing_slug(r)),698 _iso_date(r["updated_at"])) for r in rows]699 urls = _cached(f"sm:fiches:{part}", 3600, build)700 if urls is None:701 return Response("Sitemap introuvable", status_code=404)702 return _xml(urls)703704 if name in ("villes-louer.xml", "villes-acheter.xml"):705 tx = "louer" if "louer" in name else "acheter"706707 def build():708 reg = registry()[tx]709 return [_url_el(f"{BASE_URL}/{tx}/{s}")710 for s, e in sorted(reg["cities"].items())711 if e["n"] >= MIN_LISTINGS]712 return _xml(_cached(f"sm:{name}", 3600, build))713714 if name == "villes-types.xml":715 def build():716 out = []717 for tx in ("louer", "acheter"):718 reg = registry()[tx]719 out += [_url_el(f"{BASE_URL}/{tx}/{cs}/{ts_}")720 for (cs, ts_), n in sorted(reg["city_types"].items())721 if n >= MIN_LISTINGS and cs in reg["cities"]722 and ts_ in reg["types"]723 and reg["cities"][cs]["n"] >= MIN_LISTINGS]724 return out725 return _xml(_cached("sm:villes-types", 3600, build))726727 if name == "types.xml":728 def build():729 out = []730 for tx in ("louer", "acheter"):731 reg = registry()[tx]732 out += [_url_el(f"{BASE_URL}/{tx}/type/{s}")733 for s, e in sorted(reg["types"].items())734 if e["n"] >= MIN_LISTINGS]735 return out736 return _xml(_cached("sm:types", 3600, build))737738 if name == "pages.xml":739 return _xml([_url_el(f"{BASE_URL}{p}")740 for p in ["/", "/stats", "/conditions", "/confidentialite"]])741742 return Response("Sitemap introuvable", status_code=404)743744745# --- résolution de slugs pour le SPA ------------------------------------------------------746747def resolve_slugs(tx: str | None, ville: str | None, ptype: str | None) -> dict | None:748 if tx not in TXV:749 return None750 reg = registry()[tx]751 out: dict = {"tx": tx}752 if ville:753 e = reg["cities"].get(ville)754 if not e:755 return None756 out["city"] = e["label"]757 out["city_n"] = e["n"]758 if ptype:759 e = reg["types"].get(ptype)760 if not e:761 return None762 out["type"] = e["label"]763 out["type_n"] = e["n"]764 return out765766767# --- routage (appelé par le catch-all de web.py) -------------------------------------------768769def render_for_path(path: str, query: dict) -> Response:770 """HTML SEO pour `path` (ex. « /louer/levis »), sinon 404 SSR."""771 path = path.rstrip("/") or "/"772 try:773 page = max(1, int(query.get("page", "1")))774 except ValueError:775 page = 1776777 if path == "/":778 return render_home()779 if path in _STATIC_META:780 return render_static(path)781782 parts = [p for p in path.split("/") if p]783 if parts[0] == "annonce" and len(parts) in (2, 3):784 return render_listing(parts[1], parts[2] if len(parts) == 3 else None)785 if parts[0] in ("louer", "acheter"):786 tx = parts[0]787 if len(parts) == 3 and parts[1] == "type":788 return _render_category(tx, None, parts[2], page)789 if len(parts) in (2, 3):790 return _render_category(tx, parts[1],791 parts[2] if len(parts) == 3 else None, page)792 return render_404()793