# Author: Simon-Pierre Boucher
# Contact: contact@spboucher.ai
# Project: Toit-Ka
# -----------------------------------------------------------------------------
# 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 bi-univers (louer + acheter)
# /annonce/{uid}[/{slug}] fiche (301 slug canonique, 410 retirée)
# /louer/{ville}[/{type}] pages programmatiques location
# /acheter/{ville}[/{type}] pages programmatiques vente
# /louer/type/{type} /acheter/type/{type} pages par type (province)
# /stats /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
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
from .villes import slugify
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("TOITKA_BASE_URL", "https://www.toit-ka.com").rstrip("/")
SITE_NAME = "Toit-Ka"
VISIBLE = "active=1"
MIN_LISTINGS = 3
PAGE_SIZE = 48
FICHES_PER_SITEMAP = 40000
# vocabulaire par univers
TXV = {
"louer": {
"path": "louer", "noun": "logements", "verbe": "à louer",
"prix": "loyer", "Prix": "Loyer",
},
"acheter": {
"path": "acheter", "noun": "propriétés", "verbe": "à vendre",
"prix": "prix", "Prix": "Prix",
},
}
# --- utilitaires ----------------------------------------------------------------
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 fiche_href(uid: str, slug: str = "") -> str:
return f"/annonce/{quote(uid, safe='')}" + (f"/{slug}" if slug else "")
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, tx: str) -> str:
if p is None:
return "Prix sur demande"
s = f"{_fmt_n(round(p))} $"
return s + " /mois" if tx == "louer" else s
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")
_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 (par univers) — reconstruits toutes les 15 min ----------
def _build_registry() -> dict:
con = db.connect()
out: dict = {}
try:
for tx in ("louer", "acheter"):
cities: dict[str, dict] = {}
for r in con.execute(
f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"
" AND transaction_type=? AND city<>'' GROUP BY city", (tx,)):
slug = slugify(r["city"])
if len(slug) >= 2:
cities[slug] = {"label": r["city"], "n": r["n"]}
types: dict[str, dict] = {}
for r in con.execute(
f"SELECT type, COUNT(*) n FROM listings WHERE {VISIBLE}"
" AND transaction_type=? AND type<>'' GROUP BY type", (tx,)):
slug = slugify(r["type"])
if len(slug) >= 2:
types[slug] = {"label": r["type"], "n": r["n"]}
city_types: dict[tuple[str, str], int] = {}
for r in con.execute(
f"SELECT city, type, COUNT(*) n FROM listings WHERE {VISIBLE}"
" AND transaction_type=? AND city<>'' AND type<>''"
" GROUP BY city, type", (tx,)):
cs, ts_ = slugify(r["city"]), slugify(r["type"])
if len(cs) >= 2 and len(ts_) >= 2:
city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"]
out[tx] = {"cities": cities, "types": types, "city_types": city_types}
finally:
con.close()
return out
def registry() -> dict:
return _cached("registry", 900, _build_registry)
# --- gabarit ---------------------------------------------------------------------
_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_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'')
head.append('')
else:
head.append('')
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 -----------------------------------------------------------------------
def _item_li(r) -> str:
tx = r["transaction_type"]
href = fiche_href(r["uid"], listing_slug(r))
bits = [b for b in [
r["type"],
f"{r['bedrooms']} ch." if r["bedrooms"] 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 ("Logement" if tx == "louer" else "Propriété")
loc = ", ".join(x for x in [r["sector"], r["city"]] if x)
return (f'
{_esc(name)} — '
f'{_esc(_fmt_price(r["price"], tx))}'
+ (f' · {_esc(" · ".join(bits))}' if bits else "")
+ (f' · {_esc(loc)}' if loc else "") + "
")
def _agg_stats(con, tx: str, city_label: str | None = None,
type_label: str | None = None) -> dict:
where = f"{VISIBLE} AND transaction_type=?"
args: list = [tx]
if city_label:
where += " AND city=?"; args.append(city_label)
if type_label:
where += " AND type=?"; args.append(type_label)
row = con.execute(
f"SELECT COUNT(*) n, AVG(price) avg_p FROM listings WHERE {where}",
args).fetchone()
priced = con.execute(
f"SELECT COUNT(*) n FROM listings WHERE {where} AND price IS NOT NULL",
args).fetchone()["n"]
med = None
if priced:
med_row = con.execute(
f"SELECT price FROM listings WHERE {where} AND price IS NOT NULL"
f" ORDER BY price LIMIT 1 OFFSET ?", args + [priced // 2]).fetchone()
med = med_row["price"] if med_row else None
return {"n": row["n"], "avg": row["avg_p"], "med": med,
"where": where, "args": args}
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)
],
}
def _pagination_html(base_path: str, page: int, pages: int) -> str:
if pages <= 1:
return ""
out = ['")
return "".join(out)
# --- accueil ----------------------------------------------------------------------
def _home_data() -> dict:
def build():
con = db.connect()
try:
out = {}
for tx in ("louer", "acheter"):
row = con.execute(
f"SELECT COUNT(*) total, COUNT(DISTINCT NULLIF(city,'')) cities,"
f" COUNT(DISTINCT source) sources, AVG(price) avg_p"
f" FROM listings WHERE {VISIBLE} AND transaction_type=?",
(tx,)).fetchone()
recent = [dict(r) for r in con.execute(
f"SELECT uid, transaction_type, address, title, city, sector,"
f" type, price, bedrooms, area_sqft FROM listings"
f" WHERE {VISIBLE} AND transaction_type=?"
f" ORDER BY first_seen DESC LIMIT 8", (tx,))]
out[tx] = {**dict(row), "recent": recent}
return out
finally:
con.close()
return _cached("home", 900, build)
def render_home() -> HTMLResponse:
d = _home_data()
reg = registry()
total = d["louer"]["total"] + d["acheter"]["total"]
title = (f"Toit-Ka — Louer ou acheter au Québec : {_fmt_n(total)} annonces, "
f"un seul endroit")
desc = (f"{_fmt_n(d['louer']['total'])} logements à louer et "
f"{_fmt_n(d['acheter']['total'])} propriétés à vendre partout au Québec, "
f"agrégés depuis les gestionnaires immobiliers, agences et plateformes "
f"(RE/MAX, Via Capitale, Sutton, DuProprio, Kijiji…) — mises à jour en "
f"continu, chaque fiche renvoie à l'annonce originale.")
body = [
f"
Louer ou acheter un toit au Québec — {_fmt_n(total)} annonces, un seul endroit
",
f"
Toit-Ka réunit la location (Lou-Ka) et la vente (Immo-Ka) : "
f"{_fmt_n(d['louer']['total'])} logements à louer (loyer moyen "
f"{_esc(_fmt_price(d['louer']['avg_p'], 'louer'))}) et "
f"{_fmt_n(d['acheter']['total'])} propriétés à vendre (prix moyen "
f"{_esc(_fmt_price(d['acheter']['avg_p'], 'acheter'))}), mis à jour en continu. "
f"Chaque fiche renvoie à l'annonce originale de la source.
",
]
for tx, label in (("louer", "Logements à louer"), ("acheter", "Propriétés à vendre")):
top = sorted(reg[tx]["cities"].items(), key=lambda kv: -kv[1]["n"])[:40]
verbe = TXV[tx]["verbe"]
body.append(f"
')
return _page(f"Annonce retirée — {name} | {SITE_NAME}",
"Cette annonce a été retirée.",
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 "[]")
name = d["address"] or d["title"] or f"Toit {v['verbe']}"
loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec"
canonical = BASE_URL + fiche_href(uid, expected)
ptype = d["type"] or ("Logement" if tx == "louer" else "Propriété")
specs = [(lbl, val) for lbl, val in [
("Type", ptype),
(v["Prix"], _fmt_price(d["price"], tx) if d["price"] is not None else d["price_label"]),
("Chambres", d["bedrooms"]),
("Salles de bain", d["bathrooms"]),
("Superficie", 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"]),
("Animaux", d["pets"]),
("Meublé", "oui" if d["furnished"] == 1 else ("non" if d["furnished"] == 0 else None)),
("Disponibilité", "maintenant" if d["availability_date"] == "now" else d["availability_date"]),
("Ville", d["city"]),
("Secteur", d["sector"]),
("N° MLS", d["mls"]),
("Courtier", d["broker_name"]),
] 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"{v['verbe'].capitalize()} à {d['city']}", city_href))
if (city_slug, type_slug) in registry()[tx]["city_types"]:
crumbs.append((ptype, f"/{tx}/{city_slug}/{type_slug}"))
crumbs.append((name, fiche_href(uid, expected)))
body = [
'",
f"
{_esc(name)}
",
f"
{_esc(ptype)} {v['verbe']} à {_esc(loc)} — "
f"{_esc(_fmt_price(d['price'], tx) 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["area_sqft"]:
about["floorSize"] = {"@type": "QuantitativeValue",
"value": round(d["area_sqft"]), "unitCode": "FTK"}
if d["year_built"]:
about["yearBuilt"] = d["year_built"]
about = {k: x for k, x in about.items() if x is not None}
about["address"] = {k: x for k, x in about["address"].items() if x 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:
offer = {"@type": "Offer", "price": round(d["price"], 2),
"priceCurrency": "CAD",
"availability": "https://schema.org/InStock"}
if tx == "louer":
offer["priceSpecification"] = {
"@type": "UnitPriceSpecification",
"price": round(d["price"], 2), "priceCurrency": "CAD",
"unitText": "MOIS"}
jsonld[0]["offers"] = offer
jsonld[0] = {k: x for k, x in jsonld[0].items() if x is not None}
title = (f"{name} — {ptype} {v['verbe']}, {d['city'] or 'Québec'} | "
f"{_fmt_price(d['price'], tx) if d['price'] is not None else 'Prix sur demande'}")
meta_desc = (f"{ptype} {v['verbe']} à {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'], tx) 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 et 404 -----------------------------------------------------------
_STATIC_META = {
"/stats": ("Statistiques du marché — louer et acheter",
"Loyers moyens, prix moyens, volumes par ville et par type : "
"statistiques en continu des deux univers de Toit-Ka.", False),
"/conditions": ("Conditions d'utilisation",
"Conditions d'utilisation de la plateforme Toit-Ka (Groupe-Ka).", False),
"/confidentialite": ("Politique de confidentialité",
"Politique de confidentialité de Toit-Ka (Groupe-Ka) — Loi 25.", False),
"/profil": ("Mon profil", "Votre compte Groupe KA sur Toit-Ka.", True),
}
def render_static(path: str) -> HTMLResponse:
t, desc, noindex = _STATIC_META[path]
body = f"
')
return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.",
BASE_URL + "/", body, noindex=True, status=404)
# --- robots + sitemaps ------------------------------------------------------------------
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()
try:
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"]
finally:
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-louer.xml",
f"{BASE_URL}/sitemaps/villes-acheter.xml",
f"{BASE_URL}/sitemaps/villes-types.xml",
f"{BASE_URL}/sitemaps/types.xml",
f"{BASE_URL}/sitemaps/pages.xml"]
return ('\n'
'\n'
+ "\n".join(f" {html.escape(m)}"
f"{lm}" for m in maps)
+ "\n")
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()
try:
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()
finally:
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 in ("villes-louer.xml", "villes-acheter.xml"):
tx = "louer" if "louer" in name else "acheter"
def build():
reg = registry()[tx]
return [_url_el(f"{BASE_URL}/{tx}/{s}")
for s, e in sorted(reg["cities"].items())
if e["n"] >= MIN_LISTINGS]
return _xml(_cached(f"sm:{name}", 3600, build))
if name == "villes-types.xml":
def build():
out = []
for tx in ("louer", "acheter"):
reg = registry()[tx]
out += [_url_el(f"{BASE_URL}/{tx}/{cs}/{ts_}")
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 out
return _xml(_cached("sm:villes-types", 3600, build))
if name == "types.xml":
def build():
out = []
for tx in ("louer", "acheter"):
reg = registry()[tx]
out += [_url_el(f"{BASE_URL}/{tx}/type/{s}")
for s, e in sorted(reg["types"].items())
if e["n"] >= MIN_LISTINGS]
return out
return _xml(_cached("sm:types", 3600, build))
if name == "pages.xml":
return _xml([_url_el(f"{BASE_URL}{p}")
for p in ["/", "/stats", "/conditions", "/confidentialite"]])
return Response("Sitemap introuvable", status_code=404)
# --- résolution de slugs pour le SPA ------------------------------------------------------
def resolve_slugs(tx: str | None, ville: str | None, ptype: str | None) -> dict | None:
if tx not in TXV:
return None
reg = registry()[tx]
out: dict = {"tx": tx}
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["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:
"""HTML SEO pour `path` (ex. « /louer/levis »), sinon 404 SSR."""
path = path.rstrip("/") or "/"
try:
page = max(1, int(query.get("page", "1")))
except ValueError:
page = 1
if path == "/":
return render_home()
if path in _STATIC_META:
return render_static(path)
parts = [p for p in path.split("/") if p]
if parts[0] == "annonce" and len(parts) in (2, 3):
return render_listing(parts[1], parts[2] if len(parts) == 3 else None)
if parts[0] in ("louer", "acheter"):
tx = parts[0]
if len(parts) == 3 and parts[1] == "type":
return _render_category(tx, None, parts[2], page)
if len(parts) in (2, 3):
return _render_category(tx, parts[1],
parts[2] if len(parts) == 3 else None, page)
return render_404()