SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
42.2 KB · 936 lines python
Raw Blame History
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