SPB Git

Repository Public

normalize.py 13.0 KB 319 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# Heures → "HH:MM" (heure locale du Québec)96# ---------------------------------------------------------------------------9798_TIME_ISO_RE = re.compile(r"[T\s](\d{2}):(\d{2})")99_TIME_FR_RE = re.compile(r"(?<!\d)(\d{1,2})\s*[h:]\s*(\d{2})?(?!\d)", re.I)100101102def parse_time(raw: str | None) -> str | None:103    """Extrait une heure "HH:MM" d'un champ source, sans jamais inventer.104105    Accepte "19h30", "19 h", "19:30", "2026-09-11T19:30:00". Retourne None si106    aucune heure plausible (minuit "00:00" est traité comme un placeholder107    « toute la journée » par les connecteurs qui le savent, pas ici).108    """109    if not raw:110        return None111    s = str(raw).strip()112    m = _TIME_ISO_RE.search(s) or _TIME_FR_RE.search(s)113    if not m:114        return None115    h, mn = int(m.group(1)), int(m.group(2) or 0)116    if not (0 <= h <= 23 and 0 <= mn <= 59):117        return None118    return f"{h:02d}:{mn:02d}"119120121def utc_to_local(raw: str | None) -> tuple[str | None, str | None]:122    """Horodatage UTC ("2026-08-01T22:30:00[Z]") → (date, heure) locales QC.123124    Pour les sources qui publient leurs horaires en UTC (Laval, Sherbrooke —125    vérifié sur données réelles 2026-08). Heure None si minuit local126    (placeholder « toute la journée »).127    """128    if not raw:129        return None, None130    from datetime import datetime, timezone131    from zoneinfo import ZoneInfo132    try:133        dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))134        if dt.tzinfo is None:135            dt = dt.replace(tzinfo=timezone.utc)136        loc = dt.astimezone(ZoneInfo("America/Montreal"))137    except (ValueError, TypeError):138        return parse_date_iso(raw), None139    t = loc.strftime("%H:%M")140    return loc.strftime("%Y-%m-%d"), (None if t == "00:00" else t)141142143# ---------------------------------------------------------------------------144# Artistes — extraction conservatrice depuis les titres (billetteries).145# Seuls les patrons sûrs sont retenus : on n'invente jamais un artiste.146# ---------------------------------------------------------------------------147148# un « candidat artiste » ne doit contenir aucun de ces mots (événement, pas149# un nom d'artiste) — comparaison sans accents, minuscules150_ARTIST_STOP = (151    "festival", "soiree", "party", "spectacle", "concert", "show", "gala",152    "hommage", "tribute", "edition", "tournoi", "atelier", "conference",153    "exposition", "marche", "salon", "cabaret", "soir", "matinee", "camp",154    "cours", "formation", "vs", " et ses ", "karaoke", "open mic", "jam",155    "collecte", "brunch", "souper", "diner", "experience", "tour ",156    # types de billets / accès (jamais des artistes)157    "acces", "passe", "passeport", "billet", "forfait", "journalier",158    "entree", "admission", "reservation", "table ", "presente", "stationnement",159    "supplementaire",160)161# séparateurs « Artiste <sep> reste » (le reste doit matcher la salle, sinon162# on ne conclut rien) ; l'ordre n'importe pas163_ARTIST_SEPS = (" - ", " – ", " — ", " | ", " : ")164# suffixes sûrs : « X en concert », « X en spectacle », « X en rodage »…165_ARTIST_SUFFIX_RE = re.compile(166    r"^(.{2,60}?)\s+(?:enfin\s+)?en\s+"167    r"(concert|spectacle|rodage|tourn[eé]e|suppl[eé]mentaire)", re.I)168169170def _clean_artist(cand: str) -> str:171    """Nettoie un candidat : séparateurs/ponctuation résiduels en bordure."""172    return clean_text(cand).strip(" -–—:|·,")173174175def _plausible_artist(cand: str) -> bool:176    cand = _clean_artist(cand)177    if not (2 <= len(cand) <= 60) or len(cand.split()) > 6:178        return False179    k = f" {_key(cand)} "180    if any(w in k for w in _ARTIST_STOP):181        return False182    if not re.search(r"[a-zA-ZÀ-ÿ]", cand):183        return False184    return True185186187def artists_from_title(title: str, venue: str = "") -> list[str]:188    """Extrait le ou les artistes d'un titre de billetterie — patrons sûrs189    seulement :190      · « Artiste - Salle » / « Artiste : Salle » où le suffixe EST la salle191        connue de l'événement (aucune ambiguïté) ;192      · « Artiste en concert/spectacle/rodage/tournée ».193    Retourne [] au moindre doute.194    """195    t = clean_text(title)196    if not t:197        return []198    vk = _key(venue)199    if vk:200        for sep in _ARTIST_SEPS:201            if sep in t:202                left, right = t.split(sep, 1)203                if _key(right) == vk and _plausible_artist(left):204                    return [_clean_artist(left)]205    m = _ARTIST_SUFFIX_RE.match(t)206    if m and _plausible_artist(m.group(1)):207        return [_clean_artist(m.group(1))]208    return []209210211# ---------------------------------------------------------------------------212# Prix / gratuité — l'équivalent Sorti-Ka du price_context de Resto-Ka :213# ne jamais afficher un coût sans savoir d'où il vient (price_label conservé).214# ---------------------------------------------------------------------------215216_PRICE_RE = re.compile(r"(\d{1,4}(?:[.,]\d{2})?)\s*\$")217_FREE_WORDS = ("gratuit", "entree libre", "entrée libre", "free", "sans frais")218219220def parse_price(raw: str | None) -> tuple[bool | None, float | None, str]:221    """Retourne (is_free, price_min, price_label) à partir du texte source.222223    is_free : True (gratuit), False (payant), None (inconnu).224    price_min : plus bas montant trouvé en $ CAD, sinon None.225    price_label : texte original nettoyé (traçabilité, jamais inventé).226    """227    label = clean_text(raw or "")228    if not label:229        return None, None, ""230    k = _key(label)231    amounts = [float(m.replace(",", ".")) for m in _PRICE_RE.findall(label)]232    if any(w in k for w in _FREE_WORDS) and not amounts:233        return True, None, label234    if amounts:235        return False, min(amounts), label236    if "payant" in k or "billet" in k or "admission" in k:237        return False, None, label238    return None, None, label239240241# ---------------------------------------------------------------------------242# Catégories canoniques (taxonomie Sorti-Ka, multi-valué)243# ---------------------------------------------------------------------------244245CATEGORIES: list[str] = [246    "festival", "musique", "arts-scene", "exposition-musee", "cinema",247    "sport", "plein-air", "famille", "gastronomie", "marche-foire",248    "conference", "communautaire", "patrimoine", "autre",249]250251# Mots-clés (sans accents, minuscules) → catégorie canonique. L'ordre compte :252# le premier match l'emporte pour un même libellé source.253_CATEGORY_RULES: list[tuple[str, str]] = [254    ("festival", "festival"),255    ("chanson", "musique"), ("musique", "musique"), ("concert", "musique"),256    ("opera", "musique"), ("orchestre", "musique"), ("rock", "musique"),257    ("pop", "musique"), ("metal", "musique"), ("punk", "musique"),258    ("hip-hop", "musique"), ("hip hop", "musique"), ("rap", "musique"),259    ("jazz", "musique"), ("blues", "musique"), ("electro", "musique"),260    ("country et folk", "musique"), ("folk", "musique"), ("dj set", "musique"),261    ("comedie musicale", "arts-scene"),262    ("theatre", "arts-scene"), ("danse", "arts-scene"), ("humour", "arts-scene"),263    ("cirque", "arts-scene"), ("spectacle", "arts-scene"), ("arts de la scene", "arts-scene"),264    ("exposition", "exposition-musee"), ("musee", "exposition-musee"),265    ("galerie", "exposition-musee"), ("arts visuels", "exposition-musee"),266    ("cinema", "cinema"), ("film", "cinema"), ("projection", "cinema"),267    ("manifestation sportive", "sport"), ("sport", "sport"), ("course", "sport"),268    ("marathon", "sport"), ("tournoi", "sport"), ("hockey", "sport"),269    ("plein air", "plein-air"), ("randonnee", "plein-air"), ("velo", "plein-air"),270    ("nature", "plein-air"), ("parc", "plein-air"),271    ("famille", "famille"), ("enfant", "famille"), ("jeunesse", "famille"),272    ("conte", "famille"),273    ("gastronomie", "gastronomie"), ("gourmand", "gastronomie"),274    ("vin", "gastronomie"), ("biere", "gastronomie"), ("bouffe", "gastronomie"),275    ("cabane a sucre", "gastronomie"),276    ("salon / foire", "marche-foire"), ("salon", "marche-foire"),277    ("foire", "marche-foire"), ("marche", "marche-foire"), ("braderie", "marche-foire"),278    ("vente-debarras", "marche-foire"),279    ("conference", "conference"), ("atelier", "conference"),280    ("formation", "conference"), ("colloque", "conference"), ("causerie", "conference"),281    ("conseil et comite", "communautaire"), ("assemblee", "communautaire"),282    ("seance", "communautaire"), ("collecte", "communautaire"),283    ("communautaire", "communautaire"), ("benevol", "communautaire"),284    ("patrimoine", "patrimoine"), ("histoire", "patrimoine"),285    ("historique", "patrimoine"), ("commemoration", "patrimoine"),286    ("celebration", "festival"), ("fete", "festival"), ("carnaval", "festival"),287    ("defile", "festival"), ("feux", "festival"),288]289290291def map_categories(labels: list[str] | None) -> list[str]:292    """Mappe des libellés source (types, thèmes) vers la taxonomie canonique."""293    out: list[str] = []294    for raw in labels or []:295        k = _key(raw)296        if not k:297            continue298        for kw, cat in _CATEGORY_RULES:299            if kw in k:300                if cat not in out:301                    out.append(cat)302                break303    return out or ["autre"]304305306# ---------------------------------------------------------------------------307# Géolocalisation308# ---------------------------------------------------------------------------309310def parse_latlng(lat, lng) -> tuple[float | None, float | None]:311    """Valide une paire lat/lng (bornes du Québec, large)."""312    try:313        la, ln = float(lat), float(lng)314    except (TypeError, ValueError):315        return None, None316    if not (44.0 <= la <= 63.0 and -80.0 <= ln <= -56.0):317        return None, None318    return round(la, 6), round(ln, 6)319
Files 49bc70bd1d185d40f62bbc15307c72917c5c8497
Menu

Theme