SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.7 KB · 280 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/boardwalk.py : connecteur Boardwalk REIT (bwalk.com)5#   Pages de villes de la région de Montréal rendues serveur (HubSpot CMS) :6#   Longueuil, Laval, Île-des-Sœurs/Verdun, Ville Saint-Laurent. Fiches7#   propriétés avec cartes de types de suites (« À partir de … $ ») qui8#   portent aussi superficie (pi ca), c. à c./sdb et le statut (« N logements9#   disponibles » / « liste d'attente ») ; adresse complète + téléphone dans10#   l'en-tête (p.address-and-phone). Les pages de suites ne servent plus qu'aux11#   photos spécifiques, via le cache self.detail().12#   REIT pancanadien : seules les propriétés du Grand Montréal sont couvertes.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://www.bwalk.com"2526# Pages de villes du Grand Montréal -> (ville réelle, secteur par défaut)27CITY_PAGES = {28    f"{BASE}/fr-ca/appartements-a-louer-a-montreal-longueuil":29        ("Longueuil", ""),30    f"{BASE}/fr-ca/appartements-a-louer-a-montreal-laval":31        ("Laval", ""),32    f"{BASE}/fr-ca/appartements-a-louer-a-montreal-nuns-island-verdun":33        ("Montréal", "Île-des-Sœurs (Verdun)"),34    f"{BASE}/fr-ca/appartements-a-louer-a-montreal-ville-saint-laurent":35        ("Montréal", "Saint-Laurent"),36}3738PROP_RE = re.compile(39    r'href="(/fr-ca/appartements-a-louer-a-montreal-[a-z0-9\-]+/'40    r'[a-z0-9\-]+/[a-z0-9\-]+)"')41PRICE_RE = re.compile(r'À partir de\s*([\d\s\u00a0,.]+\$)')42# superficie : « 900 », « 648 - 760 », « 1 408 » (séparateur de milliers) —43# le nombre de chambres qui suit (« 3 c. à c. ») ne doit pas être aspiré44AREA_RE = re.compile(45    r'Superficie\s*\(pi\s*ca\)\s*:?\s*([\d\s\u00a0-]+?)'46    r'\s*(?:\d+\s*c\.\s*à\s*c\.|\||sdb|[A-Za-z]|$)')47BEDS_RE = re.compile(r'(\d+)\s*c\.\s*à\s*c\.')48BATHS_RE = re.compile(r'(\d+)\s*sdb')49PHONE_RE = re.compile(r'\(?(\d{3})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})')505152_BED_TYPES = {1: "3½", 2: "4½", 3: "5½", 4: "6½"}535455def _suite_type(name: str) -> str:56    """« 3 1/2 Pièces » -> 3½ ; « Maison en Rangée avec N Chambres » -> Maison ;57    « 2 Chambres » -> 4½."""58    low = name.lower()59    if "maison" in low or "rang" in low:60        return "Maison"61    m = re.search(r"(\d)\s*chambre", low)62    if m:63        return _BED_TYPES.get(int(m.group(1)), "")64    return normalize_unit_type(name)656667class BoardwalkConnector(BaseConnector):68    source_id = "boardwalk"69    request_delay = 0.670    max_properties = 25      # garde-fou71    max_images = 257273    def fetch(self) -> list[Listing]:74        listings: list[Listing] = []75        seen: set[str] = set()76        count = 077        for city_url, (city, default_sector) in CITY_PAGES.items():78            try:79                html = self.get(city_url).text80            except Exception:81                continue82            for path in dict.fromkeys(PROP_RE.findall(html)):83                if path in seen:84                    continue85                seen.add(path)86                if count >= self.max_properties:87                    break88                count += 189                try:90                    listings.extend(91                        self._property_listings(path, city, default_sector))92                except Exception:93                    continue9495        # Unicité des external_id (deux cartes peuvent pointer vers la même96        # page de type de suite avec des prix différents)97        used: dict[str, int] = {}98        for lst in listings:99            n = used.get(lst.external_id, 0)100            used[lst.external_id] = n + 1101            if n:102                lst.external_id = f"{lst.external_id}-{n + 1}"103        return listings104105    def _property_listings(self, path: str, city: str,106                           default_sector: str) -> list[Listing]:107        url = BASE + path108        prop_slug = path.rstrip("/").split("/")[-1]109        html = self.get(url).text110        soup = BeautifulSoup(html, "html.parser")111112        t = soup.find("title")113        name = (t.get_text(strip=True).split("|")[0].strip()114                if t else prop_slug.replace("-", " "))115116        # Adresse complète + téléphone : en-tête <p class="address-and-phone">117        address = ""118        phone = ""119        ap = soup.select_one("p.address-and-phone")120        if ap:121            ap_text = re.sub(r"\s+", " ", ap.get_text(" ", strip=True))122            pm_ = PHONE_RE.search(ap_text)123            if pm_:124                phone = "-".join(pm_.groups())125                ap_text = ap_text[: pm_.start()].strip(" ,")126            address = ap_text127        if not address:128            # repli : texte alt des photos « photo de la propriété pour le … »129            am = re.search(130                r'photo de la propri[ée]t[ée] pour le\s*([^"<>]{5,90})', html)131            if am:132                address = am.group(1).strip()133134        # Galerie photo (swiper)135        images: list[str] = []136        for img in soup.select("img[src]"):137            src = img.get("src", "")138            if re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)",139                         src) and src.startswith("http"):140                if src not in images:141                    images.append(src)142        images = images[: self.max_images]143144        # Description de la propriété145        desc = ""146        dh = soup.find(string=re.compile("Description de la propriété"))147        if dh:148            sec = dh.find_parent()149            nxt = sec.find_next("p") if sec else None150            if nxt:151                desc = nxt.get_text(" ", strip=True)[:600]152        if not desc:153            og = soup.find("meta", attrs={"property": "og:description"})154            if og and og.get("content"):155                desc = og["content"].strip()[:600]156157        # Commodités (« Caractéristiques et espaces d'agrément »)158        amenities: list[str] = []159        ah = soup.find(string=re.compile("Caractéristiques et espaces"))160        if ah:161            cont = ah.find_parent()162            for _ in range(3):163                if cont is None:164                    break165                lis = cont.find_all("li")166                if lis:167                    break168                cont = cont.parent169            if cont:170                for li in cont.find_all("li")[:25]:171                    txt = li.get_text(" ", strip=True)172                    if txt and len(txt) < 50 and txt not in amenities:173                        amenities.append(txt)174175        # Cartes de types de suites : <h4 class="highlight-suite"><a href=...>176        # La carte porte prix, superficie (pi ca), c. à c. et sdb ; le groupe177        # parent (div.unit_type) porte le statut (« N logements disponibles »178        # ou « liste d'attente »). Plus besoin de visiter la fiche de suite179        # pour ces champs — elle ne sert qu'aux photos (via self.detail).180        listings: list[Listing] = []181        for h4 in soup.select("h4.highlight-suite"):182            try:183                a = h4.find("a")184                if not a:185                    continue186                suite_name = a.get_text(" ", strip=True)187                suite_href = a.get("href") or ""188                suite_url = (BASE + suite_href189                             if suite_href.startswith("/") else suite_href)190                card = h4.parent191                card_text = re.sub(r"\s+", " ", card.get_text(" ", strip=True)192                                   if card else "")193                price_label = ""194                pm = PRICE_RE.search(card_text)195                if pm:196                    price_label = "À partir de " + pm.group(1).strip()197                price = parse_price(price_label)198199                # Superficie / chambres / sdb depuis la carte elle-même200                detail_bits: list[str] = []201                suite_amenities = amenities202                am_ = AREA_RE.search(card_text)203                if am_:204                    rng = re.sub(r"[\s ]+", " ", am_.group(1)).strip()205                    detail_bits.append(f"Superficie (pi ca) : {rng}")206                    lo = re.sub(r"[\s\u00a0]", "", rng.split("-")[0])207                    if lo.isdigit():208                        # borne basse -> texte brut, finalize() dérive area_sqft209                        suite_amenities = amenities + [f"{lo} pi ca"]210                bm = BEDS_RE.search(card_text)211                if bm:212                    detail_bits.append(f"{bm.group(1)} c. à c.")213                sm = BATHS_RE.search(card_text)214                if sm:215                    detail_bits.append(f"{sm.group(1)} sdb")216217                # Statut du groupe : disponible ou liste d'attente218                availability = "Disponible"219                if re.search(r"liste d.attente", card_text, re.I):220                    availability = "Sur la liste d'attente"221                else:222                    group = card.find_parent(class_="unit_type") if card else None223                    if group:224                        gm = re.search(r"(\d+)\s*logements?\s*disponibles?",225                                       group.get_text(" ", strip=True), re.I)226                        if gm:227                            availability = f"{gm.group(1)} logement(s) disponible(s)"228229                # Photos spécifiques de la suite (page détail, cache BD)230                suite_imgs: list[str] = []231                if suite_url.startswith(BASE) and self.use_detail_cache:232                    key = hashlib.sha1(233                        (suite_url + "|" + card_text).encode("utf-8")).hexdigest()234                    payload = self.detail(235                        f"{prop_slug}-{suite_href.rstrip('/').split('/')[-1]}",236                        key, lambda u=suite_url: self._suite_images(u))237                    suite_imgs = payload.get("images") or []238239                type_slug = (suite_href.rstrip("/").split("/")[-1]240                             or re.sub(r"[^a-z0-9]+", "-",241                                       suite_name.lower()).strip("-"))242                details = {"contact": {"phone": phone}} if phone else {}243                listings.append(Listing(244                    source=self.source_id,245                    external_id=f"{prop_slug}-{type_slug}",246                    url=suite_url or url,247                    title=f"{name}{suite_name}",248                    address=address,249                    sector=default_sector,250                    city=city,251                    unit_type=_suite_type(suite_name),252                    price=price,253                    price_label=price_label,254                    availability=availability,255                    description=" — ".join(256                        x for x in [desc] + detail_bits if x)[:600],257                    amenities=suite_amenities,258                    details=details,259                    images=(suite_imgs or images)[: self.max_images],260                ))261            except Exception:262                continue263        return listings264265    def _suite_images(self, suite_url: str) -> dict:266        """Photos propres à un type de suite (page détail de la suite)."""267        try:268            sh = self.get(suite_url).text269        except Exception:270            return {}271        imgs: list[str] = []272        ssoup = BeautifulSoup(sh, "html.parser")273        for img in ssoup.select("img[src]"):274            src = img.get("src", "")275            if (re.search(r"hubfs/.*(bw_properties|Web%20Photos|Web Photos)",276                          src) and src.startswith("http")277                    and src not in imgs):278                imgs.append(src)279        return {"images": imgs[: self.max_images]}280