# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/utile.py : connecteur UTILE (utile.org) # OBNL de logement étudiant. Immeubles à Montréal (Angus, Griffintown, # Milton-Parc, Saint-Laurent, Saint-Patrick, Parc La Fontaine, # Des Carrières, Hutchison…), Québec (Cité universitaire, Saint-Sacrement), # Trois-Rivières (Jean-XXIII), Rimouski et Sherbrooke (en développement). # Site Webflow rendu serveur : une annonce par immeuble × # typologie (Studio, 2 bedroom...) avec prix "From $x/month", photos de la # galerie (templates Webflow encodés en URL) et statut de l'immeuble # (For rent / Fully rented — balises conditionnelles w-condition-invisible). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import urllib.parse from bs4 import BeautifulSoup from ..schema import Listing, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.utile.org" INDEX_URL = f"{BASE}/en/immeubles" STATUSES = ("For rent", "Fully rented", "Under construction", "In development") # Ville affichée en fin d'adresse -> nom canonique Lou-Ka (clé sans accents) _CITY_CANON = { "montreal": "Montréal", "quebec city": "Québec", "quebec": "Québec", "trois-rivieres": "Trois-Rivières", "sherbrooke": "Sherbrooke", "rimouski": "Rimouski", } # Slug d'équipement (page /en/inclusions/---inclusions) -> libellé FR _EQUIP_FR = { "cuisiniere": "Cuisinière", "refrigerateur": "Réfrigérateur", "lave-vaiselles": "Lave-vaisselle", "lave-vaisselle": "Lave-vaisselle", "micro-ondes": "Micro-ondes", "chauffage": "Chauffage", "eau-chaude": "Eau chaude", "electricite": "Électricité", "internet": "Internet", "climatisation": "Climatisation", "echangeur-dair": "Échangeur d'air", "portail-locataire-en-ligne": "Portail locataire en ligne", "buanderie": "Buanderie", "salle-de-lavage": "Salle de lavage", "gym": "Gym", "terrasse": "Terrasse", "terrasse-sur-le-toit": "Terrasse sur le toit", "ascenseur": "Ascenseur", "ascenseurs": "Ascenseurs", "ascenceurs": "Ascenseurs", # coquille dans le slug de la source "air-climatise": "Air climatisé", "salle-de-colis-securisee": "Salle de colis sécurisée", "cour-interieure": "Cour intérieure", "salle-commune": "Salle commune", "salle-detude": "Salle d'étude", "rangement": "Rangement", "casier-de-rangement": "Casier de rangement", "stationnement-exterieur": "Stationnement extérieur", "stationnement-souterrain": "Stationnement souterrain", # « stationnement à vélo » ne doit pas être pris pour un stationnement # auto par la normalisation -> libellé neutre "stationnement-a-velo-interieur": "Espace vélos intérieur", "stationnement-a-velo-exterieur": "Espace vélos extérieur", "systeme-de-puces-electroniques-et-de-cameras": "Système de puces électroniques et de caméras", "presence-dun-gestionnaire-sur-place": "Présence d'un gestionnaire sur place", "abonnement-annuel-a-lapplication-partage-club": "Abonnement annuel à l'application Partage Club", "entretien-menager": "Entretien ménager", } # Équipements « Not Included » exprimables sans risque de faux positif _EQUIP_NEG = { "meubles": "Non meublé", "internet": "Internet non inclus", "chauffage": "Chauffage non inclus", "electricite": "Électricité non incluse", "eau-chaude": "Eau chaude non incluse", "climatisation": "Climatisation non incluse", } def _equip_label(slug: str) -> str: return _EQUIP_FR.get(slug, slug.replace("-", " ").capitalize()) _TYPE_MAP = { "studio": "Studio", "mezzanine studio": "Studio (mezzanine)", "1 bedroom": "3½", "2 bedroom": "4½", "3 bedroom": "5½", "4 bedroom": "6½", } def _visible(el) -> bool: """Webflow masque les éléments conditionnels avec w-condition-invisible.""" for node in [el] + list(el.parents)[:6]: if node is None or not hasattr(node, "get"): break if "w-condition-invisible" in (node.get("class") or []): return False return True def _unit_type(label: str) -> str: key = re.sub(r"\s+", " ", (label or "").strip().lower()) for k, v in _TYPE_MAP.items(): if key.startswith(k): return v m = re.search(r"(\d)\s*bedroom", key) if m: return f"{int(m.group(1)) + 2}½" return label.strip() class UtileConnector(BaseConnector): source_id = "utile" request_delay = 0.6 max_buildings = 20 # garde-fou max_incl_requests = 30 # plafond de vraies requêtes « inclusions » def fetch(self) -> list[Listing]: listings: list[Listing] = [] self._incl_requests = 0 try: index = self.get(INDEX_URL).text except Exception: return listings slugs = list(dict.fromkeys( re.findall(r'href="/en/immeubles/([a-z0-9\-]+)"', index))) for slug in slugs[:self.max_buildings]: try: listings.extend(self._fetch_building(slug)) except Exception: continue return listings # -- inclusions d'un immeuble (page CMS Webflow « nest ») ---------------------- def _fetch_inclusions(self, url: str) -> dict: """Retourne {groupe: [slugs d'équipement]} depuis la page inclusions. Groupes Webflow : equipement-appartement (inclus dans tous les appartements), equipement-immeuble (dans l'immeuble), equipement-supplement (en sus), equipement-exclus (non inclus). """ html = self.get(url).text soup = BeautifulSoup(html, "html.parser") groups: dict[str, list[str]] = {} for lst in soup.select("[fs-cmsnest-collection]"): name = lst.get("fs-cmsnest-collection") or "" slugs: list[str] = [] for a in lst.select('a[href*="/equipement/"]'): m = re.search(r"/equipement/([a-z0-9\-]+)", a.get("href") or "") if m and m.group(1) not in slugs: slugs.append(m.group(1)) if name and slugs: groups[name] = slugs return groups # -- un immeuble ------------------------------------------------------------- def _fetch_building(self, slug: str) -> list[Listing]: url = f"{BASE}/en/immeubles/{slug}" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # Secteur + adresse (bloc c-location-tag du hero) sector = address = "" infos = soup.select(".c-location-tag__adress-wrapper " ".c-location-tag__info") if len(infos) >= 2: sector = infos[0].get_text(" ", strip=True) address = infos[1].get_text(" ", strip=True) elif infos: address = infos[0].get_text(" ", strip=True) # Marqueur carte « main-building-data » : adresse + lat/lng structurés lat = lng = None mb = soup.select_one(".main-building-data") if mb: if not address: address = (mb.get("data-address") or "").strip() try: lat = float(mb.get("data-lat") or "") lng = float(mb.get("data-lng") or "") except (TypeError, ValueError): lat = lng = None # Ville = dernier segment de l'adresse (« Montreal », « Québec City », # « Trois-Rivières », « Rimouski »…), canonisée — depuis l'expansion # provinciale, tous les immeubles UTILE sont couverts. raw_city = address.rsplit(",", 1)[-1].strip() if "," in address else "" city = _CITY_CANON.get(strip_accents(raw_city.lower()), "") if not city: return [] # ville non reconnue : ne rien inventer address = f"{address.rsplit(',', 1)[0].rstrip()}, {city}" # Statut de l'immeuble : tag visible du hero (c-location-tag). # Le reste de la page (menu immeubles, cartes « autres projets ») # contient les tags de TOUS les immeubles : ne pas les balayer. status = "" hero = soup.select_one(".c-location-tag__content-wrapper") for el in (hero or soup).find_all(string=re.compile( r"^(For rent|Fully rented|Under construction|In development)$")): if _visible(el.find_parent()): status = el.strip() if hero is not None: break if status in ("Under construction", "In development"): return [] # pas encore en location name_el = soup.select_one("h1") building = name_el.get_text(" ", strip=True) if name_el else slug # Stats du hero (« Renting from », « Number of apartments »...) stats: dict[str, str] = {} for st in soup.select(".c-building-stat"): if not _visible(st): continue t = st.select_one(".c-building-stat__title") v = st.select_one(".c-building-stat__text") if t and v and v.get_text(strip=True): stats[t.get_text(" ", strip=True)] = v.get_text(" ", strip=True) # Description (og:description) + début de location annoncé desc = "" og = soup.find("meta", attrs={"property": "og:description"}) if og and og.get("content"): desc = og["content"].strip()[:600] renting_from = stats.get("Renting from", "") if renting_from: desc = (f"En location à partir du : {renting_from}. " + desc)[:600] # Inclusions par immeuble (page CMS /en/inclusions/---inclusions, # via le cache détail : 1 vraie requête par immeuble et par changement) amenities = ["Logement étudiant", "Bail 12 mois"] link = soup.select_one('a.c-inclusions__link[href^="/en/inclusions/"]') groups: dict = {} if link: incl_url = BASE + link["href"] key = hashlib.sha1(html.encode("utf-8")).hexdigest() def _fetch_incl() -> dict: if self._incl_requests >= self.max_incl_requests: return {} self._incl_requests += 1 return self._fetch_inclusions(incl_url) try: groups = self.detail(f"bldg:{slug}", key, _fetch_incl) or {} except Exception: groups = {} for eq in groups.get("equipement-appartement", []): amenities.append(_equip_label(eq)) for eq in groups.get("equipement-immeuble", []): amenities.append(_equip_label(eq)) for eq in groups.get("equipement-supplement", []): amenities.append(f"{_equip_label(eq)} (en sus)") for eq in groups.get("equipement-exclus", []): if eq in _EQUIP_NEG: amenities.append(_EQUIP_NEG[eq]) if len(amenities) == 2: # repli : énoncé général du site amenities += ["Non meublé", "Cuisinière et réfrigérateur inclus"] amenities = list(dict.fromkeys(amenities)) avail_map = {"For rent": "En location", "Fully rented": "Complet (prochaine période de location)"} building_avail = avail_map.get(status, status) out: list[Listing] = [] for card in soup.select(".c-building-apartment__collection-item"): try: title_el = card.select_one(".c-apartment-details__title") price_el = card.select_one(".c-apartment-price__content") if not title_el or not price_el: continue typology = title_el.get_text(" ", strip=True) price_label = price_el.get_text(" ", strip=True) from_el = card.select_one(".c-apartment-price__title") if from_el and from_el.get_text(strip=True): price_label = (f"{from_el.get_text(strip=True)} " f"{price_label}") price = parse_price( re.sub(r"\$(\d+)", r"\1$", price_label)) # "Complet" affiché sur la carte ? complet = any( _visible(s.find_parent()) for s in card.find_all(string=re.compile(r"^Complet$"))) # Photos : image de la carte + galerie rendue + template # Webflow (galerie modale, encodée en URL dans #