SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
39.5 KB · 914 lines python
Raw Blame History
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 &amp; 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