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/cegep_a_distance.py : connecteur Cégep à distance5# Guide de choix de cours du Cégep à distance (cegepadistance.ca) — ~1506# cours collégiaux offerts à distance (formation générale et spécifique).7# Site WordPress rendu serveur (guidedechoixdecours.cegepadistance.ca) :8# - liste : /cours/ — TOUTES les cartes sur une seule page (préalables,9# heures/pondération, unités, compétences, option), regroupées par sigle10# (un même cours existe en plusieurs « options » : Internet, papier…)11# - fiche : sections titrées (Description, Infos avec compétences complètes,12# Option, Particularités et matériel, Droits de scolarité et prix du13# matériel). Fiches en cache, rafraîchies chaque semaine (clé ISO).14# Prix affiché = droits de scolarité + matériel (cours régulier).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import datetime19import re2021from bs4 import BeautifulSoup2223from ..schema import Formation, clean_text, parse_price24from .base import BaseConnector2526BASE = "https://guidedechoixdecours.cegepadistance.ca"27LIST_URL = f"{BASE}/cours/"2829# libellés des régimes de frais -> clés du dict details["frais"]30_FEE_LABELS = (("Droits de scolarité", "droits_scolarite"),31 ("Matériel", "materiel"),32 ("Total", "total"))333435def _card_field(card, label_rx: str) -> str:36 """Valeur d'un champ « <strong>Label :</strong> valeur » d'une carte."""37 strong = card.find("strong", string=re.compile(label_rx, re.I))38 if strong is None:39 return ""40 parent = strong.parent41 txt = clean_text(parent.get_text(" "))42 return clean_text(re.sub(r"^[^:]*:?", "", txt, count=1))434445def _section_content(soup: BeautifulSoup, heading_rx: str):46 """Bloc « content » qui suit un titre de section (h2) de la fiche."""47 h = soup.find(lambda t: t.name == "h2"48 and re.search(heading_rx, t.get_text(" ", strip=True), re.I))49 if h is None:50 return None51 return h.find_next(lambda t: t.name == "div"52 and "content" in (t.get("class") or []))535455class CegepADistanceConnector(BaseConnector):56 source_id = "cegep_a_distance"57 request_delay = 0.55859 def fetch(self) -> list[Formation]:60 html = self.fetch_html(LIST_URL)61 courses = self._parse_listing(html)6263 # fiche détaillée par sigle — cache hebdomadaire64 week = datetime.date.today().strftime("%G-W%V")65 out: list[Formation] = []66 for sigle, info in courses.items():67 key = f"{week}:{'|'.join(info['options'])}:{info['hours']}"68 payload = self.detail(sigle, key,69 lambda u=info["url"]: self._fetch_detail(u))70 # cours listés à titre indicatif mais suivis dans un autre cégep71 if re.search(r"n[’']est pas offert au C[ée]gep à distance",72 payload.get("description", "")):73 continue74 f = Formation(75 source=self.source_id,76 external_id=sigle,77 url=info["url"],78 title=info["title"],79 training_type="Cours collégial",80 category=info["category"],81 mode="en ligne",82 language="fr",83 code=sigle,84 duration=info["hours"],85 credits=info["credits"],86 prerequisites=info["prerequisites"],87 details={k: v for k, v in (("options", info["options"]),88 ("ponderation", info["ponderation"]),89 ("competences", info["competences"]))90 if v},91 )92 for k, v in (payload or {}).items():93 if k == "details":94 f.details = {**f.details, **v}95 elif hasattr(f, k) and v not in (None, "", []):96 setattr(f, k, v)97 out.append(f)98 return out99100 # -- liste ------------------------------------------------------------------101 def _parse_listing(self, html: str) -> dict[str, dict]:102 """Cartes de la liste regroupées par sigle (fusion des options)."""103 soup = BeautifulSoup(html, "html.parser")104 courses: dict[str, dict] = {}105 for a in soup.select("div.course-details a.gutter[href]"):106 name = a.find("span", class_="name")107 code = a.find("span", class_="code")108 if name is None or code is None:109 continue110 sigle = clean_text(code.get_text())111 prereq = _card_field(a, r"Pr[ée]alables")112 hp = _card_field(a, r"Heures\s*/\s*Pond[ée]ration")113 hours, _, ponderation = (p.strip() for p in hp.partition("/"))114 unites = _card_field(a, r"Unit[ée]s")115 option = _card_field(a, r"Option")116 comp = _card_field(a, r"Comp[ée]tences")117 info = courses.setdefault(sigle, {118 "url": a["href"].rstrip("/") + "/",119 "title": clean_text(name.get_text()),120 "category": self._discipline(sigle),121 "hours": hours,122 "ponderation": ponderation,123 "credits": (f"{unites} unités" if unites not in ("", "1")124 else "1 unité") if unites else "",125 "prerequisites": "" if re.match(r"aucun", prereq, re.I) else prereq,126 "competences": comp.split() if comp else [],127 "options": [],128 })129 if option and option not in info["options"]:130 info["options"].append(option)131 return courses132133 @staticmethod134 def _discipline(sigle: str) -> str:135 """Grande famille de cours d'après le préfixe du sigle ministériel."""136 prefix = sigle.split("-", 1)[0]137 return {138 "109": "Éducation physique", "201": "Mathématiques",139 "202": "Chimie", "203": "Physique", "101": "Biologie",140 "320": "Géographie", "330": "Histoire", "340": "Philosophie",141 "350": "Psychologie", "360": "Sciences humaines — méthodologie",142 "383": "Économie", "385": "Science politique", "387": "Sociologie",143 "401": "Administration", "410": "Techniques administratives",144 "412": "Bureautique", "420": "Informatique",145 "504": "Arts", "601": "Français, langue et littérature",146 "602": "Français, langue seconde", "603": "Anglais, langue et littérature",147 "604": "Anglais, langue seconde", "607": "Espagnol",148 "861": "Renforcement", "961": "Cheminement",149 }.get(prefix, "")150151 # -- fiche --------------------------------------------------------------------152 def _fetch_detail(self, url: str) -> dict:153 if not url:154 return {}155 html = self.fetch_html(url)156 soup = BeautifulSoup(html, "html.parser")157 payload: dict = {}158 details: dict = {}159160 # description complète161 div = _section_content(soup, r"^Description")162 if div is not None:163 payload["description"] = clean_text(div.get_text(" "))164165 # compétences ministérielles complètes (section Infos) -> objectifs166 div = _section_content(soup, r"^Infos")167 if div is not None:168 b = div.find("b", string=re.compile(r"Comp[ée]tences", re.I))169 if b is not None:170 comp = clean_text(b.parent.get_text(" "))171 comp = re.sub(r"^Comp[ée]tences\s*", "", comp)172 objectives = [c.strip() for c in re.split(173 r"(?=\b\d?[0-9A-Z]{3,4}\s*:)", comp) if c.strip()]174 if objectives:175 payload["objectives"] = objectives176177 # option de diffusion (« 60 - Cours plurimédia, devoirs par Internet… »)178 div = _section_content(soup, r"^Option")179 if div is not None:180 label = clean_text(div.get_text(" "))181 if label:182 details["option_label"] = label183 if re.search(r"anglais", label, re.I):184 payload["language"] = "en"185186 # particularités et matériel obligatoire187 div = _section_content(soup, r"Particularit[ée]s")188 if div is not None:189 txt = clean_text(div.get_text(" "))190 if txt:191 details["particularites"] = txt192193 # droits de scolarité et prix du matériel (par régime d'études)194 div = _section_content(soup, r"Droits de scolarit[ée]")195 if div is not None:196 frais: dict[str, dict] = {}197 for p in div.find_all("p"):198 b = p.find("b")199 if b is None:200 continue201 regime = clean_text(b.get_text())202 txt = p.get_text(" ", strip=True)203 entry: dict = {}204 for label, key in _FEE_LABELS:205 m = re.search(label + r"\s*:\s*([\d\s ,.]+\$)", txt)206 if m:207 val = parse_price(m.group(1))208 if val is not None:209 entry[key] = val210 if entry:211 frais[regime] = entry212 if frais:213 details["frais"] = frais214 regulier = frais.get("Cours régulier") or next(iter(frais.values()))215 total = regulier.get("total")216 if total is not None:217 payload["price"] = total218 payload["price_label"] = (f"{total:.2f} $ "219 "(droits + matériel, cours régulier)"220 ).replace(".", ",")221222 if details:223 payload["details"] = details224 return payload225