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/habitations_sf.py : connecteur Les Habitations SF (S&F Gestion)5# (leshabitationssf.com — Saint-Charles-Borromée/Joliette, Lanaudière).6# Site Wix : les annonces sont des pages dynamiques /copy-of-location/<slug>7# (collection Wix), rendues côté serveur — le HTML contient le titre, le8# statut (« Disponible »/« Loué »), chambres/sdb/pi², le prix « … $ / mois »,9# la description riche (ligne « Adresse: … ») et la galerie wixstatic.10# Index = liens du répéteur de la page d'accueil ; external_id = slug ;11# fiches détail via cache BD (clé = hash du répéteur : tout changement12# d'accueil déclenche la relecture des fiches).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import re18import unicodedata19from urllib.parse import unquote2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://www.leshabitationssf.com"27LIST_URL = f"{BASE}/"2829# id du média wixstatic (« 5ae170_…~mv2.png ») pour dédupliquer la galerie30_MEDIA_ID = re.compile(r"/media/([^/]+~mv2\.\w+)")3132_CITIES = [33 ("saint-charles", "Saint-Charles-Borromée"),34 ("st-charles", "Saint-Charles-Borromée"),35 ("notre-dame-des-prairies", "Notre-Dame-des-Prairies"),36 ("saint-felix", "Saint-Félix-de-Valois"),37 ("st-felix", "Saint-Félix-de-Valois"),38 ("joliette", "Joliette"),39 ("piedmont", "Piedmont"),40 ("saint-paul", "Saint-Paul"),41]4243_STATUTS_PARTIS = re.compile(r"(?i)^(lou[ée]e?|non disponible|r[ée]serv[ée]e?)$")444546def _strip_accents(s: str) -> str:47 return "".join(c for c in unicodedata.normalize("NFD", s)48 if unicodedata.category(c) != "Mn")495051def _slug(s: str) -> str:52 s = _strip_accents(unquote(s).lower())53 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")545556def _find_city(txt: str) -> str:57 low = re.sub(r"[\s_]+", "-", _strip_accents(txt.lower()))58 for key, name in _CITIES:59 if key in low:60 return name61 return ""626364class HabitationsSFConnector(BaseConnector):65 source_id = "habitations_sf"66 request_delay = 0.867 max_details = 25 # garde-fou fiches détail (vraies requêtes par sync)6869 def fetch(self) -> list[Listing]:70 html = self.get(LIST_URL).text71 soup = BeautifulSoup(html, "html.parser")72 # liens du répéteur d'accueil (ordre conservé, dédupliqués)73 hrefs: list[str] = []74 for a in soup.select('a[href*="copy-of-location/"]'):75 href = a["href"]76 if href.startswith("/"):77 href = BASE + href78 if href not in hrefs:79 hrefs.append(href)80 # clé de cache : hash des liens + du texte du répéteur (prix affichés) —81 # tout changement sur l'accueil déclenche la relecture des fiches82 idx_txt = " ".join(hrefs) + re.sub(83 r"\s+", " ",84 " ".join(el.get_text(" ", strip=True)85 for el in soup.select('[data-testid="richTextElement"]')))86 index_key = hashlib.sha1(idx_txt.encode("utf-8")).hexdigest()8788 self._fetched = 089 listings: list[Listing] = []90 for href in hrefs:91 ext = _slug(href.rsplit("/", 1)[-1])92 if not ext:93 continue94 try:95 payload = self.detail(ext, index_key,96 lambda u=href: self._fetch_detail(u))97 except Exception:98 continue99 lst = self._build(ext, href, payload)100 if lst is not None:101 listings.append(lst)102 return listings103104 # -- fiche détail (page dynamique Wix) ----------------------------------------105 def _fetch_detail(self, url: str) -> dict:106 if self._fetched >= self.max_details:107 raise RuntimeError("budget de fiches détail atteint")108 self._fetched += 1109 html = self.get(url).text110 soup = BeautifulSoup(html, "html.parser")111 out: dict = {}112113 title_el = soup.find("title")114 out["title"] = re.sub(r"\s+", " ", title_el.get_text(strip=True)) if title_el else ""115116 rts = [re.sub(r"[]", "",117 re.sub(r"\s+", " ", el.get_text(" ", strip=True))).strip()118 for el in soup.select('[data-testid="richTextElement"]')]119 rts = [t for t in rts if t]120121 # statut de l'unité (« Disponible » / « Loué »)122 for t in rts:123 if re.match(r"(?i)^(disponible|lou[ée]e?|non disponible|r[ée]serv[ée]e?)$", t):124 out["status"] = t125 break126127 # champs structurés : valeur qui précède « Chambre(s) », « Salle(s) de128 # bain », « Pieds² » ; prix = « <n> $ / mois » dans la séquence129 def before(label_re: str) -> str:130 for i, t in enumerate(rts):131 if re.match(label_re, t, re.I) and i > 0:132 return rts[i - 1]133 return ""134 out["bedrooms"] = before(r"^chambre")135 out["bathrooms"] = before(r"^salle\(s\) de bain|^salles? de bain")136 out["sqft"] = before(r"^pieds")137 joined = " ".join(rts)138 m = re.search(r"(\d[\d\s,.]*)\s*\$\s*/\s*mois", joined)139 if m:140 out["price_label"] = f"{m.group(1).strip()} $ / mois"141 else: # certaines fiches omettent le « $ »142 m = re.search(r"(\d{3,4})\s*/\s*mois", joined)143 if m:144 out["price_label"] = f"{m.group(1)} / mois"145146 # description riche = plus long bloc de texte (contient « Adresse: … »)147 long_txts = [el.get_text("\n", strip=True)148 for el in soup.select('[data-testid="richTextElement"]')149 if len(el.get_text(strip=True)) > 200]150 if long_txts:151 out["description"] = max(long_txts, key=len)[:1500]152153 # galerie : images wixstatic grand format, dédupliquées par id de média154 images, seen = [], set()155 for im in soup.find_all("img"):156 src = im.get("src") or ""157 if "wixstatic.com/media/" not in src:158 continue159 m_id = _MEDIA_ID.search(src)160 if not m_id or m_id.group(1) in seen:161 continue162 if not re.search(r"w_(9\d\d|\d{4,})", src):163 continue # vignettes/логos : trop petits164 seen.add(m_id.group(1))165 images.append(src)166 out["images"] = images[:20]167 return out168169 # -- assemblage ---------------------------------------------------------------170 def _build(self, ext: str, url: str, d: dict) -> Listing | None:171 if not d:172 return None173 if _STATUTS_PARTIS.match(d.get("status", "")):174 return None # unité louée/réservée175 title = d.get("title", "")176 desc = d.get("description", "")177178 # adresse : ligne « Adresse: rue de la Visitation, Saint-Charles-Borromée »179 address, city = "", ""180 m = re.search(r"Adresse\s*:\s*([^\n]+)", desc, re.I)181 if m:182 parts = [p.strip() for p in m.group(1).split(",") if p.strip()]183 city = _find_city(parts[-1]) if parts else ""184 address = ", ".join(parts[:-1]) if city and len(parts) > 1 else m.group(1).strip()185 if not city:186 city = _find_city(title) or _find_city(desc)187188 # disponibilité : ligne « DISPONIBLE DÈS LE 1ER AOUT 2025 » de la fiche189 availability = ""190 m_av = re.search(r"(DISPONIBLE\s+D[ÈE]S[^\n]*|DISPONIBLE\s+(?:LE|MAINTENANT)[^\n]*)",191 desc, re.I)192 if m_av:193 availability = re.sub(r"\s+", " ", m_av.group(1)).strip()194 elif d.get("status"):195 availability = d["status"]196197 amenities: list[str] = []198 if d.get("bedrooms", "").replace("-", "").strip().isdigit() or \199 re.match(r"^\d+(-\d+)?$", d.get("bedrooms", "")):200 amenities.append(f"{d['bedrooms']} chambre(s)")201 if re.match(r"^\d+([.,]\d+)?$", d.get("bathrooms", "")):202 amenities.append(f"{d['bathrooms']} salle(s) de bain")203204 area = None205 if re.match(r"^\d{3,4}$", d.get("sqft", "")):206 area = float(d["sqft"])207208 price_label = d.get("price_label", "")209 if not price_label:210 # fiche multi-typologies : « 3 1/2 À PARTIR DE 1150$ … » — on211 # reprend la mention la plus basse (convention Lou-Ka « à partir de »)212 fromtags = re.findall(r"à partir de\s*(\d[\d\s]*)\s*\$", desc, re.I)213 if fromtags:214 lo = min(int(x.replace(" ", "")) for x in fromtags)215 price_label = f"À partir de {lo}$"216 return Listing(217 source=self.source_id,218 external_id=ext,219 url=url,220 title=title,221 address=address,222 city=city,223 unit_type=normalize_unit_type(title),224 price=parse_price(price_label),225 price_label=price_label,226 availability=availability,227 area_sqft=area,228 description=desc,229 amenities=amenities,230 images=d.get("images") or [],231 )232