# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # normalize.py : couche de normalisation commune — chaque connecteur retourne # les champs bruts de sa source, ce module uniformise tout : # dates ISO, prix/gratuité, catégories canoniques, villes, texte. # (patron Lou-Ka : louka/normalize.py) # ----------------------------------------------------------------------------- from __future__ import annotations import re import unicodedata from html import unescape as html_unescape # --------------------------------------------------------------------------- # Utilitaires texte # --------------------------------------------------------------------------- _NBSP = (" ", " ", " ") def strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def clean_text(s: str) -> str: """Espaces insécables -> espaces, HTML résiduel enlevé, entités décodées.""" if not s: return "" for ch in _NBSP: s = s.replace(ch, " ") s = re.sub(r"<[^>]+>", " ", s) # balises HTML résiduelles s = html_unescape(s) # & ' é … return re.sub(r"\s+", " ", s).strip() def _key(s: str) -> str: """Texte en minuscules sans accents, pour matcher des mots-clés.""" return strip_accents(clean_text(s or "")).lower() # Noms canoniques des municipalités/arrondissements du Québec (Répertoire # MAMH, données ouvertes) : « Montreal » → « Montréal », « Quebec » → # « Québec », « LEVIS » → « Lévis »… Les sources anglophones/dégradées # éclataient les facettes ville (Phase 1 : Montreal 242 vs Montréal 5 249). _CITY_CANON: dict | None = None def _city_canon_table() -> dict: global _CITY_CANON if _CITY_CANON is None: import json from pathlib import Path p = Path(__file__).resolve().parent.parent / "data" / "villes_canon.json" try: _CITY_CANON = json.loads(p.read_text(encoding="utf-8"))["villes"] except Exception: _CITY_CANON = {} return _CITY_CANON def canonical_city(city: str) -> str: """Nom officiel (accentué) d'une ville du Répertoire MAMH, sinon inchangé.""" if not city: return city from .regions import _k key = _k(city) table = _city_canon_table() hit = table.get(key) or table.get( re.sub(r"\bste\b", "sainte", re.sub(r"\bst\b", "saint", key))) return hit or city def clean_city(raw: str) -> str: """Nettoie une ville : « Montréal (Québec) » → « Montréal », puis nom canonique du Répertoire MAMH (« Montreal » → « Montréal »).""" s = clean_text(raw) s = re.sub(r"\s*\((qu[eé]bec|qc)\)\s*$", "", s, flags=re.I) s = re.sub(r",\s*(qu[eé]bec|qc|canada)\s*$", "", s, flags=re.I) return canonical_city(s.strip(" ,")) # --------------------------------------------------------------------------- # Dates → ISO "YYYY-MM-DD" # --------------------------------------------------------------------------- _ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})") _FR_MONTHS = { "janvier": 1, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6, "juillet": 7, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11, "decembre": 12, "jan": 1, "fev": 2, "mar": 3, "avr": 4, "juil": 7, "sept": 9, "sep": 9, "oct": 10, "nov": 11, "dec": 12, } _FR_DATE_RE = re.compile(r"(\d{1,2})(?:er)?\s+([a-z]+)\.?\s+(\d{4})") def parse_date_iso(raw: str | None) -> str | None: """Extrait une date ISO d'un champ source ("2026-09-11T00:00:00" → "2026-09-11").""" if not raw: return None m = _ISO_RE.match(str(raw).strip()) if not m: return None y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3)) if not (1990 <= y <= 2100 and 1 <= mo <= 12 and 1 <= d <= 31): return None return f"{y:04d}-{mo:02d}-{d:02d}" def parse_date_fr(raw: str | None) -> str | None: """Date française textuelle → ISO ("17 août 2026, 18h00" → "2026-08-17").""" if not raw: return None k = _key(str(raw)) m = _FR_DATE_RE.search(k) if not m: return parse_date_iso(raw) d, month_word, y = int(m.group(1)), m.group(2), int(m.group(3)) mo = _FR_MONTHS.get(month_word) if not mo or not (1 <= d <= 31 and 1990 <= y <= 2100): return None return f"{y:04d}-{mo:02d}-{d:02d}" # --------------------------------------------------------------------------- # Heures → "HH:MM" (heure locale du Québec) # --------------------------------------------------------------------------- _TIME_ISO_RE = re.compile(r"[T\s](\d{2}):(\d{2})") _TIME_FR_RE = re.compile(r"(? str | None: """Extrait une heure "HH:MM" d'un champ source, sans jamais inventer. Accepte "19h30", "19 h", "19:30", "2026-09-11T19:30:00". Retourne None si aucune heure plausible (minuit "00:00" est traité comme un placeholder « toute la journée » par les connecteurs qui le savent, pas ici). """ if not raw: return None s = str(raw).strip() m = _TIME_ISO_RE.search(s) or _TIME_FR_RE.search(s) if not m: return None h, mn = int(m.group(1)), int(m.group(2) or 0) if not (0 <= h <= 23 and 0 <= mn <= 59): return None return f"{h:02d}:{mn:02d}" def utc_to_local(raw: str | None) -> tuple[str | None, str | None]: """Horodatage UTC ("2026-08-01T22:30:00[Z]") → (date, heure) locales QC. Pour les sources qui publient leurs horaires en UTC (Laval, Sherbrooke — vérifié sur données réelles 2026-08). Heure None si minuit local (placeholder « toute la journée »). """ if not raw: return None, None from datetime import datetime, timezone from zoneinfo import ZoneInfo try: dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) loc = dt.astimezone(ZoneInfo("America/Montreal")) except (ValueError, TypeError): return parse_date_iso(raw), None t = loc.strftime("%H:%M") return loc.strftime("%Y-%m-%d"), (None if t == "00:00" else t) # --------------------------------------------------------------------------- # Artistes — extraction conservatrice depuis les titres (billetteries). # Seuls les patrons sûrs sont retenus : on n'invente jamais un artiste. # --------------------------------------------------------------------------- # un « candidat artiste » ne doit contenir aucun de ces mots (événement, pas # un nom d'artiste) — comparaison sans accents, minuscules _ARTIST_STOP = ( "festival", "soiree", "party", "spectacle", "concert", "show", "gala", "hommage", "tribute", "edition", "tournoi", "atelier", "conference", "exposition", "marche", "salon", "cabaret", "soir", "matinee", "camp", "cours", "formation", "vs", " et ses ", "karaoke", "open mic", "jam", "collecte", "brunch", "souper", "diner", "experience", "tour ", # types de billets / accès (jamais des artistes) "acces", "passe", "passeport", "billet", "forfait", "journalier", "entree", "admission", "reservation", "table ", "presente", "stationnement", "supplementaire", ) # séparateurs « Artiste reste » (le reste doit matcher la salle, sinon # on ne conclut rien) ; l'ordre n'importe pas _ARTIST_SEPS = (" - ", " – ", " — ", " | ", " : ") # suffixes sûrs : « X en concert », « X en spectacle », « X en rodage »… _ARTIST_SUFFIX_RE = re.compile( r"^(.{2,60}?)\s+(?:enfin\s+)?en\s+" r"(concert|spectacle|rodage|tourn[eé]e|suppl[eé]mentaire)", re.I) def _clean_artist(cand: str) -> str: """Nettoie un candidat : séparateurs/ponctuation résiduels en bordure.""" return clean_text(cand).strip(" -–—:|·,") def _plausible_artist(cand: str) -> bool: cand = _clean_artist(cand) if not (2 <= len(cand) <= 60) or len(cand.split()) > 6: return False k = f" {_key(cand)} " if any(w in k for w in _ARTIST_STOP): return False if not re.search(r"[a-zA-ZÀ-ÿ]", cand): return False return True def artists_from_title(title: str, venue: str = "") -> list[str]: """Extrait le ou les artistes d'un titre de billetterie — patrons sûrs seulement : · « Artiste - Salle » / « Artiste : Salle » où le suffixe EST la salle connue de l'événement (aucune ambiguïté) ; · « Artiste en concert/spectacle/rodage/tournée ». Retourne [] au moindre doute. """ t = clean_text(title) if not t: return [] vk = _key(venue) if vk: for sep in _ARTIST_SEPS: if sep in t: left, right = t.split(sep, 1) if _key(right) == vk and _plausible_artist(left): return [_clean_artist(left)] m = _ARTIST_SUFFIX_RE.match(t) if m and _plausible_artist(m.group(1)): return [_clean_artist(m.group(1))] return [] # --------------------------------------------------------------------------- # Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka : # ne jamais afficher un coût sans savoir d'où il vient (price_label conservé). # --------------------------------------------------------------------------- _PRICE_RE = re.compile(r"(\d{1,4}(?:[.,]\d{2})?)\s*\$") _FREE_WORDS = ("gratuit", "entree libre", "entrée libre", "free", "sans frais") def parse_price(raw: str | None) -> tuple[bool | None, float | None, str]: """Retourne (is_free, price_min, price_label) à partir du texte source. is_free : True (gratuit), False (payant), None (inconnu). price_min : plus bas montant trouvé en $ CAD, sinon None. price_label : texte original nettoyé (traçabilité, jamais inventé). """ label = clean_text(raw or "") if not label: return None, None, "" k = _key(label) amounts = [float(m.replace(",", ".")) for m in _PRICE_RE.findall(label)] if any(w in k for w in _FREE_WORDS) and not amounts: return True, None, label if amounts: return False, min(amounts), label if "payant" in k or "billet" in k or "admission" in k: return False, None, label return None, None, label # --------------------------------------------------------------------------- # Catégories canoniques (taxonomie Sorti-Ka, multi-valué) # --------------------------------------------------------------------------- CATEGORIES: list[str] = [ "festival", "musique", "arts-scene", "exposition-musee", "cinema", "sport", "plein-air", "famille", "gastronomie", "marche-foire", "conference", "communautaire", "patrimoine", "autre", ] # Mots-clés (sans accents, minuscules) → catégorie canonique. L'ordre compte : # le premier match l'emporte pour un même libellé source. _CATEGORY_RULES: list[tuple[str, str]] = [ ("festival", "festival"), ("chanson", "musique"), ("musique", "musique"), ("concert", "musique"), ("opera", "musique"), ("orchestre", "musique"), ("rock", "musique"), ("pop", "musique"), ("metal", "musique"), ("punk", "musique"), ("hip-hop", "musique"), ("hip hop", "musique"), ("rap", "musique"), ("jazz", "musique"), ("blues", "musique"), ("electro", "musique"), ("country et folk", "musique"), ("folk", "musique"), ("dj set", "musique"), ("comedie musicale", "arts-scene"), ("theatre", "arts-scene"), ("danse", "arts-scene"), ("humour", "arts-scene"), ("cirque", "arts-scene"), ("spectacle", "arts-scene"), ("arts de la scene", "arts-scene"), ("exposition", "exposition-musee"), ("musee", "exposition-musee"), ("galerie", "exposition-musee"), ("arts visuels", "exposition-musee"), ("cinema", "cinema"), ("film", "cinema"), ("projection", "cinema"), ("manifestation sportive", "sport"), ("sport", "sport"), ("course", "sport"), ("marathon", "sport"), ("tournoi", "sport"), ("hockey", "sport"), ("plein air", "plein-air"), ("randonnee", "plein-air"), ("velo", "plein-air"), ("nature", "plein-air"), ("parc", "plein-air"), ("famille", "famille"), ("enfant", "famille"), ("jeunesse", "famille"), ("conte", "famille"), ("gastronomie", "gastronomie"), ("gourmand", "gastronomie"), ("vin", "gastronomie"), ("biere", "gastronomie"), ("bouffe", "gastronomie"), ("cabane a sucre", "gastronomie"), ("salon / foire", "marche-foire"), ("salon", "marche-foire"), ("foire", "marche-foire"), ("marche", "marche-foire"), ("braderie", "marche-foire"), ("vente-debarras", "marche-foire"), ("conference", "conference"), ("atelier", "conference"), ("formation", "conference"), ("colloque", "conference"), ("causerie", "conference"), ("conseil et comite", "communautaire"), ("assemblee", "communautaire"), ("seance", "communautaire"), ("collecte", "communautaire"), ("communautaire", "communautaire"), ("benevol", "communautaire"), ("patrimoine", "patrimoine"), ("histoire", "patrimoine"), ("historique", "patrimoine"), ("commemoration", "patrimoine"), ("celebration", "festival"), ("fete", "festival"), ("carnaval", "festival"), ("defile", "festival"), ("feux", "festival"), ] def map_categories(labels: list[str] | None) -> list[str]: """Mappe des libellés source (types, thèmes) vers la taxonomie canonique.""" out: list[str] = [] for raw in labels or []: k = _key(raw) if not k: continue for kw, cat in _CATEGORY_RULES: if kw in k: if cat not in out: out.append(cat) break return out or ["autre"] # --------------------------------------------------------------------------- # Géolocalisation # --------------------------------------------------------------------------- def parse_latlng(lat, lng) -> tuple[float | None, float | None]: """Valide une paire lat/lng (bornes du Québec, large).""" try: la, ln = float(lat), float(lng) except (TypeError, ValueError): return None, None if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0): return None, None return round(la, 6), round(ln, 6) _FR_PARTIAL_RE = re.compile(r"du\s+(\d{1,2})(?:er)?(?:\s+([a-z]+)\.?)?\s+au\s+") def parse_date_range_fr(raw: str | None) -> tuple[str | None, str | None]: """Plage de dates française → (début ISO, fin ISO), sans jamais inventer. Gère les formes des calendriers QC (Phase 3) : « Du 11 au 13 septembre 2026 » → mois/année de la fin pour le début ; « Du 4 juin au 22 août 2026 » → année de la fin pour le début ; « Du 27 août 2026 au 3 janvier 2027 » (deux dates complètes) ; « 17 août 2026 » → (date, date). (None, None) si aucune date complète sûre. """ if not raw: return None, None k = _key(str(raw)) dates: list[str] = [] for m in _FR_DATE_RE.finditer(k): d, mw, y = int(m.group(1)), m.group(2), int(m.group(3)) mo = _FR_MONTHS.get(mw) if mo and 1 <= d <= 31 and 1990 <= y <= 2100: dates.append(f"{y:04d}-{mo:02d}-{d:02d}") if len(dates) >= 2: return min(dates[0], dates[1]), max(dates[0], dates[1]) if len(dates) == 1: end = dates[0] m = _FR_PARTIAL_RE.search(k) if not m: return end, end d = int(m.group(1)) mo = _FR_MONTHS.get(m.group(2)) if m.group(2) else int(end[5:7]) if mo and 1 <= d <= 31: start = f"{end[:4]}-{mo:02d}-{d:02d}" if start <= end: return start, end return None, end return None, None