# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/denux.py : connecteur Groupe Denux (groupedenux.com) # Portefeuille pancanadien (plateforme Rentsync / The Lift System). # Seules les villes québécoises sont interrogées (Montréal, Saint-Lambert, # Mascouche) — la Colombie-Britannique, l'Alberta et la France sont exclues # d'office. Découverte des immeubles via l'API JSON du site # (api.theliftsystem.com/v2/search, jeton public embarqué dans le JS du # site), puis parsing des fiches /residential/ rendues serveur : # suites disponibles (div.suite[data-suite-id] avec type, prix, chambres, # date de disponibilité), commodités et galerie photos. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector SITE = "https://www.groupedenux.com" API = "https://api.theliftsystem.com/v2/search" AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (scripts/main.js du site) CLIENT_ID = "654" # Villes québécoises desservies (id Lift System -> nom normalisé) QC_CITIES = { "1863": "Montréal", "2789": "Saint-Lambert", "1741": "Mascouche", } _GALLERY_RE = re.compile( r'https://assets\.rentsync\.com/groupe_denux/images/gallery/' r'[0-9]+/[^"\'\s\\)]+\.(?:jpg|jpeg|png|webp)', re.I) _HALF_RE = re.compile(r"(\d)\s*(?:½|1/2|[.,]5)") # Nombre de chambres -> type d'unité (à défaut d'un « X.5 » dans le libellé) _BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"} def _clean(txt: str) -> str: txt = htmllib.unescape(htmllib.unescape(txt or "")) txt = re.sub(r"<[^>]+>", " ", txt) return re.sub(r"\s+", " ", txt).strip() class DenuxConnector(BaseConnector): source_id = "denux" request_delay = 0.6 max_buildings = 40 # garde-fou de crawl # -- API de recherche (backend officiel du site) --------------------------- def _search_city(self, city_id: str) -> list[dict]: params = { "locale": "fr", "client_id": CLIENT_ID, "auth_token": AUTH_TOKEN, "city_id": city_id, "geocode": "", "min_bed": "-1", "max_bed": "100", "min_bath": "-1", "max_bath": "10", "min_rate": "0", "max_rate": "100000", "property_types": "apartments, houses", "order": "min_rate ASC", "limit": "66", "offset": "0", "count": "false", "show_all_properties": "true", } data = self.get(API, params=params).json() return data if isinstance(data, list) else [] def fetch(self) -> list[Listing]: listings: list[Listing] = [] buildings: list[dict] = [] for city_id in QC_CITIES: try: buildings.extend(self._search_city(city_id)) except Exception: continue seen: set = set() for i, b in enumerate(buildings): if i >= self.max_buildings: break try: bid = b.get("id") if bid in seen: continue seen.add(bid) addr = b.get("address") or {} # Garde-fou : Québec seulement (exclut C.-B., Alberta, France) if (addr.get("province_code") or "").upper() != "QC": continue if int(b.get("availability_count") or 0) <= 0: continue # aucune unité disponible listings.extend(self._parse_building(b)) except Exception: continue return listings # -- fiche immeuble : suites rendues serveur -------------------------------- def _parse_building(self, b: dict) -> list[Listing]: slug = (b.get("permalink") or "").rstrip("/").split("/")[-1] if not slug: return [] url = f"{SITE}/residential/{slug}" addr = b.get("address") or {} name = _clean(b.get("name") or slug) address = _clean(addr.get("address") or "") city = _clean(addr.get("city") or "") sector = _clean(addr.get("neighbourhood") or "") if sector.isupper(): sector = sector.title() if sector.lower() in ("", city.lower(), "montreal", "montréal"): sector = "" bdetails = b.get("details") or {} description = _clean(bdetails.get("overview") or "")[:600] # Champs structurés de l'API Lift System (jamais devinés du texte) pets = None if isinstance(b.get("pet_friendly"), bool): pets = "oui" if b["pet_friendly"] else "non" details: dict = {} contact = b.get("contact") or {} cinfo = {} if _clean(contact.get("phone") or ""): cinfo["phone"] = _clean(contact["phone"]) if _clean(contact.get("email") or ""): cinfo["email"] = _clean(contact["email"]) if cinfo: details["contact"] = cinfo parking = b.get("parking") or {} if parking.get("indoor") or parking.get("outdoor"): details["parking"] = { "available": True, "type": "intérieur" if parking.get("indoor") else "extérieur", } geo = b.get("geocode") or {} try: lat, lng = float(geo.get("latitude")), float(geo.get("longitude")) except (TypeError, ValueError): lat = lng = None html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # Commodités (suite + immeuble) amenities = [el.get_text(" ", strip=True) for el in soup.select(".amenities .amenity-holder")] amenities = [a for a in dict.fromkeys(amenities) if a][:25] # Galerie photos (dédupliquée par nom de fichier, tailles multiples) images: list[str] = [] seen_files: set[str] = set() for u in _GALLERY_RE.findall(html): fname = u.rsplit("/", 1)[-1] if fname not in seen_files: seen_files.add(fname) images.append(u) images = images[:25] # Détails par suite (ul.suite-info) indexés par data-suite-id info_by_id: dict[str, dict[str, str]] = {} photos_by_id: dict[str, list[str]] = {} for ul in soup.select("ul.suite-info[data-suite-id]"): sid = ul.get("data-suite-id") or "" fields: dict[str, str] = {} for li in ul.select("li.info-block"): lab = li.select_one(".label") val = li.select_one(".info") if not (lab and val): continue label = lab.get_text(strip=True).lower() # Photos propres à la suite (liens « View » de la galerie) if "photo" in label: urls = [a.get("href") or "" for a in val.select("a")] urls = [u for u in dict.fromkeys(urls) if u.startswith("http")] if urls: photos_by_id[sid] = urls[:25] continue # Le champ « Availability » contient un lien + un modal de # formulaire : ne garder que le libellé du lien. link = val.select_one("a.open-suite-modal") text = (link.get_text(" ", strip=True) if link else val.get_text(" ", strip=True)) fields[label] = text[:80].strip() info_by_id[sid] = fields results: list[Listing] = [] for div in soup.select("div.suite[data-suite-id]"): sid = div.get("data-suite-id") or "" if not sid: continue type_el = div.select_one(".suite-type") rate_el = div.select_one(".suite-rate") suite_label = _clean(type_el.get_text(" ", strip=True) if type_el else "") # Nettoyage du libellé (certains contiennent dispo + prix) suite_label = re.sub(r"\s*[-–]?\s*Starting at\s*\$[\d,]+", "", suite_label, flags=re.I) suite_label = re.sub(r"\s*[-–]?\s*Available\s+(now|immediately)\b", "", suite_label, flags=re.I).strip(" -–,") info = info_by_id.get(sid, {}) # Type d'unité : « Grand 5.5, balcon... » -> 5½, sinon nb chambres unit_type = "" hm = _HALF_RE.search(suite_label) if hm: unit_type = f"{hm.group(1)}½" else: beds_txt = (info.get("bedrooms") or (div.get("class") and next((c.replace("beds_", "") for c in div.get("class") if c.startswith("beds_")), "")) or "") try: unit_type = _BED_TYPE.get(int(beds_txt), "") except (ValueError, TypeError): unit_type = "" # Prix : « $1,320 » (à partir de) price = None price_label = "" if rate_el: raw = rate_el.get_text(" ", strip=True) digits = re.sub(r"[^\d.]", "", raw) if digits: try: price = float(digits) except ValueError: price = None price_label = f"À partir de {raw}/mois" if price is not None and not (100 <= price <= 20000): price = None availability = info.get("availability", "") or \ _clean(b.get("availability_status_label") or "") # Commodités de l'immeuble + salles de bain de la suite suite_amenities = list(amenities) baths = (info.get("bathrooms") or "").strip() if baths and re.match(r"^[\d.]+$", baths): suite_amenities.append(f"{baths} salle(s) de bain") results.append(Listing( source=self.source_id, external_id=str(sid), url=url, title=f"{name} — {suite_label}" if suite_label else name, address=address, sector=sector, city=city or QC_CITIES.get(str(addr.get("city_id")), ""), unit_type=unit_type, price=price, price_label=price_label, availability=availability, pets=pets, description=description, amenities=suite_amenities, details={k: dict(v) if isinstance(v, dict) else v for k, v in details.items()}, images=photos_by_id.get(sid) or images, lat=lat, lng=lng, )) return results