# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/vision_immo.py : connecteur Vision Immo (visionimmo.com). # Gestionnaire-promoteur : condos locatifs neufs à Lachine et Saint-Henri # (Montréal), Terrebonne (Urbanova) et Longueuil. WordPress (Astra/Spectra, # CPT « logements ») rendu serveur : la page /logements-a-louer/ liste les # unités actuellement offertes (le CPT wp-json garde aussi les fiches # louées, donc on part de la page publique), chaque fiche /logements// # donne prix ($/mois), chambres, sdb, dimension (« 4 1/2 | 1 086 pi² »), # adresse, étage, disponibilité, description et photos. # Granularité UNITÉ, external_id = slug WordPress. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://visionimmo.com" LIST_URL = f"{BASE}/logements-a-louer/" LINK_RE = re.compile(r'href="(https://visionimmo\.com/logements/([^"/]+)/?)"') PRICE_RE = re.compile(r"([\d\s ,]{3,9})\s*\$\s*/\s*mois") DIM_RE = re.compile(r"(\d\s*(?:1/2|½)|studio)\s*\|?\s*([\d\s ,]{2,7})\s*pi", re.I) IMG_RE = re.compile( r"https://visionimmo\.com/wp-content/uploads/[^\"'\s\\)]+?" r"\.(?:jpg|jpeg|png|webp)", re.I) # arrondissements montréalais fréquents chez ce gestionnaire _MTL_SECTORS = {"lachine", "saint-henri", "st-henri", "ville-emard", "ville-émard", "verdun"} class VisionImmoConnector(BaseConnector): source_id = "vision_immo" request_delay = 0.7 def _parse_detail(self, url: str) -> dict: try: html = self.get(url).text except Exception: return {} soup = BeautifulSoup(html, "html.parser") payload: dict = {} info = soup.select_one(".logements-info") scope = info if info is not None else soup prix = scope.select_one(".prix") if prix is not None: m = PRICE_RE.search(prix.get_text(" ", strip=True) .replace(" ", " ")) if m: digits = re.sub(r"[^\d]", "", m.group(1)) if digits: payload["price"] = float(digits) payload["price_label"] = f"{payload['price']:.0f} $ /mois" flat = scope.get_text("|", strip=True).replace(" ", " ") flat = re.sub(r"\|+", "|", flat) m = re.search(r"(\d+)\|\s*chambres?", flat) if m: payload["bedrooms"] = float(m.group(1)) m = re.search(r"(\d+)\|\s*salles? de bain", flat) if m: payload["bathrooms"] = float(m.group(1)) m = DIM_RE.search(flat) if m: payload["unit_type"] = normalize_unit_type(m.group(1)) digits = re.sub(r"[^\d]", "", m.group(2)) if digits: payload["area_sqft"] = float(digits) else: # fiche sans superficie publiée m = re.search(r"Dimension\|(\d\s*(?:1/2|½)|studio)", flat, re.I) if m: payload["unit_type"] = normalize_unit_type(m.group(1)) # adresse sur 1 à 3 segments (« K-180 Boul. Saint-Joseph, | Lachine, # H8S 2L3 » ou « 280, rue Rose-de-Lima, Montréal, QC H4C 2L2 ») m = re.search(r"Adresse((?:\|[^|]+){1,3})", flat) if m: kept = [] for s in (x.strip() for x in m.group(1).split("|") if x.strip()): if re.match(r"^(?:[ÉE]tage|Disponibilit)", s): break kept.append(s) parts = [x.strip() for x in ", ".join(kept).split(",") if x.strip()] while parts and re.match(r"^(?:QC\b|[A-Z]\d[A-Z])", parts[-1]): parts.pop() # province / code postal if len(parts) > 1: payload["locality"] = parts[-1] payload["address"] = ", ".join(parts[:-1]) elif parts: payload["address"] = parts[0] m = re.search(r"Étage\(?s?\)?\|([^|]{1,20})(?:\||$)", flat) if m: payload["floor"] = m.group(1).strip() m = re.search(r"Disponibilit[ée]\|([^|]{2,60})(?:\||$)", flat) if m: payload["availability"] = m.group(1).strip() # description : contenu de l'entrée (avant le bloc structuré) desc_parts = [] for node in soup.select("article p, .entry-content p, " ".ast-article-single p"): t = node.get_text(" ", strip=True) if len(t) > 40: desc_parts.append(t) if sum(len(x) for x in desc_parts) > 1200: break payload["description"] = " ".join(desc_parts)[:2000] amenities = [] for li in soup.select("article li, .entry-content li"): t = li.get_text(" ", strip=True) if 3 < len(t) < 90 and t not in amenities: amenities.append(t) payload["amenities"] = amenities[:20] images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|favicon|icon|share|-\d+x\d+\.", u, re.I)] payload["images"] = images[:20] return payload def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings links = list(dict.fromkeys(LINK_RE.findall(html))) for url, slug in links: payload = self.detail(slug, "v1", lambda u=url: self._parse_detail(u)) if not payload: continue avail = payload.get("availability", "") if re.search(r"lou[ée]e?\b", avail, re.I): continue # fiche encore publiée mais louée locality = payload.get("locality", "") sector, city = "", locality key = locality.lower().replace("é", "e") if key in _MTL_SECTORS: sector, city = locality, "Montréal" if not sector and city.lower().startswith("montr"): # arrondissement déduit du slug (ex. « …-saint-henri ») for s in _MTL_SECTORS: if s in slug.lower(): sector = s.title() break unit_type = payload.get("unit_type", "") address = payload.get("address", "") listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=(f"{unit_type} à louer — {address}" if unit_type else f"{address} — logement à louer"), address=address, sector=sector, city=city, unit_type=unit_type, bedrooms=payload.get("bedrooms"), bathrooms=payload.get("bathrooms"), price=payload.get("price"), price_label=payload.get("price_label", ""), availability=avail, area_sqft=payload.get("area_sqft"), description=payload.get("description", ""), amenities=payload.get("amenities", []), details=({"floor": payload["floor"]} if payload.get("floor") else {}), images=payload.get("images", []), )) return listings