# ----------------------------------------------------------------------------- # 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() def clean_city(raw: str) -> str: """Nettoie une ville : « Montréal (Québec) » → « Montréal », casse conservée.""" 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 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}" # --------------------------------------------------------------------------- # 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)