SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
5.4 KB · 135 lines python
Raw Blame History
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