# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/afi.py : connecteur AFI par Edgenda (afiexpertise.com) # Firme de formation TI, bureautique et leadership (Québec, Montréal, # classe virtuelle) — ~470 formations au calendrier public. # Site Gatsby : chaque fiche possède un JSON statique complet à # /page-data/fr/formation//page-data.json — nom, description HTML, # plan de cours en tableau (public concerné, certification, prérequis, # objectifs, méthode pédagogique, contenu par modules), sessions datées # avec villes et horaires, prix PAR JOUR (le prix affiché sur le site est # RegularPrice × nb de jours, « Sur demande » quand aucune session), # thématique, éditeur, formateurs. Liste découverte via sitemap-0.xml. # Fiches en cache, rafraîchies chaque semaine (clé « 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.afiexpertise.com" SITEMAP_URL = f"{BASE}/sitemap-0.xml" _COURSE_LOC_RE = re.compile( r"https://www\.afiexpertise\.com(/fr/formation/([^<]+))") def _cell_text(td) -> str: return clean_text(td.get_text(" ")) if td is not None else "" def _cell_items(td) -> list[str]: items = [clean_text(li.get_text(" ")) for li in td.find_all("li")] return [i for i in dict.fromkeys(items) if i] class AfiConnector(BaseConnector): source_id = "afi" request_delay = 0.5 limit: int | None = None # borne optionnelle (tests/débogage) def fetch(self) -> list[Formation]: # 1) Liste des fiches françaises depuis le sitemap xml = self.get(SITEMAP_URL).text slugs = list(dict.fromkeys(m.group(2).strip("/") for m in _COURSE_LOC_RE.finditer(xml))) if self.limit: slugs = slugs[: self.limit] # 2) JSON statique Gatsby par formation — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for slug in slugs: payload = self.detail(slug, week, lambda s=slug: self._fetch_detail(s)) if not payload: continue f = Formation( source=self.source_id, external_id=slug, url=f"{BASE}/fr/formation/{slug}", training_type="Formation continue", language="fr", ) for k, v in payload.items(): if hasattr(f, k) and v not in (None, "", []): setattr(f, k, v) out.append(f) return out # -- fiche ---------------------------------------------------------------- def _fetch_detail(self, slug: str) -> dict: url = f"{BASE}/page-data/fr/formation/{slug}/page-data.json" try: data = self.get(url).json() except Exception: return {} result = (data.get("result") or {}) course = ((result.get("data") or {}).get("course") or {}) if not course: return {} payload: dict = {} details: dict = {} payload["title"] = clean_text( (course.get("Name") or {}).get("fr") or "") desc = BeautifulSoup( (course.get("Description") or {}).get("fr") or "", "html.parser") paragraphs = [clean_text(p.get_text(" ")) for p in desc.find_all(["p", "li"])] payload["description"] = "\n\n".join( dict.fromkeys(p for p in paragraphs if p)) or clean_text( desc.get_text(" ")) if not payload["description"]: payload["description"] = clean_text( (course.get("MetaDescription") or {}).get("fr") or "") # plan de cours : tableau à deux colonnes (libellé -> contenu) plan = BeautifulSoup((course.get("Plan") or {}).get("fr") or "", "html.parser") program: list[str] = [] for tr in plan.find_all("tr"): tds = tr.find_all("td") if len(tds) < 2: continue label = clean_text(tds[0].get_text(" ")).lower() cell = tds[1] if "public" in label: payload["audience"] = _cell_text(cell) elif "prérequis" in label or "prealable" in label: payload["prerequisites"] = _cell_text(cell) elif "objectif" in label: payload["objectives"] = _cell_items(cell) or \ [t for t in (_cell_text(cell),) if t] elif "certification" in label: details["certification"] = _cell_text(cell) elif "méthode" in label: details["methode_pedagogique"] = _cell_text(cell) elif "contenu" in label: # titres de modules (

) quand ils structurent le contenu, # sinon puces (

  • ) ptexts = [clean_text(p.get_text(" ")) for p in cell.find_all("p")] ptexts = [p for p in dict.fromkeys(ptexts) if p] litexts = _cell_items(cell) program = ptexts if len(ptexts) >= 2 else litexts or ptexts if program: payload["program"] = program # durée en jours + prix affiché = prix/jour × jours (min. 1 jour) days = course.get("DurationInDays") if days: payload["duration"] = (f"{days:g} jour" if days <= 1 else f"{days:g} jours") payload["duration_hours"] = days * 7.0 sessions_raw = course.get("Sessions") or [] price = course.get("RegularPrice") if price is not None and sessions_raw: total = price * days if days and days >= 1 else price payload["price"] = float(total) payload["price_label"] = f"{total:g} $ + tx" pref = course.get("PreferentialPrice") if pref is not None: details["prix_preferentiel"] = (pref * days if days and days >= 1 else pref) elif not sessions_raw: payload["price_label"] = "Sur demande" # sessions datées (première journée de chaque cohorte) + villes sessions, cities, langs = [], [], [] for s in sessions_raw: dates = s.get("Dates") or [] if dates: start = str(dates[0])[:10] if re.match(r"20\d{2}-\d{2}-\d{2}", start): sessions.append(start) if s.get("City"): cities.append(s["City"]) if s.get("Language"): langs.append(s["Language"]) sessions = sorted(set(sessions)) if sessions: payload["sessions"] = sessions payload["start_date"] = sessions[0] cities = list(dict.fromkeys(cities)) modes = ["en ligne" if c.lower() == "classe virtuelle" else "présentiel" for c in cities] modes = list(dict.fromkeys(modes)) if not modes and course.get("IsVirtual"): modes = ["en ligne"] if modes: payload["mode"] = modes[0] if len(modes) == 1 else "hybride" details["modes_offerts"] = modes real_cities = [c for c in cities if c.lower() != "classe virtuelle"] if real_cities: payload["city"] = real_cities[0] if langs: payload["language"] = "/".join(dict.fromkeys(langs)) # contexte de page : thématique (catégorie) et éditeur (Microsoft…) ctx = result.get("pageContext") or {} theme = ((ctx.get("theme") or {}).get("name") or {}).get("fr", "") if theme: payload["category"] = theme editor = ((ctx.get("editor") or {}).get("name") or {}).get("fr", "") if editor: payload["tags"] = [editor] teachers = [clean_text(f"{t.get('FirstName', '')} {t.get('LastName', '')}") for t in course.get("Teachers") or []] teachers = [t for t in dict.fromkeys(teachers) if t] if teachers: payload["instructor"] = ", ".join(teachers) if course.get("IsNew"): details["nouveau"] = True if details: payload["details"] = details return payload