SPB Git

spb/forma-ka Public

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