Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/douek.py : connecteur Douek Apartments (douek.ca)5# Gestionnaire familial montréalais : Westmount, NDG, Côte-des-Neiges,6# Plateau, Vieux-Montréal, Saint-Henri, Dollard-des-Ormeaux. Même gabarit7# Rentsync que le Groupe Alfid, mais SANS API Lift System exposée : la8# découverte se fait par les cartes « featured_property » de la page9# d'accueil (/rent/<slug> + adresse), puis parsing des fiches /rent/<slug>10# rendues serveur — le parsing des rangées div.suite (id stable via11# #modal-suite-<id>, prix, photos par suite) est hérité d'AlfidConnector.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re16import time1718from bs4 import BeautifulSoup1920from ..schema import Listing21from .alfid import AlfidConnector, _clean, _GALLERY_RE2223SITE = "https://www.douek.ca"2425# Slug de la fiche -> quartier (immeubles montréalais seulement ; les adresses26# de Westmount et Dollard-des-Ormeaux portent déjà leur vraie ville)27_SECTORS = {28 "2235-Prudhomme-NDG": "Notre-Dame-de-Grâce",29 "6101-monkland-ave": "Notre-Dame-de-Grâce",30 "2120-van-horne-cote-des-neiges": "Côte-des-Neiges",31 "lofts-clark": "Plateau-Mont-Royal",32 "le-st-regis-275-saint-jacques": "Vieux-Montréal",33 "le-rolland": "Saint-Henri",34}3536_META_DESC_RE = re.compile(r'<meta name="description" content="([^"]*)"')373839class DouekConnector(AlfidConnector):40 source_id = "douek"41 request_delay = 0.642 max_buildings = 20 # garde-fou (9 immeubles à l'écriture)4344 def fetch(self) -> list[Listing]:45 # Un raté transitoire (vu 2026-08-30 ~10:00) produisait un faux46 # « 0 trouvé ok » (médiane 10) : retry de l'accueil (3 essais,47 # backoff 5/10 s) puis erreur franche plutôt qu'un zéro silencieux.48 buildings: dict[str, dict] = {}49 for attempt in range(3):50 try:51 home = self.get(f"{SITE}/").text52 except Exception:53 if attempt == 2:54 raise55 home = ""56 soup = BeautifulSoup(home, "html.parser")57 # cartes d'immeubles de l'accueil : slug, nom, adresse58 for card in soup.select(".featured_property"):59 a = card.select_one('a[href*="/rent/"]')60 if not a:61 continue62 slug = (a.get("href") or "").rstrip("/").split("/")[-1]63 if not slug or slug == "cities":64 continue65 name_el = card.select_one(".building-name")66 addr_el = card.select_one(".building-address")67 buildings.setdefault(slug, {68 "name": _clean(name_el.get_text(" ", strip=True)69 if name_el else slug),70 "address": _clean(addr_el.get_text(" ", strip=True)71 if addr_el else ""),72 })73 if buildings:74 break75 if attempt < 2:76 time.sleep(5 * (attempt + 1))77 if not buildings:78 raise RuntimeError(79 "accueil douek.ca sans carte featured_property après 3 essais")8081 listings: list[Listing] = []82 errors = 083 last_exc: Exception | None = None84 for i, (slug, meta) in enumerate(buildings.items()):85 if i >= self.max_buildings:86 break87 try:88 listings.extend(self._parse_rent_page(slug, meta))89 except Exception as exc:90 errors += 191 last_exc = exc92 # tous les immeubles en erreur et rien récolté : erreur franche93 if not listings and last_exc is not None and errors:94 raise last_exc95 return listings9697 # -- fiche /rent/<slug> : suites rendues serveur -----------------------------98 def _parse_rent_page(self, slug: str, meta: dict) -> list[Listing]:99 url = f"{SITE}/rent/{slug}"100 html = self.get(url).text101 soup = BeautifulSoup(html, "html.parser")102103 name = meta.get("name") or slug104 address = meta.get("address") or ""105 # ville en fin d'adresse (« …, Westmount QC ») ; défaut Montréal106 city = "Montréal"107 m = re.search(r",\s*([^,]+?)\s+QC\s*$", address)108 if m:109 c = _clean(m.group(1))110 if c and c.lower() not in ("montreal", "montréal"):111 city = c112 sector = _SECTORS.get(slug, "")113114 dm = _META_DESC_RE.search(html)115 description = _clean(dm.group(1))[:600] if dm else ""116117 amenities = [el.get_text(" ", strip=True)118 for el in soup.select(".amenity-holder")]119 amenities = [a for a in dict.fromkeys(amenities) if a][:25]120121 images: list[str] = []122 seen_files: set[str] = set()123 for u in _GALLERY_RE.findall(html):124 fname = u.rsplit("/", 1)[-1]125 if fname not in seen_files:126 seen_files.add(fname)127 images.append(u)128 images = images[:25]129130 return [self._row_to_listing(131 row, url=url, name=name, address=address, sector=sector,132 city=city, description=description, amenities=amenities,133 images=images)134 for row in self._suite_rows(soup)]135