# ----------------------------------------------------------------------------- # 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']*type="application/ld(?:\+|+)json"[^>]*>(.*?)', 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