SPB Git

spb/lou-ka Public

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

HTML 99.7%

POI : stratégie par tuiles Overpass (11 requêtes pour 1249 immeubles, 4 min)

L'union d'around() par immeuble était intenable (~13 s/immeuble, throttling).
On télécharge tous les POI des catégories par tuile de 0,5° (marge 4 km),
puis calcul local du plus proche par catégorie (préfiltre rectangulaire +
haversine). Miroir kumi.systems avec rotation vers l'instance officielle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 2 days ago (Aug 9, 2026) parent 61c0260

Showing 1 changed file with +107 and −38

modified louka/poi.py +107 −38
@@ -19,11 +19,23 @@ import requests
19 19
20 20 from . import db
21 21
22 OVERPASS_URL = "https://overpass-api.de/api/interpreter"
22 +# Miroirs Overpass (rotation en cas d'erreur/limitation) — kumi.systems
23 +# tolère mieux les gros volumes que l'instance officielle
24 +OVERPASS_URLS = [
25 + "https://overpass.kumi.systems/api/interpreter",
26 + "https://overpass-api.de/api/interpreter",
27 +]
23 28 USER_AGENT = "LouKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"
24 REQUEST_DELAY = 1.1
29 +REQUEST_DELAY = 2.0
25 30 REFRESH_AFTER = 90 * 86400 # les POI bougent peu : rafraîchir aux ~3 mois
26 31
32 +# Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union
33 +# d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI
34 +# des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour
35 +# Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local.
36 +TILE = 0.5
37 +TILE_MARGIN = 0.04 # ~4 km > plus grand rayon de catégorie (3 km)
38 +
27 39 # Catégories : (clé, libellé FR, sélecteur Overpass, rayon m)
28 40 CATEGORIES: list[tuple[str, str, str, int]] = [
29 41 ("epicerie", "Épicerie", '["shop"="supermarket"]', 1500),
@@ -56,12 +68,19 @@ def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
56 68 return 2 * r * math.asin(math.sqrt(a))
57 69
58 70
59 def _build_query(lat: float, lng: float) -> str:
60 """Une seule requête Overpass couvrant toutes les catégories."""
61 parts = []
62 for _cat, _label, sel, radius in CATEGORIES:
63 parts.append(f"nwr(around:{radius},{lat:.5f},{lng:.5f}){sel};")
64 return f'[out:json][timeout:25];({"".join(parts)});out center tags 200;'
71 +def _tile_of(lat: float, lng: float) -> tuple[int, int]:
72 + return (math.floor(lat / TILE), math.floor(lng / TILE))
73 +
74 +
75 +def _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_MARGIN
78 + n = (ty + 1) * TILE + TILE_MARGIN
79 + w = tx * TILE - TILE_MARGIN
80 + e = (tx + 1) * TILE + TILE_MARGIN
81 + 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;'
65 84
66 85
67 86 def _match_category(tags: dict) -> str | None:
@@ -98,29 +117,40 @@ def _match_category(tags: dict) -> str | None:
98 117 return None
99 118
100 119
120 +_RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES}
121 +
122 +
101 123 class PoiClient:
102 124 def __init__(self) -> None:
103 125 self.session = requests.Session()
104 126 self.session.headers["User-Agent"] = USER_AGENT
105 127 self._last = 0.0
128 + self._url_idx = 0
106 129
107 def fetch(self, lat: float, lng: float) -> list[dict] | None:
108 """POI les plus proches par catégorie. None = erreur réseau (re-tenter)."""
130 + def _post(self, query: str) -> list | None:
131 + """POST Overpass avec throttling et rotation de miroir sur erreur."""
109 132 wait = REQUEST_DELAY - (time.time() - self._last)
110 133 if wait > 0:
111 134 time.sleep(wait)
112 try:
113 resp = self.session.post(OVERPASS_URL,
114 data={"data": _build_query(lat, lng)},
115 timeout=40)
116 self._last = time.time()
117 resp.raise_for_status()
118 elements = resp.json().get("elements") or []
119 except Exception:
120 self._last = time.time()
121 return None
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 + continue
146 + return None
122 147
123 meilleurs: dict[str, dict] = {}
148 + 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 None
153 + pois = []
124 154 for el in elements:
125 155 tags = el.get("tags") or {}
126 156 cat = _match_category(tags)
@@ -130,12 +160,29 @@ class PoiClient:
130 160 elng = el.get("lon") or (el.get("center") or {}).get("lon")
131 161 if elat is None or elng is None:
132 162 continue
133 dist = _haversine_m(lat, lng, elat, elng)
134 if cat not in meilleurs or dist < meilleurs[cat]["dist_m"]:
135 nom = tags.get("name") or LABELS[cat]
136 meilleurs[cat] = {"cat": cat, "name": nom[:60],
137 "dist_m": round(dist)}
138 return sorted(meilleurs.values(), key=lambda p: p["dist_m"])
163 + pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60],
164 + "lat": elat, "lng": elng})
165 + return pois
166 +
167 +
168 +def _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'haversine
174 + dlat_max = radius / 111000.0
175 + dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat))))
176 + best = None
177 + for p in pois:
178 + if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max:
179 + continue
180 + 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"])
139 186
140 187
141 188 def run(limit: int | None = None) -> dict:
@@ -150,30 +197,52 @@ def run(limit: int | None = None) -> dict:
150 197 """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings
151 198 WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall()
152 199
153 done = errors = skipped = 0
154 200 now = time.time()
201 + a_faire: list[tuple[float, float]] = []
202 + skipped = 0
155 203 for r in rows:
156 key = f"{r['la']},{r['ln']}"
157 204 cached = con.execute(
158 "SELECT ts FROM poi_cache WHERE coord_key=?", (key,)).fetchone()
205 + "SELECT ts FROM poi_cache WHERE coord_key=?",
206 + (f"{r['la']},{r['ln']}",)).fetchone()
159 207 if cached and now - (cached["ts"] or 0) < REFRESH_AFTER:
160 208 skipped += 1
209 + else:
210 + a_faire.append((r["la"], r["ln"]))
211 + if limit is not None:
212 + a_faire = a_faire[:limit]
213 +
214 + # 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_cat
227 +
228 + # 2) calcul local du plus proche par catégorie pour chaque immeuble
229 + done = errors = 0
230 + 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 run
161 234 continue
162 if limit is not None and done + errors >= limit:
163 continue
164 pois = client.fetch(r["la"], r["ln"])
165 if pois is None:
166 errors += 1
167 continue # erreur réseau : pas de cache, re-tentée au prochain run
235 + pois = _nearest_by_cat(la, ln, pois_by_tile[t])
168 236 con.execute(
169 237 "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)"
170 238 " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts",
171 (key, r["la"], r["ln"], json.dumps(pois, ensure_ascii=False), now))
172 con.commit()
239 + (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now))
173 240 done += 1
241 + con.commit()
174 242
175 243 con.close()
176 244 stats = {"fetched": done, "cached": skipped, "errors": errors,
245 + "tiles": len(tuiles), "tile_errors": len(tile_errors),
177 246 "total_coords": len(rows)}
178 247 print(f"[lou-ka] poi {stats}")
179 248 return stats
180 249