SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
8.6 KB · 201 lines python
Raw Blame History
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