spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
1# -----------------------------------------------------------------------------2# Forma-Ka — Agrégateur de formations (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# normalize.py : couche de normalisation commune — chaque connecteur remplit5# des champs bruts, cette couche les canonise (prix optionnel, durées en6# heures, modes de diffusion, types de formation, dates ISO, langues…).7# -----------------------------------------------------------------------------8from __future__ import annotations910import re11import unicodedata1213__all__ = [14 "strip_accents", "clean_text", "parse_price", "price_is_from",15 "parse_duration_hours", "normalize_mode", "normalize_type",16 "normalize_language", "parse_date_fr", "extract_details", "merge_details",17]181920def strip_accents(s: str) -> str:21 return "".join(c for c in unicodedata.normalize("NFD", s or "")22 if unicodedata.category(c) != "Mn")232425def clean_text(s: str) -> str:26 """Espaces multiples/insécables -> simple espace, trim."""27 return re.sub(r"[\s ]+", " ", s or "").strip()282930# ---------------------------------------------------------------------------31# Prix — OPTIONNEL : beaucoup de formations (cours universitaires, catalogue32# de cégep…) n'affichent aucun prix ; None est une valeur normale, pas un bug.33# ---------------------------------------------------------------------------3435_FREE_RE = re.compile(r"\b(gratuit|gratuite|free|sans frais|0\s*\$)\b", re.I)36_PRICE_RE = re.compile(r"(\d{1,3}(?:[ ,]\d{3})*(?:[.,]\d{2})?)\s*\$"37 r"|\$\s*(\d{1,3}(?:[, ]\d{3})*(?:[.,]\d{2})?)")383940def parse_price(label: str) -> float | None:41 """« 1 295,00 $ + tx » -> 1295.0 ; « Gratuit » -> 0.0 ; sinon None."""42 if not label:43 return None44 if _FREE_RE.search(label):45 return 0.046 m = _PRICE_RE.search(label)47 if not m:48 return None49 raw = (m.group(1) or m.group(2) or "")50 raw = raw.replace(" ", "").replace(" ", "").replace(" ", "")51 # « 1,295.00 » (anglais) vs « 1295,00 » (français)52 if "," in raw and "." in raw:53 raw = raw.replace(",", "")54 elif "," in raw:55 raw = raw.replace(",", ".") if re.search(r",\d{2}$", raw) else raw.replace(",", "")56 try:57 return float(raw)58 except ValueError:59 return None606162def price_is_from(label: str) -> bool:63 return bool(re.search(r"à partir de|a partir de|from|dès|des\s+\d", label or "", re.I))646566# ---------------------------------------------------------------------------67# Durée -> heures (1 jour = 7 h, 1 semaine laissée telle quelle sauf mention)68# ---------------------------------------------------------------------------6970_H_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:h(?:eures?|res?)?|hours?)\b", re.I)71_D_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:jours?|journ[ée]es?|days?)\b", re.I)72_HALF_DAY_RE = re.compile(r"demi[- ]journ[ée]e", re.I)73_MIN_RE = re.compile(r"(\d+)\s*(?:min(?:utes?)?)\b", re.I)7475HOURS_PER_DAY = 7.0767778def parse_duration_hours(text: str) -> float | None:79 """« 2 jours », « 14 h », « 90 minutes », « demi-journée » -> heures."""80 if not text:81 return None82 m = _H_RE.search(text)83 if m:84 return float(m.group(1).replace(",", "."))85 m = _D_RE.search(text)86 if m:87 return float(m.group(1).replace(",", ".")) * HOURS_PER_DAY88 if _HALF_DAY_RE.search(text):89 return HOURS_PER_DAY / 290 m = _MIN_RE.search(text)91 if m:92 return round(int(m.group(1)) / 60, 2)93 return None949596# ---------------------------------------------------------------------------97# Mode de diffusion98# ---------------------------------------------------------------------------99100_MODE_MAP = [101 (re.compile(r"hybride|comodal|bimodal|mixte|blended", re.I), "hybride"),102 (re.compile(r"asynchrone|à votre rythme|a votre rythme|autoportant|"103 r"self[- ]paced|autorythmé", re.I), "asynchrone"),104 (re.compile(r"en ligne|à distance|a distance|virtuel|webinaire|webdiffusion|"105 r"online|distanciel|remote|zoom|teams|classe virtuelle", re.I), "en ligne"),106 (re.compile(r"présentiel|presentiel|en salle|en classe|sur place|in[- ]person|"107 r"en personne|campus", re.I), "présentiel"),108]109110111def normalize_mode(raw: str) -> str:112 for rx, canon in _MODE_MAP:113 if rx.search(raw or ""):114 return canon115 return clean_text(raw).lower()116117118# ---------------------------------------------------------------------------119# Type de formation — vocabulaire canonique Forma-Ka120# ---------------------------------------------------------------------------121122TYPES = ("Cours universitaire", "Cours collégial", "Formation continue",123 "Cours en ligne", "Séminaire", "Atelier", "Webinaire", "Conférence",124 "Certification", "Bootcamp", "Programme", "Formation en entreprise")125126_TYPE_MAP = [127 (re.compile(r"universitaire|university|1er cycle|premier cycle|2e cycle|"128 r"cycles? sup|bachelor|baccalaur|maîtrise|maitrise|MBA", re.I),129 "Cours universitaire"),130 (re.compile(r"coll[ée]gial|c[ée]gep|AEC\b|DEC\b", re.I), "Cours collégial"),131 (re.compile(r"bootcamp|camp d'entraînement", re.I), "Bootcamp"),132 (re.compile(r"webinaire|webinar|webdiffusion", re.I), "Webinaire"),133 (re.compile(r"certification|certificat professionnel|badge", re.I), "Certification"),134 (re.compile(r"s[ée]minaire", re.I), "Séminaire"),135 (re.compile(r"atelier|workshop", re.I), "Atelier"),136 (re.compile(r"conf[ée]rence|sommet|colloque|symposium", re.I), "Conférence"),137 (re.compile(r"programme|parcours|cohorte", re.I), "Programme"),138 (re.compile(r"en ligne|online|à distance|a distance", re.I), "Cours en ligne"),139 (re.compile(r"formation|cours|perfectionnement", re.I), "Formation continue"),140]141142143def normalize_type(raw: str) -> str:144 raw = clean_text(raw)145 for t in TYPES: # déjà canonique146 if raw.lower() == t.lower():147 return t148 for rx, canon in _TYPE_MAP:149 if rx.search(raw):150 return canon151 return raw152153154# ---------------------------------------------------------------------------155# Langue156# ---------------------------------------------------------------------------157158def normalize_language(raw: str) -> str:159 s = strip_accents((raw or "").lower())160 fr = bool(re.search(r"\bfr|francais|french", s))161 en = bool(re.search(r"\ben\b|anglais|english", s))162 if fr and en:163 return "fr/en"164 if en:165 return "en"166 if fr:167 return "fr"168 return clean_text(raw).lower()169170171# ---------------------------------------------------------------------------172# Dates françaises -> ISO173# ---------------------------------------------------------------------------174175_MONTHS = {176 "janvier": 1, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,177 "juillet": 7, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11,178 "decembre": 12,179 # anglais180 "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6,181 "july": 7, "august": 8, "september": 9, "october": 10, "november": 11,182 "december": 12,183 # abréviations courantes184 "janv": 1, "fevr": 2, "fev": 2, "avr": 4, "juil": 7, "sept": 9, "oct": 10,185 "nov": 11, "dec": 12, "jan": 1, "feb": 2, "mar": 3, "apr": 4, "jun": 6,186 "jul": 7, "aug": 8, "sep": 9,187}188189_ISO_RE = re.compile(r"\b(20\d{2})-(\d{1,2})-(\d{1,2})\b")190_FR_RE = re.compile(r"\b(1er|\d{1,2})\s+([a-zéûî]+)\.?\s+(20\d{2})", re.I)191_SLASH_RE = re.compile(r"\b(\d{1,2})/(\d{1,2})/(20\d{2})\b")192193194def parse_date_fr(text: str) -> str | None:195 """« 14 octobre 2026 », « 2026-10-14 », « 14/10/2026 » -> « 2026-10-14 »."""196 if not text:197 return None198 m = _ISO_RE.search(text)199 if m:200 y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))201 if 1 <= mo <= 12 and 1 <= d <= 31:202 return f"{y:04d}-{mo:02d}-{d:02d}"203 m = _FR_RE.search(strip_accents(text).lower())204 if m:205 d = 1 if m.group(1) == "1er" else int(m.group(1))206 mo = _MONTHS.get(m.group(2).rstrip("."))207 if mo and 1 <= d <= 31:208 return f"{int(m.group(3)):04d}-{mo:02d}-{d:02d}"209 m = _SLASH_RE.search(text)210 if m: # convention canadienne-française jj/mm/aaaa211 d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))212 if mo > 12 and d <= 12:213 d, mo = mo, d214 if 1 <= mo <= 12 and 1 <= d <= 31:215 return f"{y:04d}-{mo:02d}-{d:02d}"216 return None217218219# ---------------------------------------------------------------------------220# Extraction de détails structurés depuis les textes libres221# ---------------------------------------------------------------------------222223_UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*UEC", re.I)224_CREDITS_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*cr[ée]dits?", re.I)225_LEVEL_MAP = [226 (re.compile(r"d[ée]butant|introduction|initiation|de base|niveau 1|beginner", re.I),227 "débutant"),228 (re.compile(r"interm[ée]diaire|niveau 2|intermediate", re.I), "intermédiaire"),229 (re.compile(r"avanc[ée]|expert|niveau 3|advanced|perfectionnement avanc", re.I),230 "avancé"),231]232233234def extract_details(*texts: str) -> dict:235 """Détails dérivés des textes libres (jamais prioritaire sur le connecteur)."""236 blob = " ".join(t for t in texts if t)237 out: dict = {}238 m = _UEC_RE.search(blob)239 if m:240 out["uec"] = float(m.group(1).replace(",", "."))241 m = _CREDITS_RE.search(blob)242 if m:243 out["credits"] = float(m.group(1).replace(",", "."))244 for rx, lvl in _LEVEL_MAP:245 if rx.search(blob):246 out["level"] = lvl247 break248 h = parse_duration_hours(blob)249 if h is not None:250 out["duration_hours"] = h251 return out252253254def merge_details(explicit: dict, derived: dict) -> dict:255 """Fusion : les valeurs explicites du connecteur ont toujours priorité."""256 out = dict(derived)257 out.update({k: v for k, v in (explicit or {}).items() if v is not None})258 return out259