SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
4 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
19.4 KB · 402 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/njoyn.py6# Rôle    : Classe de plateforme Njoyn (CGI) — très répandu au secteur public7#           québécois (santé, villes, sociétés d'État). HTML serveur + jeton.8# Créé    : 2026-08-18   Modifié : 2026-09-139# =============================================================================10"""Plateforme Njoyn (CGI).1112Les babillards Njoyn servent un HTML rendu côté serveur :13    <BASE>/<CL>/xweb/xweb.asp?clid=<CLID>&page=joblisting&lang=<n>14Le serveur redirige en ajoutant un jeton de session (tbtoken) — il suffit de15suivre la redirection. Chaque offre est un <article> (habillage moderne) ou16une ligne de tableau (habillage classique) pointant vers17``page=jobdetails&jobid=J####-####``.1819⚠ Les domaines *.njoyn.com sont derrière Radware Bot Manager : pour ces20tenants, mettre ``USE_SCRAPFLY = True`` (contournement ASP). Depuis la21maintenance plateforme du 2026-09-05→07, Radware couvre aussi des domaines22personnalisés (ex. emplois.santemontreal.qc.ca depuis le 2026-09-08) ; le23fallback de ``_html`` bascule alors tout le sync sur Scrapfly.24"""25from __future__ import annotations2627import hashlib28import html as _html29import os30import re31import time3233import requests3435from ..schema import JobPosting, clean_html36from .base import BaseConnector3738MAX_DETAILS = int(os.environ.get("JOBKA_NJOYN_DETAIL_LIMIT", "40"))39PAGE_SIZE = 500     # le formulaire de recherche accepte Inp_ItemsPerPage4041_ARTICLE_RE = re.compile(r"<article[^>]*>(.*?)</article>", re.S | re.I)42_H4_RE = re.compile(r"<h4[^>]*>(.*?)</h4>", re.S | re.I)43_TIME_RE = re.compile(r"<time[^>]*>(.*?)</time>", re.S | re.I)44_JOBID_RE = re.compile(r"jobid=(J\d{4}-\d+)", re.I)45_FIELD_RE = re.compile(46    r"<li><strong>([^<:]+?)(?:&nbsp;)?\s*:?\s*</strong>\s*(.*?)</li>",47    re.S | re.I)48_APPLY_RE = re.compile(49    r"<a[^>]*class='btn btn-primary'[^>]*href='([^']+)'", re.I)50_ROW_LINK_RE = re.compile(51    r'''<a[^>]*href=["'][^"']*jobid=(J\d{4}-\d+)[^"']*["'][^>]*>(.*?)</a>''',52    re.S | re.I)53_TR_RE = re.compile(r"<tr[^>]*>(.*?)</tr>", re.S | re.I)54_TD_RE = re.compile(r"<td[^>]*>(.*?)</td>", re.S | re.I)55_TOTAL_RE = re.compile(56    r"(?:R[ée]sultats de (?:la )?recherche|Search results)\s*\((\d+)\)", re.I)57# Maintenance planifiée de la plateforme entière (constatée 2026-09-05 ~22h3058# EDT, typiquement vendredi soir → dimanche ~21h) : TOUS les tenants — domaines59# *.njoyn.com ET personnalisés — redirigent vers60# maintenance.njoyn.com/.../platform-maintenance.html (~29 ko).61_MAINT_RE = re.compile(62    r"Platform maintenance \| Njoyn|platform-maintenance", re.I)63# État transitoire post-maintenance (constaté 2026-09-07 ~11h-15h EDT, fenêtre64# officielle jusqu'à dimanche ~21h) : TOUS les tenants répondent 200 avec un65# corps minuscule « Invalid request XWP10023-UA <ip du client> » — y compris66# pour un vrai navigateur (vérifié Chromium/Playwright depuis IP résidentielle).67# Ce n'est PAS de l'anti-bot : échec immédiat, sans retry ni 2e crédit Scrapfly.68_INVALID_RE = re.compile(r"Invalid request XWP\d+", re.I)69# Mur Radware Bot Manager (« Radware Block Page », CSS shieldsquare) : depuis70# la maintenance du 2026-09-05→07 il couvre AUSSI des domaines personnalisés71# (constaté 2026-09-08 sur emplois.santemontreal.qc.ca). ⚠ La page challenge72# embarque l'URL Xweb.asp dans ses paramètres : sans détection dédiée elle73# passait le garde-fou de _fetch_listing et donnait un faux « 0 offre, ok ».74_RADWARE_RE = re.compile(r"Radware Block Page|shieldsquare|perfdrive", re.I)757677def _txt(s: str) -> str:78    return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s or ""))).strip()798081class NjoynConnector(BaseConnector):82    """Base Njoyn — sous-classes : définir source_id, EMPLOYER, BASE, CL, CLID."""8384    ats = "njoyn"85    request_delay = 1.28687    EMPLOYER = ""88    BASE = ""              # ex. https://emplois.santemontreal.qc.ca89    CL = "CL3"             # instance (CL2, CL3, CL4…)90    CLID = ""              # identifiant client Njoyn91    LANG = "2"             # 2 = français, 1 = anglais92    USE_SCRAPFLY = False   # requis pour les domaines *.njoyn.com (Radware)93    max_pages = 6          # pagination du formulaire (PAGE_SIZE offres/page)94    max_details = MAX_DETAILS   # abaisser pour les babillards via Scrapfly95    quebec_only = False    # filtrer (table classique : colonnes ville/pays)9697    def _url(self, page: str, jobid: str = "") -> str:98        # ⚠ Casse du chemin : « Xweb.asp » avec X majuscule OBLIGATOIRE.99        # Depuis le 2026-09-02, le couple Njoyn/Radware répond une page100        # malformée au chemin minuscule « xweb.asp » (Scrapfly ASP échoue en101        # ERR::SCRAPE::UPSTREAM_WEBSITE_ERROR, 0 octet) — tous les tenants102        # *.njoyn.com étaient tombés d'un coup pour cette seule raison.103        u = (f"{self.BASE}/{self.CL}/xweb/Xweb.asp?clid={self.CLID}"104             f"&page={page}&lang={self.LANG}")105        if jobid:106            u += f"&jobid={jobid}"107        return u108109    def _sf_session(self) -> str:110        """Nom de session Scrapfly du sync courant : cookies + IP (proxy111        collant) partagés entre le GET liste, les POST de pagination et les112        pages détail. Sans session, le tbtoken du formulaire (lié aux cookies113        ASP de la session du GET) est rejeté en 400 Bad Request au POST."""114        if not getattr(self, "_sf_session_name", None):115            self._sf_session_name = f"njoyn-{self.source_id}-{int(time.time())}"116        return self._sf_session_name117118    def _html(self, url: str) -> str:119        if self.USE_SCRAPFLY:120            return self.get_scrapfly(url, render_js=False,121                                     session=self._sf_session())122        text = self.get(url).text123        if _RADWARE_RE.search(text[:4000]):   # mur anti-bot : bascule Scrapfly124            # … pour TOUTE la suite du sync (pagination POST et pages détail125            # passent par le même mur), pas seulement cette requête.126            self.USE_SCRAPFLY = True127            return self.get_scrapfly(url, render_js=False,128                                     session=self._sf_session())129        return text130131    def _fetch_detail(self, jobid: str) -> dict:132        page = self._html(self._url("jobdetails", jobid))133        page = re.sub(r"<(noscript|nav|script|style)[^>]*>.*?</\1>", " ", page,134                      flags=re.S | re.I)135        # habillage moderne : sections <div class="row"><h2>Titre</h2>corps</div>136        sections = re.findall(137            r'<div class="row">\s*<h2[^>]*>(.*?)</h2>(.*?)</div>', page,138            re.S | re.I)139        if sections:140            body = "\n\n".join(f"{clean_html(h)}\n{clean_html(b)}"141                               for h, b in sections)142            return {"description": body[:20000]}143        # habillage classique : contenu principal après le <h1>144        m = re.search(r"<h1[^>]*>.*?</h1>(.*?)(?:<form|<footer|</main)", page,145                      re.S | re.I)146        body = m.group(1) if m else ""147        return {"description": clean_html(body)[:20000]}148149    def _parse_articles(self, page: str) -> list[dict]:150        items = []151        for art in _ARTICLE_RE.findall(page):152            m = _JOBID_RE.search(art)153            if not m:154                continue155            fields = {_txt(k).lower(): _txt(v) for k, v in _FIELD_RE.findall(art)}156            apply_m = _APPLY_RE.search(art)157            h4 = _H4_RE.search(art)158            t = _TIME_RE.search(art)159            items.append({160                "jobid": m.group(1),161                "title": _txt(h4.group(1)) if h4 else "",162                "date": _txt(t.group(1)).lstrip("Le ").strip() if t else "",163                "fields": fields,164                "apply_url": apply_m.group(1) if apply_m else "",165            })166        return items167168    def _parse_rows(self, page: str) -> list[dict]:169        """Habillage classique : simples liens de tableau vers jobdetails."""170        items, seen = [], set()171        for jobid, label in _ROW_LINK_RE.findall(page):172            title = _txt(label)173            if not title or title.lower().startswith(("en savoir", "postuler",174                                                      "apply", "more")):175                title = ""176            if jobid in seen:177                # garder le premier libellé non vide178                if title:179                    for it in items:180                        if it["jobid"] == jobid and not it["title"]:181                            it["title"] = title182                continue183            seen.add(jobid)184            items.append({"jobid": jobid, "title": title, "date": "",185                          "fields": {}, "apply_url": ""})186        return [it for it in items if it["title"]]187188    def _parse_table(self, page: str) -> list[dict]:189        """Habillage tableau multi-colonnes : N° | Titre | Catégorie | Ville |190        Pays (ex. CGI). Signature stricte — le libellé du lien est le numéro191        de poste lui-même et la ligne a ≥ 4 colonnes — pour ne JAMAIS192        intercepter les habillages « titre dans le lien » (STL, RTC…)."""193        items, seen = [], set()194        for tr in _TR_RE.findall(page):195            m = _ROW_LINK_RE.search(tr)196            if not m or m.group(1) in seen:197                continue198            if _txt(m.group(2)) != m.group(1):199                continue           # le lien porte un titre : autre habillage200            tds = [_txt(td) for td in _TD_RE.findall(tr)]201            if len(tds) < 4:202                continue203            seen.add(m.group(1))204            title = tds[1] if len(tds) > 1 else ""205            items.append({206                "jobid": m.group(1), "title": title, "date": "",207                "fields": {}, "apply_url": "",208                "category": tds[2] if len(tds) > 2 else "",209                "city": tds[3] if len(tds) > 3 else "",210                "country": tds[4] if len(tds) > 4 else "",211            })212        return [it for it in items if it["title"] and it["title"] != it["jobid"]]213214    def _post_form(self, action: str, data: dict) -> str:215        if self.USE_SCRAPFLY:216            from urllib.parse import urlencode217            return self.scrapfly(218                action, render_js=False, method="POST",219                body=urlencode(data),220                headers={"Content-Type": "application/x-www-form-urlencoded"},221                session=self._sf_session(),222            ).get("content") or ""223        return self.post(action, data=data).text224225    def _paginate(self, page: str, items: list[dict]) -> list[dict]:226        """Le rendu direct ne liste que ~50 offres : rejouer le formulaire de227        recherche (POST, mêmes cookies de session) avec Inp_ItemsPerPage/pn228        pour lire tout le babillard (constaté : Santé Montréal 762 offres).229        En cas d'échec (habillage différent), la première page est conservée.230        Le total affiché (« Résultats de recherche (N) ») évite de rejouer le231        formulaire quand la première page contient déjà tout le babillard.232        """233        m_tot = _TOTAL_RE.search(page)234        total = int(m_tot.group(1)) if m_tot else None235        if total is not None and total <= len(items):236            return items                     # tout est déjà sur la page 1237        if total is None and len(items) < 50:238            return items                     # page visiblement complète239        m = re.search(r'<form[^>]*action="([^"]+)"', page, re.I)240        if not m:241            return items242        action = _html.unescape(m.group(1))243        # Même gotcha de casse que _url : le formulaire annonce « xweb.asp »244        # minuscule, que le couple Njoyn/Radware rejette depuis le 2026-09-02.245        action = re.sub(r"xweb\.asp", "Xweb.asp", action)246        if not action.startswith("http"):247            action = f"{self.BASE}/{self.CL}/xweb/{action}"248        m_sort = re.search(249            r'<input[^>]*name="s"[^>]*value="([^"]*)"', page, re.I) or \250            re.search(r'<input[^>]*value="([^"]*)"[^>]*name="s"', page, re.I)251        form = {"s": m_sort.group(1) if m_sort else " PostDateFrom2 DESC ",252                "hiddencategoryid": "", "Inp_Keywords": "",253                "Inp_XWEB_JobListing_JobID": "", "inp_City": "",254                "joblistingsearchbutton": "Recherche",255                "Inp_ItemsPerPage": str(PAGE_SIZE)}256        merged: list[dict] = []257        seen: set[str] = set()258        for pn in range(1, self.max_pages + 1):259            try:260                html_pn = self._post_form(action, {**form, "pn": str(pn)})261            except Exception:262                break263            batch = (self._parse_articles(html_pn)264                     or self._parse_table(html_pn)265                     or self._parse_rows(html_pn))266            new = [it for it in batch if it["jobid"] not in seen]267            if not new:268                break269            for it in new:270                seen.add(it["jobid"])271                merged.append(it)272            if len(batch) < PAGE_SIZE:273                break274            if total is not None and len(merged) >= total:275                break276        if not merged:                       # formulaire non joué : page 1277            return items278        for it in items:                     # filet : rien perdre de la p.1279            if it["jobid"] not in seen:280                merged.append(it)281        return merged282283    def _keep(self, it: dict) -> bool:284        """Filtre Québec pour les babillards pancanadiens/mondiaux (colonnes285        ville/pays de l'habillage tableau)."""286        if not self.quebec_only:287            return True288        from ..schema import is_quebec_location289        country = (it.get("country") or "").strip().lower()290        if country and country not in ("canada", "ca"):291            # certains habillages (ex. Groupe Master) mettent la DATE dans292            # cette colonne : n'exclure que si c'est clairement un autre pays293            if country in ("united states", "usa", "us", "états-unis",294                           "etats-unis", "france", "mexico", "mexique",295                           "united kingdom", "uk", "germany", "allemagne",296                           "india", "inde", "australia", "australie"):297                return False298        place = (it.get("city") or299                 it.get("fields", {}).get("villes et arrondissements") or "")300        return bool(place) and is_quebec_location(place.replace("-", " "))301302    def _fetch_listing(self) -> tuple[str, list[dict]]:303        """Page liste + items parsés, avec garde-fou : une réponse vide ou304        anti-bot (Scrapfly en échec renvoie "" — voir base.get_scrapfly) ne305        doit JAMAIS passer pour un babillard vide. Toute page Njoyn légitime,306        même sans offre, contient le formulaire xweb.asp — sinon on retente307        (3 essais, backoff 10-20 s) puis on lève une erreur (sync en échec,308        pas « 0 trouvé »). ⚠ Entre deux essais, ré-amorcer la session309        Scrapfly : la session colle cookies + IP de proxy, donc retenter sur310        la même session rejoue le même proxy défaillant (constaté 2026-09-09311        12h26 : bellemare et groupe_master tombés ensemble, 0 octet aux deux312        essais de la même session, repartis au sync suivant)."""313        for attempt in range(3):314            if attempt:315                time.sleep(10 * attempt)316                self._sf_session_name = None   # nouvelle session = nouveau proxy317            try:318                page = self._html(self._url("joblisting"))319            except requests.RequestException:320                # Panne réseau transitoire vers api.scrapfly.io ou le site321                # (constaté 2026-09-13 04:28 : ConnectTimeout 180 s vers l'API322                # Scrapfly sur groupe_bellemare, seul échec de tout le cycle) :323                # même traitement qu'une réponse vide — nouvelle session,324                # backoff, retry — au lieu de laisser l'exception avorter le325                # sync au premier essai.326                if attempt == 2:327                    raise328                continue329            if _MAINT_RE.search(page):330                # Maintenance plateforme : inutile de retenter dans 10 s (la331                # fenêtre dure des heures) — échec immédiat et explicite, les332                # offres en base restent actives.333                raise RuntimeError(334                    f"njoyn {self.source_id}: plateforme Njoyn en maintenance "335                    f"planifiée (redirection maintenance.njoyn.com) — sync "336                    f"avorté, offres conservées")337            if _INVALID_RE.search(page[:200]):338                # Plateforme en état transitoire (« Invalid request XWP… ») :339                # touche même un vrai navigateur — inutile de retenter.340                raise RuntimeError(341                    f"njoyn {self.source_id}: plateforme Njoyn indisponible "342                    f"(réponse « Invalid request XWP10023 », état transitoire "343                    f"post-maintenance) — sync avorté, offres conservées")344            items = (self._parse_articles(page) or self._parse_table(page)345                     or self._parse_rows(page))346            if items or re.search(r"xweb\.asp", page, re.I):347                return page, items348        raise RuntimeError(349            f"njoyn {self.source_id}: page liste illisible après 3 essais "350            f"(vide ou anti-bot, {len(page)} octets) — sync avorté")351352    def fetch(self) -> list[JobPosting]:353        page, items = self._fetch_listing()354        items = self._paginate(page, items)355        items = [it for it in items if self._keep(it)]356        out: list[JobPosting] = []357        details_used = 0358        for it in items:359            f = it["fields"]360            salary = f.get("salaire", "")361            if re.match(r"^0\s*\$\s*-\s*0\s*\$", salary):362                salary = ""363            job = JobPosting(364                source=self.source_id, external_id=it["jobid"],365                url=self._url("jobdetails", it["jobid"]),366                employer=f.get("établissement") or f.get("etablissement")367                or self.EMPLOYER,368                title=it["title"],369                city=(f.get("villes et arrondissements", "").split(",")[0]370                      or (it.get("city") or "").split(",")[0]),371                location_label=(f.get("villes et arrondissements", "")372                                or it.get("city") or ""),373                salary_label=salary,374                date_posted=it["date"] or None,375                ats=self.ats,376            )377            if f.get("statut de l'employé") or f.get("statut de l'employe"):378                job.details["employment_label"] = (379                    f.get("statut de l'employé") or f.get("statut de l'employe"))380            if it.get("category"):381                job.details["team"] = it["category"]382            if f.get("niveau de scolarité"):383                job.requirements["scolarite"] = f["niveau de scolarité"]384            if it["apply_url"]:385                job.apply_url = it["apply_url"]386                job.details["apply_url"] = it["apply_url"]387            key = hashlib.sha1(388                f"{it['title']}|{it['date']}".encode()).hexdigest()[:12]389            if details_used < self.max_details:390                fresh = [False]391392                def _fn(j=it["jobid"], fresh=fresh):393                    fresh[0] = True394                    return self._fetch_detail(j)395396                d = self.detail(it["jobid"], key, _fn)397                if fresh[0]:398                    details_used += 1399                job.description = d.get("description", "")400            out.append(job)401        return out402