spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/equinoxe_batimo.py : connecteur Collection Équinoxe (Batimo)5# (collectionequinoxe.com — Équinoxe Saint-Elzéar / Lévesque / Lévesque O. /6# Daniel-Johnson à Laval, Bois-Franc à Saint-Laurent (Montréal),7# Le Carlyle à Mont-Royal, Le Westpark à Pointe-Claire).8# La page /disponibilites/ affiche un tableau JetEngine paginé (10 unités9# par page). La pagination passe par l'AJAX JetSmartFilters :10# POST /wp-admin/admin-ajax.php action=jet_smart_filters,11# provider=jet-data-table/result, paged=N -> tbody HTML complet12# (photos de l'unité incluses dans chaque rangée).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re18import time1920from bs4 import BeautifulSoup2122from ..schema import Listing, parse_price23from .base import BaseConnector2425BASE = "https://collectionequinoxe.com"26DISPO_URL = f"{BASE}/disponibilites/"27AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"2829# Projet (colonne du tableau) -> (ville, secteur, page projet, adresse civique)30# Adresses vérifiées dans le bloc contact de chaque page projet (les tours31# Lévesque/Lévesque O. sont au 3030 et 3080, boul. Lévesque O., Chomedey).32PROJECTS = {33 "saint-elzear": ("Laval", "Chomedey", f"{BASE}/saint-elzear/",34 "3830, boulevard Saint-Elzéar Ouest, Laval"),35 "levesque ouest": ("Laval", "Chomedey", f"{BASE}/levesqueouest/",36 "3080, boulevard Lévesque Ouest, Laval"),37 "levesque": ("Laval", "Chomedey", f"{BASE}/levesque/",38 "3030, boulevard Lévesque Ouest, Laval"),39 "daniel-johnson": ("Laval", "Chomedey", f"{BASE}/daniel-johnson/",40 "3105, promenade du Quartier-Saint-Martin, Laval"),41 "bois-franc": ("Montréal", "Bois-Franc (Saint-Laurent)",42 f"{BASE}/boisfranc/",43 "5375, boulevard Henri-Bourassa Ouest, Saint-Laurent"),44 "le carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/",45 "815, avenue Plymouth, Mont-Royal"),46 "carlyle": ("Mont-Royal", "Ville de Mont-Royal", f"{BASE}/carlyle/",47 "815, avenue Plymouth, Mont-Royal"),48 "le westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/",49 "265, boulevard Brunswick, Pointe-Claire"),50 "westpark": ("Pointe-Claire", "West Island", f"{BASE}/westpark/",51 "265, boulevard Brunswick, Pointe-Claire"),52}535455def _project_meta(project: str) -> tuple[str, str, str, str]:56 key = project.strip().lower()57 key = (key.replace("é", "e").replace("è", "e").replace("ê", "e")58 .replace("ô", "o"))59 for name, meta in PROJECTS.items():60 if name in key:61 return meta62 return ("", "", DISPO_URL, "")636465def _unit_type(bedrooms: str, type_label: str) -> str:66 s = (type_label or "").lower()67 if "studio" in s:68 return "Studio"69 try:70 n = int(str(bedrooms).strip())71 except (TypeError, ValueError):72 return ""73 if n <= 0:74 return "Studio"75 return f"{n + 2}½"767778class EquinoxeBatimoConnector(BaseConnector):79 source_id = "equinoxe_batimo"80 request_delay = 0.681 max_pages = 80 # garde-fou (~10 unités/page)8283 def __init__(self) -> None:84 super().__init__()85 # commodités/inclusions par page projet (1 requête par projet)86 self._building_amenities: dict[str, list[str]] = {}8788 def fetch(self) -> list[Listing]:89 listings: dict[str, Listing] = {}9091 # 1) Page initiale : rangées 1-10 + nombre total de pages92 try:93 html = self.get(DISPO_URL).text94 except Exception:95 return []96 max_pages = 197 m = re.search(r"var JetSmartFilterSettings = (\{.*?\});", html, re.S)98 if m:99 try:100 settings, _ = json.JSONDecoder().raw_decode(m.group(1))101 max_pages = int(settings["props"]["jet-data-table"]["result"]102 ["max_num_pages"])103 except Exception:104 max_pages = 1105 self._parse_rows(html, listings)106107 # 2) Pages suivantes via l'AJAX JetSmartFilters108 for page in range(2, min(max_pages, self.max_pages) + 1):109 try:110 frag = self._ajax_page(page)111 except Exception:112 continue113 if not frag.strip(): # plus de contenu -> stop114 break115 # NB : le tri du site n'est pas stable (égalités de loyer),116 # on ne s'arrête donc pas sur une page sans nouveauté.117 self._parse_rows(frag, listings)118119 return list(listings.values())120121 # -- AJAX -----------------------------------------------------------------122 def _ajax_page(self, page: int) -> str:123 wait = self.request_delay - (time.time() - self._last_request)124 if wait > 0:125 time.sleep(wait)126 resp = self.session.post(AJAX_URL, data={127 "action": "jet_smart_filters",128 "provider": "jet-data-table/result",129 "settings[table_id]": "6",130 "settings[thead]": "yes",131 "paged": str(page),132 }, headers={"X-Requested-With": "XMLHttpRequest"},133 timeout=self.timeout)134 self._last_request = time.time()135 resp.raise_for_status()136 return (resp.json() or {}).get("content") or ""137138 # -- commodités de l'immeuble (page projet) ---------------------------------139 def _project_amenities(self, url: str) -> list[str]:140 """Items des sections « Aires communes » / « Inclusions » de la page141 projet (titres Elementor sous les pictogrammes). Texte source fidèle."""142 if url in self._building_amenities:143 return self._building_amenities[url]144 items: list[str] = []145 try:146 html = self.get(url).text147 m = re.search(r"AIRES COMMUNES", html, re.I)148 if m:149 end = re.search(r"ADOPTEZ LE QUARTIER|LA Vie à Équinoxe",150 html[m.end():], re.I)151 seg = html[m.end(): m.end() + (end.start() if end else 40000)]152 for mm in re.finditer(153 r'class="elementor-heading-title[^"]*">([^<]{3,70})<',154 seg):155 t = re.sub(r"\s+", " ", mm.group(1)).replace("", "")156 t = t.strip()157 # titres de section et boutons, pas des commodités158 if (not t or re.search(r"voir|condos|contact", t, re.I)159 or re.fullmatch(r"(?i)inclusions?", t)160 or re.match(r"(?i)les aires communes", t)):161 continue162 if t not in items:163 items.append(t)164 except Exception:165 items = []166 self._building_amenities[url] = items167 return items168169 # -- parsing du tableau -----------------------------------------------------170 def _parse_rows(self, html: str, listings: dict[str, Listing]) -> None:171 soup = BeautifulSoup(html, "html.parser")172 for row in soup.select("tr[data-item-object]"):173 try:174 ext_id = row.get("data-item-object") or ""175 if not ext_id or ext_id in listings:176 continue177178 def col(name: str) -> str:179 el = row.select_one(f"td.jet-dynamic-table__col--{name}")180 return el.get_text(" ", strip=True) if el else ""181182 unit_no = col("unit")183 type_label = col("type")184 sqft = col("pi")185 project = col("projet")186 phase = col("phase")187 rent = col("loyer")188 bedrooms = col("dechambres")189 bathrooms = col("desdb")190 dispo = col("disponibilit")191192 if not project or not rent:193 continue194 city, sector, proj_url, address = _project_meta(project)195 if not city:196 continue # projet inconnu -> prudence197198 # La colonne dispo contient parfois 2 dates (dispo + du jour)199 m = re.match(200 r"((?:\d{1,2}(?:er)?\s+\S+\s+\d{4})|Maintenant|Immédiate)",201 dispo, re.I)202 availability = m.group(1) if m else dispo.split(" ")[0]203204 # Photos de l'unité (galerie de la rangée) + plan205 images = []206 for img in row.select("img"):207 u = img.get("src") or img.get("data-src") or ""208 if u.startswith("http") and not re.search(209 r"logo|icon|favicon", u, re.I):210 images.append(u)211 for a in row.select("a[href]"):212 href = a.get("href") or ""213 if re.search(r"/wp-content/uploads/.*\.(jpe?g|png|webp)$",214 href):215 images.append(href)216 images = list(dict.fromkeys(images))217218 # superficie structurée (colonne « pi² ») -> area_sqft explicite219 area_sqft = None220 am = re.search(r"(\d[\d\s]{1,6}(?:[.,]\d+)?)\s*pi", sqft)221 if am:222 v = float(am.group(1).replace(" ", "").replace(",", "."))223 if 80 <= v <= 20000:224 area_sqft = v225226 amenities = []227 if sqft:228 amenities.append(f"Superficie {sqft}")229 if bathrooms:230 amenities.append(f"{bathrooms} salle(s) de bain")231 # commodités/inclusions de l'immeuble (page projet, mise en cache)232 amenities += [a for a in self._project_amenities(proj_url)233 if a not in amenities]234235 phase_txt = f" (phase {phase})" if phase else ""236 title = (f"Équinoxe {project}{phase_txt} — unité {unit_no}"237 if "carlyle" not in project.lower()238 and "westpark" not in project.lower()239 else f"{project}{phase_txt} — unité {unit_no}")240241 listings[ext_id] = Listing(242 source=self.source_id,243 external_id=ext_id,244 url=proj_url,245 title=title,246 address=address,247 sector=sector,248 city=city,249 unit_type=_unit_type(bedrooms, type_label),250 price=parse_price(rent),251 price_label=f"{rent}/mois" if rent else "",252 availability=availability,253 area_sqft=area_sqft,254 description=type_label,255 amenities=amenities,256 images=images,257 )258 except Exception:259 continue260