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