# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/signature_boisbriand.py : connecteur Signature Boisbriand # (signatureboisbriand.com) — projet locatif au 1040-1050, rue des # Francs-Bourgeois, Boisbriand (Faubourg Boisbriand, ~266 unités). # WordPress + FacetWP rendu serveur : /unites-disponibles/ liste des lignes # .unit-row (numéro #1040-101, pièces, étage, superficie, prix « à partir # de », disponibilité, plan PDF) paginées via le paramètre ?_paged=N # (15 lignes/page). On ne garde que les unités NON « Unité louée ». # Granularité : unité. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://signatureboisbriand.com" UNITS_URL = f"{BASE}/unites-disponibles/" MAX_PAGES = 30 ADDRESS = "1040, rue des Francs-Bourgeois, Boisbriand" CITY = "Boisbriand" PRICE_RE = re.compile(r"(\d[\d\s,]*)\s*\$") IMG_RE = re.compile( r"https://signatureboisbriand\.com/wp-content/uploads/" r"[^\"'\s\\]+\.(?:jpe?g|png|webp)", re.I) SKIP_IMG_RE = re.compile(r"logo|favicon|icon|-\d{2,4}x\d{2,4}\.", re.I) class SignatureBoisbriandConnector(BaseConnector): source_id = "signature_boisbriand" request_delay = 0.6 max_images = 10 def _site_images(self) -> list[str]: """Photos du projet (page d'accueil) — les lignes n'ont qu'un plan PDF.""" try: html = self.get(f"{BASE}/").text except Exception: return [] return [u for u in dict.fromkeys(IMG_RE.findall(html)) if not SKIP_IMG_RE.search(u)][: self.max_images] @staticmethod def _txt(row, sel: str) -> str: el = row.select_one(sel) return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else "" def fetch(self) -> list[Listing]: images = self._site_images() listings: list[Listing] = [] seen: set[str] = set() for page in range(1, MAX_PAGES + 1): url = UNITS_URL if page == 1 else f"{UNITS_URL}?_paged={page}" try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") rows = soup.select(".unit-row") if not rows: break new_on_page = 0 for row in rows: num = self._txt(row, ".unit-number__text").lstrip("#").strip() if not num or num in seen: continue seen.add(num) new_on_page += 1 availability = self._txt(row, ".unit-availability__text") if re.search(r"lou[ée]", availability, re.I): continue # « Unité louée » unit_type = normalize_unit_type( self._txt(row, ".unit-room__text")) floor = self._txt(row, ".unit-floor__text") area = None m = re.search(r"(\d[\d\s]*)\s*pi", self._txt(row, ".unit-area__text")) if m: try: v = float(m.group(1).replace(" ", "").replace(" ", "")) if 100 <= v <= 10000: area = v except ValueError: pass price = None price_label = "" m = PRICE_RE.search(self._txt(row, ".unit-price__text")) if m: try: val = float(m.group(1).replace(" ", "") .replace(" ", "").replace(",", "")) if 300 <= val <= 20000: price = val price_label = f"à partir de {int(val)}$" except ValueError: pass imgs = list(images) plan = row.select_one('.unit-plan a[href$=".pdf"]') details: dict = {} if plan is not None: details["floor_plan"] = plan["href"] listings.append(Listing( source=self.source_id, external_id=num, url=UNITS_URL, title=f"Signature Boisbriand — Unité #{num}", address=ADDRESS, city=CITY, unit_type=unit_type, price=price, price_label=price_label, availability=availability or "Disponible", area_sqft=area, description=f"Unité #{num}, {floor}" if floor else "", details=details, images=imgs, )) if new_on_page == 0: break return listings