SPB Git

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.

8.4 KB · 202 lines python
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()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
Files · 1ca59aef2013d5ac6c8c0aaff0e8e32e697d8059
Menu

Theme