SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.3 KB · 232 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/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