# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/alfid.py : connecteur Groupe Alfid (alfidlouer.ca) # Gestionnaire montréalais (Plateau, Ville-Marie, Verdun, Anjou, CDN). # Plateforme Rentsync / The Lift System : découverte des immeubles via l'API # officielle du site (api.theliftsystem.com/v2/search, jeton public embarqué # dans le JS), puis parsing des fiches /properties/ rendues serveur — # rangées div.suite (type, chambres, salles de bain, prix, pi²), id stable # via le modal « Inquire Now » (#modal-suite-), photos par suite ou # galerie de l'immeuble (assets.rentsync.com/alfid_group). Le parsing des # rangées est partagé avec Douek (douek.py), même gabarit Rentsync. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector API = "https://api.theliftsystem.com/v2/search" AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (main.js du site) CLIENT_ID = "1162" CITY_IDS = "1863,69,3218" # Montréal, Anjou, Verdun _MODAL_RE = re.compile(r"#modal-suite-(\d+)") _HALF_RE = re.compile(r"(\d)\s*(?:½|1/2|[.,]5)") _GALLERY_RE = re.compile( r'https://assets\.rentsync\.com/[a-z0-9_]+/images/gallery/' r'[0-9]+/[^"\'\s\\)]+\.(?:jpg|jpeg|png|webp)', re.I) # Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres) _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 AlfidConnector(BaseConnector): source_id = "alfid" request_delay = 0.6 max_buildings = 25 # garde-fou de crawl (9 immeubles au flux) # -- API de recherche (backend officiel du site) --------------------------- def _search(self) -> list[dict]: params = { "locale": "fr", "client_id": CLIENT_ID, "auth_token": AUTH_TOKEN, "city_ids": CITY_IDS, "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] = [] seen: set = set() for i, b in enumerate(self._search()): if i >= self.max_buildings: break try: bid = b.get("id") if bid in seen: continue seen.add(bid) addr = b.get("address") or {} 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 # -- rangées de suites du gabarit Rentsync (partagé avec Douek) ------------- @staticmethod def _suite_rows(soup: BeautifulSoup) -> list[dict]: """Extrait les rangées div.suite : id stable (modal), type, chambres, salles de bain, prix, pi², photos propres à la suite.""" rows: list[dict] = [] for div in soup.select("div.suite"): modal = div.select_one('a.open-suite-modal[href^="#modal-suite-"]') m = _MODAL_RE.search(modal.get("href", "")) if modal else None if not m: continue row: dict = {"id": m.group(1)} type_el = div.select_one(".suite-type") row["type"] = _clean(type_el.get_text(" ", strip=True) if type_el else "") for field, sel in (("bed", ".suite-bed .value"), ("bath", ".suite-bath .value"), ("rate", ".suite-rate .value"), ("sqft", ".suite-sqft .value")): el = div.select_one(sel) row[field] = _clean(el.get_text(" ", strip=True)) if el else "" photos = [a.get("href") or "" for a in div.select("a.suite-photo[href]")] row["photos"] = [u for u in dict.fromkeys(photos) if u.startswith("http")][:25] rows.append(row) return rows def _row_to_listing(self, row: dict, *, url: str, name: str, address: str, sector: str, city: str, description: str = "", amenities: list[str] | None = None, images: list[str] | None = None, pets: str | None = None, details: dict | None = None, lat: float | None = None, lng: float | None = None) -> Listing: label = row["type"] # Type d'unité : « 3.5 » dans le libellé sinon nombre de chambres unit_type = "" hm = _HALF_RE.search(label) if hm: unit_type = f"{hm.group(1)}½" else: try: unit_type = _BED_TYPE.get(int(row.get("bed") or ""), "") except (TypeError, ValueError): # libellé Rentsync « 2 Bed 1 Bath » ou « 2 Bedroom » bm = re.search(r"(\d+)\s*Bed", label, re.I) if bm: unit_type = _BED_TYPE.get(int(bm.group(1)), "") elif re.search(r"bachelor|studio", label, re.I): unit_type = "Studio" price = None price_label = "" digits = re.sub(r"[^\d.]", "", row.get("rate") or "") if digits: try: price = float(digits) except ValueError: price = None price_label = f"À partir de {row['rate']}/mois" if price is not None and not (100 <= price <= 20000): price = None baths = (row.get("bath") or "").strip() try: bathrooms = float(baths) if baths else None except ValueError: bathrooms = None sq = re.sub(r"[^\d.]", "", row.get("sqft") or "") try: sqft = float(sq) if sq else None except ValueError: sqft = None return Listing( source=self.source_id, external_id=str(row["id"]), url=url, title=f"{name} — {label}" if label else name, address=address, sector=sector, city=city, unit_type=unit_type, bathrooms=bathrooms, price=price, price_label=price_label, area_sqft=sqft, pets=pets, description=description, amenities=list(amenities or []), details=dict(details or {}), images=row.get("photos") or list(images or []), lat=lat, lng=lng, ) # -- fiche immeuble : suites rendues serveur -------------------------------- def _parse_building(self, b: dict) -> list[Listing]: url = (b.get("permalink") or "").strip() if not url: return [] addr = b.get("address") or {} name = _clean(b.get("name") or "") address = _clean(addr.get("address") or "") city = _clean(addr.get("city") or "") or "Montréal" postal = _clean(addr.get("postal_code") or "") if address: address = f"{address}, {city}, QC" + (f" {postal}" if postal else "") sector = _clean(addr.get("neighbourhood") or "") if sector.isupper(): sector = sector.title() if sector.lower() in ("", city.lower(), "montreal", "montréal"): sector = "" description = _clean((b.get("details") or {}).get("overview") or "")[:600] 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 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") amenities = [el.get_text(" ", strip=True) for el in soup.select(".amenities .amenity-holder, " ".amenity-holder")] amenities = [a for a in dict.fromkeys(amenities) if a][:25] # Galerie de l'immeuble (dédupliquée par nom de fichier) 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] return [self._row_to_listing( row, url=url, name=name, address=address, sector=sector, city=city, description=description, amenities=amenities, images=images, pets=pets, details=details, lat=lat, lng=lng) for row in self._suite_rows(soup)]