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%
10.0 KB · 254 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/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