# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/institut_leadership.py : connecteur Institut de leadership # (institutleadership.ca, campus de Montréal) — certifications, programmes # et formations signatures en leadership et gestion (~30 programmes animés # par des dirigeants et personnalités québécoises). # Site WordPress multisite (sous-site /montreal/) : # - liste : liens du menu « Programmes de formation » de la page d'accueil # (slugs certification-*, programme-*, formation-signature-*, etc.) # - fiche : REST /montreal/wp-json/wp/v2/pages?slug=… -> content.rendered # propre (sans gabarit) : description, clientèle (« À QUI S'ADRESSE »), # modules (h4), objectifs, tarifs « 5595$ + taxes », cohortes datées, # mode en ligne / présentiel, formateurs (« animée par … »), image Yoast. # Fiches en cache, rafraîchies chaque semaine (clé ISO AAAA-WSS). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import json import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text, parse_date_fr from .base import BaseConnector BASE = "https://www.institutleadership.ca" LIST_URL = f"{BASE}/montreal/" API_URL = f"{BASE}/montreal/wp-json/wp/v2/pages" # slugs de pages « programme » dans le menu (le reste = pages corporatives) _PROGRAM_SLUG_RE = re.compile( r"^(certification-|programme-|formation-signature-|habiletes-politiques|" r"lessentiel-|communiquer-|c-lmh|experience-immersive)") _PRICE_RE = re.compile(r"(\d[\d\s ]{2,9})\s*\$\s*(?:\*)?\s*\+\s*taxes", re.I) _HOURS_RE = re.compile(r"de\s+(\d{1,2})\s*h\s*(\d{2})?\s*à\s+(\d{1,2})\s*h\s*(\d{2})?", re.I) _DAYS_RE = re.compile(r"(\d+)\s*(?:jours|journées)", re.I) _INSTRUCTOR_RE = re.compile( r"animée?s? par\s+((?:[A-ZÀ-Ý][\wà-ÿ'’.-]+\s+){1,3}[A-ZÀ-Ý][\wà-ÿ'’.-]+)") _DAY_MONTH_RE = re.compile( r"(\d{1,2}|1er)\s+(janvier|février|mars|avril|mai|juin|juillet|août|" r"septembre|octobre|novembre|décembre)", re.I) _YEAR_RE = re.compile(r"\b(20\d{2})\b") # mot-clé du slug/titre -> catégorie Forma-Ka _CATEGORY_MAP = [ (re.compile(r"\bia\b|chatgpt|copilot|claude|numérique|agentique", re.I), "Intelligence artificielle"), (re.compile(r"gouvernance", re.I), "Gouvernance"), (re.compile(r"strat[ée]gi", re.I), "Stratégie"), (re.compile(r"gestion de projet|gestion de projets", re.I), "Gestion de projet"), (re.compile(r"n[ée]gociation", re.I), "Négociation"), (re.compile(r"finance", re.I), "Finance"), (re.compile(r"communiquer|parler en public|réunions|rétroaction", re.I), "Communication"), (re.compile(r"transfert d", re.I), "Transfert d'entreprise"), ] def _json_lenient(raw: str): """L'API WP du site ajoute parfois du bruit après le JSON — décodage tolérant.""" return json.JSONDecoder().raw_decode(raw.lstrip(" \r\n"))[0] class InstitutLeadershipConnector(BaseConnector): source_id = "institut_leadership" request_delay = 0.5 def fetch(self) -> list[Formation]: # 1) liste des programmes depuis le menu de la page d'accueil Montréal html = self.fetch_html(LIST_URL) slugs = list(dict.fromkeys( s for s in re.findall( r'href="https?://(?:www\.)?institutleadership\.ca/montreal/([\w-]+)/"', html) if _PROGRAM_SLUG_RE.match(s))) # 2) fiche par programme via l'API REST — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for slug in slugs: payload = self.detail(slug, week, lambda s=slug: self._fetch_detail(s)) if not payload: continue f = Formation( source=self.source_id, external_id=slug, url=payload.get("url") or f"{BASE}/montreal/{slug}/", language="fr", ) # type + accréditation selon la famille de programme if slug.startswith(("certification-", "c-lmh")): f.training_type = "Certification" f.credential = "Certification de l'Institut de leadership" elif slug.startswith(("programme-", "experience-immersive")): f.training_type = "Programme" else: f.training_type = "Formation continue" for k, v in payload.items(): if k != "url" and hasattr(f, k) and v not in (None, "", []): setattr(f, k, v) # catégorie par mots-clés (défaut : Leadership) probe = f"{slug} {f.title}" f.category = next((cat for rx, cat in _CATEGORY_MAP if rx.search(probe)), "Leadership") f.tags = sorted({f.category, "Leadership"}) out.append(f) return out # -- fiche ---------------------------------------------------------------- def _fetch_detail(self, slug: str) -> dict: resp = self.get(API_URL, params={ "slug": slug, "_fields": "slug,link,title,content,excerpt,yoast_head_json.og_image", }) pages = _json_lenient(resp.text) if not pages: return {} page = pages[0] payload: dict = { "title": clean_text(BeautifulSoup( page.get("title", {}).get("rendered", ""), "html.parser").get_text(" ")), "url": page.get("link", ""), } soup = BeautifulSoup(page.get("content", {}).get("rendered", ""), "html.parser") text = soup.get_text("\n") lines = [clean_text(l) for l in text.split("\n") if clean_text(l)] # description : paragraphes avant la première grande section (h3) first_h3 = soup.find("h3") desc_parts = [] for p in soup.find_all("p"): if first_h3 and p.sourceline and first_h3.sourceline \ and p.sourceline >= first_h3.sourceline: break txt = clean_text(p.get_text(" ")) if len(txt) > 60: desc_parts.append(txt) if len(" ".join(desc_parts)) > 900: break payload["description"] = "\n\n".join(desc_parts) if not payload["description"]: exc = BeautifulSoup(page.get("excerpt", {}).get("rendered", ""), "html.parser").get_text(" ") payload["description"] = clean_text(exc) # clientèle visée : section « À QUI S'ADRESSE … » aud = soup.find(["h2", "h3"], string=re.compile(r"à qui s['’]adresse", re.I)) if aud: parts = [] for sib in aud.find_all_next(["p", "ul", "h2", "h3"], limit=6): if sib.name in ("h2", "h3"): break parts.append(clean_text(sib.get_text(" "))) payload["audience"] = " ".join(x for x in parts if x)[:600] # objectifs : liste qui suit « Objectifs de la formation » obj = soup.find(string=re.compile(r"objectifs de la (formation|certification)", re.I)) if obj: ul = obj.find_parent().find_next("ul") if ul: payload["objectives"] = [clean_text(li.get_text(" ")) for li in ul.find_all("li")] # plan : modules h4 (hors sections administratives) program = [] for h in soup.find_all("h4"): txt = clean_text(h.get_text(" ")) if txt and not re.search(r"à lire aussi|témoignages", txt, re.I): program.append(txt) if program: payload["program"] = program # tarifs : « 5595$ + taxes » (minimum = tarif d'appel) prices = [] labels = [] for line in lines: for m in _PRICE_RE.finditer(line): val = float(re.sub(r"[\s ]", "", m.group(1))) if val >= 100: # ignore les frais annexes prices.append(val) if line not in labels: labels.append(line) if prices: payload["price"] = min(prices) payload["price_label"] = " | ".join(labels[:3]) # cohortes datées : « Automne 2026 – divisé : 30 septembre, … » sessions = [] cohortes = [] for line in lines: my = _YEAR_RE.search(line) md = _DAY_MONTH_RE.search(line) if md and (my or re.search(r"20\d{2}", line)): cohortes.append(line) year = my.group(1) if my else "" iso = parse_date_fr(f"{md.group(1)} {md.group(2)} {year}") if iso: sessions.append(iso) if sessions: sessions = sorted(set(sessions)) payload["sessions"] = sessions payload["start_date"] = sessions[0] if cohortes: payload["schedule_label"] = cohortes[0] payload["details"] = {"cohortes": cohortes[:12]} # mode et ville online = re.search(r"en ligne", text, re.I) onsite = re.search(r"pr[ée]sentiel", text, re.I) if online and onsite: payload["mode"] = "hybride" elif online: payload["mode"] = "en ligne" elif onsite: payload["mode"] = "présentiel" if onsite and re.search(r"pr[ée]sentiel à Montréal|à Montréal", text, re.I): payload["city"] = "Montréal" # durée : « de 8h30 à 13h00 » (demi-journée) ou « 6 jours » mh = _HOURS_RE.search(text) md = _DAYS_RE.search(text) if mh: start = int(mh.group(1)) + int(mh.group(2) or 0) / 60 end = int(mh.group(3)) + int(mh.group(4) or 0) / 60 if end > start: payload["duration_hours"] = round(end - start, 2) payload["duration"] = f"{end - start:g} h" elif md: payload["duration"] = f"{md.group(1)} jours" # formateur(s) : « La formation est animée par … » mi = _INSTRUCTOR_RE.search(text) if mi: payload["instructor"] = clean_text(mi.group(1)) # image Yoast (og:image) ogs = (page.get("yoast_head_json") or {}).get("og_image") or [] payload["images"] = [o["url"] for o in ogs if isinstance(o, dict) and o.get("url")] return payload