SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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 1 mo ago (Aug 24, 2026) parent a36668b

10 changed files +577 −0

modified .gitignore +2 −0
@@ -29,3 +29,5 @@ data/louka_ct.db-wal
29 29 frontend/tsconfig.tsbuildinfo
30 30 data/rdl.db
31 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 595 `</svg>`;
596 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 19 import PriceAnalysis from "../components/PriceAnalysis";
20 20 import RegistreLoyers from "../components/RegistreLoyers";
21 21 import RisqueInondation from "../components/RisqueInondation";
22 +import QualiteAir from "../components/QualiteAir";
23 +import EssenceProche from "../components/EssenceProche";
22 24 import { IcoAlert, IcoDoc } from "../components/Icons";
23 25 import KaScoresBlock from "../components/KaScoresBlock";
24 26 import { markSeen } from "../search/seen";
@@ -477,6 +479,10 @@ export default function ListingPage() {
477 479
478 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 486 {l.kascores && <KaScoresBlock ks={l.kascores} />}
481 487
482 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 1932 color: var(--ink); white-space: nowrap; }
1933 1933 .q-crime-n small { font-weight: 400; color: var(--ink-3); }
1934 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 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 562 @app.get("/api/inondation")
546 563 def inondation_at(lat: float, lng: float):
547 564 """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —
modified run.py +6 −0
@@ -96,6 +96,12 @@ def main() -> None:
96 96 elif cmd == "inondation-build":
97 97 from louka import inondation
98 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 105 elif cmd == "rdl":
100 106 from louka import rdl
101 107 rdl.refresh()
102 108