# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mcgill_scs.py : connecteur McGill School of Continuing Studies # (continuingstudies.mcgill.ca). Formation continue de l'Université McGill : # cours de certificats de perfectionnement professionnel (PDC), ateliers # publics, cours en ligne, webinaires… (~200-300 activités). # Le site vitrine (www.mcgill.ca/continuingstudies) est derrière un WAF # Azure, mais le portail d'inscription Destiny One est librement accessible : # - liste : recherche avancée /search/publicCourseAdvancedSearch.do # interrogée catégorie par catégorie (avec pagination de session # « displaytag ») — titre, sigle, lieu, formats de diffusion # - fiche : /search/publicCourseSearchDetails.do?courseId=… — Description, # Topics Covered, Learning Outcomes, Who Should Attend?, Course Fee, # Duration (hours), CEU et programmes auxquels le cours contribue. # Fiches en cache, rafraîchies chaque semaine (clé ISO « AAAA-WSS »). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from urllib.parse import urlencode from bs4 import BeautifulSoup from ..schema import Formation, clean_text from .base import BaseConnector BASE = "https://continuingstudies.mcgill.ca" SEARCH_URL = f"{BASE}/search/publicCourseAdvancedSearch.do" DETAIL_URL = f"{BASE}/search/publicCourseSearchDetails.do" # catégories Destiny One de type « formation » -> type de formation Forma-Ka # (les catégories de tests de classement, services, séances d'information… # sont volontairement exclues) CATEGORIES = { "21539211": ("Transcript PDC Course", "Formation continue"), "21539210": ("Transcript Non-PDC Course", "Formation continue"), "21539205": ("Public Workshop", "Atelier"), "22158260": ("Short Program Course", "Formation continue"), "21539087": ("Asynchronous Online Course", "Cours en ligne"), "17116": ("Courses", "Formation continue"), "17117": ("Online courses", "Cours en ligne"), "17114": ("Workshops", "Atelier"), "17120": ("Intensive courses", "Formation continue"), "17119": ("Summer courses", "Formation continue"), "17122": ("Lecture Series", "Conférence"), "17124": ("Webinars", "Webinaire"), } _FEE_RE = re.compile(r"Course Fee\s*:?\s*\$?\s*([\d,]+(?:\.\d{2})?)", re.I) _HOURS_RE = re.compile(r"Duration\s*\(hours\)\s*:?\s*(\d+(?:\.\d+)?)", re.I) _CEU_RE = re.compile(r"(\d+(?:\.\d+)?)\s*Continuing Education Units?", re.I) # formats de diffusion Destiny -> mode canonique Forma-Ka _MODE_MAP = [ (re.compile(r"blended", re.I), "hybride"), (re.compile(r"asynchronous|self[- ]paced", re.I), "asynchrone"), (re.compile(r"online|remote|virtual", re.I), "en ligne"), (re.compile(r"in[- ]class|in[- ]person|classroom", re.I), "présentiel"), ] def _mode_from(formats: list[str]) -> str: modes = [] for fmt in formats: for rx, canon in _MODE_MAP: if rx.search(fmt): modes.append(canon) break modes = list(dict.fromkeys(modes)) if not modes: return "" if len(modes) == 1: return modes[0] return "hybride" # plusieurs formats offerts (en classe et en ligne) def _section_content(h2) -> tuple[list[str], list[str]]: """(paragraphes, items) qui suivent un titre h2 de la fiche Destiny.""" paras: list[str] = [] items: list[str] = [] for sib in h2.find_next_siblings(): if sib.name in ("h1", "h2"): break if sib.name in ("ul", "ol"): items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] elif sib.name == "p": paras.append(clean_text(sib.get_text(" "))) elif sib.name == "div": items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] paras += [clean_text(p.get_text(" ")) for p in sib.find_all("p")] if not paras and not items: # certains gabarits mettent le texte directement dans le conteneur du h2 # (ex. div#courseProfileRecommendedPrerequisites) parent = h2.parent if parent is not None: items = [clean_text(li.get_text(" ")) for li in parent.find_all("li")] txt = clean_text(parent.get_text(" ")) txt = re.sub(r"^" + re.escape(clean_text(h2.get_text(" "))) + r"\s*", "", txt) for i in items: txt = txt.replace(i, "") txt = clean_text(txt) if txt: paras = [txt] return [p for p in paras if p], [i for i in items if i] class McgillScsConnector(BaseConnector): source_id = "mcgill_scs" request_delay = 0.6 def fetch(self) -> list[Formation]: # amorce de session Destiny (cookie JSESSIONID pour la pagination) self.get(f"{SEARCH_URL}?method=load") # 1) Liste : une recherche par catégorie, pagination de session found: dict[str, dict] = {} # courseId -> infos de la liste for cat_id, (cat_label, ttype) in CATEGORIES.items(): params = { "method": "doPaginatedSearch", "showInternal": "false", "courseSearch.courseCategoryStringArray": cat_id, "courseSearch.filterString": "all", } html = self.get(f"{SEARCH_URL}?{urlencode(params)}").text pages = [html] # liens « displaytag » de pagination (d-XXXX-p=N), propres à la session page_links = sorted(set(re.findall( r'href="(/search/publicCourseAdvancedSearch\.do\?method=doPagination' r'[^"]*?-p=(\d+))"', html)), key=lambda t: int(t[1])) for link, _n in page_links: pages.append(self.get(BASE + link.replace("&", "&")).text) for page_html in pages: for row in self._parse_rows(page_html): cid = row["course_id"] if cid not in found: row["category_label"] = cat_label row["training_type"] = ttype found[cid] = row # 2) Fiche détaillée par cours — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for cid, row in found.items(): url = f"{DETAIL_URL}?method=load&courseId={cid}" key = f"{week}:{row['title']}" payload = self.detail(cid, key, lambda u=url: self._fetch_detail(u)) f = Formation( source=self.source_id, external_id=cid, url=url, title=row["title"], training_type=row["training_type"], language="en", code=row.get("code", ""), mode=_mode_from(row.get("formats", [])), ) if row.get("formats"): f.details["formats_offerts"] = row["formats"] f.details["categorie_scs"] = row["category_label"] if row.get("location"): f.details["lieu"] = row["location"] if "montreal" in row["location"].lower(): f.city = "Montréal" if row.get("availability"): f.details["disponibilite"] = row["availability"] 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 ------------------------------------------------------------------ @staticmethod def _parse_rows(html: str) -> list[dict]: soup = BeautifulSoup(html, "html.parser") rows: list[dict] = [] for td in soup.select("td.firstColumn"): a = td.select_one(".courseName a[href]") if a is None: continue m = re.search(r"courseId=(\d+)", a["href"]) if not m: continue tr = td.find_parent("tr") code_el = td.select_one(".courseCode") tds = tr.find_all("td") if tr else [] location = clean_text(tds[1].get_text(" ")) if len(tds) > 1 else "" formats = [clean_text(x.get_text(" ")) for x in tr.select(".courseDelivery a")] if tr else [] avail_el = tr.select_one(".courseAvailability") if tr else None rows.append({ "course_id": m.group(1), "title": clean_text(a.get_text(" ")), "code": clean_text(code_el.get_text(" ")) if code_el else "", "location": re.sub(r"\s*,\s*", ", ", location), "formats": [f for f in formats if f], "availability": clean_text(avail_el.get_text(" ")) if avail_el else "", }) return rows # -- fiche ------------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: html = self.fetch_html(url) soup = BeautifulSoup(html, "html.parser") payload: dict = {} details: dict = {} for h2 in soup.find_all("h2"): heading = clean_text(h2.get_text(" ")).lower() if heading == "description": paras, items = _section_content(h2) if paras: desc = "\n\n".join(paras) payload["description"] = re.sub( r"^Official Description\s*", "", desc) ceu = _CEU_RE.search(" ".join(paras)) if ceu: payload["credits"] = f"{ceu.group(1)} CEU" elif heading.startswith("topics covered"): _, items = _section_content(h2) if items: payload["program"] = items elif heading.startswith("learning outcomes"): paras, items = _section_content(h2) objs = [o for o in (items or paras) if not o.endswith(":")] if objs: payload["objectives"] = objs elif heading.startswith("who should attend"): paras, items = _section_content(h2) aud = " ".join(paras) or "; ".join(items) if aud: payload["audience"] = aud elif heading.startswith("applies towards"): ul = h2.find_next("ul") progs = [clean_text(a.get_text(" ")) for a in (ul.find_all("a") if ul else [])] progs = list(dict.fromkeys(p for p in progs if p)) if progs: details["programmes_lies"] = progs # catégorie lisible : « PDC in Business Analysis » -> sujet cat = re.sub(r"^(Professional Development |Graduate )?" r"Certificate in ", "", progs[0]).strip(" :") if cat: payload["category"] = cat body_txt = clean_text(soup.get_text(" ")) m = _FEE_RE.search(body_txt) if m: payload["price"] = float(m.group(1).replace(",", "")) payload["price_label"] = f"Course Fee: ${m.group(1)}" m = _HOURS_RE.search(body_txt) if m: payload["duration_hours"] = float(m.group(1)) payload["duration"] = f"{m.group(1)} hours" m = re.search(r"Discounts?\s*:\s*(.{10,220}?)(?:Thank you|Required fields|$)", body_txt) if m: details["rabais"] = clean_text(m.group(1)) if details: payload["details"] = details return payload