SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
12.6 KB · 300 lines python
Raw Blame History
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