# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lesaffaires.py : connecteur Événements Les Affaires # (evenements.lesaffaires.com) — conférences, formations et webinaires # d'affaires du média Les Affaires (Montréal/Québec/en ligne). # Boutique Shopify : l'API publique /products.json expose TOUT le catalogue # (un produit = un événement) avec des tags très structurés : # - Format_Conférence / Formation / Webinaire / Démos du midi… # - Thématique_Technologies de l'information / Finances / RH… # - start-date_/end-date_AAAA-MM-JJ HH:MM:SS, Emplacement_Montréal/En ligne, # Expert_Prénom Nom (conférenciers), edition-Gratuit, « En différé ». # Prix par variante (Présentiel / Virtuel), description dans body_html. # Aucune page détail nécessaire : la liste JSON contient tout. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text from .base import BaseConnector BASE = "https://evenements.lesaffaires.com" PRODUCTS_URL = f"{BASE}/products.json" # préfixe de date dans les titres : « 2026-11-19***Ambition » # (parfois double : « 2026-12-09 & 2026-12-10***Masterclass Défense ») _TITLE_DATE_RE = re.compile(r"^\s*20\d{2}-\d{2}-\d{2}[^*]*\*+\s*") _DATETIME_RE = re.compile(r"(20\d{2}-\d{2}-\d{2})[ T](\d{2}:\d{2})") # tag Format_… -> type de formation canonique Forma-Ka _FORMAT_MAP = { "conférence": "Conférence", "inédit : souper-conférence": "Conférence", "salon": "Conférence", "webinaire": "Webinaire", "démos du midi": "Webinaire", "formation": "Formation continue", "formation am": "Formation continue", "formation pm": "Formation continue", } def _parse_tags(tags: list[str]) -> dict: """Décompose les tags Shopify structurés (Format_, Expert_, start-date_…).""" out: dict = {"experts": [], "formats": [], "themes": [], "flags": []} for tag in tags: if "_" in tag: prefix, value = tag.split("_", 1) prefix = prefix.strip().lower() value = value.strip() if prefix == "format": out["formats"].append(value) elif prefix in ("thématique", "thematique"): out["themes"].append(value) elif prefix == "expert": out["experts"].append(value) elif prefix == "emplacement": out["emplacement"] = value elif prefix == "start-date": out["start"] = value elif prefix == "end-date": out["end"] = value else: out["flags"].append(tag) else: out["flags"].append(tag) return out class LesAffairesConnector(BaseConnector): source_id = "lesaffaires" request_delay = 0.5 def fetch(self) -> list[Formation]: # 1) catalogue complet via l'API Shopify (paginée par sécurité) products: list[dict] = [] page = 1 while True: resp = self.get(PRODUCTS_URL, params={"limit": 250, "page": page}) batch = resp.json().get("products", []) products.extend(batch) if len(batch) < 250: break page += 1 out: list[Formation] = [] for prod in products: tags = _parse_tags(prod.get("tags") or []) if "hide" in [f.lower() for f in tags["flags"]]: continue f = self._build(prod, tags) if f is not None: out.append(f) return out # -- construction d'une formation depuis un produit Shopify ---------------- def _build(self, prod: dict, tags: dict) -> Formation | None: handle = prod.get("handle") or "" raw_title = prod.get("title") or "" if not handle or not raw_title: return None title = _TITLE_DATE_RE.sub("", raw_title).strip() f = Formation( source=self.source_id, external_id=handle, url=f"{BASE}/products/{handle}", title=title, language="fr", ) # type d'événement depuis le tag Format_ fmt = (tags["formats"][0] if tags["formats"] else "").lower() f.training_type = _FORMAT_MAP.get(fmt, "Conférence") if tags["formats"]: f.details["format"] = tags["formats"][0] # thématique -> catégorie + tags if tags["themes"]: f.category = tags["themes"][0] f.tags = tags["themes"] + tags["formats"] # conférenciers / experts if tags["experts"]: f.instructor = ", ".join(dict.fromkeys(tags["experts"])) # description : body_html du produit body = prod.get("body_html") or "" if body: f.description = BeautifulSoup(body, "html.parser").get_text("\n").strip() # dates précises (tags start-date_/end-date_) start = tags.get("start") end = tags.get("end") ms = _DATETIME_RE.search(start or "") me = _DATETIME_RE.search(end or "") if ms: f.start_date = ms.group(1) f.sessions = [ms.group(1)] f.schedule_label = f"{ms.group(1)} {ms.group(2)}" f.details["start_datetime"] = f"{ms.group(1)} {ms.group(2)}" if me: f.details["end_datetime"] = f"{me.group(1)} {me.group(2)}" if ms and me: if ms.group(1) == me.group(1): # même jour -> durée en heures h1, m1 = map(int, ms.group(2).split(":")) h2, m2 = map(int, me.group(2).split(":")) hours = (h2 * 60 + m2 - h1 * 60 - m1) / 60.0 if hours > 0: f.duration_hours = round(hours, 2) f.duration = f"{hours:g} h" else: # plusieurs jours import datetime d1 = datetime.date.fromisoformat(ms.group(1)) d2 = datetime.date.fromisoformat(me.group(1)) days = (d2 - d1).days + 1 f.duration = f"{days} jours" # événement en différé (rediffusion à la demande) if any(fl.lower().startswith("en différé") for fl in tags["flags"]): f.tags.append("En différé") f.details["en_differe"] = True # mode / ville : Emplacement_ + variantes Présentiel/Virtuel variants = [v for v in (prod.get("variants") or []) if isinstance(v, dict)] vtitles = {clean_text(v.get("title", "")).lower() for v in variants} emplacement = tags.get("emplacement", "") has_pres = "présentiel" in vtitles has_virt = "virtuel" in vtitles if has_pres and has_virt: f.mode = "hybride" elif emplacement: f.mode = "en ligne" if emplacement.lower() == "en ligne" else "présentiel" if emplacement and emplacement.lower() != "en ligne": f.city = emplacement # prix : minimum des variantes (le 1 $ des gratuits est un artefact) prices = [] labels = [] for v in variants: try: p = float(v.get("price") or 0) except (TypeError, ValueError): continue prices.append(p) vt = clean_text(v.get("title", "")) if vt and vt.lower() != "default title": labels.append(f"{p:g} $ ({vt})") gratuit = any(fl.lower() == "edition-gratuit" for fl in tags["flags"]) \ or "edition-Gratuit" in (prod.get("tags") or []) if gratuit or (prices and min(prices) == 0.0): f.price = 0.0 f.is_free = True f.price_label = "Gratuit" elif prices and min(prices) > 1.0: # 1 $ = placeholder Shopify f.price = min(prices) f.price_label = " / ".join(labels) if labels else f"{min(prices):g} $" # images du produit for img in prod.get("images") or []: src = img.get("src") if isinstance(img, dict) else None if src: f.images.append(src) return f