# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/msi.py : connecteur MSI Gestion immobilière (msimmobiliers.com) # Crawl des pages de secteurs (rendu serveur) : Québec + Lévis + Montréal # + Thetford Mines (flux intermittent — souvent 0 unité). # Front Next.js : chaque fiche embarque un objet JSON `rental` complet dans # le payload « flight » (self.__next_f.push) — adresse + code postal, # lat/lng, date de disponibilité ISO, étage, superficie, caractéristiques # structurées (features), galerie photo, politique chiens (dogPolicy). # Fiches visitées via self.detail(...) (cache BD, plafond de requêtes). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.msimmobiliers.com" ROOTS = [ f"{BASE}/appartements-a-louer/quebec", f"{BASE}/appartements-a-louer/levis", f"{BASE}/appartements-a-louer/montreal", f"{BASE}/appartements-a-louer/thetford-mine", ] UNIT_RE = re.compile(r"/appartements-a-louer/[^\"]*appartement-(\d+)[^\"]*") LIST_RE = re.compile(r"^/appartements-a-louer/[a-z0-9\-/]+$") # ville par défaut selon la racine de l'URL (/appartements-a-louer//...) _ROOT_CITY = {"quebec": "Québec", "levis": "Lévis", "montreal": "Montréal", "thetford-mine": "Thetford Mines"} # morceaux de chaîne JS des payloads flight : self.__next_f.push([1,"..."]) _FLIGHT_RE = re.compile( r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)') def _default_city(path_or_url: str) -> str: m = re.search(r"/appartements-a-louer/([a-z0-9\-]+)", path_or_url) return _ROOT_CITY.get(m.group(1) if m else "", "Québec") def _extract_rental(html: str) -> dict: """Extrait l'objet JSON `rental` du payload flight Next.js de la fiche.""" for m in _FLIGHT_RE.finditer(html): try: chunk = json.loads('"' + m.group(1) + '"') # dés-échappe la chaîne except ValueError: continue i = chunk.find('"rental":{') if i < 0: continue start = i + len('"rental":') depth = 0 in_str = esc = False for j in range(start, len(chunk)): c = chunk[j] if esc: esc = False elif c == "\\": esc = True elif in_str: in_str = c != '"' elif c == '"': in_str = True elif c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: try: return json.loads(chunk[start:j + 1]) except ValueError: return {} break return {} class _CapAtteint(Exception): """Plafond de requêtes détail atteint pour cette synchronisation.""" class MSIConnector(BaseConnector): source_id = "msi" request_delay = 0.5 max_list_pages = 90 # garde-fou de crawl (Qc + Lévis + Mtl) max_real_details = 150 # vraies requêtes détail par sync (cache exclu) def fetch(self) -> list[Listing]: self._real_details = 0 # 1) BFS sur les pages de listes (arrondissements / quartiers) to_visit = list(ROOTS) visited: set[str] = set() listings: dict[str, Listing] = {} card_keys: dict[str, str] = {} while to_visit and len(visited) < self.max_list_pages: url = to_visit.pop(0) if url in visited: continue visited.add(url) try: html = self.get(url).text except Exception: continue soup = BeautifulSoup(html, "html.parser") # cartes d'unités for card in soup.select('a[href*="appartement-"]'): href = card.get("href", "") m = UNIT_RE.search(href) if not m: continue ext_id = m.group(1) if ext_id in listings: continue full_url = href if href.startswith("http") else BASE + href text = card.get_text("|", strip=True) parts = [p for p in text.split("|") if p and p != "Voir cette fiche"] # Format observé : "4 1/2 | 1195$ / mois | Appartement / Condo | # 177 Avenue Ruel | Chutes-Montmorency | Libre ..." unit_type = price_label = category = address = sector = avail = "" for p in parts: if not unit_type and re.match(r"^\d\s*1/2$|^Studio|^Loft", p, re.I): unit_type = p elif not price_label and "$" in p: price_label = p elif not category and re.search(r"Appartement|Condo|Maison|Commercial|Stationnement", p, re.I): category = p elif not address and re.match(r"^\d+[\s,]", p): address = p elif not avail and re.search(r"Libre|Disponib", p, re.I): avail = p elif not sector and address: sector = p # ignorer stationnements/espaces commerciaux if re.search(r"Stationnement|Commercial|Rangement|Parking", category or "", re.I): continue city = _default_city(href if "/appartements-a-louer/" in href else url) listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=full_url, title=address or parts[0] if parts else f"Unité {ext_id}", address=address, sector=sector, city=infer_city(sector, default=city), unit_type=normalize_unit_type(unit_type), price=parse_price(price_label), price_label=price_label, availability=avail, ) card_keys[ext_id] = hashlib.sha1( "|".join(parts).encode("utf-8")).hexdigest()[:16] # sous-pages de secteurs for a in soup.select('a[href^="/appartements-a-louer/"]'): href = a.get("href", "").split("?")[0] if LIST_RE.match(href) and "appartement-" not in href: nxt = BASE + href if nxt not in visited: to_visit.append(nxt) # 2) Fiches détaillées (cache BD) : JSON rental + caractéristiques for ext_id, lst in listings.items(): det = self._unit_detail(ext_id, lst.url, card_keys.get(ext_id, "")) if not det: continue if det.get("images"): lst.images = det["images"][:25] if det.get("address_full"): lst.address = det["address_full"] # avec ville (plus complet) lst.lat, lst.lng = det.get("lat"), det.get("lng") if det.get("area"): lst.area_sqft = det["area"] if det.get("availability") and not lst.availability: lst.availability = f"Disponibilité : {det['availability']}" lst.amenities = det.get("amenities") or [] if det.get("floor"): lst.details = {**lst.details, "floor": det["floor"]} if det.get("catchphrase"): lst.description = det["catchphrase"][:600] return list(listings.values()) def _unit_detail(self, ext_id: str, url: str, card_key: str) -> dict: """Fiche unité : objet JSON `rental` (flight Next.js) + liste des caractéristiques affichées (textes bruts, incl. chiens/fumeur).""" def _fetch() -> dict: if self._real_details >= self.max_real_details: raise _CapAtteint() self._real_details += 1 html = self.get(url).text out: dict = {} rental = _extract_rental(html) adr = rental.get("address") or {} if isinstance(adr, dict): out["address_full"] = adr.get("full") or "" if isinstance(adr.get("lat"), (int, float)): out["lat"] = adr["lat"] if isinstance(adr.get("lng"), (int, float)): out["lng"] = adr["lng"] area = rental.get("area") if isinstance(area, (int, float)) and 80 <= area <= 20000: out["area"] = float(area) floor = rental.get("floor") if isinstance(floor, int) and 0 < floor <= 60: out["floor"] = floor avail = rental.get("availability") if isinstance(avail, str) and avail: out["availability"] = avail catch = rental.get("catchphrase") if isinstance(catch, str) and catch and "$undefined" not in catch: out["catchphrase"] = re.sub(r"\s+", " ", catch).strip() imgs = [] for ph in rental.get("gallery") or []: u = ((ph.get("sizes") or {}).get("large") if isinstance(ph, dict) else None) if u and u not in imgs: imgs.append(u) # Caractéristiques affichées (textes bruts : « Balcon », # « Chien interdit », « Non fumeur », « Dernier étage »…) soup = BeautifulSoup(html, "html.parser") amenities: list[str] = [] cat_name = ((rental.get("category") or {}).get("name") or "") for li in soup.select(".rental-content__hero--facilities li"): if li.find("a"): continue # lien Google Maps (adresse) txt = li.get_text(" ", strip=True) if (not txt or txt in ("N/C",) or txt.startswith("**") or txt == cat_name or len(txt) > 60): continue if txt not in amenities: amenities.append(txt) if not amenities: # repli : features structurées du JSON amenities = [f.get("name") for f in (rental.get("features") or []) + (rental.get("secondaryFeatures") or []) if isinstance(f, dict) and f.get("name")] out["amenities"] = amenities[:20] if not imgs: # repli : images wp-content de la page imgs = [u for u in dict.fromkeys(re.findall( r'https://api\.msimmobiliers\.com/wp-content/uploads/' r'[^"\\\s\)]+\.(?:jpg|jpeg|png|webp)', html)) if not re.search(r"logo|icon|favicon", u, re.I)] out["images"] = imgs[:25] return out try: return self.detail(ext_id, card_key, _fetch) except Exception: return {}