SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
7.3 KB · 184 lines python
Raw Blame History
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