[ka2] fix connecteur devimco: escalade Scrapfly contre le pare-feu Sucuri CloudProxy apparu sur devimco.com — même cause et même fix que brio (195f19a). Les syncs du 2026-09-13 (05:03, 15:16, 20:16... puis 23:23) rapportaient found=0 ok par INTERMITTENCE (médiane 695, dérive déclenchée, retraits suspendus) : devimco.com est passé derrière Sucuri/Cloudproxy, qui répond 307 + challenge JavaScript (cookie sucuri_cloudproxy, en-tête x-sucuri-id, ~1,4 Ko sans Location) aux clients sans navigateur selon l'humeur du filtre IP — un statut < 400 invisible de raise_for_status(), le hub sans lien projet devenait « 0 trouvé ok » silencieux via le `except Exception: return listings` de fetch(). Le diagnostic « échec transitoire » du passage ka2 de 08:08 était donc incomplet : le hoquet se répétait un sync sur deux. Vérifié live : 3 GET directs consécutifs → 307 challenge ; Scrapfly ASP + rendu JS → 200, 297 Ko, 10 pages projets. Fix minimal, pattern brio : _get_html() tente le direct (gratuit quand Sucuri laisse passer), détecte le challenge (statut 307 ou marqueur sucuri_cloudproxy) et escalade vers get_rendered() (Scrapfly ASP + rendu JS, standard du repo) ; hub ET pages projets passent par _get_html() ; le hub lève désormais une erreur franche au lieu de retourner [] (fini le zéro silencieux, idem si aucun lien projet dans la soupe) ; les pages projets gardent le continue (résultats partiels acceptables) ; l'API Planpoint (app.planpoint.io, domaine distinct) reste en direct. Testé : pytest fixtures devimco 2 passed, fetch() venv → 695 annonces (13 phases : Cosma 237, Ostral 160, Maestria A/B 121, Alexander 66... — unité 309 Alexander 2 130 $/mois, 2255 René-Lévesque O, 13 photos — médiane 695 restaurée), run.py sync devimco → found=695 ok (161 s), pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +26 −5
modified
louka/connectors/devimco.py
+26 −5
@@ -88,12 +88,29 @@ class DevimcoConnector(BaseConnector): | ||
| 88 | 88 | request_delay = 0.6 |
| 89 | 89 | max_projects = 15 # garde-fou |
| 90 | 90 | |
| 91 | + def _get_html(self, url: str) -> str: | |
| 92 | + """HTML de `url`, tolérant au pare-feu Sucuri CloudProxy. | |
| 93 | + | |
| 94 | + Vu 2026-09-13 : devimco.com est passé derrière Sucuri/Cloudproxy, | |
| 95 | + qui répond par intermittence 307 + challenge JavaScript (cookie | |
| 96 | + sucuri_cloudproxy) aux clients sans navigateur — un statut < 400 | |
| 97 | + invisible de raise_for_status(), dont la soupe sans lien projet | |
| 98 | + devenait un « 0 trouvé ok » silencieux. Direct d'abord (gratuit si | |
| 99 | + Sucuri laisse passer), sinon escalade Scrapfly ASP + rendu JS qui | |
| 100 | + passe le challenge (pattern brio, fix 195f19a). | |
| 101 | + """ | |
| 102 | + try: | |
| 103 | + resp = self.get(url) | |
| 104 | + if (resp.status_code != 307 | |
| 105 | + and "sucuri_cloudproxy" not in resp.text): | |
| 106 | + return resp.text | |
| 107 | + except Exception: # noqa: BLE001 — direct bloqué : on escalade | |
| 108 | + pass | |
| 109 | + return self.get_rendered(url) | |
| 110 | + | |
| 91 | 111 | def fetch(self) -> list[Listing]: |
| 92 | 112 | listings: list[Listing] = [] |
| 93 | − try: | |
| 94 | − hub = self.get(HUB_URL).text | |
| 95 | − except Exception: | |
| 96 | − return listings | |
| 113 | + hub = self._get_html(HUB_URL) | |
| 97 | 114 | |
| 98 | 115 | # 1) Pages projets (a-louer/<région>/<secteur>/<projet>) |
| 99 | 116 | projects: dict[str, tuple[str, str]] = {} |
@@ -103,6 +120,10 @@ class DevimcoConnector(BaseConnector): | ||
| 103 | 120 | # ignorer les pages de secteur (sans slug projet "…-appartements") |
| 104 | 121 | if proj_slug.endswith("-appartements"): |
| 105 | 122 | projects[url] = (sector_slug, proj_slug) |
| 123 | + if not projects: | |
| 124 | + raise RuntimeError( | |
| 125 | + "devimco : aucun lien projet dans le hub — challenge Sucuri " | |
| 126 | + f"non passé ou site remanié ? — {HUB_URL}") | |
| 106 | 127 | |
| 107 | 128 | for i, (proj_url, (sector_slug, proj_slug)) in \ |
| 108 | 129 | enumerate(projects.items()): |
@@ -118,7 +139,7 @@ class DevimcoConnector(BaseConnector): | ||
| 118 | 139 | # -- un projet --------------------------------------------------------------- |
| 119 | 140 | def _fetch_project(self, proj_url: str, sector_slug: str) -> list[Listing]: |
| 120 | 141 | out: list[Listing] = [] |
| 121 | − html = self.get(proj_url).text | |
| 142 | + html = self._get_html(proj_url) | |
| 122 | 143 | |
| 123 | 144 | # iframe Planpoint : soit un projet (/<ns>/<host>), soit un groupe |
| 124 | 145 | # de phases. Depuis 2026-08, le widget groupe est /g/<x>/<ns> (deux |
| 125 | 146 | |