# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # web.py : FastAPI JSON API + React frontend serving (frontend/dist) # ----------------------------------------------------------------------------- from __future__ import annotations import json import threading from pathlib import Path from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from . import auth, db, ingest, kaid ROOT = Path(__file__).resolve().parent.parent SOURCES_PATH = ROOT / "data" / "sources.json" FRONTEND_DIST = ROOT / "frontend" / "dist" app = FastAPI(title="Rent-Ka API", version="1.0", description="Rental listings aggregator — Canada (outside Québec)") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.add_middleware(GZipMiddleware, minimum_size=1000) _sync_lock = threading.Lock() # comptes utilisateurs (connexion Google) — voir rentka/auth.py app.include_router(auth.router) # personnalisation KA ID v2 (rentka/kaid.py, canonique ka-ui.git/kaid) kaid.init("rent-ka") _kaid_ka_cache: dict[int, str] = {} def _kaid_user(request) -> dict | None: """Session enrichie du ka_id (absent du témoin legacy) — None sinon.""" user = auth.current_user(request) if not user: return None uid = user.get("uid") ka = _kaid_ka_cache.get(uid) if ka is None: con = db.connect() row = con.execute("SELECT ka_id FROM users WHERE id=?", (uid,)).fetchone() con.close() ka = (row["ka_id"] if row else "") or "" _kaid_ka_cache[uid] = ka return {**user, "ka_id": ka} if ka.startswith("ka-") else None def _kaid_features(d: dict) -> dict: """Caractéristiques d un logement pour le profil de préférences KA ID.""" return {k: v for k, v in { "city": d.get("city"), "sector": d.get("sector"), "unit_type": d.get("unit_type"), "pets": d.get("pets"), "furnished": (bool(d.get("furnished")) if d.get("furnished") is not None else None), "price": d.get("price"), "deal": d.get("fv_verdict"), "source": d.get("source"), }.items() if v not in (None, "")} app.include_router(kaid.build_router(_kaid_user)) # rôles, favoris, pages gestionnaires — voir rentka/accounts.py from . import accounts # noqa: E402 (import tardif : évite le cycle web<->accounts) app.include_router(accounts.router) # personnalisation du profil (bio, réseaux sociaux, photo) — rentka/profile.py from . import profile as user_profile # noqa: E402 app.include_router(user_profile.router) # fichiers téléversés (photos de profil) — data/uploads/ _UPLOADS = ROOT / "data" / "uploads" _UPLOADS.mkdir(parents=True, exist_ok=True) app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads") def _row_to_dict(row) -> dict: d = dict(row) d["amenities"] = json.loads(d.get("amenities") or "[]") d["images"] = json.loads(d.get("images") or "[]") # galerie nettoyée par le contrôle qualité (imgcheck.py) : liens morts, # minuscules, placeholders et doublons retirés — prime sur la galerie brute imgs_ok = d.pop("images_ok", None) if imgs_ok: try: cleaned = json.loads(imgs_ok) if isinstance(cleaned, list): d["images"] = cleaned except (ValueError, TypeError): pass d.pop("img_audit", None) d["details"] = json.loads(d.get("details") or "{}") # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… ») if d.get("dup_sources"): try: d["dup_sources"] = json.loads(d["dup_sources"]) except (ValueError, TypeError): d["dup_sources"] = [] if d.get("furnished") is not None: d["furnished"] = bool(d["furnished"]) return d @app.get("/api/listings") def list_listings( request: Request, province: str | None = None, city: str | None = None, sector: str | None = None, unit_type: str | None = None, source: str | None = None, price_max: float | None = None, price_min: float | None = None, pets: str | None = None, # "oui" -> oui OU conditions furnished: int | None = None, # 1 / 0 available_by: str | None = None, # ISO : dispo maintenant ou avant date area_min: float | None = None, # superficie minimale (pi²) q: str | None = None, deal: str | None = None, # sous | marche | sur (fair value) kascore_min: float | None = None, # KA Score global minimal sort: str | None = None, # "deal" affaires | "ka" KA Score active: int = 1, limit: int = Query(500, le=2000), offset: int = 0, ): con = db.connect() sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high," " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict," " fv.confidence AS fv_confidence," + _KS_COLS + " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN + " WHERE dup_of IS NULL") # doublons masqués args: list = [] if active in (0, 1): sql += " AND active=?"; args.append(active) if active == 1: # quality quarantine: never in public results sql += " AND published=1" if province: sql += " AND province=?"; args.append(province.upper()) if city: sql += " AND city=?"; args.append(city) if sector: sql += " AND sector LIKE ?"; args.append(f"%{sector}%") if unit_type: sql += " AND unit_type=?"; args.append(unit_type) if source: sql += " AND source=?"; args.append(source) if price_max is not None: sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max) if price_min is not None: sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min) if pets in ("yes", "oui"): sql += " AND pets IN ('yes','oui','conditions')" elif pets in ("no", "non"): sql += " AND pets IN ('no','non')" elif pets: sql += " AND pets=?"; args.append(pets) if furnished in (0, 1): sql += " AND furnished=?"; args.append(furnished) if available_by: # « now » trié après les dates ISO : comparaison explicite sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \ " AND availability_date<=?))" args.append(available_by) if area_min is not None: sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min) if q: sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)" args += [f"%{q}%"] * 3 if deal in ("sous", "marche", "sur"): sql += " AND fv.verdict=?"; args.append(deal) if kascore_min is not None: sql += " AND ks.global IS NOT NULL AND ks.global>=?" args.append(kascore_min) total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] if sort == "deal": # meilleures affaires d'abord (écart le plus négatif) sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC," " price IS NULL, price ASC LIMIT ? OFFSET ?") elif sort == "ka": # meilleurs emplacements d'abord (KA Score global) sql += (" ORDER BY ks.global IS NULL, ks.global DESC," " price IS NULL, price ASC LIMIT ? OFFSET ?") elif sort in ("prix", "price"): sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?" else: # défaut : les plus récentes d'abord sql += " ORDER BY first_seen DESC, price IS NULL, price ASC LIMIT ? OFFSET ?" args += [limit, offset] rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] con.close() # personnalisation KA ID : journal + reclassement (tri par défaut seulement, # jamais un tri explicite ; intention de session > préférences long terme) personalized = False user = _kaid_user(request) if user: filters = {k: v for k, v in { "city": city, "sector": sector, "unit_type": unit_type, "price_max": price_max, "price_min": price_min, "pets": pets, "furnished": furnished, "deal": deal, "q": q}.items() if v not in (None, "")} if filters and offset == 0: kaid.track(user, "search", query=q, filters=filters) if sort in (None, "", "recent"): active = {k for k in ("city", "sector", "unit_type", "pets", "deal") if filters.get(k)} if price_max is not None or price_min is not None: active.add("price") if furnished in (0, 1): active.add("furnished") rows, personalized = kaid.rerank( rows, user, features_of=_kaid_features, active_dims=active) return {"total": total, "count": len(rows), "listings": rows, "personalized": personalized} @app.get("/api/listings.geojson") def listings_geojson( province: str | None = None, city: str | None = None, sector: str | None = None, unit_type: str | None = None, source: str | None = None, price_max: float | None = None, price_min: float | None = None, pets: str | None = None, furnished: int | None = None, available_by: str | None = None, area_min: float | None = None, q: str | None = None, bbox: str | None = None, limit: int = Query(3000, le=8000), ): """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings). Léger par conception : seuls les champs nécessaires aux marqueurs et aux fiches de la carte sont inclus (1 photo, pas de description). Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible (index idx_listings_geo) ; `limit` plafonne la réponse. La collection porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées), pour afficher « N résultats sans position sur la carte ». """ con = db.connect() sql = ("SELECT uid, title, address, price, price_label, unit_type," " availability_date, source, city, sector, province, area_sqft," " images, images_ok, lat, lng, fv.verdict AS fv_verdict," " fv.deviation AS fv_deviation" " FROM listings LEFT JOIN fairvalue fv USING (uid)" " WHERE active=1 AND published=1 AND dup_of IS NULL" " AND lat IS NOT NULL AND lng IS NOT NULL") args: list = [] if bbox: try: west, south, east, north = (float(v) for v in bbox.split(",")) except ValueError: raise HTTPException(400, "expected bbox: west,south,east,north") sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?" args += [south, north, west, east] if province: sql += " AND province=?"; args.append(province.upper()) if city: sql += " AND city=?"; args.append(city) if sector: sql += " AND sector LIKE ?"; args.append(f"%{sector}%") if unit_type: sql += " AND unit_type=?"; args.append(unit_type) if source: sql += " AND source=?"; args.append(source) if price_max is not None: sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max) if price_min is not None: sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min) if pets in ("yes", "oui"): sql += " AND pets IN ('yes','oui','conditions')" elif pets in ("no", "non"): sql += " AND pets IN ('no','non')" elif pets: sql += " AND pets=?"; args.append(pets) if furnished in (0, 1): sql += " AND furnished=?"; args.append(furnished) if available_by: sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \ " AND availability_date<=?))" args.append(available_by) if area_min is not None: sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min) if q: sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)" args += [f"%{q}%"] * 3 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] # Même requête sans l'exigence de coordonnées ni le bbox : mesure les # annonces filtrées invisibles sur la carte (affordance « sans position »). sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "") args_all = list(args) if bbox: sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "") del args_all[0:4] total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"] sql += " ORDER BY first_seen DESC LIMIT ?" args.append(limit) features = [] for r in con.execute(sql, args).fetchall(): # galerie nettoyée si auditée (même vide : les images brutes sont # alors toutes mortes/placeholder — le frontend affiche le visuel # de secours Rent-Ka plutôt qu'une image cassée) images = json.loads((r["images_ok"] if r["images_ok"] is not None else r["images"]) or "[]") features.append({ "type": "Feature", "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, "properties": { "uid": r["uid"], "title": r["title"], "address": r["address"], "price": r["price"], "price_label": r["price_label"], "unit_type": r["unit_type"], "availability_date": r["availability_date"], "source": r["source"], "city": r["city"], "sector": r["sector"], "province": r["province"], "area_sqft": r["area_sqft"], "fv_verdict": r["fv_verdict"], "fv_deviation": r["fv_deviation"], "image": images[0] if images else None, }, }) con.close() return {"type": "FeatureCollection", "features": features, "totalGeocoded": total_geo, "totalMatching": total_all} # --- Recherche unifiée liste + carte ----------------------------------------- # Une seule vérité partagée : la même requête sert la page de liste ET les # points de la carte, dans la même réponse. Le compteur affiché en tête de # liste est par construction égal au nombre de points envoyés à la carte. _SEARCH_SORTS = { "prix": " ORDER BY price IS NULL, price ASC", "prix_desc": " ORDER BY price IS NULL, price DESC", "price": " ORDER BY price IS NULL, price ASC", "price_desc": " ORDER BY price IS NULL, price DESC", "recent": " ORDER BY first_seen DESC, price IS NULL, price ASC", "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC," " price IS NULL, price ASC"), "ka": (" ORDER BY ks.global IS NULL, ks.global DESC," " price IS NULL, price ASC"), } # colonnes KA Scores jointes aux annonces (pastilles + tri + fiche) _KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit," " ks.bike AS ks_bike, ks.calme AS ks_calme," " ks.services AS ks_services, ks.global AS ks_global") _KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key" _FV_CODES = {"sous": "s", "marche": "m", "sur": "o"} def _parse_poly(poly: str) -> list[tuple[float, float]]: """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400.""" try: pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p] except ValueError: raise HTTPException(400, "expected poly: lng,lat;lng,lat;…") if len(pts) < 3 or any(len(p) != 2 for p in pts): raise HTTPException(400, "poly: at least 3 lng,lat vertices") return pts # type: ignore[return-value] def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool: """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon).""" inside = False j = len(ring) - 1 for i in range(len(ring)): xi, yi = ring[i] xj, yj = ring[j] if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi: inside = not inside j = i return inside @app.get("/api/search") def search_unified( province: str | None = None, city: str | None = None, sector: str | None = None, unit_type: str | None = None, source: str | None = None, price_max: float | None = None, price_min: float | None = None, pets: str | None = None, furnished: int | None = None, available_by: str | None = None, area_min: float | None = None, q: str | None = None, deal: str | None = None, # sous | marche | sur (juste valeur) bbox: str | None = None, # ouest,sud,est,nord (zone visible carte) poly: str | None = None, # lng,lat;… (zone dessinée) kascore_min: float | None = None, # KA Score global minimal (0-100) sort: str = "recent", # recent (défaut) | prix | prix_desc | deal | ka page: int = 1, page_size: int = Query(20, ge=1, le=100), include: str = "tout", # tout | liste (points inchangés côté client) ): """Recherche synchronisée liste ↔ carte (une réponse = les deux vues). Retourne `total` (compteur partagé), `listings` (la page demandée) et `points` (TOUS les points correspondants, format compact [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la position d'un point dans `points` donne directement sa page de liste. Seules les annonces géolocalisées participent (la règle d'or : chaque annonce de la liste est visible sur la carte) ; `unpositioned` compte les annonces filtrées sans coordonnées, pour l'afficher honnêtement. """ if sort not in _SEARCH_SORTS: raise HTTPException(400, f"unknown sort: {sort}") ring = _parse_poly(poly) if poly else None con = db.connect() sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN + " WHERE dup_of IS NULL AND active=1 AND published=1" " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL") args: list = [] if bbox: try: west, south, east, north = (float(v) for v in bbox.split(",")) except ValueError: raise HTTPException(400, "expected bbox: west,south,east,north") sql += (" AND listings.lat BETWEEN ? AND ?" " AND listings.lng BETWEEN ? AND ?") args += [south, north, west, east] if ring: # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval lngs = [p[0] for p in ring]; lats = [p[1] for p in ring] sql += (" AND listings.lat BETWEEN ? AND ?" " AND listings.lng BETWEEN ? AND ?") args += [min(lats), max(lats), min(lngs), max(lngs)] if province: sql += " AND province=?"; args.append(province.upper()) if city: sql += " AND city=?"; args.append(city) if sector: sql += " AND sector LIKE ?"; args.append(f"%{sector}%") if unit_type: sql += " AND unit_type=?"; args.append(unit_type) if source: sql += " AND source=?"; args.append(source) if price_max is not None: sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max) if price_min is not None: sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min) if pets in ("yes", "oui"): sql += " AND pets IN ('yes','oui','conditions')" elif pets in ("no", "non"): sql += " AND pets IN ('no','non')" elif pets: sql += " AND pets=?"; args.append(pets) if furnished in (0, 1): sql += " AND furnished=?"; args.append(furnished) if available_by: sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \ " AND availability_date<=?))" args.append(available_by) if area_min is not None: sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min) if q: sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)" args += [f"%{q}%"] * 3 if deal in ("sous", "marche", "sur"): sql += " AND fv.verdict=?"; args.append(deal) if kascore_min is not None: sql += " AND ks.global IS NOT NULL AND ks.global>=?" args.append(kascore_min) rows = con.execute( "SELECT uid, listings.lng AS lng, listings.lat AS lat, price," " fv.verdict AS v" + sql + _SEARCH_SORTS[sort], args).fetchall() if ring: rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)] total = len(rows) # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce # tableau détermine la page (points et liste partagent le même tri) points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"], _FV_CODES.get(r["v"] or "", None)] for r in rows] # page demandée, ramenée dans les bornes si les filtres l'ont dépassée last = max(1, -(-total // page_size)) page = min(max(1, page), last) page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]] listings: list[dict] = [] if page_uids: marks = ",".join("?" * len(page_uids)) by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute( "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high," " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict," " fv.confidence AS fv_confidence," + _KS_COLS + " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN + f" WHERE uid IN ({marks})", page_uids).fetchall()} listings = [by_uid[u] for u in page_uids if u in by_uid] # annonces filtrées mais sans coordonnées (affichage honnête, hors carte) sql_nogeo = sql.replace( " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL", " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1) args_nogeo = list(args) for spatial in (bbox, poly): if spatial: sql_nogeo = sql_nogeo.replace( " AND listings.lat BETWEEN ? AND ?" " AND listings.lng BETWEEN ? AND ?", "", 1) del args_nogeo[0:4] unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo, args_nogeo).fetchone()["c"] con.close() return { "total": total, "page": page, "page_size": page_size, "sort": sort, "listings": listings, "points": points if include != "liste" else None, "unpositioned": unpositioned, } # --- Miniatures optimisées (WebP, taille écran) ------------------------------ # Sert les images des annonces redimensionnées et compressées (perf mobile) : # /api/img?u=&w=480. Seules les URLs déjà vérifiées par le contrôle # qualité (table image_checks) sont proxifiées — pas de proxy ouvert. Si # l'URL n'est pas (encore) connue : 404, le frontend retombe sur l'original. _IMG_WIDTHS = (160, 480, 800, 1280) _IMG_CACHE = ROOT / "data" / "imgcache" @app.get("/api/img") def img_thumbnail(u: str, w: int = 480): import hashlib import requests as _rq from fastapi.responses import Response w = min(_IMG_WIDTHS, key=lambda x: abs(x - w)) if not u.startswith(("http://", "https://")): raise HTTPException(400, "invalid URL") key = hashlib.sha1(u.encode()).hexdigest() _IMG_CACHE.mkdir(parents=True, exist_ok=True) path = _IMG_CACHE / f"{key}_{w}.webp" headers = {"Cache-Control": "public, max-age=2592000, immutable"} if path.exists(): return Response(path.read_bytes(), media_type="image/webp", headers=headers) con = db.connect() known = con.execute("SELECT status FROM image_checks WHERE url=?", (u,)).fetchone() con.close() if known is None or known["status"] not in ("ok",): raise HTTPException(404, "image unknown to quality control") try: resp = _rq.get(u, timeout=12, headers={"User-Agent": "RentKaImg/1.0"}) resp.raise_for_status() from io import BytesIO from PIL import Image, ImageOps img = Image.open(BytesIO(resp.content)) img = ImageOps.exif_transpose(img) if img.mode not in ("RGB", "L"): img = img.convert("RGB") if img.width > w: img = img.resize((w, max(1, round(img.height * w / img.width))), Image.LANCZOS) buf = BytesIO() img.save(buf, "WEBP", quality=80, method=4) data = buf.getvalue() path.write_bytes(data) return Response(data, media_type="image/webp", headers=headers) except HTTPException: raise except Exception: raise HTTPException(404, "unretrievable image") @app.get("/api/listings/{uid}/pdf") def listing_pdf(uid: str): """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py.""" from fastapi.responses import Response from . import pdfgen data = pdfgen.fiche_pdf(uid) if data is None: raise HTTPException(404, "Listing not found") nom = uid.replace(":", "-") return Response(content=data, media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="rentka-listing-{nom}.pdf"'}) @app.get("/api/stats/rapport.pdf") def rapport_marche_pdf(): """Rapport global du marché locatif (PDF multi-pages).""" from fastapi.responses import Response from . import pdfgen return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf", headers={"Content-Disposition": 'attachment; filename="rentka-market-report.pdf"'}) @app.get("/api/fairvalue/{uid}") def fairvalue_detail(uid: str): """Analyse de prix Rent-Ka : juste valeur, fourchette, confiance, distribution du segment (voir rentka/fairvalue.py).""" from . import fairvalue d = fairvalue.explain(uid) if d is None: raise HTTPException(404, "No estimate for this listing") return d @app.get("/api/listings/{uid}/historique") def listing_history(uid: str): """Historique Rent-Ka : timeline des observations réelles (prix, texte, photos, disponibilité, retraits/retours) — voir rentka/history.py.""" from . import history out = history.timeline(uid) if out is None: raise HTTPException(404, "Listing not found") return out @app.get("/api/listings/{uid}/recyclees") def listing_recycled(uid: str): """Annonces antérieures probables du même logement (score multi-signaux, inférence explicable) — rentka/recycled.py.""" from . import recycled return recycled.lookup(uid) @app.get("/api/immeuble") def immeuble_fiche(bkey: str): """Passeport de l'immeuble (stats précalculées par building.rollup).""" from . import building out = building.fiche(bkey) if out is None: raise HTTPException(404, "Unknown building") return out @app.get("/api/managers/{source_id}") def manager_fiche(source_id: str): """Fiche du gestionnaire (source directe) + avis Google synchronisés en base — aucun appel SerpApi au chargement de page (rentka/managers.py).""" from . import managers out = managers.fiche(source_id) if out is None: raise HTTPException(404, "Unknown manager") return out @app.get("/api/listings/{uid}") def get_listing(uid: str, request: Request): con = db.connect() row = con.execute( "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high," " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict," " fv.confidence AS fv_confidence" " FROM listings LEFT JOIN fairvalue fv USING (uid)" " WHERE uid=?", (uid,)).fetchone() d = None if row is not None: d = _row_to_dict(row) # commodités de proximité (cache par immeuble, voir rentka/poi.py) if d.get("lat") is not None and d.get("lng") is not None: key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}" poi_row = con.execute( "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone() d["poi"] = json.loads(poi_row["pois"]) if poi_row else [] else: d["poi"] = [] # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global) if d.get("coord_key"): ks = con.execute( "SELECT walk, transit, bike, calme, services, global AS glob," " details, version, computed_at FROM kascores WHERE coord_key=?", (d["coord_key"],)).fetchone() if ks is not None: d["kascores"] = { "walk": ks["walk"], "transit": ks["transit"], "bike": ks["bike"], "calme": ks["calme"], "services": ks["services"], "global": ks["glob"], "details": json.loads(ks["details"] or "{}"), "version": ks["version"], "computed_at": ks["computed_at"], } # building passport (precomputed stats) + winter score if d.get("building_key"): from . import building d["immeuble"] = building.fiche(d["building_key"], con) else: d["immeuble"] = None from . import hiver d["hiver"] = hiver.score(d.get("lat"), d.get("lng"), con) # description structurée + historique de prix d["digest"] = json.loads(d["digest"]) if d.get("digest") else None d["price_history"] = [dict(r) for r in con.execute( "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()] con.close() if d is None: raise HTTPException(404, "Listing not found") kaid.track(_kaid_user(request), "detail_view", entity_type="listing", entity_id=uid, features=_kaid_features(d)) return d @app.get("/api/kascores/stats") def kascores_stats(): """Couverture, moyennes et version du barème — page « KA Scores ».""" from . import kascores return kascores.stats() @app.get("/api/facets") def facets(city: str | None = None): """Valeurs distinctes pour construire les filtres du frontend. `city` (optionnel) restreint la liste des quartiers à cette ville — utilisé par le sélecteur « Quartier » dépendant de « Ville ». """ con = db.connect() sector_sql = ("SELECT DISTINCT sector FROM listings" " WHERE active=1 AND published=1 AND dup_of IS NULL" " AND sector<>''") sector_args: list = [] if city: sector_sql += " AND city=?" sector_args.append(city) out = { "provinces": [r["province"] for r in con.execute( "SELECT DISTINCT province FROM listings WHERE active=1" " AND published=1 AND dup_of IS NULL AND province<>''" " ORDER BY province")], "cities": [r["city"] for r in con.execute( "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1" " AND dup_of IS NULL AND city<>'' ORDER BY city")], "sectors": [r["sector"] for r in con.execute( sector_sql + " ORDER BY sector", sector_args)], "unit_types": [r["unit_type"] for r in con.execute( "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1" " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")], "sources": [dict(r) for r in con.execute( "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1" " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")], } con.close() return out @app.get("/api/sources") def sources(): registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] con = db.connect() counts = {r["source"]: r["n"] for r in con.execute( "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1" " GROUP BY source")} last = {r["source"]: r["ts"] for r in con.execute( "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} con.close() for s in registry: s["active_listings"] = counts.get(s["id"], 0) s["last_sync"] = last.get(s["id"]) return {"sources": registry} @app.get("/api/stats") def stats(syncs_since_h: float | None = None): """Stats globales + journal de synchronisation. `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne couvrent que ~11 min d'activité sur ~200 sources (fausses alertes « stale »). Avec le paramètre : toutes les entrées des N dernières heures (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières toutes époques confondues. Sans le paramètre : comportement historique inchangé (LIMIT 20). """ con = db.connect() row = con.execute( """SELECT COUNT(*) total, COUNT(DISTINCT source) sources, AVG(price) avg_price FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL""" ).fetchone() provinces = {r["province"]: r["n"] for r in con.execute( """SELECT province, COUNT(*) n FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL AND province<>'' GROUP BY province ORDER BY n DESC""")} cities_top = [dict(r) for r in con.execute( """SELECT city, COUNT(*) n FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL AND city<>'' GROUP BY city ORDER BY n DESC LIMIT 12""")] log: list[dict] = [] if syncs_since_h is not None: import time as _time cutoff = _time.time() - syncs_since_h * 3600 log = [dict(r) for r in con.execute( "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000", (cutoff,))] if syncs_since_h is None or len(log) < 20: log = [dict(r) for r in con.execute( "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] # qualité des données : complétude moyenne + quarantaine (quality.py) qual = con.execute( """SELECT ROUND(AVG(completeness),1) avg_completeness, SUM(published=1) publiees, SUM(published=0) quarantaine FROM listings WHERE active=1""" ).fetchone() con.close() return {**dict(row), "provinces": provinces, "top_cities": cities_top, "quality": dict(qual), "recent_syncs": log} @app.get("/api/stats/detailed") def stats_detailed(): """Agrégats du marché (source unique : rentka/marketstats.py).""" from . import marketstats return marketstats.compute() # --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) --- _PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"} @app.get("/api/stats/dashboard") def stats_dashboard(period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = None): """Tableau de bord analytique (KPI, séries, répartitions, tableaux…).""" from . import statsdash if period not in _PERIODES_VALIDES and not (from_ and to): period = "30j" return statsdash.compute(period, from_, to) @app.get("/api/stats/report") def stats_report(period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = None, mode: str = "complet"): """Rapport statistique PDF estampillé Groupe-KA — 5 modes : complet | synthese | tendances | repartitions | donnees (inconnu → complet).""" from fastapi.responses import Response from . import kapdf, statsdash if period not in _PERIODES_VALIDES: period = "perso" if (from_ and to) else "30j" if mode not in kapdf.REPORT_MODES: mode = "complet" dash = statsdash.compute(period if period != "perso" else "30j", from_, to) eco = json.loads((ROOT / "frontend" / "src" / "ka" / "ecosystem.json").read_text(encoding="utf-8")) site = next(s for s in eco["sites"] if s["id"] == "rent-ka") data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build() fname = kapdf.filename("rent-ka", period, mode) return Response(content=data, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/stats/catalog") def stats_catalog(period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = None): """v3 — blocs composables pour le constructeur de rapports personnalisés.""" from . import kapdf, statsdash if period not in _PERIODES_VALIDES and not (from_ and to): period = "30j" dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j", from_, to) return {"updated": dash.get("updated"), "period": dash.get("period"), "blocks": kapdf.catalog(dash)} @app.post("/api/stats/report/custom") def stats_report_custom(spec: dict = Body(...)): """v3 — rapport PDF personnalisé : {"title", "period", "from", "to", "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis).""" from fastapi.responses import Response from . import kapdf, statsdash period = str(spec.get("period") or "30j") from_ = str(spec.get("from") or "") or None to = str(spec.get("to") or "") or None if period not in _PERIODES_VALIDES: period = "perso" if (from_ and to) else "30j" dash = statsdash.compute(period if period != "perso" else "30j", from_, to) known = {b["key"] for b in kapdf.catalog(dash)} blocks = [b for b in (spec.get("blocks") or []) if isinstance(b, dict) and b.get("key") in known][:40] if not blocks: raise HTTPException(400, "No valid block in the composition") eco = json.loads((ROOT / "frontend" / "src" / "ka" / "ecosystem.json").read_text(encoding="utf-8")) site = next(s for s in eco["sites"] if s["id"] == "rent-ka") data = kapdf.GroupeKAReport( site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE, spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks}, ).build() fname = kapdf.filename("rent-ka", period, kapdf.CUSTOM_MODE) return Response(content=data, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.post("/api/sync") def trigger_sync(background: BackgroundTasks, source: str | None = None): """Déclenche une synchronisation (équivalent d'un webhook entrant).""" def _job(): with _sync_lock: ingest.run([source] if source else None) background.add_task(_job) return {"status": "started", "source": source or "all"} # --- Frontend React (build Vite) -------------------------------------------- if FRONTEND_DIST.exists(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") # référencement : SSR des routes publiques, robots.txt, sitemaps, 410. # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi. from . import seo # noqa: E402 app.include_router(seo.router) # Documentation utilisateur (frontend/public/doc → dist/doc) — routes # explicites AVANT le catch-all SPA pour servir /doc/ comme un index. @app.get("/doc") def doc_redirect(): return RedirectResponse("/doc/", status_code=308) @app.get("/doc/") def doc_index(): return FileResponse(FRONTEND_DIST / "doc" / "index.html") @app.middleware("http") async def _cache_headers(request, call_next): """Politique de cache : les bundles hachés (/assets/…) sont immuables, mais index.html doit TOUJOURS être revalidé — sinon les navigateurs gardent une vieille version de l'app après un déploiement.""" resp = await call_next(request) path = request.url.path if path.startswith("/assets/"): resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif "text/html" in (resp.headers.get("content-type") or ""): resp.headers["Cache-Control"] = "no-cache" return resp # routes de l'app rendues uniquement côté client (privées ou volatiles) — # tout autre chemin inconnu renvoie index.html avec un statut 404 : le # routeur React affiche sa page « introuvable », les moteurs de recherche # voient un vrai 404 (fin des soft-404). _CLIENT_ROUTES = {"profile", "favorites", "manage", "welcome", "bot", "contact", "fair-value", "ka-scores"} _CLIENT_PREFIXES = ("u/", "gateway/") @app.get("/{full_path:path}") def spa(full_path: str): target = FRONTEND_DIST / full_path if full_path and target.is_file(): return FileResponse(target) known = (full_path in _CLIENT_ROUTES or any(full_path == p.rstrip("/") or full_path.startswith(p) for p in _CLIENT_PREFIXES)) return FileResponse(FRONTEND_DIST / "index.html", status_code=200 if known else 404, headers={"Cache-Control": "no-cache"})