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/lynk_olymbec.py : connecteur Lynk par Olymbec (lynk.ca)5# Lynk De la Savane — 303 unités au 5200, rue De la Savane, Montréal6# (secteur Namur / De la Savane, CDN-NDG). Site RentCafe derrière un7# challenge Cloudflare (403 pour les robots) : accès direct tenté avec8# en-têtes réalistes, sinon repli sur Firecrawl (rendu JS).9# Sources combinées (1 annonce par type d'unité — Studio, 3½, 4½, 5½) :10# - /lynk-dls/units : prix marketing « à partir de » par type, inclusions11# « dans mon unité » (icônes) et « aménagements et matériaux » (liste) ;12# - /lynk-dls/floorplans : données RentCafe STRUCTURÉES par plan13# (data-floorplan-size/sqft/price) -> superficie et fourchette de prix14# réelles ; pages de plan (avec cache self.detail) -> unités disponibles15# (« # 923 — Disponible maintenant ») ;16# - /lynk-dls : description du projet + « services et commodités » (icônes)17# + téléphone (lien tel:).18# Le projet Lynk Royale (Trois-Rivières) et Lynk Griffintown19# (« bientôt disponible ») sont exclus.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import re24from urllib.parse import urljoin2526from bs4 import BeautifulSoup2728from ..schema import Listing29from .base import BaseConnector3031BASE = "https://www.lynk.ca"32UNITS_URL = f"{BASE}/lynk-dls/units"33FLOORPLANS_URL = f"{BASE}/lynk-dls/floorplans"34DLS_URL = f"{BASE}/lynk-dls"3536ADDRESS = "5200, rue De la Savane, Montréal, QC H4P 0E2"37SECTOR = "Namur / De la Savane (CDN-NDG)"3839# « Studio à partir de 1 380 $ par mois », « 3 ½ à partir de 1 700 $ … »40TYPE_PRICE_RE = re.compile(41 r"(Studio|\d\s*½|\d\s*1/2)\s*à partir de\s*([\d\s ,]+)\s*\$",42 re.I)4344IMG_RE = re.compile(45 r"https://resource\.rentcafe\.com/image/upload/[^\"'\s\\]+?\.(?:jpg|jpeg)",46 re.I)4748# nombre de chambres (data-floorplan-size / nom du plan) -> type Lou-Ka49BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½"}5051BROWSER_HEADERS = {52 "Accept": ("text/html,application/xhtml+xml,application/xml;q=0.9,"53 "image/avif,image/webp,*/*;q=0.8"),54 "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8",55 "Sec-Fetch-Dest": "document",56 "Sec-Fetch-Mode": "navigate",57 "Sec-Fetch-Site": "none",58 "Upgrade-Insecure-Requests": "1",59}606162def _strip_tags(html: str) -> str:63 txt = re.sub(r"<script.*?</script>|<style.*?</style>|<svg.*?</svg>", " ",64 html, flags=re.S | re.I)65 txt = re.sub(r"<[^>]+>", " ", txt)66 return re.sub(r"\s+", " ", txt)676869def _fmt_price(raw: str) -> str:70 """« 1650 » -> « 1 650 $ » (affichage de la fourchette RentCafe)."""71 try:72 n = int(float(raw))73 except (TypeError, ValueError):74 return ""75 return f"{n:,}".replace(",", " ") + " $"767778class LynkOlymbecConnector(BaseConnector):79 source_id = "lynk_olymbec"80 request_delay = 0.881 max_details = 10 # plafond de VRAIES requêtes de pages plan par synchro8283 def __init__(self) -> None:84 super().__init__()85 self._detail_fetches = 08687 def _get_page(self, url: str) -> str:88 """Essaie l'accès direct (en-têtes navigateur), sinon Firecrawl."""89 try:90 resp = self.get(url, headers=BROWSER_HEADERS)91 if "Just a moment" not in resp.text:92 return resp.text93 except Exception:94 pass95 return self.get_rendered(url) # Cloudflare -> rendu Firecrawl9697 # -- sections d'icônes (ysi-icon-widget : libellé + sous-titre) -----------98 @staticmethod99 def _icon_labels(html: str) -> list[str]:100 out: list[str] = []101 soup = BeautifulSoup(html, "html.parser")102 for w in soup.select(".ysi-icon-widget"):103 lines = [t for t in w.stripped_strings]104 if not lines:105 continue106 label = lines[0].strip()107 caption = " ".join(lines[1:]).strip()108 if caption and len(caption) <= 90:109 out.append(f"{label} — {caption}")110 elif label:111 out.append(label)112 return out113114 # -- page d'un plan RentCafe : unités disponibles --------------------------115 def _fetch_plan(self, url: str) -> dict:116 if self._detail_fetches >= self.max_details:117 raise RuntimeError("plafond de requêtes de plans atteint")118 self._detail_fetches += 1119 html = self._get_page(url)120 text = _strip_tags(html)121 units = []122 # « Appartement : # 923 Disponible maintenant Appelez pour connaître… »123 for m in re.finditer(r"Appartement\s*:\s*#\s*(\d+)\s+(.*?)\s*"124 r"(?:Appelez|Contactez|\d[\d\s]*\$)", text):125 dispo = m.group(2).strip()126 if len(dispo) > 60:127 dispo = ""128 units.append({"no": m.group(1), "dispo": dispo})129 images = [u for u in dict.fromkeys(IMG_RE.findall(html))130 if not re.search(r"logo|icon|chevron|download|lockup", u, re.I)]131 return {"units": units, "images": images[:5]}132133 def fetch(self) -> list[Listing]:134 listings: list[Listing] = []135 try:136 units_html = self._get_page(UNITS_URL)137 except Exception:138 return listings139 if not units_html:140 return listings141 units_text = _strip_tags(units_html)142 units_soup = BeautifulSoup(units_html, "html.parser")143144 # prix marketing « à partir de » par type (Studio, 3½, 4½, 5½)145 marketing: dict[str, str] = {}146 for m in TYPE_PRICE_RE.finditer(units_text):147 raw_type, raw_price = m.group(1), m.group(2)148 unit_type = ("Studio" if raw_type.lower().startswith("studio")149 else re.sub(r"\s*(?:½|1/2)", "½",150 raw_type.replace(" ", "")))151 num = re.sub(r"[^\d]", "", raw_price)152 if num and unit_type not in marketing:153 marketing[unit_type] = num154155 # commodités de l'unité : icônes « dans mon unité » + liste156 # « aménagements et matériaux exceptionnels »157 unit_amen = self._icon_labels(units_html)158 for li in units_soup.select("ul.pl-3 li"):159 t = li.get_text(" ", strip=True)160 if t:161 unit_amen.append(t)162163 # Photos des unités/immeuble (jpg de la galerie RentCafe)164 images = [u for u in dict.fromkeys(IMG_RE.findall(units_html))165 if not re.search(r"logo|icon|chevron|download|lockup",166 u, re.I)][:25]167168 # page projet : description, services de l'immeuble, téléphone169 desc = ("Lynk De la Savane (Olymbec) — 303 unités locatives "170 "intelligentes, studio à 5½, au 5200 De la Savane à Montréal.")171 building_amen: list[str] = []172 phone = ""173 try:174 dls_html = self._get_page(DLS_URL)175 m = re.search(r"<p[^>]*>([^<]{80,400})</p>", dls_html)176 if m:177 desc = re.sub(r"\s+", " ", m.group(1)).strip()[:600]178 building_amen = self._icon_labels(dls_html)179 mt = re.search(r'href="tel:[^"]*?(\d{3})[\s.\-]?(\d{3})[\s.\-]?(\d{4})"',180 dls_html)181 if mt:182 phone = "-".join(mt.groups())183 except Exception:184 pass185186 # plans RentCafe : superficie + fourchette de prix STRUCTURÉES187 # (data-floorplan-*) et unités disponibles (pages plan, avec cache)188 plans: dict[str, dict] = {} # type -> données agrégées189 try:190 fp_html = self._get_page(FLOORPLANS_URL)191 seen_slugs: set[str] = set()192 for a in BeautifulSoup(fp_html, "html.parser").select(193 "a[data-floorplan-name]"):194 # seules les ancres pointant vers une page de plan portent les195 # attributs sqft/price ; chaque plan apparaît 2x (desktop+mobile)196 href = urljoin(BASE, a.get("href", ""))197 if "/floorplans/" not in href or "#" in href:198 continue199 slug = href.rstrip("/").rsplit("/", 1)[-1]200 if slug in seen_slugs:201 continue202 seen_slugs.add(slug)203204 name = a.get("data-floorplan-name", "")205 mb = re.match(r"(\d)\s*Bedroom", name, re.I)206 beds = (0 if name.strip().lower() == "studio"207 else int(mb.group(1)) if mb else None)208 unit_type = BEDROOMS_TO_TYPE.get(beds) if beds is not None else None209 if not unit_type:210 continue211 agg = plans.setdefault(unit_type, {212 "sqft": [], "price": [], "units": [], "images": []})213 # superficie « 657 -829 » et prix « 1650 -2293.2 » (attributs)214 for v in re.findall(r"\d+(?:\.\d+)?",215 a.get("data-floorplan-sqft", "") or ""):216 agg["sqft"].append(float(v))217 for v in re.findall(r"\d+(?:\.\d+)?",218 a.get("data-floorplan-price", "") or ""):219 agg["price"].append(float(v))220 # page du plan -> unités disponibles (cache : clé = attributs)221 key = "v1:" + "|".join([222 name, a.get("data-floorplan-sqft", "") or "",223 a.get("data-floorplan-price", "") or ""])224 try:225 payload = self.detail(f"fp-{slug}", key,226 lambda u=href: self._fetch_plan(u))227 except Exception:228 payload = {}229 agg["units"] += payload.get("units", [])230 agg["images"] += [u for u in payload.get("images", [])231 if u not in agg["images"]]232 except Exception:233 pass234235 # 1 annonce par type (ordre stable), union marketing + plans RentCafe236 for unit_type in ("Studio", "3½", "4½", "5½"):237 mk_num = marketing.get(unit_type)238 plan = plans.get(unit_type)239 if not mk_num and not plan:240 continue241242 price = None243 price_label = ""244 if plan and plan["price"]:245 lo, hi = min(plan["price"]), max(plan["price"])246 if 100 <= lo <= 20000:247 price = lo248 price_label = (f"de {_fmt_price(lo)} à {_fmt_price(hi)} par mois"249 if hi > lo else f"{_fmt_price(lo)} par mois")250 elif mk_num:251 val = float(mk_num)252 if 100 <= val <= 20000:253 price = val254 price_label = f"à partir de {mk_num} $ par mois"255256 area = None257 amenities = list(unit_amen)258 if plan and plan["sqft"]:259 lo_s, hi_s = min(plan["sqft"]), max(plan["sqft"])260 if 80 <= lo_s <= 20000:261 area = lo_s262 amenities.append(263 f"Superficie : {int(lo_s)} à {int(hi_s)} pi²"264 if hi_s > lo_s else f"Superficie : {int(lo_s)} pi²")265 amenities += [a for a in building_amen if a not in amenities]266267 availability = ""268 desc_extra = ""269 if plan and plan["units"]:270 dispos = [u["dispo"] for u in plan["units"] if u["dispo"]]271 availability = dispos[0] if dispos else ""272 nos = ", ".join("#" + u["no"] for u in plan["units"][:12])273 desc_extra = (f" — {len(plan['units'])} unité(s) disponible(s)"274 f" : {nos}")275276 details: dict = {}277 if phone:278 details["contact"] = {"phone": phone}279280 ext = f"dls-{unit_type.replace('½', '.5').lower()}"281 listings.append(Listing(282 source=self.source_id,283 external_id=ext,284 url=UNITS_URL,285 title=f"Lynk De la Savane — {unit_type}",286 address=ADDRESS,287 sector=SECTOR,288 city="Montréal",289 unit_type=unit_type,290 price=price,291 price_label=price_label,292 availability=availability,293 area_sqft=area,294 description=(desc + desc_extra)[:700],295 amenities=list(dict.fromkeys(amenities))[:40],296 details=details,297 images=(plan["images"] if plan else []) + images,298 ))299 return listings300