SPB Git

spb/lou-ka Public

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

HTML 99.7%
12.6 KB · 282 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/sgiq.py : connecteur SGIQ — Société de Gestion Immobilière du5#   Québec (gestionimmobilierequebec.com). Liste paginée /immeubles?page=N6#   (rendu serveur) + fiches /fiche/<id> pour images, description, commodités7#   (avec cache BD self.detail) + endpoint JSON getMarkers pour lat/lng.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import hashlib12import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price, strip_accents17from .base import BaseConnector1819BASE = "https://gestionimmobilierequebec.com"20LIST_URL = f"{BASE}/immeubles"21# Endpoint AJAX du site (recherche/carte) : POST a=getMarkers&form=dispo=22# retourne [titre, "lat, ", "lng", popup, popup, "3-1-2", …] pour CHAQUE fiche.23MARKERS_URL = f"{BASE}/mod/act_p/ImmeubleAct.php"2425# Secteurs connus de l'agglomération de Québec (localité affichée après la26# virgule dans le titre) — tout le reste (hors Québec/Lévis) est exclu.27_QC_SECTORS = {28    "quebec", "ville de quebec", "sainte-foy", "ste-foy", "sillery", "limoilou",29    "beauport", "charlesbourg", "vanier", "loretteville", "val-belair",30    "l'ancienne-lorette", "ancienne-lorette", "saint-augustin",31    "saint-augustin-de-desmaures", "cap-rouge", "saint-roch", "st-roch",32    "saint-sauveur", "st-sauveur", "montcalm", "duberger", "les saules",33    "neufchatel", "lebourgneuf", "lac-saint-charles", "saint-emile", "st-emile",34    "wendake", "cite-limoilou", "la cite-limoilou",35}3637_IMG_RE = re.compile(38    r"//gestionimmobilierequebec\.com/mod/file/ImmeubleSliderFile/"39    r"[0-9a-f]+\.(?:jpg|jpeg|png|webp)", re.I)404142class _FetchBudget(Exception):43    """Plafond de requêtes fiche atteint pour cette synchronisation."""444546class SGIQConnector(BaseConnector):47    source_id = "sgiq"48    request_delay = 0.549    max_pages = 15               # garde-fou de pagination50    max_details = 250            # garde-fou de fetch des fiches51    max_fetch_per_sync = 160     # vraies requêtes fiche par sync (cache exclu)5253    # -- helpers ---------------------------------------------------------------54    @staticmethod55    def _city_from_locality(locality: str) -> str | None:56        """Ville normalisée, ou None si hors agglomération Québec/Lévis."""57        key = strip_accents(locality.strip().lower())58        if not key:59            return "Québec"60        if infer_city(locality, default="") == "Lévis":61            return "Lévis"62        if key in _QC_SECTORS:63            return "Québec"64        return None6566    def _fetch_markers(self) -> dict[str, tuple[float, float, str]]:67        """Coordonnées + type par fiche via l'endpoint carte (1 seul POST).6869        `form=dispo=` désactive le filtre de disponibilité : les 147 fiches70        sont retournées. Format : [html, "lat, ", "lng", popup, popup, type].71        """72        markers: dict[str, tuple[float, float, str]] = {}73        try:74            resp = self.session.post(75                MARKERS_URL, data={"a": "getMarkers", "form": "dispo="},76                timeout=self.timeout)77            resp.raise_for_status()78            for row in resp.json():79                if not isinstance(row, list) or len(row) < 6:80                    continue81                m = re.search(r"/fiche/(\d+)", str(row[3]))82                if not m:83                    continue84                try:85                    lat = float(str(row[1]).strip(" ,"))86                    lng = float(str(row[2]).strip(" ,"))87                except (TypeError, ValueError):88                    continue89                typ = str(row[5] or "").replace("-1-2", " 1/2")  # "3-1-2" -> "3 1/2"90                markers[m.group(1)] = (lat, lng, typ)91        except Exception:92            pass93        return markers9495    # -- fetch -----------------------------------------------------------------96    def fetch(self) -> list[Listing]:97        listings: dict[str, Listing] = {}9899        # 1) Pagination de la liste (?page=N ; total dans input#total_page)100        total_pages = 1101        page = 1102        while page <= total_pages and page <= self.max_pages:103            try:104                html = self.get(LIST_URL if page == 1 else f"{LIST_URL}?page={page}").text105            except Exception:106                break107            soup = BeautifulSoup(html, "html.parser")108            tp = soup.select_one("input#total_page")109            if tp and (tp.get("value") or "").isdigit():110                total_pages = int(tp["value"])111112            for card in soup.select("div.preview-immeuble a[href*='/fiche/']"):113                m = re.search(r"/fiche/(\d+)", card.get("href", ""))114                if not m:115                    continue116                ext_id = m.group(1)117                if ext_id in listings:118                    continue119                title = (card.get("title") or "").strip()120                if not title:121                    img = card.select_one("img[alt]")122                    title = (img.get("alt") or "").strip() if img else ""123                # Formats observés : "925-1A rue Liénard, Québec",124                # "920-1F Av. Myrand, Québec, QC G1V 2V9", "830 avenue Turnbull"125                tokens = [t.strip() for t in title.split(",") if t.strip()]126                locality = ""127                for tok in tokens[1:]:128                    tok_clean = re.sub(r"\bQC\b|\bG\d[A-Z]\s?\d[A-Z]\d\b", "", tok).strip()129                    if tok_clean:130                        locality = tok_clean131                        break132                address = tokens[0] if tokens else title133                city = self._city_from_locality(locality)134                if city is None:          # hors Québec / Lévis135                    continue136                cat_el = card.select_one("div.text p")137                category = cat_el.get_text(strip=True) if cat_el else ""138                if re.search(r"stationnement|commercial|rangement|garage",139                             category, re.I):140                    continue141                price_el = card.select_one(".background-price strong")142                price_label = f"{price_el.get_text(strip=True)} $ /Mois" if price_el else ""143                sector = locality if strip_accents(locality.lower()) not in ("quebec",) else ""144                listings[ext_id] = Listing(145                    source=self.source_id,146                    external_id=ext_id,147                    url=f"{BASE}/fiche/{ext_id}",148                    title=title or f"Logement {ext_id}",149                    address=address,150                    sector=sector,151                    city=city,152                    price=parse_price(price_label),153                    price_label=price_label,154                    description=category,155                )156            page += 1157158        # 2) lat/lng + type structurés via l'endpoint carte (1 seul POST)159        markers = self._fetch_markers()160        for ext_id, (lat, lng, typ) in markers.items():161            lst = listings.get(ext_id)162            if lst:163                lst.lat, lst.lng = lat, lng164                if typ:165                    lst.unit_type = normalize_unit_type(typ)166167        # 3) Fiches détaillées : images, description, commodités — via le168        #    cache BD self.detail() : la fiche n'est re-téléchargée que si la169        #    carte liste (titre/prix/catégorie) a changé.170        fetched = 0171        for i, lst in enumerate(listings.values()):172            if i >= self.max_details:173                break174            key = hashlib.sha1(175                f"{lst.title}|{lst.price_label}|{lst.description}"176                .encode("utf-8")).hexdigest()177178            def _fetch(url=lst.url):179                nonlocal fetched180                if fetched >= self.max_fetch_per_sync:181                    raise _FetchBudget(url)   # ni requête, ni mise en cache vide182                fetched += 1183                return self._fetch_detail(url)184185            try:186                payload = self.detail(lst.external_id, key, _fetch)187            except Exception:188                payload = {}189            if payload:190                self._apply_detail(lst, payload)191192        return list(listings.values())193194    # -- fiche détail ----------------------------------------------------------195    def _fetch_detail(self, url: str) -> dict:196        """Télécharge une fiche et en extrait le payload brut (cacheable)."""197        detail = self.get(url).text198        dsoup = BeautifulSoup(detail, "html.parser")199200        images = ["https:" + u for u in dict.fromkeys(_IMG_RE.findall(detail))][:30]201202        # description : bloc gauche de « Description et remarques » (sans les203        # items « Chauffé : Oui » qui vont dans les commodités)204        desc_el = dsoup.select_one(205            "div.description div.block-left > div.text:not(.description-item)") \206            or dsoup.select_one("div.text.description-content")207        desc = ""208        if desc_el:209            desc = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True))210        if not desc:  # repli : tout le corps de la fiche211            body = dsoup.get_text(" ", strip=True)212            m = re.search(r"Description et remarques\s*(.+?)(?:Vous pourriez aussi aimer|Siège social)",213                          body)214            desc = re.sub(r"\s+", " ", m.group(1)) if m else ""215216        # commodités : "Chauffé : Non", "1 chambre", "Chiens permis", ...217        amenities: list[str] = []218        for el in dsoup.select("div.description-item div, div.block-right div.icon div"):219            t = el.get_text(" ", strip=True)220            if t and len(t) < 60 and t not in amenities:221                amenities.append(t)222223        # icône « animal » structurée (filtre Chiens acceptés du site)224        pets_icon = ""225        icon = dsoup.select_one("div.block-right div.icon img[src*='icon_animal']")226        if icon:227            div = icon.find_next_sibling("div")228            pets_icon = div.get_text(" ", strip=True) if div else ""229230        return {"images": images, "description": desc[:800],231                "amenities": amenities, "pets_icon": pets_icon}232233    def _apply_detail(self, lst: Listing, payload: dict) -> None:234        """Applique le payload d'une fiche (frais ou depuis le cache BD)."""235        desc = payload.get("description") or ""236        if payload.get("images"):237            lst.images = payload["images"]238        if desc:239            lst.description = desc240        if payload.get("amenities"):241            lst.amenities = payload["amenities"]242243        # animaux : icône dédiée du site ("Chiens permis")244        pi = strip_accents((payload.get("pets_icon") or "").lower())245        if pi:246            lst.pets = "non" if re.search(r"\bnon\b|refus|interdit", pi) else "oui"247248        # type d'unité depuis la description ("3 1/2 LUMINEUX ...") si les249        # marqueurs ne l'ont pas fourni250        if not lst.unit_type:251            unit = normalize_unit_type(desc)252            if unit and unit != desc.strip():253                lst.unit_type = unit254255        # secteur depuis la description ("QUARTIER SAINTE-FOY", "SECTEUR LIMOILOU")256        if not lst.sector:257            m = re.search(r"(?:QUARTIER|SECTEUR)\s+(?:DE\s+|DU\s+)?"258                          r"([A-ZÀ-Ü][A-ZÀ-Üa-zà-ü']+(?:-[A-ZÀ-Üa-zà-ü']+)*)",259                          desc)260            if m:261                lst.sector = m.group(1).strip(" -").title()262                lst.city = infer_city(lst.sector, default=lst.city)263264        # disponibilité si mentionnée ("DISPONIBLE PRÉSENTEMENT", "PRÉSENTEMENT265        # DISPONIBLE", "DISPONIBLE EN JUILLET", "LIBRE 1ER JUILLET"...)266        mois = (r"JAN[A-ZÀ-Ü]*|F[ÉE]V[A-ZÀ-Ü]*|MARS|AVR[A-ZÀ-Ü]*|MAI|JUIN|"267                r"JUIL[A-ZÀ-Ü]*|AO[ÛU]T|SEPT[A-ZÀ-Ü]*|OCT[A-ZÀ-Ü]*|"268                r"NOV[A-ZÀ-Ü]*|D[ÉE]C[A-ZÀ-Ü]*")269        m = re.search(rf"(?:LIBRE|DISPONIBLE|DISPONIBILIT[ÉE])\s*:?\s*"270                      rf"(?:D[ÈE]S\s+|LE\s+|EN\s+)?"271                      rf"(MAINTENANT|PR[ÉE]SENTEMENT|IMM[ÉE]DIATEMENT|"272                      rf"\d+\s*(?:ER|E)?\s*[A-ZÀ-Ü]{{3,10}}(?:\s+20\d\d)?|"273                      rf"(?:{mois})(?:\s+20\d\d)?)", desc, re.I)274        if not m:275            m = re.search(r"(MAINTENANT|PR[ÉE]SENTEMENT|IMM[ÉE]DIATEMENT)\s+"276                          r"DISPONIBLE", desc, re.I)277        if not m:278            m = re.search(r"PRISE DE POSSESSION\s*:?\s*(FLEXIBLE\s*)?"279                          r"(\([^)]{0,50}\)|[A-ZÀ-Ü0-9][^.<–—-]{0,40})?", desc, re.I)280        if m:281            lst.availability = re.sub(r"\s+", " ", m.group(0)).strip().capitalize()282