spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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½\+?|6½\+|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