SPB Git

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.

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 (Aug 19, 2026) parent 59eed74 Browse files

5 changed files +448 −1

modified .gitignore +1 −0
@@ -17,3 +17,4 @@ data/sortika.db-wal
17 data/sortika.db 17 data/sortika.db
18 data/*.bak-* 18 data/*.bak-*
19 data/future_counts.json 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 import time 11 import time
12 import traceback 12 import traceback
13 13
14−from . import db, geocode 14+from . import db, geocode, venues
15 from .connectors import CONNECTORS 15 from .connectors import CONNECTORS
16 16
17 17
@@ -73,6 +73,15 @@ def run(sources: list[str] | None = None) -> list[dict]:
73 print(f"[sorti-ka] geocode {gstats}") 73 print(f"[sorti-ka] geocode {gstats}")
74 except Exception: # le géocodage ne bloque jamais l'ingestion 74 except Exception: # le géocodage ne bloque jamais l'ingestion
75 traceback.print_exc() 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 try: 85 try:
77 con.execute("PRAGMA optimize") 86 con.execute("PRAGMA optimize")
78 except Exception: 87 except Exception:
modified sortika/normalize.py +38 −0
@@ -349,3 +349,41 @@ def parse_latlng(lat, lng) -> tuple[float | None, float | None]:
349 if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0): 349 if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0):
350 return None, None 350 return None, None
351 return round(la, 6), round(ln, 6) 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
Menu

Theme