# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/uqam_perfectionnement.py : connecteur Formation continue UQAM # (formation.uqam.ca — remplace l'ancien perfectionnement.uqam.ca, dont le # domaine ne résout plus). ~50 formations publiques courtes (6-14 h) avec # UEC, animées par le corps enseignant de l'UQAM. # Site WordPress (WooCommerce + LMS) rendu serveur : # - liste : /formations/ — grille de liens /courses// # - fiche : h1 titre, h2 sous-titre, encadré Tarif, liste « Modalités » # (Durée, Horaire, Mode de diffusion, UEC), sections h3 (Objectifs, # Principaux éléments de contenu, Approches pédagogiques, Évaluation des # apprentissages), « Prochaine séance » (dates) et biographie de la # personne formatrice. Fiches en cache, rafraîchies chaque semaine # (clé ISO « AAAA-WSS »). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text, parse_date_fr from .base import BaseConnector BASE = "https://formation.uqam.ca" LIST_URL = f"{BASE}/formations/" _COURSE_HREF_RE = re.compile(r"https?://formation\.uqam\.ca/courses/([^/?#]+)/?") _UEC_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*unité", re.I) _BIO_RE = re.compile(r"Biographie\s+(?:de\s+la|du|de\s+l['’])\s*" r"(?:formatrice|formateur|personne formatrice)?\s*:?\s*(.*)", re.I) _DATE_LINE_RE = re.compile(r"\b(1er|\d{1,2})\s+[a-zéûôî]+\.?\s+20\d{2}", re.I) # sections h3 de la fiche -> champ Formation _SECTIONS = [ (re.compile(r"^objectifs?", re.I), "objectives"), (re.compile(r"principaux [ée]l[ée]ments de contenu|^contenu", re.I), "program"), (re.compile(r"approches? p[ée]dagogiques?", re.I), "_approches"), (re.compile(r"[ée]valuation des apprentissages", re.I), "_evaluation"), (re.compile(r"client[èe]le", re.I), "audience"), ] def _section_content(h3) -> tuple[list[str], list[str]]: """(paragraphes, items) qui suivent un titre de section, jusqu'au suivant.""" paras: list[str] = [] items: list[str] = [] for sib in h3.find_next_siblings(): if sib.name in ("h1", "h2", "h3"): break if sib.name in ("ul", "ol"): items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] elif sib.name == "p": paras.append(clean_text(sib.get_text(" "))) return [p for p in paras if p], [i for i in items if i] class UqamPerfectionnementConnector(BaseConnector): source_id = "uqam_perfectionnement" request_delay = 0.6 def fetch(self) -> list[Formation]: html = self.fetch_html(LIST_URL) soup = BeautifulSoup(html, "html.parser") # 1) Liste : liens /courses// de la grille (dédupliqués) cards: dict[str, str] = {} # slug -> titre affiché for a in soup.find_all("a", href=_COURSE_HREF_RE): slug = _COURSE_HREF_RE.search(a["href"]).group(1) title = clean_text(a.get_text(" ")) if slug not in cards or (title and not cards[slug]): cards[slug] = title # 2) Fiche détaillée par formation — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for slug, title in cards.items(): url = f"{BASE}/courses/{slug}/" payload = self.detail(slug, f"{week}:{title}", lambda u=url: self._fetch_detail(u)) f = Formation( source=self.source_id, external_id=slug, url=url, title=title, training_type="Formation continue", language="fr", ) for k, v in (payload or {}).items(): if k == "details": f.details = {**f.details, **v} elif hasattr(f, k) and v not in (None, "", []): setattr(f, k, v) out.append(f) return out # -- fiche ---------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: html = self.fetch_html(url) soup = BeautifulSoup(html, "html.parser") payload: dict = {} details: dict = {} h1 = soup.find("h1") if h1: payload["title"] = clean_text(h1.get_text(" ")) # tarif (WooCommerce) : « Tarif » suivi du montant tarif = soup.find(string=re.compile(r"^\s*Tarif\s*$")) if tarif: block = tarif.find_parent(["div", "p", "li", "td"]) label = clean_text(block.get_text(" ")) if block else "" label = re.sub(r"^Tarif\s*", "", label) if label: label = label.replace("$CA", "$").strip() payload["price_label"] = label # produit à prix variable (« 26,09 $ – 490 $ ») : le premier # montant est un acompte — le vrai tarif est le plus élevé # montants en format anglais (« $490 ») ou français (« 490 $ », # « 490,00 $ ») — ignorer les captures vides/sans chiffre amounts = [] for m_ in re.finditer( r"\$\s*([\d, ]*\d(?:\.\d{2})?)|(\d[\d ,]*(?:[.,]\d{2})?)\s*\$", label): raw = (m_.group(1) or m_.group(2) or "") raw = raw.replace(" ", "").replace(" ", "").replace(" ", "") if "," in raw and "." in raw: raw = raw.replace(",", "") elif re.search(r",\d{2}$", raw): raw = raw.replace(",", ".") else: raw = raw.replace(",", "") try: amounts.append(float(raw)) except ValueError: continue if amounts: payload["price"] = max(amounts) # liste « Modalités » : Durée, Horaire, Mode de diffusion, UEC… h_mod = soup.find(["h2", "h3"], string=re.compile(r"^\s*Modalit[ée]s?", re.I)) desc_start = None if h_mod: for sib in h_mod.find_next_siblings(): if sib.name in ("h1", "h2", "h3"): break if sib.name in ("ul", "ol"): for li in sib.find_all("li"): txt = clean_text(li.get_text(" ")) low = txt.lower() if low.startswith("durée"): payload["duration"] = txt.split(":", 1)[-1].strip() elif low.startswith("horaire"): details["horaire"] = txt.split(":", 1)[-1].strip() elif low.startswith("mode de diffusion"): payload["mode"] = txt.split(":", 1)[-1].strip() elif "uec" in low or "unité" in low: payload["credential"] = txt.split(":", 1)[-1].strip() m = _UEC_RE.search(txt) if m: uec = m.group(1).replace(",", ".") payload["credits"] = f"{uec} UEC".replace(".", ",") elif low.startswith("nombre de personnes"): details["taille_groupe"] = txt.split(":", 1)[-1].strip() desc_start = sib break # description : paragraphes entre la liste des modalités et « Objectifs » if desc_start is not None: paras = [] for sib in desc_start.find_next_siblings(): if sib.name in ("h1", "h2", "h3"): break if sib.name == "p": t = clean_text(sib.get_text(" ")) if t: paras.append(t) if paras: payload["description"] = "\n\n".join(paras) # sous-titre (h2 sous le h1) : complète la description if h1: h2 = h1.find_next("h2") if h2: sub = clean_text(h2.get_text(" ")) if sub and not re.search(r"modalit|séance|tarif|attestation|statut", sub, re.I): details["sous_titre"] = sub # sections h3 : objectifs, contenu, approches, évaluation, clientèle for h3 in soup.find_all("h3"): heading = clean_text(h3.get_text(" ")) for rx, field in _SECTIONS: if not rx.search(heading): continue paras, items = _section_content(h3) if field == "objectives": payload["objectives"] = items or paras elif field == "program": payload["program"] = items or paras elif field == "audience": payload["audience"] = " ".join(paras) or "; ".join(items) elif field == "_approches": if items or paras: details["approches_pedagogiques"] = items or paras elif field == "_evaluation": if items: details["evaluation"] = items break # biographie -> personne formatrice m = _BIO_RE.match(heading) if m and m.group(1): payload["instructor"] = clean_text(m.group(1)) # « Prochaine séance » : dates des prochaines cohortes (si affichées) h_seance = soup.find(string=re.compile(r"Prochaines? s[ée]ances?", re.I)) if h_seance: block = h_seance.find_parent(["div", "section"]) if block: txt = clean_text(block.get_text(" ")) dates = [] for dm in _DATE_LINE_RE.finditer(txt): iso = parse_date_fr(dm.group(0)) if iso: dates.append(iso) if dates: payload["sessions"] = sorted(set(dates)) payload["start_date"] = payload["sessions"][0] payload["schedule_label"] = txt[:200] # lieu : les formations en personne se donnent au campus de l'UQAM if re.search(r"en personne|présentiel", str(payload.get("mode", "")), re.I): payload["city"] = "Montréal" if details: payload["details"] = details return payload