SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.9 KB · 284 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/niddamour.py : connecteur Nid d'Amour / Karen Cadet inc.5#   (niddamour.ca — Plateau, Verdun, Outremont, Rosemont, Ville-Marie,6#    Brossard...). Front WordPress + Angular sur la plateforme source.immo :7#   on lit la config publique (_configs.json) puis on interroge directement8#   l'API JSON api-v1.source.immo (liste + fiches avec toutes les photos).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import hashlib13import json14import re1516from ..schema import Listing17from .base import BaseConnector1819SITE = "https://niddamour.ca"20# la config source.immo (config_path) est présente sur la page d'accueil ;21# les pages /proprietes/ sont des routes Angular servies en HTTP 40422HOME_URL = f"{SITE}/"23API_ROOT = "https://api-v1.source.immo/api"2425# Régions administratives admissibles (Grand Montréal / CMM)26ALLOWED_REGIONS = {"montreal", "laval", "monteregie", "lanaudiere", "laurentides"}2728_BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}2930# mots-clés des champs structurés inclusions/exclusions -> clés Lou-Ka31_INCL_KEYS = [32    (re.compile(r"electricite|hydro"), "electricity"),33    (re.compile(r"chauffage"), "heating"),34    (re.compile(r"eau chaude"), "hot_water"),35    (re.compile(r"internet|wi-?fi"), "internet"),36    (re.compile(r"cable|television"), "cable"),37]383940def _strip_accents_lower(s: str) -> str:41    import unicodedata42    return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())43                   if unicodedata.category(c) != "Mn")444546class _CapAtteint(Exception):47    """Plafond de requêtes détail atteint pour cette synchronisation."""484950class NiddamourConnector(BaseConnector):51    source_id = "niddamour"52    request_delay = 0.653    max_real_details = 150   # vraies requêtes détail par sync (cache exclu)5455    # -- helpers ---------------------------------------------------------------56    def _api(self, path: str, cfg: dict) -> dict:57        headers = {58            "x-si-account": cfg["account_id"],59            "x-si-api": cfg["api_key"],60            "x-si-appId": cfg["app_id"],61            "x-si-appVersion": cfg.get("app_version", ""),62            "Origin": SITE,63            "Referer": SITE + "/",64        }65        return self.get(f"{API_ROOT}/{path}", headers=headers).json()6667    def _load_config(self) -> dict | None:68        """Extrait l'URL du _configs.json de source.immo depuis le site."""69        try:70            page = self.get(HOME_URL).text71        except Exception:72            return None73        m = re.search(r'config_path\s*:\s*"([^"]+)"', page)74        if not m:75            return None76        cfg_url = m.group(1).replace("\\/", "/")77        if cfg_url.startswith("//"):78            cfg_url = "https:" + cfg_url79        try:80            return self.get(cfg_url).json()81        except Exception:82            return None8384    # -- fiche détaillée (via cache BD self.detail) ----------------------------85    def _unit_detail(self, ref: str, key: str, view: str, cfg: dict) -> dict:86        """Fiche source.immo : photos, adresse (+ code postal), description,87        addendum, inclusions/exclusions structurées, étage, sdb."""88        def _fetch() -> dict:89            if self._real_details >= self.max_real_details:90                raise _CapAtteint()91            self._real_details += 192            det = self._api(93                f"listing/view/{view}/fr/items/ref_number/{ref}", cfg)94            out: dict = {}95            out["images"] = [ph.get("url") for ph in (det.get("photos") or [])96                             if isinstance(ph, dict) and ph.get("url")]97            out["description"] = re.sub(98                r"\s+", " ", det.get("description") or "").strip()99100            adr = (det.get("location") or {}).get("address") or {}101            parts = [adr.get("street_number"), adr.get("street_name")]102            address = " ".join(x for x in parts if x)103            if adr.get("door") and address:104                address += f", app. {adr['door']}"105            if adr.get("postal_code") and address:106                address += f", {adr['postal_code']}"107            out["address"] = address108109            out["inclusions_txt"] = det.get("inclusions") or ""110            out["exclusions_txt"] = det.get("exclusions") or ""111112            # étage de l'unité principale + salles de bain113            for u in det.get("units") or []:114                if (u.get("category_code") == "MAIN"115                        and isinstance(u.get("level"), int)116                        and 0 < u["level"] <= 60):117                    out["floor"] = u["level"]118            main = next((u for u in det.get("units") or []119                         if u.get("category_code") == "MAIN"), {})120            if isinstance(main.get("bathroom_count"), int) \121                    and main["bathroom_count"] > 0:122                out["bathrooms"] = main["bathroom_count"]123124            # addendum HTML (détails de l'appartement, services à proximité)125            add = det.get("addendum") or ""126            add = re.sub(r"<br\s*/?>", " — ", add)127            add = re.sub(r"<[^>]+>", " ", add)128            add = re.sub(r"&nbsp;?", " ", add)129            out["addendum"] = re.sub(r"\s+", " ", add).strip()130            return out131132        try:133            return self.detail(ref, key, _fetch)134        except Exception:135            return {}136137    # -- contrat ---------------------------------------------------------------138    def fetch(self) -> list[Listing]:139        self._real_details = 0140        listings: list[Listing] = []141        cfg = self._load_config()142        if not cfg or not cfg.get("api_key"):143            return listings144145        view = cfg.get("default_view") or ""146        if view.startswith("{"):147            try:148                view = json.loads(view).get("id", "")149            except ValueError:150                return listings151        if not view:152            return listings153154        # Dictionnaires (codes ville / sous-catégorie / région -> libellés)155        try:156            meta = self._api(f"view/{view}/fr", cfg)157        except Exception:158            return listings159        dico = meta.get("dictionary") or {}160        cities = dico.get("city") or {}161        subcats = dico.get("listing_subcategory") or {}162        regions = dico.get("region") or {}163164        try:165            items = (self._api(f"listing/view/{view}/fr/items", cfg)166                     .get("items") or [])167        except Exception:168            return listings169170        for it in items:171            try:172                ref = it.get("ref_number") or ""173                if not ref:174                    continue175                # disponibles à louer, résidentiel seulement176                if it.get("status_code") != "AVAILABLE":177                    continue178                if not it.get("for_rent_flag"):179                    continue180                if (it.get("category_code") or "") != "RESIDENTIAL":181                    continue182                subcap = ((subcats.get(it.get("subcategory_code") or "") or {})183                          .get("caption") or "")184                if re.search(r"stationnement|commercial|bureau|local|terrain|"185                             r"industriel|garage|entrep[oô]t", subcap, re.I):186                    continue187188                loc = it.get("location") or {}189                region_cap = ((regions.get(loc.get("region_code") or "") or {})190                              .get("caption") or "")191                if region_cap and \192                        _strip_accents_lower(region_cap) not in ALLOWED_REGIONS:193                    continue  # hors Grand Montréal194195                # 'Montréal (Le Plateau-Mont-Royal)' -> ville + quartier196                city_cap = ((cities.get(loc.get("city_code") or "") or {})197                            .get("caption") or "")198                mcity = re.match(r"^([^(]+?)\s*(?:\(([^)]+)\))?$", city_cap)199                city = (mcity.group(1).strip() if mcity else city_cap) or "Montréal"200                sector = (mcity.group(2) or "").strip() if mcity else ""201202                price = ((it.get("price") or {}).get("rent") or {}).get("amount")203                price = float(price) if isinstance(price, (int, float)) else None204                price_label = (f"{price:,.0f}".replace(",", " ") + " $ / mois"205                               if price else "")206207                bedrooms = (it.get("main_unit") or {}).get("bedroom_count")208                if re.search(r"maison", subcap, re.I):209                    unit_type = "Maison"210                elif re.search(r"studio|loft", subcap, re.I) and not bedrooms:211                    unit_type = "Studio"212                else:213                    unit_type = _BEDROOMS_TO_TYPE.get(214                        bedrooms, f"{bedrooms} chambres" if bedrooms else "")215216                # Fiche détaillée (cache BD, clé = hash de l'item de liste)217                key = hashlib.sha1(json.dumps(218                    it, sort_keys=True, ensure_ascii=False)219                    .encode("utf-8")).hexdigest()[:16]220                det = self._unit_detail(ref, key, view, cfg)221222                address = det.get("address") or ""223                description = det.get("description") or ""224                if det.get("addendum"):225                    description = (f"{description}{det['addendum']}"226                                   if description else det["addendum"])227                if det.get("bathrooms"):228                    description = " — ".join(229                        x for x in [description,230                                    f"{det['bathrooms']} sdb"] if x)231                description = description[:600]232233                # inclusions positives -> amenities brutes (affichage)234                amenities = [re.sub(r"^[-–•\s]+", "", ln).strip(" ;.")235                             for ln in (det.get("inclusions_txt") or "")236                             .splitlines() if ln.strip(" -–•;.")]237238                # inclusions/exclusions structurées -> details.inclusions239                # (les exclusions priment : « Électricité, chauffage et eau240                #  chaude » non inclus ne doit pas devenir positif)241                details: dict = {}242                incl: dict = {}243                incl_key = _strip_accents_lower(det.get("inclusions_txt") or "")244                excl_key = _strip_accents_lower(det.get("exclusions_txt") or "")245                for rx, cle in _INCL_KEYS:246                    if rx.search(incl_key):247                        incl[cle] = True248                for rx, cle in _INCL_KEYS:249                    if rx.search(excl_key):250                        incl[cle] = False251                if incl:252                    details["inclusions"] = incl253                if det.get("floor"):254                    details["floor"] = det["floor"]255256                images = det.get("images") or []257                if not images and it.get("photo_url"):258                    images = [it["photo_url"]]259260                title = address or f"{unit_type or 'Logement'}{sector or city}"261                listings.append(Listing(262                    source=self.source_id,263                    external_id=ref,264                    url=f"{SITE}/propriete/{ref.lower()}/",265                    title=title,266                    address=address,267                    sector=sector,268                    city=city,269                    unit_type=unit_type,270                    price=price,271                    price_label=price_label,272                    availability="Disponible",273                    description=description,274                    amenities=[a for a in amenities if a][:15],275                    details=details,276                    images=list(dict.fromkeys(images))[:25],277                    lat=loc.get("latitude"),278                    lng=loc.get("longitude"),279                ))280            except Exception:281                continue282283        return listings284