HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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>([^<:]+?)(?: )?\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