SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
7.8 KB · 188 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/teluq.py : connecteur Université TÉLUQ (teluq.ca)5#   Université publique entièrement à distance — ~540 cours en ligne, tous6#   crédités (1er, 2e et 3e cycles), inscription à la carte ou par programme.7#   Site rendu serveur, très propre :8#   - liste : /etudes/cours — un <li> par cours avec sigle9#     (.listing-offre__code), titre (.listing-offre__titre a) et étiquettes10#     (département, discipline, cycle, crédits)11#   - fiche : sections « En bref » (h3 Objectifs / Contenu / Évaluation /12#     Particularités d'inscription…), encadré « Préalables » (.messagebox),13#     bloc .info-offre (crédits, cycle, département) et section14#     « Responsable » (professeur). Fiches en cache, rafraîchies chaque15#     semaine (clé ISO « AAAA-WSS »).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import datetime20import re2122from bs4 import BeautifulSoup2324from ..schema import Formation, clean_text25from .base import BaseConnector2627BASE = "https://www.teluq.ca"28LIST_URL = f"{BASE}/etudes/cours"2930_CYCLE_RE = re.compile(r"\bcycle\b", re.I)31_CREDITS_RE = re.compile(r"cr[ée]dits?", re.I)323334def _clean_cycle(txt: str) -> str:35    """« 1 er cycle » (espace introduit par le <sup>) -> « 1er cycle »."""36    return re.sub(r"(\d)\s+(er|e)\b", r"\1\2", txt)373839def _section_texts(h3) -> tuple[list[str], list[str]]:40    """Contenu (paragraphes, items de liste) qui suit un <h3> de la fiche,41    jusqu'au prochain titre de section. Retourne (paragraphes, items)."""42    paras: list[str] = []43    items: list[str] = []44    for sib in h3.find_next_siblings():45        if sib.name in ("h1", "h2", "h3"):46            break47        if sib.name in ("ul", "ol"):48            items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")]49        elif sib.name == "p":50            paras.append(clean_text(sib.get_text(" ")))51    return [p for p in paras if p], [i for i in items if i]525354class TeluqConnector(BaseConnector):55    source_id = "teluq"56    request_delay = 0.55758    def fetch(self) -> list[Formation]:59        html = self.fetch_html(LIST_URL)60        soup = BeautifulSoup(html, "html.parser")6162        # 1) Liste complète : un <li> par cours (sigle + titre + étiquettes)63        week = datetime.date.today().strftime("%G-W%V")64        out: list[Formation] = []65        seen: set[str] = set()66        for p_titre in soup.find_all("p", class_="listing-offre__titre"):67            a = p_titre.find("a", href=True)68            if a is None:69                continue70            url = a["href"] if a["href"].startswith("http") else BASE + a["href"]71            card = p_titre.find_parent("li")72            code_el = card.find("p", class_="listing-offre__code") if card else None73            code = clean_text(code_el.get_text(" ")) if code_el else ""74            external_id = code or url.rstrip("/").rsplit("/", 1)[-1]75            if external_id in seen:76                continue77            seen.add(external_id)7879            f = Formation(80                source=self.source_id,81                external_id=external_id,82                url=url,83                title=clean_text(a.get_text(" ")),84                training_type="Cours universitaire",85                mode="en ligne",86                language="fr",87                code=code,88            )8990            # étiquettes de la carte : département, discipline, cycle, crédits91            for tag in (card.select("ul.tags li.tags__item") if card else []):92                txt = clean_text(tag.get_text(" "))93                if not txt:94                    continue95                if _CREDITS_RE.search(txt):96                    f.credits = txt97                elif _CYCLE_RE.search(txt):98                    f.details["cycle"] = _clean_cycle(txt)99                elif any(c.startswith("tags--") for c in tag.get("class", [])):100                    f.details["departement"] = txt101                else:102                    f.category = txt103104            # 2) Fiche détaillée — cache hebdomadaire105            key = f"{week}:{f.title}"106            payload = self.detail(external_id, key,107                                  lambda u=url: self._fetch_detail(u))108            for k, v in (payload or {}).items():109                if k == "details":110                    f.details = {**f.details, **v}111                elif hasattr(f, k) and v not in (None, "", []):112                    setattr(f, k, v)113            out.append(f)114        return out115116    # -- fiche ----------------------------------------------------------------117    def _fetch_detail(self, url: str) -> dict:118        if not url:119            return {}120        html = self.fetch_html(url)121        soup = BeautifulSoup(html, "html.parser")122        payload: dict = {}123        details: dict = {}124125        # bloc .info-offre : crédits, cycle, département126        for info in soup.select(".info-offre .info"):127            titre = info.find("p", class_="info-titre")128            valeur = info.find("p", class_="info-valeur")129            if titre is None or valeur is None:130                continue131            t = clean_text(titre.get_text(" ")).lower()132            v = clean_text(valeur.get_text(" "))133            if "crédit" in t and v:134                if v.isdigit():135                    payload["credits"] = f"{v} crédit" + ("s" if int(v) > 1 else "")136                else:137                    payload["credits"] = v138            elif "cycle" in t:139                details["cycle"] = _clean_cycle(v)140            elif "département" in t:141                details["departement"] = v142143        # sections « En bref » : Objectifs, Contenu, Évaluation…144        for h3 in soup.find_all("h3"):145            heading = clean_text(h3.get_text(" ")).lower()146            paras, items = _section_texts(h3)147            if heading.startswith("objectifs"):148                payload["objectives"] = items or paras149            elif heading.startswith("contenu"):150                if items:151                    payload["program"] = items152                # certains cours décrivent le contenu en liste plutôt qu'en153                # paragraphes — on garde quand même une description lisible154                payload["description"] = ("\n\n".join(paras) or155                                          "\n".join(f"– {i}" for i in items))156            elif heading.startswith("évaluation"):157                if paras:158                    details["evaluation"] = " ".join(paras)159            elif heading.startswith("particularités"):160                if paras:161                    details["particularites_inscription"] = " ".join(paras)162163        # encadré « Préalables »164        for box in soup.select(".messagebox"):165            title = box.find(["h2", "h3"])166            if title and re.search(r"pr[ée]alable", title.get_text(), re.I):167                txt = clean_text(" ".join(168                    p.get_text(" ") for p in box.find_all("p")))169                if txt:170                    payload["prerequisites"] = txt171172        # section « Responsable » : professeur(s) responsable(s) du cours173        resp = soup.find("h2", string=re.compile(r"^\s*Responsables?\s*$", re.I))174        if resp:175            names = []176            for h3 in resp.find_all_next("h3", limit=4):177                name = clean_text(h3.get_text(" "))178                # on s'arrête dès qu'on retombe sur un titre de section179                if not name or re.search(r"inscrire|programme|commencer", name, re.I):180                    break181                names.append(name)182            if names:183                payload["instructor"] = ", ".join(dict.fromkeys(names))184185        if details:186            payload["details"] = details187        return payload188