SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
9.9 KB · 225 lines python
Raw Blame History
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