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/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" ?", " ", 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