Repository Public
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()414243def clean_city(raw: str) -> str:44 """Nettoie une ville : « Montréal (Québec) » → « Montréal », casse conservée."""45 s = clean_text(raw)46 s = re.sub(r"\s*\((qu[eé]bec|qc)\)\s*$", "", s, flags=re.I)47 s = re.sub(r",\s*(qu[eé]bec|qc|canada)\s*$", "", s, flags=re.I)48 return s.strip(" ,")495051# ---------------------------------------------------------------------------52# Dates → ISO "YYYY-MM-DD"53# ---------------------------------------------------------------------------5455_ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")5657_FR_MONTHS = {58 "janvier": 1, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,59 "juillet": 7, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11,60 "decembre": 12, "jan": 1, "fev": 2, "mar": 3, "avr": 4, "juil": 7,61 "sept": 9, "sep": 9, "oct": 10, "nov": 11, "dec": 12,62}63_FR_DATE_RE = re.compile(r"(\d{1,2})(?:er)?\s+([a-z]+)\.?\s+(\d{4})")646566def parse_date_iso(raw: str | None) -> str | None:67 """Extrait une date ISO d'un champ source ("2026-09-11T00:00:00" → "2026-09-11")."""68 if not raw:69 return None70 m = _ISO_RE.match(str(raw).strip())71 if not m:72 return None73 y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))74 if not (1990 <= y <= 2100 and 1 <= mo <= 12 and 1 <= d <= 31):75 return None76 return f"{y:04d}-{mo:02d}-{d:02d}"777879def parse_date_fr(raw: str | None) -> str | None:80 """Date française textuelle → ISO ("17 août 2026, 18h00" → "2026-08-17")."""81 if not raw:82 return None83 k = _key(str(raw))84 m = _FR_DATE_RE.search(k)85 if not m:86 return parse_date_iso(raw)87 d, month_word, y = int(m.group(1)), m.group(2), int(m.group(3))88 mo = _FR_MONTHS.get(month_word)89 if not mo or not (1 <= d <= 31 and 1990 <= y <= 2100):90 return None91 return f"{y:04d}-{mo:02d}-{d:02d}"929394# ---------------------------------------------------------------------------95# Heures → "HH:MM" (heure locale du Québec)96# ---------------------------------------------------------------------------9798_TIME_ISO_RE = re.compile(r"[T\s](\d{2}):(\d{2})")99_TIME_FR_RE = re.compile(r"(?<!\d)(\d{1,2})\s*[h:]\s*(\d{2})?(?!\d)", re.I)100101102def parse_time(raw: str | None) -> str | None:103 """Extrait une heure "HH:MM" d'un champ source, sans jamais inventer.104105 Accepte "19h30", "19 h", "19:30", "2026-09-11T19:30:00". Retourne None si106 aucune heure plausible (minuit "00:00" est traité comme un placeholder107 « toute la journée » par les connecteurs qui le savent, pas ici).108 """109 if not raw:110 return None111 s = str(raw).strip()112 m = _TIME_ISO_RE.search(s) or _TIME_FR_RE.search(s)113 if not m:114 return None115 h, mn = int(m.group(1)), int(m.group(2) or 0)116 if not (0 <= h <= 23 and 0 <= mn <= 59):117 return None118 return f"{h:02d}:{mn:02d}"119120121def utc_to_local(raw: str | None) -> tuple[str | None, str | None]:122 """Horodatage UTC ("2026-08-01T22:30:00[Z]") → (date, heure) locales QC.123124 Pour les sources qui publient leurs horaires en UTC (Laval, Sherbrooke —125 vérifié sur données réelles 2026-08). Heure None si minuit local126 (placeholder « toute la journée »).127 """128 if not raw:129 return None, None130 from datetime import datetime, timezone131 from zoneinfo import ZoneInfo132 try:133 dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))134 if dt.tzinfo is None:135 dt = dt.replace(tzinfo=timezone.utc)136 loc = dt.astimezone(ZoneInfo("America/Montreal"))137 except (ValueError, TypeError):138 return parse_date_iso(raw), None139 t = loc.strftime("%H:%M")140 return loc.strftime("%Y-%m-%d"), (None if t == "00:00" else t)141142143# ---------------------------------------------------------------------------144# Artistes — extraction conservatrice depuis les titres (billetteries).145# Seuls les patrons sûrs sont retenus : on n'invente jamais un artiste.146# ---------------------------------------------------------------------------147148# un « candidat artiste » ne doit contenir aucun de ces mots (événement, pas149# un nom d'artiste) — comparaison sans accents, minuscules150_ARTIST_STOP = (151 "festival", "soiree", "party", "spectacle", "concert", "show", "gala",152 "hommage", "tribute", "edition", "tournoi", "atelier", "conference",153 "exposition", "marche", "salon", "cabaret", "soir", "matinee", "camp",154 "cours", "formation", "vs", " et ses ", "karaoke", "open mic", "jam",155 "collecte", "brunch", "souper", "diner", "experience", "tour ",156 # types de billets / accès (jamais des artistes)157 "acces", "passe", "passeport", "billet", "forfait", "journalier",158 "entree", "admission", "reservation", "table ", "presente", "stationnement",159 "supplementaire",160)161# séparateurs « Artiste <sep> reste » (le reste doit matcher la salle, sinon162# on ne conclut rien) ; l'ordre n'importe pas163_ARTIST_SEPS = (" - ", " – ", " — ", " | ", " : ")164# suffixes sûrs : « X en concert », « X en spectacle », « X en rodage »…165_ARTIST_SUFFIX_RE = re.compile(166 r"^(.{2,60}?)\s+(?:enfin\s+)?en\s+"167 r"(concert|spectacle|rodage|tourn[eé]e|suppl[eé]mentaire)", re.I)168169170def _clean_artist(cand: str) -> str:171 """Nettoie un candidat : séparateurs/ponctuation résiduels en bordure."""172 return clean_text(cand).strip(" -–—:|·,")173174175def _plausible_artist(cand: str) -> bool:176 cand = _clean_artist(cand)177 if not (2 <= len(cand) <= 60) or len(cand.split()) > 6:178 return False179 k = f" {_key(cand)} "180 if any(w in k for w in _ARTIST_STOP):181 return False182 if not re.search(r"[a-zA-ZÀ-ÿ]", cand):183 return False184 return True185186187def artists_from_title(title: str, venue: str = "") -> list[str]:188 """Extrait le ou les artistes d'un titre de billetterie — patrons sûrs189 seulement :190 · « Artiste - Salle » / « Artiste : Salle » où le suffixe EST la salle191 connue de l'événement (aucune ambiguïté) ;192 · « Artiste en concert/spectacle/rodage/tournée ».193 Retourne [] au moindre doute.194 """195 t = clean_text(title)196 if not t:197 return []198 vk = _key(venue)199 if vk:200 for sep in _ARTIST_SEPS:201 if sep in t:202 left, right = t.split(sep, 1)203 if _key(right) == vk and _plausible_artist(left):204 return [_clean_artist(left)]205 m = _ARTIST_SUFFIX_RE.match(t)206 if m and _plausible_artist(m.group(1)):207 return [_clean_artist(m.group(1))]208 return []209210211# ---------------------------------------------------------------------------212# Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka :213# ne jamais afficher un coût sans savoir d'où il vient (price_label conservé).214# ---------------------------------------------------------------------------215216_PRICE_RE = re.compile(r"(\d{1,4}(?:[.,]\d{2})?)\s*\$")217_FREE_WORDS = ("gratuit", "entree libre", "entrée libre", "free", "sans frais")218219220def parse_price(raw: str | None) -> tuple[bool | None, float | None, str]:221 """Retourne (is_free, price_min, price_label) à partir du texte source.222223 is_free : True (gratuit), False (payant), None (inconnu).224 price_min : plus bas montant trouvé en $ CAD, sinon None.225 price_label : texte original nettoyé (traçabilité, jamais inventé).226 """227 label = clean_text(raw or "")228 if not label:229 return None, None, ""230 k = _key(label)231 amounts = [float(m.replace(",", ".")) for m in _PRICE_RE.findall(label)]232 if any(w in k for w in _FREE_WORDS) and not amounts:233 return True, None, label234 if amounts:235 return False, min(amounts), label236 if "payant" in k or "billet" in k or "admission" in k:237 return False, None, label238 return None, None, label239240241# ---------------------------------------------------------------------------242# Catégories canoniques (taxonomie Sorti-Ka, multi-valué)243# ---------------------------------------------------------------------------244245CATEGORIES: list[str] = [246 "festival", "musique", "arts-scene", "exposition-musee", "cinema",247 "sport", "plein-air", "famille", "gastronomie", "marche-foire",248 "conference", "communautaire", "patrimoine", "autre",249]250251# Mots-clés (sans accents, minuscules) → catégorie canonique. L'ordre compte :252# le premier match l'emporte pour un même libellé source.253_CATEGORY_RULES: list[tuple[str, str]] = [254 ("festival", "festival"),255 ("chanson", "musique"), ("musique", "musique"), ("concert", "musique"),256 ("opera", "musique"), ("orchestre", "musique"), ("rock", "musique"),257 ("pop", "musique"), ("metal", "musique"), ("punk", "musique"),258 ("hip-hop", "musique"), ("hip hop", "musique"), ("rap", "musique"),259 ("jazz", "musique"), ("blues", "musique"), ("electro", "musique"),260 ("country et folk", "musique"), ("folk", "musique"), ("dj set", "musique"),261 ("comedie musicale", "arts-scene"),262 ("theatre", "arts-scene"), ("danse", "arts-scene"), ("humour", "arts-scene"),263 ("cirque", "arts-scene"), ("spectacle", "arts-scene"), ("arts de la scene", "arts-scene"),264 ("exposition", "exposition-musee"), ("musee", "exposition-musee"),265 ("galerie", "exposition-musee"), ("arts visuels", "exposition-musee"),266 ("cinema", "cinema"), ("film", "cinema"), ("projection", "cinema"),267 ("manifestation sportive", "sport"), ("sport", "sport"), ("course", "sport"),268 ("marathon", "sport"), ("tournoi", "sport"), ("hockey", "sport"),269 ("plein air", "plein-air"), ("randonnee", "plein-air"), ("velo", "plein-air"),270 ("nature", "plein-air"), ("parc", "plein-air"),271 ("famille", "famille"), ("enfant", "famille"), ("jeunesse", "famille"),272 ("conte", "famille"),273 ("gastronomie", "gastronomie"), ("gourmand", "gastronomie"),274 ("vin", "gastronomie"), ("biere", "gastronomie"), ("bouffe", "gastronomie"),275 ("cabane a sucre", "gastronomie"),276 ("salon / foire", "marche-foire"), ("salon", "marche-foire"),277 ("foire", "marche-foire"), ("marche", "marche-foire"), ("braderie", "marche-foire"),278 ("vente-debarras", "marche-foire"),279 ("conference", "conference"), ("atelier", "conference"),280 ("formation", "conference"), ("colloque", "conference"), ("causerie", "conference"),281 ("conseil et comite", "communautaire"), ("assemblee", "communautaire"),282 ("seance", "communautaire"), ("collecte", "communautaire"),283 ("communautaire", "communautaire"), ("benevol", "communautaire"),284 ("patrimoine", "patrimoine"), ("histoire", "patrimoine"),285 ("historique", "patrimoine"), ("commemoration", "patrimoine"),286 ("celebration", "festival"), ("fete", "festival"), ("carnaval", "festival"),287 ("defile", "festival"), ("feux", "festival"),288]289290291def map_categories(labels: list[str] | None) -> list[str]:292 """Mappe des libellés source (types, thèmes) vers la taxonomie canonique."""293 out: list[str] = []294 for raw in labels or []:295 k = _key(raw)296 if not k:297 continue298 for kw, cat in _CATEGORY_RULES:299 if kw in k:300 if cat not in out:301 out.append(cat)302 break303 return out or ["autre"]304305306# ---------------------------------------------------------------------------307# Géolocalisation308# ---------------------------------------------------------------------------309310def parse_latlng(lat, lng) -> tuple[float | None, float | None]:311 """Valide une paire lat/lng (bornes du Québec, large)."""312 try:313 la, ln = float(lat), float(lng)314 except (TypeError, ValueError):315 return None, None316 if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0):317 return None, None318 return round(la, 6), round(ln, 6)319