SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.2 KB · 182 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/girs.py : connecteur GIRS — Gestion Immobilière de la Rive Sud5#   (girs.ca). Gestionnaire de Sainte-Marie (Beauce) : immeubles locatifs6#   neufs à Scott, Saint-Isidore et La Guadeloupe (Chaudière-Appalaches) +7#   Carleton-sur-Mer et New Richmond (Gaspésie).8#   Site Duda (assets irp.cdn-website.com), rendu CÔTÉ SERVEUR :9#     - le menu « À louer » du hub /location regroupe les immeubles par ville10#       (« Scott, Chaudières-Appalaches » -> /location/scott/rue-amanda-gustave) ;11#     - chaque page d'immeuble est une VITRINE : nom/rue (h1), description,12#       pictogrammes de commodités (BALCON PRIVÉ, CLIMATISATION, « CHAT ET13#       CHIEN ACCEPTÉ (sous conditions) »…), photos — mais AUCUNE liste14#       d'unités, AUCUN prix, AUCUNE disponibilité (location par téléphone /15#       formulaire) -> 1 annonce « catalogue » par immeuble, prix None et16#       availability vide (rien d'inventé) ;17#     - le type d'unités n'est cité qu'en prose (« appartements de type 4 ½ »,18#       « 4 ½ et 5 ½ ») : unit_type n'est rempli que si UN seul type est19#       mentionné, sinon la description fait foi.20#   external_id stable : slug du chemin (/location/scott/rue-amanda-gustave ->21#   scott-rue-amanda-gustave). La section « À vendre » du menu est ignorée.22#   robots.txt : permissif (sitemap public).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import re2728from bs4 import BeautifulSoup2930from ..schema import Listing31from .base import BaseConnector3233BASE = "https://www.girs.ca"34HUB_URL = f"{BASE}/location"3536# « Scott, Chaudières-Appalaches » -> ville « Scott » (le site épelle la37# région « Chaudières-Appalaches », coquille conservée telle quelle côté brut)38_CITY_RE = re.compile(r"^\s*([^,]+?)\s*(?:,|$)")39_TYPE_RE = re.compile(r"\b([1-6])\s*½")40_IMG_RE = re.compile(41    r'https://irp\.cdn-website\.com/[^"\'\s\)]+'42    r'\.(?:jpg|jpeg|png|webp)', re.I)43_SKIP_IMG = re.compile(r"logo|favicon|icon|pexels|silhouette", re.I)444546def _slug(path: str) -> str:47    """« /location/scott/rue-amanda-gustave » -> « scott-rue-amanda-gustave »."""48    return re.sub(r"[^a-z0-9]+", "-",49                  path.strip("/").removeprefix("location/").lower()).strip("-")505152def _mostly_upper(t: str) -> bool:53    letters = [c for c in t if c.isalpha()]54    if len(letters) < 5:55        return False56    return sum(c.isupper() for c in letters) / len(letters) >= 0.7575859class GirsConnector(BaseConnector):60    source_id = "girs"61    request_delay = 0.662    max_images = 126364    # -- hub /location : immeubles « À louer » groupés par ville -----------------65    def _buildings(self) -> list[tuple[str, str, str]]:66        """[(path, nom d'immeuble, ville)] depuis le menu « À louer »."""67        soup = BeautifulSoup(self.get(HUB_URL).text, "html.parser")68        out: list[tuple[str, str, str]] = []69        seen: set[str] = set()70        nav = soup.select_one("ul.unav-top")71        if nav is None:72            return out73        for top_li in nav.find_all("li", recursive=False):74            top_link = top_li.find("a")75            top_txt = top_link.get_text(" ", strip=True) if top_link else ""76            if not top_txt.lower().startswith("à louer"):77                continue   # « À vendre », « Nos services »… ignorés78            for city_li in top_li.select("ul li"):79                city_link = city_li.find("a")80                if not city_link:81                    continue82                m = _CITY_RE.match(city_link.get_text(" ", strip=True))83                city = m.group(1).strip() if m else ""84                for a in city_li.select("ul a[href^='/location/']"):85                    path = a.get("href", "").strip().rstrip("/")86                    name = re.sub(r"\s+", " ", a.get_text(" ", strip=True))87                    # seules les pages d'immeuble (/location/<ville>/<immeuble>)88                    # sont des annonces ; les pages de ville sont ignorées89                    if len(path.strip("/").split("/")) < 3:90                        continue91                    if path and name and path not in seen:92                        seen.add(path)93                        out.append((path, name, city))94        return out9596    # -- page d'immeuble ---------------------------------------------------------97    def _parse_building(self, path: str, name: str, city: str) -> Listing | None:98        url = f"{BASE}{path}"99        soup = BeautifulSoup(self.get(url).text, "html.parser")100        main = soup.select_one("#dm_content") or soup.body101        if main is None:102            return None103104        h1 = main.select_one("h1")105        street = h1.get_text(" ", strip=True) if h1 else name106107        # description et commodités : blocs de paragraphes Duda. Les légendes108        # des pictogrammes sont parfois éclatées sur plusieurs <p> du même109        # bloc (« ENVIRONNEMENT CALME » / « ET PAISIBLE ») -> on travaille au110        # niveau du bloc `div.dmNewParagraph` pour les recoller.111        paras: list[str] = []112        amenities: list[str] = []113        for div in main.select("div.dmNewParagraph"):114            if div.find(re.compile(r"^h[1-6]$")):115                continue        # titres de section (ESPACE DE VIE, COMMODITÉS…)116            t = re.sub(r"\s+", " ",117                       div.get_text(" ", strip=True).replace("", "")118                       ).strip()119            if not t or len(t) < 4:120                continue121            # le test « majuscules » ignore les précisions entre parenthèses122            # (« CHAT ET CHIEN ACCEPTÉ (sous conditions) »)123            if _mostly_upper(re.sub(r"\([^)]*\)", "", t)):124                # pictogramme de commodité (« BALCON PRIVÉ », « INTERNET125                # ILLIMITÉ », « CHAT ET CHIEN ACCEPTÉ (sous conditions) »…)126                if 5 <= len(t) <= 70 and t not in amenities:127                    amenities.append(t)128            elif len(t) >= 60 and len(paras) < 6:129                paras.append(t)130        description = "\n".join(paras)[:1400]131132        # type d'unités : seulement si UN seul type est cité en prose133        types = sorted(set(_TYPE_RE.findall(134            f"{description} {' '.join(amenities)}".replace("1/2", "½")135            .replace(" ½", "½"))))136        unit_type = f"{types[0]}½" if len(types) == 1 else ""137138        # animaux : le site publie « CHAT ET CHIEN ACCEPTÉ (sous conditions) »139        pets = None140        for t in amenities:141            if re.search(r"(?i)chat|chien|animau", t):142                pets = ("conditions" if re.search(r"(?i)condition", t)143                        else "oui")144145        # photos de l'immeuble (CDN Duda), hors logos et images génériques146        images: list[str] = []147        for u in dict.fromkeys(_IMG_RE.findall(str(main))):148            if not _SKIP_IMG.search(u) and u not in images:149                images.append(u)150151        return Listing(152            source=self.source_id,153            external_id=_slug(path),154            url=url,155            title=f"{name}{city}" if city else name,156            address=street if street.lower() != name.lower() or157            re.search(r"(?i)rue|avenue|boulevard|chemin|rang|\d", street)158            else "",159            sector="",160            city=city,161            unit_type=unit_type,162            price=None,             # aucun loyer publié sur le site163            price_label="",164            availability="",        # disponibilités par téléphone : rien d'inventé165            description=description,166            pets=pets,167            amenities=amenities[:30],168            images=images[: self.max_images],169        )170171    # -- fetch -----------------------------------------------------------------172    def fetch(self) -> list[Listing]:173        listings: dict[str, Listing] = {}174        for path, name, city in self._buildings():175            try:176                lst = self._parse_building(path, name, city)177            except Exception:178                continue179            if lst is not None and lst.external_id not in listings:180                listings[lst.external_id] = lst181        return list(listings.values())182