# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/horizon_terrebonne.py : connecteur Horizon Terrebonne # (horizonterrebonne.com — Groupe Socam, condos locatifs neufs en 2 tours # au 1441 chemin Gascon à Terrebonne). WordPress rendu SERVEUR : la page # /condos/ liste des cartes
(statut, tour, # chambres, prix mensuel, photo, lien) ; chaque fiche « Phase n - x.5 à # partir de » ajoute salle(s) de bain, étage, superficie et galerie. # Granularité MODÈLE « à partir de » par phase/typologie (pas d'unités # individuelles publiées) ; external_id = id du post WordPress (stable, # classe post-NNNN). Seules les cartes « Disponible » sont retenues. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://horizonterrebonne.com" LIST_URL = f"{BASE}/condos/" ADDRESS = "1441, chemin Gascon, Terrebonne" LAT, LNG = 45.712055, -73.65237 POST_ID_RE = re.compile(r"\bpost-(\d+)\b") PRICE_RE = re.compile(r"([\d\s  ]+)\s*\$") IMG_SIZE_RE = re.compile(r"-\d+x\d+(?=\.(?:jpe?g|png|webp))", re.I) # « Phase 2 – 4.5 à partir de » -> typologie 4½ TYPE_RE = re.compile(r"(\d)[.,]5") def _price(text: str) -> float | None: m = PRICE_RE.search(text or "") if not m: return None try: val = float(re.sub(r"[\s  ]", "", m.group(1))) except ValueError: return None return val if 300 <= val <= 20000 else None class HorizonTerrebonneConnector(BaseConnector): source_id = "horizon_terrebonne" request_delay = 0.8 def fetch(self) -> list[Listing]: try: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") except Exception: return [] listings: list[Listing] = [] for card in soup.select("article.apartment"): status = card.select_one(".apartment__status-value") status = status.get_text(strip=True) if status else "" if "disponible" not in status.lower() or "non" in status.lower(): continue link = card.select_one("a[href]") if not link: continue url = link["href"] m = POST_ID_RE.search(" ".join(card.get("class") or [])) ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1] tour = card.select_one(".apartment__location-tour") tour = tour.get_text(strip=True) if tour else "" rooms = card.select_one(".apartment__room-value") bedrooms = None if rooms: try: bedrooms = float(rooms.get_text(strip=True)) except ValueError: pass price_el = card.select_one(".apartment__monthly-price") price = _price(price_el.get_text(" ", strip=True)) if price_el else None # fiche détail (cache BD) : titre, sdb, étage, superficie, galerie detail = self.detail(ext_id, f"{status}|{price}|{tour}", lambda u=url: self._detail(u)) title = detail.get("title") or "" unit_type = "" m = TYPE_RE.search(title) if m: unit_type = normalize_unit_type(f"{m.group(1)} 1/2") images = detail.get("images") or [] img = card.select_one(".apartment__image img[src]") if img: src = IMG_SIZE_RE.sub("", img["src"]) if src not in images: images = [src] + images details: dict = {} if tour: details["tour"] = tour for k in ("bathrooms", "floor", "orientation"): if detail.get(k): details[k] = detail[k] desc_bits = [b for b in ( f"{title} — {tour}" if tour else title, f"Superficie : {detail['area']} pi²" if detail.get("area") else "", ) if b] listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=title or f"Condo {unit_type} — Horizon Terrebonne", address=ADDRESS, city="Terrebonne", unit_type=unit_type, bedrooms=bedrooms, price=price, price_label=f"à partir de {price:.0f} $ /mois" if price else "", availability=status, area_sqft=detail.get("area"), description=" | ".join(desc_bits)[:600], amenities=detail.get("amenities") or [], details=details, images=images[:25], lat=LAT, lng=LNG, )) return listings # -- page détail « Phase n - x.5 à partir de » ------------------------------ def _detail(self, url: str) -> dict: try: soup = BeautifulSoup(self.get(url).text, "html.parser") except Exception: return {} out: dict = {} h1 = soup.select_one("h1") or soup.select_one("title") if h1: out["title"] = re.sub(r"\s*\|.*$", "", h1.get_text(" ", strip=True)).strip() text = soup.get_text(" ", strip=True) m = re.search(r"Salle\(s\) de bain\s*(\d+(?:[.,]\d)?)", text) if m: out["bathrooms"] = float(m.group(1).replace(",", ".")) m = re.search(r"Étage\(s\)\s*([\w\d]+)", text) if m and m.group(1) != "—": out["floor"] = m.group(1) m = re.search(r"Superficie\s*([\d\s  ]+)\s*pi", text) if m: try: out["area"] = float(re.sub(r"[\s  ]", "", m.group(1))) except ValueError: pass # avantages de l'immeuble (liste courte avant « Détails du projet ») amen: list[str] = [] for li in soup.select("li"): t = li.get_text(" ", strip=True) if 3 < len(t) < 60 and re.search( r"piscine|potager|lavage|parc|ascenseur|lounge|" r"entra[iî]nement|terrasse", t, re.I) and t not in amen: amen.append(t) if amen: out["amenities"] = amen[:12] imgs: list[str] = [] for img in soup.select("img[src]"): src = img["src"] if "/app/uploads/" not in src or re.search(r"logo|icon", src, re.I): continue src = IMG_SIZE_RE.sub("", src) if src.endswith("-scaled.jpg"): pass if src not in imgs: imgs.append(src) out["images"] = imgs[:25] return out