SPB Git

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%
32.9 KB · 793 lines python
Raw Blame History
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