spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/mellem.py : connecteur Mellem (mellem.ca — Groupe Mach)5# Site Nuxt (rendu serveur) : un projet = une page /projets/<slug>6# - Mellem Manoir-des-Trembles (189 unités) — Gatineau7# - Mellem Grace Dart et Mellem Ville-Marie — Montréal (bonus : mêmes8# pages, même sélecteur, donc même parsing)9# Le sélecteur d'unités est le module « plans » de Livya/Realvuu, intégré en10# iframe et décrit dans la page par11# <div data-module="plans" data-project="…" data-entity="…">. La page du12# module (app.livya.com, rendue côté serveur par Next.js) embarque13# l'INVENTAIRE COMPLET en JSON : n° d'unité, étage, typologie (rooms 4.5 ->14# 4½), loyer exact (rentalPrice), superficie, balcon, chambres/salles de15# bain, adresse + ville + code postal, latitude/longitude et statut16# (AVAILABLE / RESERVED / NOT_AVAILABLE).17# Une annonce = une unité AVAILABLE. Les projets sont découverts via le18# sitemap (aucune liste codée en dur), et les commodités/inclusions du19# projet sont lues sur sa page (liste « Plus de commodités pour mieux vivre »).20# -----------------------------------------------------------------------------21from __future__ import annotations2223import json24import re2526from bs4 import BeautifulSoup2728from ..schema import Listing, normalize_unit_type29from .base import BaseConnector3031BASE = "https://www.mellem.ca"32SITEMAP_URL = f"{BASE}/sitemap.xml"33# identifiant du compte Livya de Mellem/Maître Carré : constante de routage34# publiée dans le bundle du site (script[data-client]), pas une donnée.35LIVYA_CLIENT = "maitre-carre"36LIVYA_MODULE = ("https://app.livya.com/fr/{client}/projects/{project}"37 "/plans/{entity}?noLayout=1")383940class MellemConnector(BaseConnector):41 source_id = "mellem"42 request_delay = 0.643 max_projects = 8 # garde-fou (3 projets au 2026-08)44 max_units = 400 # garde-fou par projet45 max_images = 64647 # -- inventaire Livya ------------------------------------------------------48 @staticmethod49 def _units(html: str) -> list[dict]:50 """Tableau `units` du payload Next.js du module Livya.5152 Le payload est diffusé en flux (RSC) : les guillemets sont échappés.53 On repère le tableau, on dé-échappe puis on décode le JSON.54 """55 decoder = json.JSONDecoder()56 for marker, unescape in ((r'\"units\":', True), ('"units":', False)):57 start = 058 while True:59 i = html.find(marker, start)60 if i < 0:61 break62 start = i + 163 seg = html[i + len(marker):]64 if unescape:65 seg = seg.replace('\\"', '"')66 try:67 arr, _ = decoder.raw_decode(seg)68 except ValueError:69 continue70 # une même clé « units » sert aussi au dictionnaire des71 # libellés d'interface : ne retenir que le tableau d'unités72 if (isinstance(arr, list) and arr73 and isinstance(arr[0], dict) and "unitId" in arr[0]):74 return arr75 return []7677 def _project_pages(self) -> list[str]:78 """URLs des pages projet, depuis le sitemap du site."""79 try:80 xml = self.get(SITEMAP_URL).text81 except Exception:82 return []83 urls = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", xml)84 out = [u for u in dict.fromkeys(urls)85 if re.match(rf"^{re.escape(BASE)}/projets/[^/]+/?$", u)]86 return out[: self.max_projects]8788 @staticmethod89 def _amenities(soup) -> list[str]:90 """Inclusions et commodités du projet : items de la section91 « ui-sections-projects-list-inclusion » uniquement (les autres listes92 de la page sont des commerces du quartier, pas des inclusions)."""93 items: list[str] = []94 for el in soup.select(95 "section.ui-sections-projects-list-inclusion li.item p"):96 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()97 if 3 <= len(t) <= 90 and t not in items:98 items.append(t)99 return items[:25]100101 @staticmethod102 def _images(soup) -> list[str]:103 """Photos du projet (CDN DatoCMS, variantes redimensionnées exclues)."""104 imgs: list[str] = []105 for im in soup.select("img[src*='datocms-assets.com']"):106 src = (im.get("src") or "").split("?")[0]107 if src and src not in imgs and not re.search(108 r"logo|icon|favicon", src, re.I):109 imgs.append(src)110 return imgs111112 # -- fetch -----------------------------------------------------------------113 def fetch(self) -> list[Listing]:114 listings: list[Listing] = []115 for page_url in self._project_pages():116 try:117 html = self.get(page_url).text118 soup = BeautifulSoup(html, "html.parser")119 box = soup.select_one(120 "[data-module='plans'][data-project][data-entity]")121 if box is None:122 continue123 project = (box.get("data-project") or "").strip()124 entity = (box.get("data-entity") or "").strip()125 lang = (box.get("data-lang") or "fr").strip() or "fr"126 if not (project and entity):127 continue128 h1 = soup.select_one("h1")129 name = (h1.get_text(" ", strip=True) if h1 else130 page_url.rstrip("/").rsplit("/", 1)[-1].title())131 amenities = self._amenities(soup)132 images = self._images(soup)[: self.max_images]133134 module_url = LIVYA_MODULE.format(135 client=LIVYA_CLIENT, project=project, entity=entity136 ).replace("/fr/", f"/{lang}/")137 units = self._units(self.get(module_url).text)138 except Exception:139 continue140141 slug = page_url.rstrip("/").rsplit("/", 1)[-1]142 for unit in units[: self.max_units]:143 try:144 if str(unit.get("availability") or "").upper() != "AVAILABLE":145 continue146 if not unit.get("rental", True):147 continue # unité en vente, pas en location148 num = str(unit.get("number") or "").strip()149 city = str(unit.get("city") or "").strip()150 if not num or not city:151 continue152153 price = None154 try:155 val = float(unit.get("rentalPrice") or 0)156 price = val if 100 <= val <= 20000 else None157 except (TypeError, ValueError):158 price = None159 area = None160 try:161 val = float(unit.get("unitSize") or 0)162 area = val if 80 <= val <= 20000 else None163 except (TypeError, ValueError):164 area = None165166 # typologie : `rooms` = nombre de pièces (3.5 -> 3½).167 # Quelques unités portent un `rooms` incohérent (0.5) alors168 # que le code de type de la source l'indique (« A-2.5 ») :169 # on relit alors la typologie dans ce code, sans rien170 # inventer.171 rooms = unit.get("rooms")172 type_name = str(unit.get("typeName") or "").strip()173 unit_type = normalize_unit_type(str(rooms)) if rooms else ""174 if not unit_type:175 m = re.search(r"\b(\d)[.,]5\b", type_name)176 if m:177 unit_type = normalize_unit_type(f"{m.group(1)}.5")178 addr_bits = [str(unit.get("address") or "").strip(), city,179 str(unit.get("postalCode") or "").strip()]180 address = ", ".join(b for b in addr_bits if b)181182 balcony = unit.get("balconySize") or 0183 etage = str(unit.get("floorDisplayName") or "").strip()184 desc = " — ".join(x for x in [185 type_name,186 f"{area:.0f} pi²" if area else "",187 f"balcon {balcony:.0f} pi²" if balcony else "",188 f"{unit.get('roomsBed')} chambre(s)"189 if unit.get("roomsBed") else "",190 f"{unit.get('roomsBath')} salle(s) de bain"191 if unit.get("roomsBath") else "",192 etage] if x)193194 future = str(unit.get("futureAvailability") or "").strip()195 availability = f"Libre {future}" if future else "Disponible"196197 details: dict = {}198 floor = unit.get("floorNumber")199 if isinstance(floor, str) and floor.isdigit():200 details["floor"] = int(floor)201 elif isinstance(floor, int) and floor > 0:202 details["floor"] = floor203204 imgs = list(images)205 for ti in (unit.get("typeImages") or [])[:2]:206 u = (ti.get("fullUrl") or "") if isinstance(ti, dict) else ""207 if u.startswith("http") and u not in imgs:208 imgs.insert(0, u)209210 lat = unit.get("latitude") or None211 lng = unit.get("longitude") or None212213 listings.append(Listing(214 source=self.source_id,215 external_id=f"{slug}-{num}",216 url=f"{page_url.rstrip('/')}#unite-{num}",217 title=f"{name} — Unité {num}",218 address=address,219 sector="",220 city=city,221 unit_type=unit_type,222 price=price,223 price_label=(f"{price:,.0f} $ par mois".replace(",", " ")224 if price else ""),225 availability=availability,226 area_sqft=area,227 description=desc[:600],228 amenities=list(amenities),229 details=details,230 images=imgs[: self.max_images + 2],231 lat=float(lat) if lat else None,232 lng=float(lng) if lng else None,233 ))234 except Exception:235 continue236237 uniq: dict[str, Listing] = {}238 for lst in listings:239 uniq.setdefault(lst.external_id, lst)240 return list(uniq.values())241