SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.7 KB · 216 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/jutras.py : connecteur Habitations Jutras (jutras.com)5#   Promoteur-gestionnaire au Centre-du-Québec et en Estrie. WordPress + Avada6#   (Fusion Builder) : /condos-a-louer/ liste les projets locatifs7#   (/condo/<slug>/) ; chaque page projet expose une section « LES UNITÉS »8#   (ancre id="prix") avec, par type d'unité (3½/4½/5½) : superficie,9#   chambres, stationnements et « À partir de X$/mois ». Une annonce par10#   (projet, type). Ville réelle tirée du <title> ou de la phrase « situé11#   à/dans … » de la page projet. Le projet Prisme (page spéciale12#   /condos-a-louer/drummondville/prisme/) est parsé par regex type+prix.13#   Exclu : Huit Cents (immeuble 50 ans et plus). robots.txt permissif.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://jutras.com"25LIST_URL = f"{BASE}/condos-a-louer/"26PRISME_URL = f"{BASE}/condos-a-louer/drummondville/prisme/"2728# villes du parc Jutras (Centre-du-Québec / Estrie) — vocabulaire fermé,29# utilisé pour lire la ville RÉELLE du <title> ou du texte de la page30_CITIES = ["Drummondville", "Sherbrooke", "Nicolet", "Notre-Dame-du-Bon-Conseil",31           "East Angus", "Victoriaville", "Bécancour", "Trois-Rivières"]3233_TYPE_TOKEN = re.compile(r"^(\d)\s*½$")34_AREA_RE = re.compile(r"^(\d{3,4})(?:\s*à\s*(\d{3,4}))?\s*pi\s*2?\s*$", re.I)35_PRICE_RE = re.compile(r"partir de\s*[\d\s]+\$", re.I)36# page Prisme : « 3 ½ À partir de 1050 $/mois »37_PRISME_UNIT_RE = re.compile(r"(\d)\s*½\s*À partir de\s*([\d\s]+\$\s*/\s*mois)")383940class JutrasConnector(BaseConnector):41    source_id = "jutras"42    request_delay = 0.743    max_pages = 20      # garde-fou : nombre max de pages projet visitées4445    def fetch(self) -> list[Listing]:46        listings: list[Listing] = []4748        # 1) répertoire des projets : liens /condo/<slug>/ + nom d'affichage49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")51        projects: dict[str, str] = {}      # slug -> nom du projet52        for a in soup.select('a[href*="/condo/"]'):53            m = re.search(r"/condo/([^/#?]+)/?", a.get("href", ""))54            name = a.get_text(" ", strip=True)55            if m and name and m.group(1) not in projects:56                projects[m.group(1)] = name5758        # 2) chaque page projet : section « LES UNITÉS » (ancre id="prix")59        for slug, name in list(projects.items())[: self.max_pages]:60            try:61                page = self.get(f"{BASE}/condo/{slug}/").text62            except Exception:63                continue64            listings.extend(self._parse_project(slug, f"{BASE}/condo/{slug}/",65                                                name, page))6667        # 3) projet Prisme (Drummondville) : page spéciale hors /condo/68        try:69            page = self.get(PRISME_URL).text70            listings.extend(self._parse_prisme(page))71        except Exception:72            pass7374        return listings7576    # -- ville réelle du projet --------------------------------------------------77    @staticmethod78    def _project_city(title: str, body_text: str) -> str:79        for c in _CITIES:                      # <title> : « … à louer à Sherbrooke »80            if c.lower() in title.lower():81                return c82        # sinon : phrase « situé … » de la page projet (en ignorant les83        # repères « à X minutes de Y ») — « … situé dans le charmant village84        # de Notre-Dame-du-Bon-Conseil »85        for m in re.finditer(r"[Ss]itu[ée][^.]{0,220}", body_text):86            phrase = re.sub(r"\d+\s+minutes?\s+de\s+\S+", "", m.group(0))87            for c in _CITIES:                  # « village/ville de X » d'abord88                if re.search(r"(?:village|ville|municipalité)\s+de\s+"89                             + re.escape(c), phrase, re.I):90                    return c91            for c in _CITIES:92                if c.lower() in phrase.lower():93                    return c94        return ""9596    # -- page projet standard (/condo/<slug>/) ------------------------------------97    def _parse_project(self, slug: str, url: str, name: str,98                       html: str) -> list[Listing]:99        i = html.find('id="prix"')100        if i < 0:101            return []                          # pas de section unités → rien102        soup = BeautifulSoup(html, "html.parser")103        title_tag = soup.title.get_text() if soup.title else ""104        if re.search(r"50 ans|a[îi]n[ée]s|retraite", title_tag, re.I):105            return []                          # immeuble pour aînés : exclu106        city = self._project_city(title_tag, soup.get_text(" ", strip=True))107108        og = soup.select_one('meta[property="og:image"][content]')109        images = [og["content"]] if og else []110111        # tokens texte de la section unités (jusqu'aux inclusions)112        section = BeautifulSoup(html[i:i + 80000], "html.parser")113        tokens: list[str] = []114        for el in section.find_all(["p", "h2", "h3", "h4"]):115            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))116            if not t:117                continue118            # fin de la section unités : inclusions/galerie/formulaire119            if re.search(r"Inclusions|commodités|Caractéristiques|LOOKBOOK|"120                         r"Réservez|Téléchargez|Galerie", t, re.I):121                break122            if el.name != "p" or re.match(r"^LES UNITÉS$|^Découvrez", t):123                continue                       # titres de section : ignorés124            tokens.append(t)125126        # en-tête de section : dispo projet + mention de prix « à partir de »127        avail_proj, price_proj, intro = "", "", []128        cards: list[dict] = []129        cur: dict | None = None130        for t in tokens:131            m = _TYPE_TOKEN.match(t)132            if m:133                cur = {"type": f"{m.group(1)} ½", "lines": []}134                cards.append(cur)135                continue136            if cur is None:137                intro.append(t)138                if re.search(r"[Oo]ccupation|[Dd]éménagez|[Ee]mménagez", t) \139                        and not avail_proj:140                    avail_proj = t141                if _PRICE_RE.search(t) and not price_proj:142                    price_proj = t143            elif not re.search(r"^Voir (le|la|les)|Prix sujets", t, re.I):144                cur["lines"].append(t)145146        out: list[Listing] = []147        for idx, card in enumerate(cards):148            unit_type = normalize_unit_type(card["type"])149            area_sqft, availability, price_label = None, "", ""150            lines: list[str] = []151            for t in card["lines"]:152                ma = _AREA_RE.match(t)153                if ma and area_sqft is None:154                    area_sqft = float(ma.group(1))   # borne basse si « X à Y pi² »155                elif _PRICE_RE.search(t) and not price_label:156                    price_label = t157                elif re.search(r"[Oo]ccupation", t) and not availability:158                    availability = t159                lines.append(t)160            if not availability:161                availability = avail_proj          # dispo affichée au projet162            # mention projet « Location à partir de X $ par mois » : ne163            # s'applique qu'au type le moins cher (1re carte), jamais aux autres164            if not price_label and idx == 0 and price_proj:165                price_label = price_proj166167            out.append(Listing(168                source=self.source_id,169                external_id=f"{slug}-{card['type'].replace(' ½', '-1-2')}",170                url=f"{url}#prix",171                title=f"{name}{card['type']}",172                sector="",173                city=city,174                unit_type=unit_type,175                price=parse_price(price_label),176                price_label=price_label,177                availability=availability,178                area_sqft=area_sqft,179                description=" · ".join(intro + lines)[:900],180                images=images,181            ))182        return out183184    # -- page spéciale Prisme -------------------------------------------------185    def _parse_prisme(self, html: str) -> list[Listing]:186        soup = BeautifulSoup(html, "html.parser")187        text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))188        og = soup.select_one('meta[property="og:image"][content]')189        images = [og["content"]] if og else []190        m = re.search(r"(?:Emménagez|Déménagez|Occupation)[^.]{0,60}?"191                      r"(?:dès|d[eè]s le)\s+[^.]{3,40}?(?=\s+voir|\s+3\s*½|\.)",192                      text)193        availability = m.group(0).strip() if m else ""194195        out: list[Listing] = []196        for mt in _PRISME_UNIT_RE.finditer(text):197            n, label = mt.group(1), re.sub(r"\s+", " ", mt.group(2)).strip()198            ext_id = f"prisme-{n}-1-2"199            if any(l.external_id == ext_id for l in out):200                continue201            price_label = f"À partir de {label}"202            out.append(Listing(203                source=self.source_id,204                external_id=ext_id,205                url=PRISME_URL,206                title=f"Prisme — {n} ½",207                sector="",208                city="Drummondville",     # « Condos locatifs à Drummondville »209                unit_type=normalize_unit_type(f"{n} ½"),210                price=parse_price(price_label),211                price_label=price_label,212                availability=availability,213                images=images,214            ))215        return out216