# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/progim.py : connecteur Gestion Immobilière Progim # (progimannonces.bstk.io — plateforme Building Stack, Grand Montréal : # Montréal, Dorval, Longueuil, Sainte-Julie, Châteauguay, Charlemagne...) # La page /Listing/Listings embarque `var units = [...]` (JSON complet des # unités). Le détail d'une unité (photos, équipements, description) vient # de POST /Listing/ApartmentView avec `id=`. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re import time from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://progimannonces.bstk.io" LIST_URL = f"{BASE}/Listing/Listings" VIEW_URL = f"{BASE}/Listing/ApartmentView" # Villes de la Communauté métropolitaine de Montréal (Grand Montréal) # desservies par Progim. Tout le reste (ex. Bromont, Saint-Césaire) est exclu. GRAND_MTL = { "montreal", "laval", "longueuil", "dorval", "sainte-julie", "charlemagne", "chateauguay", "brossard", "boucherville", "repentigny", "terrebonne", "saint-lambert", "pointe-claire", "kirkland", "beaconsfield", "lachine", "verdun", "lasalle", "mont-royal", "westmount", "cote-saint-luc", "dollard-des-ormeaux", "pierrefonds", "anjou", "saint-leonard", "montreal-nord", "montreal-est", "saint-laurent", "candiac", "la prairie", "chambly", "varennes", "sainte-catherine", "delson", "saint-constant", "mascouche", "blainville", "mirabel", "saint-eustache", "deux-montagnes", "rosemere", "boisbriand", "sainte-therese", "vaudreuil-dorion", "l'ile-perrot", "pincourt", "beauharnois", "mercier", "saint-bruno", "saint-basile-le-grand", "mcmasterville", "beloeil", "otterburn park", "mont-saint-hilaire", "carignan", "richelieu", "l'assomption", "saint-sulpice", } def _in_grand_mtl(city: str) -> bool: key = strip_accents((city or "").strip().lower()) return any(key == c or key.startswith(c + "-") for c in GRAND_MTL) _CITY_CANON = {"montreal": "Montréal", "chateauguay": "Châteauguay", "levis": "Lévis", "quebec": "Québec"} def _canon_city(city: str) -> str: """Uniformise la graphie ('Montreal' -> 'Montréal').""" return _CITY_CANON.get(strip_accents(city.strip().lower()), city.strip()) # Clés structurées des fragments ApartmentView (attributs `label for=`) # de la plateforme Building Stack -> champs canoniques Lou-Ka. _EQUIP_INCLUSIONS = { "HeatingIncluded": "heating", "HotWaterIncluded": "hot_water", "ElectricityIncluded": "electricity", "InternetIncluded": "internet", "CableIncluded": "cable", } _EQUIP_APPLIANCES = { "Fridge": "fridge", "Stove": "stove", "Dishwasher": "dishwasher", "DishWasher": "dishwasher", "Washer": "washer_dryer", "Dryer": "washer_dryer", } def _unit_type_from_bedrooms(bedrooms, unit_name: str = "") -> str: """Building Stack donne le nb de chambres ; 0 ch -> Studio, n ch -> (n+2)½.""" try: n = int(bedrooms) except (TypeError, ValueError): return normalize_unit_type(unit_name) if n <= 0: return "Studio" return f"{n + 2}½" class ProgimConnector(BaseConnector): source_id = "progim" request_delay = 0.5 max_details = 120 # garde-fou (une requête ApartmentView par unité) def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings m = re.search(r"var units = (\[.*?\]);", html, re.S) if not m: return listings try: units = json.loads(m.group(1)) except ValueError: return listings blobs: dict[str, dict] = {} # external_id -> unité JSON brute for u in units: try: apt = u.get("Apartment") or {} addr = u.get("Address") or {} city = (addr.get("City") or u.get("City") or "").strip() if not _in_grand_mtl(city): continue # hors Grand Montréal (ex. Bromont) if not apt.get("IsResidential", True): continue # commercial / stationnement ext_id = str(apt.get("ApartmentId") or u.get("ApartmentId") or "") if not ext_id: continue price = apt.get("Price") price_label = apt.get("PriceFormatted") or "" address = addr.get("AddressLine1") or "" building = u.get("BuildingName") or address unit_name = apt.get("UnitName") or "" # placeholders relatifs (/Content/…/residential.svg) exclus images = [img for img in (u.get("PreviewUrl"), u.get("BuildingPreviewUrl")) if img and img.startswith("http")] building_url = u.get("BuildingUrl") or "" url = BASE + building_url if building_url.startswith("/") \ else (building_url or LIST_URL) lat = lng = None try: lat = float(addr.get("Latitude")) lng = float(addr.get("Longitude")) except (TypeError, ValueError): pass # Superficie : champ structuré `Apartment.Area` (pi²) area = apt.get("Area") area_sqft = float(area) if isinstance(area, (int, float)) \ and 80 <= area <= 20000 else None # Salles de bains (structuré) -> commodité d'affichage amenities: list[str] = [] nb_bath = apt.get("NumberOfBathrooms") if isinstance(nb_bath, (int, float)) and nb_bath > 0: n = int(nb_bath) amenities.append(f"{n} salles de bains" if n > 1 else "1 salle de bain") # Détails structurés : stationnement/rangement (booléens de la # plateforme) + contact de location (nom, téléphone, courriel) details: dict = {} if isinstance(u.get("ParkingsIsAvailable"), bool): details["parking"] = {"available": u["ParkingsIsAvailable"]} if isinstance(u.get("StoragesIsAvailable"), bool): details["storage"] = u["StoragesIsAvailable"] contact: dict = {} contacts = (u.get("Building") or {}).get( "ListingEmployeesContacts") or [] if contacts: c = contacts[0] phone = c.get("FormattedPhoneNumber") or c.get("PhoneNumber") if phone: digits = re.sub(r"\D", "", phone)[-10:] if len(digits) == 10: contact["phone"] = (f"{digits[:3]}-{digits[3:6]}" f"-{digits[6:]}") if c.get("Email"): contact["email"] = c["Email"] elif (u.get("Building") or {}).get("Phone"): digits = re.sub(r"\D", "", u["Building"]["Phone"])[-10:] if len(digits) == 10: contact["phone"] = (f"{digits[:3]}-{digits[3:6]}" f"-{digits[6:]}") if contact: details["contact"] = contact blobs[ext_id] = u listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{building} — unité {unit_name}" if unit_name else building, address=address, sector="", city=_canon_city(city), unit_type=_unit_type_from_bedrooms( apt.get("NumberOfBedrooms"), unit_name), price=float(price) if isinstance(price, (int, float)) and 100 <= price <= 20000 else None, price_label=price_label, availability="", area_sqft=area_sqft, amenities=amenities, details=details, images=images, lat=lat, lng=lng, )) except Exception: continue # Détail de chaque unité (fragment ApartmentView via cache self.detail : # un vrai POST seulement si l'unité JSON de la liste a changé) budget = {"n": 0} for lst in listings: key = hashlib.sha1(json.dumps( blobs.get(lst.external_id, {}), sort_keys=True, ensure_ascii=False).encode()).hexdigest() def _fetch(ext_id=lst.external_id): if budget["n"] >= self.max_details: raise RuntimeError("plafond de fiches atteint") budget["n"] += 1 return self._fetch_view(ext_id) try: payload = self.detail(lst.external_id, key, _fetch) except Exception: payload = {} self._apply_view(lst, payload) return listings # -- détail (fragment HTML ApartmentView) --------------------------------- def _fetch_view(self, ext_id: str) -> dict: """POST /Listing/ApartmentView (throttlé) -> payload structuré.""" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(VIEW_URL, data={"id": ext_id}, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return self._parse_view(resp.text) @staticmethod def _parse_view(frag: str) -> dict: payload: dict = {} soup = BeautifulSoup(frag, "html.parser") # Photos de l'unité imgs = re.findall( r'https://wfiles\.buildingstack\.com/resources/image/[A-Za-z0-9]+' r'(?:/[a-z]+)?', frag) if imgs: payload["images"] = list(dict.fromkeys(imgs)) # Disponibilité : la plateforme n'affiche que des unités disponibles ; # le fragment précise parfois une date ("Disponible dès maintenant!"). avail = soup.find(string=re.compile( r"^\s*Disponible (dès|le|à partir|maintenant|immédiatement)", re.I)) if avail: payload["availability"] = avail.strip() # Général : paires titre/valeur (superficie, étage, chambres, sdb) general: dict[str, str] = {} for li in soup.select("ul.main-items li"): t = li.select_one("p.title") v = li.select_one("h4") if t and v: k = strip_accents(t.get_text(" ", strip=True).lower()) general.setdefault(k, v.get_text(" ", strip=True)) m = re.match(r"(\d[\d\s]*(?:[.,]\d+)?)", general.get("superficie (pi.ca)", "")) if m: try: area = float(m.group(1).replace(" ", "").replace(",", ".")) if 80 <= area <= 20000: payload["area_sqft"] = area except ValueError: pass if general.get("etage", "").isdigit(): floor = int(general["etage"]) if 0 < floor <= 60: payload["floor"] = floor # Équipements booléens : `label for=` + valeur Oui/Non (clés # structurées Building Stack : HeatingIncluded, Furnished, Fridge…) inclusions: dict[str, bool] = {} appliances: dict[str, bool] = {} amenities: list[str] = [] for li in soup.select("ul.main-items.amentities li"): lab = li.find("label") v = li.select_one("h4") if not lab or not v: continue key = lab.get("for") or "" val = v.get_text(strip=True).strip().lower() flag = val in ("oui", "yes") if key in _EQUIP_INCLUSIONS: inclusions[_EQUIP_INCLUSIONS[key]] = flag elif key == "Furnished": payload["furnished"] = flag if flag: amenities.append(lab.get_text(" ", strip=True)) for lab in soup.select("ul.amentity-links label"): key = lab.get("for") or "" if key in _EQUIP_APPLIANCES: appliances[_EQUIP_APPLIANCES[key]] = True txt = lab.get_text(" ", strip=True) if txt and txt not in amenities: amenities.append(txt) if inclusions: payload["inclusions"] = inclusions if appliances: payload["appliances"] = appliances if amenities: payload["amenities"] = amenities # Description (section Commentaires) notes = soup.select_one("div.unit-notes") if notes: desc = re.sub(r"\s+", " ", notes.get_text(" ", strip=True)) desc = re.sub(r"^Commentaires\s*", "", desc).strip(" •") if desc: payload["description"] = desc[:600] return payload @staticmethod def _apply_view(lst: Listing, payload: dict) -> None: """Applique le payload (frais ou en cache) sur l'annonce.""" if payload.get("images"): lst.images = list(dict.fromkeys(lst.images + payload["images"])) lst.availability = payload.get("availability", "Disponible") if lst.area_sqft is None and payload.get("area_sqft") is not None: lst.area_sqft = payload["area_sqft"] if payload.get("floor") is not None: lst.details["floor"] = payload["floor"] if payload.get("furnished") is not None: lst.furnished = payload["furnished"] if payload.get("inclusions"): lst.details["inclusions"] = {**payload["inclusions"], **lst.details.get("inclusions", {})} if payload.get("appliances"): lst.details["appliances"] = {**payload["appliances"], **lst.details.get("appliances", {})} for a in payload.get("amenities") or []: if a not in lst.amenities: lst.amenities.append(a) if payload.get("description"): lst.description = payload["description"]