# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # seo.py : référencement — SSR léger, pages programmatiques, robots, sitemaps # # Principe : pour chaque route publique, le serveur renvoie le MÊME index.html # que le build Vite, mais avec un unique (title, description, canonical, # og:, JSON-LD) et le contenu essentiel en HTML DANS
. Les # moteurs de recherche voient une page complète sans exécuter JavaScript ; # React, en se montant, remplace ce contenu par l'application interactive. # ----------------------------------------------------------------------------- from __future__ import annotations import html import json import math import re import statistics import time import unicodedata from datetime import date, datetime, timezone from pathlib import Path from xml.sax.saxutils import escape as xml_escape from fastapi import APIRouter, HTTPException from fastapi.responses import HTMLResponse, PlainTextResponse, Response from . import db from .shortterm import db as ctdb router = APIRouter() ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" SOURCES_PATH = ROOT / "data" / "sources.json" BASE_URL = "https://www.lou-ka.com" SITE_NAME = "Lou-Ka" # bornes de plausibilité d'un loyer mensuel — hors bornes : prix exclu des # statistiques et des données structurées (certaines sources publient 0 $) PRICE_MIN, PRICE_MAX = 195, 15000 PRICE_OK = f"price >= {PRICE_MIN} AND price <= {PRICE_MAX}" # seuil d'inclusion d'une page programmatique dans le sitemap MIN_LISTINGS_PAGE = 3 SITEMAP_CHUNK = 10000 # --- Slugs ------------------------------------------------------------------- def slugify(text: str) -> str: """« Trois-Rivières » → trois-rivieres, « 3½ » → 3-1-2, « 6½+ » → 6-1-2-plus.""" t = text.replace("½", "-1-2").replace("+", "-plus") t = t.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae") t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode() t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") return t _maps_cache: dict = {"ts": 0.0, "cities": {}, "types": {}} def _slug_maps() -> tuple[dict[str, str], dict[str, str]]: """(slug→ville, slug→type d'unité), reconstruit au plus toutes les 10 min.""" if time.time() - _maps_cache["ts"] > 600: con = db.connect() cities = [r["city"] for r in con.execute( "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>''")] types = [r["unit_type"] for r in con.execute( "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>''")] con.close() _maps_cache["cities"] = {slugify(c): c for c in sorted(cities)} _maps_cache["types"] = {slugify(t): t for t in sorted(types)} _maps_cache["ts"] = time.time() return _maps_cache["cities"], _maps_cache["types"] # --- Gabarit (index.html du build Vite) -------------------------------------- _shell_cache: dict = {"mtime": 0.0, "html": ""} def _shell() -> str: f = FRONTEND_DIST / "index.html" mtime = f.stat().st_mtime if mtime != _shell_cache["mtime"]: _shell_cache["html"] = f.read_text(encoding="utf-8") _shell_cache["mtime"] = mtime return _shell_cache["html"] def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None, body: str = "", og_image: str | None = None, status: int = 200) -> HTMLResponse: """index.html du build + head unique + contenu HTML dans #root.""" canonical = BASE_URL + path page = _shell() page = re.sub(r".*?", lambda _m: f"{html.escape(title)}", page, count=1, flags=re.S) page = re.sub(r']*/>', lambda _m: f'', page, count=1) # retire du gabarit statique les meta og:image/twitter (re-injectées ci-dessous) page = re.sub(r'\s*]*/>', "", page) extras = [ f'', f'', f'', f'', '', '', f'', f'', f'', '', f'', ] img = og_image or (BASE_URL + "/og.png") extras.append(f'') if not og_image: extras.append('') extras.append('') extras.append(f'') for obj in (jsonld or []): blob = json.dumps(obj, ensure_ascii=False).replace("{blob}') page = page.replace("", " " + "\n ".join(extras) + "\n", 1) if body: seo_div = ('
' + body + '

Lou-Ka — Un service Groupe KA

