# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # normalize.py : couche de normalisation commune — chaque connecteur remplit # des champs bruts, cette couche les canonise (prix optionnel, durées en # heures, modes de diffusion, types de formation, dates ISO, langues…). # ----------------------------------------------------------------------------- from __future__ import annotations import re import unicodedata __all__ = [ "strip_accents", "clean_text", "parse_price", "price_is_from", "parse_duration_hours", "normalize_mode", "normalize_type", "normalize_language", "parse_date_fr", "extract_details", "merge_details", ] def strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s or "") if unicodedata.category(c) != "Mn") def clean_text(s: str) -> str: """Espaces multiples/insécables -> simple espace, trim.""" return re.sub(r"[\s   ]+", " ", s or "").strip() # --------------------------------------------------------------------------- # Prix — OPTIONNEL : beaucoup de formations (cours universitaires, catalogue # de cégep…) n'affichent aucun prix ; None est une valeur normale, pas un bug. # --------------------------------------------------------------------------- _FREE_RE = re.compile(r"\b(gratuit|gratuite|free|sans frais|0\s*\$)\b", re.I) _PRICE_RE = re.compile(r"(\d{1,3}(?:[  ,]\d{3})*(?:[.,]\d{2})?)\s*\$" r"|\$\s*(\d{1,3}(?:[, ]\d{3})*(?:[.,]\d{2})?)") def parse_price(label: str) -> float | None: """« 1 295,00 $ + tx » -> 1295.0 ; « Gratuit » -> 0.0 ; sinon None.""" if not label: return None if _FREE_RE.search(label): return 0.0 m = _PRICE_RE.search(label) if not m: return None raw = (m.group(1) or m.group(2) or "") raw = raw.replace(" ", "").replace(" ", "").replace(" ", "") # « 1,295.00 » (anglais) vs « 1295,00 » (français) if "," in raw and "." in raw: raw = raw.replace(",", "") elif "," in raw: raw = raw.replace(",", ".") if re.search(r",\d{2}$", raw) else raw.replace(",", "") try: return float(raw) except ValueError: return None def price_is_from(label: str) -> bool: return bool(re.search(r"à partir de|a partir de|from|dès|des\s+\d", label or "", re.I)) # --------------------------------------------------------------------------- # Durée -> heures (1 jour = 7 h, 1 semaine laissée telle quelle sauf mention) # --------------------------------------------------------------------------- _H_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:h(?:eures?|res?)?|hours?)\b", re.I) _D_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:jours?|journ[ée]es?|days?)\b", re.I) _HALF_DAY_RE = re.compile(r"demi[- ]journ[ée]e", re.I) _MIN_RE = re.compile(r"(\d+)\s*(?:min(?:utes?)?)\b", re.I) HOURS_PER_DAY = 7.0 def parse_duration_hours(text: str) -> float | None: """« 2 jours », « 14 h », « 90 minutes », « demi-journée » -> heures.""" if not text: return None m = _H_RE.search(text) if m: return float(m.group(1).replace(",", ".")) m = _D_RE.search(text) if m: return float(m.group(1).replace(",", ".")) * HOURS_PER_DAY if _HALF_DAY_RE.search(text): return HOURS_PER_DAY / 2 m = _MIN_RE.search(text) if m: return round(int(m.group(1)) / 60, 2) return None # --------------------------------------------------------------------------- # Mode de diffusion # --------------------------------------------------------------------------- _MODE_MAP = [ (re.compile(r"hybride|comodal|bimodal|mixte|blended", re.I), "hybride"), (re.compile(r"asynchrone|à votre rythme|a votre rythme|autoportant|" r"self[- ]paced|autorythmé", re.I), "asynchrone"), (re.compile(r"en ligne|à distance|a distance|virtuel|webinaire|webdiffusion|" r"online|distanciel|remote|zoom|teams|classe virtuelle", re.I), "en ligne"), (re.compile(r"présentiel|presentiel|en salle|en classe|sur place|in[- ]person|" r"en personne|campus", re.I), "présentiel"), ] def normalize_mode(raw: str) -> str: for rx, canon in _MODE_MAP: if rx.search(raw or ""): return canon return clean_text(raw).lower() # --------------------------------------------------------------------------- # Type de formation — vocabulaire canonique Forma-Ka # --------------------------------------------------------------------------- TYPES = ("Cours universitaire", "Cours collégial", "Formation continue", "Cours en ligne", "Séminaire", "Atelier", "Webinaire", "Conférence", "Certification", "Bootcamp", "Programme", "Formation en entreprise") _TYPE_MAP = [ (re.compile(r"universitaire|university|1er cycle|premier cycle|2e cycle|" r"cycles? sup|bachelor|baccalaur|maîtrise|maitrise|MBA", re.I), "Cours universitaire"), (re.compile(r"coll[ée]gial|c[ée]gep|AEC\b|DEC\b", re.I), "Cours collégial"), (re.compile(r"bootcamp|camp d'entraînement", re.I), "Bootcamp"), (re.compile(r"webinaire|webinar|webdiffusion", re.I), "Webinaire"), (re.compile(r"certification|certificat professionnel|badge", re.I), "Certification"), (re.compile(r"s[ée]minaire", re.I), "Séminaire"), (re.compile(r"atelier|workshop", re.I), "Atelier"), (re.compile(r"conf[ée]rence|sommet|colloque|symposium", re.I), "Conférence"), (re.compile(r"programme|parcours|cohorte", re.I), "Programme"), (re.compile(r"en ligne|online|à distance|a distance", re.I), "Cours en ligne"), (re.compile(r"formation|cours|perfectionnement", re.I), "Formation continue"), ] def normalize_type(raw: str) -> str: raw = clean_text(raw) for t in TYPES: # déjà canonique if raw.lower() == t.lower(): return t for rx, canon in _TYPE_MAP: if rx.search(raw): return canon return raw # --------------------------------------------------------------------------- # Langue # --------------------------------------------------------------------------- def normalize_language(raw: str) -> str: s = strip_accents((raw or "").lower()) fr = bool(re.search(r"\bfr|francais|french", s)) en = bool(re.search(r"\ben\b|anglais|english", s)) if fr and en: return "fr/en" if en: return "en" if fr: return "fr" return clean_text(raw).lower() # --------------------------------------------------------------------------- # Dates françaises -> ISO # --------------------------------------------------------------------------- _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, # anglais "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6, "july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12, # abréviations courantes "janv": 1, "fevr": 2, "fev": 2, "avr": 4, "juil": 7, "sept": 9, "oct": 10, "nov": 11, "dec": 12, "jan": 1, "feb": 2, "mar": 3, "apr": 4, "jun": 6, "jul": 7, "aug": 8, "sep": 9, } _ISO_RE = re.compile(r"\b(20\d{2})-(\d{1,2})-(\d{1,2})\b") _FR_RE = re.compile(r"\b(1er|\d{1,2})\s+([a-zéûî]+)\.?\s+(20\d{2})", re.I) _SLASH_RE = re.compile(r"\b(\d{1,2})/(\d{1,2})/(20\d{2})\b") def parse_date_fr(text: str) -> str | None: """« 14 octobre 2026 », « 2026-10-14 », « 14/10/2026 » -> « 2026-10-14 ».""" if not text: return None m = _ISO_RE.search(text) if m: y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3)) if 1 <= mo <= 12 and 1 <= d <= 31: return f"{y:04d}-{mo:02d}-{d:02d}" m = _FR_RE.search(strip_accents(text).lower()) if m: d = 1 if m.group(1) == "1er" else int(m.group(1)) mo = _MONTHS.get(m.group(2).rstrip(".")) if mo and 1 <= d <= 31: return f"{int(m.group(3)):04d}-{mo:02d}-{d:02d}" m = _SLASH_RE.search(text) if m: # convention canadienne-française jj/mm/aaaa d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) if mo > 12 and d <= 12: d, mo = mo, d if 1 <= mo <= 12 and 1 <= d <= 31: return f"{y:04d}-{mo:02d}-{d:02d}" return None # --------------------------------------------------------------------------- # Extraction de détails structurés depuis les textes libres # --------------------------------------------------------------------------- _UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*UEC", re.I) _CREDITS_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*cr[ée]dits?", re.I) _LEVEL_MAP = [ (re.compile(r"d[ée]butant|introduction|initiation|de base|niveau 1|beginner", re.I), "débutant"), (re.compile(r"interm[ée]diaire|niveau 2|intermediate", re.I), "intermédiaire"), (re.compile(r"avanc[ée]|expert|niveau 3|advanced|perfectionnement avanc", re.I), "avancé"), ] def extract_details(*texts: str) -> dict: """Détails dérivés des textes libres (jamais prioritaire sur le connecteur).""" blob = " ".join(t for t in texts if t) out: dict = {} m = _UEC_RE.search(blob) if m: out["uec"] = float(m.group(1).replace(",", ".")) m = _CREDITS_RE.search(blob) if m: out["credits"] = float(m.group(1).replace(",", ".")) for rx, lvl in _LEVEL_MAP: if rx.search(blob): out["level"] = lvl break h = parse_duration_hours(blob) if h is not None: out["duration_hours"] = h return out def merge_details(explicit: dict, derived: dict) -> dict: """Fusion : les valeurs explicites du connecteur ont toujours priorité.""" out = dict(derived) out.update({k: v for k, v in (explicit or {}).items() if v is not None}) return out