SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
15.6 KB · 390 lines python
Raw Blame History
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)                    # &amp; &#039; &eacute; …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