# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # venues.py : ANNUAIRE DE SALLES (Phase 3) — table `venues` (nom canonique + # ville + GPS) qui résout les fiches sans coordonnées quand le # géocodage direct échoue (~3 860 fiches lavitrine/lepointdevente # au bilan Phase 2). Trois origines, jamais de GPS inventé : # · « appris » : salles déjà géocodées PAR LES SOURCES en base # (auto-apprentissage, 0 requête réseau) ; # · « osm » : lieux culturels du Québec via Overpass # (amenity=theatre/cinema/arts_centre/events_venue/ # concert_hall…, refresh 30 j, 1 requête) ; # · « nominatim »: géocodage des salles récurrentes les plus # fréquentes (top volume, plafonné par cycle). # Matching : nom normalisé (minuscules, sans accents, alphanumérique) — # d'abord (salle, ville), puis alias (segments « | », « - », « , »), puis # nom seul UNIQUEMENT si la clé est unique dans tout l'annuaire (≥ 8 car.) # et que la ville ne contredit pas. Un nom générique (« salle communautaire ») # existe en plusieurs exemplaires → jamais matché sans ville. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import sqlite3 import time from pathlib import Path from .normalize import clean_text, strip_accents OVERPASS_URL = "https://overpass-api.de/api/interpreter" # PAS overpass.osm.ch USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com; contact@spboucher.ai)" OSM_REFRESH_AFTER = 30 * 86400 # re-télécharger l'extrait OSM après 30 j GEOCODE_MAX = int(os.environ.get("VENUES_GEOCODE_MAX", "40")) # req Nominatim/cycle MIN_KEY_LEN = 8 # clé minimale pour un match sans ville _SCHEMA = """ CREATE TABLE IF NOT EXISTS venues ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, name_key TEXT NOT NULL, city TEXT DEFAULT '', city_key TEXT DEFAULT '', address TEXT DEFAULT '', lat REAL, lng REAL, origin TEXT, -- appris | osm | nominatim events_seen INTEGER DEFAULT 0, created_at REAL, updated_at REAL, UNIQUE(name_key, city_key) ); CREATE INDEX IF NOT EXISTS idx_venues_name ON venues(name_key); CREATE TABLE IF NOT EXISTS venues_meta ( key TEXT PRIMARY KEY, value TEXT ); """ # séparateurs de noms composés : « Théâtre Desjardins | Salle Jean Grimaldi », # « Salle Wilfrid-Pelletier, Place des Arts », « Le Carré 150 - Cabaret … » _SPLIT_RE = re.compile(r"\s*(?:\||,| - | – | — )\s*") def _key(s: str) -> str: """Clé de matching : minuscules, sans accents, alphanumérique + espaces.""" s = strip_accents(clean_text(s or "")).lower() s = re.sub(r"[^a-z0-9]+", " ", s) return " ".join(s.split()) def name_aliases(venue: str) -> list[str]: """Clés candidates d'un nom de salle : nom complet puis segments composés (du plus long au plus court) — on ne fabrique jamais d'autre variante.""" full = _key(venue) if not full: return [] out = [full] parts = [p for p in _SPLIT_RE.split(clean_text(venue)) if p.strip()] if len(parts) > 1: for p in sorted(parts, key=len, reverse=True): k = _key(p) # segments courts admis : ils ne servent qu'au match AVEC ville # (le match sans ville exige MIN_KEY_LEN, voir _match_row) if len(k) >= 4 and k not in out: out.append(k) return out def ensure_schema(con: sqlite3.Connection) -> None: con.executescript(_SCHEMA) con.commit() def _upsert(con: sqlite3.Connection, name: str, city: str, lat: float, lng: float, origin: str, address: str = "", events_seen: int = 0, _alias_of: bool = False) -> None: """Insère une salle ; une salle déjà apprise n'est PAS écrasée par une origine moins fiable (appris > nominatim > osm : la donnée source prime). Les noms COMPOSÉS (« Maison des Arts de Laval - Théâtre des Muses ») génèrent aussi une entrée par segment significatif (mêmes coordonnées, même ville) : une source qui ne publie que « Théâtre des Muses » matche.""" nk, ck = _key(name), _key(city) if not nk or lat is None or lng is None: return if not _alias_of: for seg in name_aliases(name)[1:]: if len(seg) >= MIN_KEY_LEN: _upsert(con, seg, city, lat, lng, origin, address=address, _alias_of=True) now = time.time() rank = {"appris": 3, "nominatim": 2, "osm": 1} row = con.execute("SELECT origin, events_seen FROM venues " "WHERE name_key=? AND city_key=?", (nk, ck)).fetchone() if row is None: con.execute( "INSERT OR IGNORE INTO venues (name, name_key, city, city_key, " "address, lat, lng, origin, events_seen, created_at, updated_at) " "VALUES (?,?,?,?,?,?,?,?,?,?,?)", (clean_text(name), nk, clean_text(city), ck, clean_text(address), lat, lng, origin, events_seen, now, now)) elif rank.get(origin, 0) >= rank.get(row["origin"] or "", 0): con.execute( "UPDATE venues SET name=?, city=?, address=?, lat=?, lng=?, " "origin=?, events_seen=MAX(events_seen, ?), updated_at=? " "WHERE name_key=? AND city_key=?", (clean_text(name), clean_text(city), clean_text(address), lat, lng, origin, events_seen, now, nk, ck)) # --------------------------------------------------------------------------- # 1) Auto-apprentissage : salles géocodées PAR LES SOURCES (0 réseau) # --------------------------------------------------------------------------- def learn(con: sqlite3.Connection) -> int: """Apprend les salles des événements qui ont déjà un GPS de la source. La coordonnée retenue est celle du groupe (salle, ville) le plus vu.""" ensure_schema(con) n = 0 for r in con.execute( "SELECT venue, city, lat, lng, COUNT(*) AS n FROM events " "WHERE lat IS NOT NULL AND lng IS NOT NULL AND venue != '' " "GROUP BY venue, city, round(lat, 3), round(lng, 3) " "ORDER BY n"): # le plus fréquent écrit en dernier = gagne _upsert(con, r["venue"], r["city"], r["lat"], r["lng"], "appris", events_seen=r["n"]) n += 1 con.commit() return n # --------------------------------------------------------------------------- # 2) OSM / Overpass : lieux culturels du Québec (1 requête, cache 30 j) # --------------------------------------------------------------------------- _OVERPASS_QUERY = """ [out:json][timeout:180]; area["ISO3166-2"="CA-QC"][admin_level=4]->.qc; ( nwr["amenity"~"^(theatre|cinema|arts_centre|events_venue|concert_hall|conference_centre|exhibition_centre|planetarium|community_centre|music_venue)$"]["name"](area.qc); nwr["leisure"="stadium"]["name"](area.qc); nwr["tourism"="museum"]["name"](area.qc); ); out center tags; """ OSM_RETRY_AFTER = 3 * 86400 # après un échec réseau, re-tenter dans 3 jours def refresh_osm(con: sqlite3.Connection, force: bool = False, path: str | None = None) -> int: """Importe l'extrait Overpass des lieux culturels du Québec. · `path` : fichier JSON Overpass déjà téléchargé (utilisé quand le nœud n'atteint pas overpass-api.de — constaté 2026-08-19 : « no route to host » depuis M3U96a alors que le reste d'Internet répond) ; · sinon téléchargement direct (overpass-api.de, UA de bot honnête) si le dernier import remonte à plus de 30 jours. Un ÉCHEC réseau est mémorisé (re-tentative après 3 jours) pour ne pas ralentir chaque cycle horaire. Retourne le nombre de lieux importés. """ import json as _json import requests ensure_schema(con) now = time.time() if path is None: row = con.execute( "SELECT value FROM venues_meta WHERE key='osm_fetched_at'").fetchone() if not force and row and now - float(row["value"]) < OSM_REFRESH_AFTER: return 0 attempt = con.execute( "SELECT value FROM venues_meta WHERE key='osm_attempt_at'").fetchone() if not force and attempt and now - float(attempt["value"]) < OSM_RETRY_AFTER: return 0 con.execute("INSERT OR REPLACE INTO venues_meta (key, value) " "VALUES ('osm_attempt_at', ?)", (str(now),)) con.commit() resp = requests.post(OVERPASS_URL, data={"data": _OVERPASS_QUERY}, headers={"User-Agent": USER_AGENT}, timeout=300) resp.raise_for_status() payload = resp.json() else: payload = _json.loads(Path(path).read_text(encoding="utf-8")) n = 0 for el in payload.get("elements", []): tags = el.get("tags") or {} name = tags.get("name", "") lat = el.get("lat") or (el.get("center") or {}).get("lat") lng = el.get("lon") or (el.get("center") or {}).get("lon") if not name or lat is None or lng is None: continue _upsert(con, name, tags.get("addr:city", ""), float(lat), float(lng), "osm", address=tags.get("addr:street", "") and f"{tags.get('addr:housenumber', '')} {tags.get('addr:street', '')}".strip()) n += 1 con.execute("INSERT OR REPLACE INTO venues_meta (key, value) " "VALUES ('osm_fetched_at', ?)", (str(time.time()),)) con.commit() return n # --------------------------------------------------------------------------- # 3) Nominatim : salles récurrentes encore sans fiche (plafonné, poli) # --------------------------------------------------------------------------- def geocode_top(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict: """Géocode les salles SANS entrée d'annuaire les plus fréquentes (volume d'événements à venir), via le géocodeur central (throttle 1,1 s, boîte englobante Québec, échecs mémorisés dans geocode_cache). 0 invention.""" import requests as _rq from . import geocode as G ensure_schema(con) stats = {"lookups": 0, "added": 0} session = _rq.Session() session.headers["User-Agent"] = G.USER_AGENT cache = G.load_cache() now = time.time() rows = con.execute( "SELECT venue, city, COUNT(*) AS n FROM events " "WHERE active=1 AND lat IS NULL AND venue != '' AND city != '' " "GROUP BY venue, city ORDER BY n DESC LIMIT 400").fetchall() for r in rows: if stats["lookups"] >= max_requests: break venue, city = r["venue"], r["city"] if _match_row(con, venue, city): continue # déjà résoluble par l'annuaire ck = G._cache_key(venue, city) entry = cache.get(ck) if entry is not None and (entry.get("ok") or now - entry.get("ts", 0) <= G.RETRY_FAIL_AFTER): if entry.get("ok"): _upsert(con, venue, city, entry["lat"], entry["lng"], "nominatim") stats["added"] += 1 continue try: found, used = G._lookup(session, venue, city) except Exception: stats["lookups"] += 1 continue stats["lookups"] += used if found: cache[ck] = {"ok": True, "lat": found[0], "lng": found[1], "ts": now} _upsert(con, venue, city, found[0], found[1], "nominatim") stats["added"] += 1 else: cache[ck] = {"ok": False, "ts": now} G.save_cache(cache) con.commit() return stats # --------------------------------------------------------------------------- # Matching & application aux événements sans GPS # --------------------------------------------------------------------------- def _match_row(con: sqlite3.Connection, venue: str, city: str): """Retourne la ligne d'annuaire pour (salle, ville), ou None. Ordre : (alias, ville) exact → alias seul si la clé est UNIQUE dans tout l'annuaire (≥ 8 car.) et que sa ville ne contredit pas celle de l'événement (fiche sans ville acceptée). Jamais de match flou. """ ck = _key(city) for alias in name_aliases(venue): if ck: # avec ville : alias exact, puis alias sans le mot générique de # tête (« salle odyssee » → « odyssee ») — la ville, obligatoire # ici, écarte les homonymes candidates = [alias] stripped = re.sub(r"^(salle|theatre|cinema|centre|espace|le|la|les|l)\s+", "", alias) if stripped != alias and len(stripped) >= 4: candidates.append(stripped) for cand in candidates: row = con.execute( "SELECT * FROM venues WHERE name_key=? AND city_key=? " "AND lat IS NOT NULL", (cand, ck)).fetchone() if row: return row if len(alias) < MIN_KEY_LEN: continue rows = con.execute( "SELECT * FROM venues WHERE name_key=? AND lat IS NOT NULL", (alias,)).fetchall() if len(rows) == 1: vck = rows[0]["city_key"] if not vck or not ck or vck == ck: return rows[0] return None def apply(con: sqlite3.Connection) -> dict: """Remplit lat/lng des événements actifs sans GPS via l'annuaire. Ne touche jamais un GPS existant. Retourne les compteurs.""" ensure_schema(con) stats = {"groups": 0, "events_filled": 0} rows = con.execute( "SELECT DISTINCT venue, city FROM events WHERE active=1 " "AND lat IS NULL AND venue != ''").fetchall() for r in rows: hit = _match_row(con, r["venue"], r["city"]) if hit is None: continue cur = con.execute( "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL " "AND venue=? AND city=?", (hit["lat"], hit["lng"], r["venue"], r["city"])) if cur.rowcount: stats["groups"] += 1 stats["events_filled"] += cur.rowcount con.commit() return stats def run(con: sqlite3.Connection) -> dict: """Cycle annuaire complet appelé par le pipeline : apprendre (0 réseau), rafraîchir OSM (au plus 1 requête/30 j), géocoder le top récurrent (plafonné), appliquer aux événements sans GPS.""" out = {"learned": learn(con)} try: out["osm"] = refresh_osm(con) except Exception as exc: # Overpass indisponible ≠ cycle cassé out["osm_error"] = str(exc)[:120] try: out["nominatim"] = geocode_top(con) except Exception as exc: out["nominatim_error"] = str(exc)[:120] out.update(apply(con)) return out