# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/equinoxe_batimo.py : connecteur Collection Équinoxe (Batimo) # (collectionequinoxe.com — Équinoxe Saint-Elzéar / Lévesque / Lévesque O. / # Daniel-Johnson à Laval, Bois-Franc à Saint-Laurent (Montréal), # Le Carlyle à Mont-Royal, Le Westpark à Pointe-Claire). # La page /disponibilites/ affiche un tableau JetEngine paginé (10 unités # par page). La pagination passe par l'AJAX JetSmartFilters : # POST /wp-admin/admin-ajax.php action=jet_smart_filters, # provider=jet-data-table/result, paged=N -> tbody HTML complet # (photos de l'unité incluses dans chaque rangée). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import time from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://collectionequinoxe.com" DISPO_URL = f"{BASE}/disponibilites/" AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" # Projet (colonne du tableau) -> (ville, secteur, page projet, adresse civique) # Adresses vérifiées dans le bloc contact de chaque page projet (les tours # Lévesque/Lévesque O. sont au 3030 et 3080, boul. Lévesque O., Chomedey). PROJECTS = { "saint-elzear": ("Laval", "Chomedey", f"{BASE}/saint-elzear/", "3830, boulevard Saint-Elzéar Ouest, Laval"), "levesque ouest": ("Laval", "Chomedey", f"{BASE}/levesqueouest/", "3080, boulevard Lévesque Ouest, Laval"), "levesque": ("Laval", "Chomedey", f"{BASE}/levesque/", "3030, boulevard Lévesque Ouest, Laval"), "daniel-johnson": ("Laval", "Chomedey", f"{BASE}/daniel-johnson/", "3105, promenade du Quartier-Saint-Martin, Laval"), "bois-franc": ("Montréal", "Bois-Franc (Saint-Laurent)", f"{BASE}/boisfranc/", "5375, boulevard Henri-Bourassa Ouest, Saint-Laurent"), "le carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/", "815, avenue Plymouth, Mont-Royal"), "carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/", "815, avenue Plymouth, Mont-Royal"), "le westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/", "265, boulevard Brunswick, Pointe-Claire"), "westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/", "265, boulevard Brunswick, Pointe-Claire"), } def _project_meta(project: str) -> tuple[str, str, str, str]: key = project.strip().lower() key = (key.replace("é", "e").replace("è", "e").replace("ê", "e") .replace("ô", "o")) for name, meta in PROJECTS.items(): if name in key: return meta return ("", "", DISPO_URL, "") def _unit_type(bedrooms: str, type_label: str) -> str: s = (type_label or "").lower() if "studio" in s: return "Studio" try: n = int(str(bedrooms).strip()) except (TypeError, ValueError): return "" if n <= 0: return "Studio" return f"{n + 2}½" class EquinoxeBatimoConnector(BaseConnector): source_id = "equinoxe_batimo" request_delay = 0.6 max_pages = 80 # garde-fou (~10 unités/page) def __init__(self) -> None: super().__init__() # commodités/inclusions par page projet (1 requête par projet) self._building_amenities: dict[str, list[str]] = {} def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} # 1) Page initiale : rangées 1-10 + nombre total de pages try: html = self.get(DISPO_URL).text except Exception: return [] max_pages = 1 m = re.search(r"var JetSmartFilterSettings = (\{.*?\});", html, re.S) if m: try: settings, _ = json.JSONDecoder().raw_decode(m.group(1)) max_pages = int(settings["props"]["jet-data-table"]["result"] ["max_num_pages"]) except Exception: max_pages = 1 self._parse_rows(html, listings) # 2) Pages suivantes via l'AJAX JetSmartFilters for page in range(2, min(max_pages, self.max_pages) + 1): try: frag = self._ajax_page(page) except Exception: continue if not frag.strip(): # plus de contenu -> stop break # NB : le tri du site n'est pas stable (égalités de loyer), # on ne s'arrête donc pas sur une page sans nouveauté. self._parse_rows(frag, listings) return list(listings.values()) # -- AJAX ----------------------------------------------------------------- def _ajax_page(self, page: int) -> str: wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(AJAX_URL, data={ "action": "jet_smart_filters", "provider": "jet-data-table/result", "settings[table_id]": "6", "settings[thead]": "yes", "paged": str(page), }, headers={"X-Requested-With": "XMLHttpRequest"}, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return (resp.json() or {}).get("content") or "" # -- commodités de l'immeuble (page projet) --------------------------------- def _project_amenities(self, url: str) -> list[str]: """Items des sections « Aires communes » / « Inclusions » de la page projet (titres Elementor sous les pictogrammes). Texte source fidèle.""" if url in self._building_amenities: return self._building_amenities[url] items: list[str] = [] try: html = self.get(url).text m = re.search(r"AIRES COMMUNES", html, re.I) if m: end = re.search(r"ADOPTEZ LE QUARTIER|LA Vie à Équinoxe", html[m.end():], re.I) seg = html[m.end(): m.end() + (end.start() if end else 40000)] for mm in re.finditer( r'class="elementor-heading-title[^"]*">([^<]{3,70})<', seg): t = re.sub(r"\s+", " ", mm.group(1)).replace("​", "") t = t.strip() # titres de section et boutons, pas des commodités if (not t or re.search(r"voir|condos|contact", t, re.I) or re.fullmatch(r"(?i)inclusions?", t) or re.match(r"(?i)les aires communes", t)): continue if t not in items: items.append(t) except Exception: items = [] self._building_amenities[url] = items return items # -- parsing du tableau ----------------------------------------------------- def _parse_rows(self, html: str, listings: dict[str, Listing]) -> None: soup = BeautifulSoup(html, "html.parser") for row in soup.select("tr[data-item-object]"): try: ext_id = row.get("data-item-object") or "" if not ext_id or ext_id in listings: continue def col(name: str) -> str: el = row.select_one(f"td.jet-dynamic-table__col--{name}") return el.get_text(" ", strip=True) if el else "" unit_no = col("unit") type_label = col("type") sqft = col("pi") project = col("projet") phase = col("phase") rent = col("loyer") bedrooms = col("dechambres") bathrooms = col("desdb") dispo = col("disponibilit") if not project or not rent: continue city, sector, proj_url, address = _project_meta(project) if not city: continue # projet inconnu -> prudence # La colonne dispo contient parfois 2 dates (dispo + du jour) m = re.match( r"((?:\d{1,2}(?:er)?\s+\S+\s+\d{4})|Maintenant|Immédiate)", dispo, re.I) availability = m.group(1) if m else dispo.split(" ")[0] # Photos de l'unité (galerie de la rangée) + plan images = [] for img in row.select("img"): u = img.get("src") or img.get("data-src") or "" if u.startswith("http") and not re.search( r"logo|icon|favicon", u, re.I): images.append(u) for a in row.select("a[href]"): href = a.get("href") or "" if re.search(r"/wp-content/uploads/.*\.(jpe?g|png|webp)$", href): images.append(href) images = list(dict.fromkeys(images)) # superficie structurée (colonne « pi² ») -> area_sqft explicite area_sqft = None am = re.search(r"(\d[\d\s]{1,6}(?:[.,]\d+)?)\s*pi", sqft) if am: v = float(am.group(1).replace(" ", "").replace(",", ".")) if 80 <= v <= 20000: area_sqft = v amenities = [] if sqft: amenities.append(f"Superficie {sqft}") if bathrooms: amenities.append(f"{bathrooms} salle(s) de bain") # commodités/inclusions de l'immeuble (page projet, mise en cache) amenities += [a for a in self._project_amenities(proj_url) if a not in amenities] phase_txt = f" (phase {phase})" if phase else "" title = (f"Équinoxe {project}{phase_txt} — unité {unit_no}" if "carlyle" not in project.lower() and "westpark" not in project.lower() else f"{project}{phase_txt} — unité {unit_no}") listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=proj_url, title=title, address=address, sector=sector, city=city, unit_type=_unit_type(bedrooms, type_label), price=parse_price(rent), price_label=f"{rent}/mois" if rent else "", availability=availability, area_sqft=area_sqft, description=type_label, amenities=amenities, images=images, ) except Exception: continue