SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.2 KB · 174 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/terra.py : connecteur TERRA Condos locatifs (terracondolocatif.ca)5#   Projet de 4 phases sur la route Mgr-Bourget (arrondissement Desjardins,6#   Lévis). Site Webflow : chaque page de phase liste les unités par immeuble7#   avec statut Disponible/Réservée/Louée (classes w-condition-invisible).8#   Prix « à partir de » par type affichés en entête (3½+/4½+).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price17from .base import BaseConnector1819BASE = "https://www.terracondolocatif.ca"20PHASES = {21    "phase-1-2": "Phases 1-2",22    "phase-3": "Phase 3",23    "phase-4": "Phase 4",24}25SECTOR = "Desjardins (Lévis)"2627# images générales du projet (photo + plan d'ensemble)28_GALLERY_RE = re.compile(29    r'https://cdn\.prod\.website-files\.com/[^"\s,]+\.(?:jpg|jpeg|webp)', re.I)303132class TerraConnector(BaseConnector):33    source_id = "terra"34    request_delay = 0.63536    def fetch(self) -> list[Listing]:37        listings: dict[str, Listing] = {}3839        for slug, phase_name in PHASES.items():40            try:41                html = self.get(f"{BASE}/{slug}").text42            except Exception:43                continue44            soup = BeautifulSoup(html, "html.parser")4546            # contact du projet (liens tel:/mailto: de la page)47            contact: dict = {}48            tel = soup.select_one("a[href^='tel:']")49            if tel:50                digits = re.sub(r"\D", "", tel.get("href", ""))[-10:]51                if len(digits) == 10:52                    contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"53            mail = soup.select_one("a[href^='mailto:']")54            if mail:55                m = re.match(r"mailto:([^?]+)", mail.get("href", ""))56                if m:57                    contact["email"] = m.group(1).strip()5859            # Prix « à partir de » par type (ex. "3 1/2 + à partir de 1185 $/mois")60            type_prices: dict[str, tuple[float | None, str]] = {}61            for m in re.finditer(r"(\d)\s*1/2\s*\+?\s*à partir de\s*"62                                 r"([\d\s  ]+)\$\s*/\s*mois",63                                 soup.get_text(" ", strip=True)):64                label = (f"{m.group(1)}½ à partir de "65                         f"{m.group(2).strip()} $/mois")66                type_prices[m.group(1)] = (parse_price(f"{m.group(2)}$"), label)6768            # Galerie générale (photo du projet)69            gallery = [u for u in dict.fromkeys(_GALLERY_RE.findall(html))70                       if not re.search(r"logo|favicon|icon", u, re.I)][:3]7172            # Onglets = immeubles ("Le 939", "Le 943", ...)73            for pane in soup.select("div.w-tab-pane"):74                building = (pane.get("data-w-tab") or "").strip()75                for card in pane.select("div.listeunitecms"):76                    try:77                        lst = self._parse_card(card, building, slug,78                                               phase_name, type_prices,79                                               gallery, contact)80                        if lst and lst.external_id not in listings:81                            listings[lst.external_id] = lst82                    except Exception:83                        continue8485        return list(listings.values())8687    def _parse_card(self, card, building, slug, phase_name,88                    type_prices, gallery, contact) -> Listing | None:89        # Statut : disponible si .tagdispo n'a PAS la classe w-condition-invisible90        tag = card.select_one(".tagdispo")91        if tag is None or "w-condition-invisible" in tag.get("class", []):92            return None9394        text = card.get_text(" ", strip=True)95        num_m = re.search(r"\(\s*(\d+)\s*\)", text)96        if not num_m:97            return None98        unit_no = num_m.group(1)99100        type_m = re.search(r"(\d\s*1/2\s*\+?)", text)101        raw_type = type_m.group(1) if type_m else ""102        unit_type = normalize_unit_type(raw_type)103104        floor_m = re.search(r"(RDC|Étage\s*\d+)", text)105        sqft_m = re.search(r"(\d{3,4})\s*pi", text)106        beds_m = re.search(r"(\d+)\s*chambres?", text)107        expo_m = re.search(r"Exposition\s+([\w.]+)", text)108        model_m = re.search(r"Type\s+([\w.]+)", text)109110        # Fiche PDF de l'unité (le lien visible), ex. .../Fiche_939-101.pdf,111        # .../FIche_943-402.pdf ou .../68f5..._951-106.pdf112        fiche = ""113        for a in card.select("a.buttonplans[href]"):114            href = a.get("href", "")115            if href.startswith("http") and href.lower().endswith(".pdf"):116                fiche = href117                break118        fm = re.search(r"(\d{3})-(\d{3})\.pdf$", fiche, re.I)119        if fm:120            building = fm.group(1)121            unit_no = fm.group(2)122        elif not building.isdigit():123            # sans fiche ni onglet d'immeuble (onglet d'étage) : doublon124            return None125126        digit = re.search(r"(\d)", raw_type)127        price, price_label = (None, "")128        if digit and digit.group(1) in type_prices:129            price, price_label = type_prices[digit.group(1)]130131        desc_parts = []132        if floor_m:133            desc_parts.append(floor_m.group(1))134        if sqft_m:135            desc_parts.append(f"{sqft_m.group(1)} pi²")136        if beds_m:137            desc_parts.append(f"{beds_m.group(1)} chambre(s)")138        if expo_m:139            desc_parts.append(f"Exposition {expo_m.group(1)}")140        if model_m:141            desc_parts.append(f"Modèle {model_m.group(1)}")142        if fiche:143            desc_parts.append(f"Fiche : {fiche}")144145        ext_id = f"{building or slug}-{unit_no}"146        address = (f"{building}, route Mgr-Bourget, Lévis"147                   if building.isdigit() else "route Mgr-Bourget, Lévis")148149        return Listing(150            source=self.source_id,151            external_id=ext_id,152            url=f"{BASE}/{slug}",153            title=f"TERRA {phase_name} — Le {building}, unité {unit_no}"154                  f" ({unit_type})",155            address=address,156            sector=SECTOR,157            city=infer_city(SECTOR, default="Lévis"),158            unit_type=unit_type,159            price=price,160            price_label=price_label,161            availability="Disponible",162            description=" | ".join(desc_parts),163            # « Formule tout inclus » affichée sur le site (accueil + pages164            # de phase) : chauffé et climatisé, eau chaude, internet,165            # stationnement intérieur, ascenseur ; « plafonds de 9 pieds,166            # balcons de 134 à 254 pi², salle commune » (pied des pages phase).167            amenities=["Chauffé et climatisé", "Eau chaude incluse",168                       "Internet inclus", "Stationnement intérieur",169                       "Ascenseur", "Balcon", "Salle commune",170                       "Plafonds de 9 pieds"],171            details={"contact": contact} if contact else {},172            images=list(gallery),173        )174