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):639 """Stations-service à proximité et prix courants (gazquebec.ca)."""640 from . import gaz641 return gaz.nearby(lat, lng)642643644@app.get("/api/inondation")645def inondation_at(lat: float, lng: float):646 """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —647 bloc « Risque d'inondation » de la fiche."""648 from . import inondation649 d = inondation.lookup(lat, lng)650 if d is None:651 raise HTTPException(404, "Base des zones inondables non disponible")652 return d653654655@app.get("/api/rdl")656def rdl_nearby(lat: float, lng: float, radius: int = 600):657 """Loyers déclarés au Registre des loyers (registre-des-loyers.ca)658 autour d'un point — bloc « Registre des loyers » de la fiche."""659 from . import rdl660 radius = max(100, min(radius, 2000))661 d = rdl.nearby(lat, lng, radius)662 if d is None:663 raise HTTPException(404, "Registre des loyers non disponible")664 return d665666667@app.get("/api/tal")668def tal_history(address: str, city: str | None = None):669 """Historique au Tribunal administratif du logement (décisions SOQUIJ)670 à l'adresse de l'annonce — bloc « Historique TAL » de la fiche.671 Une adresse jamais vérifiée est mise en file prioritaire (status pending)."""672 from . import tal673 return tal.lookup(address, city)674675676@app.get("/api/listings/{uid}/historique")677def listing_history(uid: str):678 """Historique Lou-Ka : timeline des observations réelles (prix, texte,679 photos, disponibilité, retraits/retours) — voir louka/history.py."""680 from . import history681 out = history.timeline(uid)682 if out is None:683 raise HTTPException(404, "Annonce introuvable")684 return out685686687@app.get("/api/listings/{uid}/cout-reel")688def listing_cout_reel(uid: str):689 """Coût réel mensuel : loyer + frais non inclus ligne par ligne, chaque690 poste étiqueté included/observed/estimated/unknown — louka/coutreel.py."""691 from . import coutreel692 out = coutreel.compute(uid)693 if out is None:694 raise HTTPException(404, "Annonce introuvable")695 return out696697698@app.get("/api/listings/{uid}/recyclees")699def listing_recycled(uid: str):700 """Annonces antérieures probables du même logement (score multi-signaux,701 inférence explicable) — louka/recycled.py."""702 from . import recycled703 return recycled.lookup(uid)704705706@app.get("/api/immeuble")707def immeuble_fiche(bkey: str):708 """Passeport de l'immeuble (stats précalculées par building.rollup)."""709 from . import building710 out = building.fiche(bkey)711 if out is None:712 raise HTTPException(404, "Immeuble inconnu")713 return out714715716@app.get("/api/managers/{source_id}")717def manager_fiche(source_id: str):718 """Fiche du gestionnaire (source directe) + avis Google synchronisés en719 base — aucun appel SerpApi au chargement de page (louka/managers.py)."""720 from . import managers721 out = managers.fiche(source_id)722 if out is None:723 raise HTTPException(404, "Gestionnaire inconnu")724 return out725726727@app.get("/api/listings/{uid}")728def get_listing(uid: str, request: Request):729 con = db.connect()730 row = con.execute(731 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"732 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"733 " fv.confidence AS fv_confidence"734 " FROM listings LEFT JOIN fairvalue fv USING (uid)"735 " WHERE uid=?", (uid,)).fetchone()736 d = None737 if row is not None:738 d = _row_to_dict(row)739 # commodités de proximité (cache par immeuble, voir louka/poi.py)740 if d.get("lat") is not None and d.get("lng") is not None:741 key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"742 poi_row = con.execute(743 "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()744 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []745 else:746 d["poi"] = []747 # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)748 if d.get("coord_key"):749 ks = con.execute(750 "SELECT walk, transit, bike, calme, services, global AS glob,"751 " details, version, computed_at FROM kascores WHERE coord_key=?",752 (d["coord_key"],)).fetchone()753 if ks is not None:754 d["kascores"] = {755 "walk": ks["walk"], "transit": ks["transit"],756 "bike": ks["bike"], "calme": ks["calme"],757 "services": ks["services"], "global": ks["glob"],758 "details": json.loads(ks["details"] or "{}"),759 "version": ks["version"], "computed_at": ks["computed_at"],760 }761 # statistiques de quartier (recensement, proximité, chaleur, criminalité)762 from . import quartier763 dauid = d.get("dauid")764 d["quartier"] = quartier.fiche_quartier(765 d.get("lat"), d.get("lng"), d.get("city") or "",766 dauid if dauid and dauid != "hors-zone" else None)767 # passeport de l'immeuble (stats précalculées) + score hiver768 if d.get("building_key"):769 from . import building770 d["immeuble"] = building.fiche(d["building_key"], con)771 else:772 d["immeuble"] = None773 from . import hiver774 d["hiver"] = hiver.score(d.get("lat"), d.get("lng"), con)775 # description structurée + historique de prix776 d["digest"] = json.loads(d["digest"]) if d.get("digest") else None777 d["price_history"] = [dict(r) for r in con.execute(778 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",779 (uid,)).fetchall()]780 con.close()781 if d is None:782 raise HTTPException(404, "Annonce introuvable")783 kaid.track(_kaid_user(request), "detail_view", entity_type="listing",784 entity_id=uid, features=_kaid_features(d))785 return d786787788@app.get("/api/kascores/stats")789def kascores_stats():790 """Couverture, moyennes et version du barème — page « KA Scores »."""791 from . import kascores792 return kascores.stats()793794795@app.get("/api/facets")796def facets(city: str | None = None):797 """Valeurs distinctes pour construire les filtres du frontend.798799 `city` (optionnel) restreint la liste des quartiers à cette ville —800 utilisé par le sélecteur « Quartier » dépendant de « Ville ».801 """802 con = db.connect()803 sector_sql = ("SELECT DISTINCT sector FROM listings"804 " WHERE active=1 AND published=1 AND dup_of IS NULL"805 " AND sector<>''")806 sector_args: list = []807 if city:808 sector_sql += " AND city=?"809 sector_args.append(city)810 out = {811 "cities": [r["city"] for r in con.execute(812 "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1"813 " AND dup_of IS NULL AND city<>'' ORDER BY city")],814 "sectors": [r["sector"] for r in con.execute(815 sector_sql + " ORDER BY sector", sector_args)],816 "unit_types": [r["unit_type"] for r in con.execute(817 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1"818 " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],819 "sources": [dict(r) for r in con.execute(820 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"821 " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],822 }823 con.close()824 return out825826827@app.get("/api/sources")828def sources():829 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]830 con = db.connect()831 counts = {r["source"]: r["n"] for r in con.execute(832 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"833 " GROUP BY source")}834 last = {r["source"]: r["ts"] for r in con.execute(835 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}836 con.close()837 for s in registry:838 s["active_listings"] = counts.get(s["id"], 0)839 s["last_sync"] = last.get(s["id"])840 return {"sources": registry}841842843# Villes de la grande région de Montréal (le reste de la province est844# compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…)845_VILLES_GM = (846 "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne",847 "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount",848 "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou",849 "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly",850 "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire",851 "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand",852 "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion",853 "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont",854)855856857@app.get("/api/stats")858def stats(syncs_since_h: float | None = None):859 """Stats globales + journal de synchronisation.860861 `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le862 moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne863 couvrent que ~11 min d'activité sur ~200 sources (fausses alertes864 « stale »). Avec le paramètre : toutes les entrées des N dernières heures865 (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières866 toutes époques confondues. Sans le paramètre : comportement historique867 inchangé (LIMIT 20).868 """869 con = db.connect()870 gm = ",".join("?" * len(_VILLES_GM))871 row = con.execute(872 f"""SELECT COUNT(*) total,873 SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,874 SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,875 SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal,876 SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm})877 THEN 1 ELSE 0 END) autres,878 COUNT(DISTINCT source) sources,879 AVG(price) avg_price880 FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL""",881 list(_VILLES_GM) + list(_VILLES_GM)).fetchone()882 log: list[dict] = []883 if syncs_since_h is not None:884 import time as _time885 cutoff = _time.time() - syncs_since_h * 3600886 log = [dict(r) for r in con.execute(887 "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",888 (cutoff,))]889 if syncs_since_h is None or len(log) < 20:890 log = [dict(r) for r in con.execute(891 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]892 # qualité des données : complétude moyenne + quarantaine (quality.py)893 qual = con.execute(894 """SELECT ROUND(AVG(completeness),1) avg_completeness,895 SUM(published=1) publiees, SUM(published=0) quarantaine896 FROM listings WHERE active=1"""897 ).fetchone()898 con.close()899 return {**dict(row), "quality": dict(qual), "recent_syncs": log}900901902@app.get("/api/stats/detailed")903def stats_detailed():904 """Agrégats du marché (source unique : louka/marketstats.py)."""905 from . import marketstats906 return marketstats.compute()907908909# --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) ---910_PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}911912913@app.get("/api/stats/dashboard")914def stats_dashboard(period: str = "30j",915 from_: str | None = Query(None, alias="from"),916 to: str | None = None):917 """Tableau de bord analytique (KPI, séries, répartitions, tableaux…)."""918 from . import statsdash919 if period not in _PERIODES_VALIDES and not (from_ and to):920 period = "30j"921 return statsdash.compute(period, from_, to)922923924@app.get("/api/stats/report")925def stats_report(period: str = "30j",926 from_: str | None = Query(None, alias="from"),927 to: str | None = None,928 mode: str = "complet"):929 """Rapport statistique PDF estampillé Groupe-KA — 5 modes :930 complet | synthese | tendances | repartitions | donnees (inconnu → complet)."""931 from fastapi.responses import Response932 from . import kapdf, statsdash933 if period not in _PERIODES_VALIDES:934 period = "perso" if (from_ and to) else "30j"935 if mode not in kapdf.REPORT_MODES:936 mode = "complet"937 dash = statsdash.compute(period if period != "perso" else "30j",938 from_, to)939 eco = json.loads((ROOT / "frontend" / "src" / "ka" /940 "ecosystem.json").read_text(encoding="utf-8"))941 site = next(s for s in eco["sites"] if s["id"] == "lou-ka")942 data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()943 fname = kapdf.filename("lou-ka", period, mode)944 return Response(content=data, media_type="application/pdf",945 headers={"Content-Disposition":946 f'attachment; filename="{fname}"'})947948949950@app.get("/api/stats/catalog")951def stats_catalog(period: str = "30j",952 from_: str | None = Query(None, alias="from"),953 to: str | None = None):954 """v3 — blocs composables pour le constructeur de rapports personnalisés."""955 from . import kapdf, statsdash956 if period not in _PERIODES_VALIDES and not (from_ and to):957 period = "30j"958 dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j",959 from_, to)960 return {"updated": dash.get("updated"), "period": dash.get("period"),961 "blocks": kapdf.catalog(dash)}962963964@app.post("/api/stats/report/custom")965def stats_report_custom(spec: dict = Body(...)):966 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",967 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""968 from fastapi.responses import Response969 from . import kapdf, statsdash970 period = str(spec.get("period") or "30j")971 from_ = str(spec.get("from") or "") or None972 to = str(spec.get("to") or "") or None973 if period not in _PERIODES_VALIDES:974 period = "perso" if (from_ and to) else "30j"975 dash = statsdash.compute(period if period != "perso" else "30j",976 from_, to)977 known = {b["key"] for b in kapdf.catalog(dash)}978 blocks = [b for b in (spec.get("blocks") or [])979 if isinstance(b, dict) and b.get("key") in known][:40]980 if not blocks:981 raise HTTPException(400, "Aucun bloc valide dans la composition")982 eco = json.loads((ROOT / "frontend" / "src" / "ka" /983 "ecosystem.json").read_text(encoding="utf-8"))984 site = next(s for s in eco["sites"] if s["id"] == "lou-ka")985 data = kapdf.GroupeKAReport(986 site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,987 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},988 ).build()989 fname = kapdf.filename("lou-ka", period, kapdf.CUSTOM_MODE)990 return Response(content=data, media_type="application/pdf",991 headers={"Content-Disposition":992 f'attachment; filename="{fname}"'})993994995@app.post("/api/sync")996def trigger_sync(background: BackgroundTasks, source: str | None = None):997 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""998 def _job():999 with _sync_lock:1000 ingest.run([source] if source else None)1001 background.add_task(_job)1002 return {"status": "démarré", "source": source or "toutes"}100310041005# --- Frontend React (build Vite) --------------------------------------------1006if FRONTEND_DIST.exists():1007 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")10081009 # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.1010 # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.1011 from . import seo # noqa: E4021012 app.include_router(seo.router)10131014 # Documentation utilisateur (frontend/public/doc → dist/doc) — routes1015 # explicites AVANT le catch-all SPA pour servir /doc/ comme un index.1016 @app.get("/doc")1017 def doc_redirect():1018 return RedirectResponse("/doc/", status_code=308)10191020 @app.get("/doc/")1021 def doc_index():1022 return FileResponse(FRONTEND_DIST / "doc" / "index.html")10231024 @app.middleware("http")1025 async def _cache_headers(request, call_next):1026 """Politique de cache : les bundles hachés (/assets/…) sont immuables,1027 mais index.html doit TOUJOURS être revalidé — sinon les navigateurs1028 gardent une vieille version de l'app après un déploiement."""1029 resp = await call_next(request)1030 path = request.url.path1031 if path.startswith("/assets/"):1032 resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"1033 elif "text/html" in (resp.headers.get("content-type") or ""):1034 resp.headers["Cache-Control"] = "no-cache"1035 return resp10361037 # routes de l'app rendues uniquement côté client (privées ou volatiles) —1038 # tout autre chemin inconnu renvoie index.html avec un statut 404 : le1039 # routeur React affiche sa page « introuvable », les moteurs de recherche1040 # voient un vrai 404 (fin des soft-404).1041 _CLIENT_ROUTES = {"profil", "favoris", "gestion", "bienvenue", "bot",1042 "contact", "juste-valeur", "court-terme"}1043 _CLIENT_PREFIXES = ("u/", "passerelle/", "court-terme/")10441045 @app.get("/{full_path:path}")1046 def spa(full_path: str):1047 target = FRONTEND_DIST / full_path1048 if full_path and target.is_file():1049 return FileResponse(target)1050 known = (full_path in _CLIENT_ROUTES1051 or any(full_path == p.rstrip("/") or full_path.startswith(p)1052 for p in _CLIENT_PREFIXES))1053 return FileResponse(FRONTEND_DIST / "index.html",1054 status_code=200 if known else 404,1055 headers={"Cache-Control": "no-cache"})1056