spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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½\+?|6½\+|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