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 alstom: faux positif de supervision (réponse sitemap transitoirement illisible) — garde-fou _fetch_sitemap dans la plateforme successfactors — le statut stale vient d'un unique sync found=0 à 06:14 (contre une médiane stable de 224), alors que le connecteur est INTACT : le sitemap RSS live (24 Mo, 2285 items monde) parse 224 offres QC au rejeu. Vraie faiblesse corrigée : SuccessFactorsConnector.fetch() avalait silencieusement une réponse 200 sans racine <rss>/<urlset> (page de maintenance/anti-bot, corps vide) — pas d'<item> ni de <loc> → [] → faux « 0 trouvé, ok ». Nouveau _fetch_sitemap sur le modèle de njoyn._fetch_listing (7938ca6) : 3 essais avec backoff 10-20 s, retry sur RequestException transitoire et sur réponse sans racine sitemap, échec explicite (RuntimeError, offres conservées) après 3 réponses illisibles ; un sitemap valide mais vide après 3 essais reste accepté (tenant légitimement sans offre, le garde-fou de dérive suspend déjà les retraits). Test unitaire ajouté (retry maintenance+ConnectTimeout, échec explicite après 3 corps vides, tolérance tenant vide). Sync rejoué post-patch : found=224 en 8,9 s, ok. 103 tests pytest verts. pm2 restart job-ka-sync OK, site 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 days ago (Sep 13, 2026) parent 7938ca6

2 changed files +100 −2

