# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/tour_fides.py : connecteur La Tour Fides (latourfides.com) # 290 unités locatives au 235, boulevard René-Lévesque Est (Ville-Marie, # Montréal). Le site WordPress ne publie aucun prix (le « budget » de la # page d'accueil est un menu de formulaire) : les vraies données vivent sur # le portail Yardi RentCafe/SecureCafe # latourfides.securecafe.com/onlineleasing/la-tour-fides/floorplans.aspx, # protégé par Cloudflare (403 direct) -> Scrapfly (ASP + rendu JS). # La page floorplans expose un accordéon par plan (data-selenium-id # FloorPlanName_n / Bed_Bath_n / Rent_n / Availability_n / floorplandesc_n) # avec fourchette de loyer réelle, superficie, salles de bain et carrousel # de plans par unité (id fp-myCarousel, identifiant RentCafe # STABLE -> external_id). Une annonce par plan (granularité la plus fine # publiée, comme elite.py). Les photos du projet viennent du site vitrine # latourfides.com (accès direct, wp-content). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector SITE = "https://latourfides.com" FLOORPLANS_URL = ("https://latourfides.securecafe.com/onlineleasing/" "la-tour-fides/floorplans.aspx?nocache=-1") ADDRESS = "235, boulevard René-Lévesque Est, Montréal, QC H2X 1N8" SECTOR = "Ville-Marie" PHONE = "514-700-2589" # « $1,920 - à $2,920 » / « $8,600 » (format RentCafe, virgule = milliers) _RENT_RE = re.compile(r"\$\s*([\d,]+)(?:\s*-\s*(?:à\s*)?\$\s*([\d,]+))?") # « 424 - à 766 Square Foot » (texte de la rangée d'accordéon) _SQFT_RE = re.compile(r"([\d,]+)(?:\s*-\s*(?:à\s*)?([\d,]+))?\s*Square\s*F", re.I) # « Bed/Bath Studio / 1 » / « Bed/Bath 2 / 1.5 » _BEDBATH_RE = re.compile(r"(Studio|\d+)\s*/\s*([\d.]+)", re.I) # identifiant RentCafe stable du plan (carrousel de l'accordéon) _CAROUSEL_RE = re.compile(r"fp-myCarousel(\d+)") # photos du site vitrine (originaux seulement, pas les variantes -WxH) _WP_IMG_RE = re.compile( r"https://latourfides\.com/wp-content/uploads/[^\"'\s\)]+\.(?:jpg|jpeg|webp)", re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I) def _slugify(s: str) -> str: return re.sub(r"[^a-z0-9]+", "-", strip_accents(s.lower())).strip("-") def _fmt(n: float) -> str: return f"{int(n):,}".replace(",", " ") + " $" class TourFidesConnector(BaseConnector): source_id = "tour_fides" request_delay = 1.0 max_plans = 25 max_images = 12 # -- page floorplans SecureCafe (Cloudflare -> Scrapfly) -------------------- def _floorplans_html(self) -> str: for wait in (4000, 9000): # seconde chance si rendu incomplet html = self.get_scrapfly(FLOORPLANS_URL, rendering_wait=wait) if html and "FloorPlanName_1" in html: return html raise RuntimeError( "floorplans.aspx SecureCafe inaccessible via Scrapfly") # -- photos + description du site vitrine (accès direct) -------------------- def _site_extras(self) -> tuple[list[str], str]: images: list[str] = [] blurb = "" try: html = self.get(SITE).text for u in dict.fromkeys(_WP_IMG_RE.findall(html)): if not _SKIP_IMG.search(u) and u not in images: images.append(u) og = BeautifulSoup(html, "html.parser").find( "meta", attrs={"property": "og:description"}) if og: blurb = (og.get("content") or "").strip()[:400] except Exception: pass return images[: self.max_images], blurb def fetch(self) -> list[Listing]: # un échec transitoire doit être journalisé comme ÉCHEC par ingest # (ok=0), pas comme un passage « ok » à 0 annonce soup = BeautifulSoup(self._floorplans_html(), "html.parser") site_images, blurb = self._site_extras() listings: list[Listing] = [] seen: set[str] = set() for n in range(1, self.max_plans + 1): name_el = soup.select_one(f"[data-selenium-id='FloorPlanName_{n}']") if not name_el: break try: lst = self._plan_listing(soup, n, name_el, site_images, blurb) except Exception: continue if lst and lst.external_id not in seen: seen.add(lst.external_id) listings.append(lst) return listings # -- une annonce par plan d'étage -------------------------------------------- def _plan_listing(self, soup, n: int, name_el, site_images: list[str], blurb: str) -> Listing | None: name = re.sub(r"(?i)^plan d.[ée]tage\s*", "", name_el.get_text(" ", strip=True)).strip() if not name: return None def _txt(prefix: str) -> str: el = soup.select_one(f"[data-selenium-id='{prefix}_{n}']") return el.get_text(" ", strip=True) if el else "" # chambres / salles de bain (« Bed/Bath 2 / 1.5 ») unit_type = "" bathrooms = None m = _BEDBATH_RE.search(_txt("Bed_Bath")) if m: if m.group(1).lower() == "studio": unit_type = "Studio" else: unit_type = normalize_unit_type(f"{m.group(1)} chambres") try: bathrooms = float(m.group(2)) except ValueError: pass # loyer : fourchette réelle RentCafe (borne basse retenue) rent_txt = _txt("Rent") price = None price_label = "" rm = _RENT_RE.search(rent_txt) if rm: lo = float(rm.group(1).replace(",", "")) hi = float(rm.group(2).replace(",", "")) if rm.group(2) else None if 300 <= lo <= 30000: price = lo price_label = (f"À partir de {_fmt(lo)} (max {_fmt(hi)})/mois" if hi and hi > lo else f"{_fmt(lo)}/mois") elif re.search(r"(?i)appelez|call", rent_txt): price_label = "Appelez-nous pour plus de détails" availability = _txt("Availability") # superficie « 424 - à 766 Square Foot » de la rangée d'accordéon area = None row = soup.select_one(f"[data-selenium-id='tRow{n}_1']") row_txt = row.get_text(" ", strip=True) if row else "" sm = _SQFT_RE.search(row_txt) sqft_bits = "" if sm: lo_s = float(sm.group(1).replace(",", "")) hi_s = float(sm.group(2).replace(",", "")) if sm.group(2) else None if 80 <= lo_s <= 20000: area = lo_s sqft_bits = (f"Superficie : {int(lo_s)} à {int(hi_s)} pi²" if hi_s and hi_s > lo_s else f"Superficie : {int(lo_s)} pi²") # identifiant RentCafe stable du plan (id du carrousel de l'accordéon) ext_id = "" img_cell = soup.select_one(f"[data-selenium-id='Image_{n}']") if img_cell: cm = _CAROUSEL_RE.search(str(img_cell)) if cm: ext_id = f"fp-{cm.group(1)}" ext_id = ext_id or _slugify(name) # plans des unités du carrousel (cdngeneralcf.rentcafe.com) + photos site plan_imgs: list[str] = [] if img_cell: for im in img_cell.select("img"): src = (im.get("src") or im.get("data-src") or "").strip() if src.startswith("http") and src not in plan_imgs: plan_imgs.append(src) images = (site_images + plan_imgs)[: self.max_images + 6] # description : blurb du site + texte du plan (« lire plus » exclu) desc = _txt("floorplandesc") desc = re.sub(r"(?i)\s*lire (plus|moins)\s*", " ", desc).strip() desc_parts = [b for b in (blurb, desc) if b] amenities = [] if sqft_bits: amenities.append(sqft_bits) specials = _txt("Specials") if 3 < len(specials) < 120: amenities.append(f"Promotion : {specials}") return Listing( source=self.source_id, external_id=ext_id, url=FLOORPLANS_URL, title=f"La Tour Fides — {name}", address=ADDRESS, sector=SECTOR, city="Montréal", unit_type=unit_type, bathrooms=bathrooms, price=price, price_label=price_label, availability=availability, area_sqft=area, description=" — ".join(desc_parts)[:700], amenities=amenities, details={"contact": {"phone": PHONE}}, images=images, )