SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.7 KB · 194 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/labonte.py : connecteur Gestion Labonté (gestionlabonte.com)5#   Gestionnaire établi de Sherbrooke (66 ans). Site Wix : la page /logements6#   est rendue serveur — répéteur Wix dont chaque item7#   (div id="comp-…__<guid>") porte le prix (« À partir de N $/mois »),8#   l'adresse (h2), le nombre de pièces, le secteur, la photo et le lien vers9#   la page dynamique /logements/<guid> (le GUID du CMS Wix = external_id10#   stable). Les fiches détail (via self.detail, cache BD) ajoutent la11#   description, les paires « Détails du logement » (secteur, pièces,12#   chambres, étage) et l'adresse complète du bloc « Emplacement ». La galerie13#   photos des fiches est chargée côté client (pro-gallery, non SSR) : seule14#   la photo de la carte est disponible. Aucun statut « loué » affiché — la15#   liste ne montre que l'inventaire courant.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price25from .base import BaseConnector2627BASE = "https://www.gestionlabonte.com"28LIST_URL = f"{BASE}/logements"2930_GUID_RE = re.compile(r"/logements/([0-9a-f-]{36})")3132# villes possibles dans l'adresse « Emplacement » (jamais devinées)33_CITY_RE = re.compile(r"\b(Sherbrooke|Magog|East Angus|Lennoxville)\b", re.I)343536def _clean_unit_type(raw: str) -> str:37    """normalize_unit_type, mais seulement si le motif est net (« 3.5 »,38    « 4,5 », « studio »…) — jamais le texte brut en guise de type."""39    ut = normalize_unit_type(raw)40    if re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison", ut or ""):41        return ut42    return ""434445def _lines(el) -> list[str]:46    """Lignes de texte nettoyées d'un sous-arbre."""47    out = []48    for raw in el.get_text("\n", strip=True).split("\n"):49        t = re.sub(r"\s+", " ", raw).strip()50        if t:51            out.append(t)52    return out535455class LabonteConnector(BaseConnector):56    source_id = "labonte"57    request_delay = 0.758    max_details = 25       # garde-fou fiches détail (vraies requêtes)5960    def fetch(self) -> list[Listing]:61        html = self.get(LIST_URL).text62        soup = BeautifulSoup(html, "html.parser")6364        listings: dict[str, Listing] = {}65        # items du répéteur : conteneurs suffixés par le GUID de l'item CMS66        for item in soup.select('div[id*="__"]'):67            link = item.select_one('a[href*="/logements/"]')68            if not link:69                continue70            m = _GUID_RE.search(link.get("href") or "")71            if not m:72                continue73            guid = m.group(1)74            iid = (item.get("id") or "")75            if not iid.endswith(f"__{guid}") or guid in listings:76                continue    # garder le conteneur d'item complet, une fois7778            lines = _lines(item)79            # « À partir de | 1150 | $/mois | 40 rue St-François |80            #   Nombre de pièces | 3.5 | Secteur | Centre-ville | En savoir plus »81            h2 = item.select_one("h2")82            address = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else ""8384            price = None85            price_label = ""86            for i, t in enumerate(lines):87                if t.lower().startswith("à partir de") and i + 1 < len(lines):88                    amount = lines[i + 1]89                    unit = lines[i + 2] if i + 2 < len(lines) else ""90                    price_label = f"À partir de {amount} {unit}".strip()91                    price = parse_price(f"{amount} $")92                    break9394            pieces = sector = ""95            for i, t in enumerate(lines):96                if t.lower().startswith("nombre de pièces") and i + 1 < len(lines):97                    pieces = lines[i + 1]98                elif t.lower() == "secteur" and i + 1 < len(lines):99                    sector = lines[i + 1]100101            img = item.select_one('img[src*="static.wixstatic.com/media"]')102            images = []103            if img:104                images.append(img["src"].split("/v1/")[0])105106            listings[guid] = Listing(107                source=self.source_id,108                external_id=guid,109                url=f"{BASE}/logements/{guid}",110                title=address or f"Logement {guid[:8]}",111                address=address,112                sector=sector,113                city="Sherbrooke",114                unit_type=_clean_unit_type(pieces),115                price=price,116                price_label=price_label,117                availability="",       # non publié par la source118                images=images,119            )120121        # fiches détail (cache BD) : description, chambres/étage, adresse complète122        self._fetched = 0123        for lst in listings.values():124            key = hashlib.sha1(125                f"{lst.title}|{lst.price_label}|{lst.sector}"126                .encode("utf-8")).hexdigest()127            try:128                payload = self.detail(lst.external_id, key,129                                      lambda u=lst.url: self._fetch_detail(u))130            except Exception:131                continue132            self._apply_detail(lst, payload)133        return list(listings.values())134135    # -- fiche détail (page dynamique Wix) -------------------------------------------136    def _fetch_detail(self, url: str) -> dict:137        if self._fetched >= self.max_details:138            raise RuntimeError("budget de fiches détail atteint")139        self._fetched += 1140        html = self.get(url).text141        soup = BeautifulSoup(html, "html.parser")142        lines = _lines(soup.body or soup)143        out: dict = {}144145        # description : entre « Description » et « Détails du logement »146        try:147            i = lines.index("Description")148            j = lines.index("Détails du logement")149            if 0 <= i < j:150                out["description"] = " ".join(lines[i + 1:j])[:1500]151        except ValueError:152            pass153154        # paires « Détails du logement » : Secteur / pièces / chambres / Étage155        for i, t in enumerate(lines):156            low = t.lower()157            if i + 1 >= len(lines):158                break159            if low == "secteur":160                out["sector"] = lines[i + 1]161            elif low.startswith("nombre de pièces"):162                out["pieces"] = lines[i + 1]163            elif low.startswith("nombre de chambres"):164                out["bedrooms"] = lines[i + 1]165            elif low == "étage":166                out["floor"] = lines[i + 1]167            elif low == "emplacement":168                out["full_address"] = lines[i + 1]169        return out170171    def _apply_detail(self, lst: Listing, d: dict) -> None:172        if not d:173            return174        if d.get("description"):175            lst.description = d["description"]176        if d.get("sector") and not lst.sector:177            lst.sector = d["sector"]178        if d.get("full_address"):179            full = d["full_address"]180            # « 49 Rue King Ouest, Sherbrooke, QC J1H 1P1, Canada »181            lst.address = ", ".join(p.strip() for p in full.split(",")[:2])182            m = _CITY_RE.search(full)183            if m:184                lst.city = m.group(1).title()185        if not lst.unit_type and d.get("pieces"):186            lst.unit_type = _clean_unit_type(d["pieces"])187        details: dict = {}188        if str(d.get("bedrooms") or "").isdigit():189            details["bedrooms"] = int(d["bedrooms"])190        if str(d.get("floor") or "").isdigit():191            details["floor"] = int(d["floor"])192        if details:193            lst.details = {**lst.details, **details}194