SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
3 days agolast push
HTML 98.9% Python 0.6%
9.0 KB · 220 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/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