[ka6] fix connecteur lassonde: faux positif de supervision (épisode Sucuri transitoire) — repli Bright Data sur la page liste — le statut stale vient d'un unique échec à 08:17 (RuntimeError explicite de la garde du 2026-09-05 : 3× « HTTP 200 via scrapfly, 4515 octets » sans le conteneur job-result-default), combiné à la panne SQLite du matin (2b01fd4) qui avait empêché tout sync entre 05:32 et 08:17 — le connecteur est INTACT : rejeu = found=35, médiane exacte. Vraie faiblesse corrigée : pendant un épisode Sucuri, TOUTE la chaîne principale (direct→oxylabs→scrapfly) sert la même page tronquée « propre en apparence » (200 sans marqueur connu — 'access denied', 'sucuri_cloudproxy' etc. déjà couverts par is_blocked) à chaque essai, et le backoff 10-20 s ne suffit pas à sortir de l'épisode ; escalate() acceptait la réponse Scrapfly et ne tentait jamais Bright Data. La liste étant rendue serveur (render_js ne sert qu'au challenge), _fetch_list tente désormais Bright Data Web Unlocker (vendeur de déblocage différent) à chaque essai raté — vérifié live : BD livre 200/110 ko/35 cartes au moment même où le direct sert le challenge Sucuri 307. L'échec reste explicite (offres conservées) si BD échoue aussi. Test unitaire ajouté (repli BD gagnant, échec explicite si BD muet, BD jamais sollicité sur liste saine). Syncs rejoués post-patch : found=35 ok ×2. 104 tests pytest verts. pm2 restart job-ka-sync OK, site 200.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 changed files +77 −2
modified
jobka/connectors/lassonde.py
+18 −1
@@ -18,7 +18,12 @@ | ||
| 18 | 18 | # (sync à 0 le 2026-09-05 21h09 alors que 32 offres réelles) ; le |
| 19 | 19 | # conteneur `job-result-default` est maintenant exigé, retry+backoff |
| 20 | 20 | # sinon erreur explicite dans sync_log. |
| 21 | −# Créé : 2026-08-18 Modifié : 2026-09-05 | |
| 21 | +# 2026-09-14 : repli Bright Data sur la page liste — pendant un | |
| 22 | +# épisode Sucuri, la chaîne principale (direct→oxylabs→scrapfly) | |
| 23 | +# sert la même page tronquée à chaque essai (3× « HTTP 200 via | |
| 24 | +# scrapfly, 4515 octets » à 08:17) ; la liste étant rendue serveur, | |
| 25 | +# un vendeur de déblocage différent suffit à passer l'épisode. | |
| 26 | +# Créé : 2026-08-18 Modifié : 2026-09-14 | |
| 22 | 27 | # ============================================================================= |
| 23 | 28 | from __future__ import annotations |
| 24 | 29 | |
@@ -29,6 +34,7 @@ import time | ||
| 29 | 34 | |
| 30 | 35 | from ..schema import JobPosting |
| 31 | 36 | from . import _jsonld |
| 37 | +from ._resilient import _try_brightdata | |
| 32 | 38 | from .base import BaseConnector |
| 33 | 39 | |
| 34 | 40 | MAX_DETAILS = int(os.environ.get("JOBKA_LASSONDE_DETAIL_LIMIT", "40")) |
@@ -85,6 +91,17 @@ class LassondeConnector(BaseConnector): | ||
| 85 | 91 | return html |
| 86 | 92 | last = (f"HTTP {resp.status_code} via " |
| 87 | 93 | f"{getattr(resp, 'via', 'direct')}, {len(html)} octets") |
| 94 | + # Pendant un épisode Sucuri, toute la chaîne principale peut | |
| 95 | + # servir la même page tronquée « propre en apparence » (200 sans | |
| 96 | + # marqueur connu) à chaque essai — vu le 2026-09-14 08:17 : 3× | |
| 97 | + # « HTTP 200 via scrapfly, 4515 octets » malgré le backoff. La | |
| 98 | + # liste est rendue serveur (render_js ne sert qu'au challenge) : | |
| 99 | + # un vendeur de déblocage différent livre la vraie page pendant | |
| 100 | + # l'épisode (Bright Data vérifié live : 200, 110 ko, 35 cartes, | |
| 101 | + # au moment même où le direct sert le challenge Sucuri). | |
| 102 | + alt = _try_brightdata(self.LIST_URL, 60, None) | |
| 103 | + if alt is not None and "job-result-default" in (alt.text or ""): | |
| 104 | + return alt.text | |
| 88 | 105 | if attempt < 2: |
| 89 | 106 | time.sleep(10 * (attempt + 1)) |
| 90 | 107 | raise RuntimeError( |
modified
tests/test_connectors.py
+59 −1
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | # Fichier : tests/test_connectors.py |
| 6 | 6 | # Rôle : Tests de régression des connecteurs (rejeu des fixtures hors ligne) |
| 7 | 7 | # Ré-enregistrer après un changement de site : run.py record <source> |
| 8 | −# Créé : 2026-08-17 Modifié : 2026-09-13 | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-09-14 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | import json |
| 11 | 11 | import re |
@@ -168,3 +168,61 @@ def test_successfactors_sitemap_guard(monkeypatch): | ||
| 168 | 168 | monkeypatch.setattr(conn3, "get", lambda url, **kw: _Resp(empty_rss)) |
| 169 | 169 | assert conn3._fetch_sitemap() == empty_rss |
| 170 | 170 | assert conn3.fetch() == [] |
| 171 | + | |
| 172 | + | |
| 173 | +def test_lassonde_list_brightdata_fallback(monkeypatch): | |
| 174 | + """Pendant un épisode Sucuri, la chaîne principale sert la même page 200 | |
| 175 | + tronquée (sans `job-result-default` ni marqueur connu) à chaque essai — | |
| 176 | + vu le 2026-09-14 08:17 : 3× « HTTP 200 via scrapfly, 4515 octets ». Le | |
| 177 | + repli Bright Data (vendeur de déblocage différent) doit alors livrer la | |
| 178 | + vraie liste ; s'il échoue aussi, l'erreur reste explicite (pas de faux | |
| 179 | + « 0 offre ») ; et une liste saine ne le sollicite jamais.""" | |
| 180 | + from jobka.connectors.lassonde import LassondeConnector | |
| 181 | + | |
| 182 | + monkeypatch.setattr("jobka.connectors.lassonde.time.sleep", lambda s: None) | |
| 183 | + | |
| 184 | + class _Resp: | |
| 185 | + def __init__(self, text, status_code=200, via="scrapfly"): | |
| 186 | + self.text = text | |
| 187 | + self.status_code = status_code | |
| 188 | + self.via = via | |
| 189 | + | |
| 190 | + truncated = "<html><body>page tronquée sans section emplois</body></html>" | |
| 191 | + real_list = "<div class='job-result-default'>…35 cartes…</div>" | |
| 192 | + | |
| 193 | + # 1. chaîne principale tronquée → Bright Data livre la vraie page | |
| 194 | + conn = LassondeConnector() | |
| 195 | + conn.source_id = "test_lassonde" | |
| 196 | + bd_calls = {"n": 0} | |
| 197 | + monkeypatch.setattr(conn, "get_resilient", | |
| 198 | + lambda url, **kw: _Resp(truncated)) | |
| 199 | + | |
| 200 | + def bd_ok(url, timeout, headers): | |
| 201 | + bd_calls["n"] += 1 | |
| 202 | + return _Resp(real_list, via="brightdata") | |
| 203 | + | |
| 204 | + monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", bd_ok) | |
| 205 | + assert conn._fetch_list() == real_list and bd_calls["n"] == 1 | |
| 206 | + | |
| 207 | + # 2. Bright Data échoue aussi → erreur explicite après 3 essais | |
| 208 | + conn2 = LassondeConnector() | |
| 209 | + conn2.source_id = "test_lassonde" | |
| 210 | + monkeypatch.setattr(conn2, "get_resilient", | |
| 211 | + lambda url, **kw: _Resp(truncated)) | |
| 212 | + monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", | |
| 213 | + lambda url, timeout, headers: None) | |
| 214 | + with pytest.raises(RuntimeError, match="job-result-default"): | |
| 215 | + conn2._fetch_list() | |
| 216 | + | |
| 217 | + # 3. liste saine au premier essai → Bright Data jamais sollicité | |
| 218 | + conn3 = LassondeConnector() | |
| 219 | + conn3.source_id = "test_lassonde" | |
| 220 | + monkeypatch.setattr(conn3, "get_resilient", | |
| 221 | + lambda url, **kw: _Resp(real_list)) | |
| 222 | + | |
| 223 | + def bd_forbidden(url, timeout, headers): | |
| 224 | + raise AssertionError("Bright Data ne doit pas être appelé") | |
| 225 | + | |
| 226 | + monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", | |
| 227 | + bd_forbidden) | |
| 228 | + assert conn3._fetch_list() == real_list | |
| 171 | 229 | |