SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.8 KB · 131 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/montagnais.py : connecteur Le Montagnais, résidences étudiantes5#   (lemontagnais.com) — 6 campus/immeubles à Sherbrooke (Campus principal,6#   Campus de la santé, Campus Centro, Campus Mont-Bellevue, Résidences E et G),7#   location longue durée (bail) : chambres, studios, 2½ à 5½.8#   WordPress (thème Oktane « campus ») : la page /logement/ charge la liste9#   via admin-ajax `get_posts_location` (post_type=logement) qui retourne le10#   JSON complet des types de logements — titre, prix « à partir de »11#   (startingFrom), campus (habitation_terms), statut (projet_status/acf),12#   adresse structurée Google (ville, code postal, GPS), description,13#   caractéristiques ACF et photo. Granularité = type de logement par campus14#   (aucune unité individuelle publiée), comme sig.py. 1 requête par sync.15#   Seuls les types au statut « Disponible » deviennent des annonces.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://www.lemontagnais.com"27LOGEMENT_PAGE = f"{BASE}/logement/"28AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"293031class MontagnaisConnector(BaseConnector):32    source_id = "montagnais"33    request_delay = 0.63435    def fetch(self) -> list[Listing]:36        # POST admin-ajax : le même appel que la page /logement/ (post_type37        # logement, toutes les fiches d'un coup)38        resp = self.session.post(39            AJAX_URL,40            data={"action": "get_posts_location", "post_type": "logement",41                  "posts_per_page": "-1", "page": "1"},42            timeout=self.timeout)43        resp.raise_for_status()44        data = resp.json()4546        listings: list[Listing] = []47        seen: set[str] = set()48        for row in data.get("results") or []:49            post = (row or {}).get("post") or {}50            ext_id = str(post.get("ID") or "")51            if not ext_id or ext_id in seen:52                continue53            seen.add(ext_id)5455            # statut : « Disponible » sinon on saute (rien d'inventé)56            status = (post.get("projet_status") or "").strip()57            if status.lower() != "disponible":58                continue5960            title = (post.get("post_title") or "").strip()61            campus = (post.get("habitation_terms") or "").strip()6263            # type d'unité depuis le titre (« Logement 2 ½ », « Studio Galt64            # Ouest », « Grande chambre… ») — motif net seulement65            unit_type = normalize_unit_type(title)66            if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",67                                unit_type or ""):68                unit_type = ""6970            # prix « à partir de » (champ startingFrom / ACF logement_a_partir_de)71            raw_price = str(post.get("startingFrom") or "").strip()72            price_label = f"À partir de {raw_price} $/mois" if raw_price else ""73            price = parse_price(price_label)74            if price is None and re.fullmatch(r"\d{3,5}(?:[.,]\d{2})?", raw_price):75                price = float(raw_price.replace(",", "."))7677            # adresse structurée Google (ACF) : rue, ville, code postal, GPS78            addr = post.get("address") or {}79            street = " ".join(x for x in (80                str(addr.get("street_number") or ""),81                str(addr.get("street_name") or "")) if x).strip()82            postal = (addr.get("post_code") or "").strip()83            address = ", ".join(x for x in (street, postal) if x)84            city = (addr.get("city") or "").strip() or "Sherbrooke"85            lat, lng = addr.get("lat"), addr.get("lng")8687            # description (contenu du billet) + caractéristiques ACF (liste)88            description = re.sub(89                r"\s+", " ", (post.get("post_content") or "")).strip()[:1500]90            amenities: list[str] = []91            specs = (post.get("habitation_specs") or "").strip()92            for t in specs.split(","):93                t = t.strip()94                if t and t not in amenities:95                    amenities.append(t)96            acf = post.get("acf") or {}97            carac = acf.get("logement_caracteristiques") or ""98            if carac:99                for li in BeautifulSoup(carac, "html.parser").select("li"):100                    t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))101                    if t and t not in amenities and len(amenities) < 25:102                        amenities.append(t)103104            images = []105            thumb = (post.get("post_thumbnail_url") or "").strip()106            if thumb.startswith("http"):107                images.append(thumb)108109            details = {"residence": campus} if campus else {}110            listings.append(Listing(111                source=self.source_id,112                external_id=ext_id,113                url=(post.get("permalink") or LOGEMENT_PAGE),114                title=f"{title}{campus}" if campus and115                      campus.lower() not in title.lower() else title,116                address=address,117                sector=campus,          # campus = « secteur » du Montagnais118                city=city,119                unit_type=unit_type,120                price=price,121                price_label=price_label,122                availability=status,    # « Disponible »123                description=description,124                amenities=amenities,125                details=details,126                images=images,127                lat=float(lat) if lat else None,128                lng=float(lng) if lng else None,129            ))130        return listings131