# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/cinq_etoiles.py : connecteur Gestion Cinq Étoiles # (appartementslocatifs.ca) — 5 immeubles locatifs neufs à Blainville : # L'Émeraude (842 Curé-Labelle), Le Cassiopée (832 Curé-Labelle), L'Azur # (33 Gaëtan-Mailhiot), Le Célesta (11 Gaëtan-Mailhiot), L'Élysée # (40 Gaëtan-Mailhiot). Site GoDaddy Website Builder rendu serveur : la # page d'accueil liste les immeubles (nav), chaque page immeuble affiche # « à partir de N$/mois », l'adresse civique, la ligne des # typologies (« Logements 3 1/2 et 3 1/2 + den et 4 1/2 ») et les # inclusions. Photos sur le CDN img1.wsimg.com (variantes /:/rs=… # dédupliquées). Granularité TYPOLOGIE par immeuble ; le prix « à partir # de » n'est associé qu'à la plus petite typologie (jamais inventé). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://appartementslocatifs.ca" CITY = "Blainville" # « L'AZUR, à partir de 1575$ » / « LE CASSIOPÉE À PARTIR DE 1510$/ MOIS » NAME_PRICE_RE = re.compile( r"^(.{2,40}?),?\s*à partir de\s*([\d ]{3,6})\s*\$", re.I) # ligne composée uniquement de typologies : « Logements 3 1/2 + den et 4 1/2 » TYPES_LINE_RE = re.compile( r"^(?:Logements?\s+)?((?:\d\s*(?:1/2|½)(?:\s*\+\s*den)?)" r"(?:\s*(?:et|,)\s*\d\s*(?:1/2|½)(?:\s*\+\s*den)?)*)\s*$", re.I) TYPE_TOKEN_RE = re.compile(r"(\d)\s*(?:1/2|½)(\s*\+\s*den)?", re.I) # « 33 Gaëtan-Mailhiot, Blainville, QC , J7C 0W9 » / « Situé au 842, boul. … » ADDR_RE = re.compile( r"^(?:Situé au\s+)?(\d{1,5}[^\n]{3,60}?),?\s*Blainville\b", re.I) IMG_RE = re.compile( r"(?:https?:)?//img1\.wsimg\.com/isteam/ip/[^\"'\s\\,)]+?\.(?:jpe?g|png|webp)", re.I) # l'adresse de L'Élysée n'apparaît que sur la page d'accueil FALLBACK_ADDR = {"l%C3%A9lys%C3%A9e": "40 Gaëtan-Mailhiot"} EXCLUDED_PATHS = {"/", "/service"} class CinqEtoilesConnector(BaseConnector): source_id = "cinq_etoiles" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: home = self.get(BASE + "/").text except Exception: return listings soup = BeautifulSoup(home, "html.parser") paths = [] for a in soup.select("a[href]"): href = a.get("href") or "" if (href.startswith("/") and href not in EXCLUDED_PATHS and href not in paths and "#" not in href): paths.append(href) for path in paths: try: listings.extend(self._building(path)) except Exception: continue # une page cassée ne bloque pas return listings # -- page immeuble : nom, prix « à partir de », adresse, typologies -------- def _building(self, path: str) -> list[Listing]: url = BASE + path html = self.get(url).text soup = BeautifulSoup(html, "html.parser") for tag in soup.select("title, script, style"): tag.decompose() # le contient des typologies lines = [ln for ln in soup.get_text("\n", strip=True).split("\n") if ln] name, price, price_label = "", None, "" address = FALLBACK_ADDR.get(path.lstrip("/"), "") types: list[str] = [] amenities: list[str] = [] for i, line in enumerate(lines): m = NAME_PRICE_RE.match(line) if m and not name: name = m.group(1).strip(" ,–-").title() price_label = line.strip() try: price = float(re.sub(r"[ ]", "", m.group(2))) except ValueError: price = None m = TYPES_LINE_RE.match(line) if m and not types: types = [f"{t[0]}½" + (" + DEN" if t[1] else "") for t in TYPE_TOKEN_RE.findall(m.group(1))] m = ADDR_RE.match(line) if m and not FALLBACK_ADDR.get(path.lstrip("/")): address = m.group(1).strip(" ,") if line.lower().startswith("inclusions"): rest = line.split(":", 1)[-1].strip() \ if ":" in line else line[len("Inclusions"):].strip() if len(rest) > 3: amenities.append(rest) for nxt in lines[i + 1:i + 4]: if re.match(r"Copyright|Powered|This website", nxt, re.I): break if len(nxt) > 3: amenities.append(nxt) if not name or not types: return [] # photos : dédupliquées sur le chemin de base (avant les variantes /:/) images: list[str] = [] for u in IMG_RE.findall(html): full = ("https:" + u) if u.startswith("//") else u if full not in images and not re.search(r"logo|favicon", full, re.I): images.append(full) # une annonce par typologie ; « à partir de » = plus petite typologie out: list[Listing] = [] for i, ut in enumerate(types): first = (i == 0) slug = re.sub(r"[^a-z0-9]+", "-", name.lower().replace("é", "e").replace("è", "e") .replace("'", "")).strip("-") out.append(Listing( source=self.source_id, external_id=f"{slug}-{ut.replace('½', '.5').replace(' + DEN', 'den').replace(' ', '')}", url=url, title=f"{name} — {ut}", address=address, city=CITY, unit_type=ut, price=price if first else None, price_label=(price_label if first else ""), availability="Disponible", amenities=list(amenities), details=({"price_from": True, "building": name} if first and price else {"building": name}), images=images[:15], )) return out