SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.1 KB · 146 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/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