# ----------------------------------------------------------------------------- # Forma-Ka — Agrégateur de formations (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/base.py : classe de base des connecteurs + backends de fetch # 1. requests direct (sites rendus serveur) # 2. Scrapfly (asp=anti-bot bypass, render_js) — backend robuste privilégié # 3. Firecrawl (rendu JavaScript) — solution de repli / sites SPA # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from urllib.parse import urlencode import requests from ..schema import Formation _LDJSON_RE = re.compile( r']*type="application/ld\+json"[^>]*>(.*?)', re.S | re.I) def ldjson_objects(html: str) -> list[dict]: """Extrait tous les objets JSON-LD d'une page (schema.org Course, etc.). Tolère les blocs contenant plusieurs objets concaténés (sans virgule), fréquents sur les sites ASP.NET. """ dec = json.JSONDecoder() out: list[dict] = [] for m in _LDJSON_RE.finditer(html): blob = m.group(1).strip() i = 0 while i < len(blob): try: obj, j = dec.raw_decode(blob, i) if isinstance(obj, dict): if "@graph" in obj: out.extend(g for g in obj["@graph"] if isinstance(g, dict)) else: out.append(obj) elif isinstance(obj, list): out.extend(o for o in obj if isinstance(o, dict)) i = j while i < len(blob) and blob[i] in " \r\n\t,": i += 1 except ValueError: i += 1 return out USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 " "FormaKaBot/1.0 (+https://www.forma-ka.com/bot; contact@spboucher.ai)") FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape" SCRAPFLY_API = "https://api.scrapfly.io/scrape" class BaseConnector: """Un connecteur = un adaptateur propre à un site de formation. Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne la liste complète des formations actuellement affichées sur le site. Le pipeline (ingest.py) s'occupe du diff avec la base de données. """ source_id: str = "" request_delay: float = 0.6 # politesse entre requêtes timeout: int = 30 use_detail_cache: bool = True # cache BD des pages détail def __init__(self) -> None: self.session = requests.Session() self.session.headers["User-Agent"] = USER_AGENT self._last_request = 0.0 self._detail_con = None # -- backends ------------------------------------------------------------- def get(self, url: str, **kw) -> requests.Response: """GET direct avec throttling poli.""" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.get(url, timeout=self.timeout, **kw) self._last_request = time.time() resp.raise_for_status() return resp def post(self, url: str, **kw) -> requests.Response: """POST direct avec throttling poli (APIs JSON internes).""" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(url, timeout=self.timeout, **kw) self._last_request = time.time() resp.raise_for_status() return resp def get_scrapfly(self, url: str, render_js: bool = False, asp: bool = True, country: str = "ca", wait_for: str | None = None) -> str: """Récupère le HTML via Scrapfly — backend robuste (anti-bot bypass). Nécessite SCRAPFLY_API_KEY dans l'environnement (.env). `asp=True` active l'Anti Scraping Protection bypass (Cloudflare…), `render_js=True` exécute le JavaScript (navigateur headless), `wait_for` attend un sélecteur CSS avant de capturer le HTML. """ key = os.environ.get("SCRAPFLY_API_KEY") if not key: raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)") params: dict = {"key": key, "url": url, "country": country} if asp: params["asp"] = "true" if render_js: params["render_js"] = "true" if wait_for: params["render_js"] = "true" params["wait_for_selector"] = wait_for resp = requests.get(f"{SCRAPFLY_API}?{urlencode(params)}", timeout=120) resp.raise_for_status() data = resp.json() result = data.get("result") or {} status = result.get("status_code") if status and int(status) >= 400: raise RuntimeError(f"Scrapfly: la cible a répondu {status} pour {url}") return result.get("content", "") def get_firecrawl(self, url: str, formats: list[str] | None = None) -> dict: """Récupère la page via Firecrawl (rendu JavaScript). Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). Retourne le dict `data` de Firecrawl ({"html": …, "markdown": …}). """ key = os.environ.get("FIRECRAWL_API_KEY") if not key: raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)") resp = requests.post( FIRECRAWL_API, json={"url": url, "formats": formats or ["html"]}, headers={"Authorization": f"Bearer {key}"}, timeout=90, ) resp.raise_for_status() return resp.json().get("data") or {} def get_rendered(self, url: str) -> str: """HTML rendu (JavaScript exécuté) — Scrapfly d'abord (plus robuste), Firecrawl en repli. À utiliser pour les sites SPA / derrière Cloudflare. """ try: html = self.get_scrapfly(url, render_js=True) if html: return html except Exception: pass return self.get_firecrawl(url).get("html", "") def fetch_html(self, url: str) -> str: """HTML d'une page « normale » avec repli automatique : requests direct -> Scrapfly (asp) -> Firecrawl. """ try: resp = self.get(url) if resp.status_code == 200 and len(resp.text) > 500: return resp.text except Exception: pass try: html = self.get_scrapfly(url) if html: return html except Exception: pass return self.get_firecrawl(url).get("html", "") def detail(self, external_id: str, key: str, fetch_fn) -> dict: """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si la formation est nouvelle ou si sa clé (hash du contenu liste) a changé. Permet d'extraire les champs riches (description, objectifs, plan de cours, préalables…) sans revisiter chaque page à chaque synchronisation. `fetch_fn` doit retourner un dict JSON-sérialisable. """ if not self.use_detail_cache: return fetch_fn() or {} from .. import db if self._detail_con is None: self._detail_con = db.connect() cached = db.get_cached_detail(self._detail_con, self.source_id, str(external_id), key) if cached is not None: return cached payload = fetch_fn() or {} db.put_cached_detail(self._detail_con, self.source_id, str(external_id), key, payload) return payload # -- contrat -------------------------------------------------------------- def fetch(self) -> list[Formation]: raise NotImplementedError