SPB Git

spb/lou-ka Public

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

HTML 99.7%
13.5 KB · 299 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/immomarketing.py : connecteur IMMOMARKETING / Immoappart5#   (immoappart.ca). Le portail couvre la région de Québec ET le Grand6#   Montréal (île de Montréal, Laval, Longueuil/Rive-Sud) : on crawle les7#   pages de secteurs (les plus précises d'abord pour un meilleur secteur).8#   Une annonce par unité (fiche /appartement/<slug>/) ; les fiches passent9#   par le cache BD self.detail() (clé = hash de la carte liste) : seules10#   les fiches nouvelles ou modifiées sont re-téléchargées.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import re1617from bs4 import BeautifulSoup1819from ..schema import (Listing, infer_city, normalize_unit_type, parse_price,20                      strip_accents)21from .base import BaseConnector2223BASE = "https://immoappart.ca"24# page de secteur -> (secteur par défaut, ville par défaut). Ordre important :25# pages précises d'abord (le premier secteur rencontré est conservé).26SECTOR_PAGES = {27    # Région de Québec28    f"{BASE}/appartements-a-louer/quebec/lebourgneuf/": ("Lebourgneuf", "Québec"),29    f"{BASE}/appartements-a-louer/quebec/saint-roch-2/": ("Saint-Roch", "Québec"),30    # Île de Montréal31    f"{BASE}/appartements-a-louer/montreal/ahuntsic-cartierville/":32        ("Ahuntsic-Cartierville", "Montréal"),33    f"{BASE}/appartements-a-louer/montreal/cote-des-neiges/":34        ("Côte-des-Neiges", "Montréal"),35    f"{BASE}/appartements-a-louer/montreal/saint-leonard/":36        ("Saint-Léonard", "Montréal"),37    f"{BASE}/appartements-a-louer/saint-laurent/": ("Saint-Laurent", "Montréal"),38    f"{BASE}/appartements-a-louer/montreal/": ("", "Montréal"),39    # Laval40    f"{BASE}/appartements-a-louer/laval/": ("", "Laval"),41    # Rive-Sud (page agrégée en dernier : villes déduites fiche par fiche)42    f"{BASE}/appartements-a-louer/saint-lambert/": ("", "Saint-Lambert"),43    f"{BASE}/appartements-a-louer/longueuil/": ("", "Longueuil"),44    f"{BASE}/appartements-a-louer/rive-sud/": ("", ""),45}46# secteur affiché sur la fiche (bandeau .city) -> vraie ville, hors région Qc47_SECTOR_CITY = {48    "montreal": "Montréal", "ahuntsic": "Montréal",49    "ahuntsic-cartierville": "Montréal", "cote-des-neiges": "Montréal",50    "saint-leonard": "Montréal", "saint-laurent": "Montréal",51    "ville saint-laurent": "Montréal",52    "laval": "Laval",53    "longueuil": "Longueuil", "saint-hubert": "Longueuil",54    "greenfield park": "Longueuil", "vieux-longueuil": "Longueuil",55    "saint-lambert": "Saint-Lambert", "brossard": "Brossard",56    "boucherville": "Boucherville", "chambly": "Chambly",57    "candiac": "Candiac", "la prairie": "La Prairie",58    "richelieu": "Richelieu", "mcmasterville": "McMasterville",59    "saint-bruno": "Saint-Bruno-de-Montarville",60    "saint-bruno-de-montarville": "Saint-Bruno-de-Montarville",61    "saint-hilaire": "Mont-Saint-Hilaire",62    "mont-saint-hilaire": "Mont-Saint-Hilaire",63    "sainte-julie": "Sainte-Julie", "varennes": "Varennes",64    "beloeil": "Belœil",65}66IMG_RE = re.compile(r"https://immoappart\.ca/wp-content/uploads/"67                    r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)686970class ImmomarketingConnector(BaseConnector):71    source_id = "immomarketing"72    request_delay = 0.673    max_details = 120        # garde-fou de fetch des fiches (Qc + Grand Mtl)7475    def fetch(self) -> list[Listing]:76        # 1) Fiches référencées par les pages de secteurs (Qc + Grand Mtl).77        #    La carte liste (adresse, prix, type, badge) sert de clé de cache :78        #    la fiche n'est re-téléchargée que si sa carte a changé.79        pages: dict[str, tuple[str, str, str]] = {}  # url -> (secteur, ville, clé)80        for sector_url, (sector, city) in SECTOR_PAGES.items():81            try:82                html = self.get(sector_url).text83            except Exception:84                continue85            soup = BeautifulSoup(html, "html.parser")86            for a in soup.select('a[href*="/appartement/"]'):87                href = (a.get("href") or "").split("?")[0]88                if not re.search(r"/appartement/[a-z0-9\-]+/?$", href):89                    continue90                card_txt = re.sub(r"\s+", " ", a.get_text(" ", strip=True))91                key = hashlib.sha1(card_txt.encode("utf-8")).hexdigest()[:20]92                pages.setdefault(href.rstrip("/") + "/", (sector, city, key))9394        # 2) Fiches détaillées (via le cache BD ; budget de vraies requêtes)95        self._fetched = 096        listings: list[Listing] = []97        for url, (sector, city, key) in pages.items():98            slug = url.rstrip("/").split("/")[-1]99            try:100                payload = self.detail(slug, key,101                                      lambda u=url: self._fetch_detail(u))102            except Exception:103                continue104            if not payload:      # fiche commerciale / stationnement / rangement105                continue106            try:107                listings.append(self._build(url, slug, sector, city, payload))108            except Exception:109                continue110        return listings111112    # -- fiche détaillée -> payload JSON-sérialisable (mis en cache) ------------113    def _fetch_detail(self, url: str) -> dict:114        if self._fetched >= self.max_details:115            raise RuntimeError("budget de requêtes détail atteint")116        self._fetched += 1117        html = self.get(url).text118        soup = BeautifulSoup(html, "html.parser")119120        h1 = soup.find("h1")121        title = h1.get_text(" ", strip=True) if h1 else ""122        if re.search(r"Commercial|Stationnement|Rangement", title, re.I):123            return {}124125        # Bandeau : secteur / type / prix / disponibilité126        band = soup.select_one("p.selected-appart-title")127        band_sector = unit_type = price_label = ""128        if band:129            size = band.select_one(".size")130            unit_type = size.get_text(strip=True) if size else ""131            price = band.select_one(".price")132            price_label = price.get_text(strip=True) if price else ""133            city_el = band.select_one(".city")134            if city_el:135                band_sector = city_el.get_text(strip=True)136137        availability = ""138        for s in soup.find_all(string=re.compile("Date de disponibilité")):139            parent = s.find_parent("p")140            if parent:141                cand = re.sub(r".*Date de disponibilité\s*:?\s*", "",142                              parent.get_text(" ", strip=True)).strip()143                if cand:144                    availability = cand145                    break146147        # Adresse : h2 sous le h1148        address = ""149        for h2 in soup.find_all("h2"):150            t = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))151            if re.match(r"^\d{1,5},?\s+\S[^,]{1,60},", t):152                address = t153                break154        if not address:155            # h2 « numéro + rue » sans virgule finale (ex. « 570, Charest est »)156            for h2 in soup.find_all("h2"):157                t = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))158                if len(t) <= 70 and re.match(r"^\d{1,5},?\s+\S", t) \159                        and not re.search(r"mois|gratuit|offre|promo|rabais",160                                          t, re.I):161                    address = t162                    break163        if not address:164            m = re.search(r"(?<![\d½])\b\d{2,5}\s+(?:rue|boulevard|avenue|"165                          r"chemin|côte)[^,<>]{2,50},\s*[^,<>]{2,40},\s*QC",166                          re.sub(r"\s+", " ", soup.get_text(" ", strip=True)),167                          re.I)168            if m:169                address = m.group(0)170        if not address:171            # dernier recours : le titre commence parfois par l'adresse172            m = re.match(r"^(\d{1,5},?\s+.{3,60}?)\s+[-–]\s+\d\s*½", title)173            if m:174                address = m.group(1).strip()175176        # Photos : galerie de la fiche (éviter les fiches « même secteur »)177        images: list[str] = []178        gallery = soup.select_one(".appart-gallery") or soup179        for el in gallery.select("img[src], a[href]"):180            u = (el.get("src") or el.get("href") or "").split("?")[0]181            if IMG_RE.match(u) and not re.search(r"logo|icon|favicon", u, re.I):182                images.append(u)183        banner = soup.select_one(".banner-appart img[src]")184        if banner:185            images.insert(0, banner["src"].split("?")[0])186        images = list(dict.fromkeys(images))[:25]187188        # Description complète (.the-content : promotions, inclusions de189        # loisirs, informations supplémentaires — animaux, fumeur, contact)190        desc = ""191        content = soup.select_one(".the-content")192        if content:193            desc = re.sub(r"\s+", " ",194                          content.get_text(" ", strip=True)).strip()[:1800]195        if not desc:196            og = soup.find("meta", attrs={"property": "og:description"})197            desc = og["content"].strip()[:600] if og and og.get("content") else ""198199        # Commodités : liste « Inclusions » (dropdown dédié de la fiche)200        amenities: list[str] = []201        specs: dict[str, str] = {}202        for dd in soup.select(".single-dropdown"):203            trig = dd.select_one(".trigger")204            label = trig.get_text(strip=True).lower() if trig else ""205            for li in dd.select("li"):206                t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))207                if not t:208                    continue209                m = re.match(r"^([\w' éèêàâîôû-]{2,25})\s*:\s*(.+)$", t)210                if "caractéristiques" in label and m:211                    specs[m.group(1).strip().lower()] = m.group(2).strip()212                elif "inclusion" in label and 3 < len(t) < 80:213                    amenities.append(t)214        if not amenities:215            # repli : listes à puces de la section descriptive216            for li in soup.select("main li"):217                t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))218                if t and 3 < len(t) < 80 and "http" not in t and ":" not in t:219                    amenities.append(t)220        amenities = list(dict.fromkeys(amenities))[:15]221222        # Coordonnées GPS de la carte interactive de la fiche223        lat = lng = None224        map_el = soup.select_one("[data-lat][data-lng]")225        if map_el:226            try:227                lat = float(map_el["data-lat"])228                lng = float(map_el["data-lng"])229            except (ValueError, KeyError):230                lat = lng = None231232        return {233            "title": title, "band_sector": band_sector,234            "unit_type": unit_type, "price_label": price_label,235            "availability": availability, "address": address,236            "description": desc, "amenities": amenities, "specs": specs,237            "images": images, "lat": lat, "lng": lng,238        }239240    # -- payload -> Listing ------------------------------------------------------241    def _build(self, url: str, slug: str, sector: str, page_city: str,242               payload: dict) -> Listing:243        if payload.get("band_sector"):244            sector = payload["band_sector"]245        address = payload.get("address", "")246247        # ville : secteur du bandeau s'il correspond à une ville du Grand248        # Montréal, sinon ville de la page de secteur, sinon adresse/Québec249        key = strip_accents((sector or "").strip().lower())250        if key in _SECTOR_CITY:251            city = _SECTOR_CITY[key]252        elif page_city:253            city = infer_city(sector, default=page_city)254        else:255            # page agrégée (ex. Rive-Sud) : dernier segment de l'adresse256            city = ""257            if address:258                parts = [p.strip() for p in address.split(",")259                         if p.strip() and not re.fullmatch(260                             r"QC|Qu[ée]bec", p.strip(), re.I)]261                if len(parts) >= 2 and not parts[-1][:1].isdigit():262                    city = parts[-1]263            city = city or infer_city(sector, default="Québec")264        if sector and sector.lower() == city.lower():265            sector = ""              # éviter secteur == ville (redondant)266267        # spécifications structurées (chambres, salles de bain) -> description268        specs = payload.get("specs") or {}269        desc = payload.get("description", "")270        bits = []271        if specs.get("chambre"):272            bits.append(f"{specs['chambre']} chambre(s)")273        if specs.get("salle de bain"):274            bits.append(f"{specs['salle de bain']} salle(s) de bain")275        if bits:276            desc = (desc + " — " if desc else "") + ", ".join(bits) + "."277278        price_label = payload.get("price_label") or specs.get("prix", "")279        unit_type = payload.get("unit_type") or specs.get("pièces", "")280281        return Listing(282            source=self.source_id,283            external_id=slug,284            url=url,285            title=payload.get("title") or slug,286            address=address,287            sector=sector,288            city=city,289            unit_type=normalize_unit_type(unit_type),290            price=parse_price(price_label),291            price_label=price_label,292            availability=payload.get("availability", ""),293            description=desc,294            amenities=list(payload.get("amenities") or []),295            images=list(payload.get("images") or []),296            lat=payload.get("lat"),297            lng=payload.get("lng"),298        )299