|
1 |
+# ----------------------------------------------------------------------------- |
|
2 |
+# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) |
|
3 |
+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
|
4 |
+# venues.py : ANNUAIRE DE SALLES (Phase 3) — table `venues` (nom canonique + |
|
5 |
+# ville + GPS) qui résout les fiches sans coordonnées quand le |
|
6 |
+# géocodage direct échoue (~3 860 fiches lavitrine/lepointdevente |
|
7 |
+# au bilan Phase 2). Trois origines, jamais de GPS inventé : |
|
8 |
+# · « appris » : salles déjà géocodées PAR LES SOURCES en base |
|
9 |
+# (auto-apprentissage, 0 requête réseau) ; |
|
10 |
+# · « osm » : lieux culturels du Québec via Overpass |
|
11 |
+# (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 plus |
|
14 |
+# fréquentes (top volume, plafonné par cycle). |
|
15 |
+# Matching : nom normalisé (minuscules, sans accents, alphanumérique) — |
|
16 |
+# d'abord (salle, ville), puis alias (segments « | », « - », « , »), puis |
|
17 |
+# 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 |
+# ----------------------------------------------------------------------------- |
|
21 |
+from __future__ import annotations |
|
22 |
+ |
|
23 |
+import json |
|
24 |
+import os |
|
25 |
+import re |
|
26 |
+import sqlite3 |
|
27 |
+import time |
|
28 |
+from pathlib import Path |
|
29 |
+ |
|
30 |
+from .normalize import clean_text, strip_accents |
|
31 |
+ |
|
32 |
+OVERPASS_URL = "https://overpass-api.de/api/interpreter" # PAS overpass.osm.ch |
|
33 |
+USER_AGENT = "SortiKaBot/1.0 (+https://www.sorti-ka.com; contact@spboucher.ai)" |
|
34 |
+ |
|
35 |
+OSM_REFRESH_AFTER = 30 * 86400 # re-télécharger l'extrait OSM après 30 j |
|
36 |
+GEOCODE_MAX = int(os.environ.get("VENUES_GEOCODE_MAX", "40")) # req Nominatim/cycle |
|
37 |
+MIN_KEY_LEN = 8 # clé minimale pour un match sans ville |
|
38 |
+ |
|
39 |
+_SCHEMA = """ |
|
40 |
+CREATE 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 | nominatim |
|
50 |
+ events_seen INTEGER DEFAULT 0, |
|
51 |
+ created_at REAL, |
|
52 |
+ updated_at REAL, |
|
53 |
+ UNIQUE(name_key, city_key) |
|
54 |
+); |
|
55 |
+CREATE INDEX IF NOT EXISTS idx_venues_name ON venues(name_key); |
|
56 |
+ |
|
57 |
+CREATE TABLE IF NOT EXISTS venues_meta ( |
|
58 |
+ key TEXT PRIMARY KEY, |
|
59 |
+ value TEXT |
|
60 |
+); |
|
61 |
+""" |
|
62 |
+ |
|
63 |
+# 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*") |
|
66 |
+ |
|
67 |
+ |
|
68 |
+def _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()) |
|
73 |
+ |
|
74 |
+ |
|
75 |
+def name_aliases(venue: str) -> list[str]: |
|
76 |
+ """Clés candidates d'un nom de salle : nom complet puis segments composés |
|
77 |
+ (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 ville |
|
87 |
+ # (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 out |
|
91 |
+ |
|
92 |
+ |
|
93 |
+def ensure_schema(con: sqlite3.Connection) -> None: |
|
94 |
+ con.executescript(_SCHEMA) |
|
95 |
+ con.commit() |
|
96 |
+ |
|
97 |
+ |
|
98 |
+def _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 une |
|
102 |
+ 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 |
+ return |
|
109 |
+ 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)) |
|
132 |
+ |
|
133 |
+ |
|
134 |
+# --------------------------------------------------------------------------- |
|
135 |
+# 1) Auto-apprentissage : salles géocodées PAR LES SOURCES (0 réseau) |
|
136 |
+# --------------------------------------------------------------------------- |
|
137 |
+ |
|
138 |
+def 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 = 0 |
|
143 |
+ 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 = gagne |
|
148 |
+ _upsert(con, r["venue"], r["city"], r["lat"], r["lng"], |
|
149 |
+ "appris", events_seen=r["n"]) |
|
150 |
+ n += 1 |
|
151 |
+ con.commit() |
|
152 |
+ return n |
|
153 |
+ |
|
154 |
+ |
|
155 |
+# --------------------------------------------------------------------------- |
|
156 |
+# 2) OSM / Overpass : lieux culturels du Québec (1 requête, cache 30 j) |
|
157 |
+# --------------------------------------------------------------------------- |
|
158 |
+ |
|
159 |
+_OVERPASS_QUERY = """ |
|
160 |
+[out:json][timeout:180]; |
|
161 |
+area["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 |
+); |
|
167 |
+out center tags; |
|
168 |
+""" |
|
169 |
+ |
|
170 |
+ |
|
171 |
+OSM_RETRY_AFTER = 3 * 86400 # après un échec réseau, re-tenter dans 3 jours |
|
172 |
+ |
|
173 |
+ |
|
174 |
+def 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. |
|
177 |
+ |
|
178 |
+ · `path` : fichier JSON Overpass déjà téléchargé (utilisé quand le nœud |
|
179 |
+ n'atteint pas overpass-api.de — constaté 2026-08-19 : « no route to |
|
180 |
+ 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 le |
|
182 |
+ 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 _json |
|
187 |
+ |
|
188 |
+ import requests |
|
189 |
+ 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 0 |
|
196 |
+ 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 0 |
|
200 |
+ 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 = 0 |
|
210 |
+ 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 |
+ continue |
|
217 |
+ _upsert(con, name, tags.get("addr:city", ""), float(lat), float(lng), |
|
218 |
+ "osm", address=tags.get("addr:street", "") and |
|
219 |
+ f"{tags.get('addr:housenumber', '')} {tags.get('addr:street', '')}".strip()) |
|
220 |
+ n += 1 |
|
221 |
+ con.execute("INSERT OR REPLACE INTO venues_meta (key, value) " |
|
222 |
+ "VALUES ('osm_fetched_at', ?)", (str(time.time()),)) |
|
223 |
+ con.commit() |
|
224 |
+ return n |
|
225 |
+ |
|
226 |
+ |
|
227 |
+# --------------------------------------------------------------------------- |
|
228 |
+# 3) Nominatim : salles récurrentes encore sans fiche (plafonné, poli) |
|
229 |
+# --------------------------------------------------------------------------- |
|
230 |
+ |
|
231 |
+def 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 (volume |
|
233 |
+ d'événements à venir), via le géocodeur central (throttle 1,1 s, boîte |
|
234 |
+ englobante Québec, échecs mémorisés dans geocode_cache). 0 invention.""" |
|
235 |
+ import requests as _rq |
|
236 |
+ |
|
237 |
+ from . import geocode as G |
|
238 |
+ ensure_schema(con) |
|
239 |
+ stats = {"lookups": 0, "added": 0} |
|
240 |
+ session = _rq.Session() |
|
241 |
+ session.headers["User-Agent"] = G.USER_AGENT |
|
242 |
+ 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 |
+ break |
|
251 |
+ venue, city = r["venue"], r["city"] |
|
252 |
+ if _match_row(con, venue, city): |
|
253 |
+ continue # déjà résoluble par l'annuaire |
|
254 |
+ 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"] += 1 |
|
261 |
+ continue |
|
262 |
+ try: |
|
263 |
+ found, used = G._lookup(session, venue, city) |
|
264 |
+ except Exception: |
|
265 |
+ stats["lookups"] += 1 |
|
266 |
+ continue |
|
267 |
+ stats["lookups"] += used |
|
268 |
+ 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"] += 1 |
|
272 |
+ else: |
|
273 |
+ cache[ck] = {"ok": False, "ts": now} |
|
274 |
+ G.save_cache(cache) |
|
275 |
+ con.commit() |
|
276 |
+ return stats |
|
277 |
+ |
|
278 |
+ |
|
279 |
+# --------------------------------------------------------------------------- |
|
280 |
+# Matching & application aux événements sans GPS |
|
281 |
+# --------------------------------------------------------------------------- |
|
282 |
+ |
|
283 |
+def _match_row(con: sqlite3.Connection, venue: str, city: str): |
|
284 |
+ """Retourne la ligne d'annuaire pour (salle, ville), ou None. |
|
285 |
+ |
|
286 |
+ Ordre : (alias, ville) exact → alias seul si la clé est UNIQUE dans tout |
|
287 |
+ l'annuaire (≥ 8 car.) et que sa ville ne contredit pas celle de l'événement |
|
288 |
+ (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 de |
|
294 |
+ # tête (« salle odyssee » → « odyssee ») — la ville, obligatoire |
|
295 |
+ # ici, écarte les homonymes |
|
296 |
+ 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 row |
|
307 |
+ if len(alias) < MIN_KEY_LEN: |
|
308 |
+ continue |
|
309 |
+ 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 None |
|
317 |
+ |
|
318 |
+ |
|
319 |
+def 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 |
+ continue |
|
331 |
+ 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"] += 1 |
|
337 |
+ stats["events_filled"] += cur.rowcount |
|
338 |
+ con.commit() |
|
339 |
+ return stats |
|
340 |
+ |
|
341 |
+ |
|
342 |
+def 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écurrent |
|
345 |
+ (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 out |
|
357 |
|