' + "
") page = page.replace('
', '
' + seo_div, 1) return HTMLResponse(page, status_code=status, headers={"Cache-Control": "no-cache"}) def _e(t) -> str: return html.escape(str(t or "")) def _fmt_price(p) -> str: return f"{int(round(p)):,} $".replace(",", " ") if p else "" def _price_ok(p) -> bool: return p is not None and PRICE_MIN <= p <= PRICE_MAX def _iso(ts) -> str: if not ts: return date.today().isoformat() return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() def _listing_li(r) -> str: """Une annonce dans une liste HTML serveur.""" label = r["title"] or r["address"] or r["uid"] bits = [b for b in (r["unit_type"], _fmt_price(r["price"]) + "/mois" if _price_ok(r["price"]) else "", r["sector"] or r["city"]) if b] return (f'
  • {_e(label)}' f'{" — " + _e(" · ".join(bits)) if bits else ""}
  • ') def _not_found(message: str, path: str) -> HTMLResponse: """404 HTML : le shell React est servi (la SPA affichera sa page), mais le statut et le contenu serveur disent clairement « introuvable » aux bots.""" return _render(title="Page introuvable | Lou-Ka", description="Cette page n'existe pas sur Lou-Ka.", path=path, body=f"

    {_e(message)}

    " '

    Voir tous les logements à louer au Québec · ' 'Logements par ville

    ', status=404) def _breadcrumb(items: 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(items)]} # --- Données ----------------------------------------------------------------- def _city_stats(con, city: str) -> dict: n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND city=?", (city,)).fetchone()["c"] prices = [r["price"] for r in con.execute( f"SELECT price FROM listings WHERE active=1 AND city=? AND {PRICE_OK}", (city,))] types = [dict(r) for r in con.execute( """SELECT unit_type, COUNT(*) n FROM listings WHERE active=1 AND city=? AND unit_type<>'' GROUP BY unit_type ORDER BY n DESC""", (city,))] for t in types: t["slug"] = slugify(t["unit_type"]) return {"n": n, "avg": round(statistics.mean(prices)) if prices else None, "med": round(statistics.median(prices)) if prices else None, "types": types} def _villes_rows(con, minimum: int = 1) -> list[dict]: rows = [dict(r) for r in con.execute( f"""SELECT city, COUNT(*) n, AVG(CASE WHEN {PRICE_OK} THEN price END) avg_price, MAX(updated_at) last FROM listings WHERE active=1 AND city<>'' GROUP BY city HAVING n>=? ORDER BY n DESC""", (minimum,))] for r in rows: r["slug"] = slugify(r["city"]) r["avg_price"] = round(r["avg_price"]) if r["avg_price"] else None return rows def _parse_row(r) -> dict: d = dict(r) for k in ("amenities", "images"): d[k] = json.loads(d.get(k) or "[]") d["details"] = json.loads(d.get("details") or "{}") return d # --- API JSON pour les pages villes du frontend ------------------------------ @router.get("/api/seo/villes") def api_villes(): con = db.connect() rows = _villes_rows(con) con.close() return {"villes": rows} @router.get("/api/seo/ville/{slug}") def api_ville(slug: str, type: str | None = None): cities, types = _slug_maps() city = cities.get(slug) if not city: raise HTTPException(404, "Ville inconnue") unit_type = None if type: unit_type = types.get(type) if not unit_type: raise HTTPException(404, "Type de logement inconnu") con = db.connect() stats = _city_stats(con, city) sql = "SELECT * FROM listings WHERE active=1 AND city=?" args: list = [city] if unit_type: sql += " AND unit_type=?" args.append(unit_type) listings = [_parse_row(r) for r in con.execute( sql + " ORDER BY updated_at DESC LIMIT 100", args)] neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12] con.close() return {"city": city, "slug": slug, "unit_type": unit_type, **stats, "listings": listings, "neighbors": neighbors} # --- robots.txt & sitemaps ---------------------------------------------------- @router.get("/robots.txt", include_in_schema=False) def robots() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\n" "Allow: /\n" "Disallow: /api/\n" "Disallow: /uploads/\n" "Disallow: /profil\n" "Disallow: /favoris\n" "Disallow: /gestion\n" "Disallow: /bienvenue\n" "Disallow: /bot\n" "Disallow: /passerelle/\n" f"\nSitemap: {BASE_URL}/sitemap.xml\n") def _xml(content: str) -> Response: return Response('\n' + content, media_type="application/xml", headers={"Cache-Control": "public, max-age=3600"}) def _urlset(urls: list[tuple[str, str | None]]) -> Response: rows = [] for loc, lastmod in urls: lm = f"{lastmod}" if lastmod else "" rows.append(f"{xml_escape(loc)}{lm}") return _xml('\n' + "\n".join(rows) + "\n") @router.get("/sitemap.xml", include_in_schema=False) def sitemap_index(): con = db.connect() total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"] con.close() ct_con = ctdb.connect() ct_total = ct_con.execute( "SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"] ct_con.close() chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) ct_chunks = max(1, math.ceil(ct_total / SITEMAP_CHUNK)) names = (["sitemap-pages.xml", "sitemap-villes.xml"] + [f"sitemap-annonces-{i}.xml" for i in range(1, chunks + 1)] + [f"sitemap-ct-{i}.xml" for i in range(1, ct_chunks + 1)]) today = date.today().isoformat() rows = "\n".join( f"{BASE_URL}/{n}{today}" for n in names) return _xml('\n' + rows + "\n") @router.get("/sitemap-pages.xml", include_in_schema=False) def sitemap_pages(): urls: list[tuple[str, str | None]] = [ (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), (f"{BASE_URL}/court-terme", None), (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None), (f"{BASE_URL}/demenageurs", None), (f"{BASE_URL}/confidentialite", None), (f"{BASE_URL}/conditions", None)] con = db.connect() for r in con.execute( """SELECT source, MAX(updated_at) last FROM listings WHERE active=1 GROUP BY source"""): urls.append((f"{BASE_URL}/g/{r['source']}", _iso(r["last"]))) con.close() return _urlset(urls) @router.get("/sitemap-villes.xml", include_in_schema=False) def sitemap_villes(): con = db.connect() urls: list[tuple[str, str | None]] = [] for v in _villes_rows(con, MIN_LISTINGS_PAGE): urls.append((f"{BASE_URL}/ville/{v['slug']}", _iso(v["last"]))) for r in con.execute( f"""SELECT city, unit_type, COUNT(*) n, MAX(updated_at) last FROM listings WHERE active=1 AND city<>'' AND unit_type<>'' GROUP BY city, unit_type HAVING n>=?""", (MIN_LISTINGS_PAGE,)): urls.append((f"{BASE_URL}/ville/{slugify(r['city'])}/{slugify(r['unit_type'])}", _iso(r["last"]))) con.close() return _urlset(urls) @router.get("/sitemap-annonces-{num}.xml", include_in_schema=False) def sitemap_annonces(num: int): if num < 1: raise HTTPException(404) con = db.connect() rows = con.execute( """SELECT uid, updated_at FROM listings WHERE active=1 ORDER BY uid LIMIT ? OFFSET ?""", (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() con.close() if not rows: raise HTTPException(404) return _urlset([(f"{BASE_URL}/logement/{r['uid']}", _iso(r["updated_at"])) for r in rows]) @router.get("/sitemap-ct-{num}.xml", include_in_schema=False) def sitemap_ct(num: int): if num < 1: raise HTTPException(404) con = ctdb.connect() rows = con.execute( """SELECT uid, updated_at FROM st_listings WHERE active=1 ORDER BY uid LIMIT ? OFFSET ?""", (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() con.close() if not rows: raise HTTPException(404) return _urlset([(f"{BASE_URL}/court-terme/{r['uid']}", _iso(r["updated_at"])) for r in rows]) # --- Pages SSR ---------------------------------------------------------------- @router.get("/", include_in_schema=False) def home_ssr(): con = db.connect() total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"] nsources = con.execute( "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"] villes = _villes_rows(con, MIN_LISTINGS_PAGE) recents = con.execute( f"""SELECT uid, title, address, sector, city, unit_type, price FROM listings WHERE active=1 AND {PRICE_OK} ORDER BY first_seen DESC LIMIT 20""").fetchall() con.close() title = f"Lou-Ka — Un service Groupe KA · {total:,} logements à louer au Québec".replace(",", " ") description = (f"{total:,} appartements et logements à louer partout au Québec, " f"agrégés depuis {nsources} gestionnaires immobiliers et toujours à jour : " "Montréal, Québec, Lévis, Gatineau et plus. Photos, prix, disponibilité " "et lien direct vers l'annonce originale.").replace(",", " ") top = villes[:30] body = ( f"

    Logements à louer au Québec — {total:,} annonces à jour

    ".replace(",", " ") + f"

    Lou-Ka agrège les appartements à louer publiés par {nsources} gestionnaires " "immobiliers partout au Québec : chaque annonce avec ses photos, son prix, sa " "disponibilité et un lien direct vers le site du gestionnaire.

    " + "

    Logements par ville

    Toutes les villes ({len(villes)})

    ' + "

    Dernières annonces

      " + "".join(_listing_li(r) for r in recents) + "
    ") jsonld = [ {"@context": "https://schema.org", "@type": "WebSite", "name": SITE_NAME, "url": BASE_URL + "/", "description": description, "inLanguage": "fr-CA"}, {"@context": "https://schema.org", "@type": "Organization", "name": "Lou-Ka (Groupe KA)", "url": BASE_URL + "/"}] return _render(title=title, description=description, path="/", jsonld=jsonld, body=body) @router.get("/demenageurs", include_in_schema=False) def demenageurs_ssr(): 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) | Lou-Ka" 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

    ", "

    " + _e(description) + "

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

    {_e(region)} ({len(ms)})

      ") for m in ms: item = f"{_e(m['name'])} — {_e(m['city'])}" if m.get("phone"): item += ", " + _e(m["phone"]) if m.get("website"): item += ' — site web' parts.append("
    • " + item + "
    • ") parts.append("
    ") jsonld = [_breadcrumb([("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 _render(title=title, description=description, path="/demenageurs", jsonld=jsonld, body="".join(parts)) @router.get("/villes", include_in_schema=False) def villes_ssr(): con = db.connect() villes = _villes_rows(con) con.close() total = sum(v["n"] for v in villes) title = f"Logements à louer par ville au Québec ({len(villes)} villes) | Lou-Ka" description = (f"Toutes les villes du Québec où Lou-Ka recense des logements à louer : " f"{total:,} annonces dans {len(villes)} villes, avec loyer moyen et " "nombre d'appartements disponibles par ville.").replace(",", " ") body = (f"

    Logements à louer par ville — {len(villes)} villes au Québec

      " + "".join(f'
    • {_e(v["city"])} — {v["n"]} annonces' + (f", loyer moyen {_fmt_price(v['avg_price'])}" if v["avg_price"] else "") + "
    • " for v in villes) + "
    ") jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Logements à louer par ville au Québec", "numberOfItems": len(villes), "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": f"Logements à louer à {v['city']}", "url": f"{BASE_URL}/ville/{v['slug']}"} for i, v in enumerate(villes[:100])]}] return _render(title=title, description=description, path="/villes", jsonld=jsonld, body=body) def _ville_ssr(slug: str, type_slug: str | None = None): cities, types = _slug_maps() path = f"/ville/{slug}" + (f"/{type_slug}" if type_slug else "") city = cities.get(slug) if not city: return _not_found("Aucun logement recensé pour cette ville", path) unit_type = None if type_slug is not None: unit_type = types.get(type_slug) if not unit_type: return _not_found("Type de logement inconnu", path) con = db.connect() stats = _city_stats(con, city) sql = "SELECT * FROM listings WHERE active=1 AND city=?" args: list = [city] if unit_type: sql += " AND unit_type=?" args.append(unit_type) n = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] prices = [r["price"] for r in con.execute( f"SELECT price FROM listings WHERE active=1 AND city=? AND unit_type=? AND {PRICE_OK}", (city, unit_type))] avg = round(statistics.mean(prices)) if prices else None med = round(statistics.median(prices)) if prices else None else: n, avg, med = stats["n"], stats["avg"], stats["med"] rows = con.execute(sql + " ORDER BY updated_at DESC LIMIT 100", args).fetchall() neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12] con.close() if n == 0: return _not_found(f"Aucune annonce active à {city} pour ce type", path) what = f"{unit_type} à louer" if unit_type else "Logements à louer" title = f"{what} à {city} — {n} annonces | Lou-Ka" desc_stats = (f"loyer moyen {_fmt_price(avg)}, médian {_fmt_price(med)}" if avg and med else "") description = (f"{n} {what.lower()} à {city}" + (f" ({desc_stats})" if desc_stats else "") + ". Annonces à jour des gestionnaires immobiliers, avec photos, prix, " "disponibilité et lien direct vers l'annonce originale.") body = [f"

    {_e(what)} à {_e(city)} — {n} annonces

    "] if avg and med: body.append(f"

    Loyer moyen : {_fmt_price(avg)}/mois · " f"loyer médian : {_fmt_price(med)}/mois.

    ") if not unit_type and stats["types"]: body.append("

    Par type de logement

    ") body.append("

    Annonces

      " + "".join(_listing_li(r) for r in rows) + "
    ") if unit_type: body.append(f'

    Tous les logements à {_e(city)}

    ') body.append("

    Autres villes

    ") crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, f"/ville/{slug}")] if unit_type: crumbs.append((f"{unit_type} à {city}", path)) jsonld = [_breadcrumb(crumbs), {"@context": "https://schema.org", "@type": "ItemList", "name": f"{what} à {city}", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["title"] or r["address"] or r["uid"], "url": f"{BASE_URL}/logement/{r['uid']}"} for i, r in enumerate(rows[:50])]}] return _render(title=title, description=description, path=path, jsonld=jsonld, body="".join(body)) @router.get("/ville/{slug}", include_in_schema=False) def ville_ssr(slug: str): return _ville_ssr(slug) @router.get("/ville/{slug}/{type_slug}", include_in_schema=False) def ville_type_ssr(slug: str, type_slug: str): return _ville_ssr(slug, type_slug) @router.get("/logement/{uid:path}", include_in_schema=False) def listing_ssr(uid: str): con = db.connect() row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() con.close() if row is None: return _render(title="Annonce introuvable | Lou-Ka", description="Cette annonce n'existe pas ou plus sur Lou-Ka.", path=f"/logement/{uid}", body="

    Annonce introuvable

    " '

    Voir tous les logements à louer au Québec

    ', status=404) d = _parse_row(row) city_slug = slugify(d["city"]) if d["city"] else "" if not d["active"]: # annonce retirée chez la source : 410 Gone + lien vers la ville parente link = (f'Logements à louer à {_e(d["city"])}' if city_slug else 'Tous les logements') return _render( title="Annonce retirée | Lou-Ka", description="Cette annonce a été retirée par le gestionnaire immobilier.", path=f"/logement/{uid}", body=f"

    Cette annonce n'est plus disponible

    " f"

    Elle a été retirée par le gestionnaire. {link}.

    ", status=410) label = d["title"] or d["address"] or "Logement à louer" where = d["city"] if d["city"] and d["city"] not in label else "" title = (f"{d['unit_type'] + ' à louer — ' if d['unit_type'] else ''}{label}" + (f", {where}" if where else "") + " | Lou-Ka") price_txt = f"{_fmt_price(d['price'])}/mois" if _price_ok(d["price"]) else (d["price_label"] or "") bits = [b for b in (d["unit_type"], price_txt, d["sector"], d["city"], d["availability"]) if b] description = (" · ".join(bits) + ". " if bits else "") + \ (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150 else (d["description"] or "")).strip() description = description[:300] or f"Logement à louer à {d['city']} sur Lou-Ka." body = [f"

    {_e(label)}{' — ' + _e(d['unit_type']) if d['unit_type'] else ''}

    "] facts = [("Adresse", d["address"]), ("Ville", d["city"]), ("Quartier", d["sector"]), ("Type", d["unit_type"]), ("Loyer", price_txt), ("Disponibilité", d["availability"]), ("Superficie", f"{int(d['area_sqft'])} pi²" if d["area_sqft"] else "")] body.append("
      " + "".join(f"
    • {k} : {_e(v)}
    • " for k, v in facts if v) + "
    ") if d["description"]: body.append(f"

    {_e(d['description'][:600])}

    ") if d["amenities"]: body.append("

    Commodités : " + _e(", ".join(d["amenities"][:15])) + "

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

    ' "Voir l'annonce originale chez le gestionnaire

    ") if city_slug: body.append(f'

    Autres logements à louer à ' f'{_e(d["city"])}

    ') rooms = None m = re.match(r"(\d+)", d["unit_type"] or "") if m: rooms = int(m.group(1)) apartment: dict = { "@type": "Apartment", "name": label, "address": {"@type": "PostalAddress", "streetAddress": d["address"] or None, "addressLocality": d["city"] or None, "addressRegion": "QC", "addressCountry": "CA"}} if d["lat"] and d["lng"]: apartment["geo"] = {"@type": "GeoCoordinates", "latitude": d["lat"], "longitude": d["lng"]} if rooms: apartment["numberOfRooms"] = rooms if d["area_sqft"]: apartment["floorSize"] = {"@type": "QuantitativeValue", "value": d["area_sqft"], "unitCode": "FTK"} if d["images"]: apartment["photo"] = d["images"][:5] listing_ld: dict = { "@context": "https://schema.org", "@type": "RealEstateListing", "name": title.removesuffix(" | Lou-Ka"), "url": f"{BASE_URL}/logement/{uid}", "datePosted": _iso(d["first_seen"]), "inLanguage": "fr-CA", "about": apartment} if _price_ok(d["price"]): listing_ld["offers"] = { "@type": "Offer", "price": d["price"], "priceCurrency": "CAD", "availability": "https://schema.org/InStock", "businessFunction": "http://purl.org/goodrelations/v1#LeaseOut"} crumbs = [("Accueil", "/")] if city_slug: crumbs.append((d["city"], f"/ville/{city_slug}")) crumbs.append((label, f"/logement/{uid}")) return _render(title=title, description=description, path=f"/logement/{uid}", jsonld=[listing_ld, _breadcrumb(crumbs)], body="".join(body), og_image=d["images"][0] if d["images"] else None) @router.get("/g/{source_id}", include_in_schema=False) def gestionnaire_ssr(source_id: str): registry = {s["id"]: s for s in json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]} src = registry.get(source_id) if not src: return _not_found("Gestionnaire inconnu", f"/g/{source_id}") con = db.connect() n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND source=?", (source_id,)).fetchone()["c"] rows = con.execute( """SELECT uid, title, address, sector, city, unit_type, price FROM listings WHERE active=1 AND source=? ORDER BY updated_at DESC LIMIT 60""", (source_id,)).fetchall() con.close() name = src["name"] title = f"{name} — {n} logements à louer | Lou-Ka" description = (f"Les {n} logements à louer de {name}" + (f" ({src['region']})" if src.get("region") else "") + " recensés par Lou-Ka, avec prix, photos et lien direct " "vers l'annonce originale.") body = (f"

    {_e(name)} — {n} logements à louer

    " + (f"

    Secteurs : {_e(src['sectors'])}.

    " if src.get("sectors") else "") + "
      " + "".join(_listing_li(r) for r in rows) + "
    " + '

    Tous les gestionnaires

    ') jsonld = [_breadcrumb([("Accueil", "/"), ("Sources", "/sources"), (name, f"/g/{source_id}")]), {"@context": "https://schema.org", "@type": "Organization", "name": name, "url": src.get("url") or f"{BASE_URL}/g/{source_id}"}] return _render(title=title, description=description, path=f"/g/{source_id}", jsonld=jsonld, body=body) # --- Court terme (chalets & hébergements à la nuit) --------------------------- # bornes de plausibilité d'un prix à la nuit CT_PRICE_MIN, CT_PRICE_MAX = 25, 10000 def _ct_price_ok(p) -> bool: return p is not None and CT_PRICE_MIN <= p <= CT_PRICE_MAX def _ct_li(r) -> str: bits = [b for b in ( r["property_type"], f"{_fmt_price(r['price_night'])}/nuit" if _ct_price_ok(r["price_night"]) else "", r["city"] or r["region"]) if b] return (f'
  • {_e(r["title"] or r["uid"])}' f'{" — " + _e(" · ".join(bits)) if bits else ""}
  • ') @router.get("/court-terme", include_in_schema=False) def court_terme_ssr(): con = ctdb.connect() total = con.execute( "SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"] nsources = con.execute( "SELECT COUNT(DISTINCT source) c FROM st_listings WHERE active=1").fetchone()["c"] regions = [dict(r) for r in con.execute( "SELECT region, COUNT(*) n FROM st_listings WHERE active=1" " AND region<>'' GROUP BY region ORDER BY n DESC")] types = [dict(r) for r in con.execute( "SELECT property_type t, COUNT(*) n FROM st_listings WHERE active=1" " AND property_type<>'' GROUP BY property_type ORDER BY n DESC")] recents = con.execute( f"""SELECT uid, title, property_type, city, region, price_night FROM st_listings WHERE active=1 AND price_night BETWEEN {CT_PRICE_MIN} AND {CT_PRICE_MAX} ORDER BY first_seen DESC LIMIT 20""").fetchall() con.close() title = (f"Location court terme au Québec — {total:,} chalets et hébergements" " | Lou-Ka").replace(",", " ") description = (f"{total:,} chalets, condos et hébergements à louer à la nuit " f"partout au Québec, agrégés depuis {nsources} plateformes " "(Airbnb, Vrbo, Booking, WeChalet, Sépaq et plus) : " "Laurentides, Charlevoix, Cantons-de-l'Est, Lanaudière… " "Prix par nuit, photos et lien direct vers l'annonce " "originale.").replace(",", " ") body = ( f"

    Location court terme au Québec — {total:,} hébergements

    ".replace(",", " ") + f"

    Lou-Ka agrège les chalets et hébergements à la nuit publiés sur " f"{nsources} plateformes de location court terme couvrant le Québec : " "chaque fiche avec ses photos, son prix par nuit, sa capacité et un " "lien direct vers l'annonce originale.

    " + "

    Par région

      " + "".join(f"
    • {_e(r['region'])} — {r['n']} hébergements
    • " for r in regions[:20]) + "

    Par type d'hébergement

      " + "".join(f"
    • {_e(t['t'])} — {t['n']}
    • " for t in types[:12]) + "

    Derniers hébergements recensés

      " + "".join(_ct_li(r) for r in recents) + '

    Logements à louer au mois · ' 'Toutes les sources

    ') jsonld = [ _breadcrumb([("Accueil", "/"), ("Court terme", "/court-terme")]), {"@context": "https://schema.org", "@type": "ItemList", "name": "Location court terme au Québec", "numberOfItems": total, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["title"] or r["uid"], "url": f"{BASE_URL}/court-terme/{r['uid']}"} for i, r in enumerate(recents)]}] return _render(title=title, description=description, path="/court-terme", jsonld=jsonld, body=body) @router.get("/court-terme/{uid:path}", include_in_schema=False) def court_terme_fiche_ssr(uid: str): con = ctdb.connect() row = con.execute("SELECT * FROM st_listings WHERE uid=?", (uid,)).fetchone() con.close() if row is None: return _render(title="Hébergement introuvable | Lou-Ka", description="Cet hébergement n'existe pas ou plus sur Lou-Ka.", path=f"/court-terme/{uid}", body="

    Hébergement introuvable

    " '

    Voir tous les hébergements ' "court terme au Québec

    ", status=404) d = dict(row) d["amenities"] = json.loads(d.get("amenities") or "[]") d["images"] = json.loads(d.get("images") or "[]") if not d["active"]: return _render( title="Hébergement retiré | Lou-Ka", description="Cet hébergement a été retiré de la plateforme d'origine.", path=f"/court-terme/{uid}", body="

    Cet hébergement n'est plus disponible

    " '

    Il a été retiré de la plateforme d\'origine. ' 'Tous les hébergements court terme.

    ', status=410) label = d["title"] or "Hébergement court terme" where = ", ".join(p for p in (d["city"], d["region"]) if p and p not in label) title = (f"{d['property_type'] + ' à louer — ' if d['property_type'] else ''}" f"{label}" + (f", {where}" if where else "") + " | Lou-Ka") price_txt = (f"{_fmt_price(d['price_night'])}/nuit" if _ct_price_ok(d["price_night"]) else (d["price_label"] or "")) bits = [b for b in ( d["property_type"], price_txt, f"{int(d['capacity'])} personnes" if d["capacity"] else "", f"{int(d['bedrooms'])} chambres" if d["bedrooms"] else "", d["city"], d["region"]) if b] description = (" · ".join(bits) + ". " if bits else "") + \ (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150 else (d["description"] or "")).strip() description = (description[:300] or f"Hébergement court terme à {d['city'] or 'louer'} sur Lou-Ka.") body = [f"

    {_e(label)}" f"{' — ' + _e(d['property_type']) if d['property_type'] else ''}

    "] facts = [("Type", d["property_type"]), ("Ville", d["city"]), ("Région", d["region"]), ("Prix", price_txt), ("Capacité", f"{int(d['capacity'])} personnes" if d["capacity"] else ""), ("Chambres", int(d["bedrooms"]) if d["bedrooms"] else ""), ("Salles de bain", d["bathrooms"] or ""), ("Note", f"{d['rating']}/5 ({d['reviews']} avis)" if d["rating"] and d["reviews"] else ""), ("Enregistrement CITQ", d["citq"] or "")] body.append("
      " + "".join(f"
    • {k} : {_e(v)}
    • " for k, v in facts if v) + "
    ") if d["description"]: body.append(f"

    {_e(d['description'][:600])}

    ") if d["amenities"]: body.append("

    Commodités : " + _e(", ".join(d["amenities"][:15])) + "

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

    ' "Voir l'annonce originale sur la plateforme

    ") body.append('

    Tous les hébergements court terme ' "au Québec

    ") rental: dict = { "@context": "https://schema.org", "@type": "VacationRental", "name": label, "url": f"{BASE_URL}/court-terme/{uid}", "inLanguage": "fr-CA", "address": {"@type": "PostalAddress", "addressLocality": d["city"] or None, "addressRegion": "QC", "addressCountry": "CA"}} if d["lat"] and d["lng"]: rental["latitude"], rental["longitude"] = d["lat"], d["lng"] if d["images"]: rental["image"] = d["images"][:5] if d["bedrooms"]: rental["numberOfBedrooms"] = int(d["bedrooms"]) if d["capacity"]: rental["occupancy"] = {"@type": "QuantitativeValue", "maxValue": int(d["capacity"]), "unitText": "personnes"} if d["rating"] and d["reviews"]: rental["aggregateRating"] = {"@type": "AggregateRating", "ratingValue": d["rating"], "reviewCount": d["reviews"], "bestRating": 5} jsonld: list[dict] = [rental] if _ct_price_ok(d["price_night"]): jsonld.append({ "@context": "https://schema.org", "@type": "Offer", "itemOffered": {"@type": "VacationRental", "name": label}, "price": d["price_night"], "priceCurrency": "CAD", "availability": "https://schema.org/InStock", "url": f"{BASE_URL}/court-terme/{uid}"}) crumbs = [("Accueil", "/"), ("Court terme", "/court-terme"), (label, f"/court-terme/{uid}")] jsonld.append(_breadcrumb(crumbs)) return _render(title=title, description=description, path=f"/court-terme/{uid}", jsonld=jsonld, body="".join(body), og_image=d["images"][0] if d["images"] else None) # pages statiques de l'app : head unique, contenu rendu par React _STATIC_META = { "/stats": ("Statistiques du marché locatif québécois | Lou-Ka", "Loyers moyens et médians, répartition par ville et par type de logement : " "les statistiques du marché locatif québécois, calculées en continu par Lou-Ka."), "/sources": ("Sources agrégées — gestionnaires et plateformes court terme | Lou-Ka", "Toutes les sources agrégées par Lou-Ka : gestionnaires immobiliers du " "Québec (logements au mois) et plateformes de location court terme " "(Airbnb, Vrbo, Booking, WeChalet, Sépaq…), avec le nombre d'annonces " "actives de chacune."), "/confidentialite": ("Politique de confidentialité | Lou-Ka", "Politique de confidentialité de Lou-Ka : données collectées, " "témoins (cookies) et droits des utilisateurs."), "/conditions": ("Conditions d'utilisation | Lou-Ka", "Conditions d'utilisation du service Lou-Ka, agrégateur indépendant " "de logements à louer au Québec."), } def _static_page(path: str): title, description = _STATIC_META[path] return _render(title=title, description=description, path=path) @router.get("/stats", include_in_schema=False) def stats_page(): return _static_page("/stats") @router.get("/sources", include_in_schema=False) def sources_page(): return _static_page("/sources") @router.get("/confidentialite", include_in_schema=False) def privacy_page(): return _static_page("/confidentialite") @router.get("/conditions", include_in_schema=False) def terms_page(): return _static_page("/conditions")