SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.8 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/bestlife.py : connecteur Les Gestions Bestlife5#   (lesgestionsbestlife.com) — 500+ portes en gestion à Sherbrooke6#   (Fleurimont, Rock Forest, Mont-Bellevue), East Angus, Richmond.7#   WordPress Divi + WooCommerce : chaque logement est un « produit »8#   (archive /appartements-a-louer-sherbrooke/, cartes li.product avec titre9#   « adresse – type », prix WooCommerce — promo = del/ins — et taxonomie10#   product_cat-<type>). Les fiches produit (via self.detail, cache BD)11#   ajoutent la disponibilité (« Disponible dès maintenant »), les listes12#   Inclusions/Spécifications et la galerie photos. La ville est extraite de13#   la parenthèse du titre (« (East-Angus) », « (Richemond) ») — Sherbrooke14#   par défaut. external_id = slug du produit (stable).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://lesgestionsbestlife.com"27LIST_URL = f"{BASE}/appartements-a-louer-sherbrooke/"2829# « (East-Angus) », « (Richemond) »… -> vraie ville ; sinon Sherbrooke30_CITY_PARENS = {31    "east-angus": "East Angus", "east angus": "East Angus",32    "richemond": "Richmond", "richmond": "Richmond",33    "windsor": "Windsor", "magog": "Magog",34}35_VARIANT_IMG = re.compile(r"[?&](?:resize|fit)=", re.I)363738def _clean_price_label(label: str) -> str:39    """« 1,150 $ » (virgule de milliers WooCommerce) -> compatible parse_price."""40    return re.sub(r"(\d),(\d{3})", r"\1\2", label)414243class BestlifeConnector(BaseConnector):44    source_id = "bestlife"45    request_delay = 0.646    max_details = 30      # garde-fou fiches produit (vraies requêtes)4748    def fetch(self) -> list[Listing]:49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")5152        listings: dict[str, Listing] = {}53        for card in soup.select("li.product"):54            try:55                lst = self._parse_card(card)56            except Exception:57                continue58            if lst and lst.external_id not in listings:59                listings[lst.external_id] = lst6061        # fiches produit (cache BD) : dispo, inclusions, spécifications, photos62        self._fetched = 063        for lst in listings.values():64            key = hashlib.sha1(65                f"{lst.title}|{lst.price_label}".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 produit ---------------------------------------------------------------75    def _parse_card(self, card) -> Listing | None:76        link = card.select_one("a.woocommerce-loop-product__link[href]")77        title_el = card.select_one("h2")78        if not (link and title_el):79            return None80        url = link["href"]81        m = re.search(r"/produit/([^/]+)/?", url)82        if not m:83            return None84        slug = m.group(1)85        title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)).strip()8687        # « 1082 Sainte-Thérèse – 4 1/2 » -> adresse + type88        addr_part = re.split(r"\s*[–—-]\s*(?=\d\s*1/2|Loft|Studio|Chambre)",89                             title, maxsplit=1, flags=re.I)[0].strip()90        unit_type = normalize_unit_type(title)91        if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",92                            unit_type or ""):93            unit_type = ""9495        # ville depuis la parenthèse du titre (sinon Sherbrooke)96        city = "Sherbrooke"97        pm = re.search(r"\(([^)]+)\)", title)98        if pm:99            key = pm.group(1).strip().lower()100            if key in _CITY_PARENS:101                city = _CITY_PARENS[key]102                addr_part = re.sub(r"\s*\([^)]+\)", "", addr_part).strip()103104        # prix WooCommerce : promo = <del>régulier</del> <ins>courant</ins>105        price = None106        price_label = ""107        price_el = card.select_one("span.price")108        if price_el:109            price_label = re.sub(r"\s+", " ",110                                 price_el.get_text(" ", strip=True)).strip()111            ins = price_el.select_one("ins .woocommerce-Price-amount")112            amount = ins or price_el.select_one(".woocommerce-Price-amount")113            if amount:114                price = parse_price(_clean_price_label(115                    amount.get_text(" ", strip=True)))116117        img = card.select_one("img[src]")118        images = []119        if img:120            src = (img.get("data-orig-file") or img["src"]).strip()121            if src.startswith("http"):122                images.append(src)123124        return Listing(125            source=self.source_id,126            external_id=slug,127            url=url,128            title=title,129            address=addr_part,130            sector="",             # non publié sur la carte131            city=city,132            unit_type=unit_type,133            price=price,134            price_label=price_label,135            availability="",       # complété par la fiche produit136            images=images,137        )138139    # -- fiche produit ------------------------------------------------------------140    def _fetch_detail(self, url: str) -> dict:141        if self._fetched >= self.max_details:142            raise RuntimeError("budget de fiches détail atteint")143        self._fetched += 1144        html = self.get(url).text145        soup = BeautifulSoup(html, "html.parser")146        out: dict = {}147148        # « Disponible dès maintenant » / « Disponible le 1er septembre » —149        # ligne en emphase de la fiche (jamais les messages techniques du thème)150        for el in soup.find_all(["em", "strong", "p", "h3"]):151            t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()152            if re.match(r"^Disponible\b", t) and len(t) <= 80:153                out["availability"] = t154                break155156        # listes Inclusions / Spécifications (commodités affichées)157        amenities: list[str] = []158        for h in soup.find_all(["h3", "h4"]):159            t = h.get_text(" ", strip=True)160            if t in ("Inclusions", "Spécifications"):161                ul = h.find_next("ul")162                if ul:163                    for li in ul.select("li"):164                        item = re.sub(r"\s+", " ", li.get_text(" ", strip=True))165                        if item and item not in amenities:166                            amenities.append(item)167        out["amenities"] = amenities[:25]168169        # galerie photos (pleine taille i0.wp.com sans resize)170        images: list[str] = []171        for img in soup.select(".woocommerce-product-gallery img[src], "172                               ".et_pb_gallery img[src]"):173            src = (img.get("data-orig-file") or img.get("src") or "").strip()174            src = src.split("?")[0] if _VARIANT_IMG.search(src) else src175            if src.startswith("http") and src not in images:176                images.append(src)177        out["images"] = images[:20]178        return out179180    def _apply_detail(self, lst: Listing, d: dict) -> None:181        if not d:182            return183        if d.get("availability"):184            lst.availability = d["availability"]185        if d.get("amenities"):186            lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))187        if d.get("images"):188            merged = list(dict.fromkeys(d["images"] + lst.images))189            lst.images = merged[:20]190