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/savouet.py : connecteur Groupe Savouet (savouet.ca)5# Gestionnaire-propriétaire de Sherbrooke (Fleurimont, Mont-Bellevue,6# Centre-ville, Rock-Forest, Lennoxville) + East Angus. Webflow CMS rendu7# serveur (même famille que copley.py) : page /logement, cartes8# `a[href^=/logements-a-louer/]` avec champs étiquetés `fs-cmsfilter-field`9# (prix, dimensions, secteur, atout/meublé, type) + bandeau `.rabais`10# (« Libre dès maintenant ! »). Fiches détail via self.detail() (cache BD) :11# blocs `.term-block-1` (étage, animaux, ameublement, stationnement, sdb,12# typologie), description `.w-richtext`, galerie `img.cover-image`.13# ATTENTION Webflow : les variantes conditionnelles `w-condition-invisible`14# (valeurs masquées côté client) doivent être exclues partout.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price, strip_accents25from .base import BaseConnector2627BASE = "https://www.savouet.ca"28LIST_URL = f"{BASE}/logement"2930# image de remplacement Webflow « aucune photo »31_PLACEHOLDER_RE = re.compile(r"sans-photo|placeholder", re.I)323334def _visible(el) -> bool:35 """Faux si l'élément (ou un parent proche) porte w-condition-invisible."""36 node = el37 for _ in range(4):38 if node is None or not getattr(node, "get", None):39 break40 if "w-condition-invisible" in (node.get("class") or []):41 return False42 node = node.parent43 return True444546def _pets_value(raw: str) -> str | None:47 """« Non permis (chat toléré) » -> conditions ; « Non permis » -> non ;48 « Permis » -> oui ; sinon None (jamais deviné)."""49 k = strip_accents((raw or "").strip().lower())50 if not k:51 return None52 if "non permis" in k or k.startswith("non"):53 return "conditions" if re.search(r"tolere|chat|chien|sauf", k) else "non"54 if "permis" in k or "accepte" in k:55 return "oui"56 return None575859def _furnished_value(raw: str) -> bool | None:60 """« Non meublé » -> False ; « Meublé » -> True ; « Semi meublé » -> None61 (état partiel : on garde le texte source dans les commodités)."""62 k = strip_accents((raw or "").strip().lower())63 if not k or "semi" in k:64 return None65 if k.startswith("non"):66 return False67 if "meuble" in k:68 return True69 return None707172class SavouetConnector(BaseConnector):73 source_id = "savouet"74 request_delay = 0.675 max_details = 60 # garde-fou : vraies requêtes de fiches détail7677 def fetch(self) -> list[Listing]:78 html = self.get(LIST_URL).text79 soup = BeautifulSoup(html, "html.parser")8081 listings: dict[str, Listing] = {}82 for card in soup.select('a[href^="/logements-a-louer/"]'):83 try:84 lst = self._parse_card(card)85 except Exception:86 continue87 if lst and lst.external_id not in listings:88 listings[lst.external_id] = lst8990 # fiches détail (cache BD) : étage, animaux, meublé, description, photos91 self._fetched = 092 for lst in listings.values():93 key = hashlib.sha1(94 f"{lst.price_label}|{lst.availability}|{lst.unit_type}"95 .encode("utf-8")).hexdigest()9697 def fetch_fn(u=lst.url):98 if self._fetched >= self.max_details:99 raise RuntimeError("budget de fiches détail atteint")100 self._fetched += 1101 return self._fetch_detail(u)102103 try:104 payload = self.detail(lst.external_id, key, fetch_fn)105 except Exception:106 continue107 self._apply_detail(lst, payload)108109 return list(listings.values())110111 # -- carte Webflow ------------------------------------------------------------112 def _parse_card(self, card) -> Listing | None:113 href = (card.get("href") or "").split("?")[0].rstrip("/")114 m = re.match(r"/logements-a-louer/([\w\-%.]+)$", href)115 if not m:116 return None117 slug = m.group(1)118119 title_el = card.select_one("h2.is-service-title")120 title = " ".join(title_el.get_text(" ", strip=True).split()) if title_el else ""121122 # hors périmètre logement : garages, stationnements, locaux123 if re.search(r"garage|stationnement|local|entrep[oô]t", title, re.I):124 return None125126 # champs étiquetés fs-cmsfilter-field (variantes invisibles exclues) ;127 # « secteur » sert deux fois : bandeau .rabais (disponibilité) + secteur128 fields: dict[str, list[str]] = {}129 availability = ""130 for el in card.select("[fs-cmsfilter-field]"):131 if not _visible(el):132 continue133 val = " ".join(el.get_text(" ", strip=True).split())134 if not val:135 continue136 if el.find_parent(class_="rabais") is not None:137 availability = availability or val138 continue139 fields.setdefault(el.get("fs-cmsfilter-field", ""), []).append(val)140141 sector = (fields.get("secteur") or [""])[0]142 price_label = (fields.get("prix") or [""])[0]143 unit_type = normalize_unit_type((fields.get("dimensions") or [""])[0])144 housing_type = (fields.get("type") or [""])[0]145 atout = (fields.get("atout") or [""])[0] # « Non meublé »…146147 if re.search(r"garage|stationnement|local", housing_type, re.I):148 return None149 if not unit_type and re.search(r"chambre", housing_type, re.I):150 unit_type = "Chambre"151152 amenities = []153 if housing_type:154 amenities.append(housing_type)155 if atout:156 amenities.append(atout)157158 # ville réelle : Sherbrooke par défaut (parc local) ; East Angus159 # lorsque l'adresse du titre le précise. Lennoxville = secteur.160 city = "East Angus" if re.search(r"east[\s-]angus", title, re.I) else "Sherbrooke"161 if strip_accents(sector.lower()).startswith("arrondissement"):162 sector = ""163164 images = []165 for img in card.select("img.first-image[src], .image-animation-trigger img[src]"):166 src = img["src"]167 if (src.startswith("http") and not _PLACEHOLDER_RE.search(src)168 and _visible(img) and src not in images):169 images.append(src)170171 return Listing(172 source=self.source_id,173 external_id=slug,174 url=f"{BASE}/logements-a-louer/{slug}",175 title=title or slug.replace("-", " "),176 address=title,177 sector=sector,178 city=city,179 unit_type=unit_type,180 price=parse_price(price_label),181 price_label=price_label,182 availability=availability,183 furnished=_furnished_value(atout),184 amenities=amenities,185 images=images[:5],186 )187188 # -- fiche détail ---------------------------------------------------------------189 def _fetch_detail(self, url: str) -> dict:190 """Blocs .term-block-1 (paires libellé/valeur), description, galerie."""191 html = self.get(url).text192 soup = BeautifulSoup(html, "html.parser")193 out: dict = {}194195 pairs: dict[str, str] = {}196 for blk in soup.select(".term-block-1"):197 lab_el = blk.select_one(".content-title-1")198 if not lab_el:199 continue200 lab = strip_accents(lab_el.get_text(" ", strip=True).lower())201 vals = [" ".join(v.get_text(" ", strip=True).split())202 for v in blk.select(".terms-text-2") if _visible(v)]203 vals = [v for v in vals if v]204 if lab and vals and lab not in pairs:205 pairs[lab] = vals[0]206 out["pairs"] = pairs207208 rich = next((r for r in soup.select(".w-richtext")209 if r.find_parent(class_="appartement-item") is None), None)210 if rich:211 txt = rich.get_text("\n", strip=True)212 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]213214 # galerie de la fiche : lightbox Webflow (scripts JSON w-json) — les215 # scripts situés dans la section « Poursuivre votre recherche »216 # (.appartement-item = autres annonces) sont exclus217 images: list[str] = []218 for sc in soup.select("script.w-json"):219 if sc.find_parent(class_="appartement-item") is not None:220 continue221 try:222 data = json.loads(sc.string or "")223 except (ValueError, TypeError):224 continue225 for item in (data.get("items") or []):226 src = item.get("url") or ""227 if (src.startswith("http") and not _PLACEHOLDER_RE.search(src)228 and src not in images):229 images.append(src)230 out["images"] = images[:30]231 return out232233 def _apply_detail(self, lst: Listing, d: dict) -> None:234 if not d:235 return236 if d.get("description"):237 lst.description = d["description"]238 if d.get("images"):239 lst.images = d["images"]240 pairs = d.get("pairs") or {}241242 extra: list[str] = []243 for lab, val in pairs.items():244 if lab.startswith("etage"):245 extra.append(f"Étage : {val}")246 elif lab.startswith("typologie"):247 extra.append(f"Typologie : {val}")248 elif lab.startswith("stationnement"):249 extra.append(val)250 if re.search(r"inclus|disponible", val, re.I):251 lst.details["parking"] = {"available": True}252 elif lab.startswith("entree"):253 extra.append(val)254 elif lab.startswith("salle de bain"):255 extra.append(f"{val} salle(s) de bain")256 elif lab.startswith("animaux"):257 extra.append(f"Animaux : {val}")258 pets = _pets_value(val)259 if pets:260 lst.pets = pets261 elif lab.startswith("ameublement"):262 furn = _furnished_value(val)263 if furn is not None:264 lst.furnished = furn265 elif lab.startswith("disponibilite") and not lst.availability:266 lst.availability = val267 if extra:268 lst.amenities = list(dict.fromkeys(lst.amenities + extra))269