SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

45.0 KB · 1,070 lines python
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist)5# -----------------------------------------------------------------------------6from __future__ import annotations78import json9import threading10from pathlib import Path1112from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request13from fastapi.middleware.cors import CORSMiddleware14from fastapi.middleware.gzip import GZipMiddleware15from fastapi.responses import FileResponse, RedirectResponse16from fastapi.staticfiles import StaticFiles1718from . import auth, db, ingest, kaid1920ROOT = Path(__file__).resolve().parent.parent21SOURCES_PATH = ROOT / "data" / "sources.json"22FRONTEND_DIST = ROOT / "frontend" / "dist"2324app = FastAPI(title="Lou-Ka API", version="1.0",25              description="Agrégateur de logements à louer — province de Québec")26app.add_middleware(CORSMiddleware, allow_origins=["*"],27                   allow_methods=["*"], allow_headers=["*"])28app.add_middleware(GZipMiddleware, minimum_size=1000)2930_sync_lock = threading.Lock()3132# comptes utilisateurs (connexion Google) — voir louka/auth.py33app.include_router(auth.router)3435# personnalisation KA ID v2 (louka/kaid.py, canonique ka-ui.git/kaid)36kaid.init("lou-ka")3738_kaid_ka_cache: dict[int, str] = {}394041def _kaid_user(request) -> dict | None:42    """Session enrichie du ka_id (absent du témoin legacy) — None sinon."""43    user = auth.current_user(request)44    if not user:45        return None46    uid = user.get("uid")47    ka = _kaid_ka_cache.get(uid)48    if ka is None:49        con = db.connect()50        row = con.execute("SELECT ka_id FROM users WHERE id=?", (uid,)).fetchone()51        con.close()52        ka = (row["ka_id"] if row else "") or ""53        _kaid_ka_cache[uid] = ka54    return {**user, "ka_id": ka} if ka.startswith("ka-") else None555657def _kaid_features(d: dict) -> dict:58    """Caractéristiques d un logement pour le profil de préférences KA ID."""59    return {k: v for k, v in {60        "city": d.get("city"), "sector": d.get("sector"),61        "unit_type": d.get("unit_type"), "pets": d.get("pets"),62        "furnished": (bool(d.get("furnished"))63                      if d.get("furnished") is not None else None),64        "price": d.get("price"), "deal": d.get("fv_verdict"),65        "source": d.get("source"),66    }.items() if v not in (None, "")}676869app.include_router(kaid.build_router(_kaid_user))7071# rôles, favoris, pages gestionnaires — voir louka/accounts.py72from . import accounts  # noqa: E402  (import tardif : évite le cycle web<->accounts)73app.include_router(accounts.router)7475# personnalisation du profil (bio, réseaux sociaux, photo) — louka/profile.py76from . import profile as user_profile  # noqa: E40277app.include_router(user_profile.router)7879# section « Location court terme » — sous-système isolé (louka/shortterm/)80from .shortterm import web as ct_web  # noqa: E40281app.include_router(ct_web.router)8283# annuaire des déménageurs du Québec — page partagée avec Immo-Ka84from . import demenageurs  # noqa: E40285app.include_router(demenageurs.router)8687# fichiers téléversés (photos de profil) — data/uploads/88_UPLOADS = ROOT / "data" / "uploads"89_UPLOADS.mkdir(parents=True, exist_ok=True)90app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads")919293def _row_to_dict(row) -> dict:94    d = dict(row)95    d["amenities"] = json.loads(d.get("amenities") or "[]")96    d["images"] = json.loads(d.get("images") or "[]")97    # galerie nettoyée par le contrôle qualité (imgcheck.py) : liens morts,98    # minuscules, placeholders et doublons retirés — prime sur la galerie brute99    imgs_ok = d.pop("images_ok", None)100    if imgs_ok:101        try:102            cleaned = json.loads(imgs_ok)103            if isinstance(cleaned, list):104                d["images"] = cleaned105        except (ValueError, TypeError):106            pass107    d.pop("img_audit", None)108    d["details"] = json.loads(d.get("details") or "{}")109    # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… »)110    if d.get("dup_sources"):111        try:112            d["dup_sources"] = json.loads(d["dup_sources"])113        except (ValueError, TypeError):114            d["dup_sources"] = []115    if d.get("furnished") is not None:116        d["furnished"] = bool(d["furnished"])117    return d118119120@app.get("/api/listings")121def list_listings(122    request: Request,123    city: str | None = None,124    sector: str | None = None,125    unit_type: str | None = None,126    source: str | None = None,127    price_max: float | None = None,128    price_min: float | None = None,129    pets: str | None = None,             # "oui" -> oui OU conditions130    furnished: int | None = None,        # 1 / 0131    available_by: str | None = None,     # ISO : dispo maintenant ou avant date132    area_min: float | None = None,       # superficie minimale (pi²)133    q: str | None = None,134    deal: str | None = None,             # sous | marche | sur (fair value)135    kascore_min: float | None = None,    # KA Score global minimal136    sort: str | None = None,             # "deal" affaires | "ka" KA Score137    active: int = 1,138    limit: int = Query(500, le=2000),139    offset: int = 0,140):141    con = db.connect()142    sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"143           " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"144           " fv.confidence AS fv_confidence," + _KS_COLS +145           " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +146           " WHERE dup_of IS NULL")   # doublons masqués147    args: list = []148    if active in (0, 1):149        sql += " AND active=?"; args.append(active)150    if active == 1:   # quarantaine qualité : jamais dans les résultats publics151        sql += " AND published=1"152    if city:153        sql += " AND city=?"; args.append(city)154    if sector:155        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")156    if unit_type:157        sql += " AND unit_type=?"; args.append(unit_type)158    if source:159        sql += " AND source=?"; args.append(source)160    if price_max is not None:161        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)162    if price_min is not None:163        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)164    if pets == "oui":165        sql += " AND pets IN ('oui','conditions')"166    elif pets:167        sql += " AND pets=?"; args.append(pets)168    if furnished in (0, 1):169        sql += " AND furnished=?"; args.append(furnished)170    if available_by:171        # « now » trié après les dates ISO : comparaison explicite172        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \173               " AND availability_date<=?))"174        args.append(available_by)175    if area_min is not None:176        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)177    if q:178        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"179        args += [f"%{q}%"] * 3180    if deal in ("sous", "marche", "sur"):181        sql += " AND fv.verdict=?"; args.append(deal)182    if kascore_min is not None:183        sql += " AND ks.global IS NOT NULL AND ks.global>=?"184        args.append(kascore_min)185    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]186    if sort == "deal":   # meilleures affaires d'abord (écart le plus négatif)187        sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"188                " price IS NULL, price ASC LIMIT ? OFFSET ?")189    elif sort == "ka":   # meilleurs emplacements d'abord (KA Score global)190        sql += (" ORDER BY ks.global IS NULL, ks.global DESC,"191                " price IS NULL, price ASC LIMIT ? OFFSET ?")192    elif sort == "prix":193        sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"194    else:                # défaut : les plus récentes d'abord195        sql += " ORDER BY first_seen DESC, price IS NULL, price ASC LIMIT ? OFFSET ?"196    args += [limit, offset]197    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]198    con.close()199    # personnalisation KA ID : journal + reclassement (tri par défaut seulement,200    # jamais un tri explicite ; intention de session > préférences long terme)201    personalized = False202    user = _kaid_user(request)203    if user:204        filters = {k: v for k, v in {205            "city": city, "sector": sector, "unit_type": unit_type,206            "price_max": price_max, "price_min": price_min, "pets": pets,207            "furnished": furnished, "deal": deal, "q": q}.items()208            if v not in (None, "")}209        if filters and offset == 0:210            kaid.track(user, "search", query=q, filters=filters)211        if sort in (None, "", "recent"):212            active = {k for k in ("city", "sector", "unit_type", "pets", "deal")213                      if filters.get(k)}214            if price_max is not None or price_min is not None:215                active.add("price")216            if furnished in (0, 1):217                active.add("furnished")218            rows, personalized = kaid.rerank(219                rows, user, features_of=_kaid_features, active_dims=active)220    return {"total": total, "count": len(rows), "listings": rows,221            "personalized": personalized}222223224@app.get("/api/listings.geojson")225def listings_geojson(226    city: str | None = None,227    sector: str | None = None,228    unit_type: str | None = None,229    source: str | None = None,230    price_max: float | None = None,231    price_min: float | None = None,232    pets: str | None = None,233    furnished: int | None = None,234    available_by: str | None = None,235    area_min: float | None = None,236    q: str | None = None,237    bbox: str | None = None,238    limit: int = Query(3000, le=8000),239):240    """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).241242    Léger par conception : seuls les champs nécessaires aux marqueurs et aux243    fiches de la carte sont inclus (1 photo, pas de description).244245    Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible246    (index idx_listings_geo) ; `limit` plafonne la réponse. La collection247    porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées248    correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées),249    pour afficher « N résultats sans position sur la carte ».250    """251    con = db.connect()252    sql = ("SELECT uid, title, address, price, price_label, unit_type,"253           " availability_date, source, city, sector, area_sqft, images,"254           " images_ok, lat, lng, fv.verdict AS fv_verdict,"255           " fv.deviation AS fv_deviation"256           " FROM listings LEFT JOIN fairvalue fv USING (uid)"257           " WHERE active=1 AND published=1 AND dup_of IS NULL"258           " AND lat IS NOT NULL AND lng IS NOT NULL")259    args: list = []260    if bbox:261        try:262            west, south, east, north = (float(v) for v in bbox.split(","))263        except ValueError:264            raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")265        sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"266        args += [south, north, west, east]267    if city:268        sql += " AND city=?"; args.append(city)269    if sector:270        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")271    if unit_type:272        sql += " AND unit_type=?"; args.append(unit_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 pets == "oui":280        sql += " AND pets IN ('oui','conditions')"281    elif pets:282        sql += " AND pets=?"; args.append(pets)283    if furnished in (0, 1):284        sql += " AND furnished=?"; args.append(furnished)285    if available_by:286        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \287               " AND availability_date<=?))"288        args.append(available_by)289    if area_min is not None:290        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)291    if q:292        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"293        args += [f"%{q}%"] * 3294295    total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]296    # Même requête sans l'exigence de coordonnées ni le bbox : mesure les297    # annonces filtrées invisibles sur la carte (affordance « sans position »).298    sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")299    args_all = list(args)300    if bbox:301        sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")302        del args_all[0:4]303    total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]304305    sql += " ORDER BY first_seen DESC LIMIT ?"306    args.append(limit)307308    features = []309    for r in con.execute(sql, args).fetchall():310        # galerie nettoyée si auditée (même vide : les images brutes sont311        # alors toutes mortes/placeholder — le frontend affiche le visuel312        # de secours Lou-Ka plutôt qu'une image cassée)313        images = json.loads((r["images_ok"] if r["images_ok"] is not None314                             else r["images"]) or "[]")315        features.append({316            "type": "Feature",317            "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},318            "properties": {319                "uid": r["uid"], "title": r["title"], "address": r["address"],320                "price": r["price"],321                "price_label": r["price_label"], "unit_type": r["unit_type"],322                "availability_date": r["availability_date"],323                "source": r["source"], "city": r["city"], "sector": r["sector"],324                "area_sqft": r["area_sqft"],325                "fv_verdict": r["fv_verdict"], "fv_deviation": r["fv_deviation"],326                "image": images[0] if images else None,327            },328        })329    con.close()330    return {"type": "FeatureCollection", "features": features,331            "totalGeocoded": total_geo, "totalMatching": total_all}332333334# --- Recherche unifiée liste + carte -----------------------------------------335# Une seule vérité partagée : la même requête sert la page de liste ET les336# points de la carte, dans la même réponse. Le compteur affiché en tête de337# liste est par construction égal au nombre de points envoyés à la carte.338339_SEARCH_SORTS = {340    "prix": " ORDER BY price IS NULL, price ASC",341    "prix_desc": " ORDER BY price IS NULL, price DESC",342    "recent": " ORDER BY first_seen DESC, price IS NULL, price ASC",343    "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"344             " price IS NULL, price ASC"),345    "ka": (" ORDER BY ks.global IS NULL, ks.global DESC,"346           " price IS NULL, price ASC"),347}348349# colonnes KA Scores jointes aux annonces (pastilles + tri + fiche)350_KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit,"351            " ks.bike AS ks_bike, ks.calme AS ks_calme,"352            " ks.services AS ks_services, ks.global AS ks_global")353_KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key"354_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}355356357def _parse_poly(poly: str) -> list[tuple[float, float]]:358    """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400."""359    try:360        pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p]361    except ValueError:362        raise HTTPException(400, "poly attendu : lng,lat;lng,lat;…")363    if len(pts) < 3 or any(len(p) != 2 for p in pts):364        raise HTTPException(400, "poly : au moins 3 sommets lng,lat")365    return pts  # type: ignore[return-value]366367368def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool:369    """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon)."""370    inside = False371    j = len(ring) - 1372    for i in range(len(ring)):373        xi, yi = ring[i]374        xj, yj = ring[j]375        if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi:376            inside = not inside377        j = i378    return inside379380381@app.get("/api/search")382def search_unified(383    city: str | None = None,384    sector: str | None = None,385    unit_type: str | None = None,386    source: str | None = None,387    price_max: float | None = None,388    price_min: float | None = None,389    pets: str | None = None,390    furnished: int | None = None,391    available_by: str | None = None,392    area_min: float | None = None,393    q: str | None = None,394    deal: str | None = None,          # sous | marche | sur (juste valeur)395    bbox: str | None = None,          # ouest,sud,est,nord (zone visible carte)396    poly: str | None = None,          # lng,lat;… (zone dessinée)397    kascore_min: float | None = None,  # KA Score global minimal (0-100)398    sort: str = "recent",             # recent (défaut) | prix | prix_desc | deal | ka399    page: int = 1,400    page_size: int = Query(20, ge=1, le=100),401    include: str = "tout",            # tout | liste (points inchangés côté client)402):403    """Recherche synchronisée liste ↔ carte (une réponse = les deux vues).404405    Retourne `total` (compteur partagé), `listings` (la page demandée) et406    `points` (TOUS les points correspondants, format compact407    [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la408    position d'un point dans `points` donne directement sa page de liste.409    Seules les annonces géolocalisées participent (la règle d'or : chaque410    annonce de la liste est visible sur la carte) ; `unpositioned` compte411    les annonces filtrées sans coordonnées, pour l'afficher honnêtement.412    """413    if sort not in _SEARCH_SORTS:414        raise HTTPException(400, f"sort inconnu : {sort}")415    ring = _parse_poly(poly) if poly else None416417    con = db.connect()418    sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +419           " WHERE dup_of IS NULL AND active=1 AND published=1"420           " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL")421    args: list = []422    if bbox:423        try:424            west, south, east, north = (float(v) for v in bbox.split(","))425        except ValueError:426            raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")427        sql += (" AND listings.lat BETWEEN ? AND ?"428               " AND listings.lng BETWEEN ? AND ?")429        args += [south, north, west, east]430    if ring:431        # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval432        lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]433        sql += (" AND listings.lat BETWEEN ? AND ?"434               " AND listings.lng BETWEEN ? AND ?")435        args += [min(lats), max(lats), min(lngs), max(lngs)]436    if city:437        sql += " AND city=?"; args.append(city)438    if sector:439        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")440    if unit_type:441        sql += " AND unit_type=?"; args.append(unit_type)442    if source:443        sql += " AND source=?"; args.append(source)444    if price_max is not None:445        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)446    if price_min is not None:447        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)448    if pets == "oui":449        sql += " AND pets IN ('oui','conditions')"450    elif pets:451        sql += " AND pets=?"; args.append(pets)452    if furnished in (0, 1):453        sql += " AND furnished=?"; args.append(furnished)454    if available_by:455        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \456               " AND availability_date<=?))"457        args.append(available_by)458    if area_min is not None:459        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)460    if q:461        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"462        args += [f"%{q}%"] * 3463    if deal in ("sous", "marche", "sur"):464        sql += " AND fv.verdict=?"; args.append(deal)465    if kascore_min is not None:466        sql += " AND ks.global IS NOT NULL AND ks.global>=?"467        args.append(kascore_min)468469    rows = con.execute(470        "SELECT uid, listings.lng AS lng, listings.lat AS lat, price,"471        " fv.verdict AS v" + sql + _SEARCH_SORTS[sort],472        args).fetchall()473    if ring:474        rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]475    total = len(rows)476477    # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce478    # tableau détermine la page (points et liste partagent le même tri)479    points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"],480               _FV_CODES.get(r["v"] or "", None)] for r in rows]481482    # page demandée, ramenée dans les bornes si les filtres l'ont dépassée483    last = max(1, -(-total // page_size))484    page = min(max(1, page), last)485    page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]]486487    listings: list[dict] = []488    if page_uids:489        marks = ",".join("?" * len(page_uids))490        by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(491            "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"492            " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"493            " fv.confidence AS fv_confidence," + _KS_COLS +494            " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +495            f" WHERE uid IN ({marks})", page_uids).fetchall()}496        listings = [by_uid[u] for u in page_uids if u in by_uid]497498    # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)499    sql_nogeo = sql.replace(500        " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL",501        " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1)502    args_nogeo = list(args)503    for spatial in (bbox, poly):504        if spatial:505            sql_nogeo = sql_nogeo.replace(506                " AND listings.lat BETWEEN ? AND ?"507                " AND listings.lng BETWEEN ? AND ?", "", 1)508            del args_nogeo[0:4]509    unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,510                               args_nogeo).fetchone()["c"]511    con.close()512513    return {514        "total": total, "page": page, "page_size": page_size, "sort": sort,515        "listings": listings,516        "points": points if include != "liste" else None,517        "unpositioned": unpositioned,518    }519520521# --- Miniatures optimisées (WebP, taille écran) ------------------------------522# Sert les images des annonces redimensionnées et compressées (perf mobile) :523# /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle524# qualité (table image_checks) sont proxifiées — pas de proxy ouvert. Si525# l'URL n'est pas (encore) connue : 404, le frontend retombe sur l'original.526_IMG_WIDTHS = (160, 480, 800, 1280)527_IMG_CACHE = ROOT / "data" / "imgcache"528529530@app.get("/api/img")531def img_thumbnail(u: str, w: int = 480):532    import hashlib533    import requests as _rq534    from fastapi.responses import Response535    w = min(_IMG_WIDTHS, key=lambda x: abs(x - w))536    if not u.startswith(("http://", "https://")):537        raise HTTPException(400, "URL invalide")538    key = hashlib.sha1(u.encode()).hexdigest()539    _IMG_CACHE.mkdir(parents=True, exist_ok=True)540    path = _IMG_CACHE / f"{key}_{w}.webp"541    headers = {"Cache-Control": "public, max-age=2592000, immutable"}542    if path.exists():543        return Response(path.read_bytes(), media_type="image/webp",544                        headers=headers)545    con = db.connect()546    known = con.execute("SELECT status FROM image_checks WHERE url=?",547                        (u,)).fetchone()548    con.close()549    if known is None or known["status"] not in ("ok",):550        raise HTTPException(404, "image inconnue du contrôle qualité")551    try:552        resp = _rq.get(u, timeout=12, headers={"User-Agent": "LouKaImg/1.0"})553        resp.raise_for_status()554        from io import BytesIO555        from PIL import Image, ImageOps556        img = Image.open(BytesIO(resp.content))557        img = ImageOps.exif_transpose(img)558        if img.mode not in ("RGB", "L"):559            img = img.convert("RGB")560        if img.width > w:561            img = img.resize((w, max(1, round(img.height * w / img.width))),562                             Image.LANCZOS)563        buf = BytesIO()564        img.save(buf, "WEBP", quality=80, method=4)565        data = buf.getvalue()566        path.write_bytes(data)567        return Response(data, media_type="image/webp", headers=headers)568    except HTTPException:569        raise570    except Exception:571        raise HTTPException(404, "image irrécupérable")572573574@app.get("/api/listings/{uid}/pdf")575def listing_pdf(uid: str):576    """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""577    from fastapi.responses import Response578    from . import pdfgen579    data = pdfgen.fiche_pdf(uid)580    if data is None:581        raise HTTPException(404, "Annonce introuvable")582    nom = uid.replace(":", "-")583    return Response(content=data, media_type="application/pdf", headers={584        "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'})585586587@app.get("/api/stats/rapport.pdf")588def rapport_marche_pdf():589    """Rapport global du marché locatif (PDF multi-pages)."""590    from fastapi.responses import Response591    from . import pdfgen592    return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",593                    headers={"Content-Disposition":594                             'attachment; filename="louka-rapport-marche.pdf"'})595596597@app.get("/api/fairvalue/{uid}")598def fairvalue_detail(uid: str):599    """Analyse de prix Lou-Ka : juste valeur, fourchette, confiance,600    distribution du segment (voir louka/fairvalue.py)."""601    from . import fairvalue602    d = fairvalue.explain(uid)603    if d is None:604        raise HTTPException(404, "Pas d'estimation pour cette annonce")605    return d606607608@app.get("/api/hydro")609def hydro_at(adresse: str, solve: int = 0, uid: str | None = None,610             lat: float | None = None, lng: float | None = None):611    """Estimation du coût d'électricité à une adresse (Hydro-Québec).612    Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul.613    uid/lat/lng permettent la lecture cache par annonce et la dérivation du614    code postal (adresses sans CP)."""615    from . import hydro616    return hydro.estimate(adresse=adresse, uid=uid, lat=lat, lng=lng,617                          solve=bool(solve))618619620@app.get("/api/commerces")621def commerces_at(lat: float, lng: float):622    """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""623    from . import commerces624    return commerces.nearby(lat, lng)625626627@app.get("/api/air")628def air_at(lat: float, lng: float):629    """Qualité de l'air : station RSQAQ la plus proche (MELCCFP)."""630    from . import air631    d = air.lookup(lat, lng)632    if d is None:633        raise HTTPException(404, "Base qualité de l'air non disponible")634    return d635636637@app.get("/api/gaz")638def gaz_at(lat: float, lng: float, limit: int = 5):639    """Stations-service à proximité et prix courants (gazquebec.ca).640    `limit` (défaut 5, max 60) : la fiche demande la liste complète pour son641    panneau « Voir les N stations »."""642    from . import gaz643    return gaz.nearby(lat, lng, limit=max(1, min(limit, 60)))644645646@app.get("/api/inondation")647def inondation_at(lat: float, lng: float):648    """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —649    bloc « Risque d'inondation » de la fiche."""650    from . import inondation651    d = inondation.lookup(lat, lng)652    if d is None:653        raise HTTPException(404, "Base des zones inondables non disponible")654    return d655656657@app.get("/api/rdl")658def rdl_nearby(lat: float, lng: float, radius: int = 600, limit: int = 12):659    """Loyers déclarés au Registre des loyers (registre-des-loyers.ca)660    autour d'un point — bloc « Registre des loyers » de la fiche.661    `limit` (défaut 12, max 300) : nombre de déclarations retournées dans662    `items` ; la fiche demande la liste complète pour son panneau663    « Voir les N loyers comparables »."""664    from . import rdl665    radius = max(100, min(radius, 2000))666    d = rdl.nearby(lat, lng, radius, limit=max(1, min(limit, 300)))667    if d is None:668        raise HTTPException(404, "Registre des loyers non disponible")669    return d670671672@app.get("/api/tal")673def tal_history(address: str, city: str | None = None):674    """Historique au Tribunal administratif du logement (décisions SOQUIJ)675    à l'adresse de l'annonce — bloc « Historique TAL » de la fiche.676    Une adresse jamais vérifiée est mise en file prioritaire (status pending)."""677    from . import tal678    return tal.lookup(address, city)679680681@app.get("/api/listings/{uid}/historique")682def listing_history(uid: str):683    """Historique Lou-Ka : timeline des observations réelles (prix, texte,684    photos, disponibilité, retraits/retours) — voir louka/history.py."""685    from . import history686    out = history.timeline(uid)687    if out is None:688        raise HTTPException(404, "Annonce introuvable")689    return out690691692@app.get("/api/listings/{uid}/cout-reel")693def listing_cout_reel(uid: str):694    """Coût réel mensuel : loyer + frais non inclus ligne par ligne, chaque695    poste étiqueté included/observed/estimated/unknown — louka/coutreel.py."""696    from . import coutreel697    out = coutreel.compute(uid)698    if out is None:699        raise HTTPException(404, "Annonce introuvable")700    return out701702703@app.get("/api/listings/{uid}/recyclees")704def listing_recycled(uid: str):705    """Annonces antérieures probables du même logement (score multi-signaux,706    inférence explicable) — louka/recycled.py."""707    from . import recycled708    return recycled.lookup(uid)709710711@app.get("/api/immeuble")712def immeuble_fiche(bkey: str):713    """Passeport de l'immeuble (stats précalculées par building.rollup)."""714    from . import building715    out = building.fiche(bkey)716    if out is None:717        raise HTTPException(404, "Immeuble inconnu")718    return out719720721@app.get("/api/managers/{source_id}")722def manager_fiche(source_id: str):723    """Fiche du gestionnaire (source directe) + avis Google synchronisés en724    base — aucun appel SerpApi au chargement de page (louka/managers.py)."""725    from . import managers726    out = managers.fiche(source_id)727    if out is None:728        raise HTTPException(404, "Gestionnaire inconnu")729    return out730731732@app.get("/api/listings/{uid}")733def get_listing(uid: str, request: Request):734    con = db.connect()735    row = con.execute(736        "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"737        " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"738        " fv.confidence AS fv_confidence"739        " FROM listings LEFT JOIN fairvalue fv USING (uid)"740        " WHERE uid=?", (uid,)).fetchone()741    d = None742    if row is not None:743        d = _row_to_dict(row)744        # commodités de proximité (cache par immeuble, voir louka/poi.py)745        if d.get("lat") is not None and d.get("lng") is not None:746            key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"747            poi_row = con.execute(748                "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()749            d["poi"] = json.loads(poi_row["pois"]) if poi_row else []750        else:751            d["poi"] = []752        # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)753        if d.get("coord_key"):754            ks = con.execute(755                "SELECT walk, transit, bike, calme, services, global AS glob,"756                " details, version, computed_at FROM kascores WHERE coord_key=?",757                (d["coord_key"],)).fetchone()758            if ks is not None:759                d["kascores"] = {760                    "walk": ks["walk"], "transit": ks["transit"],761                    "bike": ks["bike"], "calme": ks["calme"],762                    "services": ks["services"], "global": ks["glob"],763                    "details": json.loads(ks["details"] or "{}"),764                    "version": ks["version"], "computed_at": ks["computed_at"],765                }766        # statistiques de quartier (recensement, proximité, chaleur, criminalité)767        from . import quartier768        dauid = d.get("dauid")769        d["quartier"] = quartier.fiche_quartier(770            d.get("lat"), d.get("lng"), d.get("city") or "",771            dauid if dauid and dauid != "hors-zone" else None)772        # passeport de l'immeuble (stats précalculées) + score hiver773        if d.get("building_key"):774            from . import building775            d["immeuble"] = building.fiche(d["building_key"], con)776        else:777            d["immeuble"] = None778        from . import hiver779        d["hiver"] = hiver.score(d.get("lat"), d.get("lng"), con)780        # description structurée + historique de prix781        d["digest"] = json.loads(d["digest"]) if d.get("digest") else None782        d["price_history"] = [dict(r) for r in con.execute(783            "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",784            (uid,)).fetchall()]785    con.close()786    if d is None:787        raise HTTPException(404, "Annonce introuvable")788    kaid.track(_kaid_user(request), "detail_view", entity_type="listing",789               entity_id=uid, features=_kaid_features(d))790    return d791792793@app.get("/api/kascores/stats")794def kascores_stats():795    """Couverture, moyennes et version du barème — page « KA Scores »."""796    from . import kascores797    return kascores.stats()798799800@app.get("/api/facets")801def facets(city: str | None = None):802    """Valeurs distinctes pour construire les filtres du frontend.803804    `city` (optionnel) restreint la liste des quartiers à cette ville —805    utilisé par le sélecteur « Quartier » dépendant de « Ville ».806    """807    con = db.connect()808    sector_sql = ("SELECT DISTINCT sector FROM listings"809                  " WHERE active=1 AND published=1 AND dup_of IS NULL"810                  " AND sector<>''")811    sector_args: list = []812    if city:813        sector_sql += " AND city=?"814        sector_args.append(city)815    out = {816        "cities": [r["city"] for r in con.execute(817            "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1"818            " AND dup_of IS NULL AND city<>'' ORDER BY city")],819        "sectors": [r["sector"] for r in con.execute(820            sector_sql + " ORDER BY sector", sector_args)],821        "unit_types": [r["unit_type"] for r in con.execute(822            "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1"823            " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],824        "sources": [dict(r) for r in con.execute(825            "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"826            " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],827    }828    con.close()829    return out830831832@app.get("/api/sources")833def sources():834    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]835    con = db.connect()836    counts = {r["source"]: r["n"] for r in con.execute(837        "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"838        " GROUP BY source")}839    last = {r["source"]: r["ts"] for r in con.execute(840        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}841    con.close()842    for s in registry:843        s["active_listings"] = counts.get(s["id"], 0)844        s["last_sync"] = last.get(s["id"])845    return {"sources": registry}846847848# Villes de la grande région de Montréal (le reste de la province est849# compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…)850_VILLES_GM = (851    "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne",852    "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount",853    "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou",854    "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly",855    "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire",856    "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand",857    "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion",858    "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont",859)860861862@app.get("/api/stats")863def stats(syncs_since_h: float | None = None):864    """Stats globales + journal de synchronisation.865866    `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le867    moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne868    couvrent que ~11 min d'activité sur ~200 sources (fausses alertes869    « stale »). Avec le paramètre : toutes les entrées des N dernières heures870    (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières871    toutes époques confondues. Sans le paramètre : comportement historique872    inchangé (LIMIT 20).873    """874    con = db.connect()875    gm = ",".join("?" * len(_VILLES_GM))876    row = con.execute(877        f"""SELECT COUNT(*) total,878                  SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,879                  SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,880                  SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal,881                  SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm})882                      THEN 1 ELSE 0 END) autres,883                  COUNT(DISTINCT source) sources,884                  AVG(price) avg_price885           FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL""",886        list(_VILLES_GM) + list(_VILLES_GM)).fetchone()887    log: list[dict] = []888    if syncs_since_h is not None:889        import time as _time890        cutoff = _time.time() - syncs_since_h * 3600891        log = [dict(r) for r in con.execute(892            "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",893            (cutoff,))]894    if syncs_since_h is None or len(log) < 20:895        log = [dict(r) for r in con.execute(896            "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]897    # qualité des données : complétude moyenne + quarantaine (quality.py)898    qual = con.execute(899        """SELECT ROUND(AVG(completeness),1) avg_completeness,900                  SUM(published=1) publiees, SUM(published=0) quarantaine901           FROM listings WHERE active=1"""902    ).fetchone()903    con.close()904    # Sources en pause (ex. pause Ontario 2026-08-27) déclarées au moniteur905    # central api-ka via `disabled_sources` : il les retire de la supervision906    # au lieu de les flagger « stale » et de missionner des agents pour rien.907    disabled = [908        s["id"]909        for s in json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]910        if (s.get("status") or "").startswith("pause")911    ]912    return {**dict(row), "quality": dict(qual), "recent_syncs": log,913            "disabled_sources": disabled}914915916@app.get("/api/stats/detailed")917def stats_detailed():918    """Agrégats du marché (source unique : louka/marketstats.py)."""919    from . import marketstats920    return marketstats.compute()921922923# --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) ---924_PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}925926927@app.get("/api/stats/dashboard")928def stats_dashboard(period: str = "30j",929                    from_: str | None = Query(None, alias="from"),930                    to: str | None = None):931    """Tableau de bord analytique (KPI, séries, répartitions, tableaux…)."""932    from . import statsdash933    if period not in _PERIODES_VALIDES and not (from_ and to):934        period = "30j"935    return statsdash.compute(period, from_, to)936937938@app.get("/api/stats/report")939def stats_report(period: str = "30j",940                 from_: str | None = Query(None, alias="from"),941                 to: str | None = None,942                 mode: str = "complet"):943    """Rapport statistique PDF estampillé Groupe-KA — 5 modes :944    complet | synthese | tendances | repartitions | donnees (inconnu → complet)."""945    from fastapi.responses import Response946    from . import kapdf, statsdash947    if period not in _PERIODES_VALIDES:948        period = "perso" if (from_ and to) else "30j"949    if mode not in kapdf.REPORT_MODES:950        mode = "complet"951    dash = statsdash.compute(period if period != "perso" else "30j",952                             from_, to)953    eco = json.loads((ROOT / "frontend" / "src" / "ka" /954                      "ecosystem.json").read_text(encoding="utf-8"))955    site = next(s for s in eco["sites"] if s["id"] == "lou-ka")956    data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()957    fname = kapdf.filename("lou-ka", period, mode)958    return Response(content=data, media_type="application/pdf",959                    headers={"Content-Disposition":960                             f'attachment; filename="{fname}"'})961962963964@app.get("/api/stats/catalog")965def stats_catalog(period: str = "30j",966                  from_: str | None = Query(None, alias="from"),967                  to: str | None = None):968    """v3 — blocs composables pour le constructeur de rapports personnalisés."""969    from . import kapdf, statsdash970    if period not in _PERIODES_VALIDES and not (from_ and to):971        period = "30j"972    dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j",973                             from_, to)974    return {"updated": dash.get("updated"), "period": dash.get("period"),975            "blocks": kapdf.catalog(dash)}976977978@app.post("/api/stats/report/custom")979def stats_report_custom(spec: dict = Body(...)):980    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",981    "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""982    from fastapi.responses import Response983    from . import kapdf, statsdash984    period = str(spec.get("period") or "30j")985    from_ = str(spec.get("from") or "") or None986    to = str(spec.get("to") or "") or None987    if period not in _PERIODES_VALIDES:988        period = "perso" if (from_ and to) else "30j"989    dash = statsdash.compute(period if period != "perso" else "30j",990                             from_, to)991    known = {b["key"] for b in kapdf.catalog(dash)}992    blocks = [b for b in (spec.get("blocks") or [])993              if isinstance(b, dict) and b.get("key") in known][:40]994    if not blocks:995        raise HTTPException(400, "Aucun bloc valide dans la composition")996    eco = json.loads((ROOT / "frontend" / "src" / "ka" /997                      "ecosystem.json").read_text(encoding="utf-8"))998    site = next(s for s in eco["sites"] if s["id"] == "lou-ka")999    data = kapdf.GroupeKAReport(1000        site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,1001        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},1002    ).build()1003    fname = kapdf.filename("lou-ka", period, kapdf.CUSTOM_MODE)1004    return Response(content=data, media_type="application/pdf",1005                    headers={"Content-Disposition":1006                             f'attachment; filename="{fname}"'})100710081009@app.post("/api/sync")1010def trigger_sync(background: BackgroundTasks, source: str | None = None):1011    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""1012    def _job():1013        with _sync_lock:1014            ingest.run([source] if source else None)1015    background.add_task(_job)1016    return {"status": "démarré", "source": source or "toutes"}101710181019# --- Frontend React (build Vite) --------------------------------------------1020if FRONTEND_DIST.exists():1021    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")10221023    # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.1024    # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.1025    from . import seo  # noqa: E4021026    app.include_router(seo.router)10271028    # Documentation utilisateur (frontend/public/doc → dist/doc) — routes1029    # explicites AVANT le catch-all SPA pour servir /doc/ comme un index.1030    @app.get("/doc")1031    def doc_redirect():1032        return RedirectResponse("/doc/", status_code=308)10331034    @app.get("/doc/")1035    def doc_index():1036        return FileResponse(FRONTEND_DIST / "doc" / "index.html")10371038    @app.middleware("http")1039    async def _cache_headers(request, call_next):1040        """Politique de cache : les bundles hachés (/assets/…) sont immuables,1041        mais index.html doit TOUJOURS être revalidé — sinon les navigateurs1042        gardent une vieille version de l'app après un déploiement."""1043        resp = await call_next(request)1044        path = request.url.path1045        if path.startswith("/assets/"):1046            resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"1047        elif "text/html" in (resp.headers.get("content-type") or ""):1048            resp.headers["Cache-Control"] = "no-cache"1049        return resp10501051    # routes de l'app rendues uniquement côté client (privées ou volatiles) —1052    # tout autre chemin inconnu renvoie index.html avec un statut 404 : le1053    # routeur React affiche sa page « introuvable », les moteurs de recherche1054    # voient un vrai 404 (fin des soft-404).1055    _CLIENT_ROUTES = {"profil", "favoris", "gestion", "bienvenue", "bot",1056                      "contact", "juste-valeur", "court-terme"}1057    _CLIENT_PREFIXES = ("u/", "passerelle/", "court-terme/")10581059    @app.get("/{full_path:path}")1060    def spa(full_path: str):1061        target = FRONTEND_DIST / full_path1062        if full_path and target.is_file():1063            return FileResponse(target)1064        known = (full_path in _CLIENT_ROUTES1065                 or any(full_path == p.rstrip("/") or full_path.startswith(p)1066                        for p in _CLIENT_PREFIXES))1067        return FileResponse(FRONTEND_DIST / "index.html",1068                            status_code=200 if known else 404,1069                            headers={"Cache-Control": "no-cache"})1070
Files · f9aa650b5dc0ff1772c91c86818d444d5f0f27fd
Menu

Theme