SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
8.5 KB · 194 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/eeq.py : connecteur École des entrepreneurs du Québec (eequebec.com)5#   ~50 formations pour entrepreneurs (démarrage, croissance, finances,6#   fiscalité…), majoritairement GRATUITES ou subventionnées.7#   Site WordPress rendu serveur :8#   - liste : /formations/?trainings_page=N (pagination) — cartes avec titre,9#     image, nombre de modules, durée (« 2 heures », « 11 minutes »), prix10#     (« Gratuit » ou montant) et type de parcours (activité / programme)11#   - fiche : intro, accordéon « Description » (avec liste d'objectifs),12#     boîte de métadonnées (Type, Coaching, Phase de la croissance, Prix,13#     clientèle) et menu déroulant des prochaines dates.14#     Fiches en cache, rafraîchies chaque semaine (clé ISO).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import datetime19import re20from urllib.parse import urljoin2122from bs4 import BeautifulSoup2324from ..schema import Formation, clean_text, parse_date_fr25from .base import BaseConnector2627BASE = "https://eequebec.com"28LIST_URL = f"{BASE}/formations/?trainings_page={{page}}"29MAX_PAGES = 30                        # garde-fou (6 pages à l'écriture)3031_SLUG_RE = re.compile(r"/formation/([^/]+)/?")323334def _txt(node, cls: str) -> str:35    el = node.find(class_=cls) if node else None36    return clean_text(el.get_text(" ")) if el else ""373839class EeqConnector(BaseConnector):40    source_id = "eeq"41    request_delay = 0.54243    def fetch(self) -> list[Formation]:44        cards = self._fetch_cards()4546        # fiche détaillée par formation — cache hebdomadaire47        week = datetime.date.today().strftime("%G-W%V")48        out: list[Formation] = []49        for slug, card in cards.items():50            key = f"{week}:{card['price_label']}:{card['duration']}"51            payload = self.detail(slug, key,52                                  lambda u=card["url"]: self._fetch_detail(u))53            f = Formation(54                source=self.source_id,55                external_id=slug,56                url=card["url"],57                title=card["title"],58                training_type="Formation continue",59                category="Entrepreneuriat",60                # les « programmes » sont des autoformations sur la plateforme61                mode="en ligne" if card["approach"] == "programme" else "",62                language="fr",63                duration=card["duration"],64                price_label=card["price_label"],65                is_free=(True if re.search(r"gratuit", card["price_label"], re.I)66                         else None),67                images=card["images"],68                details={k: v for k, v in (("approche", card["approach"]),69                                           ("modules", card["modules"]),70                                           ("statut", card["status"]))71                         if v},72            )73            for k, v in (payload or {}).items():74                if k == "details":75                    f.details = {**f.details, **v}76                elif hasattr(f, k) and v not in (None, "", []):77                    setattr(f, k, v)78            out.append(f)79        return out8081    # -- liste ------------------------------------------------------------------82    def _fetch_cards(self) -> dict[str, dict]:83        """Cartes de toutes les pages de la liste, indexées par slug."""84        cards: dict[str, dict] = {}85        for page in range(1, MAX_PAGES + 1):86            html = self.fetch_html(LIST_URL.format(page=page))87            soup = BeautifulSoup(html, "html.parser")88            new = 089            for item in soup.find_all(class_="training-item"):90                a = item.find("a", class_="training-item__link-to-single",91                              href=True)92                if a is None:93                    continue94                url = urljoin(BASE, a["href"])95                m = _SLUG_RE.search(url)96                if not m or m.group(1) in cards:97                    continue98                img = item.find("img", src=True)99                cards[m.group(1)] = {100                    "url": url,101                    "title": _txt(item, "training-item__title"),102                    "duration": _txt(item, "training-item__days__text"),103                    "price_label": _txt(item, "training-item__price__nb"),104                    "modules": _txt(item, "training-item__modules__nb"),105                    "approach": _txt(item, "training-item__approach-type__text"),106                    "status": _txt(item, "training-item__encours"),107                    "images": [img["src"]] if img and img["src"].startswith("http")108                              else [],109                }110                new += 1111            if new == 0:                     # dernière page atteinte112                break113        return cards114115    # -- fiche --------------------------------------------------------------------116    def _fetch_detail(self, url: str) -> dict:117        if not url:118            return {}119        html = self.fetch_html(url)120        soup = BeautifulSoup(html, "html.parser")121        payload: dict = {}122        details: dict = {}123124        # phrase d'introduction (« Une formation pour passer de l'idée à l'action! »)125        intro = _txt(soup, "single-training__intro")126127        # accordéons de description128        desc_zone = soup.find(class_="single-training__descriptions")129        for acc in (desc_zone.find_all("section", class_="accordion")130                    if desc_zone else []):131            title = _txt(acc, "accordion__header__title")132            body = acc.find(class_="accordion__sub-rows")133            if body is None:134                continue135            if re.match(r"description", title, re.I):136                # objectifs : liste à puces de l'accordéon Description137                ul = body.find("ul")138                if ul is not None:139                    payload["objectives"] = [clean_text(li.get_text(" "))140                                             for li in ul.find_all("li")]141                    ul.extract()142                text = clean_text(body.get_text(" "))143                text = re.sub(r"\s*Objectifs?\s*:?\s*$", "", text)144                if intro and text.startswith(intro[:60]):145                    intro = ""              # intro déjà reprise dans le corps146                payload["description"] = "\n\n".join(147                    t for t in (intro, text) if t)148            elif re.match(r"table des mati|contenu|programme", title, re.I):149                payload["program"] = [clean_text(li.get_text(" "))150                                      for li in body.find_all("li")]151        if "description" not in payload and intro:152            payload["description"] = intro153154        # boîte de métadonnées (Type, Coaching, Phase, Prix, clientèle…)155        for row in soup.find_all(class_="single-metas-box__detail-wrapper"):156            label = _txt(row, "single-training__detail-text").rstrip(" :")157            value = _txt(row, "single-training__detail-content")158            if not label or not value:159                continue160            low = label.lower()161            if low.startswith("type"):162                payload["training_type"] = value163            elif low.startswith("prix"):164                payload["price_label"] = value165            elif re.search(r"client[èe]le|public", low):166                payload["audience"] = value167            elif low.startswith("dur"):168                payload["duration"] = value169            else:170                details[low.replace(" ", "_")] = value171172        # prochaines dates offertes (<select> avec valeurs ISO)173        drop = soup.find(class_="training-dropdown")174        if drop is not None:175            sessions = []176            for opt in drop.find_all("option"):177                iso = parse_date_fr(str(opt.get("value", ""))[:10]) \178                    or parse_date_fr(clean_text(opt.get_text(" ")))179                if iso:180                    sessions.append(iso)181            if sessions:182                payload["sessions"] = sorted(set(sessions))183                payload["start_date"] = payload["sessions"][0]184185        # visuel de la fiche186        cover = soup.find(class_="single-training__cover")187        img = cover.find("img", src=True) if cover else None188        if img is not None and img["src"].startswith("http"):189            payload["images"] = [img["src"]]190191        if details:192            payload["details"] = details193        return payload194