[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 | # Fichier : jobka/connectors/base.py | 5 | # Fichier : jobka/connectors/base.py | ||
| 6 | # Rôle : Classe de base des connecteurs + backends de fetch (requests | 6 | # Rôle : Classe de base des connecteurs + backends de fetch (requests | ||
| 7 | # direct, Scrapfly pour les pages carrières JS / derrière anti-bot) | 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 | from __future__ import annotations | 10 | from __future__ import annotations | ||
| 11 | 11 | ||||
@@ -69,13 +69,18 @@ class BaseConnector: | |||||
| 69 | wait_for_selector: str | None = None, | 69 | wait_for_selector: str | None = None, | ||
| 70 | js_scenario: list | str | None = None, | 70 | js_scenario: list | str | None = None, | ||
| 71 | proxy_pool: str | None = None, headers: dict | None = None, | 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 | """Appel Scrapfly complet — retourne le dict `result` (content, status_code…). | 74 | """Appel Scrapfly complet — retourne le dict `result` (content, status_code…). | ||
| 74 | 75 | ||||
| 75 | - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (base64) | 76 | - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (base64) | ||
| 76 | pour charger les listes d'offres virtualisées. | 77 | pour charger les listes d'offres virtualisées. | ||
| 77 | - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif). | 78 | - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif). | ||
| 78 | - `headers`/`method`/`body` : pour rejouer une API JSON interne via ASP. | 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 | import base64 | 85 | import base64 | ||
| 81 | key = os.environ.get("SCRAPFLY_API_KEY") | 86 | key = os.environ.get("SCRAPFLY_API_KEY") | ||
@@ -95,6 +100,9 @@ class BaseConnector: | |||||
| 95 | if js_scenario is not None: | 100 | if js_scenario is not None: | ||
| 96 | js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario) | 101 | js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario) | ||
| 97 | params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode() | 102 | params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode() | ||
| 103 | + if session: | ||||
| 104 | + params["session"] = session | ||||
| 105 | + params["session_sticky_proxy"] = "true" | ||||
| 98 | if headers: | 106 | if headers: | ||
| 99 | for k, v in headers.items(): | 107 | for k, v in headers.items(): | ||
| 100 | params[f"headers[{k}]"] = v | 108 | params[f"headers[{k}]"] = v | ||
@@ -114,13 +122,15 @@ class BaseConnector: | |||||
| 114 | rendering_wait: int = 0, country: str = "ca", | 122 | rendering_wait: int = 0, country: str = "ca", | ||
| 115 | wait_for_selector: str | None = None, | 123 | wait_for_selector: str | None = None, | ||
| 116 | js_scenario: list | str | None = None, | 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 | """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne | 127 | """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne | ||
| 119 | le HTML (result.content) ou "" en cas d'échec ASP.""" | 128 | le HTML (result.content) ou "" en cas d'échec ASP.""" | ||
| 120 | return self.scrapfly(url, render_js=render_js, asp=asp, | 129 | return self.scrapfly(url, render_js=render_js, asp=asp, | ||
| 121 | rendering_wait=rendering_wait, country=country, | 130 | rendering_wait=rendering_wait, country=country, | ||
| 122 | wait_for_selector=wait_for_selector, | 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 | ).get("content") or "" | 134 | ).get("content") or "" | ||
| 125 | 135 | ||||
| 126 | def detail(self, external_id: str, key: str, fetch_fn) -> dict: | 136 | def detail(self, external_id: str, key: str, fetch_fn) -> dict: | ||
modified
jobka/connectors/njoyn.py
+32 −7
@@ -5,7 +5,7 @@ | |||||
| 5 | # Fichier : jobka/connectors/njoyn.py | 5 | # Fichier : jobka/connectors/njoyn.py | ||
| 6 | # Rôle : Classe de plateforme Njoyn (CGI) — très répandu au secteur public | 6 | # Rôle : Classe de plateforme Njoyn (CGI) — très répandu au secteur public | ||
| 7 | # québécois (santé, villes, sociétés d'État). HTML serveur + jeton. | 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 | """Plateforme Njoyn (CGI). | 10 | """Plateforme Njoyn (CGI). | ||
| 11 | 11 | ||||
@@ -17,9 +17,10 @@ une ligne de tableau (habillage classique) pointant vers | |||||
| 17 | ``page=jobdetails&jobid=J####-####``. | 17 | ``page=jobdetails&jobid=J####-####``. | ||
| 18 | 18 | ||||
| 19 | ⚠ Les domaines *.njoyn.com sont derrière Radware Bot Manager : pour ces | 19 | ⚠ Les domaines *.njoyn.com sont derrière Radware Bot Manager : pour ces | ||
| 20 | −tenants, mettre ``USE_SCRAPFLY = True`` (contournement ASP). Les babillards | 20 | +tenants, mettre ``USE_SCRAPFLY = True`` (contournement ASP). Depuis la | ||
| 21 | −servis sur un domaine personnalisé (ex. emplois.santemontreal.qc.ca) se | 21 | +maintenance plateforme du 2026-09-05→07, Radware couvre aussi des domaines | ||
| 22 | −consultent directement. | 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 | from __future__ import annotations | 25 | from __future__ import annotations | ||
| 25 | 26 | ||||
@@ -63,6 +64,12 @@ _MAINT_RE = re.compile( | |||||
| 63 | # pour un vrai navigateur (vérifié Chromium/Playwright depuis IP résidentielle). | 64 | # pour un vrai navigateur (vérifié Chromium/Playwright depuis IP résidentielle). | ||
| 64 | # Ce n'est PAS de l'anti-bot : échec immédiat, sans retry ni 2e crédit Scrapfly. | 65 | # Ce n'est PAS de l'anti-bot : échec immédiat, sans retry ni 2e crédit Scrapfly. | ||
| 65 | _INVALID_RE = re.compile(r"Invalid request XWP\d+", re.I) | 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 | def _txt(s: str) -> str: | 75 | def _txt(s: str) -> str: | ||
@@ -97,12 +104,26 @@ class NjoynConnector(BaseConnector): | |||||
| 97 | u += f"&jobid={jobid}" | 104 | u += f"&jobid={jobid}" | ||
| 98 | return u | 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 | def _html(self, url: str) -> str: | 116 | def _html(self, url: str) -> str: | ||
| 101 | if self.USE_SCRAPFLY: | 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 | text = self.get(url).text | 120 | text = self.get(url).text | ||
| 104 | − if "perfdrive" in text[:4000]: # mur anti-bot : bascule Scrapfly | 121 | + if _RADWARE_RE.search(text[:4000]): # mur anti-bot : bascule Scrapfly | ||
| 105 | − return self.get_scrapfly(url, render_js=False) | 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 | return text | 127 | return text | ||
| 107 | 128 | ||||
| 108 | def _fetch_detail(self, jobid: str) -> dict: | 129 | def _fetch_detail(self, jobid: str) -> dict: | ||
@@ -195,6 +216,7 @@ class NjoynConnector(BaseConnector): | |||||
| 195 | action, render_js=False, method="POST", | 216 | action, render_js=False, method="POST", | ||
| 196 | body=urlencode(data), | 217 | body=urlencode(data), | ||
| 197 | headers={"Content-Type": "application/x-www-form-urlencoded"}, | 218 | headers={"Content-Type": "application/x-www-form-urlencoded"}, | ||
| 219 | + session=self._sf_session(), | ||||
| 198 | ).get("content") or "" | 220 | ).get("content") or "" | ||
| 199 | return self.post(action, data=data).text | 221 | return self.post(action, data=data).text | ||
| 200 | 222 | ||||
@@ -216,6 +238,9 @@ class NjoynConnector(BaseConnector): | |||||
| 216 | if not m: | 238 | if not m: | ||
| 217 | return items | 239 | return items | ||
| 218 | action = _html.unescape(m.group(1)) | 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 | if not action.startswith("http"): | 244 | if not action.startswith("http"): | ||
| 220 | action = f"{self.BASE}/{self.CL}/xweb/{action}" | 245 | action = f"{self.BASE}/{self.CL}/xweb/{action}" | ||
| 221 | m_sort = re.search( | 246 | m_sort = re.search( | ||
modified
jobka/connectors/sante_montreal.py
+6 −1
@@ -5,7 +5,7 @@ | |||||
| 5 | # Fichier : jobka/connectors/sante_montreal.py | 5 | # Fichier : jobka/connectors/sante_montreal.py | ||
| 6 | # Rôle : Connecteur Santé Québec — Montréal — njoyn (base='https://emplois.santemontreal.qc.ca', cl='CL3') | 6 | # Rôle : Connecteur Santé Québec — Montréal — njoyn (base='https://emplois.santemontreal.qc.ca', cl='CL3') | ||
| 7 | # [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-18] | 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 | from .njoyn import NjoynConnector | 10 | from .njoyn import NjoynConnector | ||
| 11 | 11 | ||||
@@ -16,3 +16,8 @@ class SanteMontrealConnector(NjoynConnector): | |||||
| 16 | BASE = 'https://emplois.santemontreal.qc.ca' | 16 | BASE = 'https://emplois.santemontreal.qc.ca' | ||
| 17 | CL = 'CL3' | 17 | CL = 'CL3' | ||
| 18 | CLID = '54327' | 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 | ||||