# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/teluq.py : connecteur Université TÉLUQ (teluq.ca) # Université publique entièrement à distance — ~540 cours en ligne, tous # crédités (1er, 2e et 3e cycles), inscription à la carte ou par programme. # Site rendu serveur, très propre : # - liste : /etudes/cours — un
  • par cours avec sigle # (.listing-offre__code), titre (.listing-offre__titre a) et étiquettes # (département, discipline, cycle, crédits) # - fiche : sections « En bref » (h3 Objectifs / Contenu / Évaluation / # Particularités d'inscription…), encadré « Préalables » (.messagebox), # bloc .info-offre (crédits, cycle, département) et section # « Responsable » (professeur). Fiches en cache, rafraîchies chaque # semaine (clé ISO « AAAA-WSS »). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text from .base import BaseConnector BASE = "https://www.teluq.ca" LIST_URL = f"{BASE}/etudes/cours" _CYCLE_RE = re.compile(r"\bcycle\b", re.I) _CREDITS_RE = re.compile(r"cr[ée]dits?", re.I) def _clean_cycle(txt: str) -> str: """« 1 er cycle » (espace introduit par le ) -> « 1er cycle ».""" return re.sub(r"(\d)\s+(er|e)\b", r"\1\2", txt) def _section_texts(h3) -> tuple[list[str], list[str]]: """Contenu (paragraphes, items de liste) qui suit un

    de la fiche, jusqu'au prochain titre de section. Retourne (paragraphes, items).""" paras: list[str] = [] items: list[str] = [] for sib in h3.find_next_siblings(): if sib.name in ("h1", "h2", "h3"): break if sib.name in ("ul", "ol"): items += [clean_text(li.get_text(" ")) for li in sib.find_all("li")] elif sib.name == "p": paras.append(clean_text(sib.get_text(" "))) return [p for p in paras if p], [i for i in items if i] class TeluqConnector(BaseConnector): source_id = "teluq" request_delay = 0.5 def fetch(self) -> list[Formation]: html = self.fetch_html(LIST_URL) soup = BeautifulSoup(html, "html.parser") # 1) Liste complète : un
  • par cours (sigle + titre + étiquettes) week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] seen: set[str] = set() for p_titre in soup.find_all("p", class_="listing-offre__titre"): a = p_titre.find("a", href=True) if a is None: continue url = a["href"] if a["href"].startswith("http") else BASE + a["href"] card = p_titre.find_parent("li") code_el = card.find("p", class_="listing-offre__code") if card else None code = clean_text(code_el.get_text(" ")) if code_el else "" external_id = code or url.rstrip("/").rsplit("/", 1)[-1] if external_id in seen: continue seen.add(external_id) f = Formation( source=self.source_id, external_id=external_id, url=url, title=clean_text(a.get_text(" ")), training_type="Cours universitaire", mode="en ligne", language="fr", code=code, ) # étiquettes de la carte : département, discipline, cycle, crédits for tag in (card.select("ul.tags li.tags__item") if card else []): txt = clean_text(tag.get_text(" ")) if not txt: continue if _CREDITS_RE.search(txt): f.credits = txt elif _CYCLE_RE.search(txt): f.details["cycle"] = _clean_cycle(txt) elif any(c.startswith("tags--") for c in tag.get("class", [])): f.details["departement"] = txt else: f.category = txt # 2) Fiche détaillée — cache hebdomadaire key = f"{week}:{f.title}" payload = self.detail(external_id, key, lambda u=url: self._fetch_detail(u)) 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 # -- 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 = {} # bloc .info-offre : crédits, cycle, département for info in soup.select(".info-offre .info"): titre = info.find("p", class_="info-titre") valeur = info.find("p", class_="info-valeur") if titre is None or valeur is None: continue t = clean_text(titre.get_text(" ")).lower() v = clean_text(valeur.get_text(" ")) if "crédit" in t and v: if v.isdigit(): payload["credits"] = f"{v} crédit" + ("s" if int(v) > 1 else "") else: payload["credits"] = v elif "cycle" in t: details["cycle"] = _clean_cycle(v) elif "département" in t: details["departement"] = v # sections « En bref » : Objectifs, Contenu, Évaluation… for h3 in soup.find_all("h3"): heading = clean_text(h3.get_text(" ")).lower() paras, items = _section_texts(h3) if heading.startswith("objectifs"): payload["objectives"] = items or paras elif heading.startswith("contenu"): if items: payload["program"] = items # certains cours décrivent le contenu en liste plutôt qu'en # paragraphes — on garde quand même une description lisible payload["description"] = ("\n\n".join(paras) or "\n".join(f"– {i}" for i in items)) elif heading.startswith("évaluation"): if paras: details["evaluation"] = " ".join(paras) elif heading.startswith("particularités"): if paras: details["particularites_inscription"] = " ".join(paras) # encadré « Préalables » for box in soup.select(".messagebox"): title = box.find(["h2", "h3"]) if title and re.search(r"pr[ée]alable", title.get_text(), re.I): txt = clean_text(" ".join( p.get_text(" ") for p in box.find_all("p"))) if txt: payload["prerequisites"] = txt # section « Responsable » : professeur(s) responsable(s) du cours resp = soup.find("h2", string=re.compile(r"^\s*Responsables?\s*$", re.I)) if resp: names = [] for h3 in resp.find_all_next("h3", limit=4): name = clean_text(h3.get_text(" ")) # on s'arrête dès qu'on retombe sur un titre de section if not name or re.search(r"inscrire|programme|commencer", name, re.I): break names.append(name) if names: payload["instructor"] = ", ".join(dict.fromkeys(names)) if details: payload["details"] = details return payload