# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/montagnais.py : connecteur Le Montagnais, résidences étudiantes # (lemontagnais.com) — 6 campus/immeubles à Sherbrooke (Campus principal, # Campus de la santé, Campus Centro, Campus Mont-Bellevue, Résidences E et G), # location longue durée (bail) : chambres, studios, 2½ à 5½. # WordPress (thème Oktane « campus ») : la page /logement/ charge la liste # via admin-ajax `get_posts_location` (post_type=logement) qui retourne le # JSON complet des types de logements — titre, prix « à partir de » # (startingFrom), campus (habitation_terms), statut (projet_status/acf), # adresse structurée Google (ville, code postal, GPS), description, # caractéristiques ACF et photo. Granularité = type de logement par campus # (aucune unité individuelle publiée), comme sig.py. 1 requête par sync. # Seuls les types au statut « Disponible » deviennent des annonces. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.lemontagnais.com" LOGEMENT_PAGE = f"{BASE}/logement/" AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" class MontagnaisConnector(BaseConnector): source_id = "montagnais" request_delay = 0.6 def fetch(self) -> list[Listing]: # POST admin-ajax : le même appel que la page /logement/ (post_type # logement, toutes les fiches d'un coup) resp = self.session.post( AJAX_URL, data={"action": "get_posts_location", "post_type": "logement", "posts_per_page": "-1", "page": "1"}, timeout=self.timeout) resp.raise_for_status() data = resp.json() listings: list[Listing] = [] seen: set[str] = set() for row in data.get("results") or []: post = (row or {}).get("post") or {} ext_id = str(post.get("ID") or "") if not ext_id or ext_id in seen: continue seen.add(ext_id) # statut : « Disponible » sinon on saute (rien d'inventé) status = (post.get("projet_status") or "").strip() if status.lower() != "disponible": continue title = (post.get("post_title") or "").strip() campus = (post.get("habitation_terms") or "").strip() # type d'unité depuis le titre (« Logement 2 ½ », « Studio Galt # Ouest », « Grande chambre… ») — motif net seulement unit_type = normalize_unit_type(title) if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", unit_type or ""): unit_type = "" # prix « à partir de » (champ startingFrom / ACF logement_a_partir_de) raw_price = str(post.get("startingFrom") or "").strip() price_label = f"À partir de {raw_price} $/mois" if raw_price else "" price = parse_price(price_label) if price is None and re.fullmatch(r"\d{3,5}(?:[.,]\d{2})?", raw_price): price = float(raw_price.replace(",", ".")) # adresse structurée Google (ACF) : rue, ville, code postal, GPS addr = post.get("address") or {} street = " ".join(x for x in ( str(addr.get("street_number") or ""), str(addr.get("street_name") or "")) if x).strip() postal = (addr.get("post_code") or "").strip() address = ", ".join(x for x in (street, postal) if x) city = (addr.get("city") or "").strip() or "Sherbrooke" lat, lng = addr.get("lat"), addr.get("lng") # description (contenu du billet) + caractéristiques ACF (liste) description = re.sub( r"\s+", " ", (post.get("post_content") or "")).strip()[:1500] amenities: list[str] = [] specs = (post.get("habitation_specs") or "").strip() for t in specs.split(","): t = t.strip() if t and t not in amenities: amenities.append(t) acf = post.get("acf") or {} carac = acf.get("logement_caracteristiques") or "" if carac: for li in BeautifulSoup(carac, "html.parser").select("li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and t not in amenities and len(amenities) < 25: amenities.append(t) images = [] thumb = (post.get("post_thumbnail_url") or "").strip() if thumb.startswith("http"): images.append(thumb) details = {"residence": campus} if campus else {} listings.append(Listing( source=self.source_id, external_id=ext_id, url=(post.get("permalink") or LOGEMENT_PAGE), title=f"{title} — {campus}" if campus and campus.lower() not in title.lower() else title, address=address, sector=campus, # campus = « secteur » du Montagnais city=city, unit_type=unit_type, price=price, price_label=price_label, availability=status, # « Disponible » description=description, amenities=amenities, details=details, images=images, lat=float(lat) if lat else None, lng=float(lng) if lng else None, )) return listings