SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
9.9 KB · 259 lines python
Raw Blame History
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