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%
14.8 KB · 344 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/killam.py : connecteur Killam Apartment REIT (killamreit.com)5#   REIT national (~17 000 unités, Maritimes + Ontario + Alberta/C.-B.) — on ne6#   garde ici QUE l'Ontario : Toronto, Ottawa (Kanata), London, Kitchener-7#   Waterloo, Cambridge, Mississauga (~29 propriétés au moment de l'écriture).8#   Le site est un « corporate site » Yardi RentCafe derrière Cloudflare9#   (challenge JS : curl nu = 403 « Just a moment… »). Aucune API interne à10#   rejouer : la page de recherche /apartments embarque TOUT le portefeuille11#   (204 propriétés) dans un input caché `#available_prop` — un JSON doublement12#   encodé (chaîne JSON contenant du JSON aux guillemets "). On le décode13#   avec deux json.loads, sans exécuter de JavaScript : Scrapfly ASP suffit14#   (asp=true, PAS de render_js), via la chaîne de secours de BaseConnector.get15#   (Oxylabs résidentiel → Scrapfly → Bright Data).16#   Le flux liste donne nom, adresse civique, ville/province/code postal,17#   lat/lng, fourchettes loyer/chambres/sdb/pi², politique animaux, téléphone18#   et l'URL de la fiche. La fiche propriété (rendu serveur, même anti-bot)19#   fournit description, galerie photo (resource.rentcafe.com) et les cartes20#   « plans d'étage » (nom, chambres, sdb, pi², « Starting at $X /Month ») —21#   visitée via le cache BD self.detail() (clé = hash de la ligne du flux).22#   On émet UNE annonce PAR PLAN D'ÉTAGE (granularité type d'unité), avec un23#   repli « une annonce par propriété » si les cartes sont introuvables.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import hashlib28import json29import os30import re3132from bs4 import BeautifulSoup3334from ..schema import Listing, normalize_unit_type35from .base import BaseConnector3637BASE = "https://www.killamreit.com"38SEARCH_PAGE = f"{BASE}/apartments"3940# Gate expansion Ontario : le connecteur reste hors registre tant que la41# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée (zéro impact prod).42_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4344# blob JSON du portefeuille dans la page de recherche (input caché)45_PROP_BLOB_RE = re.compile(46    r"id=\"available_prop\"\s+value='(.*?)'", re.S)4748# cartes plans d'étage de la fiche : « 1 Bed », « 1.5 Bath », « 415 Sq. Ft. »49_BED_RE = re.compile(r"(\d+)\s*Bed")50_BATH_RE = re.compile(r"([\d.]+)\s*Bath")51_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)52_PRICE_RE = re.compile(r"\$\s*([\d,]+)")53_FPID_RE = re.compile(r"#fp-modal-(\d+)")54# disclaimer promo servi DANS le paragraphe descriptif — retiré, pas interprété55_DISCLAIMER_RE = re.compile(56    r"Limited-time offer[^.]*\.\s*(Subject to [^.]*\.\s*)?", re.I)57_BOILERPLATE_RE = re.compile(58    r"marketing text|cookie|consent|unsubscribe|privacy policy", re.I)5960# IDs de commodités RentCafe (checkboxes du filtre corpsearch) -> libellé FR61_AMENITY_FR = {62    "1": "Air climatisé", "2": "Lave-vaisselle", "3": "Espace de rangement",63    "7": "Planchers de bois franc", "8": "Grands placards",64    "9": "Balcon/terrasse", "10": "Laveuse-sécheuse",65    "11": "Entrées laveuse-sécheuse", "12": "Accès fauteuil roulant",66    "13": "Aire BBQ/pique-nique", "14": "Centre d'affaires",67    "15": "Salle de réception", "16": "Accès contrôlé",68    "17": "Stationnement couvert", "18": "Garage",69    "19": "Internet haute vitesse", "20": "Spa", "21": "Buanderie",70    "22": "Salle multimédia", "23": "Bail court terme", "24": "Piscine",71    "25": "Salle d'entraînement", "26": "Terrain de basketball",72    "27": "Terrain de tennis", "28": "Service de garde", "29": "Aire de jeux",73    "30": "Supports à vélos", "31": "Électroménagers écoénergétiques",74    "32": "Thermostat électronique", "33": "Bâtiment écologique",75    "34": "Transport en commun à proximité", "35": "Recyclage",76}7778# villes du flux à normaliser (le flux écrit parfois « Kanata Ottawa »)79_CITY_FIX = {80    "kanata ottawa": ("Ottawa", "Kanata"),81    "kanata-ottawa": ("Ottawa", "Kanata"),82}838485def _money(txt: str) -> float | None:86    m = _PRICE_RE.search(txt or "")87    return float(m.group(1).replace(",", "")) if m else None888990class KillamConnector(BaseConnector):91    source_id = "killam"92    request_delay = 1.2       # Cloudflare/Scrapfly : rester très poli93    disabled = not _ONTARIO   # gate expansion Ontario (LOUKA_ONTARIO=1)94    max_properties = 40       # garde-fou (29 propriétés ON aujourd'hui)95    max_images = 209697    # -- portefeuille : blob embarqué de la page de recherche -------------------98    def _properties(self) -> list[dict]:99        page = self.get(SEARCH_PAGE).text100        m = _PROP_BLOB_RE.search(page)101        if not m:102            raise RuntimeError("Killam : blob #available_prop introuvable "103                               "(gabarit RentCafe modifié ?)")104        # doublement encodé : value='"[{"companyid":0,…}]"'105        return json.loads(json.loads(m.group(1)))106107    def fetch(self) -> list[Listing]:108        props = self._properties()109        listings: list[Listing] = []110        count = 0111        for p in props:112            if (p.get("propertyState") or "").strip().upper() != "ON":113                continue        # REIT national : Ontario seulement ici114            if count >= self.max_properties:115                break116            count += 1117            try:118                listings.extend(self._listings(p))119            except Exception:120                continue121        return listings122123    # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------124    def _listings(self, p: dict) -> list[Listing]:125        pid = str(p.get("propertyid"))126        url = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")127               .strip().rstrip("?")) or SEARCH_PAGE128        name = (p.get("propertyName") or "").strip()129130        # adresse complète : rue + ville + « ON » + code postal131        city_raw = (p.get("propertyCity") or "").strip()132        city, sector = _CITY_FIX.get(city_raw.lower(), (city_raw, ""))133        street = (p.get("propertyAddress") or "").strip()134        postal = (p.get("propertyZipCode") or "").strip()135        full_addr = ", ".join(x for x in (street, city) if x) + ", ON"136        if postal:137            full_addr += f" {postal}"138139        # coordonnées GPS du flux (finalize() valide la bbox Ontario)140        try:141            lat = float(p.get("propertyLat")) or None142            lng = float(p.get("propertyLng")) or None143        except (TypeError, ValueError):144            lat = lng = None145146        # politique animaux : {"bNoPetsAllowed":false,"bCats":true,"bDogs":true}147        pets = None148        try:149            pol = json.loads(p.get("bPetPolicy") or "{}")150            if pol.get("bNoPetsAllowed") is True:151                pets = "non"152            elif pol.get("bCats") and pol.get("bDogs"):153                pets = "oui"154            elif pol.get("bCats") or pol.get("bDogs"):155                pets = "conditions"156        except (ValueError, TypeError):157            pass158159        # commodités : IDs RentCafe -> libellés français160        amenities = [_AMENITY_FR[a] for a in (p.get("sAmenity") or [])161                     if a in _AMENITY_FR]162163        # disponibilité : le flux n'expose qu'un booléen d'occupation + une164        # date minimale (« 1/1/0001 » = aucune affichée) — rien d'inventé165        occupied = p.get("IsFullyOccupied") is True166        availability = ("Aucune unité disponible" if occupied167                        else "Unités disponibles")168        avail_date = None169        mad = str(p.get("dtMinUnitAvailable") or "").strip()170        md = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(20\d{2})", mad)171        if md:172            avail_date = f"{md.group(3)}-{int(md.group(1)):02d}-{int(md.group(2)):02d}"173174        details: dict = {}175        phone = (p.get("phone") or "").strip()176        if phone:177            details["contact"] = {"phone": phone}178179        # fiche propriété (description, galerie, plans d'étage) — via le cache180        # BD : revisitée seulement quand la ligne du flux change181        feed_key = hashlib.sha1("|".join(str(x) for x in (182            p.get("propertyMinRent"), p.get("propertyMaxRent"),183            p.get("propertyMinBed"), p.get("propertyMaxBed"),184            p.get("propertyMinArea"), p.get("propertyMaxArea"),185            p.get("IsFullyOccupied"), mad, p.get("propertyThumb"),186        )).encode("utf-8")).hexdigest()187        d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))188189        desc = d.get("description") or ""190        images = list(d.get("images") or [])191        thumb = (p.get("propertyThumb") or "").strip()192        if thumb and thumb not in images:193            images.insert(0, thumb)194        images = images[: self.max_images]195196        common = dict(197            source=self.source_id, url=url, address=full_addr, sector=sector,198            city=city, province="ON", availability=availability,199            availability_date=avail_date, pets=pets, description=desc,200            amenities=amenities[:25], images=images, lat=lat, lng=lng,201        )202203        # une annonce par plan d'étage (prix/chambres/sdb/pi² précis)204        out: list[Listing] = []205        for fp in d.get("floorplans") or []:206            price = fp.get("price")207            beds = fp.get("beds")208            out.append(Listing(209                external_id=f"{pid}-{fp['fpid']}",210                title=f"{name} — {fp['name']}" if fp.get("name") else name,211                unit_type=("Studio" if beds == 0 else normalize_unit_type(212                    f"{int(beds)} chambres") if beds is not None else ""),213                bedrooms=beds,214                bathrooms=fp.get("baths"),215                price=price,216                price_label=(f"À partir de {price:.0f} $ /mois"217                             if price is not None else ""),218                area_sqft=fp.get("sqft"),219                details=dict(details),220                **common,221            ))222        if out:223            return out224225        # repli : une annonce par propriété (fourchettes du flux liste)226        price = None227        try:228            price = float(p.get("propertyMinRent") or 0) or None229        except (TypeError, ValueError):230            pass231        price_label = ""232        if price is not None:233            try:234                pmax = float(p.get("propertyMaxRent") or 0)235            except (TypeError, ValueError):236                pmax = 0.0237            price_label = (f"À partir de {price:.0f} $"238                           if pmax and pmax != price else f"{price:.0f} $ /mois")239        beds = baths = None240        try:241            bmin, bmax = float(p.get("propertyMinBed") or -1), \242                float(p.get("propertyMaxBed") or -1)243            if bmin >= 0 and bmin == bmax:244                beds = bmin245            if float(p.get("propertyMinBath") or 0) > 0:246                baths = float(p["propertyMinBath"])247        except (TypeError, ValueError):248            pass249        area = None250        try:251            v = float(p.get("propertyMinArea") or 0)252            if 80 <= v <= 20000:253                area = v254        except (TypeError, ValueError):255            pass256        return [Listing(257            external_id=pid,258            title=name,259            unit_type=("Studio" if beds == 0 else normalize_unit_type(260                f"{int(beds)} chambres") if beds is not None else ""),261            bedrooms=beds,262            bathrooms=baths,263            price=price,264            price_label=price_label,265            area_sqft=area,266            details=details,267            **common,268        )]269270    # -- fiche propriété : description + galerie + plans d'étage ----------------271    def _fetch_detail(self, url: str) -> dict:272        out: dict = {"description": "", "images": [], "floorplans": []}273        try:274            page = self.get(url).text275        except Exception:276            return out277        soup = BeautifulSoup(page, "html.parser")278279        # plans d'étage : cartes serveur « Nom / N Bed / N Bath / N Sq. Ft. /280        # Starting at $X /Month » (modal #fp-modal-<id> = identifiant stable)281        fp_imgs: set[str] = set()282        for card in soup.select(".card"):283            txt = card.get_text(" ", strip=True)284            if "Bed" not in txt and "Studio" not in txt:285                continue286            ref = card.find(attrs={"data-target": _FPID_RE})287            mid = _FPID_RE.search(ref["data-target"]) if ref else None288            if not mid:289                continue290            h = card.find(["h2", "h3", "h4"])291            mb = _BED_RE.search(txt)292            beds = (float(mb.group(1)) if mb293                    else 0.0 if "Studio" in txt else None)294            mba = _BATH_RE.search(txt)295            msq = _SQFT_RE.search(txt)296            sqft = float(msq.group(1).replace(",", "")) if msq else None297            self._append_fp(out, {298                "fpid": mid.group(1),299                "name": h.get_text(" ", strip=True) if h else "",300                "beds": beds,301                "baths": float(mba.group(1)) if mba else None,302                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,303                "price": _money(txt),304            })305            for img in card.find_all("img"):306                src = img.get("src") or img.get("data-src") or ""307                if src:308                    fp_imgs.add(src)309310        # galerie : visuels resource.rentcafe.com HORS logos et plans d'étage311        images: list[str] = []312        for img in soup.find_all("img"):313            src = (img.get("src") or img.get("data-src") or "").strip()314            if ("resource.rentcafe.com" not in src or src in fp_imgs315                    or img.find_parent(class_=re.compile(r"ysi-logo"))):316                continue317            if not img.find_parent(class_=re.compile(r"ysi-picture")):318                continue319            # variante généreuse de la transformation Cloudinary320            src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",321                         "c_limit,w_1200", src)322            if src not in images:323                images.append(src)324        out["images"] = images[: self.max_images]325326        # description : paragraphes de présentation (sans le boilerplate327        # consentement/cookies ni le disclaimer promo)328        paras = []329        for ptag in soup.find_all("p"):330            t = ptag.get_text(" ", strip=True)331            if len(t) < 150 or _BOILERPLATE_RE.search(t):332                continue333            t = _DISCLAIMER_RE.sub("", t).strip()334            if t and t not in paras:335                paras.append(t)336        out["description"] = " ".join(paras)[:900]337        return out338339    @staticmethod340    def _append_fp(out: dict, fp: dict) -> None:341        """Ajoute un plan d'étage en dédoublonnant sur son identifiant."""342        if fp["fpid"] not in {f["fpid"] for f in out["floorplans"]}:343            out["floorplans"].append(fp)344