# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immobilia.py : connecteur Immobilia Rimouski (immobiliarimouski.com) # Gestionnaire du centre-ville de Rimouski (ex-Immeubles MT, ~100 logements). # Site WordPress (Bedrock) + plugin « building-stack-sync » : l'inventaire # Building Stack est synchronisé dans le CPT WordPress `bss_property` et # rendu côté serveur — famille Building Stack (edifia, urban_services) mais # via le DOM WP plutôt que le portail hébergé. # - /logements/ : une carte par unité disponible (a.bss-property-item : # titre, adresse complète, type (4½/Loft…), sdb, prix, date de dispo) ; # - fiche /proprietes// : statut (« Disponible à partir du: … »), # bloc infos (prix « X$ par mois », type, sdb, pi², plan PDF), # description complète, commodités (feature-list), galerie d'images, # lien Google Maps (adresse seulement, pas de GPS). # Une annonce Lou-Ka = une unité. Fiches via self.detail() (cache BD, # invalidé quand la carte de la liste change), max max_details/sync. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://immobiliarimouski.com" LIST_URL = f"{BASE}/logements/" def _clean(s: str) -> str: return re.sub(r"\s+", " ", s or "").strip() class ImmobiliaConnector(BaseConnector): source_id = "immobilia" request_delay = 0.6 max_details = 30 # garde-fou fiches propriété (vraies requêtes par sync) max_images = 15 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") self._fetched = 0 listings: list[Listing] = [] for card in soup.select("a.bss-property-item[href]"): try: lst = self._parse_card(card) if lst: listings.append(lst) except Exception: continue return listings # -- carte de la liste /logements/ --------------------------------------------- def _parse_card(self, card) -> Listing | None: url = card["href"] slug = url.rstrip("/").rsplit("/", 1)[-1] if not slug: return None title_el = card.select_one(".property-title") title = _clean(title_el.get_text(" ", strip=True)) if title_el else slug addr_el = card.select_one(".address") address = _clean(addr_el.get_text(" ", strip=True)) if addr_el else "" # « 201 - 149 Rue de l'Évêché Ouest, Rimouski, QC, Canada » parts = [p.strip() for p in address.split(",") if p.strip()] city = "" for i, p in enumerate(parts): if p.upper() == "QC" and i >= 1: city = parts[i - 1] break # type (4½, Loft…) et sdb : icônes structurées de la carte unit_type_raw, bath = "", "" for w in card.select(".features .info-wrapper"): val = _clean(w.select_one(".info-value").get_text(" ", strip=True) if w.select_one(".info-value") else "") if w.select_one(".fa-building"): unit_type_raw = val elif w.select_one(".fa-bath"): bath = val price_el = card.select_one(".price-box .price") price_txt = _clean(price_el.get_text(" ", strip=True)) if price_el else "" avail_el = card.select_one(".availability-tag") card_avail = _clean(avail_el.get_text(" ", strip=True)) if avail_el else "" # fiche propriété (cache BD, invalidée quand la carte change) card_sig = f"{address}|{unit_type_raw}|{bath}|{price_txt}|{card_avail}" cache_key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest() def fetch_fn(): if self._fetched >= self.max_details: raise RuntimeError("budget de fiches propriété atteint") self._fetched += 1 return self._fetch_property(url) payload = self.detail(slug, cache_key, fetch_fn) amenities = list(payload.get("features", [])) if bath: amenities.insert(0, f"{bath} salle(s) de bain") details: dict = {} if payload.get("floorplan"): details["floorplan"] = payload["floorplan"] if payload.get("maps_url"): details["maps_url"] = payload["maps_url"] # prix : la carte affiche le montant nu (« 900 ») -> la fiche fait foi price_label = payload.get("price_label") or "" price = parse_price(price_label) if price is None and re.fullmatch(r"[\d\s ]+", price_txt or ""): price = parse_price(price_txt + " $") price_label = price_label or f"{price_txt} $" area = None try: v = float(payload.get("area") or 0) if 80 <= v <= 20000: area = v except (TypeError, ValueError): pass return Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, sector="", city=city or "Rimouski", unit_type=normalize_unit_type(payload.get("unit_type") or unit_type_raw), price=price, price_label=price_label, availability=payload.get("status") or card_avail, area_sqft=area, description=payload.get("description", ""), amenities=amenities, details=details, images=payload.get("images", [])[: self.max_images], ) # -- fiche /proprietes// ---------------------------------------------------- def _fetch_property(self, url: str) -> dict: """Payload JSON-sérialisable : infos structurées de la fiche.""" soup = BeautifulSoup(self.get(url).text, "html.parser") payload: dict = {} # statut (« Disponible à partir du: 15 août 2026 », « Disponible ») status = soup.select_one(".property-status") if status: payload["status"] = _clean(status.get_text(" ", strip=True)) # bloc « general-info » : prix / type / sdb / superficie / plan PDF for col in soup.select(".general-info .info-col"): val = _clean(col.get_text(" ", strip=True)) if col.select_one(".fa-dollar-sign"): payload["price_label"] = val # « 900$ par mois » elif col.select_one(".fa-building"): payload["unit_type"] = val # « Loft », « 4 1/2 » elif col.select_one(".fa-ruler"): m = re.search(r"([\d\s ]+)\s*pi", val) if m: payload["area"] = re.sub(r"[\s ]", "", m.group(1)) if col.name == "a" and col.get("href", "").lower().endswith(".pdf"): payload["floorplan"] = col["href"] # description complète (paragraphes et puces, ordre du DOM) desc = soup.select_one(".bss-property-container .description") if desc: payload["description"] = _clean( desc.get_text(" ", strip=True))[:2000] # commodités (« Accès à internet inclus », « Réfrigérateur »…) features: list[str] = [] for f in soup.select(".feature-list .feature"): t = _clean(f.get_text(" ", strip=True)) if 2 <= len(t) <= 90 and t not in features: features.append(t) payload["features"] = features # galerie (carrousel de la visionneuse, URLs absolues, dédupliquées) images: list[str] = [] for img in soup.select(".simple-carousel-slide img[data-src]"): u = img["data-src"] if u.startswith("http") and u not in images: images.append(u) if not images: # repli : image principale de la fiche m = re.search(r"data-bg-image=\"url\('([^']+)'\)", str(soup)) if m: images.append(m.group(1)) payload["images"] = images # lien Google Maps (adresse complète avec code postal — pas de GPS) maps = soup.select_one('a.under-map-button[href^="http"]') if maps: payload["maps_url"] = maps["href"] return payload