Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# web.py : FastAPI JSON API + React frontend serving (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="Rent-Ka API", version="1.0",25 description="Rental listings aggregator — Canada (outside 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 rentka/auth.py33app.include_router(auth.router)3435# personnalisation KA ID v2 (rentka/kaid.py, canonique ka-ui.git/kaid)36kaid.init("rent-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 rentka/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) — rentka/profile.py76from . import profile as user_profile # noqa: E40277app.include_router(user_profile.router)7879# fichiers téléversés (photos de profil) — data/uploads/80_UPLOADS = ROOT / "data" / "uploads"81_UPLOADS.mkdir(parents=True, exist_ok=True)82app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads")838485def _row_to_dict(row) -> dict:86 d = dict(row)87 d["amenities"] = json.loads(d.get("amenities") or "[]")88 d["images"] = json.loads(d.get("images") or "[]")89 # galerie nettoyée par le contrôle qualité (imgcheck.py) : liens morts,90 # minuscules, placeholders et doublons retirés — prime sur la galerie brute91 imgs_ok = d.pop("images_ok", None)92 if imgs_ok:93 try:94 cleaned = json.loads(imgs_ok)95 if isinstance(cleaned, list):96 d["images"] = cleaned97 except (ValueError, TypeError):98 pass99 d.pop("img_audit", None)100 d["details"] = json.loads(d.get("details") or "{}")101 # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… »)102 if d.get("dup_sources"):103 try:104 d["dup_sources"] = json.loads(d["dup_sources"])105 except (ValueError, TypeError):106 d["dup_sources"] = []107 if d.get("furnished") is not None:108 d["furnished"] = bool(d["furnished"])109 return d110111112@app.get("/api/listings")113def list_listings(114 request: Request,115 province: str | None = None,116 city: str | None = None,117 sector: str | None = None,118 unit_type: str | None = None,119 source: str | None = None,120 price_max: float | None = None,121 price_min: float | None = None,122 pets: str | None = None, # "oui" -> oui OU conditions123 furnished: int | None = None, # 1 / 0124 available_by: str | None = None, # ISO : dispo maintenant ou avant date125 area_min: float | None = None, # superficie minimale (pi²)126 q: str | None = None,127 deal: str | None = None, # sous | marche | sur (fair value)128 kascore_min: float | None = None, # KA Score global minimal129 sort: str | None = None, # "deal" affaires | "ka" KA Score130 active: int = 1,131 limit: int = Query(500, le=2000),132 offset: int = 0,133):134 con = db.connect()135 sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"136 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"137 " fv.confidence AS fv_confidence," + _KS_COLS +138 " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +139 " WHERE dup_of IS NULL") # doublons masqués140 args: list = []141 if active in (0, 1):142 sql += " AND active=?"; args.append(active)143 if active == 1: # quality quarantine: never in public results144 sql += " AND published=1"145 if province:146 sql += " AND province=?"; args.append(province.upper())147 if city:148 sql += " AND city=?"; args.append(city)149 if sector:150 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")151 if unit_type:152 sql += " AND unit_type=?"; args.append(unit_type)153 if source:154 sql += " AND source=?"; args.append(source)155 if price_max is not None:156 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)157 if price_min is not None:158 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)159 if pets in ("yes", "oui"):160 sql += " AND pets IN ('yes','oui','conditions')"161 elif pets in ("no", "non"):162 sql += " AND pets IN ('no','non')"163 elif pets:164 sql += " AND pets=?"; args.append(pets)165 if furnished in (0, 1):166 sql += " AND furnished=?"; args.append(furnished)167 if available_by:168 # « now » trié après les dates ISO : comparaison explicite169 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \170 " AND availability_date<=?))"171 args.append(available_by)172 if area_min is not None:173 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)174 if q:175 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"176 args += [f"%{q}%"] * 3177 if deal in ("sous", "marche", "sur"):178 sql += " AND fv.verdict=?"; args.append(deal)179 if kascore_min is not None:180 sql += " AND ks.global IS NOT NULL AND ks.global>=?"181 args.append(kascore_min)182 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]183 if sort == "deal": # meilleures affaires d'abord (écart le plus négatif)184 sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"185 " price IS NULL, price ASC LIMIT ? OFFSET ?")186 elif sort == "ka": # meilleurs emplacements d'abord (KA Score global)187 sql += (" ORDER BY ks.global IS NULL, ks.global DESC,"188 " price IS NULL, price ASC LIMIT ? OFFSET ?")189 elif sort in ("prix", "price"):190 sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"191 else: # défaut : les plus récentes d'abord192 sql += " ORDER BY first_seen DESC, price IS NULL, price ASC LIMIT ? OFFSET ?"193 args += [limit, offset]194 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]195 con.close()196 # personnalisation KA ID : journal + reclassement (tri par défaut seulement,197 # jamais un tri explicite ; intention de session > préférences long terme)198 personalized = False199 user = _kaid_user(request)200 if user:201 filters = {k: v for k, v in {202 "city": city, "sector": sector, "unit_type": unit_type,203 "price_max": price_max, "price_min": price_min, "pets": pets,204 "furnished": furnished, "deal": deal, "q": q}.items()205 if v not in (None, "")}206 if filters and offset == 0:207 kaid.track(user, "search", query=q, filters=filters)208 if sort in (None, "", "recent"):209 active = {k for k in ("city", "sector", "unit_type", "pets", "deal")210 if filters.get(k)}211 if price_max is not None or price_min is not None:212 active.add("price")213 if furnished in (0, 1):214 active.add("furnished")215 rows, personalized = kaid.rerank(216 rows, user, features_of=_kaid_features, active_dims=active)217 return {"total": total, "count": len(rows), "listings": rows,218 "personalized": personalized}219220221@app.get("/api/listings.geojson")222def listings_geojson(223 province: str | None = None,224 city: str | None = None,225 sector: str | None = None,226 unit_type: str | None = None,227 source: str | None = None,228 price_max: float | None = None,229 price_min: float | None = None,230 pets: str | None = None,231 furnished: int | None = None,232 available_by: str | None = None,233 area_min: float | None = None,234 q: str | None = None,235 bbox: str | None = None,236 limit: int = Query(3000, le=8000),237):238 """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).239240 Léger par conception : seuls les champs nécessaires aux marqueurs et aux241 fiches de la carte sont inclus (1 photo, pas de description).242243 Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible244 (index idx_listings_geo) ; `limit` plafonne la réponse. La collection245 porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées246 correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées),247 pour afficher « N résultats sans position sur la carte ».248 """249 con = db.connect()250 sql = ("SELECT uid, title, address, price, price_label, unit_type,"251 " availability_date, source, city, sector, province, area_sqft,"252 " images, images_ok, lat, lng, fv.verdict AS fv_verdict,"253 " fv.deviation AS fv_deviation"254 " FROM listings LEFT JOIN fairvalue fv USING (uid)"255 " WHERE active=1 AND published=1 AND dup_of IS NULL"256 " AND lat IS NOT NULL AND lng IS NOT NULL")257 args: list = []258 if bbox:259 try:260 west, south, east, north = (float(v) for v in bbox.split(","))261 except ValueError:262 raise HTTPException(400, "expected bbox: west,south,east,north")263 sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"264 args += [south, north, west, east]265 if province:266 sql += " AND province=?"; args.append(province.upper())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 in ("yes", "oui"):280 sql += " AND pets IN ('yes','oui','conditions')"281 elif pets in ("no", "non"):282 sql += " AND pets IN ('no','non')"283 elif pets:284 sql += " AND pets=?"; args.append(pets)285 if furnished in (0, 1):286 sql += " AND furnished=?"; args.append(furnished)287 if available_by:288 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \289 " AND availability_date<=?))"290 args.append(available_by)291 if area_min is not None:292 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)293 if q:294 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"295 args += [f"%{q}%"] * 3296297 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]298 # Même requête sans l'exigence de coordonnées ni le bbox : mesure les299 # annonces filtrées invisibles sur la carte (affordance « sans position »).300 sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")301 args_all = list(args)302 if bbox:303 sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")304 del args_all[0:4]305 total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]306307 sql += " ORDER BY first_seen DESC LIMIT ?"308 args.append(limit)309310 features = []311 for r in con.execute(sql, args).fetchall():312 # galerie nettoyée si auditée (même vide : les images brutes sont313 # alors toutes mortes/placeholder — le frontend affiche le visuel314 # de secours Rent-Ka plutôt qu'une image cassée)315 images = json.loads((r["images_ok"] if r["images_ok"] is not None316 else r["images"]) or "[]")317 features.append({318 "type": "Feature",319 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},320 "properties": {321 "uid": r["uid"], "title": r["title"], "address": r["address"],322 "price": r["price"],323 "price_label": r["price_label"], "unit_type": r["unit_type"],324 "availability_date": r["availability_date"],325 "source": r["source"], "city": r["city"], "sector": r["sector"],326 "province": r["province"],327 "area_sqft": r["area_sqft"],328 "fv_verdict": r["fv_verdict"], "fv_deviation": r["fv_deviation"],329 "image": images[0] if images else None,330 },331 })332 con.close()333 return {"type": "FeatureCollection", "features": features,334 "totalGeocoded": total_geo, "totalMatching": total_all}335336337# --- Recherche unifiée liste + carte -----------------------------------------338# Une seule vérité partagée : la même requête sert la page de liste ET les339# points de la carte, dans la même réponse. Le compteur affiché en tête de340# liste est par construction égal au nombre de points envoyés à la carte.341342_SEARCH_SORTS = {343 "prix": " ORDER BY price IS NULL, price ASC",344 "prix_desc": " ORDER BY price IS NULL, price DESC",345 "price": " ORDER BY price IS NULL, price ASC",346 "price_desc": " ORDER BY price IS NULL, price DESC",347 "recent": " ORDER BY first_seen DESC, price IS NULL, price ASC",348 "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"349 " price IS NULL, price ASC"),350 "ka": (" ORDER BY ks.global IS NULL, ks.global DESC,"351 " price IS NULL, price ASC"),352}353354# colonnes KA Scores jointes aux annonces (pastilles + tri + fiche)355_KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit,"356 " ks.bike AS ks_bike, ks.calme AS ks_calme,"357 " ks.services AS ks_services, ks.global AS ks_global")358_KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key"359_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}360361362def _parse_poly(poly: str) -> list[tuple[float, float]]:363 """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400."""364 try:365 pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p]366 except ValueError:367 raise HTTPException(400, "expected poly: lng,lat;lng,lat;…")368 if len(pts) < 3 or any(len(p) != 2 for p in pts):369 raise HTTPException(400, "poly: at least 3 lng,lat vertices")370 return pts # type: ignore[return-value]371372373def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool:374 """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon)."""375 inside = False376 j = len(ring) - 1377 for i in range(len(ring)):378 xi, yi = ring[i]379 xj, yj = ring[j]380 if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi:381 inside = not inside382 j = i383 return inside384385386@app.get("/api/search")387def search_unified(388 province: str | None = None,389 city: str | None = None,390 sector: str | None = None,391 unit_type: str | None = None,392 source: str | None = None,393 price_max: float | None = None,394 price_min: float | None = None,395 pets: str | None = None,396 furnished: int | None = None,397 available_by: str | None = None,398 area_min: float | None = None,399 q: str | None = None,400 deal: str | None = None, # sous | marche | sur (juste valeur)401 bbox: str | None = None, # ouest,sud,est,nord (zone visible carte)402 poly: str | None = None, # lng,lat;… (zone dessinée)403 kascore_min: float | None = None, # KA Score global minimal (0-100)404 sort: str = "recent", # recent (défaut) | prix | prix_desc | deal | ka405 page: int = 1,406 page_size: int = Query(20, ge=1, le=100),407 include: str = "tout", # tout | liste (points inchangés côté client)408):409 """Recherche synchronisée liste ↔ carte (une réponse = les deux vues).410411 Retourne `total` (compteur partagé), `listings` (la page demandée) et412 `points` (TOUS les points correspondants, format compact413 [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la414 position d'un point dans `points` donne directement sa page de liste.415 Seules les annonces géolocalisées participent (la règle d'or : chaque416 annonce de la liste est visible sur la carte) ; `unpositioned` compte417 les annonces filtrées sans coordonnées, pour l'afficher honnêtement.418 """419 if sort not in _SEARCH_SORTS:420 raise HTTPException(400, f"unknown sort: {sort}")421 ring = _parse_poly(poly) if poly else None422423 con = db.connect()424 sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +425 " WHERE dup_of IS NULL AND active=1 AND published=1"426 " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL")427 args: list = []428 if bbox:429 try:430 west, south, east, north = (float(v) for v in bbox.split(","))431 except ValueError:432 raise HTTPException(400, "expected bbox: west,south,east,north")433 sql += (" AND listings.lat BETWEEN ? AND ?"434 " AND listings.lng BETWEEN ? AND ?")435 args += [south, north, west, east]436 if ring:437 # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval438 lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]439 sql += (" AND listings.lat BETWEEN ? AND ?"440 " AND listings.lng BETWEEN ? AND ?")441 args += [min(lats), max(lats), min(lngs), max(lngs)]442 if province:443 sql += " AND province=?"; args.append(province.upper())444 if city:445 sql += " AND city=?"; args.append(city)446 if sector:447 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")448 if unit_type:449 sql += " AND unit_type=?"; args.append(unit_type)450 if source:451 sql += " AND source=?"; args.append(source)452 if price_max is not None:453 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)454 if price_min is not None:455 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)456 if pets in ("yes", "oui"):457 sql += " AND pets IN ('yes','oui','conditions')"458 elif pets in ("no", "non"):459 sql += " AND pets IN ('no','non')"460 elif pets:461 sql += " AND pets=?"; args.append(pets)462 if furnished in (0, 1):463 sql += " AND furnished=?"; args.append(furnished)464 if available_by:465 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \466 " AND availability_date<=?))"467 args.append(available_by)468 if area_min is not None:469 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)470 if q:471 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"472 args += [f"%{q}%"] * 3473 if deal in ("sous", "marche", "sur"):474 sql += " AND fv.verdict=?"; args.append(deal)475 if kascore_min is not None:476 sql += " AND ks.global IS NOT NULL AND ks.global>=?"477 args.append(kascore_min)478479 rows = con.execute(480 "SELECT uid, listings.lng AS lng, listings.lat AS lat, price,"481 " fv.verdict AS v" + sql + _SEARCH_SORTS[sort],482 args).fetchall()483 if ring:484 rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]485 total = len(rows)486487 # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce488 # tableau détermine la page (points et liste partagent le même tri)489 points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"],490 _FV_CODES.get(r["v"] or "", None)] for r in rows]491492 # page demandée, ramenée dans les bornes si les filtres l'ont dépassée493 last = max(1, -(-total // page_size))494 page = min(max(1, page), last)495 page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]]496497 listings: list[dict] = []498 if page_uids:499 marks = ",".join("?" * len(page_uids))500 by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(501 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"502 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"503 " fv.confidence AS fv_confidence," + _KS_COLS +504 " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +505 f" WHERE uid IN ({marks})", page_uids).fetchall()}506 listings = [by_uid[u] for u in page_uids if u in by_uid]507508 # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)509 sql_nogeo = sql.replace(510 " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL",511 " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1)512 args_nogeo = list(args)513 for spatial in (bbox, poly):514 if spatial:515 sql_nogeo = sql_nogeo.replace(516 " AND listings.lat BETWEEN ? AND ?"517 " AND listings.lng BETWEEN ? AND ?", "", 1)518 del args_nogeo[0:4]519 unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,520 args_nogeo).fetchone()["c"]521 con.close()522523 return {524 "total": total, "page": page, "page_size": page_size, "sort": sort,525 "listings": listings,526 "points": points if include != "liste" else None,527 "unpositioned": unpositioned,528 }529530531# --- Miniatures optimisées (WebP, taille écran) ------------------------------532# Sert les images des annonces redimensionnées et compressées (perf mobile) :533# /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle534# qualité (table image_checks) sont proxifiées — pas de proxy ouvert. Si535# l'URL n'est pas (encore) connue : 404, le frontend retombe sur l'original.536_IMG_WIDTHS = (160, 480, 800, 1280)537_IMG_CACHE = ROOT / "data" / "imgcache"538539540@app.get("/api/img")541def img_thumbnail(u: str, w: int = 480):542 import hashlib543 import requests as _rq544 from fastapi.responses import Response545 w = min(_IMG_WIDTHS, key=lambda x: abs(x - w))546 if not u.startswith(("http://", "https://")):547 raise HTTPException(400, "invalid URL")548 key = hashlib.sha1(u.encode()).hexdigest()549 _IMG_CACHE.mkdir(parents=True, exist_ok=True)550 path = _IMG_CACHE / f"{key}_{w}.webp"551 headers = {"Cache-Control": "public, max-age=2592000, immutable"}552 if path.exists():553 return Response(path.read_bytes(), media_type="image/webp",554 headers=headers)555 con = db.connect()556 known = con.execute("SELECT status FROM image_checks WHERE url=?",557 (u,)).fetchone()558 con.close()559 if known is None or known["status"] not in ("ok",):560 raise HTTPException(404, "image unknown to quality control")561 try:562 resp = _rq.get(u, timeout=12, headers={"User-Agent": "RentKaImg/1.0"})563 resp.raise_for_status()564 from io import BytesIO565 from PIL import Image, ImageOps566 img = Image.open(BytesIO(resp.content))567 img = ImageOps.exif_transpose(img)568 if img.mode not in ("RGB", "L"):569 img = img.convert("RGB")570 if img.width > w:571 img = img.resize((w, max(1, round(img.height * w / img.width))),572 Image.LANCZOS)573 buf = BytesIO()574 img.save(buf, "WEBP", quality=80, method=4)575 data = buf.getvalue()576 path.write_bytes(data)577 return Response(data, media_type="image/webp", headers=headers)578 except HTTPException:579 raise580 except Exception:581 raise HTTPException(404, "unretrievable image")582583584@app.get("/api/listings/{uid}/pdf")585def listing_pdf(uid: str):586 """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""587 from fastapi.responses import Response588 from . import pdfgen589 data = pdfgen.fiche_pdf(uid)590 if data is None:591 raise HTTPException(404, "Listing not found")592 nom = uid.replace(":", "-")593 return Response(content=data, media_type="application/pdf", headers={594 "Content-Disposition": f'attachment; filename="rentka-listing-{nom}.pdf"'})595596597@app.get("/api/stats/rapport.pdf")598def rapport_marche_pdf():599 """Rapport global du marché locatif (PDF multi-pages)."""600 from fastapi.responses import Response601 from . import pdfgen602 return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",603 headers={"Content-Disposition":604 'attachment; filename="rentka-market-report.pdf"'})605606607@app.get("/api/fairvalue/{uid}")608def fairvalue_detail(uid: str):609 """Analyse de prix Rent-Ka : juste valeur, fourchette, confiance,610 distribution du segment (voir rentka/fairvalue.py)."""611 from . import fairvalue612 d = fairvalue.explain(uid)613 if d is None:614 raise HTTPException(404, "No estimate for this listing")615 return d616617618@app.get("/api/listings/{uid}/historique")619def listing_history(uid: str):620 """Historique Rent-Ka : timeline des observations réelles (prix, texte,621 photos, disponibilité, retraits/retours) — voir rentka/history.py."""622 from . import history623 out = history.timeline(uid)624 if out is None:625 raise HTTPException(404, "Listing not found")626 return out627628629@app.get("/api/listings/{uid}/recyclees")630def listing_recycled(uid: str):631 """Annonces antérieures probables du même logement (score multi-signaux,632 inférence explicable) — rentka/recycled.py."""633 from . import recycled634 return recycled.lookup(uid)635636637@app.get("/api/immeuble")638def immeuble_fiche(bkey: str):639 """Passeport de l'immeuble (stats précalculées par building.rollup)."""640 from . import building641 out = building.fiche(bkey)642 if out is None:643 raise HTTPException(404, "Unknown building")644 return out645646647@app.get("/api/managers/{source_id}")648def manager_fiche(source_id: str):649 """Fiche du gestionnaire (source directe) + avis Google synchronisés en650 base — aucun appel SerpApi au chargement de page (rentka/managers.py)."""651 from . import managers652 out = managers.fiche(source_id)653 if out is None:654 raise HTTPException(404, "Unknown manager")655 return out656657658@app.get("/api/listings/{uid}")659def get_listing(uid: str, request: Request):660 con = db.connect()661 row = con.execute(662 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"663 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"664 " fv.confidence AS fv_confidence"665 " FROM listings LEFT JOIN fairvalue fv USING (uid)"666 " WHERE uid=?", (uid,)).fetchone()667 d = None668 if row is not None:669 d = _row_to_dict(row)670 # commodités de proximité (cache par immeuble, voir rentka/poi.py)671 if d.get("lat") is not None and d.get("lng") is not None:672 key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"673 poi_row = con.execute(674 "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()675 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []676 else:677 d["poi"] = []678 # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)679 if d.get("coord_key"):680 ks = con.execute(681 "SELECT walk, transit, bike, calme, services, global AS glob,"682 " details, version, computed_at FROM kascores WHERE coord_key=?",683 (d["coord_key"],)).fetchone()684 if ks is not None:685 d["kascores"] = {686 "walk": ks["walk"], "transit": ks["transit"],687 "bike": ks["bike"], "calme": ks["calme"],688 "services": ks["services"], "global": ks["glob"],689 "details": json.loads(ks["details"] or "{}"),690 "version": ks["version"], "computed_at": ks["computed_at"],691 }692 # building passport (precomputed stats) + winter score693 if d.get("building_key"):694 from . import building695 d["immeuble"] = building.fiche(d["building_key"], con)696 else:697 d["immeuble"] = None698 from . import hiver699 d["hiver"] = hiver.score(d.get("lat"), d.get("lng"), con)700 # description structurée + historique de prix701 d["digest"] = json.loads(d["digest"]) if d.get("digest") else None702 d["price_history"] = [dict(r) for r in con.execute(703 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",704 (uid,)).fetchall()]705 con.close()706 if d is None:707 raise HTTPException(404, "Listing not found")708 kaid.track(_kaid_user(request), "detail_view", entity_type="listing",709 entity_id=uid, features=_kaid_features(d))710 return d711712713@app.get("/api/kascores/stats")714def kascores_stats():715 """Couverture, moyennes et version du barème — page « KA Scores »."""716 from . import kascores717 return kascores.stats()718719720@app.get("/api/facets")721def facets(city: str | None = None):722 """Valeurs distinctes pour construire les filtres du frontend.723724 `city` (optionnel) restreint la liste des quartiers à cette ville —725 utilisé par le sélecteur « Quartier » dépendant de « Ville ».726 """727 con = db.connect()728 sector_sql = ("SELECT DISTINCT sector FROM listings"729 " WHERE active=1 AND published=1 AND dup_of IS NULL"730 " AND sector<>''")731 sector_args: list = []732 if city:733 sector_sql += " AND city=?"734 sector_args.append(city)735 out = {736 "provinces": [r["province"] for r in con.execute(737 "SELECT DISTINCT province FROM listings WHERE active=1"738 " AND published=1 AND dup_of IS NULL AND province<>''"739 " ORDER BY province")],740 "cities": [r["city"] for r in con.execute(741 "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1"742 " AND dup_of IS NULL AND city<>'' ORDER BY city")],743 "sectors": [r["sector"] for r in con.execute(744 sector_sql + " ORDER BY sector", sector_args)],745 "unit_types": [r["unit_type"] for r in con.execute(746 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1"747 " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],748 "sources": [dict(r) for r in con.execute(749 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"750 " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],751 }752 con.close()753 return out754755756@app.get("/api/sources")757def sources():758 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]759 con = db.connect()760 counts = {r["source"]: r["n"] for r in con.execute(761 "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"762 " GROUP BY source")}763 last = {r["source"]: r["ts"] for r in con.execute(764 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}765 con.close()766 for s in registry:767 s["active_listings"] = counts.get(s["id"], 0)768 s["last_sync"] = last.get(s["id"])769 return {"sources": registry}770771772@app.get("/api/stats")773def stats(syncs_since_h: float | None = None):774 """Stats globales + journal de synchronisation.775776 `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le777 moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne778 couvrent que ~11 min d'activité sur ~200 sources (fausses alertes779 « stale »). Avec le paramètre : toutes les entrées des N dernières heures780 (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières781 toutes époques confondues. Sans le paramètre : comportement historique782 inchangé (LIMIT 20).783 """784 con = db.connect()785 row = con.execute(786 """SELECT COUNT(*) total,787 COUNT(DISTINCT source) sources,788 AVG(price) avg_price789 FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL"""790 ).fetchone()791 provinces = {r["province"]: r["n"] for r in con.execute(792 """SELECT province, COUNT(*) n FROM listings793 WHERE active=1 AND published=1 AND dup_of IS NULL AND province<>''794 GROUP BY province ORDER BY n DESC""")}795 cities_top = [dict(r) for r in con.execute(796 """SELECT city, COUNT(*) n FROM listings797 WHERE active=1 AND published=1 AND dup_of IS NULL AND city<>''798 GROUP BY city ORDER BY n DESC LIMIT 12""")]799 log: list[dict] = []800 if syncs_since_h is not None:801 import time as _time802 cutoff = _time.time() - syncs_since_h * 3600803 log = [dict(r) for r in con.execute(804 "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",805 (cutoff,))]806 if syncs_since_h is None or len(log) < 20:807 log = [dict(r) for r in con.execute(808 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]809 # qualité des données : complétude moyenne + quarantaine (quality.py)810 qual = con.execute(811 """SELECT ROUND(AVG(completeness),1) avg_completeness,812 SUM(published=1) publiees, SUM(published=0) quarantaine813 FROM listings WHERE active=1"""814 ).fetchone()815 con.close()816 return {**dict(row), "provinces": provinces, "top_cities": cities_top,817 "quality": dict(qual), "recent_syncs": log}818819820@app.get("/api/stats/detailed")821def stats_detailed():822 """Agrégats du marché (source unique : rentka/marketstats.py)."""823 from . import marketstats824 return marketstats.compute()825826827# --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) ---828_PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}829830831@app.get("/api/stats/dashboard")832def stats_dashboard(period: str = "30j",833 from_: str | None = Query(None, alias="from"),834 to: str | None = None):835 """Tableau de bord analytique (KPI, séries, répartitions, tableaux…)."""836 from . import statsdash837 if period not in _PERIODES_VALIDES and not (from_ and to):838 period = "30j"839 return statsdash.compute(period, from_, to)840841842@app.get("/api/stats/report")843def stats_report(period: str = "30j",844 from_: str | None = Query(None, alias="from"),845 to: str | None = None,846 mode: str = "complet"):847 """Rapport statistique PDF estampillé Groupe-KA — 5 modes :848 complet | synthese | tendances | repartitions | donnees (inconnu → complet)."""849 from fastapi.responses import Response850 from . import kapdf, statsdash851 if period not in _PERIODES_VALIDES:852 period = "perso" if (from_ and to) else "30j"853 if mode not in kapdf.REPORT_MODES:854 mode = "complet"855 dash = statsdash.compute(period if period != "perso" else "30j",856 from_, to)857 eco = json.loads((ROOT / "frontend" / "src" / "ka" /858 "ecosystem.json").read_text(encoding="utf-8"))859 site = next(s for s in eco["sites"] if s["id"] == "rent-ka")860 data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()861 fname = kapdf.filename("rent-ka", period, mode)862 return Response(content=data, media_type="application/pdf",863 headers={"Content-Disposition":864 f'attachment; filename="{fname}"'})865866867868@app.get("/api/stats/catalog")869def stats_catalog(period: str = "30j",870 from_: str | None = Query(None, alias="from"),871 to: str | None = None):872 """v3 — blocs composables pour le constructeur de rapports personnalisés."""873 from . import kapdf, statsdash874 if period not in _PERIODES_VALIDES and not (from_ and to):875 period = "30j"876 dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j",877 from_, to)878 return {"updated": dash.get("updated"), "period": dash.get("period"),879 "blocks": kapdf.catalog(dash)}880881882@app.post("/api/stats/report/custom")883def stats_report_custom(spec: dict = Body(...)):884 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",885 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""886 from fastapi.responses import Response887 from . import kapdf, statsdash888 period = str(spec.get("period") or "30j")889 from_ = str(spec.get("from") or "") or None890 to = str(spec.get("to") or "") or None891 if period not in _PERIODES_VALIDES:892 period = "perso" if (from_ and to) else "30j"893 dash = statsdash.compute(period if period != "perso" else "30j",894 from_, to)895 known = {b["key"] for b in kapdf.catalog(dash)}896 blocks = [b for b in (spec.get("blocks") or [])897 if isinstance(b, dict) and b.get("key") in known][:40]898 if not blocks:899 raise HTTPException(400, "No valid block in the composition")900 eco = json.loads((ROOT / "frontend" / "src" / "ka" /901 "ecosystem.json").read_text(encoding="utf-8"))902 site = next(s for s in eco["sites"] if s["id"] == "rent-ka")903 data = kapdf.GroupeKAReport(904 site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,905 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},906 ).build()907 fname = kapdf.filename("rent-ka", period, kapdf.CUSTOM_MODE)908 return Response(content=data, media_type="application/pdf",909 headers={"Content-Disposition":910 f'attachment; filename="{fname}"'})911912913@app.post("/api/sync")914def trigger_sync(background: BackgroundTasks, source: str | None = None):915 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""916 def _job():917 with _sync_lock:918 ingest.run([source] if source else None)919 background.add_task(_job)920 return {"status": "started", "source": source or "all"}921922923# --- Frontend React (build Vite) --------------------------------------------924if FRONTEND_DIST.exists():925 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")926927 # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.928 # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.929 from . import seo # noqa: E402930 app.include_router(seo.router)931932 # Documentation utilisateur (frontend/public/doc → dist/doc) — routes933 # explicites AVANT le catch-all SPA pour servir /doc/ comme un index.934 @app.get("/doc")935 def doc_redirect():936 return RedirectResponse("/doc/", status_code=308)937938 @app.get("/doc/")939 def doc_index():940 return FileResponse(FRONTEND_DIST / "doc" / "index.html")941942 @app.middleware("http")943 async def _cache_headers(request, call_next):944 """Politique de cache : les bundles hachés (/assets/…) sont immuables,945 mais index.html doit TOUJOURS être revalidé — sinon les navigateurs946 gardent une vieille version de l'app après un déploiement."""947 resp = await call_next(request)948 path = request.url.path949 if path.startswith("/assets/"):950 resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"951 elif "text/html" in (resp.headers.get("content-type") or ""):952 resp.headers["Cache-Control"] = "no-cache"953 return resp954955 # routes de l'app rendues uniquement côté client (privées ou volatiles) —956 # tout autre chemin inconnu renvoie index.html avec un statut 404 : le957 # routeur React affiche sa page « introuvable », les moteurs de recherche958 # voient un vrai 404 (fin des soft-404).959 _CLIENT_ROUTES = {"profile", "favorites", "manage", "welcome", "bot",960 "contact", "fair-value", "ka-scores"}961 _CLIENT_PREFIXES = ("u/", "gateway/")962963 @app.get("/{full_path:path}")964 def spa(full_path: str):965 target = FRONTEND_DIST / full_path966 if full_path and target.is_file():967 return FileResponse(target)968 known = (full_path in _CLIENT_ROUTES969 or any(full_path == p.rstrip("/") or full_path.startswith(p)970 for p in _CLIENT_PREFIXES))971 return FileResponse(FRONTEND_DIST / "index.html",972 status_code=200 if known else 404,973 headers={"Cache-Control": "no-cache"})974