[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>
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 | |