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# 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