# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immnasr.py : connecteur Les Immeubles Michel Nazair (immnasr.com) # Petit gestionnaire de Gatineau (Mont-Fleuri, Val-Tétreau…). Vieux site # « SiteBuilder » (Website.com) rendu serveur : la section À LOUER de # l'accueil aligne, pour chaque logement, un bloc texte structuré # (« Loyer: 1 325 $/mois », « Disponibilité: 1er octobre 2026 », « Nombre de # chambres à coucher », « Surface », « Prises laveuse et sécheuse », « Coût # approx. électricité/énergie », « Description: » bilingue) suivi d'un bloc # adresse (« 183, du Mont-Fleuri, Gatineau QC »). Les carrousels de photos # (CDN dotezcdn.com) précèdent les blocs : chaque galerie est rattachée à # son logement par le numéro civique en tête des attributs alt # (« 183_Facade… », « 31-2 Salon… », « 8_H-Dupuis… »). # external_id = adresse en slug (le site n'expose aucun identifiant). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.immnasr.com" _NBSP = " " def _slug(text: str) -> str: s = strip_accents(text.lower()) s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s def _civic(address: str) -> str: m = re.match(r"\s*(\d+)", address) return m.group(1) if m else "" class ImmnasrConnector(BaseConnector): source_id = "immnasr" request_delay = 1.0 max_images = 20 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(f"{BASE}/").text, "html.parser") # 1) galeries : liste ordonnée de (préfixes alt, [urls]) galleries: list[tuple[list[str], list[str]]] = [] for car in soup.select("div.element"): imgs = car.select(".carousel img[src]") if not imgs: continue alts, urls = [], [] for im in imgs: alt = (im.get("alt") or "").strip() if alt: alts.append(alt) u = (im.get("src") or "").strip() if u.startswith("//"): u = "https:" + u if u.startswith("http") and u not in urls: urls.append(u) if urls: galleries.append((alts, urls)) # 2) blocs annonces : élément texte « Loyer: … » suivi de l'adresse elements = soup.select("div.element") listings: dict[str, Listing] = {} for i, el in enumerate(elements): txt = el.get_text("\n", strip=True).replace(_NBSP, " ") if not re.match(r"^\s*Loyer\s*:", txt): continue # l'adresse est le prochain élément texte court « …, Gatineau QC » address = "" for nxt in elements[i + 1:i + 3]: t = re.sub(r"\s+", " ", nxt.get_text(" ", strip=True) .replace(_NBSP, " ")).strip() if re.search(r",\s*(Gatineau|Hull|Aylmer)\b", t, re.I) and len(t) < 90: address = t break if not address: continue ext_id = _slug(address) if not ext_id or ext_id in listings: continue try: listings[ext_id] = self._parse_block(txt, address, ext_id, galleries) except Exception: continue return list(listings.values()) def _parse_block(self, txt: str, address: str, ext_id: str, galleries: list[tuple[list[str], list[str]]]) -> Listing: flat = re.sub(r"[ \t]+", " ", txt) def _field(label: str) -> str: m = re.search(label + r"\s*:?\s*\n?(.+?)(?:\n[A-ZÀ-Ü][^\n]*:|\Z)", flat, re.S) return re.sub(r"\s+", " ", m.group(1)).strip() if m else "" m = re.search(r"Loyer\s*:\s*([\d\s]+)\s*\$", flat) price_label = f"{m.group(1).strip()} $ / mois" if m else "" # « Disponibilité: 1 / er / octobre / 2026 » (exposant sur sa propre # ligne) : capturer jusqu'au libellé suivant puis aplatir m = re.search(r"Disponibilit[ée]\s*:\s*(.*?)\s*(?:\nNombre|\nSurface|\Z)", flat, re.S | re.I) availability = re.sub(r"\s+", " ", m.group(1)).strip(" .") if m else "" m = re.search(r"chambres? à coucher\s*:\s*\n?([^\n]+)", flat, re.I) bedrooms_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else "" m = re.search(r"Surface\s*:\s*\n?([^\n]+)", flat, re.I) surface_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else "" m = re.search(r"(?:Prises? laveuse[^:]*)\s*:\s*\n?([^\n]+)", flat, re.I) washer_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else "" m = re.search(r"Co[ûu]t approx\.[^:]*:\s*\n?([^\n]+)", flat, re.I) energy_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else "" m = re.search(r"Description\s*:\s*(.+)$", flat, re.S | re.I) description = "" if m: description = re.sub(r"[ \t]+", " ", m.group(1)).strip()[:2500] # type d'unité : maison/bungalow écrit par l'agence, sinon n chambres unit_type = "" if re.search(r"\b(bungalow|maison)\b", description, re.I): unit_type = "Maison" else: mb = re.match(r"(\d+)", bedrooms_raw) if mb: unit_type = normalize_unit_type(f"{mb.group(1)} chambres") details: dict = {} if bedrooms_raw: mb = re.match(r"(\d+)", bedrooms_raw) if mb: details["bedrooms"] = int(mb.group(1)) details["bedrooms_raw"] = bedrooms_raw if energy_raw: details["energy_cost"] = energy_raw amenities: list[str] = [] if washer_raw.lower().startswith("oui"): amenities.append("Prises laveuse et sécheuse") # galerie associée par numéro civique en tête des alt civic = _civic(address) images: list[str] = [] if civic: for alts, urls in galleries: heads = [re.match(r"[\w]+", strip_accents(a.replace("Façade", "")).strip()) for a in alts] prefixes = {re.match(r"(\d+)", a.strip()).group(1) for a in alts if re.match(r"(\d+)", a.strip())} _ = heads if civic in prefixes: images = urls[: self.max_images] break return Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}/#{ext_id}", title=address, address=address, city="Gatineau", unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=parse_area_sqft(surface_raw), description=description, amenities=amenities, details=details, images=images, )