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, Query13from fastapi.middleware.cors import CORSMiddleware14from fastapi.middleware.gzip import GZipMiddleware15from fastapi.responses import FileResponse16from fastapi.staticfiles import StaticFiles1718from . import auth, db, ingest1920ROOT = 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# rôles, favoris, pages gestionnaires — voir louka/accounts.py36from . import accounts # noqa: E402 (import tardif : évite le cycle web<->accounts)37app.include_router(accounts.router)3839# personnalisation du profil (bio, réseaux sociaux, photo) — louka/profile.py40from . import profile as user_profile # noqa: E40241app.include_router(user_profile.router)4243# section « Location court terme » — sous-système isolé (louka/shortterm/)44from .shortterm import web as ct_web # noqa: E40245app.include_router(ct_web.router)4647# fichiers téléversés (photos de profil) — data/uploads/48_UPLOADS = ROOT / "data" / "uploads"49_UPLOADS.mkdir(parents=True, exist_ok=True)50app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads")515253def _row_to_dict(row) -> dict:54 d = dict(row)55 d["amenities"] = json.loads(d.get("amenities") or "[]")56 d["images"] = json.loads(d.get("images") or "[]")57 # galerie nettoyée par le contrôle qualité (imgcheck.py) : liens morts,58 # minuscules, placeholders et doublons retirés — prime sur la galerie brute59 imgs_ok = d.pop("images_ok", None)60 if imgs_ok:61 try:62 cleaned = json.loads(imgs_ok)63 if isinstance(cleaned, list):64 d["images"] = cleaned65 except (ValueError, TypeError):66 pass67 d.pop("img_audit", None)68 d["details"] = json.loads(d.get("details") or "{}")69 # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… »)70 if d.get("dup_sources"):71 try:72 d["dup_sources"] = json.loads(d["dup_sources"])73 except (ValueError, TypeError):74 d["dup_sources"] = []75 if d.get("furnished") is not None:76 d["furnished"] = bool(d["furnished"])77 return d787980@app.get("/api/listings")81def list_listings(82 city: str | None = None,83 sector: str | None = None,84 unit_type: str | None = None,85 source: str | None = None,86 price_max: float | None = None,87 price_min: float | None = None,88 pets: str | None = None, # "oui" -> oui OU conditions89 furnished: int | None = None, # 1 / 090 available_by: str | None = None, # ISO : dispo maintenant ou avant date91 area_min: float | None = None, # superficie minimale (pi²)92 q: str | None = None,93 deal: str | None = None, # sous | marche | sur (fair value)94 kascore_min: float | None = None, # KA Score global minimal95 sort: str | None = None, # "deal" affaires | "ka" KA Score96 active: int = 1,97 limit: int = Query(500, le=2000),98 offset: int = 0,99):100 con = db.connect()101 sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"102 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"103 " fv.confidence AS fv_confidence," + _KS_COLS +104 " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +105 " WHERE dup_of IS NULL") # doublons masqués106 args: list = []107 if active in (0, 1):108 sql += " AND active=?"; args.append(active)109 if active == 1: # quarantaine qualité : jamais dans les résultats publics110 sql += " AND published=1"111 if city:112 sql += " AND city=?"; args.append(city)113 if sector:114 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")115 if unit_type:116 sql += " AND unit_type=?"; args.append(unit_type)117 if source:118 sql += " AND source=?"; args.append(source)119 if price_max is not None:120 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)121 if price_min is not None:122 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)123 if pets == "oui":124 sql += " AND pets IN ('oui','conditions')"125 elif pets:126 sql += " AND pets=?"; args.append(pets)127 if furnished in (0, 1):128 sql += " AND furnished=?"; args.append(furnished)129 if available_by:130 # « now » trié après les dates ISO : comparaison explicite131 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \132 " AND availability_date<=?))"133 args.append(available_by)134 if area_min is not None:135 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)136 if q:137 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"138 args += [f"%{q}%"] * 3139 if deal in ("sous", "marche", "sur"):140 sql += " AND fv.verdict=?"; args.append(deal)141 if kascore_min is not None:142 sql += " AND ks.global IS NOT NULL AND ks.global>=?"143 args.append(kascore_min)144 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]145 if sort == "deal": # meilleures affaires d'abord (écart le plus négatif)146 sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"147 " price IS NULL, price ASC LIMIT ? OFFSET ?")148 elif sort == "ka": # meilleurs emplacements d'abord (KA Score global)149 sql += (" ORDER BY ks.global IS NULL, ks.global DESC,"150 " price IS NULL, price ASC LIMIT ? OFFSET ?")151 elif sort == "prix":152 sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"153 else: # défaut : les plus récentes d'abord154 sql += " ORDER BY first_seen DESC, price IS NULL, price ASC LIMIT ? OFFSET ?"155 args += [limit, offset]156 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]157 con.close()158 return {"total": total, "count": len(rows), "listings": rows}159160161@app.get("/api/listings.geojson")162def listings_geojson(163 city: str | None = None,164 sector: str | None = None,165 unit_type: str | None = None,166 source: str | None = None,167 price_max: float | None = None,168 price_min: float | None = None,169 pets: str | None = None,170 furnished: int | None = None,171 available_by: str | None = None,172 area_min: float | None = None,173 q: str | None = None,174 bbox: str | None = None,175 limit: int = Query(3000, le=8000),176):177 """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).178179 Léger par conception : seuls les champs nécessaires aux marqueurs et aux180 fiches de la carte sont inclus (1 photo, pas de description).181182 Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible183 (index idx_listings_geo) ; `limit` plafonne la réponse. La collection184 porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées185 correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées),186 pour afficher « N résultats sans position sur la carte ».187 """188 con = db.connect()189 sql = ("SELECT uid, title, address, price, price_label, unit_type,"190 " availability_date, source, city, sector, area_sqft, images,"191 " images_ok, lat, lng, fv.verdict AS fv_verdict,"192 " fv.deviation AS fv_deviation"193 " FROM listings LEFT JOIN fairvalue fv USING (uid)"194 " WHERE active=1 AND published=1 AND dup_of IS NULL"195 " AND lat IS NOT NULL AND lng IS NOT NULL")196 args: list = []197 if bbox:198 try:199 west, south, east, north = (float(v) for v in bbox.split(","))200 except ValueError:201 raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")202 sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"203 args += [south, north, west, east]204 if city:205 sql += " AND city=?"; args.append(city)206 if sector:207 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")208 if unit_type:209 sql += " AND unit_type=?"; args.append(unit_type)210 if source:211 sql += " AND source=?"; args.append(source)212 if price_max is not None:213 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)214 if price_min is not None:215 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)216 if pets == "oui":217 sql += " AND pets IN ('oui','conditions')"218 elif pets:219 sql += " AND pets=?"; args.append(pets)220 if furnished in (0, 1):221 sql += " AND furnished=?"; args.append(furnished)222 if available_by:223 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \224 " AND availability_date<=?))"225 args.append(available_by)226 if area_min is not None:227 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)228 if q:229 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"230 args += [f"%{q}%"] * 3231232 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]233 # Même requête sans l'exigence de coordonnées ni le bbox : mesure les234 # annonces filtrées invisibles sur la carte (affordance « sans position »).235 sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")236 args_all = list(args)237 if bbox:238 sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")239 del args_all[0:4]240 total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]241242 sql += " ORDER BY first_seen DESC LIMIT ?"243 args.append(limit)244245 features = []246 for r in con.execute(sql, args).fetchall():247 # galerie nettoyée si auditée (même vide : les images brutes sont248 # alors toutes mortes/placeholder — le frontend affiche le visuel249 # de secours Lou-Ka plutôt qu'une image cassée)250 images = json.loads((r["images_ok"] if r["images_ok"] is not None251 else r["images"]) or "[]")252 features.append({253 "type": "Feature",254 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},255 "properties": {256 "uid": r["uid"], "title": r["title"], "address": r["address"],257 "price": r["price"],258 "price_label": r["price_label"], "unit_type": r["unit_type"],259 "availability_date": r["availability_date"],260 "source": r["source"], "city": r["city"], "sector": r["sector"],261 "area_sqft": r["area_sqft"],262 "fv_verdict": r["fv_verdict"], "fv_deviation": r["fv_deviation"],263 "image": images[0] if images else None,264 },265 })266 con.close()267 return {"type": "FeatureCollection", "features": features,268 "totalGeocoded": total_geo, "totalMatching": total_all}269270271# --- Recherche unifiée liste + carte -----------------------------------------272# Une seule vérité partagée : la même requête sert la page de liste ET les273# points de la carte, dans la même réponse. Le compteur affiché en tête de274# liste est par construction égal au nombre de points envoyés à la carte.275276_SEARCH_SORTS = {277 "prix": " ORDER BY price IS NULL, price ASC",278 "prix_desc": " ORDER BY price IS NULL, price DESC",279 "recent": " ORDER BY first_seen DESC, price IS NULL, price ASC",280 "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"281 " price IS NULL, price ASC"),282 "ka": (" ORDER BY ks.global IS NULL, ks.global DESC,"283 " price IS NULL, price ASC"),284}285286# colonnes KA Scores jointes aux annonces (pastilles + tri + fiche)287_KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit,"288 " ks.bike AS ks_bike, ks.calme AS ks_calme,"289 " ks.services AS ks_services, ks.global AS ks_global")290_KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key"291_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}292293294def _parse_poly(poly: str) -> list[tuple[float, float]]:295 """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400."""296 try:297 pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p]298 except ValueError:299 raise HTTPException(400, "poly attendu : lng,lat;lng,lat;…")300 if len(pts) < 3 or any(len(p) != 2 for p in pts):301 raise HTTPException(400, "poly : au moins 3 sommets lng,lat")302 return pts # type: ignore[return-value]303304305def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool:306 """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon)."""307 inside = False308 j = len(ring) - 1309 for i in range(len(ring)):310 xi, yi = ring[i]311 xj, yj = ring[j]312 if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi:313 inside = not inside314 j = i315 return inside316317318@app.get("/api/search")319def search_unified(320 city: str | None = None,321 sector: str | None = None,322 unit_type: str | None = None,323 source: str | None = None,324 price_max: float | None = None,325 price_min: float | None = None,326 pets: str | None = None,327 furnished: int | None = None,328 available_by: str | None = None,329 area_min: float | None = None,330 q: str | None = None,331 deal: str | None = None, # sous | marche | sur (juste valeur)332 bbox: str | None = None, # ouest,sud,est,nord (zone visible carte)333 poly: str | None = None, # lng,lat;… (zone dessinée)334 kascore_min: float | None = None, # KA Score global minimal (0-100)335 sort: str = "recent", # recent (défaut) | prix | prix_desc | deal | ka336 page: int = 1,337 page_size: int = Query(20, ge=1, le=100),338 include: str = "tout", # tout | liste (points inchangés côté client)339):340 """Recherche synchronisée liste ↔ carte (une réponse = les deux vues).341342 Retourne `total` (compteur partagé), `listings` (la page demandée) et343 `points` (TOUS les points correspondants, format compact344 [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la345 position d'un point dans `points` donne directement sa page de liste.346 Seules les annonces géolocalisées participent (la règle d'or : chaque347 annonce de la liste est visible sur la carte) ; `unpositioned` compte348 les annonces filtrées sans coordonnées, pour l'afficher honnêtement.349 """350 if sort not in _SEARCH_SORTS:351 raise HTTPException(400, f"sort inconnu : {sort}")352 ring = _parse_poly(poly) if poly else None353354 con = db.connect()355 sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +356 " WHERE dup_of IS NULL AND active=1 AND published=1"357 " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL")358 args: list = []359 if bbox:360 try:361 west, south, east, north = (float(v) for v in bbox.split(","))362 except ValueError:363 raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")364 sql += (" AND listings.lat BETWEEN ? AND ?"365 " AND listings.lng BETWEEN ? AND ?")366 args += [south, north, west, east]367 if ring:368 # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval369 lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]370 sql += (" AND listings.lat BETWEEN ? AND ?"371 " AND listings.lng BETWEEN ? AND ?")372 args += [min(lats), max(lats), min(lngs), max(lngs)]373 if city:374 sql += " AND city=?"; args.append(city)375 if sector:376 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")377 if unit_type:378 sql += " AND unit_type=?"; args.append(unit_type)379 if source:380 sql += " AND source=?"; args.append(source)381 if price_max is not None:382 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)383 if price_min is not None:384 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)385 if pets == "oui":386 sql += " AND pets IN ('oui','conditions')"387 elif pets:388 sql += " AND pets=?"; args.append(pets)389 if furnished in (0, 1):390 sql += " AND furnished=?"; args.append(furnished)391 if available_by:392 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \393 " AND availability_date<=?))"394 args.append(available_by)395 if area_min is not None:396 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)397 if q:398 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"399 args += [f"%{q}%"] * 3400 if deal in ("sous", "marche", "sur"):401 sql += " AND fv.verdict=?"; args.append(deal)402 if kascore_min is not None:403 sql += " AND ks.global IS NOT NULL AND ks.global>=?"404 args.append(kascore_min)405406 rows = con.execute(407 "SELECT uid, listings.lng AS lng, listings.lat AS lat, price,"408 " fv.verdict AS v" + sql + _SEARCH_SORTS[sort],409 args).fetchall()410 if ring:411 rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]412 total = len(rows)413414 # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce415 # tableau détermine la page (points et liste partagent le même tri)416 points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"],417 _FV_CODES.get(r["v"] or "", None)] for r in rows]418419 # page demandée, ramenée dans les bornes si les filtres l'ont dépassée420 last = max(1, -(-total // page_size))421 page = min(max(1, page), last)422 page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]]423424 listings: list[dict] = []425 if page_uids:426 marks = ",".join("?" * len(page_uids))427 by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(428 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"429 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"430 " fv.confidence AS fv_confidence," + _KS_COLS +431 " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +432 f" WHERE uid IN ({marks})", page_uids).fetchall()}433 listings = [by_uid[u] for u in page_uids if u in by_uid]434435 # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)436 sql_nogeo = sql.replace(437 " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL",438 " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1)439 args_nogeo = list(args)440 for spatial in (bbox, poly):441 if spatial:442 sql_nogeo = sql_nogeo.replace(443 " AND listings.lat BETWEEN ? AND ?"444 " AND listings.lng BETWEEN ? AND ?", "", 1)445 del args_nogeo[0:4]446 unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,447 args_nogeo).fetchone()["c"]448 con.close()449450 return {451 "total": total, "page": page, "page_size": page_size, "sort": sort,452 "listings": listings,453 "points": points if include != "liste" else None,454 "unpositioned": unpositioned,455 }456457458# --- Miniatures optimisées (WebP, taille écran) ------------------------------459# Sert les images des annonces redimensionnées et compressées (perf mobile) :460# /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle461# qualité (table image_checks) sont proxifiées — pas de proxy ouvert. Si462# l'URL n'est pas (encore) connue : 404, le frontend retombe sur l'original.463_IMG_WIDTHS = (160, 480, 800, 1280)464_IMG_CACHE = ROOT / "data" / "imgcache"465466467@app.get("/api/img")468def img_thumbnail(u: str, w: int = 480):469 import hashlib470 import requests as _rq471 from fastapi.responses import Response472 w = min(_IMG_WIDTHS, key=lambda x: abs(x - w))473 if not u.startswith(("http://", "https://")):474 raise HTTPException(400, "URL invalide")475 key = hashlib.sha1(u.encode()).hexdigest()476 _IMG_CACHE.mkdir(parents=True, exist_ok=True)477 path = _IMG_CACHE / f"{key}_{w}.webp"478 headers = {"Cache-Control": "public, max-age=2592000, immutable"}479 if path.exists():480 return Response(path.read_bytes(), media_type="image/webp",481 headers=headers)482 con = db.connect()483 known = con.execute("SELECT status FROM image_checks WHERE url=?",484 (u,)).fetchone()485 con.close()486 if known is None or known["status"] not in ("ok",):487 raise HTTPException(404, "image inconnue du contrôle qualité")488 try:489 resp = _rq.get(u, timeout=12, headers={"User-Agent": "LouKaImg/1.0"})490 resp.raise_for_status()491 from io import BytesIO492 from PIL import Image, ImageOps493 img = Image.open(BytesIO(resp.content))494 img = ImageOps.exif_transpose(img)495 if img.mode not in ("RGB", "L"):496 img = img.convert("RGB")497 if img.width > w:498 img = img.resize((w, max(1, round(img.height * w / img.width))),499 Image.LANCZOS)500 buf = BytesIO()501 img.save(buf, "WEBP", quality=80, method=4)502 data = buf.getvalue()503 path.write_bytes(data)504 return Response(data, media_type="image/webp", headers=headers)505 except HTTPException:506 raise507 except Exception:508 raise HTTPException(404, "image irrécupérable")509510511@app.get("/api/listings/{uid}/pdf")512def listing_pdf(uid: str):513 """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""514 from fastapi.responses import Response515 from . import pdfgen516 data = pdfgen.fiche_pdf(uid)517 if data is None:518 raise HTTPException(404, "Annonce introuvable")519 nom = uid.replace(":", "-")520 return Response(content=data, media_type="application/pdf", headers={521 "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'})522523524@app.get("/api/stats/rapport.pdf")525def rapport_marche_pdf():526 """Rapport global du marché locatif (PDF multi-pages)."""527 from fastapi.responses import Response528 from . import pdfgen529 return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",530 headers={"Content-Disposition":531 'attachment; filename="louka-rapport-marche.pdf"'})532533534@app.get("/api/fairvalue/{uid}")535def fairvalue_detail(uid: str):536 """Analyse de prix Lou-Ka : juste valeur, fourchette, confiance,537 distribution du segment (voir louka/fairvalue.py)."""538 from . import fairvalue539 d = fairvalue.explain(uid)540 if d is None:541 raise HTTPException(404, "Pas d'estimation pour cette annonce")542 return d543544545@app.get("/api/hydro")546def hydro_at(adresse: str, solve: int = 0):547 """Estimation du coût d'électricité à une adresse (Hydro-Québec).548 Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul."""549 from . import hydro550 return hydro.estimate(adresse=adresse, solve=bool(solve))551552553@app.get("/api/commerces")554def commerces_at(lat: float, lng: float):555 """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""556 from . import commerces557 return commerces.nearby(lat, lng)558559560@app.get("/api/air")561def air_at(lat: float, lng: float):562 """Qualité de l'air : station RSQAQ la plus proche (MELCCFP)."""563 from . import air564 d = air.lookup(lat, lng)565 if d is None:566 raise HTTPException(404, "Base qualité de l'air non disponible")567 return d568569570@app.get("/api/gaz")571def gaz_at(lat: float, lng: float):572 """Stations-service à proximité et prix courants (gazquebec.ca)."""573 from . import gaz574 return gaz.nearby(lat, lng)575576577@app.get("/api/inondation")578def inondation_at(lat: float, lng: float):579 """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —580 bloc « Risque d'inondation » de la fiche."""581 from . import inondation582 d = inondation.lookup(lat, lng)583 if d is None:584 raise HTTPException(404, "Base des zones inondables non disponible")585 return d586587588@app.get("/api/rdl")589def rdl_nearby(lat: float, lng: float, radius: int = 600):590 """Loyers déclarés au Registre des loyers (registre-des-loyers.ca)591 autour d'un point — bloc « Registre des loyers » de la fiche."""592 from . import rdl593 radius = max(100, min(radius, 2000))594 d = rdl.nearby(lat, lng, radius)595 if d is None:596 raise HTTPException(404, "Registre des loyers non disponible")597 return d598599600@app.get("/api/listings/{uid}")601def get_listing(uid: str):602 con = db.connect()603 row = con.execute(604 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"605 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"606 " fv.confidence AS fv_confidence"607 " FROM listings LEFT JOIN fairvalue fv USING (uid)"608 " WHERE uid=?", (uid,)).fetchone()609 d = None610 if row is not None:611 d = _row_to_dict(row)612 # commodités de proximité (cache par immeuble, voir louka/poi.py)613 if d.get("lat") is not None and d.get("lng") is not None:614 key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"615 poi_row = con.execute(616 "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()617 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []618 else:619 d["poi"] = []620 # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)621 if d.get("coord_key"):622 ks = con.execute(623 "SELECT walk, transit, bike, calme, services, global AS glob,"624 " details, version, computed_at FROM kascores WHERE coord_key=?",625 (d["coord_key"],)).fetchone()626 if ks is not None:627 d["kascores"] = {628 "walk": ks["walk"], "transit": ks["transit"],629 "bike": ks["bike"], "calme": ks["calme"],630 "services": ks["services"], "global": ks["glob"],631 "details": json.loads(ks["details"] or "{}"),632 "version": ks["version"], "computed_at": ks["computed_at"],633 }634 # statistiques de quartier (recensement, proximité, chaleur, criminalité)635 from . import quartier636 dauid = d.get("dauid")637 d["quartier"] = quartier.fiche_quartier(638 d.get("lat"), d.get("lng"), d.get("city") or "",639 dauid if dauid and dauid != "hors-zone" else None)640 # description structurée + historique de prix641 d["digest"] = json.loads(d["digest"]) if d.get("digest") else None642 d["price_history"] = [dict(r) for r in con.execute(643 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",644 (uid,)).fetchall()]645 con.close()646 if d is None:647 raise HTTPException(404, "Annonce introuvable")648 return d649650651@app.get("/api/kascores/stats")652def kascores_stats():653 """Couverture, moyennes et version du barème — page « KA Scores »."""654 from . import kascores655 return kascores.stats()656657658@app.get("/api/facets")659def facets(city: str | None = None):660 """Valeurs distinctes pour construire les filtres du frontend.661662 `city` (optionnel) restreint la liste des quartiers à cette ville —663 utilisé par le sélecteur « Quartier » dépendant de « Ville ».664 """665 con = db.connect()666 sector_sql = ("SELECT DISTINCT sector FROM listings"667 " WHERE active=1 AND published=1 AND dup_of IS NULL"668 " AND sector<>''")669 sector_args: list = []670 if city:671 sector_sql += " AND city=?"672 sector_args.append(city)673 out = {674 "cities": [r["city"] for r in con.execute(675 "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1"676 " AND dup_of IS NULL AND city<>'' ORDER BY city")],677 "sectors": [r["sector"] for r in con.execute(678 sector_sql + " ORDER BY sector", sector_args)],679 "unit_types": [r["unit_type"] for r in con.execute(680 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1"681 " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],682 "sources": [dict(r) for r in con.execute(683 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"684 " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],685 }686 con.close()687 return out688689690@app.get("/api/sources")691def sources():692 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]693 con = db.connect()694 counts = {r["source"]: r["n"] for r in con.execute(695 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"696 " GROUP BY source")}697 last = {r["source"]: r["ts"] for r in con.execute(698 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}699 con.close()700 for s in registry:701 s["active_listings"] = counts.get(s["id"], 0)702 s["last_sync"] = last.get(s["id"])703 return {"sources": registry}704705706# Villes de la grande région de Montréal (le reste de la province est707# compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…)708_VILLES_GM = (709 "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne",710 "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount",711 "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou",712 "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly",713 "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire",714 "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand",715 "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion",716 "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont",717)718719720@app.get("/api/stats")721def stats(syncs_since_h: float | None = None):722 """Stats globales + journal de synchronisation.723724 `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le725 moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne726 couvrent que ~11 min d'activité sur ~200 sources (fausses alertes727 « stale »). Avec le paramètre : toutes les entrées des N dernières heures728 (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières729 toutes époques confondues. Sans le paramètre : comportement historique730 inchangé (LIMIT 20).731 """732 con = db.connect()733 gm = ",".join("?" * len(_VILLES_GM))734 row = con.execute(735 f"""SELECT COUNT(*) total,736 SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,737 SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,738 SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal,739 SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm})740 THEN 1 ELSE 0 END) autres,741 COUNT(DISTINCT source) sources,742 AVG(price) avg_price743 FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL""",744 list(_VILLES_GM) + list(_VILLES_GM)).fetchone()745 log: list[dict] = []746 if syncs_since_h is not None:747 import time as _time748 cutoff = _time.time() - syncs_since_h * 3600749 log = [dict(r) for r in con.execute(750 "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",751 (cutoff,))]752 if syncs_since_h is None or len(log) < 20:753 log = [dict(r) for r in con.execute(754 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]755 # qualité des données : complétude moyenne + quarantaine (quality.py)756 qual = con.execute(757 """SELECT ROUND(AVG(completeness),1) avg_completeness,758 SUM(published=1) publiees, SUM(published=0) quarantaine759 FROM listings WHERE active=1"""760 ).fetchone()761 con.close()762 return {**dict(row), "quality": dict(qual), "recent_syncs": log}763764765@app.get("/api/stats/detailed")766def stats_detailed():767 """Agrégats du marché (source unique : louka/marketstats.py)."""768 from . import marketstats769 return marketstats.compute()770771772# --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) ---773_PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}774775776@app.get("/api/stats/dashboard")777def stats_dashboard(period: str = "30j",778 from_: str | None = Query(None, alias="from"),779 to: str | None = None):780 """Tableau de bord analytique (KPI, séries, répartitions, tableaux…)."""781 from . import statsdash782 if period not in _PERIODES_VALIDES and not (from_ and to):783 period = "30j"784 return statsdash.compute(period, from_, to)785786787@app.get("/api/stats/report")788def stats_report(period: str = "30j",789 from_: str | None = Query(None, alias="from"),790 to: str | None = None,791 mode: str = "complet"):792 """Rapport statistique PDF estampillé Groupe-KA — 5 modes :793 complet | synthese | tendances | repartitions | donnees (inconnu → complet)."""794 from fastapi.responses import Response795 from . import kapdf, statsdash796 if period not in _PERIODES_VALIDES:797 period = "perso" if (from_ and to) else "30j"798 if mode not in kapdf.REPORT_MODES:799 mode = "complet"800 dash = statsdash.compute(period if period != "perso" else "30j",801 from_, to)802 eco = json.loads((ROOT / "frontend" / "src" / "ka" /803 "ecosystem.json").read_text(encoding="utf-8"))804 site = next(s for s in eco["sites"] if s["id"] == "lou-ka")805 data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()806 fname = kapdf.filename("lou-ka", period, mode)807 return Response(content=data, media_type="application/pdf",808 headers={"Content-Disposition":809 f'attachment; filename="{fname}"'})810811812813@app.get("/api/stats/catalog")814def stats_catalog(period: str = "30j",815 from_: str | None = Query(None, alias="from"),816 to: str | None = None):817 """v3 — blocs composables pour le constructeur de rapports personnalisés."""818 from . import kapdf, statsdash819 if period not in _PERIODES_VALIDES and not (from_ and to):820 period = "30j"821 dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j",822 from_, to)823 return {"updated": dash.get("updated"), "period": dash.get("period"),824 "blocks": kapdf.catalog(dash)}825826827@app.post("/api/stats/report/custom")828def stats_report_custom(spec: dict = Body(...)):829 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",830 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""831 from fastapi.responses import Response832 from . import kapdf, statsdash833 period = str(spec.get("period") or "30j")834 from_ = str(spec.get("from") or "") or None835 to = str(spec.get("to") or "") or None836 if period not in _PERIODES_VALIDES:837 period = "perso" if (from_ and to) else "30j"838 dash = statsdash.compute(period if period != "perso" else "30j",839 from_, to)840 known = {b["key"] for b in kapdf.catalog(dash)}841 blocks = [b for b in (spec.get("blocks") or [])842 if isinstance(b, dict) and b.get("key") in known][:40]843 if not blocks:844 raise HTTPException(400, "Aucun bloc valide dans la composition")845 eco = json.loads((ROOT / "frontend" / "src" / "ka" /846 "ecosystem.json").read_text(encoding="utf-8"))847 site = next(s for s in eco["sites"] if s["id"] == "lou-ka")848 data = kapdf.GroupeKAReport(849 site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,850 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},851 ).build()852 fname = kapdf.filename("lou-ka", period, kapdf.CUSTOM_MODE)853 return Response(content=data, media_type="application/pdf",854 headers={"Content-Disposition":855 f'attachment; filename="{fname}"'})856857858@app.post("/api/sync")859def trigger_sync(background: BackgroundTasks, source: str | None = None):860 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""861 def _job():862 with _sync_lock:863 ingest.run([source] if source else None)864 background.add_task(_job)865 return {"status": "démarré", "source": source or "toutes"}866867868# --- Frontend React (build Vite) --------------------------------------------869if FRONTEND_DIST.exists():870 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")871872 # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.873 # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.874 from . import seo # noqa: E402875 app.include_router(seo.router)876877 @app.middleware("http")878 async def _cache_headers(request, call_next):879 """Politique de cache : les bundles hachés (/assets/…) sont immuables,880 mais index.html doit TOUJOURS être revalidé — sinon les navigateurs881 gardent une vieille version de l'app après un déploiement."""882 resp = await call_next(request)883 path = request.url.path884 if path.startswith("/assets/"):885 resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"886 elif "text/html" in (resp.headers.get("content-type") or ""):887 resp.headers["Cache-Control"] = "no-cache"888 return resp889890 # routes de l'app rendues uniquement côté client (privées ou volatiles) —891 # tout autre chemin inconnu renvoie index.html avec un statut 404 : le892 # routeur React affiche sa page « introuvable », les moteurs de recherche893 # voient un vrai 404 (fin des soft-404).894 _CLIENT_ROUTES = {"profil", "favoris", "gestion", "bienvenue", "bot",895 "contact", "juste-valeur", "court-terme"}896 _CLIENT_PREFIXES = ("u/", "passerelle/", "court-terme/")897898 @app.get("/{full_path:path}")899 def spa(full_path: str):900 target = FRONTEND_DIST / full_path901 if full_path and target.is_file():902 return FileResponse(target)903 known = (full_path in _CLIENT_ROUTES904 or any(full_path == p.rstrip("/") or full_path.startswith(p)905 for p in _CLIENT_PREFIXES))906 return FileResponse(FRONTEND_DIST / "index.html",907 status_code=200 if known else 404,908 headers={"Cache-Control": "no-cache"})909