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%
7.1 KB · 171 lines python
Raw Blame History
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