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.3 KB · 145 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/cinq_etoiles.py : connecteur Gestion Cinq Étoiles5#   (appartementslocatifs.ca) — 5 immeubles locatifs neufs à Blainville :6#   L'Émeraude (842 Curé-Labelle), Le Cassiopée (832 Curé-Labelle), L'Azur7#   (33 Gaëtan-Mailhiot), Le Célesta (11 Gaëtan-Mailhiot), L'Élysée8#   (40 Gaëtan-Mailhiot). Site GoDaddy Website Builder rendu serveur : la9#   page d'accueil liste les immeubles (nav), chaque page immeuble affiche10#   « <NOM> à partir de N$/mois », l'adresse civique, la ligne des11#   typologies (« Logements 3 1/2 et 3 1/2 + den et 4 1/2 ») et les12#   inclusions. Photos sur le CDN img1.wsimg.com (variantes /:/rs=…13#   dédupliquées). Granularité TYPOLOGIE par immeuble ; le prix « à partir14#   de » n'est associé qu'à la plus petite typologie (jamais inventé).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing23from .base import BaseConnector2425BASE = "https://appartementslocatifs.ca"26CITY = "Blainville"2728# « L'AZUR, à partir de 1575$ » / « LE CASSIOPÉE À PARTIR DE 1510$/ MOIS »29NAME_PRICE_RE = re.compile(30    r"^(.{2,40}?),?\s*à partir de\s*([\d  ]{3,6})\s*\$", re.I)31# ligne composée uniquement de typologies : « Logements 3 1/2 + den et 4 1/2 »32TYPES_LINE_RE = re.compile(33    r"^(?:Logements?\s+)?((?:\d\s*(?:1/2|½)(?:\s*\+\s*den)?)"34    r"(?:\s*(?:et|,)\s*\d\s*(?:1/2|½)(?:\s*\+\s*den)?)*)\s*$", re.I)35TYPE_TOKEN_RE = re.compile(r"(\d)\s*(?:1/2|½)(\s*\+\s*den)?", re.I)36# « 33 Gaëtan-Mailhiot, Blainville, QC , J7C 0W9 » / « Situé au 842, boul. … »37ADDR_RE = re.compile(38    r"^(?:Situé au\s+)?(\d{1,5}[^\n]{3,60}?),?\s*Blainville\b", re.I)39IMG_RE = re.compile(40    r"(?:https?:)?//img1\.wsimg\.com/isteam/ip/[^\"'\s\\,)]+?\.(?:jpe?g|png|webp)",41    re.I)4243# l'adresse de L'Élysée n'apparaît que sur la page d'accueil44FALLBACK_ADDR = {"l%C3%A9lys%C3%A9e": "40 Gaëtan-Mailhiot"}45EXCLUDED_PATHS = {"/", "/service"}464748class CinqEtoilesConnector(BaseConnector):49    source_id = "cinq_etoiles"50    request_delay = 0.85152    def fetch(self) -> list[Listing]:53        listings: list[Listing] = []54        try:55            home = self.get(BASE + "/").text56        except Exception:57            return listings58        soup = BeautifulSoup(home, "html.parser")59        paths = []60        for a in soup.select("a[href]"):61            href = a.get("href") or ""62            if (href.startswith("/") and href not in EXCLUDED_PATHS63                    and href not in paths and "#" not in href):64                paths.append(href)65        for path in paths:66            try:67                listings.extend(self._building(path))68            except Exception:69                continue                       # une page cassée ne bloque pas70        return listings7172    # -- page immeuble : nom, prix « à partir de », adresse, typologies --------73    def _building(self, path: str) -> list[Listing]:74        url = BASE + path75        html = self.get(url).text76        soup = BeautifulSoup(html, "html.parser")77        for tag in soup.select("title, script, style"):78            tag.decompose()                    # le <title> contient des typologies79        lines = [ln for ln in soup.get_text("\n", strip=True).split("\n") if ln]8081        name, price, price_label = "", None, ""82        address = FALLBACK_ADDR.get(path.lstrip("/"), "")83        types: list[str] = []84        amenities: list[str] = []85        for i, line in enumerate(lines):86            m = NAME_PRICE_RE.match(line)87            if m and not name:88                name = m.group(1).strip(" ,–-").title()89                price_label = line.strip()90                try:91                    price = float(re.sub(r"[  ]", "", m.group(2)))92                except ValueError:93                    price = None94            m = TYPES_LINE_RE.match(line)95            if m and not types:96                types = [f"{t[0]}½" + (" + DEN" if t[1] else "")97                         for t in TYPE_TOKEN_RE.findall(m.group(1))]98            m = ADDR_RE.match(line)99            if m and not FALLBACK_ADDR.get(path.lstrip("/")):100                address = m.group(1).strip(" ,")101            if line.lower().startswith("inclusions"):102                rest = line.split(":", 1)[-1].strip() \103                    if ":" in line else line[len("Inclusions"):].strip()104                if len(rest) > 3:105                    amenities.append(rest)106                for nxt in lines[i + 1:i + 4]:107                    if re.match(r"Copyright|Powered|This website", nxt, re.I):108                        break109                    if len(nxt) > 3:110                        amenities.append(nxt)111        if not name or not types:112            return []113114        # photos : dédupliquées sur le chemin de base (avant les variantes /:/)115        images: list[str] = []116        for u in IMG_RE.findall(html):117            full = ("https:" + u) if u.startswith("//") else u118            if full not in images and not re.search(r"logo|favicon", full, re.I):119                images.append(full)120121        # une annonce par typologie ; « à partir de » = plus petite typologie122        out: list[Listing] = []123        for i, ut in enumerate(types):124            first = (i == 0)125            slug = re.sub(r"[^a-z0-9]+", "-",126                          name.lower().replace("é", "e").replace("è", "e")127                          .replace("'", "")).strip("-")128            out.append(Listing(129                source=self.source_id,130                external_id=f"{slug}-{ut.replace('½', '.5').replace(' + DEN', 'den').replace(' ', '')}",131                url=url,132                title=f"{name} — {ut}",133                address=address,134                city=CITY,135                unit_type=ut,136                price=price if first else None,137                price_label=(price_label if first else ""),138                availability="Disponible",139                amenities=list(amenities),140                details=({"price_from": True, "building": name}141                         if first and price else {"building": name}),142                images=images[:15],143            ))144        return out145