# Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai # Project: Toit-Ka # ----------------------------------------------------------------------------- # seo.py : rendu HTML côté serveur pour le référencement. # # Le SPA React reste inchangé : ce module pré-remplit le HTML initial servi par # le catch-all de web.py — /meta/canonical/og uniques, JSON-LD # schema.org, contenu et liens internes dans <div id="root"> (que React # remplace au montage). Il génère aussi robots.txt et les sitemaps. # # Pages servies : # / accueil bi-univers (louer + acheter) # /annonce/{uid}[/{slug}] fiche (301 slug canonique, 410 retirée) # /louer/{ville}[/{type}] pages programmatiques location # /acheter/{ville}[/{type}] pages programmatiques vente # /louer/type/{type} /acheter/type/{type} pages par type (province) # /stats /conditions /confidentialite /profil meta dédiées # /robots.txt /sitemap.xml /sitemaps/*.xml # ----------------------------------------------------------------------------- from __future__ import annotations import html import json import os import re import time from datetime import datetime, timezone from pathlib import Path from urllib.parse import quote from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response from . import db from .villes import slugify ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend" BASE_URL = os.environ.get("TOITKA_BASE_URL", "https://www.toit-ka.com").rstrip("/") SITE_NAME = "Toit-Ka" VISIBLE = "active=1" MIN_LISTINGS = 3 PAGE_SIZE = 48 FICHES_PER_SITEMAP = 40000 # vocabulaire par univers TXV = { "louer": { "path": "louer", "noun": "logements", "verbe": "à louer", "prix": "loyer", "Prix": "Loyer", }, "acheter": { "path": "acheter", "noun": "propriétés", "verbe": "à vendre", "prix": "prix", "Prix": "Prix", }, } # --- utilitaires ---------------------------------------------------------------- def listing_slug(row) -> str: """Slug d'une fiche — MÊME logique que fichePath() de frontend/src/api.ts.""" base = slugify(row["address"] or row["title"] or "") city = slugify(row["city"] or "") if city and city not in base: base = slugify(f"{base} {city}") if base else city return base def fiche_href(uid: str, slug: str = "") -> str: return f"/annonce/{quote(uid, safe='')}" + (f"/{slug}" if slug else "") def _esc(s) -> str: return html.escape(str(s or ""), quote=True) def _fmt_n(n) -> str: return f"{int(n):,}".replace(",", " ") def _fmt_price(p, tx: str) -> str: if p is None: return "Prix sur demande" s = f"{_fmt_n(round(p))} $" return s + " /mois" if tx == "louer" else s def _iso_date(ts) -> str: try: return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d") except (TypeError, ValueError): return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") _cache: dict[str, tuple[float, object]] = {} def _cached(key: str, ttl: float, build): now = time.time() hit = _cache.get(key) if hit and now - hit[0] < ttl: return hit[1] val = build() _cache[key] = (now, val) return val # --- registres de slugs (par univers) — reconstruits toutes les 15 min ---------- def _build_registry() -> dict: con = db.connect() out: dict = {} try: for tx in ("louer", "acheter"): cities: dict[str, dict] = {} for r in con.execute( f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}" " AND transaction_type=? AND city<>'' GROUP BY city", (tx,)): slug = slugify(r["city"]) if len(slug) >= 2: cities[slug] = {"label": r["city"], "n": r["n"]} types: dict[str, dict] = {} for r in con.execute( f"SELECT type, COUNT(*) n FROM listings WHERE {VISIBLE}" " AND transaction_type=? AND type<>'' GROUP BY type", (tx,)): slug = slugify(r["type"]) if len(slug) >= 2: types[slug] = {"label": r["type"], "n": r["n"]} city_types: dict[tuple[str, str], int] = {} for r in con.execute( f"SELECT city, type, COUNT(*) n FROM listings WHERE {VISIBLE}" " AND transaction_type=? AND city<>'' AND type<>''" " GROUP BY city, type", (tx,)): cs, ts_ = slugify(r["city"]), slugify(r["type"]) if len(cs) >= 2 and len(ts_) >= 2: city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"] out[tx] = {"cities": cities, "types": types, "city_types": city_types} finally: con.close() return out def registry() -> dict: return _cached("registry", 900, _build_registry) # --- gabarit --------------------------------------------------------------------- _tpl_cache: tuple[float, str] | None = None def _template() -> str: global _tpl_cache path = FRONTEND_DIR / "index.html" mtime = path.stat().st_mtime if _tpl_cache and _tpl_cache[0] == mtime: return _tpl_cache[1] tpl = path.read_text(encoding="utf-8") tpl = re.sub(r"<title>.*?\s*", "", tpl, flags=re.S) tpl = re.sub(r']*>\s*', "", tpl) _tpl_cache = (mtime, tpl) return tpl def _page(title: str, description: str, canonical: str, body: str, jsonld: list[dict] | None = None, og_image: str | None = None, og_type: str = "website", noindex: bool = False, status: int = 200) -> HTMLResponse: head = [ f"{_esc(title)}", f'', f'', f'', '', f'', f'', f'', f'', ] if og_image: head.append(f'') head.append('') else: head.append('') if noindex: head.append('') for obj in (jsonld or []): head.append('") tpl = _template() out = tpl.replace("", " " + "\n ".join(head) + "\n ", 1) out = out.replace('
', f'
{body}
', 1) return HTMLResponse(out, status_code=status) # --- blocs ----------------------------------------------------------------------- def _item_li(r) -> str: tx = r["transaction_type"] href = fiche_href(r["uid"], listing_slug(r)) bits = [b for b in [ r["type"], f"{r['bedrooms']} ch." if r["bedrooms"] is not None else "", f"{_fmt_n(round(r['area_sqft']))} pi²" if r["area_sqft"] else "", ] if b] name = r["address"] or r["title"] or ("Logement" if tx == "louer" else "Propriété") loc = ", ".join(x for x in [r["sector"], r["city"]] if x) return (f'
  • {_esc(name)} — ' f'{_esc(_fmt_price(r["price"], tx))}' + (f' · {_esc(" · ".join(bits))}' if bits else "") + (f' · {_esc(loc)}' if loc else "") + "
  • ") def _agg_stats(con, tx: str, city_label: str | None = None, type_label: str | None = None) -> dict: where = f"{VISIBLE} AND transaction_type=?" args: list = [tx] if city_label: where += " AND city=?"; args.append(city_label) if type_label: where += " AND type=?"; args.append(type_label) row = con.execute( f"SELECT COUNT(*) n, AVG(price) avg_p FROM listings WHERE {where}", args).fetchone() priced = con.execute( f"SELECT COUNT(*) n FROM listings WHERE {where} AND price IS NOT NULL", args).fetchone()["n"] med = None if priced: med_row = con.execute( f"SELECT price FROM listings WHERE {where} AND price IS NOT NULL" f" ORDER BY price LIMIT 1 OFFSET ?", args + [priced // 2]).fetchone() med = med_row["price"] if med_row else None return {"n": row["n"], "avg": row["avg_p"], "med": med, "where": where, "args": args} def _breadcrumb_ld(crumbs: list[tuple[str, str]]) -> dict: return { "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": name, "item": BASE_URL + path} for i, (name, path) in enumerate(crumbs) ], } def _pagination_html(base_path: str, page: int, pages: int) -> str: if pages <= 1: return "" out = ['") return "".join(out) # --- accueil ---------------------------------------------------------------------- def _home_data() -> dict: def build(): con = db.connect() try: out = {} for tx in ("louer", "acheter"): row = con.execute( f"SELECT COUNT(*) total, COUNT(DISTINCT NULLIF(city,'')) cities," f" COUNT(DISTINCT source) sources, AVG(price) avg_p" f" FROM listings WHERE {VISIBLE} AND transaction_type=?", (tx,)).fetchone() recent = [dict(r) for r in con.execute( f"SELECT uid, transaction_type, address, title, city, sector," f" type, price, bedrooms, area_sqft FROM listings" f" WHERE {VISIBLE} AND transaction_type=?" f" ORDER BY first_seen DESC LIMIT 8", (tx,))] out[tx] = {**dict(row), "recent": recent} return out finally: con.close() return _cached("home", 900, build) def render_home() -> HTMLResponse: d = _home_data() reg = registry() total = d["louer"]["total"] + d["acheter"]["total"] title = (f"Toit-Ka — Louer ou acheter au Québec : {_fmt_n(total)} annonces, " f"un seul endroit") desc = (f"{_fmt_n(d['louer']['total'])} logements à louer et " f"{_fmt_n(d['acheter']['total'])} propriétés à vendre partout au Québec, " f"agrégés depuis les gestionnaires immobiliers, agences et plateformes " f"(RE/MAX, Via Capitale, Sutton, DuProprio, Kijiji…) — mises à jour en " f"continu, chaque fiche renvoie à l'annonce originale.") body = [ f"

    Louer ou acheter un toit au Québec — {_fmt_n(total)} annonces, un seul endroit

    ", f"

    Toit-Ka réunit la location (Lou-Ka) et la vente (Immo-Ka) : " f"{_fmt_n(d['louer']['total'])} logements à louer (loyer moyen " f"{_esc(_fmt_price(d['louer']['avg_p'], 'louer'))}) et " f"{_fmt_n(d['acheter']['total'])} propriétés à vendre (prix moyen " f"{_esc(_fmt_price(d['acheter']['avg_p'], 'acheter'))}), mis à jour en continu. " f"Chaque fiche renvoie à l'annonce originale de la source.

    ", ] for tx, label in (("louer", "Logements à louer"), ("acheter", "Propriétés à vendre")): top = sorted(reg[tx]["cities"].items(), key=lambda kv: -kv[1]["n"])[:40] verbe = TXV[tx]["verbe"] body.append(f"

    {label} par ville

    ") types = sorted(reg[tx]["types"].items(), key=lambda kv: -kv[1]["n"]) body.append(f"

    Par type

    ") body.append("

    Dernières annonces

    ") body.append('

    Statistiques du marché

    ') 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 --------------------------------------------------------- def _render_category(tx: str, city_slug: str | None, type_slug: str | None, page: int) -> HTMLResponse: reg = registry().get(tx) if reg is None: return render_404() 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() v = TXV[tx] con = db.connect() try: st = _agg_stats(con, tx, city["label"] if city else None, ptype["label"] if ptype else None) if st["n"] < 1: return render_404() pages = max(1, -(-st["n"] // PAGE_SIZE)) if page < 1 or page > pages: return render_404() rows = con.execute( f"SELECT uid, transaction_type, address, title, city, sector, type," f" price, bedrooms, 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() links = [] if city: 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)} {v['verbe']} à {_esc(city['label'])}" f" ({_fmt_n(n)})
  • ") if tlinks: links.append(f"

    Autres types {v['verbe']} à " + _esc(city["label"]) + "

    ") if type_slug: tous = "Tous les logements" if tx == "louer" else "Toutes les propriétés" links.append(f'

    {tous} ' f'{v["verbe"]} à {_esc(city["label"])} · ' f'{_esc(ptype["label"])} ' f'{v["verbe"]} au Québec

    ') other_tx = "acheter" if tx == "louer" else "louer" if city_slug in registry()[other_tx]["cities"]: ov = TXV[other_tx] links.append(f'

    ' f'{ov["noun"].capitalize()} {ov["verbe"]} à ' f'{_esc(city["label"])}

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

    Autres villes

    ") finally: con.close() if city and ptype: base_path = f"/{tx}/{city_slug}/{type_slug}" h1 = f"{ptype['label']} {v['verbe']} à {city['label']}" elif city: base_path = f"/{tx}/{city_slug}" h1 = f"{v['noun'].capitalize()} {v['verbe']} à {city['label']}" else: base_path = f"/{tx}/type/{type_slug}" h1 = f"{ptype['label']} {v['verbe']} 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 "") + f" | {SITE_NAME}") desc = (f"{_fmt_n(st['n'])} {h1.lower()} : {v['prix']} médian " f"{_fmt_price(st['med'], tx)}, {v['prix']} moyen " f"{_fmt_price(st['avg'], tx)}. Annonces agrégées de toutes les sources, " f"mises à jour en continu — chaque fiche renvoie à l'annonce originale.") stats_p = (f"

    {_fmt_n(st['n'])} annonces · {v['prix']} médian " f"{_esc(_fmt_price(st['med'], tx))} · {v['prix']} moyen " f"{_esc(_fmt_price(st['avg'], tx))}.

    ") crumbs = [("Accueil", "/")] if city: crumbs.append((f"{v['noun'].capitalize()} {v['verbe']} à {city['label']}", f"/{tx}/{city_slug}")) if ptype: crumbs.append((ptype["label"], base_path)) else: crumbs.append((h1, base_path)) body = ('

    {_esc(h1)}

    " + stats_p + "" + _pagination_html(base_path, page, pages) + "".join(links)) return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)]) # --- fiche --------------------------------------------------------------------------- _TYPE_SCHEMA = { "maison": "SingleFamilyResidence", "condo": "Apartment", "chalet": "House", "duplex": "Residence", "triplex": "Residence", "multiplex": "Residence", "maison-mobile": "House", "studio": "Apartment", "loft": "Apartment", "appartement": "Apartment", "chambre": "Room", "penthouse": "Apartment", } def render_listing(uid: str, slug: str | None) -> Response: con = db.connect() try: row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() finally: con.close() if row is None: return render_404() tx = row["transaction_type"] v = TXV[tx] city_slug = slugify(row["city"] or "") city_known = city_slug in registry()[tx]["cities"] city_href = f"/{tx}/{city_slug}" if city_known else "/" if not row["active"]: name = row["address"] or row["title"] or "Annonce" body = (f"

    Cette annonce n'est plus disponible

    " f"

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

    ') return _page(f"Annonce retirée — {name} | {SITE_NAME}", "Cette annonce a été retirée.", BASE_URL + fiche_href(uid), body, noindex=True, status=410) expected = listing_slug(row) if expected and slug != expected: return RedirectResponse(BASE_URL + fiche_href(uid, expected), status_code=301) d = dict(row) images = json.loads(d.get("images") or "[]") name = d["address"] or d["title"] or f"Toit {v['verbe']}" loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec" canonical = BASE_URL + fiche_href(uid, expected) ptype = d["type"] or ("Logement" if tx == "louer" else "Propriété") specs = [(lbl, val) for lbl, val in [ ("Type", ptype), (v["Prix"], _fmt_price(d["price"], tx) if d["price"] is not None else d["price_label"]), ("Chambres", d["bedrooms"]), ("Salles de bain", d["bathrooms"]), ("Superficie", f"{_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else None), ("Terrain", f"{_fmt_n(round(d['lot_sqft']))} pi²" if d["lot_sqft"] else None), ("Année de construction", d["year_built"]), ("Animaux", d["pets"]), ("Meublé", "oui" if d["furnished"] == 1 else ("non" if d["furnished"] == 0 else None)), ("Disponibilité", "maintenant" if d["availability_date"] == "now" else d["availability_date"]), ("Ville", d["city"]), ("Secteur", d["sector"]), ("N° MLS", d["mls"]), ("Courtier", d["broker_name"]), ] if val not in (None, "", 0)] descr = (d["description"] or "").strip() if len(descr) > 1500: descr = descr[:1500].rsplit(" ", 1)[0] + "…" type_slug = slugify(ptype) crumbs = [("Accueil", "/")] if city_known: crumbs.append((f"{v['verbe'].capitalize()} à {d['city']}", city_href)) if (city_slug, type_slug) in registry()[tx]["city_types"]: crumbs.append((ptype, f"/{tx}/{city_slug}/{type_slug}")) crumbs.append((name, fiche_href(uid, expected))) body = [ '", f"

    {_esc(name)}

    ", f"

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

    ", ] if images: body.append("".join( f'{_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 d["url"]: body.append(f'

    ' f"Voir l'annonce originale ({_esc(d['source'])})

    ") if city_known: body.append(f'

    {v["noun"].capitalize()} {v["verbe"]} à ' f'{_esc(d["city"])}

    ') about_type = _TYPE_SCHEMA.get(type_slug, "Residence") about: dict = { "@type": about_type, "name": name, "address": {"@type": "PostalAddress", "streetAddress": d["address"] or None, "addressLocality": d["city"] or None, "addressRegion": "QC", "addressCountry": "CA"}, } if d["lat"] is not None and d["lng"] is not None: about["geo"] = {"@type": "GeoCoordinates", "latitude": d["lat"], "longitude": d["lng"]} if d["bedrooms"] is not None: about["numberOfBedrooms"] = d["bedrooms"] if d["area_sqft"]: about["floorSize"] = {"@type": "QuantitativeValue", "value": round(d["area_sqft"]), "unitCode": "FTK"} if d["year_built"]: about["yearBuilt"] = d["year_built"] about = {k: x for k, x in about.items() if x is not None} about["address"] = {k: x for k, x in about["address"].items() if x is not None} jsonld: list[dict] = [{ "@context": "https://schema.org", "@type": "RealEstateListing", "name": name, "url": canonical, "inLanguage": "fr-CA", "datePosted": _iso_date(d.get("first_seen")), "image": images[:6] or None, "about": about, }, _breadcrumb_ld(crumbs)] if d["price"] is not None: offer = {"@type": "Offer", "price": round(d["price"], 2), "priceCurrency": "CAD", "availability": "https://schema.org/InStock"} if tx == "louer": offer["priceSpecification"] = { "@type": "UnitPriceSpecification", "price": round(d["price"], 2), "priceCurrency": "CAD", "unitText": "MOIS"} jsonld[0]["offers"] = offer jsonld[0] = {k: x for k, x in jsonld[0].items() if x is not None} title = (f"{name} — {ptype} {v['verbe']}, {d['city'] or 'Québec'} | " f"{_fmt_price(d['price'], tx) if d['price'] is not None else 'Prix sur demande'}") meta_desc = (f"{ptype} {v['verbe']} à {loc}" + (f", {d['bedrooms']} chambres" if d["bedrooms"] else "") + (f", {_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else "") + f" — {_fmt_price(d['price'], tx) if d['price'] is not None else 'prix sur demande'}. " + (descr[:120] + "…" if len(descr) > 120 else descr)) return _page(title, meta_desc, canonical, "".join(body), jsonld, og_image=images[0] if images else None, og_type="article") # --- pages statiques et 404 ----------------------------------------------------------- _STATIC_META = { "/stats": ("Statistiques du marché — louer et acheter", "Loyers moyens, prix moyens, volumes par ville et par type : " "statistiques en continu des deux univers de Toit-Ka.", False), "/conditions": ("Conditions d'utilisation", "Conditions d'utilisation de la plateforme Toit-Ka (Groupe-Ka).", False), "/confidentialite": ("Politique de confidentialité", "Politique de confidentialité de Toit-Ka (Groupe-Ka) — Loi 25.", False), "/profil": ("Mon profil", "Votre compte Groupe KA sur Toit-Ka.", True), } def render_static(path: str) -> HTMLResponse: t, desc, noindex = _STATIC_META[path] body = f"

    {_esc(t)}

    {_esc(desc)}

    " return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex) def render_404() -> HTMLResponse: body = ('

    Page introuvable

    Le lien demandé n\'existe pas.

    ' '

    Tous les toits à louer et à vendre au Québec

    ') return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.", BASE_URL + "/", body, noindex=True, status=404) # --- robots + sitemaps ------------------------------------------------------------------ def robots_txt() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\n" "Allow: /\n" "Disallow: /api/\n" "Disallow: /profil\n" f"\nSitemap: {BASE_URL}/sitemap.xml\n") def _xml(urls: list[str]) -> Response: body = ('\n' '\n' + "\n".join(urls) + "\n") return Response(body, media_type="application/xml") def _url_el(loc: str, lastmod: str | None = None) -> str: lm = f"{lastmod}" if lastmod else "" return f" {html.escape(loc)}{lm}" def sitemap_index() -> Response: def build(): con = db.connect() try: n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"] last = con.execute( f"SELECT MAX(updated_at) m FROM listings WHERE {VISIBLE}").fetchone()["m"] finally: con.close() parts = -(-n // FICHES_PER_SITEMAP) or 1 lm = _iso_date(last) maps = [f"{BASE_URL}/sitemaps/fiches-{i + 1}.xml" for i in range(parts)] maps += [f"{BASE_URL}/sitemaps/villes-louer.xml", f"{BASE_URL}/sitemaps/villes-acheter.xml", f"{BASE_URL}/sitemaps/villes-types.xml", f"{BASE_URL}/sitemaps/types.xml", f"{BASE_URL}/sitemaps/pages.xml"] return ('\n' '\n' + "\n".join(f" {html.escape(m)}" f"{lm}" for m in maps) + "\n") return Response(_cached("sm:index", 3600, build), media_type="application/xml") def sitemap_file(name: str) -> Response: m = re.fullmatch(r"fiches-(\d+)\.xml", name) if m: part = int(m.group(1)) def build(): con = db.connect() try: rows = con.execute( f"SELECT uid, address, title, city, updated_at FROM listings" f" WHERE {VISIBLE} ORDER BY uid LIMIT ? OFFSET ?", (FICHES_PER_SITEMAP, (part - 1) * FICHES_PER_SITEMAP)).fetchall() finally: con.close() if not rows: return None return [_url_el(BASE_URL + fiche_href(r["uid"], listing_slug(r)), _iso_date(r["updated_at"])) for r in rows] urls = _cached(f"sm:fiches:{part}", 3600, build) if urls is None: return Response("Sitemap introuvable", status_code=404) return _xml(urls) if name in ("villes-louer.xml", "villes-acheter.xml"): tx = "louer" if "louer" in name else "acheter" def build(): reg = registry()[tx] return [_url_el(f"{BASE_URL}/{tx}/{s}") for s, e in sorted(reg["cities"].items()) if e["n"] >= MIN_LISTINGS] return _xml(_cached(f"sm:{name}", 3600, build)) if name == "villes-types.xml": def build(): out = [] for tx in ("louer", "acheter"): reg = registry()[tx] out += [_url_el(f"{BASE_URL}/{tx}/{cs}/{ts_}") for (cs, ts_), n in sorted(reg["city_types"].items()) if n >= MIN_LISTINGS and cs in reg["cities"] and ts_ in reg["types"] and reg["cities"][cs]["n"] >= MIN_LISTINGS] return out return _xml(_cached("sm:villes-types", 3600, build)) if name == "types.xml": def build(): out = [] for tx in ("louer", "acheter"): reg = registry()[tx] out += [_url_el(f"{BASE_URL}/{tx}/type/{s}") for s, e in sorted(reg["types"].items()) if e["n"] >= MIN_LISTINGS] return out return _xml(_cached("sm:types", 3600, build)) if name == "pages.xml": return _xml([_url_el(f"{BASE_URL}{p}") for p in ["/", "/stats", "/conditions", "/confidentialite"]]) return Response("Sitemap introuvable", status_code=404) # --- résolution de slugs pour le SPA ------------------------------------------------------ def resolve_slugs(tx: str | None, ville: str | None, ptype: str | None) -> dict | None: if tx not in TXV: return None reg = registry()[tx] out: dict = {"tx": tx} if ville: e = reg["cities"].get(ville) if not e: return None out["city"] = e["label"] out["city_n"] = e["n"] if ptype: e = reg["types"].get(ptype) if not e: return None out["type"] = e["label"] out["type_n"] = e["n"] return out # --- routage (appelé par le catch-all de web.py) ------------------------------------------- def render_for_path(path: str, query: dict) -> Response: """HTML SEO pour `path` (ex. « /louer/levis »), sinon 404 SSR.""" path = path.rstrip("/") or "/" try: page = max(1, int(query.get("page", "1"))) except ValueError: page = 1 if path == "/": return render_home() if path in _STATIC_META: return render_static(path) parts = [p for p in path.split("/") if p] if parts[0] == "annonce" and len(parts) in (2, 3): return render_listing(parts[1], parts[2] if len(parts) == 3 else None) if parts[0] in ("louer", "acheter"): tx = parts[0] if len(parts) == 3 and parts[1] == "type": return _render_category(tx, None, parts[2], page) if len(parts) in (2, 3): return _render_category(tx, parts[1], parts[2] if len(parts) == 3 else None, page) return render_404()