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.4 KB · 214 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/mountbridge.py : connecteur MountBridge (mountbridge.ca)5#   Gestionnaire montréalais (~75 immeubles : Montréal, Longueuil,6#   Sainte-Thérèse…), inventaire complet incluant le projet ELEVA (2330 rue7#   Tupper) — pas de connecteur séparé pour ELEVA. WordPress/Elementor +8#   FacetWP mais les 75 URLs /property/<slug>/ sont dans le HTML statique de9#   /properties/. Chaque fiche affiche ses types d'unités disponibles en10#   rangées .e-loop-item : séquence de h6 label -> valeur (Beds, Baths,11#   Surface Area, Price « Starting at $1,595 », Availability Date). Une12#   annonce par type disponible, id stable <slug>-<beds>b-<baths>sdb.13#   Les fiches sont mises en cache BD (self.detail) clé = hash des rangées.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import json19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing24from .base import BaseConnector2526BASE = "https://mountbridge.ca"27LIST_URL = f"{BASE}/properties/"2829_PROP_RE = re.compile(r"https://mountbridge\.ca/property/([a-z0-9-]+)/")30_IMG_RE = re.compile(31    r'https://mountbridge\.ca/wp-content/uploads/[^"\'\s\\)]+?'32    r'\.(?:jpg|jpeg|webp)', re.I)33_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I)34_ADDR_RE = re.compile(35    r"\d{1,5}[^<>{}|]{3,70}?(?:QC|Québec|Quebec),?\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d")36_PRICE_RE = re.compile(r"\$\s*([\d][\d,\s]*\d|\d)")37# étiquettes des rangées .e-loop-item (tout autre h6 = valeur)38_LABELS = {"available units", "beds", "baths", "surface area", "price",39           "availability date", "plan", "see plan"}4041# Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres)42_BED_TYPE = {"studio": "Studio", "0": "Studio", "1": "3½", "2": "4½",43             "3": "5½", "4": "6½"}444546class MountbridgeConnector(BaseConnector):47    source_id = "mountbridge"48    request_delay = 0.649    max_properties = 90          # garde-fou (75 fiches à l'écriture)50    max_images = 205152    def fetch(self) -> list[Listing]:53        page = self.get(LIST_URL).text54        slugs = list(dict.fromkeys(_PROP_RE.findall(page)))5556        listings: list[Listing] = []57        for i, slug in enumerate(slugs):58            if i >= self.max_properties:59                break60            try:61                listings.extend(self._property_listings(slug))62            except Exception:63                continue64        return listings6566    def _property_listings(self, slug: str) -> list[Listing]:67        url = f"{BASE}/property/{slug}/"68        d = self._fetch_property(url)69        rows = d.get("rows", [])70        if not rows:71            return []72        # cache BD : la fiche complète n'est re-parsée que si les rangées73        # d'unités changent (le hash des rangées sert de clé)74        key = hashlib.sha1(json.dumps(rows, sort_keys=True)75                           .encode("utf-8")).hexdigest()76        payload = self.detail(slug, key, lambda: d)7778        name = payload.get("name") or slug79        address = payload.get("address") or ""80        city = payload.get("city") or "Montréal"81        desc = payload.get("desc") or ""82        amenities = payload.get("amenities") or []83        images = payload.get("images") or []8485        out: list[Listing] = []86        seen: dict[str, int] = {}87        for r in payload.get("rows", []):88            beds = (r.get("beds") or "").strip().lower()89            baths = (r.get("baths") or "").strip()90            pm = _PRICE_RE.search(r.get("price") or "")91            price = None92            if pm:93                try:94                    price = float(re.sub(r"[^\d]", "", pm.group(1)))95                except ValueError:96                    price = None97            if price is not None and not (100 <= price <= 20000):98                price = None99100            ext = f"{slug}-{beds or 'x'}b-{baths or 'x'}sdb"101            seen[ext] = seen.get(ext, 0) + 1102            if seen[ext] > 1:        # doublon de type sur la même fiche103                ext = f"{ext}-{seen[ext]}"104105            unit_type = _BED_TYPE.get(beds, "")106            try:107                bedrooms = 0.0 if beds == "studio" else float(beds)108            except ValueError:109                bedrooms = None110            try:111                bathrooms = float(baths) if baths else None112            except ValueError:113                bathrooms = None114115            label = ("Studio" if beds == "studio"116                     else f"{beds} chambre(s)" if beds else "")117            out.append(Listing(118                source=self.source_id,119                external_id=ext,120                url=url,121                title=f"{name} — {label}" if label else name,122                address=address,123                sector="",124                city=city,125                unit_type=unit_type,126                bedrooms=bedrooms,127                bathrooms=bathrooms,128                price=price,129                price_label=(f"À partir de {int(price)} $/mois"130                             if price else (r.get("price") or "")),131                availability=r.get("avail") or "",132                area_sqft=None,133                description=desc,134                amenities=list(amenities),135                images=list(images),136            ))137        return out138139    def _fetch_property(self, url: str) -> dict:140        """Scrape la fiche : nom (h1), adresse, description « About … »,141        commodités (icônes SVG + libellé), galerie et rangées d'unités."""142        out: dict = {"name": "", "address": "", "city": "Montréal",143                     "desc": "", "amenities": [], "images": [], "rows": []}144        page = self.get(url).text145        soup = BeautifulSoup(page, "html.parser")146147        if soup.h1:148            out["name"] = soup.h1.get_text(" ", strip=True)149150        # adresse civique « 9675 rue Papineau, Montreal QC, H2B 3C8 »151        am = _ADDR_RE.search(soup.get_text(" ", strip=True))152        if am:153            out["address"] = re.sub(r"\s+", " ", am.group(0)).strip()154            cm = re.search(r",\s*([^,]+?)\s+(?:QC|Québec|Quebec)",155                           out["address"])156            if cm:157                c = cm.group(1).strip()158                out["city"] = ("Montréal" if c.lower() in159                               ("montreal", "montréal") else c)160161        # description (« About <nom> »)162        h = soup.find("h2", string=re.compile(r"^About\s|^À propos"))163        if h:164            nxt = h.find_next("p")165            if nxt:166                out["desc"] = nxt.get_text(" ", strip=True)[:600]167168        # commodités : icône SVG suivie du libellé (span)169        amenities: list[str] = []170        for img in soup.select('img[src$=".svg"]'):171            span = img.find_next("span")172            if not span:173                continue174            t = span.get_text(" ", strip=True)175            if t and len(t) < 60 and t not in amenities:176                amenities.append(t)177        out["amenities"] = amenities[:25]178179        # galerie photos (jpg/webp du site, sans logos/miniatures)180        images: list[str] = []181        for u in _IMG_RE.findall(page):182            if _SKIP_IMG.search(u):183                continue184            if u not in images:185                images.append(u)186        out["images"] = images[: self.max_images]187188        # rangées d'unités disponibles : h6 label -> h6 valeur189        for li in soup.select(".e-loop-item"):190            texts = [h.get_text(" ", strip=True) for h in li.select("h6")]191            row: dict = {}192            i = 0193            while i < len(texts):194                lab = texts[i].strip().lower()195                val = ""196                if (i + 1 < len(texts)197                        and texts[i + 1].strip().lower() not in _LABELS):198                    val = texts[i + 1].strip()199                    i += 1200                if lab == "beds":201                    row["beds"] = val202                elif lab == "baths":203                    row["baths"] = val204                elif lab == "surface area":205                    row["sqft"] = val206                elif lab == "price":207                    row["price"] = val208                elif lab == "availability date":209                    row["avail"] = val210                i += 1211            if row.get("beds") or row.get("price"):212                out["rows"].append(row)213        return out214