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>
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 | ||