[ka2] fix connecteur citiluxx: contournement du challenge sgcaptcha SiteGround via proxy résidentiel Oxylabs — même cause et même fix que boreal_abitibi (8923045), hébergeur SiteGround identique. Le sync du 2026-09-13 01:12 a rapporté found=0 ok=1 missed=3 (médiane 3.0, stale) : SiteGround sert son anti-bot sgcaptcha (HTTP 202, 195 octets, meta refresh vers /.well-known/sgcaptcha/) à l'IP datacenter du nœud (76.70.60.50 flaguée « ipr ») — un 2xx sans HTTPError, avalé par le `except Exception: return []` de fetch() en « 0 trouvé ok », invisible du disjoncteur, qui a raté les 3 annonces. Vérifié live : IP directe → 202 challenge ; IP résidentielle Oxylabs CA → 200, 239 Ko, les 3 typologies avec prix présentes ; l'API Silo (smartcondoplans.silo.immo) répond 200 en direct (pas derrière SiteGround). Fix minimal, pattern boreal_abitibi : _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, erreur franche si le challenge persiste (fini le zéro silencieux) ; le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 3 annonces (3½ 1 932 $, 4½ 2 324 $, 5½ 3 282 $, superficies + building_units Silo via la même session proxy), run.py sync citiluxx → found=3 ok (médiane 3.0 restaurée), pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +42 −4
modified
louka/connectors/citiluxx.py
+42 −4
@@ -11,10 +11,17 @@ | ||
| 11 | 11 | # salles de bain, superficies) — mais SANS prix ni disponibilité par unité. |
| 12 | 12 | # Granularité : une annonce par typologie (prix « à partir de »), enrichie |
| 13 | 13 | # des superficies min-max et du nombre d'unités via l'API Silo. |
| 14 | +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, | |
| 15 | +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient | |
| 16 | +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA | |
| 17 | +# (même pattern que boreal_abitibi, hébergeur SiteGround identique). | |
| 14 | 18 | # ----------------------------------------------------------------------------- |
| 15 | 19 | from __future__ import annotations |
| 16 | 20 | |
| 21 | +import os | |
| 17 | 22 | import re |
| 23 | +import time | |
| 24 | +from urllib.parse import quote | |
| 18 | 25 | |
| 19 | 26 | from ..schema import Listing, normalize_unit_type, parse_price |
| 20 | 27 | from .base import BaseConnector |
@@ -39,6 +46,16 @@ IMG_RE = re.compile( | ||
| 39 | 46 | # nb de chambres (API Silo, champ `room`) -> typologie québécoise |
| 40 | 47 | UT_OF_ROOMS = {1: "3½", 2: "4½", 3: "5½"} |
| 41 | 48 | |
| 49 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 50 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 51 | + | |
| 52 | + | |
| 53 | +def _sg_challenge(resp) -> bool: | |
| 54 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 55 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 56 | + return True | |
| 57 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 58 | + | |
| 42 | 59 | AMENITIES = [ |
| 43 | 60 | "Formule tout inclus (chauffage, climatisation, électricité, eau chaude)", |
| 44 | 61 | "5 électroménagers haut de gamme", |
@@ -57,12 +74,33 @@ class CitiluxxConnector(BaseConnector): | ||
| 57 | 74 | source_id = "citiluxx" |
| 58 | 75 | request_delay = 0.8 |
| 59 | 76 | |
| 77 | + def _enable_residential_proxy(self) -> bool: | |
| 78 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 79 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 80 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 81 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 82 | + if not (endpoint and user and pwd): | |
| 83 | + return False | |
| 84 | + puser = f"{user}-cc-CA-sessid-citiluxx{int(time.time())}-sesstime-10" | |
| 85 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 86 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 87 | + self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient) | |
| 88 | + return True | |
| 89 | + | |
| 90 | + def _get_html(self, url: str) -> str: | |
| 91 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 92 | + resp = self.get(url) | |
| 93 | + if not _sg_challenge(resp): | |
| 94 | + return resp.text | |
| 95 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 96 | + resp = self.get(url) | |
| 97 | + if not _sg_challenge(resp): | |
| 98 | + return resp.text | |
| 99 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 100 | + | |
| 60 | 101 | def fetch(self) -> list[Listing]: |
| 61 | 102 | listings: list[Listing] = [] |
| 62 | − try: | |
| 63 | − html = self.get(PLANS_URL).text | |
| 64 | − except Exception: | |
| 65 | − return listings | |
| 103 | + html = self._get_html(PLANS_URL) | |
| 66 | 104 | text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)) |
| 67 | 105 | |
| 68 | 106 | images = [u for u in dict.fromkeys(IMG_RE.findall(html)) |
| 69 | 107 | |