# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/mellem.py : connecteur Mellem (mellem.ca — Groupe Mach) # Site Nuxt (rendu serveur) : un projet = une page /projets/ # - Mellem Manoir-des-Trembles (189 unités) — Gatineau # - Mellem Grace Dart et Mellem Ville-Marie — Montréal (bonus : mêmes # pages, même sélecteur, donc même parsing) # Le sélecteur d'unités est le module « plans » de Livya/Realvuu, intégré en # iframe et décrit dans la page par #
. La page du # module (app.livya.com, rendue côté serveur par Next.js) embarque # l'INVENTAIRE COMPLET en JSON : n° d'unité, étage, typologie (rooms 4.5 -> # 4½), loyer exact (rentalPrice), superficie, balcon, chambres/salles de # bain, adresse + ville + code postal, latitude/longitude et statut # (AVAILABLE / RESERVED / NOT_AVAILABLE). # Une annonce = une unité AVAILABLE. Les projets sont découverts via le # sitemap (aucune liste codée en dur), et les commodités/inclusions du # projet sont lues sur sa page (liste « Plus de commodités pour mieux vivre »). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.mellem.ca" SITEMAP_URL = f"{BASE}/sitemap.xml" # identifiant du compte Livya de Mellem/Maître Carré : constante de routage # publiée dans le bundle du site (script[data-client]), pas une donnée. LIVYA_CLIENT = "maitre-carre" LIVYA_MODULE = ("https://app.livya.com/fr/{client}/projects/{project}" "/plans/{entity}?noLayout=1") class MellemConnector(BaseConnector): source_id = "mellem" request_delay = 0.6 max_projects = 8 # garde-fou (3 projets au 2026-08) max_units = 400 # garde-fou par projet max_images = 6 # -- inventaire Livya ------------------------------------------------------ @staticmethod def _units(html: str) -> list[dict]: """Tableau `units` du payload Next.js du module Livya. Le payload est diffusé en flux (RSC) : les guillemets sont échappés. On repère le tableau, on dé-échappe puis on décode le JSON. """ decoder = json.JSONDecoder() for marker, unescape in ((r'\"units\":', True), ('"units":', False)): start = 0 while True: i = html.find(marker, start) if i < 0: break start = i + 1 seg = html[i + len(marker):] if unescape: seg = seg.replace('\\"', '"') try: arr, _ = decoder.raw_decode(seg) except ValueError: continue # une même clé « units » sert aussi au dictionnaire des # libellés d'interface : ne retenir que le tableau d'unités if (isinstance(arr, list) and arr and isinstance(arr[0], dict) and "unitId" in arr[0]): return arr return [] def _project_pages(self) -> list[str]: """URLs des pages projet, depuis le sitemap du site.""" try: xml = self.get(SITEMAP_URL).text except Exception: return [] urls = re.findall(r"\s*([^<\s]+)\s*", xml) out = [u for u in dict.fromkeys(urls) if re.match(rf"^{re.escape(BASE)}/projets/[^/]+/?$", u)] return out[: self.max_projects] @staticmethod def _amenities(soup) -> list[str]: """Inclusions et commodités du projet : items de la section « ui-sections-projects-list-inclusion » uniquement (les autres listes de la page sont des commerces du quartier, pas des inclusions).""" items: list[str] = [] for el in soup.select( "section.ui-sections-projects-list-inclusion li.item p"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if 3 <= len(t) <= 90 and t not in items: items.append(t) return items[:25] @staticmethod def _images(soup) -> list[str]: """Photos du projet (CDN DatoCMS, variantes redimensionnées exclues).""" imgs: list[str] = [] for im in soup.select("img[src*='datocms-assets.com']"): src = (im.get("src") or "").split("?")[0] if src and src not in imgs and not re.search( r"logo|icon|favicon", src, re.I): imgs.append(src) return imgs # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: listings: list[Listing] = [] for page_url in self._project_pages(): try: html = self.get(page_url).text soup = BeautifulSoup(html, "html.parser") box = soup.select_one( "[data-module='plans'][data-project][data-entity]") if box is None: continue project = (box.get("data-project") or "").strip() entity = (box.get("data-entity") or "").strip() lang = (box.get("data-lang") or "fr").strip() or "fr" if not (project and entity): continue h1 = soup.select_one("h1") name = (h1.get_text(" ", strip=True) if h1 else page_url.rstrip("/").rsplit("/", 1)[-1].title()) amenities = self._amenities(soup) images = self._images(soup)[: self.max_images] module_url = LIVYA_MODULE.format( client=LIVYA_CLIENT, project=project, entity=entity ).replace("/fr/", f"/{lang}/") units = self._units(self.get(module_url).text) except Exception: continue slug = page_url.rstrip("/").rsplit("/", 1)[-1] for unit in units[: self.max_units]: try: if str(unit.get("availability") or "").upper() != "AVAILABLE": continue if not unit.get("rental", True): continue # unité en vente, pas en location num = str(unit.get("number") or "").strip() city = str(unit.get("city") or "").strip() if not num or not city: continue price = None try: val = float(unit.get("rentalPrice") or 0) price = val if 100 <= val <= 20000 else None except (TypeError, ValueError): price = None area = None try: val = float(unit.get("unitSize") or 0) area = val if 80 <= val <= 20000 else None except (TypeError, ValueError): area = None # typologie : `rooms` = nombre de pièces (3.5 -> 3½). # Quelques unités portent un `rooms` incohérent (0.5) alors # que le code de type de la source l'indique (« A-2.5 ») : # on relit alors la typologie dans ce code, sans rien # inventer. rooms = unit.get("rooms") type_name = str(unit.get("typeName") or "").strip() unit_type = normalize_unit_type(str(rooms)) if rooms else "" if not unit_type: m = re.search(r"\b(\d)[.,]5\b", type_name) if m: unit_type = normalize_unit_type(f"{m.group(1)}.5") addr_bits = [str(unit.get("address") or "").strip(), city, str(unit.get("postalCode") or "").strip()] address = ", ".join(b for b in addr_bits if b) balcony = unit.get("balconySize") or 0 etage = str(unit.get("floorDisplayName") or "").strip() desc = " — ".join(x for x in [ type_name, f"{area:.0f} pi²" if area else "", f"balcon {balcony:.0f} pi²" if balcony else "", f"{unit.get('roomsBed')} chambre(s)" if unit.get("roomsBed") else "", f"{unit.get('roomsBath')} salle(s) de bain" if unit.get("roomsBath") else "", etage] if x) future = str(unit.get("futureAvailability") or "").strip() availability = f"Libre {future}" if future else "Disponible" details: dict = {} floor = unit.get("floorNumber") if isinstance(floor, str) and floor.isdigit(): details["floor"] = int(floor) elif isinstance(floor, int) and floor > 0: details["floor"] = floor imgs = list(images) for ti in (unit.get("typeImages") or [])[:2]: u = (ti.get("fullUrl") or "") if isinstance(ti, dict) else "" if u.startswith("http") and u not in imgs: imgs.insert(0, u) lat = unit.get("latitude") or None lng = unit.get("longitude") or None listings.append(Listing( source=self.source_id, external_id=f"{slug}-{num}", url=f"{page_url.rstrip('/')}#unite-{num}", title=f"{name} — Unité {num}", address=address, sector="", city=city, unit_type=unit_type, price=price, price_label=(f"{price:,.0f} $ par mois".replace(",", " ") if price else ""), availability=availability, area_sqft=area, description=desc[:600], amenities=list(amenities), details=details, images=imgs[: self.max_images + 2], lat=float(lat) if lat else None, lng=float(lng) if lng else None, )) except Exception: continue uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values())