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/alphanumerique.py : connecteur AlphaNumérique (alphanumerique.ca)5# Initiative de littératie numérique pilotée par des bibliothèques publiques6# québécoises — cours autonomes en ligne 100 % GRATUITS pour le grand public7# (bases de l'informatique, courriel, sécurité en ligne, fausses nouvelles,8# tablettes, intelligence artificielle…).9# Site WordPress rendu serveur :10# - liste : /espace-public/cours-autonomes/ — cartes regroupées par11# thématique (thème, niveau, titre, visuel)12# - fiche : page Elementor avec description, liste « Ce cours vous13# permettra de… » (objectifs) et vidéo du cours.14# Fiches en cache, rafraîchies chaque semaine (clé ISO).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import datetime19import re20from urllib.parse import urljoin2122from bs4 import BeautifulSoup2324from ..schema import Formation, clean_text25from .base import BaseConnector2627BASE = "https://alphanumerique.ca"28LIST_URL = f"{BASE}/espace-public/cours-autonomes/"2930_SLUG_RE = re.compile(r"/ressources/([^/]+)/?")31# marqueurs de fin de la zone descriptive d'une fiche32_STOP_RE = re.compile(r"cliquer sur la vid[ée]o|cliquez sur le lien ci-dessous|"33 r"ressources compl[ée]mentaires|[ée]valuer nos ressources",34 re.I)353637class AlphanumeriqueConnector(BaseConnector):38 source_id = "alphanumerique"39 request_delay = 0.54041 def fetch(self) -> list[Formation]:42 html = self.fetch_html(LIST_URL)43 cards = self._parse_listing(html)4445 # fiche détaillée par cours — cache hebdomadaire46 week = datetime.date.today().strftime("%G-W%V")47 out: list[Formation] = []48 for slug, card in cards.items():49 key = f"{week}:{card['title']}:{card['level']}"50 payload = self.detail(slug, key,51 lambda c=card: self._fetch_detail(c))52 f = Formation(53 source=self.source_id,54 external_id=slug,55 url=card["url"],56 title=card["title"],57 training_type="Cours en ligne",58 category=card["theme"],59 mode="asynchrone",60 language="fr",61 price=0.0,62 price_label="Gratuit",63 is_free=True,64 level=card["level"].lower(),65 audience="Grand public",66 tags=["littératie numérique"],67 images=card["images"],68 )69 for k, v in (payload or {}).items():70 if hasattr(f, k) and v not in (None, "", []):71 setattr(f, k, v)72 out.append(f)73 return out7475 # -- liste ------------------------------------------------------------------76 def _parse_listing(self, html: str) -> dict[str, dict]:77 """Cartes de cours autonomes, indexées par slug (dédupliquées)."""78 soup = BeautifulSoup(html, "html.parser")79 cards: dict[str, dict] = {}80 for box in soup.find_all("div", class_="ressources-container"):81 a = box.find("a", href=_SLUG_RE)82 if a is None:83 continue84 url = urljoin(BASE, a["href"])85 slug = _SLUG_RE.search(url).group(1)86 if slug in cards:87 continue88 terms = [clean_text(t.get_text(" "))89 for t in box.find_all("div", class_="term")]90 title = box.find("div", class_="title")91 img = box.find("img")92 src = (img.get("data-lazy-src") or img.get("src") or "") if img else ""93 cards[slug] = {94 "url": url,95 "title": clean_text(title.get_text(" ")) if title else slug,96 "theme": terms[0] if terms else "",97 "level": terms[1] if len(terms) > 1 else "",98 "images": [src] if src.startswith("http") else [],99 }100 return cards101102 # -- fiche --------------------------------------------------------------------103 def _fetch_detail(self, card: dict) -> dict:104 if not card.get("url"):105 return {}106 html = self.fetch_html(card["url"])107 soup = BeautifulSoup(html, "html.parser")108 main = (soup.find(class_="elementor-location-single")109 or soup.find("main") or soup.body)110 if main is None:111 return {}112 payload: dict = {}113114 # textes déjà connus (titre, thème, niveau) à exclure de la description115 title = main.find(class_="elementor-heading-title")116 skip = {clean_text(title.get_text(" ")) if title else "",117 card.get("title", ""), card.get("theme", ""),118 card.get("level", "")}119120 desc_parts: list[str] = []121 objectives: list[str] = []122 program: list[str] = []123 for el in main.find_all(["p", "ul"]):124 text = clean_text(el.get_text(" "))125 if not text or text in skip:126 continue127 if _STOP_RE.search(text):128 break129 if el.name == "ul":130 items = [t for t in (clean_text(li.get_text(" "))131 for li in el.find_all("li"))132 if t and t not in skip]133 if not items:134 continue135 lead = desc_parts[-1] if desc_parts else ""136 # « Ce cours vous permettra de… » = objectifs137 if not objectives and re.search(138 r"permettra|apprendrez|d[ée]couvrirez", lead, re.I):139 objectives = items140 desc_parts.pop() # phrase d'amorce retirée141 # « …les grandes questions qui seront abordées » = plan du cours142 elif not program and re.search(r"abord[ée]e?s?\b", lead, re.I):143 program = items144 else:145 desc_parts += items146 else:147 desc_parts.append(text)148149 if desc_parts:150 payload["description"] = "\n\n".join(desc_parts)151 if objectives:152 payload["objectives"] = objectives153 if program:154 payload["program"] = program155 return payload156