# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/isarta.py : connecteur Isarta Formations (formations.isarta.com) # Catalogue de formations professionnelles en marketing, communication, # médias sociaux, IA, RH et gestion (Montréal — surtout en virtuel). # Site rendu serveur : la page catalogue liste TOUTES les fiches dans des # cartes
très riches (titre, formateur, # durée, niveau, catégories en data-attributes, sommaire, offres EN DIRECT # avec date/mode/prix et EN PRIVÉ sur mesure). Les libellés français des # catégories sont repris des filtres de la page. Les fiches /cours// # ajoutent description et objectifs, plan de formation et public cible. # Fiches en cache, rafraîchies chaque semaine (clé « AAAA-WSS »). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Formation, clean_text from .base import BaseConnector BASE = "https://formations.isarta.com" LIST_URL = f"{BASE}/" _DURATION_RE = re.compile(r"\b(\d{1,2})\s*h\s*(\d{2})?\b") _ID_RE = re.compile(r"/cours/([^/]+)") # slug unique (le nombre final = id de catégorie, non unique) # type de contenu Isarta -> type de formation Forma-Ka _TYPE_MAP = {"training": "Formation continue", "conference": "Conférence", "workshop": "Atelier"} # libellé du mode de diffusion -> mode canonique _MODE_MAP = {"virtuel interactif": "en ligne", "vidéo enregistrée": "asynchrone", "en présentiel": "présentiel"} def _category_labels(soup: BeautifulSoup) -> dict[str, str]: """Code de catégorie -> libellé français, depuis les filtres de la page.""" labels: dict[str, str] = {} for inp in soup.find_all("input", attrs={"name": "categories[]"}): span = inp.find_next("span", class_="catalog-filters__option-text") if inp.get("value") and span: labels[inp["value"]] = clean_text(span.get_text(" ")) return labels def _heading_section(soup: BeautifulSoup, heading_rx: str): """Conteneur (parent direct) du

correspondant.""" for h in soup.find_all("h3"): if re.search(heading_rx, clean_text(h.get_text(" ")), re.I): return h.parent return None class IsartaConnector(BaseConnector): source_id = "isarta" request_delay = 0.5 limit: int | None = None # borne optionnelle (tests/débogage) def fetch(self) -> list[Formation]: html = self.fetch_html(LIST_URL) soup = BeautifulSoup(html, "html.parser") cat_labels = _category_labels(soup) cards = soup.find_all("article", class_="training-card") if self.limit: cards = cards[: self.limit] week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for card in cards: link = card.find("h2", class_="training-card__title") link = link.find("a") if link else None if link is None or not link.get("href"): continue url = urljoin(BASE, link["href"]) m = _ID_RE.search(url) external_id = m.group(1) if m else url.rsplit("/", 1)[-1] f = Formation( source=self.source_id, external_id=str(external_id), url=url, title=clean_text(link.get_text(" ")), training_type=_TYPE_MAP.get(card.get("data-content-type", ""), "Formation continue"), language="fr", ) # catégories (codes -> libellés français des filtres) codes = [c for c in card.get("data-categories", "").split(",") if c] names = [cat_labels.get(c, c.replace("_", " ").capitalize()) for c in codes] if names: f.category = names[0] f.tags = names trainers = card.find("p", class_="training-card__trainers") if trainers: f.instructor = clean_text( trainers.get_text(" ")).removeprefix("par ").strip() # méta : durée (« 3h30 ») + niveau (« Débutant-Intermédiaire ») meta = card.find(class_="training-card__meta") if meta: txt = clean_text(meta.get_text(" ")) dm = _DURATION_RE.search(txt) if dm: f.duration = dm.group(0) f.duration_hours = (int(dm.group(1)) + int(dm.group(2) or 0) / 60.0) txt = txt.replace(dm.group(0), "") level = clean_text(txt) if level and "pour tous" not in level.lower(): f.level = level elif level: f.details["niveau"] = level summary = card.find("p", class_="training-card__summary") if summary: f.description = clean_text(summary.get_text(" ")) img = card.find("img", class_="training-card__image") if img and img.get("src"): f.images = [urljoin(BASE, img["src"])] # offres : EN DIRECT / EN VIDÉO (date, mode, prix) + EN PRIVÉ modes, sessions = [], [] for offer in card.find_all(class_="training-card__offer"): classes = " ".join(offer.get("class") or []) if "offer--private" in classes: f.details["offre_privee"] = "sur mesure" continue label = offer.find(class_="training-card__mode-label") if label: mode = _MODE_MAP.get(clean_text(label.get_text(" ")).lower()) if mode: modes.append(mode) t = offer.find("time") if t and t.get("datetime"): sessions.append(t["datetime"][:10]) price = offer.find(class_="training-card__price") if price and not f.price_label: txt = clean_text(price.get_text(" ")) if txt and "demande" not in txt.lower(): f.price_label = txt modes = list(dict.fromkeys(modes)) if modes: f.mode = modes[0] f.details["modes_offerts"] = modes sessions = sorted(set(sessions)) if sessions: f.sessions = sessions f.start_date = sessions[0] # fiche détaillée — cache hebdomadaire key = f"{week}:{f.start_date}:{f.price_label}" payload = self.detail(external_id, key, lambda u=url: self._fetch_detail(u)) for k, v in (payload or {}).items(): if k == "details": f.details.update(v or {}) 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 = {} # « Description et objectifs » : paragraphes + puces (objectifs) sec = _heading_section(soup, r"description et objectifs") if sec is not None: paragraphs = [clean_text(p.get_text(" ")) for p in sec.find_all("p")] desc = "\n\n".join(dict.fromkeys(p for p in paragraphs if p)) if desc: payload["description"] = desc objectives = [clean_text(li.get_text(" ")) for li in sec.find_all("li")] objectives = [o for o in dict.fromkeys(objectives) if o] if objectives: payload["objectives"] = objectives sec = _heading_section(soup, r"plan de (formation|cours)") if sec is not None: program = [clean_text(li.get_text(" ")) for li in sec.find_all("li")] program = [p for p in dict.fromkeys(program) if p] if program: payload["program"] = program sec = _heading_section(soup, r"public cible") if sec is not None: txt = clean_text(sec.get_text(" ")) payload["audience"] = re.sub(r"^public cible\s*", "", txt, flags=re.I) return payload