SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

[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.

Simon-Pierre Boucher committed 12 days ago (Sep 13, 2026) parent 8923045

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