[ka6] fix connecteur sante_montreal: Radware étendu au domaine personnalisé post-maintenance Njoyn — bascule Scrapfly + sessions Scrapfly pour la pagination
Depuis la maintenance Njoyn du 2026-09-05→07, Radware Bot Manager couvre aussi emplois.santemontreal.qc.ca (avant : seulement *.njoyn.com). Chronologie des syncs : 731 offres (08/09 14h32) → 50 (POST de pagination rejeté) → 0 (page liste bloquée). La page « Radware Block Page » embarque l'URL Xweb.asp dans ses paramètres et passait le garde-fou de _fetch_listing → faux « 0 offre, ok ». - sante_montreal.py : USE_SCRAPFLY = True (pattern des 10 autres tenants Njoyn) - njoyn.py : détection _RADWARE_RE (Radware Block Page/shieldsquare/perfdrive) dans _html avec bascule Scrapfly pour tout le reste du sync ; sessions Scrapfly (_sf_session) partagées entre GET liste / POST pagination / détails — sans session le tbtoken (lié aux cookies ASP du GET) est rejeté en 400 Bad Request au POST ; casse Xweb.asp forcée sur l'action du formulaire (gotcha 2026-09-02) - base.py : kwarg optionnel session sur scrapfly()/get_scrapfly() (session + session_sticky_proxy), rétrocompatible Sync rejoué : 726 offres (médiane historique ~706), removed=16 offres dépubliées pendant la panne. pm2 restart job-ka-sync OK, site 200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 changed files +52 −12
modified
jobka/connectors/base.py
+14 −4
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | # Fichier : jobka/connectors/base.py |
| 6 | 6 | # Rôle : Classe de base des connecteurs + backends de fetch (requests |
| 7 | 7 | # direct, Scrapfly pour les pages carrières JS / derrière anti-bot) |
| 8 | −# Créé : 2026-08-17 Modifié : 2026-08-27 | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-09-09 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | from __future__ import annotations |
| 11 | 11 | |
@@ -69,13 +69,18 @@ class BaseConnector: | ||
| 69 | 69 | wait_for_selector: str | None = None, |
| 70 | 70 | js_scenario: list | str | None = None, |
| 71 | 71 | proxy_pool: str | None = None, headers: dict | None = None, |
| 72 | − method: str = "GET", body: str | None = None) -> dict: | |
| 72 | + method: str = "GET", body: str | None = None, | |
| 73 | + session: str | None = None) -> dict: | |
| 73 | 74 | """Appel Scrapfly complet — retourne le dict `result` (content, status_code…). |
| 74 | 75 | |
| 75 | 76 | - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (base64) |
| 76 | 77 | pour charger les listes d'offres virtualisées. |
| 77 | 78 | - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif). |
| 78 | 79 | - `headers`/`method`/`body` : pour rejouer une API JSON interne via ASP. |
| 80 | + - `session` : nom de session Scrapfly — cookies + IP (proxy collant) | |
| 81 | + partagés entre les requêtes du même sync ; requis quand un POST doit | |
| 82 | + rejouer un jeton lié à la session du GET (ex. tbtoken Njoyn, sinon | |
| 83 | + 400 Bad Request). | |
| 79 | 84 | """ |
| 80 | 85 | import base64 |
| 81 | 86 | key = os.environ.get("SCRAPFLY_API_KEY") |
@@ -95,6 +100,9 @@ class BaseConnector: | ||
| 95 | 100 | if js_scenario is not None: |
| 96 | 101 | js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario) |
| 97 | 102 | params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode() |
| 103 | + if session: | |
| 104 | + params["session"] = session | |
| 105 | + params["session_sticky_proxy"] = "true" | |
| 98 | 106 | if headers: |
| 99 | 107 | for k, v in headers.items(): |
| 100 | 108 | params[f"headers[{k}]"] = v |
@@ -114,13 +122,15 @@ class BaseConnector: | ||
| 114 | 122 | rendering_wait: int = 0, country: str = "ca", |
| 115 | 123 | wait_for_selector: str | None = None, |
| 116 | 124 | js_scenario: list | str | None = None, |
| 117 | − proxy_pool: str | None = None) -> str: | |
| 125 | + proxy_pool: str | None = None, | |
| 126 | + session: str | None = None) -> str: | |
| 118 | 127 | """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne |
| 119 | 128 | le HTML (result.content) ou "" en cas d'échec ASP.""" |
| 120 | 129 | return self.scrapfly(url, render_js=render_js, asp=asp, |
| 121 | 130 | rendering_wait=rendering_wait, country=country, |
| 122 | 131 | wait_for_selector=wait_for_selector, |
| 123 | − js_scenario=js_scenario, proxy_pool=proxy_pool | |
| 132 | + js_scenario=js_scenario, proxy_pool=proxy_pool, | |
| 133 | + session=session | |
| 124 | 134 | ).get("content") or "" |
| 125 | 135 | |
| 126 | 136 | def detail(self, external_id: str, key: str, fetch_fn) -> dict: |
modified
jobka/connectors/njoyn.py
+32 −7
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | # Fichier : jobka/connectors/njoyn.py |
| 6 | 6 | # Rôle : Classe de plateforme Njoyn (CGI) — très répandu au secteur public |
| 7 | 7 | # québécois (santé, villes, sociétés d'État). HTML serveur + jeton. |
| 8 | −# Créé : 2026-08-18 Modifié : 2026-09-07 | |
| 8 | +# Créé : 2026-08-18 Modifié : 2026-09-09 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | """Plateforme Njoyn (CGI). |
| 11 | 11 | |
@@ -17,9 +17,10 @@ une ligne de tableau (habillage classique) pointant vers | ||
| 17 | 17 | ``page=jobdetails&jobid=J####-####``. |
| 18 | 18 | |
| 19 | 19 | ⚠ Les domaines *.njoyn.com sont derrière Radware Bot Manager : pour ces |
| 20 | −tenants, mettre ``USE_SCRAPFLY = True`` (contournement ASP). Les babillards | |
| 21 | −servis sur un domaine personnalisé (ex. emplois.santemontreal.qc.ca) se | |
| 22 | −consultent directement. | |
| 20 | +tenants, mettre ``USE_SCRAPFLY = True`` (contournement ASP). Depuis la | |
| 21 | +maintenance plateforme du 2026-09-05→07, Radware couvre aussi des domaines | |
| 22 | +personnalisés (ex. emplois.santemontreal.qc.ca depuis le 2026-09-08) ; le | |
| 23 | +fallback de ``_html`` bascule alors tout le sync sur Scrapfly. | |
| 23 | 24 | """ |
| 24 | 25 | from __future__ import annotations |
| 25 | 26 | |
@@ -63,6 +64,12 @@ _MAINT_RE = re.compile( | ||
| 63 | 64 | # pour un vrai navigateur (vérifié Chromium/Playwright depuis IP résidentielle). |
| 64 | 65 | # Ce n'est PAS de l'anti-bot : échec immédiat, sans retry ni 2e crédit Scrapfly. |
| 65 | 66 | _INVALID_RE = re.compile(r"Invalid request XWP\d+", re.I) |
| 67 | +# Mur Radware Bot Manager (« Radware Block Page », CSS shieldsquare) : depuis | |
| 68 | +# la maintenance du 2026-09-05→07 il couvre AUSSI des domaines personnalisés | |
| 69 | +# (constaté 2026-09-08 sur emplois.santemontreal.qc.ca). ⚠ La page challenge | |
| 70 | +# embarque l'URL Xweb.asp dans ses paramètres : sans détection dédiée elle | |
| 71 | +# passait le garde-fou de _fetch_listing et donnait un faux « 0 offre, ok ». | |
| 72 | +_RADWARE_RE = re.compile(r"Radware Block Page|shieldsquare|perfdrive", re.I) | |
| 66 | 73 | |
| 67 | 74 | |
| 68 | 75 | def _txt(s: str) -> str: |
@@ -97,12 +104,26 @@ class NjoynConnector(BaseConnector): | ||
| 97 | 104 | u += f"&jobid={jobid}" |
| 98 | 105 | return u |
| 99 | 106 | |
| 107 | + def _sf_session(self) -> str: | |
| 108 | + """Nom de session Scrapfly du sync courant : cookies + IP (proxy | |
| 109 | + collant) partagés entre le GET liste, les POST de pagination et les | |
| 110 | + pages détail. Sans session, le tbtoken du formulaire (lié aux cookies | |
| 111 | + ASP de la session du GET) est rejeté en 400 Bad Request au POST.""" | |
| 112 | + if not getattr(self, "_sf_session_name", None): | |
| 113 | + self._sf_session_name = f"njoyn-{self.source_id}-{int(time.time())}" | |
| 114 | + return self._sf_session_name | |
| 115 | + | |
| 100 | 116 | def _html(self, url: str) -> str: |
| 101 | 117 | if self.USE_SCRAPFLY: |
| 102 | − return self.get_scrapfly(url, render_js=False) | |
| 118 | + return self.get_scrapfly(url, render_js=False, | |
| 119 | + session=self._sf_session()) | |
| 103 | 120 | text = self.get(url).text |
| 104 | − if "perfdrive" in text[:4000]: # mur anti-bot : bascule Scrapfly | |
| 105 | − return self.get_scrapfly(url, render_js=False) | |
| 121 | + if _RADWARE_RE.search(text[:4000]): # mur anti-bot : bascule Scrapfly | |
| 122 | + # … pour TOUTE la suite du sync (pagination POST et pages détail | |
| 123 | + # passent par le même mur), pas seulement cette requête. | |
| 124 | + self.USE_SCRAPFLY = True | |
| 125 | + return self.get_scrapfly(url, render_js=False, | |
| 126 | + session=self._sf_session()) | |
| 106 | 127 | return text |
| 107 | 128 | |
| 108 | 129 | def _fetch_detail(self, jobid: str) -> dict: |
@@ -195,6 +216,7 @@ class NjoynConnector(BaseConnector): | ||
| 195 | 216 | action, render_js=False, method="POST", |
| 196 | 217 | body=urlencode(data), |
| 197 | 218 | headers={"Content-Type": "application/x-www-form-urlencoded"}, |
| 219 | + session=self._sf_session(), | |
| 198 | 220 | ).get("content") or "" |
| 199 | 221 | return self.post(action, data=data).text |
| 200 | 222 | |
@@ -216,6 +238,9 @@ class NjoynConnector(BaseConnector): | ||
| 216 | 238 | if not m: |
| 217 | 239 | return items |
| 218 | 240 | action = _html.unescape(m.group(1)) |
| 241 | + # Même gotcha de casse que _url : le formulaire annonce « xweb.asp » | |
| 242 | + # minuscule, que le couple Njoyn/Radware rejette depuis le 2026-09-02. | |
| 243 | + action = re.sub(r"xweb\.asp", "Xweb.asp", action) | |
| 219 | 244 | if not action.startswith("http"): |
| 220 | 245 | action = f"{self.BASE}/{self.CL}/xweb/{action}" |
| 221 | 246 | m_sort = re.search( |
modified
jobka/connectors/sante_montreal.py
+6 −1
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | # Fichier : jobka/connectors/sante_montreal.py |
| 6 | 6 | # Rôle : Connecteur Santé Québec — Montréal — njoyn (base='https://emplois.santemontreal.qc.ca', cl='CL3') |
| 7 | 7 | # [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-18] |
| 8 | −# Créé : 2026-08-18 Modifié : 2026-08-18 | |
| 8 | +# Créé : 2026-08-18 Modifié : 2026-09-09 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | from .njoyn import NjoynConnector |
| 11 | 11 | |
@@ -16,3 +16,8 @@ class SanteMontrealConnector(NjoynConnector): | ||
| 16 | 16 | BASE = 'https://emplois.santemontreal.qc.ca' |
| 17 | 17 | CL = 'CL3' |
| 18 | 18 | CLID = '54327' |
| 19 | + # Depuis la maintenance Njoyn du 2026-09-05→07, Radware Bot Manager couvre | |
| 20 | + # aussi ce domaine personnalisé (avant : seulement *.njoyn.com). L'accès | |
| 21 | + # direct renvoie « Radware Block Page » — passage par Scrapfly (ASP) comme | |
| 22 | + # tous les autres tenants Njoyn du repo. | |
| 23 | + USE_SCRAPFLY = True | |
| 19 | 24 | |