spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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