# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/savouet.py : connecteur Groupe Savouet (savouet.ca) # Gestionnaire-propriétaire de Sherbrooke (Fleurimont, Mont-Bellevue, # Centre-ville, Rock-Forest, Lennoxville) + East Angus. Webflow CMS rendu # serveur (même famille que copley.py) : page /logement, cartes # `a[href^=/logements-a-louer/]` avec champs étiquetés `fs-cmsfilter-field` # (prix, dimensions, secteur, atout/meublé, type) + bandeau `.rabais` # (« Libre dès maintenant ! »). Fiches détail via self.detail() (cache BD) : # blocs `.term-block-1` (étage, animaux, ameublement, stationnement, sdb, # typologie), description `.w-richtext`, galerie `img.cover-image`. # ATTENTION Webflow : les variantes conditionnelles `w-condition-invisible` # (valeurs masquées côté client) doivent être exclues partout. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.savouet.ca" LIST_URL = f"{BASE}/logement" # image de remplacement Webflow « aucune photo » _PLACEHOLDER_RE = re.compile(r"sans-photo|placeholder", re.I) def _visible(el) -> bool: """Faux si l'élément (ou un parent proche) porte w-condition-invisible.""" node = el for _ in range(4): if node is None or not getattr(node, "get", None): break if "w-condition-invisible" in (node.get("class") or []): return False node = node.parent return True def _pets_value(raw: str) -> str | None: """« Non permis (chat toléré) » -> conditions ; « Non permis » -> non ; « Permis » -> oui ; sinon None (jamais deviné).""" k = strip_accents((raw or "").strip().lower()) if not k: return None if "non permis" in k or k.startswith("non"): return "conditions" if re.search(r"tolere|chat|chien|sauf", k) else "non" if "permis" in k or "accepte" in k: return "oui" return None def _furnished_value(raw: str) -> bool | None: """« Non meublé » -> False ; « Meublé » -> True ; « Semi meublé » -> None (état partiel : on garde le texte source dans les commodités).""" k = strip_accents((raw or "").strip().lower()) if not k or "semi" in k: return None if k.startswith("non"): return False if "meuble" in k: return True return None class SavouetConnector(BaseConnector): source_id = "savouet" request_delay = 0.6 max_details = 60 # garde-fou : vraies requêtes de fiches détail 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('a[href^="/logements-a-louer/"]'): try: lst = self._parse_card(card) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # fiches détail (cache BD) : étage, animaux, meublé, description, photos self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.price_label}|{lst.availability}|{lst.unit_type}" .encode("utf-8")).hexdigest() def fetch_fn(u=lst.url): if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 return self._fetch_detail(u) try: payload = self.detail(lst.external_id, key, fetch_fn) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte Webflow ------------------------------------------------------------ def _parse_card(self, card) -> Listing | None: href = (card.get("href") or "").split("?")[0].rstrip("/") m = re.match(r"/logements-a-louer/([\w\-%.]+)$", href) if not m: return None slug = m.group(1) title_el = card.select_one("h2.is-service-title") title = " ".join(title_el.get_text(" ", strip=True).split()) if title_el else "" # hors périmètre logement : garages, stationnements, locaux if re.search(r"garage|stationnement|local|entrep[oô]t", title, re.I): return None # champs étiquetés fs-cmsfilter-field (variantes invisibles exclues) ; # « secteur » sert deux fois : bandeau .rabais (disponibilité) + secteur fields: dict[str, list[str]] = {} availability = "" for el in card.select("[fs-cmsfilter-field]"): if not _visible(el): continue val = " ".join(el.get_text(" ", strip=True).split()) if not val: continue if el.find_parent(class_="rabais") is not None: availability = availability or val continue fields.setdefault(el.get("fs-cmsfilter-field", ""), []).append(val) sector = (fields.get("secteur") or [""])[0] price_label = (fields.get("prix") or [""])[0] unit_type = normalize_unit_type((fields.get("dimensions") or [""])[0]) housing_type = (fields.get("type") or [""])[0] atout = (fields.get("atout") or [""])[0] # « Non meublé »… if re.search(r"garage|stationnement|local", housing_type, re.I): return None if not unit_type and re.search(r"chambre", housing_type, re.I): unit_type = "Chambre" amenities = [] if housing_type: amenities.append(housing_type) if atout: amenities.append(atout) # ville réelle : Sherbrooke par défaut (parc local) ; East Angus # lorsque l'adresse du titre le précise. Lennoxville = secteur. city = "East Angus" if re.search(r"east[\s-]angus", title, re.I) else "Sherbrooke" if strip_accents(sector.lower()).startswith("arrondissement"): sector = "" images = [] for img in card.select("img.first-image[src], .image-animation-trigger img[src]"): src = img["src"] if (src.startswith("http") and not _PLACEHOLDER_RE.search(src) and _visible(img) and src not in images): images.append(src) return Listing( source=self.source_id, external_id=slug, url=f"{BASE}/logements-a-louer/{slug}", title=title or slug.replace("-", " "), address=title, sector=sector, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, furnished=_furnished_value(atout), amenities=amenities, images=images[:5], ) # -- fiche détail --------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Blocs .term-block-1 (paires libellé/valeur), description, galerie.""" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} pairs: dict[str, str] = {} for blk in soup.select(".term-block-1"): lab_el = blk.select_one(".content-title-1") if not lab_el: continue lab = strip_accents(lab_el.get_text(" ", strip=True).lower()) vals = [" ".join(v.get_text(" ", strip=True).split()) for v in blk.select(".terms-text-2") if _visible(v)] vals = [v for v in vals if v] if lab and vals and lab not in pairs: pairs[lab] = vals[0] out["pairs"] = pairs rich = next((r for r in soup.select(".w-richtext") if r.find_parent(class_="appartement-item") is None), None) if rich: txt = rich.get_text("\n", strip=True) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] # galerie de la fiche : lightbox Webflow (scripts JSON w-json) — les # scripts situés dans la section « Poursuivre votre recherche » # (.appartement-item = autres annonces) sont exclus images: list[str] = [] for sc in soup.select("script.w-json"): if sc.find_parent(class_="appartement-item") is not None: continue try: data = json.loads(sc.string or "") except (ValueError, TypeError): continue for item in (data.get("items") or []): src = item.get("url") or "" if (src.startswith("http") and not _PLACEHOLDER_RE.search(src) and src not in images): images.append(src) out["images"] = images[:30] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("description"): lst.description = d["description"] if d.get("images"): lst.images = d["images"] pairs = d.get("pairs") or {} extra: list[str] = [] for lab, val in pairs.items(): if lab.startswith("etage"): extra.append(f"Étage : {val}") elif lab.startswith("typologie"): extra.append(f"Typologie : {val}") elif lab.startswith("stationnement"): extra.append(val) if re.search(r"inclus|disponible", val, re.I): lst.details["parking"] = {"available": True} elif lab.startswith("entree"): extra.append(val) elif lab.startswith("salle de bain"): extra.append(f"{val} salle(s) de bain") elif lab.startswith("animaux"): extra.append(f"Animaux : {val}") pets = _pets_value(val) if pets: lst.pets = pets elif lab.startswith("ameublement"): furn = _furnished_value(val) if furn is not None: lst.furnished = furn elif lab.startswith("disponibilite") and not lst.availability: lst.availability = val if extra: lst.amenities = list(dict.fromkeys(lst.amenities + extra))