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%
10.3 KB · 255 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/groupe_mathieu.py : connecteur Groupe Mathieu (groupemathieu.com)5#   Promoteur Rive-Nord : condos locatifs à Blainville (Station 54/56,6#   Le Siège Social, Place Notre Dame), Sainte-Thérèse (Ste-Thé, Quartier7#   Belmont), Terrebonne (Noüvo District) et Laval. WordPress/Elementor rendu8#   serveur : le méga-menu « PROJETS À LOUER » liste les projets par ville ;9#   chaque page projet affiche l'adresse, « Occupation … » et des blocs10#   typologie (« 3 ½ », description pi²/chambres, « À partir de N$ par mois »).11#   Granularité : une annonce par TYPOLOGIE de projet (sinon une par projet12#   quand la page ne détaille pas les typologies). Les projets « 100 % vendu/13#   loué » sont exclus.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type22from .base import BaseConnector2324BASE = "https://groupemathieu.com"2526UNIT_RE = re.compile(r"^\s*(\d)\s*½\s*$")27FROM_RE = re.compile(r"[ÀA]\s*partir\s*de\s*([\d\s  ,]+)\s*\$\s*(?:/|par)?"28                     r"\s*mois", re.I)29AREA_RE = re.compile(r"de\s*([\d\s  ]+)\s*(?:à\s*[\d\s  ]+\s*)?pi", re.I)30BED_RE = re.compile(r"(\d)\s*chambres?\b", re.I)31ADDR_RE = re.compile(32    r"\d{1,5}[^,<>\n]{3,60},?\s*(?:<br\s*/?>)?\s*"33    r"[A-Za-zÀ-ÿ\- ]{3,30},?\s*\(Qu[ée]bec\)\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d")34OCC_RE = re.compile(r"Occupation\s+[^<\n]{2,40}", re.I)35SOLD_RE = re.compile(r"100\s*%\s*(?:vendu|lou[ée])", re.I)36IMG_RE = re.compile(r"https://groupemathieu\.com/wp-content/uploads/"37                    r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)383940class GroupeMathieuConnector(BaseConnector):41    source_id = "groupe_mathieu"42    request_delay = 0.74344    def fetch(self) -> list[Listing]:45        listings: list[Listing] = []46        projects: list[tuple[str, str, str]] = []   # (nom, url, ville)4748        # Découverte primaire : la page /condos-locatifs/ (rendue serveur,49        # stable) liste tous les projets locatifs via des cartes50        # « En savoir plus ». Le méga-menu Jet de l'accueil n'est servi que51        # par certaines variantes de cache Elementor → repli seulement.52        try:53            lhtml = self.get(BASE + "/condos-locatifs/").text54            lsoup = BeautifulSoup(lhtml, "html.parser")55            for a in lsoup.find_all("a", href=True):56                if "en savoir plus" not in a.get_text(" ", strip=True).lower():57                    continue58                href = a["href"].split("?")[0]59                if href.startswith("/"):60                    href = BASE + href61                if "groupemathieu.com" not in href or "promo" in href:62                    continue63                projects.append(("", href, ""))64        except Exception:65            pass6667        if not projects:68            projects = self._projects_from_menu()6970        seen: set[str] = set()71        for name, url, ville in projects:72            slug = url.rstrip("/").split("/")[-1]73            if not slug or slug in seen:74                continue75            seen.add(slug)76            try:77                listings.extend(self._fetch_project(name, url, slug, ville))78            except Exception:79                continue80        return listings8182    def _projects_from_menu(self) -> list[tuple[str, str, str]]:83        # méga-menu « PROJETS À LOUER » : colonnes ville (h5) + items projets84        try:85            html = self.get(BASE + "/").text86        except Exception:87            return []88        soup = BeautifulSoup(html, "html.parser")89        menu = None90        for title in soup.select(".jet-menu-title, .jet-menu-item"):91            if "PROJETS À LOUER" in title.get_text(" ", strip=True).upper():92                menu = title if title.name == "li" else \93                    title.find_parent("li")94                break95        if menu is None:96            return []97        sub = menu.select_one(".jet-sub-mega-menu")98        if sub is None:99            return []100101        projects: list[tuple[str, str, str]] = []102        city = ""103        for el in sub.select("h5.elementor-heading-title,"104                             " li.elementor-icon-list-item a"):105            if el.name == "h5":106                city = el.get_text(" ", strip=True).title()107                continue108            label_el = el.select_one(".elementor-icon-list-text")109            label = label_el.get_text(" ", strip=True) if label_el else ""110            if "locatif" not in label.lower():111                continue112            href = el.get("href", "")113            if href.startswith("/"):114                href = BASE + href115            strong = el.find("strong")116            name = label.replace(strong.get_text(strip=True), "").strip() \117                if strong else label118            if href:119                projects.append((name or href, href.split("?")[0], city))120        return projects121122        seen: set[str] = set()123        for name, url, ville in projects:124            slug = url.rstrip("/").split("/")[-1]125            if not slug or slug in seen:126                continue127            seen.add(slug)128            try:129                listings.extend(self._fetch_project(name, url, slug, ville))130            except Exception:131                continue132        return listings133134    def _fetch_project(self, name: str, url: str, slug: str,135                       city: str) -> list[Listing]:136        html = self.get(url).text137        if SOLD_RE.search(html):138            return []          # projet complet (ex. Vëe « 100% vendu »)139        soup = BeautifulSoup(html, "html.parser")140        text = soup.get_text("\n", strip=True)141142        address = ""143        m = ADDR_RE.search(text.replace("\n", ", "))144        if m:145            address = re.sub(r"\s+", " ", m.group(0)).replace(" ,", ",")146        if not city and address:   # découverte via /condos-locatifs/ : ville147            m = re.search(r",\s*([A-Za-zÀ-ÿ\- ']{3,30}),?\s*\(Qu", address)148            if m:149                city = m.group(1).strip().title()150        if not name:151            h1 = soup.find("h1")152            t = h1.get_text(" ", strip=True) if h1 else ""153            if not t and soup.title:154                t = soup.title.get_text(strip=True).split("|")[0].strip()155            t = re.sub(r"\s*[-–|]\s*Groupe Mathieu\s*$", "", t, flags=re.I)156            name = re.sub(r"\s+", " ", t) or slug157        availability = ""158        m = OCC_RE.search(text)159        if m:160            availability = re.sub(r"\s+", " ", m.group(0)).strip()161        rented = ""162        m = re.search(r"LOU[ÉE] À\s*\n?\s*(\d{1,3}\s*%)", text, re.I)163        if m:164            rented = m.group(1).replace(" ", "")165166        images = [u for u in dict.fromkeys(IMG_RE.findall(html))167                  if not re.search(r"logo|icon|favicon|plan|-\d{2,3}x\d{2,3}\.",168                                   u, re.I)][:20]169170        # blocs typologie, en ordre du document (dédoublés desktop/mobile)171        units: dict[str, dict] = {}172        current: str | None = None173        for el in soup.select("h2.elementor-heading-title,"174                              " .elementor-widget-text-editor p"):175            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))176            if not t:177                continue178            m = UNIT_RE.match(t)179            if m:180                ut = f"{m.group(1)}½"181                if ut in units:182                    current = None      # doublon (version mobile)183                else:184                    units[ut] = {}185                    current = ut186                continue187            if current is None:188                continue189            u = units[current]190            m = FROM_RE.search(t)191            if m and "price" not in u:192                try:193                    u["price"] = float(m.group(1).replace(" ", "")194                                       .replace(" ", "").replace(" ", "")195                                       .replace(",", ""))196                    u["price_label"] = re.sub(r"\s+", " ", m.group(0))197                except ValueError:198                    pass199                current = None          # bloc complet200                continue201            if el.name == "p" and len(t) > 40 and "desc" not in u:202                u["desc"] = t[:400]203                m = AREA_RE.search(t)204                if m:205                    try:206                        u["area"] = float(m.group(1).replace(" ", "")207                                          .replace(" ", "").replace(" ", ""))208                    except ValueError:209                        pass210                m = BED_RE.search(t)211                if m:212                    u["beds"] = float(m.group(1))213214        details: dict = {}215        if rented:216            details["taux_loue"] = rented217218        out: list[Listing] = []219        if units:220            for ut, u in units.items():221                out.append(Listing(222                    source=self.source_id,223                    external_id=f"{slug}-{ut.replace('½', '.5')}",224                    url=url,225                    title=f"{ut} — {name} ({city})",226                    address=address,227                    city=city,228                    unit_type=normalize_unit_type(ut),229                    bedrooms=u.get("beds"),230                    price=u.get("price"),231                    price_label=u.get("price_label", ""),232                    availability=availability,233                    area_sqft=u.get("area"),234                    description=u.get("desc", ""),235                    details=dict(details),236                    images=images,237                ))238        elif availability:   # page sans blocs typologie (ex. Place Notre Dame)239            desc = " ".join(p.get_text(" ", strip=True)240                            for p in soup.find_all("p")241                            if len(p.get_text(strip=True)) > 80)[:900]242            out.append(Listing(243                source=self.source_id,244                external_id=slug,245                url=url,246                title=f"{name} — condos locatifs ({city})",247                address=address,248                city=city,249                availability=availability,250                description=re.sub(r"\s+", " ", desc),251                details=dict(details),252                images=images,253            ))254        return out255