# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/versalys.py : connecteur Versalys (versalys.com) # Firme de formation bureautique, TI, langues et développement professionnel # (Montréal, Québec, Laval, Brossard + classes virtuelles) — ~260 fiches # françaises. Site WordPress rendu serveur : # - liste : sitemap dédié formation-sitemap1.xml (URLs /formation// # avec lastmod — utilisé comme clé de cache : la fiche n'est revisitée # que si elle a été modifiée chez la source) # - fiche : sections HTML (Description, Prérequis, Éléments du contenu de # la formation en

), encadré Durée / Tarif régulier / Tarif # préférentiel, prochaines dates par ville (blocs event-date-item avec # JSON encodé), fil d'Ariane BreadcrumbList JSON-LD pour la catégorie. # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import json import re from urllib.parse import unquote_plus from bs4 import BeautifulSoup from ..schema import Formation, clean_text, parse_price from .base import BaseConnector, ldjson_objects BASE = "https://www.versalys.com" SITEMAP_URL = f"{BASE}/formation-sitemap1.xml" LIST_URL = f"{BASE}/formations/" _URL_RE = re.compile(r"\s*(https://www\.versalys\.com/formation/" r"([^<]+?)/?)(?:\s*([^<]+))?") # sigle du cours à la fin du slug (« …-wo-039 » -> « WO-039 ») _CODE_RE = re.compile(r"-([a-z]{2})-?(\d{3})$") _SIDEBAR_RE = re.compile( r"\s*(Durée|Tarif régulier|Tarif préférentiel)\s*:?\s*" r"\s*:?\s*([^<]*)", re.I) def _section_text(soup: BeautifulSoup, heading_rx: str) -> str: """Paragraphes qui suivent un

donné, jusqu'au

suivant.""" h = soup.find("h2", string=re.compile(heading_rx, re.I)) if h is None: return "" parts: list[str] = [] for sib in h.find_all_next(["p", "li", "h2"]): if sib.name == "h2": break txt = clean_text(sib.get_text(" ")) if txt: parts.append(txt) return "\n".join(dict.fromkeys(parts)) class VersalysConnector(BaseConnector): source_id = "versalys" request_delay = 0.5 limit: int | None = None # borne optionnelle (tests/débogage) def fetch(self) -> list[Formation]: # 1) Fiches françaises du sitemap dédié (avec date de modification) xml = self.get(SITEMAP_URL).text pages = [(url, slug.strip("/"), lastmod) for url, slug, lastmod in _URL_RE.findall(xml)] if self.limit: pages = pages[: self.limit] # 2) Fiche par formation — cache invalidé par lastmod du sitemap week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for url, slug, lastmod in pages: payload = self.detail(slug, lastmod or week, lambda u=url: self._fetch_detail(u)) m = _CODE_RE.search(slug) f = Formation( source=self.source_id, external_id=slug, url=url, training_type="Formation continue", language="fr", code=f"{m.group(1).upper()}-{m.group(2)}" if m else "", ) for k, v in (payload or {}).items(): if 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: html = self.fetch_html(url) soup = BeautifulSoup(html, "html.parser") payload: dict = {} details: dict = {} h1 = soup.find("h1") if h1: payload["title"] = clean_text(h1.get_text(" ")) # sections rédactionnelles de la fiche desc = _section_text(soup, r"^\s*description\s*$") if desc: payload["description"] = desc prereq = _section_text(soup, r"pr[ée]requis") if prereq: payload["prerequisites"] = prereq # plan de cours :

entre « Éléments du contenu » et l'encadré dates h = soup.find("h2", string=re.compile(r"[ée]l[ée]ments du contenu", re.I)) if h: program = [] for sib in h.find_all_next("h3"): txt = clean_text(sib.get_text(" ")) if not txt or re.search(r"PROCHAINE", txt): break program.append(txt.rstrip(" :;")) if program: payload["program"] = program # encadré : durée + tarifs for label, value in _SIDEBAR_RE.findall(html): value = clean_text(value) if not value: continue key = label.lower() if "durée" in key: payload["duration"] = value elif "régulier" in key: payload["price"] = parse_price(value) payload["price_label"] = value elif "préférentiel" in key: details["prix_preferentiel"] = parse_price(value) # prochaines dates par ville (JSON encodé dans des champs cachés) sessions, cities = [], [] for item in soup.find_all("div", class_="event-date-item"): inp = item.find("input", attrs={"name": "event_date_time[]"}) try: slots = json.loads(unquote_plus(inp["value"])) if inp else {} except (ValueError, KeyError): slots = {} # le JSON est tantôt un objet {id: slot}, tantôt un tableau [slot] slot_list = (slots.values() if isinstance(slots, dict) else slots if isinstance(slots, list) else []) for slot in slot_list: if not isinstance(slot, dict): continue start = str(slot.get("startDate", ""))[:10] if re.match(r"20\d{2}-\d{2}-\d{2}", start): sessions.append(start) h3 = item.find("h3") if h3: city = clean_text(h3.get_text(" ")) if city: cities.append(city) sessions = sorted(set(sessions)) if sessions: payload["sessions"] = sessions payload["start_date"] = sessions[0] cities = list(dict.fromkeys(cities)) modes = ["en ligne" if "virtuelle" in c.lower() else "présentiel" for c in cities] modes = list(dict.fromkeys(modes)) real_cities = [c for c in cities if "virtuelle" not in c.lower()] if real_cities: payload["city"] = real_cities[0] # fil d'Ariane JSON-LD : catégorie + mode (eLearning, classe virtuelle) for obj in ldjson_objects(html): if obj.get("@type") == "BreadcrumbList": names = [clean_text(it.get("name", "")) for it in obj.get("itemListElement", []) if isinstance(it, dict)] names = [n for n in names[1:] if n] # sans « Accueil » if names: names.pop() # le dernier item = la page courante if names: payload["category"] = names[-1] if len(names) > 1: payload["tags"] = names[:-1] crumb = " ".join(names).lower() if "conférence" in crumb: payload["training_type"] = "Conférence" elif "atelier" in crumb: payload["training_type"] = "Atelier" if "elearning" in crumb or "e-learning" in crumb: modes = ["asynchrone"] elif not modes and "virtuelle" in crumb: modes = ["en ligne"] elif obj.get("@type") == "Course": if (len(payload.get("description", "")) < 30 and obj.get("description")): payload["description"] = clean_text(obj["description"]) if obj.get("inLanguage", "").lower().startswith("en"): payload["language"] = "en" if modes: payload["mode"] = modes[0] if len(modes) == 1 else "hybride" details["modes_offerts"] = modes og = soup.find("meta", property="og:image") if og and og.get("content"): payload["images"] = [og["content"]] if details: payload["details"] = details return payload