SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
11.4 KB · 267 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/accommod8u.py : connecteur Accommod8u (accommod8u.com)5#   Gros gestionnaire de Waterloo ON (~10 immeubles/villages au moment de6#   l'écriture : tours Albert/Lester/Sunview, THE LINQ, villages étudiants7#   Linden/Spring/Walnut au bail mensuel — longue durée, donc pertinent).8#   Site « corporate » Yardi RentCafe derrière Cloudflare : curl nu = 403,9#   Scrapfly asp=true suffit (rendu serveur, PAS de render_js). Comme Killam,10#   la page /residential/apartments embarque TOUT le portefeuille dans11#   l'input caché `#available_prop` (JSON doublement encodé) : nom, adresse,12#   ville/ON/code postal, lat/lng, fourchette de loyer, politique animaux,13#   téléphone, vignette, occupation et l'URL du MICROSITE RentCafe de chaque14#   immeuble (apartments-waterloo-228albert.com…).15#   La page /floorplans du microsite (même anti-bot, via self.detail() +16#   Scrapfly) donne les cartes plans d'étage : « N Bed / N Bath / N Sq. Ft. /17#   Starting at $X /Month » quand des unités sont disponibles, « Call for18#   details » sinon (carte sans lien « Availability » → écartée, rien19#   d'inventé). Une annonce PAR PLAN D'ÉTAGE DISPONIBLE, avec repli « une20#   annonce par immeuble » (loyer plancher du flux) si aucune carte n'a de21#   prix mais que l'immeuble n'est pas complet. Les villages étudiants22#   affichent des loyers à la chambre (~600 $) : le nom du plan (« Room »,23#   « 4 Bedroom »…) donne le type d'unité, jamais de valeur inventée.24#   Immeubles IsFullyOccupied (ex. « Fir Village (Fully Leased) ») : ignorés.25#   Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.26# -----------------------------------------------------------------------------27from __future__ import annotations2829import hashlib30import json31import os32import re3334from bs4 import BeautifulSoup3536from ..schema import Listing, normalize_unit_type, strip_accents37from .base import BaseConnector3839BASE = "https://www.accommod8u.com"40SEARCH_PAGE = f"{BASE}/residential/apartments"4142# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent43_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4445# blob JSON du portefeuille (input caché RentCafe, doublement encodé)46_PROP_BLOB_RE = re.compile(r"id=\"available_prop\"\s+value='(.*?)'", re.S)4748# cartes plans d'étage du microsite : « 1 Bed », « 2 Bath », « 660 Sq. Ft. »49_BED_RE = re.compile(r"([\d.]+)\s*Bed\b")50_BATH_RE = re.compile(r"([\d.]+)\s*Bath")51_SQFT_RE = re.compile(r"([\d,]+)(?:-\s*to\s*[\d,]+)?\s*Sq\.?\s*Ft", re.I)52_PRICE_RE = re.compile(r"\$\s*([\d,]+(?:\.\d{2})?)")535455class Accommod8uConnector(BaseConnector):56    source_id = "accommod8u"57    request_delay = 1.5       # tout passe par Scrapfly ASP : rester très poli58    disabled = not _ONTARIO   # gate expansion Ontario (LOUKA_ONTARIO=1)59    max_properties = 20       # garde-fou (10 immeubles au 2026-08)60    max_images = 156162    # -- portefeuille : blob embarqué de la page de recherche --------------------63    def _properties(self) -> list[dict]:64        page = self.get_scrapfly(SEARCH_PAGE, render_js=False, asp=True)65        m = _PROP_BLOB_RE.search(page or "")66        if not m:67            raise RuntimeError("Accommod8u : blob #available_prop introuvable "68                               "(gabarit RentCafe modifié ou échec ASP)")69        return json.loads(json.loads(m.group(1)))7071    def fetch(self) -> list[Listing]:72        listings: list[Listing] = []73        count = 074        for p in self._properties():75            if (p.get("propertyState") or "").strip().upper() != "ON":76                continue77            if p.get("IsFullyOccupied") is True:78                continue          # complet (« Fully Leased ») : rien à publier79            if count >= self.max_properties:80                break81            count += 182            try:83                listings.extend(self._listings(p))84            except Exception:85                continue86        return listings8788    # -- annonces d'un immeuble (une par plan d'étage disponible) ----------------89    def _listings(self, p: dict) -> list[Listing]:90        pid = str(p.get("propertyid"))91        name = (p.get("propertyName") or "").strip()92        # microsite RentCafe de l'immeuble (sans le paramètre de tracking)93        site = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")94                .split("?")[0].rstrip("/"))95        url = f"{site}/floorplans" if site.startswith("http") else SEARCH_PAGE9697        city = (p.get("propertyCity") or "").strip()98        street = (p.get("propertyAddress") or "").strip()99        postal = (p.get("propertyZipCode") or "").strip()100        address = ", ".join(x for x in (street, city) if x) + ", ON"101        if postal:102            address += f" {postal}"103104        try:105            lat = float(p.get("propertyLat")) or None106            lng = float(p.get("propertyLng")) or None107        except (TypeError, ValueError):108            lat = lng = None109110        # politique animaux structurée du flux111        pets = None112        try:113            pol = p.get("bPetPolicy") or {}114            if isinstance(pol, str):115                pol = json.loads(pol)116            if pol.get("bNoPetsAllowed") is True:117                pets = "non"118            elif pol.get("bCats") and pol.get("bDogs"):119                pets = "oui"120            elif pol.get("bCats") or pol.get("bDogs"):121                pets = "conditions"122        except (ValueError, TypeError):123            pass124125        details: dict = {}126        if (p.get("phone") or "").strip():127            details["contact"] = {"phone": p["phone"].strip()}128129        # page /floorplans du microsite via le cache BD : revisitée seulement130        # quand la ligne du flux change (loyers, occupation, vignette)131        feed_key = hashlib.sha1("|".join(str(p.get(k)) for k in (132            "propertyMinRent", "propertyMaxRent", "propertyMinBed",133            "propertyMaxBed", "IsFullyOccupied", "dtMinUnitAvailable",134            "propertyThumb", "PropertySiteUrl",135        )).encode("utf-8")).hexdigest()136        d = self.detail(pid, feed_key, lambda: self._fetch_floorplans(url))137138        thumb = (p.get("propertyThumb") or "").strip()139        base_images = [thumb] if thumb else []140141        common = dict(142            source=self.source_id, url=url, address=address, city=city,143            province="ON", pets=pets, lat=lat, lng=lng,144        )145146        out: list[Listing] = []147        for fp in d.get("floorplans") or []:148            if fp.get("price") is None:149                continue    # « Call for details » sans lien Availability :150                # aucune unité annoncée disponible — rien d'inventé151            images = [u for u in base_images + (fp.get("images") or [])152                      if u][: self.max_images]153            beds = fp.get("beds")154            slug = re.sub(r"[^a-z0-9]+", "-",155                          strip_accents((fp.get("name") or "").lower())156                          ).strip("-")157            out.append(Listing(158                external_id=f"{pid}-{slug or 'u'}",159                title=f"{name} — {fp['name']}" if fp.get("name") else name,160                unit_type=self._unit_type(fp.get("name") or "", beds),161                bedrooms=beds,162                bathrooms=fp.get("baths"),163                price=fp["price"],164                price_label=f"À partir de {fp['price']:.0f} $ /mois",165                availability="Unités disponibles",166                area_sqft=fp.get("sqft"),167                details=dict(details),168                images=images,169                **common,170            ))171        if out:172            return out173174        # repli : une annonce par immeuble avec le loyer plancher du flux175        price = None176        try:177            price = float(p.get("propertyMinRent") or 0) or None178        except (TypeError, ValueError):179            pass180        if price is None:181            return []      # ni plan disponible ni loyer affiché : rien182        beds = None183        try:184            bmin = float(p.get("propertyMinBed") or -1)185            if bmin >= 0 and bmin == float(p.get("propertyMaxBed") or -1):186                beds = bmin187        except (TypeError, ValueError):188            pass189        return [Listing(190            external_id=pid,191            title=name,192            unit_type=("Studio" if beds == 0 else normalize_unit_type(193                f"{int(beds)} chambres") if beds is not None else ""),194            bedrooms=beds,195            price=price,196            price_label=f"À partir de {price:.0f} $ /mois",197            availability="Unités disponibles",198            details=details,199            images=base_images[: self.max_images],200            **common,201        )]202203    @staticmethod204    def _unit_type(fp_name: str, beds: float | None) -> str:205        """Type d'unité : nom du plan (« Room » -> Chambre) sinon dérivé cc."""206        ut = normalize_unit_type(fp_name)207        if ut and ut != fp_name.strip():208            return ut209        if beds == 0:210            return "Studio"211        if beds is not None:212            return normalize_unit_type(f"{int(beds)} chambres")213        return ut214215    # -- page /floorplans du microsite RentCafe ----------------------------------216    def _fetch_floorplans(self, url: str) -> dict:217        out: dict = {"floorplans": []}218        if not url.startswith("http"):219            return out220        try:221            page = self.get_scrapfly(url, render_js=False, asp=True)222        except Exception:223            return out224        if not page:225            return out226        soup = BeautifulSoup(page, "html.parser")227        seen: set[str] = set()228        for card in soup.select(".fp-container"):229            txt = card.get_text(" ", strip=True)230            h = card.find(["h2", "h3", "h4"])231            fp_name = h.get_text(" ", strip=True) if h else ""232            if not fp_name:233                # premier segment avant « N Bed » (gabarit compact)234                fp_name = re.split(r"\d+\s*Bed", txt)[0].strip()235            if not fp_name or fp_name.lower() in seen:236                continue237            seen.add(fp_name.lower())238            mb = _BED_RE.search(txt)239            mba = _BATH_RE.search(txt)240            msq = _SQFT_RE.search(txt)241            sqft = float(msq.group(1).replace(",", "")) if msq else None242            # prix seulement si des unités sont disponibles (lien Availability)243            price = None244            has_avail = card.find(245                "a", href=re.compile(r"/floorplans/")) is not None246            mp = _PRICE_RE.search(txt)247            if mp and has_avail:248                price = float(mp.group(1).replace(",", ""))249            images = []250            for img in card.find_all("img"):251                src = (img.get("src") or img.get("data-src") or "").strip()252                if src and "resource.rentcafe.com" in src \253                        and src not in images:254                    src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",255                                 "c_limit,w_1200", src)256                    images.append(src)257            out["floorplans"].append({258                "name": fp_name,259                "beds": (float(mb.group(1)) if mb260                         else 0.0 if "Studio" in txt else None),261                "baths": float(mba.group(1)) if mba else None,262                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,263                "price": price,264                "images": images[:5],265            })266        return out267