# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lynk_olymbec.py : connecteur Lynk par Olymbec (lynk.ca) # Lynk De la Savane — 303 unités au 5200, rue De la Savane, Montréal # (secteur Namur / De la Savane, CDN-NDG). Site RentCafe derrière un # challenge Cloudflare (403 pour les robots) : accès direct tenté avec # en-têtes réalistes, sinon repli sur Firecrawl (rendu JS). # Sources combinées (1 annonce par type d'unité — Studio, 3½, 4½, 5½) : # - /lynk-dls/units : prix marketing « à partir de » par type, inclusions # « dans mon unité » (icônes) et « aménagements et matériaux » (liste) ; # - /lynk-dls/floorplans : données RentCafe STRUCTURÉES par plan # (data-floorplan-size/sqft/price) -> superficie et fourchette de prix # réelles ; pages de plan (avec cache self.detail) -> unités disponibles # (« # 923 — Disponible maintenant ») ; # - /lynk-dls : description du projet + « services et commodités » (icônes) # + téléphone (lien tel:). # Le projet Lynk Royale (Trois-Rivières) et Lynk Griffintown # (« bientôt disponible ») sont exclus. # ----------------------------------------------------------------------------- from __future__ import annotations import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.lynk.ca" UNITS_URL = f"{BASE}/lynk-dls/units" FLOORPLANS_URL = f"{BASE}/lynk-dls/floorplans" DLS_URL = f"{BASE}/lynk-dls" ADDRESS = "5200, rue De la Savane, Montréal, QC H4P 0E2" SECTOR = "Namur / De la Savane (CDN-NDG)" # « Studio à partir de 1 380 $ par mois », « 3 ½ à partir de 1 700 $ … » TYPE_PRICE_RE = re.compile( r"(Studio|\d\s*½|\d\s*1/2)\s*à partir de\s*([\d\s ,]+)\s*\$", re.I) IMG_RE = re.compile( r"https://resource\.rentcafe\.com/image/upload/[^\"'\s\\]+?\.(?:jpg|jpeg)", re.I) # nombre de chambres (data-floorplan-size / nom du plan) -> type Lou-Ka BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½"} BROWSER_HEADERS = { "Accept": ("text/html,application/xhtml+xml,application/xml;q=0.9," "image/avif,image/webp,*/*;q=0.8"), "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Upgrade-Insecure-Requests": "1", } def _strip_tags(html: str) -> str: txt = re.sub(r"||", " ", html, flags=re.S | re.I) txt = re.sub(r"<[^>]+>", " ", txt) return re.sub(r"\s+", " ", txt) def _fmt_price(raw: str) -> str: """« 1650 » -> « 1 650 $ » (affichage de la fourchette RentCafe).""" try: n = int(float(raw)) except (TypeError, ValueError): return "" return f"{n:,}".replace(",", " ") + " $" class LynkOlymbecConnector(BaseConnector): source_id = "lynk_olymbec" request_delay = 0.8 max_details = 10 # plafond de VRAIES requêtes de pages plan par synchro def __init__(self) -> None: super().__init__() self._detail_fetches = 0 def _get_page(self, url: str) -> str: """Essaie l'accès direct (en-têtes navigateur), sinon Firecrawl.""" try: resp = self.get(url, headers=BROWSER_HEADERS) if "Just a moment" not in resp.text: return resp.text except Exception: pass return self.get_rendered(url) # Cloudflare -> rendu Firecrawl # -- sections d'icônes (ysi-icon-widget : libellé + sous-titre) ----------- @staticmethod def _icon_labels(html: str) -> list[str]: out: list[str] = [] soup = BeautifulSoup(html, "html.parser") for w in soup.select(".ysi-icon-widget"): lines = [t for t in w.stripped_strings] if not lines: continue label = lines[0].strip() caption = " ".join(lines[1:]).strip() if caption and len(caption) <= 90: out.append(f"{label} — {caption}") elif label: out.append(label) return out # -- page d'un plan RentCafe : unités disponibles -------------------------- def _fetch_plan(self, url: str) -> dict: if self._detail_fetches >= self.max_details: raise RuntimeError("plafond de requêtes de plans atteint") self._detail_fetches += 1 html = self._get_page(url) text = _strip_tags(html) units = [] # « Appartement : # 923 Disponible maintenant Appelez pour connaître… » for m in re.finditer(r"Appartement\s*:\s*#\s*(\d+)\s+(.*?)\s*" r"(?:Appelez|Contactez|\d[\d\s]*\$)", text): dispo = m.group(2).strip() if len(dispo) > 60: dispo = "" units.append({"no": m.group(1), "dispo": dispo}) images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|chevron|download|lockup", u, re.I)] return {"units": units, "images": images[:5]} def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: units_html = self._get_page(UNITS_URL) except Exception: return listings if not units_html: return listings units_text = _strip_tags(units_html) units_soup = BeautifulSoup(units_html, "html.parser") # prix marketing « à partir de » par type (Studio, 3½, 4½, 5½) marketing: dict[str, str] = {} for m in TYPE_PRICE_RE.finditer(units_text): raw_type, raw_price = m.group(1), m.group(2) unit_type = ("Studio" if raw_type.lower().startswith("studio") else re.sub(r"\s*(?:½|1/2)", "½", raw_type.replace(" ", ""))) num = re.sub(r"[^\d]", "", raw_price) if num and unit_type not in marketing: marketing[unit_type] = num # commodités de l'unité : icônes « dans mon unité » + liste # « aménagements et matériaux exceptionnels » unit_amen = self._icon_labels(units_html) for li in units_soup.select("ul.pl-3 li"): t = li.get_text(" ", strip=True) if t: unit_amen.append(t) # Photos des unités/immeuble (jpg de la galerie RentCafe) images = [u for u in dict.fromkeys(IMG_RE.findall(units_html)) if not re.search(r"logo|icon|chevron|download|lockup", u, re.I)][:25] # page projet : description, services de l'immeuble, téléphone desc = ("Lynk De la Savane (Olymbec) — 303 unités locatives " "intelligentes, studio à 5½, au 5200 De la Savane à Montréal.") building_amen: list[str] = [] phone = "" try: dls_html = self._get_page(DLS_URL) m = re.search(r"]*>([^<]{80,400})

