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
2 days agolast push
HTML 98.9% Python 0.6%
6.2 KB · 154 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/terrasse_pionniers.py : connecteur Terrasse des Pionniers5#   (terrassedespionniers.ca — appartements locatifs neufs long terme au6#   973, rue Saint-Faustin à Mont-Blanc (Laurentides), près de la station7#   de ski ; 16 unités 4½ + 4 unités 5½). WordPress/Elementor + JetEngine8#   rendu SERVEUR : l'accueil expose une carte par TYPOLOGIE (badge de9#   disponibilité dynamique « Disponible »/« Non-Disponible », nb d'unités,10#   prix « à partir de », superficie) ; la fiche liste les inclusions11#   (« Le loyer comprend : ») et la galerie. Granularité TYPOLOGIE12#   (external_id = slug WordPress de la fiche, stable). Le badge de13#   disponibilité est repris tel quel dans `availability` (peut être14#   « Non-Disponible » quand tout est loué — bail au 1er juillet).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type23from .base import BaseConnector2425BASE = "https://terrassedespionniers.ca"26HOME_URL = f"{BASE}/"27ADDRESS = "973, rue Saint-Faustin"28CITY = "Mont-Blanc"2930TYPE_RE = re.compile(r"^\s*(\d)\s*½\s*$")31COUNT_RE = re.compile(r"(\d+)\s*Unités", re.I)32PRICE_RE = re.compile(r"à partir de\s*([\d\s  ,]+)\s*\$", re.I)33AREA_RE = re.compile(r"([\d\s  ]+)\s*pi²")34BADGE_RE = re.compile(r"^(Non-)?Disponible", re.I)35IMG_RE = re.compile(36    r"https://terrassedespionniers\.ca/wp-content/uploads/"37    r"[^\"\s\\]+?\.(?:jpe?g|png|webp)", re.I)38IMG_SIZE_RE = re.compile(r"-\d+x\d+(?=\.(?:jpe?g|png|webp))", re.I)394041class TerrassePionniersConnector(BaseConnector):42    source_id = "terrasse_pionniers"43    request_delay = 0.84445    def fetch(self) -> list[Listing]:46        try:47            soup = BeautifulSoup(self.get(HOME_URL).text, "html.parser")48        except Exception:49            return []50        listings: list[Listing] = []51        for card in soup.select(".jet-listing-grid__item"):52            wrap = card.select_one("[data-url]")53            url = wrap["data-url"] if wrap else ""54            if not url or "/appartements/" not in url:55                continue56            ext_id = url.rstrip("/").rsplit("/", 1)[-1]57            text = re.sub(r"\s+", " ", card.get_text(" ", strip=True))5859            unit_type = ""60            for h in card.select("h1, h2, h3, h4, h5, h6"):61                m = TYPE_RE.match(h.get_text(" ", strip=True))62                if m:63                    unit_type = normalize_unit_type(f"{m.group(1)} 1/2")64                    break65            if not unit_type:66                continue6768            badge = ""69            for h in card.select("h6"):70                m = BADGE_RE.match(h.get_text(" ", strip=True))71                if m:72                    badge = h.get_text(" ", strip=True)73                    break74            price = None75            m = PRICE_RE.search(text)76            if m:77                try:78                    price = float(re.sub(r"[\s  ,]", "", m.group(1)))79                except ValueError:80                    pass81            area = None82            m = AREA_RE.search(text)83            if m:84                try:85                    area = float(re.sub(r"[\s  ]", "", m.group(1)))86                except ValueError:87                    pass88            m = COUNT_RE.search(text)89            nb_units = m.group(1) if m else ""9091            # fiche typologie (cache BD) : description, inclusions, galerie92            detail = self.detail(ext_id, f"{badge}|{price}|{area}",93                                 lambda u=url: self._detail(u))94            details: dict = {}95            if nb_units:96                details["nb_unites"] = nb_units97            listings.append(Listing(98                source=self.source_id,99                external_id=ext_id,          # slug WP de la fiche typologie100                url=url,101                title=f"Appartement {unit_type} — Terrasse des Pionniers",102                address=ADDRESS,103                city=CITY,104                unit_type=unit_type,105                price=price,106                price_label=f"à partir de {price:.0f} $ /mois" if price else "",107                availability=badge,108                area_sqft=area,109                furnished=False,             # « Non meublés » sur la fiche110                description=detail.get("description", "")[:1500],111                amenities=detail.get("amenities") or [],112                details=details,113                images=detail.get("images") or [],114            ))115        return listings116117    # -- fiche typologie (4½ / 5½) ------------------------------------------------118    def _detail(self, url: str) -> dict:119        try:120            html = self.get(url).text121        except Exception:122            return {}123        soup = BeautifulSoup(html, "html.parser")124        out: dict = {}125        text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))126        # description : phrase de présentation + emplacement127        parts: list[str] = []128        m = re.search(r"(Chaque \d ½ offre[^|]+?\.)\s", text)129        if m:130            parts.append(m.group(1).strip())131        m = re.search(r"Emplacement idéal\s*:\s*([^.]+\.)", text)132        if m:133            parts.append("Emplacement : " + m.group(1).strip())134        out["description"] = " ".join(parts)135        # inclusions : « Le loyer comprend : » + description des unités136        amen: list[str] = []137        m = re.search(r"Le loyer comprend\s*:\s*(.+?)Exclus de la location",138                      text)139        if m:140            for item in re.split(r"(?<=[a-zà-ü)\.])\s+(?=[A-ZÉÈÀ])",141                                 m.group(1).strip()):142                item = item.strip(" .")143                if 3 < len(item) < 90 and item not in amen:144                    amen.append(item)145        out["amenities"] = amen[:15]146        imgs = []147        for u in dict.fromkeys(IMG_RE.findall(html)):148            u = IMG_SIZE_RE.sub("", u)149            if not re.search(r"logo|icon|favicon|ImageWithFallback", u, re.I) \150                    and u not in imgs:151                imgs.append(u)152        out["images"] = imgs[:20]153        return out154