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/base.py : classe de base des connecteurs + backends de fetch5# 1. requests direct (sites rendus serveur)6# 2. Scrapfly (asp=anti-bot bypass, render_js) — backend robuste privilégié7# 3. Firecrawl (rendu JavaScript) — solution de repli / sites SPA8# -----------------------------------------------------------------------------9from __future__ import annotations1011import json12import os13import re14import time15from urllib.parse import urlencode1617import requests1819from ..schema import Formation2021_LDJSON_RE = re.compile(22 r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S | re.I)232425def ldjson_objects(html: str) -> list[dict]:26 """Extrait tous les objets JSON-LD d'une page (schema.org Course, etc.).2728 Tolère les blocs contenant plusieurs objets concaténés (sans virgule),29 fréquents sur les sites ASP.NET.30 """31 dec = json.JSONDecoder()32 out: list[dict] = []33 for m in _LDJSON_RE.finditer(html):34 blob = m.group(1).strip()35 i = 036 while i < len(blob):37 try:38 obj, j = dec.raw_decode(blob, i)39 if isinstance(obj, dict):40 if "@graph" in obj:41 out.extend(g for g in obj["@graph"] if isinstance(g, dict))42 else:43 out.append(obj)44 elif isinstance(obj, list):45 out.extend(o for o in obj if isinstance(o, dict))46 i = j47 while i < len(blob) and blob[i] in " \r\n\t,":48 i += 149 except ValueError:50 i += 151 return out5253USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "54 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "55 "FormaKaBot/1.0 (+https://www.forma-ka.com/bot; contact@spboucher.ai)")5657FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"58SCRAPFLY_API = "https://api.scrapfly.io/scrape"596061class BaseConnector:62 """Un connecteur = un adaptateur propre à un site de formation.6364 Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne65 la liste complète des formations actuellement affichées sur le site.66 Le pipeline (ingest.py) s'occupe du diff avec la base de données.67 """6869 source_id: str = ""70 request_delay: float = 0.6 # politesse entre requêtes71 timeout: int = 3072 use_detail_cache: bool = True # cache BD des pages détail7374 def __init__(self) -> None:75 self.session = requests.Session()76 self.session.headers["User-Agent"] = USER_AGENT77 self._last_request = 0.078 self._detail_con = None7980 # -- backends -------------------------------------------------------------81 def get(self, url: str, **kw) -> requests.Response:82 """GET direct avec throttling poli."""83 wait = self.request_delay - (time.time() - self._last_request)84 if wait > 0:85 time.sleep(wait)86 resp = self.session.get(url, timeout=self.timeout, **kw)87 self._last_request = time.time()88 resp.raise_for_status()89 return resp9091 def post(self, url: str, **kw) -> requests.Response:92 """POST direct avec throttling poli (APIs JSON internes)."""93 wait = self.request_delay - (time.time() - self._last_request)94 if wait > 0:95 time.sleep(wait)96 resp = self.session.post(url, timeout=self.timeout, **kw)97 self._last_request = time.time()98 resp.raise_for_status()99 return resp100101 def get_scrapfly(self, url: str, render_js: bool = False,102 asp: bool = True, country: str = "ca",103 wait_for: str | None = None) -> str:104 """Récupère le HTML via Scrapfly — backend robuste (anti-bot bypass).105106 Nécessite SCRAPFLY_API_KEY dans l'environnement (.env).107 `asp=True` active l'Anti Scraping Protection bypass (Cloudflare…),108 `render_js=True` exécute le JavaScript (navigateur headless),109 `wait_for` attend un sélecteur CSS avant de capturer le HTML.110 """111 key = os.environ.get("SCRAPFLY_API_KEY")112 if not key:113 raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")114 params: dict = {"key": key, "url": url, "country": country}115 if asp:116 params["asp"] = "true"117 if render_js:118 params["render_js"] = "true"119 if wait_for:120 params["render_js"] = "true"121 params["wait_for_selector"] = wait_for122 resp = requests.get(f"{SCRAPFLY_API}?{urlencode(params)}", timeout=120)123 resp.raise_for_status()124 data = resp.json()125 result = data.get("result") or {}126 status = result.get("status_code")127 if status and int(status) >= 400:128 raise RuntimeError(f"Scrapfly: la cible a répondu {status} pour {url}")129 return result.get("content", "")130131 def get_firecrawl(self, url: str, formats: list[str] | None = None) -> dict:132 """Récupère la page via Firecrawl (rendu JavaScript).133134 Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).135 Retourne le dict `data` de Firecrawl ({"html": …, "markdown": …}).136 """137 key = os.environ.get("FIRECRAWL_API_KEY")138 if not key:139 raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")140 resp = requests.post(141 FIRECRAWL_API,142 json={"url": url, "formats": formats or ["html"]},143 headers={"Authorization": f"Bearer {key}"},144 timeout=90,145 )146 resp.raise_for_status()147 return resp.json().get("data") or {}148149 def get_rendered(self, url: str) -> str:150 """HTML rendu (JavaScript exécuté) — Scrapfly d'abord (plus robuste),151 Firecrawl en repli. À utiliser pour les sites SPA / derrière Cloudflare.152 """153 try:154 html = self.get_scrapfly(url, render_js=True)155 if html:156 return html157 except Exception:158 pass159 return self.get_firecrawl(url).get("html", "")160161 def fetch_html(self, url: str) -> str:162 """HTML d'une page « normale » avec repli automatique :163 requests direct -> Scrapfly (asp) -> Firecrawl.164 """165 try:166 resp = self.get(url)167 if resp.status_code == 200 and len(resp.text) > 500:168 return resp.text169 except Exception:170 pass171 try:172 html = self.get_scrapfly(url)173 if html:174 return html175 except Exception:176 pass177 return self.get_firecrawl(url).get("html", "")178179 def detail(self, external_id: str, key: str, fetch_fn) -> dict:180 """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si181 la formation est nouvelle ou si sa clé (hash du contenu liste) a changé.182183 Permet d'extraire les champs riches (description, objectifs, plan de184 cours, préalables…) sans revisiter chaque page à chaque synchronisation.185 `fetch_fn` doit retourner un dict JSON-sérialisable.186 """187 if not self.use_detail_cache:188 return fetch_fn() or {}189 from .. import db190 if self._detail_con is None:191 self._detail_con = db.connect()192 cached = db.get_cached_detail(self._detail_con, self.source_id,193 str(external_id), key)194 if cached is not None:195 return cached196 payload = fetch_fn() or {}197 db.put_cached_detail(self._detail_con, self.source_id,198 str(external_id), key, payload)199 return payload200201 # -- contrat --------------------------------------------------------------202 def fetch(self) -> list[Formation]:203 raise NotImplementedError204