SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
10.5 KB · 234 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/ithq.py : connecteur ITHQ (ithq.qc.ca)5#   Ateliers grand public (cours SAQ par ITHQ : vins, spiritueux, dégustation)6#   et formations continues professionnelles (tourisme, hôtellerie,7#   restauration) de l'Institut de tourisme et d'hôtellerie du Québec.8#   Site WordPress : l'API REST publique expose deux types de contenus9#   (« workshop » et « formation ») avec le HTML Gutenberg complet de chaque10#   fiche + taxonomies (_embed) — AUCUNE page à visiter individuellement :11#   - caractéristiques : Durée, Formule (en présence/en ligne), Langue, Coût12#   - blocs libres : présentation, « Ce que vous découvrirez/apprendrez »13#     (objectifs), Contenu, Préalable, Coût, Modalités (attestation)14#   - bloc de dates : séances datées par ville (Montréal, Québec, Brossard…)15#   - taxonomies : catégorie, langue, niveau, domaine, clientèle, formule.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import json20import re2122from bs4 import BeautifulSoup2324from ..schema import Formation, clean_text, parse_date_fr25from .base import BaseConnector2627BASE = "https://www.ithq.qc.ca"28API_URL = f"{BASE}/wp-json/wp/v2/{{cpt}}?per_page=100&page={{page}}&_embed=1"2930# type de contenu WordPress -> type de formation Forma-Ka31_CPT_TYPES = {"workshop": "Atelier", "formation": "Formation continue"}3233# titres de sections (h2/h3/h4 Gutenberg) -> rôle dans le schéma34_SECTION_ROLES = [35    (re.compile(r"d[ée]couvrirez|apprendrez|objectifs?", re.I), "objectives"),36    (re.compile(r"^contenu|programme|au menu|d[ée]roulement", re.I), "program"),37    (re.compile(r"pr[ée]alable", re.I), "prerequisites"),38    (re.compile(r"^co[ûu]t", re.I), "price"),39    (re.compile(r"modalit[ée]s?", re.I), "modalites"),40    (re.compile(r"s[’']adresse|client[èe]le|à qui", re.I), "audience"),41    (re.compile(r"^dur[ée]e", re.I), "duration"),42    (re.compile(r"^pr[ée]sentation", re.I), ""),43    (re.compile(r"^dates?$|information compl[ée]mentaire", re.I), "skip"),44]4546# « Formule » / taxonomie activity-type -> mode canonique Forma-Ka47_FORMULE_MAP = {"en présence": "présentiel", "en ligne": "en ligne",48                "hybride": "hybride"}495051def _terms_by_taxonomy(item: dict) -> dict[str, list[str]]:52    """Noms des termes de taxonomie ({'workshop-category': ['Cours vins']…})."""53    out: dict[str, list[str]] = {}54    for group in (item.get("_embedded", {}).get("wp:term") or []):55        for term in group or []:56            if isinstance(term, dict) and term.get("name"):57                out.setdefault(term.get("taxonomy", ""), []).append(term["name"])58    return out596061def _section_role(title: str) -> str | None:62    """Rôle d'un titre de section, ou None si ce n'est pas un titre connu."""63    for rx, role in _SECTION_ROLES:64        if rx.search(title):65            return role66    return None676869class IthqConnector(BaseConnector):70    source_id = "ithq"71    request_delay = 0.57273    def fetch(self) -> list[Formation]:74        out: list[Formation] = []75        for cpt, training_type in _CPT_TYPES.items():76            for item in self._fetch_items(cpt):77                out.append(self._build(item, cpt, training_type))78        return out7980    # -- API REST -----------------------------------------------------------------81    def _get_json(self, url: str) -> list:82        """JSON de l'API avec repli : requests direct -> Scrapfly (le site83        refuse certains agents utilisateurs, mais l'API reste publique)."""84        try:85            return self.get(url).json()86        except Exception:87            pass88        text = self.get_scrapfly(url).strip()89        if text.startswith("<"):             # réponse enrobée de HTML90            text = BeautifulSoup(text, "html.parser").get_text()91        return json.loads(text)9293    def _fetch_items(self, cpt: str) -> list[dict]:94        """Tous les contenus publiés d'un type donné (pagination REST)."""95        items: list[dict] = []96        page = 197        while True:98            batch = self._get_json(API_URL.format(cpt=cpt, page=page))99            items.extend(batch)100            if len(batch) < 100:101                break102            page += 1103        return items104105    # -- construction ---------------------------------------------------------------106    def _build(self, item: dict, cpt: str, training_type: str) -> Formation:107        taxes = _terms_by_taxonomy(item)108        title = BeautifulSoup(item.get("title", {}).get("rendered", ""),109                              "html.parser").get_text()110        f = Formation(111            source=self.source_id,112            external_id=f"{cpt}:{item.get('slug', item.get('id'))}",113            url=item.get("link", ""),114            title=clean_text(title),115            training_type=training_type,116            category=(taxes.get(f"{cpt}-category") or [""])[0],117            mode=_FORMULE_MAP.get(118                (taxes.get("activity-type") or [""])[0].lower(),119                (taxes.get("activity-type") or [""])[0]),120            language=(taxes.get(f"{cpt}-language") or [""])[0],121            level=(taxes.get("workshop-level") or [""])[0],122            audience=", ".join(taxes.get("formation-clientele") or []),123            tags=(taxes.get("formation-domain") or [])124                 + (taxes.get("formation-family") or []),125        )126        media = item.get("_embedded", {}).get("wp:featuredmedia") or []127        if media and isinstance(media[0], dict) and media[0].get("source_url"):128            f.images = [media[0]["source_url"]]129        self._parse_content(f, item.get("content", {}).get("rendered", ""))130        return f131132    # -- HTML Gutenberg de la fiche ---------------------------------------------------133    def _parse_content(self, f: Formation, html: str) -> None:134        soup = BeautifulSoup(html, "html.parser")135136        # 1) bloc de caractéristiques (Durée / Formule / Langue / Coût / Domaine)137        for it in soup.find_all(class_="activity-characteristics__item"):138            label = clean_text((it.find(class_="label") or it).get_text(" "))139            content = it.find(class_="content")140            value = clean_text(content.get_text(" ")) if content else ""141            if not value:142                continue143            low = label.lower()144            if low.startswith("dur"):145                f.duration = value146            elif low.startswith("formule") and not f.mode:147                f.mode = _FORMULE_MAP.get(value.lower(), value)148            elif low.startswith("langue") and not f.language:149                f.language = value150            elif low.startswith("co"):151                f.price_label = value152            elif low.startswith("domaine") and not f.category:153                f.category = value154155        # 2) blocs libres au premier niveau : description + sections titrées156        #    (les titres de section connus — Présentation, Objectifs, Contenu,157        #    Coût, Modalités… — routent les blocs qui les suivent ; les autres158        #    titres sont des sous-titres qui restent dans la section courante)159        desc_parts: list[str] = []160        role = ""161        for el in soup.find_all(recursive=False):162            cls = " ".join(el.get("class") or [])163            if el.name in ("h2", "h3", "h4") and "wp-block-heading" in cls:164                title = clean_text(el.get_text(" "))165                known = _section_role(title)166                if known is not None:167                    role = known168                else:                           # sous-titre descriptif169                    role = ""170                    desc_parts.append(title)171                continue172            if "activity-dates-block" in cls or "activity-related-block" in cls:173                role = "skip"174            if role == "skip" or el.name in ("section", "hr", "figure"):175                continue176            if el.name not in ("p", "ul", "ol", "div"):177                continue178            items = [clean_text(li.get_text(" ")) for li in el.find_all("li")]179            text = clean_text(el.get_text(" "))180            if not text:181                continue182            if role in ("objectives", "program"):183                target = f.objectives if role == "objectives" else f.program184                if items:185                    target += items186                elif not text.endswith(":"):    # phrase d'amorce ignorée187                    target.append(text)188            elif role == "prerequisites":189                f.prerequisites = (f.prerequisites + " " + text).strip()190            elif role == "price":191                if not f.price_label:192                    f.price_label = text193                f.details.setdefault("cout_details", text)194            elif role == "duration":195                m = re.search(r"\d+\s*h(?:\s*\d{1,2})?\b|\d+\s*min\w*", text)196                if m and not f.duration:197                    f.duration = m.group(0)198            elif role == "modalites":199                f.details.setdefault("modalites", []).extend(items or [text])200                if re.search(r"attestation", text, re.I):201                    f.credential = "Attestation de participation"202            elif role == "audience":203                f.audience = clean_text(f"{f.audience} {text}").strip()204            elif role == "":205                # « Cette formation s'adresse : … » (paragraphe ou bloc dédié)206                m = re.search(r"s[’']adresse\s*:?", text)207                if m:208                    rest = clean_text(text[m.end():])209                    if rest:210                        f.audience = clean_text(f"{f.audience} {rest}").strip()211                    else:212                        role = "audience"213                    continue214                desc_parts += items or [text]215        f.description = "\n\n".join(desc_parts)216217        # 3) séances datées par ville218        sessions, cities = [], []219        for it in soup.find_all(class_="activity-dates-block__date-item"):220            t = it.find(class_="activity-dates-block__date-item__title")221            iso = parse_date_fr(clean_text(t.get_text(" "))) if t else None222            if iso:223                sessions.append(iso)224            city = clean_text(it.get("data-location", ""))225            if city and city not in cities:226                cities.append(city)227        if sessions:228            f.sessions = sorted(set(sessions))229            f.start_date = f.sessions[0]230        if cities:231            f.city = cities[0]232            if len(cities) > 1:233                f.details["villes"] = cities234