# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # poi.py : commodités de proximité par immeuble via Overpass (OpenStreetMap) # Pour chaque immeuble géolocalisé, une requête Overpass unique récupère # les points d'intérêt utiles à un locataire (épicerie, pharmacie, école, # garderie, parc, arrêt de bus, gym, clinique…) ; on retient le PLUS PROCHE # de chaque catégorie avec sa distance. Cache permanent par coordonnées # (table poi_cache, clé arrondie à 4 décimales ≈ 11 m : les unités d'un # même immeuble partagent la même entrée). Politesse : 1 requête/seconde. # ----------------------------------------------------------------------------- from __future__ import annotations import json import math import time import requests from . import db # Miroirs Overpass (rotation en cas d'erreur/limitation) — kumi.systems # tolère mieux les gros volumes que l'instance officielle OVERPASS_URLS = [ "https://overpass.kumi.systems/api/interpreter", "https://overpass-api.de/api/interpreter", ] USER_AGENT = "LouKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)" REQUEST_DELAY = 2.0 REFRESH_AFTER = 90 * 86400 # les POI bougent peu : rafraîchir aux ~3 mois # Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union # d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI # des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour # Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local. TILE = 0.5 TILE_MARGIN = 0.04 # ~4 km > plus grand rayon de catégorie (3 km) # Catégories : (clé, libellé FR, sélecteur Overpass, rayon m) CATEGORIES: list[tuple[str, str, str, int]] = [ ("epicerie", "Épicerie", '["shop"="supermarket"]', 1500), ("depanneur", "Dépanneur", '["shop"="convenience"]', 800), ("pharmacie", "Pharmacie", '["amenity"="pharmacy"]', 1500), ("ecole", "École", '["amenity"="school"]', 1500), ("garderie", "Garderie", '["amenity"~"^(kindergarten|childcare)$"]', 1500), ("parc", "Parc", '["leisure"="park"]', 1200), ("bus", "Arrêt de bus", '["highway"="bus_stop"]', 600), ("metro", "Métro", '["railway"="station"]["station"="subway"]', 1500), ("gym", "Gym", '["leisure"="fitness_centre"]', 1500), ("cafe", "Café", '["amenity"="cafe"]', 1000), ("clinique", "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]', 2000), ("hopital", "Hôpital", '["amenity"="hospital"]', 3000), ("bibliotheque", "Bibliothèque", '["amenity"="library"]', 2000), ] LABELS = {cat: label for cat, label, _, _ in CATEGORIES} def coord_key(lat: float, lng: float) -> str: return f"{round(lat, 4)},{round(lng, 4)}" def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: r = 6371000.0 p1, p2 = math.radians(lat1), math.radians(lat2) dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * r * math.asin(math.sqrt(a)) def _tile_of(lat: float, lng: float) -> tuple[int, int]: return (math.floor(lat / TILE), math.floor(lng / TILE)) def _build_tile_query(ty: int, tx: int) -> str: """Tous les POI des catégories dans la tuile (bbox élargie de la marge).""" s = ty * TILE - TILE_MARGIN n = (ty + 1) * TILE + TILE_MARGIN w = tx * TILE - TILE_MARGIN e = (tx + 1) * TILE + TILE_MARGIN bbox = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}" parts = [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES] return f'[out:json][timeout:180];({"".join(parts)});out center tags;' def _match_category(tags: dict) -> str | None: """Retrouve la catégorie Lou-Ka d'un élément OSM retourné.""" shop = tags.get("shop") amenity = tags.get("amenity") leisure = tags.get("leisure") if shop == "supermarket": return "epicerie" if shop == "convenience": return "depanneur" if amenity == "pharmacy": return "pharmacie" if amenity == "school": return "ecole" if amenity in ("kindergarten", "childcare"): return "garderie" if leisure == "park": return "parc" if tags.get("highway") == "bus_stop": return "bus" if tags.get("railway") == "station" and tags.get("station") == "subway": return "metro" if leisure == "fitness_centre": return "gym" if amenity == "cafe": return "cafe" if amenity in ("clinic", "doctors"): return "clinique" if amenity == "hospital": return "hopital" if amenity == "library": return "bibliotheque" return None _RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES} class PoiClient: def __init__(self) -> None: self.session = requests.Session() self.session.headers["User-Agent"] = USER_AGENT self._last = 0.0 self._url_idx = 0 def _post(self, query: str) -> list | None: """POST Overpass avec throttling et rotation de miroir sur erreur.""" wait = REQUEST_DELAY - (time.time() - self._last) if wait > 0: time.sleep(wait) for essai in range(len(OVERPASS_URLS)): url = OVERPASS_URLS[(self._url_idx + essai) % len(OVERPASS_URLS)] try: resp = self.session.post(url, data={"data": query}, timeout=90) self._last = time.time() resp.raise_for_status() self._url_idx = (self._url_idx + essai) % len(OVERPASS_URLS) return resp.json().get("elements") or [] except Exception: self._last = time.time() continue return None def fetch_tile(self, ty: int, tx: int) -> list[dict] | None: """Tous les POI catégorisés d'une tuile : [{cat, name, lat, lng}].""" elements = self._post(_build_tile_query(ty, tx)) if elements is None: return None pois = [] for el in elements: tags = el.get("tags") or {} cat = _match_category(tags) if cat is None: continue elat = el.get("lat") or (el.get("center") or {}).get("lat") elng = el.get("lon") or (el.get("center") or {}).get("lon") if elat is None or elng is None: continue pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60], "lat": elat, "lng": elng}) return pois def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]]) -> list[dict]: """Plus proche POI de chaque catégorie (dans son rayon), trié par distance.""" out = [] for cat, pois in pois_by_cat.items(): radius = _RADII[cat] # préfiltre rectangulaire bon marché avant l'haversine dlat_max = radius / 111000.0 dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat)))) best = None for p in pois: if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max: continue d = _haversine_m(lat, lng, p["lat"], p["lng"]) if d <= radius and (best is None or d < best["dist_m"]): best = {"cat": cat, "name": p["name"], "dist_m": round(d)} if best: out.append(best) return sorted(out, key=lambda p: p["dist_m"]) def run(limit: int | None = None) -> dict: """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas. `limit` borne le nombre de requêtes Overpass de cette exécution (les entrées déjà en cache ne coûtent rien). """ con = db.connect() client = PoiClient() rows = con.execute( """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall() now = time.time() a_faire: list[tuple[float, float]] = [] skipped = 0 for r in rows: cached = con.execute( "SELECT ts FROM poi_cache WHERE coord_key=?", (f"{r['la']},{r['ln']}",)).fetchone() if cached and now - (cached["ts"] or 0) < REFRESH_AFTER: skipped += 1 else: a_faire.append((r["la"], r["ln"])) if limit is not None: a_faire = a_faire[:limit] # 1) télécharger les POI des tuiles nécessaires (une requête par tuile) tuiles = sorted({_tile_of(la, ln) for la, ln in a_faire}) pois_by_tile: dict[tuple[int, int], dict[str, list[dict]]] = {} tile_errors = [] for t in tuiles: res = client.fetch_tile(*t) if res is None: tile_errors.append(t) else: by_cat: dict[str, list[dict]] = {} for p in res: by_cat.setdefault(p["cat"], []).append(p) pois_by_tile[t] = by_cat # 2) calcul local du plus proche par catégorie pour chaque immeuble done = errors = 0 for la, ln in a_faire: t = _tile_of(la, ln) if t not in pois_by_tile: errors += 1 # tuile en échec : re-tentée au prochain run continue pois = _nearest_by_cat(la, ln, pois_by_tile[t]) con.execute( "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)" " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts", (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now)) done += 1 con.commit() con.close() stats = {"fetched": done, "cached": skipped, "errors": errors, "tiles": len(tuiles), "tile_errors": len(tile_errors), "total_coords": len(rows)} print(f"[lou-ka] poi {stats}") return stats