# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/cegep_a_distance.py : connecteur Cégep à distance # Guide de choix de cours du Cégep à distance (cegepadistance.ca) — ~150 # cours collégiaux offerts à distance (formation générale et spécifique). # Site WordPress rendu serveur (guidedechoixdecours.cegepadistance.ca) : # - liste : /cours/ — TOUTES les cartes sur une seule page (préalables, # heures/pondération, unités, compétences, option), regroupées par sigle # (un même cours existe en plusieurs « options » : Internet, papier…) # - fiche : sections titrées (Description, Infos avec compétences complètes, # Option, Particularités et matériel, Droits de scolarité et prix du # matériel). Fiches en cache, rafraîchies chaque semaine (clé ISO). # Prix affiché = droits de scolarité + matériel (cours régulier). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text, parse_price from .base import BaseConnector BASE = "https://guidedechoixdecours.cegepadistance.ca" LIST_URL = f"{BASE}/cours/" # libellés des régimes de frais -> clés du dict details["frais"] _FEE_LABELS = (("Droits de scolarité", "droits_scolarite"), ("Matériel", "materiel"), ("Total", "total")) def _card_field(card, label_rx: str) -> str: """Valeur d'un champ « Label : valeur » d'une carte.""" strong = card.find("strong", string=re.compile(label_rx, re.I)) if strong is None: return "" parent = strong.parent txt = clean_text(parent.get_text(" ")) return clean_text(re.sub(r"^[^:]*:?", "", txt, count=1)) def _section_content(soup: BeautifulSoup, heading_rx: str): """Bloc « content » qui suit un titre de section (h2) de la fiche.""" h = soup.find(lambda t: t.name == "h2" and re.search(heading_rx, t.get_text(" ", strip=True), re.I)) if h is None: return None return h.find_next(lambda t: t.name == "div" and "content" in (t.get("class") or [])) class CegepADistanceConnector(BaseConnector): source_id = "cegep_a_distance" request_delay = 0.5 def fetch(self) -> list[Formation]: html = self.fetch_html(LIST_URL) courses = self._parse_listing(html) # fiche détaillée par sigle — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for sigle, info in courses.items(): key = f"{week}:{'|'.join(info['options'])}:{info['hours']}" payload = self.detail(sigle, key, lambda u=info["url"]: self._fetch_detail(u)) # cours listés à titre indicatif mais suivis dans un autre cégep if re.search(r"n[’']est pas offert au C[ée]gep à distance", payload.get("description", "")): continue f = Formation( source=self.source_id, external_id=sigle, url=info["url"], title=info["title"], training_type="Cours collégial", category=info["category"], mode="en ligne", language="fr", code=sigle, duration=info["hours"], credits=info["credits"], prerequisites=info["prerequisites"], details={k: v for k, v in (("options", info["options"]), ("ponderation", info["ponderation"]), ("competences", info["competences"])) 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 _parse_listing(self, html: str) -> dict[str, dict]: """Cartes de la liste regroupées par sigle (fusion des options).""" soup = BeautifulSoup(html, "html.parser") courses: dict[str, dict] = {} for a in soup.select("div.course-details a.gutter[href]"): name = a.find("span", class_="name") code = a.find("span", class_="code") if name is None or code is None: continue sigle = clean_text(code.get_text()) prereq = _card_field(a, r"Pr[ée]alables") hp = _card_field(a, r"Heures\s*/\s*Pond[ée]ration") hours, _, ponderation = (p.strip() for p in hp.partition("/")) unites = _card_field(a, r"Unit[ée]s") option = _card_field(a, r"Option") comp = _card_field(a, r"Comp[ée]tences") info = courses.setdefault(sigle, { "url": a["href"].rstrip("/") + "/", "title": clean_text(name.get_text()), "category": self._discipline(sigle), "hours": hours, "ponderation": ponderation, "credits": (f"{unites} unités" if unites not in ("", "1") else "1 unité") if unites else "", "prerequisites": "" if re.match(r"aucun", prereq, re.I) else prereq, "competences": comp.split() if comp else [], "options": [], }) if option and option not in info["options"]: info["options"].append(option) return courses @staticmethod def _discipline(sigle: str) -> str: """Grande famille de cours d'après le préfixe du sigle ministériel.""" prefix = sigle.split("-", 1)[0] return { "109": "Éducation physique", "201": "Mathématiques", "202": "Chimie", "203": "Physique", "101": "Biologie", "320": "Géographie", "330": "Histoire", "340": "Philosophie", "350": "Psychologie", "360": "Sciences humaines — méthodologie", "383": "Économie", "385": "Science politique", "387": "Sociologie", "401": "Administration", "410": "Techniques administratives", "412": "Bureautique", "420": "Informatique", "504": "Arts", "601": "Français, langue et littérature", "602": "Français, langue seconde", "603": "Anglais, langue et littérature", "604": "Anglais, langue seconde", "607": "Espagnol", "861": "Renforcement", "961": "Cheminement", }.get(prefix, "") # -- 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 = {} # description complète div = _section_content(soup, r"^Description") if div is not None: payload["description"] = clean_text(div.get_text(" ")) # compétences ministérielles complètes (section Infos) -> objectifs div = _section_content(soup, r"^Infos") if div is not None: b = div.find("b", string=re.compile(r"Comp[ée]tences", re.I)) if b is not None: comp = clean_text(b.parent.get_text(" ")) comp = re.sub(r"^Comp[ée]tences\s*", "", comp) objectives = [c.strip() for c in re.split( r"(?=\b\d?[0-9A-Z]{3,4}\s*:)", comp) if c.strip()] if objectives: payload["objectives"] = objectives # option de diffusion (« 60 - Cours plurimédia, devoirs par Internet… ») div = _section_content(soup, r"^Option") if div is not None: label = clean_text(div.get_text(" ")) if label: details["option_label"] = label if re.search(r"anglais", label, re.I): payload["language"] = "en" # particularités et matériel obligatoire div = _section_content(soup, r"Particularit[ée]s") if div is not None: txt = clean_text(div.get_text(" ")) if txt: details["particularites"] = txt # droits de scolarité et prix du matériel (par régime d'études) div = _section_content(soup, r"Droits de scolarit[ée]") if div is not None: frais: dict[str, dict] = {} for p in div.find_all("p"): b = p.find("b") if b is None: continue regime = clean_text(b.get_text()) txt = p.get_text(" ", strip=True) entry: dict = {} for label, key in _FEE_LABELS: m = re.search(label + r"\s*:\s*([\d\s  ,.]+\$)", txt) if m: val = parse_price(m.group(1)) if val is not None: entry[key] = val if entry: frais[regime] = entry if frais: details["frais"] = frais regulier = frais.get("Cours régulier") or next(iter(frais.values())) total = regulier.get("total") if total is not None: payload["price"] = total payload["price_label"] = (f"{total:.2f} $ " "(droits + matériel, cours régulier)" ).replace(".", ",") if details: payload["details"] = details return payload