# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lewagon_mtl.py : connecteur Le Wagon — campus de Montréal # (lewagon.com/fr/montreal) — bootcamps intensifs en développement web / # IA / data (Web Development, Data Analytics, Data Science, Data Engineering). # Site Rails rendu serveur (Hotwire/Turbo) : # - liste : liens /fr/montreal/-course sur la page campus # - fiche : HTML riche (h1, og:description, modules du cursus #curriculum, # pré-requis de la section Admission, « X semaines ») # - séances : turbo-frame /fr/local_courses//sections/upcoming_sessions # (requiert l'en-tête « Turbo-Frame ») — rythme, dates, format campus/en # ligne, prix en CAD et langue (onglets Anglais/Français) par cohorte. # Fiches en cache, rafraîchies chaque semaine (clé ISO AAAA-WSS). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import json import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text from .base import BaseConnector BASE = "https://www.lewagon.com" LIST_URL = f"{BASE}/fr/montreal" # secours si la page campus change : les 4 formations connues de Montréal _KNOWN_SLUGS = ["web-development-course", "data-analytics-course", "data-science-course", "data-engineering-course"] _FRAME_RE = re.compile(r"/fr/local_courses/(\d+)/sections/upcoming_sessions") _PRICE_RE = re.compile(r"([\d][\d\s  ]*)\s*CAD") _WEEKS_RE = re.compile(r"(\d+)\s*semaines", re.I) # dates du tableau des séances : « oct. 12, 2026 » (mois abrégé français) _SESSION_DATE_RE = re.compile(r"([a-zûé]+)\.?\s+(\d{1,2}),\s*(20\d{2})", re.I) _MONTHS_FR = {"jan": 1, "fév": 2, "fev": 2, "mar": 3, "avr": 4, "mai": 5, "jui": 6, "juin": 6, "juil": 7, "aoû": 8, "aou": 8, "sep": 9, "oct": 10, "nov": 11, "déc": 12, "dec": 12} def _parse_session_date(text: str) -> str | None: """« oct. 12, 2026 » -> « 2026-10-12 » (attention : juin/juil).""" m = _SESSION_DATE_RE.search(text or "") if not m: return None raw = m.group(1).lower() mo = _MONTHS_FR.get(raw[:4]) or _MONTHS_FR.get(raw[:3]) if not mo: return None return f"{int(m.group(3)):04d}-{mo:02d}-{int(m.group(2)):02d}" class LeWagonMtlConnector(BaseConnector): source_id = "lewagon_mtl" request_delay = 0.5 def fetch(self) -> list[Formation]: # 1) liste des formations offertes au campus de Montréal html = self.fetch_html(LIST_URL) slugs = list(dict.fromkeys( m.rsplit("/", 1)[-1] for m in re.findall(r'href="/fr/montreal/([\w-]+-course)"', html))) if not slugs: slugs = _KNOWN_SLUGS # 2) fiche + séances par formation — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for slug in slugs: url = f"{BASE}/fr/montreal/{slug}" payload = self.detail(slug, week, lambda u=url: self._fetch_detail(u)) f = Formation( source=self.source_id, external_id=slug, url=url, training_type="Bootcamp", category="Informatique", city="Montréal", credential="Certificat Le Wagon", ) for k, v in (payload or {}).items(): if hasattr(f, k) and v not in (None, "", []): setattr(f, k, v) out.append(f) return out # -- fiche ---------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: html = self.fetch_html(url) soup = BeautifulSoup(html, "html.parser") payload: dict = {} # titre : og:title (« Bootcamp Développement Web à Montréal | Le Wagon ») og = soup.find("meta", property="og:title") title = (og.get("content", "") if og else "").split("|")[0] if not title: h1 = soup.find("h1") title = h1.get_text(" ", strip=True) if h1 else "" payload["title"] = clean_text(title) # description : og:description + phrase d'accroche du héros ogd = soup.find("meta", property="og:description") if ogd and ogd.get("content"): payload["description"] = clean_text(ogd["content"]) # durée : « Formez-vous en 9 semaines… » m = _WEEKS_RE.search(html) if m: payload["duration"] = f"{m.group(1)} semaines (temps plein)" # plan de cours : modules de la section #curriculum cur = soup.find(id="curriculum") if cur: program = [] for h in cur.find_all("h3"): txt = clean_text(h.get_text(" ")) if (txt and not txt.endswith(":") and txt not in program and not _WEEKS_RE.search(txt)): # titre de section program.append(txt) if program: payload["program"] = program # pré-requis : bloc « Pré-requis » de la section Admission pre = soup.find(string=lambda s: s and s.strip().lower() in ("pré-requis", "prérequis")) if pre: for p in pre.find_parent().find_all_next("p", limit=5): txt = clean_text(p.get_text(" ")) if (len(txt) < 60 or txt.lower().startswith("en france") or txt.lower().startswith("découvrez")): continue # bruit / mention hors Québec payload["prerequisites"] = txt break # séances : turbo-frame des prochaines sessions mf = _FRAME_RE.search(html) if mf: payload.update(self._fetch_sessions(mf.group(0))) return payload # -- séances (turbo-frame) -------------------------------------------------- def _fetch_sessions(self, frame_path: str) -> dict: try: resp = self.get(f"{BASE}{frame_path}", headers={"Turbo-Frame": "local_course_upcoming_sessions"}) except Exception: return {} soup = BeautifulSoup(resp.text, "html.parser") sessions: list[dict] = [] seen: set = set() for pane_id, lang in (("en-tab-pane", "en"), ("fr-tab-pane", "fr")): pane = soup.find(id=pane_id) if pane is None: continue for art in pane.find_all("article", class_="upcoming-sessions-grid"): cells = [clean_text(d.get_text(" ")) for d in art.find_all("div", recursive=False)] if len(cells) < 4: continue pace, dates, fmt, price_txt = cells[0], cells[1], cells[2], cells[3] # métadonnées GTM du bouton (batch_id, format, campus/en ligne) gtm = {} btn = art.find("a", attrs={"data-gtm-data-layer-attributes-value": True}) if btn: try: gtm = json.loads(btn["data-gtm-data-layer-attributes-value"]) except (ValueError, KeyError): gtm = {} key = (gtm.get("batch_id"), lang) if gtm.get("batch_id") \ else (pace, dates, lang) if key in seen: continue seen.add(key) found = _SESSION_DATE_RE.findall(dates) start = _parse_session_date(dates) end = _parse_session_date(dates[dates.find("->") + 2:]) \ if "->" in dates else ( _parse_session_date(" ".join( f"{a} {b}, {c}" for a, b, c in found[1:2])) or None) mprice = _PRICE_RE.search(price_txt) sessions.append({ "rythme": pace, "debut": start, "fin": end, "format": fmt, "langue": lang, "prix": float(re.sub(r"[\s  ]", "", mprice.group(1))) if mprice else None, "batch_id": gtm.get("batch_id"), }) if not sessions: return {} payload: dict = {"details": {"cohortes": sessions}} starts = sorted({s["debut"] for s in sessions if s["debut"]}) if starts: payload["sessions"] = starts payload["start_date"] = starts[0] payload["schedule_label"] = "Rentrées : " + ", ".join(starts) modes = {("en ligne" if "ligne" in s["format"].lower() else "présentiel") for s in sessions} payload["mode"] = modes.pop() if len(modes) == 1 else "hybride" langs = {s["langue"] for s in sessions} payload["language"] = "fr/en" if len(langs) > 1 else langs.pop() prices = [s["prix"] for s in sessions if s["prix"]] if prices: payload["price"] = min(prices) payload["price_label"] = f"{min(prices):,.0f} CAD".replace(",", " ") return payload