spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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, FastAPI, HTTPException, Query13from fastapi.middleware.cors import CORSMiddleware14from fastapi.responses import FileResponse15from fastapi.staticfiles import StaticFiles1617from . import db, ingest1819ROOT = Path(__file__).resolve().parent.parent20SOURCES_PATH = ROOT / "data" / "sources.json"21FRONTEND_DIST = ROOT / "frontend" / "dist"2223app = FastAPI(title="Lou-Ka API", version="1.0",24 description="Agrégateur de logements à louer — province de Québec")25app.add_middleware(CORSMiddleware, allow_origins=["*"],26 allow_methods=["*"], allow_headers=["*"])2728_sync_lock = threading.Lock()293031def _row_to_dict(row) -> dict:32 d = dict(row)33 d["amenities"] = json.loads(d.get("amenities") or "[]")34 d["images"] = json.loads(d.get("images") or "[]")35 d["details"] = json.loads(d.get("details") or "{}")36 if d.get("furnished") is not None:37 d["furnished"] = bool(d["furnished"])38 return d394041@app.get("/api/listings")42def list_listings(43 city: str | None = None,44 sector: str | None = None,45 unit_type: str | None = None,46 source: str | None = None,47 price_max: float | None = None,48 price_min: float | None = None,49 pets: str | None = None, # "oui" -> oui OU conditions50 furnished: int | None = None, # 1 / 051 available_by: str | None = None, # ISO : dispo maintenant ou avant date52 area_min: float | None = None, # superficie minimale (pi²)53 q: str | None = None,54 active: int = 1,55 limit: int = Query(500, le=2000),56 offset: int = 0,57):58 con = db.connect()59 sql = "SELECT * FROM listings WHERE 1=1"60 args: list = []61 if active in (0, 1):62 sql += " AND active=?"; args.append(active)63 if city:64 sql += " AND city=?"; args.append(city)65 if sector:66 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")67 if unit_type:68 sql += " AND unit_type=?"; args.append(unit_type)69 if source:70 sql += " AND source=?"; args.append(source)71 if price_max is not None:72 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)73 if price_min is not None:74 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)75 if pets == "oui":76 sql += " AND pets IN ('oui','conditions')"77 elif pets:78 sql += " AND pets=?"; args.append(pets)79 if furnished in (0, 1):80 sql += " AND furnished=?"; args.append(furnished)81 if available_by:82 # « now » trié après les dates ISO : comparaison explicite83 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \84 " AND availability_date<=?))"85 args.append(available_by)86 if area_min is not None:87 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)88 if q:89 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"90 args += [f"%{q}%"] * 391 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]92 sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"93 args += [limit, offset]94 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]95 con.close()96 return {"total": total, "count": len(rows), "listings": rows}979899@app.get("/api/listings.geojson")100def listings_geojson(101 city: str | None = None,102 sector: str | None = None,103 unit_type: str | None = None,104 source: str | None = None,105 price_max: float | None = None,106 price_min: float | None = None,107 pets: str | None = None,108 furnished: int | None = None,109 available_by: str | None = None,110 area_min: float | None = None,111 q: str | None = None,112):113 """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).114115 Léger par conception : seuls les champs nécessaires aux marqueurs et aux116 fiches de la carte sont inclus (1 photo, pas de description).117 """118 con = db.connect()119 sql = ("SELECT uid, title, price, price_label, unit_type, availability_date,"120 " source, city, sector, area_sqft, images, lat, lng FROM listings"121 " WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")122 args: list = []123 if city:124 sql += " AND city=?"; args.append(city)125 if sector:126 sql += " AND sector LIKE ?"; args.append(f"%{sector}%")127 if unit_type:128 sql += " AND unit_type=?"; args.append(unit_type)129 if source:130 sql += " AND source=?"; args.append(source)131 if price_max is not None:132 sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)133 if price_min is not None:134 sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)135 if pets == "oui":136 sql += " AND pets IN ('oui','conditions')"137 elif pets:138 sql += " AND pets=?"; args.append(pets)139 if furnished in (0, 1):140 sql += " AND furnished=?"; args.append(furnished)141 if available_by:142 sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \143 " AND availability_date<=?))"144 args.append(available_by)145 if area_min is not None:146 sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)147 if q:148 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"149 args += [f"%{q}%"] * 3150151 features = []152 for r in con.execute(sql, args).fetchall():153 images = json.loads(r["images"] or "[]")154 features.append({155 "type": "Feature",156 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},157 "properties": {158 "uid": r["uid"], "title": r["title"], "price": r["price"],159 "price_label": r["price_label"], "unit_type": r["unit_type"],160 "availability_date": r["availability_date"],161 "source": r["source"], "city": r["city"], "sector": r["sector"],162 "area_sqft": r["area_sqft"],163 "image": images[0] if images else None,164 },165 })166 con.close()167 return {"type": "FeatureCollection", "features": features}168169170@app.get("/api/listings/{uid}/pdf")171def listing_pdf(uid: str):172 """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""173 from fastapi.responses import Response174 from . import pdfgen175 data = pdfgen.fiche_pdf(uid)176 if data is None:177 raise HTTPException(404, "Annonce introuvable")178 nom = uid.replace(":", "-")179 return Response(content=data, media_type="application/pdf", headers={180 "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'})181182183@app.get("/api/stats/rapport.pdf")184def rapport_marche_pdf():185 """Rapport global du marché locatif (PDF multi-pages)."""186 from fastapi.responses import Response187 from . import pdfgen188 return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",189 headers={"Content-Disposition":190 'attachment; filename="louka-rapport-marche.pdf"'})191192193@app.get("/api/listings/{uid}")194def get_listing(uid: str):195 con = db.connect()196 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()197 d = None198 if row is not None:199 d = _row_to_dict(row)200 # commodités de proximité (cache par immeuble, voir louka/poi.py)201 if d.get("lat") is not None and d.get("lng") is not None:202 key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"203 poi_row = con.execute(204 "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()205 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []206 else:207 d["poi"] = []208 # statistiques de quartier (recensement, proximité, chaleur, criminalité)209 from . import quartier210 dauid = d.get("dauid")211 d["quartier"] = quartier.fiche_quartier(212 d.get("lat"), d.get("lng"), d.get("city") or "",213 dauid if dauid and dauid != "hors-zone" else None)214 # description structurée + historique de prix215 d["digest"] = json.loads(d["digest"]) if d.get("digest") else None216 d["price_history"] = [dict(r) for r in con.execute(217 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",218 (uid,)).fetchall()]219 con.close()220 if d is None:221 raise HTTPException(404, "Annonce introuvable")222 return d223224225@app.get("/api/facets")226def facets(city: str | None = None):227 """Valeurs distinctes pour construire les filtres du frontend.228229 `city` (optionnel) restreint la liste des quartiers à cette ville —230 utilisé par le sélecteur « Quartier » dépendant de « Ville ».231 """232 con = db.connect()233 sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"234 sector_args: list = []235 if city:236 sector_sql += " AND city=?"237 sector_args.append(city)238 out = {239 "cities": [r["city"] for r in con.execute(240 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],241 "sectors": [r["sector"] for r in con.execute(242 sector_sql + " ORDER BY sector", sector_args)],243 "unit_types": [r["unit_type"] for r in con.execute(244 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")],245 "sources": [dict(r) for r in con.execute(246 "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source ORDER BY n DESC")],247 }248 con.close()249 return out250251252@app.get("/api/sources")253def sources():254 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]255 con = db.connect()256 counts = {r["source"]: r["n"] for r in con.execute(257 "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}258 last = {r["source"]: r["ts"] for r in con.execute(259 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}260 con.close()261 for s in registry:262 s["active_listings"] = counts.get(s["id"], 0)263 s["last_sync"] = last.get(s["id"])264 return {"sources": registry}265266267# Villes de la grande région de Montréal (le reste de la province est268# compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…)269_VILLES_GM = (270 "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne",271 "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount",272 "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou",273 "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly",274 "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire",275 "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand",276 "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion",277 "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont",278)279280281@app.get("/api/stats")282def stats():283 con = db.connect()284 gm = ",".join("?" * len(_VILLES_GM))285 row = con.execute(286 f"""SELECT COUNT(*) total,287 SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,288 SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,289 SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal,290 SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm})291 THEN 1 ELSE 0 END) autres,292 COUNT(DISTINCT source) sources,293 AVG(price) avg_price294 FROM listings WHERE active=1""",295 list(_VILLES_GM) + list(_VILLES_GM)).fetchone()296 log = [dict(r) for r in con.execute(297 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]298 con.close()299 return {**dict(row), "recent_syncs": log}300301302@app.get("/api/stats/detailed")303def stats_detailed():304 """Agrégats du marché (source unique : louka/marketstats.py)."""305 from . import marketstats306 return marketstats.compute()307308309310@app.post("/api/sync")311def trigger_sync(background: BackgroundTasks, source: str | None = None):312 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""313 def _job():314 with _sync_lock:315 ingest.run([source] if source else None)316 background.add_task(_job)317 return {"status": "démarré", "source": source or "toutes"}318319320# --- Frontend React (build Vite) --------------------------------------------321if FRONTEND_DIST.exists():322 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")323324 @app.get("/{full_path:path}")325 def spa(full_path: str):326 target = FRONTEND_DIST / full_path327 if full_path and target.is_file():328 return FileResponse(target)329 return FileResponse(FRONTEND_DIST / "index.html")330