Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# normalize.py : couche de normalisation commune — chaque connecteur retourne5# les champs bruts de sa source, ce module uniformise tout :6# dates ISO, prix/gratuité, catégories canoniques, villes, texte.7# (patron Lou-Ka : louka/normalize.py)8# -----------------------------------------------------------------------------9from __future__ import annotations1011import re12import unicodedata13from html import unescape as html_unescape1415# ---------------------------------------------------------------------------16# Utilitaires texte17# ---------------------------------------------------------------------------1819_NBSP = (" ", " ", " ")202122def strip_accents(s: str) -> str:23 return "".join(c for c in unicodedata.normalize("NFD", s)24 if unicodedata.category(c) != "Mn")252627def clean_text(s: str) -> str:28 """Espaces insécables -> espaces, HTML résiduel enlevé, entités décodées."""29 if not s:30 return ""31 for ch in _NBSP:32 s = s.replace(ch, " ")33 s = re.sub(r"<[^>]+>", " ", s) # balises HTML résiduelles34 s = html_unescape(s) # & ' é …35 return re.sub(r"\s+", " ", s).strip()363738def _key(s: str) -> str:39 """Texte en minuscules sans accents, pour matcher des mots-clés."""40 return strip_accents(clean_text(s or "")).lower()414243# Noms canoniques des municipalités/arrondissements du Québec (Répertoire44# MAMH, données ouvertes) : « Montreal » → « Montréal », « Quebec » →45# « Québec », « LEVIS » → « Lévis »… Les sources anglophones/dégradées46# éclataient les facettes ville (Phase 1 : Montreal 242 vs Montréal 5 249).47_CITY_CANON: dict | None = None484950def _city_canon_table() -> dict:51 global _CITY_CANON52 if _CITY_CANON is None:53 import json54 from pathlib import Path55 p = Path(__file__).resolve().parent.parent / "data" / "villes_canon.json"56 try:57 _CITY_CANON = json.loads(p.read_text(encoding="utf-8"))["villes"]58 except Exception:59 _CITY_CANON = {}60 return _CITY_CANON616263def canonical_city(city: str) -> str:64 """Nom officiel (accentué) d'une ville du Répertoire MAMH, sinon inchangé."""65 if not city:66 return city67 from .regions import _k68 key = _k(city)69 table = _city_canon_table()70 hit = table.get(key) or table.get(71 re.sub(r"\bste\b", "sainte", re.sub(r"\bst\b", "saint", key)))72 return hit or city737475def clean_city(raw: str) -> str:76 """Nettoie une ville : « Montréal (Québec) » → « Montréal », puis nom77 canonique du Répertoire MAMH (« Montreal » → « Montréal »)."""78 s = clean_text(raw)79 s = re.sub(r"\s*\((qu[eé]bec|qc)\)\s*$", "", s, flags=re.I)80 s = re.sub(r",\s*(qu[eé]bec|qc|canada)\s*$", "", s, flags=re.I)81 return canonical_city(s.strip(" ,"))828384# ---------------------------------------------------------------------------85# Dates → ISO "YYYY-MM-DD"86# ---------------------------------------------------------------------------8788_ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")8990_FR_MONTHS = {91 "janvier": 1, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,92 "juillet": 7, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11,93 "decembre": 12, "jan": 1, "fev": 2, "mar": 3, "avr": 4, "juil": 7,94 "sept": 9, "sep": 9, "oct": 10, "nov": 11, "dec": 12,95}96_FR_DATE_RE = re.compile(r"(\d{1,2})(?:er)?\s+([a-z]+)\.?\s+(\d{4})")979899def parse_date_iso(raw: str | None) -> str | None:100 """Extrait une date ISO d'un champ source ("2026-09-11T00:00:00" → "2026-09-11")."""101 if not raw:102 return None103 m = _ISO_RE.match(str(raw).strip())104 if not m:105 return None106 y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))107 if not (1990 <= y <= 2100 and 1 <= mo <= 12 and 1 <= d <= 31):108 return None109 return f"{y:04d}-{mo:02d}-{d:02d}"110111112def parse_date_fr(raw: str | None) -> str | None:113 """Date française textuelle → ISO ("17 août 2026, 18h00" → "2026-08-17")."""114 if not raw:115 return None116 k = _key(str(raw))117 m = _FR_DATE_RE.search(k)118 if not m:119 return parse_date_iso(raw)120 d, month_word, y = int(m.group(1)), m.group(2), int(m.group(3))121 mo = _FR_MONTHS.get(month_word)122 if not mo or not (1 <= d <= 31 and 1990 <= y <= 2100):123 return None124 return f"{y:04d}-{mo:02d}-{d:02d}"125126127# ---------------------------------------------------------------------------128# Heures → "HH:MM" (heure locale du Québec)129# ---------------------------------------------------------------------------130131_TIME_ISO_RE = re.compile(r"[T\s](\d{2}):(\d{2})")132_TIME_FR_RE = re.compile(r"(?<!\d)(\d{1,2})\s*[h:]\s*(\d{2})?(?!\d)", re.I)133134135def parse_time(raw: str | None) -> str | None:136 """Extrait une heure "HH:MM" d'un champ source, sans jamais inventer.137138 Accepte "19h30", "19 h", "19:30", "2026-09-11T19:30:00". Retourne None si139 aucune heure plausible (minuit "00:00" est traité comme un placeholder140 « toute la journée » par les connecteurs qui le savent, pas ici).141 """142 if not raw:143 return None144 s = str(raw).strip()145 m = _TIME_ISO_RE.search(s) or _TIME_FR_RE.search(s)146 if not m:147 return None148 h, mn = int(m.group(1)), int(m.group(2) or 0)149 if not (0 <= h <= 23 and 0 <= mn <= 59):150 return None151 return f"{h:02d}:{mn:02d}"152153154def utc_to_local(raw: str | None) -> tuple[str | None, str | None]:155 """Horodatage UTC ("2026-08-01T22:30:00[Z]") → (date, heure) locales QC.156157 Pour les sources qui publient leurs horaires en UTC (Laval, Sherbrooke —158 vérifié sur données réelles 2026-08). Heure None si minuit local159 (placeholder « toute la journée »).160 """161 if not raw:162 return None, None163 from datetime import datetime, timezone164 from zoneinfo import ZoneInfo165 try:166 dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))167 if dt.tzinfo is None:168 dt = dt.replace(tzinfo=timezone.utc)169 loc = dt.astimezone(ZoneInfo("America/Montreal"))170 except (ValueError, TypeError):171 return parse_date_iso(raw), None172 t = loc.strftime("%H:%M")173 return loc.strftime("%Y-%m-%d"), (None if t == "00:00" else t)174175176# ---------------------------------------------------------------------------177# Artistes — extraction conservatrice depuis les titres (billetteries).178# Seuls les patrons sûrs sont retenus : on n'invente jamais un artiste.179# ---------------------------------------------------------------------------180181# un « candidat artiste » ne doit contenir aucun de ces mots (événement, pas182# un nom d'artiste) — comparaison sans accents, minuscules183_ARTIST_STOP = (184 "festival", "soiree", "party", "spectacle", "concert", "show", "gala",185 "hommage", "tribute", "edition", "tournoi", "atelier", "conference",186 "exposition", "marche", "salon", "cabaret", "soir", "matinee", "camp",187 "cours", "formation", "vs", " et ses ", "karaoke", "open mic", "jam",188 "collecte", "brunch", "souper", "diner", "experience", "tour ",189 # types de billets / accès (jamais des artistes)190 "acces", "passe", "passeport", "billet", "forfait", "journalier",191 "entree", "admission", "reservation", "table ", "presente", "stationnement",192 "supplementaire",193)194# séparateurs « Artiste <sep> reste » (le reste doit matcher la salle, sinon195# on ne conclut rien) ; l'ordre n'importe pas196_ARTIST_SEPS = (" - ", " – ", " — ", " | ", " : ")197# suffixes sûrs : « X en concert », « X en spectacle », « X en rodage »…198_ARTIST_SUFFIX_RE = re.compile(199 r"^(.{2,60}?)\s+(?:enfin\s+)?en\s+"200 r"(concert|spectacle|rodage|tourn[eé]e|suppl[eé]mentaire)", re.I)201202203def _clean_artist(cand: str) -> str:204 """Nettoie un candidat : séparateurs/ponctuation résiduels en bordure."""205 return clean_text(cand).strip(" -–—:|·,")206207208def _plausible_artist(cand: str) -> bool:209 cand = _clean_artist(cand)210 if not (2 <= len(cand) <= 60) or len(cand.split()) > 6:211 return False212 k = f" {_key(cand)} "213 if any(w in k for w in _ARTIST_STOP):214 return False215 if not re.search(r"[a-zA-ZÀ-ÿ]", cand):216 return False217 return True218219220def artists_from_title(title: str, venue: str = "") -> list[str]:221 """Extrait le ou les artistes d'un titre de billetterie — patrons sûrs222 seulement :223 · « Artiste - Salle » / « Artiste : Salle » où le suffixe EST la salle224 connue de l'événement (aucune ambiguïté) ;225 · « Artiste en concert/spectacle/rodage/tournée ».226 Retourne [] au moindre doute.227 """228 t = clean_text(title)229 if not t:230 return []231 vk = _key(venue)232 if vk:233 for sep in _ARTIST_SEPS:234 if sep in t:235 left, right = t.split(sep, 1)236 if _key(right) == vk and _plausible_artist(left):237 return [_clean_artist(left)]238 m = _ARTIST_SUFFIX_RE.match(t)239 if m and _plausible_artist(m.group(1)):240 return [_clean_artist(m.group(1))]241 return []242243244# ---------------------------------------------------------------------------245# Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka :246# ne jamais afficher un coût sans savoir d'où il vient (price_label conservé).247# ---------------------------------------------------------------------------248249_PRICE_RE = re.compile(r"(\d{1,4}(?:[.,]\d{2})?)\s*\$")250_FREE_WORDS = ("gratuit", "entree libre", "entrée libre", "free", "sans frais")251252253def parse_price(raw: str | None) -> tuple[bool | None, float | None, str]:254 """Retourne (is_free, price_min, price_label) à partir du texte source.255256 is_free : True (gratuit), False (payant), None (inconnu).257 price_min : plus bas montant trouvé en $ CAD, sinon None.258 price_label : texte original nettoyé (traçabilité, jamais inventé).259 """260 label = clean_text(raw or "")261 if not label:262 return None, None, ""263 k = _key(label)264 amounts = [float(m.replace(",", ".")) for m in _PRICE_RE.findall(label)]265 if any(w in k for w in _FREE_WORDS) and not amounts:266 return True, None, label267 if amounts:268 return False, min(amounts), label269 if "payant" in k or "billet" in k or "admission" in k:270 return False, None, label271 return None, None, label272273274# ---------------------------------------------------------------------------275# Catégories canoniques (taxonomie Sorti-Ka, multi-valué)276# ---------------------------------------------------------------------------277278CATEGORIES: list[str] = [279 "festival", "musique", "arts-scene", "exposition-musee", "cinema",280 "sport", "plein-air", "famille", "gastronomie", "marche-foire",281 "conference", "communautaire", "patrimoine", "autre",282]283284# Mots-clés (sans accents, minuscules) → catégorie canonique. L'ordre compte :285# le premier match l'emporte pour un même libellé source.286_CATEGORY_RULES: list[tuple[str, str]] = [287 ("festival", "festival"),288 ("chanson", "musique"), ("musique", "musique"), ("concert", "musique"),289 ("opera", "musique"), ("orchestre", "musique"), ("rock", "musique"),290 ("pop", "musique"), ("metal", "musique"), ("punk", "musique"),291 ("hip-hop", "musique"), ("hip hop", "musique"), ("rap", "musique"),292 ("jazz", "musique"), ("blues", "musique"), ("electro", "musique"),293 ("country et folk", "musique"), ("folk", "musique"), ("dj set", "musique"),294 ("comedie musicale", "arts-scene"),295 ("theatre", "arts-scene"), ("danse", "arts-scene"), ("humour", "arts-scene"),296 ("cirque", "arts-scene"), ("spectacle", "arts-scene"), ("arts de la scene", "arts-scene"),297 ("exposition", "exposition-musee"), ("musee", "exposition-musee"),298 ("galerie", "exposition-musee"), ("arts visuels", "exposition-musee"),299 ("cinema", "cinema"), ("film", "cinema"), ("projection", "cinema"),300 ("manifestation sportive", "sport"), ("sport", "sport"), ("course", "sport"),301 ("marathon", "sport"), ("tournoi", "sport"), ("hockey", "sport"),302 ("plein air", "plein-air"), ("randonnee", "plein-air"), ("velo", "plein-air"),303 ("nature", "plein-air"), ("parc", "plein-air"),304 ("famille", "famille"), ("enfant", "famille"), ("jeunesse", "famille"),305 ("conte", "famille"),306 ("gastronomie", "gastronomie"), ("gourmand", "gastronomie"),307 ("vin", "gastronomie"), ("biere", "gastronomie"), ("bouffe", "gastronomie"),308 ("cabane a sucre", "gastronomie"),309 ("salon / foire", "marche-foire"), ("salon", "marche-foire"),310 ("foire", "marche-foire"), ("marche", "marche-foire"), ("braderie", "marche-foire"),311 ("vente-debarras", "marche-foire"),312 ("conference", "conference"), ("atelier", "conference"),313 ("formation", "conference"), ("colloque", "conference"), ("causerie", "conference"),314 ("conseil et comite", "communautaire"), ("assemblee", "communautaire"),315 ("seance", "communautaire"), ("collecte", "communautaire"),316 ("communautaire", "communautaire"), ("benevol", "communautaire"),317 ("patrimoine", "patrimoine"), ("histoire", "patrimoine"),318 ("historique", "patrimoine"), ("commemoration", "patrimoine"),319 ("celebration", "festival"), ("fete", "festival"), ("carnaval", "festival"),320 ("defile", "festival"), ("feux", "festival"),321]322323324def map_categories(labels: list[str] | None) -> list[str]:325 """Mappe des libellés source (types, thèmes) vers la taxonomie canonique."""326 out: list[str] = []327 for raw in labels or []:328 k = _key(raw)329 if not k:330 continue331 for kw, cat in _CATEGORY_RULES:332 if kw in k:333 if cat not in out:334 out.append(cat)335 break336 return out or ["autre"]337338339# ---------------------------------------------------------------------------340# Géolocalisation341# ---------------------------------------------------------------------------342343def parse_latlng(lat, lng) -> tuple[float | None, float | None]:344 """Valide une paire lat/lng (bornes du Québec, large)."""345 try:346 la, ln = float(lat), float(lng)347 except (TypeError, ValueError):348 return None, None349 if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0):350 return None, None351 return round(la, 6), round(ln, 6)352353_FR_PARTIAL_RE = re.compile(r"du\s+(\d{1,2})(?:er)?(?:\s+([a-z]+)\.?)?\s+au\s+")354355356def 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.358359 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, None368 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, end382 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, end388 return None, end389 return None, None390