Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# gaz.py : stations-service à proximité et prix de l'essence — gazquebec.ca5#6# gazquebec.ca expose un GeoJSON public de ~2 450 stations du Québec avec7# les prix courants par carburant (Régulier / Super / Diesel). On garde une8# copie locale (data/gaz.db) rafraîchie automatiquement au plus toutes les9# 4 h ; `nearby(lat, lng)` retourne les stations les plus proches avec10# leurs prix et la médiane du rayon pour situer chaque prix.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import json15import math16import sqlite317import time18import urllib.request19from pathlib import Path20from statistics import median2122DB_PATH = Path(__file__).resolve().parent.parent / "data" / "gaz.db"23API = "https://gazquebec.ca/api/stations"24UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)"25TTL = 4 * 3600 # fraîcheur maximale de la copie locale262728def _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.Row32 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 con414243def refresh(con: sqlite3.Connection | None = None) -> int:44 """Télécharge le GeoJSON des stations et remplace la copie locale."""45 own = con is None46 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 continue56 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 # ¢/L62 except ValueError:63 pass64 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)777879def _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))858687def 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 rien97 d = radius_m / 111320.098 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 None113 mini = round(min(regs), 1) if regs else None114 items = [{"nom": r["banniere"] or r["nom"], "adresse": r["adresse"],115 "dist_m": round(dist),116 # position (2026-09-04) : filtre « Essence » de la carte de la fiche117 "lat": r["lat"], "lng": r["lng"],118 "regulier": r["prix_regulier"], "super": r["prix_super"],119 "diesel": r["prix_diesel"],120 "moins_chere": bool(r["prix_regulier"] and mini121 and r["prix_regulier"] <= mini)}122 for dist, r in hits[:limit]]123 return {"n": len(hits), "rayon_m": radius_m, "mediane_regulier": med,124 "min_regulier": mini, "stations": items,125 "maj": time.strftime("%Y-%m-%d %H:%M",126 time.localtime(float(maj["v"])))127 if maj else None}128