Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# seo.py : référencement — SSR léger, pages programmatiques, robots, sitemaps5#6# Principe : pour chaque route publique, le serveur renvoie le MÊME index.html7# que le build Vite, mais avec un <head> unique (title, description, canonical,8# og:, JSON-LD) et le contenu essentiel en HTML DANS <div id="root">. Les9# moteurs de recherche voient une page complète sans exécuter JavaScript ;10# React, en se montant, remplace ce contenu par l'application interactive.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import html15import json16import math17import re18import statistics19import time20import unicodedata21from datetime import date, datetime, timezone22from pathlib import Path23from xml.sax.saxutils import escape as xml_escape2425from fastapi import APIRouter, HTTPException26from fastapi.responses import HTMLResponse, PlainTextResponse, Response2728from . import db29from .shortterm import db as ctdb3031router = APIRouter()3233ROOT = Path(__file__).resolve().parent.parent34FRONTEND_DIST = ROOT / "frontend" / "dist"35SOURCES_PATH = ROOT / "data" / "sources.json"3637BASE_URL = "https://www.lou-ka.com"38SITE_NAME = "Lou-Ka"3940# bornes de plausibilité d'un loyer mensuel — hors bornes : prix exclu des41# statistiques et des données structurées (certaines sources publient 0 $)42PRICE_MIN, PRICE_MAX = 195, 1500043PRICE_OK = f"price >= {PRICE_MIN} AND price <= {PRICE_MAX}"4445# seuil d'inclusion d'une page programmatique dans le sitemap46MIN_LISTINGS_PAGE = 347SITEMAP_CHUNK = 10000484950# --- Slugs -------------------------------------------------------------------5152def slugify(text: str) -> str:53 """« Trois-Rivières » → trois-rivieres, « 3½ » → 3-1-2, « 6½+ » → 6-1-2-plus."""54 t = text.replace("½", "-1-2").replace("+", "-plus")55 t = t.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae")56 t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()57 t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")58 return t596061_maps_cache: dict = {"ts": 0.0, "cities": {}, "types": {}}626364def _slug_maps() -> tuple[dict[str, str], dict[str, str]]:65 """(slug→ville, slug→type d'unité), reconstruit au plus toutes les 10 min."""66 if time.time() - _maps_cache["ts"] > 600:67 con = db.connect()68 cities = [r["city"] for r in con.execute(69 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>''")]70 types = [r["unit_type"] for r in con.execute(71 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>''")]72 con.close()73 _maps_cache["cities"] = {slugify(c): c for c in sorted(cities)}74 _maps_cache["types"] = {slugify(t): t for t in sorted(types)}75 _maps_cache["ts"] = time.time()76 return _maps_cache["cities"], _maps_cache["types"]777879# --- Gabarit (index.html du build Vite) --------------------------------------8081_shell_cache: dict = {"mtime": 0.0, "html": ""}828384def _shell() -> str:85 f = FRONTEND_DIST / "index.html"86 mtime = f.stat().st_mtime87 if mtime != _shell_cache["mtime"]:88 _shell_cache["html"] = f.read_text(encoding="utf-8")89 _shell_cache["mtime"] = mtime90 return _shell_cache["html"]919293def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None,94 body: str = "", og_image: str | None = None, status: int = 200) -> HTMLResponse:95 """index.html du build + head unique + contenu HTML dans #root."""96 canonical = BASE_URL + path97 page = _shell()98 page = re.sub(r"<title>.*?</title>",99 lambda _m: f"<title>{html.escape(title)}</title>", page, count=1, flags=re.S)100 page = re.sub(r'<meta name="description"[^>]*/>',101 lambda _m: f'<meta name="description" content="{html.escape(description, quote=True)}" />',102 page, count=1)103 # retire du gabarit statique les meta og:image/twitter (re-injectées ci-dessous)104 page = re.sub(r'\s*<meta (?:property="og:image[^"]*"|name="twitter:(?:card|image)")[^>]*/>', "", page)105 extras = [106 f'<link rel="canonical" href="{canonical}" />',107 f'<link rel="alternate" hreflang="fr-ca" href="{canonical}" />',108 f'<link rel="alternate" hreflang="x-default" href="{canonical}" />',109 f'<meta property="og:site_name" content="{SITE_NAME}" />',110 '<meta property="og:locale" content="fr_CA" />',111 '<meta property="og:type" content="website" />',112 f'<meta property="og:title" content="{html.escape(title, quote=True)}" />',113 f'<meta property="og:description" content="{html.escape(description, quote=True)}" />',114 f'<meta property="og:url" content="{canonical}" />',115 '<meta name="twitter:card" content="summary_large_image" />',116 f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />',117 ]118 img = og_image or (BASE_URL + "/og.png")119 extras.append(f'<meta property="og:image" content="{html.escape(img, quote=True)}" />')120 if not og_image:121 extras.append('<meta property="og:image:width" content="1200" />')122 extras.append('<meta property="og:image:height" content="630" />')123 extras.append(f'<meta name="twitter:image" content="{html.escape(img, quote=True)}" />')124 for obj in (jsonld or []):125 blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")126 extras.append(f'<script type="application/ld+json">{blob}</script>')127 page = page.replace("</head>", " " + "\n ".join(extras) + "\n</head>", 1)128 if body:129 seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;'130 'font-family:system-ui,sans-serif;color:#141814">' + body131 + '<p>Lou-Ka — Un service <a href="https://www.groupe-ka.com">Groupe KA</a></p>'132 + "</div>")133 page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1)134 return HTMLResponse(page, status_code=status,135 headers={"Cache-Control": "no-cache"})136137138def _e(t) -> str:139 return html.escape(str(t or ""))140141142def _fmt_price(p) -> str:143 return f"{int(round(p)):,} $".replace(",", " ") if p else ""144145146def _price_ok(p) -> bool:147 return p is not None and PRICE_MIN <= p <= PRICE_MAX148149150def _iso(ts) -> str:151 if not ts:152 return date.today().isoformat()153 return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()154155156def _listing_li(r) -> str:157 """Une annonce dans une liste HTML serveur."""158 label = r["title"] or r["address"] or r["uid"]159 bits = [b for b in (r["unit_type"], _fmt_price(r["price"]) + "/mois" if _price_ok(r["price"]) else "",160 r["sector"] or r["city"]) if b]161 return (f'<li><a href="/logement/{_e(r["uid"])}">{_e(label)}</a>'162 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')163164165def _not_found(message: str, path: str) -> HTMLResponse:166 """404 HTML : le shell React est servi (la SPA affichera sa page), mais le167 statut et le contenu serveur disent clairement « introuvable » aux bots."""168 return _render(title="Page introuvable | Lou-Ka",169 description="Cette page n'existe pas sur Lou-Ka.",170 path=path,171 body=f"<h1>{_e(message)}</h1>"172 '<p><a href="/">Voir tous les logements à louer au Québec</a> · '173 '<a href="/villes">Logements par ville</a></p>',174 status=404)175176177def _breadcrumb(items: list[tuple[str, str]]) -> dict:178 return {"@context": "https://schema.org", "@type": "BreadcrumbList",179 "itemListElement": [180 {"@type": "ListItem", "position": i + 1, "name": name,181 "item": BASE_URL + path}182 for i, (name, path) in enumerate(items)]}183184185# --- Données -----------------------------------------------------------------186187def _city_stats(con, city: str) -> dict:188 n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND city=?",189 (city,)).fetchone()["c"]190 prices = [r["price"] for r in con.execute(191 f"SELECT price FROM listings WHERE active=1 AND city=? AND {PRICE_OK}", (city,))]192 types = [dict(r) for r in con.execute(193 """SELECT unit_type, COUNT(*) n FROM listings194 WHERE active=1 AND city=? AND unit_type<>''195 GROUP BY unit_type ORDER BY n DESC""", (city,))]196 for t in types:197 t["slug"] = slugify(t["unit_type"])198 return {"n": n,199 "avg": round(statistics.mean(prices)) if prices else None,200 "med": round(statistics.median(prices)) if prices else None,201 "types": types}202203204def _villes_rows(con, minimum: int = 1) -> list[dict]:205 rows = [dict(r) for r in con.execute(206 f"""SELECT city, COUNT(*) n,207 AVG(CASE WHEN {PRICE_OK} THEN price END) avg_price,208 MAX(updated_at) last209 FROM listings WHERE active=1 AND city<>''210 GROUP BY city HAVING n>=? ORDER BY n DESC""", (minimum,))]211 for r in rows:212 r["slug"] = slugify(r["city"])213 r["avg_price"] = round(r["avg_price"]) if r["avg_price"] else None214 return rows215216217def _parse_row(r) -> dict:218 d = dict(r)219 for k in ("amenities", "images"):220 d[k] = json.loads(d.get(k) or "[]")221 d["details"] = json.loads(d.get("details") or "{}")222 return d223224225# --- API JSON pour les pages villes du frontend ------------------------------226227@router.get("/api/seo/villes")228def api_villes():229 con = db.connect()230 rows = _villes_rows(con)231 con.close()232 return {"villes": rows}233234235@router.get("/api/seo/ville/{slug}")236def api_ville(slug: str, type: str | None = None):237 cities, types = _slug_maps()238 city = cities.get(slug)239 if not city:240 raise HTTPException(404, "Ville inconnue")241 unit_type = None242 if type:243 unit_type = types.get(type)244 if not unit_type:245 raise HTTPException(404, "Type de logement inconnu")246 con = db.connect()247 stats = _city_stats(con, city)248 sql = "SELECT * FROM listings WHERE active=1 AND city=?"249 args: list = [city]250 if unit_type:251 sql += " AND unit_type=?"252 args.append(unit_type)253 listings = [_parse_row(r) for r in con.execute(254 sql + " ORDER BY updated_at DESC LIMIT 100", args)]255 neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12]256 con.close()257 return {"city": city, "slug": slug, "unit_type": unit_type,258 **stats, "listings": listings, "neighbors": neighbors}259260261# --- robots.txt & sitemaps ----------------------------------------------------262263@router.get("/robots.txt", include_in_schema=False)264def robots() -> PlainTextResponse:265 return PlainTextResponse(266 "User-agent: *\n"267 "Allow: /\n"268 "Disallow: /api/\n"269 "Disallow: /uploads/\n"270 "Disallow: /profil\n"271 "Disallow: /favoris\n"272 "Disallow: /gestion\n"273 "Disallow: /bienvenue\n"274 "Disallow: /bot\n"275 "Disallow: /passerelle/\n"276 f"\nSitemap: {BASE_URL}/sitemap.xml\n")277278279def _xml(content: str) -> Response:280 return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content,281 media_type="application/xml",282 headers={"Cache-Control": "public, max-age=3600"})283284285def _urlset(urls: list[tuple[str, str | None]]) -> Response:286 rows = []287 for loc, lastmod in urls:288 lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""289 rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>")290 return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'291 + "\n".join(rows) + "\n</urlset>")292293294@router.get("/sitemap.xml", include_in_schema=False)295def sitemap_index():296 con = db.connect()297 total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"]298 con.close()299 ct_con = ctdb.connect()300 ct_total = ct_con.execute(301 "SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"]302 ct_con.close()303 chunks = max(1, math.ceil(total / SITEMAP_CHUNK))304 ct_chunks = max(1, math.ceil(ct_total / SITEMAP_CHUNK))305 names = (["sitemap-pages.xml", "sitemap-villes.xml"]306 + [f"sitemap-annonces-{i}.xml" for i in range(1, chunks + 1)]307 + [f"sitemap-ct-{i}.xml" for i in range(1, ct_chunks + 1)])308 today = date.today().isoformat()309 rows = "\n".join(310 f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>"311 for n in names)312 return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'313 + rows + "\n</sitemapindex>")314315316@router.get("/sitemap-pages.xml", include_in_schema=False)317def sitemap_pages():318 urls: list[tuple[str, str | None]] = [319 (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None),320 (f"{BASE_URL}/court-terme", None),321 (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None),322 (f"{BASE_URL}/demenageurs", None),323 (f"{BASE_URL}/confidentialite", None), (f"{BASE_URL}/conditions", None)]324 con = db.connect()325 for r in con.execute(326 """SELECT source, MAX(updated_at) last FROM listings327 WHERE active=1 GROUP BY source"""):328 urls.append((f"{BASE_URL}/g/{r['source']}", _iso(r["last"])))329 con.close()330 return _urlset(urls)331332333@router.get("/sitemap-villes.xml", include_in_schema=False)334def sitemap_villes():335 con = db.connect()336 urls: list[tuple[str, str | None]] = []337 for v in _villes_rows(con, MIN_LISTINGS_PAGE):338 urls.append((f"{BASE_URL}/ville/{v['slug']}", _iso(v["last"])))339 for r in con.execute(340 f"""SELECT city, unit_type, COUNT(*) n, MAX(updated_at) last341 FROM listings WHERE active=1 AND city<>'' AND unit_type<>''342 GROUP BY city, unit_type HAVING n>=?""", (MIN_LISTINGS_PAGE,)):343 urls.append((f"{BASE_URL}/ville/{slugify(r['city'])}/{slugify(r['unit_type'])}",344 _iso(r["last"])))345 con.close()346 return _urlset(urls)347348349@router.get("/sitemap-annonces-{num}.xml", include_in_schema=False)350def sitemap_annonces(num: int):351 if num < 1:352 raise HTTPException(404)353 con = db.connect()354 rows = con.execute(355 """SELECT uid, updated_at FROM listings WHERE active=1356 ORDER BY uid LIMIT ? OFFSET ?""",357 (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()358 con.close()359 if not rows:360 raise HTTPException(404)361 return _urlset([(f"{BASE_URL}/logement/{r['uid']}", _iso(r["updated_at"]))362 for r in rows])363364365@router.get("/sitemap-ct-{num}.xml", include_in_schema=False)366def sitemap_ct(num: int):367 if num < 1:368 raise HTTPException(404)369 con = ctdb.connect()370 rows = con.execute(371 """SELECT uid, updated_at FROM st_listings WHERE active=1372 ORDER BY uid LIMIT ? OFFSET ?""",373 (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()374 con.close()375 if not rows:376 raise HTTPException(404)377 return _urlset([(f"{BASE_URL}/court-terme/{r['uid']}", _iso(r["updated_at"]))378 for r in rows])379380381# --- Pages SSR ----------------------------------------------------------------382383@router.get("/", include_in_schema=False)384def home_ssr():385 con = db.connect()386 total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"]387 nsources = con.execute(388 "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"]389 villes = _villes_rows(con, MIN_LISTINGS_PAGE)390 recents = con.execute(391 f"""SELECT uid, title, address, sector, city, unit_type, price FROM listings392 WHERE active=1 AND {PRICE_OK} ORDER BY first_seen DESC LIMIT 20""").fetchall()393 con.close()394395 title = f"Lou-Ka — Un service Groupe KA · {total:,} logements à louer au Québec".replace(",", " ")396 description = (f"{total:,} appartements et logements à louer partout au Québec, "397 f"agrégés depuis {nsources} gestionnaires immobiliers et toujours à jour : "398 "Montréal, Québec, Lévis, Gatineau et plus. Photos, prix, disponibilité "399 "et lien direct vers l'annonce originale.").replace(",", " ")400 top = villes[:30]401 body = (402 f"<h1>Logements à louer au Québec — {total:,} annonces à jour</h1>".replace(",", " ")403 + f"<p>Lou-Ka agrège les appartements à louer publiés par {nsources} gestionnaires "404 "immobiliers partout au Québec : chaque annonce avec ses photos, son prix, sa "405 "disponibilité et un lien direct vers le site du gestionnaire.</p>"406 + "<h2>Logements par ville</h2><ul>"407 + "".join(f'<li><a href="/ville/{v["slug"]}">Logements à louer à {_e(v["city"])}</a>'408 f' — {v["n"]} annonces'409 + (f", loyer moyen {_fmt_price(v['avg_price'])}" if v["avg_price"] else "")410 + "</li>" for v in top)411 + f'</ul><p><a href="/villes">Toutes les villes ({len(villes)})</a></p>'412 + "<h2>Dernières annonces</h2><ul>"413 + "".join(_listing_li(r) for r in recents)414 + "</ul>")415 jsonld = [416 {"@context": "https://schema.org", "@type": "WebSite",417 "name": SITE_NAME, "url": BASE_URL + "/",418 "description": description, "inLanguage": "fr-CA"},419 {"@context": "https://schema.org", "@type": "Organization",420 "name": "Lou-Ka (Groupe KA)", "url": BASE_URL + "/"}]421 return _render(title=title, description=description, path="/",422 jsonld=jsonld, body=body)423424425@router.get("/demenageurs", include_in_schema=False)426def demenageurs_ssr():427 from . import demenageurs as dem428 doc = dem.load()429 movers = doc.get("movers", [])430 n = len(movers)431 title = f"Déménageurs au Québec — annuaire complet ({n} entreprises) | Lou-Ka"432 description = (f"Annuaire complet des déménageurs du Québec : {n} entreprises de "433 "déménagement dans les 17 régions administratives, avec téléphone, "434 "site web et note Google. Trouvez un déménageur près de chez vous.")435 by_region: dict[str, list] = {}436 for m in movers:437 by_region.setdefault(m["region"], []).append(m)438439 def _mover_ld(m: dict) -> dict:440 d = {"@type": "MovingCompany", "name": m["name"]}441 if m.get("address"):442 d["address"] = m["address"]443 if m.get("phone"):444 d["telephone"] = m["phone"]445 if m.get("website"):446 d["url"] = m["website"]447 return d448449 parts = [f"<h1>Déménageurs du Québec — {n} entreprises</h1>",450 "<p>" + _e(description) + "</p>"]451 for region in sorted(by_region, key=lambda r: -len(by_region[r])):452 ms = by_region[region]453 parts.append(f"<h2>{_e(region)} ({len(ms)})</h2><ul>")454 for m in ms:455 item = f"<b>{_e(m['name'])}</b> — {_e(m['city'])}"456 if m.get("phone"):457 item += ", " + _e(m["phone"])458 if m.get("website"):459 item += ' — <a href="' + _e(m["website"]) + '" rel="nofollow">site web</a>'460 parts.append("<li>" + item + "</li>")461 parts.append("</ul>")462 jsonld = [_breadcrumb([("Accueil", "/"), ("Déménageurs", "/demenageurs")]),463 {"@context": "https://schema.org", "@type": "ItemList",464 "name": "Déménageurs du Québec", "numberOfItems": n,465 "itemListElement": [466 {"@type": "ListItem", "position": i + 1, "item": _mover_ld(m)}467 for i, m in enumerate(movers[:100])]}]468 return _render(title=title, description=description, path="/demenageurs",469 jsonld=jsonld, body="".join(parts))470471472@router.get("/villes", include_in_schema=False)473def villes_ssr():474 con = db.connect()475 villes = _villes_rows(con)476 con.close()477 total = sum(v["n"] for v in villes)478 title = f"Logements à louer par ville au Québec ({len(villes)} villes) | Lou-Ka"479 description = (f"Toutes les villes du Québec où Lou-Ka recense des logements à louer : "480 f"{total:,} annonces dans {len(villes)} villes, avec loyer moyen et "481 "nombre d'appartements disponibles par ville.").replace(",", " ")482 body = (f"<h1>Logements à louer par ville — {len(villes)} villes au Québec</h1><ul>"483 + "".join(f'<li><a href="/ville/{v["slug"]}">{_e(v["city"])}</a> — {v["n"]} annonces'484 + (f", loyer moyen {_fmt_price(v['avg_price'])}" if v["avg_price"] else "")485 + "</li>" for v in villes)486 + "</ul>")487 jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]),488 {"@context": "https://schema.org", "@type": "ItemList",489 "name": "Logements à louer par ville au Québec",490 "numberOfItems": len(villes),491 "itemListElement": [492 {"@type": "ListItem", "position": i + 1,493 "name": f"Logements à louer à {v['city']}",494 "url": f"{BASE_URL}/ville/{v['slug']}"}495 for i, v in enumerate(villes[:100])]}]496 return _render(title=title, description=description, path="/villes",497 jsonld=jsonld, body=body)498499500def _ville_ssr(slug: str, type_slug: str | None = None):501 cities, types = _slug_maps()502 path = f"/ville/{slug}" + (f"/{type_slug}" if type_slug else "")503 city = cities.get(slug)504 if not city:505 return _not_found("Aucun logement recensé pour cette ville", path)506 unit_type = None507 if type_slug is not None:508 unit_type = types.get(type_slug)509 if not unit_type:510 return _not_found("Type de logement inconnu", path)511512 con = db.connect()513 stats = _city_stats(con, city)514 sql = "SELECT * FROM listings WHERE active=1 AND city=?"515 args: list = [city]516 if unit_type:517 sql += " AND unit_type=?"518 args.append(unit_type)519 n = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]520 prices = [r["price"] for r in con.execute(521 f"SELECT price FROM listings WHERE active=1 AND city=? AND unit_type=? AND {PRICE_OK}",522 (city, unit_type))]523 avg = round(statistics.mean(prices)) if prices else None524 med = round(statistics.median(prices)) if prices else None525 else:526 n, avg, med = stats["n"], stats["avg"], stats["med"]527 rows = con.execute(sql + " ORDER BY updated_at DESC LIMIT 100", args).fetchall()528 neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12]529 con.close()530 if n == 0:531 return _not_found(f"Aucune annonce active à {city} pour ce type", path)532533 what = f"{unit_type} à louer" if unit_type else "Logements à louer"534 title = f"{what} à {city} — {n} annonces | Lou-Ka"535 desc_stats = (f"loyer moyen {_fmt_price(avg)}, médian {_fmt_price(med)}"536 if avg and med else "")537 description = (f"{n} {what.lower()} à {city}"538 + (f" ({desc_stats})" if desc_stats else "")539 + ". Annonces à jour des gestionnaires immobiliers, avec photos, prix, "540 "disponibilité et lien direct vers l'annonce originale.")541542 body = [f"<h1>{_e(what)} à {_e(city)} — {n} annonces</h1>"]543 if avg and med:544 body.append(f"<p>Loyer moyen : <strong>{_fmt_price(avg)}/mois</strong> · "545 f"loyer médian : <strong>{_fmt_price(med)}/mois</strong>.</p>")546 if not unit_type and stats["types"]:547 body.append("<h2>Par type de logement</h2><ul>" + "".join(548 f'<li><a href="/ville/{slug}/{t["slug"]}">{_e(t["unit_type"])} à louer à '549 f'{_e(city)}</a> — {t["n"]} annonces</li>' for t in stats["types"]) + "</ul>")550 body.append("<h2>Annonces</h2><ul>"551 + "".join(_listing_li(r) for r in rows) + "</ul>")552 if unit_type:553 body.append(f'<p><a href="/ville/{slug}">Tous les logements à {_e(city)}</a></p>')554 body.append("<h2>Autres villes</h2><ul>" + "".join(555 f'<li><a href="/ville/{v["slug"]}">Logements à louer à {_e(v["city"])}</a>'556 f' — {v["n"]}</li>' for v in neighbors) + "</ul>")557558 crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, f"/ville/{slug}")]559 if unit_type:560 crumbs.append((f"{unit_type} à {city}", path))561 jsonld = [_breadcrumb(crumbs),562 {"@context": "https://schema.org", "@type": "ItemList",563 "name": f"{what} à {city}", "numberOfItems": n,564 "itemListElement": [565 {"@type": "ListItem", "position": i + 1,566 "name": r["title"] or r["address"] or r["uid"],567 "url": f"{BASE_URL}/logement/{r['uid']}"}568 for i, r in enumerate(rows[:50])]}]569 return _render(title=title, description=description, path=path,570 jsonld=jsonld, body="".join(body))571572573@router.get("/ville/{slug}", include_in_schema=False)574def ville_ssr(slug: str):575 return _ville_ssr(slug)576577578@router.get("/ville/{slug}/{type_slug}", include_in_schema=False)579def ville_type_ssr(slug: str, type_slug: str):580 return _ville_ssr(slug, type_slug)581582583@router.get("/logement/{uid:path}", include_in_schema=False)584def listing_ssr(uid: str):585 con = db.connect()586 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()587 con.close()588 if row is None:589 return _render(title="Annonce introuvable | Lou-Ka",590 description="Cette annonce n'existe pas ou plus sur Lou-Ka.",591 path=f"/logement/{uid}",592 body="<h1>Annonce introuvable</h1>"593 '<p><a href="/">Voir tous les logements à louer au Québec</a></p>',594 status=404)595 d = _parse_row(row)596 city_slug = slugify(d["city"]) if d["city"] else ""597 if not d["active"]:598 # annonce retirée chez la source : 410 Gone + lien vers la ville parente599 link = (f'<a href="/ville/{city_slug}">Logements à louer à {_e(d["city"])}</a>'600 if city_slug else '<a href="/">Tous les logements</a>')601 return _render(602 title="Annonce retirée | Lou-Ka",603 description="Cette annonce a été retirée par le gestionnaire immobilier.",604 path=f"/logement/{uid}",605 body=f"<h1>Cette annonce n'est plus disponible</h1>"606 f"<p>Elle a été retirée par le gestionnaire. {link}.</p>",607 status=410)608609 label = d["title"] or d["address"] or "Logement à louer"610 where = d["city"] if d["city"] and d["city"] not in label else ""611 title = (f"{d['unit_type'] + ' à louer — ' if d['unit_type'] else ''}{label}"612 + (f", {where}" if where else "") + " | Lou-Ka")613 price_txt = f"{_fmt_price(d['price'])}/mois" if _price_ok(d["price"]) else (d["price_label"] or "")614 bits = [b for b in (d["unit_type"], price_txt, d["sector"], d["city"],615 d["availability"]) if b]616 description = (" · ".join(bits) + ". " if bits else "") + \617 (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150618 else (d["description"] or "")).strip()619 description = description[:300] or f"Logement à louer à {d['city']} sur Lou-Ka."620621 body = [f"<h1>{_e(label)}{' — ' + _e(d['unit_type']) if d['unit_type'] else ''}</h1>"]622 facts = [("Adresse", d["address"]), ("Ville", d["city"]), ("Quartier", d["sector"]),623 ("Type", d["unit_type"]), ("Loyer", price_txt),624 ("Disponibilité", d["availability"]),625 ("Superficie", f"{int(d['area_sqft'])} pi²" if d["area_sqft"] else "")]626 body.append("<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>"627 for k, v in facts if v) + "</ul>")628 if d["description"]:629 body.append(f"<p>{_e(d['description'][:600])}</p>")630 if d["amenities"]:631 body.append("<p><strong>Commodités</strong> : "632 + _e(", ".join(d["amenities"][:15])) + "</p>")633 if d["url"]:634 body.append(f'<p><a href="{_e(d["url"])}" rel="nofollow">'635 "Voir l'annonce originale chez le gestionnaire</a></p>")636 if city_slug:637 body.append(f'<p><a href="/ville/{city_slug}">Autres logements à louer à '638 f'{_e(d["city"])}</a></p>')639640 rooms = None641 m = re.match(r"(\d+)", d["unit_type"] or "")642 if m:643 rooms = int(m.group(1))644 apartment: dict = {645 "@type": "Apartment", "name": label,646 "address": {"@type": "PostalAddress",647 "streetAddress": d["address"] or None,648 "addressLocality": d["city"] or None,649 "addressRegion": "QC", "addressCountry": "CA"}}650 if d["lat"] and d["lng"]:651 apartment["geo"] = {"@type": "GeoCoordinates",652 "latitude": d["lat"], "longitude": d["lng"]}653 if rooms:654 apartment["numberOfRooms"] = rooms655 if d["area_sqft"]:656 apartment["floorSize"] = {"@type": "QuantitativeValue",657 "value": d["area_sqft"], "unitCode": "FTK"}658 if d["images"]:659 apartment["photo"] = d["images"][:5]660 listing_ld: dict = {661 "@context": "https://schema.org", "@type": "RealEstateListing",662 "name": title.removesuffix(" | Lou-Ka"),663 "url": f"{BASE_URL}/logement/{uid}",664 "datePosted": _iso(d["first_seen"]), "inLanguage": "fr-CA",665 "about": apartment}666 if _price_ok(d["price"]):667 listing_ld["offers"] = {668 "@type": "Offer", "price": d["price"], "priceCurrency": "CAD",669 "availability": "https://schema.org/InStock",670 "businessFunction": "http://purl.org/goodrelations/v1#LeaseOut"}671 crumbs = [("Accueil", "/")]672 if city_slug:673 crumbs.append((d["city"], f"/ville/{city_slug}"))674 crumbs.append((label, f"/logement/{uid}"))675 return _render(title=title, description=description, path=f"/logement/{uid}",676 jsonld=[listing_ld, _breadcrumb(crumbs)], body="".join(body),677 og_image=d["images"][0] if d["images"] else None)678679680@router.get("/g/{source_id}", include_in_schema=False)681def gestionnaire_ssr(source_id: str):682 registry = {s["id"]: s for s in683 json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]}684 src = registry.get(source_id)685 if not src:686 return _not_found("Gestionnaire inconnu", f"/g/{source_id}")687 con = db.connect()688 n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND source=?",689 (source_id,)).fetchone()["c"]690 rows = con.execute(691 """SELECT uid, title, address, sector, city, unit_type, price FROM listings692 WHERE active=1 AND source=? ORDER BY updated_at DESC LIMIT 60""",693 (source_id,)).fetchall()694 con.close()695 name = src["name"]696 title = f"{name} — {n} logements à louer | Lou-Ka"697 description = (f"Les {n} logements à louer de {name}"698 + (f" ({src['region']})" if src.get("region") else "")699 + " recensés par Lou-Ka, avec prix, photos et lien direct "700 "vers l'annonce originale.")701 body = (f"<h1>{_e(name)} — {n} logements à louer</h1>"702 + (f"<p>Secteurs : {_e(src['sectors'])}.</p>" if src.get("sectors") else "")703 + "<ul>" + "".join(_listing_li(r) for r in rows) + "</ul>"704 + '<p><a href="/sources">Tous les gestionnaires</a></p>')705 jsonld = [_breadcrumb([("Accueil", "/"), ("Sources", "/sources"),706 (name, f"/g/{source_id}")]),707 {"@context": "https://schema.org", "@type": "Organization",708 "name": name, "url": src.get("url") or f"{BASE_URL}/g/{source_id}"}]709 return _render(title=title, description=description, path=f"/g/{source_id}",710 jsonld=jsonld, body=body)711712713# --- Court terme (chalets & hébergements à la nuit) ---------------------------714715# bornes de plausibilité d'un prix à la nuit716CT_PRICE_MIN, CT_PRICE_MAX = 25, 10000717718719def _ct_price_ok(p) -> bool:720 return p is not None and CT_PRICE_MIN <= p <= CT_PRICE_MAX721722723def _ct_li(r) -> str:724 bits = [b for b in (725 r["property_type"],726 f"{_fmt_price(r['price_night'])}/nuit" if _ct_price_ok(r["price_night"]) else "",727 r["city"] or r["region"]) if b]728 return (f'<li><a href="/court-terme/{_e(r["uid"])}">{_e(r["title"] or r["uid"])}</a>'729 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')730731732@router.get("/court-terme", include_in_schema=False)733def court_terme_ssr():734 con = ctdb.connect()735 total = con.execute(736 "SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"]737 nsources = con.execute(738 "SELECT COUNT(DISTINCT source) c FROM st_listings WHERE active=1").fetchone()["c"]739 regions = [dict(r) for r in con.execute(740 "SELECT region, COUNT(*) n FROM st_listings WHERE active=1"741 " AND region<>'' GROUP BY region ORDER BY n DESC")]742 types = [dict(r) for r in con.execute(743 "SELECT property_type t, COUNT(*) n FROM st_listings WHERE active=1"744 " AND property_type<>'' GROUP BY property_type ORDER BY n DESC")]745 recents = con.execute(746 f"""SELECT uid, title, property_type, city, region, price_night747 FROM st_listings WHERE active=1748 AND price_night BETWEEN {CT_PRICE_MIN} AND {CT_PRICE_MAX}749 ORDER BY first_seen DESC LIMIT 20""").fetchall()750 con.close()751752 title = (f"Location court terme au Québec — {total:,} chalets et hébergements"753 " | Lou-Ka").replace(",", " ")754 description = (f"{total:,} chalets, condos et hébergements à louer à la nuit "755 f"partout au Québec, agrégés depuis {nsources} plateformes "756 "(Airbnb, Vrbo, Booking, WeChalet, Sépaq et plus) : "757 "Laurentides, Charlevoix, Cantons-de-l'Est, Lanaudière… "758 "Prix par nuit, photos et lien direct vers l'annonce "759 "originale.").replace(",", " ")760 body = (761 f"<h1>Location court terme au Québec — {total:,} hébergements</h1>".replace(",", " ")762 + f"<p>Lou-Ka agrège les chalets et hébergements à la nuit publiés sur "763 f"{nsources} plateformes de location court terme couvrant le Québec : "764 "chaque fiche avec ses photos, son prix par nuit, sa capacité et un "765 "lien direct vers l'annonce originale.</p>"766 + "<h2>Par région</h2><ul>"767 + "".join(f"<li>{_e(r['region'])} — {r['n']} hébergements</li>"768 for r in regions[:20])769 + "</ul><h2>Par type d'hébergement</h2><ul>"770 + "".join(f"<li>{_e(t['t'])} — {t['n']}</li>" for t in types[:12])771 + "</ul><h2>Derniers hébergements recensés</h2><ul>"772 + "".join(_ct_li(r) for r in recents)773 + '</ul><p><a href="/">Logements à louer au mois</a> · '774 '<a href="/sources">Toutes les sources</a></p>')775 jsonld = [776 _breadcrumb([("Accueil", "/"), ("Court terme", "/court-terme")]),777 {"@context": "https://schema.org", "@type": "ItemList",778 "name": "Location court terme au Québec",779 "numberOfItems": total,780 "itemListElement": [781 {"@type": "ListItem", "position": i + 1,782 "name": r["title"] or r["uid"],783 "url": f"{BASE_URL}/court-terme/{r['uid']}"}784 for i, r in enumerate(recents)]}]785 return _render(title=title, description=description, path="/court-terme",786 jsonld=jsonld, body=body)787788789@router.get("/court-terme/{uid:path}", include_in_schema=False)790def court_terme_fiche_ssr(uid: str):791 con = ctdb.connect()792 row = con.execute("SELECT * FROM st_listings WHERE uid=?", (uid,)).fetchone()793 con.close()794 if row is None:795 return _render(title="Hébergement introuvable | Lou-Ka",796 description="Cet hébergement n'existe pas ou plus sur Lou-Ka.",797 path=f"/court-terme/{uid}",798 body="<h1>Hébergement introuvable</h1>"799 '<p><a href="/court-terme">Voir tous les hébergements '800 "court terme au Québec</a></p>",801 status=404)802 d = dict(row)803 d["amenities"] = json.loads(d.get("amenities") or "[]")804 d["images"] = json.loads(d.get("images") or "[]")805 if not d["active"]:806 return _render(807 title="Hébergement retiré | Lou-Ka",808 description="Cet hébergement a été retiré de la plateforme d'origine.",809 path=f"/court-terme/{uid}",810 body="<h1>Cet hébergement n'est plus disponible</h1>"811 '<p>Il a été retiré de la plateforme d\'origine. '812 '<a href="/court-terme">Tous les hébergements court terme</a>.</p>',813 status=410)814815 label = d["title"] or "Hébergement court terme"816 where = ", ".join(p for p in (d["city"], d["region"]) if p and p not in label)817 title = (f"{d['property_type'] + ' à louer — ' if d['property_type'] else ''}"818 f"{label}" + (f", {where}" if where else "") + " | Lou-Ka")819 price_txt = (f"{_fmt_price(d['price_night'])}/nuit"820 if _ct_price_ok(d["price_night"]) else (d["price_label"] or ""))821 bits = [b for b in (822 d["property_type"], price_txt,823 f"{int(d['capacity'])} personnes" if d["capacity"] else "",824 f"{int(d['bedrooms'])} chambres" if d["bedrooms"] else "",825 d["city"], d["region"]) if b]826 description = (" · ".join(bits) + ". " if bits else "") + \827 (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150828 else (d["description"] or "")).strip()829 description = (description[:300]830 or f"Hébergement court terme à {d['city'] or 'louer'} sur Lou-Ka.")831832 body = [f"<h1>{_e(label)}"833 f"{' — ' + _e(d['property_type']) if d['property_type'] else ''}</h1>"]834 facts = [("Type", d["property_type"]), ("Ville", d["city"]),835 ("Région", d["region"]), ("Prix", price_txt),836 ("Capacité", f"{int(d['capacity'])} personnes" if d["capacity"] else ""),837 ("Chambres", int(d["bedrooms"]) if d["bedrooms"] else ""),838 ("Salles de bain", d["bathrooms"] or ""),839 ("Note", f"{d['rating']}/5 ({d['reviews']} avis)"840 if d["rating"] and d["reviews"] else ""),841 ("Enregistrement CITQ", d["citq"] or "")]842 body.append("<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>"843 for k, v in facts if v) + "</ul>")844 if d["description"]:845 body.append(f"<p>{_e(d['description'][:600])}</p>")846 if d["amenities"]:847 body.append("<p><strong>Commodités</strong> : "848 + _e(", ".join(d["amenities"][:15])) + "</p>")849 if d["url"]:850 body.append(f'<p><a href="{_e(d["url"])}" rel="nofollow">'851 "Voir l'annonce originale sur la plateforme</a></p>")852 body.append('<p><a href="/court-terme">Tous les hébergements court terme '853 "au Québec</a></p>")854855 rental: dict = {856 "@context": "https://schema.org", "@type": "VacationRental",857 "name": label, "url": f"{BASE_URL}/court-terme/{uid}",858 "inLanguage": "fr-CA",859 "address": {"@type": "PostalAddress",860 "addressLocality": d["city"] or None,861 "addressRegion": "QC", "addressCountry": "CA"}}862 if d["lat"] and d["lng"]:863 rental["latitude"], rental["longitude"] = d["lat"], d["lng"]864 if d["images"]:865 rental["image"] = d["images"][:5]866 if d["bedrooms"]:867 rental["numberOfBedrooms"] = int(d["bedrooms"])868 if d["capacity"]:869 rental["occupancy"] = {"@type": "QuantitativeValue",870 "maxValue": int(d["capacity"]),871 "unitText": "personnes"}872 if d["rating"] and d["reviews"]:873 rental["aggregateRating"] = {"@type": "AggregateRating",874 "ratingValue": d["rating"],875 "reviewCount": d["reviews"],876 "bestRating": 5}877 jsonld: list[dict] = [rental]878 if _ct_price_ok(d["price_night"]):879 jsonld.append({880 "@context": "https://schema.org", "@type": "Offer",881 "itemOffered": {"@type": "VacationRental", "name": label},882 "price": d["price_night"], "priceCurrency": "CAD",883 "availability": "https://schema.org/InStock",884 "url": f"{BASE_URL}/court-terme/{uid}"})885 crumbs = [("Accueil", "/"), ("Court terme", "/court-terme"),886 (label, f"/court-terme/{uid}")]887 jsonld.append(_breadcrumb(crumbs))888 return _render(title=title, description=description,889 path=f"/court-terme/{uid}", jsonld=jsonld,890 body="".join(body),891 og_image=d["images"][0] if d["images"] else None)892893894# pages statiques de l'app : head unique, contenu rendu par React895_STATIC_META = {896 "/stats": ("Statistiques du marché locatif québécois | Lou-Ka",897 "Loyers moyens et médians, répartition par ville et par type de logement : "898 "les statistiques du marché locatif québécois, calculées en continu par Lou-Ka."),899 "/sources": ("Sources agrégées — gestionnaires et plateformes court terme | Lou-Ka",900 "Toutes les sources agrégées par Lou-Ka : gestionnaires immobiliers du "901 "Québec (logements au mois) et plateformes de location court terme "902 "(Airbnb, Vrbo, Booking, WeChalet, Sépaq…), avec le nombre d'annonces "903 "actives de chacune."),904 "/confidentialite": ("Politique de confidentialité | Lou-Ka",905 "Politique de confidentialité de Lou-Ka : données collectées, "906 "témoins (cookies) et droits des utilisateurs."),907 "/conditions": ("Conditions d'utilisation | Lou-Ka",908 "Conditions d'utilisation du service Lou-Ka, agrégateur indépendant "909 "de logements à louer au Québec."),910}911912913def _static_page(path: str):914 title, description = _STATIC_META[path]915 return _render(title=title, description=description, path=path)916917918@router.get("/stats", include_in_schema=False)919def stats_page():920 return _static_page("/stats")921922923@router.get("/sources", include_in_schema=False)924def sources_page():925 return _static_page("/sources")926927928@router.get("/confidentialite", include_in_schema=False)929def privacy_page():930 return _static_page("/confidentialite")931932933@router.get("/conditions", include_in_schema=False)934def terms_page():935 return _static_page("/conditions")936