# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/eeq.py : connecteur École des entrepreneurs du Québec (eequebec.com) # ~50 formations pour entrepreneurs (démarrage, croissance, finances, # fiscalité…), majoritairement GRATUITES ou subventionnées. # Site WordPress rendu serveur : # - liste : /formations/?trainings_page=N (pagination) — cartes avec titre, # image, nombre de modules, durée (« 2 heures », « 11 minutes »), prix # (« Gratuit » ou montant) et type de parcours (activité / programme) # - fiche : intro, accordéon « Description » (avec liste d'objectifs), # boîte de métadonnées (Type, Coaching, Phase de la croissance, Prix, # clientèle) et menu déroulant des prochaines dates. # Fiches en cache, rafraîchies chaque semaine (clé ISO). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Formation, clean_text, parse_date_fr from .base import BaseConnector BASE = "https://eequebec.com" LIST_URL = f"{BASE}/formations/?trainings_page={{page}}" MAX_PAGES = 30 # garde-fou (6 pages à l'écriture) _SLUG_RE = re.compile(r"/formation/([^/]+)/?") def _txt(node, cls: str) -> str: el = node.find(class_=cls) if node else None return clean_text(el.get_text(" ")) if el else "" class EeqConnector(BaseConnector): source_id = "eeq" request_delay = 0.5 def fetch(self) -> list[Formation]: cards = self._fetch_cards() # fiche détaillée par formation — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for slug, card in cards.items(): key = f"{week}:{card['price_label']}:{card['duration']}" payload = self.detail(slug, key, lambda u=card["url"]: self._fetch_detail(u)) f = Formation( source=self.source_id, external_id=slug, url=card["url"], title=card["title"], training_type="Formation continue", category="Entrepreneuriat", # les « programmes » sont des autoformations sur la plateforme mode="en ligne" if card["approach"] == "programme" else "", language="fr", duration=card["duration"], price_label=card["price_label"], is_free=(True if re.search(r"gratuit", card["price_label"], re.I) else None), images=card["images"], details={k: v for k, v in (("approche", card["approach"]), ("modules", card["modules"]), ("statut", card["status"])) if v}, ) for k, v in (payload or {}).items(): if k == "details": f.details = {**f.details, **v} elif hasattr(f, k) and v not in (None, "", []): setattr(f, k, v) out.append(f) return out # -- liste ------------------------------------------------------------------ def _fetch_cards(self) -> dict[str, dict]: """Cartes de toutes les pages de la liste, indexées par slug.""" cards: dict[str, dict] = {} for page in range(1, MAX_PAGES + 1): html = self.fetch_html(LIST_URL.format(page=page)) soup = BeautifulSoup(html, "html.parser") new = 0 for item in soup.find_all(class_="training-item"): a = item.find("a", class_="training-item__link-to-single", href=True) if a is None: continue url = urljoin(BASE, a["href"]) m = _SLUG_RE.search(url) if not m or m.group(1) in cards: continue img = item.find("img", src=True) cards[m.group(1)] = { "url": url, "title": _txt(item, "training-item__title"), "duration": _txt(item, "training-item__days__text"), "price_label": _txt(item, "training-item__price__nb"), "modules": _txt(item, "training-item__modules__nb"), "approach": _txt(item, "training-item__approach-type__text"), "status": _txt(item, "training-item__encours"), "images": [img["src"]] if img and img["src"].startswith("http") else [], } new += 1 if new == 0: # dernière page atteinte break return cards # -- fiche -------------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: if not url: return {} html = self.fetch_html(url) soup = BeautifulSoup(html, "html.parser") payload: dict = {} details: dict = {} # phrase d'introduction (« Une formation pour passer de l'idée à l'action! ») intro = _txt(soup, "single-training__intro") # accordéons de description desc_zone = soup.find(class_="single-training__descriptions") for acc in (desc_zone.find_all("section", class_="accordion") if desc_zone else []): title = _txt(acc, "accordion__header__title") body = acc.find(class_="accordion__sub-rows") if body is None: continue if re.match(r"description", title, re.I): # objectifs : liste à puces de l'accordéon Description ul = body.find("ul") if ul is not None: payload["objectives"] = [clean_text(li.get_text(" ")) for li in ul.find_all("li")] ul.extract() text = clean_text(body.get_text(" ")) text = re.sub(r"\s*Objectifs?\s*:?\s*$", "", text) if intro and text.startswith(intro[:60]): intro = "" # intro déjà reprise dans le corps payload["description"] = "\n\n".join( t for t in (intro, text) if t) elif re.match(r"table des mati|contenu|programme", title, re.I): payload["program"] = [clean_text(li.get_text(" ")) for li in body.find_all("li")] if "description" not in payload and intro: payload["description"] = intro # boîte de métadonnées (Type, Coaching, Phase, Prix, clientèle…) for row in soup.find_all(class_="single-metas-box__detail-wrapper"): label = _txt(row, "single-training__detail-text").rstrip(" :") value = _txt(row, "single-training__detail-content") if not label or not value: continue low = label.lower() if low.startswith("type"): payload["training_type"] = value elif low.startswith("prix"): payload["price_label"] = value elif re.search(r"client[èe]le|public", low): payload["audience"] = value elif low.startswith("dur"): payload["duration"] = value else: details[low.replace(" ", "_")] = value # prochaines dates offertes (