spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/leviu.py : connecteur Le VIU / VIU2 (leviu.ca — Groupe Heafey)5# Tours de condos locatifs du centre-ville de Gatineau (199, rue Laurier,6# secteur Hull). Le site vitrine leviu.ca renvoie « Voir les condos à7# louer » vers le portail Proprio Expert de Magex Technologies :8# https://peweb.magextechnologies.com/RentalListings/Lsae1/Groupe-Heafey9# Page ASP.NET WebForms ENTIÈREMENT rendue serveur : une carte par unité10# disponible (« VIU2 Opérations Inc. — n condominiums à louer ») avec prix11# (« 2 045,00 $ / mois »), adresse complète, taille (« 3 ½ (6 Étages,12# 890,00 pi²) »), animaux, code d'unité + note, date « Disponible: … » et13# photo (ImageDownloadHandler). La navigation de fiche est un __doPostBack14# sans URL propre : l'URL d'annonce pointe sur le portail avec un fragment.15# external_id = adresse en slug (aucun identifiant public stable exposé).16# NB : distinct du connecteur heafey (We 2/We 3/Loggia via Resimo, qui ne17# publie pas les unités du VIU) — aucun doublon entre les deux sources.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, normalize_unit_type, parse_area_sqft, strip_accents26from .base import BaseConnector2728PORTAL_BASE = "https://peweb.magextechnologies.com"29LIST_URL = f"{PORTAL_BASE}/RentalListings/Lsae1/Groupe-Heafey"30SITE = "https://www.leviu.ca"313233def _slug(text: str) -> str:34 s = strip_accents(text.lower())35 s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")36 return s373839class LeViuConnector(BaseConnector):40 source_id = "leviu"41 request_delay = 1.04243 def fetch(self) -> list[Listing]:44 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")45 listings: dict[str, Listing] = {}46 for card in soup.select("div[id*='ctlRentalListings_lView_divItem_']"):47 try:48 self._parse_card(card, listings)49 except Exception:50 continue51 return list(listings.values())5253 def _parse_card(self, card, listings: dict[str, Listing]) -> None:54 addr_el = card.select_one("span[id*='_ctlAddress_'][id*='_lLine1_']")55 if not addr_el:56 return57 # « 199 rue Laurier Unité 602, Gatineau (Québec) »58 full_addr = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip()59 if not full_addr:60 return61 m = re.match(r"^(.*?),\s*([^,()]+?)\s*\(([^)]+)\)\s*$", full_addr)62 street, city, province = (m.groups() if m else (full_addr, "", ""))63 if province and "qu" not in strip_accents(province.lower()):64 return # portail multi-provinces : QC seulement6566 ext_id = _slug(street or full_addr)67 if not ext_id or ext_id in listings:68 return6970 price_el = card.select_one("span[id*='_lPrice_']")71 price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) \72 if price_el else ""7374 # « 3 ½ (6 Étages, 890,00 pi²) »75 rooms_el = card.select_one("span[id*='_lRooms_']")76 rooms_txt = re.sub(r"\s+", " ", rooms_el.get_text(" ", strip=True)) \77 if rooms_el else ""78 unit_type = ""79 m = re.match(r"^\s*(\d+\s*½|Studio|Loft)", rooms_txt)80 if m:81 unit_type = normalize_unit_type(m.group(1))82 area_sqft = parse_area_sqft(rooms_txt)83 details: dict = {}84 m = re.search(r"(\d+)\s*Étages?", rooms_txt)85 if m:86 details["building_floors"] = int(m.group(1))8788 # animaux : « Oui » / « Non », avec note éventuelle en textNote89 # (« Sous certaines conditions, animaux tolérés » -> conditions)90 pets = None91 pets_note = ""92 anim_el = card.select_one("span[id*='_lAnimals_']")93 if anim_el:94 note_el = anim_el.select_one("span.textNote")95 if note_el:96 pets_note = re.sub(r"\s+", " ", note_el.get_text(" ", strip=True))97 k = strip_accents(anim_el.get_text(" ", strip=True).lower())98 if k.startswith("oui"):99 pets = "conditions" if re.search(r"condition", k) else "oui"100 elif k.startswith("non"):101 pets = "non"102 if pets_note:103 details["pets_note"] = pets_note104105 desc_el = card.select_one("span[id*='_lDescription_']")106 description = ""107 if desc_el:108 description = re.sub(r"[ \t]+", " ",109 desc_el.get_text("\n", strip=True)).strip()[:2500]110 m = re.match(r"^([A-Z]{1,3}-\d+[^\n]*)", description)111 if m:112 details["unit_code"] = m.group(1).strip()113 # « Sous certaines conditions, animaux tolérés » -> conditions114 if pets == "oui" and re.search(r"condition", description, re.I):115 pets = "conditions"116117 avail_el = card.select_one("span[id*='_lAvailable_']")118 availability = re.sub(r"\s+", " ", avail_el.get_text(" ", strip=True)) \119 if avail_el else ""120121 images: list[str] = []122 for im in card.select("img[id*='_img_'][src]"):123 u = im["src"]124 if not u.startswith("http"):125 u = PORTAL_BASE + u126 if u not in images:127 images.append(u)128129 listings[ext_id] = Listing(130 source=self.source_id,131 external_id=ext_id,132 url=f"{LIST_URL}#{ext_id}",133 title=street,134 address=f"{street}, {city}" if city else street,135 sector="Hull" if re.search(r"rue Laurier", street, re.I) else "",136 city=city or "Gatineau",137 unit_type=unit_type,138 price_label=price_label,139 availability=availability,140 area_sqft=area_sqft,141 pets=pets,142 description=description,143 details=details,144 images=images,145 )146