# =============================================================================
# Job·Ka — Groupe KA
# Auteur : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Fichier : jobka/connectors/njoyn.py
# Rôle : Classe de plateforme Njoyn (CGI) — très répandu au secteur public
# québécois (santé, villes, sociétés d'État). HTML serveur + jeton.
# Créé : 2026-08-18 Modifié : 2026-09-13
# =============================================================================
"""Plateforme Njoyn (CGI).
Les babillards Njoyn servent un HTML rendu côté serveur :
//xweb/xweb.asp?clid=&page=joblisting&lang=
Le serveur redirige en ajoutant un jeton de session (tbtoken) — il suffit de
suivre la redirection. Chaque offre est un (habillage moderne) ou
une ligne de tableau (habillage classique) pointant vers
``page=jobdetails&jobid=J####-####``.
⚠ Les domaines *.njoyn.com sont derrière Radware Bot Manager : pour ces
tenants, mettre ``USE_SCRAPFLY = True`` (contournement ASP). Depuis la
maintenance plateforme du 2026-09-05→07, Radware couvre aussi des domaines
personnalisés (ex. emplois.santemontreal.qc.ca depuis le 2026-09-08) ; le
fallback de ``_html`` bascule alors tout le sync sur Scrapfly.
"""
from __future__ import annotations
import hashlib
import html as _html
import os
import re
import time
import requests
from ..schema import JobPosting, clean_html
from .base import BaseConnector
MAX_DETAILS = int(os.environ.get("JOBKA_NJOYN_DETAIL_LIMIT", "40"))
PAGE_SIZE = 500 # le formulaire de recherche accepte Inp_ItemsPerPage
_ARTICLE_RE = re.compile(r"]*>(.*?)", re.S | re.I)
_H4_RE = re.compile(r"
", re.S | re.I)
_TOTAL_RE = re.compile(
r"(?:R[ée]sultats de (?:la )?recherche|Search results)\s*\((\d+)\)", re.I)
# Maintenance planifiée de la plateforme entière (constatée 2026-09-05 ~22h30
# EDT, typiquement vendredi soir → dimanche ~21h) : TOUS les tenants — domaines
# *.njoyn.com ET personnalisés — redirigent vers
# maintenance.njoyn.com/.../platform-maintenance.html (~29 ko).
_MAINT_RE = re.compile(
r"Platform maintenance \| Njoyn|platform-maintenance", re.I)
# État transitoire post-maintenance (constaté 2026-09-07 ~11h-15h EDT, fenêtre
# officielle jusqu'à dimanche ~21h) : TOUS les tenants répondent 200 avec un
# corps minuscule « Invalid request XWP10023-UA » — y compris
# pour un vrai navigateur (vérifié Chromium/Playwright depuis IP résidentielle).
# Ce n'est PAS de l'anti-bot : échec immédiat, sans retry ni 2e crédit Scrapfly.
_INVALID_RE = re.compile(r"Invalid request XWP\d+", re.I)
# Mur Radware Bot Manager (« Radware Block Page », CSS shieldsquare) : depuis
# la maintenance du 2026-09-05→07 il couvre AUSSI des domaines personnalisés
# (constaté 2026-09-08 sur emplois.santemontreal.qc.ca). ⚠ La page challenge
# embarque l'URL Xweb.asp dans ses paramètres : sans détection dédiée elle
# passait le garde-fou de _fetch_listing et donnait un faux « 0 offre, ok ».
_RADWARE_RE = re.compile(r"Radware Block Page|shieldsquare|perfdrive", re.I)
def _txt(s: str) -> str:
return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s or ""))).strip()
class NjoynConnector(BaseConnector):
"""Base Njoyn — sous-classes : définir source_id, EMPLOYER, BASE, CL, CLID."""
ats = "njoyn"
request_delay = 1.2
EMPLOYER = ""
BASE = "" # ex. https://emplois.santemontreal.qc.ca
CL = "CL3" # instance (CL2, CL3, CL4…)
CLID = "" # identifiant client Njoyn
LANG = "2" # 2 = français, 1 = anglais
USE_SCRAPFLY = False # requis pour les domaines *.njoyn.com (Radware)
max_pages = 6 # pagination du formulaire (PAGE_SIZE offres/page)
max_details = MAX_DETAILS # abaisser pour les babillards via Scrapfly
quebec_only = False # filtrer (table classique : colonnes ville/pays)
def _url(self, page: str, jobid: str = "") -> str:
# ⚠ Casse du chemin : « Xweb.asp » avec X majuscule OBLIGATOIRE.
# Depuis le 2026-09-02, le couple Njoyn/Radware répond une page
# malformée au chemin minuscule « xweb.asp » (Scrapfly ASP échoue en
# ERR::SCRAPE::UPSTREAM_WEBSITE_ERROR, 0 octet) — tous les tenants
# *.njoyn.com étaient tombés d'un coup pour cette seule raison.
u = (f"{self.BASE}/{self.CL}/xweb/Xweb.asp?clid={self.CLID}"
f"&page={page}&lang={self.LANG}")
if jobid:
u += f"&jobid={jobid}"
return u
def _sf_session(self) -> str:
"""Nom de session Scrapfly du sync courant : cookies + IP (proxy
collant) partagés entre le GET liste, les POST de pagination et les
pages détail. Sans session, le tbtoken du formulaire (lié aux cookies
ASP de la session du GET) est rejeté en 400 Bad Request au POST."""
if not getattr(self, "_sf_session_name", None):
self._sf_session_name = f"njoyn-{self.source_id}-{int(time.time())}"
return self._sf_session_name
def _html(self, url: str) -> str:
if self.USE_SCRAPFLY:
return self.get_scrapfly(url, render_js=False,
session=self._sf_session())
text = self.get(url).text
if _RADWARE_RE.search(text[:4000]): # mur anti-bot : bascule Scrapfly
# … pour TOUTE la suite du sync (pagination POST et pages détail
# passent par le même mur), pas seulement cette requête.
self.USE_SCRAPFLY = True
return self.get_scrapfly(url, render_js=False,
session=self._sf_session())
return text
def _fetch_detail(self, jobid: str) -> dict:
page = self._html(self._url("jobdetails", jobid))
page = re.sub(r"<(noscript|nav|script|style)[^>]*>.*?\1>", " ", page,
flags=re.S | re.I)
# habillage moderne : sections
Titre
corps
sections = re.findall(
r'
\s*
]*>(.*?)
(.*?)
', page,
re.S | re.I)
if sections:
body = "\n\n".join(f"{clean_html(h)}\n{clean_html(b)}"
for h, b in sections)
return {"description": body[:20000]}
# habillage classique : contenu principal après le