spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
1# -----------------------------------------------------------------------------2# Forma-Ka — Agrégateur de formations (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/versalys.py : connecteur Versalys (versalys.com)5# Firme de formation bureautique, TI, langues et développement professionnel6# (Montréal, Québec, Laval, Brossard + classes virtuelles) — ~260 fiches7# françaises. Site WordPress rendu serveur :8# - liste : sitemap dédié formation-sitemap1.xml (URLs /formation/<slug>/9# avec lastmod — utilisé comme clé de cache : la fiche n'est revisitée10# que si elle a été modifiée chez la source)11# - fiche : sections HTML (Description, Prérequis, Éléments du contenu de12# la formation en <h3>), encadré Durée / Tarif régulier / Tarif13# préférentiel, prochaines dates par ville (blocs event-date-item avec14# JSON encodé), fil d'Ariane BreadcrumbList JSON-LD pour la catégorie.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import datetime19import json20import re21from urllib.parse import unquote_plus2223from bs4 import BeautifulSoup2425from ..schema import Formation, clean_text, parse_price26from .base import BaseConnector, ldjson_objects2728BASE = "https://www.versalys.com"29SITEMAP_URL = f"{BASE}/formation-sitemap1.xml"30LIST_URL = f"{BASE}/formations/"3132_URL_RE = re.compile(r"<url>\s*<loc>(https://www\.versalys\.com/formation/"33 r"([^<]+?)/?)</loc>(?:\s*<lastmod>([^<]+)</lastmod>)?")34# sigle du cours à la fin du slug (« …-wo-039 » -> « WO-039 »)35_CODE_RE = re.compile(r"-([a-z]{2})-?(\d{3})$")36_SIDEBAR_RE = re.compile(37 r"<strong>\s*(Durée|Tarif régulier|Tarif préférentiel)\s*:?\s*</strong>"38 r"\s*:?\s*([^<]*)", re.I)394041def _section_text(soup: BeautifulSoup, heading_rx: str) -> str:42 """Paragraphes qui suivent un <h2> donné, jusqu'au <h2> suivant."""43 h = soup.find("h2", string=re.compile(heading_rx, re.I))44 if h is None:45 return ""46 parts: list[str] = []47 for sib in h.find_all_next(["p", "li", "h2"]):48 if sib.name == "h2":49 break50 txt = clean_text(sib.get_text(" "))51 if txt:52 parts.append(txt)53 return "\n".join(dict.fromkeys(parts))545556class VersalysConnector(BaseConnector):57 source_id = "versalys"58 request_delay = 0.559 limit: int | None = None # borne optionnelle (tests/débogage)6061 def fetch(self) -> list[Formation]:62 # 1) Fiches françaises du sitemap dédié (avec date de modification)63 xml = self.get(SITEMAP_URL).text64 pages = [(url, slug.strip("/"), lastmod)65 for url, slug, lastmod in _URL_RE.findall(xml)]66 if self.limit:67 pages = pages[: self.limit]6869 # 2) Fiche par formation — cache invalidé par lastmod du sitemap70 week = datetime.date.today().strftime("%G-W%V")71 out: list[Formation] = []72 for url, slug, lastmod in pages:73 payload = self.detail(slug, lastmod or week,74 lambda u=url: self._fetch_detail(u))75 m = _CODE_RE.search(slug)76 f = Formation(77 source=self.source_id,78 external_id=slug,79 url=url,80 training_type="Formation continue",81 language="fr",82 code=f"{m.group(1).upper()}-{m.group(2)}" if m else "",83 )84 for k, v in (payload or {}).items():85 if hasattr(f, k) and v not in (None, "", []):86 setattr(f, k, v)87 out.append(f)88 return out8990 # -- fiche ----------------------------------------------------------------91 def _fetch_detail(self, url: str) -> dict:92 html = self.fetch_html(url)93 soup = BeautifulSoup(html, "html.parser")94 payload: dict = {}95 details: dict = {}9697 h1 = soup.find("h1")98 if h1:99 payload["title"] = clean_text(h1.get_text(" "))100101 # sections rédactionnelles de la fiche102 desc = _section_text(soup, r"^\s*description\s*$")103 if desc:104 payload["description"] = desc105 prereq = _section_text(soup, r"pr[ée]requis")106 if prereq:107 payload["prerequisites"] = prereq108109 # plan de cours : <h3> entre « Éléments du contenu » et l'encadré dates110 h = soup.find("h2", string=re.compile(r"[ée]l[ée]ments du contenu", re.I))111 if h:112 program = []113 for sib in h.find_all_next("h3"):114 txt = clean_text(sib.get_text(" "))115 if not txt or re.search(r"PROCHAINE", txt):116 break117 program.append(txt.rstrip(" :;"))118 if program:119 payload["program"] = program120121 # encadré : durée + tarifs122 for label, value in _SIDEBAR_RE.findall(html):123 value = clean_text(value)124 if not value:125 continue126 key = label.lower()127 if "durée" in key:128 payload["duration"] = value129 elif "régulier" in key:130 payload["price"] = parse_price(value)131 payload["price_label"] = value132 elif "préférentiel" in key:133 details["prix_preferentiel"] = parse_price(value)134135 # prochaines dates par ville (JSON encodé dans des champs cachés)136 sessions, cities = [], []137 for item in soup.find_all("div", class_="event-date-item"):138 inp = item.find("input", attrs={"name": "event_date_time[]"})139 try:140 slots = json.loads(unquote_plus(inp["value"])) if inp else {}141 except (ValueError, KeyError):142 slots = {}143 # le JSON est tantôt un objet {id: slot}, tantôt un tableau [slot]144 slot_list = (slots.values() if isinstance(slots, dict)145 else slots if isinstance(slots, list) else [])146 for slot in slot_list:147 if not isinstance(slot, dict):148 continue149 start = str(slot.get("startDate", ""))[:10]150 if re.match(r"20\d{2}-\d{2}-\d{2}", start):151 sessions.append(start)152 h3 = item.find("h3")153 if h3:154 city = clean_text(h3.get_text(" "))155 if city:156 cities.append(city)157 sessions = sorted(set(sessions))158 if sessions:159 payload["sessions"] = sessions160 payload["start_date"] = sessions[0]161 cities = list(dict.fromkeys(cities))162 modes = ["en ligne" if "virtuelle" in c.lower() else "présentiel"163 for c in cities]164 modes = list(dict.fromkeys(modes))165 real_cities = [c for c in cities if "virtuelle" not in c.lower()]166 if real_cities:167 payload["city"] = real_cities[0]168169 # fil d'Ariane JSON-LD : catégorie + mode (eLearning, classe virtuelle)170 for obj in ldjson_objects(html):171 if obj.get("@type") == "BreadcrumbList":172 names = [clean_text(it.get("name", ""))173 for it in obj.get("itemListElement", [])174 if isinstance(it, dict)]175 names = [n for n in names[1:] if n] # sans « Accueil »176 if names:177 names.pop() # le dernier item = la page courante178 if names:179 payload["category"] = names[-1]180 if len(names) > 1:181 payload["tags"] = names[:-1]182 crumb = " ".join(names).lower()183 if "conférence" in crumb:184 payload["training_type"] = "Conférence"185 elif "atelier" in crumb:186 payload["training_type"] = "Atelier"187 if "elearning" in crumb or "e-learning" in crumb:188 modes = ["asynchrone"]189 elif not modes and "virtuelle" in crumb:190 modes = ["en ligne"]191 elif obj.get("@type") == "Course":192 if (len(payload.get("description", "")) < 30193 and obj.get("description")):194 payload["description"] = clean_text(obj["description"])195 if obj.get("inLanguage", "").lower().startswith("en"):196 payload["language"] = "en"197 if modes:198 payload["mode"] = modes[0] if len(modes) == 1 else "hybride"199 details["modes_offerts"] = modes200201 og = soup.find("meta", property="og:image")202 if og and og.get("content"):203 payload["images"] = [og["content"]]204 if details:205 payload["details"] = details206 return payload207