SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.5 KB · 267 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/gestion_montreal.py : connecteur Gestion Montréal5#   (gestion-montreal.com — Montréal, Longueuil, Repentigny, La Prairie).6#   Site immosquare rendu serveur : la page /fr/inscriptions embarque un7#   GeoJSON complet (window.properties_geojson) avec prix, adresse, photos,8#   description, chambres/salles de bain et coordonnées. Les pages détail9#   /fr/inscriptions/<slug> ajoutent les sections structurées « Équipements /10#   Caractéristiques / Services / Animaux » (via self.detail, mises en cache).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import html as html_lib16import json17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_area_sqft, strip_accents22from .base import BaseConnector2324BASE = "https://gestion-montreal.com"25LIST_URL = f"{BASE}/fr/inscriptions"2627# Garde-fou géographique : bounding box du Grand Montréal (CMM approx.)28BBOX = (-74.30, 45.15, -73.10, 45.85)   # lng_min, lat_min, lng_max, lat_max2930# Nombre de chambres -> type d'unité normalisé31_BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}323334def _fix_mojibake(s: str) -> str:35    """Répare 'Montrã©Al' -> 'Montréal' (UTF-8 lu en latin-1 chez la source,36    parfois re-capitalisé ensuite, d'où le 'ã©' minuscule)."""37    if not s:38        return ""39    for bad, good in (("é", "é"), ("ã©", "é"), ("è", "è"), ("ã¨", "è"),40                      ("ô", "ô"), ("ã´", "ô"), ("î", "î"), ("ã®", "î"),41                      ("É", "É"), ("à ", "à ")):42        s = s.replace(bad, good)43    # la source re-capitalise après le 'ã©' : « MontréAl » -> « Montréal »44    s = re.sub(r"([éèêôîà])([A-Z])(?=[a-z])",45               lambda m: m.group(1) + m.group(2).lower(), s)46    return s.strip()474849def _norm_city(raw: str) -> str:50    city = _fix_mojibake(raw)51    if strip_accents(city).lower().startswith("montreal"):52        return "Montréal"53    return city545556# Quartiers/arrondissements connus (repérés dans le titre de l'annonce)57_SECTORS = [58    "Centre-ville", "Vieux-Montréal", "Plateau-Mont-Royal", "Plateau",59    "Mile-End", "Mile End", "Griffintown", "Saint-Henri", "Petite-Italie",60    "Petite-Patrie", "Rosemont", "Hochelaga-Maisonneuve", "Hochelaga",61    "Mercier", "Tétreaultville", "Verdun", "Île-des-Sœurs", "LaSalle",62    "Lachine", "Villeray", "Parc-Extension", "Ahuntsic", "Cartierville",63    "Saint-Laurent", "Saint-Léonard", "Saint-Michel", "Anjou",64    "Montréal-Nord", "Rivière-des-Prairies", "Pointe-aux-Trembles",65    "Côte-des-Neiges", "Notre-Dame-de-Grâce", "NDG", "Outremont",66    "Westmount", "Ville-Marie", "Sud-Ouest", "Pointe-Saint-Charles",67    "Quartier latin", "Quartier des spectacles", "Vieux-Longueuil",68]69_SECTOR_RE = re.compile(70    "|".join(re.escape(s) for s in _SECTORS), re.IGNORECASE)717273def _strip_html(s: str) -> str:74    s = html_lib.unescape(html_lib.unescape(s or ""))   # &amp;eacute; -> é75    s = re.sub(r"<[^>]+>", " ", s)76    return re.sub(r"\s+", " ", s).strip()777879def _sector_from_text(*texts: str) -> str:80    """Repère un quartier connu dans le titre (puis la description)."""81    for text in texts:82        m = _SECTOR_RE.search(_fix_mojibake(text or ""))83        if m:84            sector = m.group(0)85            # recapitalisation propre à partir de la liste de référence86            for ref in _SECTORS:87                if ref.lower() == sector.lower():88                    return ref89            return sector90    return ""919293class GestionMontrealConnector(BaseConnector):94    source_id = "gestion_montreal"95    request_delay = 0.696    max_detail_requests = 150     # vraies requêtes détail par sync (hors cache)9798    def __init__(self) -> None:99        super().__init__()100        # gestion-montreal.com coupe la connexion dès que le User-Agent101        # contient un suffixe de type bot ("LouKaBot/1.0 (+courriel)") ;102        # on se présente donc avec un UA navigateur standard.103        self.session.headers["User-Agent"] = (104            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "105            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36")106107    def fetch(self) -> list[Listing]:108        listings: list[Listing] = []109        self._detail_budget = 0110        try:111            html = self.get(LIST_URL).text112        except Exception:113            return listings114115        m = re.search(r"window\.properties_geojson\s*=\s*", html)116        if not m:117            return listings118        try:119            features, _ = json.JSONDecoder().raw_decode(html[m.end():])120        except ValueError:121            return listings122123        for feat in features:124            try:125                p = feat.get("properties") or {}126                ext_id = str(p.get("id") or "")127                slug = p.get("slug_link") or ""128                if not ext_id or not slug:129                    continue130131                # exclusions : déjà loué, non résidentiel132                flag = (p.get("property_flag") or {})133                if isinstance(flag, dict) and flag.get("fr") == "Loué":134                    continue135                if p.get("sold_rented"):136                    continue137                classification = ((p.get("property_classification") or {})138                                  .get("fr") or "")139                if re.search(r"stationnement|commercial|bureau|local|terrain|"140                             r"industriel|garage|entrep[oô]t", classification, re.I):141                    continue142143                # garde-fou géographique (Grand Montréal seulement)144                coords = (feat.get("geometry") or {}).get("coordinates") or []145                lng, lat = (coords + [None, None])[:2]146                if lng is not None and lat is not None and not (147                        BBOX[0] <= lng <= BBOX[2] and BBOX[1] <= lat <= BBOX[3]):148                    continue149150                city = _norm_city(p.get("locality") or "")151                title_i18n = p.get("title") or {}152                title = _fix_mojibake(title_i18n.get("fr")153                                      or title_i18n.get("en")154                                      or p.get("address_short") or "")155                desc_fr = ((p.get("description") or {}).get("fr") or "")156                sector = (p.get("sublocality") or "").strip() or \157                    _sector_from_text(title, desc_fr[:800])158159                # type d'unité : Studio/Chambre/Maison direct, sinon chambres160                if re.search(r"studio", classification, re.I):161                    unit_type = "Studio"162                elif re.search(r"chambre", classification, re.I):163                    unit_type = "Chambre"164                elif re.search(r"maison", classification, re.I):165                    unit_type = "Maison"166                else:167                    unit_type = _BEDROOMS_TO_TYPE.get(168                        p.get("bedrooms"),169                        normalize_unit_type(classification))170171                price = p.get("price")172                price = float(price) if isinstance(price, (int, float)) else None173                price_label = ((p.get("prices_formatted") or {}).get("fr")174                               or p.get("price_formatted") or "")175176                desc_i18n = p.get("description") or {}177                description = _strip_html(desc_i18n.get("fr")178                                          or desc_i18n.get("en") or "")[:600]179180                amenities: list[str] = []181                if re.search(r"meubl", classification, re.I):182                    amenities.append("Meublé")183                bathrooms = p.get("bathrooms")184                if isinstance(bathrooms, (int, float)) and bathrooms > 0:185                    amenities.append(f"{int(bathrooms)} salle(s) de bain")186187                # superficie structurée (rare : « 800 pc »)188                area_sqft = None189                if p.get("area"):190                    area_sqft = parse_area_sqft(str(p.get("area_formatted")191                                                    or ""))192193                # page détail : sections « Équipements / Caractéristiques /194                # Services / Animaux » (cache BD, plafonné par sync)195                detail_url = f"{BASE}/fr/inscriptions/{slug}"196                key = hashlib.sha1(json.dumps(197                    [p.get("updated_at"), p.get("major_updated_at"),198                     p.get("price"), p.get("availability_date"),199                     title], ensure_ascii=False).encode()).hexdigest()[:16]200                payload = self.detail(ext_id, key,201                                      lambda u=detail_url: self._fetch_detail(u))202                amenities += [a for a in (payload.get("amenities") or [])203                              if a not in amenities]204                details: dict = {}205                if payload.get("phone"):206                    details["contact"] = {"phone": payload["phone"]}207208                images = [u for u in (p.get("assets") or [])209                          if isinstance(u, str)210                          and not re.search(r"placehold|logo|icon", u, re.I)]211212                listings.append(Listing(213                    source=self.source_id,214                    external_id=ext_id,215                    url=detail_url,216                    title=title or _fix_mojibake(p.get("address_short") or ""),217                    address=_fix_mojibake(p.get("address_short")218                                          or p.get("address") or ""),219                    sector=sector,220                    city=city,221                    unit_type=unit_type,222                    price=price,223                    price_label=price_label,224                    availability=(p.get("availability_date") or "")[:10],225                    area_sqft=area_sqft,226                    description=description,227                    amenities=amenities,228                    details=details,229                    images=list(dict.fromkeys(images)),230                    lat=lat,231                    lng=lng,232                ))233            except Exception:234                continue235236        return listings237238    # -- page détail : commodités structurées -----------------------------------239    def _fetch_detail(self, url: str) -> dict:240        """Sections « Autres » de la fiche : Équipements, Caractéristiques241        Intérieures/Extérieures, Services, Animaux, Transport (items <small>).242        Retourne {} au-delà du budget de requêtes (les hits de cache ne243        passent pas par ici)."""244        if self._detail_budget >= self.max_detail_requests:245            return {}246        self._detail_budget += 1247        try:248            html = self.get(url).text249        except Exception:250            return {}251        soup = BeautifulSoup(html, "html.parser")252        amenities: list[str] = []253        for small in soup.select("div.card-body ul li ul li small"):254            t = re.sub(r"\s+", " ", small.get_text(" ", strip=True))255            if t and t not in amenities:256                amenities.append(t)257        payload: dict = {}258        if amenities:259            payload["amenities"] = amenities260        tel = soup.select_one('a[href^="tel:"]')261        if tel:262            m = re.search(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})",263                          tel.get("href", ""))264            if m:265                payload["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"266        return payload267