# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/hec_dirigeants.py : connecteur École des dirigeant(e)s HEC Montréal # (ecole-dirigeants.hec.ca — remplace l'ancien ecoledesdirigeants.hec.ca, # dont le domaine ne résout plus). ~90 séminaires et certifications pour # cadres et gestionnaires (leadership, finance, IA, stratégie…). # Boutique Shopify : l'API publique /products.json fournit tout le catalogue # (titre, type, prix, variantes avec mode + dates de séance, résumé HTML, # images, formateurs en étiquettes). La fiche produit ajoute les sections # riches (OBJECTIFS, EST-CE POUR VOUS?, MÉTHODE PÉDAGOGIQUE, Programme, # Animé par, durée, fil d'Ariane -> catégorie). 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 from .base import BaseConnector BASE = "https://ecole-dirigeants.hec.ca" PRODUCTS_URL = f"{BASE}/products.json" # les produits « Ticket »/« Event » sont des événements promotionnels gratuits # (conférences, webinaires d'information) — pas des formations du catalogue _SKIP_TYPES = {"ticket", "event"} # product_type Shopify -> type de formation Forma-Ka _TYPE_MAP = { "certification": "Certification", "séminaire": "Séminaire", } _DUREE_VAL_RE = re.compile(r"\d(?:[.,]\d)?\s*(?:jours?|journ[ée]es?|heures?|h\b|" r"semaines?|mois)", re.I) # sections h3 du corps « Présentation » de la fiche _SECTION_FIELDS = [ (re.compile(r"^objectifs?", re.I), "objectives"), (re.compile(r"est-ce pour vous", re.I), "audience"), (re.compile(r"avantages distinctifs", re.I), "_avantages"), (re.compile(r"m[ée]thodes? p[ée]dagogiques?", re.I), "_methode"), ] def _strip_html(html: str) -> str: """HTML de résumé Shopify -> texte propre en paragraphes.""" soup = BeautifulSoup(html or "", "html.parser") paras = [clean_text(p.get_text(" ")) for p in soup.find_all(["p", "li"])] paras = [p for p in paras if p] return "\n\n".join(paras) or clean_text(soup.get_text(" ")) def _rich_sections(container) -> dict: """Découpe un bloc riche (métachamp Shopify) selon ses titres h3/strong.""" out: dict[str, list[str]] = {} current: str | None = None for el in container.find_all(["h3", "h4", "p", "ul", "ol"]): if el.name in ("h3", "h4"): heading = clean_text(el.get_text(" ")) current = None for rx, field in _SECTION_FIELDS: if rx.search(heading): current = field out.setdefault(current, []) break continue if current is None: continue if el.name in ("ul", "ol"): out[current] += [clean_text(li.get_text(" ")) for li in el.find_all("li") if clean_text(li.get_text(" "))] else: txt = clean_text(el.get_text(" ")) if txt: out[current].append(txt) return out class HecDirigeantsConnector(BaseConnector): source_id = "hec_dirigeants" request_delay = 0.5 def fetch(self) -> list[Formation]: # 1) Catalogue complet via l'API Shopify /products.json (paginée) products: list[dict] = [] page = 1 while True: resp = self.get(PRODUCTS_URL, params={"limit": 250, "page": page}) batch = resp.json().get("products", []) products += batch if len(batch) < 250: break page += 1 week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for prod in products: ptype = (prod.get("product_type") or "").strip().lower() if ptype in _SKIP_TYPES: continue handle = prod.get("handle", "") url = f"{BASE}/products/{handle}" f = Formation( source=self.source_id, external_id=handle, url=url, title=clean_text(prod.get("title", "")), training_type=_TYPE_MAP.get(ptype, "Séminaire"), language="fr", description=_strip_html(prod.get("body_html", "")), images=[img["src"] for img in prod.get("images", []) if img.get("src")][:3], ) # variantes : prix + options (Localisation, Date) prices = [] for var in prod.get("variants", []): try: p = float(var.get("price") or 0) except (TypeError, ValueError): continue if p > 0: prices.append(p) if prices: f.price = min(prices) f.price_label = f"{f.price:g} $ + tx" if len(set(prices)) > 1: f.details["price_from"] = True modes, dates = [], [] for opt in prod.get("options", []): name = (opt.get("name") or "").lower() values = [v for v in opt.get("values", []) if v] if "localisation" in name or "format" in name: modes += values elif "date" in name: dates += values if modes: f.mode = modes[0] if len(set(modes)) == 1 else "hybride" f.details["modes_offerts"] = sorted(set(modes)) if dates: f.schedule_label = " ; ".join(dates) if re.search(r"présentiel|campus|montr[ée]al", f.mode, re.I): f.city = "Montréal" # étiquettes : catégorie + formateurs (affinées par la fiche) if prod.get("tags"): f.tags = [t for t in prod["tags"] if isinstance(t, str)] # 2) Fiche produit — sections riches, cache hebdomadaire key = f"{week}:{prod.get('updated_at', '')}" payload = self.detail(handle, key, lambda u=url: self._fetch_detail(u)) 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 = {} # fil d'Ariane : « Accueil > Leadership > … » -> catégorie bc = soup.select_one(".product-main__breadcrumbs") if bc: crumbs = [clean_text(a.get_text(" ")) for a in bc.find_all("a")] crumbs = [c for c in crumbs if c and c.lower() != "accueil"] if crumbs: payload["category"] = crumbs[0] # contenus d'onglets : deux gabarits selon le type de produit — # séminaires (.product-main__presentation-body / __programme) ou # certifications (onglets dynamiques « hec-tab-* » appariés par ordre # aux boutons Présentation / Programme / Animé par) tabs: dict[str, "BeautifulSoup"] = {} btns = soup.select('[class*="hec-tab-btn"]') conts = soup.select('[class*="hec-tab-content"]') for btn, cont in zip(btns, conts): tabs[clean_text(btn.get_text(" ")).lower()] = cont # corps « Présentation » : OBJECTIFS / EST-CE POUR VOUS? / MÉTHODE… body = soup.select_one(".product-main__presentation-body") or \ tabs.get("présentation") if body: sections = _rich_sections(body) if sections.get("objectives"): # on écarte les phrases d'amorce (« Ce programme vous permettra de : ») objs = [o for o in sections["objectives"] if not o.endswith(":")] payload["objectives"] = objs or sections["objectives"] if sections.get("audience"): payload["audience"] = " ".join(sections["audience"]) if sections.get("_avantages"): details["avantages_distinctifs"] = sections["_avantages"] if sections.get("_methode"): details["methode_pedagogique"] = " ".join(sections["_methode"]) # onglet « Programme » : plan de la formation prog = soup.select_one(".product-main__programme") or tabs.get("programme") if prog: items = [clean_text(li.get_text(" ")) for li in prog.find_all("li")] items = [i for i in items if i] if not items: items = [clean_text(p.get_text(" ")) for p in prog.find_all("p") if clean_text(p.get_text(" "))] if items: payload["program"] = items # « Animé par » : formateurs et formatrices names = [clean_text(n.get_text(" ")) for n in soup.select(".product-main__animateurs-expert-name")] titles = [clean_text(t.get_text(" ")) for t in soup.select(".product-main__animateurs-expert-title")] experts = list(dict.fromkeys( f"{n} ({t})" if t else n for n, t in zip(names, titles + [""] * len(names)) if n)) if not experts and tabs.get("animé par") is not None: # gabarit certification : noms en , titres en

suivants experts = list(dict.fromkeys( clean_text(s.get_text(" ")) for s in tabs["animé par"].find_all("strong") if clean_text(s.get_text(" ")))) if experts: payload["instructor"] = ", ".join(experts) # encadré d'inscription : durée (« Durée » suivi de « 2 jours »…) lbl = soup.find(string=re.compile(r"^\s*Durée\s*$")) if lbl: for nxt in lbl.find_all_next(string=True, limit=8): val = clean_text(str(nxt)) if _DUREE_VAL_RE.search(val): payload["duration"] = val break if re.search(r"Tarif|Langue|Format", val): break if details: payload["details"] = details return payload