SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
9.3 KB · 227 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/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