SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.8 KB · 203 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/mondev.py : connecteur Mondev (mondev.ca)5#   Grand constructeur-locateur montréalais (Ville-Marie, Sud-Ouest,6#   Griffintown, Plateau, LaSalle, etc.). Site WordPress/Elementor rendu7#   serveur : la page /apartments-and-condos-for-rent/ liste les immeubles8#   (une carte par immeuble, quartier dans l'URL), et chaque fiche immeuble9#   contient un « PLAN SELECTOR » avec, par typologie, « Starting at $X » ou10#   « not available ». Une annonce par (immeuble, typologie) disponible.11#   Prix « à partir de », adresse, description, commodités et galerie photos.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing20from .base import BaseConnector2122BASE = "https://mondev.ca"23LIST_URL = f"{BASE}/apartments-and-condos-for-rent/"2425_BUILDING_RE = re.compile(26    r'href="(https://mondev\.ca/apartments-and-condos-for-rent/'27    r'([a-z0-9-]+)/([a-z0-9-]+)/)"')2829# Typologie (site anglophone) -> type normalisé Lou-Ka30_TYPE_MAP = {31    "studio": "Studio",32    "1-bedroom": "3½",33    "2-bedroom": "4½",34    "3-bedroom": "5½",35    "4-bedroom": "6½",36    "penthouse": "Penthouse",37    "loft": "Loft",38    "townhouse": "Maison",39}4041_PLAN_RE = re.compile(42    r"(Studio|\d-bedroom|Penthouse|Loft|Townhouse)\s*[-–]\s*"43    r"(?:Starting at\s*\$\s*([\d,]+)|not\s+available)", re.I)4445# Quartier (slug d'URL) -> nom d'affichage46_ZONES = {47    "ahuntsic-cartierville": "Ahuntsic-Cartierville",48    "cote-des-neiges": "Côte-des-Neiges",49    "downtown": "Centre-ville",50    "griffintown": "Griffintown",51    "lasalle": "LaSalle",52    "little-burgundy": "Petite-Bourgogne",53    "old-montreal": "Vieux-Montréal",54    "park-extension": "Parc-Extension",55    "plateau-mont-royal": "Plateau-Mont-Royal",56    "quartier-des-spectacles": "Quartier des spectacles",57    "rosemont-la-petite-patrie": "Rosemont–La Petite-Patrie",58    "sud-ouest": "Sud-Ouest",59    "ville-marie": "Ville-Marie",60    "ville-saint-laurent": "Saint-Laurent",61    "villeray-saint-michel-parc-extension": "Villeray–Saint-Michel–Parc-Extension",62}6364_ADDR_RE = re.compile(65    r"\d[\w\s.'’&,-]{3,70},\s*(?:Montr[ée]al|(?:Ville )?Saint-Laurent|LaSalle|"66    r"Verdun)\b[^<>\"|]{0,60}", re.I)67# entête d'adresse dédiée : « 605 Rue Fullum, Montreal, QC, Canada H3L 0A9 »68_ADDR_HEAD_RE = re.compile(69    r"^\d{2,5}\b.{5,90}(?:,\s*(?:QC|Qu[ée]bec)\b|,\s*Canada\b)", re.I)70_PHONE_RE = re.compile(r'href="tel:\+?([\d\-() .]{7,20})"')71_GALLERY_RE = re.compile(72    r'href="(https://mondev\.ca/wp-content/uploads/[^"]+?\.(?:jpg|jpeg|png|webp))"'73    r'[^>]*data-elementor-lightbox-slideshow="wl_property_gallery_photos[^"]*"')747576class MondevConnector(BaseConnector):77    source_id = "mondev"78    request_delay = 0.679    max_buildings = 45           # garde-fou de crawl8081    def fetch(self) -> list[Listing]:82        listings: list[Listing] = []83        try:84            index = self.get(LIST_URL).text85        except Exception:86            return listings8788        buildings: dict[str, tuple[str, str]] = {}89        for url, zone, slug in _BUILDING_RE.findall(index):90            buildings.setdefault(url, (zone, slug))9192        for i, (url, (zone, slug)) in enumerate(buildings.items()):93            if i >= self.max_buildings:94                break95            try:96                listings.extend(self._parse_building(url, zone, slug))97            except Exception:98                continue99        return listings100101    def _parse_building(self, url: str, zone: str, slug: str) -> list[Listing]:102        html = self.get(url).text103        soup = BeautifulSoup(html, "html.parser")104105        h1 = soup.find("h1")106        name = h1.get_text(" ", strip=True) if h1 else slug.replace("-", " ")107        name = re.sub(r"\s*[-–]\s*(Condo|Apartment)\s+Rentals?\s*$", "", name,108                      flags=re.I).strip()109110        sector = _ZONES.get(zone, zone.replace("-", " ").title())111112        # Adresse civique : entête dédiée (h1-h3 « n° …, QC/Canada »), sinon113        # premier segment de texte ressemblant à une adresse114        address = ""115        for h in soup.select("h1, h2, h3"):116            htxt = h.get_text(" ", strip=True)117            if len(htxt) < 120 and _ADDR_HEAD_RE.match(htxt):118                address = re.sub(r"\s+", " ", htxt).strip().rstrip(",")119                break120        body_txt = soup.get_text("|", strip=True)121        if not address:122            for seg in body_txt.split("|"):123                seg = seg.strip()124                if len(seg) > 120:125                    continue126                am = _ADDR_RE.search(seg)127                if am:128                    address = re.sub(r"\s+", " ",129                                     am.group(0)).strip().rstrip(",")130                    break131132        # Description (meta Yoast)133        description = ""134        meta = soup.find("meta", attrs={"name": "description"})135        if meta and meta.get("content"):136            description = meta["content"].strip()[:600]137138        # Commodités (bloc AMENITIES — icônes structurées)139        amenities = [el.get_text(" ", strip=True)140                     for el in soup.select(".wl_property_amenities li .name")]141        amenities = [a for a in dict.fromkeys(amenities) if a]142143        # Animaux : icône « Pets » dédiée du bloc amenities (structuré)144        pets = "oui" if any(a.strip().lower() in ("pets", "pet friendly")145                            for a in amenities) else None146147        # Contact : lien tel: du bouton d'appel148        details: dict = {}149        pm = _PHONE_RE.search(html)150        if pm:151            digits = re.sub(r"\D", "", pm.group(1))152            if len(digits) == 10:153                details["contact"] = {154                    "phone": f"{digits[0:3]}-{digits[3:6]}-{digits[6:]}"}155156        # Galerie photos (liens lightbox)157        images = list(dict.fromkeys(_GALLERY_RE.findall(html)))[:40]158159        # PLAN SELECTOR : « Studio - Starting at $1,635 » / « not available »160        plan_txt = ""161        marker = soup.find(string=re.compile(r"^\s*PLAN SELECTOR\s*$", re.I))162        if marker:163            widget = marker.find_parent(class_="elementor-widget")164            if widget:165                sib = widget.find_next_sibling(class_="elementor-widget")166                if sib:167                    plan_txt = sib.get_text("\n", strip=True)168        if not plan_txt:169            plan_txt = body_txt.replace("|", "\n")170171        results: list[Listing] = []172        for m in _PLAN_RE.finditer(plan_txt):173            raw_type, raw_price = m.group(1), m.group(2)174            if not raw_price:175                continue                      # typologie non disponible176            type_key = raw_type.lower()177            unit_type = _TYPE_MAP.get(type_key, raw_type)178            try:179                price = float(re.sub(r"[^\d]", "", raw_price))180            except ValueError:181                continue182            if not (100 <= price <= 20000):183                continue184            results.append(Listing(185                source=self.source_id,186                external_id=f"{zone}-{slug}-{type_key}",187                url=url,188                title=f"{name}{unit_type}",189                address=address,190                sector=sector,191                city="Montréal",192                unit_type=unit_type,193                price=price,194                price_label=f"À partir de {int(price)} $/mois",195                availability="Disponible",196                pets=pets,197                description=description,198                amenities=amenities,199                details=dict(details),200                images=images,201            ))202        return results203