SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
10.6 KB · 242 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# connectors/uqam_perfectionnement.py : connecteur Formation continue UQAM5#   (formation.uqam.ca — remplace l'ancien perfectionnement.uqam.ca, dont le6#   domaine ne résout plus). ~50 formations publiques courtes (6-14 h) avec7#   UEC, animées par le corps enseignant de l'UQAM.8#   Site WordPress (WooCommerce + LMS) rendu serveur :9#   - liste : /formations/ — grille de liens /courses/<slug>/10#   - fiche : h1 titre, h2 sous-titre, encadré Tarif, liste « Modalités »11#     (Durée, Horaire, Mode de diffusion, UEC), sections h3 (Objectifs,12#     Principaux éléments de contenu, Approches pédagogiques, Évaluation des13#     apprentissages), « Prochaine séance » (dates) et biographie de la14#     personne formatrice. Fiches en cache, rafraîchies chaque semaine15#     (clé ISO « AAAA-WSS »).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import datetime20import re2122from bs4 import BeautifulSoup2324from ..schema import Formation, clean_text, parse_date_fr2526from .base import BaseConnector2728BASE = "https://formation.uqam.ca"29LIST_URL = f"{BASE}/formations/"3031_COURSE_HREF_RE = re.compile(r"https?://formation\.uqam\.ca/courses/([^/?#]+)/?")32_UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*unité", re.I)33_BIO_RE = re.compile(r"Biographie\s+(?:de\s+la|du|de\s+l['’])\s*"34                     r"(?:formatrice|formateur|personne formatrice)?\s*:?\s*(.*)", re.I)35_DATE_LINE_RE = re.compile(r"\b(1er|\d{1,2})\s+[a-zéûôî]+\.?\s+20\d{2}", re.I)3637# sections h3 de la fiche -> champ Formation38_SECTIONS = [39    (re.compile(r"^objectifs?", re.I), "objectives"),40    (re.compile(r"principaux [ée]l[ée]ments de contenu|^contenu", re.I), "program"),41    (re.compile(r"approches? p[ée]dagogiques?", re.I), "_approches"),42    (re.compile(r"[ée]valuation des apprentissages", re.I), "_evaluation"),43    (re.compile(r"client[èe]le", re.I), "audience"),44]454647def _section_content(h3) -> tuple[list[str], list[str]]:48    """(paragraphes, items) qui suivent un titre de section, jusqu'au suivant."""49    paras: list[str] = []50    items: list[str] = []51    for sib in h3.find_next_siblings():52        if sib.name in ("h1", "h2", "h3"):53            break54        if sib.name in ("ul", "ol"):55            items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")]56        elif sib.name == "p":57            paras.append(clean_text(sib.get_text(" ")))58    return [p for p in paras if p], [i for i in items if i]596061class UqamPerfectionnementConnector(BaseConnector):62    source_id = "uqam_perfectionnement"63    request_delay = 0.66465    def fetch(self) -> list[Formation]:66        html = self.fetch_html(LIST_URL)67        soup = BeautifulSoup(html, "html.parser")6869        # 1) Liste : liens /courses/<slug>/ de la grille (dédupliqués)70        cards: dict[str, str] = {}          # slug -> titre affiché71        for a in soup.find_all("a", href=_COURSE_HREF_RE):72            slug = _COURSE_HREF_RE.search(a["href"]).group(1)73            title = clean_text(a.get_text(" "))74            if slug not in cards or (title and not cards[slug]):75                cards[slug] = title7677        # 2) Fiche détaillée par formation — cache hebdomadaire78        week = datetime.date.today().strftime("%G-W%V")79        out: list[Formation] = []80        for slug, title in cards.items():81            url = f"{BASE}/courses/{slug}/"82            payload = self.detail(slug, f"{week}:{title}",83                                  lambda u=url: self._fetch_detail(u))84            f = Formation(85                source=self.source_id,86                external_id=slug,87                url=url,88                title=title,89                training_type="Formation continue",90                language="fr",91            )92            for k, v in (payload or {}).items():93                if k == "details":94                    f.details = {**f.details, **v}95                elif hasattr(f, k) and v not in (None, "", []):96                    setattr(f, k, v)97            out.append(f)98        return out99100    # -- fiche ----------------------------------------------------------------101    def _fetch_detail(self, url: str) -> dict:102        html = self.fetch_html(url)103        soup = BeautifulSoup(html, "html.parser")104        payload: dict = {}105        details: dict = {}106107        h1 = soup.find("h1")108        if h1:109            payload["title"] = clean_text(h1.get_text(" "))110111        # tarif (WooCommerce) : « Tarif » suivi du montant112        tarif = soup.find(string=re.compile(r"^\s*Tarif\s*$"))113        if tarif:114            block = tarif.find_parent(["div", "p", "li", "td"])115            label = clean_text(block.get_text(" ")) if block else ""116            label = re.sub(r"^Tarif\s*", "", label)117            if label:118                label = label.replace("$CA", "$").strip()119                payload["price_label"] = label120                # produit à prix variable (« 26,09 $ – 490 $ ») : le premier121                # montant est un acompte — le vrai tarif est le plus élevé122                # montants en format anglais (« $490 ») ou français (« 490 $ »,123                # « 490,00 $ ») — ignorer les captures vides/sans chiffre124                amounts = []125                for m_ in re.finditer(126                        r"\$\s*([\d, ]*\d(?:\.\d{2})?)|(\d[\d  ,]*(?:[.,]\d{2})?)\s*\$",127                        label):128                    raw = (m_.group(1) or m_.group(2) or "")129                    raw = raw.replace(" ", "").replace(" ", "").replace(" ", "")130                    if "," in raw and "." in raw:131                        raw = raw.replace(",", "")132                    elif re.search(r",\d{2}$", raw):133                        raw = raw.replace(",", ".")134                    else:135                        raw = raw.replace(",", "")136                    try:137                        amounts.append(float(raw))138                    except ValueError:139                        continue140                if amounts:141                    payload["price"] = max(amounts)142143        # liste « Modalités » : Durée, Horaire, Mode de diffusion, UEC…144        h_mod = soup.find(["h2", "h3"], string=re.compile(r"^\s*Modalit[ée]s?", re.I))145        desc_start = None146        if h_mod:147            for sib in h_mod.find_next_siblings():148                if sib.name in ("h1", "h2", "h3"):149                    break150                if sib.name in ("ul", "ol"):151                    for li in sib.find_all("li"):152                        txt = clean_text(li.get_text(" "))153                        low = txt.lower()154                        if low.startswith("durée"):155                            payload["duration"] = txt.split(":", 1)[-1].strip()156                        elif low.startswith("horaire"):157                            details["horaire"] = txt.split(":", 1)[-1].strip()158                        elif low.startswith("mode de diffusion"):159                            payload["mode"] = txt.split(":", 1)[-1].strip()160                        elif "uec" in low or "unité" in low:161                            payload["credential"] = txt.split(":", 1)[-1].strip()162                            m = _UEC_RE.search(txt)163                            if m:164                                uec = m.group(1).replace(",", ".")165                                payload["credits"] = f"{uec} UEC".replace(".", ",")166                        elif low.startswith("nombre de personnes"):167                            details["taille_groupe"] = txt.split(":", 1)[-1].strip()168                    desc_start = sib169                    break170171        # description : paragraphes entre la liste des modalités et « Objectifs »172        if desc_start is not None:173            paras = []174            for sib in desc_start.find_next_siblings():175                if sib.name in ("h1", "h2", "h3"):176                    break177                if sib.name == "p":178                    t = clean_text(sib.get_text(" "))179                    if t:180                        paras.append(t)181            if paras:182                payload["description"] = "\n\n".join(paras)183184        # sous-titre (h2 sous le h1) : complète la description185        if h1:186            h2 = h1.find_next("h2")187            if h2:188                sub = clean_text(h2.get_text(" "))189                if sub and not re.search(r"modalit|séance|tarif|attestation|statut",190                                         sub, re.I):191                    details["sous_titre"] = sub192193        # sections h3 : objectifs, contenu, approches, évaluation, clientèle194        for h3 in soup.find_all("h3"):195            heading = clean_text(h3.get_text(" "))196            for rx, field in _SECTIONS:197                if not rx.search(heading):198                    continue199                paras, items = _section_content(h3)200                if field == "objectives":201                    payload["objectives"] = items or paras202                elif field == "program":203                    payload["program"] = items or paras204                elif field == "audience":205                    payload["audience"] = " ".join(paras) or "; ".join(items)206                elif field == "_approches":207                    if items or paras:208                        details["approches_pedagogiques"] = items or paras209                elif field == "_evaluation":210                    if items:211                        details["evaluation"] = items212                break213214            # biographie -> personne formatrice215            m = _BIO_RE.match(heading)216            if m and m.group(1):217                payload["instructor"] = clean_text(m.group(1))218219        # « Prochaine séance » : dates des prochaines cohortes (si affichées)220        h_seance = soup.find(string=re.compile(r"Prochaines? s[ée]ances?", re.I))221        if h_seance:222            block = h_seance.find_parent(["div", "section"])223            if block:224                txt = clean_text(block.get_text(" "))225                dates = []226                for dm in _DATE_LINE_RE.finditer(txt):227                    iso = parse_date_fr(dm.group(0))228                    if iso:229                        dates.append(iso)230                if dates:231                    payload["sessions"] = sorted(set(dates))232                    payload["start_date"] = payload["sessions"][0]233                    payload["schedule_label"] = txt[:200]234235        # lieu : les formations en personne se donnent au campus de l'UQAM236        if re.search(r"en personne|présentiel", str(payload.get("mode", "")), re.I):237            payload["city"] = "Montréal"238239        if details:240            payload["details"] = details241        return payload242