SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

Phase 3 — annuaire de salles : table venues (appris/OSM/Nominatim), matching par alias, plages de dates FR

- sortika/venues.py : annuaire de salles QC — auto-apprentissage des salles
  géocodées par les sources, extrait Overpass des lieux culturels (import par
  fichier si le nœud n atteint pas overpass-api.de, échec mémorisé 3 j),
  géocodage Nominatim des salles récurrentes (plafonné) ; matching nom
  normalisé + alias de segments + « Salle X » → « X » (ville obligatoire),
  match sans ville seulement si clé unique ≥ 8 car. — jamais de GPS inventé
- scripts/build_venues.py : amorçage one-shot de l annuaire
- ingest.py : venues.run à chaque cycle (après le géocodeur)
- normalize.py : parse_date_range_fr (« Du 4 juin au 22 août 2026 »…)
- GPS des « à venir » : 64,9 % → 76,4 % (+3 264 fiches localisées)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 19, 2026) parent 59eed74

5 changed files +448 −1

modified .gitignore +1 −0
@@ -17,3 +17,4 @@ data/sortika.db-wal
17 17 data/sortika.db
18 18 data/*.bak-*
19 19 data/future_counts.json
20 +data/osm_venues.json
added scripts/build_venues.py +43 −0
@@ -0,0 +1,43 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# scripts/build_venues.py : AMORÇAGE de l'annuaire de salles (Phase 3) —
6 +# apprentissage depuis la base (0 réseau) + extrait Overpass des lieux
7 +# culturels du Québec (1 requête, overpass-api.de) + géocodage Nominatim des
8 +# salles récurrentes (plafond en argument, défaut 300, throttle 1,1 s) +
9 +# application aux événements sans GPS. Les syncs horaires entretiennent
10 +# ensuite l'annuaire (venues.run dans le pipeline, plafonds réduits).
11 +# Usage : .venv/bin/python scripts/build_venues.py [max_nominatim]
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import sys
16 +from pathlib import Path
17 +
18 +sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
19 +
20 +from sortika import db, venues # noqa: E402
21 +
22 +
23 +def main() -> None:
24 + max_nominatim = int(sys.argv[1]) if len(sys.argv) > 1 else 300
25 + con = db.connect()
26 + print("[venues] apprentissage…")
27 + print(" appris :", venues.learn(con))
28 + print("[venues] Overpass (lieux culturels QC)…")
29 + try:
30 + print(" osm :", venues.refresh_osm(con, force=True))
31 + except Exception as exc:
32 + print(" osm ÉCHEC :", exc)
33 + print(f"[venues] Nominatim top salles (max {max_nominatim})…")
34 + print(" nominatim :", venues.geocode_top(con, max_requests=max_nominatim))
35 + print("[venues] application aux événements sans GPS…")
36 + print(" apply :", venues.apply(con))
37 + total = con.execute("SELECT COUNT(*) FROM venues").fetchone()[0]
38 + print(f"[venues] annuaire : {total} salles")
39 + con.close()
40 +
41 +
42 +if __name__ == "__main__":
43 + main()
modified sortika/ingest.py +10 −1
@@ -11,7 +11,7 @@ import sys
11 11 import time
12 12 import traceback
13 13
14 from . import db, geocode
14 +from . import db, geocode, venues
15 15 from .connectors import CONNECTORS
16 16
17 17
@@ -73,6 +73,15 @@ def run(sources: list[str] | None = None) -> list[dict]:
73 73 print(f"[sorti-ka] geocode {gstats}")
74 74 except Exception: # le géocodage ne bloque jamais l'ingestion
75 75 traceback.print_exc()
76 + # Annuaire de salles (Phase 3) : apprentissage des salles géocodées par
77 + # les sources (0 réseau), extrait OSM (≤ 1 requête/30 j), géocodage des
78 + # salles récurrentes (plafond VENUES_GEOCODE_MAX), puis application aux
79 + # événements sans GPS — voir sortika/venues.py. Jamais de GPS inventé.
80 + try:
81 + vstats = venues.run(con)
82 + print(f"[sorti-ka] venues {vstats}")
83 + except Exception: # l'annuaire ne bloque jamais l'ingestion
84 + traceback.print_exc()
76 85 try:
77 86 con.execute("PRAGMA optimize")
78 87 except Exception:
modified sortika/normalize.py +38 −0
@@ -349,3 +349,41 @@ def parse_latlng(lat, lng) -> tuple[float | None, float | None]:
349 349 if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0):
350 350 return None, None
351 351 return round(la, 6), round(ln, 6)
352 +
353 +_FR_PARTIAL_RE = re.compile(r"du\s+(\d{1,2})(?:er)?(?:\s+([a-z]+)\.?)?\s+au\s+")
354 +
355 +
356 +def parse_date_range_fr(raw: str | None) -> tuple[str | None, str | None]:
357 + """Plage de dates française → (début ISO, fin ISO), sans jamais inventer.
358 +
359 + Gère les formes des calendriers QC (Phase 3) :
360 + « Du 11 au 13 septembre 2026 » → mois/année de la fin pour le début ;
361 + « Du 4 juin au 22 août 2026 » → année de la fin pour le début ;
362 + « Du 27 août 2026 au 3 janvier 2027 » (deux dates complètes) ;
363 + « 17 août 2026 » → (date, date).
364 + (None, None) si aucune date complète sûre.
365 + """
366 + if not raw:
367 + return None, None
368 + k = _key(str(raw))
369 + dates: list[str] = []
370 + for m in _FR_DATE_RE.finditer(k):
371 + d, mw, y = int(m.group(1)), m.group(2), int(m.group(3))
372 + mo = _FR_MONTHS.get(mw)
373 + if mo and 1 <= d <= 31 and 1990 <= y <= 2100:
374 + dates.append(f"{y:04d}-{mo:02d}-{d:02d}")
375 + if len(dates) >= 2:
376 + return min(dates[0], dates[1]), max(dates[0], dates[1])
377 + if len(dates) == 1:
378 + end = dates[0]
379 + m = _FR_PARTIAL_RE.search(k)
380 + if not m:
381 + return end, end
382 + d = int(m.group(1))
383 + mo = _FR_MONTHS.get(m.group(2)) if m.group(2) else int(end[5:7])
384 + if mo and 1 <= d <= 31:
385 + start = f"{end[:4]}-{mo:02d}-{d:02d}"
386 + if start <= end:
387 + return start, end
388 + return None, end
389 + return None, None
added sortika/venues.py +356 −0
@@ -0,0 +1,356 @@
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