Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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