# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/terrasse_pionniers.py : connecteur Terrasse des Pionniers # (terrassedespionniers.ca — appartements locatifs neufs long terme au # 973, rue Saint-Faustin à Mont-Blanc (Laurentides), près de la station # de ski ; 16 unités 4½ + 4 unités 5½). WordPress/Elementor + JetEngine # rendu SERVEUR : l'accueil expose une carte par TYPOLOGIE (badge de # disponibilité dynamique « Disponible »/« Non-Disponible », nb d'unités, # prix « à partir de », superficie) ; la fiche liste les inclusions # (« Le loyer comprend : ») et la galerie. Granularité TYPOLOGIE # (external_id = slug WordPress de la fiche, stable). Le badge de # disponibilité est repris tel quel dans `availability` (peut être # « Non-Disponible » quand tout est loué — bail au 1er juillet). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://terrassedespionniers.ca" HOME_URL = f"{BASE}/" ADDRESS = "973, rue Saint-Faustin" CITY = "Mont-Blanc" TYPE_RE = re.compile(r"^\s*(\d)\s*½\s*$") COUNT_RE = re.compile(r"(\d+)\s*Unités", re.I) PRICE_RE = re.compile(r"à partir de\s*([\d\s ,]+)\s*\$", re.I) AREA_RE = re.compile(r"([\d\s ]+)\s*pi²") BADGE_RE = re.compile(r"^(Non-)?Disponible", re.I) IMG_RE = re.compile( r"https://terrassedespionniers\.ca/wp-content/uploads/" r"[^\"\s\\]+?\.(?:jpe?g|png|webp)", re.I) IMG_SIZE_RE = re.compile(r"-\d+x\d+(?=\.(?:jpe?g|png|webp))", re.I) class TerrassePionniersConnector(BaseConnector): source_id = "terrasse_pionniers" request_delay = 0.8 def fetch(self) -> list[Listing]: try: soup = BeautifulSoup(self.get(HOME_URL).text, "html.parser") except Exception: return [] listings: list[Listing] = [] for card in soup.select(".jet-listing-grid__item"): wrap = card.select_one("[data-url]") url = wrap["data-url"] if wrap else "" if not url or "/appartements/" not in url: continue ext_id = url.rstrip("/").rsplit("/", 1)[-1] text = re.sub(r"\s+", " ", card.get_text(" ", strip=True)) unit_type = "" for h in card.select("h1, h2, h3, h4, h5, h6"): m = TYPE_RE.match(h.get_text(" ", strip=True)) if m: unit_type = normalize_unit_type(f"{m.group(1)} 1/2") break if not unit_type: continue badge = "" for h in card.select("h6"): m = BADGE_RE.match(h.get_text(" ", strip=True)) if m: badge = h.get_text(" ", strip=True) break price = None m = PRICE_RE.search(text) if m: try: price = float(re.sub(r"[\s ,]", "", m.group(1))) except ValueError: pass area = None m = AREA_RE.search(text) if m: try: area = float(re.sub(r"[\s ]", "", m.group(1))) except ValueError: pass m = COUNT_RE.search(text) nb_units = m.group(1) if m else "" # fiche typologie (cache BD) : description, inclusions, galerie detail = self.detail(ext_id, f"{badge}|{price}|{area}", lambda u=url: self._detail(u)) details: dict = {} if nb_units: details["nb_unites"] = nb_units listings.append(Listing( source=self.source_id, external_id=ext_id, # slug WP de la fiche typologie url=url, title=f"Appartement {unit_type} — Terrasse des Pionniers", address=ADDRESS, city=CITY, unit_type=unit_type, price=price, price_label=f"à partir de {price:.0f} $ /mois" if price else "", availability=badge, area_sqft=area, furnished=False, # « Non meublés » sur la fiche description=detail.get("description", "")[:1500], amenities=detail.get("amenities") or [], details=details, images=detail.get("images") or [], )) return listings # -- fiche typologie (4½ / 5½) ------------------------------------------------ def _detail(self, url: str) -> dict: try: html = self.get(url).text except Exception: return {} soup = BeautifulSoup(html, "html.parser") out: dict = {} text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) # description : phrase de présentation + emplacement parts: list[str] = [] m = re.search(r"(Chaque \d ½ offre[^|]+?\.)\s", text) if m: parts.append(m.group(1).strip()) m = re.search(r"Emplacement idéal\s*:\s*([^.]+\.)", text) if m: parts.append("Emplacement : " + m.group(1).strip()) out["description"] = " ".join(parts) # inclusions : « Le loyer comprend : » + description des unités amen: list[str] = [] m = re.search(r"Le loyer comprend\s*:\s*(.+?)Exclus de la location", text) if m: for item in re.split(r"(?<=[a-zà-ü)\.])\s+(?=[A-ZÉÈÀ])", m.group(1).strip()): item = item.strip(" .") if 3 < len(item) < 90 and item not in amen: amen.append(item) out["amenities"] = amen[:15] imgs = [] for u in dict.fromkeys(IMG_RE.findall(html)): u = IMG_SIZE_RE.sub("", u) if not re.search(r"logo|icon|favicon|ImageWithFallback", u, re.I) \ and u not in imgs: imgs.append(u) out["images"] = imgs[:20] return out