SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
12.3 KB · 288 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/qresidential.py : connecteur Q Residential (qresidential.ca)5#   Gestionnaire ontarien (~7 000 unités — Toronto, St. Catharines, Hamilton,6#   Oshawa/GTA, Brampton, Barrie). « Corporate site » Yardi RentCafe derrière7#   un Cloudflare STRICT (curl direct = 403) : tout passe par Scrapfly ASP8#   (self.get_scrapfly, asp=true, SANS render_js — le contenu est rendu9#   serveur, vérifié sur la recherche et les fiches).10#   Contrairement à killam.py (même famille RentCafe), il n'y a PAS de blob11#   #available_prop : la page /searchlisting.aspx rend 25 cartes propriétés12#   côté serveur (nom, adresse, ville, province, vignette, URL de fiche).13#   La fiche propriété fournit un JSON-LD ApartmentComplex (description,14#   adresse postale, GPS, commodités, téléphone) et un tableau « Floor Plans »15#   (tbody.floorplan-details : nom, Bed/Bath, pi², loyer — souvent « Call for16#   pricing » —, disponibilité). Une annonce PAR PLAN D'ÉTAGE quand au moins17#   un plan affiche un prix ; sinon repli « une annonce par propriété » sans18#   prix (rien d'inventé). Fiches visitées via le cache BD self.detail(),19#   clé datée du jour : au plus UNE visite Scrapfly par propriété par jour,20#   peu importe le nombre de synchronisations.21#22#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import datetime as _dt27import json28import os29import re3031from bs4 import BeautifulSoup3233from ..schema import Listing, normalize_unit_type, parse_price34from .base import BaseConnector3536BASE = "https://www.qresidential.ca"37SEARCH_PAGE = f"{BASE}/searchlisting.aspx"3839# Gate expansion Ontario : le connecteur reste hors registre tant que la40_ONTARIO = True  # Rent-Ka: always on (ROC scope)4142# cellules du tableau plans d'étage : « Bed/Bath Studio / 1 », « 2 / 1.5 »43_BEDBATH_RE = re.compile(44    r"Bed/Bath\s+(Studio|\d+)\s*/\s*([\d.]+)", re.I)45_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)46# identifiant stable du plan d'étage (carrousel photo du plan)47_FPID_RE = re.compile(r"fp-myCarousel(\d+)")484950class QResidentialConnector(BaseConnector):51    source_id = "qresidential"52    request_delay = 1.5       # Cloudflare strict : Scrapfly ASP, rester poli53    disabled = False54    max_properties = 35       # garde-fou (25 propriétés aujourd'hui)55    max_images = 155657    def fetch(self) -> list[Listing]:58        page = self.get_scrapfly(SEARCH_PAGE, asp=True, render_js=False)59        if not page:60            raise RuntimeError("Q Residential : Scrapfly ASP n'a pas "61                               "retourné la page de recherche")62        soup = BeautifulSoup(page, "html.parser")6364        cards = soup.select("div.searchResult")65        listings: list[Listing] = []66        count = 067        seen: set[str] = set()68        for card in cards:69            try:70                a = card.select_one("a.propertyUrl")71                if a is None:72                    continue73                url = (a.get("href") or "").strip()74                if not url or url in seen:75                    continue76                seen.add(url)77                # résidentiel longue durée seulement (structure « Apartment »)78                st = card.select_one(".structure-type")79                if st and st.get_text(" ", strip=True) and \80                        "apartment" not in st.get_text(" ", strip=True).lower():81                    continue82                state = card.select_one(".propertyState")83                if state and state.get_text(strip=True).upper() != "ON":84                    continue85                if count >= self.max_properties:86                    break87                count += 188                listings.extend(self._property_listings(card, url))89            except Exception:90                continue91        return listings9293    # -- annonces d'une propriété (une par plan d'étage tarifé, repli) ----------94    def _property_listings(self, card, url: str) -> list[Listing]:95        # identifiant : slug de la fiche (« queenston-manor »)96        slug = url.rstrip("/").split("/")[-2] if url.endswith("default.aspx") \97            else re.sub(r"[^a-z0-9-]", "", url.rstrip("/").split("/")[-1])98        a = card.select_one("a.propertyUrl")99        name = a.get_text(" ", strip=True)100        city = (card.select_one(".propertyCity") or a) \101            .get_text(" ", strip=True) if card.select_one(".propertyCity") \102            else ""103        # adresse complète de la carte (« 382 Queenston Street St. Catharines104        # ON L2P 3V5 ») — on la reconstruit avec des virgules105        street = ""106        spans = card.select(".propertyAddress")107        if spans:108            street = spans[-1].get_text(" ", strip=True)109        postal = ""110        m = re.search(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d",111                      card.get_text(" ", strip=True))112        if m:113            postal = m.group(0)114        address = ", ".join(x for x in (street, city) if x)115        if address:116            address += f", ON {postal}".rstrip()117118        thumb = ""119        img = card.select_one("img.propertyThumb")120        if img is not None:121            thumb = (img.get("src") or "").strip()122123        # fiche propriété via le cache BD — clé datée : au plus une visite124        # Scrapfly par propriété par jour (les prix « Call for pricing »125        # changent rarement, et jamais entre deux syncs du même jour)126        feed_key = f"{_dt.date.today().isoformat()}|{name}|{thumb}"127        d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))128129        desc = d.get("description") or ""130        amenities = d.get("amenities") or []131        images = [u for u in ([thumb] + (d.get("images") or [])) if u]132        lat, lng = d.get("lat"), d.get("lng")133        details: dict = {}134        if d.get("phone"):135            details["contact"] = {"phone": d["phone"]}136137        common = dict(138            source=self.source_id, url=url, address=address, city=city,139            province="ON", description=desc, amenities=amenities[:25],140            images=images[: self.max_images], lat=lat, lng=lng,141        )142143        # une annonce par plan d'étage TARIFÉ (prix affiché) ; les plans144        # « Call for pricing » ne deviennent pas des annonces individuelles145        out: list[Listing] = []146        priced = [fp for fp in d.get("floorplans") or []147                  if fp.get("price") is not None]148        for fp in priced:149            beds = fp.get("beds")150            price = fp["price"]151            out.append(Listing(152                external_id=f"{slug}-{fp['fpid']}",153                title=f"{name} — {fp['name']}" if fp.get("name") else name,154                unit_type=("Studio" if beds == 0 else normalize_unit_type(155                    f"{int(beds)} chambres") if beds is not None else ""),156                bedrooms=beds,157                bathrooms=fp.get("baths"),158                price=price,159                price_label=f"À partir de {price:.0f} $ /mois",160                availability=fp.get("avail") or "",161                area_sqft=fp.get("sqft"),162                details=dict(details),163                **common,164            ))165        if out:166            return out167168        # repli : une annonce par propriété — gamme de types connue seulement169        # via les plans d'étage non tarifés ; aucun prix inventé170        fps = d.get("floorplans") or []171        beds_set = {fp.get("beds") for fp in fps if fp.get("beds") is not None}172        unit_type = ""173        if len(beds_set) == 1:174            b = beds_set.pop()175            unit_type = "Studio" if b == 0 else normalize_unit_type(176                f"{int(b)} chambres")177        return [Listing(178            external_id=slug,179            title=name,180            unit_type=unit_type,181            availability="Sur demande (contacter la gestion)" if fps else "",182            details=details,183            **common,184        )]185186    # -- fiche propriété : JSON-LD + tableau plans d'étage ----------------------187    def _fetch_detail(self, url: str) -> dict:188        out: dict = {"description": "", "amenities": [], "images": [],189                     "floorplans": [], "lat": None, "lng": None, "phone": ""}190        page = self.get_scrapfly(url, asp=True, render_js=False)191        if not page:192            return out193        soup = BeautifulSoup(page, "html.parser")194195        # JSON-LD ApartmentComplex : description, GPS, commodités, téléphone196        for tag in soup.find_all("script", type="application/ld+json"):197            try:198                ld = json.loads(tag.string or "")199            except (TypeError, ValueError):200                continue201            if not isinstance(ld, dict) or \202                    ld.get("@type") != "ApartmentComplex":203                continue204            out["description"] = BeautifulSoup(205                ld.get("description") or "", "html.parser"206            ).get_text(" ", strip=True)[:600]207            geo = ld.get("geo") or {}208            try:209                out["lat"] = float(geo.get("latitude"))210                out["lng"] = float(geo.get("longitude"))211            except (TypeError, ValueError):212                pass213            out["phone"] = ((ld.get("address") or {})214                            .get("telephone") or "").strip()215            ams = []216            for af in ld.get("amenityFeature") or []:217                t = (af.get("name") or "").strip() if isinstance(af, dict) \218                    else ""219                if t and t not in ams:220                    ams.append(t)221            out["amenities"] = ams[:25]222            break223224        # visuels de la fiche (bannière dmslivecafe) — HORS plans d'étage/logos225        images: list[str] = []226        for img in soup.find_all("img"):227            src = (img.get("src") or "").strip()228            if "cdngeneralcf.rentcafe.com/dmslivecafe" not in src:229                continue230            if re.search(r"logo|icon|floor\s?plan|FloorPlan", src, re.I):231                continue232            if img.find_parent(class_=re.compile("floorplan")):233                continue234            if src not in images:235                images.append(src)236        out["images"] = images[: self.max_images]237238        # tableau plans d'étage : une ligne par variation (nom, Bed/Bath,239        # pi² éventuel, loyer, disponibilité)240        seen: set[str] = set()241        for tr in soup.select("tbody.floorplan-details tr"):242            txt = tr.get_text(" ", strip=True)243            if "Bed/Bath" not in txt:244                continue245            mid = _FPID_RE.search(str(tr))246            name_td = tr.find(string=re.compile(r"Floor Plan\s"))247            name = ""248            if name_td:249                name = re.sub(r"^Floor Plan\s+", "",250                              str(name_td).strip()).strip()251            fpid = mid.group(1) if mid else \252                re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")253            if not fpid or fpid in seen:254                continue255            seen.add(fpid)256            mbb = _BEDBATH_RE.search(txt)257            beds = baths = None258            if mbb:259                beds = 0.0 if mbb.group(1).lower() == "studio" \260                    else float(mbb.group(1))261                try:262                    baths = float(mbb.group(2))263                except ValueError:264                    baths = None265            msq = _SQFT_RE.search(txt)266            sqft = float(msq.group(1).replace(",", "")) if msq else None267            # loyer : montant affiché seulement (« Call for pricing » -> None)268            price = None269            mrent = re.search(r"Rent\s+([^D]*?)(?:Deposit|$)", txt)270            if mrent:271                price = parse_price(mrent.group(1))272            avail = ""273            mav = re.search(r"Deposit\s*(.*?)(?:Read More|$)", txt)274            if mav:275                avail = mav.group(1).strip()276                if avail.lower() in ("contact us", ""):277                    avail = ""278            out["floorplans"].append({279                "fpid": fpid,280                "name": name,281                "beds": beds,282                "baths": baths,283                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,284                "price": price,285                "avail": avail,286            })287        return out288