SPB Git

spb/lou-ka Public

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

HTML 99.7%

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 2 days ago (Aug 9, 2026) parent 3586dcd

Showing 10 changed files with +495 and −0

modified frontend/src/api.ts +21 −0
@@ -27,6 +27,26 @@ export interface Poi {
27 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 50 export interface Listing {
31 51 uid: string;
32 52 source: string;
@@ -51,6 +71,7 @@ export interface Listing {
51 71 lat: number | null;
52 72 lng: number | null;
53 73 poi?: Poi[]; // commodités de proximité (fiche seulement)
74 + quartier?: Quartier | null; // stats de quartier (fiche seulement)
54 75 last_seen: number;
55 76 updated_at: number;
56 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 6 import { useEffect, useState } from "react";
7 7 import { Link, useParams } from "react-router-dom";
8 8 import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";
9 +import QuartierBlock from "../components/QuartierBlock";
9 10
10 11 // Icônes et libellés des commodités de proximité (louka/poi.py)
11 12 const POI_META: Record<string, { icon: string; label: string }> = {
@@ -136,6 +137,7 @@ export default function ListingPage() {
136 137 {l.description && (
137 138 <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p>
138 139 )}
140 + {l.quartier && <QuartierBlock q={l.quartier} />}
139 141 </div>
140 142
141 143 <aside className="panel">
modified frontend/src/styles.css +36 −0
@@ -681,3 +681,39 @@ img { display: block; }
681 681 border: 0; background: none; padding: 0; cursor: pointer;
682 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 113 "details": "TEXT",
114 114 "geocode_failed": "INTEGER DEFAULT 0",
115 115 "miss_count": "INTEGER DEFAULT 0",
116 + "dauid": "TEXT", # aire de diffusion 2021 (stats de quartier)
116 117 },
117 118 "sync_log": {
118 119 "stats": "TEXT",
modified louka/ingest.py +5 −0
@@ -64,6 +64,11 @@ def watch(interval_seconds: int = 3600) -> None:
64 64 poi.run(limit=80)
65 65 except Exception as exc:
66 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 72 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
68 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 182 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []
183 183 else:
184 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 191 con.close()
186 192 if d is None:
187 193 raise HTTPException(404, "Annonce introuvable")
modified run.py +4 −0
@@ -45,6 +45,10 @@ def main() -> None:
45 45 from louka import poi
46 46 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
47 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 52 elif cmd == "record":
49 53 from louka import fixtures
50 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