# ----------------------------------------------------------------------------- # House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first) # Author: Simon-Pierre Boucher — contact@spboucher.ai # web.py : FastAPI JSON API + static frontend serving (frontend/) # ----------------------------------------------------------------------------- from __future__ import annotations import json import threading import time 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, Response from fastapi.staticfiles import StaticFiles from . import auth, db, favorites, ingest, kaid, seo from . import stats as kastats ROOT = Path(__file__).resolve().parent.parent SOURCES_PATH = ROOT / "data" / "sources.json" # Frontend : build Vite (React) si présent, sinon la page statique de secours. FRONTEND_DIST = ROOT / "frontend" / "dist" FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend" app = FastAPI(title="House-Ka API", version="0.1", description="Homes-for-sale aggregator — Canada (outside Québec)") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) # le GeoJSON complet pèse ~20 Mo — la compression est vitale pour la carte app.add_middleware(GZipMiddleware, minimum_size=1024) # connexion Google (compte Groupe-Ka partagé avec Lou-Ka) — voir immoka/auth.py app.include_router(auth.router) # favoris ♥ — magasin central au hub Groupe KA, voir immoka/favorites.py app.include_router(favorites.router) # personnalisation KA ID v2 (immoka/kaid.py, canonique ka-ui.git/kaid) kaid.init("house-ka") app.include_router(kaid.build_router(auth.current_user)) def _kaid_features(d: dict) -> dict: """Caractéristiques d une propriété pour le profil de préférences KA ID.""" return {k: v for k, v in { "city": d.get("city"), "sector": d.get("sector"), "region": d.get("region"), "property_type": d.get("property_type"), "price": d.get("price"), "bedrooms": d.get("bedrooms"), "source": d.get("source"), }.items() if v not in (None, "")} # moteur hypothécaire — taux réels multibanques + calculateur canadien, # voir immoka/mortgage/ (docs/mortgage-engine.md) from .mortgage import api as mortgage_api # noqa: E402 app.include_router(mortgage_api.router) _sync_lock = threading.Lock() # Déduplication de la famille RE/MAX : le flux central (remax_quebec) et les # ~42 connecteurs de sous-agences (remax_ag_*) décrivent les MÊMES inscriptions, # identifiées de façon unique par leur numéro Centris (= external_id). On masque # donc toute fiche de sous-agence dès qu'une fiche de plus haute priorité existe # (le central enrichi d'abord, puis la sous-agence au plus petit uid). Les fiches # du central et des autres agences ne sont jamais masquées. → aucun double-comptage, # et les sous-agences prennent le relais automatiquement si le central disparaît. # Déduplication PRÉ-CALCULÉE : la colonne `dup_hidden` (remplie par # db.refresh_dedup après chaque sync) marque les doublons de sous-agences. La # lecture est ainsi instantanée (index) au lieu d'un sous-select corrélé par # ligne (~300 s sur 75 k lignes). Voir db.refresh_dedup pour la règle. # + `published=1` : seuil de qualité minimum (voir immoka/quality.py) — une # annonce sans prix plausible, sans ville, sans type de bien ou sans contenu # exploitable part en QUARANTAINE (grille, carte, stats, sources) jusqu'à # complétion par les prochains cycles. La fiche détail par uid reste accessible. DEDUP_CLAUSE = " AND dup_hidden=0 AND published=1" def _row_to_dict(row) -> dict: d = dict(row) d["features"] = json.loads(d.get("features") or "[]") d["images"] = json.loads(d.get("images") or "[]") d["details"] = json.loads(d.get("details") or "{}") if d.get("vraiprix"): try: d["vraiprix"] = json.loads(d["vraiprix"]) or None except (ValueError, TypeError): d["vraiprix"] = None # jours sur le marché (depuis la 1re observation par Immo-Ka ; la date # d'inscription de la source, si connue, est dans details.listed_at) if d.get("first_seen"): d["days_on_market"] = max(0, int((time.time() - d["first_seen"]) // 86400)) return d @app.get("/api/listings") def list_listings( request: Request, city: str | None = None, sector: str | None = None, region: str | None = None, property_type: str | None = None, source: str | None = None, price_max: float | None = None, price_min: float | None = None, bedrooms_min: int | None = None, bathrooms_min: int | None = None, area_min: float | None = None, # superficie habitable minimale (pi²) q: str | None = None, active: int = 1, sort: str = "price_asc", # price_asc | price_desc | recent limit: int = Query(500, le=2000), offset: int = 0, ): con = db.connect() sql = "SELECT * FROM listings WHERE 1=1" args: list = [] if active in (0, 1): sql += " AND active=?"; args.append(active) if city: sql += " AND city=?"; args.append(city) if sector: sql += " AND sector LIKE ?"; args.append(f"%{sector}%") if region: sql += " AND region=?"; args.append(region) if property_type: sql += " AND property_type=?"; args.append(property_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 bedrooms_min is not None: sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min) if bathrooms_min is not None: sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min) 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 city LIKE ? OR mls LIKE ?)" args += [f"%{q}%"] * 4 sql += DEDUP_CLAUSE total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] order = { "price_asc": " ORDER BY price IS NULL, price ASC", "price_desc": " ORDER BY price IS NULL, price DESC", "recent": " ORDER BY first_seen DESC", }.get(sort, " ORDER BY price IS NULL, price ASC") sql += order + " 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 prix (défaut) = # ordre explicite → annotation seule (blend=0) ; « recent » → reclassement. personalized = False user = auth.current_user(request) if user: filters = {k: v for k, v in { "city": city, "sector": sector, "region": region, "property_type": property_type, "price_max": price_max, "price_min": price_min, "bedrooms_min": bedrooms_min, "q": q}.items() if v not in (None, "")} if filters and offset == 0: kaid.track(user, "search", query=q, filters=filters) active = {k for k in ("city", "sector", "region", "property_type") if filters.get(k)} if price_max is not None or price_min is not None: active.add("price") if bedrooms_min is not None: active.add("bedrooms") rows, personalized = kaid.rerank( rows, user, features_of=_kaid_features, active_dims=active, blend=0.35 if sort == "recent" else 0.0) return {"total": total, "count": len(rows), "listings": rows, "personalized": personalized} @app.get("/api/commerces") def commerces_at(lat: float, lng: float, region: str | None = None): """Grands commerces (bannières PAR PROVINCE) + transit les plus proches (Mapbox / OSM). `region` = province de l'annonce ; sinon inférée du point.""" from . import commerces return commerces.nearby(lat, lng, region=region) @app.get("/api/listings/{uid}") def get_listing(uid: str, request: Request): con = db.connect() row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() d = None if row is not None: d = _row_to_dict(row) # historique de prix (baisses/hausses du prix demandé) d["price_history"] = [dict(r) for r in con.execute( "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10", (uid,)).fetchall()] # commodités de proximité (cache par immeuble, voir immoka/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"] = [] # autres publications de la même propriété (doublons masqués par la # dédup, rattachés via dup_of) — pour la box « Aussi publiée sur… » d["duplicates"] = [dict(r) for r in con.execute( "SELECT uid, source, url, broker_name, agency, price_label" " FROM listings WHERE dup_of=? AND active=1 ORDER BY source", (uid,)).fetchall()] # statistiques de quartier — base quartier.db absente sur House-Ka # (couverture Québec seulement) : fiche_quartier retourne None sans elle try: from . import quartier dauid = d.get("dauid") d["quartier"] = quartier.fiche_quartier( d.get("lat"), d.get("lng"), d.get("city") or "", dauid if dauid and dauid != "hors-zone" else None) except Exception: d["quartier"] = None con.close() if d is None: raise HTTPException(404, "Propriété introuvable") kaid.track(auth.current_user(request), "detail_view", entity_type="property", entity_id=uid, features=_kaid_features(d)) return d @app.get("/api/listings.geojson") def listings_geojson( city: str | None = None, sector: str | None = None, region: str | None = None, property_type: str | None = None, source: str | None = None, price_max: float | None = None, price_min: float | None = None, bedrooms_min: int | None = None, bathrooms_min: int | None = None, area_min: float | None = None, q: str | None = None, bbox: str | None = None, limit: int = Query(3000, le=8000), ): """Propriétés géolocalisées (marqueurs de carte, champs allégés). Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible (index idx_listings_geo) ; `limit` plafonne la réponse ; la collection porte `totalGeocoded` (géolocalisées correspondantes) et `totalMatching` (avec ou sans coordonnées). Mêmes filtres que /api/listings pour que la carte suive exactement la recherche. """ con = db.connect() sql = ("SELECT uid, title, address, price, price_label, property_type," " bedrooms, bathrooms, source, city, sector, images, lat, lng, vraiprix," " json_extract(COALESCE(details,'{}'),'$.cover_thumb') AS cover_thumb" " FROM listings WHERE active=1 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, "bbox attendu : ouest,sud,est,nord") sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?" args += [south, north, west, east] if city: sql += " AND city=?"; args.append(city) if sector: sql += " AND sector LIKE ?"; args.append(f"%{sector}%") if region: sql += " AND region=?"; args.append(region) if property_type: sql += " AND property_type=?"; args.append(property_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 bedrooms_min is not None: sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min) if bathrooms_min is not None: sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min) 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 city LIKE ? OR mls LIKE ?)" args += [f"%{q}%"] * 4 sql += DEDUP_CLAUSE total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] 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 price IS NULL, price ASC LIMIT ?" args.append(limit) features = [] for r in con.execute(sql, args).fetchall(): images = json.loads(r["images"] or "[]") try: vp_val = (json.loads(r["vraiprix"]) or {}).get("value") except (TypeError, ValueError): vp_val = None features.append({ "type": "Feature", "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, "properties": { "uid": r["uid"], "title": None if r["address"] else r["title"], "address": r["address"], "price": r["price"], "price_label": r["price_label"], "property_type": r["property_type"], "bedrooms": r["bedrooms"], "bathrooms": r["bathrooms"], "source": r["source"], "city": r["city"], "sector": r["sector"], # miniature légère pour le popup mobile si le connecteur en a une "image": r["cover_thumb"] or (images[0] if images else None), "vp": vp_val, }, }) con.close() return {"type": "FeatureCollection", "features": features, "totalGeocoded": total_geo, "totalMatching": total_all} @app.get("/api/facets") def facets(city: str | None = None): """Valeurs distinctes pour construire les filtres du frontend.""" con = db.connect() sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''" sector_args: list = [] if city: sector_sql += " AND city=?" sector_args.append(city) out = { "cities": [r["city"] for r in con.execute( "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")], "sectors": [r["sector"] for r in con.execute( sector_sql + " ORDER BY sector", sector_args)], "property_types": [r["property_type"] for r in con.execute( "SELECT property_type FROM listings WHERE active=1" " AND property_type<>''" + DEDUP_CLAUSE + " GROUP BY property_type ORDER BY COUNT(*) DESC")], "sources": [dict(r) for r in con.execute( "SELECT source, COUNT(*) n FROM listings WHERE active=1" + DEDUP_CLAUSE + " 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 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} # Rattachement d'une source à sa bannière (franchise) pour le regroupement. _FRANCHISES = [ ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")), ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")), ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")), ("Royal LePage", lambda s: s == "royal_lepage"), ("Groupe Sutton", lambda s: s == "sutton"), ("Keller Williams", lambda s: s.startswith("kw_")), ("DuProprio", lambda s: s == "duproprio"), ] def _franchise_of(source: str, source_names: dict) -> str: for name, match in _FRANCHISES: if match(source): return name return source_names.get(source, source) # agence indépendante = elle-même @app.get("/api/agencies") def agencies(): """Arbre bannière → sous-agences (bureaux) avec le nombre d'inscriptions. Alimente la page « Sources » de l'app : chaque bannière est éclatée par sous-agence via le champ `agency` (bureau). Dédupliqué (n° Centris).""" con = db.connect() registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] source_names = {s["id"]: s["name"] for s in registry} rows = con.execute( "SELECT source, COALESCE(NULLIF(agency,''), '') agency, COUNT(*) n" " FROM listings WHERE active=1" + DEDUP_CLAUSE + " GROUP BY source, agency").fetchall() con.close() tree: dict[str, dict] = {} for r in rows: fr = _franchise_of(r["source"], source_names) node = tree.setdefault(fr, {"franchise": fr, "total": 0, "agencies": {}}) node["total"] += r["n"] # nom de sous-agence : le bureau (agency) sinon le nom de la source label = r["agency"] or source_names.get(r["source"], r["source"]) a = node["agencies"].setdefault(label, {"name": label, "count": 0, "sources": set()}) a["count"] += r["n"] a["sources"].add(r["source"]) out = [] for node in tree.values(): ags = sorted(node["agencies"].values(), key=lambda x: -x["count"]) for a in ags: a["sources"] = sorted(a["sources"]) out.append({"franchise": node["franchise"], "total": node["total"], "sub_agencies": len(ags), "agencies": ags}) out.sort(key=lambda x: -x["total"]) return {"franchises": out} @app.get("/api/stats") def stats(syncs_since_h: float | None = None): con = db.connect() row = con.execute( """SELECT COUNT(*) total, COUNT(DISTINCT source) sources, COUNT(DISTINCT city) cities, AVG(price) avg_price, MIN(price) min_price, MAX(price) max_price FROM listings WHERE active=1""" + DEDUP_CLAUSE).fetchone() # Fenêtre de synchronisations pour la supervision (moniteur central api-ka) : # sans paramètre, comportement historique (20 dernières entrées) ; avec # ?syncs_since_h=, toutes les entrées de la fenêtre (borne 4000) — # évite les fausses alertes « stale » quand 100+ sources synchronisent # plus de 20 fois entre deux sondages. log: list[dict] = [] if syncs_since_h is not None: 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 len(log) < 20: log = [dict(r) for r in con.execute( "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] # --- écart prix demandé vs estimation Vrai-Prix, par bannière ----------- registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] source_names = {s["id"]: s["name"] for s in registry} deltas: dict[str, list[float]] = {} for r in con.execute( "SELECT source, price," " CAST(json_extract(vraiprix, '$.value') AS REAL) vp" " FROM listings WHERE active=1" + DEDUP_CLAUSE + " AND price IS NOT NULL AND price > 0" " AND vraiprix LIKE '%estimation%'"): if not r["vp"] or r["vp"] <= 0: continue d = (r["price"] - r["vp"]) / r["vp"] * 100.0 if -80.0 <= d <= 300.0: # coupe les aberrations (terrains, données sales) deltas.setdefault(_franchise_of(r["source"], source_names), []).append(d) con.close() def _agg(name: str, ds: list[float]) -> dict: ds = sorted(ds) n = len(ds) med = ds[n // 2] if n % 2 else (ds[n // 2 - 1] + ds[n // 2]) / 2 return { "banniere": name, "n": n, "median_delta_pct": round(med, 1), "p25": round(ds[n // 4], 1), "p75": round(ds[(3 * n) // 4], 1), "pct_sur10": round(100 * sum(1 for d in ds if d > 10) / n, 1), "pct_juste": round(100 * sum(1 for d in ds if -5 <= d <= 10) / n, 1), "pct_sous5": round(100 * sum(1 for d in ds if d < -5) / n, 1), } tous = [d for ds in deltas.values() for d in ds] vraiprix = { "ensemble": _agg("Toutes bannières", tous) if tous else None, "bannieres": sorted( (_agg(k, v) for k, v in deltas.items() if len(v) >= 30), key=lambda x: -x["median_delta_pct"]), } # qualité des données : complétude, quarantaine, anomalies (par connecteur) from . import quality con2 = db.connect() try: qualite = quality.summary(con2) finally: con2.close() return {**dict(row), "vraiprix": vraiprix, "qualite": qualite, "recent_syncs": log} # --- Module Stats commun Groupe KA (voir frontend/src/ka/stats/SPEC.md) ------ @app.get("/api/stats/dashboard") def stats_dashboard( period: str | None = Query(None), from_: str | None = Query(None, alias="from"), to: str | None = Query(None), ): """Tableau de bord analytique (contrat SPEC ka-stats, cache 5 min).""" return kastats.dashboard(period, from_, to) @app.get("/api/stats/report") def stats_report( period: str = Query("30j"), from_: str | None = Query(None, alias="from"), to: str | None = Query(None), mode: str = Query("complet"), ): """Rapport PDF estampillé Groupe-KA (gabarit commun immoka/kapdf.py).""" from . import kapdf dash = kastats.dashboard(period, from_, to) site = { "wordmark": "House·Ka", "accent": "#0f6b4f", "domain": "www.house-ka.com", "tagline": "Homes-for-sale aggregator — Canada (outside Québec)", } pdf = kapdf.GroupeKAReport( site=site, dashboard=dash, mode="synthese" if mode == "synthese" else "complet").build() return Response( content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{kapdf.filename("house-ka", period)}"'}) @app.get("/api/stats/catalog") def stats_catalog( period: str = Query("30j"), from_: str | None = Query(None, alias="from"), to: str | None = Query(None), ): """v3 — blocs composables pour le constructeur de rapports personnalisés.""" from . import kapdf dash = kastats.dashboard(period, 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 . import kapdf period = str(spec.get("period") or "30j") dash = kastats.dashboard(period, str(spec.get("from") or "") or None, str(spec.get("to") or "") or None) site = { "wordmark": "House·Ka", "accent": "#0f6b4f", "domain": "www.house-ka.com", "tagline": "Homes-for-sale aggregator — Canada (outside Québec)", } 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, "Aucun bloc valide dans la composition") pdf = kapdf.GroupeKAReport( site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE, spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks}, ).build() fname = kapdf.filename("house-ka", period, kapdf.CUSTOM_MODE) return Response( content=pdf, 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": "démarré", "source": source or "toutes"} # --- Référencement : robots, sitemaps, résolution de slugs (voir immoka/seo.py) @app.get("/robots.txt") def robots(): return seo.robots_txt() @app.get("/sitemap.xml") def sitemap(): return seo.sitemap_index() @app.get("/sitemaps/{name}") def sitemap_part(name: str): return seo.sitemap_file(name) @app.get("/api/seo/resolve") def seo_resolve(ville: str | None = None, type: str | None = None): """Slug d'URL programmatique → valeurs exactes (pages /a-vendre du SPA).""" out = seo.resolve_slugs(ville, type) if out is None: raise HTTPException(404, "Slug inconnu") return out # --- Frontend statique ------------------------------------------------------- if FRONTEND_DIR.exists(): if (FRONTEND_DIR / "assets").is_dir(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets") @app.get("/doc", include_in_schema=False) def doc_redirect(): return RedirectResponse("/doc/", status_code=301) # /doc : documentation statique (index.html + captures + PDF) — montée # explicitement pour que le catch-all SPA ne l'intercepte pas. if (FRONTEND_DIR / "doc").is_dir(): app.mount("/doc", StaticFiles(directory=FRONTEND_DIR / "doc", html=True), name="doc") @app.get("/{full_path:path}") def spa(full_path: str, request: Request): target = FRONTEND_DIR / full_path if full_path and target.is_file(): return FileResponse(target) # rendu SEO côté serveur : HTML complet par route (meta, JSON-LD, # contenu dans #root que React remplace au montage). Ne doit JAMAIS # casser l'app → repli sur l'index brut à la moindre erreur. try: return seo.render_for_path("/" + full_path, dict(request.query_params)) except Exception: return FileResponse(FRONTEND_DIR / "index.html")