# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ithq.py : connecteur ITHQ (ithq.qc.ca) # Ateliers grand public (cours SAQ par ITHQ : vins, spiritueux, dégustation) # et formations continues professionnelles (tourisme, hôtellerie, # restauration) de l'Institut de tourisme et d'hôtellerie du Québec. # Site WordPress : l'API REST publique expose deux types de contenus # (« workshop » et « formation ») avec le HTML Gutenberg complet de chaque # fiche + taxonomies (_embed) — AUCUNE page à visiter individuellement : # - caractéristiques : Durée, Formule (en présence/en ligne), Langue, Coût # - blocs libres : présentation, « Ce que vous découvrirez/apprendrez » # (objectifs), Contenu, Préalable, Coût, Modalités (attestation) # - bloc de dates : séances datées par ville (Montréal, Québec, Brossard…) # - taxonomies : catégorie, langue, niveau, domaine, clientèle, formule. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from bs4 import BeautifulSoup from ..schema import Formation, clean_text, parse_date_fr from .base import BaseConnector BASE = "https://www.ithq.qc.ca" API_URL = f"{BASE}/wp-json/wp/v2/{{cpt}}?per_page=100&page={{page}}&_embed=1" # type de contenu WordPress -> type de formation Forma-Ka _CPT_TYPES = {"workshop": "Atelier", "formation": "Formation continue"} # titres de sections (h2/h3/h4 Gutenberg) -> rôle dans le schéma _SECTION_ROLES = [ (re.compile(r"d[ée]couvrirez|apprendrez|objectifs?", re.I), "objectives"), (re.compile(r"^contenu|programme|au menu|d[ée]roulement", re.I), "program"), (re.compile(r"pr[ée]alable", re.I), "prerequisites"), (re.compile(r"^co[ûu]t", re.I), "price"), (re.compile(r"modalit[ée]s?", re.I), "modalites"), (re.compile(r"s[’']adresse|client[èe]le|à qui", re.I), "audience"), (re.compile(r"^dur[ée]e", re.I), "duration"), (re.compile(r"^pr[ée]sentation", re.I), ""), (re.compile(r"^dates?$|information compl[ée]mentaire", re.I), "skip"), ] # « Formule » / taxonomie activity-type -> mode canonique Forma-Ka _FORMULE_MAP = {"en présence": "présentiel", "en ligne": "en ligne", "hybride": "hybride"} def _terms_by_taxonomy(item: dict) -> dict[str, list[str]]: """Noms des termes de taxonomie ({'workshop-category': ['Cours vins']…}).""" out: dict[str, list[str]] = {} for group in (item.get("_embedded", {}).get("wp:term") or []): for term in group or []: if isinstance(term, dict) and term.get("name"): out.setdefault(term.get("taxonomy", ""), []).append(term["name"]) return out def _section_role(title: str) -> str | None: """Rôle d'un titre de section, ou None si ce n'est pas un titre connu.""" for rx, role in _SECTION_ROLES: if rx.search(title): return role return None class IthqConnector(BaseConnector): source_id = "ithq" request_delay = 0.5 def fetch(self) -> list[Formation]: out: list[Formation] = [] for cpt, training_type in _CPT_TYPES.items(): for item in self._fetch_items(cpt): out.append(self._build(item, cpt, training_type)) return out # -- API REST ----------------------------------------------------------------- def _get_json(self, url: str) -> list: """JSON de l'API avec repli : requests direct -> Scrapfly (le site refuse certains agents utilisateurs, mais l'API reste publique).""" try: return self.get(url).json() except Exception: pass text = self.get_scrapfly(url).strip() if text.startswith("<"): # réponse enrobée de HTML text = BeautifulSoup(text, "html.parser").get_text() return json.loads(text) def _fetch_items(self, cpt: str) -> list[dict]: """Tous les contenus publiés d'un type donné (pagination REST).""" items: list[dict] = [] page = 1 while True: batch = self._get_json(API_URL.format(cpt=cpt, page=page)) items.extend(batch) if len(batch) < 100: break page += 1 return items # -- construction --------------------------------------------------------------- def _build(self, item: dict, cpt: str, training_type: str) -> Formation: taxes = _terms_by_taxonomy(item) title = BeautifulSoup(item.get("title", {}).get("rendered", ""), "html.parser").get_text() f = Formation( source=self.source_id, external_id=f"{cpt}:{item.get('slug', item.get('id'))}", url=item.get("link", ""), title=clean_text(title), training_type=training_type, category=(taxes.get(f"{cpt}-category") or [""])[0], mode=_FORMULE_MAP.get( (taxes.get("activity-type") or [""])[0].lower(), (taxes.get("activity-type") or [""])[0]), language=(taxes.get(f"{cpt}-language") or [""])[0], level=(taxes.get("workshop-level") or [""])[0], audience=", ".join(taxes.get("formation-clientele") or []), tags=(taxes.get("formation-domain") or []) + (taxes.get("formation-family") or []), ) media = item.get("_embedded", {}).get("wp:featuredmedia") or [] if media and isinstance(media[0], dict) and media[0].get("source_url"): f.images = [media[0]["source_url"]] self._parse_content(f, item.get("content", {}).get("rendered", "")) return f # -- HTML Gutenberg de la fiche --------------------------------------------------- def _parse_content(self, f: Formation, html: str) -> None: soup = BeautifulSoup(html, "html.parser") # 1) bloc de caractéristiques (Durée / Formule / Langue / Coût / Domaine) for it in soup.find_all(class_="activity-characteristics__item"): label = clean_text((it.find(class_="label") or it).get_text(" ")) content = it.find(class_="content") value = clean_text(content.get_text(" ")) if content else "" if not value: continue low = label.lower() if low.startswith("dur"): f.duration = value elif low.startswith("formule") and not f.mode: f.mode = _FORMULE_MAP.get(value.lower(), value) elif low.startswith("langue") and not f.language: f.language = value elif low.startswith("co"): f.price_label = value elif low.startswith("domaine") and not f.category: f.category = value # 2) blocs libres au premier niveau : description + sections titrées # (les titres de section connus — Présentation, Objectifs, Contenu, # Coût, Modalités… — routent les blocs qui les suivent ; les autres # titres sont des sous-titres qui restent dans la section courante) desc_parts: list[str] = [] role = "" for el in soup.find_all(recursive=False): cls = " ".join(el.get("class") or []) if el.name in ("h2", "h3", "h4") and "wp-block-heading" in cls: title = clean_text(el.get_text(" ")) known = _section_role(title) if known is not None: role = known else: # sous-titre descriptif role = "" desc_parts.append(title) continue if "activity-dates-block" in cls or "activity-related-block" in cls: role = "skip" if role == "skip" or el.name in ("section", "hr", "figure"): continue if el.name not in ("p", "ul", "ol", "div"): continue items = [clean_text(li.get_text(" ")) for li in el.find_all("li")] text = clean_text(el.get_text(" ")) if not text: continue if role in ("objectives", "program"): target = f.objectives if role == "objectives" else f.program if items: target += items elif not text.endswith(":"): # phrase d'amorce ignorée target.append(text) elif role == "prerequisites": f.prerequisites = (f.prerequisites + " " + text).strip() elif role == "price": if not f.price_label: f.price_label = text f.details.setdefault("cout_details", text) elif role == "duration": m = re.search(r"\d+\s*h(?:\s*\d{1,2})?\b|\d+\s*min\w*", text) if m and not f.duration: f.duration = m.group(0) elif role == "modalites": f.details.setdefault("modalites", []).extend(items or [text]) if re.search(r"attestation", text, re.I): f.credential = "Attestation de participation" elif role == "audience": f.audience = clean_text(f"{f.audience} {text}").strip() elif role == "": # « Cette formation s'adresse : … » (paragraphe ou bloc dédié) m = re.search(r"s[’']adresse\s*:?", text) if m: rest = clean_text(text[m.end():]) if rest: f.audience = clean_text(f"{f.audience} {rest}").strip() else: role = "audience" continue desc_parts += items or [text] f.description = "\n\n".join(desc_parts) # 3) séances datées par ville sessions, cities = [], [] for it in soup.find_all(class_="activity-dates-block__date-item"): t = it.find(class_="activity-dates-block__date-item__title") iso = parse_date_fr(clean_text(t.get_text(" "))) if t else None if iso: sessions.append(iso) city = clean_text(it.get("data-location", "")) if city and city not in cities: cities.append(city) if sessions: f.sessions = sorted(set(sessions)) f.start_date = f.sessions[0] if cities: f.city = cities[0] if len(cities) > 1: f.details["villes"] = cities