# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/habitations_sf.py : connecteur Les Habitations SF (S&F Gestion) # (leshabitationssf.com — Saint-Charles-Borromée/Joliette, Lanaudière). # Site Wix : les annonces sont des pages dynamiques /copy-of-location/ # (collection Wix), rendues côté serveur — le HTML contient le titre, le # statut (« Disponible »/« Loué »), chambres/sdb/pi², le prix « … $ / mois », # la description riche (ligne « Adresse: … ») et la galerie wixstatic. # Index = liens du répéteur de la page d'accueil ; external_id = slug ; # fiches détail via cache BD (clé = hash du répéteur : tout changement # d'accueil déclenche la relecture des fiches). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import unicodedata from urllib.parse import unquote from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.leshabitationssf.com" LIST_URL = f"{BASE}/" # id du média wixstatic (« 5ae170_…~mv2.png ») pour dédupliquer la galerie _MEDIA_ID = re.compile(r"/media/([^/]+~mv2\.\w+)") _CITIES = [ ("saint-charles", "Saint-Charles-Borromée"), ("st-charles", "Saint-Charles-Borromée"), ("notre-dame-des-prairies", "Notre-Dame-des-Prairies"), ("saint-felix", "Saint-Félix-de-Valois"), ("st-felix", "Saint-Félix-de-Valois"), ("joliette", "Joliette"), ("piedmont", "Piedmont"), ("saint-paul", "Saint-Paul"), ] _STATUTS_PARTIS = re.compile(r"(?i)^(lou[ée]e?|non disponible|r[ée]serv[ée]e?)$") def _strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def _slug(s: str) -> str: s = _strip_accents(unquote(s).lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") def _find_city(txt: str) -> str: low = re.sub(r"[\s_]+", "-", _strip_accents(txt.lower())) for key, name in _CITIES: if key in low: return name return "" class HabitationsSFConnector(BaseConnector): source_id = "habitations_sf" request_delay = 0.8 max_details = 25 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") # liens du répéteur d'accueil (ordre conservé, dédupliqués) hrefs: list[str] = [] for a in soup.select('a[href*="copy-of-location/"]'): href = a["href"] if href.startswith("/"): href = BASE + href if href not in hrefs: hrefs.append(href) # clé de cache : hash des liens + du texte du répéteur (prix affichés) — # tout changement sur l'accueil déclenche la relecture des fiches idx_txt = " ".join(hrefs) + re.sub( r"\s+", " ", " ".join(el.get_text(" ", strip=True) for el in soup.select('[data-testid="richTextElement"]'))) index_key = hashlib.sha1(idx_txt.encode("utf-8")).hexdigest() self._fetched = 0 listings: list[Listing] = [] for href in hrefs: ext = _slug(href.rsplit("/", 1)[-1]) if not ext: continue try: payload = self.detail(ext, index_key, lambda u=href: self._fetch_detail(u)) except Exception: continue lst = self._build(ext, href, payload) if lst is not None: listings.append(lst) return listings # -- fiche détail (page dynamique Wix) ---------------------------------------- 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 = {} title_el = soup.find("title") out["title"] = re.sub(r"\s+", " ", title_el.get_text(strip=True)) if title_el else "" rts = [re.sub(r"[​]", "", re.sub(r"\s+", " ", el.get_text(" ", strip=True))).strip() for el in soup.select('[data-testid="richTextElement"]')] rts = [t for t in rts if t] # statut de l'unité (« Disponible » / « Loué ») for t in rts: if re.match(r"(?i)^(disponible|lou[ée]e?|non disponible|r[ée]serv[ée]e?)$", t): out["status"] = t break # champs structurés : valeur qui précède « Chambre(s) », « Salle(s) de # bain », « Pieds² » ; prix = « $ / mois » dans la séquence def before(label_re: str) -> str: for i, t in enumerate(rts): if re.match(label_re, t, re.I) and i > 0: return rts[i - 1] return "" out["bedrooms"] = before(r"^chambre") out["bathrooms"] = before(r"^salle\(s\) de bain|^salles? de bain") out["sqft"] = before(r"^pieds") joined = " ".join(rts) m = re.search(r"(\d[\d\s,.]*)\s*\$\s*/\s*mois", joined) if m: out["price_label"] = f"{m.group(1).strip()} $ / mois" else: # certaines fiches omettent le « $ » m = re.search(r"(\d{3,4})\s*/\s*mois", joined) if m: out["price_label"] = f"{m.group(1)} / mois" # description riche = plus long bloc de texte (contient « Adresse: … ») long_txts = [el.get_text("\n", strip=True) for el in soup.select('[data-testid="richTextElement"]') if len(el.get_text(strip=True)) > 200] if long_txts: out["description"] = max(long_txts, key=len)[:1500] # galerie : images wixstatic grand format, dédupliquées par id de média images, seen = [], set() for im in soup.find_all("img"): src = im.get("src") or "" if "wixstatic.com/media/" not in src: continue m_id = _MEDIA_ID.search(src) if not m_id or m_id.group(1) in seen: continue if not re.search(r"w_(9\d\d|\d{4,})", src): continue # vignettes/логos : trop petits seen.add(m_id.group(1)) images.append(src) out["images"] = images[:20] return out # -- assemblage --------------------------------------------------------------- def _build(self, ext: str, url: str, d: dict) -> Listing | None: if not d: return None if _STATUTS_PARTIS.match(d.get("status", "")): return None # unité louée/réservée title = d.get("title", "") desc = d.get("description", "") # adresse : ligne « Adresse: rue de la Visitation, Saint-Charles-Borromée » address, city = "", "" m = re.search(r"Adresse\s*:\s*([^\n]+)", desc, re.I) if m: parts = [p.strip() for p in m.group(1).split(",") if p.strip()] city = _find_city(parts[-1]) if parts else "" address = ", ".join(parts[:-1]) if city and len(parts) > 1 else m.group(1).strip() if not city: city = _find_city(title) or _find_city(desc) # disponibilité : ligne « DISPONIBLE DÈS LE 1ER AOUT 2025 » de la fiche availability = "" m_av = re.search(r"(DISPONIBLE\s+D[ÈE]S[^\n]*|DISPONIBLE\s+(?:LE|MAINTENANT)[^\n]*)", desc, re.I) if m_av: availability = re.sub(r"\s+", " ", m_av.group(1)).strip() elif d.get("status"): availability = d["status"] amenities: list[str] = [] if d.get("bedrooms", "").replace("-", "").strip().isdigit() or \ re.match(r"^\d+(-\d+)?$", d.get("bedrooms", "")): amenities.append(f"{d['bedrooms']} chambre(s)") if re.match(r"^\d+([.,]\d+)?$", d.get("bathrooms", "")): amenities.append(f"{d['bathrooms']} salle(s) de bain") area = None if re.match(r"^\d{3,4}$", d.get("sqft", "")): area = float(d["sqft"]) price_label = d.get("price_label", "") if not price_label: # fiche multi-typologies : « 3 1/2 À PARTIR DE 1150$ … » — on # reprend la mention la plus basse (convention Lou-Ka « à partir de ») fromtags = re.findall(r"à partir de\s*(\d[\d\s]*)\s*\$", desc, re.I) if fromtags: lo = min(int(x.replace(" ", "")) for x in fromtags) price_label = f"À partir de {lo}$" return Listing( source=self.source_id, external_id=ext, url=url, title=title, address=address, city=city, unit_type=normalize_unit_type(title), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area, description=desc, amenities=amenities, images=d.get("images") or [], )