spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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