Lou·Ka — tous les logements à louer du Québec, un seul endroit.
- 264commits
- 1branches
- 0releases
- 180.7 MBon disk
- maindefault branch
- 5 days agolast push
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist)5# -----------------------------------------------------------------------------6from __future__ import annotations78import json9import threading10from pathlib import Path1112from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request13from fastapi.middleware.cors import CORSMiddleware14from fastapi.middleware.gzip import GZipMiddleware15from fastapi.responses import FileResponse, RedirectResponse16from fastapi.staticfiles import StaticFiles1718from . import auth, db, ingest, kaid1920ROOT = Path(__file__).resolve().parent.parent21SOURCES_PATH = ROOT / "data" / "sources.json"22FRONTEND_DIST = ROOT / "frontend" / "dist"2324app = FastAPI(title="Lou-Ka API", version="1.0",25 description="Agrégateur de logements à louer — province de Québec")26app.add_middleware(CORSMiddleware, allow_origins=["*"],27 allow_methods=["*"], allow_headers=["*"])28app.add_middleware(GZipMiddleware, minimum_size=1000)2930_sync_lock = threading.Lock()3132# comptes utilisateurs (connexion Google) — voir louka/auth.py33app.include_router(auth.router)3435# personnalisation KA ID v2 (louka/kaid.py, canonique ka-ui.git/kaid)36kaid.init("lou-ka")3738_kaid_ka_cache: dict[int, str] = {}394041def _kaid_user(request) -> dict | None:42 """Session enrichie du ka_id (absent du témoin legacy) — None sinon."""43 user = auth.current_user(request)44 if not user:45 return None46 uid = user.get("uid")47 ka = _kaid_ka_cache.get(uid)48 if ka is None:49 con = db.connect()50 row = con.execute("SELECT ka_id FROM users WHERE id=?", (uid,)).fetchone()51 con.close()52 ka = (row["ka_id"] if row else "") or ""53 _kaid_ka_cache[uid] = ka54 return {**user, "ka_id": ka} if ka.startswith("ka-") else None555657def _kaid_features(d: dict) -> dict:58 """Caractéristiques d un logement pour le profil de préférences KA ID."""59 return {k: v for k, v in {60 "city": d.get("city"), "sector": d.get("sector"),61 "unit_type": d.get("unit_type"), "pets": d.get("pets"),62 "furnished": (bool(d.get("furnished"))63 if d.get("furnished") is not None else None),64 "price": d.get("price"), "deal": d.get("fv_verdict"),65 "source": d.get("source"),66 }.items() if v not in (None, "")}676869app.include_router(kaid.build_router(_kaid_user))7071# rôles, favoris, pages gestionnaires — voir louka/accounts.py72from . import accounts # noqa: E402 (import tardif : évite le cycle web<->accounts)73app.include_router(accounts.router)7475# personnalisation du profil (bio, réseaux sociaux, photo) — louka/profile.py76from . import profile as user_profile # noqa: E40277app.include_router(user_profile.router)7879# section « Location court terme » — sous-système isolé (louka/shortterm/)80from .shortterm import web as ct_web # noqa: E40281app.include_router(ct_web.router)8283# annuaire des déménageurs du Québec — page partagée avec Immo-Ka84from . import demenageurs # noqa: E40285app.include_router(demenageurs.router)8687# fichiers téléversés (photos de profil) — data/uploads/88_UPLOADS = ROOT / "data" / "uploads"89_UPLOADS.mkdir(parents=True, exist_ok=True)90app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads")919293def _row_to_dict(row) -> dict:94 d = dict(row)95 d["amenities"] = json.loads(d.get("amenities") or "[]")96 d["images"] = json.loads(d.get("images") or "[]")97 # galerie nettoyée par le contrôle qualité (imgcheck.py) : liens morts,98 # minuscules, placeholders et doublons retirés — prime sur la galerie brute99 imgs_ok = d.pop("images_ok", None)100 if imgs_ok:101 try:102 cleaned = json.loads(imgs_ok)103 if isinstance(cleaned, list):104 d["images"] = cleaned105 except (ValueError, TypeError):106 pass107 d.pop("img_audit", None)108 d["details"] = json.loads(d.get("details") or "{}")109 # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… »)110 if d.get("dup_sources"):111 try:112 d["dup_sources"] = json.loads(d["dup_sources"])113 except (ValueError, TypeError):114 d["dup_sources"] = []115 if d.get("furnished") is not None:116 d["furnished"] = bool(d["furnished"])117 return d118119120@app.get("/api/listings")121def list_listings(122 request: Request,123 city: str | None = None,124 sector: str | None = None,125 unit_type: str | None = None,126 source: str | None = None,127 price_max: float | None = None,128 price_min: float | None = None,129 pets: str | None = None, # "oui" -> oui OU conditions130 furnished: int | None = None, # 1 / 0131 available_by: str | None = None, # ISO : dispo maintenant ou avant date132 area_min: float | None = None, # superficie minimale (pi²)133 q: str | None = None,134 deal: str | None = None, # sous | marche | sur (fair value)135 kascore_min: float | None = None, # KA Score global minimal136 sort: str | None = None, # "deal" affaires | "ka" KA Score137 active: int = 1,138 limit: int = Query(500, le=2000),139 offset: int = 0,140):141 con = db.connect()142 sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"143 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"144 " fv.confidence AS fv_confidence," + _KS_COLS +145 " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +146 " WHERE dup_of IS NULL") # doublons masqués147 args: list = []148 if active in (0, 1):149 sql += " AND active=?"; args.append(active)150 if active == 1: # quarantaine qualité : jamais dans les résultats publics151 sql += " AND published=1"152 if city:153 sql += " AND city=?"; args.append(city)154 if sector:155 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")156 if unit_type:157 sql += " AND unit_type=?"; args.append(unit_type)158 if source:159 sql += " AND source=?"; args.append(source)160 if price_max is not None:161 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)162 if price_min is not None:163 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)164 if pets == "oui":165 sql += " AND pets IN ('oui','conditions')"166 elif pets:167 sql += " AND pets=?"; args.append(pets)168 if furnished in (0, 1):169 sql += " AND furnished=?"; args.append(furnished)170 if available_by:171 # « now » trié après les dates ISO : comparaison explicite172 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \173 " AND availability_date<=?))"174 args.append(available_by)175 if area_min is not None:176 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)177 if q:178 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"179 args += [f"%{q}%"] * 3180 if deal in ("sous", "marche", "sur"):181 sql += " AND fv.verdict=?"; args.append(deal)182 if kascore_min is not None:183 sql += " AND ks.global IS NOT NULL AND ks.global>=?"184 args.append(kascore_min)185 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]186 if sort == "deal": # meilleures affaires d'abord (écart le plus négatif)187 sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"188 " price IS NULL, price ASC LIMIT ? OFFSET ?")189 elif sort == "ka": # meilleurs emplacements d'abord (KA Score global)190 sql += (" ORDER BY ks.global IS NULL, ks.global DESC,"191 " price IS NULL, price ASC LIMIT ? OFFSET ?")192 elif sort == "prix":193 sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"194 else: # défaut : les plus récentes d'abord195 sql += " ORDER BY first_seen DESC, price IS NULL, price ASC LIMIT ? OFFSET ?"196 args += [limit, offset]197 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]198 con.close()199 # personnalisation KA ID : journal + reclassement (tri par défaut seulement,200 # jamais un tri explicite ; intention de session > préférences long terme)201 personalized = False202 user = _kaid_user(request)203 if user:204 filters = {k: v for k, v in {205 "city": city, "sector": sector, "unit_type": unit_type,206 "price_max": price_max, "price_min": price_min, "pets": pets,207 "furnished": furnished, "deal": deal, "q": q}.items()208 if v not in (None, "")}209 if filters and offset == 0:210 kaid.track(user, "search", query=q, filters=filters)211 if sort in (None, "", "recent"):212 active = {k for k in ("city", "sector", "unit_type", "pets", "deal")213 if filters.get(k)}214 if price_max is not None or price_min is not None:215 active.add("price")216 if furnished in (0, 1):217 active.add("furnished")218 rows, personalized = kaid.rerank(219 rows, user, features_of=_kaid_features, active_dims=active)220 return {"total": total, "count": len(rows), "listings": rows,221 "personalized": personalized}222223224@app.get("/api/listings.geojson")225def listings_geojson(226 city: str | None = None,227 sector: str | None = None,228 unit_type: str | None = None,229 source: str | None = None,230 price_max: float | None = None,231 price_min: float | None = None,232 pets: str | None = None,233 furnished: int | None = None,234 available_by: str | None = None,235 area_min: float | None = None,236 q: str | None = None,237 bbox: str | None = None,238 limit: int = Query(3000, le=8000),239):240 """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).241242 Léger par conception : seuls les champs nécessaires aux marqueurs et aux243 fiches de la carte sont inclus (1 photo, pas de description).244245 Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible246 (index idx_listings_geo) ; `limit` plafonne la réponse. La collection247 porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées248 correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées),249 pour afficher « N résultats sans position sur la carte ».250 """251 con = db.connect()252 sql = ("SELECT uid, title, address, price, price_label, unit_type,"253 " availability_date, source, city, sector, area_sqft, images,"254 " images_ok, lat, lng, fv.verdict AS fv_verdict,"255 " fv.deviation AS fv_deviation"256 " FROM listings LEFT JOIN fairvalue fv USING (uid)"257 " WHERE active=1 AND published=1 AND dup_of IS NULL"258 " AND lat IS NOT NULL AND lng IS NOT NULL")259 args: list = []260 if bbox:261 try:262 west, south, east, north = (float(v) for v in bbox.split(","))263 except ValueError:264 raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")265 sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"266 args += [south, north, west, east]267 if city:268 sql += " AND city=?"; args.append(city)269 if sector:270 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")271 if unit_type:272 sql += " AND unit_type=?"; args.append(unit_type)273 if source:274 sql += " AND source=?"; args.append(source)275 if price_max is not None:276 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)277 if price_min is not None:278 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)279 if pets == "oui":280 sql += " AND pets IN ('oui','conditions')"281 elif pets:282 sql += " AND pets=?"; args.append(pets)283 if furnished in (0, 1):284 sql += " AND furnished=?"; args.append(furnished)285 if available_by:286 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \287 " AND availability_date<=?))"288 args.append(available_by)289 if area_min is not None:290 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)291 if q:292 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"293 args += [f"%{q}%"] * 3294295 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]296 # Même requête sans l'exigence de coordonnées ni le bbox : mesure les297 # annonces filtrées invisibles sur la carte (affordance « sans position »).298 sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")299 args_all = list(args)300 if bbox:301 sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")302 del args_all[0:4]303 total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]304305 sql += " ORDER BY first_seen DESC LIMIT ?"306 args.append(limit)307308 features = []309 for r in con.execute(sql, args).fetchall():310 # galerie nettoyée si auditée (même vide : les images brutes sont311 # alors toutes mortes/placeholder — le frontend affiche le visuel312 # de secours Lou-Ka plutôt qu'une image cassée)313 images = json.loads((r["images_ok"] if r["images_ok"] is not None314 else r["images"]) or "[]")315 features.append({316 "type": "Feature",317 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},318 "properties": {319 "uid": r["uid"], "title": r["title"], "address": r["address"],320 "price": r["price"],321 "price_label": r["price_label"], "unit_type": r["unit_type"],322 "availability_date": r["availability_date"],323 "source": r["source"], "city": r["city"], "sector": r["sector"],324 "area_sqft": r["area_sqft"],325 "fv_verdict": r["fv_verdict"], "fv_deviation": r["fv_deviation"],326 "image": images[0] if images else None,327 },328 })329 con.close()330 return {"type": "FeatureCollection", "features": features,331 "totalGeocoded": total_geo, "totalMatching": total_all}332333334# --- Recherche unifiée liste + carte -----------------------------------------335# Une seule vérité partagée : la même requête sert la page de liste ET les336# points de la carte, dans la même réponse. Le compteur affiché en tête de337# liste est par construction égal au nombre de points envoyés à la carte.338339_SEARCH_SORTS = {340 "prix": " ORDER BY price IS NULL, price ASC",341 "prix_desc": " ORDER BY price IS NULL, price DESC",342 "recent": " ORDER BY first_seen DESC, price IS NULL, price ASC",343 "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"344 " price IS NULL, price ASC"),345 "ka": (" ORDER BY ks.global IS NULL, ks.global DESC,"346 " price IS NULL, price ASC"),347}348349# colonnes KA Scores jointes aux annonces (pastilles + tri + fiche)350_KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit,"351 " ks.bike AS ks_bike, ks.calme AS ks_calme,"352 " ks.services AS ks_services, ks.global AS ks_global")353_KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key"354_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}355356357def _parse_poly(poly: str) -> list[tuple[float, float]]:358 """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400."""359 try:360 pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p]361 except ValueError:362 raise HTTPException(400, "poly attendu : lng,lat;lng,lat;…")363 if len(pts) < 3 or any(len(p) != 2 for p in pts):364 raise HTTPException(400, "poly : au moins 3 sommets lng,lat")365 return pts # type: ignore[return-value]366367368def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool:369 """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon)."""370 inside = False371 j = len(ring) - 1372 for i in range(len(ring)):373 xi, yi = ring[i]374 xj, yj = ring[j]375 if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi:376 inside = not inside377 j = i378 return inside379380381@app.get("/api/search")382def search_unified(383 city: str | None = None,384 sector: str | None = None,385 unit_type: str | None = None,386 source: str | None = None,387 price_max: float | None = None,388 price_min: float | None = None,389 pets: str | None = None,390 furnished: int | None = None,391 available_by: str | None = None,392 area_min: float | None = None,393 q: str | None = None,394 deal: str | None = None, # sous | marche | sur (juste valeur)395 bbox: str | None = None, # ouest,sud,est,nord (zone visible carte)396 poly: str | None = None, # lng,lat;… (zone dessinée)397 kascore_min: float | None = None, # KA Score global minimal (0-100)398 sort: str = "recent", # recent (défaut) | prix | prix_desc | deal | ka399 page: int = 1,400 page_size: int = Query(20, ge=1, le=100),401 include: str = "tout", # tout | liste (points inchangés côté client)402):403 """Recherche synchronisée liste ↔ carte (une réponse = les deux vues).404405 Retourne `total` (compteur partagé), `listings` (la page demandée) et406 `points` (TOUS les points correspondants, format compact407 [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la408 position d'un point dans `points` donne directement sa page de liste.409 Seules les annonces géolocalisées participent (la règle d'or : chaque410 annonce de la liste est visible sur la carte) ; `unpositioned` compte411 les annonces filtrées sans coordonnées, pour l'afficher honnêtement.412 """413 if sort not in _SEARCH_SORTS:414 raise HTTPException(400, f"sort inconnu : {sort}")415 ring = _parse_poly(poly) if poly else None416417 con = db.connect()418 sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +419 " WHERE dup_of IS NULL AND active=1 AND published=1"420 " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL")421 args: list = []422 if bbox:423 try:424 west, south, east, north = (float(v) for v in bbox.split(","))425 except ValueError:426 raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")427 sql += (" AND listings.lat BETWEEN ? AND ?"428 " AND listings.lng BETWEEN ? AND ?")429 args += [south, north, west, east]430 if ring:431 # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval432 lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]433 sql += (" AND listings.lat BETWEEN ? AND ?"434 " AND listings.lng BETWEEN ? AND ?")435 args += [min(lats), max(lats), min(lngs), max(lngs)]436 if city:437 sql += " AND city=?"; args.append(city)438 if sector:439 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")440 if unit_type:441 sql += " AND unit_type=?"; args.append(unit_type)442 if source:443 sql += " AND source=?"; args.append(source)444 if price_max is not None:445 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)446 if price_min is not None:447 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)448 if pets == "oui":449 sql += " AND pets IN ('oui','conditions')"450 elif pets:451 sql += " AND pets=?"; args.append(pets)452 if furnished in (0, 1):453 sql += " AND furnished=?"; args.append(furnished)454 if available_by:455 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \456 " AND availability_date<=?))"457 args.append(available_by)458 if area_min is not None:459 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)460 if q:461 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"462 args += [f"%{q}%"] * 3463 if deal in ("sous", "marche", "sur"):464 sql += " AND fv.verdict=?"; args.append(deal)465 if kascore_min is not None:466 sql += " AND ks.global IS NOT NULL AND ks.global>=?"467 args.append(kascore_min)468469 rows = con.execute(470 "SELECT uid, listings.lng AS lng, listings.lat AS lat, price,"471 " fv.verdict AS v" + sql + _SEARCH_SORTS[sort],472 args).fetchall()473 if ring:474 rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]475 total = len(rows)476477 # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce478 # tableau détermine la page (points et liste partagent le même tri)479 points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"],480 _FV_CODES.get(r["v"] or "", None)] for r in rows]481482 # page demandée, ramenée dans les bornes si les filtres l'ont dépassée483 last = max(1, -(-total // page_size))484 page = min(max(1, page), last)485 page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]]486487 listings: list[dict] = []488 if page_uids:489 marks = ",".join("?" * len(page_uids))490 by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(491 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"492 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"493 " fv.confidence AS fv_confidence," + _KS_COLS +494 " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +495 f" WHERE uid IN ({marks})", page_uids).fetchall()}496 listings = [by_uid[u] for u in page_uids if u in by_uid]497498 # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)499 sql_nogeo = sql.replace(500 " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL",501 " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1)502 args_nogeo = list(args)503 for spatial in (bbox, poly):504 if spatial:505 sql_nogeo = sql_nogeo.replace(506 " AND listings.lat BETWEEN ? AND ?"507 " AND listings.lng BETWEEN ? AND ?", "", 1)508 del args_nogeo[0:4]509 unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,510 args_nogeo).fetchone()["c"]511 con.close()512513 return {514 "total": total, "page": page, "page_size": page_size, "sort": sort,515 "listings": listings,516 "points": points if include != "liste" else None,517 "unpositioned": unpositioned,518 }519520521# --- Miniatures optimisées (WebP, taille écran) ------------------------------522# Sert les images des annonces redimensionnées et compressées (perf mobile) :523# /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle524# qualité (table image_checks) sont proxifiées — pas de proxy ouvert. Si525# l'URL n'est pas (encore) connue : 404, le frontend retombe sur l'original.526_IMG_WIDTHS = (160, 480, 800, 1280)527_IMG_CACHE = ROOT / "data" / "imgcache"528529530@app.get("/api/img")531def img_thumbnail(u: str, w: int = 480):532 import hashlib533 import requests as _rq534 from fastapi.responses import Response535 w = min(_IMG_WIDTHS, key=lambda x: abs(x - w))536 if not u.startswith(("http://", "https://")):537 raise HTTPException(400, "URL invalide")538 key = hashlib.sha1(u.encode()).hexdigest()539 _IMG_CACHE.mkdir(parents=True, exist_ok=True)540 path = _IMG_CACHE / f"{key}_{w}.webp"541 headers = {"Cache-Control": "public, max-age=2592000, immutable"}542 if path.exists():543 return Response(path.read_bytes(), media_type="image/webp",544 headers=headers)545 con = db.connect()546 known = con.execute("SELECT status FROM image_checks WHERE url=?",547 (u,)).fetchone()548 con.close()549 if known is None or known["status"] not in ("ok",):550 raise HTTPException(404, "image inconnue du contrôle qualité")551 try:552 resp = _rq.get(u, timeout=12, headers={"User-Agent": "LouKaImg/1.0"})553 resp.raise_for_status()554 from io import BytesIO555 from PIL import Image, ImageOps556 img = Image.open(BytesIO(resp.content))557 img = ImageOps.exif_transpose(img)558 if img.mode not in ("RGB", "L"):559 img = img.convert("RGB")560 if img.width > w:561 img = img.resize((w, max(1, round(img.height * w / img.width))),562 Image.LANCZOS)563 buf = BytesIO()564 img.save(buf, "WEBP", quality=80, method=4)565 data = buf.getvalue()566 path.write_bytes(data)567 return Response(data, media_type="image/webp", headers=headers)568 except HTTPException:569 raise570 except Exception:571 raise HTTPException(404, "image irrécupérable")572573574@app.get("/api/listings/{uid}/pdf")575def listing_pdf(uid: str):576 """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""577 from fastapi.responses import Response578 from . import pdfgen579 data = pdfgen.fiche_pdf(uid)580 if data is None:581 raise HTTPException(404, "Annonce introuvable")582 nom = uid.replace(":", "-")583 return Response(content=data, media_type="application/pdf", headers={584 "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'})585586587@app.get("/api/stats/rapport.pdf")588def rapport_marche_pdf():589 """Rapport global du marché locatif (PDF multi-pages)."""590 from fastapi.responses import Response591 from . import pdfgen592 return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",593 headers={"Content-Disposition":594 'attachment; filename="louka-rapport-marche.pdf"'})595596597@app.get("/api/fairvalue/{uid}")598def fairvalue_detail(uid: str):599 """Analyse de prix Lou-Ka : juste valeur, fourchette, confiance,600 distribution du segment (voir louka/fairvalue.py)."""601 from . import fairvalue602 d = fairvalue.explain(uid)603 if d is None:604 raise HTTPException(404, "Pas d'estimation pour cette annonce")605 return d606607608@app.get("/api/hydro")609def hydro_at(adresse: str, solve: int = 0, uid: str | None = None,610 lat: float | None = None, lng: float | None = None):611 """Estimation du coût d'électricité à une adresse (Hydro-Québec).612 Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul.613 uid/lat/lng permettent la lecture cache par annonce et la dérivation du614 code postal (adresses sans CP)."""615 from . import hydro616 return hydro.estimate(adresse=adresse, uid=uid, lat=lat, lng=lng,617 solve=bool(solve))618619620@app.get("/api/commerces")621def commerces_at(lat: float, lng: float):622 """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""623 from . import commerces624 return commerces.nearby(lat, lng)625626627@app.get("/api/air")628def air_at(lat: float, lng: float):629 """Qualité de l'air : station RSQAQ la plus proche (MELCCFP)."""630 from . import air631 d = air.lookup(lat, lng)632 if d is None:633 raise HTTPException(404, "Base qualité de l'air non disponible")634 return d635636637@app.get("/api/gaz")638def gaz_at(lat: float, lng: float, limit: int = 5):639 """Stations-service à proximité et prix courants (gazquebec.ca).640 `limit` (défaut 5, max 60) : la fiche demande la liste complète pour son641 panneau « Voir les N stations »."""642 from . import gaz643 return gaz.nearby(lat, lng, limit=max(1, min(limit, 60)))644645646@app.get("/api/inondation")647def inondation_at(lat: float, lng: float):648 """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —649 bloc « Risque d'inondation » de la fiche."""650 from . import inondation651 d = inondation.lookup(lat, lng)652 if d is None:653 raise HTTPException(404, "Base des zones inondables non disponible")654 return d655656657@app.get("/api/rdl")658def rdl_nearby(lat: float, lng: float, radius: int = 600, limit: int = 12):659 """Loyers déclarés au Registre des loyers (registre-des-loyers.ca)660 autour d'un point — bloc « Registre des loyers » de la fiche.661 `limit` (défaut 12, max 300) : nombre de déclarations retournées dans662 `items` ; la fiche demande la liste complète pour son panneau663 « Voir les N loyers comparables »."""664 from . import rdl665 radius = max(100, min(radius, 2000))666 d = rdl.nearby(lat, lng, radius, limit=max(1, min(limit, 300)))667 if d is None:668 raise HTTPException(404, "Registre des loyers non disponible")669 return d670671672@app.get("/api/tal")673def tal_history(address: str, city: str | None = None):674 """Historique au Tribunal administratif du logement (décisions SOQUIJ)675 à l'adresse de l'annonce — bloc « Historique TAL » de la fiche.676 Une adresse jamais vérifiée est mise en file prioritaire (status pending)."""677 from . import tal678 return tal.lookup(address, city)679680681@app.get("/api/listings/{uid}/historique")682def listing_history(uid: str):683 """Historique Lou-Ka : timeline des observations réelles (prix, texte,684 photos, disponibilité, retraits/retours) — voir louka/history.py."""685 from . import history686 out = history.timeline(uid)687 if out is None:688 raise HTTPException(404, "Annonce introuvable")689 return out690691692@app.get("/api/listings/{uid}/cout-reel")693def listing_cout_reel(uid: str):694 """Coût réel mensuel : loyer + frais non inclus ligne par ligne, chaque695 poste étiqueté included/observed/estimated/unknown — louka/coutreel.py."""696 from . import coutreel697 out = coutreel.compute(uid)698 if out is None:699 raise HTTPException(404, "Annonce introuvable")700 return out701702703@app.get("/api/listings/{uid}/recyclees")704def listing_recycled(uid: str):705 """Annonces antérieures probables du même logement (score multi-signaux,706 inférence explicable) — louka/recycled.py."""707 from . import recycled708 return recycled.lookup(uid)709710711@app.get("/api/immeuble")712def immeuble_fiche(bkey: str):713 """Passeport de l'immeuble (stats précalculées par building.rollup)."""714 from . import building715 out = building.fiche(bkey)716 if out is None:717 raise HTTPException(404, "Immeuble inconnu")718 return out719720721@app.get("/api/managers/{source_id}")722def manager_fiche(source_id: str):723 """Fiche du gestionnaire (source directe) + avis Google synchronisés en724 base — aucun appel SerpApi au chargement de page (louka/managers.py)."""725 from . import managers726 out = managers.fiche(source_id)727 if out is None:728 raise HTTPException(404, "Gestionnaire inconnu")729 return out730731732@app.get("/api/listings/{uid}")733def get_listing(uid: str, request: Request):734 con = db.connect()735 row = con.execute(736 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"737 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"738 " fv.confidence AS fv_confidence"739 " FROM listings LEFT JOIN fairvalue fv USING (uid)"740 " WHERE uid=?", (uid,)).fetchone()741 d = None742 if row is not None:743 d = _row_to_dict(row)744 # commodités de proximité (cache par immeuble, voir louka/poi.py)745 if d.get("lat") is not None and d.get("lng") is not None:746 key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"747 poi_row = con.execute(748 "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()749 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []750 else:751 d["poi"] = []752 # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)753 if d.get("coord_key"):754 ks = con.execute(755 "SELECT walk, transit, bike, calme, services, global AS glob,"756 " details, version, computed_at FROM kascores WHERE coord_key=?",757 (d["coord_key"],)).fetchone()758 if ks is not None:759 d["kascores"] = {760 "walk": ks["walk"], "transit": ks["transit"],761 "bike": ks["bike"], "calme": ks["calme"],762 "services": ks["services"], "global": ks["glob"],763 "details": json.loads(ks["details"] or "{}"),764 "version": ks["version"], "computed_at": ks["computed_at"],765 }766 # statistiques de quartier (recensement, proximité, chaleur, criminalité)767 from . import quartier768 dauid = d.get("dauid")769 d["quartier"] = quartier.fiche_quartier(770 d.get("lat"), d.get("lng"), d.get("city") or "",771 dauid if dauid and dauid != "hors-zone" else None)772 # passeport de l'immeuble (stats précalculées) + score hiver773 if d.get("building_key"):774 from . import building775 d["immeuble"] = building.fiche(d["building_key"], con)776 else:777 d["immeuble"] = None778 from . import hiver779 d["hiver"] = hiver.score(d.get("lat"), d.get("lng"), con)780 # description structurée + historique de prix781 d["digest"] = json.loads(d["digest"]) if d.get("digest") else None782 d["price_history"] = [dict(r) for r in con.execute(783 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",784 (uid,)).fetchall()]785 con.close()786 if d is None:787 raise HTTPException(404, "Annonce introuvable")788 kaid.track(_kaid_user(request), "detail_view", entity_type="listing",789 entity_id=uid, features=_kaid_features(d))790 return d791792793@app.get("/api/kascores/stats")794def kascores_stats():795 """Couverture, moyennes et version du barème — page « KA Scores »."""796 from . import kascores797 return kascores.stats()798799800@app.get("/api/facets")801def facets(city: str | None = None):802 """Valeurs distinctes pour construire les filtres du frontend.803804 `city` (optionnel) restreint la liste des quartiers à cette ville —805 utilisé par le sélecteur « Quartier » dépendant de « Ville ».806 """807 con = db.connect()808 sector_sql = ("SELECT DISTINCT sector FROM listings"809 " WHERE active=1 AND published=1 AND dup_of IS NULL"810 " AND sector<>''")811 sector_args: list = []812 if city:813 sector_sql += " AND city=?"814 sector_args.append(city)815 out = {816 "cities": [r["city"] for r in con.execute(817 "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1"818 " AND dup_of IS NULL AND city<>'' ORDER BY city")],819 "sectors": [r["sector"] for r in con.execute(820 sector_sql + " ORDER BY sector", sector_args)],821 "unit_types": [r["unit_type"] for r in con.execute(822 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1"823 " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],824 "sources": [dict(r) for r in con.execute(825 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"826 " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],827 }828 con.close()829 return out830831832@app.get("/api/sources")833def sources():834 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]835 con = db.connect()836 counts = {r["source"]: r["n"] for r in con.execute(837 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"838 " GROUP BY source")}839 last = {r["source"]: r["ts"] for r in con.execute(840 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}841 con.close()842 for s in registry:843 s["active_listings"] = counts.get(s["id"], 0)844 s["last_sync"] = last.get(s["id"])845 return {"sources": registry}846847848# Villes de la grande région de Montréal (le reste de la province est849# compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…)850_VILLES_GM = (851 "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne",852 "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount",853 "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou",854 "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly",855 "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire",856 "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand",857 "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion",858 "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont",859)860861862@app.get("/api/stats")863def stats(syncs_since_h: float | None = None):864 """Stats globales + journal de synchronisation.865866 `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le867 moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne868 couvrent que ~11 min d'activité sur ~200 sources (fausses alertes869 « stale »). Avec le paramètre : toutes les entrées des N dernières heures870 (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières871 toutes époques confondues. Sans le paramètre : comportement historique872 inchangé (LIMIT 20).873 """874 con = db.connect()875 gm = ",".join("?" * len(_VILLES_GM))876 row = con.execute(877 f"""SELECT COUNT(*) total,878 SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,879 SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,880 SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal,881 SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm})882 THEN 1 ELSE 0 END) autres,883 COUNT(DISTINCT source) sources,884 AVG(price) avg_price885 FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL""",886 list(_VILLES_GM) + list(_VILLES_GM)).fetchone()887 log: list[dict] = []888 if syncs_since_h is not None:889 import time as _time890 cutoff = _time.time() - syncs_since_h * 3600891 log = [dict(r) for r in con.execute(892 "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",893 (cutoff,))]894 if syncs_since_h is None or len(log) < 20:895 log = [dict(r) for r in con.execute(896 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]897 # qualité des données : complétude moyenne + quarantaine (quality.py)898 qual = con.execute(899 """SELECT ROUND(AVG(completeness),1) avg_completeness,900 SUM(published=1) publiees, SUM(published=0) quarantaine901 FROM listings WHERE active=1"""902 ).fetchone()903 con.close()904 # Sources en pause (ex. pause Ontario 2026-08-27) déclarées au moniteur905 # central api-ka via `disabled_sources` : il les retire de la supervision906 # au lieu de les flagger « stale » et de missionner des agents pour rien.907 disabled = [908 s["id"]909 for s in json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]910 if (s.get("status") or "").startswith("pause")911 ]912 return {**dict(row), "quality": dict(qual), "recent_syncs": log,913 "disabled_sources": disabled}914915916@app.get("/api/stats/detailed")917def stats_detailed():918 """Agrégats du marché (source unique : louka/marketstats.py)."""919 from . import marketstats920 return marketstats.compute()921922923# --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) ---924_PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}925926927@app.get("/api/stats/dashboard")928def stats_dashboard(period: str = "30j",929 from_: str | None = Query(None, alias="from"),930 to: str | None = None):931 """Tableau de bord analytique (KPI, séries, répartitions, tableaux…)."""932 from . import statsdash933 if period not in _PERIODES_VALIDES and not (from_ and to):934 period = "30j"935 return statsdash.compute(period, from_, to)936937938@app.get("/api/stats/report")939def stats_report(period: str = "30j",940 from_: str | None = Query(None, alias="from"),941 to: str | None = None,942 mode: str = "complet"):943 """Rapport statistique PDF estampillé Groupe-KA — 5 modes :944 complet | synthese | tendances | repartitions | donnees (inconnu → complet)."""945 from fastapi.responses import Response946 from . import kapdf, statsdash947 if period not in _PERIODES_VALIDES:948 period = "perso" if (from_ and to) else "30j"949 if mode not in kapdf.REPORT_MODES:950 mode = "complet"951 dash = statsdash.compute(period if period != "perso" else "30j",952 from_, to)953 eco = json.loads((ROOT / "frontend" / "src" / "ka" /954 "ecosystem.json").read_text(encoding="utf-8"))955 site = next(s for s in eco["sites"] if s["id"] == "lou-ka")956 data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()957 fname = kapdf.filename("lou-ka", period, mode)958 return Response(content=data, media_type="application/pdf",959 headers={"Content-Disposition":960 f'attachment; filename="{fname}"'})961962963964@app.get("/api/stats/catalog")965def stats_catalog(period: str = "30j",966 from_: str | None = Query(None, alias="from"),967 to: str | None = None):968 """v3 — blocs composables pour le constructeur de rapports personnalisés."""969 from . import kapdf, statsdash970 if period not in _PERIODES_VALIDES and not (from_ and to):971 period = "30j"972 dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j",973 from_, to)974 return {"updated": dash.get("updated"), "period": dash.get("period"),975 "blocks": kapdf.catalog(dash)}976977978@app.post("/api/stats/report/custom")979def stats_report_custom(spec: dict = Body(...)):980 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",981 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""982 from fastapi.responses import Response983 from . import kapdf, statsdash984 period = str(spec.get("period") or "30j")985 from_ = str(spec.get("from") or "") or None986 to = str(spec.get("to") or "") or None987 if period not in _PERIODES_VALIDES:988 period = "perso" if (from_ and to) else "30j"989 dash = statsdash.compute(period if period != "perso" else "30j",990 from_, to)991 known = {b["key"] for b in kapdf.catalog(dash)}992 blocks = [b for b in (spec.get("blocks") or [])993 if isinstance(b, dict) and b.get("key") in known][:40]994 if not blocks:995 raise HTTPException(400, "Aucun bloc valide dans la composition")996 eco = json.loads((ROOT / "frontend" / "src" / "ka" /997 "ecosystem.json").read_text(encoding="utf-8"))998 site = next(s for s in eco["sites"] if s["id"] == "lou-ka")999 data = kapdf.GroupeKAReport(1000 site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,1001 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},1002 ).build()1003 fname = kapdf.filename("lou-ka", period, kapdf.CUSTOM_MODE)1004 return Response(content=data, media_type="application/pdf",1005 headers={"Content-Disposition":1006 f'attachment; filename="{fname}"'})100710081009@app.post("/api/sync")1010def trigger_sync(background: BackgroundTasks, source: str | None = None):1011 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""1012 def _job():1013 with _sync_lock:1014 ingest.run([source] if source else None)1015 background.add_task(_job)1016 return {"status": "démarré", "source": source or "toutes"}101710181019# --- Frontend React (build Vite) --------------------------------------------1020if FRONTEND_DIST.exists():1021 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")10221023 # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.1024 # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.1025 from . import seo # noqa: E4021026 app.include_router(seo.router)10271028 # Documentation utilisateur (frontend/public/doc → dist/doc) — routes1029 # explicites AVANT le catch-all SPA pour servir /doc/ comme un index.1030 @app.get("/doc")1031 def doc_redirect():1032 return RedirectResponse("/doc/", status_code=308)10331034 @app.get("/doc/")1035 def doc_index():1036 return FileResponse(FRONTEND_DIST / "doc" / "index.html")10371038 @app.middleware("http")1039 async def _cache_headers(request, call_next):1040 """Politique de cache : les bundles hachés (/assets/…) sont immuables,1041 mais index.html doit TOUJOURS être revalidé — sinon les navigateurs1042 gardent une vieille version de l'app après un déploiement."""1043 resp = await call_next(request)1044 path = request.url.path1045 if path.startswith("/assets/"):1046 resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"1047 elif "text/html" in (resp.headers.get("content-type") or ""):1048 resp.headers["Cache-Control"] = "no-cache"1049 return resp10501051 # routes de l'app rendues uniquement côté client (privées ou volatiles) —1052 # tout autre chemin inconnu renvoie index.html avec un statut 404 : le1053 # routeur React affiche sa page « introuvable », les moteurs de recherche1054 # voient un vrai 404 (fin des soft-404).1055 _CLIENT_ROUTES = {"profil", "favoris", "gestion", "bienvenue", "bot",1056 "contact", "juste-valeur", "court-terme"}1057 _CLIENT_PREFIXES = ("u/", "passerelle/", "court-terme/")10581059 @app.get("/{full_path:path}")1060 def spa(full_path: str):1061 target = FRONTEND_DIST / full_path1062 if full_path and target.is_file():1063 return FileResponse(target)1064 known = (full_path in _CLIENT_ROUTES1065 or any(full_path == p.rstrip("/") or full_path.startswith(p)1066 for p in _CLIENT_PREFIXES))1067 return FileResponse(FRONTEND_DIST / "index.html",1068 status_code=200 if known else 404,1069 headers={"Cache-Control": "no-cache"})1070