# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/alphanumerique.py : connecteur AlphaNumérique (alphanumerique.ca) # Initiative de littératie numérique pilotée par des bibliothèques publiques # québécoises — cours autonomes en ligne 100 % GRATUITS pour le grand public # (bases de l'informatique, courriel, sécurité en ligne, fausses nouvelles, # tablettes, intelligence artificielle…). # Site WordPress rendu serveur : # - liste : /espace-public/cours-autonomes/ — cartes regroupées par # thématique (thème, niveau, titre, visuel) # - fiche : page Elementor avec description, liste « Ce cours vous # permettra de… » (objectifs) et vidéo du cours. # Fiches en cache, rafraîchies chaque semaine (clé ISO). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Formation, clean_text from .base import BaseConnector BASE = "https://alphanumerique.ca" LIST_URL = f"{BASE}/espace-public/cours-autonomes/" _SLUG_RE = re.compile(r"/ressources/([^/]+)/?") # marqueurs de fin de la zone descriptive d'une fiche _STOP_RE = re.compile(r"cliquer sur la vid[ée]o|cliquez sur le lien ci-dessous|" r"ressources compl[ée]mentaires|[ée]valuer nos ressources", re.I) class AlphanumeriqueConnector(BaseConnector): source_id = "alphanumerique" request_delay = 0.5 def fetch(self) -> list[Formation]: html = self.fetch_html(LIST_URL) cards = self._parse_listing(html) # fiche détaillée par cours — cache hebdomadaire week = datetime.date.today().strftime("%G-W%V") out: list[Formation] = [] for slug, card in cards.items(): key = f"{week}:{card['title']}:{card['level']}" payload = self.detail(slug, key, lambda c=card: self._fetch_detail(c)) f = Formation( source=self.source_id, external_id=slug, url=card["url"], title=card["title"], training_type="Cours en ligne", category=card["theme"], mode="asynchrone", language="fr", price=0.0, price_label="Gratuit", is_free=True, level=card["level"].lower(), audience="Grand public", tags=["littératie numérique"], images=card["images"], ) 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 # -- liste ------------------------------------------------------------------ def _parse_listing(self, html: str) -> dict[str, dict]: """Cartes de cours autonomes, indexées par slug (dédupliquées).""" soup = BeautifulSoup(html, "html.parser") cards: dict[str, dict] = {} for box in soup.find_all("div", class_="ressources-container"): a = box.find("a", href=_SLUG_RE) if a is None: continue url = urljoin(BASE, a["href"]) slug = _SLUG_RE.search(url).group(1) if slug in cards: continue terms = [clean_text(t.get_text(" ")) for t in box.find_all("div", class_="term")] title = box.find("div", class_="title") img = box.find("img") src = (img.get("data-lazy-src") or img.get("src") or "") if img else "" cards[slug] = { "url": url, "title": clean_text(title.get_text(" ")) if title else slug, "theme": terms[0] if terms else "", "level": terms[1] if len(terms) > 1 else "", "images": [src] if src.startswith("http") else [], } return cards # -- fiche -------------------------------------------------------------------- def _fetch_detail(self, card: dict) -> dict: if not card.get("url"): return {} html = self.fetch_html(card["url"]) soup = BeautifulSoup(html, "html.parser") main = (soup.find(class_="elementor-location-single") or soup.find("main") or soup.body) if main is None: return {} payload: dict = {} # textes déjà connus (titre, thème, niveau) à exclure de la description title = main.find(class_="elementor-heading-title") skip = {clean_text(title.get_text(" ")) if title else "", card.get("title", ""), card.get("theme", ""), card.get("level", "")} desc_parts: list[str] = [] objectives: list[str] = [] program: list[str] = [] for el in main.find_all(["p", "ul"]): text = clean_text(el.get_text(" ")) if not text or text in skip: continue if _STOP_RE.search(text): break if el.name == "ul": items = [t for t in (clean_text(li.get_text(" ")) for li in el.find_all("li")) if t and t not in skip] if not items: continue lead = desc_parts[-1] if desc_parts else "" # « Ce cours vous permettra de… » = objectifs if not objectives and re.search( r"permettra|apprendrez|d[ée]couvrirez", lead, re.I): objectives = items desc_parts.pop() # phrase d'amorce retirée # « …les grandes questions qui seront abordées » = plan du cours elif not program and re.search(r"abord[ée]e?s?\b", lead, re.I): program = items else: desc_parts += items else: desc_parts.append(text) if desc_parts: payload["description"] = "\n\n".join(desc_parts) if objectives: payload["objectives"] = objectives if program: payload["program"] = program return payload