[ka2] fix connecteur cromwell: contournement du challenge sgcaptcha SiteGround via proxy résidentiel Oxylabs — même cause et même fix que boreal_abitibi (8923045) et citiluxx (d17f5fe), hébergeur SiteGround identique. Le sync du 2026-09-13 01:16 a rapporté found=0 ok=1 (médiane 11.0, stale, retraits suspendus par le disjoncteur de dérive) : SiteGround sert son anti-bot sgcaptcha (HTTP 202, 197 octets, meta refresh vers /.well-known/sgcaptcha/, en-tête sg-captcha: challenge) à l'IP datacenter du nœud (76.70.60.50 flaguée « ipr ») — un 2xx sans HTTPError, avalé par le `except Exception: break` de fetch() en « 0 trouvé ok », invisible du disjoncteur. Vérifié live : IP directe → 202 challenge ; IP résidentielle Oxylabs CA → 200, 244 Ko, les 11 cartes item-listing-wrap présentes. Fix minimal, pattern citiluxx : _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 détail partagent l'IP), erreur franche si le challenge persiste — la 1re page de liste lève désormais au lieu de break (fini le zéro silencieux), les pages suivantes gardent le break (résultats partiels acceptables) ; _fetch_detail() passe aussi par _get_html() pour ne pas empoisonner detail_cache avec un payload vide. Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 11 annonces (Studio 1 345 $ → 6½ 3 995 $, photos 7-10, dispo + promos), pytest fixtures 2 passed, run.py sync cromwell → found=11 ok (médiane 11.0 restaurée), pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +54 −7
modified
louka/connectors/cromwell.py
+54 −7
@@ -7,11 +7,18 @@ | ||
| 7 | 7 | # publiées sur le site jumeau cromwellmontreal.ca (WordPress + thème |
| 8 | 8 | # Houzez, rendu serveur) : cartes .item-listing-wrap avec data-listid, |
| 9 | 9 | # fiches /property/<slug>/ pour photos, statut et description. |
| 10 | +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, | |
| 11 | +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient | |
| 12 | +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA | |
| 13 | +# (même pattern que boreal_abitibi/citiluxx, hébergeur SiteGround identique). | |
| 10 | 14 | # ----------------------------------------------------------------------------- |
| 11 | 15 | from __future__ import annotations |
| 12 | 16 | |
| 13 | 17 | import hashlib |
| 18 | +import os | |
| 14 | 19 | import re |
| 20 | +import time | |
| 21 | +from urllib.parse import quote | |
| 15 | 22 | |
| 16 | 23 | from bs4 import BeautifulSoup |
| 17 | 24 | |
@@ -25,6 +32,17 @@ IMG_RE = re.compile( | ||
| 25 | 32 | r"https://cromwellmontreal\.ca/wp-content/uploads/" |
| 26 | 33 | r"[^\"\s\\]+?\.(?:jpg|jpeg|png|webp)", re.I) |
| 27 | 34 | |
| 35 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 36 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 37 | + | |
| 38 | + | |
| 39 | +def _sg_challenge(resp) -> bool: | |
| 40 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 41 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 42 | + return True | |
| 43 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 44 | + | |
| 45 | + | |
| 28 | 46 | # Municipalités de l'île qui ne sont pas des arrondissements de Montréal |
| 29 | 47 | _INDEPENDENT_CITIES = { |
| 30 | 48 | "westmount": "Westmount", "hampstead": "Hampstead", |
@@ -69,16 +87,44 @@ class CromwellConnector(BaseConnector): | ||
| 69 | 87 | max_pages = 8 # garde-fou de pagination |
| 70 | 88 | max_details = 60 # garde-fou de fetch des fiches |
| 71 | 89 | |
| 90 | + def _enable_residential_proxy(self) -> bool: | |
| 91 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 92 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 93 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 94 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 95 | + if not (endpoint and user and pwd): | |
| 96 | + return False | |
| 97 | + puser = f"{user}-cc-CA-sessid-cromwell{int(time.time())}-sesstime-10" | |
| 98 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 99 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 100 | + self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient) | |
| 101 | + return True | |
| 102 | + | |
| 103 | + def _get_html(self, url: str) -> str: | |
| 104 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 105 | + resp = self.get(url) | |
| 106 | + if not _sg_challenge(resp): | |
| 107 | + return resp.text | |
| 108 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 109 | + resp = self.get(url) | |
| 110 | + if not _sg_challenge(resp): | |
| 111 | + return resp.text | |
| 112 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 113 | + | |
| 72 | 114 | def fetch(self) -> list[Listing]: |
| 73 | 115 | listings: dict[str, Listing] = {} |
| 74 | 116 | |
| 75 | − # 1) Liste paginée des unités disponibles | |
| 117 | + # 1) Liste paginée des unités disponibles — la 1re page échoue | |
| 118 | + # franchement (fini le « 0 trouvé ok » silencieux du challenge) | |
| 76 | 119 | url = LIST_URL |
| 77 | − for _ in range(self.max_pages): | |
| 78 | − try: | |
| 79 | − html = self.get(url).text | |
| 80 | − except Exception: | |
| 81 | − break | |
| 120 | + for page in range(self.max_pages): | |
| 121 | + if page == 0: | |
| 122 | + html = self._get_html(url) | |
| 123 | + else: | |
| 124 | + try: | |
| 125 | + html = self._get_html(url) | |
| 126 | + except Exception: | |
| 127 | + break | |
| 82 | 128 | soup = BeautifulSoup(html, "html.parser") |
| 83 | 129 | for it in soup.select("div.item-listing-wrap"): |
| 84 | 130 | try: |
@@ -125,7 +171,8 @@ class CromwellConnector(BaseConnector): | ||
| 125 | 171 | |
| 126 | 172 | # -- fiche détaillée (thème Houzez) ------------------------------------------ |
| 127 | 173 | def _fetch_detail(self, url: str) -> dict: |
| 128 | − detail = self.get(url).text | |
| 174 | + # _get_html lève sur challenge : pas de payload vide en detail_cache | |
| 175 | + detail = self._get_html(url) | |
| 129 | 176 | payload: dict = {} |
| 130 | 177 | imgs = [u for u in dict.fromkeys(IMG_RE.findall(detail)) |
| 131 | 178 | if not re.search(r"logo|favicon|icon|-\d+x\d+\.", u, re.I)] |
| 132 | 179 | |