SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
10.9 KB · 241 lines python
Raw Blame History
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