# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # 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 <div id="root"> (que React # remplace au montage). Il génère aussi robots.txt et les sitemaps. # # Pages servies : # / accueil enrichi (stats + liens villes/types) # /propriete/{uid}[/{slug}] fiche (301 vers le slug canonique, # 410 si retirée, 404 si inconnue) # /a-vendre/{ville}[/{type}] pages programmatiques par ville (+ type) # /type/{type} page par type de propriété (province) # /stats /agences /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 import unicodedata 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 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("IMMOKA_BASE_URL", "https://www.immo-ka.com").rstrip("/") SITE_NAME = "Immo-Ka" # même règle de visibilité que /api/listings (dédup Centris + prix affichable) VISIBLE = "active=1 AND dup_hidden=0 AND published=1" MIN_LISTINGS = 3 # seuil de qualité : pas de page ville/type quasi vide PAGE_SIZE = 48 # annonces par page sur les pages programmatiques # ----------------------------------------------------------------------------- # Utilitaires # ----------------------------------------------------------------------------- def slugify(s: str) -> str: """Slug URL — MÊME algorithme que slugify() de frontend/src/api.ts.""" s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode() s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") return s[:80].strip("-") 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 _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) -> str: return f"{_fmt_n(round(p))} $" if p is not None else "Prix sur demande" 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") # petit cache TTL en mémoire (les données changent au rythme du watch, ~1 h) _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 (villes, types) — reconstruits toutes les 15 min # ----------------------------------------------------------------------------- def _build_registry() -> dict: con = db.connect() cities: dict[str, dict] = {} for r in con.execute( f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}" " AND city<>'' GROUP BY city"): slug = slugify(r["city"]) if len(slug) < 2: continue e = cities.setdefault(slug, {"label": r["city"], "n": 0, "values": [], "best": 0}) e["n"] += r["n"] e["values"].append(r["city"]) if r["n"] > e["best"]: e["best"] = r["n"]; e["label"] = r["city"] types: dict[str, dict] = {} for r in con.execute( f"SELECT property_type, COUNT(*) n FROM listings WHERE {VISIBLE}" " AND property_type<>'' GROUP BY property_type"): slug = slugify(r["property_type"]) if len(slug) < 2: continue e = types.setdefault(slug, {"label": r["property_type"], "n": 0, "values": [], "best": 0}) e["n"] += r["n"] e["values"].append(r["property_type"]) if r["n"] > e["best"]: e["best"] = r["n"]; e["label"] = r["property_type"] city_types: dict[tuple[str, str], int] = {} for r in con.execute( f"SELECT city, property_type, COUNT(*) n FROM listings WHERE {VISIBLE}" " AND city<>'' AND property_type<>'' GROUP BY city, property_type"): cs, ts_ = slugify(r["city"]), slugify(r["property_type"]) if len(cs) < 2 or len(ts_) < 2: continue city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"] con.close() return {"cities": cities, "types": types, "city_types": city_types} def registry() -> dict: return _cached("registry", 900, _build_registry) # ----------------------------------------------------------------------------- # Gabarit : dist/index.html, dépouillé de son <title>/description statiques # ----------------------------------------------------------------------------- _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"<title>.*?\s*", "", tpl, flags=re.S) tpl = re.sub(r']*>\s*', "", tpl) 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'') else: og_image = BASE_URL + "/og.png" head.append(f'') head.append('') head.append('') head.append('') head.append(f'') 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 HTML réutilisables # ----------------------------------------------------------------------------- def fiche_href(uid: str, slug: str = "") -> str: path = f"/propriete/{quote(uid, safe='')}" return path + (f"/{slug}" if slug else "") def _item_li(r) -> str: href = fiche_href(r["uid"], listing_slug(r)) bits = [b for b in [ r["property_type"], f"{r['bedrooms']} ch." if r["bedrooms"] is not None else "", f"{r['bathrooms']} sdb" if r["bathrooms"] 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 "Propriété" loc = ", ".join(x for x in [r["sector"], r["city"]] if x) return (f'
  • {_esc(name)} — ' f'{_esc(_fmt_price(r["price"]))}' + (f' · {_esc(" · ".join(bits))}' if bits else "") + (f' · {_esc(loc)}' if loc else "") + "
  • ") def _agg_stats(con, cities: list[str] | None = None, types_values: list[str] | None = None) -> dict: where = VISIBLE args: list = [] if cities: where += f" AND city IN ({','.join('?' * len(cities))})" args += cities if types_values: where += f" AND property_type IN ({','.join('?' * len(types_values))})" args += types_values row = con.execute( f"SELECT COUNT(*) n, AVG(price) avg_p, MIN(price) min_p, MAX(price) max_p" f" FROM listings WHERE {where}", args).fetchone() med = None if row["n"]: med_row = con.execute( f"SELECT price FROM listings WHERE {where}" f" ORDER BY price LIMIT 1 OFFSET ?", args + [row["n"] // 2]).fetchone() med = med_row["price"] if med_row else None return {"n": row["n"], "avg": row["avg_p"], "med": med, "min": row["min_p"], "max": row["max_p"], "where": where, "args": args} def _pagination_html(base_path: str, page: int, pages: int) -> str: if pages <= 1: return "" out = ['") return "".join(out) 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) ], } # ----------------------------------------------------------------------------- # Accueil # ----------------------------------------------------------------------------- def _home_data() -> dict: def build(): con = db.connect() row = con.execute( f"SELECT COUNT(*) total, COUNT(DISTINCT city) cities," f" COUNT(DISTINCT source) sources, AVG(price) avg_p" f" FROM listings WHERE {VISIBLE}").fetchone() recent = [dict(r) for r in con.execute( f"SELECT uid, address, title, city, sector, property_type, price," f" bedrooms, bathrooms, area_sqft FROM listings WHERE {VISIBLE}" f" ORDER BY first_seen DESC LIMIT 12")] con.close() return {**dict(row), "recent": recent} return _cached("home", 900, build) def render_home() -> HTMLResponse: d = _home_data() reg = registry() total = _fmt_n(d["total"]) title = f"Immo-Ka — {total} propriétés à vendre au Québec · Un service Groupe KA" desc = (f"{total} propriétés à vendre dans {_fmt_n(d['cities'])} villes du Québec, " f"agrégées depuis {d['sources']} sources (RE/MAX, Via Capitale, Sutton, " f"Proprio Direct, DuProprio, Centris et plus) — mises à jour en continu. " f"Prix moyen : {_fmt_price(d['avg_p'])}.") top_cities = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:60] types = sorted(reg["types"].items(), key=lambda kv: -kv[1]["n"]) body = [ f"

    Propriétés à vendre au Québec — {total} annonces de toutes les agences

    ", f"

    Immo-Ka agrège en continu les propriétés à vendre affichées publiquement par " f"les agences de courtage immobilier et les plateformes du Québec : RE/MAX, " f"Via Capitale, Sutton, Proprio Direct, Engel & Völkers, Sotheby's, DuProprio, " f"LesPAC et plus — {d['sources']} sources, {_fmt_n(d['cities'])} villes, " f"prix moyen {_esc(_fmt_price(d['avg_p']))}. Chaque fiche renvoie à l'annonce " f"originale de l'agence.

    ", "

    Propriétés à vendre par ville

    ", "", "

    Par type de propriété

    ", "", "

    Dernières propriétés ajoutées

    ", "", '

    Statistiques du marché · ' 'Agences couvertes

    ', ] jsonld = [{ "@context": "https://schema.org", "@type": "WebSite", "name": SITE_NAME, "url": BASE_URL + "/", "inLanguage": "fr-CA", "description": desc, "potentialAction": { "@type": "SearchAction", "target": {"@type": "EntryPoint", "urlTemplate": BASE_URL + "/?q={search_term_string}"}, "query-input": "required name=search_term_string", }, }, { "@context": "https://schema.org", "@type": "Organization", "name": "Groupe-Ka", "url": BASE_URL + "/", "email": "contact@groupe-ka.com", }] return _page(title, desc, BASE_URL + "/", "".join(body), jsonld) # ----------------------------------------------------------------------------- # Pages programmatiques : ville, ville+type, type # ----------------------------------------------------------------------------- def _render_category(city_slug: str | None, type_slug: str | None, page: int) -> HTMLResponse: reg = registry() city = reg["cities"].get(city_slug) if city_slug else None ptype = reg["types"].get(type_slug) if type_slug else None if (city_slug and not city) or (type_slug and not ptype): return render_404() if city_slug and type_slug and reg["city_types"].get((city_slug, type_slug), 0) < 1: return render_404() con = db.connect() st = _agg_stats(con, city["values"] if city else None, ptype["values"] if ptype else None) if st["n"] < 1: con.close() return render_404() pages = max(1, -(-st["n"] // PAGE_SIZE)) if page < 1 or page > pages: con.close() return render_404() rows = con.execute( f"SELECT uid, address, title, city, sector, property_type, price," f" bedrooms, bathrooms, area_sqft FROM listings WHERE {st['where']}" f" ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?", st["args"] + [PAGE_SIZE, (page - 1) * PAGE_SIZE]).fetchall() # maillage interne links = [] if city: # types offerts dans cette ville tlinks = [] for (cs, ts_), n in sorted(reg["city_types"].items(), key=lambda kv: -kv[1]): if cs == city_slug and n >= 1 and ts_ in reg["types"] and ts_ != type_slug: lbl = reg["types"][ts_]["label"] tlinks.append(f'
  • ' f"{_esc(lbl)} à vendre à {_esc(city['label'])} ({_fmt_n(n)})
  • ") if tlinks: links.append("

    Autres types de propriété à " + _esc(city["label"]) + "

    ") if type_slug: links.append(f'

    Toutes les propriétés à vendre ' f"à {_esc(city['label'])} · " f'{_esc(ptype["label"])} à vendre au Québec

    ') top = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:30] links.append("

    Autres villes

    ") con.close() if city and ptype: base_path = f"/a-vendre/{city_slug}/{type_slug}" h1 = f"{ptype['label']} à vendre à {city['label']}" what = f"{ptype['label'].lower()} à vendre à {city['label']}" elif city: base_path = f"/a-vendre/{city_slug}" h1 = f"Propriétés à vendre à {city['label']}" what = f"propriétés à vendre à {city['label']}" else: base_path = f"/type/{type_slug}" h1 = f"{ptype['label']} à vendre au Québec" what = f"{ptype['label'].lower()} à vendre au Québec" canonical = BASE_URL + base_path + (f"?page={page}" if page > 1 else "") title = f"{h1} — {_fmt_n(st['n'])} annonces" + (f" (page {page})" if page > 1 else "") + " | Immo-Ka" desc = (f"{_fmt_n(st['n'])} {what} : prix médian {_fmt_price(st['med'])}, " f"prix moyen {_fmt_price(st['avg'])}. Annonces de toutes les agences " f"(RE/MAX, Via Capitale, Sutton, DuProprio…), mises à jour en continu.") stats_p = (f"

    {_fmt_n(st['n'])} annonces · prix médian " f"{_esc(_fmt_price(st['med']))} · prix moyen " f"{_esc(_fmt_price(st['avg']))} · de " f"{_esc(_fmt_price(st['min']))} à {_esc(_fmt_price(st['max']))}.

    ") crumbs = [("Accueil", "/")] if city: crumbs.append((f"À vendre à {city['label']}", f"/a-vendre/{city_slug}")) if ptype: crumbs.append((f"{ptype['label']}", base_path)) else: crumbs.append((h1, base_path)) body = (f'

    {_esc(h1)}

    " + stats_p + "" + _pagination_html(base_path, page, pages) + "".join(links)) return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)]) # ----------------------------------------------------------------------------- # Fiche propriété # ----------------------------------------------------------------------------- _TYPE_SCHEMA = { "maison": "SingleFamilyResidence", "condo": "Apartment", "chalet": "House", "bi-generation": "House", "domaine-et-villa": "House", "duplex": "Residence", "triplex": "Residence", "quadruplex": "Residence", "multiplex": "Residence", "loft": "Apartment", "maison-mobile": "House", } def render_listing(uid: str, slug: str | None) -> Response: con = db.connect() row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() con.close() if row is None: return render_404() city_slug = slugify(row["city"] or "") city_known = city_slug in registry()["cities"] city_href = f"/a-vendre/{city_slug}" if city_known else "/" if not row["active"]: # propriété retirée / vendue → 410 Gone, avec des portes de sortie name = row["address"] or row["title"] or "Propriété" body = (f"

    Cette propriété n'est plus à vendre

    " f"

    L'annonce « {_esc(name)} » ({_esc(row['city'] or 'Québec')}) a été " f"retirée ou vendue.

    ') return _page(f"Propriété retirée — {name} | {SITE_NAME}", "Cette annonce a été retirée ou vendue.", 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 "[]") features = json.loads(d.get("features") or "[]") name = d["address"] or d["title"] or "Propriété à vendre" loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec" canonical = BASE_URL + fiche_href(uid, expected) ptype = d["property_type"] or "Propriété" specs = [(lbl, val) for lbl, val in [ ("Type", ptype), ("Prix", _fmt_price(d["price"]) if d["price"] is not None else d["price_label"]), ("Chambres", d["bedrooms"]), ("Salles de bain", d["bathrooms"]), ("Salles d'eau", d["powder_rooms"]), ("Superficie habitable", 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"]), ("Ville", d["city"]), ("Secteur", d["sector"]), ("N° MLS", d["mls"]), ("Courtier", d["broker_name"]), ("Agence", d["agency"]), ] 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"À vendre à {d['city']}", city_href)) if (city_slug, type_slug) in registry()["city_types"]: crumbs.append((ptype, f"/a-vendre/{city_slug}/{type_slug}")) crumbs.append((name, fiche_href(uid, expected))) body = [ '", f"

    {_esc(name)}

    ", f"

    {_esc(ptype)} à vendre à {_esc(loc)} — " f"{_esc(_fmt_price(d['price']) if d['price'] is not None else (d['price_label'] or 'Prix sur demande'))}

    ", ] if images: body.append("".join( f'{_esc(name)} — photo {i + 1}' for i, u in enumerate(images[:3]))) body.append("

    Caractéristiques

    ") if descr: body.append(f"

    Description

    {_esc(descr)}

    ") if features: body.append("

    Détails

    ") if d["url"]: body.append(f'

    ' f"Voir l'annonce originale chez {_esc(d['broker_name'] or d['agency'] or 'l’agence')}

    ") if city_known: body.append(f'

    Propriétés à vendre à {_esc(d["city"])}' + (f' · {_esc(ptype)} à vendre à ' f'{_esc(d["city"])}' if (city_slug, type_slug) in registry()["city_types"] else "") + "

    ") 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["bathrooms"] is not None: about["numberOfBathroomsTotal"] = d["bathrooms"] 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: v for k, v in about.items() if v is not None} about["address"] = {k: v for k, v in about["address"].items() if v 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: jsonld[0]["offers"] = {"@type": "Offer", "price": round(d["price"], 2), "priceCurrency": "CAD", "availability": "https://schema.org/InStock"} jsonld[0] = {k: v for k, v in jsonld[0].items() if v is not None} title = f"{name} — {ptype} à vendre, {d['city'] or 'Québec'} | {_fmt_price(d['price']) if d['price'] is not None else 'Prix sur demande'}" meta_desc = (f"{ptype} à vendre à {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']) 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 du SPA (meta dédiées) et 404 # ----------------------------------------------------------------------------- _STATIC_META = { "/stats": ("Statistiques du marché immobilier québécois", "Prix moyens, écarts prix demandé vs estimation Vrai-Prix par bannière, " "et volumes d'annonces par source — statistiques en continu d'Immo-Ka.", False), "/agences": ("Agences immobilières couvertes", "Toutes les bannières et agences agrégées par Immo-Ka : RE/MAX, Via Capitale, " "Sutton, Proprio Direct, Engel & Völkers, Sotheby's, DuProprio et plus.", False), "/taux-hypothecaires": ( "Taux hypothécaires au Canada — comparateur en direct", "Comparez les taux hypothécaires réellement publiés par RBC, TD, BMO, CIBC, " "Scotia, BNC, Desjardins, Tangerine, EQ et plus — fixes et variables, avec " "source officielle, fraîcheur et historique. Collecte continue par Immo-Ka.", False), "/conditions": ("Conditions d'utilisation", "Conditions d'utilisation de la plateforme Immo-Ka (Groupe-Ka).", False), "/confidentialite": ("Politique de confidentialité", "Politique de confidentialité d'Immo-Ka (Groupe-Ka) — Loi 25.", False), "/profil": ("Mon profil", "Votre compte Groupe KA sur Immo-Ka.", True), "/contact": ("Contact — Groupe KA", "Écrire au Groupe KA : contact@groupe-ka.com (projets et données), " "info@groupe-ka.com (médias), admin@groupe-ka.com (légal et Loi 25). " "Immo-Ka est un service Groupe KA — https://www.groupe-ka.com.", False), } def render_static(path: str) -> HTMLResponse: t, desc, noindex = _STATIC_META[path] body = f"

    {_esc(t)}

    {_esc(desc)}

    " return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex) def render_demenageurs() -> HTMLResponse: from . import demenageurs as dem doc = dem.load() movers = doc.get("movers", []) n = len(movers) title = f"Déménageurs au Québec — annuaire complet ({n} entreprises) | {SITE_NAME}" description = (f"Annuaire complet des déménageurs du Québec : {n} entreprises de " "déménagement dans les 17 régions administratives, avec téléphone, " "site web et note Google. Trouvez un déménageur près de chez vous.") by_region: dict[str, list] = {} for m in movers: by_region.setdefault(m["region"], []).append(m) def _mover_ld(m: dict) -> dict: d = {"@type": "MovingCompany", "name": m["name"]} if m.get("address"): d["address"] = m["address"] if m.get("phone"): d["telephone"] = m["phone"] if m.get("website"): d["url"] = m["website"] return d parts = [f"

    Déménageurs du Québec — {n} entreprises

    ", "

    " + _esc(description) + "

    "] for region in sorted(by_region, key=lambda r: -len(by_region[r])): ms = by_region[region] parts.append(f"

    {_esc(region)} ({len(ms)})

    ") jsonld = [_breadcrumb_ld([("Accueil", "/"), ("Déménageurs", "/demenageurs")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Déménageurs du Québec", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "item": _mover_ld(m)} for i, m in enumerate(movers[:100])]}] return _page(title, description, BASE_URL + "/demenageurs", "".join(parts), jsonld=jsonld) def render_inspecteurs() -> HTMLResponse: from . import inspecteurs as insp doc = insp.load() entries = doc.get("movers", []) n = len(entries) title = (f"Inspecteurs en bâtiment au Québec — annuaire complet " f"({n} entreprises) | {SITE_NAME}") description = (f"Annuaire complet des inspecteurs en bâtiment du Québec : {n} " "entreprises d'inspection préachat, prévente et préréception dans " "les 17 régions administratives, avec téléphone, site web et note " "Google. Trouvez un inspecteur près de chez vous.") by_region: dict[str, list] = {} for m in entries: by_region.setdefault(m["region"], []).append(m) def _insp_ld(m: dict) -> dict: d = {"@type": "ProfessionalService", "name": m["name"]} if m.get("address"): d["address"] = m["address"] if m.get("phone"): d["telephone"] = m["phone"] if m.get("website"): d["url"] = m["website"] return d parts = [f"

    Inspecteurs en bâtiment du Québec — {n} entreprises

    ", "

    " + _esc(description) + "

    "] for region in sorted(by_region, key=lambda r: -len(by_region[r])): ms = by_region[region] parts.append(f"

    {_esc(region)} ({len(ms)})

    ") jsonld = [_breadcrumb_ld([("Accueil", "/"), ("Inspecteurs en bâtiment", "/inspecteurs")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Inspecteurs en bâtiment du Québec", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "item": _insp_ld(m)} for i, m in enumerate(entries[:100])]}] return _page(title, description, BASE_URL + "/inspecteurs", "".join(parts), jsonld=jsonld) def render_404() -> HTMLResponse: body = ('

    Page introuvable

    Le lien demandé n\'existe pas.

    ' '

    Toutes les propriétés à vendre au Québec

    ') return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.", BASE_URL + "/", body, noindex=True, status=404) # ----------------------------------------------------------------------------- # robots.txt et sitemaps # ----------------------------------------------------------------------------- FICHES_PER_SITEMAP = 40000 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() 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"] 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.xml", f"{BASE_URL}/sitemaps/villes-types.xml", f"{BASE_URL}/sitemaps/types.xml", f"{BASE_URL}/sitemaps/pages.xml"] body = ('\n' '\n' + "\n".join(f" {html.escape(m)}" f"{lm}" for m in maps) + "\n") return body 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() 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() 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 == "villes.xml": def build(): reg = registry() lm = _city_lastmod() return [_url_el(f"{BASE_URL}/a-vendre/{s}", lm.get(s)) for s, e in sorted(reg["cities"].items()) if e["n"] >= MIN_LISTINGS] return _xml(_cached("sm:villes", 3600, build)) if name == "villes-types.xml": def build(): reg = registry() lm = _city_lastmod() return [_url_el(f"{BASE_URL}/a-vendre/{cs}/{ts_}", lm.get(cs)) 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 _xml(_cached("sm:villes-types", 3600, build)) if name == "types.xml": def build(): reg = registry() return [_url_el(f"{BASE_URL}/type/{s}") for s, e in sorted(reg["types"].items()) if e["n"] >= MIN_LISTINGS] return _xml(_cached("sm:types", 3600, build)) if name == "pages.xml": return _xml([_url_el(f"{BASE_URL}{p}") for p in ["/", "/stats", "/agences", "/taux-hypothecaires", "/demenageurs", "/inspecteurs", "/conditions", "/confidentialite"]]) return Response("Sitemap introuvable", status_code=404) def _city_lastmod() -> dict[str, str]: def build(): con = db.connect() out: dict[str, str] = {} for r in con.execute( f"SELECT city, MAX(updated_at) m FROM listings WHERE {VISIBLE}" " AND city<>'' GROUP BY city"): s = slugify(r["city"]) if s: prev = out.get(s) cur = _iso_date(r["m"]) out[s] = max(prev, cur) if prev else cur con.close() return out return _cached("sm:citylastmod", 3600, build) # ----------------------------------------------------------------------------- # Résolution de slugs pour le frontend (pages /a-vendre côté client) # ----------------------------------------------------------------------------- def resolve_slugs(ville: str | None, ptype: str | None) -> dict | None: reg = registry() out: dict = {} 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["property_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 | None: """HTML SEO pour `path` (ex. « /a-vendre/levis »), ou None → index brut.""" path = path.rstrip("/") or "/" try: page = max(1, int(query.get("page", "1"))) except ValueError: page = 1 if path == "/": return render_home() if path == "/demenageurs": return render_demenageurs() if path == "/inspecteurs": return render_inspecteurs() if path in _STATIC_META: return render_static(path) parts = [p for p in path.split("/") if p] if parts[0] == "propriete" and len(parts) in (2, 3): return render_listing(parts[1], parts[2] if len(parts) == 3 else None) if parts[0] == "a-vendre" and len(parts) in (2, 3): return _render_category(parts[1], parts[2] if len(parts) == 3 else None, page) if parts[0] == "type" and len(parts) == 2: return _render_category(None, parts[1], page) return render_404()