# -----------------------------------------------------------------------------
# Forma-Ka — Agrégateur de formations (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/technologia.py : connecteur Technologia (technologia.com)
# Grande firme de formation professionnelle (Montréal/Québec) — ~600
# formations en TI, IA, gestion de projets, leadership, manufacturier…
# Site Umbraco + Vue, mais très bien outillé côté données :
# - liste : API JSON interne /Api/Catalog/Browse (code, titre, thématique,
# sous-thématique, durée en jours ou en heures, prix régulier et
# préférentiel, formats ClasseVirtuelle/ELearning/EnClasse)
# - fiche : JSON complet embarqué (', re.S)
# les scripts JSON-LD ont un attribut type HTML-encodé (« ld+json »)
_LDJSON_RE = re.compile(
r'',
re.S | re.I)
# formats Technologia -> mode canonique Forma-Ka
_FORMAT_MAP = {
"classevirtuelle": "en ligne",
"elearning": "asynchrone",
"enclasse": "présentiel",
}
def _strip_html(fragment: str) -> str:
"""Texte propre d'un fragment HTML (paragraphes séparés)."""
if not fragment:
return ""
soup = BeautifulSoup(fragment, "html.parser")
parts = [clean_text(p.get_text(" ")) for p in soup.find_all(["p", "li"])]
parts = [p for p in parts if p]
return "\n".join(dict.fromkeys(parts)) or clean_text(soup.get_text(" "))
def _item_mode(formats: list[str] | None) -> tuple[str, list[str]]:
"""(mode canonique, tous les modes offerts) depuis les formats de l'API."""
modes = [_FORMAT_MAP[f.lower()] for f in (formats or [])
if f.lower() in _FORMAT_MAP]
modes = list(dict.fromkeys(modes))
if not modes:
return "", []
return (modes[0] if len(modes) == 1 else "hybride"), modes
class TechnologiaConnector(BaseConnector):
source_id = "technologia"
request_delay = 0.5
limit: int | None = None # borne optionnelle (tests/débogage)
def fetch(self) -> list[Formation]:
# 1) Catalogue complet via l'API JSON interne
resp = self.get(API_BROWSE,
params={"Count": 5000, "Page": 1, "Culture": "fr-CA"})
items = resp.json().get("Items") or []
if self.limit:
items = items[: self.limit]
# 2) Fiche détaillée par formation — cache hebdomadaire
week = datetime.date.today().strftime("%G-W%V")
out: list[Formation] = []
for item in items:
code = item.get("Code") or item.get("ID") or ""
url = BASE + (item.get("Uri") or "")
key = f"{week}:{item.get('Title', '')}:{item.get('RegularPrice')}"
payload = self.detail(code, key, lambda u=url: self._fetch_detail(u))
days = item.get("Duration")
hours = item.get("DurationInHours")
duration = (f"{hours:g} h" if hours
else f"{days:g} jour{'s' if days and days > 1 else ''}"
if days else "")
price = item.get("RegularPrice")
tags = [t for t in (item.get("SubThematic"),) if t]
if "NouveauCours" in (item.get("Tags") or []):
tags.append("Nouveau")
f = Formation(
source=self.source_id,
external_id=str(code),
url=url,
title=clean_text(item.get("Title", "")),
training_type="Formation continue",
category=item.get("Thematic", ""),
language="fr",
code=item.get("Code", ""),
duration=duration,
duration_hours=float(hours) if hours else None,
price=float(price) if price is not None else None,
price_label=f"{price:g} $ + tx" if price is not None else "",
tags=tags,
)
mode, modes = _item_mode(item.get("Formats"))
if mode:
f.mode = mode
f.details["modes_offerts"] = modes
if item.get("IsOnDemand"):
f.mode = f.mode or "asynchrone"
pref = item.get("PreferentialPrice")
if pref is not None:
f.details["prix_preferentiel"] = pref
for k, v in (payload or {}).items():
if k == "details":
f.details.update(v or {})
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)
payload: dict = {}
details: dict = {}
m = _TRAINING_DATA_RE.search(html)
if m:
try:
data = json.loads(m.group(1))
except ValueError:
data = {}
# description : sommaire + corps de la fiche
desc = [clean_text(data.get("summary", ""))]
body = BeautifulSoup(data.get("content") or "", "html.parser")
desc += [clean_text(p.get_text(" ")) for p in body.find_all("p")]
payload["description"] = "\n\n".join(
dict.fromkeys(d for d in desc if d and d != "\xa0"))
# objectifs : objectif général + « Ce que vous saurez faire »
objectives = []
if data.get("goal"):
objectives.append(clean_text(data["goal"]))
arch = BeautifulSoup(data.get("courseArchitecture") or "",
"html.parser")
objectives += [clean_text(p.get_text(" "))
for p in arch.find_all(["p", "li"])]
payload["objectives"] = [o for o in dict.fromkeys(objectives)
if o and o != "\xa0"]
if data.get("prerequisite"):
payload["prerequisites"] = _strip_html(str(data["prerequisite"]))
if data.get("targetCustomer"):
payload["audience"] = clean_text(str(data["targetCustomer"]))
if data.get("subTitle"):
details["sous_titre"] = clean_text(data["subTitle"])
if data.get("teachingMethod"):
details["methode_pedagogique"] = clean_text(data["teachingMethod"])
if data.get("satisfaction"):
details["satisfaction_pct"] = data["satisfaction"]
if data.get("pduCount"):
details["pdu"] = data["pduCount"]
# plan de cours : section « Contenu de la formation » du syllabus
syllabus = BeautifulSoup(data.get("syllabusHtml") or "",
"html.parser")
program, in_content = [], False
for h in syllabus.find_all(["h2", "h3"]):
txt = clean_text(h.get_text(" "))
if h.name == "h2":
in_content = bool(re.search(r"contenu", txt, re.I))
elif in_content and txt:
program.append(re.sub(r"^\d+\s*", "", txt))
if not program: # repli : puces du corps de la fiche
program = [clean_text(li.get_text(" "))
for li in body.find_all("li")]
payload["program"] = [p for p in dict.fromkeys(program) if p]
teachers = [t.get("name") for t in data.get("teachers") or []
if isinstance(t, dict) and t.get("name")]
if teachers:
payload["instructor"] = ", ".join(dict.fromkeys(teachers))
images = [t.get("image") for t in data.get("teachers") or []
if isinstance(t, dict) and t.get("image")]
if images:
payload["images"] = list(dict.fromkeys(images))
# séances datées + villes via les EducationEvent JSON-LD
sessions, cities = [], []
for mm in _LDJSON_RE.finditer(html):
try:
obj = json.loads(mm.group(1))
except ValueError:
continue
events = obj if isinstance(obj, list) else [obj]
for ev in events:
if not isinstance(ev, dict) or ev.get("@type") != "EducationEvent":
continue
start = str(ev.get("startDate", ""))[:10]
if re.match(r"20\d{2}-\d{2}-\d{2}", start):
sessions.append(start)
loc = ev.get("location") or {}
addr = loc.get("address") if isinstance(loc, dict) else {}
city = (addr.get("addressLocality", "")
if isinstance(addr, dict) else "")
if city and city.lower() != "virtuelle":
cities.append(city)
sessions = sorted(set(sessions))
if sessions:
payload["sessions"] = sessions
payload["start_date"] = sessions[0]
if cities:
payload["city"] = cities[0]
if details:
payload["details"] = details
return payload