# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/apero.py : connecteur Apéro Appartement (aperoappartement.com) # Trois immeubles locatifs neufs : Apéro Teasdale (900 boul. Lucille- # Teasdale, Terrebonne), Apéro Charlemagne (115 boul. Céline-Dion, # Charlemagne) et Apéro LaSalle (Montréal, arrondissement LaSalle). # Site Webflow ; la gestion est sur Building Stack (app.buildingstack.com, # portail résident sans annonces publiques). L'inventaire vient de : # - le CMS Webflow exposé via le proxy de l'agence Momentumm # (tools.momentumm.ca/webflow_cms_new.php?collection_id=) : une # entrée par unité (numero stable « ter101 » / « char1228 », étage, # typologie, superficie, chambres, sdb, drapeau disponibilite) — la # collection et le code d'immeuble (« ter » / « char ») sont lus dans # les blocs cachés #units-collection-id / #building-code des pages ; # - les pages /terrebonne /charlemagne /lasalle : prix plancher par # typologie (« À partir de 1250$ ») et photos — le CMS n'a pas de prix # par unité (champs prix/braquette vides pour les unités disponibles). # Granularité : unité pour Terrebonne/Charlemagne ; typologie pour LaSalle # (aucune collection d'unités sur cette page). Location résidentielle # longue durée (bail standard), pas de meublé court terme. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector SITE = "https://www.aperoappartement.com" CMS = ("https://tools.momentumm.ca/webflow_cms_new.php" "?api_key=j3$jre$3jd$5k76%&webflow_api_key=1&collection_id={cid}") # pages d'immeuble : (chemin, ville, secteur, adresse civique — vérifiée sur # la page : JSON-LD (Terrebonne) et texte de présentation (Charlemagne)) BUILDINGS = { "terrebonne": ("Terrebonne", "Lachenaie", "900 Boulevard Lucille-Teasdale, Terrebonne"), "charlemagne": ("Charlemagne", "", "115 Boulevard Céline-Dion, Charlemagne"), "lasalle": ("Montréal", "LaSalle", ""), } _WS_RE = re.compile(r"\s+") _PRICE_CARD_RE = re.compile( r"(Studio|\d\s*1/2)\s*À partir de\s*(\d[\d\s]*)\$", re.I) _IMG_RE = re.compile( r'https://cdn\.prod\.website-files\.com/[^"\s]+\.(?:jpe?g|webp|png)', re.I) _SKIP_IMG_RE = re.compile(r"logo|icon|favicon|arrow|plan|svg|p-500|p-800", re.I) def _typologie(raw: str) -> str: """'4 1/2' -> '4½', 'Studio' -> 'Studio'.""" s = (raw or "").strip() if not s: return "" if "studio" in s.lower(): return "Studio" m = re.match(r"(\d)\s*1/2", s) return f"{m.group(1)}½" if m else s class AperoConnector(BaseConnector): source_id = "apero" request_delay = 0.6 max_images = 10 # -- page d'immeuble : prix plancher par typologie + photos + config CMS ---- def _page(self, path: str) -> dict: html = self.get(f"{SITE}/{path}").text soup = BeautifulSoup(html, "html.parser") prices: dict[str, str] = {} # '3½' -> 'À partir de 1540$' for wrap in soup.select(".unit-nfo_txt-wrapper"): txt = _WS_RE.sub(" ", wrap.get_text(" ", strip=True)) m = _PRICE_CARD_RE.search(txt) if m: amount = m.group(2).replace(" ", "").replace(" ", "") prices[_typologie(m.group(1))] = f"À partir de {amount}$" cid = soup.select_one("#units-collection-id") code = soup.select_one("#building-code") imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not _SKIP_IMG_RE.search(u)] return { "prices": prices, "collection_id": cid.get_text(strip=True) if cid else "", "building_code": (code.get_text(strip=True).lower() if code else ""), "images": imgs[: self.max_images], } # -- collection CMS Webflow (toutes les unités, tous immeubles) ------------- def _cms_units(self, cid: str) -> list[dict]: data = self.get(CMS.format(cid=cid)).json() return [x.get("fieldData") or {} for x in data if isinstance(x, dict)] def fetch(self) -> list[Listing]: pages = {} for path in BUILDINGS: try: pages[path] = self._page(path) except Exception: pages[path] = {} # collection d'unités (une seule collection pour tous les immeubles) cids = [p.get("collection_id") for p in pages.values() if p.get("collection_id")] units: list[dict] = [] if cids: try: units = self._cms_units(cids[0]) except Exception: units = [] listings: list[Listing] = [] for path, (city, sector, address) in BUILDINGS.items(): page = pages.get(path) or {} code = page.get("building_code") url = f"{SITE}/{path}" prices = page.get("prices") or {} images = page.get("images") or [] if code: # granularité unité (ter / char) for f in units: numero = str(f.get("numero") or "").strip() if not numero.lower().startswith(code): continue if not f.get("disponibilite"): continue listings.append(self._unit_listing( f, numero, url, city, sector, address, prices, images)) else: # LaSalle : typologie seulement for utype, label in prices.items(): slug = (utype.replace("½", "-et-demi") .lower().replace(" ", "")) listings.append(Listing( source=self.source_id, external_id=f"{path}-{slug}", url=url, title=f"Apéro {path.capitalize()} — {utype}", address=address, sector=sector, city=city, unit_type=utype, price_label=label, availability="Disponible", description=(f"Appartement {utype} dans l'immeuble " f"Apéro {path.capitalize()} — {label}."), images=list(images), )) # dédup par external_id (sécurité) uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values()) # -- une annonce par unité disponible (Terrebonne / Charlemagne) ----------- def _unit_listing(self, f: dict, numero: str, url: str, city: str, sector: str, address: str, prices: dict[str, str], images: list[str]) -> Listing: utype = _typologie(str(f.get("typeoutput") or "")) num_out = str(f.get("numerooutput") or numero).strip() def _num(v) -> float | None: try: return float(str(v).strip().replace(",", ".")) except (TypeError, ValueError): return None beds = _num(f.get("chambres")) baths = _num(f.get("salledebains")) area = _num(f.get("superficie-2")) if area is not None and not 80 <= area <= 20000: area = None # prix : champ CMS de l'unité si présent, sinon plancher de typologie price = _num(f.get("prix")) price_label = prices.get(utype, "") if price and not 100 <= price <= 20000: price = None details: dict = {} etage = str(f.get("etage") or "").strip() if etage.isdigit(): details["floor"] = int(etage) if beds is not None: details["bedrooms"] = int(beds) if baths is not None: details["bathrooms"] = int(baths) desc = " — ".join(x for x in [ f"Modèle {f['modele']}" if f.get("modele") else "", f"{area:g} pi²" if area else "", f"étage {etage}" if etage else "", f"{int(beds)} chambre(s)" if beds else "", f"{int(baths)} salle(s) de bain" if baths else ""] if x) imgs = list(images) plan = str(f.get("planurl") or "") if plan.startswith("http"): imgs.insert(0, plan) bname = url.rstrip("/").rsplit("/", 1)[-1].capitalize() return Listing( source=self.source_id, external_id=numero, # slug CMS stable (ter101, char1228) url=url, title=f"Apéro {bname} — Unité {num_out}", address=address, sector=sector, city=city, unit_type=utype, bedrooms=beds, bathrooms=baths, price=price, price_label=price_label, availability="Disponible", area_sqft=area, description=desc[:600], images=imgs[: self.max_images + 1], )