SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.1 KB · 216 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/moderno.py : connecteur Moderno Construction (moderno.immo)5#   Promoteur-gestionnaire de Lanaudière (Joliette — Aqua Roca, Domaine du6#   Sentier Riverain). Site custom SSR très propre : /logements-a-louer liste7#   des cartes BEM (titre, disponibilité, prix, adresse, badge « Loué »),8#   chaque carte pointe vers /logements-a-louer/<projet>/<CODE> ; le CODE9#   (= champ « Référence » de la fiche) sert d'external_id stable. La fiche10#   détail (via cache BD) ajoute chambres/sdb, unité/niveau/vue/superficie,11#   inclusions, commodités, description et galerie.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price21from .base import BaseConnector2223BASE = "https://moderno.immo"24LIST_URL = f"{BASE}/logements-a-louer"2526_CARD = "appartements-a-louer__liste__item"27_DET = "details-appartement__details"2829# vignettes DigitalOcean Spaces : ".../<id>/conversions/<nom>-thumb.jpg"30# -> pleine taille ".../<id>/<nom>.jpg" (même schéma que les images de cartes)31_THUMB_RE = re.compile(r"/conversions/(.+?)-(?:first_)?thumb(\.\w+)$")323334def _full_img(url: str) -> str:35    return _THUMB_RE.sub(r"/\1\2", url.strip())363738class ModernoConnector(BaseConnector):39    source_id = "moderno"40    request_delay = 0.741    max_details = 40    # garde-fou fiches détail (vraies requêtes par sync)4243    def fetch(self) -> list[Listing]:44        html = self.get(LIST_URL).text45        soup = BeautifulSoup(html, "html.parser")46        listings: dict[str, Listing] = {}47        for card in soup.select(f"a.{_CARD}[href]"):48            try:49                self._parse_card(card, listings)50            except Exception:51                continue5253        self._fetched = 054        for lst in listings.values():55            key = hashlib.sha1(56                f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.address}"57                .encode("utf-8")).hexdigest()58            try:59                payload = self.detail(lst.external_id, key,60                                      lambda u=lst.url: self._fetch_detail(u))61            except Exception:62                continue63            self._apply_detail(lst, payload)64        return list(listings.values())6566    # -- carte liste ------------------------------------------------------------67    def _parse_card(self, card, listings: dict[str, Listing]) -> None:68        url = card["href"]69        if url.startswith("/"):70            url = BASE + url71        m = re.search(r"/logements-a-louer/([^/]+)/([A-Z0-9]+)/?$", url)72        if not m:73            return74        project_slug, code = m.group(1), m.group(2)75        if code in listings:76            return7778        def _txt(suffix: str) -> str:79            el = card.select_one(f".{_CARD}__{suffix}")80            return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else ""8182        # badge « Loué » : annonce déjà partie, on l'ignore83        if re.search(r"lou[ée]", _txt("rented"), re.I):84            return8586        title = _txt("titre")                      # « 4 1/2 - Domaine du Sentier Riverain »87        address_full = _txt("adresse")             # « 105-1002 rue Gustave-Guertin, Joliette »88        parts = [p.strip() for p in address_full.split(",") if p.strip()]89        address = re.sub(r"\s+", " ", parts[0]) if parts else ""90        city = parts[-1] if len(parts) > 1 else ""91        price_label = _txt("prix")                 # « 1 915 $ par mois »92        message = _txt("message")                  # accroche rédigée par Moderno9394        images: list[str] = []95        img = card.select_one("img[src]")96        if img and img["src"].startswith("http"):97            images.append(_full_img(img["src"]))9899        listings[code] = Listing(100            source=self.source_id,101            external_id=code,                      # champ « Référence » de la fiche102            url=url,103            title=title,104            address=address,105            city=city,106            unit_type=normalize_unit_type(title),107            price=parse_price(price_label),108            price_label=price_label,109            availability=_txt("disponibilite"),    # « Disponible 1er octobre 2026 »110            description=message,111            details={"project": project_slug},112            images=images,113        )114115    # -- fiche détail -------------------------------------------------------------116    def _fetch_detail(self, url: str) -> dict:117        """Chambres/sdb, chiffres (unité, niveau, vue, superficie, dispo),118        inclusions + commodités, description et galerie pleine taille."""119        if self._fetched >= self.max_details:120            raise RuntimeError("budget de fiches détail atteint")121        self._fetched += 1122        html = self.get(url).text123        soup = BeautifulSoup(html, "html.parser")124        out: dict = {}125126        det = soup.select_one(f"section.{_DET}")127        if det is None:128            return out129130        # adresse complète de la fiche (parfois plus propre que la carte)131        addr = det.select_one(f".{_DET}__adresse")132        if addr:133            out["address_full"] = re.sub(r"\s+", " ", addr.get_text(" ", strip=True))134135        # « 1 chambre », « 1 salle de bain »136        out["pieces"] = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))137                         for p in det.select(f".{_DET}__pieces__piece")]138139        # paires libellé/valeur : Unité, Niveau, Vue, Superficie, Disponibilité, Référence140        chiffres: dict[str, str] = {}141        for div in det.select(f".{_DET}__chiffres > div"):142            ps = div.find_all("p")143            if len(ps) >= 2:144                lab = ps[0].get_text(" ", strip=True)145                val = re.sub(r"\s+", " ", ps[1].get_text(" ", strip=True))146                if lab and val:147                    chiffres[lab] = val148        out["chiffres"] = chiffres149150        # Inclusions (eau chaude, internet…) et Commodités (logement + immeuble)151        feats: list[str] = []152        for box in det.select(f".{_DET}__inclusions, .{_DET}__commodites"):153            items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))154                     for li in box.find_all("li")]155            if not items:   # listes parfois en <p>156                items = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))157                         for p in box.find_all("p")][1:]158            head = box.find(["h4", "h5", "div"])159            head_txt = head.get_text(" ", strip=True) if head else ""160            if re.match(r"(?i)localisation", head_txt):161                continue                             # adresse déjà captée162            for it in items:163                if it and it not in feats and not re.match(r"(?i)inclusions|commodit|caractéristiques|localisation", it):164                    feats.append(it)165        out["features"] = feats[:40]166167        # description : accroche + paragraphes rédigés de la fiche168        msg = det.select_one(f".{_DET}__message")169        paras = [msg.get_text(" ", strip=True)] if msg else []170        for p in det.find_all("p"):171            t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))172            if len(t) > 60 and t not in paras:173                paras.append(t)174        out["description"] = "\n".join(paras)[:1200]175176        # galerie (vignettes /conversions/ -> pleine taille)177        images: list[str] = []178        for img in soup.select(".details-appartement__introduction__galerie img[src]"):179            u = _full_img(img["src"])180            if u.startswith("http") and u not in images:181                images.append(u)182        out["images"] = images[:30]183        return out184185    def _apply_detail(self, lst: Listing, d: dict) -> None:186        if not d:187            return188        ch = d.get("chiffres") or {}189        details = dict(lst.details)190        for lab, key in (("Unité", "unit_number"), ("Niveau", "floor"),191                         ("Vue", "view"), ("Référence", "reference")):192            if ch.get(lab):193                details[key] = ch[lab]194        lst.details = details195        m = re.match(r"([\d\s,.]+)\s*pi", ch.get("Superficie", ""))196        if m:197            try:198                lst.area_sqft = float(m.group(1).replace(" ", "").replace(",", ""))199            except ValueError:200                pass201        if ch.get("Disponibilité") and not lst.availability:202            lst.availability = ch["Disponibilité"]203        if d.get("address_full") and not lst.address:204            parts = [p.strip() for p in d["address_full"].split(",")]205            lst.address = parts[0]206            if len(parts) > 1 and not lst.city:207                lst.city = parts[-1]208        extra = (d.get("pieces") or []) + (d.get("features") or [])209        if extra:210            lst.amenities = list(dict.fromkeys(lst.amenities + extra))211        if d.get("description"):212            lst.description = d["description"]213        if d.get("images"):214            merged = d["images"] + [u for u in lst.images if u not in d["images"]]215            lst.images = merged[:30]216