1# -----------------------------------------------------------------------------2# House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# web.py : FastAPI JSON API + static frontend serving (frontend/)5# -----------------------------------------------------------------------------6from __future__ import annotations78import json9import threading10import time11from pathlib import Path1213from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request14from fastapi.middleware.cors import CORSMiddleware15from fastapi.middleware.gzip import GZipMiddleware16from fastapi.responses import FileResponse, RedirectResponse, Response17from fastapi.staticfiles import StaticFiles1819from . import auth, db, favorites, ingest, kaid, seo20from . import stats as kastats2122ROOT = Path(__file__).resolve().parent.parent23SOURCES_PATH = ROOT / "data" / "sources.json"24# Frontend : build Vite (React) si présent, sinon la page statique de secours.25FRONTEND_DIST = ROOT / "frontend" / "dist"26FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"2728app = FastAPI(title="House-Ka API", version="0.1",29 description="Homes-for-sale aggregator — Canada (outside Québec)")30app.add_middleware(CORSMiddleware, allow_origins=["*"],31 allow_methods=["*"], allow_headers=["*"])32# le GeoJSON complet pèse ~20 Mo — la compression est vitale pour la carte33app.add_middleware(GZipMiddleware, minimum_size=1024)34# connexion Google (compte Groupe-Ka partagé avec Lou-Ka) — voir immoka/auth.py35app.include_router(auth.router)36# favoris ♥ — magasin central au hub Groupe KA, voir immoka/favorites.py37app.include_router(favorites.router)3839# personnalisation KA ID v2 (immoka/kaid.py, canonique ka-ui.git/kaid)40kaid.init("house-ka")41app.include_router(kaid.build_router(auth.current_user))424344def _kaid_features(d: dict) -> dict:45 """Caractéristiques d une propriété pour le profil de préférences KA ID."""46 return {k: v for k, v in {47 "city": d.get("city"), "sector": d.get("sector"),48 "region": d.get("region"), "property_type": d.get("property_type"),49 "price": d.get("price"), "bedrooms": d.get("bedrooms"),50 "source": d.get("source"),51 }.items() if v not in (None, "")}52# moteur hypothécaire — taux réels multibanques + calculateur canadien,53# voir immoka/mortgage/ (docs/mortgage-engine.md)54from .mortgage import api as mortgage_api # noqa: E40255app.include_router(mortgage_api.router)5657_sync_lock = threading.Lock()5859# Déduplication de la famille RE/MAX : le flux central (remax_quebec) et les60# ~42 connecteurs de sous-agences (remax_ag_*) décrivent les MÊMES inscriptions,61# identifiées de façon unique par leur numéro Centris (= external_id). On masque62# donc toute fiche de sous-agence dès qu'une fiche de plus haute priorité existe63# (le central enrichi d'abord, puis la sous-agence au plus petit uid). Les fiches64# du central et des autres agences ne sont jamais masquées. → aucun double-comptage,65# et les sous-agences prennent le relais automatiquement si le central disparaît.66# Déduplication PRÉ-CALCULÉE : la colonne `dup_hidden` (remplie par67# db.refresh_dedup après chaque sync) marque les doublons de sous-agences. La68# lecture est ainsi instantanée (index) au lieu d'un sous-select corrélé par69# ligne (~300 s sur 75 k lignes). Voir db.refresh_dedup pour la règle.70# + `published=1` : seuil de qualité minimum (voir immoka/quality.py) — une71# annonce sans prix plausible, sans ville, sans type de bien ou sans contenu72# exploitable part en QUARANTAINE (grille, carte, stats, sources) jusqu'à73# complétion par les prochains cycles. La fiche détail par uid reste accessible.74DEDUP_CLAUSE = " AND dup_hidden=0 AND published=1"757677def _row_to_dict(row) -> dict:78 d = dict(row)79 d["features"] = json.loads(d.get("features") or "[]")80 d["images"] = json.loads(d.get("images") or "[]")81 d["details"] = json.loads(d.get("details") or "{}")82 if d.get("vraiprix"):83 try:84 d["vraiprix"] = json.loads(d["vraiprix"]) or None85 except (ValueError, TypeError):86 d["vraiprix"] = None87 # jours sur le marché (depuis la 1re observation par Immo-Ka ; la date88 # d'inscription de la source, si connue, est dans details.listed_at)89 if d.get("first_seen"):90 d["days_on_market"] = max(0, int((time.time() - d["first_seen"]) // 86400))91 return d929394@app.get("/api/listings")95def list_listings(96 request: Request,97 city: str | None = None,98 sector: str | None = None,99 region: str | None = None,100 property_type: str | None = None,101 source: str | None = None,102 price_max: float | None = None,103 price_min: float | None = None,104 bedrooms_min: int | None = None,105 bathrooms_min: int | None = None,106 area_min: float | None = None, # superficie habitable minimale (pi²)107 q: str | None = None,108 active: int = 1,109 sort: str = "price_asc", # price_asc | price_desc | recent110 limit: int = Query(500, le=2000),111 offset: int = 0,112):113 con = db.connect()114 sql = "SELECT * FROM listings WHERE 1=1"115 args: list = []116 if active in (0, 1):117 sql += " AND active=?"; args.append(active)118 if city:119 sql += " AND city=?"; args.append(city)120 if sector:121 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")122 if region:123 sql += " AND region=?"; args.append(region)124 if property_type:125 sql += " AND property_type=?"; args.append(property_type)126 if source:127 sql += " AND source=?"; args.append(source)128 if price_max is not None:129 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)130 if price_min is not None:131 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)132 if bedrooms_min is not None:133 sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)134 if bathrooms_min is not None:135 sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min)136 if area_min is not None:137 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)138 if q:139 sql += " AND (title LIKE ? OR address LIKE ? OR city LIKE ? OR mls LIKE ?)"140 args += [f"%{q}%"] * 4141 sql += DEDUP_CLAUSE142 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]143 order = {144 "price_asc": " ORDER BY price IS NULL, price ASC",145 "price_desc": " ORDER BY price IS NULL, price DESC",146 "recent": " ORDER BY first_seen DESC",147 }.get(sort, " ORDER BY price IS NULL, price ASC")148 sql += order + " LIMIT ? OFFSET ?"149 args += [limit, offset]150 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]151 con.close()152 # personnalisation KA ID : journal + reclassement. Tri par prix (défaut) =153 # ordre explicite → annotation seule (blend=0) ; « recent » → reclassement.154 personalized = False155 user = auth.current_user(request)156 if user:157 filters = {k: v for k, v in {158 "city": city, "sector": sector, "region": region,159 "property_type": property_type, "price_max": price_max,160 "price_min": price_min, "bedrooms_min": bedrooms_min,161 "q": q}.items() if v not in (None, "")}162 if filters and offset == 0:163 kaid.track(user, "search", query=q, filters=filters)164 active = {k for k in ("city", "sector", "region", "property_type")165 if filters.get(k)}166 if price_max is not None or price_min is not None:167 active.add("price")168 if bedrooms_min is not None:169 active.add("bedrooms")170 rows, personalized = kaid.rerank(171 rows, user, features_of=_kaid_features, active_dims=active,172 blend=0.35 if sort == "recent" else 0.0)173 return {"total": total, "count": len(rows), "listings": rows,174 "personalized": personalized}175176177@app.get("/api/commerces")178def commerces_at(lat: float, lng: float):179 """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""180 from . import commerces181 return commerces.nearby(lat, lng)182183184@app.get("/api/listings/{uid}")185def get_listing(uid: str, request: Request):186 con = db.connect()187 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()188 d = None189 if row is not None:190 d = _row_to_dict(row)191 # historique de prix (baisses/hausses du prix demandé)192 d["price_history"] = [dict(r) for r in con.execute(193 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",194 (uid,)).fetchall()]195 # commodités de proximité (cache par immeuble, voir immoka/poi.py)196 if d.get("lat") is not None and d.get("lng") is not None:197 key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"198 poi_row = con.execute(199 "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()200 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []201 else:202 d["poi"] = []203 # autres publications de la même propriété (doublons masqués par la204 # dédup, rattachés via dup_of) — pour la box « Aussi publiée sur… »205 d["duplicates"] = [dict(r) for r in con.execute(206 "SELECT uid, source, url, broker_name, agency, price_label"207 " FROM listings WHERE dup_of=? AND active=1 ORDER BY source",208 (uid,)).fetchall()]209 # statistiques de quartier — base quartier.db absente sur House-Ka210 # (couverture Québec seulement) : fiche_quartier retourne None sans elle211 try:212 from . import quartier213 dauid = d.get("dauid")214 d["quartier"] = quartier.fiche_quartier(215 d.get("lat"), d.get("lng"), d.get("city") or "",216 dauid if dauid and dauid != "hors-zone" else None)217 except Exception:218 d["quartier"] = None219 con.close()220 if d is None:221 raise HTTPException(404, "Propriété introuvable")222 kaid.track(auth.current_user(request), "detail_view", entity_type="property",223 entity_id=uid, features=_kaid_features(d))224 return d225226227@app.get("/api/listings.geojson")228def listings_geojson(229 city: str | None = None,230 sector: str | None = None,231 region: str | None = None,232 property_type: str | None = None,233 source: str | None = None,234 price_max: float | None = None,235 price_min: float | None = None,236 bedrooms_min: int | None = None,237 bathrooms_min: int | None = None,238 area_min: float | None = None,239 q: str | None = None,240 bbox: str | None = None,241 limit: int = Query(3000, le=8000),242):243 """Propriétés géolocalisées (marqueurs de carte, champs allégés).244245 Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible246 (index idx_listings_geo) ; `limit` plafonne la réponse ; la collection247 porte `totalGeocoded` (géolocalisées correspondantes) et248 `totalMatching` (avec ou sans coordonnées). Mêmes filtres que249 /api/listings pour que la carte suive exactement la recherche.250 """251 con = db.connect()252 sql = ("SELECT uid, title, address, price, price_label, property_type,"253 " bedrooms, bathrooms, source, city, sector, images, lat, lng, vraiprix,"254 " json_extract(COALESCE(details,'{}'),'$.cover_thumb') AS cover_thumb"255 " FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")256 args: list = []257 if bbox:258 try:259 west, south, east, north = (float(v) for v in bbox.split(","))260 except ValueError:261 raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")262 sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"263 args += [south, north, west, east]264 if city:265 sql += " AND city=?"; args.append(city)266 if sector:267 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")268 if region:269 sql += " AND region=?"; args.append(region)270 if property_type:271 sql += " AND property_type=?"; args.append(property_type)272 if source:273 sql += " AND source=?"; args.append(source)274 if price_max is not None:275 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)276 if price_min is not None:277 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)278 if bedrooms_min is not None:279 sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)280 if bathrooms_min is not None:281 sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min)282 if area_min is not None:283 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)284 if q:285 sql += " AND (title LIKE ? OR address LIKE ? OR city LIKE ? OR mls LIKE ?)"286 args += [f"%{q}%"] * 4287 sql += DEDUP_CLAUSE288289 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]290 sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")291 args_all = list(args)292 if bbox:293 sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")294 del args_all[0:4]295 total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]296297 sql += " ORDER BY price IS NULL, price ASC LIMIT ?"298 args.append(limit)299 features = []300 for r in con.execute(sql, args).fetchall():301 images = json.loads(r["images"] or "[]")302 try:303 vp_val = (json.loads(r["vraiprix"]) or {}).get("value")304 except (TypeError, ValueError):305 vp_val = None306 features.append({307 "type": "Feature",308 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},309 "properties": {310 "uid": r["uid"],311 "title": None if r["address"] else r["title"],312 "address": r["address"],313 "price": r["price"], "price_label": r["price_label"],314 "property_type": r["property_type"], "bedrooms": r["bedrooms"],315 "bathrooms": r["bathrooms"], "source": r["source"],316 "city": r["city"], "sector": r["sector"],317 # miniature légère pour le popup mobile si le connecteur en a une318 "image": r["cover_thumb"] or (images[0] if images else None),319 "vp": vp_val,320 },321 })322 con.close()323 return {"type": "FeatureCollection", "features": features,324 "totalGeocoded": total_geo, "totalMatching": total_all}325326327@app.get("/api/facets")328def facets(city: str | None = None):329 """Valeurs distinctes pour construire les filtres du frontend."""330 con = db.connect()331 sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"332 sector_args: list = []333 if city:334 sector_sql += " AND city=?"335 sector_args.append(city)336 out = {337 "cities": [r["city"] for r in con.execute(338 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],339 "sectors": [r["sector"] for r in con.execute(340 sector_sql + " ORDER BY sector", sector_args)],341 "property_types": [r["property_type"] for r in con.execute(342 "SELECT property_type FROM listings WHERE active=1"343 " AND property_type<>''" + DEDUP_CLAUSE +344 " GROUP BY property_type ORDER BY COUNT(*) DESC")],345 "sources": [dict(r) for r in con.execute(346 "SELECT source, COUNT(*) n FROM listings WHERE active=1"347 + DEDUP_CLAUSE + " GROUP BY source ORDER BY n DESC")],348 }349 con.close()350 return out351352353@app.get("/api/sources")354def sources():355 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]356 con = db.connect()357 counts = {r["source"]: r["n"] for r in con.execute(358 "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}359 last = {r["source"]: r["ts"] for r in con.execute(360 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}361 con.close()362 for s in registry:363 s["active_listings"] = counts.get(s["id"], 0)364 s["last_sync"] = last.get(s["id"])365 return {"sources": registry}366367368# Rattachement d'une source à sa bannière (franchise) pour le regroupement.369_FRANCHISES = [370 ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")),371 ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")),372 ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")),373 ("Royal LePage", lambda s: s == "royal_lepage"),374 ("Groupe Sutton", lambda s: s == "sutton"),375 ("Keller Williams", lambda s: s.startswith("kw_")),376 ("DuProprio", lambda s: s == "duproprio"),377]378379380def _franchise_of(source: str, source_names: dict) -> str:381 for name, match in _FRANCHISES:382 if match(source):383 return name384 return source_names.get(source, source) # agence indépendante = elle-même385386387@app.get("/api/agencies")388def agencies():389 """Arbre bannière → sous-agences (bureaux) avec le nombre d'inscriptions.390391 Alimente la page « Sources » de l'app : chaque bannière est éclatée par392 sous-agence via le champ `agency` (bureau). Dédupliqué (n° Centris)."""393 con = db.connect()394 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]395 source_names = {s["id"]: s["name"] for s in registry}396 rows = con.execute(397 "SELECT source, COALESCE(NULLIF(agency,''), '') agency, COUNT(*) n"398 " FROM listings WHERE active=1" + DEDUP_CLAUSE +399 " GROUP BY source, agency").fetchall()400 con.close()401 tree: dict[str, dict] = {}402 for r in rows:403 fr = _franchise_of(r["source"], source_names)404 node = tree.setdefault(fr, {"franchise": fr, "total": 0, "agencies": {}})405 node["total"] += r["n"]406 # nom de sous-agence : le bureau (agency) sinon le nom de la source407 label = r["agency"] or source_names.get(r["source"], r["source"])408 a = node["agencies"].setdefault(label, {"name": label, "count": 0,409 "sources": set()})410 a["count"] += r["n"]411 a["sources"].add(r["source"])412 out = []413 for node in tree.values():414 ags = sorted(node["agencies"].values(), key=lambda x: -x["count"])415 for a in ags:416 a["sources"] = sorted(a["sources"])417 out.append({"franchise": node["franchise"], "total": node["total"],418 "sub_agencies": len(ags), "agencies": ags})419 out.sort(key=lambda x: -x["total"])420 return {"franchises": out}421422423@app.get("/api/stats")424def stats(syncs_since_h: float | None = None):425 con = db.connect()426 row = con.execute(427 """SELECT COUNT(*) total,428 COUNT(DISTINCT source) sources,429 COUNT(DISTINCT city) cities,430 AVG(price) avg_price,431 MIN(price) min_price,432 MAX(price) max_price433 FROM listings WHERE active=1""" + DEDUP_CLAUSE).fetchone()434 # Fenêtre de synchronisations pour la supervision (moniteur central api-ka) :435 # sans paramètre, comportement historique (20 dernières entrées) ; avec436 # ?syncs_since_h=<heures>, toutes les entrées de la fenêtre (borne 4000) —437 # évite les fausses alertes « stale » quand 100+ sources synchronisent438 # plus de 20 fois entre deux sondages.439 log: list[dict] = []440 if syncs_since_h is not None:441 cutoff = time.time() - syncs_since_h * 3600442 log = [dict(r) for r in con.execute(443 "SELECT * FROM sync_log WHERE ts >= ?"444 " ORDER BY ts DESC LIMIT 4000", (cutoff,))]445 if len(log) < 20:446 log = [dict(r) for r in con.execute(447 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]448449 # --- écart prix demandé vs estimation Vrai-Prix, par bannière -----------450 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]451 source_names = {s["id"]: s["name"] for s in registry}452 deltas: dict[str, list[float]] = {}453 for r in con.execute(454 "SELECT source, price,"455 " CAST(json_extract(vraiprix, '$.value') AS REAL) vp"456 " FROM listings WHERE active=1" + DEDUP_CLAUSE +457 " AND price IS NOT NULL AND price > 0"458 " AND vraiprix LIKE '%estimation%'"):459 if not r["vp"] or r["vp"] <= 0:460 continue461 d = (r["price"] - r["vp"]) / r["vp"] * 100.0462 if -80.0 <= d <= 300.0: # coupe les aberrations (terrains, données sales)463 deltas.setdefault(_franchise_of(r["source"], source_names), []).append(d)464 con.close()465466 def _agg(name: str, ds: list[float]) -> dict:467 ds = sorted(ds)468 n = len(ds)469 med = ds[n // 2] if n % 2 else (ds[n // 2 - 1] + ds[n // 2]) / 2470 return {471 "banniere": name, "n": n,472 "median_delta_pct": round(med, 1),473 "p25": round(ds[n // 4], 1), "p75": round(ds[(3 * n) // 4], 1),474 "pct_sur10": round(100 * sum(1 for d in ds if d > 10) / n, 1),475 "pct_juste": round(100 * sum(1 for d in ds if -5 <= d <= 10) / n, 1),476 "pct_sous5": round(100 * sum(1 for d in ds if d < -5) / n, 1),477 }478479 tous = [d for ds in deltas.values() for d in ds]480 vraiprix = {481 "ensemble": _agg("Toutes bannières", tous) if tous else None,482 "bannieres": sorted(483 (_agg(k, v) for k, v in deltas.items() if len(v) >= 30),484 key=lambda x: -x["median_delta_pct"]),485 }486 # qualité des données : complétude, quarantaine, anomalies (par connecteur)487 from . import quality488 con2 = db.connect()489 try:490 qualite = quality.summary(con2)491 finally:492 con2.close()493 return {**dict(row), "vraiprix": vraiprix, "qualite": qualite,494 "recent_syncs": log}495496497# --- Module Stats commun Groupe KA (voir frontend/src/ka/stats/SPEC.md) ------498@app.get("/api/stats/dashboard")499def stats_dashboard(500 period: str | None = Query(None),501 from_: str | None = Query(None, alias="from"),502 to: str | None = Query(None),503):504 """Tableau de bord analytique (contrat SPEC ka-stats, cache 5 min)."""505 return kastats.dashboard(period, from_, to)506507508@app.get("/api/stats/report")509def stats_report(510 period: str = Query("30j"),511 from_: str | None = Query(None, alias="from"),512 to: str | None = Query(None),513 mode: str = Query("complet"),514):515 """Rapport PDF estampillé Groupe-KA (gabarit commun immoka/kapdf.py)."""516 from . import kapdf517 dash = kastats.dashboard(period, from_, to)518 site = {519 "wordmark": "House·Ka",520 "accent": "#0f6b4f",521 "domain": "www.house-ka.com",522 "tagline": "Homes-for-sale aggregator — Canada (outside Québec)",523 }524 pdf = kapdf.GroupeKAReport(525 site=site, dashboard=dash,526 mode="synthese" if mode == "synthese" else "complet").build()527 return Response(528 content=pdf, media_type="application/pdf",529 headers={"Content-Disposition":530 f'attachment; filename="{kapdf.filename("house-ka", period)}"'})531532533534@app.get("/api/stats/catalog")535def stats_catalog(536 period: str = Query("30j"),537 from_: str | None = Query(None, alias="from"),538 to: str | None = Query(None),539):540 """v3 — blocs composables pour le constructeur de rapports personnalisés."""541 from . import kapdf542 dash = kastats.dashboard(period, from_, to)543 return {"updated": dash.get("updated"), "period": dash.get("period"),544 "blocks": kapdf.catalog(dash)}545546547@app.post("/api/stats/report/custom")548def stats_report_custom(spec: dict = Body(...)):549 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",550 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""551 from . import kapdf552 period = str(spec.get("period") or "30j")553 dash = kastats.dashboard(period, str(spec.get("from") or "") or None,554 str(spec.get("to") or "") or None)555 site = {556 "wordmark": "House·Ka",557 "accent": "#0f6b4f",558 "domain": "www.house-ka.com",559 "tagline": "Homes-for-sale aggregator — Canada (outside Québec)",560 }561 known = {b["key"] for b in kapdf.catalog(dash)}562 blocks = [b for b in (spec.get("blocks") or [])563 if isinstance(b, dict) and b.get("key") in known][:40]564 if not blocks:565 raise HTTPException(400, "Aucun bloc valide dans la composition")566 pdf = kapdf.GroupeKAReport(567 site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,568 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},569 ).build()570 fname = kapdf.filename("house-ka", period, kapdf.CUSTOM_MODE)571 return Response(572 content=pdf, media_type="application/pdf",573 headers={"Content-Disposition": f'attachment; filename="{fname}"'})574575@app.post("/api/sync")576def trigger_sync(background: BackgroundTasks, source: str | None = None):577 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""578 def _job():579 with _sync_lock:580 ingest.run([source] if source else None)581 background.add_task(_job)582 return {"status": "démarré", "source": source or "toutes"}583584585# --- Référencement : robots, sitemaps, résolution de slugs (voir immoka/seo.py)586@app.get("/robots.txt")587def robots():588 return seo.robots_txt()589590591@app.get("/sitemap.xml")592def sitemap():593 return seo.sitemap_index()594595596@app.get("/sitemaps/{name}")597def sitemap_part(name: str):598 return seo.sitemap_file(name)599600601@app.get("/api/seo/resolve")602def seo_resolve(ville: str | None = None, type: str | None = None):603 """Slug d'URL programmatique → valeurs exactes (pages /a-vendre du SPA)."""604 out = seo.resolve_slugs(ville, type)605 if out is None:606 raise HTTPException(404, "Slug inconnu")607 return out608609610# --- Frontend statique -------------------------------------------------------611if FRONTEND_DIR.exists():612613 if (FRONTEND_DIR / "assets").is_dir():614 app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")615616 @app.get("/doc", include_in_schema=False)617 def doc_redirect():618 return RedirectResponse("/doc/", status_code=301)619620 # /doc : documentation statique (index.html + captures + PDF) — montée621 # explicitement pour que le catch-all SPA ne l'intercepte pas.622 if (FRONTEND_DIR / "doc").is_dir():623 app.mount("/doc", StaticFiles(directory=FRONTEND_DIR / "doc", html=True), name="doc")624625 @app.get("/{full_path:path}")626 def spa(full_path: str, request: Request):627 target = FRONTEND_DIR / full_path628 if full_path and target.is_file():629 return FileResponse(target)630 # rendu SEO côté serveur : HTML complet par route (meta, JSON-LD,631 # contenu dans #root que React remplace au montage). Ne doit JAMAIS632 # casser l'app → repli sur l'index brut à la moindre erreur.633 try:634 return seo.render_for_path("/" + full_path,635 dict(request.query_params))636 except Exception:637 return FileResponse(FRONTEND_DIR / "index.html")638