Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# geocode.py : géocodage des salles — Nominatim (OpenStreetMap) avec cache5# persistant. Le plus gros gain GPS du pipeline : lavitrine,6# lepointdevente, evenko, atuvu, laval… publient salle + ville7# (~100 %) mais aucune coordonnée. ~200 salles récurrentes →8# le cache disque converge vite, puis plus aucune requête réseau.9# Politique Nominatim (https://operations.osmfoundation.org/policies/nominatim/) :10# 1 requête/s max (throttle 1,1 s), User-Agent identifié, cache obligatoire.11# Garde-fous : on n'invente JAMAIS un GPS — résultat rejeté hors du12# rectangle englobant du Québec ; échecs mémorisés (re-tentés après 30 j) ;13# plafond de requêtes réseau par cycle (SORTIKA_GEOCODE_MAX, défaut 120).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import os19import sqlite320import time21from pathlib import Path2223import requests2425from .normalize import clean_text, strip_accents2627NOMINATIM = "https://nominatim.openstreetmap.org/search"28USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com)"29CACHE_PATH = Path(__file__).resolve().parent.parent / "data" / "geocode_cache.json"3031REQUEST_DELAY = 1.1 # politesse Nominatim : max ~1 req/s32GEOCODE_MAX = int(os.environ.get("SORTIKA_GEOCODE_MAX", "120")) # req réseau/cycle33RETRY_FAIL_AFTER = 30 * 86400 # re-tenter un échec après 30 jours3435# Rectangle englobant du Québec (avec marge) : tout résultat hors de cette36# boîte est rejeté (homonymes en France, aux É.-U., etc.).37QC_BBOX = {"lat_min": 44.5, "lat_max": 63.5, "lng_min": -80.5, "lng_max": -56.5}3839_last_request = 0.0404142def _cache_key(venue: str, city: str) -> str:43 """Clé normalisée « venue|ville » : minuscules, sans accents, espaces réduits."""44 v = " ".join(strip_accents(clean_text(venue)).lower().split())45 c = " ".join(strip_accents(clean_text(city)).lower().split())46 return f"{v}|{c}"474849def load_cache() -> dict:50 try:51 return json.loads(CACHE_PATH.read_text(encoding="utf-8"))52 except Exception:53 return {}545556def save_cache(cache: dict) -> None:57 CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)58 CACHE_PATH.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")596061def _in_quebec(lat: float, lng: float) -> bool:62 return (QC_BBOX["lat_min"] <= lat <= QC_BBOX["lat_max"]63 and QC_BBOX["lng_min"] <= lng <= QC_BBOX["lng_max"])646566def _nominatim(session: requests.Session, query: str,67 city: str) -> tuple[float, float] | None:68 """Une requête Nominatim throttlée ; None si aucun résultat valide.6970 Double garde-fou anti-invention : résultat rejeté hors du rectangle71 Québec ET si sa display_name ne mentionne pas la ville attendue72 (homonymes d'une autre municipalité).73 """74 global _last_request75 wait = REQUEST_DELAY - (time.time() - _last_request)76 if wait > 0:77 time.sleep(wait)78 resp = session.get(NOMINATIM, params={79 "q": query, "format": "jsonv2", "limit": 1,80 "countrycodes": "ca", "accept-language": "fr",81 }, timeout=20)82 _last_request = time.time()83 resp.raise_for_status()84 results = resp.json()85 if not results:86 return None87 lat, lng = float(results[0]["lat"]), float(results[0]["lon"])88 if not _in_quebec(lat, lng):89 return None90 city_key = strip_accents(city).lower()91 display = strip_accents(results[0].get("display_name", "")).lower()92 if city_key and city_key not in display:93 return None94 return lat, lng959697# séparateurs de doubles noms de salle (« Salle X | Centre des arts Y ») :98# en cas d'échec sur le nom complet, on re-tente avec le segment le plus long99_VENUE_SPLIT = ("|", " - ", " – ", " — ")100101102def _lookup(session: requests.Session, venue: str,103 city: str) -> tuple[tuple[float, float] | None, int]:104 """Résout une salle : nom complet, puis segment le plus long en repli.105 Retourne (coordonnées ou None, nombre de requêtes réseau consommées)."""106 found = _nominatim(session, f"{venue}, {city}, Québec, Canada", city)107 requests_used = 1108 if found:109 return found, requests_used110 for sep in _VENUE_SPLIT:111 if sep in venue:112 segment = max((s.strip() for s in venue.split(sep)), key=len)113 if segment and segment != venue:114 found = _nominatim(115 session, f"{segment}, {city}, Québec, Canada", city)116 requests_used += 1117 break118 return found, requests_used119120121def run(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict:122 """Remplit lat/lng des événements actifs sans GPS (salle + ville connues).123124 Appelé par le pipeline d'ingestion après les syncs. Les salles déjà125 résolues viennent du cache (0 requête) — seules les nouvelles salles126 consomment le plafond de requêtes réseau.127 """128 cache = load_cache()129 session = requests.Session()130 session.headers["User-Agent"] = USER_AGENT131 now = time.time()132 stats = {"events_filled": 0, "lookups": 0, "hits": 0, "misses": 0,133 "cached_places": len(cache)}134135 # Phase 2 : l'ADRESSE CIVIQUE prime quand la source la publie (Brossard :136 # 94 % d'adresses mais 0,7 % de GPS via salle+ville ; Longueuil : 100 %137 # d'adresses). Repli : salle + ville (comportement historique).138 rows = con.execute(139 "SELECT DISTINCT venue, address, city FROM events WHERE active=1 "140 "AND lat IS NULL AND city != '' AND (venue != '' OR address != '') "141 "ORDER BY address DESC, venue, city").fetchall()142143 for row in rows:144 venue, address, city = row["venue"], row["address"], row["city"]145 # candidats dans l'ordre de précision : adresse civique, puis salle146 candidates = []147 if address:148 candidates.append(("addr:" + _cache_key(address, city),149 "addr", address))150 if venue:151 candidates.append((_cache_key(venue, city), "venue", venue))152 entry = None153 for key, kind, query in candidates:154 entry = cache.get(key)155 if entry is None or (not entry.get("ok")156 and now - entry.get("ts", 0) > RETRY_FAIL_AFTER):157 if stats["lookups"] >= max_requests:158 entry = None159 break # plafond atteint : lieu re-tenté au prochain cycle160 try:161 if kind == "addr":162 found = _nominatim(163 session, f"{query}, {city}, Québec, Canada", city)164 used = 1165 else:166 found, used = _lookup(session, query, city)167 except Exception: # réseau/HTTP : ne bloque pas le cycle, pas mémorisé168 stats["lookups"] += 1169 entry = None170 continue171 stats["lookups"] += used172 if found:173 entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}174 stats["hits"] += 1175 else: # échec mémorisé (re-tenté après 30 jours)176 entry = {"ok": False, "ts": now}177 stats["misses"] += 1178 cache[key] = entry179 if entry and entry.get("ok"):180 break # premier candidat résolu = le plus précis181 if entry and entry.get("ok"):182 cur = con.execute(183 "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL "184 "AND venue=? AND address=? AND city=?",185 (entry["lat"], entry["lng"], venue, address, city))186 stats["events_filled"] += cur.rowcount187188 save_cache(cache)189 con.commit()190 stats["cached_places"] = len(cache)191 return stats192