# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/werkliv.py : connecteur Werkliv (logement étudiant) # werkliv.com est le site corporatif ; la location de ses immeubles passe # par sa plateforme University Apartments (universityapartments.ca — # WordPress + FacetWP rendu serveur). Immeubles montréalais : Palay # (2025 rue Peel, centre-ville) et Le Mojave (3476 rue Saint-Dominique, # Plateau/Milton-Parc). Une annonce par typologie (1-BEDROOM, 4-BEDROOM...), # loyer par personne (colocation étudiante meublée). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://universityapartments.ca" LIST_URL = f"{BASE}/apartment-listings/?_listings_city_en=montreal" # Immeuble (nom affiché) -> (slug fiche immeuble, secteur) BUILDINGS = { "palay": ("palay", "Centre-ville (Ville-Marie)"), "le mojave": ("le-mojave", "Milton-Parc / Plateau-Mont-Royal"), "mojave": ("le-mojave", "Milton-Parc / Plateau-Mont-Royal"), } ADDR_RE = re.compile( r"\d{2,5}[^<>\"|]{2,60}?(?:Montr[ée]al)[,\s]+QC(?:\s+[A-Z]\d[A-Z]\s?\d[A-Z]\d)?") # Émojis/puces en tête des items de commodités des fiches _EMOJI_PREFIX_RE = re.compile(r"^[\W_]+", re.UNICODE) def _unit_type(label: str) -> str: """'1-BEDROOM' -> 3½, '4-BEDROOM' -> 6½, 'STUDIO' -> Studio.""" s = (label or "").lower() if "studio" in s: return "Studio" m = re.search(r"(\d+)", s) if m: n = int(m.group(1)) return "Studio" if n == 0 else f"{n + 2}½" return label.strip() class WerklivConnector(BaseConnector): source_id = "werkliv" request_delay = 0.6 max_details = 20 # garde-fou def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") addresses: dict[str, str] = {} # slug immeuble -> adresse seen: set[str] = set() for card in soup.select(".lcl-card"): try: a = card.select_one('a.lcl-link[href*="/listings/"]') or \ card.select_one('a[href*="/listings/"]') if not a: continue url = (a.get("href") or "").split("?")[0] m = re.search(r"/listings/([^/]+)/?$", url) if not m or m.group(1) in seen: continue slug = m.group(1) seen.add(slug) city_el = card.select_one(".lcl-city") city_raw = city_el.get_text(" ", strip=True) if city_el else "" if "montreal" not in city_raw.lower(): continue # hors Montréal (Halifax, PEI...) typo_el = card.select_one(".lcl-title .h4, .lcl-title") typology = typo_el.get_text(" ", strip=True) if typo_el else "" prop_el = card.select_one(".lcl-property") building = prop_el.get_text(" ", strip=True) if prop_el else "" price_el = card.select_one(".lcl-price") price_label = price_el.get_text(" ", strip=True) \ if price_el else "" price = None pm = re.search(r"\$\s*([\d,]+)", price_label) if pm: try: v = float(pm.group(1).replace(",", "")) price = v if 100 <= v <= 20000 else None except ValueError: pass avail = "" av_el = card.select_one(".lcl-available span") if av_el: avail = re.sub(r"\s+", " ", av_el.get_text(" ", strip=True)) amenities = ["Logement étudiant"] for chip in card.select(".lcl-chip"): amenities.append(re.sub(r"\s+", " ", chip.get_text(" ", strip=True))) bslug, sector = BUILDINGS.get(building.strip().lower(), ("", "")) # adresse depuis la fiche de l'immeuble (mise en cache) address = "" if bslug: if bslug not in addresses: addresses[bslug] = self._building_address(bslug) address = addresses[bslug] images = [] header = card.select_one("[data-bg]") if header and header.get("data-bg", "").startswith("http"): images.append(header["data-bg"]) listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=f"{building} — {typology} (par chambre)", address=address, sector=sector, city="Montréal", unit_type=_unit_type(typology), price=price, price_label=f"{price_label} (par personne)" if price_label else "", availability=avail, amenities=list(dict.fromkeys(amenities)), images=images, )) except Exception: continue # Fiches détaillées (photos, description, dispo ACF, bail, commodités) # via le cache BD : 1 vraie requête par annonce et par changement. self._detail_requests = 0 for i, lst in enumerate(listings): if i >= self.max_details: break key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" f"|{'|'.join(lst.amenities)}".encode("utf-8")).hexdigest() def _fetch(url=lst.url) -> dict: if self._detail_requests >= self.max_details: return {} self._detail_requests += 1 return self._fetch_detail(url) try: payload = self.detail(lst.external_id, key, _fetch) or {} except Exception: payload = {} self._apply_detail(lst, payload) return listings # -- fiche immeuble (adresse) -------------------------------------------------- def _building_address(self, slug: str) -> str: try: html = self.get(f"{BASE}/properties/{slug}/").text except Exception: return "" m = ADDR_RE.search(html.replace("+", " ")) if not m: return "" addr = re.sub(r"\s+", " ", m.group(0)).strip() return addr.replace("Montreal", "Montréal") # -- fiche annonce --------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Fiche d'une annonce : photos, description, sidebar ACF structuré (Available, Lease Terms) et commodités (BUILDING AMENITIES, cuisine). """ html = self.get(url).text soup = BeautifulSoup(html, "html.parser") imgs = re.findall( r'https://universityapartments\.ca/wp-content/uploads/' r'[^"\s\\\)]+\.(?:jpg|jpeg|png|webp)', html) imgs = [u for u in dict.fromkeys(imgs) if not re.search(r"logo|icon|favicon|chrome|-\d{2,3}x\d{2,3}\.", u, re.I)] description = "" og = soup.find("meta", attrs={"property": "og:description"}) if og and og.get("content"): description = og["content"].strip()[:600] else: p = soup.select_one(".fl-rich-text p, article p") if p: description = p.get_text(" ", strip=True)[:600] # Sidebar ACF :
Available
...
available = "" for dl in soup.select("dl.acf-data"): dts = [d.get_text(" ", strip=True) for d in dl.select("dt")] dds = [d.get_text(" ", strip=True) for d in dl.select("dd")] for k, v in zip(dts, dds): if k.lower().startswith("available") and v: available = v lease_terms = [t.get_text(" ", strip=True) for t in soup.select(".rental-term")] # Commodités de l'immeuble : items

