# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/douek.py : connecteur Douek Apartments (douek.ca) # Gestionnaire familial montréalais : Westmount, NDG, Côte-des-Neiges, # Plateau, Vieux-Montréal, Saint-Henri, Dollard-des-Ormeaux. Même gabarit # Rentsync que le Groupe Alfid, mais SANS API Lift System exposée : la # découverte se fait par les cartes « featured_property » de la page # d'accueil (/rent/ + adresse), puis parsing des fiches /rent/ # rendues serveur — le parsing des rangées div.suite (id stable via # #modal-suite-, prix, photos par suite) est hérité d'AlfidConnector. # ----------------------------------------------------------------------------- from __future__ import annotations import re import time from bs4 import BeautifulSoup from ..schema import Listing from .alfid import AlfidConnector, _clean, _GALLERY_RE SITE = "https://www.douek.ca" # Slug de la fiche -> quartier (immeubles montréalais seulement ; les adresses # de Westmount et Dollard-des-Ormeaux portent déjà leur vraie ville) _SECTORS = { "2235-Prudhomme-NDG": "Notre-Dame-de-Grâce", "6101-monkland-ave": "Notre-Dame-de-Grâce", "2120-van-horne-cote-des-neiges": "Côte-des-Neiges", "lofts-clark": "Plateau-Mont-Royal", "le-st-regis-275-saint-jacques": "Vieux-Montréal", "le-rolland": "Saint-Henri", } _META_DESC_RE = re.compile(r' list[Listing]: # Un raté transitoire (vu 2026-08-30 ~10:00) produisait un faux # « 0 trouvé ok » (médiane 10) : retry de l'accueil (3 essais, # backoff 5/10 s) puis erreur franche plutôt qu'un zéro silencieux. buildings: dict[str, dict] = {} for attempt in range(3): try: home = self.get(f"{SITE}/").text except Exception: if attempt == 2: raise home = "" soup = BeautifulSoup(home, "html.parser") # cartes d'immeubles de l'accueil : slug, nom, adresse for card in soup.select(".featured_property"): a = card.select_one('a[href*="/rent/"]') if not a: continue slug = (a.get("href") or "").rstrip("/").split("/")[-1] if not slug or slug == "cities": continue name_el = card.select_one(".building-name") addr_el = card.select_one(".building-address") buildings.setdefault(slug, { "name": _clean(name_el.get_text(" ", strip=True) if name_el else slug), "address": _clean(addr_el.get_text(" ", strip=True) if addr_el else ""), }) if buildings: break if attempt < 2: time.sleep(5 * (attempt + 1)) if not buildings: raise RuntimeError( "accueil douek.ca sans carte featured_property après 3 essais") listings: list[Listing] = [] errors = 0 last_exc: Exception | None = None for i, (slug, meta) in enumerate(buildings.items()): if i >= self.max_buildings: break try: listings.extend(self._parse_rent_page(slug, meta)) except Exception as exc: errors += 1 last_exc = exc # tous les immeubles en erreur et rien récolté : erreur franche if not listings and last_exc is not None and errors: raise last_exc return listings # -- fiche /rent/ : suites rendues serveur ----------------------------- def _parse_rent_page(self, slug: str, meta: dict) -> list[Listing]: url = f"{SITE}/rent/{slug}" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") name = meta.get("name") or slug address = meta.get("address") or "" # ville en fin d'adresse (« …, Westmount QC ») ; défaut Montréal city = "Montréal" m = re.search(r",\s*([^,]+?)\s+QC\s*$", address) if m: c = _clean(m.group(1)) if c and c.lower() not in ("montreal", "montréal"): city = c sector = _SECTORS.get(slug, "") dm = _META_DESC_RE.search(html) description = _clean(dm.group(1))[:600] if dm else "" amenities = [el.get_text(" ", strip=True) for el in soup.select(".amenity-holder")] amenities = [a for a in dict.fromkeys(amenities) if a][:25] images: list[str] = [] seen_files: set[str] = set() for u in _GALLERY_RE.findall(html): fname = u.rsplit("/", 1)[-1] if fname not in seen_files: seen_files.add(fname) images.append(u) images = images[:25] return [self._row_to_listing( row, url=url, name=name, address=address, sector=sector, city=city, description=description, amenities=amenities, images=images) for row in self._suite_rows(soup)]