SPB Git

spb/lou-ka Public

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

HTML 99.7%
11.1 KB · 267 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/werkliv.py : connecteur Werkliv (logement étudiant)5#   werkliv.com est le site corporatif ; la location de ses immeubles passe6#   par sa plateforme University Apartments (universityapartments.ca —7#   WordPress + FacetWP rendu serveur). Immeubles montréalais : Palay8#   (2025 rue Peel, centre-ville) et Le Mojave (3476 rue Saint-Dominique,9#   Plateau/Milton-Parc). Une annonce par typologie (1-BEDROOM, 4-BEDROOM...),10#   loyer par personne (colocation étudiante meublée).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import re1617from bs4 import BeautifulSoup1819from ..schema import Listing20from .base import BaseConnector2122BASE = "https://universityapartments.ca"23LIST_URL = f"{BASE}/apartment-listings/?_listings_city_en=montreal"2425# Immeuble (nom affiché) -> (slug fiche immeuble, secteur)26BUILDINGS = {27    "palay": ("palay", "Centre-ville (Ville-Marie)"),28    "le mojave": ("le-mojave", "Milton-Parc / Plateau-Mont-Royal"),29    "mojave": ("le-mojave", "Milton-Parc / Plateau-Mont-Royal"),30}3132ADDR_RE = re.compile(33    r"\d{2,5}[^<>\"|]{2,60}?(?:Montr[ée]al)[,\s]+QC(?:\s+[A-Z]\d[A-Z]\s?\d[A-Z]\d)?")3435# Émojis/puces en tête des items de commodités des fiches36_EMOJI_PREFIX_RE = re.compile(r"^[\W_]+", re.UNICODE)373839def _unit_type(label: str) -> str:40    """'1-BEDROOM' -> 3½, '4-BEDROOM' -> 6½, 'STUDIO' -> Studio."""41    s = (label or "").lower()42    if "studio" in s:43        return "Studio"44    m = re.search(r"(\d+)", s)45    if m:46        n = int(m.group(1))47        return "Studio" if n == 0 else f"{n + 2}½"48    return label.strip()495051class WerklivConnector(BaseConnector):52    source_id = "werkliv"53    request_delay = 0.654    max_details = 20         # garde-fou5556    def fetch(self) -> list[Listing]:57        listings: list[Listing] = []58        try:59            html = self.get(LIST_URL).text60        except Exception:61            return listings62        soup = BeautifulSoup(html, "html.parser")6364        addresses: dict[str, str] = {}     # slug immeuble -> adresse65        seen: set[str] = set()66        for card in soup.select(".lcl-card"):67            try:68                a = card.select_one('a.lcl-link[href*="/listings/"]') or \69                    card.select_one('a[href*="/listings/"]')70                if not a:71                    continue72                url = (a.get("href") or "").split("?")[0]73                m = re.search(r"/listings/([^/]+)/?$", url)74                if not m or m.group(1) in seen:75                    continue76                slug = m.group(1)77                seen.add(slug)7879                city_el = card.select_one(".lcl-city")80                city_raw = city_el.get_text(" ", strip=True) if city_el else ""81                if "montreal" not in city_raw.lower():82                    continue         # hors Montréal (Halifax, PEI...)8384                typo_el = card.select_one(".lcl-title .h4, .lcl-title")85                typology = typo_el.get_text(" ", strip=True) if typo_el else ""86                prop_el = card.select_one(".lcl-property")87                building = prop_el.get_text(" ", strip=True) if prop_el else ""88                price_el = card.select_one(".lcl-price")89                price_label = price_el.get_text(" ", strip=True) \90                    if price_el else ""91                price = None92                pm = re.search(r"\$\s*([\d,]+)", price_label)93                if pm:94                    try:95                        v = float(pm.group(1).replace(",", ""))96                        price = v if 100 <= v <= 20000 else None97                    except ValueError:98                        pass99100                avail = ""101                av_el = card.select_one(".lcl-available span")102                if av_el:103                    avail = re.sub(r"\s+", " ",104                                   av_el.get_text(" ", strip=True))105                amenities = ["Logement étudiant"]106                for chip in card.select(".lcl-chip"):107                    amenities.append(re.sub(r"\s+", " ",108                                            chip.get_text(" ", strip=True)))109110                bslug, sector = BUILDINGS.get(building.strip().lower(),111                                              ("", ""))112                # adresse depuis la fiche de l'immeuble (mise en cache)113                address = ""114                if bslug:115                    if bslug not in addresses:116                        addresses[bslug] = self._building_address(bslug)117                    address = addresses[bslug]118119                images = []120                header = card.select_one("[data-bg]")121                if header and header.get("data-bg", "").startswith("http"):122                    images.append(header["data-bg"])123124                listings.append(Listing(125                    source=self.source_id,126                    external_id=slug,127                    url=url,128                    title=f"{building}{typology} (par chambre)",129                    address=address,130                    sector=sector,131                    city="Montréal",132                    unit_type=_unit_type(typology),133                    price=price,134                    price_label=f"{price_label} (par personne)"135                                if price_label else "",136                    availability=avail,137                    amenities=list(dict.fromkeys(amenities)),138                    images=images,139                ))140            except Exception:141                continue142143        # Fiches détaillées (photos, description, dispo ACF, bail, commodités)144        # via le cache BD : 1 vraie requête par annonce et par changement.145        self._detail_requests = 0146        for i, lst in enumerate(listings):147            if i >= self.max_details:148                break149            key = hashlib.sha1(150                f"{lst.title}|{lst.price_label}|{lst.availability}"151                f"|{'|'.join(lst.amenities)}".encode("utf-8")).hexdigest()152153            def _fetch(url=lst.url) -> dict:154                if self._detail_requests >= self.max_details:155                    return {}156                self._detail_requests += 1157                return self._fetch_detail(url)158159            try:160                payload = self.detail(lst.external_id, key, _fetch) or {}161            except Exception:162                payload = {}163            self._apply_detail(lst, payload)164        return listings165166    # -- fiche immeuble (adresse) --------------------------------------------------167    def _building_address(self, slug: str) -> str:168        try:169            html = self.get(f"{BASE}/properties/{slug}/").text170        except Exception:171            return ""172        m = ADDR_RE.search(html.replace("+", " "))173        if not m:174            return ""175        addr = re.sub(r"\s+", " ", m.group(0)).strip()176        return addr.replace("Montreal", "Montréal")177178    # -- fiche annonce ---------------------------------------------------------------179    def _fetch_detail(self, url: str) -> dict:180        """Fiche d'une annonce : photos, description, sidebar ACF structuré181        (Available, Lease Terms) et commodités (BUILDING AMENITIES, cuisine).182        """183        html = self.get(url).text184        soup = BeautifulSoup(html, "html.parser")185186        imgs = re.findall(187            r'https://universityapartments\.ca/wp-content/uploads/'188            r'[^"\s\\\)]+\.(?:jpg|jpeg|png|webp)', html)189        imgs = [u for u in dict.fromkeys(imgs)190                if not re.search(r"logo|icon|favicon|chrome|-\d{2,3}x\d{2,3}\.",191                                 u, re.I)]192193        description = ""194        og = soup.find("meta", attrs={"property": "og:description"})195        if og and og.get("content"):196            description = og["content"].strip()[:600]197        else:198            p = soup.select_one(".fl-rich-text p, article p")199            if p:200                description = p.get_text(" ", strip=True)[:600]201202        # Sidebar ACF : <dl class="acf-data"><dt>Available</dt><dd>...</dd></dl>203        available = ""204        for dl in soup.select("dl.acf-data"):205            dts = [d.get_text(" ", strip=True) for d in dl.select("dt")]206            dds = [d.get_text(" ", strip=True) for d in dl.select("dd")]207            for k, v in zip(dts, dds):208                if k.lower().startswith("available") and v:209                    available = v210        lease_terms = [t.get_text(" ", strip=True)211                       for t in soup.select(".rental-term")]212213        # Commodités de l'immeuble : items <p> à émoji après le titre214        # « BUILDING AMENITIES » (même bloc rich-text).215        amenities: list[str] = []216        head = soup.find(string=re.compile(r"BUILDING AMENITIES", re.I))217        if head:218            h = head.find_parent(["h1", "h2", "h3", "h4", "strong"]) \219                or head.parent220            node = h.find_parent(["h1", "h2", "h3", "h4"]) or h221            for sib in node.find_next_siblings():222                if sib.name in ("h1", "h2", "h3"):223                    break224                text = _EMOJI_PREFIX_RE.sub("", sib.get_text(" ", strip=True))225                text = re.sub(r"\s+", " ", text).strip()226                if not text or text.startswith("***") or "Disclaimer" in text:227                    break228                if len(text) > 90:229                    continue230                # « Wi-Fi ($) » = payant : ne pas laisser la normalisation231                # le classer « internet inclus » (lacune générique notée)232                if text.endswith("($)"):233                    continue234                # stationnement à vélo ≠ stationnement auto235                if re.search(r"\bbike (?:parking|storage)\b", text, re.I):236                    text = "Espace vélos (sous-sol)"237                if text not in amenities:238                    amenities.append(text)239240        # Électroménagers de la cuisine partagée (ligne explicite de la fiche)241        kitchen = soup.find(string=re.compile(242            r"appliances,? including a fridge", re.I))243        if kitchen:244            amenities.append(_EMOJI_PREFIX_RE.sub(245                "", re.sub(r"\s+", " ", str(kitchen)).strip()))246247        return {"images": imgs, "description": description,248                "available": available, "lease_terms": lease_terms,249                "amenities": amenities}250251    def _apply_detail(self, lst: Listing, payload: dict) -> None:252        if not payload:253            return254        if payload.get("images"):255            lst.images = list(dict.fromkeys(256                lst.images + payload["images"]))[:40]257        if payload.get("description"):258            lst.description = payload["description"]259        if not lst.availability and payload.get("available"):260            lst.availability = f"Available {payload['available']}"261        if payload.get("lease_terms"):262            lst.amenities.append(263                "Lease terms: " + ", ".join(payload["lease_terms"]))264        for a in payload.get("amenities") or []:265            if a not in lst.amenities:266                lst.amenities.append(a)267