Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/citiluxx.py : connecteur Citiluxx (citiluxx.com — Groupe Cleary)5# Condos locatifs tout inclus au Faubourg Cousineau, 8121 boulevard6# Cousineau, Saint-Hubert (Longueuil). WordPress (Astra/Elementor) rendu7# serveur : la page /plans-appartements-a-louer/ publie le prix plancher8# par typologie (« 3 1/2 à louer : à partir de 1 932$/mois »…) et embarque9# le widget Smart Condo Plans (API JSON publique smartcondoplans.silo.immo,10# projet « citiluxx ») qui fournit le plan des ~199 unités (chambres,11# salles de bain, superficies) — mais SANS prix ni disponibilité par unité.12# Granularité : une annonce par typologie (prix « à partir de »), enrichie13# 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ésilient16# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA17# (même pattern que boreal_abitibi, hébergeur SiteGround identique).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import os22import re23import time24from urllib.parse import quote2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://citiluxx.com"30PLANS_URL = f"{BASE}/plans-appartements-a-louer/"31SILO_API = ("https://smartcondoplans.silo.immo/v2/building/get-project-data"32 "?project=citiluxx&language=fr")3334ADDRESS = "8121, boulevard Cousineau, Saint-Hubert"35CITY = "Longueuil"36SECTOR = "Saint-Hubert"3738# « 3 1/2 à louer : à partir de 1 932$/mois »39PRICE_RE = re.compile(40 r"(\d)\s*(?:½|1/2)\s*(?:\([^)]*\)\s*)?(?::|à louer\s*:)\s*"41 r"[àa] partir de\s*([\d\s,]{3,9})\s*\$\s*/\s*mois", re.I)42IMG_RE = re.compile(43 r"https://citiluxx\.com/wp-content/uploads/"44 r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)4546# nb de chambres (API Silo, champ `room`) -> typologie québécoise47UT_OF_ROOMS = {1: "3½", 2: "4½", 3: "5½"}4849# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…)50_SG_MARKER = "/.well-known/sgcaptcha/"515253def _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 True57 return _SG_MARKER in (resp.text or "")[:600]5859AMENITIES = [60 "Formule tout inclus (chauffage, climatisation, électricité, eau chaude)",61 "5 électroménagers haut de gamme",62 "Concierge",63 "Hauts plafonds de 9 pieds",64 "Comptoirs en quartz",65 "Grand balcon-terrasse",66 "Casiers à colis intelligents",67 "Espace lave-auto",68 "Stationnement intérieur ($) avec espace vélo",69 "Animaux acceptés (max 15 lb, conditions)",70]717273class CitiluxxConnector(BaseConnector):74 source_id = "citiluxx"75 request_delay = 0.87677 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 False84 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 True8990 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.text95 if not self.session.proxies and self._enable_residential_proxy():96 resp = self.get(url)97 if not _sg_challenge(resp):98 return resp.text99 raise RuntimeError(f"challenge sgcaptcha non contourné — {url}")100101 def fetch(self) -> list[Listing]:102 listings: list[Listing] = []103 html = self._get_html(PLANS_URL)104 text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html))105106 images = [u for u in dict.fromkeys(IMG_RE.findall(html))107 if not re.search(r"logo|icon|favicon|cropped-|Plans?\.webp|"108 r"-\d{2,3}x\d{2,3}\.", u, re.I)][:15]109110 # enrichissement : plan des unités via l'API Silo (superficies,111 # nombre d'unités par typologie) — best effort, jamais bloquant112 silo: dict[str, dict] = {}113 try:114 data = self.get(SILO_API, headers={"Accept": "application/json"}).json()115 for u in data.get("units") or []:116 if u.get("not_unit") or u.get("hide"):117 continue118 ut = UT_OF_ROOMS.get(u.get("room"))119 if not ut:120 continue121 s = silo.setdefault(ut, {"n": 0, "areas": [], "baths": set()})122 s["n"] += 1123 if u.get("area"):124 s["areas"].append(float(u["area"]))125 if u.get("bathroom"):126 s["baths"].add(u["bathroom"])127 except Exception:128 silo = {}129130 seen: set[str] = set()131 for n, price_txt in PRICE_RE.findall(text):132 ut = normalize_unit_type(f"{n}½")133 if ut in seen:134 continue135 seen.add(ut)136 price_label = f"à partir de {price_txt.strip()}$/mois"137138 desc = ("Condos locatifs neufs tout inclus Citiluxx au Faubourg "139 "Cousineau (Saint-Hubert, Longueuil) : espaces de vie "140 "ouverts, abondante luminosité, finitions haut de gamme. "141 "Phases 1 et 2 en occupation immédiate.")142 details: dict = {}143 area_min = None144 s = silo.get(ut)145 if s and s["areas"]:146 area_min = min(s["areas"])147 desc += (f" Superficies de {int(min(s['areas']))} à "148 f"{int(max(s['areas']))} pi² selon l'unité "149 f"({s['n']} unités de ce type au plan).")150 details["building_units"] = s["n"]151152 listings.append(Listing(153 source=self.source_id,154 external_id=f"citiluxx-{n}.5",155 url=PLANS_URL,156 title=f"Citiluxx — {ut} au Faubourg Cousineau",157 address=ADDRESS,158 sector=SECTOR,159 city=CITY,160 unit_type=ut,161 price=parse_price(price_label),162 price_label=price_label,163 availability="Occupation immédiate",164 area_sqft=area_min,165 description=desc,166 amenities=list(AMENITIES),167 details=details,168 images=images,169 ))170 return listings171