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