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%
8.9 KB · 209 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/sotramont.py : connecteur Sotramont (sotramont.com)5#   Promoteur montréalais : condos locatifs Liveo (Pointe-Claire/Greenwich6#   1-2-3, Mascouche 1-2, Repentigny 1-2, Bois-Franc) et Curtiss Charlie7#   (Saint-Laurent). WordPress rendu serveur : le portfolio8#   ?category=locatifs liste les cartes article.c--article-project9#   (badge occupation, nom, « quartier – ville », « N condos locatifs »,10#   li typologies « 1-2 chambres sur 10 étages » + « à partir de N $ par11#   mois avec inclusions »). AUCUN inventaire par unité publié → une12#   annonce par PHASE/immeuble ; les cartes « Vendu » ou « En construction »13#   sont exclues. Détail (description, photos) tiré de la page phase quand14#   elle est hébergée sur sotramont.com (Repentigny pointe vers liveo.ca).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re20import time2122from bs4 import BeautifulSoup2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://sotramont.com"28LIST_URL = f"{BASE}/projets-maison-ville-condo-vendre-louer/?category=locatifs"2930SKIP_TAG_RE = re.compile(r"vendu|en\s+construction", re.I)31PRICE_RE = re.compile(r"[àa]\s*partir\s*de\s*([\d\s  ,]+)\s*\$", re.I)32BEDS_RE = re.compile(r"(\d)(?:\s*-\s*(\d))?\s*chambres?", re.I)33UNITS_RE = re.compile(r"(\d{2,4})\s*condos\s*locatifs", re.I)34IMG_RE = re.compile(r"https://sotramont\.com/wp-content/uploads/"35                    r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)363738def _slugify(name: str) -> str:39    s = name.lower()40    s = re.sub(r"[àâä]", "a", s)41    s = re.sub(r"[éèêë]", "e", s)42    s = re.sub(r"[îï]", "i", s)43    s = re.sub(r"[ôö]", "o", s)44    s = re.sub(r"[ûüù]", "u", s)45    s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")46    return s474849class SotramontConnector(BaseConnector):50    source_id = "sotramont"51    request_delay = 0.75253    def fetch(self) -> list[Listing]:54        listings: list[Listing] = []55        # Hoquets réseau transitoires (vagues DNS du nœud 09-10/09-18, resets56        # du VPS) : retry court, puis échec FRANC — un `return []` silencieux57        # devenait « 0 trouvé ok » et déclenchait la dérive/stale à tort.58        html = ""59        last_err: Exception | None = None60        for attempt in range(3):61            try:62                html = self.get(LIST_URL).text63                break64            except Exception as e:  # noqa: BLE001 — retry puis erreur franche65                last_err = e66                if attempt < 2:67                    time.sleep(3 * (attempt + 1))68        else:69            raise RuntimeError(70                f"sotramont : portfolio inaccessible après 3 tentatives "71                f"({last_err!r}) — {LIST_URL}")72        soup = BeautifulSoup(html, "html.parser")7374        arts = soup.select("article.c--article-project")75        if not arts:76            # Vrai zéro = cartes présentes mais aucune locative active ;77            # AUCUNE carte = gabarit remanié ou page vide → erreur franche.78            raise RuntimeError(79                "sotramont : aucune carte projet dans le portfolio — "80                f"site remanié ? — {LIST_URL}")8182        seen: set[str] = set()83        for art in arts:84            try:85                if "project-category-locatifs" not in (art.get("class") or []):86                    continue87                tag_el = art.select_one(".c--article-project__tag")88                tag = tag_el.get_text(" ", strip=True) if tag_el else ""89                if not tag or SKIP_TAG_RE.search(tag):90                    continue        # vendu / en construction / sans badge91                name_el = art.select_one(".c--article-project__text-lg")92                name = (name_el.get_text(" ", strip=True)93                        if name_el else "")94                if not name:95                    continue96                ext_id = _slugify(name)97                if ext_id in seen:98                    continue99                seen.add(ext_id)100101                # « Greenwich – Pointe-Claire » / « Mascouche »102                loc_el = art.select_one(".c--article-project__text-md")103                loc = re.sub(r"\s+", " ", loc_el.get_text(" ", strip=True)104                             if loc_el else "")105                sector, city = "", loc106                if "–" in loc or "-" in loc and "," not in loc:107                    parts = re.split(r"\s*[–—]\s*", loc)108                    if len(parts) == 2:109                        sector, city = parts[0].strip(), parts[1].strip()110                        # doublon d'accessibilité (« Bois-FrancBois-Franc »)111                        half = len(sector) // 2112                        if half and sector[:half] == sector[half:]:113                            sector = sector[:half]114115                lis = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))116                       for li in art.select("li")]117                price = None118                price_label = ""119                typologie = ""120                bedrooms = None121                for t in lis:122                    m = PRICE_RE.search(t)123                    if m and price is None:124                        try:125                            price = float(m.group(1).replace(" ", "")126                                          .replace(" ", "").replace(" ", "")127                                          .replace(",", ""))128                            price_label = t129                        except ValueError:130                            pass131                    m = BEDS_RE.search(t)132                    if m and not typologie:133                        typologie = t   # fourchette (« 1-2 chambres… »)134                        if not m.group(2):      # nombre précis seulement135                            bedrooms = float(m.group(1))136137                details: dict = {}138                if typologie:139                    details["typologies"] = typologie140                h5 = art.select_one(".content-text h5")141                if h5:142                    m = UNITS_RE.search(h5.get_text(" ", strip=True))143                    if m:144                        details["nb_unites"] = m.group(1)145146                a = art.select_one("a.c--article-project__inner")147                url = (a.get("href") or "").split("?")[0] if a else ""148                images: list[str] = []149                fig = art.find("figure")150                if fig:151                    m = re.search(r"url\(([^)]+)\)", fig.get("style", ""))152                    if m:153                        images.append(m.group(1).strip("'\""))154155                d: dict = {}156                if url.startswith(BASE):157                    key = hashlib.sha1(158                        "|".join([tag, name] + lis).encode("utf-8")159                    ).hexdigest()160                    d = self.detail(ext_id, key,161                                    lambda url=url: self._fetch_detail(url))162                elif not url:163                    url = LIST_URL164165                listings.append(Listing(166                    source=self.source_id,167                    external_id=ext_id,168                    url=url,169                    title=f"{name} — condos locatifs ({city})",170                    sector=sector,171                    city=city,172                    bedrooms=bedrooms,173                    price=price,174                    price_label=price_label,175                    availability=tag,176                    description=d.get("description", ""),177                    details=details,178                    images=(d.get("images") or images)[:20],179                ))180            except Exception:181                continue182        return listings183184    def _fetch_detail(self, url: str) -> dict:185        """Page phase : description marketing + galerie photos."""186        out: dict = {}187        try:188            html = self.get(url).text189        except Exception:190            return out191        soup = BeautifulSoup(html, "html.parser")192        main = soup.find("main") or soup193194        paras = [p.get_text(" ", strip=True) for p in main.find_all("p")195                 if len(p.get_text(strip=True)) > 80]196        # sauter les descriptions génériques du méga-menu (répétées partout)197        paras = [p for p in paras198                 if "portfolio" not in p.lower()199                 and "équilibre sain" not in p.lower()200                 and "quel que soit le budget" not in p.lower()]201        if paras:202            out["description"] = re.sub(r"\s+", " ",203                                        " ".join(paras[:3])).strip()[:1200]204        out["images"] = [205            u for u in dict.fromkeys(IMG_RE.findall(html))206            if not re.search(r"logo|icon|favicon|cropped-|plan|"207                             r"-\d{2,3}x\d{2,3}\.", u, re.I)][:20]208        return out209