SPB Git

spb/immo-ka Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

Python 64% TypeScript 21.1% CSS 14.3% HTML 0.6%
10.2 KB · 259 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# web.py : API FastAPI (JSON) + service du frontend statique (frontend/)5# -----------------------------------------------------------------------------6from __future__ import annotations78import json9import threading10from pathlib import Path1112from fastapi import BackgroundTasks, FastAPI, HTTPException, Query13from fastapi.middleware.cors import CORSMiddleware14from fastapi.responses import FileResponse15from fastapi.staticfiles import StaticFiles1617from . import db, ingest1819ROOT = Path(__file__).resolve().parent.parent20SOURCES_PATH = ROOT / "data" / "sources.json"21# Frontend : build Vite (React) si présent, sinon la page statique de secours.22FRONTEND_DIST = ROOT / "frontend" / "dist"23FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"2425app = FastAPI(title="Immo-Ka API", version="0.1",26              description="Agrégateur de maisons à vendre — province de Québec")27app.add_middleware(CORSMiddleware, allow_origins=["*"],28                   allow_methods=["*"], allow_headers=["*"])2930_sync_lock = threading.Lock()3132# Déduplication de la famille RE/MAX : le flux central (remax_quebec) et les33# ~42 connecteurs de sous-agences (remax_ag_*) décrivent les MÊMES inscriptions,34# identifiées de façon unique par leur numéro Centris (= external_id). On masque35# donc toute fiche de sous-agence dès qu'une fiche de plus haute priorité existe36# (le central enrichi d'abord, puis la sous-agence au plus petit uid). Les fiches37# du central et des autres agences ne sont jamais masquées. → aucun double-comptage,38# et les sous-agences prennent le relais automatiquement si le central disparaît.39DEDUP_CLAUSE = (40    " AND NOT EXISTS (SELECT 1 FROM listings d WHERE d.active=1"41    "   AND d.external_id = listings.external_id AND d.uid <> listings.uid"42    "   AND (listings.source LIKE '%\\_ag\\_%' ESCAPE '\\')"43    "   AND ((d.source NOT LIKE '%\\_ag\\_%' ESCAPE '\\')"44    "        OR (d.source LIKE '%\\_ag\\_%' ESCAPE '\\' AND d.uid < listings.uid)))"45)464748def _row_to_dict(row) -> dict:49    d = dict(row)50    d["features"] = json.loads(d.get("features") or "[]")51    d["images"] = json.loads(d.get("images") or "[]")52    d["details"] = json.loads(d.get("details") or "{}")53    return d545556@app.get("/api/listings")57def list_listings(58    city: str | None = None,59    sector: str | None = None,60    region: str | None = None,61    property_type: str | None = None,62    source: str | None = None,63    price_max: float | None = None,64    price_min: float | None = None,65    bedrooms_min: int | None = None,66    bathrooms_min: int | None = None,67    area_min: float | None = None,       # superficie habitable minimale (pi²)68    q: str | None = None,69    active: int = 1,70    sort: str = "price_asc",             # price_asc | price_desc | recent71    limit: int = Query(500, le=2000),72    offset: int = 0,73):74    con = db.connect()75    sql = "SELECT * FROM listings WHERE 1=1"76    args: list = []77    if active in (0, 1):78        sql += " AND active=?"; args.append(active)79    if city:80        sql += " AND city=?"; args.append(city)81    if sector:82        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")83    if region:84        sql += " AND region=?"; args.append(region)85    if property_type:86        sql += " AND property_type=?"; args.append(property_type)87    if source:88        sql += " AND source=?"; args.append(source)89    if price_max is not None:90        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)91    if price_min is not None:92        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)93    if bedrooms_min is not None:94        sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)95    if bathrooms_min is not None:96        sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min)97    if area_min is not None:98        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)99    if q:100        sql += " AND (title LIKE ? OR address LIKE ? OR city LIKE ? OR mls LIKE ?)"101        args += [f"%{q}%"] * 4102    sql += DEDUP_CLAUSE103    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]104    order = {105        "price_asc": " ORDER BY price IS NULL, price ASC",106        "price_desc": " ORDER BY price IS NULL, price DESC",107        "recent": " ORDER BY first_seen DESC",108    }.get(sort, " ORDER BY price IS NULL, price ASC")109    sql += order + " LIMIT ? OFFSET ?"110    args += [limit, offset]111    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]112    con.close()113    return {"total": total, "count": len(rows), "listings": rows}114115116@app.get("/api/listings/{uid}")117def get_listing(uid: str):118    con = db.connect()119    row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()120    d = None121    if row is not None:122        d = _row_to_dict(row)123        # historique de prix (baisses/hausses du prix demandé)124        d["price_history"] = [dict(r) for r in con.execute(125            "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",126            (uid,)).fetchall()]127    con.close()128    if d is None:129        raise HTTPException(404, "Propriété introuvable")130    return d131132133@app.get("/api/listings.geojson")134def listings_geojson(135    city: str | None = None,136    property_type: str | None = None,137    source: str | None = None,138    price_max: float | None = None,139    price_min: float | None = None,140    bedrooms_min: int | None = None,141):142    """Propriétés géolocalisées (marqueurs de carte, champs allégés)."""143    con = db.connect()144    sql = ("SELECT uid, title, address, price, price_label, property_type,"145           " bedrooms, bathrooms, source, city, sector, images, lat, lng"146           " FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")147    args: list = []148    if city:149        sql += " AND city=?"; args.append(city)150    if property_type:151        sql += " AND property_type=?"; args.append(property_type)152    if source:153        sql += " AND source=?"; args.append(source)154    if price_max is not None:155        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)156    if price_min is not None:157        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)158    if bedrooms_min is not None:159        sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)160    sql += DEDUP_CLAUSE161    features = []162    for r in con.execute(sql, args).fetchall():163        images = json.loads(r["images"] or "[]")164        features.append({165            "type": "Feature",166            "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},167            "properties": {168                "uid": r["uid"], "title": r["title"], "address": r["address"],169                "price": r["price"], "price_label": r["price_label"],170                "property_type": r["property_type"], "bedrooms": r["bedrooms"],171                "bathrooms": r["bathrooms"], "source": r["source"],172                "city": r["city"], "sector": r["sector"],173                "image": images[0] if images else None,174            },175        })176    con.close()177    return {"type": "FeatureCollection", "features": features}178179180@app.get("/api/facets")181def facets(city: str | None = None):182    """Valeurs distinctes pour construire les filtres du frontend."""183    con = db.connect()184    sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"185    sector_args: list = []186    if city:187        sector_sql += " AND city=?"188        sector_args.append(city)189    out = {190        "cities": [r["city"] for r in con.execute(191            "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],192        "sectors": [r["sector"] for r in con.execute(193            sector_sql + " ORDER BY sector", sector_args)],194        "property_types": [r["property_type"] for r in con.execute(195            "SELECT DISTINCT property_type FROM listings WHERE active=1"196            " AND property_type<>'' ORDER BY property_type")],197        "sources": [dict(r) for r in con.execute(198            "SELECT source, COUNT(*) n FROM listings WHERE active=1"199            + DEDUP_CLAUSE + " GROUP BY source ORDER BY n DESC")],200    }201    con.close()202    return out203204205@app.get("/api/sources")206def sources():207    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]208    con = db.connect()209    counts = {r["source"]: r["n"] for r in con.execute(210        "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}211    last = {r["source"]: r["ts"] for r in con.execute(212        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}213    con.close()214    for s in registry:215        s["active_listings"] = counts.get(s["id"], 0)216        s["last_sync"] = last.get(s["id"])217    return {"sources": registry}218219220@app.get("/api/stats")221def stats():222    con = db.connect()223    row = con.execute(224        """SELECT COUNT(*) total,225                  COUNT(DISTINCT source) sources,226                  COUNT(DISTINCT city) cities,227                  AVG(price) avg_price,228                  MIN(price) min_price,229                  MAX(price) max_price230           FROM listings WHERE active=1""" + DEDUP_CLAUSE).fetchone()231    log = [dict(r) for r in con.execute(232        "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]233    con.close()234    return {**dict(row), "recent_syncs": log}235236237@app.post("/api/sync")238def trigger_sync(background: BackgroundTasks, source: str | None = None):239    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""240    def _job():241        with _sync_lock:242            ingest.run([source] if source else None)243    background.add_task(_job)244    return {"status": "démarré", "source": source or "toutes"}245246247# --- Frontend statique -------------------------------------------------------248if FRONTEND_DIR.exists():249250    if (FRONTEND_DIR / "assets").is_dir():251        app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")252253    @app.get("/{full_path:path}")254    def spa(full_path: str):255        target = FRONTEND_DIR / full_path256        if full_path and target.is_file():257            return FileResponse(target)258        return FileResponse(FRONTEND_DIR / "index.html")259