# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ets_formation.py : connecteur ÉTS Formation (perf.etsmtl.ca) # Formation continue de l'École de technologie supérieure — ~600 formations # professionnelles (technologie, gestion, construction, RH…). # Site ASP.NET rendu serveur, très bien balisé schema.org : # - liste : ItemList JSON-LD (toutes les formations, dédupliquées par sigle) # - fiche : Course JSON-LD (description, UEC, séances datées avec prix, # mode Onsite/Online, charge PTxxH, formateur, lieu) + sections HTML # (objectifs pédagogiques, contenu, clientèle visée) + catégorie du # fil d'Ariane. Fiches en cache, rafraîchies chaque semaine (clé ISO). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text from .base import BaseConnector, ldjson_objects BASE = "https://www.perf.etsmtl.ca" LIST_URL = f"{BASE}/Formations" _WORKLOAD_RE = re.compile(r"PT(\d+(?:\.\d+)?)H", re.I) # eventAttendanceMode / courseMode -> mode canonique Forma-Ka _MODE_MAP = { "onsite": "présentiel", "online": "en ligne", "offlineeventattendancemode": "présentiel", "onlineeventattendancemode": "en ligne", "mixedeventattendancemode": "hybride", } def _instance_mode(inst: dict) -> str: for raw in (str(inst.get("eventAttendanceMode", "")), str(inst.get("courseMode", ""))): key = raw.rsplit("/", 1)[-1].lower() if key in _MODE_MAP: return _MODE_MAP[key] return "" def _flatten(seq) -> list[str]: """educationalCredentialAwarded arrive parfois en listes imbriquées.""" out: list[str] = [] if isinstance(seq, str): return [seq] for item in seq or []: out.extend(_flatten(item) if isinstance(item, (list, tuple)) else [str(item)]) return out def _section_items(soup: BeautifulSoup, heading_rx: str) -> list[str]: """Items de liste (
  • ) qui suivent un titre de section donné.""" h = soup.find(["h2", "h3", "h4"], string=re.compile(heading_rx, re.I)) if h is None: return [] items: list[str] = [] for sib in h.find_all_next(["ul", "ol", "h2", "h3", "h4"], limit=8): if sib.name in ("h2", "h3", "h4"): break items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] if items: break return [i for i in items if i] class EtsFormationConnector(BaseConnector): source_id = "ets_formation" request_delay = 0.5 def fetch(self) -> list[Formation]: html = self.fetch_html(LIST_URL) # 1) Liste complète depuis l'ItemList JSON-LD (contient des doublons) courses: dict[str, dict] = {} for obj in ldjson_objects(html): if obj.get("@type") != "ItemList": continue for li in obj.get("itemListElement", []): item = li.get("item") or {} code = item.get("courseCode") or "" if item.get("@type") == "Course" and code and code not in courses: courses[code] = item # 2) Fiche détaillée par formation — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for code, item in courses.items(): url = item.get("url") or item.get("@id") or "" key = f"{week}:{item.get('name', '')}" payload = self.detail(code, key, lambda u=url: self._fetch_detail(u)) f = Formation( source=self.source_id, external_id=code, url=url, title=item.get("name", ""), training_type="Formation continue", language="fr", code=code, ) for k, v in (payload or {}).items(): if 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: if not url: return {} html = self.fetch_html(url) soup = BeautifulSoup(html, "html.parser") payload: dict = {} course = next((o for o in ldjson_objects(html) if o.get("@type") == "Course"), None) if course: payload["description"] = clean_text(course.get("description", "")) creds = _flatten(course.get("educationalCredentialAwarded")) payload["credential"] = " · ".join(dict.fromkeys(creds)) sessions, cities, modes, instructors = [], [], [], [] price = None hours = None for inst in course.get("hasCourseInstance", []) or []: if not isinstance(inst, dict): continue start = str(inst.get("startDate", ""))[:10] if re.match(r"20\d{2}-\d{2}-\d{2}", start): sessions.append(start) mode = _instance_mode(inst) if mode: modes.append(mode) loc = ((inst.get("location") or {}).get("address") or {}) city = loc.get("addressLocality", "") if city: cities.append(city) offer = inst.get("offers") or {} if isinstance(offer, list): offer = offer[0] if offer else {} if price is None and offer.get("price"): try: price = float(str(offer["price"]).replace(",", ".")) except ValueError: pass m = _WORKLOAD_RE.search(str(inst.get("courseWorkload", ""))) if m and hours is None: hours = float(m.group(1)) for pers in inst.get("instructor", []) or []: if isinstance(pers, dict) and pers.get("name"): instructors.append(pers["name"]) sessions = sorted(set(sessions)) if sessions: payload["sessions"] = sessions payload["start_date"] = sessions[0] modes = list(dict.fromkeys(modes)) if modes: payload["mode"] = modes[0] if len(modes) == 1 else "hybride" payload["details"] = {"modes_offerts": modes} if cities: payload["city"] = cities[0] if price is not None: payload["price"] = price payload["price_label"] = f"{price:g} $ + tx" if hours is not None: payload["duration_hours"] = hours payload["duration"] = f"{hours:g} h" if instructors: payload["instructor"] = ", ".join(dict.fromkeys(instructors)) # catégorie : lien du fil d'Ariane (« Technologies de l'information… ») cat = soup.find(id="ContentPlaceHolder1_LinkFilArianeCat") if cat: payload["category"] = clean_text(cat.get_text(" ")) # sections riches de la fiche objectives = _section_items(soup, r"objectifs? p[ée]dagogiques?") if objectives: payload["objectives"] = objectives program = [] for h in soup.find_all("h3"): txt = clean_text(h.get_text(" ")) if ((txt.endswith(":") or txt.endswith(" :")) and not re.search(r"clients qui ont suivi", txt, re.I)): program.append(txt.rstrip(" :")) if program: payload["program"] = program aud = soup.find(["h2", "h3"], string=re.compile(r"client[èe]le", re.I)) if aud: nxt = aud.find_next(["p", "ul"]) if nxt: payload["audience"] = clean_text(nxt.get_text(" ")) return payload