SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
14.1 KB · 343 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/utile.py : connecteur UTILE (utile.org)5#   OBNL de logement étudiant. Immeubles à Montréal (Angus, Griffintown,6#   Milton-Parc, Saint-Laurent, Saint-Patrick, Parc La Fontaine,7#   Des Carrières, Hutchison…), Québec (Cité universitaire, Saint-Sacrement),8#   Trois-Rivières (Jean-XXIII), Rimouski et Sherbrooke (en développement).9#   Site Webflow rendu serveur : une annonce par immeuble ×10#   typologie (Studio, 2 bedroom...) avec prix "From $x/month", photos de la11#   galerie (templates Webflow encodés en URL) et statut de l'immeuble12#   (For rent / Fully rented — balises conditionnelles w-condition-invisible).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re18import urllib.parse1920from bs4 import BeautifulSoup2122from ..schema import Listing, parse_price, strip_accents23from .base import BaseConnector2425BASE = "https://www.utile.org"26INDEX_URL = f"{BASE}/en/immeubles"2728STATUSES = ("For rent", "Fully rented", "Under construction", "In development")2930# Ville affichée en fin d'adresse -> nom canonique Lou-Ka (clé sans accents)31_CITY_CANON = {32    "montreal": "Montréal",33    "quebec city": "Québec",34    "quebec": "Québec",35    "trois-rivieres": "Trois-Rivières",36    "sherbrooke": "Sherbrooke",37    "rimouski": "Rimouski",38}3940# Slug d'équipement (page /en/inclusions/<immeuble>---inclusions) -> libellé FR41_EQUIP_FR = {42    "cuisiniere": "Cuisinière",43    "refrigerateur": "Réfrigérateur",44    "lave-vaiselles": "Lave-vaisselle",45    "lave-vaisselle": "Lave-vaisselle",46    "micro-ondes": "Micro-ondes",47    "chauffage": "Chauffage",48    "eau-chaude": "Eau chaude",49    "electricite": "Électricité",50    "internet": "Internet",51    "climatisation": "Climatisation",52    "echangeur-dair": "Échangeur d'air",53    "portail-locataire-en-ligne": "Portail locataire en ligne",54    "buanderie": "Buanderie",55    "salle-de-lavage": "Salle de lavage",56    "gym": "Gym",57    "terrasse": "Terrasse",58    "terrasse-sur-le-toit": "Terrasse sur le toit",59    "ascenseur": "Ascenseur",60    "ascenseurs": "Ascenseurs",61    "ascenceurs": "Ascenseurs",     # coquille dans le slug de la source62    "air-climatise": "Air climatisé",63    "salle-de-colis-securisee": "Salle de colis sécurisée",64    "cour-interieure": "Cour intérieure",65    "salle-commune": "Salle commune",66    "salle-detude": "Salle d'étude",67    "rangement": "Rangement",68    "casier-de-rangement": "Casier de rangement",69    "stationnement-exterieur": "Stationnement extérieur",70    "stationnement-souterrain": "Stationnement souterrain",71    # « stationnement à vélo » ne doit pas être pris pour un stationnement72    # auto par la normalisation -> libellé neutre73    "stationnement-a-velo-interieur": "Espace vélos intérieur",74    "stationnement-a-velo-exterieur": "Espace vélos extérieur",75    "systeme-de-puces-electroniques-et-de-cameras":76        "Système de puces électroniques et de caméras",77    "presence-dun-gestionnaire-sur-place": "Présence d'un gestionnaire sur place",78    "abonnement-annuel-a-lapplication-partage-club":79        "Abonnement annuel à l'application Partage Club",80    "entretien-menager": "Entretien ménager",81}8283# Équipements « Not Included » exprimables sans risque de faux positif84_EQUIP_NEG = {85    "meubles": "Non meublé",86    "internet": "Internet non inclus",87    "chauffage": "Chauffage non inclus",88    "electricite": "Électricité non incluse",89    "eau-chaude": "Eau chaude non incluse",90    "climatisation": "Climatisation non incluse",91}929394def _equip_label(slug: str) -> str:95    return _EQUIP_FR.get(slug, slug.replace("-", " ").capitalize())969798_TYPE_MAP = {99    "studio": "Studio",100    "mezzanine studio": "Studio (mezzanine)",101    "1 bedroom": "3½",102    "2 bedroom": "4½",103    "3 bedroom": "5½",104    "4 bedroom": "6½",105}106107108def _visible(el) -> bool:109    """Webflow masque les éléments conditionnels avec w-condition-invisible."""110    for node in [el] + list(el.parents)[:6]:111        if node is None or not hasattr(node, "get"):112            break113        if "w-condition-invisible" in (node.get("class") or []):114            return False115    return True116117118def _unit_type(label: str) -> str:119    key = re.sub(r"\s+", " ", (label or "").strip().lower())120    for k, v in _TYPE_MAP.items():121        if key.startswith(k):122            return v123    m = re.search(r"(\d)\s*bedroom", key)124    if m:125        return f"{int(m.group(1)) + 2}½"126    return label.strip()127128129class UtileConnector(BaseConnector):130    source_id = "utile"131    request_delay = 0.6132    max_buildings = 20       # garde-fou133    max_incl_requests = 30   # plafond de vraies requêtes « inclusions »134135    def fetch(self) -> list[Listing]:136        listings: list[Listing] = []137        self._incl_requests = 0138        try:139            index = self.get(INDEX_URL).text140        except Exception:141            return listings142        slugs = list(dict.fromkeys(143            re.findall(r'href="/en/immeubles/([a-z0-9\-]+)"', index)))144145        for slug in slugs[:self.max_buildings]:146            try:147                listings.extend(self._fetch_building(slug))148            except Exception:149                continue150        return listings151152    # -- inclusions d'un immeuble (page CMS Webflow « nest ») ----------------------153    def _fetch_inclusions(self, url: str) -> dict:154        """Retourne {groupe: [slugs d'équipement]} depuis la page inclusions.155156        Groupes Webflow : equipement-appartement (inclus dans tous les157        appartements), equipement-immeuble (dans l'immeuble),158        equipement-supplement (en sus), equipement-exclus (non inclus).159        """160        html = self.get(url).text161        soup = BeautifulSoup(html, "html.parser")162        groups: dict[str, list[str]] = {}163        for lst in soup.select("[fs-cmsnest-collection]"):164            name = lst.get("fs-cmsnest-collection") or ""165            slugs: list[str] = []166            for a in lst.select('a[href*="/equipement/"]'):167                m = re.search(r"/equipement/([a-z0-9\-]+)", a.get("href") or "")168                if m and m.group(1) not in slugs:169                    slugs.append(m.group(1))170            if name and slugs:171                groups[name] = slugs172        return groups173174    # -- un immeuble -------------------------------------------------------------175    def _fetch_building(self, slug: str) -> list[Listing]:176        url = f"{BASE}/en/immeubles/{slug}"177        html = self.get(url).text178        soup = BeautifulSoup(html, "html.parser")179180        # Secteur + adresse (bloc c-location-tag du hero)181        sector = address = ""182        infos = soup.select(".c-location-tag__adress-wrapper "183                            ".c-location-tag__info")184        if len(infos) >= 2:185            sector = infos[0].get_text(" ", strip=True)186            address = infos[1].get_text(" ", strip=True)187        elif infos:188            address = infos[0].get_text(" ", strip=True)189190        # Marqueur carte « main-building-data » : adresse + lat/lng structurés191        lat = lng = None192        mb = soup.select_one(".main-building-data")193        if mb:194            if not address:195                address = (mb.get("data-address") or "").strip()196            try:197                lat = float(mb.get("data-lat") or "")198                lng = float(mb.get("data-lng") or "")199            except (TypeError, ValueError):200                lat = lng = None201202        # Ville = dernier segment de l'adresse (« Montreal », « Québec City »,203        # « Trois-Rivières », « Rimouski »…), canonisée — depuis l'expansion204        # provinciale, tous les immeubles UTILE sont couverts.205        raw_city = address.rsplit(",", 1)[-1].strip() if "," in address else ""206        city = _CITY_CANON.get(strip_accents(raw_city.lower()), "")207        if not city:208            return []            # ville non reconnue : ne rien inventer209        address = f"{address.rsplit(',', 1)[0].rstrip()}, {city}"210211        # Statut de l'immeuble : tag visible du hero (c-location-tag).212        # Le reste de la page (menu immeubles, cartes « autres projets »)213        # contient les tags de TOUS les immeubles : ne pas les balayer.214        status = ""215        hero = soup.select_one(".c-location-tag__content-wrapper")216        for el in (hero or soup).find_all(string=re.compile(217                r"^(For rent|Fully rented|Under construction|In development)$")):218            if _visible(el.find_parent()):219                status = el.strip()220                if hero is not None:221                    break222        if status in ("Under construction", "In development"):223            return []            # pas encore en location224225        name_el = soup.select_one("h1")226        building = name_el.get_text(" ", strip=True) if name_el else slug227228        # Stats du hero (« Renting from », « Number of apartments »...)229        stats: dict[str, str] = {}230        for st in soup.select(".c-building-stat"):231            if not _visible(st):232                continue233            t = st.select_one(".c-building-stat__title")234            v = st.select_one(".c-building-stat__text")235            if t and v and v.get_text(strip=True):236                stats[t.get_text(" ", strip=True)] = v.get_text(" ", strip=True)237238        # Description (og:description) + début de location annoncé239        desc = ""240        og = soup.find("meta", attrs={"property": "og:description"})241        if og and og.get("content"):242            desc = og["content"].strip()[:600]243        renting_from = stats.get("Renting from", "")244        if renting_from:245            desc = (f"En location à partir du : {renting_from}. " + desc)[:600]246247        # Inclusions par immeuble (page CMS /en/inclusions/<slug>---inclusions,248        # via le cache détail : 1 vraie requête par immeuble et par changement)249        amenities = ["Logement étudiant", "Bail 12 mois"]250        link = soup.select_one('a.c-inclusions__link[href^="/en/inclusions/"]')251        groups: dict = {}252        if link:253            incl_url = BASE + link["href"]254            key = hashlib.sha1(html.encode("utf-8")).hexdigest()255256            def _fetch_incl() -> dict:257                if self._incl_requests >= self.max_incl_requests:258                    return {}259                self._incl_requests += 1260                return self._fetch_inclusions(incl_url)261262            try:263                groups = self.detail(f"bldg:{slug}", key, _fetch_incl) or {}264            except Exception:265                groups = {}266        for eq in groups.get("equipement-appartement", []):267            amenities.append(_equip_label(eq))268        for eq in groups.get("equipement-immeuble", []):269            amenities.append(_equip_label(eq))270        for eq in groups.get("equipement-supplement", []):271            amenities.append(f"{_equip_label(eq)} (en sus)")272        for eq in groups.get("equipement-exclus", []):273            if eq in _EQUIP_NEG:274                amenities.append(_EQUIP_NEG[eq])275        if len(amenities) == 2:      # repli : énoncé général du site276            amenities += ["Non meublé",277                          "Cuisinière et réfrigérateur inclus"]278        amenities = list(dict.fromkeys(amenities))279280        avail_map = {"For rent": "En location",281                     "Fully rented": "Complet (prochaine période de location)"}282        building_avail = avail_map.get(status, status)283284        out: list[Listing] = []285        for card in soup.select(".c-building-apartment__collection-item"):286            try:287                title_el = card.select_one(".c-apartment-details__title")288                price_el = card.select_one(".c-apartment-price__content")289                if not title_el or not price_el:290                    continue291                typology = title_el.get_text(" ", strip=True)292                price_label = price_el.get_text(" ", strip=True)293                from_el = card.select_one(".c-apartment-price__title")294                if from_el and from_el.get_text(strip=True):295                    price_label = (f"{from_el.get_text(strip=True)} "296                                   f"{price_label}")297                price = parse_price(298                    re.sub(r"\$(\d+)", r"\1$", price_label))299300                # "Complet" affiché sur la carte ?301                complet = any(302                    _visible(s.find_parent()) for s in303                    card.find_all(string=re.compile(r"^Complet$")))304305                # Photos : image de la carte + galerie rendue + template306                # Webflow (galerie modale, encodée en URL dans307                # <script type="text/x-wf-template">)308                images: list[str] = []309                for img in card.select("img"):310                    src = img.get("src") or ""311                    if src.startswith("http") and not src.endswith(".svg"):312                        images.append(src)313                for tmpl in card.select('script[type="text/x-wf-template"]'):314                    decoded = urllib.parse.unquote(tmpl.get_text())315                    images.extend(re.findall(316                        r'https://cdn\.prod\.website-files\.com/'317                        r'[^"\s]+?\.(?:jpe?g|png|webp)', decoded))318                images = [u for u in dict.fromkeys(images)319                          if "-p-" not in u]      # variantes responsive320321                type_slug = re.sub(r"[^a-z0-9]+", "-", typology.lower()).strip("-")322                out.append(Listing(323                    source=self.source_id,324                    external_id=f"{slug}-{type_slug}",325                    url=url,326                    title=f"UTILE {building}{typology}",327                    address=address,328                    sector=sector,329                    city=city,330                    unit_type=_unit_type(typology),331                    price=price,332                    price_label=price_label,333                    availability="Complet" if complet else building_avail,334                    description=desc,335                    amenities=list(amenities),336                    images=images,337                    lat=lat,338                    lng=lng,339                ))340            except Exception:341                continue342        return out343