Python 67%
TypeScript 18.2%
CSS 14.4%
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, region: str | None = None):179 """Grands commerces (bannières PAR PROVINCE) + transit les plus proches180 (Mapbox / OSM). `region` = province de l'annonce ; sinon inférée du point."""181 from . import commerces182 return commerces.nearby(lat, lng, region=region)183184185@app.get("/api/listings/{uid}")186def get_listing(uid: str, request: Request):187 con = db.connect()188 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()189 d = None190 if row is not None:191 d = _row_to_dict(row)192 # historique de prix (baisses/hausses du prix demandé)193 d["price_history"] = [dict(r) for r in con.execute(194 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",195 (uid,)).fetchall()]196 # commodités de proximité (cache par immeuble, voir immoka/poi.py)197 if d.get("lat") is not None and d.get("lng") is not None:198 key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"199 poi_row = con.execute(200 "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()201 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []202 else:203 d["poi"] = []204 # autres publications de la même propriété (doublons masqués par la205 # dédup, rattachés via dup_of) — pour la box « Aussi publiée sur… »206 d["duplicates"] = [dict(r) for r in con.execute(207 "SELECT uid, source, url, broker_name, agency, price_label"208 " FROM listings WHERE dup_of=? AND active=1 ORDER BY source",209 (uid,)).fetchall()]210 # statistiques de quartier — base quartier.db absente sur House-Ka211 # (couverture Québec seulement) : fiche_quartier retourne None sans elle212 try:213 from . import quartier214 dauid = d.get("dauid")215 d["quartier"] = quartier.fiche_quartier(216 d.get("lat"), d.get("lng"), d.get("city") or "",217 dauid if dauid and dauid != "hors-zone" else None)218 except Exception:219 d["quartier"] = None220 con.close()221 if d is None:222 raise HTTPException(404, "Propriété introuvable")223 kaid.track(auth.current_user(request), "detail_view", entity_type="property",224 entity_id=uid, features=_kaid_features(d))225 return d226227228@app.get("/api/listings.geojson")229def listings_geojson(230 city: str | None = None,231 sector: str | None = None,232 region: str | None = None,233 property_type: str | None = None,234 source: str | None = None,235 price_max: float | None = None,236 price_min: float | None = None,237 bedrooms_min: int | None = None,238 bathrooms_min: int | None = None,239 area_min: float | None = None,240 q: str | None = None,241 bbox: str | None = None,242 limit: int = Query(3000, le=8000),243):244 """Propriétés géolocalisées (marqueurs de carte, champs allégés).245246 Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible247 (index idx_listings_geo) ; `limit` plafonne la réponse ; la collection248 porte `totalGeocoded` (géolocalisées correspondantes) et249 `totalMatching` (avec ou sans coordonnées). Mêmes filtres que250 /api/listings pour que la carte suive exactement la recherche.251 """252 con = db.connect()253 sql = ("SELECT uid, title, address, price, price_label, property_type,"254 " bedrooms, bathrooms, source, city, sector, images, lat, lng, vraiprix,"255 " json_extract(COALESCE(details,'{}'),'$.cover_thumb') AS cover_thumb"256 " FROM listings WHERE active=1 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, "bbox attendu : ouest,sud,est,nord")263 sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"264 args += [south, north, west, east]265 if city:266 sql += " AND city=?"; args.append(city)267 if sector:268 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")269 if region:270 sql += " AND region=?"; args.append(region)271 if property_type:272 sql += " AND property_type=?"; args.append(property_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 bedrooms_min is not None:280 sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)281 if bathrooms_min is not None:282 sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min)283 if area_min is not None:284 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)285 if q:286 sql += " AND (title LIKE ? OR address LIKE ? OR city LIKE ? OR mls LIKE ?)"287 args += [f"%{q}%"] * 4288 sql += DEDUP_CLAUSE289290 total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]291 sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")292 args_all = list(args)293 if bbox:294 sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")295 del args_all[0:4]296 total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]297298 sql += " ORDER BY price IS NULL, price ASC LIMIT ?"299 args.append(limit)300 features = []301 for r in con.execute(sql, args).fetchall():302 images = json.loads(r["images"] or "[]")303 try:304 vp_val = (json.loads(r["vraiprix"]) or {}).get("value")305 except (TypeError, ValueError):306 vp_val = None307 features.append({308 "type": "Feature",309 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},310 "properties": {311 "uid": r["uid"],312 "title": None if r["address"] else r["title"],313 "address": r["address"],314 "price": r["price"], "price_label": r["price_label"],315 "property_type": r["property_type"], "bedrooms": r["bedrooms"],316 "bathrooms": r["bathrooms"], "source": r["source"],317 "city": r["city"], "sector": r["sector"],318 # miniature légère pour le popup mobile si le connecteur en a une319 "image": r["cover_thumb"] or (images[0] if images else None),320 "vp": vp_val,321 },322 })323 con.close()324 return {"type": "FeatureCollection", "features": features,325 "totalGeocoded": total_geo, "totalMatching": total_all}326327328@app.get("/api/facets")329def facets(city: str | None = None):330 """Valeurs distinctes pour construire les filtres du frontend."""331 con = db.connect()332 sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"333 sector_args: list = []334 if city:335 sector_sql += " AND city=?"336 sector_args.append(city)337 out = {338 "cities": [r["city"] for r in con.execute(339 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],340 "sectors": [r["sector"] for r in con.execute(341 sector_sql + " ORDER BY sector", sector_args)],342 "property_types": [r["property_type"] for r in con.execute(343 "SELECT property_type FROM listings WHERE active=1"344 " AND property_type<>''" + DEDUP_CLAUSE +345 " GROUP BY property_type ORDER BY COUNT(*) DESC")],346 "sources": [dict(r) for r in con.execute(347 "SELECT source, COUNT(*) n FROM listings WHERE active=1"348 + DEDUP_CLAUSE + " GROUP BY source ORDER BY n DESC")],349 }350 con.close()351 return out352353354@app.get("/api/sources")355def sources():356 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]357 con = db.connect()358 counts = {r["source"]: r["n"] for r in con.execute(359 "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}360 last = {r["source"]: r["ts"] for r in con.execute(361 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}362 con.close()363 for s in registry:364 s["active_listings"] = counts.get(s["id"], 0)365 s["last_sync"] = last.get(s["id"])366 return {"sources": registry}367368369# Rattachement d'une source à sa bannière (franchise) pour le regroupement.370_FRANCHISES = [371 ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")),372 ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")),373 ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")),374 ("Royal LePage", lambda s: s == "royal_lepage"),375 ("Groupe Sutton", lambda s: s == "sutton"),376 ("Keller Williams", lambda s: s.startswith("kw_")),377 ("DuProprio", lambda s: s == "duproprio"),378]379380381def _franchise_of(source: str, source_names: dict) -> str:382 for name, match in _FRANCHISES:383 if match(source):384 return name385 return source_names.get(source, source) # agence indépendante = elle-même386387388@app.get("/api/agencies")389def agencies():390 """Arbre bannière → sous-agences (bureaux) avec le nombre d'inscriptions.391392 Alimente la page « Sources » de l'app : chaque bannière est éclatée par393 sous-agence via le champ `agency` (bureau). Dédupliqué (n° Centris)."""394 con = db.connect()395 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]396 source_names = {s["id"]: s["name"] for s in registry}397 rows = con.execute(398 "SELECT source, COALESCE(NULLIF(agency,''), '') agency, COUNT(*) n"399 " FROM listings WHERE active=1" + DEDUP_CLAUSE +400 " GROUP BY source, agency").fetchall()401 con.close()402 tree: dict[str, dict] = {}403 for r in rows:404 fr = _franchise_of(r["source"], source_names)405 node = tree.setdefault(fr, {"franchise": fr, "total": 0, "agencies": {}})406 node["total"] += r["n"]407 # nom de sous-agence : le bureau (agency) sinon le nom de la source408 label = r["agency"] or source_names.get(r["source"], r["source"])409 a = node["agencies"].setdefault(label, {"name": label, "count": 0,410 "sources": set()})411 a["count"] += r["n"]412 a["sources"].add(r["source"])413 out = []414 for node in tree.values():415 ags = sorted(node["agencies"].values(), key=lambda x: -x["count"])416 for a in ags:417 a["sources"] = sorted(a["sources"])418 out.append({"franchise": node["franchise"], "total": node["total"],419 "sub_agencies": len(ags), "agencies": ags})420 out.sort(key=lambda x: -x["total"])421 return {"franchises": out}422423424@app.get("/api/stats")425def stats(syncs_since_h: float | None = None):426 con = db.connect()427 row = con.execute(428 """SELECT COUNT(*) total,429 COUNT(DISTINCT source) sources,430 COUNT(DISTINCT city) cities,431 AVG(price) avg_price,432 MIN(price) min_price,433 MAX(price) max_price434 FROM listings WHERE active=1""" + DEDUP_CLAUSE).fetchone()435 # Fenêtre de synchronisations pour la supervision (moniteur central api-ka) :436 # sans paramètre, comportement historique (20 dernières entrées) ; avec437 # ?syncs_since_h=<heures>, toutes les entrées de la fenêtre (borne 4000) —438 # évite les fausses alertes « stale » quand 100+ sources synchronisent439 # plus de 20 fois entre deux sondages.440 log: list[dict] = []441 if syncs_since_h is not None:442 cutoff = time.time() - syncs_since_h * 3600443 log = [dict(r) for r in con.execute(444 "SELECT * FROM sync_log WHERE ts >= ?"445 " ORDER BY ts DESC LIMIT 4000", (cutoff,))]446 if len(log) < 20:447 log = [dict(r) for r in con.execute(448 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]449450 # --- écart prix demandé vs estimation Vrai-Prix, par bannière -----------451 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]452 source_names = {s["id"]: s["name"] for s in registry}453 deltas: dict[str, list[float]] = {}454 for r in con.execute(455 "SELECT source, price,"456 " CAST(json_extract(vraiprix, '$.value') AS REAL) vp"457 " FROM listings WHERE active=1" + DEDUP_CLAUSE +458 " AND price IS NOT NULL AND price > 0"459 " AND vraiprix LIKE '%estimation%'"):460 if not r["vp"] or r["vp"] <= 0:461 continue462 d = (r["price"] - r["vp"]) / r["vp"] * 100.0463 if -80.0 <= d <= 300.0: # coupe les aberrations (terrains, données sales)464 deltas.setdefault(_franchise_of(r["source"], source_names), []).append(d)465 con.close()466467 def _agg(name: str, ds: list[float]) -> dict:468 ds = sorted(ds)469 n = len(ds)470 med = ds[n // 2] if n % 2 else (ds[n // 2 - 1] + ds[n // 2]) / 2471 return {472 "banniere": name, "n": n,473 "median_delta_pct": round(med, 1),474 "p25": round(ds[n // 4], 1), "p75": round(ds[(3 * n) // 4], 1),475 "pct_sur10": round(100 * sum(1 for d in ds if d > 10) / n, 1),476 "pct_juste": round(100 * sum(1 for d in ds if -5 <= d <= 10) / n, 1),477 "pct_sous5": round(100 * sum(1 for d in ds if d < -5) / n, 1),478 }479480 tous = [d for ds in deltas.values() for d in ds]481 vraiprix = {482 "ensemble": _agg("Toutes bannières", tous) if tous else None,483 "bannieres": sorted(484 (_agg(k, v) for k, v in deltas.items() if len(v) >= 30),485 key=lambda x: -x["median_delta_pct"]),486 }487 # qualité des données : complétude, quarantaine, anomalies (par connecteur)488 from . import quality489 con2 = db.connect()490 try:491 qualite = quality.summary(con2)492 finally:493 con2.close()494 return {**dict(row), "vraiprix": vraiprix, "qualite": qualite,495 "recent_syncs": log}496497498# --- Module Stats commun Groupe KA (voir frontend/src/ka/stats/SPEC.md) ------499@app.get("/api/stats/dashboard")500def stats_dashboard(501 period: str | None = Query(None),502 from_: str | None = Query(None, alias="from"),503 to: str | None = Query(None),504):505 """Tableau de bord analytique (contrat SPEC ka-stats, cache 5 min)."""506 return kastats.dashboard(period, from_, to)507508509@app.get("/api/stats/report")510def stats_report(511 period: str = Query("30j"),512 from_: str | None = Query(None, alias="from"),513 to: str | None = Query(None),514 mode: str = Query("complet"),515):516 """Rapport PDF estampillé Groupe-KA (gabarit commun immoka/kapdf.py)."""517 from . import kapdf518 dash = kastats.dashboard(period, from_, to)519 site = {520 "wordmark": "House·Ka",521 "accent": "#0f6b4f",522 "domain": "www.house-ka.com",523 "tagline": "Homes-for-sale aggregator — Canada (outside Québec)",524 }525 pdf = kapdf.GroupeKAReport(526 site=site, dashboard=dash,527 mode="synthese" if mode == "synthese" else "complet").build()528 return Response(529 content=pdf, media_type="application/pdf",530 headers={"Content-Disposition":531 f'attachment; filename="{kapdf.filename("house-ka", period)}"'})532533534535@app.get("/api/stats/catalog")536def stats_catalog(537 period: str = Query("30j"),538 from_: str | None = Query(None, alias="from"),539 to: str | None = Query(None),540):541 """v3 — blocs composables pour le constructeur de rapports personnalisés."""542 from . import kapdf543 dash = kastats.dashboard(period, from_, to)544 return {"updated": dash.get("updated"), "period": dash.get("period"),545 "blocks": kapdf.catalog(dash)}546547548@app.post("/api/stats/report/custom")549def stats_report_custom(spec: dict = Body(...)):550 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",551 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""552 from . import kapdf553 period = str(spec.get("period") or "30j")554 dash = kastats.dashboard(period, str(spec.get("from") or "") or None,555 str(spec.get("to") or "") or None)556 site = {557 "wordmark": "House·Ka",558 "accent": "#0f6b4f",559 "domain": "www.house-ka.com",560 "tagline": "Homes-for-sale aggregator — Canada (outside Québec)",561 }562 known = {b["key"] for b in kapdf.catalog(dash)}563 blocks = [b for b in (spec.get("blocks") or [])564 if isinstance(b, dict) and b.get("key") in known][:40]565 if not blocks:566 raise HTTPException(400, "Aucun bloc valide dans la composition")567 pdf = kapdf.GroupeKAReport(568 site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,569 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},570 ).build()571 fname = kapdf.filename("house-ka", period, kapdf.CUSTOM_MODE)572 return Response(573 content=pdf, media_type="application/pdf",574 headers={"Content-Disposition": f'attachment; filename="{fname}"'})575576@app.post("/api/sync")577def trigger_sync(background: BackgroundTasks, source: str | None = None):578 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""579 def _job():580 with _sync_lock:581 ingest.run([source] if source else None)582 background.add_task(_job)583 return {"status": "démarré", "source": source or "toutes"}584585586# --- Référencement : robots, sitemaps, résolution de slugs (voir immoka/seo.py)587@app.get("/robots.txt")588def robots():589 return seo.robots_txt()590591592@app.get("/sitemap.xml")593def sitemap():594 return seo.sitemap_index()595596597@app.get("/sitemaps/{name}")598def sitemap_part(name: str):599 return seo.sitemap_file(name)600601602@app.get("/api/seo/resolve")603def seo_resolve(ville: str | None = None, type: str | None = None):604 """Slug d'URL programmatique → valeurs exactes (pages /a-vendre du SPA)."""605 out = seo.resolve_slugs(ville, type)606 if out is None:607 raise HTTPException(404, "Slug inconnu")608 return out609610611# --- Frontend statique -------------------------------------------------------612if FRONTEND_DIR.exists():613614 if (FRONTEND_DIR / "assets").is_dir():615 app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")616617 @app.get("/doc", include_in_schema=False)618 def doc_redirect():619 return RedirectResponse("/doc/", status_code=301)620621 # /doc : documentation statique (index.html + captures + PDF) — montée622 # explicitement pour que le catch-all SPA ne l'intercepte pas.623 if (FRONTEND_DIR / "doc").is_dir():624 app.mount("/doc", StaticFiles(directory=FRONTEND_DIR / "doc", html=True), name="doc")625626 @app.get("/{full_path:path}")627 def spa(full_path: str, request: Request):628 target = FRONTEND_DIR / full_path629 if full_path and target.is_file():630 return FileResponse(target)631 # rendu SEO côté serveur : HTML complet par route (meta, JSON-LD,632 # contenu dans #root que React remplace au montage). Ne doit JAMAIS633 # casser l'app → repli sur l'index brut à la moindre erreur.634 try:635 return seo.render_for_path("/" + full_path,636 dict(request.query_params))637 except Exception:638 return FileResponse(FRONTEND_DIR / "index.html")639