Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
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# Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka :96# ne jamais afficher un coût sans savoir d'où il vient (price_label conservé).97# ---------------------------------------------------------------------------9899_PRICE_RE = re.compile(r"(\d{1,4}(?:[.,]\d{2})?)\s*\$")100_FREE_WORDS = ("gratuit", "entree libre", "entrée libre", "free", "sans frais")101102103def parse_price(raw: str | None) -> tuple[bool | None, float | None, str]:104 """Retourne (is_free, price_min, price_label) à partir du texte source.105106 is_free : True (gratuit), False (payant), None (inconnu).107 price_min : plus bas montant trouvé en $ CAD, sinon None.108 price_label : texte original nettoyé (traçabilité, jamais inventé).109 """110 label = clean_text(raw or "")111 if not label:112 return None, None, ""113 k = _key(label)114 amounts = [float(m.replace(",", ".")) for m in _PRICE_RE.findall(label)]115 if any(w in k for w in _FREE_WORDS) and not amounts:116 return True, None, label117 if amounts:118 return False, min(amounts), label119 if "payant" in k or "billet" in k or "admission" in k:120 return False, None, label121 return None, None, label122123124# ---------------------------------------------------------------------------125# Catégories canoniques (taxonomie Sorti-Ka, multi-valué)126# ---------------------------------------------------------------------------127128CATEGORIES: list[str] = [129 "festival", "musique", "arts-scene", "exposition-musee", "cinema",130 "sport", "plein-air", "famille", "gastronomie", "marche-foire",131 "conference", "communautaire", "patrimoine", "autre",132]133134# Mots-clés (sans accents, minuscules) → catégorie canonique. L'ordre compte :135# le premier match l'emporte pour un même libellé source.136_CATEGORY_RULES: list[tuple[str, str]] = [137 ("festival", "festival"),138 ("chanson", "musique"), ("musique", "musique"), ("concert", "musique"),139 ("opera", "musique"), ("orchestre", "musique"), ("rock", "musique"),140 ("pop", "musique"), ("metal", "musique"), ("punk", "musique"),141 ("hip-hop", "musique"), ("hip hop", "musique"), ("rap", "musique"),142 ("jazz", "musique"), ("blues", "musique"), ("electro", "musique"),143 ("country et folk", "musique"), ("folk", "musique"), ("dj set", "musique"),144 ("comedie musicale", "arts-scene"),145 ("theatre", "arts-scene"), ("danse", "arts-scene"), ("humour", "arts-scene"),146 ("cirque", "arts-scene"), ("spectacle", "arts-scene"), ("arts de la scene", "arts-scene"),147 ("exposition", "exposition-musee"), ("musee", "exposition-musee"),148 ("galerie", "exposition-musee"), ("arts visuels", "exposition-musee"),149 ("cinema", "cinema"), ("film", "cinema"), ("projection", "cinema"),150 ("manifestation sportive", "sport"), ("sport", "sport"), ("course", "sport"),151 ("marathon", "sport"), ("tournoi", "sport"), ("hockey", "sport"),152 ("plein air", "plein-air"), ("randonnee", "plein-air"), ("velo", "plein-air"),153 ("nature", "plein-air"), ("parc", "plein-air"),154 ("famille", "famille"), ("enfant", "famille"), ("jeunesse", "famille"),155 ("conte", "famille"),156 ("gastronomie", "gastronomie"), ("gourmand", "gastronomie"),157 ("vin", "gastronomie"), ("biere", "gastronomie"), ("bouffe", "gastronomie"),158 ("cabane a sucre", "gastronomie"),159 ("salon / foire", "marche-foire"), ("salon", "marche-foire"),160 ("foire", "marche-foire"), ("marche", "marche-foire"), ("braderie", "marche-foire"),161 ("vente-debarras", "marche-foire"),162 ("conference", "conference"), ("atelier", "conference"),163 ("formation", "conference"), ("colloque", "conference"), ("causerie", "conference"),164 ("conseil et comite", "communautaire"), ("assemblee", "communautaire"),165 ("seance", "communautaire"), ("collecte", "communautaire"),166 ("communautaire", "communautaire"), ("benevol", "communautaire"),167 ("patrimoine", "patrimoine"), ("histoire", "patrimoine"),168 ("historique", "patrimoine"), ("commemoration", "patrimoine"),169 ("celebration", "festival"), ("fete", "festival"), ("carnaval", "festival"),170 ("defile", "festival"), ("feux", "festival"),171]172173174def map_categories(labels: list[str] | None) -> list[str]:175 """Mappe des libellés source (types, thèmes) vers la taxonomie canonique."""176 out: list[str] = []177 for raw in labels or []:178 k = _key(raw)179 if not k:180 continue181 for kw, cat in _CATEGORY_RULES:182 if kw in k:183 if cat not in out:184 out.append(cat)185 break186 return out or ["autre"]187188189# ---------------------------------------------------------------------------190# Géolocalisation191# ---------------------------------------------------------------------------192193def parse_latlng(lat, lng) -> tuple[float | None, float | None]:194 """Valide une paire lat/lng (bornes du Québec, large)."""195 try:196 la, ln = float(lat), float(lng)197 except (TypeError, ValueError):198 return None, None199 if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0):200 return None, None201 return round(la, 6), round(ln, 6)202