SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.5 KB · 160 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/probloc.py : connecteur Probloc CL inc. (probloccl.com)5#   Gestionnaire immobilier de Bécancour (Centre-du-Québec) — parc de plex et6#   d'immeubles répartis dans les secteurs Gentilly / Croix-du-Sud / centre.7#   Le site vitrine probloccl.com (Next.js) renvoie « Logements à louer » vers8#   le portail Proprio Expert de Magex Technologies :9#     https://peweb.magextechnologies.com/RentalListings/Ffllf1/Probloc-Cl-Inc10#   Même plateforme ASP.NET WebForms rendue serveur que leviu.py : une carte11#   par unité disponible avec prix (« 1 150,00 $ / mois »), adresse complète,12#   taille (« 4 ½ (2 Chambres, 1 200,00 pi²) » ou « 1 Chambre » pour les13#   chambres), animaux, description, date « Disponible: … » et photo.14#   Pas d'URL de fiche propre (__doPostBack) : l'URL d'annonce pointe sur le15#   portail avec un fragment. external_id = adresse en slug (aucun identifiant16#   public stable exposé — même convention que leviu).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_area_sqft, strip_accents25from .base import BaseConnector2627PORTAL_BASE = "https://peweb.magextechnologies.com"28LIST_URL = f"{PORTAL_BASE}/RentalListings/Ffllf1/Probloc-Cl-Inc"29SITE = "https://probloccl.com"3031UNIT_RE = re.compile(r"^\s*(\d+\s*½|Studio|Loft)")32CHAMBRE_RE = re.compile(r"^\s*\d+\s*Chambres?\s*(?:\(|$)", re.I)33BEDROOMS_RE = re.compile(r"(\d+)\s*Chambres?", re.I)34FLOORS_RE = re.compile(r"(\d+)\s*Étages?", re.I)353637def _slug(text: str) -> str:38    s = strip_accents(text.lower())39    s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")40    return s414243class ProblocConnector(BaseConnector):44    source_id = "probloc"45    request_delay = 1.04647    def fetch(self) -> list[Listing]:48        soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")49        listings: dict[str, Listing] = {}50        for card in soup.select("div[id*='ctlRentalListings_lView_divItem_']"):51            try:52                self._parse_card(card, listings)53            except Exception:54                continue55        return list(listings.values())5657    def _parse_card(self, card, listings: dict[str, Listing]) -> None:58        addr_el = card.select_one("span[id*='_ctlAddress_'][id*='_lLine1_']")59        if not addr_el:60            return61        # « 1842 Av. de la Croix du Sud, Bécancour (Québec) »62        full_addr = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip()63        if not full_addr:64            return65        m = re.match(r"^(.*?),\s*([^,()]+?)\s*\(([^)]+)\)\s*$", full_addr)66        street, city, province = (m.groups() if m else (full_addr, "", ""))67        if province and "qu" not in strip_accents(province.lower()):68            return                      # portail multi-provinces : QC seulement6970        ext_id = _slug(street or full_addr)71        if not ext_id or ext_id in listings:72            return7374        price_el = card.select_one("span[id*='_lPrice_']")75        price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) \76            if price_el else ""7778        # « 4 ½ (2 Chambres, 2 Étages, 1 200,00 pi²) » ou « 1 Chambre »79        rooms_el = card.select_one("span[id*='_lRooms_']")80        rooms_txt = re.sub(r"\s+", " ", rooms_el.get_text(" ", strip=True)) \81            if rooms_el else ""82        unit_type = ""83        bedrooms: float | None = None84        m = UNIT_RE.match(rooms_txt)85        if m:86            unit_type = normalize_unit_type(m.group(1))87            m2 = BEDROOMS_RE.search(rooms_txt)88            if m2:89                bedrooms = float(m2.group(1))90        elif CHAMBRE_RE.match(rooms_txt):91            unit_type = "Chambre"       # chambres à louer (550-600 $)92        area_sqft = parse_area_sqft(rooms_txt)93        details: dict = {}94        m = FLOORS_RE.search(rooms_txt)95        if m:96            details["building_floors"] = int(m.group(1))9798        # animaux : « Oui » / « Non », avec note éventuelle en textNote99        pets = None100        pets_note = ""101        anim_el = card.select_one("span[id*='_lAnimals_']")102        if anim_el:103            note_el = anim_el.select_one("span.textNote")104            if note_el:105                pets_note = re.sub(r"\s+", " ", note_el.get_text(" ", strip=True))106            k = strip_accents(anim_el.get_text(" ", strip=True).lower())107            if k.startswith("oui"):108                pets = "conditions" if re.search(r"condition", k) else "oui"109            elif k.startswith("non"):110                pets = "non"111        if pets_note:112            details["pets_note"] = pets_note113114        desc_el = card.select_one("span[id*='_lDescription_']")115        description = ""116        if desc_el:117            description = re.sub(r"[ \t]+", " ",118                                 desc_el.get_text("\n", strip=True)).strip()[:2500]119            m = re.match(r"^([A-Z]{1,3}-\d+[^\n]*)", description)120            if m:121                details["unit_code"] = m.group(1).strip()122        if pets == "oui" and re.search(r"condition", description, re.I):123            pets = "conditions"124125        avail_el = card.select_one("span[id*='_lAvailable_']")126        availability = re.sub(r"\s+", " ", avail_el.get_text(" ", strip=True)) \127            if avail_el else ""128129        images: list[str] = []130        for im in card.select("img[id*='_img_'][src]"):131            u = im["src"]132            if not u.startswith("http"):133                u = PORTAL_BASE + u134            if u not in images:135                images.append(u)136137        # secteur : la Croix-du-Sud / Gentilly sont des repères connus du parc138        sector = ""139        if re.search(r"croix[- ]du[- ]s", strip_accents(street.lower())):140            sector = "Gentilly"141142        listings[ext_id] = Listing(143            source=self.source_id,144            external_id=ext_id,145            url=f"{LIST_URL}#{ext_id}",146            title=street,147            address=f"{street}, {city}" if city else street,148            sector=sector,149            city=city or "Bécancour",150            unit_type=unit_type,151            bedrooms=bedrooms,152            price_label=price_label,153            availability=availability,154            area_sqft=area_sqft,155            pets=pets,156            description=description,157            details=details,158            images=images,159        )160