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.9 KB · 257 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/mostra.py : connecteur Mostra (condos locatifs gérés par Cogir)5#   Couvre TOUS les sites Mostra dans un seul connecteur :6#   1. mostra.ca (ColdFusion) — Mostra Longueuil et Mostra Candiac : pages7#      /fr/disponibilites-condo-<ville>.html découvertes depuis l'accueil,8#      puis rejeu de l'AJAX officiel POST /inc/ajax/unite.cfm (mêmes champs9#      que le bouton « Plus de résultats ») qui retourne UNIQUEMENT les10#      unités disponibles : emplacement, no d'unité, pi², ch./sb., modèle11#      (3 1/2…), plan PDF et loyer. Une annonce par unité disponible.12#   2. Sites satellites WordPress/Elementor (un immeuble chacun) — prix13#      « à partir de » par type d'unité, rendus serveur :14#      - mostracentropolis.ca (Laval, 550 unités, phases A/B) ;15#      - mostramascouche.ca (/appartements/) ;16#      - mostramaisonneuve.ca (/choisir-mon-unite/, Hochelaga-Maisonneuve) —17#        les types marqués « liste d'attente » sont exclus (aucune dispo).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type, parse_price26from .base import BaseConnector2728BASE = "https://www.mostra.ca"29AJAX_URL = f"{BASE}/inc/ajax/unite.cfm"3031# Pages de disponibilités du site principal (découvertes depuis l'accueil)32DISPO_RE = re.compile(r'href="(?:/)?(fr/disponibilites-condo-([a-z-]+)\.html)"')33# Pages « emplacements » (fiche projet avec la galerie photo)34EMPL_RE = re.compile(35    r'href="(?:/)?(fr/emplacements-condos/\d+-mostra-([a-z-]+)\.html)"')36PROJET_RE = re.compile(r"projetID\s*=\s*'(\d+)'")37PAGE_RE = re.compile(r"var\s+p\s*=\s*'([^']+)'")38NBMAX_RE = re.compile(r'class="nombreMax"\s+value="(\d+)"')39CHSB_RE = re.compile(r"(\d+)\s*ch\.(?:\s*/\s*([\d.]+)\s*sb\.)?", re.I)40PHOTO_MAIN_RE = re.compile(r'(?:src|href)="(DATA/PHOTO/\d+_photo\.jpg)"', re.I)41BAD_IMG_RE = re.compile(r"logo|icon|favicon|plan|etage|-\d+x\d+\.|/20\d\d/0?\d/"42                        r"[A-Z][a-z]+\.png", re.I)4344# Sites satellites : un immeuble par site, prix « à partir de » par type45SATELLITES = [46    {47        "slug": "centropolis",48        "url": "https://mostracentropolis.ca/",49        "name": "Mostra Centropolis",50        "address": "2255, boulevard Daniel-Johnson, Laval",51        "sector": "Centropolis",52        "city": "Laval",53    },54    {55        "slug": "mascouche",56        "url": "https://mostramascouche.ca/appartements/",57        "name": "Mostra Mascouche",58        "address": "Mascouche",59        "sector": "",60        "city": "Mascouche",61    },62    {63        "slug": "maisonneuve",64        "url": "https://mostramaisonneuve.ca/choisir-mon-unite/",65        "name": "Mostra Maisonneuve",66        "address": "5460, rue Sherbrooke Est, Montréal",67        "sector": "Hochelaga-Maisonneuve",68        "city": "Montréal",69    },70]7172# « 3 1/2 À partir de 1550$ » (Mascouche)73SAT_TYPE_RE = re.compile(74    r"(\d\s*(?:½|1/2))\s*\|\s*À partir de\s*([\d\s]+)\s*\$", re.I)75# « Appartements 3 1/2 à louer | à partir de 1 811$ [(liste d'attente)] »76# et « Studios à louer à partir de 1 401$ (liste d'attente) » (Maisonneuve)77SAT_LOUER_RE = re.compile(78    r"(Studios?|Appartements?\s*\d\s*(?:½|1/2))\s*à\s*louer\s*(?:\|\s*)?"79    r"à\s*partir\s*de\s*([\d\s]+)\s*\$\s*(\(liste d'attente\))?", re.I)80# « 3½ - 1 chambre | à louer | Phase A : 1 800 $ | Phase B : 1 540 $ »81SAT_PHASE_BLOC_RE = re.compile(82    r"(\d½)\s*-\s*\d\s*chambres?\s*\|\s*à louer((?:\s*\|\s*Phase\s+[A-Z]\s*:"83    r"\s*[\d\s]+\s*\$)+)", re.I)84SAT_PHASE_RE = re.compile(r"Phase\s+([A-Z])\s*:\s*([\d\s]+)\s*\$", re.I)85SAT_IMG_RE = re.compile(86    r'(?:src|data-lazy-src)="(https?://[^"]*/wp-content/uploads/'87    r'[^"]+\.(?:jpg|jpeg|webp))"', re.I)888990class MostraConnector(BaseConnector):91    source_id = "mostra"92    request_delay = 0.89394    # -- site principal (ColdFusion) : unités disponibles via l'AJAX officiel --95    def _fetch_main(self) -> list[Listing]:96        listings: list[Listing] = []97        try:98            home = self.get(f"{BASE}/").text99        except Exception:100            return listings101        pages = {}                       # slug ville -> chemin relatif102        for path, ville in DISPO_RE.findall(home):103            pages.setdefault(ville, path)104        empl = {}                        # slug ville -> page fiche projet105        for path, ville in EMPL_RE.findall(home):106            empl.setdefault(ville, path)107108        for ville, path in pages.items():109            try:110                page = self.get(f"{BASE}/{path}").text111            except Exception:112                continue113            pm = PROJET_RE.search(page)114            pp = PAGE_RE.search(page)115            nm = NBMAX_RE.search(page)116            if not (pm and pp):117                continue118            nombre_max = nm.group(1) if nm else "500"119120            # photos : galerie de la fiche projet (page « emplacements »)121            images: list[str] = []122            if ville in empl:123                try:124                    fiche = self.get(f"{BASE}/{empl[ville]}").text125                    images = [f"{BASE}/{u}" for u in dict.fromkeys(126                        PHOTO_MAIN_RE.findall(fiche))][:12]127                except Exception:128                    images = []129130            try:131                frag = self.post(AJAX_URL, data={132                    "grandeur": 0, "chambre": 0, "exposition": 0, "prix": 0,133                    "salledebain": 0, "typeunite": 0, "typeunitePage": 0,134                    "emplacement": 0, "etage": 0,135                    "projet": pm.group(1),136                    "nombreAffichage": 500,137                    "nombreMax": nombre_max,138                    "page": pp.group(1),139                }).text140            except Exception:141                continue142            soup = BeautifulSoup(frag, "html.parser")143            for item in soup.select("div.uniteItem[data-id]"):144                try:145                    uid = item["data-id"]146                    cols = {}147                    for i in range(1, 9):148                        el = item.select_one(f".uniteItemCol.c{i}")149                        cols[i] = el.get_text(" ", strip=True) if el else ""150                    emplacement = cols[1]151                    num = re.sub(r"^\s*Unité\s*", "", cols[2]).strip()152                    sqft = cols[3]153                    modele = cols[5]                  # « 4 1/2 »154                    # loyer parfois sans « $ » (ex. « 2485 »)155                    price = parse_price(cols[7])156                    if price is None and re.fullmatch(r"[\d\s]{3,6}",157                                                      cols[7].strip()):158                        try:159                            price = float(re.sub(r"\s", "", cols[7]))160                        except ValueError:161                            price = None162                    bedrooms = bathrooms = None163                    m = CHSB_RE.search(cols[4])164                    if m:165                        bedrooms = float(m.group(1))166                        if m.group(2):167                            bathrooms = float(m.group(2))168                    amenities = []169                    if re.match(r"^\d[\d\s]*$", sqft):170                        amenities.append(f"{sqft} pi²")171                    listings.append(Listing(172                        source=self.source_id,173                        external_id=f"{ville}-{uid}",174                        url=f"{BASE}/{path}",175                        title=f"Mostra {emplacement} — "176                              f"{modele or 'unité'} · unité {num}",177                        address=emplacement,178                        sector="",179                        city=emplacement,180                        unit_type=normalize_unit_type(modele),181                        bedrooms=bedrooms,182                        bathrooms=bathrooms,183                        price=price,184                        price_label=cols[7],185                        availability="Disponible",186                        amenities=amenities,187                        images=images,188                    ))189                except Exception:190                    continue191        return listings192193    # -- satellites WordPress : prix « à partir de » par type d'unité ----------194    def _fetch_satellite(self, cfg: dict) -> list[Listing]:195        listings: list[Listing] = []196        try:197            html = self.get(cfg["url"]).text198        except Exception:199            return listings200        soup = BeautifulSoup(html, "html.parser")201        text = soup.get_text(" | ", strip=True).replace("\xa0", " ")202        text = re.sub(r"\s+", " ", text)203204        images = [u for u in dict.fromkeys(SAT_IMG_RE.findall(html))205                  if not BAD_IMG_RE.search(u)][:15]206207        seen: set[str] = set()208209        def add(unit_raw: str, price_label: str, extra: str = ""):210            ut = normalize_unit_type(unit_raw)211            key = re.sub(r"[^\w.]", "", f"{ut}{extra}".replace("½", ".5"))212            ext_id = f"{cfg['slug']}-{key or 'u'}"213            if ext_id in seen:214                return215            seen.add(ext_id)216            title = f"{cfg['name']} — {ut}" + (f" ({extra})" if extra else "")217            listings.append(Listing(218                source=self.source_id,219                external_id=ext_id,220                url=cfg["url"],221                title=title,222                address=cfg["address"],223                sector=cfg["sector"],224                city=cfg["city"],225                unit_type=ut,226                price=parse_price(price_label),227                price_label=price_label,228                availability="Disponible",229                images=images,230            ))231232        # Centropolis : blocs « 3½ - 1 chambre | à louer | Phase A : … »233        for m in SAT_PHASE_BLOC_RE.finditer(text):234            for phase, prix in SAT_PHASE_RE.findall(m.group(2)):235                add(m.group(1), f"À partir de {prix.strip()}$",236                    extra=f"Phase {phase}")237        # Maisonneuve : « Appartements 3 1/2 à louer à partir de 1 811$ »238        for m in SAT_LOUER_RE.finditer(text):239            if m.group(3):               # « (liste d'attente) » : pas de dispo240                continue241            unit_raw = "Studio" if m.group(1).lower().startswith("studio") \242                else m.group(1)243            add(unit_raw, f"À partir de {m.group(2).strip()}$")244        # Mascouche : « 3 1/2 | À partir de 1550$ »245        for m in SAT_TYPE_RE.finditer(text):246            add(m.group(1), f"À partir de {m.group(2).strip()}$")247        return listings248249    def fetch(self) -> list[Listing]:250        listings = self._fetch_main()251        for cfg in SATELLITES:252            try:253                listings.extend(self._fetch_satellite(cfg))254            except Exception:255                continue256        return listings257