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.7 KB · 181 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/vision_immo.py : connecteur Vision Immo (visionimmo.com).5#   Gestionnaire-promoteur : condos locatifs neufs à Lachine et Saint-Henri6#   (Montréal), Terrebonne (Urbanova) et Longueuil. WordPress (Astra/Spectra,7#   CPT « logements ») rendu serveur : la page /logements-a-louer/ liste les8#   unités actuellement offertes (le CPT wp-json garde aussi les fiches9#   louées, donc on part de la page publique), chaque fiche /logements/<slug>/10#   donne prix ($/mois), chambres, sdb, dimension (« 4 1/2 | 1 086 pi² »),11#   adresse, étage, disponibilité, description et photos.12#   Granularité UNITÉ, external_id = slug WordPress.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://visionimmo.com"24LIST_URL = f"{BASE}/logements-a-louer/"2526LINK_RE = re.compile(r'href="(https://visionimmo\.com/logements/([^"/]+)/?)"')27PRICE_RE = re.compile(r"([\d\s  ,]{3,9})\s*\$\s*/\s*mois")28DIM_RE = re.compile(r"(\d\s*(?:1/2|½)|studio)\s*\|?\s*([\d\s  ,]{2,7})\s*pi",29                    re.I)30IMG_RE = re.compile(31    r"https://visionimmo\.com/wp-content/uploads/[^\"'\s\\)]+?"32    r"\.(?:jpg|jpeg|png|webp)", re.I)3334# arrondissements montréalais fréquents chez ce gestionnaire35_MTL_SECTORS = {"lachine", "saint-henri", "st-henri", "ville-emard",36                "ville-émard", "verdun"}373839class VisionImmoConnector(BaseConnector):40    source_id = "vision_immo"41    request_delay = 0.74243    def _parse_detail(self, url: str) -> dict:44        try:45            html = self.get(url).text46        except Exception:47            return {}48        soup = BeautifulSoup(html, "html.parser")49        payload: dict = {}5051        info = soup.select_one(".logements-info")52        scope = info if info is not None else soup53        prix = scope.select_one(".prix")54        if prix is not None:55            m = PRICE_RE.search(prix.get_text(" ", strip=True)56                                .replace(" ", " "))57            if m:58                digits = re.sub(r"[^\d]", "", m.group(1))59                if digits:60                    payload["price"] = float(digits)61                    payload["price_label"] = f"{payload['price']:.0f} $ /mois"6263        flat = scope.get_text("|", strip=True).replace(" ", " ")64        flat = re.sub(r"\|+", "|", flat)65        m = re.search(r"(\d+)\|\s*chambres?", flat)66        if m:67            payload["bedrooms"] = float(m.group(1))68        m = re.search(r"(\d+)\|\s*salles? de bain", flat)69        if m:70            payload["bathrooms"] = float(m.group(1))71        m = DIM_RE.search(flat)72        if m:73            payload["unit_type"] = normalize_unit_type(m.group(1))74            digits = re.sub(r"[^\d]", "", m.group(2))75            if digits:76                payload["area_sqft"] = float(digits)77        else:                                 # fiche sans superficie publiée78            m = re.search(r"Dimension\|(\d\s*(?:1/2|½)|studio)", flat, re.I)79            if m:80                payload["unit_type"] = normalize_unit_type(m.group(1))81        # adresse sur 1 à 3 segments (« K-180 Boul. Saint-Joseph, | Lachine,82        # H8S 2L3 » ou « 280, rue Rose-de-Lima, Montréal, QC H4C 2L2 »)83        m = re.search(r"Adresse((?:\|[^|]+){1,3})", flat)84        if m:85            kept = []86            for s in (x.strip() for x in m.group(1).split("|") if x.strip()):87                if re.match(r"^(?:[ÉE]tage|Disponibilit)", s):88                    break89                kept.append(s)90            parts = [x.strip() for x in ", ".join(kept).split(",")91                     if x.strip()]92            while parts and re.match(r"^(?:QC\b|[A-Z]\d[A-Z])", parts[-1]):93                parts.pop()                   # province / code postal94            if len(parts) > 1:95                payload["locality"] = parts[-1]96                payload["address"] = ", ".join(parts[:-1])97            elif parts:98                payload["address"] = parts[0]99        m = re.search(r"Étage\(?s?\)?\|([^|]{1,20})(?:\||$)", flat)100        if m:101            payload["floor"] = m.group(1).strip()102        m = re.search(r"Disponibilit[ée]\|([^|]{2,60})(?:\||$)", flat)103        if m:104            payload["availability"] = m.group(1).strip()105106        # description : contenu de l'entrée (avant le bloc structuré)107        desc_parts = []108        for node in soup.select("article p, .entry-content p, "109                                ".ast-article-single p"):110            t = node.get_text(" ", strip=True)111            if len(t) > 40:112                desc_parts.append(t)113            if sum(len(x) for x in desc_parts) > 1200:114                break115        payload["description"] = " ".join(desc_parts)[:2000]116        amenities = []117        for li in soup.select("article li, .entry-content li"):118            t = li.get_text(" ", strip=True)119            if 3 < len(t) < 90 and t not in amenities:120                amenities.append(t)121        payload["amenities"] = amenities[:20]122123        images = [u for u in dict.fromkeys(IMG_RE.findall(html))124                  if not re.search(r"logo|favicon|icon|share|-\d+x\d+\.",125                                   u, re.I)]126        payload["images"] = images[:20]127        return payload128129    def fetch(self) -> list[Listing]:130        listings: list[Listing] = []131        try:132            html = self.get(LIST_URL).text133        except Exception:134            return listings135        links = list(dict.fromkeys(LINK_RE.findall(html)))136        for url, slug in links:137            payload = self.detail(slug, "v1",138                                  lambda u=url: self._parse_detail(u))139            if not payload:140                continue141            avail = payload.get("availability", "")142            if re.search(r"lou[ée]e?\b", avail, re.I):143                continue                      # fiche encore publiée mais louée144145            locality = payload.get("locality", "")146            sector, city = "", locality147            key = locality.lower().replace("é", "e")148            if key in _MTL_SECTORS:149                sector, city = locality, "Montréal"150            if not sector and city.lower().startswith("montr"):151                # arrondissement déduit du slug (ex. « …-saint-henri »)152                for s in _MTL_SECTORS:153                    if s in slug.lower():154                        sector = s.title()155                        break156            unit_type = payload.get("unit_type", "")157            address = payload.get("address", "")158            listings.append(Listing(159                source=self.source_id,160                external_id=slug,161                url=url,162                title=(f"{unit_type} à louer — {address}"163                       if unit_type else f"{address} — logement à louer"),164                address=address,165                sector=sector,166                city=city,167                unit_type=unit_type,168                bedrooms=payload.get("bedrooms"),169                bathrooms=payload.get("bathrooms"),170                price=payload.get("price"),171                price_label=payload.get("price_label", ""),172                availability=avail,173                area_sqft=payload.get("area_sqft"),174                description=payload.get("description", ""),175                amenities=payload.get("amenities", []),176                details=({"floor": payload["floor"]}177                         if payload.get("floor") else {}),178                images=payload.get("images", []),179            ))180        return listings181