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/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