SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.1 KB · 249 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# poi.py : commodités de proximité par immeuble via Overpass (OpenStreetMap)5#   Pour chaque immeuble géolocalisé, une requête Overpass unique récupère6#   les points d'intérêt utiles à un locataire (épicerie, pharmacie, école,7#   garderie, parc, arrêt de bus, gym, clinique…) ; on retient le PLUS PROCHE8#   de chaque catégorie avec sa distance. Cache permanent par coordonnées9#   (table poi_cache, clé arrondie à 4 décimales ≈ 11 m : les unités d'un10#   même immeuble partagent la même entrée). Politesse : 1 requête/seconde.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import json15import math16import time1718import requests1920from . import db2122# Miroirs Overpass (rotation en cas d'erreur/limitation) — kumi.systems23# tolère mieux les gros volumes que l'instance officielle24OVERPASS_URLS = [25    "https://overpass.kumi.systems/api/interpreter",26    "https://overpass-api.de/api/interpreter",27]28USER_AGENT = "LouKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"29REQUEST_DELAY = 2.030REFRESH_AFTER = 90 * 86400      # les POI bougent peu : rafraîchir aux ~3 mois3132# Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union33# d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI34# des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour35# Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local.36TILE = 0.537TILE_MARGIN = 0.04              # ~4 km > plus grand rayon de catégorie (3 km)3839# Catégories : (clé, libellé FR, sélecteur Overpass, rayon m)40CATEGORIES: list[tuple[str, str, str, int]] = [41    ("epicerie",   "Épicerie",        '["shop"="supermarket"]',                    1500),42    ("depanneur",  "Dépanneur",       '["shop"="convenience"]',                     800),43    ("pharmacie",  "Pharmacie",       '["amenity"="pharmacy"]',                    1500),44    ("ecole",      "École",           '["amenity"="school"]',                      1500),45    ("garderie",   "Garderie",        '["amenity"~"^(kindergarten|childcare)$"]',  1500),46    ("parc",       "Parc",            '["leisure"="park"]',                        1200),47    ("bus",        "Arrêt de bus",    '["highway"="bus_stop"]',                     600),48    ("metro",      "Métro",           '["railway"="station"]["station"="subway"]', 1500),49    ("gym",        "Gym",             '["leisure"="fitness_centre"]',              1500),50    ("cafe",       "Café",            '["amenity"="cafe"]',                        1000),51    ("clinique",   "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]',          2000),52    ("hopital",    "Hôpital",         '["amenity"="hospital"]',                    3000),53    ("bibliotheque", "Bibliothèque",  '["amenity"="library"]',                     2000),54]5556LABELS = {cat: label for cat, label, _, _ in CATEGORIES}575859def coord_key(lat: float, lng: float) -> str:60    return f"{round(lat, 4)},{round(lng, 4)}"616263def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:64    r = 6371000.065    p1, p2 = math.radians(lat1), math.radians(lat2)66    dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)67    a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 268    return 2 * r * math.asin(math.sqrt(a))697071def _tile_of(lat: float, lng: float) -> tuple[int, int]:72    return (math.floor(lat / TILE), math.floor(lng / TILE))737475def _build_tile_query(ty: int, tx: int) -> str:76    """Tous les POI des catégories dans la tuile (bbox élargie de la marge)."""77    s = ty * TILE - TILE_MARGIN78    n = (ty + 1) * TILE + TILE_MARGIN79    w = tx * TILE - TILE_MARGIN80    e = (tx + 1) * TILE + TILE_MARGIN81    bbox = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"82    parts = [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES]83    return f'[out:json][timeout:180];({"".join(parts)});out center tags;'848586def _match_category(tags: dict) -> str | None:87    """Retrouve la catégorie Lou-Ka d'un élément OSM retourné."""88    shop = tags.get("shop")89    amenity = tags.get("amenity")90    leisure = tags.get("leisure")91    if shop == "supermarket":92        return "epicerie"93    if shop == "convenience":94        return "depanneur"95    if amenity == "pharmacy":96        return "pharmacie"97    if amenity == "school":98        return "ecole"99    if amenity in ("kindergarten", "childcare"):100        return "garderie"101    if leisure == "park":102        return "parc"103    if tags.get("highway") == "bus_stop":104        return "bus"105    if tags.get("railway") == "station" and tags.get("station") == "subway":106        return "metro"107    if leisure == "fitness_centre":108        return "gym"109    if amenity == "cafe":110        return "cafe"111    if amenity in ("clinic", "doctors"):112        return "clinique"113    if amenity == "hospital":114        return "hopital"115    if amenity == "library":116        return "bibliotheque"117    return None118119120_RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES}121122123class PoiClient:124    def __init__(self) -> None:125        self.session = requests.Session()126        self.session.headers["User-Agent"] = USER_AGENT127        self._last = 0.0128        self._url_idx = 0129130    def _post(self, query: str) -> list | None:131        """POST Overpass avec throttling et rotation de miroir sur erreur."""132        wait = REQUEST_DELAY - (time.time() - self._last)133        if wait > 0:134            time.sleep(wait)135        for essai in range(len(OVERPASS_URLS)):136            url = OVERPASS_URLS[(self._url_idx + essai) % len(OVERPASS_URLS)]137            try:138                resp = self.session.post(url, data={"data": query}, timeout=90)139                self._last = time.time()140                resp.raise_for_status()141                self._url_idx = (self._url_idx + essai) % len(OVERPASS_URLS)142                return resp.json().get("elements") or []143            except Exception:144                self._last = time.time()145                continue146        return None147148    def fetch_tile(self, ty: int, tx: int) -> list[dict] | None:149        """Tous les POI catégorisés d'une tuile : [{cat, name, lat, lng}]."""150        elements = self._post(_build_tile_query(ty, tx))151        if elements is None:152            return None153        pois = []154        for el in elements:155            tags = el.get("tags") or {}156            cat = _match_category(tags)157            if cat is None:158                continue159            elat = el.get("lat") or (el.get("center") or {}).get("lat")160            elng = el.get("lon") or (el.get("center") or {}).get("lon")161            if elat is None or elng is None:162                continue163            pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60],164                         "lat": elat, "lng": elng})165        return pois166167168def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]]) -> list[dict]:169    """Plus proche POI de chaque catégorie (dans son rayon), trié par distance."""170    out = []171    for cat, pois in pois_by_cat.items():172        radius = _RADII[cat]173        # préfiltre rectangulaire bon marché avant l'haversine174        dlat_max = radius / 111000.0175        dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat))))176        best = None177        for p in pois:178            if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max:179                continue180            d = _haversine_m(lat, lng, p["lat"], p["lng"])181            if d <= radius and (best is None or d < best["dist_m"]):182                best = {"cat": cat, "name": p["name"], "dist_m": round(d)}183        if best:184            out.append(best)185    return sorted(out, key=lambda p: p["dist_m"])186187188def run(limit: int | None = None) -> dict:189    """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas.190191    `limit` borne le nombre de requêtes Overpass de cette exécution192    (les entrées déjà en cache ne coûtent rien).193    """194    con = db.connect()195    client = PoiClient()196    rows = con.execute(197        """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings198           WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall()199200    now = time.time()201    a_faire: list[tuple[float, float]] = []202    skipped = 0203    for r in rows:204        cached = con.execute(205            "SELECT ts FROM poi_cache WHERE coord_key=?",206            (f"{r['la']},{r['ln']}",)).fetchone()207        if cached and now - (cached["ts"] or 0) < REFRESH_AFTER:208            skipped += 1209        else:210            a_faire.append((r["la"], r["ln"]))211    if limit is not None:212        a_faire = a_faire[:limit]213214    # 1) télécharger les POI des tuiles nécessaires (une requête par tuile)215    tuiles = sorted({_tile_of(la, ln) for la, ln in a_faire})216    pois_by_tile: dict[tuple[int, int], dict[str, list[dict]]] = {}217    tile_errors = []218    for t in tuiles:219        res = client.fetch_tile(*t)220        if res is None:221            tile_errors.append(t)222        else:223            by_cat: dict[str, list[dict]] = {}224            for p in res:225                by_cat.setdefault(p["cat"], []).append(p)226            pois_by_tile[t] = by_cat227228    # 2) calcul local du plus proche par catégorie pour chaque immeuble229    done = errors = 0230    for la, ln in a_faire:231        t = _tile_of(la, ln)232        if t not in pois_by_tile:233            errors += 1             # tuile en échec : re-tentée au prochain run234            continue235        pois = _nearest_by_cat(la, ln, pois_by_tile[t])236        con.execute(237            "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)"238            " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts",239            (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now))240        done += 1241    con.commit()242243    con.close()244    stats = {"fetched": done, "cached": skipped, "errors": errors,245             "tiles": len(tuiles), "tile_errors": len(tile_errors),246             "total_coords": len(rows)}247    print(f"[lou-ka] poi {stats}")248    return stats249