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/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