SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
10.6 KB · 248 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/medallion.py : connecteur Medallion Corporation (medallioncorp.com)5#   Gestionnaire torontois (~25 000 unités est., Toronto/GTA + Brampton, Ajax,6#   Whitby, Oshawa, Richmond Hill, Hamilton, London). Site WordPress (thème7#   RealHomes/inspiry + CPT UI) SANS anti-bot : le CPT `property` est exposé8#   par l'API REST standard — `/wp-json/wp/v2/properties?per_page=100`9#   (84 propriétés au moment de l'écriture, x-wp-total 84, une seule page).10#   Chaque entrée porte dans `class_list` la ville (property-city-<slug>), le11#   type (property-type-residential/commercial…) et le statut (property-status-12#   for-rent vs commercial-rent) : on ne garde que le résidentiel à louer13#   (56 immeubles). `property_meta` fournit l'adresse civique complète14#   (REAL_HOMES_property_address), les coordonnées GPS et la galerie photo15#   (URLs wp-content/uploads, variantes redimensionnées).16#   Les prix/types d'unités ne sont PAS dans le flux : la fiche propriété17#   (rendu serveur) affiche un accordéon « Floor Plans » (nom, N Bedrooms,18#   N Bathrooms, prix plancher, « Call for Availability ») — visitée via le19#   cache BD self.detail() (clé = date `modified` du flux). Une annonce par20#   plan d'étage ; repli « une annonce par propriété » (sans prix inventé)21#   quand l'accordéon est absent.22#23#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re2930from bs4 import BeautifulSoup3132from ..schema import Listing, normalize_unit_type, parse_price33from .base import BaseConnector3435BASE = "https://medallioncorp.com"36FEED_URL = f"{BASE}/wp-json/wp/v2/properties?per_page=100"3738# Gate expansion Ontario : le connecteur reste hors registre tant que la39_ONTARIO = True  # Rent-Ka: always on (ROC scope)4041# villes du flux (slug de class_list `property-city-<slug>`) -> affichage ;42# tout slug inconnu est titré tel quel (« richmond-hill » -> « Richmond Hill »)43_CITY_LABEL = {44    "toronto": "Toronto",45    "brampton": "Brampton",46    "ajax": "Ajax",47    "whitby": "Whitby",48    "oshawa": "Oshawa",49    "richmond-hill": "Richmond Hill",50    "hamilton": "Hamilton",51    "london": "London",52    "milton": "Milton",53}5455# lignes de l'accordéon plans d'étage : « 2 Bedrooms », « 1.5 Bathrooms »56_BED_RE = re.compile(r"(\d+)\s*Bedroom", re.I)57_BATH_RE = re.compile(r"([\d.]+)\s*Bathroom", re.I)58_SQFT_RE = re.compile(r"([\d,]+)\s*(?:sq\.?\s*ft|pi2|ft2)", re.I)59_CITY_CLS_RE = re.compile(r"^property-city-(.+)$")606162class MedallionConnector(BaseConnector):63    source_id = "medallion"64    request_delay = 1.065    disabled = False66    max_properties = 80       # garde-fou (56 immeubles résidentiels aujourd'hui)67    max_images = 206869    def fetch(self) -> list[Listing]:70        props = self.get(FEED_URL, headers={"Accept": "application/json"}).json()7172        listings: list[Listing] = []73        count = 074        for p in props:75            try:76                cls = set(p.get("class_list") or [])77                # résidentiel longue durée seulement : à louer, non commercial78                if "property-status-for-rent" not in cls:79                    continue80                if "property-type-residential" not in cls:81                    continue82                if count >= self.max_properties:83                    break84                count += 185                listings.extend(self._property_listings(p))86            except Exception:87                continue88        return listings8990    # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------91    def _property_listings(self, p: dict) -> list[Listing]:92        pid = str(p.get("id"))93        url = p.get("link") or ""94        title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",95                              "html.parser").get_text(" ", strip=True)96        meta = p.get("property_meta") or {}9798        # adresse civique complète du flux (« 25 Kitney Dr, Ajax, ON L1S 0G6,99        # Canada ») — suffixe « , Canada » retiré100        address = re.sub(r",\s*Canada$", "",101                         (meta.get("REAL_HOMES_property_address") or "").strip())102103        # ville : slug property-city-<slug> de class_list104        city = ""105        for c in p.get("class_list") or []:106            m = _CITY_CLS_RE.match(c)107            if m:108                slug = m.group(1)109                city = _CITY_LABEL.get(slug, slug.replace("-", " ").title())110                break111112        # coordonnées GPS du flux (finalize() valide la bbox Ontario)113        loc = meta.get("REAL_HOMES_property_location") or {}114        try:115            lat = float(loc.get("latitude")) if loc.get("latitude") else None116            lng = float(loc.get("longitude")) if loc.get("longitude") else None117        except (TypeError, ValueError):118            lat = lng = None119120        # galerie photo : variantes redimensionnées du flux (large ~1024px)121        images: list[str] = []122        for img in meta.get("REAL_HOMES_property_images") or []:123            sizes = (img or {}).get("sizes") or {}124            u = ""125            for k in ("1536x1536", "large", "medium_large"):126                u = (sizes.get(k) or {}).get("url") or ""127                if u:128                    break129            if not u and img.get("file"):130                u = f"{BASE}/wp-content/uploads/{img['file']}"131            if u and u not in images:132                images.append(u)133        images = images[: self.max_images]134135        # description : contenu WordPress de la fiche (rendu texte)136        desc = BeautifulSoup((p.get("content") or {}).get("rendered") or "",137                             "html.parser").get_text(" ", strip=True)[:600]138139        # fiche propriété (plans d'étage + commodités) via le cache BD :140        # revisitée seulement quand le post WordPress est modifié141        feed_key = str(p.get("modified") or p.get("modified_gmt") or "")142        d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))143        amenities = d.get("amenities") or []144145        common = dict(146            source=self.source_id, url=url, address=address, city=city,147            province="ON", description=desc, amenities=amenities[:25],148            images=images, lat=lat, lng=lng,149        )150151        # une annonce par plan d'étage (type d'unité + prix plancher affiché)152        out: list[Listing] = []153        for fp in d.get("floorplans") or []:154            name = fp.get("name") or ""155            slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")156            beds = fp.get("beds")157            price = fp.get("price")158            out.append(Listing(159                external_id=f"{pid}-{slug or 'u'}",160                title=f"{title} — {name}" if name else title,161                unit_type=("Studio" if beds == 0 else normalize_unit_type(162                    f"{int(beds)} chambres") if beds is not None163                    else normalize_unit_type(name)),164                bedrooms=beds,165                bathrooms=fp.get("baths"),166                price=price,167                price_label=(f"À partir de {price:.0f} $ /mois"168                             if price is not None else ""),169                availability=fp.get("avail") or "",170                area_sqft=fp.get("sqft"),171                **common,172            ))173        if out:174            return out175176        # repli : une annonce par propriété — aucun prix inventé (None = inconnu)177        return [Listing(178            external_id=pid,179            title=title,180            unit_type="",181            **common,182        )]183184    # -- fiche propriété : plans d'étage + commodités ----------------------------185    def _fetch_detail(self, url: str) -> dict:186        """Scrape l'accordéon « Floor Plans » (nom, chambres, sdb, prix,187        disponibilité) et la liste « Buildings Features » de la fiche."""188        out: dict = {"floorplans": [], "amenities": []}189        if not url:190            return out191        try:192            page = self.get(url).text193        except Exception:194            return out195        soup = BeautifulSoup(page, "html.parser")196197        # plans d'étage : <div class="floor-plan"> (nom h3, meta « N Bedrooms /198        # N Bathrooms / prix / Call for Availability »)199        seen: set[str] = set()200        for fp in soup.select(".floor-plans-accordions .floor-plan"):201            h = fp.select_one(".floor-plan-title .title h3")202            name = h.get_text(" ", strip=True) if h else ""203            if not name or name.lower() in seen:204                continue205            seen.add(name.lower())206            meta_el = fp.select_one(".floor-plan-meta")207            meta_txt = meta_el.get_text(" ", strip=True) if meta_el else ""208            mb = _BED_RE.search(meta_txt) or _BED_RE.search(name)209            beds = (float(mb.group(1)) if mb210                    else 0.0 if re.search(r"bachelor|studio", name, re.I)211                    else None)212            mba = _BATH_RE.search(meta_txt)213            msq = _SQFT_RE.search(meta_txt)214            sqft = float(msq.group(1).replace(",", "")) if msq else None215            price_el = fp.select_one(".floor-price-value")216            price = parse_price(price_el.get_text(" ", strip=True)) \217                if price_el else None218            # texte de dispo : ce qui suit le prix (« Call for Availability »)219            avail = ""220            fprice = fp.select_one(".floor-price")221            if fprice:222                avail = fprice.get_text(" ", strip=True)223                if price_el:224                    avail = avail.replace(225                        price_el.get_text(" ", strip=True), "").strip()226            out["floorplans"].append({227                "name": name,228                "beds": beds,229                "baths": float(mba.group(1)) if mba else None,230                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,231                "price": price,232                "avail": avail,233            })234235        # commodités : « Buildings Features » (liste à puces de la fiche)236        h = soup.find(["h3", "h4"], string=re.compile(237            r"Buildings? Features", re.I))238        if h:239            ul = h.find_next("ul")240            if ul:241                amenities = []242                for li in ul.find_all("li"):243                    t = li.get_text(" ", strip=True)244                    if t and len(t) < 60 and t not in amenities:245                        amenities.append(t)246                out["amenities"] = amenities[:25]247        return out248