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%
5.4 KB · 128 lines python
Raw Blame History
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