SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.6 KB · 268 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/brivia_1sp.py : connecteur 1 Square Phillips (Groupe Brivia)5#   (1squarephillips.ca/locatif — tour locative au centre-ville de Montréal,6#    1205 rue du Square-Phillips, Ville-Marie). La page /locatif présente les7#    trois typologies offertes (Studio / 1 Chambre / 2 Chambres) avec loyer8#    "à partir de" -> une annonce par typologie (uid stables). L'inventaire9#    unité par unité est exposé par l'API AJAX des plans (2022/php/10#    ajax_load_unit_selector/_floor/_unit.php, phase=rental) : on y lit les11#    unités réellement disponibles (« onsale »), leur étage, leur superficie12#    (pi²) et le plan — utilisés pour enrichir chaque typologie.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re17import time1819from bs4 import BeautifulSoup2021from ..schema import Listing, parse_price22from .base import BaseConnector2324BASE = "https://www.1squarephillips.ca"25LOCATIF_URL = f"{BASE}/locatif"26GALERIE_URL = f"{BASE}/galerie"27PLANS_AJAX = f"{BASE}/2022/php/"2829# Adresse du pied de page (avec code postal)30ADDRESS = "1205, rue du Square-Phillips, Montréal, QC H3B 3C9"3132_TYPE_MAP = {"studio": "Studio", "1 chambre": "3½", "2 chambres": "4½",33             "3 chambres": "5½"}34# Typologie -> data-type de l'API des plans (phase locative)35_PLAN_TYPE = {"studio": 11, "1 chambre": 12, "2 chambres": 13}3637DESCRIPTION = ("Condos locatifs de luxe au centre-ville de Montréal, formule "38               "tout inclus : électroménagers, climatisation, chauffage, "39               "électricité, eau chaude et Wi-Fi.")4041# Repli si la section « Caractéristiques » du site devenait illisible42AMENITIES = ["Tout inclus (électricité, chauffage, climatisation, eau chaude, "43             "Wi-Fi)", "Électroménagers inclus", "Piscine, sauna et bain "44             "vapeur", "Salles d'entraînement", "Espace de cotravail",45             "Salle de cinéma", "Terrasse", "Gardien 24 h", "Lounge du 21e "46             "étage", "Stationnement souterrain"]4748_ONSALE_RE = re.compile(r'id="unit(\d+)" class="unit onsale"')49_FLOOR_RE = re.compile(r'data-floor="(\d+)"')50_AREA_RE = re.compile(r"Superficie\s*</span><span>([\d\s  ,]+)\s*pi", re.I)51_BALCONY_RE = re.compile(r"Balcon\s*</span><span>([\d\s  ,]+)\s*pi", re.I)525354def _unit_type(label: str) -> str:55    key = re.sub(r"\s+", " ", (label or "").strip().lower())56    return _TYPE_MAP.get(key, label.strip())575859def _num(txt: str) -> float | None:60    try:61        return float(re.sub(r"[\s  ,]", "", txt))62    except (TypeError, ValueError):63        return None646566class Brivia1SPConnector(BaseConnector):67    source_id = "brivia_1sp"68    request_delay = 0.669    max_unit_details = 150   # plafond de fiches unité par sync7071    # -- helpers ---------------------------------------------------------------72    def _post_json(self, path: str, data: dict) -> dict:73        """POST throttlé vers l'API AJAX des plans (réponses JSON)."""74        wait = self.request_delay - (time.time() - self._last_request)75        if wait > 0:76            time.sleep(wait)77        resp = self.session.post(PLANS_AJAX + path, data=data,78                                 timeout=self.timeout)79        self._last_request = time.time()80        resp.raise_for_status()81        return resp.json()8283    def _fetch_unit(self, unit: str) -> dict:84        """Fiche d'une unité (type, étage, superficie, plan) via l'API."""85        d = self._post_json("ajax_load_plans_unit.php",86                            {"lang": "fr", "phase": "rental", "unit": unit})87        html = d.get("unit_details") or ""88        out: dict = {"unit": unit, "type": int(d.get("type") or 0),89                     "floor": int(d.get("floor") or 0)}90        m = _AREA_RE.search(html)91        if m:92            out["area_sqft"] = _num(m.group(1))93        m = _BALCONY_RE.search(html)94        if m:95            out["balcony_sqft"] = _num(m.group(1))96        # « 1 chambre / 1 salle de bain »97        m = re.search(r'<p class="uppercase">([^<]+)</p>', html)98        if m:99            out["rooms"] = m.group(1).strip()100        m = re.search(r"<h3>\s*Plan\s*([^<]+)</h3>", html)101        if m:102            out["plan"] = m.group(1).strip()103        m = re.search(r'<img src="([^"]+)"', d.get("unit_plan") or "")104        if m:105            out["plan_img"] = m.group(1)106        return out107108    def _rental_units(self) -> dict[int, list[dict]]:109        """Unités disponibles (« onsale ») par type (11/12/13), via l'API."""110        sel = self._post_json("ajax_load_unit_selector.php",111                              {"lang": "fr", "phase": "rental"})112        floors = sorted({int(f) for f in113                         _FLOOR_RE.findall(sel.get("unit_selector") or "")})114        onsale: list[str] = []115        for f in floors[:40]:116            d = self._post_json("ajax_load_plans_floor.php",117                                {"lang": "fr", "phase": "rental",118                                 "type": 11, "floor": f})119            onsale.extend(_ONSALE_RE.findall(d.get("floor") or ""))120        by_type: dict[int, list[dict]] = {}121        for u in onsale[: self.max_unit_details]:122            # la fiche d'une unité (plan) est immuable -> clé de cache fixe123            info = self.detail(f"unit-{u}", "plan-v1",124                               lambda u=u: self._fetch_unit(u))125            if info.get("unit"):126                by_type.setdefault(int(info.get("type") or 0), []).append(info)127        return by_type128129    # -- fetch -----------------------------------------------------------------130    def fetch(self) -> list[Listing]:131        listings: list[Listing] = []132        try:133            html = self.get(LOCATIF_URL).text134        except Exception:135            return listings136        soup = BeautifulSoup(html, "html.parser")137138        # Photos : perspectives de la page locatif + galerie du site139        images = self._collect_images(html)140        try:141            images += self._collect_images(self.get(GALERIE_URL).text)142        except Exception:143            pass144        images = list(dict.fromkeys(images))[:30]145146        # Caractéristiques réelles de l'immeuble (section .features)147        amenities = self._collect_features(soup) or list(AMENITIES)148149        # Contact structuré (liens tel:/mailto: du pied de page)150        contact: dict = {}151        tel = soup.select_one('a[href^="tel:"]')152        if tel:153            digits = re.sub(r"\D", "", tel["href"])[-10:]154            if len(digits) == 10:155                contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"156        mail = soup.select_one('a[href^="mailto:"]')157        if mail:158            contact["email"] = mail["href"].removeprefix("mailto:").strip()159160        # Inventaire unité par unité (API AJAX des plans, phase locative)161        inventory: dict[int, list[dict]] = {}162        try:163            inventory = self._rental_units()164        except Exception:165            pass166167        # Typologies (ul.grid3cols : h4 = type, p = "à partir de X $/mois")168        for li in soup.select("ul.grid3cols li"):169            try:170                h4 = li.select_one("h4")171                p = li.select_one("p")172                if not h4 or not p:173                    continue174                typology = h4.get_text(" ", strip=True).replace("\xa0", " ")175                price_label = re.sub(r"\s+", " ",176                                     p.get_text(" ", strip=True))177                if "$" not in price_label:178                    continue179                type_slug = re.sub(r"[^a-z0-9]+", "-",180                                   typology.lower()).strip("-")181182                # Enrichissement avec les unités disponibles de la typologie183                tkey = re.sub(r"\s+", " ", typology.strip().lower())184                units = sorted(inventory.get(_PLAN_TYPE.get(tkey, -1), []),185                               key=lambda u: u["unit"])186                availability = "Disponible (tour locative en location)"187                description = DESCRIPTION188                area = None189                unit_images: list[str] = []190                if units:191                    n = len(units)192                    availability = (f"{n} unité{'s' if n > 1 else ''} "193                                    f"disponible{'s' if n > 1 else ''}")194                    areas = [u["area_sqft"] for u in units195                             if u.get("area_sqft")]196                    area = min(areas) if areas else None197                    # nota : pas de mention « étage N » ici, sinon la198                    # normalisation centrale déduirait un faux details.floor199                    dispo = ", ".join(200                        f"unité {u['unit']}"201                        + (f" ({u['area_sqft']:.0f} pi²"202                           + (f" + balcon {u['balcony_sqft']:.0f} pi²"203                              if u.get("balcony_sqft") else "") + ")"204                           if u.get("area_sqft") else "")205                        for u in units)206                    description = f"{DESCRIPTION} Unités disponibles : {dispo}."207                    unit_images = [u["plan_img"] for u in units208                                   if u.get("plan_img")]209210                details: dict = {}211                if contact:212                    details["contact"] = dict(contact)213214                listings.append(Listing(215                    source=self.source_id,216                    external_id=f"1sp-{type_slug}",217                    url=LOCATIF_URL,218                    title=f"1 Square Phillips — {typology} locatif",219                    address=ADDRESS,220                    sector="Centre-ville (Ville-Marie)",221                    city="Montréal",222                    unit_type=_unit_type(typology),223                    price=parse_price(price_label),224                    price_label=price_label,225                    availability=availability,226                    area_sqft=area,227                    description=description,228                    amenities=amenities,229                    details=details,230                    images=list(dict.fromkeys(unit_images + images))[:40],231                ))232            except Exception:233                continue234        return listings235236    @staticmethod237    def _collect_features(soup: BeautifulSoup) -> list[str]:238        """Caractéristiques de l'immeuble (section .features, div.back)."""239        out: list[str] = []240        for el in soup.select("section.features li div.back"):241            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))242            if t and t not in out:243                out.append(t)244        if out:245            # inclusions énoncées dans l'intro de la page locatif246            out.insert(0, "Tout inclus (électricité, chauffage, climatisation, "247                          "eau chaude, Wi-Fi)")248            out.insert(1, "Électroménagers inclus")249        return out[:40]250251    @staticmethod252    def _collect_images(html: str) -> list[str]:253        """Images pleine taille du site (perspectives + galerie)."""254        urls = re.findall(255            r'(?:https://www\.1squarephillips\.ca)?/?2022/images/'256            r'[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)', html)257        out = []258        for u in urls:259            if not u.startswith("http"):260                u = f"{BASE}/{u.lstrip('/')}"261            # exclure variantes portrait (doublons) et visuels non pertinents262            if re.search(r"-portrait\.|ico-|logo|favicon|bckg-contact|"263                         r"bckg-project-(1|4)\b", u, re.I):264                continue265            if re.search(r"gallery|rental|persp|condo", u, re.I):266                out.append(u)267        return list(dict.fromkeys(out))268