SPB Git

spb/lou-ka

Public

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

Stats de quartier (à la Centris) — runtime + fiche « Le quartier »

- louka/quartier.py : jointure lat/lng -> aire de diffusion 2021 (point-dans-
  polygone local, préfiltre bbox), assemblage fiche (démographie recensement,
  scores de proximité StatCan, défavorisation INSPQ, îlot de chaleur,
  criminalité SPVM <500 m sur l'île / indice IGC ailleurs), enrichissement
  listings.dauid dans la boucle watch
- data/quartier.db : base statique construite par scripts/ (fusion via
  scripts/merge_quartier.py), défensif si absente
- fiche : section « Le quartier » (tuiles démographiques, barres de proximité
  0-100, badges chaleur/criminalité, attributions licences ouvertes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Aug 9, 2026) parent 3586dcd Browse files

10 changed files +495 −0

modified frontend/src/api.ts +21 −0
@@ -27,6 +27,26 @@ export interface Poi {
27 dist_m: number; 27 dist_m: number;
28 } 28 }
29 29
30+export interface Quartier {
31+ dauid?: string | null;
32+ demographie?: {
33+ population: number | null;
34+ densite: number | null;
35+ age_median: number | null;
36+ revenu_median: number | null;
37+ pct_locataires: number | null;
38+ loyer_moyen: number | null;
39+ pct_francais: number | null;
40+ pct_univ: number | null;
41+ };
42+ proximite?: Record<string, number>; // scores 0..1 (PMD StatCan)
43+ defavorisation?: { quintile_materiel: number | null; quintile_social: number | null };
44+ chaleur?: { classe: number; ecart: number | null }; // 1 fraîcheur … 9 chaleur
45+ crime?:
46+ | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number }
47+ | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null };
48+}
49+
30 export interface Listing { 50 export interface Listing {
31 uid: string; 51 uid: string;
32 source: string; 52 source: string;
@@ -51,6 +71,7 @@ export interface Listing {
51 lat: number | null; 71 lat: number | null;
52 lng: number | null; 72 lng: number | null;
53 poi?: Poi[]; // commodités de proximité (fiche seulement) 73 poi?: Poi[]; // commodités de proximité (fiche seulement)
74+ quartier?: Quartier | null; // stats de quartier (fiche seulement)
54 last_seen: number; 75 last_seen: number;
55 updated_at: number; 76 updated_at: number;
56 active: number; 77 active: number;
added frontend/src/components/QuartierBlock.tsx +123 −0
@@ -0,0 +1,123 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/QuartierBlock.tsx : section « Le quartier » de la fiche —
5+// démographie du recensement 2021 (aire de diffusion ~500 hab.), scores de
6+// proximité StatCan, îlot de chaleur/fraîcheur INSPQ, criminalité.
7+// -----------------------------------------------------------------------------
8+import { Quartier } from "../api";
9+
10+const fmtMoney = (v: number | null | undefined) =>
11+ v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";
12+const fmtPct = (v: number | null | undefined) =>
13+ v == null ? null : Math.round(v) + " %";
14+
15+// scores PMD affichés (clé backend -> libellé)
16+const PROX_LABELS: [string, string][] = [
17+ ["prox_epicerie", "Épiceries"],
18+ ["prox_transport", "Transport en commun"],
19+ ["prox_parc", "Parcs"],
20+ ["prox_ecole_prim", "Écoles primaires"],
21+ ["prox_sante", "Soins de santé"],
22+ ["prox_pharmacie", "Pharmacies"],
23+];
24+
25+function chaleurBadge(classe: number, ecart: number | null) {
26+ if (classe <= 3)
27+ return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "🌿" };
28+ if (classe >= 7)
29+ return {
30+ txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`,
31+ cls: "q-badge hot", ico: "🌡",
32+ };
33+ return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "🌤" };
34+}
35+
36+export default function QuartierBlock({ q }: { q: Quartier }) {
37+ const d = q.demographie;
38+ const stats: [string, string | null][] = d
39+ ? [
40+ ["Revenu médian des ménages", fmtMoney(d.revenu_median)],
41+ ["Ménages locataires", fmtPct(d.pct_locataires)],
42+ ["Loyer moyen du secteur", fmtMoney(d.loyer_moyen)],
43+ ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],
44+ ["Français à la maison", fmtPct(d.pct_francais)],
45+ ["Diplôme universitaire", fmtPct(d.pct_univ)],
46+ ]
47+ : [];
48+ const statsOk = stats.filter(([, v]) => v != null) as [string, string][];
49+ const prox = q.proximite ?? {};
50+ const proxOk = PROX_LABELS.filter(([k]) => typeof prox[k] === "number");
51+
52+ if (statsOk.length === 0 && proxOk.length === 0 && !q.chaleur && !q.crime)
53+ return null;
54+
55+ return (
56+ <section className="quartier">
57+ <h2>Le quartier</h2>
58+ <p className="q-sub">
59+ Secteur immédiat de l'immeuble (aire de diffusion du recensement, ± 500 habitants).
60+ </p>
61+
62+ {statsOk.length > 0 && (
63+ <div className="q-grid">
64+ {statsOk.map(([label, val]) => (
65+ <div className="q-cell" key={label}>
66+ <div className="q-val">{val}</div>
67+ <div className="q-label">{label}</div>
68+ </div>
69+ ))}
70+ </div>
71+ )}
72+
73+ {proxOk.length > 0 && (
74+ <div className="q-prox">
75+ {proxOk.map(([k, label]) => {
76+ const v = Math.max(0, Math.min(1, prox[k]));
77+ return (
78+ <div className="q-bar" key={k}>
79+ <span className="q-bar-label">{label}</span>
80+ <span className="q-bar-track">
81+ <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} />
82+ </span>
83+ <span className="q-bar-num">{Math.round(v * 100)}</span>
84+ </div>
85+ );
86+ })}
87+ <div className="fine">Accessibilité 0–100 — mesures de proximité de Statistique Canada.</div>
88+ </div>
89+ )}
90+
91+ <div className="q-badges">
92+ {q.chaleur && (() => {
93+ const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart);
94+ return <span className={b.cls}>{b.ico} {b.txt}</span>;
95+ })()}
96+ {q.crime?.type === "points" && (
97+ <span className="q-badge neutral">
98+ 🛡 {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à
99+ moins de 500 m (12 mois)
100+ {q.crime.douze_mois_precedents > 0 && (
101+ q.crime.douze_mois <= q.crime.douze_mois_precedents
102+ ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)`
103+ : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)`
104+ )}
105+ </span>
106+ )}
107+ {q.crime?.type === "igc" && (
108+ <span className="q-badge neutral">
109+ 🛡 Gravité de la criminalité ({q.crime.ville}, {q.crime.annee}) :{" "}
110+ <b>{q.crime.indice}</b>
111+ {q.crime.indice_canada != null && <> · Canada : {q.crime.indice_canada}</>}
112+ </span>
113+ )}
114+ </div>
115+
116+ <div className="fine">
117+ Sources : Statistique Canada (Recensement 2021, licence ouverte), INSPQ
118+ (CC-BY 4.0){q.crime?.type === "points" ? ", Ville de Montréal (CC-BY 4.0)" : ""}.
119+ Statistiques du secteur, pas de l'immeuble.
120+ </div>
121+ </section>
122+ );
123+}
modified frontend/src/pages/Listing.tsx +2 −0
@@ -6,6 +6,7 @@
6 import { useEffect, useState } from "react"; 6 import { useEffect, useState } from "react";
7 import { Link, useParams } from "react-router-dom"; 7 import { Link, useParams } from "react-router-dom";
8 import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; 8 import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";
9+import QuartierBlock from "../components/QuartierBlock";
9 10
10 // Icônes et libellés des commodités de proximité (louka/poi.py) 11 // Icônes et libellés des commodités de proximité (louka/poi.py)
11 const POI_META: Record<string, { icon: string; label: string }> = { 12 const POI_META: Record<string, { icon: string; label: string }> = {
@@ -136,6 +137,7 @@ export default function ListingPage() {
136 {l.description && ( 137 {l.description && (
137 <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p> 138 <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p>
138 )} 139 )}
140+ {l.quartier && <QuartierBlock q={l.quartier} />}
139 </div> 141 </div>
140 142
141 <aside className="panel"> 143 <aside className="panel">
modified frontend/src/styles.css +36 −0
@@ -681,3 +681,39 @@ img { display: block; }
681 border: 0; background: none; padding: 0; cursor: pointer; 681 border: 0; background: none; padding: 0; cursor: pointer;
682 color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px; 682 color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px;
683 } 683 }
684+
685+/* --- Section « Le quartier » (fiche) --------------------------------------- */
686+.quartier { margin-top: 34px; }
687+.quartier h2 { font-size: 24px; letter-spacing: -0.02em; }
688+.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; }
689+.q-grid {
690+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
691+}
692+@media (max-width: 640px) { .q-grid { grid-template-columns: repeat(2, 1fr); } }
693+.q-cell {
694+ background: var(--surface); border: 1.5px solid var(--line-strong);
695+ border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat);
696+}
697+.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
698+.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
699+ text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; }
700+.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; }
701+.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
702+.q-bar-label { flex: 0 0 150px; color: var(--ink-2); }
703+@media (max-width: 640px) { .q-bar-label { flex-basis: 120px; font-size: 12px; } }
704+.q-bar-track {
705+ flex: 1; height: 10px; background: var(--surface-2);
706+ border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden;
707+}
708+.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; }
709+.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono);
710+ font-size: 11.5px; font-weight: 700; }
711+.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
712+.q-badge {
713+ display: inline-flex; align-items: center; gap: 6px;
714+ border: 1.5px solid var(--line-strong); border-radius: 999px;
715+ padding: 7px 13px; font-size: 12.5px; background: var(--surface);
716+}
717+.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
718+.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
719+.quartier .fine { margin-top: 10px; }
modified louka/db.py +1 −0
@@ -113,6 +113,7 @@ _MIGRATIONS = {
113 "details": "TEXT", 113 "details": "TEXT",
114 "geocode_failed": "INTEGER DEFAULT 0", 114 "geocode_failed": "INTEGER DEFAULT 0",
115 "miss_count": "INTEGER DEFAULT 0", 115 "miss_count": "INTEGER DEFAULT 0",
116+ "dauid": "TEXT", # aire de diffusion 2021 (stats de quartier)
116 }, 117 },
117 "sync_log": { 118 "sync_log": {
118 "stats": "TEXT", 119 "stats": "TEXT",
modified louka/ingest.py +5 −0
@@ -64,6 +64,11 @@ def watch(interval_seconds: int = 3600) -> None:
64 poi.run(limit=80) 64 poi.run(limit=80)
65 except Exception as exc: 65 except Exception as exc:
66 print(f"[lou-ka] poi: erreur non bloquante: {exc}", file=sys.stderr) 66 print(f"[lou-ka] poi: erreur non bloquante: {exc}", file=sys.stderr)
67+ try: # aire de diffusion (stats de quartier) des nouvelles annonces
68+ from . import quartier
69+ quartier.enrich()
70+ except Exception as exc:
71+ print(f"[lou-ka] quartier: erreur non bloquante: {exc}", file=sys.stderr)
67 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s") 72 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
68 time.sleep(interval_seconds) 73 time.sleep(interval_seconds)
69 74
added louka/quartier.py +221 −0
@@ -0,0 +1,221 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# quartier.py : statistiques de quartier par annonce (à la Centris, en libre)
5+# Base statique data/quartier.db construite par scripts/build_*.py :
6+# - da_poly / da_stats : aires de diffusion 2021 + profil du recensement
7+# - da_pmd : mesures de proximité StatCan (scores 0..1)
8+# - da_defav : défavorisation matérielle/sociale INSPQ (quintiles)
9+# - heat : classe d'îlot de chaleur/fraîcheur INSPQ par immeuble
10+# - crime_mtl / igc : actes criminels SPVM (points) + indice de gravité
11+# Jointure : lat/lng -> DAUID par point-dans-polygone local (préfiltre bbox),
12+# mémorisée dans listings.dauid à l'enrichissement (boucle watch).
13+# -----------------------------------------------------------------------------
14+from __future__ import annotations
15+
16+import json
17+import math
18+import sqlite3
19+import time
20+from pathlib import Path
21+
22+from . import db
23+
24+QUARTIER_DB = Path(__file__).resolve().parent.parent / "data" / "quartier.db"
25+
26+# villes couvertes par les points SPVM (agglomération de Montréal)
27+_VILLES_SPVM = {"montreal", "montreal-est", "montreal-ouest", "westmount",
28+ "cote saint-luc", "cote-saint-luc", "hampstead", "mont-royal",
29+ "outremont", "verdun", "lasalle", "lachine", "anjou",
30+ "saint-leonard", "saint-laurent", "ahuntsic", "dorval",
31+ "pointe-claire", "kirkland", "beaconsfield", "dollard-des-ormeaux"}
32+
33+# correspondance ville -> service de police du tableau IGC (35-10-0187)
34+_IGC_SERVICE = {
35+ "quebec": "quebec", "levis": "levis", "montreal": "montreal",
36+ "laval": "laval", "longueuil": "longueuil",
37+}
38+
39+
40+def disponible() -> bool:
41+ return QUARTIER_DB.exists()
42+
43+
44+def _connect() -> sqlite3.Connection:
45+ con = sqlite3.connect(f"file:{QUARTIER_DB}?mode=ro", uri=True)
46+ con.row_factory = sqlite3.Row
47+ return con
48+
49+
50+# ---------------------------------------------------------------------------
51+# lat/lng -> DAUID (point dans polygone, préfiltre bbox)
52+# ---------------------------------------------------------------------------
53+
54+def _dans_anneau(lat: float, lng: float, anneau: list) -> bool:
55+ """Lancer de rayon (even-odd). anneau = [[lng, lat], ...]."""
56+ dedans = False
57+ n = len(anneau)
58+ j = n - 1
59+ for i in range(n):
60+ xi, yi = anneau[i][0], anneau[i][1]
61+ xj, yj = anneau[j][0], anneau[j][1]
62+ if (yi > lat) != (yj > lat) and \
63+ lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi:
64+ dedans = not dedans
65+ j = i
66+ return dedans
67+
68+
69+def dauid_for(qcon: sqlite3.Connection, lat: float, lng: float) -> str | None:
70+ rows = qcon.execute(
71+ "SELECT dauid, poly FROM da_poly WHERE lat_min<=? AND lat_max>=?"
72+ " AND lng_min<=? AND lng_max>=?", (lat, lat, lng, lng)).fetchall()
73+ for r in rows:
74+ anneaux = json.loads(r["poly"])
75+ # even-odd sur tous les anneaux (les trous annulent)
76+ compte = sum(1 for a in anneaux if _dans_anneau(lat, lng, a))
77+ if compte % 2 == 1:
78+ return r["dauid"]
79+ return None
80+
81+
82+# ---------------------------------------------------------------------------
83+# Assemblage pour la fiche
84+# ---------------------------------------------------------------------------
85+
86+def _cle_ville(city: str) -> str:
87+ import unicodedata
88+ s = "".join(c for c in unicodedata.normalize("NFD", city or "")
89+ if unicodedata.category(c) != "Mn")
90+ return s.strip().lower()
91+
92+
93+def _crime_mtl(qcon: sqlite3.Connection, lat: float, lng: float) -> dict | None:
94+ """Comptage des actes criminels SPVM à < 500 m : 12 mois vs 12 précédents."""
95+ dlat = 500 / 111000.0
96+ dlng = 500 / (111000.0 * max(0.2, math.cos(math.radians(lat))))
97+ now = time.time()
98+ rows = qcon.execute(
99+ "SELECT lat, lng, ts FROM crime_mtl WHERE lat BETWEEN ? AND ?"
100+ " AND lng BETWEEN ? AND ? AND ts >= ?",
101+ (lat - dlat, lat + dlat, lng - dlng, lng + dlng, now - 730 * 86400)).fetchall()
102+ recent = avant = 0
103+ for r in rows:
104+ # distance exacte (le bbox est un carré)
105+ d = math.hypot((r["lat"] - lat) * 111000.0,
106+ (r["lng"] - lng) * 111000.0 * math.cos(math.radians(lat)))
107+ if d > 500:
108+ continue
109+ if r["ts"] >= now - 365 * 86400:
110+ recent += 1
111+ else:
112+ avant += 1
113+ if recent == 0 and avant == 0:
114+ return None
115+ return {"type": "points", "rayon_m": 500, "douze_mois": recent,
116+ "douze_mois_precedents": avant}
117+
118+
119+def _crime_igc(qcon: sqlite3.Connection, city: str) -> dict | None:
120+ service = _IGC_SERVICE.get(_cle_ville(city))
121+ if not service:
122+ return None
123+ row = qcon.execute(
124+ "SELECT annee, indice FROM igc WHERE service LIKE '%' || ? || '%'"
125+ " ORDER BY annee DESC LIMIT 1", (service,)).fetchone()
126+ if row is None or row["indice"] is None:
127+ return None
128+ ref = qcon.execute(
129+ "SELECT indice FROM igc WHERE service LIKE '%canada%' AND annee=?",
130+ (row["annee"],)).fetchone()
131+ return {"type": "igc", "ville": city, "annee": row["annee"],
132+ "indice": round(row["indice"], 1),
133+ "indice_canada": round(ref["indice"], 1) if ref and ref["indice"] else None}
134+
135+
136+def fiche_quartier(lat: float | None, lng: float | None, city: str,
137+ dauid: str | None = None) -> dict | None:
138+ """Bloc « Le quartier » d'une fiche. None si données indisponibles."""
139+ if not disponible() or lat is None or lng is None:
140+ return None
141+ qcon = _connect()
142+ try:
143+ if not dauid:
144+ dauid = dauid_for(qcon, lat, lng)
145+ out: dict = {"dauid": dauid}
146+
147+ if dauid:
148+ r = qcon.execute("SELECT * FROM da_stats WHERE dauid=?", (dauid,)).fetchone()
149+ if r:
150+ out["demographie"] = {k: r[k] for k in
151+ ("population", "densite", "age_median",
152+ "revenu_median", "pct_locataires",
153+ "loyer_moyen", "pct_francais", "pct_univ")}
154+ r = qcon.execute("SELECT * FROM da_pmd WHERE dauid=?", (dauid,)).fetchone()
155+ if r:
156+ out["proximite"] = {k: r[k] for k in r.keys() if k != "dauid"
157+ and r[k] is not None}
158+ r = qcon.execute("SELECT quintile_materiel, quintile_social FROM da_defav"
159+ " WHERE dauid=?", (dauid,)).fetchone()
160+ if r:
161+ out["defavorisation"] = dict(r)
162+
163+ # îlot de chaleur : coordonnée exacte, sinon la plus proche (~120 m)
164+ key = f"{round(lat, 4)},{round(lng, 4)}"
165+ r = qcon.execute("SELECT classe, ecart FROM heat WHERE coord_key=?",
166+ (key,)).fetchone()
167+ if r is None:
168+ r = qcon.execute(
169+ "SELECT classe, ecart FROM heat WHERE coord_key LIKE ?"
170+ " AND classe IS NOT NULL LIMIT 1",
171+ (f"{round(lat, 3)}%",)).fetchone()
172+ if r and r["classe"] is not None:
173+ out["chaleur"] = {"classe": r["classe"], "ecart": r["ecart"]}
174+
175+ # criminalité : points SPVM sur l'île, indice IGC ailleurs
176+ crime = None
177+ if _cle_ville(city) in _VILLES_SPVM:
178+ crime = _crime_mtl(qcon, lat, lng)
179+ if crime is None:
180+ crime = _crime_igc(qcon, city)
181+ if crime:
182+ out["crime"] = crime
183+
184+ return out if len(out) > 1 else None
185+ except sqlite3.Error:
186+ return None
187+ finally:
188+ qcon.close()
189+
190+
191+# ---------------------------------------------------------------------------
192+# Enrichissement : mémoriser le DAUID de chaque annonce (boucle watch)
193+# ---------------------------------------------------------------------------
194+
195+def enrich(limit: int | None = None) -> dict:
196+ """Remplit listings.dauid pour les annonces géolocalisées qui ne l'ont pas."""
197+ if not disponible():
198+ print("[lou-ka] quartier: data/quartier.db absent — étape sautée")
199+ return {"enriched": 0, "missing_db": True}
200+ con = db.connect()
201+ qcon = _connect()
202+ rows = con.execute(
203+ "SELECT uid, lat, lng FROM listings WHERE active=1 AND lat IS NOT NULL"
204+ " AND (dauid IS NULL OR dauid='')").fetchall()
205+ if limit is not None:
206+ rows = rows[:limit]
207+ done = introuvable = 0
208+ for r in rows:
209+ d = dauid_for(qcon, r["lat"], r["lng"])
210+ con.execute("UPDATE listings SET dauid=? WHERE uid=?",
211+ (d or "hors-zone", r["uid"]))
212+ if d:
213+ done += 1
214+ else:
215+ introuvable += 1
216+ con.commit()
217+ qcon.close()
218+ con.close()
219+ stats = {"enriched": done, "hors_zone": introuvable, "candidats": len(rows)}
220+ print(f"[lou-ka] quartier {stats}")
221+ return stats
modified louka/web.py +6 −0
@@ -182,6 +182,12 @@ def get_listing(uid: str):
182 d["poi"] = json.loads(poi_row["pois"]) if poi_row else [] 182 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []
183 else: 183 else:
184 d["poi"] = [] 184 d["poi"] = []
185+ # statistiques de quartier (recensement, proximité, chaleur, criminalité)
186+ from . import quartier
187+ dauid = d.get("dauid")
188+ d["quartier"] = quartier.fiche_quartier(
189+ d.get("lat"), d.get("lng"), d.get("city") or "",
190+ dauid if dauid and dauid != "hors-zone" else None)
185 con.close() 191 con.close()
186 if d is None: 192 if d is None:
187 raise HTTPException(404, "Annonce introuvable") 193 raise HTTPException(404, "Annonce introuvable")
modified run.py +4 −0
@@ -45,6 +45,10 @@ def main() -> None:
45 from louka import poi 45 from louka import poi
46 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None 46 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
47 poi.run(limit) 47 poi.run(limit)
48+ elif cmd == "quartier":
49+ from louka import quartier
50+ limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
51+ quartier.enrich(limit)
48 elif cmd == "record": 52 elif cmd == "record":
49 from louka import fixtures 53 from louka import fixtures
50 from louka.connectors import CONNECTORS 54 from louka.connectors import CONNECTORS
added scripts/merge_quartier.py +76 −0
@@ -0,0 +1,76 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# scripts/merge_quartier.py : fusionne les bases de préparation
5+# data/staging-{recensement,contexte,environnement}.db -> data/quartier.db
6+# (la base statique servie en production ; voir louka/quartier.py)
7+# Usage : .venv/bin/python scripts/merge_quartier.py
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import sqlite3
12+import sys
13+from pathlib import Path
14+
15+DATA = Path(__file__).resolve().parent.parent / "data"
16+CIBLE = DATA / "quartier.db"
17+
18+# staging -> tables attendues
19+SOURCES = {
20+ "staging-recensement.db": ["da_poly", "da_stats"],
21+ "staging-contexte.db": ["da_pmd", "da_defav", "ecoles"],
22+ "staging-environnement.db": ["heat", "crime_mtl", "igc"],
23+}
24+
25+
26+def main() -> None:
27+ if CIBLE.exists():
28+ CIBLE.unlink()
29+ out = sqlite3.connect(CIBLE)
30+ out.execute("CREATE TABLE meta (cle TEXT PRIMARY KEY, valeur TEXT)")
31+
32+ for fichier, tables in SOURCES.items():
33+ chemin = DATA / fichier
34+ if not chemin.exists():
35+ print(f"⚠ {fichier} absent — tables {tables} sautées", file=sys.stderr)
36+ continue
37+ out.execute("ATTACH DATABASE ? AS src", (str(chemin),))
38+ for t in tables:
39+ existe = out.execute(
40+ "SELECT name FROM src.sqlite_master WHERE type='table' AND name=?",
41+ (t,)).fetchone()
42+ if not existe:
43+ print(f"⚠ table {t} absente de {fichier}", file=sys.stderr)
44+ continue
45+ schema = out.execute(
46+ "SELECT sql FROM src.sqlite_master WHERE type='table' AND name=?",
47+ (t,)).fetchone()[0]
48+ out.execute(schema)
49+ out.execute(f"INSERT INTO {t} SELECT * FROM src.{t}")
50+ n = out.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
51+ print(f"✓ {t}: {n} lignes (depuis {fichier})")
52+ # préfixer les métadonnées de provenance
53+ if out.execute("SELECT name FROM src.sqlite_master WHERE name='meta'").fetchone():
54+ for cle, val in out.execute("SELECT cle, valeur FROM src.meta"):
55+ out.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
56+ (f"{fichier.replace('.db', '')}:{cle}", val))
57+ out.commit()
58+ out.execute("DETACH DATABASE src")
59+
60+ # index utiles au runtime
61+ for idx in [
62+ "CREATE INDEX IF NOT EXISTS idx_poly_bbox ON da_poly(lat_min, lat_max)",
63+ "CREATE INDEX IF NOT EXISTS idx_crime_lat2 ON crime_mtl(lat)",
64+ ]:
65+ try:
66+ out.execute(idx)
67+ except sqlite3.Error as e:
68+ print(f"⚠ index: {e}", file=sys.stderr)
69+ out.commit()
70+ out.execute("VACUUM")
71+ out.close()
72+ print(f"→ {CIBLE} : {CIBLE.stat().st_size / 1e6:.1f} Mo")
73+
74+
75+if __name__ == "__main__":
76+ main()
77
Menu

Theme