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/immobilia.py : connecteur Immobilia Rimouski (immobiliarimouski.com)5# Gestionnaire du centre-ville de Rimouski (ex-Immeubles MT, ~100 logements).6# Site WordPress (Bedrock) + plugin « building-stack-sync » : l'inventaire7# Building Stack est synchronisé dans le CPT WordPress `bss_property` et8# rendu côté serveur — famille Building Stack (edifia, urban_services) mais9# via le DOM WP plutôt que le portail hébergé.10# - /logements/ : une carte par unité disponible (a.bss-property-item :11# titre, adresse complète, type (4½/Loft…), sdb, prix, date de dispo) ;12# - fiche /proprietes/<slug>/ : statut (« Disponible à partir du: … »),13# bloc infos (prix « X$ par mois », type, sdb, pi², plan PDF),14# description complète, commodités (feature-list), galerie d'images,15# lien Google Maps (adresse seulement, pas de GPS).16# Une annonce Lou-Ka = une unité. Fiches via self.detail() (cache BD,17# invalidé quand la carte de la liste change), max max_details/sync.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import hashlib22import re2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://immobiliarimouski.com"30LIST_URL = f"{BASE}/logements/"313233def _clean(s: str) -> str:34 return re.sub(r"\s+", " ", s or "").strip()353637class ImmobiliaConnector(BaseConnector):38 source_id = "immobilia"39 request_delay = 0.640 max_details = 30 # garde-fou fiches propriété (vraies requêtes par sync)41 max_images = 154243 def fetch(self) -> list[Listing]:44 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")45 self._fetched = 046 listings: list[Listing] = []47 for card in soup.select("a.bss-property-item[href]"):48 try:49 lst = self._parse_card(card)50 if lst:51 listings.append(lst)52 except Exception:53 continue54 return listings5556 # -- carte de la liste /logements/ ---------------------------------------------57 def _parse_card(self, card) -> Listing | None:58 url = card["href"]59 slug = url.rstrip("/").rsplit("/", 1)[-1]60 if not slug:61 return None6263 title_el = card.select_one(".property-title")64 title = _clean(title_el.get_text(" ", strip=True)) if title_el else slug65 addr_el = card.select_one(".address")66 address = _clean(addr_el.get_text(" ", strip=True)) if addr_el else ""67 # « 201 - 149 Rue de l'Évêché Ouest, Rimouski, QC, Canada »68 parts = [p.strip() for p in address.split(",") if p.strip()]69 city = ""70 for i, p in enumerate(parts):71 if p.upper() == "QC" and i >= 1:72 city = parts[i - 1]73 break7475 # type (4½, Loft…) et sdb : icônes structurées de la carte76 unit_type_raw, bath = "", ""77 for w in card.select(".features .info-wrapper"):78 val = _clean(w.select_one(".info-value").get_text(" ", strip=True)79 if w.select_one(".info-value") else "")80 if w.select_one(".fa-building"):81 unit_type_raw = val82 elif w.select_one(".fa-bath"):83 bath = val8485 price_el = card.select_one(".price-box .price")86 price_txt = _clean(price_el.get_text(" ", strip=True)) if price_el else ""87 avail_el = card.select_one(".availability-tag")88 card_avail = _clean(avail_el.get_text(" ", strip=True)) if avail_el else ""8990 # fiche propriété (cache BD, invalidée quand la carte change)91 card_sig = f"{address}|{unit_type_raw}|{bath}|{price_txt}|{card_avail}"92 cache_key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest()9394 def fetch_fn():95 if self._fetched >= self.max_details:96 raise RuntimeError("budget de fiches propriété atteint")97 self._fetched += 198 return self._fetch_property(url)99100 payload = self.detail(slug, cache_key, fetch_fn)101102 amenities = list(payload.get("features", []))103 if bath:104 amenities.insert(0, f"{bath} salle(s) de bain")105106 details: dict = {}107 if payload.get("floorplan"):108 details["floorplan"] = payload["floorplan"]109 if payload.get("maps_url"):110 details["maps_url"] = payload["maps_url"]111112 # prix : la carte affiche le montant nu (« 900 ») -> la fiche fait foi113 price_label = payload.get("price_label") or ""114 price = parse_price(price_label)115 if price is None and re.fullmatch(r"[\d\s ]+", price_txt or ""):116 price = parse_price(price_txt + " $")117 price_label = price_label or f"{price_txt} $"118119 area = None120 try:121 v = float(payload.get("area") or 0)122 if 80 <= v <= 20000:123 area = v124 except (TypeError, ValueError):125 pass126127 return Listing(128 source=self.source_id,129 external_id=slug,130 url=url,131 title=title,132 address=address,133 sector="",134 city=city or "Rimouski",135 unit_type=normalize_unit_type(payload.get("unit_type")136 or unit_type_raw),137 price=price,138 price_label=price_label,139 availability=payload.get("status") or card_avail,140 area_sqft=area,141 description=payload.get("description", ""),142 amenities=amenities,143 details=details,144 images=payload.get("images", [])[: self.max_images],145 )146147 # -- fiche /proprietes/<slug>/ ----------------------------------------------------148 def _fetch_property(self, url: str) -> dict:149 """Payload JSON-sérialisable : infos structurées de la fiche."""150 soup = BeautifulSoup(self.get(url).text, "html.parser")151 payload: dict = {}152153 # statut (« Disponible à partir du: 15 août 2026 », « Disponible »)154 status = soup.select_one(".property-status")155 if status:156 payload["status"] = _clean(status.get_text(" ", strip=True))157158 # bloc « general-info » : prix / type / sdb / superficie / plan PDF159 for col in soup.select(".general-info .info-col"):160 val = _clean(col.get_text(" ", strip=True))161 if col.select_one(".fa-dollar-sign"):162 payload["price_label"] = val # « 900$ par mois »163 elif col.select_one(".fa-building"):164 payload["unit_type"] = val # « Loft », « 4 1/2 »165 elif col.select_one(".fa-ruler"):166 m = re.search(r"([\d\s ]+)\s*pi", val)167 if m:168 payload["area"] = re.sub(r"[\s ]", "", m.group(1))169 if col.name == "a" and col.get("href", "").lower().endswith(".pdf"):170 payload["floorplan"] = col["href"]171172 # description complète (paragraphes et puces, ordre du DOM)173 desc = soup.select_one(".bss-property-container .description")174 if desc:175 payload["description"] = _clean(176 desc.get_text(" ", strip=True))[:2000]177178 # commodités (« Accès à internet inclus », « Réfrigérateur »…)179 features: list[str] = []180 for f in soup.select(".feature-list .feature"):181 t = _clean(f.get_text(" ", strip=True))182 if 2 <= len(t) <= 90 and t not in features:183 features.append(t)184 payload["features"] = features185186 # galerie (carrousel de la visionneuse, URLs absolues, dédupliquées)187 images: list[str] = []188 for img in soup.select(".simple-carousel-slide img[data-src]"):189 u = img["data-src"]190 if u.startswith("http") and u not in images:191 images.append(u)192 if not images: # repli : image principale de la fiche193 m = re.search(r"data-bg-image=\"url\('([^']+)'\)", str(soup))194 if m:195 images.append(m.group(1))196 payload["images"] = images197198 # lien Google Maps (adresse complète avec code postal — pas de GPS)199 maps = soup.select_one('a.under-map-button[href^="http"]')200 if maps:201 payload["maps_url"] = maps["href"]202 return payload203