SPB Git

spb/house-ka

Public
26.7 KB · 638 lines python
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
Files · c9b9b3f3d08e0b5c5a3456bacb8474eccdf4b984
Menu

Theme