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>
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 | |