SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.1 KB · 190 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/uptimo.py : connecteur Uptimo Gestion immobilière (uptimo.ca)5#   Apparts à Sherbrooke (Fleurimont, Mont-Bellevue, Jacques-Cartier…) et6#   environs. WordPress Themify + Post Type Builder : l'archive7#   /logements-a-louer/ (CPT « propriete », 15 cartes/page, ~6 pages) liste le8#   catalogue à louer — cartes .ptb_post avec titre, arrondissement9#   (taxonomie) et photo ; l'ID WordPress (classe post-<id>) sert10#   d'external_id stable. Les fiches (via self.detail, cache BD) portent les11#   champs structurés : « Adresse: », « Ville: », « Prix: »,12#   « Type de propriété: » (2 1/2…), salles de bain, description et galerie.13#   Aucun statut structuré de disponibilité — la date de libération n'apparaît14#   qu'en texte libre dans la description (textmine central).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://www.uptimo.ca"27LIST_URL = f"{BASE}/logements-a-louer/"2829_POST_ID_RE = re.compile(r"\bpost-(\d+)\b")30_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)313233class UptimoConnector(BaseConnector):34    source_id = "uptimo"35    request_delay = 0.636    max_pages = 10        # garde-fou de pagination (6 pages observées)37    max_details = 90      # garde-fou fiches détail (77 propriétés au catalogue)3839    def fetch(self) -> list[Listing]:40        listings: dict[str, Listing] = {}41        for page in range(1, self.max_pages + 1):42            url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"43            try:44                html = self.get(url).text45            except Exception:46                break47            soup = BeautifulSoup(html, "html.parser")48            cards = soup.select(".ptb_post")49            if not cards:50                break51            for card in cards:52                try:53                    lst = self._parse_card(card)54                except Exception:55                    continue56                if lst and lst.external_id not in listings:57                    listings[lst.external_id] = lst5859        # fiches détail (cache BD) : adresse, ville, prix, type, description,60        # salles de bain, galerie61        self._fetched = 062        for lst in listings.values():63            key = hashlib.sha1(64                f"{lst.title}|{lst.sector}|{lst.images[:1]}"65                .encode("utf-8")).hexdigest()66            try:67                payload = self.detail(lst.external_id, key,68                                      lambda u=lst.url: self._fetch_detail(u))69            except Exception:70                continue71            self._apply_detail(lst, payload)72        return list(listings.values())7374    # -- carte d'archive -------------------------------------------------------------75    def _parse_card(self, card) -> Listing | None:76        link = card.select_one("h3.ptb_post_title a[href]")77        if not link:78            return None79        url = link["href"]80        title = re.sub(r"\s+", " ", link.get_text(" ", strip=True)).strip()81        m = _POST_ID_RE.search(" ".join(card.get("class") or []))82        ext_id = m.group(1) if m else re.sub(r".*/propriete/([^/]+)/?.*", r"\1", url)8384        # arrondissement (taxonomie) : « Sherbrooke-Fleurimont »85        sector = ""86        tax = card.select_one(".ptb_taxonomies_tous_les_arrondissements")87        if tax:88            sector = re.sub(r"\s+", " ", tax.get_text(" ", strip=True)).strip()89            sector = re.sub(r"^Sherbrooke-", "", sector)9091        img = card.select_one("img[src]")92        images = []93        if img and str(img.get("src", "")).startswith("http"):94            images.append(_VARIANT_IMG.sub("", img["src"]))9596        # type d'unité dans le titre le cas échéant (« - Studio », « 3 1/2 »)97        unit_type = normalize_unit_type(title)98        if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",99                            unit_type or ""):100            unit_type = ""101102        return Listing(103            source=self.source_id,104            external_id=str(ext_id),105            url=url,106            title=title,107            address="",            # complété par la fiche108            sector=sector,109            city="Sherbrooke",110            unit_type=unit_type,111            availability="",       # aucun statut structuré publié112            images=images,113        )114115    # -- fiche propriété (modules PTB) ---------------------------------------------116    def _fetch_detail(self, url: str) -> dict:117        if self._fetched >= self.max_details:118            raise RuntimeError("budget de fiches détail atteint")119        self._fetched += 1120        html = self.get(url).text121        soup = BeautifulSoup(html, "html.parser")122        out: dict = {}123124        def module_text(sel: str) -> str:125            el = soup.select_one(sel)126            return re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if el else ""127128        addr = module_text(".ptb_proprite_adresse")129        if addr:130            out["address"] = re.sub(r"^Adresse\s*:\s*", "", addr, flags=re.I)131        prix = module_text(".ptb_proprite_prix")132        if prix:133            out["price_label"] = re.sub(r"^Prix\s*:\s*", "", prix, flags=re.I)134135        # taxonomies : « Ville: Sherbrooke », « Type de propriété: 2 1/2 »,136        # « Nombre de salle(s) de bain: 1 »137        for tax in soup.select(".ptb_taxonomies"):138            t = re.sub(r"\s+", " ", tax.get_text(" ", strip=True)).strip()139            if t.lower().startswith("ville"):140                out["city"] = re.sub(r"^Ville\s*:\s*", "", t, flags=re.I)141            elif t.lower().startswith("type de propriété"):142                out["type_raw"] = re.sub(r"^Type de propriété\s*:\s*", "", t,143                                         flags=re.I)144            elif "salle(s) de bain" in t.lower():145                m = re.search(r"(\d+)\s*$", t)146                if m:147                    out["bathrooms"] = int(m.group(1))148149        desc_el = soup.select_one(".ptb_textarea")150        if desc_el:151            out["description"] = re.sub(152                r"[ \t]+", " ", desc_el.get_text("\n", strip=True)).strip()[:1500]153154        images: list[str] = []155        for img in soup.select(".ptb_gallery img[src], "156                               ".ptb_proprite_image_principal img[src]"):157            src = _VARIANT_IMG.sub("", str(img.get("src") or ""))158            if src.startswith("http") and src not in images:159                images.append(src)160        out["images"] = images[:20]161        return out162163    def _apply_detail(self, lst: Listing, d: dict) -> None:164        if not d:165            return166        if d.get("address"):167            lst.address = d["address"]168        if d.get("city"):169            # la taxonomie « Ville » mêle parfois la province (« Québec »,170            # « Québec, Sherbrooke ») : ne retenir qu'une ville réelle connue171            m = re.search(r"\b(Sherbrooke|Magog|Windsor|East Angus|Coaticook|"172                          r"Ascot Corner|Lennoxville|Richmond)\b",173                          d["city"], re.I)174            if m:175                lst.city = m.group(1).title()176        if d.get("price_label"):177            lst.price_label = d["price_label"]178            lst.price = parse_price(re.sub(r"(\d)[,\s](\d{3})", r"\1\2",179                                           d["price_label"]))180        if d.get("description"):181            lst.description = d["description"]182        if not lst.unit_type and d.get("type_raw"):183            ut = normalize_unit_type(d["type_raw"])184            if re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison", ut or ""):185                lst.unit_type = ut186        if d.get("bathrooms"):187            lst.details = {**lst.details, "bathrooms": d["bathrooms"]}188        if d.get("images"):189            lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]190