SPB Git

spb/lou-ka

Public

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

Fiche : qualité de l air (RSQAQ/PST) + essence à proximité (gazquebec.ca)

- louka/air.py : agrégats annuels par station du RSQAQ (MELCCFP) — horaires
  2024-2025 (PM2.5, NO2, O3, SO2, CO) + séquentielles PST/PM10 (<LD -> LD/2),
  data/air.db minuscule, station la plus proche ≤ 60 km ; bloc fiche avec
  badge global et barres vs repères OMS 2021 / norme RAA (PST 60 µg/m³)
- louka/gaz.py : copie locale des ~2 450 stations de gazquebec.ca (prix
  Régulier/Super/Diesel, TTL 4 h, auto-refresh au lookup) ; bloc fiche avec
  KPI (stations, médiane du secteur, meilleur prix) et tableau des stations
  les plus proches, la moins chère mise en évidence
- run.py air-refresh / gaz-refresh, GET /api/air et /api/gaz

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 24, 2026) parent a36668b Browse files

10 changed files +577 −0

modified .gitignore +2 −0
@@ -29,3 +29,5 @@ data/louka_ct.db-wal
29 frontend/tsconfig.tsbuildinfo 29 frontend/tsconfig.tsbuildinfo
30 data/rdl.db 30 data/rdl.db
31 data/inondation.db 31 data/inondation.db
32+data/air.db
33+data/gaz.db
modified frontend/src/api.ts +29 −0
@@ -595,3 +595,32 @@ export function placeholderImage(label?: string): string {
595 `</svg>`; 595 `</svg>`;
596 return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; 596 return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
597 } 597 }
598+
599+export interface AirMesure {
600+ moyenne: number; annee: number; n: number; unite: string;
601+ ref: number | null; ref_nom: string | null;
602+}
603+
604+export interface AirNearby {
605+ station: string | null; ville?: string; distance_km?: number;
606+ mesures: Record<string, AirMesure>;
607+}
608+
609+/** Qualité de l'air : station RSQAQ la plus proche (MELCCFP). */
610+export const fetchAir = (lat: number, lng: number) =>
611+ get<AirNearby>(`/api/air?lat=${lat}&lng=${lng}`);
612+
613+export interface GazStation {
614+ nom: string; adresse: string; dist_m: number;
615+ regulier: number | null; super: number | null; diesel: number | null;
616+ moins_chere: boolean;
617+}
618+
619+export interface GazNearby {
620+ n: number; rayon_m: number; mediane_regulier: number | null;
621+ min_regulier: number | null; stations: GazStation[]; maj: string | null;
622+}
623+
624+/** Stations-service à proximité et prix courants (gazquebec.ca). */
625+export const fetchGaz = (lat: number, lng: number) =>
626+ get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`);
added frontend/src/components/EssenceProche.tsx +74 −0
@@ -0,0 +1,74 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/EssenceProche.tsx : bloc « Essence à proximité » (fiche)
5+// Stations-service les plus proches avec les prix courants (gazquebec.ca) :
6+// médiane du secteur, station la moins chère mise en évidence, prix
7+// Régulier / Super / Diesel par station.
8+// -----------------------------------------------------------------------------
9+import { useEffect, useState } from "react";
10+import { fetchGaz, fmtDist, GazNearby } from "../api";
11+
12+const cents = (v: number | null | undefined) =>
13+ v == null ? "—" : `${v.toLocaleString("fr-CA", { minimumFractionDigits: 1 })} ¢`;
14+
15+export default function EssenceProche({ lat, lng }:
16+ { lat: number | null; lng: number | null }) {
17+ const [d, setD] = useState<GazNearby | null>(null);
18+ useEffect(() => {
19+ setD(null);
20+ if (lat == null || lng == null) return;
21+ fetchGaz(lat, lng).then(setD).catch(() => setD(null));
22+ }, [lat, lng]);
23+ if (lat == null || lng == null || !d || d.stations.length === 0) return null;
24+
25+ return (
26+ <section className="f-bloc f-gaz" id="essence">
27+ <h2>Essence à proximité</h2>
28+ <div className="rdl-kpis">
29+ <div className="rdl-kpi">
30+ <span className="rdl-kpi-v">{d.n}</span>
31+ <span className="rdl-kpi-l">stations<br />à moins de {fmtDist(d.rayon_m)}</span>
32+ </div>
33+ {d.mediane_regulier != null && (
34+ <div className="rdl-kpi">
35+ <span className="rdl-kpi-v">{cents(d.mediane_regulier)}<small>/L</small></span>
36+ <span className="rdl-kpi-l">médiane du secteur<br />essence régulière</span>
37+ </div>
38+ )}
39+ {d.min_regulier != null && (
40+ <div className="rdl-kpi">
41+ <span className="rdl-kpi-v">{cents(d.min_regulier)}<small>/L</small></span>
42+ <span className="rdl-kpi-l">meilleur prix<br />du secteur</span>
43+ </div>
44+ )}
45+ </div>
46+ <table className="rdl-table">
47+ <caption className="rdl-cap">Stations les plus proches</caption>
48+ <thead className="gaz-head">
49+ <tr><th>Station</th><th>Régulier</th><th>Super</th><th>Diesel</th><th></th></tr>
50+ </thead>
51+ <tbody>
52+ {d.stations.map((s, i) => (
53+ <tr key={i}>
54+ <td className="rdl-addr">
55+ <b>{s.nom}</b>
56+ {s.moins_chere && <span className="gaz-best"> la moins chère</span>}
57+ <span className="gaz-adr">{s.adresse}</span>
58+ </td>
59+ <td className="rdl-prix">{cents(s.regulier)}</td>
60+ <td className="rdl-date">{cents(s.super)}</td>
61+ <td className="rdl-date">{cents(s.diesel)}</td>
62+ <td className="rdl-dist">{fmtDist(s.dist_m)}</td>
63+ </tr>
64+ ))}
65+ </tbody>
66+ </table>
67+ <p className="fine">
68+ Prix courants en ¢/litre —{" "}
69+ <a href="https://gazquebec.ca" target="_blank"
70+ rel="noopener noreferrer">gazquebec.ca</a>, mis à jour {d.maj}.
71+ </p>
72+ </section>
73+ );
74+}
added frontend/src/components/QualiteAir.tsx +86 −0
@@ -0,0 +1,86 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/QualiteAir.tsx : bloc « Qualité de l'air » (fiche)
5+// Moyennes annuelles de la station RSQAQ (MELCCFP) la plus proche :
6+// PM2.5, PST (particules en suspension totales), PM10, NO2, O3, SO2 —
7+// chaque mesure située par rapport à son repère annuel (OMS 2021, ou la
8+// norme québécoise RAA pour les PST) avec une barre de progression.
9+// -----------------------------------------------------------------------------
10+import { useEffect, useState } from "react";
11+import { AirNearby, fetchAir } from "../api";
12+
13+const ORDRE = ["PM2.5", "PST", "PM10", "NO2", "O3", "SO2", "CO"];
14+const NOMS: Record<string, string> = {
15+ "PM2.5": "Particules fines (PM2,5)",
16+ PST: "Particules totales (PST)",
17+ PM10: "Particules (PM10)",
18+ NO2: "Dioxyde d'azote (NO₂)",
19+ O3: "Ozone (O₃)", SO2: "Dioxyde de soufre (SO₂)", CO: "Monoxyde (CO)",
20+};
21+
22+function badge(d: AirNearby): [string, string] {
23+ const pm = d.mesures?.["PM2.5"];
24+ if (pm?.ref) {
25+ if (pm.moyenne <= pm.ref) return ["zi-ok", "Air de très bonne qualité"];
26+ if (pm.moyenne <= 2 * pm.ref) return ["zi-ok", "Air de bonne qualité"];
27+ if (pm.moyenne <= 3 * pm.ref) return ["zi-modere", "Qualité passable"];
28+ return ["zi-eleve", "Particules élevées"];
29+ }
30+ const pst = d.mesures?.["PST"];
31+ if (pst?.ref)
32+ return pst.moyenne <= pst.ref
33+ ? ["zi-ok", "Particules sous la norme"]
34+ : ["zi-eleve", "Particules au-dessus de la norme"];
35+ return ["zi-nc", "Mesures disponibles"];
36+}
37+
38+export default function QualiteAir({ lat, lng }:
39+ { lat: number | null; lng: number | null }) {
40+ const [d, setD] = useState<AirNearby | null>(null);
41+ useEffect(() => {
42+ setD(null);
43+ if (lat == null || lng == null) return;
44+ fetchAir(lat, lng).then(setD).catch(() => setD(null));
45+ }, [lat, lng]);
46+ if (lat == null || lng == null || !d || !d.station) return null;
47+
48+ const pols = ORDRE.filter((p) => d.mesures[p]);
49+ if (pols.length === 0) return null;
50+ const [cls, label] = badge(d);
51+ return (
52+ <section className="f-bloc f-air" id="qualite-air">
53+ <h2>Qualité de l'air</h2>
54+ <div className="zi-head"><span className={`zi-badge ${cls}`}>{label}</span></div>
55+ <ul className="air-liste">
56+ {pols.map((p) => {
57+ const m = d.mesures[p];
58+ const pct = m.ref ? Math.min(150, (m.moyenne / m.ref) * 100) : null;
59+ return (
60+ <li key={p}>
61+ <span className="air-nom">{NOMS[p] ?? p}</span>
62+ <span className="air-barre" aria-hidden="true">
63+ {pct != null && (
64+ <i className={pct > 100 ? "air-sur" : ""}
65+ style={{ width: `${Math.max(4, Math.min(100, pct * 2 / 3))}%` }} />
66+ )}
67+ </span>
68+ <span className="air-val">
69+ {m.moyenne.toLocaleString("fr-CA")} <small>{m.unite}</small>
70+ {m.ref != null && (
71+ <small className="air-ref"> · repère {m.ref_nom} : {m.ref}</small>
72+ )}
73+ </span>
74+ </li>
75+ );
76+ })}
77+ </ul>
78+ <p className="fine">
79+ Moyennes annuelles {Object.values(d.mesures)[0]?.annee} mesurées à la
80+ station <b>{d.station}</b> ({d.ville}, à {d.distance_km} km) — Réseau de
81+ surveillance de la qualité de l'air du Québec (MELCCFP, données
82+ ouvertes). L'air à l'adresse peut différer localement.
83+ </p>
84+ </section>
85+ );
86+}
modified frontend/src/pages/Listing.tsx +6 −0
@@ -19,6 +19,8 @@ import FairValueBadge from "../components/FairValueBadge";
19 import PriceAnalysis from "../components/PriceAnalysis"; 19 import PriceAnalysis from "../components/PriceAnalysis";
20 import RegistreLoyers from "../components/RegistreLoyers"; 20 import RegistreLoyers from "../components/RegistreLoyers";
21 import RisqueInondation from "../components/RisqueInondation"; 21 import RisqueInondation from "../components/RisqueInondation";
22+import QualiteAir from "../components/QualiteAir";
23+import EssenceProche from "../components/EssenceProche";
22 import { IcoAlert, IcoDoc } from "../components/Icons"; 24 import { IcoAlert, IcoDoc } from "../components/Icons";
23 import KaScoresBlock from "../components/KaScoresBlock"; 25 import KaScoresBlock from "../components/KaScoresBlock";
24 import { markSeen } from "../search/seen"; 26 import { markSeen } from "../search/seen";
@@ -477,6 +479,10 @@ export default function ListingPage() {
477 479
478 <RisqueInondation lat={l.lat} lng={l.lng} /> 480 <RisqueInondation lat={l.lat} lng={l.lng} />
479 481
482+ <QualiteAir lat={l.lat} lng={l.lng} />
483+
484+ <EssenceProche lat={l.lat} lng={l.lng} />
485+
480 {l.kascores && <KaScoresBlock ks={l.kascores} />} 486 {l.kascores && <KaScoresBlock ks={l.kascores} />}
481 487
482 <section className="f-bloc f-quartier" id="quartier"> 488 <section className="f-bloc f-quartier" id="quartier">
modified frontend/src/styles.css +24 −0
@@ -1932,3 +1932,27 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1932 color: var(--ink); white-space: nowrap; } 1932 color: var(--ink); white-space: nowrap; }
1933 .q-crime-n small { font-weight: 400; color: var(--ink-3); } 1933 .q-crime-n small { font-weight: 400; color: var(--ink-3); }
1934 .q-crime-src { margin-top: 8px; } 1934 .q-crime-src { margin-top: 8px; }
1935+
1936+
1937+/* ---- Qualité de l'air (RSQAQ) + Essence à proximité ---- */
1938+.air-liste { list-style: none; margin: 10px 0 0; padding: 0; }
1939+.air-liste li { display: flex; align-items: center; gap: 8px;
1940+ padding: 4px 0; font-size: 12.5px; }
1941+.air-nom { flex: 0 0 44%; color: var(--ink-2); overflow: hidden;
1942+ text-overflow: ellipsis; white-space: nowrap; }
1943+.air-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8);
1944+ border-radius: 5px; overflow: hidden; }
1945+.air-barre i { display: block; height: 100%; border-radius: 5px;
1946+ background: #4d9e64; }
1947+.air-barre i.air-sur { background: #c96a1f; }
1948+.air-val { flex: 0 0 34%; text-align: right; font-weight: 600;
1949+ color: var(--ink); white-space: nowrap; overflow: hidden;
1950+ text-overflow: ellipsis; }
1951+.air-val small { font-weight: 400; color: var(--ink-3); }
1952+.air-val .air-ref { display: block; font-size: 10px; }
1953+.gaz-head th { text-align: left; font-size: 11px; color: var(--ink-3);
1954+ font-weight: 600; padding: 2px 8px 2px 0; }
1955+.gaz-best { display: inline-block; margin-left: 6px; padding: 1px 7px;
1956+ border-radius: 999px; background: #e7f4ea; color: #1e6b34;
1957+ font-size: 10.5px; font-weight: 700; }
1958+.gaz-adr { display: block; font-size: 11px; color: var(--ink-3); }
added louka/air.py +208 −0
@@ -0,0 +1,208 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# air.py : qualité de l'air à l'adresse — RSQAQ (MELCCFP, Données Québec)
5+#
6+# Sources ouvertes officielles du Réseau de surveillance de la qualité de
7+# l'air du Québec :
8+# · stations (rsqaq-stations) : coordonnées, ouverture/fermeture ;
9+# · données horaires continues (PM2.5, NO2, O3, SO2, CO) 2024-2025 ;
10+# · données séquentielles (Concentration PST / PM10 + métaux) 2007-2026 —
11+# les particules en suspension totales demandées (étiquette PST).
12+# `refresh()` télécharge les CSV et réduit tout en moyennes annuelles par
13+# station dans data/air.db (minuscule) ; `lookup(lat, lng)` remonte la
14+# station la plus proche (≤ 60 km) avec ses mesures les plus récentes,
15+# comparées aux repères annuels de l'OMS (2021).
16+# -----------------------------------------------------------------------------
17+from __future__ import annotations
18+
19+import csv
20+import io
21+import math
22+import sqlite3
23+import time
24+import urllib.request
25+from pathlib import Path
26+
27+DB_PATH = Path(__file__).resolve().parent.parent / "data" / "air.db"
28+UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)"
29+
30+URL_STATIONS = ("https://www.donneesquebec.ca/recherche/dataset/"
31+ "8656ad05-c174-41c5-9ed7-8c69d308beb9/resource/"
32+ "cebea532-a9e0-4a39-8c2d-54f33d937c73/download/"
33+ "rsqaq_stations_de_la_qualite_de_lair.csv")
34+URL_HORAIRE = {
35+ 2025: ("https://www.donneesquebec.ca/recherche/dataset/"
36+ "a80757bd-d442-4d3d-9269-11628330b727/resource/"
37+ "370a6be4-1530-4c2b-92f3-0a308224f284/download/"
38+ "rsqaq_continues_horaires_2025.csv"),
39+ 2024: ("https://www.donneesquebec.ca/recherche/dataset/"
40+ "a80757bd-d442-4d3d-9269-11628330b727/resource/"
41+ "135146b7-fd4a-4564-b10b-b1ae94257889/download/"
42+ "rsqaq_continues_horaires_2024.csv"),
43+}
44+URL_SEQ = ("https://www.donneesquebec.ca/recherche/dataset/"
45+ "bff56fad-22c3-450b-aafa-4ccdad6c91f2/resource/"
46+ "b83cac3b-6199-4cfd-a903-cdec2369630c/download/"
47+ "rsqaq_sequentielles_2007-2026.csv")
48+
49+# colonnes horaires retenues -> polluant canonique
50+_HOURLY = {"PM2.5-T640": "PM2.5", "PM2.5-BAM": "PM2.5", "NO2": "NO2",
51+ "O3": "O3", "SO2": "SO2", "CO": "CO"}
52+# contaminants séquentiels retenus (concentrations de particules)
53+_SEQ = {"Concentration PST": "PST", "Concentration PM10": "PM10"}
54+
55+# repères ANNUELS (moyenne) : OMS 2021 pour PM2.5/PM10/NO2 ; PST : norme
56+# annuelle du Règlement sur l'assainissement de l'atmosphère (Québec)
57+REFS = {"PM2.5": ("OMS 2021", 5.0), "PM10": ("OMS 2021", 15.0),
58+ "NO2": ("OMS 2021", 10.0), "PST": ("RAA Québec", 60.0)}
59+UNITES = {"PM2.5": "µg/m³", "PM10": "µg/m³", "PST": "µg/m³",
60+ "NO2": "ppb", "O3": "ppb", "SO2": "ppb", "CO": "ppm"}
61+
62+
63+def _dl(url: str) -> io.StringIO:
64+ req = urllib.request.Request(url, headers={"User-Agent": UA})
65+ with urllib.request.urlopen(req, timeout=300) as r:
66+ return io.StringIO(r.read().decode("utf-8-sig"))
67+
68+
69+def refresh() -> None:
70+ stations: dict[str, dict] = {}
71+ for row in csv.DictReader(_dl(URL_STATIONS)):
72+ sid = row["ID_STATION"].strip().lstrip("0")
73+ stations[sid] = {"nom": row["NOM_STATION"], "ville": row["MUNICIPALITE"],
74+ "lat": float(row["LATITUDE"] or 0),
75+ "lng": float(row["LONGITUDE"] or 0)}
76+ print(f"[air] {len(stations)} stations")
77+
78+ # (station, annee, polluant) -> [somme, n]
79+ acc: dict[tuple, list[float]] = {}
80+
81+ def _sid(label: str) -> str:
82+ return label.split(" - ")[0].strip().lstrip("0")
83+
84+ for annee, url in URL_HORAIRE.items():
85+ n = 0
86+ rd = csv.DictReader(_dl(url))
87+ cols = [c for c in (rd.fieldnames or []) if c in _HOURLY]
88+ for row in rd:
89+ sid = _sid(row["Station"])
90+ for c in cols:
91+ v = row.get(c)
92+ if not v:
93+ continue
94+ try:
95+ x = float(v)
96+ except ValueError:
97+ continue
98+ if x < 0:
99+ continue
100+ a = acc.setdefault((sid, annee, _HOURLY[c]), [0.0, 0])
101+ a[0] += x
102+ a[1] += 1
103+ n += 1
104+ print(f"[air] horaires {annee} : {n} mesures")
105+
106+ n = 0
107+ for row in csv.DictReader(_dl(URL_SEQ)):
108+ pol = _SEQ.get(row["Contaminant"])
109+ if not pol:
110+ continue
111+ annee = int(row["Date"][:4])
112+ if annee < 2022:
113+ continue
114+ v = row.get("Resultat")
115+ if not v:
116+ ld = row.get("LD")
117+ if not ld:
118+ continue
119+ try:
120+ x = float(ld) / 2.0 # convention < LD -> LD/2
121+ except ValueError:
122+ continue
123+ else:
124+ try:
125+ x = float(v)
126+ except ValueError:
127+ continue
128+ a = acc.setdefault((_sid(row["Station"]), annee, pol), [0.0, 0])
129+ a[0] += x
130+ a[1] += 1
131+ n += 1
132+ print(f"[air] séquentielles PST/PM10 : {n} mesures (≥ 2022)")
133+
134+ con = sqlite3.connect(DB_PATH)
135+ con.executescript("""
136+ DROP TABLE IF EXISTS air_stats;
137+ CREATE TABLE air_stats (station TEXT, nom TEXT, ville TEXT,
138+ lat REAL, lng REAL, annee INTEGER, polluant TEXT,
139+ moyenne REAL, n INTEGER);
140+ CREATE INDEX idx_air_latlng ON air_stats (lat, lng);
141+ """)
142+ now = time.strftime("%Y-%m-%d")
143+ rows = []
144+ for (sid, annee, pol), (somme, cnt) in acc.items():
145+ st = stations.get(sid)
146+ # minimum de couverture : 30 jours d'heures ou 15 échantillons
147+ if not st or cnt < (720 if pol in ("PM2.5", "NO2", "O3", "SO2", "CO")
148+ else 15):
149+ continue
150+ rows.append((sid, st["nom"], st["ville"], st["lat"], st["lng"],
151+ annee, pol, round(somme / cnt, 2), cnt))
152+ with con:
153+ con.executemany("INSERT INTO air_stats VALUES (?,?,?,?,?,?,?,?,?)",
154+ rows)
155+ con.execute("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v)")
156+ con.execute("INSERT OR REPLACE INTO meta VALUES ('maj', ?)", (now,))
157+ print(f"[air] {len(rows)} agrégats station-année-polluant -> {DB_PATH}")
158+ con.close()
159+
160+
161+def _dist_km(lat1, lng1, lat2, lng2) -> float:
162+ dlat = math.radians(lat2 - lat1)
163+ dlng = math.radians(lng2 - lng1)
164+ a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1))
165+ * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2)
166+ return 6371 * 2 * math.asin(math.sqrt(a))
167+
168+
169+def lookup(lat: float, lng: float, max_km: float = 60.0) -> dict | None:
170+ """Mesures de la station RSQAQ la plus proche (année la plus récente)."""
171+ if not DB_PATH.exists():
172+ return None
173+ con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
174+ con.row_factory = sqlite3.Row
175+ d = max_km / 111.0
176+ rows = con.execute(
177+ "SELECT * FROM air_stats WHERE lat BETWEEN ? AND ? AND lng BETWEEN "
178+ "? AND ?", (lat - d, lat + d, lng - d / max(0.2, math.cos(
179+ math.radians(lat))), lng + d / max(0.2, math.cos(
180+ math.radians(lat))))).fetchall()
181+ con.close()
182+ if not rows:
183+ return {"station": None}
184+ # station la plus proche disposant de données récentes
185+ best: dict[str, dict] = {}
186+ for r in rows:
187+ km = _dist_km(lat, lng, r["lat"], r["lng"])
188+ if km > max_km:
189+ continue
190+ b = best.setdefault(r["station"], {"km": km, "rows": [], "r": r})
191+ b["rows"].append(r)
192+ if not best:
193+ return {"station": None}
194+ sid, b = min(best.items(), key=lambda kv: kv[1]["km"])
195+ # par polluant : l'année la plus récente
196+ mesures: dict[str, dict] = {}
197+ for r in sorted(b["rows"], key=lambda r: -r["annee"]):
198+ if r["polluant"] in mesures:
199+ continue
200+ ref = REFS.get(r["polluant"])
201+ mesures[r["polluant"]] = {
202+ "moyenne": r["moyenne"], "annee": r["annee"], "n": r["n"],
203+ "unite": UNITES.get(r["polluant"], ""),
204+ "ref": ref[1] if ref else None,
205+ "ref_nom": ref[0] if ref else None}
206+ st = b["r"]
207+ return {"station": st["nom"], "ville": st["ville"],
208+ "distance_km": round(b["km"], 1), "mesures": mesures}
added louka/gaz.py +125 −0
@@ -0,0 +1,125 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# gaz.py : stations-service à proximité et prix de l'essence — gazquebec.ca
5+#
6+# gazquebec.ca expose un GeoJSON public de ~2 450 stations du Québec avec
7+# les prix courants par carburant (Régulier / Super / Diesel). On garde une
8+# copie locale (data/gaz.db) rafraîchie automatiquement au plus toutes les
9+# 4 h ; `nearby(lat, lng)` retourne les stations les plus proches avec
10+# leurs prix et la médiane du rayon pour situer chaque prix.
11+# -----------------------------------------------------------------------------
12+from __future__ import annotations
13+
14+import json
15+import math
16+import sqlite3
17+import time
18+import urllib.request
19+from pathlib import Path
20+from statistics import median
21+
22+DB_PATH = Path(__file__).resolve().parent.parent / "data" / "gaz.db"
23+API = "https://gazquebec.ca/api/stations"
24+UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)"
25+TTL = 4 * 3600 # fraîcheur maximale de la copie locale
26+
27+
28+def _connect() -> sqlite3.Connection:
29+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
30+ con = sqlite3.connect(DB_PATH, timeout=15)
31+ con.row_factory = sqlite3.Row
32+ con.executescript("""
33+ CREATE TABLE IF NOT EXISTS gaz_stations (
34+ id INTEGER PRIMARY KEY, nom TEXT, banniere TEXT, adresse TEXT,
35+ region TEXT, lat REAL, lng REAL,
36+ prix_regulier REAL, prix_super REAL, prix_diesel REAL);
37+ CREATE INDEX IF NOT EXISTS idx_gaz_latlng ON gaz_stations (lat, lng);
38+ CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v);
39+ """)
40+ return con
41+
42+
43+def refresh(con: sqlite3.Connection | None = None) -> int:
44+ """Télécharge le GeoJSON des stations et remplace la copie locale."""
45+ own = con is None
46+ con = con or _connect()
47+ req = urllib.request.Request(API, headers={"User-Agent": UA})
48+ with urllib.request.urlopen(req, timeout=60) as r:
49+ data = json.load(r)
50+ rows = []
51+ for f in data.get("features", []):
52+ try:
53+ lng, lat = f["geometry"]["coordinates"][:2]
54+ except (KeyError, TypeError, ValueError):
55+ continue
56+ p = f.get("properties") or {}
57+ prix = {"Régulier": None, "Super": None, "Diesel": None}
58+ try:
59+ for e in json.loads(p.get("allPrices") or "[]"):
60+ if e.get("fuelType") in prix and e.get("priceCents"):
61+ prix[e["fuelType"]] = e["priceCents"] / 10.0 # ¢/L
62+ except ValueError:
63+ pass
64+ rows.append((p.get("id"), p.get("name"), p.get("brand"),
65+ p.get("address"), p.get("region"), lat, lng,
66+ prix["Régulier"], prix["Super"], prix["Diesel"]))
67+ with con:
68+ con.execute("DELETE FROM gaz_stations")
69+ con.executemany(
70+ "INSERT OR REPLACE INTO gaz_stations VALUES (?,?,?,?,?,?,?,?,?,?)",
71+ rows)
72+ con.execute("INSERT OR REPLACE INTO meta VALUES ('maj', ?)",
73+ (time.time(),))
74+ if own:
75+ con.close()
76+ return len(rows)
77+
78+
79+def _dist_m(lat1, lng1, lat2, lng2) -> float:
80+ dlat = math.radians(lat2 - lat1)
81+ dlng = math.radians(lng2 - lng1)
82+ a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1))
83+ * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2)
84+ return 6371000 * 2 * math.asin(math.sqrt(a))
85+
86+
87+def nearby(lat: float, lng: float, radius_m: int = 5000,
88+ limit: int = 5) -> dict:
89+ """Stations les plus proches + médiane du prix Régulier dans le rayon."""
90+ con = _connect()
91+ row = con.execute("SELECT v FROM meta WHERE k='maj'").fetchone()
92+ if row is None or time.time() - float(row["v"]) > TTL:
93+ try:
94+ refresh(con)
95+ except Exception:
96+ pass # copie périmée mieux que rien
97+ d = radius_m / 111320.0
98+ dl = d / max(0.2, math.cos(math.radians(lat)))
99+ rows = con.execute(
100+ "SELECT * FROM gaz_stations WHERE lat BETWEEN ? AND ? "
101+ "AND lng BETWEEN ? AND ?",
102+ (lat - d, lat + d, lng - dl, lng + dl)).fetchall()
103+ maj = con.execute("SELECT v FROM meta WHERE k='maj'").fetchone()
104+ con.close()
105+ hits = []
106+ for r in rows:
107+ dist = _dist_m(lat, lng, r["lat"], r["lng"])
108+ if dist <= radius_m:
109+ hits.append((dist, r))
110+ hits.sort(key=lambda t: t[0])
111+ regs = [r["prix_regulier"] for _, r in hits if r["prix_regulier"]]
112+ med = round(median(regs), 1) if len(regs) >= 3 else None
113+ mini = round(min(regs), 1) if regs else None
114+ items = [{"nom": r["banniere"] or r["nom"], "adresse": r["adresse"],
115+ "dist_m": round(dist),
116+ "regulier": r["prix_regulier"], "super": r["prix_super"],
117+ "diesel": r["prix_diesel"],
118+ "moins_chere": bool(r["prix_regulier"] and mini
119+ and r["prix_regulier"] <= mini)}
120+ for dist, r in hits[:limit]]
121+ return {"n": len(hits), "rayon_m": radius_m, "mediane_regulier": med,
122+ "min_regulier": mini, "stations": items,
123+ "maj": time.strftime("%Y-%m-%d %H:%M",
124+ time.localtime(float(maj["v"])))
125+ if maj else None}
modified louka/web.py +17 −0
@@ -542,6 +542,23 @@ def fairvalue_detail(uid: str):
542 return d 542 return d
543 543
544 544
545+@app.get("/api/air")
546+def air_at(lat: float, lng: float):
547+ """Qualité de l'air : station RSQAQ la plus proche (MELCCFP)."""
548+ from . import air
549+ d = air.lookup(lat, lng)
550+ if d is None:
551+ raise HTTPException(404, "Base qualité de l'air non disponible")
552+ return d
553+
554+
555+@app.get("/api/gaz")
556+def gaz_at(lat: float, lng: float):
557+ """Stations-service à proximité et prix courants (gazquebec.ca)."""
558+ from . import gaz
559+ return gaz.nearby(lat, lng)
560+
561+
545 @app.get("/api/inondation") 562 @app.get("/api/inondation")
546 def inondation_at(lat: float, lng: float): 563 def inondation_at(lat: float, lng: float):
547 """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) — 564 """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —
modified run.py +6 −0
@@ -96,6 +96,12 @@ def main() -> None:
96 elif cmd == "inondation-build": 96 elif cmd == "inondation-build":
97 from louka import inondation 97 from louka import inondation
98 inondation.build() 98 inondation.build()
99+ elif cmd == "air-refresh":
100+ from louka import air
101+ air.refresh()
102+ elif cmd == "gaz-refresh":
103+ from louka import gaz
104+ print(f"[gaz] {gaz.refresh()} stations")
99 elif cmd == "rdl": 105 elif cmd == "rdl":
100 from louka import rdl 106 from louka import rdl
101 rdl.refresh() 107 rdl.refresh()
102 108
Menu

Theme