[ka2] fix connecteur mondev: contournement du challenge sgcaptcha SiteGround via proxy résidentiel Oxylabs — même cause et même fix que boreal_abitibi (8923045), citiluxx (d17f5fe), cromwell (8351131), deschenes_pepin (f8aca03), gestion_habitation (8b0660b), gimcote (12df764), appartements_rimouski (af2be7c), lbm (5decd97), gestion_mj (15d2bbb) et mercini (5125859), hébergeur SiteGround identique (11e connecteur touché). Le sync du 2026-09-12 22:42 a rapporté found=0 ok (médiane 20.0, stale) : SiteGround sert son anti-bot sgcaptcha (HTTP 202, 199 octets, en-tête sg-captcha: challenge, meta refresh vers /.well-known/sgcaptcha/) à l'IP datacenter du nœud — un 2xx sans HTTPError, avalé par le `except Exception: return listings` de fetch() en « 0 trouvé ok », invisible du disjoncteur. Vérifié live : IP directe → 202 challenge ; IP résidentielle Oxylabs CA → 200, 557 Ko, 30 immeubles présents. Fix minimal, pattern lbm (liste unique + fiches immeubles re-fetchées à chaque sync, pas de detail_cache) : _get_html() détecte le challenge (en-tête sg-captcha ou marqueur /.well-known/sgcaptcha/) et bascule la session sur pr.oxylabs.io:7777 en session collante -cc-CA (liste + fiches immeubles partagent l'IP), erreur franche si le challenge persiste — la page de liste (unique, pas de pagination) lève désormais au lieu de retourner [] (fini le zéro silencieux, ingest journalise ok=0) ; _parse_building() passe aussi par _get_html() et garde le continue autour (résultats partiels acceptables). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 40 annonces (Florence 3½ 1 500 $ → 5½ 2 600 $ rue Florian, Jacques Studio 1 630 $ rue Fullum — adresses complètes, 16-40 photos, médiane 20.0 largement couverte, dernier sync sain à 43), pytest fixtures 2 passed, run.py sync mondev → found=40 ok (94 s), pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +45 −5
modified
louka/connectors/mondev.py
+45 −5
@@ -9,10 +9,17 @@ | ||
| 9 | 9 | # contient un « PLAN SELECTOR » avec, par typologie, « Starting at $X » ou |
| 10 | 10 | # « not available ». Une annonce par (immeuble, typologie) disponible. |
| 11 | 11 | # Prix « à partir de », adresse, description, commodités et galerie photos. |
| 12 | +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, | |
| 13 | +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient | |
| 14 | +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA | |
| 15 | +# (même pattern que boreal_abitibi/…/mercini, hébergeur identique). | |
| 12 | 16 | # ----------------------------------------------------------------------------- |
| 13 | 17 | from __future__ import annotations |
| 14 | 18 | |
| 19 | +import os | |
| 15 | 20 | import re |
| 21 | +import time | |
| 22 | +from urllib.parse import quote | |
| 16 | 23 | |
| 17 | 24 | from bs4 import BeautifulSoup |
| 18 | 25 | |
@@ -22,6 +29,16 @@ from .base import BaseConnector | ||
| 22 | 29 | BASE = "https://mondev.ca" |
| 23 | 30 | LIST_URL = f"{BASE}/apartments-and-condos-for-rent/" |
| 24 | 31 | |
| 32 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 33 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 34 | + | |
| 35 | + | |
| 36 | +def _sg_challenge(resp) -> bool: | |
| 37 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 38 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 39 | + return True | |
| 40 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 41 | + | |
| 25 | 42 | _BUILDING_RE = re.compile( |
| 26 | 43 | r'href="(https://mondev\.ca/apartments-and-condos-for-rent/' |
| 27 | 44 | r'([a-z0-9-]+)/([a-z0-9-]+)/)"') |
@@ -78,12 +95,35 @@ class MondevConnector(BaseConnector): | ||
| 78 | 95 | request_delay = 0.6 |
| 79 | 96 | max_buildings = 45 # garde-fou de crawl |
| 80 | 97 | |
| 98 | + def _enable_residential_proxy(self) -> bool: | |
| 99 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 100 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 101 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 102 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 103 | + if not (endpoint and user and pwd): | |
| 104 | + return False | |
| 105 | + puser = f"{user}-cc-CA-sessid-mondev{int(time.time())}-sesstime-10" | |
| 106 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 107 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 108 | + self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient) | |
| 109 | + return True | |
| 110 | + | |
| 111 | + def _get_html(self, url: str) -> str: | |
| 112 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 113 | + resp = self.get(url) | |
| 114 | + if not _sg_challenge(resp): | |
| 115 | + return resp.text | |
| 116 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 117 | + resp = self.get(url) | |
| 118 | + if not _sg_challenge(resp): | |
| 119 | + return resp.text | |
| 120 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 121 | + | |
| 81 | 122 | def fetch(self) -> list[Listing]: |
| 82 | 123 | listings: list[Listing] = [] |
| 83 | − try: | |
| 84 | − index = self.get(LIST_URL).text | |
| 85 | − except Exception: | |
| 86 | − return listings | |
| 124 | + # la liste échoue franchement sur challenge (fini le « 0 trouvé ok » | |
| 125 | + # silencieux — ingest journalise ok=0) | |
| 126 | + index = self._get_html(LIST_URL) | |
| 87 | 127 | |
| 88 | 128 | buildings: dict[str, tuple[str, str]] = {} |
| 89 | 129 | for url, zone, slug in _BUILDING_RE.findall(index): |
@@ -99,7 +139,7 @@ class MondevConnector(BaseConnector): | ||
| 99 | 139 | return listings |
| 100 | 140 | |
| 101 | 141 | def _parse_building(self, url: str, zone: str, slug: str) -> list[Listing]: |
| 102 | − html = self.get(url).text | |
| 142 | + html = self._get_html(url) | |
| 103 | 143 | soup = BeautifulSoup(html, "html.parser") |
| 104 | 144 | |
| 105 | 145 | h1 = soup.find("h1") |
| 106 | 146 | |