SPB Git

spb/lou-ka

Public

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

38.5 KB · 909 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, Query13from fastapi.middleware.cors import CORSMiddleware14from fastapi.middleware.gzip import GZipMiddleware15from fastapi.responses import FileResponse16from fastapi.staticfiles import StaticFiles1718from . import auth, db, ingest1920ROOT = 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# rôles, favoris, pages gestionnaires — voir louka/accounts.py36from . import accounts  # noqa: E402  (import tardif : évite le cycle web<->accounts)37app.include_router(accounts.router)3839# personnalisation du profil (bio, réseaux sociaux, photo) — louka/profile.py40from . import profile as user_profile  # noqa: E40241app.include_router(user_profile.router)4243# section « Location court terme » — sous-système isolé (louka/shortterm/)44from .shortterm import web as ct_web  # noqa: E40245app.include_router(ct_web.router)4647# fichiers téléversés (photos de profil) — data/uploads/48_UPLOADS = ROOT / "data" / "uploads"49_UPLOADS.mkdir(parents=True, exist_ok=True)50app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads")515253def _row_to_dict(row) -> dict:54    d = dict(row)55    d["amenities"] = json.loads(d.get("amenities") or "[]")56    d["images"] = json.loads(d.get("images") or "[]")57    # galerie nettoyée par le contrôle qualité (imgcheck.py) : liens morts,58    # minuscules, placeholders et doublons retirés — prime sur la galerie brute59    imgs_ok = d.pop("images_ok", None)60    if imgs_ok:61        try:62            cleaned = json.loads(imgs_ok)63            if isinstance(cleaned, list):64                d["images"] = cleaned65        except (ValueError, TypeError):66            pass67    d.pop("img_audit", None)68    d["details"] = json.loads(d.get("details") or "{}")69    # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… »)70    if d.get("dup_sources"):71        try:72            d["dup_sources"] = json.loads(d["dup_sources"])73        except (ValueError, TypeError):74            d["dup_sources"] = []75    if d.get("furnished") is not None:76        d["furnished"] = bool(d["furnished"])77    return d787980@app.get("/api/listings")81def list_listings(82    city: str | None = None,83    sector: str | None = None,84    unit_type: str | None = None,85    source: str | None = None,86    price_max: float | None = None,87    price_min: float | None = None,88    pets: str | None = None,             # "oui" -> oui OU conditions89    furnished: int | None = None,        # 1 / 090    available_by: str | None = None,     # ISO : dispo maintenant ou avant date91    area_min: float | None = None,       # superficie minimale (pi²)92    q: str | None = None,93    deal: str | None = None,             # sous | marche | sur (fair value)94    kascore_min: float | None = None,    # KA Score global minimal95    sort: str | None = None,             # "deal" affaires | "ka" KA Score96    active: int = 1,97    limit: int = Query(500, le=2000),98    offset: int = 0,99):100    con = db.connect()101    sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"102           " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"103           " fv.confidence AS fv_confidence," + _KS_COLS +104           " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +105           " WHERE dup_of IS NULL")   # doublons masqués106    args: list = []107    if active in (0, 1):108        sql += " AND active=?"; args.append(active)109    if active == 1:   # quarantaine qualité : jamais dans les résultats publics110        sql += " AND published=1"111    if city:112        sql += " AND city=?"; args.append(city)113    if sector:114        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")115    if unit_type:116        sql += " AND unit_type=?"; args.append(unit_type)117    if source:118        sql += " AND source=?"; args.append(source)119    if price_max is not None:120        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)121    if price_min is not None:122        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)123    if pets == "oui":124        sql += " AND pets IN ('oui','conditions')"125    elif pets:126        sql += " AND pets=?"; args.append(pets)127    if furnished in (0, 1):128        sql += " AND furnished=?"; args.append(furnished)129    if available_by:130        # « now » trié après les dates ISO : comparaison explicite131        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \132               " AND availability_date<=?))"133        args.append(available_by)134    if area_min is not None:135        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)136    if q:137        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"138        args += [f"%{q}%"] * 3139    if deal in ("sous", "marche", "sur"):140        sql += " AND fv.verdict=?"; args.append(deal)141    if kascore_min is not None:142        sql += " AND ks.global IS NOT NULL AND ks.global>=?"143        args.append(kascore_min)144    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]145    if sort == "deal":   # meilleures affaires d'abord (écart le plus négatif)146        sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"147                " price IS NULL, price ASC LIMIT ? OFFSET ?")148    elif sort == "ka":   # meilleurs emplacements d'abord (KA Score global)149        sql += (" ORDER BY ks.global IS NULL, ks.global DESC,"150                " price IS NULL, price ASC LIMIT ? OFFSET ?")151    elif sort == "prix":152        sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"153    else:                # défaut : les plus récentes d'abord154        sql += " ORDER BY first_seen DESC, price IS NULL, price ASC LIMIT ? OFFSET ?"155    args += [limit, offset]156    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]157    con.close()158    return {"total": total, "count": len(rows), "listings": rows}159160161@app.get("/api/listings.geojson")162def listings_geojson(163    city: str | None = None,164    sector: str | None = None,165    unit_type: str | None = None,166    source: str | None = None,167    price_max: float | None = None,168    price_min: float | None = None,169    pets: str | None = None,170    furnished: int | None = None,171    available_by: str | None = None,172    area_min: float | None = None,173    q: str | None = None,174    bbox: str | None = None,175    limit: int = Query(3000, le=8000),176):177    """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).178179    Léger par conception : seuls les champs nécessaires aux marqueurs et aux180    fiches de la carte sont inclus (1 photo, pas de description).181182    Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible183    (index idx_listings_geo) ; `limit` plafonne la réponse. La collection184    porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées185    correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées),186    pour afficher « N résultats sans position sur la carte ».187    """188    con = db.connect()189    sql = ("SELECT uid, title, address, price, price_label, unit_type,"190           " availability_date, source, city, sector, area_sqft, images,"191           " images_ok, lat, lng, fv.verdict AS fv_verdict,"192           " fv.deviation AS fv_deviation"193           " FROM listings LEFT JOIN fairvalue fv USING (uid)"194           " WHERE active=1 AND published=1 AND dup_of IS NULL"195           " AND lat IS NOT NULL AND lng IS NOT NULL")196    args: list = []197    if bbox:198        try:199            west, south, east, north = (float(v) for v in bbox.split(","))200        except ValueError:201            raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")202        sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"203        args += [south, north, west, east]204    if city:205        sql += " AND city=?"; args.append(city)206    if sector:207        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")208    if unit_type:209        sql += " AND unit_type=?"; args.append(unit_type)210    if source:211        sql += " AND source=?"; args.append(source)212    if price_max is not None:213        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)214    if price_min is not None:215        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)216    if pets == "oui":217        sql += " AND pets IN ('oui','conditions')"218    elif pets:219        sql += " AND pets=?"; args.append(pets)220    if furnished in (0, 1):221        sql += " AND furnished=?"; args.append(furnished)222    if available_by:223        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \224               " AND availability_date<=?))"225        args.append(available_by)226    if area_min is not None:227        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)228    if q:229        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"230        args += [f"%{q}%"] * 3231232    total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]233    # Même requête sans l'exigence de coordonnées ni le bbox : mesure les234    # annonces filtrées invisibles sur la carte (affordance « sans position »).235    sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")236    args_all = list(args)237    if bbox:238        sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")239        del args_all[0:4]240    total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]241242    sql += " ORDER BY first_seen DESC LIMIT ?"243    args.append(limit)244245    features = []246    for r in con.execute(sql, args).fetchall():247        # galerie nettoyée si auditée (même vide : les images brutes sont248        # alors toutes mortes/placeholder — le frontend affiche le visuel249        # de secours Lou-Ka plutôt qu'une image cassée)250        images = json.loads((r["images_ok"] if r["images_ok"] is not None251                             else r["images"]) or "[]")252        features.append({253            "type": "Feature",254            "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},255            "properties": {256                "uid": r["uid"], "title": r["title"], "address": r["address"],257                "price": r["price"],258                "price_label": r["price_label"], "unit_type": r["unit_type"],259                "availability_date": r["availability_date"],260                "source": r["source"], "city": r["city"], "sector": r["sector"],261                "area_sqft": r["area_sqft"],262                "fv_verdict": r["fv_verdict"], "fv_deviation": r["fv_deviation"],263                "image": images[0] if images else None,264            },265        })266    con.close()267    return {"type": "FeatureCollection", "features": features,268            "totalGeocoded": total_geo, "totalMatching": total_all}269270271# --- Recherche unifiée liste + carte -----------------------------------------272# Une seule vérité partagée : la même requête sert la page de liste ET les273# points de la carte, dans la même réponse. Le compteur affiché en tête de274# liste est par construction égal au nombre de points envoyés à la carte.275276_SEARCH_SORTS = {277    "prix": " ORDER BY price IS NULL, price ASC",278    "prix_desc": " ORDER BY price IS NULL, price DESC",279    "recent": " ORDER BY first_seen DESC, price IS NULL, price ASC",280    "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"281             " price IS NULL, price ASC"),282    "ka": (" ORDER BY ks.global IS NULL, ks.global DESC,"283           " price IS NULL, price ASC"),284}285286# colonnes KA Scores jointes aux annonces (pastilles + tri + fiche)287_KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit,"288            " ks.bike AS ks_bike, ks.calme AS ks_calme,"289            " ks.services AS ks_services, ks.global AS ks_global")290_KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key"291_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}292293294def _parse_poly(poly: str) -> list[tuple[float, float]]:295    """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400."""296    try:297        pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p]298    except ValueError:299        raise HTTPException(400, "poly attendu : lng,lat;lng,lat;…")300    if len(pts) < 3 or any(len(p) != 2 for p in pts):301        raise HTTPException(400, "poly : au moins 3 sommets lng,lat")302    return pts  # type: ignore[return-value]303304305def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool:306    """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon)."""307    inside = False308    j = len(ring) - 1309    for i in range(len(ring)):310        xi, yi = ring[i]311        xj, yj = ring[j]312        if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi:313            inside = not inside314        j = i315    return inside316317318@app.get("/api/search")319def search_unified(320    city: str | None = None,321    sector: str | None = None,322    unit_type: str | None = None,323    source: str | None = None,324    price_max: float | None = None,325    price_min: float | None = None,326    pets: str | None = None,327    furnished: int | None = None,328    available_by: str | None = None,329    area_min: float | None = None,330    q: str | None = None,331    deal: str | None = None,          # sous | marche | sur (juste valeur)332    bbox: str | None = None,          # ouest,sud,est,nord (zone visible carte)333    poly: str | None = None,          # lng,lat;… (zone dessinée)334    kascore_min: float | None = None,  # KA Score global minimal (0-100)335    sort: str = "recent",             # recent (défaut) | prix | prix_desc | deal | ka336    page: int = 1,337    page_size: int = Query(20, ge=1, le=100),338    include: str = "tout",            # tout | liste (points inchangés côté client)339):340    """Recherche synchronisée liste ↔ carte (une réponse = les deux vues).341342    Retourne `total` (compteur partagé), `listings` (la page demandée) et343    `points` (TOUS les points correspondants, format compact344    [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la345    position d'un point dans `points` donne directement sa page de liste.346    Seules les annonces géolocalisées participent (la règle d'or : chaque347    annonce de la liste est visible sur la carte) ; `unpositioned` compte348    les annonces filtrées sans coordonnées, pour l'afficher honnêtement.349    """350    if sort not in _SEARCH_SORTS:351        raise HTTPException(400, f"sort inconnu : {sort}")352    ring = _parse_poly(poly) if poly else None353354    con = db.connect()355    sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +356           " WHERE dup_of IS NULL AND active=1 AND published=1"357           " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL")358    args: list = []359    if bbox:360        try:361            west, south, east, north = (float(v) for v in bbox.split(","))362        except ValueError:363            raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")364        sql += (" AND listings.lat BETWEEN ? AND ?"365               " AND listings.lng BETWEEN ? AND ?")366        args += [south, north, west, east]367    if ring:368        # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval369        lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]370        sql += (" AND listings.lat BETWEEN ? AND ?"371               " AND listings.lng BETWEEN ? AND ?")372        args += [min(lats), max(lats), min(lngs), max(lngs)]373    if city:374        sql += " AND city=?"; args.append(city)375    if sector:376        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")377    if unit_type:378        sql += " AND unit_type=?"; args.append(unit_type)379    if source:380        sql += " AND source=?"; args.append(source)381    if price_max is not None:382        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)383    if price_min is not None:384        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)385    if pets == "oui":386        sql += " AND pets IN ('oui','conditions')"387    elif pets:388        sql += " AND pets=?"; args.append(pets)389    if furnished in (0, 1):390        sql += " AND furnished=?"; args.append(furnished)391    if available_by:392        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \393               " AND availability_date<=?))"394        args.append(available_by)395    if area_min is not None:396        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)397    if q:398        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"399        args += [f"%{q}%"] * 3400    if deal in ("sous", "marche", "sur"):401        sql += " AND fv.verdict=?"; args.append(deal)402    if kascore_min is not None:403        sql += " AND ks.global IS NOT NULL AND ks.global>=?"404        args.append(kascore_min)405406    rows = con.execute(407        "SELECT uid, listings.lng AS lng, listings.lat AS lat, price,"408        " fv.verdict AS v" + sql + _SEARCH_SORTS[sort],409        args).fetchall()410    if ring:411        rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]412    total = len(rows)413414    # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce415    # tableau détermine la page (points et liste partagent le même tri)416    points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"],417               _FV_CODES.get(r["v"] or "", None)] for r in rows]418419    # page demandée, ramenée dans les bornes si les filtres l'ont dépassée420    last = max(1, -(-total // page_size))421    page = min(max(1, page), last)422    page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]]423424    listings: list[dict] = []425    if page_uids:426        marks = ",".join("?" * len(page_uids))427        by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(428            "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"429            " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"430            " fv.confidence AS fv_confidence," + _KS_COLS +431            " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +432            f" WHERE uid IN ({marks})", page_uids).fetchall()}433        listings = [by_uid[u] for u in page_uids if u in by_uid]434435    # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)436    sql_nogeo = sql.replace(437        " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL",438        " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1)439    args_nogeo = list(args)440    for spatial in (bbox, poly):441        if spatial:442            sql_nogeo = sql_nogeo.replace(443                " AND listings.lat BETWEEN ? AND ?"444                " AND listings.lng BETWEEN ? AND ?", "", 1)445            del args_nogeo[0:4]446    unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,447                               args_nogeo).fetchone()["c"]448    con.close()449450    return {451        "total": total, "page": page, "page_size": page_size, "sort": sort,452        "listings": listings,453        "points": points if include != "liste" else None,454        "unpositioned": unpositioned,455    }456457458# --- Miniatures optimisées (WebP, taille écran) ------------------------------459# Sert les images des annonces redimensionnées et compressées (perf mobile) :460# /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle461# qualité (table image_checks) sont proxifiées — pas de proxy ouvert. Si462# l'URL n'est pas (encore) connue : 404, le frontend retombe sur l'original.463_IMG_WIDTHS = (160, 480, 800, 1280)464_IMG_CACHE = ROOT / "data" / "imgcache"465466467@app.get("/api/img")468def img_thumbnail(u: str, w: int = 480):469    import hashlib470    import requests as _rq471    from fastapi.responses import Response472    w = min(_IMG_WIDTHS, key=lambda x: abs(x - w))473    if not u.startswith(("http://", "https://")):474        raise HTTPException(400, "URL invalide")475    key = hashlib.sha1(u.encode()).hexdigest()476    _IMG_CACHE.mkdir(parents=True, exist_ok=True)477    path = _IMG_CACHE / f"{key}_{w}.webp"478    headers = {"Cache-Control": "public, max-age=2592000, immutable"}479    if path.exists():480        return Response(path.read_bytes(), media_type="image/webp",481                        headers=headers)482    con = db.connect()483    known = con.execute("SELECT status FROM image_checks WHERE url=?",484                        (u,)).fetchone()485    con.close()486    if known is None or known["status"] not in ("ok",):487        raise HTTPException(404, "image inconnue du contrôle qualité")488    try:489        resp = _rq.get(u, timeout=12, headers={"User-Agent": "LouKaImg/1.0"})490        resp.raise_for_status()491        from io import BytesIO492        from PIL import Image, ImageOps493        img = Image.open(BytesIO(resp.content))494        img = ImageOps.exif_transpose(img)495        if img.mode not in ("RGB", "L"):496            img = img.convert("RGB")497        if img.width > w:498            img = img.resize((w, max(1, round(img.height * w / img.width))),499                             Image.LANCZOS)500        buf = BytesIO()501        img.save(buf, "WEBP", quality=80, method=4)502        data = buf.getvalue()503        path.write_bytes(data)504        return Response(data, media_type="image/webp", headers=headers)505    except HTTPException:506        raise507    except Exception:508        raise HTTPException(404, "image irrécupérable")509510511@app.get("/api/listings/{uid}/pdf")512def listing_pdf(uid: str):513    """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""514    from fastapi.responses import Response515    from . import pdfgen516    data = pdfgen.fiche_pdf(uid)517    if data is None:518        raise HTTPException(404, "Annonce introuvable")519    nom = uid.replace(":", "-")520    return Response(content=data, media_type="application/pdf", headers={521        "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'})522523524@app.get("/api/stats/rapport.pdf")525def rapport_marche_pdf():526    """Rapport global du marché locatif (PDF multi-pages)."""527    from fastapi.responses import Response528    from . import pdfgen529    return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",530                    headers={"Content-Disposition":531                             'attachment; filename="louka-rapport-marche.pdf"'})532533534@app.get("/api/fairvalue/{uid}")535def fairvalue_detail(uid: str):536    """Analyse de prix Lou-Ka : juste valeur, fourchette, confiance,537    distribution du segment (voir louka/fairvalue.py)."""538    from . import fairvalue539    d = fairvalue.explain(uid)540    if d is None:541        raise HTTPException(404, "Pas d'estimation pour cette annonce")542    return d543544545@app.get("/api/hydro")546def hydro_at(adresse: str, solve: int = 0):547    """Estimation du coût d'électricité à une adresse (Hydro-Québec).548    Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul."""549    from . import hydro550    return hydro.estimate(adresse=adresse, solve=bool(solve))551552553@app.get("/api/commerces")554def commerces_at(lat: float, lng: float):555    """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""556    from . import commerces557    return commerces.nearby(lat, lng)558559560@app.get("/api/air")561def air_at(lat: float, lng: float):562    """Qualité de l'air : station RSQAQ la plus proche (MELCCFP)."""563    from . import air564    d = air.lookup(lat, lng)565    if d is None:566        raise HTTPException(404, "Base qualité de l'air non disponible")567    return d568569570@app.get("/api/gaz")571def gaz_at(lat: float, lng: float):572    """Stations-service à proximité et prix courants (gazquebec.ca)."""573    from . import gaz574    return gaz.nearby(lat, lng)575576577@app.get("/api/inondation")578def inondation_at(lat: float, lng: float):579    """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —580    bloc « Risque d'inondation » de la fiche."""581    from . import inondation582    d = inondation.lookup(lat, lng)583    if d is None:584        raise HTTPException(404, "Base des zones inondables non disponible")585    return d586587588@app.get("/api/rdl")589def rdl_nearby(lat: float, lng: float, radius: int = 600):590    """Loyers déclarés au Registre des loyers (registre-des-loyers.ca)591    autour d'un point — bloc « Registre des loyers » de la fiche."""592    from . import rdl593    radius = max(100, min(radius, 2000))594    d = rdl.nearby(lat, lng, radius)595    if d is None:596        raise HTTPException(404, "Registre des loyers non disponible")597    return d598599600@app.get("/api/listings/{uid}")601def get_listing(uid: str):602    con = db.connect()603    row = con.execute(604        "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"605        " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"606        " fv.confidence AS fv_confidence"607        " FROM listings LEFT JOIN fairvalue fv USING (uid)"608        " WHERE uid=?", (uid,)).fetchone()609    d = None610    if row is not None:611        d = _row_to_dict(row)612        # commodités de proximité (cache par immeuble, voir louka/poi.py)613        if d.get("lat") is not None and d.get("lng") is not None:614            key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"615            poi_row = con.execute(616                "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()617            d["poi"] = json.loads(poi_row["pois"]) if poi_row else []618        else:619            d["poi"] = []620        # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)621        if d.get("coord_key"):622            ks = con.execute(623                "SELECT walk, transit, bike, calme, services, global AS glob,"624                " details, version, computed_at FROM kascores WHERE coord_key=?",625                (d["coord_key"],)).fetchone()626            if ks is not None:627                d["kascores"] = {628                    "walk": ks["walk"], "transit": ks["transit"],629                    "bike": ks["bike"], "calme": ks["calme"],630                    "services": ks["services"], "global": ks["glob"],631                    "details": json.loads(ks["details"] or "{}"),632                    "version": ks["version"], "computed_at": ks["computed_at"],633                }634        # statistiques de quartier (recensement, proximité, chaleur, criminalité)635        from . import quartier636        dauid = d.get("dauid")637        d["quartier"] = quartier.fiche_quartier(638            d.get("lat"), d.get("lng"), d.get("city") or "",639            dauid if dauid and dauid != "hors-zone" else None)640        # description structurée + historique de prix641        d["digest"] = json.loads(d["digest"]) if d.get("digest") else None642        d["price_history"] = [dict(r) for r in con.execute(643            "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",644            (uid,)).fetchall()]645    con.close()646    if d is None:647        raise HTTPException(404, "Annonce introuvable")648    return d649650651@app.get("/api/kascores/stats")652def kascores_stats():653    """Couverture, moyennes et version du barème — page « KA Scores »."""654    from . import kascores655    return kascores.stats()656657658@app.get("/api/facets")659def facets(city: str | None = None):660    """Valeurs distinctes pour construire les filtres du frontend.661662    `city` (optionnel) restreint la liste des quartiers à cette ville —663    utilisé par le sélecteur « Quartier » dépendant de « Ville ».664    """665    con = db.connect()666    sector_sql = ("SELECT DISTINCT sector FROM listings"667                  " WHERE active=1 AND published=1 AND dup_of IS NULL"668                  " AND sector<>''")669    sector_args: list = []670    if city:671        sector_sql += " AND city=?"672        sector_args.append(city)673    out = {674        "cities": [r["city"] for r in con.execute(675            "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1"676            " AND dup_of IS NULL AND city<>'' ORDER BY city")],677        "sectors": [r["sector"] for r in con.execute(678            sector_sql + " ORDER BY sector", sector_args)],679        "unit_types": [r["unit_type"] for r in con.execute(680            "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1"681            " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],682        "sources": [dict(r) for r in con.execute(683            "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"684            " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],685    }686    con.close()687    return out688689690@app.get("/api/sources")691def sources():692    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]693    con = db.connect()694    counts = {r["source"]: r["n"] for r in con.execute(695        "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"696        " GROUP BY source")}697    last = {r["source"]: r["ts"] for r in con.execute(698        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}699    con.close()700    for s in registry:701        s["active_listings"] = counts.get(s["id"], 0)702        s["last_sync"] = last.get(s["id"])703    return {"sources": registry}704705706# Villes de la grande région de Montréal (le reste de la province est707# compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…)708_VILLES_GM = (709    "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne",710    "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount",711    "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou",712    "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly",713    "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire",714    "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand",715    "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion",716    "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont",717)718719720@app.get("/api/stats")721def stats(syncs_since_h: float | None = None):722    """Stats globales + journal de synchronisation.723724    `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le725    moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne726    couvrent que ~11 min d'activité sur ~200 sources (fausses alertes727    « stale »). Avec le paramètre : toutes les entrées des N dernières heures728    (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières729    toutes époques confondues. Sans le paramètre : comportement historique730    inchangé (LIMIT 20).731    """732    con = db.connect()733    gm = ",".join("?" * len(_VILLES_GM))734    row = con.execute(735        f"""SELECT COUNT(*) total,736                  SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,737                  SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,738                  SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal,739                  SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm})740                      THEN 1 ELSE 0 END) autres,741                  COUNT(DISTINCT source) sources,742                  AVG(price) avg_price743           FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL""",744        list(_VILLES_GM) + list(_VILLES_GM)).fetchone()745    log: list[dict] = []746    if syncs_since_h is not None:747        import time as _time748        cutoff = _time.time() - syncs_since_h * 3600749        log = [dict(r) for r in con.execute(750            "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",751            (cutoff,))]752    if syncs_since_h is None or len(log) < 20:753        log = [dict(r) for r in con.execute(754            "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]755    # qualité des données : complétude moyenne + quarantaine (quality.py)756    qual = con.execute(757        """SELECT ROUND(AVG(completeness),1) avg_completeness,758                  SUM(published=1) publiees, SUM(published=0) quarantaine759           FROM listings WHERE active=1"""760    ).fetchone()761    con.close()762    return {**dict(row), "quality": dict(qual), "recent_syncs": log}763764765@app.get("/api/stats/detailed")766def stats_detailed():767    """Agrégats du marché (source unique : louka/marketstats.py)."""768    from . import marketstats769    return marketstats.compute()770771772# --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) ---773_PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}774775776@app.get("/api/stats/dashboard")777def stats_dashboard(period: str = "30j",778                    from_: str | None = Query(None, alias="from"),779                    to: str | None = None):780    """Tableau de bord analytique (KPI, séries, répartitions, tableaux…)."""781    from . import statsdash782    if period not in _PERIODES_VALIDES and not (from_ and to):783        period = "30j"784    return statsdash.compute(period, from_, to)785786787@app.get("/api/stats/report")788def stats_report(period: str = "30j",789                 from_: str | None = Query(None, alias="from"),790                 to: str | None = None,791                 mode: str = "complet"):792    """Rapport statistique PDF estampillé Groupe-KA — 5 modes :793    complet | synthese | tendances | repartitions | donnees (inconnu → complet)."""794    from fastapi.responses import Response795    from . import kapdf, statsdash796    if period not in _PERIODES_VALIDES:797        period = "perso" if (from_ and to) else "30j"798    if mode not in kapdf.REPORT_MODES:799        mode = "complet"800    dash = statsdash.compute(period if period != "perso" else "30j",801                             from_, to)802    eco = json.loads((ROOT / "frontend" / "src" / "ka" /803                      "ecosystem.json").read_text(encoding="utf-8"))804    site = next(s for s in eco["sites"] if s["id"] == "lou-ka")805    data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()806    fname = kapdf.filename("lou-ka", period, mode)807    return Response(content=data, media_type="application/pdf",808                    headers={"Content-Disposition":809                             f'attachment; filename="{fname}"'})810811812813@app.get("/api/stats/catalog")814def stats_catalog(period: str = "30j",815                  from_: str | None = Query(None, alias="from"),816                  to: str | None = None):817    """v3 — blocs composables pour le constructeur de rapports personnalisés."""818    from . import kapdf, statsdash819    if period not in _PERIODES_VALIDES and not (from_ and to):820        period = "30j"821    dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j",822                             from_, to)823    return {"updated": dash.get("updated"), "period": dash.get("period"),824            "blocks": kapdf.catalog(dash)}825826827@app.post("/api/stats/report/custom")828def stats_report_custom(spec: dict = Body(...)):829    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",830    "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""831    from fastapi.responses import Response832    from . import kapdf, statsdash833    period = str(spec.get("period") or "30j")834    from_ = str(spec.get("from") or "") or None835    to = str(spec.get("to") or "") or None836    if period not in _PERIODES_VALIDES:837        period = "perso" if (from_ and to) else "30j"838    dash = statsdash.compute(period if period != "perso" else "30j",839                             from_, to)840    known = {b["key"] for b in kapdf.catalog(dash)}841    blocks = [b for b in (spec.get("blocks") or [])842              if isinstance(b, dict) and b.get("key") in known][:40]843    if not blocks:844        raise HTTPException(400, "Aucun bloc valide dans la composition")845    eco = json.loads((ROOT / "frontend" / "src" / "ka" /846                      "ecosystem.json").read_text(encoding="utf-8"))847    site = next(s for s in eco["sites"] if s["id"] == "lou-ka")848    data = kapdf.GroupeKAReport(849        site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,850        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},851    ).build()852    fname = kapdf.filename("lou-ka", period, kapdf.CUSTOM_MODE)853    return Response(content=data, media_type="application/pdf",854                    headers={"Content-Disposition":855                             f'attachment; filename="{fname}"'})856857858@app.post("/api/sync")859def trigger_sync(background: BackgroundTasks, source: str | None = None):860    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""861    def _job():862        with _sync_lock:863            ingest.run([source] if source else None)864    background.add_task(_job)865    return {"status": "démarré", "source": source or "toutes"}866867868# --- Frontend React (build Vite) --------------------------------------------869if FRONTEND_DIST.exists():870    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")871872    # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.873    # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.874    from . import seo  # noqa: E402875    app.include_router(seo.router)876877    @app.middleware("http")878    async def _cache_headers(request, call_next):879        """Politique de cache : les bundles hachés (/assets/…) sont immuables,880        mais index.html doit TOUJOURS être revalidé — sinon les navigateurs881        gardent une vieille version de l'app après un déploiement."""882        resp = await call_next(request)883        path = request.url.path884        if path.startswith("/assets/"):885            resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"886        elif "text/html" in (resp.headers.get("content-type") or ""):887            resp.headers["Cache-Control"] = "no-cache"888        return resp889890    # routes de l'app rendues uniquement côté client (privées ou volatiles) —891    # tout autre chemin inconnu renvoie index.html avec un statut 404 : le892    # routeur React affiche sa page « introuvable », les moteurs de recherche893    # voient un vrai 404 (fin des soft-404).894    _CLIENT_ROUTES = {"profil", "favoris", "gestion", "bienvenue", "bot",895                      "contact", "juste-valeur", "court-terme"}896    _CLIENT_PREFIXES = ("u/", "passerelle/", "court-terme/")897898    @app.get("/{full_path:path}")899    def spa(full_path: str):900        target = FRONTEND_DIST / full_path901        if full_path and target.is_file():902            return FileResponse(target)903        known = (full_path in _CLIENT_ROUTES904                 or any(full_path == p.rstrip("/") or full_path.startswith(p)905                        for p in _CLIENT_PREFIXES))906        return FileResponse(FRONTEND_DIST / "index.html",907                            status_code=200 if known else 404,908                            headers={"Cache-Control": "no-cache"})909
Files · 64876d4e4f8b10eb90f2d6b8fa359cbbc91e2727
Menu

Theme