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%
7.1 KB · 193 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/royal_laval.py : connecteur Gestion Immobilière Royal5#   (gestionroyal.com) — gestionnaire de Laval-des-Rapides et Montréal6#   (~16 annonces actives). WordPress + thème immobilier Houzez rendu7#   serveur : la page /a-louer/ (+ /a-louer/page/N/) affiche des cartes8#   .item-listing-wrap complètes (post ID data-hz-id, prix, adresse, type9#   n½ en étiquette, galerie data-images). La page détail /property/…10#   fournit description et ville (bloc « Ville | … ») via le cache detail().11#   Granularité UNITÉ (une annonce par logement).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import json16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing21from .base import BaseConnector2223BASE = "https://gestionroyal.com"24LIST_URL = f"{BASE}/a-louer/"25MAX_PAGES = 102627HZ_ID_RE = re.compile(r"^hz-(\d+)$")28UNIT_LABEL_RE = re.compile(r"^\s*\d\s*(?:1/2|½)\s*$")29PRICE_RE = re.compile(r"\$?\s*([\d, ]{3,9})\s*\$?(?:\s*/\s*mois)?", re.I)30CITY_RE = re.compile(31    r"\b(Laval|Montréal|Montreal|Saint-Léonard|Longueuil|Terrebonne)\b", re.I)323334class RoyalLavalConnector(BaseConnector):35    source_id = "royal_laval"36    request_delay = 0.83738    def fetch(self) -> list[Listing]:39        listings: list[Listing] = []40        seen: set[str] = set()41        url = LIST_URL42        for _ in range(MAX_PAGES):43            try:44                html = self.get(url).text45            except Exception:46                break47            soup = BeautifulSoup(html, "html.parser")48            for card in soup.select(".item-listing-wrap"):49                lst = self._from_card(card)50                if lst is not None and lst.external_id not in seen:51                    seen.add(lst.external_id)52                    listings.append(lst)53            nxt = soup.select_one('a[href*="/a-louer/page/"]')54            nxt_url = nxt.get("href") if nxt else ""55            if not nxt_url or nxt_url == url:56                break57            url = nxt_url58        return listings5960    def _from_card(self, card) -> Listing | None:61        m = HZ_ID_RE.match(card.get("data-hz-id") or "")62        if not m:63            return None64        post_id = m.group(1)                       # ID WordPress : stable6566        statuses = {a.get_text(strip=True) for a in card.select(".label-status")}67        if "À Louer" not in statuses:68            return None                            # vendu/loué6970        link = card.select_one('.item-title a[href*="/property/"]') \71            or card.select_one('a[href*="/property/"]')72        if link is None:73            return None74        url = link.get("href") or LIST_URL75        title = link.get_text(" ", strip=True)7677        addr_el = card.select_one(".item-address")78        address = addr_el.get_text(" ", strip=True) if addr_el else ""79        # adresses Houzez très verbeuses (géocodeur complet) : tronquer aux80        # deux premiers segments (rue + quartier)81        short_addr = ", ".join(p.strip() for p in address.split(",")[:2])8283        unit_type = ""84        for lbl in card.select(".hz-label"):85            t = lbl.get_text(strip=True)86            if UNIT_LABEL_RE.match(t):87                unit_type = t88                break8990        price = None91        price_label = ""92        pr = card.select_one(".item-price")93        if pr is not None:94            price_label = pr.get_text(" ", strip=True)95            pm = PRICE_RE.search(price_label)96            if pm:97                try:98                    val = float(pm.group(1).replace(",", "").replace(" ", ""))99                    if 300 <= val <= 20000:100                        price = val101                except ValueError:102                    pass103104        if price is None:105            # certaines cartes n'ont pas d'.item-price : le loyer est alors106            # dans le titre (« Condo à louer, Laval … | 1 975$ »)107            tm = re.search(r"(\d[\d  ,]{2,7})\s*\$(?:\s*/?\s*mois)?", title)108            if tm:109                try:110                    val = float(re.sub(r"[ , ]", "", tm.group(1)))111                    if 300 <= val <= 20000:112                        price = val113                        price_label = price_label or tm.group(0).strip()114                except ValueError:115                    pass116117        beds = None118        bel = card.select_one(".h-beds .hz-figure")119        if bel is not None:120            try:121                beds = float(bel.get_text(strip=True))122            except ValueError:123                pass124        baths = None125        bael = card.select_one(".h-baths .hz-figure")126        if bael is not None:127            try:128                baths = float(bael.get_text(strip=True))129            except ValueError:130                pass131132        images: list[str] = []133        try:134            images = [i["image"] for i in json.loads(card.get("data-images") or "[]")135                      if isinstance(i, dict) and i.get("image")][:15]136        except (ValueError, TypeError):137            pass138        if not images:139            img = card.select_one("img")140            if img is not None and (img.get("src") or "").startswith("http"):141                images = [img["src"]]142143        # ville : détectée dans l'adresse/le titre, précisée par la page détail144        city = ""145        cm = CITY_RE.search(f"{address} {title}")146        if cm:147            city = cm.group(1)148149        detail = self.detail(post_id, f"{title}|{price_label}|{address}",150                             lambda: self._detail(url))151        description = detail.get("description", "")152        city = detail.get("city") or city153        sector = detail.get("sector", "")154155        return Listing(156            source=self.source_id,157            external_id=post_id,158            url=url,159            title=title or short_addr,160            address=short_addr,161            sector=sector,162            city=city,163            unit_type=unit_type,164            bedrooms=beds,165            bathrooms=baths,166            price=price,167            price_label=price_label,168            description=description,169            images=images,170        )171172    # -- page détail /property/… (description + ville) -------------------------173    def _detail(self, url: str) -> dict:174        payload: dict = {}175        try:176            html = self.get(url).text177        except Exception:178            return payload179        soup = BeautifulSoup(html, "html.parser")180        desc = soup.select_one("#property-description-wrap .block-content-wrap")181        if desc is not None:182            payload["description"] = desc.get_text("\n", strip=True)[:4000]183        # bloc « Address » Houzez : liste Ville / Région / State184        for li in soup.select(".block-content-wrap li, .detail-city"):185            txt = li.get_text(" ", strip=True)186            m = re.match(r"^Ville\s+(.+)$", txt)187            if m:188                payload["city"] = m.group(1).strip()189            m = re.match(r"^(?:Quartier|Secteur)\s+(.+)$", txt)190            if m:191                payload["sector"] = m.group(1).strip()192        return payload193