| |
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 | |