Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/apero.py : connecteur Apéro Appartement (aperoappartement.com)5# Trois immeubles locatifs neufs : Apéro Teasdale (900 boul. Lucille-6# Teasdale, Terrebonne), Apéro Charlemagne (115 boul. Céline-Dion,7# Charlemagne) et Apéro LaSalle (Montréal, arrondissement LaSalle).8# Site Webflow ; la gestion est sur Building Stack (app.buildingstack.com,9# portail résident sans annonces publiques). L'inventaire vient de :10# - le CMS Webflow exposé via le proxy de l'agence Momentumm11# (tools.momentumm.ca/webflow_cms_new.php?collection_id=<id>) : une12# entrée par unité (numero stable « ter101 » / « char1228 », étage,13# typologie, superficie, chambres, sdb, drapeau disponibilite) — la14# collection et le code d'immeuble (« ter » / « char ») sont lus dans15# les blocs cachés #units-collection-id / #building-code des pages ;16# - les pages /terrebonne /charlemagne /lasalle : prix plancher par17# typologie (« À partir de 1250$ ») et photos — le CMS n'a pas de prix18# par unité (champs prix/braquette vides pour les unités disponibles).19# Granularité : unité pour Terrebonne/Charlemagne ; typologie pour LaSalle20# (aucune collection d'unités sur cette page). Location résidentielle21# longue durée (bail standard), pas de meublé court terme.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import json26import re2728from bs4 import BeautifulSoup2930from ..schema import Listing31from .base import BaseConnector3233SITE = "https://www.aperoappartement.com"34CMS = ("https://tools.momentumm.ca/webflow_cms_new.php"35 "?api_key=j3$jre$3jd$5k76%&webflow_api_key=1&collection_id={cid}")3637# pages d'immeuble : (chemin, ville, secteur, adresse civique — vérifiée sur38# la page : JSON-LD (Terrebonne) et texte de présentation (Charlemagne))39BUILDINGS = {40 "terrebonne": ("Terrebonne", "Lachenaie",41 "900 Boulevard Lucille-Teasdale, Terrebonne"),42 "charlemagne": ("Charlemagne", "",43 "115 Boulevard Céline-Dion, Charlemagne"),44 "lasalle": ("Montréal", "LaSalle", ""),45}4647_WS_RE = re.compile(r"\s+")48_PRICE_CARD_RE = re.compile(49 r"(Studio|\d\s*1/2)\s*À partir de\s*(\d[\d\s]*)\$", re.I)50_IMG_RE = re.compile(51 r'https://cdn\.prod\.website-files\.com/[^"\s]+\.(?:jpe?g|webp|png)',52 re.I)53_SKIP_IMG_RE = re.compile(r"logo|icon|favicon|arrow|plan|svg|p-500|p-800", re.I)545556def _typologie(raw: str) -> str:57 """'4 1/2' -> '4½', 'Studio' -> 'Studio'."""58 s = (raw or "").strip()59 if not s:60 return ""61 if "studio" in s.lower():62 return "Studio"63 m = re.match(r"(\d)\s*1/2", s)64 return f"{m.group(1)}½" if m else s656667class AperoConnector(BaseConnector):68 source_id = "apero"69 request_delay = 0.670 max_images = 107172 # -- page d'immeuble : prix plancher par typologie + photos + config CMS ----73 def _page(self, path: str) -> dict:74 html = self.get(f"{SITE}/{path}").text75 soup = BeautifulSoup(html, "html.parser")76 prices: dict[str, str] = {} # '3½' -> 'À partir de 1540$'77 for wrap in soup.select(".unit-nfo_txt-wrapper"):78 txt = _WS_RE.sub(" ", wrap.get_text(" ", strip=True))79 m = _PRICE_CARD_RE.search(txt)80 if m:81 amount = m.group(2).replace(" ", "").replace(" ", "")82 prices[_typologie(m.group(1))] = f"À partir de {amount}$"83 cid = soup.select_one("#units-collection-id")84 code = soup.select_one("#building-code")85 imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))86 if not _SKIP_IMG_RE.search(u)]87 return {88 "prices": prices,89 "collection_id": cid.get_text(strip=True) if cid else "",90 "building_code": (code.get_text(strip=True).lower()91 if code else ""),92 "images": imgs[: self.max_images],93 }9495 # -- collection CMS Webflow (toutes les unités, tous immeubles) -------------96 def _cms_units(self, cid: str) -> list[dict]:97 data = self.get(CMS.format(cid=cid)).json()98 return [x.get("fieldData") or {} for x in data99 if isinstance(x, dict)]100101 def fetch(self) -> list[Listing]:102 pages = {}103 for path in BUILDINGS:104 try:105 pages[path] = self._page(path)106 except Exception:107 pages[path] = {}108109 # collection d'unités (une seule collection pour tous les immeubles)110 cids = [p.get("collection_id") for p in pages.values()111 if p.get("collection_id")]112 units: list[dict] = []113 if cids:114 try:115 units = self._cms_units(cids[0])116 except Exception:117 units = []118119 listings: list[Listing] = []120 for path, (city, sector, address) in BUILDINGS.items():121 page = pages.get(path) or {}122 code = page.get("building_code")123 url = f"{SITE}/{path}"124 prices = page.get("prices") or {}125 images = page.get("images") or []126 if code: # granularité unité (ter / char)127 for f in units:128 numero = str(f.get("numero") or "").strip()129 if not numero.lower().startswith(code):130 continue131 if not f.get("disponibilite"):132 continue133 listings.append(self._unit_listing(134 f, numero, url, city, sector, address,135 prices, images))136 else: # LaSalle : typologie seulement137 for utype, label in prices.items():138 slug = (utype.replace("½", "-et-demi")139 .lower().replace(" ", ""))140 listings.append(Listing(141 source=self.source_id,142 external_id=f"{path}-{slug}",143 url=url,144 title=f"Apéro {path.capitalize()} — {utype}",145 address=address,146 sector=sector,147 city=city,148 unit_type=utype,149 price_label=label,150 availability="Disponible",151 description=(f"Appartement {utype} dans l'immeuble "152 f"Apéro {path.capitalize()} — {label}."),153 images=list(images),154 ))155156 # dédup par external_id (sécurité)157 uniq: dict[str, Listing] = {}158 for lst in listings:159 uniq.setdefault(lst.external_id, lst)160 return list(uniq.values())161162 # -- une annonce par unité disponible (Terrebonne / Charlemagne) -----------163 def _unit_listing(self, f: dict, numero: str, url: str, city: str,164 sector: str, address: str, prices: dict[str, str],165 images: list[str]) -> Listing:166 utype = _typologie(str(f.get("typeoutput") or ""))167 num_out = str(f.get("numerooutput") or numero).strip()168169 def _num(v) -> float | None:170 try:171 return float(str(v).strip().replace(",", "."))172 except (TypeError, ValueError):173 return None174175 beds = _num(f.get("chambres"))176 baths = _num(f.get("salledebains"))177 area = _num(f.get("superficie-2"))178 if area is not None and not 80 <= area <= 20000:179 area = None180181 # prix : champ CMS de l'unité si présent, sinon plancher de typologie182 price = _num(f.get("prix"))183 price_label = prices.get(utype, "")184 if price and not 100 <= price <= 20000:185 price = None186187 details: dict = {}188 etage = str(f.get("etage") or "").strip()189 if etage.isdigit():190 details["floor"] = int(etage)191 if beds is not None:192 details["bedrooms"] = int(beds)193 if baths is not None:194 details["bathrooms"] = int(baths)195196 desc = " — ".join(x for x in [197 f"Modèle {f['modele']}" if f.get("modele") else "",198 f"{area:g} pi²" if area else "",199 f"étage {etage}" if etage else "",200 f"{int(beds)} chambre(s)" if beds else "",201 f"{int(baths)} salle(s) de bain" if baths else ""] if x)202203 imgs = list(images)204 plan = str(f.get("planurl") or "")205 if plan.startswith("http"):206 imgs.insert(0, plan)207208 bname = url.rstrip("/").rsplit("/", 1)[-1].capitalize()209 return Listing(210 source=self.source_id,211 external_id=numero, # slug CMS stable (ter101, char1228)212 url=url,213 title=f"Apéro {bname} — Unité {num_out}",214 address=address,215 sector=sector,216 city=city,217 unit_type=utype,218 bedrooms=beds,219 bathrooms=baths,220 price=price,221 price_label=price_label,222 availability="Disponible",223 area_sqft=area,224 description=desc[:600],225 images=imgs[: self.max_images + 1],226 )227