# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/leviu.py : connecteur Le VIU / VIU2 (leviu.ca — Groupe Heafey) # Tours de condos locatifs du centre-ville de Gatineau (199, rue Laurier, # secteur Hull). Le site vitrine leviu.ca renvoie « Voir les condos à # louer » vers le portail Proprio Expert de Magex Technologies : # https://peweb.magextechnologies.com/RentalListings/Lsae1/Groupe-Heafey # Page ASP.NET WebForms ENTIÈREMENT rendue serveur : une carte par unité # disponible (« VIU2 Opérations Inc. — n condominiums à louer ») avec prix # (« 2 045,00 $ / mois »), adresse complète, taille (« 3 ½ (6 Étages, # 890,00 pi²) »), animaux, code d'unité + note, date « Disponible: … » et # photo (ImageDownloadHandler). La navigation de fiche est un __doPostBack # sans URL propre : l'URL d'annonce pointe sur le portail avec un fragment. # external_id = adresse en slug (aucun identifiant public stable exposé). # NB : distinct du connecteur heafey (We 2/We 3/Loggia via Resimo, qui ne # publie pas les unités du VIU) — aucun doublon entre les deux sources. # ----------------------------------------------------------------------------- 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/Lsae1/Groupe-Heafey" SITE = "https://www.leviu.ca" def _slug(text: str) -> str: s = strip_accents(text.lower()) s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s class LeViuConnector(BaseConnector): source_id = "leviu" 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 # « 199 rue Laurier Unité 602, Gatineau (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 "" # « 3 ½ (6 Étages, 890,00 pi²) » 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 = "" m = re.match(r"^\s*(\d+\s*½|Studio|Loft)", rooms_txt) if m: unit_type = normalize_unit_type(m.group(1)) area_sqft = parse_area_sqft(rooms_txt) details: dict = {} m = re.search(r"(\d+)\s*Étages?", rooms_txt) if m: details["building_floors"] = int(m.group(1)) # animaux : « Oui » / « Non », avec note éventuelle en textNote # (« Sous certaines conditions, animaux tolérés » -> conditions) 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() # « Sous certaines conditions, animaux tolérés » -> conditions 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) 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="Hull" if re.search(r"rue Laurier", street, re.I) else "", city=city or "Gatineau", unit_type=unit_type, price_label=price_label, availability=availability, area_sqft=area_sqft, pets=pets, description=description, details=details, images=images, )