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# venues.py : ANNUAIRE DE SALLES (Phase 3) — table `venues` (nom canonique +5# ville + GPS) qui résout les fiches sans coordonnées quand le6# géocodage direct échoue (~3 860 fiches lavitrine/lepointdevente7# au bilan Phase 2). Trois origines, jamais de GPS inventé :8# · « appris » : salles déjà géocodées PAR LES SOURCES en base9# (auto-apprentissage, 0 requête réseau) ;10# · « osm » : lieux culturels du Québec via Overpass11# (amenity=theatre/cinema/arts_centre/events_venue/12# concert_hall…, refresh 30 j, 1 requête) ;13# · « nominatim »: géocodage des salles récurrentes les plus14# fréquentes (top volume, plafonné par cycle).15# Matching : nom normalisé (minuscules, sans accents, alphanumérique) —16# d'abord (salle, ville), puis alias (segments « | », « - », « , »), puis17# nom seul UNIQUEMENT si la clé est unique dans tout l'annuaire (≥ 8 car.)18# et que la ville ne contredit pas. Un nom générique (« salle communautaire »)19# existe en plusieurs exemplaires → jamais matché sans ville.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import json24import os25import re26import sqlite327import time28from pathlib import Path2930from .normalize import clean_text, strip_accents3132OVERPASS_URL = "https://overpass-api.de/api/interpreter" # PAS overpass.osm.ch33USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com; contact@spboucher.ai)"3435OSM_REFRESH_AFTER = 30 * 86400 # re-télécharger l'extrait OSM après 30 j36GEOCODE_MAX = int(os.environ.get("VENUES_GEOCODE_MAX", "40")) # req Nominatim/cycle37MIN_KEY_LEN = 8 # clé minimale pour un match sans ville3839_SCHEMA = """40CREATE TABLE IF NOT EXISTS venues (41 id INTEGER PRIMARY KEY AUTOINCREMENT,42 name TEXT NOT NULL,43 name_key TEXT NOT NULL,44 city TEXT DEFAULT '',45 city_key TEXT DEFAULT '',46 address TEXT DEFAULT '',47 lat REAL,48 lng REAL,49 origin TEXT, -- appris | osm | nominatim50 events_seen INTEGER DEFAULT 0,51 created_at REAL,52 updated_at REAL,53 UNIQUE(name_key, city_key)54);55CREATE INDEX IF NOT EXISTS idx_venues_name ON venues(name_key);5657CREATE TABLE IF NOT EXISTS venues_meta (58 key TEXT PRIMARY KEY,59 value TEXT60);61"""6263# séparateurs de noms composés : « Théâtre Desjardins | Salle Jean Grimaldi »,64# « Salle Wilfrid-Pelletier, Place des Arts », « Le Carré 150 - Cabaret … »65_SPLIT_RE = re.compile(r"\s*(?:\||,| - | – | — )\s*")666768def _key(s: str) -> str:69 """Clé de matching : minuscules, sans accents, alphanumérique + espaces."""70 s = strip_accents(clean_text(s or "")).lower()71 s = re.sub(r"[^a-z0-9]+", " ", s)72 return " ".join(s.split())737475def name_aliases(venue: str) -> list[str]:76 """Clés candidates d'un nom de salle : nom complet puis segments composés77 (du plus long au plus court) — on ne fabrique jamais d'autre variante."""78 full = _key(venue)79 if not full:80 return []81 out = [full]82 parts = [p for p in _SPLIT_RE.split(clean_text(venue)) if p.strip()]83 if len(parts) > 1:84 for p in sorted(parts, key=len, reverse=True):85 k = _key(p)86 # segments courts admis : ils ne servent qu'au match AVEC ville87 # (le match sans ville exige MIN_KEY_LEN, voir _match_row)88 if len(k) >= 4 and k not in out:89 out.append(k)90 return out919293def ensure_schema(con: sqlite3.Connection) -> None:94 con.executescript(_SCHEMA)95 con.commit()969798def _upsert(con: sqlite3.Connection, name: str, city: str, lat: float,99 lng: float, origin: str, address: str = "",100 events_seen: int = 0, _alias_of: bool = False) -> None:101 """Insère une salle ; une salle déjà apprise n'est PAS écrasée par une102 origine moins fiable (appris > nominatim > osm : la donnée source prime).103 Les noms COMPOSÉS (« Maison des Arts de Laval - Théâtre des Muses »)104 génèrent aussi une entrée par segment significatif (mêmes coordonnées,105 même ville) : une source qui ne publie que « Théâtre des Muses » matche."""106 nk, ck = _key(name), _key(city)107 if not nk or lat is None or lng is None:108 return109 if not _alias_of:110 for seg in name_aliases(name)[1:]:111 if len(seg) >= MIN_KEY_LEN:112 _upsert(con, seg, city, lat, lng, origin,113 address=address, _alias_of=True)114 now = time.time()115 rank = {"appris": 3, "nominatim": 2, "osm": 1}116 row = con.execute("SELECT origin, events_seen FROM venues "117 "WHERE name_key=? AND city_key=?", (nk, ck)).fetchone()118 if row is None:119 con.execute(120 "INSERT OR IGNORE INTO venues (name, name_key, city, city_key, "121 "address, lat, lng, origin, events_seen, created_at, updated_at) "122 "VALUES (?,?,?,?,?,?,?,?,?,?,?)",123 (clean_text(name), nk, clean_text(city), ck, clean_text(address),124 lat, lng, origin, events_seen, now, now))125 elif rank.get(origin, 0) >= rank.get(row["origin"] or "", 0):126 con.execute(127 "UPDATE venues SET name=?, city=?, address=?, lat=?, lng=?, "128 "origin=?, events_seen=MAX(events_seen, ?), updated_at=? "129 "WHERE name_key=? AND city_key=?",130 (clean_text(name), clean_text(city), clean_text(address),131 lat, lng, origin, events_seen, now, nk, ck))132133134# ---------------------------------------------------------------------------135# 1) Auto-apprentissage : salles géocodées PAR LES SOURCES (0 réseau)136# ---------------------------------------------------------------------------137138def learn(con: sqlite3.Connection) -> int:139 """Apprend les salles des événements qui ont déjà un GPS de la source.140 La coordonnée retenue est celle du groupe (salle, ville) le plus vu."""141 ensure_schema(con)142 n = 0143 for r in con.execute(144 "SELECT venue, city, lat, lng, COUNT(*) AS n FROM events "145 "WHERE lat IS NOT NULL AND lng IS NOT NULL AND venue != '' "146 "GROUP BY venue, city, round(lat, 3), round(lng, 3) "147 "ORDER BY n"): # le plus fréquent écrit en dernier = gagne148 _upsert(con, r["venue"], r["city"], r["lat"], r["lng"],149 "appris", events_seen=r["n"])150 n += 1151 con.commit()152 return n153154155# ---------------------------------------------------------------------------156# 2) OSM / Overpass : lieux culturels du Québec (1 requête, cache 30 j)157# ---------------------------------------------------------------------------158159_OVERPASS_QUERY = """160[out:json][timeout:180];161area["ISO3166-2"="CA-QC"][admin_level=4]->.qc;162(163 nwr["amenity"~"^(theatre|cinema|arts_centre|events_venue|concert_hall|conference_centre|exhibition_centre|planetarium|community_centre|music_venue)$"]["name"](area.qc);164 nwr["leisure"="stadium"]["name"](area.qc);165 nwr["tourism"="museum"]["name"](area.qc);166);167out center tags;168"""169170171OSM_RETRY_AFTER = 3 * 86400 # après un échec réseau, re-tenter dans 3 jours172173174def refresh_osm(con: sqlite3.Connection, force: bool = False,175 path: str | None = None) -> int:176 """Importe l'extrait Overpass des lieux culturels du Québec.177178 · `path` : fichier JSON Overpass déjà téléchargé (utilisé quand le nœud179 n'atteint pas overpass-api.de — constaté 2026-08-19 : « no route to180 host » depuis M3U96a alors que le reste d'Internet répond) ;181 · sinon téléchargement direct (overpass-api.de, UA de bot honnête) si le182 dernier import remonte à plus de 30 jours. Un ÉCHEC réseau est mémorisé183 (re-tentative après 3 jours) pour ne pas ralentir chaque cycle horaire.184 Retourne le nombre de lieux importés.185 """186 import json as _json187188 import requests189 ensure_schema(con)190 now = time.time()191 if path is None:192 row = con.execute(193 "SELECT value FROM venues_meta WHERE key='osm_fetched_at'").fetchone()194 if not force and row and now - float(row["value"]) < OSM_REFRESH_AFTER:195 return 0196 attempt = con.execute(197 "SELECT value FROM venues_meta WHERE key='osm_attempt_at'").fetchone()198 if not force and attempt and now - float(attempt["value"]) < OSM_RETRY_AFTER:199 return 0200 con.execute("INSERT OR REPLACE INTO venues_meta (key, value) "201 "VALUES ('osm_attempt_at', ?)", (str(now),))202 con.commit()203 resp = requests.post(OVERPASS_URL, data={"data": _OVERPASS_QUERY},204 headers={"User-Agent": USER_AGENT}, timeout=300)205 resp.raise_for_status()206 payload = resp.json()207 else:208 payload = _json.loads(Path(path).read_text(encoding="utf-8"))209 n = 0210 for el in payload.get("elements", []):211 tags = el.get("tags") or {}212 name = tags.get("name", "")213 lat = el.get("lat") or (el.get("center") or {}).get("lat")214 lng = el.get("lon") or (el.get("center") or {}).get("lon")215 if not name or lat is None or lng is None:216 continue217 _upsert(con, name, tags.get("addr:city", ""), float(lat), float(lng),218 "osm", address=tags.get("addr:street", "") and219 f"{tags.get('addr:housenumber', '')} {tags.get('addr:street', '')}".strip())220 n += 1221 con.execute("INSERT OR REPLACE INTO venues_meta (key, value) "222 "VALUES ('osm_fetched_at', ?)", (str(time.time()),))223 con.commit()224 return n225226227# ---------------------------------------------------------------------------228# 3) Nominatim : salles récurrentes encore sans fiche (plafonné, poli)229# ---------------------------------------------------------------------------230231def geocode_top(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict:232 """Géocode les salles SANS entrée d'annuaire les plus fréquentes (volume233 d'événements à venir), via le géocodeur central (throttle 1,1 s, boîte234 englobante Québec, échecs mémorisés dans geocode_cache). 0 invention."""235 import requests as _rq236237 from . import geocode as G238 ensure_schema(con)239 stats = {"lookups": 0, "added": 0}240 session = _rq.Session()241 session.headers["User-Agent"] = G.USER_AGENT242 cache = G.load_cache()243 now = time.time()244 rows = con.execute(245 "SELECT venue, city, COUNT(*) AS n FROM events "246 "WHERE active=1 AND lat IS NULL AND venue != '' AND city != '' "247 "GROUP BY venue, city ORDER BY n DESC LIMIT 400").fetchall()248 for r in rows:249 if stats["lookups"] >= max_requests:250 break251 venue, city = r["venue"], r["city"]252 if _match_row(con, venue, city):253 continue # déjà résoluble par l'annuaire254 ck = G._cache_key(venue, city)255 entry = cache.get(ck)256 if entry is not None and (entry.get("ok")257 or now - entry.get("ts", 0) <= G.RETRY_FAIL_AFTER):258 if entry.get("ok"):259 _upsert(con, venue, city, entry["lat"], entry["lng"], "nominatim")260 stats["added"] += 1261 continue262 try:263 found, used = G._lookup(session, venue, city)264 except Exception:265 stats["lookups"] += 1266 continue267 stats["lookups"] += used268 if found:269 cache[ck] = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}270 _upsert(con, venue, city, found[0], found[1], "nominatim")271 stats["added"] += 1272 else:273 cache[ck] = {"ok": False, "ts": now}274 G.save_cache(cache)275 con.commit()276 return stats277278279# ---------------------------------------------------------------------------280# Matching & application aux événements sans GPS281# ---------------------------------------------------------------------------282283def _match_row(con: sqlite3.Connection, venue: str, city: str):284 """Retourne la ligne d'annuaire pour (salle, ville), ou None.285286 Ordre : (alias, ville) exact → alias seul si la clé est UNIQUE dans tout287 l'annuaire (≥ 8 car.) et que sa ville ne contredit pas celle de l'événement288 (fiche sans ville acceptée). Jamais de match flou.289 """290 ck = _key(city)291 for alias in name_aliases(venue):292 if ck:293 # avec ville : alias exact, puis alias sans le mot générique de294 # tête (« salle odyssee » → « odyssee ») — la ville, obligatoire295 # ici, écarte les homonymes296 candidates = [alias]297 stripped = re.sub(r"^(salle|theatre|cinema|centre|espace|le|la|les|l)\s+",298 "", alias)299 if stripped != alias and len(stripped) >= 4:300 candidates.append(stripped)301 for cand in candidates:302 row = con.execute(303 "SELECT * FROM venues WHERE name_key=? AND city_key=? "304 "AND lat IS NOT NULL", (cand, ck)).fetchone()305 if row:306 return row307 if len(alias) < MIN_KEY_LEN:308 continue309 rows = con.execute(310 "SELECT * FROM venues WHERE name_key=? AND lat IS NOT NULL",311 (alias,)).fetchall()312 if len(rows) == 1:313 vck = rows[0]["city_key"]314 if not vck or not ck or vck == ck:315 return rows[0]316 return None317318319def apply(con: sqlite3.Connection) -> dict:320 """Remplit lat/lng des événements actifs sans GPS via l'annuaire.321 Ne touche jamais un GPS existant. Retourne les compteurs."""322 ensure_schema(con)323 stats = {"groups": 0, "events_filled": 0}324 rows = con.execute(325 "SELECT DISTINCT venue, city FROM events WHERE active=1 "326 "AND lat IS NULL AND venue != ''").fetchall()327 for r in rows:328 hit = _match_row(con, r["venue"], r["city"])329 if hit is None:330 continue331 cur = con.execute(332 "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL "333 "AND venue=? AND city=?",334 (hit["lat"], hit["lng"], r["venue"], r["city"]))335 if cur.rowcount:336 stats["groups"] += 1337 stats["events_filled"] += cur.rowcount338 con.commit()339 return stats340341342def run(con: sqlite3.Connection) -> dict:343 """Cycle annuaire complet appelé par le pipeline : apprendre (0 réseau),344 rafraîchir OSM (au plus 1 requête/30 j), géocoder le top récurrent345 (plafonné), appliquer aux événements sans GPS."""346 out = {"learned": learn(con)}347 try:348 out["osm"] = refresh_osm(con)349 except Exception as exc: # Overpass indisponible ≠ cycle cassé350 out["osm_error"] = str(exc)[:120]351 try:352 out["nominatim"] = geocode_top(con)353 except Exception as exc:354 out["nominatim_error"] = str(exc)[:120]355 out.update(apply(con))356 return out357