Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/tour_fides.py : connecteur La Tour Fides (latourfides.com)5# 290 unités locatives au 235, boulevard René-Lévesque Est (Ville-Marie,6# Montréal). Le site WordPress ne publie aucun prix (le « budget » de la7# page d'accueil est un menu de formulaire) : les vraies données vivent sur8# le portail Yardi RentCafe/SecureCafe9# latourfides.securecafe.com/onlineleasing/la-tour-fides/floorplans.aspx,10# protégé par Cloudflare (403 direct) -> Scrapfly (ASP + rendu JS).11# La page floorplans expose un accordéon par plan (data-selenium-id12# FloorPlanName_n / Bed_Bath_n / Rent_n / Availability_n / floorplandesc_n)13# avec fourchette de loyer réelle, superficie, salles de bain et carrousel14# de plans par unité (id fp-myCarousel<FloorPlanID>, identifiant RentCafe15# STABLE -> external_id). Une annonce par plan (granularité la plus fine16# publiée, comme elite.py). Les photos du projet viennent du site vitrine17# latourfides.com (accès direct, wp-content).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type, strip_accents26from .base import BaseConnector2728SITE = "https://latourfides.com"29FLOORPLANS_URL = ("https://latourfides.securecafe.com/onlineleasing/"30 "la-tour-fides/floorplans.aspx?nocache=-1")3132ADDRESS = "235, boulevard René-Lévesque Est, Montréal, QC H2X 1N8"33SECTOR = "Ville-Marie"34PHONE = "514-700-2589"3536# « $1,920 - à $2,920 » / « $8,600 » (format RentCafe, virgule = milliers)37_RENT_RE = re.compile(r"\$\s*([\d,]+)(?:\s*-\s*(?:à\s*)?\$\s*([\d,]+))?")38# « 424 - à 766 Square Foot » (texte de la rangée d'accordéon)39_SQFT_RE = re.compile(r"([\d,]+)(?:\s*-\s*(?:à\s*)?([\d,]+))?\s*Square\s*F",40 re.I)41# « Bed/Bath Studio / 1 » / « Bed/Bath 2 / 1.5 »42_BEDBATH_RE = re.compile(r"(Studio|\d+)\s*/\s*([\d.]+)", re.I)43# identifiant RentCafe stable du plan (carrousel de l'accordéon)44_CAROUSEL_RE = re.compile(r"fp-myCarousel(\d+)")45# photos du site vitrine (originaux seulement, pas les variantes -WxH)46_WP_IMG_RE = re.compile(47 r"https://latourfides\.com/wp-content/uploads/[^\"'\s\)]+\.(?:jpg|jpeg|webp)",48 re.I)49_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I)505152def _slugify(s: str) -> str:53 return re.sub(r"[^a-z0-9]+", "-", strip_accents(s.lower())).strip("-")545556def _fmt(n: float) -> str:57 return f"{int(n):,}".replace(",", " ") + " $"585960class TourFidesConnector(BaseConnector):61 source_id = "tour_fides"62 request_delay = 1.063 max_plans = 2564 max_images = 126566 # -- page floorplans SecureCafe (Cloudflare -> Scrapfly) --------------------67 def _floorplans_html(self) -> str:68 for wait in (4000, 9000): # seconde chance si rendu incomplet69 html = self.get_scrapfly(FLOORPLANS_URL, rendering_wait=wait)70 if html and "FloorPlanName_1" in html:71 return html72 raise RuntimeError(73 "floorplans.aspx SecureCafe inaccessible via Scrapfly")7475 # -- photos + description du site vitrine (accès direct) --------------------76 def _site_extras(self) -> tuple[list[str], str]:77 images: list[str] = []78 blurb = ""79 try:80 html = self.get(SITE).text81 for u in dict.fromkeys(_WP_IMG_RE.findall(html)):82 if not _SKIP_IMG.search(u) and u not in images:83 images.append(u)84 og = BeautifulSoup(html, "html.parser").find(85 "meta", attrs={"property": "og:description"})86 if og:87 blurb = (og.get("content") or "").strip()[:400]88 except Exception:89 pass90 return images[: self.max_images], blurb9192 def fetch(self) -> list[Listing]:93 # un échec transitoire doit être journalisé comme ÉCHEC par ingest94 # (ok=0), pas comme un passage « ok » à 0 annonce95 soup = BeautifulSoup(self._floorplans_html(), "html.parser")96 site_images, blurb = self._site_extras()9798 listings: list[Listing] = []99 seen: set[str] = set()100 for n in range(1, self.max_plans + 1):101 name_el = soup.select_one(f"[data-selenium-id='FloorPlanName_{n}']")102 if not name_el:103 break104 try:105 lst = self._plan_listing(soup, n, name_el, site_images, blurb)106 except Exception:107 continue108 if lst and lst.external_id not in seen:109 seen.add(lst.external_id)110 listings.append(lst)111 return listings112113 # -- une annonce par plan d'étage --------------------------------------------114 def _plan_listing(self, soup, n: int, name_el, site_images: list[str],115 blurb: str) -> Listing | None:116 name = re.sub(r"(?i)^plan d.[ée]tage\s*", "",117 name_el.get_text(" ", strip=True)).strip()118 if not name:119 return None120121 def _txt(prefix: str) -> str:122 el = soup.select_one(f"[data-selenium-id='{prefix}_{n}']")123 return el.get_text(" ", strip=True) if el else ""124125 # chambres / salles de bain (« Bed/Bath 2 / 1.5 »)126 unit_type = ""127 bathrooms = None128 m = _BEDBATH_RE.search(_txt("Bed_Bath"))129 if m:130 if m.group(1).lower() == "studio":131 unit_type = "Studio"132 else:133 unit_type = normalize_unit_type(f"{m.group(1)} chambres")134 try:135 bathrooms = float(m.group(2))136 except ValueError:137 pass138139 # loyer : fourchette réelle RentCafe (borne basse retenue)140 rent_txt = _txt("Rent")141 price = None142 price_label = ""143 rm = _RENT_RE.search(rent_txt)144 if rm:145 lo = float(rm.group(1).replace(",", ""))146 hi = float(rm.group(2).replace(",", "")) if rm.group(2) else None147 if 300 <= lo <= 30000:148 price = lo149 price_label = (f"À partir de {_fmt(lo)} (max {_fmt(hi)})/mois"150 if hi and hi > lo else f"{_fmt(lo)}/mois")151 elif re.search(r"(?i)appelez|call", rent_txt):152 price_label = "Appelez-nous pour plus de détails"153154 availability = _txt("Availability")155156 # superficie « 424 - à 766 Square Foot » de la rangée d'accordéon157 area = None158 row = soup.select_one(f"[data-selenium-id='tRow{n}_1']")159 row_txt = row.get_text(" ", strip=True) if row else ""160 sm = _SQFT_RE.search(row_txt)161 sqft_bits = ""162 if sm:163 lo_s = float(sm.group(1).replace(",", ""))164 hi_s = float(sm.group(2).replace(",", "")) if sm.group(2) else None165 if 80 <= lo_s <= 20000:166 area = lo_s167 sqft_bits = (f"Superficie : {int(lo_s)} à {int(hi_s)} pi²"168 if hi_s and hi_s > lo_s169 else f"Superficie : {int(lo_s)} pi²")170171 # identifiant RentCafe stable du plan (id du carrousel de l'accordéon)172 ext_id = ""173 img_cell = soup.select_one(f"[data-selenium-id='Image_{n}']")174 if img_cell:175 cm = _CAROUSEL_RE.search(str(img_cell))176 if cm:177 ext_id = f"fp-{cm.group(1)}"178 ext_id = ext_id or _slugify(name)179180 # plans des unités du carrousel (cdngeneralcf.rentcafe.com) + photos site181 plan_imgs: list[str] = []182 if img_cell:183 for im in img_cell.select("img"):184 src = (im.get("src") or im.get("data-src") or "").strip()185 if src.startswith("http") and src not in plan_imgs:186 plan_imgs.append(src)187 images = (site_images + plan_imgs)[: self.max_images + 6]188189 # description : blurb du site + texte du plan (« lire plus » exclu)190 desc = _txt("floorplandesc")191 desc = re.sub(r"(?i)\s*lire (plus|moins)\s*", " ", desc).strip()192 desc_parts = [b for b in (blurb, desc) if b]193194 amenities = []195 if sqft_bits:196 amenities.append(sqft_bits)197 specials = _txt("Specials")198 if 3 < len(specials) < 120:199 amenities.append(f"Promotion : {specials}")200201 return Listing(202 source=self.source_id,203 external_id=ext_id,204 url=FLOORPLANS_URL,205 title=f"La Tour Fides — {name}",206 address=ADDRESS,207 sector=SECTOR,208 city="Montréal",209 unit_type=unit_type,210 bathrooms=bathrooms,211 price=price,212 price_label=price_label,213 availability=availability,214 area_sqft=area,215 description=" — ".join(desc_parts)[:700],216 amenities=amenities,217 details={"contact": {"phone": PHONE}},218 images=images,219 )220