Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: 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 # replis vérifiés couvrant le Québec (panne simultanée kumi+officiel28 # constatée le 2026-08-25) ; overpass.osm.ch exclu (extrait suisse)29 "https://maps.mail.ru/osm/tools/overpass/api/interpreter",30 "https://overpass.openstreetmap.fr/api/interpreter",31]32USER_AGENT = "RentKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"33REQUEST_DELAY = 2.034REFRESH_AFTER = 90 * 86400 # les POI bougent peu : rafraîchir aux ~3 mois3536# Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union37# d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI38# des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour39# Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local.40TILE = 0.541TILE_MARGIN = 0.04 # ~4 km > plus grand rayon de catégorie (3 km)4243# Catégories : (clé, libellé FR, sélecteur Overpass, rayon m)44CATEGORIES: list[tuple[str, str, str, int]] = [45 ("epicerie", "Épicerie", '["shop"="supermarket"]', 1500),46 ("depanneur", "Dépanneur", '["shop"="convenience"]', 800),47 ("pharmacie", "Pharmacie", '["amenity"="pharmacy"]', 1500),48 ("ecole", "École", '["amenity"="school"]', 1500),49 ("garderie", "Garderie", '["amenity"~"^(kindergarten|childcare)$"]', 1500),50 ("parc", "Parc", '["leisure"="park"]', 1200),51 ("bus", "Arrêt de bus", '["highway"="bus_stop"]', 600),52 ("metro", "Métro", '["railway"="station"]["station"="subway"]', 1500),53 ("gym", "Gym", '["leisure"="fitness_centre"]', 1500),54 ("cafe", "Café", '["amenity"="cafe"]', 1000),55 ("clinique", "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]', 2000),56 ("hopital", "Hôpital", '["amenity"="hospital"]', 3000),57 ("bibliotheque", "Bibliothèque", '["amenity"="library"]', 2000),58]5960LABELS = {cat: label for cat, label, _, _ in CATEGORIES}616263def coord_key(lat: float, lng: float) -> str:64 return f"{round(lat, 4)},{round(lng, 4)}"656667def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:68 r = 6371000.069 p1, p2 = math.radians(lat1), math.radians(lat2)70 dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)71 a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 272 return 2 * r * math.asin(math.sqrt(a))737475def _tile_of(lat: float, lng: float) -> tuple[int, int]:76 return (math.floor(lat / TILE), math.floor(lng / TILE))777879def _build_tile_query(ty: int, tx: int) -> str:80 """Tous les POI des catégories dans la tuile (bbox élargie de la marge)."""81 s = ty * TILE - TILE_MARGIN82 n = (ty + 1) * TILE + TILE_MARGIN83 w = tx * TILE - TILE_MARGIN84 e = (tx + 1) * TILE + TILE_MARGIN85 bbox = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"86 parts = [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES]87 return f'[out:json][timeout:180];({"".join(parts)});out center tags;'888990def _match_category(tags: dict) -> str | None:91 """Retrouve la catégorie Rent-Ka d'un élément OSM retourné."""92 shop = tags.get("shop")93 amenity = tags.get("amenity")94 leisure = tags.get("leisure")95 if shop == "supermarket":96 return "epicerie"97 if shop == "convenience":98 return "depanneur"99 if amenity == "pharmacy":100 return "pharmacie"101 if amenity == "school":102 return "ecole"103 if amenity in ("kindergarten", "childcare"):104 return "garderie"105 if leisure == "park":106 return "parc"107 if tags.get("highway") == "bus_stop":108 return "bus"109 if tags.get("railway") == "station" and tags.get("station") == "subway":110 return "metro"111 if leisure == "fitness_centre":112 return "gym"113 if amenity == "cafe":114 return "cafe"115 if amenity in ("clinic", "doctors"):116 return "clinique"117 if amenity == "hospital":118 return "hopital"119 if amenity == "library":120 return "bibliotheque"121 return None122123124_RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES}125126127class PoiClient:128 def __init__(self) -> None:129 self.session = requests.Session()130 self.session.headers["User-Agent"] = USER_AGENT131 self._last = 0.0132 self._url_idx = 0133134 def _post(self, query: str) -> list | None:135 """POST Overpass avec throttling et rotation de miroir sur erreur."""136 wait = REQUEST_DELAY - (time.time() - self._last)137 if wait > 0:138 time.sleep(wait)139 for essai in range(len(OVERPASS_URLS)):140 url = OVERPASS_URLS[(self._url_idx + essai) % len(OVERPASS_URLS)]141 try:142 resp = self.session.post(url, data={"data": query}, timeout=90)143 self._last = time.time()144 resp.raise_for_status()145 self._url_idx = (self._url_idx + essai) % len(OVERPASS_URLS)146 return resp.json().get("elements") or []147 except Exception:148 self._last = time.time()149 continue150 return None151152 def fetch_tile(self, ty: int, tx: int) -> list[dict] | None:153 """Tous les POI catégorisés d'une tuile : [{cat, name, lat, lng}]."""154 elements = self._post(_build_tile_query(ty, tx))155 if elements is None:156 return None157 pois = []158 for el in elements:159 tags = el.get("tags") or {}160 cat = _match_category(tags)161 if cat is None:162 continue163 elat = el.get("lat") or (el.get("center") or {}).get("lat")164 elng = el.get("lon") or (el.get("center") or {}).get("lon")165 if elat is None or elng is None:166 continue167 pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60],168 "lat": elat, "lng": elng})169 return pois170171172def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]]) -> list[dict]:173 """Plus proche POI de chaque catégorie (dans son rayon), trié par distance."""174 out = []175 for cat, pois in pois_by_cat.items():176 radius = _RADII[cat]177 # préfiltre rectangulaire bon marché avant l'haversine178 dlat_max = radius / 111000.0179 dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat))))180 best = None181 for p in pois:182 if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max:183 continue184 d = _haversine_m(lat, lng, p["lat"], p["lng"])185 if d <= radius and (best is None or d < best["dist_m"]):186 best = {"cat": cat, "name": p["name"], "dist_m": round(d)}187 if best:188 out.append(best)189 return sorted(out, key=lambda p: p["dist_m"])190191192def run(limit: int | None = None) -> dict:193 """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas.194195 `limit` borne le nombre de requêtes Overpass de cette exécution196 (les entrées déjà en cache ne coûtent rien).197 """198 con = db.connect()199 client = PoiClient()200 rows = con.execute(201 """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings202 WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall()203204 now = time.time()205 a_faire: list[tuple[float, float]] = []206 skipped = 0207 for r in rows:208 cached = con.execute(209 "SELECT ts FROM poi_cache WHERE coord_key=?",210 (f"{r['la']},{r['ln']}",)).fetchone()211 if cached and now - (cached["ts"] or 0) < REFRESH_AFTER:212 skipped += 1213 else:214 a_faire.append((r["la"], r["ln"]))215 if limit is not None:216 a_faire = a_faire[:limit]217218 # 1) télécharger les POI des tuiles nécessaires (une requête par tuile)219 tuiles = sorted({_tile_of(la, ln) for la, ln in a_faire})220 pois_by_tile: dict[tuple[int, int], dict[str, list[dict]]] = {}221 tile_errors = []222 for t in tuiles:223 res = client.fetch_tile(*t)224 if res is None:225 tile_errors.append(t)226 else:227 by_cat: dict[str, list[dict]] = {}228 for p in res:229 by_cat.setdefault(p["cat"], []).append(p)230 pois_by_tile[t] = by_cat231232 # 2) calcul local du plus proche par catégorie pour chaque immeuble233 done = errors = 0234 for la, ln in a_faire:235 t = _tile_of(la, ln)236 if t not in pois_by_tile:237 errors += 1 # tuile en échec : re-tentée au prochain run238 continue239 pois = _nearest_by_cat(la, ln, pois_by_tile[t])240 con.execute(241 "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)"242 " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts",243 (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now))244 done += 1245 con.commit()246247 con.close()248 stats = {"fetched": done, "cached": skipped, "errors": errors,249 "tiles": len(tuiles), "tile_errors": len(tile_errors),250 "total_coords": len(rows)}251 print(f"[rent-ka] poi {stats}")252 return stats253