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/immnasr.py : connecteur Les Immeubles Michel Nazair (immnasr.com)5# Petit gestionnaire de Gatineau (Mont-Fleuri, Val-Tétreau…). Vieux site6# « SiteBuilder » (Website.com) rendu serveur : la section À LOUER de7# l'accueil aligne, pour chaque logement, un bloc texte structuré8# (« Loyer: 1 325 $/mois », « Disponibilité: 1er octobre 2026 », « Nombre de9# chambres à coucher », « Surface », « Prises laveuse et sécheuse », « Coût10# approx. électricité/énergie », « Description: » bilingue) suivi d'un bloc11# adresse (« 183, du Mont-Fleuri, Gatineau QC »). Les carrousels de photos12# (CDN dotezcdn.com) précèdent les blocs : chaque galerie est rattachée à13# son logement par le numéro civique en tête des attributs alt14# (« 183_Facade… », « 31-2 Salon… », « 8_H-Dupuis… »).15# external_id = adresse en slug (le site n'expose aucun identifiant).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price, strip_accents24from .base import BaseConnector2526BASE = "https://www.immnasr.com"2728_NBSP = " "293031def _slug(text: str) -> str:32 s = strip_accents(text.lower())33 s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")34 return s353637def _civic(address: str) -> str:38 m = re.match(r"\s*(\d+)", address)39 return m.group(1) if m else ""404142class ImmnasrConnector(BaseConnector):43 source_id = "immnasr"44 request_delay = 1.045 max_images = 204647 def fetch(self) -> list[Listing]:48 soup = BeautifulSoup(self.get(f"{BASE}/").text, "html.parser")4950 # 1) galeries : liste ordonnée de (préfixes alt, [urls])51 galleries: list[tuple[list[str], list[str]]] = []52 for car in soup.select("div.element"):53 imgs = car.select(".carousel img[src]")54 if not imgs:55 continue56 alts, urls = [], []57 for im in imgs:58 alt = (im.get("alt") or "").strip()59 if alt:60 alts.append(alt)61 u = (im.get("src") or "").strip()62 if u.startswith("//"):63 u = "https:" + u64 if u.startswith("http") and u not in urls:65 urls.append(u)66 if urls:67 galleries.append((alts, urls))6869 # 2) blocs annonces : élément texte « Loyer: … » suivi de l'adresse70 elements = soup.select("div.element")71 listings: dict[str, Listing] = {}72 for i, el in enumerate(elements):73 txt = el.get_text("\n", strip=True).replace(_NBSP, " ")74 if not re.match(r"^\s*Loyer\s*:", txt):75 continue76 # l'adresse est le prochain élément texte court « …, Gatineau QC »77 address = ""78 for nxt in elements[i + 1:i + 3]:79 t = re.sub(r"\s+", " ", nxt.get_text(" ", strip=True)80 .replace(_NBSP, " ")).strip()81 if re.search(r",\s*(Gatineau|Hull|Aylmer)\b", t, re.I) and len(t) < 90:82 address = t83 break84 if not address:85 continue86 ext_id = _slug(address)87 if not ext_id or ext_id in listings:88 continue89 try:90 listings[ext_id] = self._parse_block(txt, address, ext_id, galleries)91 except Exception:92 continue9394 return list(listings.values())9596 def _parse_block(self, txt: str, address: str, ext_id: str,97 galleries: list[tuple[list[str], list[str]]]) -> Listing:98 flat = re.sub(r"[ \t]+", " ", txt)99100 def _field(label: str) -> str:101 m = re.search(label + r"\s*:?\s*\n?(.+?)(?:\n[A-ZÀ-Ü][^\n]*:|\Z)",102 flat, re.S)103 return re.sub(r"\s+", " ", m.group(1)).strip() if m else ""104105 m = re.search(r"Loyer\s*:\s*([\d\s]+)\s*\$", flat)106 price_label = f"{m.group(1).strip()} $ / mois" if m else ""107108 # « Disponibilité: 1 / er / octobre / 2026 » (exposant sur sa propre109 # ligne) : capturer jusqu'au libellé suivant puis aplatir110 m = re.search(r"Disponibilit[ée]\s*:\s*(.*?)\s*(?:\nNombre|\nSurface|\Z)",111 flat, re.S | re.I)112 availability = re.sub(r"\s+", " ", m.group(1)).strip(" .") if m else ""113114 m = re.search(r"chambres? à coucher\s*:\s*\n?([^\n]+)", flat, re.I)115 bedrooms_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else ""116117 m = re.search(r"Surface\s*:\s*\n?([^\n]+)", flat, re.I)118 surface_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else ""119120 m = re.search(r"(?:Prises? laveuse[^:]*)\s*:\s*\n?([^\n]+)", flat, re.I)121 washer_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else ""122123 m = re.search(r"Co[ûu]t approx\.[^:]*:\s*\n?([^\n]+)", flat, re.I)124 energy_raw = re.sub(r"\s+", " ", m.group(1)).strip() if m else ""125126 m = re.search(r"Description\s*:\s*(.+)$", flat, re.S | re.I)127 description = ""128 if m:129 description = re.sub(r"[ \t]+", " ", m.group(1)).strip()[:2500]130131 # type d'unité : maison/bungalow écrit par l'agence, sinon n chambres132 unit_type = ""133 if re.search(r"\b(bungalow|maison)\b", description, re.I):134 unit_type = "Maison"135 else:136 mb = re.match(r"(\d+)", bedrooms_raw)137 if mb:138 unit_type = normalize_unit_type(f"{mb.group(1)} chambres")139140 details: dict = {}141 if bedrooms_raw:142 mb = re.match(r"(\d+)", bedrooms_raw)143 if mb:144 details["bedrooms"] = int(mb.group(1))145 details["bedrooms_raw"] = bedrooms_raw146 if energy_raw:147 details["energy_cost"] = energy_raw148149 amenities: list[str] = []150 if washer_raw.lower().startswith("oui"):151 amenities.append("Prises laveuse et sécheuse")152153 # galerie associée par numéro civique en tête des alt154 civic = _civic(address)155 images: list[str] = []156 if civic:157 for alts, urls in galleries:158 heads = [re.match(r"[\w]+", strip_accents(a.replace("Façade", "")).strip())159 for a in alts]160 prefixes = {re.match(r"(\d+)", a.strip()).group(1)161 for a in alts if re.match(r"(\d+)", a.strip())}162 _ = heads163 if civic in prefixes:164 images = urls[: self.max_images]165 break166167 return Listing(168 source=self.source_id,169 external_id=ext_id,170 url=f"{BASE}/#{ext_id}",171 title=address,172 address=address,173 city="Gatineau",174 unit_type=unit_type,175 price=parse_price(price_label),176 price_label=price_label,177 availability=availability,178 area_sqft=parse_area_sqft(surface_raw),179 description=description,180 amenities=amenities,181 details=details,182 images=images,183 )184