# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/terra.py : connecteur TERRA Condos locatifs (terracondolocatif.ca) # Projet de 4 phases sur la route Mgr-Bourget (arrondissement Desjardins, # Lévis). Site Webflow : chaque page de phase liste les unités par immeuble # avec statut Disponible/Réservée/Louée (classes w-condition-invisible). # Prix « à partir de » par type affichés en entête (3½+/4½+). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.terracondolocatif.ca" PHASES = { "phase-1-2": "Phases 1-2", "phase-3": "Phase 3", "phase-4": "Phase 4", } SECTOR = "Desjardins (Lévis)" # images générales du projet (photo + plan d'ensemble) _GALLERY_RE = re.compile( r'https://cdn\.prod\.website-files\.com/[^"\s,]+\.(?:jpg|jpeg|webp)', re.I) class TerraConnector(BaseConnector): source_id = "terra" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for slug, phase_name in PHASES.items(): try: html = self.get(f"{BASE}/{slug}").text except Exception: continue soup = BeautifulSoup(html, "html.parser") # contact du projet (liens tel:/mailto: de la page) contact: dict = {} tel = soup.select_one("a[href^='tel:']") if tel: digits = re.sub(r"\D", "", tel.get("href", ""))[-10:] if len(digits) == 10: contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" mail = soup.select_one("a[href^='mailto:']") if mail: m = re.match(r"mailto:([^?]+)", mail.get("href", "")) if m: contact["email"] = m.group(1).strip() # Prix « à partir de » par type (ex. "3 1/2 + à partir de 1185 $/mois") type_prices: dict[str, tuple[float | None, str]] = {} for m in re.finditer(r"(\d)\s*1/2\s*\+?\s*à partir de\s*" r"([\d\s ]+)\$\s*/\s*mois", soup.get_text(" ", strip=True)): label = (f"{m.group(1)}½ à partir de " f"{m.group(2).strip()} $/mois") type_prices[m.group(1)] = (parse_price(f"{m.group(2)}$"), label) # Galerie générale (photo du projet) gallery = [u for u in dict.fromkeys(_GALLERY_RE.findall(html)) if not re.search(r"logo|favicon|icon", u, re.I)][:3] # Onglets = immeubles ("Le 939", "Le 943", ...) for pane in soup.select("div.w-tab-pane"): building = (pane.get("data-w-tab") or "").strip() for card in pane.select("div.listeunitecms"): try: lst = self._parse_card(card, building, slug, phase_name, type_prices, gallery, contact) if lst and lst.external_id not in listings: listings[lst.external_id] = lst except Exception: continue return list(listings.values()) def _parse_card(self, card, building, slug, phase_name, type_prices, gallery, contact) -> Listing | None: # Statut : disponible si .tagdispo n'a PAS la classe w-condition-invisible tag = card.select_one(".tagdispo") if tag is None or "w-condition-invisible" in tag.get("class", []): return None text = card.get_text(" ", strip=True) num_m = re.search(r"\(\s*(\d+)\s*\)", text) if not num_m: return None unit_no = num_m.group(1) type_m = re.search(r"(\d\s*1/2\s*\+?)", text) raw_type = type_m.group(1) if type_m else "" unit_type = normalize_unit_type(raw_type) floor_m = re.search(r"(RDC|Étage\s*\d+)", text) sqft_m = re.search(r"(\d{3,4})\s*pi", text) beds_m = re.search(r"(\d+)\s*chambres?", text) expo_m = re.search(r"Exposition\s+([\w.]+)", text) model_m = re.search(r"Type\s+([\w.]+)", text) # Fiche PDF de l'unité (le lien visible), ex. .../Fiche_939-101.pdf, # .../FIche_943-402.pdf ou .../68f5..._951-106.pdf fiche = "" for a in card.select("a.buttonplans[href]"): href = a.get("href", "") if href.startswith("http") and href.lower().endswith(".pdf"): fiche = href break fm = re.search(r"(\d{3})-(\d{3})\.pdf$", fiche, re.I) if fm: building = fm.group(1) unit_no = fm.group(2) elif not building.isdigit(): # sans fiche ni onglet d'immeuble (onglet d'étage) : doublon return None digit = re.search(r"(\d)", raw_type) price, price_label = (None, "") if digit and digit.group(1) in type_prices: price, price_label = type_prices[digit.group(1)] desc_parts = [] if floor_m: desc_parts.append(floor_m.group(1)) if sqft_m: desc_parts.append(f"{sqft_m.group(1)} pi²") if beds_m: desc_parts.append(f"{beds_m.group(1)} chambre(s)") if expo_m: desc_parts.append(f"Exposition {expo_m.group(1)}") if model_m: desc_parts.append(f"Modèle {model_m.group(1)}") if fiche: desc_parts.append(f"Fiche : {fiche}") ext_id = f"{building or slug}-{unit_no}" address = (f"{building}, route Mgr-Bourget, Lévis" if building.isdigit() else "route Mgr-Bourget, Lévis") return Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/{slug}", title=f"TERRA {phase_name} — Le {building}, unité {unit_no}" f" ({unit_type})", address=address, sector=SECTOR, city=infer_city(SECTOR, default="Lévis"), unit_type=unit_type, price=price, price_label=price_label, availability="Disponible", description=" | ".join(desc_parts), # « Formule tout inclus » affichée sur le site (accueil + pages # de phase) : chauffé et climatisé, eau chaude, internet, # stationnement intérieur, ascenseur ; « plafonds de 9 pieds, # balcons de 134 à 254 pi², salle commune » (pied des pages phase). amenities=["Chauffé et climatisé", "Eau chaude incluse", "Internet inclus", "Stationnement intérieur", "Ascenseur", "Balcon", "Salle commune", "Plafonds de 9 pieds"], details={"contact": contact} if contact else {}, images=list(gallery), )