SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
8.6 KB · 207 lines python
Raw Blame History
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