SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
26.8 KB · 639 lines python
Raw Blame History
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