SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
10.6 KB · 269 lines python
Raw Blame History
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