spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
1# -----------------------------------------------------------------------------2# Forma-Ka — Agrégateur de formations (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/isarta.py : connecteur Isarta Formations (formations.isarta.com)5# Catalogue de formations professionnelles en marketing, communication,6# médias sociaux, IA, RH et gestion (Montréal — surtout en virtuel).7# Site rendu serveur : la page catalogue liste TOUTES les fiches dans des8# cartes <article class="training-card"> très riches (titre, formateur,9# durée, niveau, catégories en data-attributes, sommaire, offres EN DIRECT10# avec date/mode/prix et EN PRIVÉ sur mesure). Les libellés français des11# catégories sont repris des filtres de la page. Les fiches /cours/<slug>/<id>12# ajoutent description et objectifs, plan de formation et public cible.13# Fiches en cache, rafraîchies chaque semaine (clé « AAAA-WSS »).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import datetime18import re19from urllib.parse import urljoin2021from bs4 import BeautifulSoup2223from ..schema import Formation, clean_text24from .base import BaseConnector2526BASE = "https://formations.isarta.com"27LIST_URL = f"{BASE}/"2829_DURATION_RE = re.compile(r"\b(\d{1,2})\s*h\s*(\d{2})?\b")30_ID_RE = re.compile(r"/cours/([^/]+)") # slug unique (le nombre final = id de catégorie, non unique)3132# type de contenu Isarta -> type de formation Forma-Ka33_TYPE_MAP = {"training": "Formation continue", "conference": "Conférence",34 "workshop": "Atelier"}35# libellé du mode de diffusion -> mode canonique36_MODE_MAP = {"virtuel interactif": "en ligne", "vidéo enregistrée": "asynchrone",37 "en présentiel": "présentiel"}383940def _category_labels(soup: BeautifulSoup) -> dict[str, str]:41 """Code de catégorie -> libellé français, depuis les filtres de la page."""42 labels: dict[str, str] = {}43 for inp in soup.find_all("input", attrs={"name": "categories[]"}):44 span = inp.find_next("span", class_="catalog-filters__option-text")45 if inp.get("value") and span:46 labels[inp["value"]] = clean_text(span.get_text(" "))47 return labels484950def _heading_section(soup: BeautifulSoup, heading_rx: str):51 """Conteneur (parent direct) du <h3 class="gray-title"> correspondant."""52 for h in soup.find_all("h3"):53 if re.search(heading_rx, clean_text(h.get_text(" ")), re.I):54 return h.parent55 return None565758class IsartaConnector(BaseConnector):59 source_id = "isarta"60 request_delay = 0.561 limit: int | None = None # borne optionnelle (tests/débogage)6263 def fetch(self) -> list[Formation]:64 html = self.fetch_html(LIST_URL)65 soup = BeautifulSoup(html, "html.parser")66 cat_labels = _category_labels(soup)67 cards = soup.find_all("article", class_="training-card")68 if self.limit:69 cards = cards[: self.limit]7071 week = datetime.date.today().strftime("%G-W%V")72 out: list[Formation] = []73 for card in cards:74 link = card.find("h2", class_="training-card__title")75 link = link.find("a") if link else None76 if link is None or not link.get("href"):77 continue78 url = urljoin(BASE, link["href"])79 m = _ID_RE.search(url)80 external_id = m.group(1) if m else url.rsplit("/", 1)[-1]8182 f = Formation(83 source=self.source_id,84 external_id=str(external_id),85 url=url,86 title=clean_text(link.get_text(" ")),87 training_type=_TYPE_MAP.get(card.get("data-content-type", ""),88 "Formation continue"),89 language="fr",90 )9192 # catégories (codes -> libellés français des filtres)93 codes = [c for c in card.get("data-categories", "").split(",") if c]94 names = [cat_labels.get(c, c.replace("_", " ").capitalize())95 for c in codes]96 if names:97 f.category = names[0]98 f.tags = names99100 trainers = card.find("p", class_="training-card__trainers")101 if trainers:102 f.instructor = clean_text(103 trainers.get_text(" ")).removeprefix("par ").strip()104105 # méta : durée (« 3h30 ») + niveau (« Débutant-Intermédiaire »)106 meta = card.find(class_="training-card__meta")107 if meta:108 txt = clean_text(meta.get_text(" "))109 dm = _DURATION_RE.search(txt)110 if dm:111 f.duration = dm.group(0)112 f.duration_hours = (int(dm.group(1))113 + int(dm.group(2) or 0) / 60.0)114 txt = txt.replace(dm.group(0), "")115 level = clean_text(txt)116 if level and "pour tous" not in level.lower():117 f.level = level118 elif level:119 f.details["niveau"] = level120121 summary = card.find("p", class_="training-card__summary")122 if summary:123 f.description = clean_text(summary.get_text(" "))124 img = card.find("img", class_="training-card__image")125 if img and img.get("src"):126 f.images = [urljoin(BASE, img["src"])]127128 # offres : EN DIRECT / EN VIDÉO (date, mode, prix) + EN PRIVÉ129 modes, sessions = [], []130 for offer in card.find_all(class_="training-card__offer"):131 classes = " ".join(offer.get("class") or [])132 if "offer--private" in classes:133 f.details["offre_privee"] = "sur mesure"134 continue135 label = offer.find(class_="training-card__mode-label")136 if label:137 mode = _MODE_MAP.get(clean_text(label.get_text(" ")).lower())138 if mode:139 modes.append(mode)140 t = offer.find("time")141 if t and t.get("datetime"):142 sessions.append(t["datetime"][:10])143 price = offer.find(class_="training-card__price")144 if price and not f.price_label:145 txt = clean_text(price.get_text(" "))146 if txt and "demande" not in txt.lower():147 f.price_label = txt148 modes = list(dict.fromkeys(modes))149 if modes:150 f.mode = modes[0]151 f.details["modes_offerts"] = modes152 sessions = sorted(set(sessions))153 if sessions:154 f.sessions = sessions155 f.start_date = sessions[0]156157 # fiche détaillée — cache hebdomadaire158 key = f"{week}:{f.start_date}:{f.price_label}"159 payload = self.detail(external_id, key,160 lambda u=url: self._fetch_detail(u))161 for k, v in (payload or {}).items():162 if k == "details":163 f.details.update(v or {})164 elif hasattr(f, k) and v not in (None, "", []):165 setattr(f, k, v)166 out.append(f)167 return out168169 # -- fiche ----------------------------------------------------------------170 def _fetch_detail(self, url: str) -> dict:171 html = self.fetch_html(url)172 soup = BeautifulSoup(html, "html.parser")173 payload: dict = {}174175 # « Description et objectifs » : paragraphes + puces (objectifs)176 sec = _heading_section(soup, r"description et objectifs")177 if sec is not None:178 paragraphs = [clean_text(p.get_text(" ")) for p in sec.find_all("p")]179 desc = "\n\n".join(dict.fromkeys(p for p in paragraphs if p))180 if desc:181 payload["description"] = desc182 objectives = [clean_text(li.get_text(" "))183 for li in sec.find_all("li")]184 objectives = [o for o in dict.fromkeys(objectives) if o]185 if objectives:186 payload["objectives"] = objectives187188 sec = _heading_section(soup, r"plan de (formation|cours)")189 if sec is not None:190 program = [clean_text(li.get_text(" ")) for li in sec.find_all("li")]191 program = [p for p in dict.fromkeys(program) if p]192 if program:193 payload["program"] = program194195 sec = _heading_section(soup, r"public cible")196 if sec is not None:197 txt = clean_text(sec.get_text(" "))198 payload["audience"] = re.sub(r"^public cible\s*", "", txt,199 flags=re.I)200 return payload201