à émoji après le titre # « BUILDING AMENITIES » (même bloc rich-text). amenities: list[str] = [] head = soup.find(string=re.compile(r"BUILDING AMENITIES", re.I)) if head: h = head.find_parent(["h1", "h2", "h3", "h4", "strong"]) \ or head.parent node = h.find_parent(["h1", "h2", "h3", "h4"]) or h for sib in node.find_next_siblings(): if sib.name in ("h1", "h2", "h3"): break text = _EMOJI_PREFIX_RE.sub("", sib.get_text(" ", strip=True)) text = re.sub(r"\s+", " ", text).strip() if not text or text.startswith("***") or "Disclaimer" in text: break if len(text) > 90: continue # « Wi-Fi ($) » = payant : ne pas laisser la normalisation # le classer « internet inclus » (lacune générique notée) if text.endswith("($)"): continue # stationnement à vélo ≠ stationnement auto if re.search(r"\bbike (?:parking|storage)\b", text, re.I): text = "Espace vélos (sous-sol)" if text not in amenities: amenities.append(text) # Électroménagers de la cuisine partagée (ligne explicite de la fiche) kitchen = soup.find(string=re.compile( r"appliances,? including a fridge", re.I)) if kitchen: amenities.append(_EMOJI_PREFIX_RE.sub( "", re.sub(r"\s+", " ", str(kitchen)).strip())) return {"images": imgs, "description": description, "available": available, "lease_terms": lease_terms, "amenities": amenities} def _apply_detail(self, lst: Listing, payload: dict) -> None: if not payload: return if payload.get("images"): lst.images = list(dict.fromkeys( lst.images + payload["images"]))[:40] if payload.get("description"): lst.description = payload["description"] if not lst.availability and payload.get("available"): lst.availability = f"Available {payload['available']}" if payload.get("lease_terms"): lst.amenities.append( "Lease terms: " + ", ".join(payload["lease_terms"])) for a in payload.get("amenities") or []: if a not in lst.amenities: lst.amenities.append(a)