# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # geocode.py : géocodage des salles — Nominatim (OpenStreetMap) avec cache # persistant. Le plus gros gain GPS du pipeline : lavitrine, # lepointdevente, evenko, atuvu, laval… publient salle + ville # (~100 %) mais aucune coordonnée. ~200 salles récurrentes → # le cache disque converge vite, puis plus aucune requête réseau. # Politique Nominatim (https://operations.osmfoundation.org/policies/nominatim/) : # 1 requête/s max (throttle 1,1 s), User-Agent identifié, cache obligatoire. # Garde-fous : on n'invente JAMAIS un GPS — résultat rejeté hors du # rectangle englobant du Québec ; échecs mémorisés (re-tentés après 30 j) ; # plafond de requêtes réseau par cycle (SORTIKA_GEOCODE_MAX, défaut 120). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import sqlite3 import time from pathlib import Path import requests from .normalize import clean_text, strip_accents NOMINATIM = "https://nominatim.openstreetmap.org/search" USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com)" CACHE_PATH = Path(__file__).resolve().parent.parent / "data" / "geocode_cache.json" REQUEST_DELAY = 1.1 # politesse Nominatim : max ~1 req/s GEOCODE_MAX = int(os.environ.get("SORTIKA_GEOCODE_MAX", "120")) # req réseau/cycle RETRY_FAIL_AFTER = 30 * 86400 # re-tenter un échec après 30 jours # Rectangle englobant du Québec (avec marge) : tout résultat hors de cette # boîte est rejeté (homonymes en France, aux É.-U., etc.). QC_BBOX = {"lat_min": 44.5, "lat_max": 63.5, "lng_min": -80.5, "lng_max": -56.5} _last_request = 0.0 def _cache_key(venue: str, city: str) -> str: """Clé normalisée « venue|ville » : minuscules, sans accents, espaces réduits.""" v = " ".join(strip_accents(clean_text(venue)).lower().split()) c = " ".join(strip_accents(clean_text(city)).lower().split()) return f"{v}|{c}" def load_cache() -> dict: try: return json.loads(CACHE_PATH.read_text(encoding="utf-8")) except Exception: return {} def save_cache(cache: dict) -> None: CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) CACHE_PATH.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8") def _in_quebec(lat: float, lng: float) -> bool: return (QC_BBOX["lat_min"] <= lat <= QC_BBOX["lat_max"] and QC_BBOX["lng_min"] <= lng <= QC_BBOX["lng_max"]) def _nominatim(session: requests.Session, query: str, city: str) -> tuple[float, float] | None: """Une requête Nominatim throttlée ; None si aucun résultat valide. Double garde-fou anti-invention : résultat rejeté hors du rectangle Québec ET si sa display_name ne mentionne pas la ville attendue (homonymes d'une autre municipalité). """ global _last_request wait = REQUEST_DELAY - (time.time() - _last_request) if wait > 0: time.sleep(wait) resp = session.get(NOMINATIM, params={ "q": query, "format": "jsonv2", "limit": 1, "countrycodes": "ca", "accept-language": "fr", }, timeout=20) _last_request = time.time() resp.raise_for_status() results = resp.json() if not results: return None lat, lng = float(results[0]["lat"]), float(results[0]["lon"]) if not _in_quebec(lat, lng): return None city_key = strip_accents(city).lower() display = strip_accents(results[0].get("display_name", "")).lower() if city_key and city_key not in display: return None return lat, lng # séparateurs de doubles noms de salle (« Salle X | Centre des arts Y ») : # en cas d'échec sur le nom complet, on re-tente avec le segment le plus long _VENUE_SPLIT = ("|", " - ", " – ", " — ") def _lookup(session: requests.Session, venue: str, city: str) -> tuple[tuple[float, float] | None, int]: """Résout une salle : nom complet, puis segment le plus long en repli. Retourne (coordonnées ou None, nombre de requêtes réseau consommées).""" found = _nominatim(session, f"{venue}, {city}, Québec, Canada", city) requests_used = 1 if found: return found, requests_used for sep in _VENUE_SPLIT: if sep in venue: segment = max((s.strip() for s in venue.split(sep)), key=len) if segment and segment != venue: found = _nominatim( session, f"{segment}, {city}, Québec, Canada", city) requests_used += 1 break return found, requests_used def run(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict: """Remplit lat/lng des événements actifs sans GPS (salle + ville connues). Appelé par le pipeline d'ingestion après les syncs. Les salles déjà résolues viennent du cache (0 requête) — seules les nouvelles salles consomment le plafond de requêtes réseau. """ cache = load_cache() session = requests.Session() session.headers["User-Agent"] = USER_AGENT now = time.time() stats = {"events_filled": 0, "lookups": 0, "hits": 0, "misses": 0, "cached_places": len(cache)} # Phase 2 : l'ADRESSE CIVIQUE prime quand la source la publie (Brossard : # 94 % d'adresses mais 0,7 % de GPS via salle+ville ; Longueuil : 100 % # d'adresses). Repli : salle + ville (comportement historique). rows = con.execute( "SELECT DISTINCT venue, address, city FROM events WHERE active=1 " "AND lat IS NULL AND city != '' AND (venue != '' OR address != '') " "ORDER BY address DESC, venue, city").fetchall() for row in rows: venue, address, city = row["venue"], row["address"], row["city"] # candidats dans l'ordre de précision : adresse civique, puis salle candidates = [] if address: candidates.append(("addr:" + _cache_key(address, city), "addr", address)) if venue: candidates.append((_cache_key(venue, city), "venue", venue)) entry = None for key, kind, query in candidates: entry = cache.get(key) if entry is None or (not entry.get("ok") and now - entry.get("ts", 0) > RETRY_FAIL_AFTER): if stats["lookups"] >= max_requests: entry = None break # plafond atteint : lieu re-tenté au prochain cycle try: if kind == "addr": found = _nominatim( session, f"{query}, {city}, Québec, Canada", city) used = 1 else: found, used = _lookup(session, query, city) except Exception: # réseau/HTTP : ne bloque pas le cycle, pas mémorisé stats["lookups"] += 1 entry = None continue stats["lookups"] += used if found: entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now} stats["hits"] += 1 else: # échec mémorisé (re-tenté après 30 jours) entry = {"ok": False, "ts": now} stats["misses"] += 1 cache[key] = entry if entry and entry.get("ok"): break # premier candidat résolu = le plus précis if entry and entry.get("ok"): cur = con.execute( "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL " "AND venue=? AND address=? AND city=?", (entry["lat"], entry["lng"], venue, address, city)) stats["events_filled"] += cur.rowcount save_cache(cache) con.commit() stats["cached_places"] = len(cache) return stats