# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/bestlife.py : connecteur Les Gestions Bestlife # (lesgestionsbestlife.com) — 500+ portes en gestion à Sherbrooke # (Fleurimont, Rock Forest, Mont-Bellevue), East Angus, Richmond. # WordPress Divi + WooCommerce : chaque logement est un « produit » # (archive /appartements-a-louer-sherbrooke/, cartes li.product avec titre # « adresse – type », prix WooCommerce — promo = del/ins — et taxonomie # product_cat-). Les fiches produit (via self.detail, cache BD) # ajoutent la disponibilité (« Disponible dès maintenant »), les listes # Inclusions/Spécifications et la galerie photos. La ville est extraite de # la parenthèse du titre (« (East-Angus) », « (Richemond) ») — Sherbrooke # par défaut. external_id = slug du produit (stable). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://lesgestionsbestlife.com" LIST_URL = f"{BASE}/appartements-a-louer-sherbrooke/" # « (East-Angus) », « (Richemond) »… -> vraie ville ; sinon Sherbrooke _CITY_PARENS = { "east-angus": "East Angus", "east angus": "East Angus", "richemond": "Richmond", "richmond": "Richmond", "windsor": "Windsor", "magog": "Magog", } _VARIANT_IMG = re.compile(r"[?&](?:resize|fit)=", re.I) def _clean_price_label(label: str) -> str: """« 1,150 $ » (virgule de milliers WooCommerce) -> compatible parse_price.""" return re.sub(r"(\d),(\d{3})", r"\1\2", label) class BestlifeConnector(BaseConnector): source_id = "bestlife" request_delay = 0.6 max_details = 30 # garde-fou fiches produit (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for card in soup.select("li.product"): try: lst = self._parse_card(card) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # fiches produit (cache BD) : dispo, inclusions, spécifications, photos self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}".encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte produit --------------------------------------------------------------- def _parse_card(self, card) -> Listing | None: link = card.select_one("a.woocommerce-loop-product__link[href]") title_el = card.select_one("h2") if not (link and title_el): return None url = link["href"] m = re.search(r"/produit/([^/]+)/?", url) if not m: return None slug = m.group(1) title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)).strip() # « 1082 Sainte-Thérèse – 4 1/2 » -> adresse + type addr_part = re.split(r"\s*[–—-]\s*(?=\d\s*1/2|Loft|Studio|Chambre)", title, maxsplit=1, flags=re.I)[0].strip() unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = "" # ville depuis la parenthèse du titre (sinon Sherbrooke) city = "Sherbrooke" pm = re.search(r"\(([^)]+)\)", title) if pm: key = pm.group(1).strip().lower() if key in _CITY_PARENS: city = _CITY_PARENS[key] addr_part = re.sub(r"\s*\([^)]+\)", "", addr_part).strip() # prix WooCommerce : promo = régulier courant price = None price_label = "" price_el = card.select_one("span.price") if price_el: price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)).strip() ins = price_el.select_one("ins .woocommerce-Price-amount") amount = ins or price_el.select_one(".woocommerce-Price-amount") if amount: price = parse_price(_clean_price_label( amount.get_text(" ", strip=True))) img = card.select_one("img[src]") images = [] if img: src = (img.get("data-orig-file") or img["src"]).strip() if src.startswith("http"): images.append(src) return Listing( source=self.source_id, external_id=slug, url=url, title=title, address=addr_part, sector="", # non publié sur la carte city=city, unit_type=unit_type, price=price, price_label=price_label, availability="", # complété par la fiche produit images=images, ) # -- fiche produit ------------------------------------------------------------ def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # « Disponible dès maintenant » / « Disponible le 1er septembre » — # ligne en emphase de la fiche (jamais les messages techniques du thème) for el in soup.find_all(["em", "strong", "p", "h3"]): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() if re.match(r"^Disponible\b", t) and len(t) <= 80: out["availability"] = t break # listes Inclusions / Spécifications (commodités affichées) amenities: list[str] = [] for h in soup.find_all(["h3", "h4"]): t = h.get_text(" ", strip=True) if t in ("Inclusions", "Spécifications"): ul = h.find_next("ul") if ul: for li in ul.select("li"): item = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if item and item not in amenities: amenities.append(item) out["amenities"] = amenities[:25] # galerie photos (pleine taille i0.wp.com sans resize) images: list[str] = [] for img in soup.select(".woocommerce-product-gallery img[src], " ".et_pb_gallery img[src]"): src = (img.get("data-orig-file") or img.get("src") or "").strip() src = src.split("?")[0] if _VARIANT_IMG.search(src) else src if src.startswith("http") and src not in images: images.append(src) out["images"] = images[:20] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("availability"): lst.availability = d["availability"] if d.get("amenities"): lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) if d.get("images"): merged = list(dict.fromkeys(d["images"] + lst.images)) lst.images = merged[:20]