SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
6.4 KB · 156 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/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