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/alfid.py : connecteur Groupe Alfid (alfidlouer.ca)5# Gestionnaire montréalais (Plateau, Ville-Marie, Verdun, Anjou, CDN).6# Plateforme Rentsync / The Lift System : découverte des immeubles via l'API7# officielle du site (api.theliftsystem.com/v2/search, jeton public embarqué8# dans le JS), puis parsing des fiches /properties/<slug> rendues serveur —9# rangées div.suite (type, chambres, salles de bain, prix, pi²), id stable10# via le modal « Inquire Now » (#modal-suite-<id>), photos par suite ou11# galerie de l'immeuble (assets.rentsync.com/alfid_group). Le parsing des12# rangées est partagé avec Douek (douek.py), même gabarit Rentsync.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import html as htmllib17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing22from .base import BaseConnector2324API = "https://api.theliftsystem.com/v2/search"25AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (main.js du site)26CLIENT_ID = "1162"27CITY_IDS = "1863,69,3218" # Montréal, Anjou, Verdun2829_MODAL_RE = re.compile(r"#modal-suite-(\d+)")30_HALF_RE = re.compile(r"(\d)\s*(?:½|1/2|[.,]5)")31_GALLERY_RE = re.compile(32 r'https://assets\.rentsync\.com/[a-z0-9_]+/images/gallery/'33 r'[0-9]+/[^"\'\s\\)]+\.(?:jpg|jpeg|png|webp)', re.I)3435# Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres)36_BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}373839def _clean(txt: str) -> str:40 txt = htmllib.unescape(htmllib.unescape(txt or ""))41 txt = re.sub(r"<[^>]+>", " ", txt)42 return re.sub(r"\s+", " ", txt).strip()434445class AlfidConnector(BaseConnector):46 source_id = "alfid"47 request_delay = 0.648 max_buildings = 25 # garde-fou de crawl (9 immeubles au flux)4950 # -- API de recherche (backend officiel du site) ---------------------------51 def _search(self) -> list[dict]:52 params = {53 "locale": "fr",54 "client_id": CLIENT_ID,55 "auth_token": AUTH_TOKEN,56 "city_ids": CITY_IDS,57 "geocode": "",58 "min_bed": "-1", "max_bed": "100",59 "min_bath": "-1", "max_bath": "10",60 "min_rate": "0", "max_rate": "100000",61 "property_types": "apartments, houses",62 "order": "min_rate ASC",63 "limit": "66", "offset": "0",64 "count": "false",65 "show_all_properties": "true",66 }67 data = self.get(API, params=params).json()68 return data if isinstance(data, list) else []6970 def fetch(self) -> list[Listing]:71 listings: list[Listing] = []72 seen: set = set()73 for i, b in enumerate(self._search()):74 if i >= self.max_buildings:75 break76 try:77 bid = b.get("id")78 if bid in seen:79 continue80 seen.add(bid)81 addr = b.get("address") or {}82 if (addr.get("province_code") or "").upper() != "QC":83 continue84 if int(b.get("availability_count") or 0) <= 0:85 continue # aucune unité disponible86 listings.extend(self._parse_building(b))87 except Exception:88 continue89 return listings9091 # -- rangées de suites du gabarit Rentsync (partagé avec Douek) -------------92 @staticmethod93 def _suite_rows(soup: BeautifulSoup) -> list[dict]:94 """Extrait les rangées div.suite : id stable (modal), type, chambres,95 salles de bain, prix, pi², photos propres à la suite."""96 rows: list[dict] = []97 for div in soup.select("div.suite"):98 modal = div.select_one('a.open-suite-modal[href^="#modal-suite-"]')99 m = _MODAL_RE.search(modal.get("href", "")) if modal else None100 if not m:101 continue102 row: dict = {"id": m.group(1)}103 type_el = div.select_one(".suite-type")104 row["type"] = _clean(type_el.get_text(" ", strip=True)105 if type_el else "")106 for field, sel in (("bed", ".suite-bed .value"),107 ("bath", ".suite-bath .value"),108 ("rate", ".suite-rate .value"),109 ("sqft", ".suite-sqft .value")):110 el = div.select_one(sel)111 row[field] = _clean(el.get_text(" ", strip=True)) if el else ""112 photos = [a.get("href") or ""113 for a in div.select("a.suite-photo[href]")]114 row["photos"] = [u for u in dict.fromkeys(photos)115 if u.startswith("http")][:25]116 rows.append(row)117 return rows118119 def _row_to_listing(self, row: dict, *, url: str, name: str, address: str,120 sector: str, city: str, description: str = "",121 amenities: list[str] | None = None,122 images: list[str] | None = None,123 pets: str | None = None, details: dict | None = None,124 lat: float | None = None,125 lng: float | None = None) -> Listing:126 label = row["type"]127 # Type d'unité : « 3.5 » dans le libellé sinon nombre de chambres128 unit_type = ""129 hm = _HALF_RE.search(label)130 if hm:131 unit_type = f"{hm.group(1)}½"132 else:133 try:134 unit_type = _BED_TYPE.get(int(row.get("bed") or ""), "")135 except (TypeError, ValueError):136 # libellé Rentsync « 2 Bed 1 Bath » ou « 2 Bedroom »137 bm = re.search(r"(\d+)\s*Bed", label, re.I)138 if bm:139 unit_type = _BED_TYPE.get(int(bm.group(1)), "")140 elif re.search(r"bachelor|studio", label, re.I):141 unit_type = "Studio"142143 price = None144 price_label = ""145 digits = re.sub(r"[^\d.]", "", row.get("rate") or "")146 if digits:147 try:148 price = float(digits)149 except ValueError:150 price = None151 price_label = f"À partir de {row['rate']}/mois"152 if price is not None and not (100 <= price <= 20000):153 price = None154155 baths = (row.get("bath") or "").strip()156 try:157 bathrooms = float(baths) if baths else None158 except ValueError:159 bathrooms = None160161 sq = re.sub(r"[^\d.]", "", row.get("sqft") or "")162 try:163 sqft = float(sq) if sq else None164 except ValueError:165 sqft = None166167 return Listing(168 source=self.source_id,169 external_id=str(row["id"]),170 url=url,171 title=f"{name} — {label}" if label else name,172 address=address,173 sector=sector,174 city=city,175 unit_type=unit_type,176 bathrooms=bathrooms,177 price=price,178 price_label=price_label,179 area_sqft=sqft,180 pets=pets,181 description=description,182 amenities=list(amenities or []),183 details=dict(details or {}),184 images=row.get("photos") or list(images or []),185 lat=lat,186 lng=lng,187 )188189 # -- fiche immeuble : suites rendues serveur --------------------------------190 def _parse_building(self, b: dict) -> list[Listing]:191 url = (b.get("permalink") or "").strip()192 if not url:193 return []194195 addr = b.get("address") or {}196 name = _clean(b.get("name") or "")197 address = _clean(addr.get("address") or "")198 city = _clean(addr.get("city") or "") or "Montréal"199 postal = _clean(addr.get("postal_code") or "")200 if address:201 address = f"{address}, {city}, QC" + (f" {postal}" if postal202 else "")203 sector = _clean(addr.get("neighbourhood") or "")204 if sector.isupper():205 sector = sector.title()206 if sector.lower() in ("", city.lower(), "montreal", "montréal"):207 sector = ""208209 description = _clean((b.get("details") or {}).get("overview")210 or "")[:600]211 pets = None212 if isinstance(b.get("pet_friendly"), bool):213 pets = "oui" if b["pet_friendly"] else "non"214215 details: dict = {}216 contact = b.get("contact") or {}217 cinfo = {}218 if _clean(contact.get("phone") or ""):219 cinfo["phone"] = _clean(contact["phone"])220 if _clean(contact.get("email") or ""):221 cinfo["email"] = _clean(contact["email"])222 if cinfo:223 details["contact"] = cinfo224225 geo = b.get("geocode") or {}226 try:227 lat, lng = float(geo.get("latitude")), float(geo.get("longitude"))228 except (TypeError, ValueError):229 lat = lng = None230231 html = self.get(url).text232 soup = BeautifulSoup(html, "html.parser")233234 amenities = [el.get_text(" ", strip=True)235 for el in soup.select(".amenities .amenity-holder, "236 ".amenity-holder")]237 amenities = [a for a in dict.fromkeys(amenities) if a][:25]238239 # Galerie de l'immeuble (dédupliquée par nom de fichier)240 images: list[str] = []241 seen_files: set[str] = set()242 for u in _GALLERY_RE.findall(html):243 fname = u.rsplit("/", 1)[-1]244 if fname not in seen_files:245 seen_files.add(fname)246 images.append(u)247 images = images[:25]248249 return [self._row_to_listing(250 row, url=url, name=name, address=address, sector=sector,251 city=city, description=description, amenities=amenities,252 images=images, pets=pets, details=details, lat=lat, lng=lng)253 for row in self._suite_rows(soup)]254