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%
10.7 KB · 250 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/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#   Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le24#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).25# -----------------------------------------------------------------------------26from __future__ import annotations2728import os29import re3031from bs4 import BeautifulSoup3233from ..schema import Listing, normalize_unit_type, parse_price34from .base import BaseConnector3536BASE = "https://medallioncorp.com"37FEED_URL = f"{BASE}/wp-json/wp/v2/properties?per_page=100"3839# Gate expansion Ontario : le connecteur reste hors registre tant que la40# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.41_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4243# villes du flux (slug de class_list `property-city-<slug>`) -> affichage ;44# tout slug inconnu est titré tel quel (« richmond-hill » -> « Richmond Hill »)45_CITY_LABEL = {46    "toronto": "Toronto",47    "brampton": "Brampton",48    "ajax": "Ajax",49    "whitby": "Whitby",50    "oshawa": "Oshawa",51    "richmond-hill": "Richmond Hill",52    "hamilton": "Hamilton",53    "london": "London",54    "milton": "Milton",55}5657# lignes de l'accordéon plans d'étage : « 2 Bedrooms », « 1.5 Bathrooms »58_BED_RE = re.compile(r"(\d+)\s*Bedroom", re.I)59_BATH_RE = re.compile(r"([\d.]+)\s*Bathroom", re.I)60_SQFT_RE = re.compile(r"([\d,]+)\s*(?:sq\.?\s*ft|pi2|ft2)", re.I)61_CITY_CLS_RE = re.compile(r"^property-city-(.+)$")626364class MedallionConnector(BaseConnector):65    source_id = "medallion"66    request_delay = 1.067    disabled = not _ONTARIO   # gate expansion Ontario (LOUKA_ONTARIO=1)68    max_properties = 80       # garde-fou (56 immeubles résidentiels aujourd'hui)69    max_images = 207071    def fetch(self) -> list[Listing]:72        props = self.get(FEED_URL, headers={"Accept": "application/json"}).json()7374        listings: list[Listing] = []75        count = 076        for p in props:77            try:78                cls = set(p.get("class_list") or [])79                # résidentiel longue durée seulement : à louer, non commercial80                if "property-status-for-rent" not in cls:81                    continue82                if "property-type-residential" not in cls:83                    continue84                if count >= self.max_properties:85                    break86                count += 187                listings.extend(self._property_listings(p))88            except Exception:89                continue90        return listings9192    # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------93    def _property_listings(self, p: dict) -> list[Listing]:94        pid = str(p.get("id"))95        url = p.get("link") or ""96        title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",97                              "html.parser").get_text(" ", strip=True)98        meta = p.get("property_meta") or {}99100        # adresse civique complète du flux (« 25 Kitney Dr, Ajax, ON L1S 0G6,101        # Canada ») — suffixe « , Canada » retiré102        address = re.sub(r",\s*Canada$", "",103                         (meta.get("REAL_HOMES_property_address") or "").strip())104105        # ville : slug property-city-<slug> de class_list106        city = ""107        for c in p.get("class_list") or []:108            m = _CITY_CLS_RE.match(c)109            if m:110                slug = m.group(1)111                city = _CITY_LABEL.get(slug, slug.replace("-", " ").title())112                break113114        # coordonnées GPS du flux (finalize() valide la bbox Ontario)115        loc = meta.get("REAL_HOMES_property_location") or {}116        try:117            lat = float(loc.get("latitude")) if loc.get("latitude") else None118            lng = float(loc.get("longitude")) if loc.get("longitude") else None119        except (TypeError, ValueError):120            lat = lng = None121122        # galerie photo : variantes redimensionnées du flux (large ~1024px)123        images: list[str] = []124        for img in meta.get("REAL_HOMES_property_images") or []:125            sizes = (img or {}).get("sizes") or {}126            u = ""127            for k in ("1536x1536", "large", "medium_large"):128                u = (sizes.get(k) or {}).get("url") or ""129                if u:130                    break131            if not u and img.get("file"):132                u = f"{BASE}/wp-content/uploads/{img['file']}"133            if u and u not in images:134                images.append(u)135        images = images[: self.max_images]136137        # description : contenu WordPress de la fiche (rendu texte)138        desc = BeautifulSoup((p.get("content") or {}).get("rendered") or "",139                             "html.parser").get_text(" ", strip=True)[:600]140141        # fiche propriété (plans d'étage + commodités) via le cache BD :142        # revisitée seulement quand le post WordPress est modifié143        feed_key = str(p.get("modified") or p.get("modified_gmt") or "")144        d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))145        amenities = d.get("amenities") or []146147        common = dict(148            source=self.source_id, url=url, address=address, city=city,149            province="ON", description=desc, amenities=amenities[:25],150            images=images, lat=lat, lng=lng,151        )152153        # une annonce par plan d'étage (type d'unité + prix plancher affiché)154        out: list[Listing] = []155        for fp in d.get("floorplans") or []:156            name = fp.get("name") or ""157            slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")158            beds = fp.get("beds")159            price = fp.get("price")160            out.append(Listing(161                external_id=f"{pid}-{slug or 'u'}",162                title=f"{title} — {name}" if name else title,163                unit_type=("Studio" if beds == 0 else normalize_unit_type(164                    f"{int(beds)} chambres") if beds is not None165                    else normalize_unit_type(name)),166                bedrooms=beds,167                bathrooms=fp.get("baths"),168                price=price,169                price_label=(f"À partir de {price:.0f} $ /mois"170                             if price is not None else ""),171                availability=fp.get("avail") or "",172                area_sqft=fp.get("sqft"),173                **common,174            ))175        if out:176            return out177178        # repli : une annonce par propriété — aucun prix inventé (None = inconnu)179        return [Listing(180            external_id=pid,181            title=title,182            unit_type="",183            **common,184        )]185186    # -- fiche propriété : plans d'étage + commodités ----------------------------187    def _fetch_detail(self, url: str) -> dict:188        """Scrape l'accordéon « Floor Plans » (nom, chambres, sdb, prix,189        disponibilité) et la liste « Buildings Features » de la fiche."""190        out: dict = {"floorplans": [], "amenities": []}191        if not url:192            return out193        try:194            page = self.get(url).text195        except Exception:196            return out197        soup = BeautifulSoup(page, "html.parser")198199        # plans d'étage : <div class="floor-plan"> (nom h3, meta « N Bedrooms /200        # N Bathrooms / prix / Call for Availability »)201        seen: set[str] = set()202        for fp in soup.select(".floor-plans-accordions .floor-plan"):203            h = fp.select_one(".floor-plan-title .title h3")204            name = h.get_text(" ", strip=True) if h else ""205            if not name or name.lower() in seen:206                continue207            seen.add(name.lower())208            meta_el = fp.select_one(".floor-plan-meta")209            meta_txt = meta_el.get_text(" ", strip=True) if meta_el else ""210            mb = _BED_RE.search(meta_txt) or _BED_RE.search(name)211            beds = (float(mb.group(1)) if mb212                    else 0.0 if re.search(r"bachelor|studio", name, re.I)213                    else None)214            mba = _BATH_RE.search(meta_txt)215            msq = _SQFT_RE.search(meta_txt)216            sqft = float(msq.group(1).replace(",", "")) if msq else None217            price_el = fp.select_one(".floor-price-value")218            price = parse_price(price_el.get_text(" ", strip=True)) \219                if price_el else None220            # texte de dispo : ce qui suit le prix (« Call for Availability »)221            avail = ""222            fprice = fp.select_one(".floor-price")223            if fprice:224                avail = fprice.get_text(" ", strip=True)225                if price_el:226                    avail = avail.replace(227                        price_el.get_text(" ", strip=True), "").strip()228            out["floorplans"].append({229                "name": name,230                "beds": beds,231                "baths": float(mba.group(1)) if mba else None,232                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,233                "price": price,234                "avail": avail,235            })236237        # commodités : « Buildings Features » (liste à puces de la fiche)238        h = soup.find(["h3", "h4"], string=re.compile(239            r"Buildings? Features", re.I))240        if h:241            ul = h.find_next("ul")242            if ul:243                amenities = []244                for li in ul.find_all("li"):245                    t = li.get_text(" ", strip=True)246                    if t and len(t) < 60 and t not in amenities:247                        amenities.append(t)248                out["amenities"] = amenities[:25]249        return out250