modified jobka/connectors/successfactors.py +47 −2
@@ -5,7 +5,7 @@
5 5 # Fichier : jobka/connectors/successfactors.py
6 6 # Rôle : Classe de plateforme SAP SuccessFactors (sites carrières « Career
7 7 # Site Builder » / jobs2web) — sitemap RSS ou XML + microdonnées
8 −# Créé : 2026-08-18 Modifié : 2026-08-25
8 +# Créé : 2026-08-18 Modifié : 2026-09-13
9 9 # =============================================================================
10 10 """Plateforme SAP SuccessFactors (Career Site Builder, ex-jobs2web).
11 11
@@ -22,8 +22,11 @@ from __future__ import annotations
22 22 import html as _html
23 23 import os
24 24 import re
25 +import time
25 26 import urllib.parse
26 27
28 +import requests
29 +
27 30 from ..schema import JobPosting, clean_html, is_quebec_location
28 31 from .base import BaseConnector
29 32
@@ -174,8 +177,50 @@ class SuccessFactorsConnector(BaseConnector):
174 177 out.append(job)
175 178 return out
176 179
180 + def _fetch_sitemap(self) -> str:
181 + """sitemap.xml avec garde-fou : une réponse 200 sans racine
182 + ``<rss>``/``<urlset>`` (page de maintenance ou anti-bot en HTML, corps
183 + vide) ne doit JAMAIS passer pour un site carrières sans offre — sinon
184 + le sync enregistre un faux « 0 trouvé, ok » (constaté 2026-09-13 06:14
185 + sur alstom : found=0 unique contre une médiane de 224, sitemap intact
186 + au rejeu). Même famille de retry que njoyn._fetch_listing : 3 essais,
187 + backoff 10-20 s, les pannes réseau transitoires (RequestException)
188 + sont retentées au lieu d'avorter au premier essai. Un sitemap valide
189 + mais sans aucune entrée brute (fenêtre de régénération CSB) est aussi
190 + retenté ; s'il reste vide après 3 essais on l'accepte (tenant
191 + légitimement sans offre — le garde-fou de dérive suspend de toute
192 + façon les retraits)."""
193 + xml = ""
194 + for attempt in range(3):
195 + if attempt:
196 + time.sleep(10 * attempt)
197 + try:
198 + xml = self.get(f"{self.BASE}/sitemap.xml").text
199 + except requests.RequestException:
200 + if attempt == 2:
201 + raise
202 + continue
203 + head = xml[:300].lower()
204 + if "<rss" in head:
205 + if _ITEM_RE.search(xml):
206 + return xml
207 + elif "<urlset" in head:
208 + if _LOC_RE.search(xml):
209 + return xml
210 + else:
211 + # ni RSS ni urlset : réponse illisible, on retente
212 + continue
213 + # racine valide mais 0 entrée : régénération probable, on retente
214 + head = xml[:300].lower()
215 + if "<rss" in head or "<urlset" in head:
216 + return xml
217 + raise RuntimeError(
218 + f"successfactors {self.source_id}: sitemap illisible après "
219 + f"3 essais (ni <rss> ni <urlset>, {len(xml)} octets) — sync "
220 + f"avorté, offres conservées")
221 +
177 222 def fetch(self) -> list[JobPosting]:
178 − xml = self.get(f"{self.BASE}/sitemap.xml").text
223 + xml = self._fetch_sitemap()
179 224 if "<rss" in xml[:300].lower():
180 225 return self._fetch_rss(xml)
181 226 return self._fetch_urlset(xml)
modified tests/test_connectors.py +53 −0
@@ -115,3 +115,56 @@ def test_njoyn_listing_retries_network_errors(monkeypatch):
115 115 monkeypatch.setattr(conn2, "_html", dead_html)
116 116 with pytest.raises(requests.ConnectTimeout):
117 117 conn2._fetch_listing()
118 +
119 +
120 +def test_successfactors_sitemap_guard(monkeypatch):
121 + """Une réponse 200 sans racine <rss>/<urlset> (maintenance, anti-bot,
122 + corps vide — vu le 2026-09-13 06:14 sur alstom : found=0 unique contre
123 + une médiane de 224) doit engager le retry de _fetch_sitemap au lieu de
124 + passer pour un site sans offre ; une panne réseau transitoire est
125 + retentée ; après 3 réponses illisibles, le sync échoue explicitement."""
126 + import requests
127 +
128 + from jobka.connectors.successfactors import SuccessFactorsConnector
129 +
130 + monkeypatch.setattr(
131 + "jobka.connectors.successfactors.time.sleep", lambda s: None)
132 +
133 + rss_ok = ("<rss version=\"2.0\"><channel><item><title>Poste (VILLE, QC, "
134 + "CA)</title><link>https://x/job/p/1/</link></item></channel>"
135 + "</rss>")
136 +
137 + class _Resp:
138 + def __init__(self, text):
139 + self.text = text
140 +
141 + # 1. maintenance HTML puis panne réseau puis sitemap valide → retry gagne
142 + conn = SuccessFactorsConnector()
143 + conn.source_id = "test_sf"
144 + calls = {"n": 0}
145 +
146 + def flaky_get(url, **kw):
147 + calls["n"] += 1
148 + if calls["n"] == 1:
149 + return _Resp("<html>maintenance en cours</html>")
150 + if calls["n"] == 2:
151 + raise requests.ConnectTimeout("site injoignable (simulé)")
152 + return _Resp(rss_ok)
153 +
154 + monkeypatch.setattr(conn, "get", flaky_get)
155 + assert conn._fetch_sitemap() == rss_ok and calls["n"] == 3
156 +
157 + # 2. trois réponses illisibles → échec explicite, pas « 0 trouvé, ok »
158 + conn2 = SuccessFactorsConnector()
159 + conn2.source_id = "test_sf"
160 + monkeypatch.setattr(conn2, "get", lambda url, **kw: _Resp(""))
161 + with pytest.raises(RuntimeError, match="sitemap illisible"):
162 + conn2._fetch_sitemap()
163 +
164 + # 3. sitemap valide mais sans entrée après 3 essais → accepté (tenant vide)
165 + conn3 = SuccessFactorsConnector()
166 + conn3.source_id = "test_sf"
167 + empty_rss = "<rss version=\"2.0\"><channel></channel></rss>"
168 + monkeypatch.setattr(conn3, "get", lambda url, **kw: _Resp(empty_rss))
169 + assert conn3._fetch_sitemap() == empty_rss
170 + assert conn3.fetch() == []
118 171