Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/horizon_terrebonne.py : connecteur Horizon Terrebonne5# (horizonterrebonne.com — Groupe Socam, condos locatifs neufs en 2 tours6# au 1441 chemin Gascon à Terrebonne). WordPress rendu SERVEUR : la page7# /condos/ liste des cartes <article class="apartment"> (statut, tour,8# chambres, prix mensuel, photo, lien) ; chaque fiche « Phase n - x.5 à9# partir de » ajoute salle(s) de bain, étage, superficie et galerie.10# Granularité MODÈLE « à partir de » par phase/typologie (pas d'unités11# individuelles publiées) ; external_id = id du post WordPress (stable,12# classe post-NNNN). Seules les cartes « Disponible » sont retenues.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://horizonterrebonne.com"24LIST_URL = f"{BASE}/condos/"25ADDRESS = "1441, chemin Gascon, Terrebonne"26LAT, LNG = 45.712055, -73.652372728POST_ID_RE = re.compile(r"\bpost-(\d+)\b")29PRICE_RE = re.compile(r"([\d\s ]+)\s*\$")30IMG_SIZE_RE = re.compile(r"-\d+x\d+(?=\.(?:jpe?g|png|webp))", re.I)31# « Phase 2 – 4.5 à partir de » -> typologie 4½32TYPE_RE = re.compile(r"(\d)[.,]5")333435def _price(text: str) -> float | None:36 m = PRICE_RE.search(text or "")37 if not m:38 return None39 try:40 val = float(re.sub(r"[\s ]", "", m.group(1)))41 except ValueError:42 return None43 return val if 300 <= val <= 20000 else None444546class HorizonTerrebonneConnector(BaseConnector):47 source_id = "horizon_terrebonne"48 request_delay = 0.84950 def fetch(self) -> list[Listing]:51 try:52 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")53 except Exception:54 return []55 listings: list[Listing] = []56 for card in soup.select("article.apartment"):57 status = card.select_one(".apartment__status-value")58 status = status.get_text(strip=True) if status else ""59 if "disponible" not in status.lower() or "non" in status.lower():60 continue61 link = card.select_one("a[href]")62 if not link:63 continue64 url = link["href"]65 m = POST_ID_RE.search(" ".join(card.get("class") or []))66 ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]6768 tour = card.select_one(".apartment__location-tour")69 tour = tour.get_text(strip=True) if tour else ""70 rooms = card.select_one(".apartment__room-value")71 bedrooms = None72 if rooms:73 try:74 bedrooms = float(rooms.get_text(strip=True))75 except ValueError:76 pass77 price_el = card.select_one(".apartment__monthly-price")78 price = _price(price_el.get_text(" ", strip=True)) if price_el else None7980 # fiche détail (cache BD) : titre, sdb, étage, superficie, galerie81 detail = self.detail(ext_id, f"{status}|{price}|{tour}",82 lambda u=url: self._detail(u))83 title = detail.get("title") or ""84 unit_type = ""85 m = TYPE_RE.search(title)86 if m:87 unit_type = normalize_unit_type(f"{m.group(1)} 1/2")88 images = detail.get("images") or []89 img = card.select_one(".apartment__image img[src]")90 if img:91 src = IMG_SIZE_RE.sub("", img["src"])92 if src not in images:93 images = [src] + images9495 details: dict = {}96 if tour:97 details["tour"] = tour98 for k in ("bathrooms", "floor", "orientation"):99 if detail.get(k):100 details[k] = detail[k]101102 desc_bits = [b for b in (103 f"{title} — {tour}" if tour else title,104 f"Superficie : {detail['area']} pi²" if detail.get("area") else "",105 ) if b]106 listings.append(Listing(107 source=self.source_id,108 external_id=ext_id,109 url=url,110 title=title or f"Condo {unit_type} — Horizon Terrebonne",111 address=ADDRESS,112 city="Terrebonne",113 unit_type=unit_type,114 bedrooms=bedrooms,115 price=price,116 price_label=f"à partir de {price:.0f} $ /mois" if price else "",117 availability=status,118 area_sqft=detail.get("area"),119 description=" | ".join(desc_bits)[:600],120 amenities=detail.get("amenities") or [],121 details=details,122 images=images[:25],123 lat=LAT,124 lng=LNG,125 ))126 return listings127128 # -- page détail « Phase n - x.5 à partir de » ------------------------------129 def _detail(self, url: str) -> dict:130 try:131 soup = BeautifulSoup(self.get(url).text, "html.parser")132 except Exception:133 return {}134 out: dict = {}135 h1 = soup.select_one("h1") or soup.select_one("title")136 if h1:137 out["title"] = re.sub(r"\s*\|.*$", "",138 h1.get_text(" ", strip=True)).strip()139 text = soup.get_text(" ", strip=True)140 m = re.search(r"Salle\(s\) de bain\s*(\d+(?:[.,]\d)?)", text)141 if m:142 out["bathrooms"] = float(m.group(1).replace(",", "."))143 m = re.search(r"Étage\(s\)\s*([\w\d]+)", text)144 if m and m.group(1) != "—":145 out["floor"] = m.group(1)146 m = re.search(r"Superficie\s*([\d\s ]+)\s*pi", text)147 if m:148 try:149 out["area"] = float(re.sub(r"[\s ]", "",150 m.group(1)))151 except ValueError:152 pass153 # avantages de l'immeuble (liste courte avant « Détails du projet »)154 amen: list[str] = []155 for li in soup.select("li"):156 t = li.get_text(" ", strip=True)157 if 3 < len(t) < 60 and re.search(158 r"piscine|potager|lavage|parc|ascenseur|lounge|"159 r"entra[iî]nement|terrasse", t, re.I) and t not in amen:160 amen.append(t)161 if amen:162 out["amenities"] = amen[:12]163 imgs: list[str] = []164 for img in soup.select("img[src]"):165 src = img["src"]166 if "/app/uploads/" not in src or re.search(r"logo|icon", src, re.I):167 continue168 src = IMG_SIZE_RE.sub("", src)169 if src.endswith("-scaled.jpg"):170 pass171 if src not in imgs:172 imgs.append(src)173 out["images"] = imgs[:25]174 return out175