# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/probloc.py : connecteur Probloc CL inc. (probloccl.com) # Gestionnaire immobilier de Bécancour (Centre-du-Québec) — parc de plex et # d'immeubles répartis dans les secteurs Gentilly / Croix-du-Sud / centre. # Le site vitrine probloccl.com (Next.js) renvoie « Logements à louer » vers # le portail Proprio Expert de Magex Technologies : # https://peweb.magextechnologies.com/RentalListings/Ffllf1/Probloc-Cl-Inc # Même plateforme ASP.NET WebForms rendue serveur que leviu.py : une carte # par unité disponible avec prix (« 1 150,00 $ / mois »), adresse complète, # taille (« 4 ½ (2 Chambres, 1 200,00 pi²) » ou « 1 Chambre » pour les # chambres), animaux, description, date « Disponible: … » et photo. # Pas d'URL de fiche propre (__doPostBack) : l'URL d'annonce pointe sur le # portail avec un fragment. external_id = adresse en slug (aucun identifiant # public stable exposé — même convention que leviu). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_area_sqft, strip_accents from .base import BaseConnector PORTAL_BASE = "https://peweb.magextechnologies.com" LIST_URL = f"{PORTAL_BASE}/RentalListings/Ffllf1/Probloc-Cl-Inc" SITE = "https://probloccl.com" UNIT_RE = re.compile(r"^\s*(\d+\s*½|Studio|Loft)") CHAMBRE_RE = re.compile(r"^\s*\d+\s*Chambres?\s*(?:\(|$)", re.I) BEDROOMS_RE = re.compile(r"(\d+)\s*Chambres?", re.I) FLOORS_RE = re.compile(r"(\d+)\s*Étages?", re.I) def _slug(text: str) -> str: s = strip_accents(text.lower()) s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s class ProblocConnector(BaseConnector): source_id = "probloc" request_delay = 1.0 def fetch(self) -> list[Listing]: soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") listings: dict[str, Listing] = {} for card in soup.select("div[id*='ctlRentalListings_lView_divItem_']"): try: self._parse_card(card, listings) except Exception: continue return list(listings.values()) def _parse_card(self, card, listings: dict[str, Listing]) -> None: addr_el = card.select_one("span[id*='_ctlAddress_'][id*='_lLine1_']") if not addr_el: return # « 1842 Av. de la Croix du Sud, Bécancour (Québec) » full_addr = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip() if not full_addr: return m = re.match(r"^(.*?),\s*([^,()]+?)\s*\(([^)]+)\)\s*$", full_addr) street, city, province = (m.groups() if m else (full_addr, "", "")) if province and "qu" not in strip_accents(province.lower()): return # portail multi-provinces : QC seulement ext_id = _slug(street or full_addr) if not ext_id or ext_id in listings: return price_el = card.select_one("span[id*='_lPrice_']") price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) \ if price_el else "" # « 4 ½ (2 Chambres, 2 Étages, 1 200,00 pi²) » ou « 1 Chambre » rooms_el = card.select_one("span[id*='_lRooms_']") rooms_txt = re.sub(r"\s+", " ", rooms_el.get_text(" ", strip=True)) \ if rooms_el else "" unit_type = "" bedrooms: float | None = None m = UNIT_RE.match(rooms_txt) if m: unit_type = normalize_unit_type(m.group(1)) m2 = BEDROOMS_RE.search(rooms_txt) if m2: bedrooms = float(m2.group(1)) elif CHAMBRE_RE.match(rooms_txt): unit_type = "Chambre" # chambres à louer (550-600 $) area_sqft = parse_area_sqft(rooms_txt) details: dict = {} m = FLOORS_RE.search(rooms_txt) if m: details["building_floors"] = int(m.group(1)) # animaux : « Oui » / « Non », avec note éventuelle en textNote pets = None pets_note = "" anim_el = card.select_one("span[id*='_lAnimals_']") if anim_el: note_el = anim_el.select_one("span.textNote") if note_el: pets_note = re.sub(r"\s+", " ", note_el.get_text(" ", strip=True)) k = strip_accents(anim_el.get_text(" ", strip=True).lower()) if k.startswith("oui"): pets = "conditions" if re.search(r"condition", k) else "oui" elif k.startswith("non"): pets = "non" if pets_note: details["pets_note"] = pets_note desc_el = card.select_one("span[id*='_lDescription_']") description = "" if desc_el: description = re.sub(r"[ \t]+", " ", desc_el.get_text("\n", strip=True)).strip()[:2500] m = re.match(r"^([A-Z]{1,3}-\d+[^\n]*)", description) if m: details["unit_code"] = m.group(1).strip() if pets == "oui" and re.search(r"condition", description, re.I): pets = "conditions" avail_el = card.select_one("span[id*='_lAvailable_']") availability = re.sub(r"\s+", " ", avail_el.get_text(" ", strip=True)) \ if avail_el else "" images: list[str] = [] for im in card.select("img[id*='_img_'][src]"): u = im["src"] if not u.startswith("http"): u = PORTAL_BASE + u if u not in images: images.append(u) # secteur : la Croix-du-Sud / Gentilly sont des repères connus du parc sector = "" if re.search(r"croix[- ]du[- ]s", strip_accents(street.lower())): sector = "Gentilly" listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=f"{LIST_URL}#{ext_id}", title=street, address=f"{street}, {city}" if city else street, sector=sector, city=city or "Bécancour", unit_type=unit_type, bedrooms=bedrooms, price_label=price_label, availability=availability, area_sqft=area_sqft, pets=pets, description=description, details=details, images=images, )