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/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