SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

[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>
Simon-Pierre Boucher committed 15 days ago (Sep 9, 2026) parent 4b92938

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