SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.7 KB · 190 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/summum.py : connecteur Gestion immobilière Summum5#   (location.summumpm.com) — portail hébergé Building Stack (même famille6#   que le portail edifialocation.com du connecteur edifia.py) :7#   Saint-Jérôme, Blainville, Montréal, Laval, Longueuil, Granby…8#   - /Listing/Listings embarque `var units = [...]` : JSON complet des9#     unités affichées (ApartmentId stable, prix, pi², chambres, salles de10#     bain, adresse complète avec GPS, contact de location, photo) ;11#   - les pages immeuble /b/<id> donnent la date de disponibilité par unité12#     et les commodités de l'immeuble (une requête par immeuble, en cache13#     via self.detail, plafonnée).14#   Portail SaaS standard, pas de robots.txt (tout permis).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://location.summumpm.com"2829# graphies sans accent vues dans le JSON du portail -> toponyme officiel30_CITY_FIX = {"Montreal": "Montréal"}313233def _extract_json(html: str, marker: str):34    """Décode la structure JSON qui suit `marker` dans un script inline."""35    i = html.find(marker)36    if i < 0:37        return None38    try:39        data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):].lstrip())40    except Exception:41        return None42    return data434445class _CapAtteint(Exception):46    """Plafond de requêtes détail atteint pour cette synchronisation."""474849class SummumConnector(BaseConnector):50    source_id = "summum"51    request_delay = 0.752    max_real_details = 45        # pages immeuble par sync (cache exclu)5354    # -- pages immeuble (dates de disponibilité + commodités) ----------------------55    def _building_page(self, pub_id: str, key: str) -> dict:56        """Page /b/<id> : { 'dates': {unité: dispo}, 'amenities': [...] }."""57        def _fetch() -> dict:58            if self._real_details >= self.max_real_details:59                raise _CapAtteint()60            self._real_details += 161            html = self.get(f"{BASE}/b/{pub_id}").text62            soup = BeautifulSoup(html, "html.parser")63            dates: dict[str, str] = {}64            for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"):65                spans = [s.get_text(" ", strip=True) for s in a.find_all("span")]66                if len(spans) >= 6 and spans[0]:67                    dates[spans[0]] = spans[5]      # Unité -> Disponible68            amenities: list[str] = []69            for li in soup.select(".facilities ul li label"):70                t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))71                if 3 <= len(t) <= 90 and t not in amenities:72                    amenities.append(t)73            imgs = [u for u in dict.fromkeys(re.findall(74                r"https://wfiles\.buildingstack\.com/resources/image/[\w]+",75                html))]76            return {"dates": dates, "amenities": amenities[:20],77                    "images": imgs[:10]}7879        try:80            return self.detail(pub_id, key, _fetch)81        except Exception:82            return {}8384    # -- fetch ----------------------------------------------------------------------85    def fetch(self) -> list[Listing]:86        self._real_details = 087        html = self.get(f"{BASE}/Listing/Listings").text88        units = _extract_json(html, "var units =") or []8990        # une page immeuble par immeuble (clé de cache = contenu des unités)91        by_building: dict[str, list[dict]] = {}92        for u in units:93            pub = str((u.get("Building") or {})94                      .get("PublicListBuildingName") or "")95            if pub:96                by_building.setdefault(pub, []).append(u)97        bldg_info: dict[str, dict] = {}98        for pub, us in by_building.items():99            key = hashlib.sha1(json.dumps(100                sorted((str(x.get("ApartmentId")),101                        str((x.get("Apartment") or {}).get("Price")))102                       for x in us)).encode("utf-8")).hexdigest()[:16]103            bldg_info[pub] = self._building_page(pub, key)104105        listings: list[Listing] = []106        for u in units:107            apt = u.get("Apartment") or {}108            adr = u.get("Address") or {}109            bld = u.get("Building") or {}110            ext_id = str(u.get("ApartmentId") or "")111            if not ext_id:112                continue113            num = str(apt.get("UnitName") or "").strip()114            building_name = str(u.get("BuildingName") or "").strip()115            pub = str(bld.get("PublicListBuildingName") or "")116            info = bldg_info.get(pub) or {}117118            # typologie dérivée des chambres structurées (patron edifia.py)119            beds = apt.get("NumberOfBedrooms") or 0120            baths = apt.get("NumberOfBathrooms") or 0121            unit_type = "Studio" if beds == 0 else f"{beds + 2}½"122123            dispo = (info.get("dates") or {}).get(num, "")124            if not dispo:125                availability = ""126            elif re.search(r"\d", dispo):       # « déc. 01, 2026 »127                availability = f"Libre {dispo}"128            else:                               # « Disponible dès maintenant! »129                availability = dispo130131            area = apt.get("Area")132            try:133                area = float(area)134                if not 80 <= area <= 20000:135                    area = None136            except (TypeError, ValueError):137                area = None138139            desc = " — ".join(x for x in [140                f"{int(area)} pi²" if area else "",141                f"{beds} chambre(s)" if beds else "",142                f"{baths} salle(s) de bain" if baths else ""] if x)143144            details: dict = {"bedrooms": int(beds), "bathrooms": int(baths)}145            contacts = bld.get("ListingEmployeesContacts") or []146            if contacts:147                c = contacts[0]148                contact = {k: v for k, v in [149                    ("name", c.get("FullName")),150                    ("phone", c.get("FormattedPhoneNumber")),151                    ("email", c.get("Email"))] if v}152                if contact:153                    details["contact"] = contact154155            imgs = list(info.get("images") or [])156            prev = str(u.get("PreviewUrl") or "")157            if prev.startswith("http") and prev not in imgs:158                imgs.insert(0, prev)159160            try:161                lat, lng = float(adr.get("Latitude")), float(adr.get("Longitude"))162            except (TypeError, ValueError):163                lat = lng = None164165            city = str(adr.get("City") or "").strip()166            city = _CITY_FIX.get(city, city)167168            listings.append(Listing(169                source=self.source_id,170                external_id=ext_id,171                url=f"{BASE}{u.get('BuildingUrl') or f'/b/{pub}'}",172                title=(f"{building_name} — Unité {num}" if num173                       else building_name),174                address=str(adr.get("Full") or ""),175                sector="",176                city=city,177                unit_type=unit_type,178                price=float(apt.get("Price") or 0) or None,179                price_label=str(apt.get("PriceFormatted") or ""),180                availability=availability,181                area_sqft=area,182                description=desc[:600],183                amenities=list(info.get("amenities") or []),184                details=details,185                images=imgs[:12],186                lat=lat,187                lng=lng,188            ))189        return listings190