", dls_html) if m: desc = re.sub(r"\s+", " ", m.group(1)).strip()[:600] building_amen = self._icon_labels(dls_html) mt = re.search(r'href="tel:[^"]*?(\d{3})[\s.\-]?(\d{3})[\s.\-]?(\d{4})"', dls_html) if mt: phone = "-".join(mt.groups()) except Exception: pass # plans RentCafe : superficie + fourchette de prix STRUCTURÉES # (data-floorplan-*) et unités disponibles (pages plan, avec cache) plans: dict[str, dict] = {} # type -> données agrégées try: fp_html = self._get_page(FLOORPLANS_URL) seen_slugs: set[str] = set() for a in BeautifulSoup(fp_html, "html.parser").select( "a[data-floorplan-name]"): # seules les ancres pointant vers une page de plan portent les # attributs sqft/price ; chaque plan apparaît 2x (desktop+mobile) href = urljoin(BASE, a.get("href", "")) if "/floorplans/" not in href or "#" in href: continue slug = href.rstrip("/").rsplit("/", 1)[-1] if slug in seen_slugs: continue seen_slugs.add(slug) name = a.get("data-floorplan-name", "") mb = re.match(r"(\d)\s*Bedroom", name, re.I) beds = (0 if name.strip().lower() == "studio" else int(mb.group(1)) if mb else None) unit_type = BEDROOMS_TO_TYPE.get(beds) if beds is not None else None if not unit_type: continue agg = plans.setdefault(unit_type, { "sqft": [], "price": [], "units": [], "images": []}) # superficie « 657 -829 » et prix « 1650 -2293.2 » (attributs) for v in re.findall(r"\d+(?:\.\d+)?", a.get("data-floorplan-sqft", "") or ""): agg["sqft"].append(float(v)) for v in re.findall(r"\d+(?:\.\d+)?", a.get("data-floorplan-price", "") or ""): agg["price"].append(float(v)) # page du plan -> unités disponibles (cache : clé = attributs) key = "v1:" + "|".join([ name, a.get("data-floorplan-sqft", "") or "", a.get("data-floorplan-price", "") or ""]) try: payload = self.detail(f"fp-{slug}", key, lambda u=href: self._fetch_plan(u)) except Exception: payload = {} agg["units"] += payload.get("units", []) agg["images"] += [u for u in payload.get("images", []) if u not in agg["images"]] except Exception: pass # 1 annonce par type (ordre stable), union marketing + plans RentCafe for unit_type in ("Studio", "3½", "4½", "5½"): mk_num = marketing.get(unit_type) plan = plans.get(unit_type) if not mk_num and not plan: continue price = None price_label = "" if plan and plan["price"]: lo, hi = min(plan["price"]), max(plan["price"]) if 100 <= lo <= 20000: price = lo price_label = (f"de {_fmt_price(lo)} à {_fmt_price(hi)} par mois" if hi > lo else f"{_fmt_price(lo)} par mois") elif mk_num: val = float(mk_num) if 100 <= val <= 20000: price = val price_label = f"à partir de {mk_num} $ par mois" area = None amenities = list(unit_amen) if plan and plan["sqft"]: lo_s, hi_s = min(plan["sqft"]), max(plan["sqft"]) if 80 <= lo_s <= 20000: area = lo_s amenities.append( f"Superficie : {int(lo_s)} à {int(hi_s)} pi²" if hi_s > lo_s else f"Superficie : {int(lo_s)} pi²") amenities += [a for a in building_amen if a not in amenities] availability = "" desc_extra = "" if plan and plan["units"]: dispos = [u["dispo"] for u in plan["units"] if u["dispo"]] availability = dispos[0] if dispos else "" nos = ", ".join("#" + u["no"] for u in plan["units"][:12]) desc_extra = (f" — {len(plan['units'])} unité(s) disponible(s)" f" : {nos}") details: dict = {} if phone: details["contact"] = {"phone": phone} ext = f"dls-{unit_type.replace('½', '.5').lower()}" listings.append(Listing( source=self.source_id, external_id=ext, url=UNITS_URL, title=f"Lynk De la Savane — {unit_type}", address=ADDRESS, sector=SECTOR, city="Montréal", unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area, description=(desc + desc_extra)[:700], amenities=list(dict.fromkeys(amenities))[:40], details=details, images=(plan["images"] if plan else []) + images, )) return listings