SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
41.0 KB · 974 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# web.py : FastAPI JSON API + React frontend serving (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="Rent-Ka API", version="1.0",25              description="Rental listings aggregator — Canada (outside 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 rentka/auth.py33app.include_router(auth.router)3435# personnalisation KA ID v2 (rentka/kaid.py, canonique ka-ui.git/kaid)36kaid.init("rent-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 rentka/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) — rentka/profile.py76from . import profile as user_profile  # noqa: E40277app.include_router(user_profile.router)7879# fichiers téléversés (photos de profil) — data/uploads/80_UPLOADS = ROOT / "data" / "uploads"81_UPLOADS.mkdir(parents=True, exist_ok=True)82app.mount("/uploads", StaticFiles(directory=_UPLOADS), name="uploads")838485def _row_to_dict(row) -> dict:86    d = dict(row)87    d["amenities"] = json.loads(d.get("amenities") or "[]")88    d["images"] = json.loads(d.get("images") or "[]")89    # galerie nettoyée par le contrôle qualité (imgcheck.py) : liens morts,90    # minuscules, placeholders et doublons retirés — prime sur la galerie brute91    imgs_ok = d.pop("images_ok", None)92    if imgs_ok:93        try:94            cleaned = json.loads(imgs_ok)95            if isinstance(cleaned, list):96                d["images"] = cleaned97        except (ValueError, TypeError):98            pass99    d.pop("img_audit", None)100    d["details"] = json.loads(d.get("details") or "{}")101    # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… »)102    if d.get("dup_sources"):103        try:104            d["dup_sources"] = json.loads(d["dup_sources"])105        except (ValueError, TypeError):106            d["dup_sources"] = []107    if d.get("furnished") is not None:108        d["furnished"] = bool(d["furnished"])109    return d110111112@app.get("/api/listings")113def list_listings(114    request: Request,115    province: str | None = None,116    city: str | None = None,117    sector: str | None = None,118    unit_type: str | None = None,119    source: str | None = None,120    price_max: float | None = None,121    price_min: float | None = None,122    pets: str | None = None,             # "oui" -> oui OU conditions123    furnished: int | None = None,        # 1 / 0124    available_by: str | None = None,     # ISO : dispo maintenant ou avant date125    area_min: float | None = None,       # superficie minimale (pi²)126    q: str | None = None,127    deal: str | None = None,             # sous | marche | sur (fair value)128    kascore_min: float | None = None,    # KA Score global minimal129    sort: str | None = None,             # "deal" affaires | "ka" KA Score130    active: int = 1,131    limit: int = Query(500, le=2000),132    offset: int = 0,133):134    con = db.connect()135    sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"136           " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"137           " fv.confidence AS fv_confidence," + _KS_COLS +138           " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +139           " WHERE dup_of IS NULL")   # doublons masqués140    args: list = []141    if active in (0, 1):142        sql += " AND active=?"; args.append(active)143    if active == 1:   # quality quarantine: never in public results144        sql += " AND published=1"145    if province:146        sql += " AND province=?"; args.append(province.upper())147    if city:148        sql += " AND city=?"; args.append(city)149    if sector:150        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")151    if unit_type:152        sql += " AND unit_type=?"; args.append(unit_type)153    if source:154        sql += " AND source=?"; args.append(source)155    if price_max is not None:156        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)157    if price_min is not None:158        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)159    if pets in ("yes", "oui"):160        sql += " AND pets IN ('yes','oui','conditions')"161    elif pets in ("no", "non"):162        sql += " AND pets IN ('no','non')"163    elif pets:164        sql += " AND pets=?"; args.append(pets)165    if furnished in (0, 1):166        sql += " AND furnished=?"; args.append(furnished)167    if available_by:168        # « now » trié après les dates ISO : comparaison explicite169        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \170               " AND availability_date<=?))"171        args.append(available_by)172    if area_min is not None:173        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)174    if q:175        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"176        args += [f"%{q}%"] * 3177    if deal in ("sous", "marche", "sur"):178        sql += " AND fv.verdict=?"; args.append(deal)179    if kascore_min is not None:180        sql += " AND ks.global IS NOT NULL AND ks.global>=?"181        args.append(kascore_min)182    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]183    if sort == "deal":   # meilleures affaires d'abord (écart le plus négatif)184        sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"185                " price IS NULL, price ASC LIMIT ? OFFSET ?")186    elif sort == "ka":   # meilleurs emplacements d'abord (KA Score global)187        sql += (" ORDER BY ks.global IS NULL, ks.global DESC,"188                " price IS NULL, price ASC LIMIT ? OFFSET ?")189    elif sort in ("prix", "price"):190        sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"191    else:                # défaut : les plus récentes d'abord192        sql += " ORDER BY first_seen DESC, price IS NULL, price ASC LIMIT ? OFFSET ?"193    args += [limit, offset]194    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]195    con.close()196    # personnalisation KA ID : journal + reclassement (tri par défaut seulement,197    # jamais un tri explicite ; intention de session > préférences long terme)198    personalized = False199    user = _kaid_user(request)200    if user:201        filters = {k: v for k, v in {202            "city": city, "sector": sector, "unit_type": unit_type,203            "price_max": price_max, "price_min": price_min, "pets": pets,204            "furnished": furnished, "deal": deal, "q": q}.items()205            if v not in (None, "")}206        if filters and offset == 0:207            kaid.track(user, "search", query=q, filters=filters)208        if sort in (None, "", "recent"):209            active = {k for k in ("city", "sector", "unit_type", "pets", "deal")210                      if filters.get(k)}211            if price_max is not None or price_min is not None:212                active.add("price")213            if furnished in (0, 1):214                active.add("furnished")215            rows, personalized = kaid.rerank(216                rows, user, features_of=_kaid_features, active_dims=active)217    return {"total": total, "count": len(rows), "listings": rows,218            "personalized": personalized}219220221@app.get("/api/listings.geojson")222def listings_geojson(223    province: str | None = None,224    city: str | None = None,225    sector: str | None = None,226    unit_type: str | None = None,227    source: str | None = None,228    price_max: float | None = None,229    price_min: float | None = None,230    pets: str | None = None,231    furnished: int | None = None,232    available_by: str | None = None,233    area_min: float | None = None,234    q: str | None = None,235    bbox: str | None = None,236    limit: int = Query(3000, le=8000),237):238    """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).239240    Léger par conception : seuls les champs nécessaires aux marqueurs et aux241    fiches de la carte sont inclus (1 photo, pas de description).242243    Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible244    (index idx_listings_geo) ; `limit` plafonne la réponse. La collection245    porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées246    correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées),247    pour afficher « N résultats sans position sur la carte ».248    """249    con = db.connect()250    sql = ("SELECT uid, title, address, price, price_label, unit_type,"251           " availability_date, source, city, sector, province, area_sqft,"252           " images, images_ok, lat, lng, fv.verdict AS fv_verdict,"253           " fv.deviation AS fv_deviation"254           " FROM listings LEFT JOIN fairvalue fv USING (uid)"255           " WHERE active=1 AND published=1 AND dup_of IS NULL"256           " AND lat IS NOT NULL AND lng IS NOT NULL")257    args: list = []258    if bbox:259        try:260            west, south, east, north = (float(v) for v in bbox.split(","))261        except ValueError:262            raise HTTPException(400, "expected bbox: west,south,east,north")263        sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"264        args += [south, north, west, east]265    if province:266        sql += " AND province=?"; args.append(province.upper())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 in ("yes", "oui"):280        sql += " AND pets IN ('yes','oui','conditions')"281    elif pets in ("no", "non"):282        sql += " AND pets IN ('no','non')"283    elif pets:284        sql += " AND pets=?"; args.append(pets)285    if furnished in (0, 1):286        sql += " AND furnished=?"; args.append(furnished)287    if available_by:288        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \289               " AND availability_date<=?))"290        args.append(available_by)291    if area_min is not None:292        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)293    if q:294        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"295        args += [f"%{q}%"] * 3296297    total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]298    # Même requête sans l'exigence de coordonnées ni le bbox : mesure les299    # annonces filtrées invisibles sur la carte (affordance « sans position »).300    sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")301    args_all = list(args)302    if bbox:303        sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")304        del args_all[0:4]305    total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]306307    sql += " ORDER BY first_seen DESC LIMIT ?"308    args.append(limit)309310    features = []311    for r in con.execute(sql, args).fetchall():312        # galerie nettoyée si auditée (même vide : les images brutes sont313        # alors toutes mortes/placeholder — le frontend affiche le visuel314        # de secours Rent-Ka plutôt qu'une image cassée)315        images = json.loads((r["images_ok"] if r["images_ok"] is not None316                             else r["images"]) or "[]")317        features.append({318            "type": "Feature",319            "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},320            "properties": {321                "uid": r["uid"], "title": r["title"], "address": r["address"],322                "price": r["price"],323                "price_label": r["price_label"], "unit_type": r["unit_type"],324                "availability_date": r["availability_date"],325                "source": r["source"], "city": r["city"], "sector": r["sector"],326                "province": r["province"],327                "area_sqft": r["area_sqft"],328                "fv_verdict": r["fv_verdict"], "fv_deviation": r["fv_deviation"],329                "image": images[0] if images else None,330            },331        })332    con.close()333    return {"type": "FeatureCollection", "features": features,334            "totalGeocoded": total_geo, "totalMatching": total_all}335336337# --- Recherche unifiée liste + carte -----------------------------------------338# Une seule vérité partagée : la même requête sert la page de liste ET les339# points de la carte, dans la même réponse. Le compteur affiché en tête de340# liste est par construction égal au nombre de points envoyés à la carte.341342_SEARCH_SORTS = {343    "prix": " ORDER BY price IS NULL, price ASC",344    "prix_desc": " ORDER BY price IS NULL, price DESC",345    "price": " ORDER BY price IS NULL, price ASC",346    "price_desc": " ORDER BY price IS NULL, price DESC",347    "recent": " ORDER BY first_seen DESC, price IS NULL, price ASC",348    "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"349             " price IS NULL, price ASC"),350    "ka": (" ORDER BY ks.global IS NULL, ks.global DESC,"351           " price IS NULL, price ASC"),352}353354# colonnes KA Scores jointes aux annonces (pastilles + tri + fiche)355_KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit,"356            " ks.bike AS ks_bike, ks.calme AS ks_calme,"357            " ks.services AS ks_services, ks.global AS ks_global")358_KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key"359_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}360361362def _parse_poly(poly: str) -> list[tuple[float, float]]:363    """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400."""364    try:365        pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p]366    except ValueError:367        raise HTTPException(400, "expected poly: lng,lat;lng,lat;…")368    if len(pts) < 3 or any(len(p) != 2 for p in pts):369        raise HTTPException(400, "poly: at least 3 lng,lat vertices")370    return pts  # type: ignore[return-value]371372373def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool:374    """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon)."""375    inside = False376    j = len(ring) - 1377    for i in range(len(ring)):378        xi, yi = ring[i]379        xj, yj = ring[j]380        if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi:381            inside = not inside382        j = i383    return inside384385386@app.get("/api/search")387def search_unified(388    province: str | None = None,389    city: str | None = None,390    sector: str | None = None,391    unit_type: str | None = None,392    source: str | None = None,393    price_max: float | None = None,394    price_min: float | None = None,395    pets: str | None = None,396    furnished: int | None = None,397    available_by: str | None = None,398    area_min: float | None = None,399    q: str | None = None,400    deal: str | None = None,          # sous | marche | sur (juste valeur)401    bbox: str | None = None,          # ouest,sud,est,nord (zone visible carte)402    poly: str | None = None,          # lng,lat;… (zone dessinée)403    kascore_min: float | None = None,  # KA Score global minimal (0-100)404    sort: str = "recent",             # recent (défaut) | prix | prix_desc | deal | ka405    page: int = 1,406    page_size: int = Query(20, ge=1, le=100),407    include: str = "tout",            # tout | liste (points inchangés côté client)408):409    """Recherche synchronisée liste ↔ carte (une réponse = les deux vues).410411    Retourne `total` (compteur partagé), `listings` (la page demandée) et412    `points` (TOUS les points correspondants, format compact413    [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la414    position d'un point dans `points` donne directement sa page de liste.415    Seules les annonces géolocalisées participent (la règle d'or : chaque416    annonce de la liste est visible sur la carte) ; `unpositioned` compte417    les annonces filtrées sans coordonnées, pour l'afficher honnêtement.418    """419    if sort not in _SEARCH_SORTS:420        raise HTTPException(400, f"unknown sort: {sort}")421    ring = _parse_poly(poly) if poly else None422423    con = db.connect()424    sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +425           " WHERE dup_of IS NULL AND active=1 AND published=1"426           " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL")427    args: list = []428    if bbox:429        try:430            west, south, east, north = (float(v) for v in bbox.split(","))431        except ValueError:432            raise HTTPException(400, "expected bbox: west,south,east,north")433        sql += (" AND listings.lat BETWEEN ? AND ?"434               " AND listings.lng BETWEEN ? AND ?")435        args += [south, north, west, east]436    if ring:437        # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval438        lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]439        sql += (" AND listings.lat BETWEEN ? AND ?"440               " AND listings.lng BETWEEN ? AND ?")441        args += [min(lats), max(lats), min(lngs), max(lngs)]442    if province:443        sql += " AND province=?"; args.append(province.upper())444    if city:445        sql += " AND city=?"; args.append(city)446    if sector:447        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")448    if unit_type:449        sql += " AND unit_type=?"; args.append(unit_type)450    if source:451        sql += " AND source=?"; args.append(source)452    if price_max is not None:453        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)454    if price_min is not None:455        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)456    if pets in ("yes", "oui"):457        sql += " AND pets IN ('yes','oui','conditions')"458    elif pets in ("no", "non"):459        sql += " AND pets IN ('no','non')"460    elif pets:461        sql += " AND pets=?"; args.append(pets)462    if furnished in (0, 1):463        sql += " AND furnished=?"; args.append(furnished)464    if available_by:465        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \466               " AND availability_date<=?))"467        args.append(available_by)468    if area_min is not None:469        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)470    if q:471        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"472        args += [f"%{q}%"] * 3473    if deal in ("sous", "marche", "sur"):474        sql += " AND fv.verdict=?"; args.append(deal)475    if kascore_min is not None:476        sql += " AND ks.global IS NOT NULL AND ks.global>=?"477        args.append(kascore_min)478479    rows = con.execute(480        "SELECT uid, listings.lng AS lng, listings.lat AS lat, price,"481        " fv.verdict AS v" + sql + _SEARCH_SORTS[sort],482        args).fetchall()483    if ring:484        rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]485    total = len(rows)486487    # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce488    # tableau détermine la page (points et liste partagent le même tri)489    points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"],490               _FV_CODES.get(r["v"] or "", None)] for r in rows]491492    # page demandée, ramenée dans les bornes si les filtres l'ont dépassée493    last = max(1, -(-total // page_size))494    page = min(max(1, page), last)495    page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]]496497    listings: list[dict] = []498    if page_uids:499        marks = ",".join("?" * len(page_uids))500        by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(501            "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"502            " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"503            " fv.confidence AS fv_confidence," + _KS_COLS +504            " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +505            f" WHERE uid IN ({marks})", page_uids).fetchall()}506        listings = [by_uid[u] for u in page_uids if u in by_uid]507508    # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)509    sql_nogeo = sql.replace(510        " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL",511        " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1)512    args_nogeo = list(args)513    for spatial in (bbox, poly):514        if spatial:515            sql_nogeo = sql_nogeo.replace(516                " AND listings.lat BETWEEN ? AND ?"517                " AND listings.lng BETWEEN ? AND ?", "", 1)518            del args_nogeo[0:4]519    unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,520                               args_nogeo).fetchone()["c"]521    con.close()522523    return {524        "total": total, "page": page, "page_size": page_size, "sort": sort,525        "listings": listings,526        "points": points if include != "liste" else None,527        "unpositioned": unpositioned,528    }529530531# --- Miniatures optimisées (WebP, taille écran) ------------------------------532# Sert les images des annonces redimensionnées et compressées (perf mobile) :533# /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle534# qualité (table image_checks) sont proxifiées — pas de proxy ouvert. Si535# l'URL n'est pas (encore) connue : 404, le frontend retombe sur l'original.536_IMG_WIDTHS = (160, 480, 800, 1280)537_IMG_CACHE = ROOT / "data" / "imgcache"538539540@app.get("/api/img")541def img_thumbnail(u: str, w: int = 480):542    import hashlib543    import requests as _rq544    from fastapi.responses import Response545    w = min(_IMG_WIDTHS, key=lambda x: abs(x - w))546    if not u.startswith(("http://", "https://")):547        raise HTTPException(400, "invalid URL")548    key = hashlib.sha1(u.encode()).hexdigest()549    _IMG_CACHE.mkdir(parents=True, exist_ok=True)550    path = _IMG_CACHE / f"{key}_{w}.webp"551    headers = {"Cache-Control": "public, max-age=2592000, immutable"}552    if path.exists():553        return Response(path.read_bytes(), media_type="image/webp",554                        headers=headers)555    con = db.connect()556    known = con.execute("SELECT status FROM image_checks WHERE url=?",557                        (u,)).fetchone()558    con.close()559    if known is None or known["status"] not in ("ok",):560        raise HTTPException(404, "image unknown to quality control")561    try:562        resp = _rq.get(u, timeout=12, headers={"User-Agent": "RentKaImg/1.0"})563        resp.raise_for_status()564        from io import BytesIO565        from PIL import Image, ImageOps566        img = Image.open(BytesIO(resp.content))567        img = ImageOps.exif_transpose(img)568        if img.mode not in ("RGB", "L"):569            img = img.convert("RGB")570        if img.width > w:571            img = img.resize((w, max(1, round(img.height * w / img.width))),572                             Image.LANCZOS)573        buf = BytesIO()574        img.save(buf, "WEBP", quality=80, method=4)575        data = buf.getvalue()576        path.write_bytes(data)577        return Response(data, media_type="image/webp", headers=headers)578    except HTTPException:579        raise580    except Exception:581        raise HTTPException(404, "unretrievable image")582583584@app.get("/api/listings/{uid}/pdf")585def listing_pdf(uid: str):586    """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""587    from fastapi.responses import Response588    from . import pdfgen589    data = pdfgen.fiche_pdf(uid)590    if data is None:591        raise HTTPException(404, "Listing not found")592    nom = uid.replace(":", "-")593    return Response(content=data, media_type="application/pdf", headers={594        "Content-Disposition": f'attachment; filename="rentka-listing-{nom}.pdf"'})595596597@app.get("/api/stats/rapport.pdf")598def rapport_marche_pdf():599    """Rapport global du marché locatif (PDF multi-pages)."""600    from fastapi.responses import Response601    from . import pdfgen602    return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",603                    headers={"Content-Disposition":604                             'attachment; filename="rentka-market-report.pdf"'})605606607@app.get("/api/fairvalue/{uid}")608def fairvalue_detail(uid: str):609    """Analyse de prix Rent-Ka : juste valeur, fourchette, confiance,610    distribution du segment (voir rentka/fairvalue.py)."""611    from . import fairvalue612    d = fairvalue.explain(uid)613    if d is None:614        raise HTTPException(404, "No estimate for this listing")615    return d616617618@app.get("/api/listings/{uid}/historique")619def listing_history(uid: str):620    """Historique Rent-Ka : timeline des observations réelles (prix, texte,621    photos, disponibilité, retraits/retours) — voir rentka/history.py."""622    from . import history623    out = history.timeline(uid)624    if out is None:625        raise HTTPException(404, "Listing not found")626    return out627628629@app.get("/api/listings/{uid}/recyclees")630def listing_recycled(uid: str):631    """Annonces antérieures probables du même logement (score multi-signaux,632    inférence explicable) — rentka/recycled.py."""633    from . import recycled634    return recycled.lookup(uid)635636637@app.get("/api/immeuble")638def immeuble_fiche(bkey: str):639    """Passeport de l'immeuble (stats précalculées par building.rollup)."""640    from . import building641    out = building.fiche(bkey)642    if out is None:643        raise HTTPException(404, "Unknown building")644    return out645646647@app.get("/api/managers/{source_id}")648def manager_fiche(source_id: str):649    """Fiche du gestionnaire (source directe) + avis Google synchronisés en650    base — aucun appel SerpApi au chargement de page (rentka/managers.py)."""651    from . import managers652    out = managers.fiche(source_id)653    if out is None:654        raise HTTPException(404, "Unknown manager")655    return out656657658@app.get("/api/listings/{uid}")659def get_listing(uid: str, request: Request):660    con = db.connect()661    row = con.execute(662        "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"663        " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"664        " fv.confidence AS fv_confidence"665        " FROM listings LEFT JOIN fairvalue fv USING (uid)"666        " WHERE uid=?", (uid,)).fetchone()667    d = None668    if row is not None:669        d = _row_to_dict(row)670        # commodités de proximité (cache par immeuble, voir rentka/poi.py)671        if d.get("lat") is not None and d.get("lng") is not None:672            key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"673            poi_row = con.execute(674                "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()675            d["poi"] = json.loads(poi_row["pois"]) if poi_row else []676        else:677            d["poi"] = []678        # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)679        if d.get("coord_key"):680            ks = con.execute(681                "SELECT walk, transit, bike, calme, services, global AS glob,"682                " details, version, computed_at FROM kascores WHERE coord_key=?",683                (d["coord_key"],)).fetchone()684            if ks is not None:685                d["kascores"] = {686                    "walk": ks["walk"], "transit": ks["transit"],687                    "bike": ks["bike"], "calme": ks["calme"],688                    "services": ks["services"], "global": ks["glob"],689                    "details": json.loads(ks["details"] or "{}"),690                    "version": ks["version"], "computed_at": ks["computed_at"],691                }692        # building passport (precomputed stats) + winter score693        if d.get("building_key"):694            from . import building695            d["immeuble"] = building.fiche(d["building_key"], con)696        else:697            d["immeuble"] = None698        from . import hiver699        d["hiver"] = hiver.score(d.get("lat"), d.get("lng"), con)700        # description structurée + historique de prix701        d["digest"] = json.loads(d["digest"]) if d.get("digest") else None702        d["price_history"] = [dict(r) for r in con.execute(703            "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",704            (uid,)).fetchall()]705    con.close()706    if d is None:707        raise HTTPException(404, "Listing not found")708    kaid.track(_kaid_user(request), "detail_view", entity_type="listing",709               entity_id=uid, features=_kaid_features(d))710    return d711712713@app.get("/api/kascores/stats")714def kascores_stats():715    """Couverture, moyennes et version du barème — page « KA Scores »."""716    from . import kascores717    return kascores.stats()718719720@app.get("/api/facets")721def facets(city: str | None = None):722    """Valeurs distinctes pour construire les filtres du frontend.723724    `city` (optionnel) restreint la liste des quartiers à cette ville —725    utilisé par le sélecteur « Quartier » dépendant de « Ville ».726    """727    con = db.connect()728    sector_sql = ("SELECT DISTINCT sector FROM listings"729                  " WHERE active=1 AND published=1 AND dup_of IS NULL"730                  " AND sector<>''")731    sector_args: list = []732    if city:733        sector_sql += " AND city=?"734        sector_args.append(city)735    out = {736        "provinces": [r["province"] for r in con.execute(737            "SELECT DISTINCT province FROM listings WHERE active=1"738            " AND published=1 AND dup_of IS NULL AND province<>''"739            " ORDER BY province")],740        "cities": [r["city"] for r in con.execute(741            "SELECT DISTINCT city FROM listings WHERE active=1 AND published=1"742            " AND dup_of IS NULL AND city<>'' ORDER BY city")],743        "sectors": [r["sector"] for r in con.execute(744            sector_sql + " ORDER BY sector", sector_args)],745        "unit_types": [r["unit_type"] for r in con.execute(746            "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND published=1"747            " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],748        "sources": [dict(r) for r in con.execute(749            "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"750            " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],751    }752    con.close()753    return out754755756@app.get("/api/sources")757def sources():758    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]759    con = db.connect()760    counts = {r["source"]: r["n"] for r in con.execute(761        "SELECT source, COUNT(*) n FROM listings WHERE active=1 AND published=1"762        " GROUP BY source")}763    last = {r["source"]: r["ts"] for r in con.execute(764        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}765    con.close()766    for s in registry:767        s["active_listings"] = counts.get(s["id"], 0)768        s["last_sync"] = last.get(s["id"])769    return {"sources": registry}770771772@app.get("/api/stats")773def stats(syncs_since_h: float | None = None):774    """Stats globales + journal de synchronisation.775776    `syncs_since_h` (optionnel) : fenêtre en heures pour `recent_syncs` — le777    moniteur central (api-ka) polle aux 2 h et les 20 dernières entrées ne778    couvrent que ~11 min d'activité sur ~200 sources (fausses alertes779    « stale »). Avec le paramètre : toutes les entrées des N dernières heures780    (max 4000) ; s'il y en a moins de 20, on retombe sur les 20 dernières781    toutes époques confondues. Sans le paramètre : comportement historique782    inchangé (LIMIT 20).783    """784    con = db.connect()785    row = con.execute(786        """SELECT COUNT(*) total,787                  COUNT(DISTINCT source) sources,788                  AVG(price) avg_price789           FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL"""790    ).fetchone()791    provinces = {r["province"]: r["n"] for r in con.execute(792        """SELECT province, COUNT(*) n FROM listings793           WHERE active=1 AND published=1 AND dup_of IS NULL AND province<>''794           GROUP BY province ORDER BY n DESC""")}795    cities_top = [dict(r) for r in con.execute(796        """SELECT city, COUNT(*) n FROM listings797           WHERE active=1 AND published=1 AND dup_of IS NULL AND city<>''798           GROUP BY city ORDER BY n DESC LIMIT 12""")]799    log: list[dict] = []800    if syncs_since_h is not None:801        import time as _time802        cutoff = _time.time() - syncs_since_h * 3600803        log = [dict(r) for r in con.execute(804            "SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000",805            (cutoff,))]806    if syncs_since_h is None or len(log) < 20:807        log = [dict(r) for r in con.execute(808            "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]809    # qualité des données : complétude moyenne + quarantaine (quality.py)810    qual = con.execute(811        """SELECT ROUND(AVG(completeness),1) avg_completeness,812                  SUM(published=1) publiees, SUM(published=0) quarantaine813           FROM listings WHERE active=1"""814    ).fetchone()815    con.close()816    return {**dict(row), "provinces": provinces, "top_cities": cities_top,817            "quality": dict(qual), "recent_syncs": log}818819820@app.get("/api/stats/detailed")821def stats_detailed():822    """Agrégats du marché (source unique : rentka/marketstats.py)."""823    from . import marketstats824    return marketstats.compute()825826827# --- Module Stats commun Groupe KA (contrat frontend/src/ka/stats/SPEC.md) ---828_PERIODES_VALIDES = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}829830831@app.get("/api/stats/dashboard")832def stats_dashboard(period: str = "30j",833                    from_: str | None = Query(None, alias="from"),834                    to: str | None = None):835    """Tableau de bord analytique (KPI, séries, répartitions, tableaux…)."""836    from . import statsdash837    if period not in _PERIODES_VALIDES and not (from_ and to):838        period = "30j"839    return statsdash.compute(period, from_, to)840841842@app.get("/api/stats/report")843def stats_report(period: str = "30j",844                 from_: str | None = Query(None, alias="from"),845                 to: str | None = None,846                 mode: str = "complet"):847    """Rapport statistique PDF estampillé Groupe-KA — 5 modes :848    complet | synthese | tendances | repartitions | donnees (inconnu → complet)."""849    from fastapi.responses import Response850    from . import kapdf, statsdash851    if period not in _PERIODES_VALIDES:852        period = "perso" if (from_ and to) else "30j"853    if mode not in kapdf.REPORT_MODES:854        mode = "complet"855    dash = statsdash.compute(period if period != "perso" else "30j",856                             from_, to)857    eco = json.loads((ROOT / "frontend" / "src" / "ka" /858                      "ecosystem.json").read_text(encoding="utf-8"))859    site = next(s for s in eco["sites"] if s["id"] == "rent-ka")860    data = kapdf.GroupeKAReport(site=site, dashboard=dash, mode=mode).build()861    fname = kapdf.filename("rent-ka", period, mode)862    return Response(content=data, media_type="application/pdf",863                    headers={"Content-Disposition":864                             f'attachment; filename="{fname}"'})865866867868@app.get("/api/stats/catalog")869def stats_catalog(period: str = "30j",870                  from_: str | None = Query(None, alias="from"),871                  to: str | None = None):872    """v3 — blocs composables pour le constructeur de rapports personnalisés."""873    from . import kapdf, statsdash874    if period not in _PERIODES_VALIDES and not (from_ and to):875        period = "30j"876    dash = statsdash.compute(period if period in _PERIODES_VALIDES else "30j",877                             from_, to)878    return {"updated": dash.get("updated"), "period": dash.get("period"),879            "blocks": kapdf.catalog(dash)}880881882@app.post("/api/stats/report/custom")883def stats_report_custom(spec: dict = Body(...)):884    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",885    "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""886    from fastapi.responses import Response887    from . import kapdf, statsdash888    period = str(spec.get("period") or "30j")889    from_ = str(spec.get("from") or "") or None890    to = str(spec.get("to") or "") or None891    if period not in _PERIODES_VALIDES:892        period = "perso" if (from_ and to) else "30j"893    dash = statsdash.compute(period if period != "perso" else "30j",894                             from_, to)895    known = {b["key"] for b in kapdf.catalog(dash)}896    blocks = [b for b in (spec.get("blocks") or [])897              if isinstance(b, dict) and b.get("key") in known][:40]898    if not blocks:899        raise HTTPException(400, "No valid block in the composition")900    eco = json.loads((ROOT / "frontend" / "src" / "ka" /901                      "ecosystem.json").read_text(encoding="utf-8"))902    site = next(s for s in eco["sites"] if s["id"] == "rent-ka")903    data = kapdf.GroupeKAReport(904        site=site, dashboard=dash, mode=kapdf.CUSTOM_MODE,905        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},906    ).build()907    fname = kapdf.filename("rent-ka", period, kapdf.CUSTOM_MODE)908    return Response(content=data, media_type="application/pdf",909                    headers={"Content-Disposition":910                             f'attachment; filename="{fname}"'})911912913@app.post("/api/sync")914def trigger_sync(background: BackgroundTasks, source: str | None = None):915    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""916    def _job():917        with _sync_lock:918            ingest.run([source] if source else None)919    background.add_task(_job)920    return {"status": "started", "source": source or "all"}921922923# --- Frontend React (build Vite) --------------------------------------------924if FRONTEND_DIST.exists():925    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")926927    # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.928    # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.929    from . import seo  # noqa: E402930    app.include_router(seo.router)931932    # Documentation utilisateur (frontend/public/doc → dist/doc) — routes933    # explicites AVANT le catch-all SPA pour servir /doc/ comme un index.934    @app.get("/doc")935    def doc_redirect():936        return RedirectResponse("/doc/", status_code=308)937938    @app.get("/doc/")939    def doc_index():940        return FileResponse(FRONTEND_DIST / "doc" / "index.html")941942    @app.middleware("http")943    async def _cache_headers(request, call_next):944        """Politique de cache : les bundles hachés (/assets/…) sont immuables,945        mais index.html doit TOUJOURS être revalidé — sinon les navigateurs946        gardent une vieille version de l'app après un déploiement."""947        resp = await call_next(request)948        path = request.url.path949        if path.startswith("/assets/"):950            resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"951        elif "text/html" in (resp.headers.get("content-type") or ""):952            resp.headers["Cache-Control"] = "no-cache"953        return resp954955    # routes de l'app rendues uniquement côté client (privées ou volatiles) —956    # tout autre chemin inconnu renvoie index.html avec un statut 404 : le957    # routeur React affiche sa page « introuvable », les moteurs de recherche958    # voient un vrai 404 (fin des soft-404).959    _CLIENT_ROUTES = {"profile", "favorites", "manage", "welcome", "bot",960                      "contact", "fair-value", "ka-scores"}961    _CLIENT_PREFIXES = ("u/", "gateway/")962963    @app.get("/{full_path:path}")964    def spa(full_path: str):965        target = FRONTEND_DIST / full_path966        if full_path and target.is_file():967            return FileResponse(target)968        known = (full_path in _CLIENT_ROUTES969                 or any(full_path == p.rstrip("/") or full_path.startswith(p)970                        for p in _CLIENT_PREFIXES))971        return FileResponse(FRONTEND_DIST / "index.html",972                            status_code=200 if known else 404,973                            headers={"Cache-Control": "no-cache"})974